diff --git a/.gitignore b/.gitignore index fff0556..bfef9e1 100644 --- a/.gitignore +++ b/.gitignore @@ -46,10 +46,12 @@ build/ ./storage_quotes ./storage_requests ./storage_media +./storage_shop storage_orders storage_quotes storage_requests storage_media +storage_shop # Qodana local reports/artifacts backend/.qodana/ diff --git a/backend/src/main/java/com/printcalculator/controller/PublicShopController.java b/backend/src/main/java/com/printcalculator/controller/PublicShopController.java new file mode 100644 index 0000000..e4680f0 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/PublicShopController.java @@ -0,0 +1,77 @@ +package com.printcalculator.controller; + +import com.printcalculator.dto.ShopCategoryDetailDto; +import com.printcalculator.dto.ShopCategoryTreeDto; +import com.printcalculator.dto.ShopProductCatalogResponseDto; +import com.printcalculator.dto.ShopProductDetailDto; +import com.printcalculator.service.shop.PublicShopCatalogService; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; +import java.util.List; + +@RestController +@RequestMapping("/api/shop") +@Transactional(readOnly = true) +public class PublicShopController { + private final PublicShopCatalogService publicShopCatalogService; + + public PublicShopController(PublicShopCatalogService publicShopCatalogService) { + this.publicShopCatalogService = publicShopCatalogService; + } + + @GetMapping("/categories") + public ResponseEntity> getCategories(@RequestParam(required = false) String lang) { + return ResponseEntity.ok(publicShopCatalogService.getCategories(lang)); + } + + @GetMapping("/categories/{slug}") + public ResponseEntity getCategory(@PathVariable String slug, + @RequestParam(required = false) String lang) { + return ResponseEntity.ok(publicShopCatalogService.getCategory(slug, lang)); + } + + @GetMapping("/products") + public ResponseEntity getProducts( + @RequestParam(required = false) String categorySlug, + @RequestParam(required = false) Boolean featured, + @RequestParam(required = false) String lang + ) { + return ResponseEntity.ok(publicShopCatalogService.getProductCatalog(categorySlug, featured, lang)); + } + + @GetMapping("/products/{slug}") + public ResponseEntity getProduct(@PathVariable String slug, + @RequestParam(required = false) String lang) { + return ResponseEntity.ok(publicShopCatalogService.getProduct(slug, lang)); + } + + @GetMapping("/products/{slug}/model") + public ResponseEntity getProductModel(@PathVariable String slug) throws IOException { + PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug); + Resource resource = new UrlResource(model.path().toUri()); + MediaType contentType = MediaType.APPLICATION_OCTET_STREAM; + if (model.mimeType() != null && !model.mimeType().isBlank()) { + try { + contentType = MediaType.parseMediaType(model.mimeType()); + } catch (IllegalArgumentException ignored) { + contentType = MediaType.APPLICATION_OCTET_STREAM; + } + } + + return ResponseEntity.ok() + .contentType(contentType) + .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + model.filename() + "\"") + .body(resource); + } +} diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 5f13e46..cde3605 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -62,6 +62,7 @@ public class QuoteSessionController { public ResponseEntity createSession() { QuoteSession session = new QuoteSession(); session.setStatus("ACTIVE"); + session.setSessionType("PRINT_QUOTE"); session.setPricingVersion("v1"); session.setMaterialCode("PLA"); session.setSupportsEnabled(false); diff --git a/backend/src/main/java/com/printcalculator/controller/ShopCartController.java b/backend/src/main/java/com/printcalculator/controller/ShopCartController.java new file mode 100644 index 0000000..fc58f6e --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/ShopCartController.java @@ -0,0 +1,85 @@ +package com.printcalculator.controller; + +import com.printcalculator.dto.ShopCartAddItemRequest; +import com.printcalculator.dto.ShopCartUpdateItemRequest; +import com.printcalculator.service.shop.ShopCartCookieService; +import com.printcalculator.service.shop.ShopCartService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@RestController +@RequestMapping("/api/shop/cart") +public class ShopCartController { + private final ShopCartService shopCartService; + private final ShopCartCookieService shopCartCookieService; + + public ShopCartController(ShopCartService shopCartService, ShopCartCookieService shopCartCookieService) { + this.shopCartService = shopCartService; + this.shopCartCookieService = shopCartCookieService; + } + + @GetMapping + public ResponseEntity getCart(HttpServletRequest request, HttpServletResponse response) { + ShopCartService.CartResult result = shopCartService.loadCart(request); + applyCookie(response, result); + return ResponseEntity.ok(result.response()); + } + + @PostMapping("/items") + public ResponseEntity addItem(HttpServletRequest request, + HttpServletResponse response, + @Valid @RequestBody ShopCartAddItemRequest payload) { + ShopCartService.CartResult result = shopCartService.addItem(request, payload); + applyCookie(response, result); + return ResponseEntity.ok(result.response()); + } + + @PatchMapping("/items/{lineItemId}") + public ResponseEntity updateItem(HttpServletRequest request, + HttpServletResponse response, + @PathVariable UUID lineItemId, + @Valid @RequestBody ShopCartUpdateItemRequest payload) { + ShopCartService.CartResult result = shopCartService.updateItem(request, lineItemId, payload); + applyCookie(response, result); + return ResponseEntity.ok(result.response()); + } + + @DeleteMapping("/items/{lineItemId}") + public ResponseEntity removeItem(HttpServletRequest request, + HttpServletResponse response, + @PathVariable UUID lineItemId) { + ShopCartService.CartResult result = shopCartService.removeItem(request, lineItemId); + applyCookie(response, result); + return ResponseEntity.ok(result.response()); + } + + @DeleteMapping + public ResponseEntity clearCart(HttpServletRequest request, HttpServletResponse response) { + ShopCartService.CartResult result = shopCartService.clearCart(request); + applyCookie(response, result); + return ResponseEntity.ok(result.response()); + } + + private void applyCookie(HttpServletResponse response, ShopCartService.CartResult result) { + if (result.clearCookie()) { + response.addHeader(HttpHeaders.SET_COOKIE, shopCartCookieService.buildClearCookie().toString()); + return; + } + if (result.sessionId() != null) { + response.addHeader(HttpHeaders.SET_COOKIE, shopCartCookieService.buildSessionCookie(result.sessionId()).toString()); + } + } +} diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminMediaController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminMediaController.java index df5d06f..d487145 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminMediaController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminMediaController.java @@ -53,6 +53,13 @@ public class AdminMediaController { return ResponseEntity.ok(adminMediaControllerService.getAsset(mediaAssetId)); } + @GetMapping("/usages") + public ResponseEntity> getUsages(@RequestParam String usageType, + @RequestParam String usageKey, + @RequestParam(required = false) UUID ownerId) { + return ResponseEntity.ok(adminMediaControllerService.getUsages(usageType, usageKey, ownerId)); + } + @PatchMapping("/assets/{mediaAssetId}") @Transactional public ResponseEntity updateAsset(@PathVariable UUID mediaAssetId, diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminShopCategoryController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminShopCategoryController.java new file mode 100644 index 0000000..21a9b37 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminShopCategoryController.java @@ -0,0 +1,64 @@ +package com.printcalculator.controller.admin; + +import com.printcalculator.dto.AdminShopCategoryDto; +import com.printcalculator.dto.AdminUpsertShopCategoryRequest; +import com.printcalculator.service.admin.AdminShopCategoryControllerService; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/admin/shop/categories") +@Transactional(readOnly = true) +public class AdminShopCategoryController { + private final AdminShopCategoryControllerService adminShopCategoryControllerService; + + public AdminShopCategoryController(AdminShopCategoryControllerService adminShopCategoryControllerService) { + this.adminShopCategoryControllerService = adminShopCategoryControllerService; + } + + @GetMapping + public ResponseEntity> getCategories() { + return ResponseEntity.ok(adminShopCategoryControllerService.getCategories()); + } + + @GetMapping("/tree") + public ResponseEntity> getCategoryTree() { + return ResponseEntity.ok(adminShopCategoryControllerService.getCategoryTree()); + } + + @GetMapping("/{categoryId}") + public ResponseEntity getCategory(@PathVariable UUID categoryId) { + return ResponseEntity.ok(adminShopCategoryControllerService.getCategory(categoryId)); + } + + @PostMapping + @Transactional + public ResponseEntity createCategory(@RequestBody AdminUpsertShopCategoryRequest payload) { + return ResponseEntity.ok(adminShopCategoryControllerService.createCategory(payload)); + } + + @PutMapping("/{categoryId}") + @Transactional + public ResponseEntity updateCategory(@PathVariable UUID categoryId, + @RequestBody AdminUpsertShopCategoryRequest payload) { + return ResponseEntity.ok(adminShopCategoryControllerService.updateCategory(categoryId, payload)); + } + + @DeleteMapping("/{categoryId}") + @Transactional + public ResponseEntity deleteCategory(@PathVariable UUID categoryId) { + adminShopCategoryControllerService.deleteCategory(categoryId); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminShopProductController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminShopProductController.java new file mode 100644 index 0000000..dc31270 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminShopProductController.java @@ -0,0 +1,99 @@ +package com.printcalculator.controller.admin; + +import com.printcalculator.dto.AdminShopProductDto; +import com.printcalculator.dto.AdminUpsertShopProductRequest; +import com.printcalculator.service.admin.AdminShopProductControllerService; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/admin/shop/products") +@Transactional(readOnly = true) +public class AdminShopProductController { + private final AdminShopProductControllerService adminShopProductControllerService; + + public AdminShopProductController(AdminShopProductControllerService adminShopProductControllerService) { + this.adminShopProductControllerService = adminShopProductControllerService; + } + + @GetMapping + public ResponseEntity> getProducts() { + return ResponseEntity.ok(adminShopProductControllerService.getProducts()); + } + + @GetMapping("/{productId}") + public ResponseEntity getProduct(@PathVariable UUID productId) { + return ResponseEntity.ok(adminShopProductControllerService.getProduct(productId)); + } + + @PostMapping + @Transactional + public ResponseEntity createProduct(@RequestBody AdminUpsertShopProductRequest payload) { + return ResponseEntity.ok(adminShopProductControllerService.createProduct(payload)); + } + + @PutMapping("/{productId}") + @Transactional + public ResponseEntity updateProduct(@PathVariable UUID productId, + @RequestBody AdminUpsertShopProductRequest payload) { + return ResponseEntity.ok(adminShopProductControllerService.updateProduct(productId, payload)); + } + + @DeleteMapping("/{productId}") + @Transactional + public ResponseEntity deleteProduct(@PathVariable UUID productId) { + adminShopProductControllerService.deleteProduct(productId); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{productId}/model") + @Transactional + public ResponseEntity uploadProductModel(@PathVariable UUID productId, + @RequestParam("file") MultipartFile file) throws IOException { + return ResponseEntity.ok(adminShopProductControllerService.uploadProductModel(productId, file)); + } + + @DeleteMapping("/{productId}/model") + @Transactional + public ResponseEntity deleteProductModel(@PathVariable UUID productId) { + adminShopProductControllerService.deleteProductModel(productId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/{productId}/model") + public ResponseEntity getProductModel(@PathVariable UUID productId) throws IOException { + AdminShopProductControllerService.ProductModelDownload model = adminShopProductControllerService.getProductModel(productId); + Resource resource = new UrlResource(model.path().toUri()); + MediaType contentType = MediaType.APPLICATION_OCTET_STREAM; + if (model.mimeType() != null && !model.mimeType().isBlank()) { + try { + contentType = MediaType.parseMediaType(model.mimeType()); + } catch (IllegalArgumentException ignored) { + contentType = MediaType.APPLICATION_OCTET_STREAM; + } + } + + return ResponseEntity.ok() + .contentType(contentType) + .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + model.filename() + "\"") + .body(resource); + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminQuoteSessionDto.java b/backend/src/main/java/com/printcalculator/dto/AdminQuoteSessionDto.java index 1b362eb..7d29ea5 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminQuoteSessionDto.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminQuoteSessionDto.java @@ -7,6 +7,7 @@ import java.util.UUID; public class AdminQuoteSessionDto { private UUID id; private String status; + private String sessionType; private String materialCode; private OffsetDateTime createdAt; private OffsetDateTime expiresAt; @@ -32,6 +33,14 @@ public class AdminQuoteSessionDto { this.status = status; } + public String getSessionType() { + return sessionType; + } + + public void setSessionType(String sessionType) { + this.sessionType = sessionType; + } + public String getMaterialCode() { return materialCode; } diff --git a/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryDto.java b/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryDto.java new file mode 100644 index 0000000..3e43c0d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryDto.java @@ -0,0 +1,215 @@ +package com.printcalculator.dto; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +public class AdminShopCategoryDto { + private UUID id; + private UUID parentCategoryId; + private String parentCategoryName; + private String slug; + private String name; + private String description; + private String seoTitle; + private String seoDescription; + private String ogTitle; + private String ogDescription; + private Boolean indexable; + private Boolean isActive; + private Integer sortOrder; + private Integer depth; + private Integer childCount; + private Integer directProductCount; + private Integer descendantProductCount; + private String mediaUsageType; + private String mediaUsageKey; + private List breadcrumbs; + private List children; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public UUID getParentCategoryId() { + return parentCategoryId; + } + + public void setParentCategoryId(UUID parentCategoryId) { + this.parentCategoryId = parentCategoryId; + } + + public String getParentCategoryName() { + return parentCategoryName; + } + + public void setParentCategoryName(String parentCategoryName) { + this.parentCategoryName = parentCategoryName; + } + + public String getSlug() { + return slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getSeoTitle() { + return seoTitle; + } + + public void setSeoTitle(String seoTitle) { + this.seoTitle = seoTitle; + } + + public String getSeoDescription() { + return seoDescription; + } + + public void setSeoDescription(String seoDescription) { + this.seoDescription = seoDescription; + } + + public String getOgTitle() { + return ogTitle; + } + + public void setOgTitle(String ogTitle) { + this.ogTitle = ogTitle; + } + + public String getOgDescription() { + return ogDescription; + } + + public void setOgDescription(String ogDescription) { + this.ogDescription = ogDescription; + } + + public Boolean getIndexable() { + return indexable; + } + + public void setIndexable(Boolean indexable) { + this.indexable = indexable; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Integer getDepth() { + return depth; + } + + public void setDepth(Integer depth) { + this.depth = depth; + } + + public Integer getChildCount() { + return childCount; + } + + public void setChildCount(Integer childCount) { + this.childCount = childCount; + } + + public Integer getDirectProductCount() { + return directProductCount; + } + + public void setDirectProductCount(Integer directProductCount) { + this.directProductCount = directProductCount; + } + + public Integer getDescendantProductCount() { + return descendantProductCount; + } + + public void setDescendantProductCount(Integer descendantProductCount) { + this.descendantProductCount = descendantProductCount; + } + + public String getMediaUsageType() { + return mediaUsageType; + } + + public void setMediaUsageType(String mediaUsageType) { + this.mediaUsageType = mediaUsageType; + } + + public String getMediaUsageKey() { + return mediaUsageKey; + } + + public void setMediaUsageKey(String mediaUsageKey) { + this.mediaUsageKey = mediaUsageKey; + } + + public List getBreadcrumbs() { + return breadcrumbs; + } + + public void setBreadcrumbs(List breadcrumbs) { + this.breadcrumbs = breadcrumbs; + } + + public List getChildren() { + return children; + } + + public void setChildren(List children) { + this.children = children; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryRefDto.java b/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryRefDto.java new file mode 100644 index 0000000..624591a --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryRefDto.java @@ -0,0 +1,33 @@ +package com.printcalculator.dto; + +import java.util.UUID; + +public class AdminShopCategoryRefDto { + private UUID id; + private String slug; + private String name; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getSlug() { + return slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminShopProductDto.java b/backend/src/main/java/com/printcalculator/dto/AdminShopProductDto.java new file mode 100644 index 0000000..c7d70c6 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminShopProductDto.java @@ -0,0 +1,369 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +public class AdminShopProductDto { + private UUID id; + private UUID categoryId; + private String categoryName; + private String categorySlug; + private String slug; + private String name; + private String nameIt; + private String nameEn; + private String nameDe; + private String nameFr; + private String excerpt; + private String excerptIt; + private String excerptEn; + private String excerptDe; + private String excerptFr; + private String description; + private String descriptionIt; + private String descriptionEn; + private String descriptionDe; + private String descriptionFr; + private String seoTitle; + private String seoDescription; + private String ogTitle; + private String ogDescription; + private Boolean indexable; + private Boolean isFeatured; + private Boolean isActive; + private Integer sortOrder; + private Integer variantCount; + private Integer activeVariantCount; + private BigDecimal priceFromChf; + private BigDecimal priceToChf; + private String mediaUsageType; + private String mediaUsageKey; + private List mediaUsages; + private List images; + private ShopProductModelDto model3d; + private List variants; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public UUID getCategoryId() { + return categoryId; + } + + public void setCategoryId(UUID categoryId) { + this.categoryId = categoryId; + } + + public String getCategoryName() { + return categoryName; + } + + public void setCategoryName(String categoryName) { + this.categoryName = categoryName; + } + + public String getCategorySlug() { + return categorySlug; + } + + public void setCategorySlug(String categorySlug) { + this.categorySlug = categorySlug; + } + + public String getSlug() { + return slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getNameIt() { + return nameIt; + } + + public void setNameIt(String nameIt) { + this.nameIt = nameIt; + } + + public String getNameEn() { + return nameEn; + } + + public void setNameEn(String nameEn) { + this.nameEn = nameEn; + } + + public String getNameDe() { + return nameDe; + } + + public void setNameDe(String nameDe) { + this.nameDe = nameDe; + } + + public String getNameFr() { + return nameFr; + } + + public void setNameFr(String nameFr) { + this.nameFr = nameFr; + } + + public String getExcerpt() { + return excerpt; + } + + public void setExcerpt(String excerpt) { + this.excerpt = excerpt; + } + + public String getExcerptIt() { + return excerptIt; + } + + public void setExcerptIt(String excerptIt) { + this.excerptIt = excerptIt; + } + + public String getExcerptEn() { + return excerptEn; + } + + public void setExcerptEn(String excerptEn) { + this.excerptEn = excerptEn; + } + + public String getExcerptDe() { + return excerptDe; + } + + public void setExcerptDe(String excerptDe) { + this.excerptDe = excerptDe; + } + + public String getExcerptFr() { + return excerptFr; + } + + public void setExcerptFr(String excerptFr) { + this.excerptFr = excerptFr; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getDescriptionIt() { + return descriptionIt; + } + + public void setDescriptionIt(String descriptionIt) { + this.descriptionIt = descriptionIt; + } + + public String getDescriptionEn() { + return descriptionEn; + } + + public void setDescriptionEn(String descriptionEn) { + this.descriptionEn = descriptionEn; + } + + public String getDescriptionDe() { + return descriptionDe; + } + + public void setDescriptionDe(String descriptionDe) { + this.descriptionDe = descriptionDe; + } + + public String getDescriptionFr() { + return descriptionFr; + } + + public void setDescriptionFr(String descriptionFr) { + this.descriptionFr = descriptionFr; + } + + public String getSeoTitle() { + return seoTitle; + } + + public void setSeoTitle(String seoTitle) { + this.seoTitle = seoTitle; + } + + public String getSeoDescription() { + return seoDescription; + } + + public void setSeoDescription(String seoDescription) { + this.seoDescription = seoDescription; + } + + public String getOgTitle() { + return ogTitle; + } + + public void setOgTitle(String ogTitle) { + this.ogTitle = ogTitle; + } + + public String getOgDescription() { + return ogDescription; + } + + public void setOgDescription(String ogDescription) { + this.ogDescription = ogDescription; + } + + public Boolean getIndexable() { + return indexable; + } + + public void setIndexable(Boolean indexable) { + this.indexable = indexable; + } + + public Boolean getIsFeatured() { + return isFeatured; + } + + public void setIsFeatured(Boolean featured) { + isFeatured = featured; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Integer getVariantCount() { + return variantCount; + } + + public void setVariantCount(Integer variantCount) { + this.variantCount = variantCount; + } + + public Integer getActiveVariantCount() { + return activeVariantCount; + } + + public void setActiveVariantCount(Integer activeVariantCount) { + this.activeVariantCount = activeVariantCount; + } + + public BigDecimal getPriceFromChf() { + return priceFromChf; + } + + public void setPriceFromChf(BigDecimal priceFromChf) { + this.priceFromChf = priceFromChf; + } + + public BigDecimal getPriceToChf() { + return priceToChf; + } + + public void setPriceToChf(BigDecimal priceToChf) { + this.priceToChf = priceToChf; + } + + public String getMediaUsageType() { + return mediaUsageType; + } + + public void setMediaUsageType(String mediaUsageType) { + this.mediaUsageType = mediaUsageType; + } + + public String getMediaUsageKey() { + return mediaUsageKey; + } + + public void setMediaUsageKey(String mediaUsageKey) { + this.mediaUsageKey = mediaUsageKey; + } + + public List getMediaUsages() { + return mediaUsages; + } + + public void setMediaUsages(List mediaUsages) { + this.mediaUsages = mediaUsages; + } + + public List getImages() { + return images; + } + + public void setImages(List images) { + this.images = images; + } + + public ShopProductModelDto getModel3d() { + return model3d; + } + + public void setModel3d(ShopProductModelDto model3d) { + this.model3d = model3d; + } + + public List getVariants() { + return variants; + } + + public void setVariants(List variants) { + this.variants = variants; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminShopProductVariantDto.java b/backend/src/main/java/com/printcalculator/dto/AdminShopProductVariantDto.java new file mode 100644 index 0000000..e03c629 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminShopProductVariantDto.java @@ -0,0 +1,116 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.UUID; + +public class AdminShopProductVariantDto { + private UUID id; + private String sku; + private String variantLabel; + private String colorName; + private String colorHex; + private String internalMaterialCode; + private BigDecimal priceChf; + private Boolean isDefault; + private Boolean isActive; + private Integer sortOrder; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getSku() { + return sku; + } + + public void setSku(String sku) { + this.sku = sku; + } + + public String getVariantLabel() { + return variantLabel; + } + + public void setVariantLabel(String variantLabel) { + this.variantLabel = variantLabel; + } + + public String getColorName() { + return colorName; + } + + public void setColorName(String colorName) { + this.colorName = colorName; + } + + public String getColorHex() { + return colorHex; + } + + public void setColorHex(String colorHex) { + this.colorHex = colorHex; + } + + public String getInternalMaterialCode() { + return internalMaterialCode; + } + + public void setInternalMaterialCode(String internalMaterialCode) { + this.internalMaterialCode = internalMaterialCode; + } + + public BigDecimal getPriceChf() { + return priceChf; + } + + public void setPriceChf(BigDecimal priceChf) { + this.priceChf = priceChf; + } + + public Boolean getIsDefault() { + return isDefault; + } + + public void setIsDefault(Boolean aDefault) { + isDefault = aDefault; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopCategoryRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopCategoryRequest.java new file mode 100644 index 0000000..28096f2 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopCategoryRequest.java @@ -0,0 +1,105 @@ +package com.printcalculator.dto; + +import java.util.UUID; + +public class AdminUpsertShopCategoryRequest { + private UUID parentCategoryId; + private String slug; + private String name; + private String description; + private String seoTitle; + private String seoDescription; + private String ogTitle; + private String ogDescription; + private Boolean indexable; + private Boolean isActive; + private Integer sortOrder; + + public UUID getParentCategoryId() { + return parentCategoryId; + } + + public void setParentCategoryId(UUID parentCategoryId) { + this.parentCategoryId = parentCategoryId; + } + + public String getSlug() { + return slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getSeoTitle() { + return seoTitle; + } + + public void setSeoTitle(String seoTitle) { + this.seoTitle = seoTitle; + } + + public String getSeoDescription() { + return seoDescription; + } + + public void setSeoDescription(String seoDescription) { + this.seoDescription = seoDescription; + } + + public String getOgTitle() { + return ogTitle; + } + + public void setOgTitle(String ogTitle) { + this.ogTitle = ogTitle; + } + + public String getOgDescription() { + return ogDescription; + } + + public void setOgDescription(String ogDescription) { + this.ogDescription = ogDescription; + } + + public Boolean getIndexable() { + return indexable; + } + + public void setIndexable(Boolean indexable) { + this.indexable = indexable; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductRequest.java new file mode 100644 index 0000000..0c00744 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductRequest.java @@ -0,0 +1,241 @@ +package com.printcalculator.dto; + +import java.util.List; +import java.util.UUID; + +public class AdminUpsertShopProductRequest { + private UUID categoryId; + private String slug; + private String name; + private String nameIt; + private String nameEn; + private String nameDe; + private String nameFr; + private String excerpt; + private String excerptIt; + private String excerptEn; + private String excerptDe; + private String excerptFr; + private String description; + private String descriptionIt; + private String descriptionEn; + private String descriptionDe; + private String descriptionFr; + private String seoTitle; + private String seoDescription; + private String ogTitle; + private String ogDescription; + private Boolean indexable; + private Boolean isFeatured; + private Boolean isActive; + private Integer sortOrder; + private List variants; + + public UUID getCategoryId() { + return categoryId; + } + + public void setCategoryId(UUID categoryId) { + this.categoryId = categoryId; + } + + public String getSlug() { + return slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getNameIt() { + return nameIt; + } + + public void setNameIt(String nameIt) { + this.nameIt = nameIt; + } + + public String getNameEn() { + return nameEn; + } + + public void setNameEn(String nameEn) { + this.nameEn = nameEn; + } + + public String getNameDe() { + return nameDe; + } + + public void setNameDe(String nameDe) { + this.nameDe = nameDe; + } + + public String getNameFr() { + return nameFr; + } + + public void setNameFr(String nameFr) { + this.nameFr = nameFr; + } + + public String getExcerpt() { + return excerpt; + } + + public void setExcerpt(String excerpt) { + this.excerpt = excerpt; + } + + public String getExcerptIt() { + return excerptIt; + } + + public void setExcerptIt(String excerptIt) { + this.excerptIt = excerptIt; + } + + public String getExcerptEn() { + return excerptEn; + } + + public void setExcerptEn(String excerptEn) { + this.excerptEn = excerptEn; + } + + public String getExcerptDe() { + return excerptDe; + } + + public void setExcerptDe(String excerptDe) { + this.excerptDe = excerptDe; + } + + public String getExcerptFr() { + return excerptFr; + } + + public void setExcerptFr(String excerptFr) { + this.excerptFr = excerptFr; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getDescriptionIt() { + return descriptionIt; + } + + public void setDescriptionIt(String descriptionIt) { + this.descriptionIt = descriptionIt; + } + + public String getDescriptionEn() { + return descriptionEn; + } + + public void setDescriptionEn(String descriptionEn) { + this.descriptionEn = descriptionEn; + } + + public String getDescriptionDe() { + return descriptionDe; + } + + public void setDescriptionDe(String descriptionDe) { + this.descriptionDe = descriptionDe; + } + + public String getDescriptionFr() { + return descriptionFr; + } + + public void setDescriptionFr(String descriptionFr) { + this.descriptionFr = descriptionFr; + } + + public String getSeoTitle() { + return seoTitle; + } + + public void setSeoTitle(String seoTitle) { + this.seoTitle = seoTitle; + } + + public String getSeoDescription() { + return seoDescription; + } + + public void setSeoDescription(String seoDescription) { + this.seoDescription = seoDescription; + } + + public String getOgTitle() { + return ogTitle; + } + + public void setOgTitle(String ogTitle) { + this.ogTitle = ogTitle; + } + + public String getOgDescription() { + return ogDescription; + } + + public void setOgDescription(String ogDescription) { + this.ogDescription = ogDescription; + } + + public Boolean getIndexable() { + return indexable; + } + + public void setIndexable(Boolean indexable) { + this.indexable = indexable; + } + + public Boolean getIsFeatured() { + return isFeatured; + } + + public void setIsFeatured(Boolean featured) { + isFeatured = featured; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public List getVariants() { + return variants; + } + + public void setVariants(List variants) { + this.variants = variants; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductVariantRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductVariantRequest.java new file mode 100644 index 0000000..14ef9af --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductVariantRequest.java @@ -0,0 +1,97 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +public class AdminUpsertShopProductVariantRequest { + private UUID id; + private String sku; + private String variantLabel; + private String colorName; + private String colorHex; + private String internalMaterialCode; + private BigDecimal priceChf; + private Boolean isDefault; + private Boolean isActive; + private Integer sortOrder; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getSku() { + return sku; + } + + public void setSku(String sku) { + this.sku = sku; + } + + public String getVariantLabel() { + return variantLabel; + } + + public void setVariantLabel(String variantLabel) { + this.variantLabel = variantLabel; + } + + public String getColorName() { + return colorName; + } + + public void setColorName(String colorName) { + this.colorName = colorName; + } + + public String getColorHex() { + return colorHex; + } + + public void setColorHex(String colorHex) { + this.colorHex = colorHex; + } + + public String getInternalMaterialCode() { + return internalMaterialCode; + } + + public void setInternalMaterialCode(String internalMaterialCode) { + this.internalMaterialCode = internalMaterialCode; + } + + public BigDecimal getPriceChf() { + return priceChf; + } + + public void setPriceChf(BigDecimal priceChf) { + this.priceChf = priceChf; + } + + public Boolean getIsDefault() { + return isDefault; + } + + public void setIsDefault(Boolean aDefault) { + isDefault = aDefault; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/OrderDto.java b/backend/src/main/java/com/printcalculator/dto/OrderDto.java index 9653d99..4534d37 100644 --- a/backend/src/main/java/com/printcalculator/dto/OrderDto.java +++ b/backend/src/main/java/com/printcalculator/dto/OrderDto.java @@ -8,6 +8,7 @@ import java.util.UUID; public class OrderDto { private UUID id; private String orderNumber; + private String sourceType; private String status; private String paymentStatus; private String paymentMethod; @@ -45,6 +46,9 @@ public class OrderDto { public String getOrderNumber() { return orderNumber; } public void setOrderNumber(String orderNumber) { this.orderNumber = orderNumber; } + public String getSourceType() { return sourceType; } + public void setSourceType(String sourceType) { this.sourceType = sourceType; } + public String getStatus() { return status; } public void setStatus(String status) { this.status = status; } diff --git a/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java b/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java index d6f5f68..efbcc87 100644 --- a/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java +++ b/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java @@ -5,10 +5,19 @@ import java.util.UUID; public class OrderItemDto { private UUID id; + private String itemType; private String originalFilename; + private String displayName; private String materialCode; private String colorCode; private Long filamentVariantId; + private UUID shopProductId; + private UUID shopProductVariantId; + private String shopProductSlug; + private String shopProductName; + private String shopVariantLabel; + private String shopVariantColorName; + private String shopVariantColorHex; private String filamentVariantDisplayName; private String filamentColorName; private String filamentColorHex; @@ -28,9 +37,15 @@ public class OrderItemDto { public UUID getId() { return id; } public void setId(UUID id) { this.id = id; } + public String getItemType() { return itemType; } + public void setItemType(String itemType) { this.itemType = itemType; } + public String getOriginalFilename() { return originalFilename; } public void setOriginalFilename(String originalFilename) { this.originalFilename = originalFilename; } + public String getDisplayName() { return displayName; } + public void setDisplayName(String displayName) { this.displayName = displayName; } + public String getMaterialCode() { return materialCode; } public void setMaterialCode(String materialCode) { this.materialCode = materialCode; } @@ -40,6 +55,27 @@ public class OrderItemDto { public Long getFilamentVariantId() { return filamentVariantId; } public void setFilamentVariantId(Long filamentVariantId) { this.filamentVariantId = filamentVariantId; } + public UUID getShopProductId() { return shopProductId; } + public void setShopProductId(UUID shopProductId) { this.shopProductId = shopProductId; } + + public UUID getShopProductVariantId() { return shopProductVariantId; } + public void setShopProductVariantId(UUID shopProductVariantId) { this.shopProductVariantId = shopProductVariantId; } + + public String getShopProductSlug() { return shopProductSlug; } + public void setShopProductSlug(String shopProductSlug) { this.shopProductSlug = shopProductSlug; } + + public String getShopProductName() { return shopProductName; } + public void setShopProductName(String shopProductName) { this.shopProductName = shopProductName; } + + public String getShopVariantLabel() { return shopVariantLabel; } + public void setShopVariantLabel(String shopVariantLabel) { this.shopVariantLabel = shopVariantLabel; } + + public String getShopVariantColorName() { return shopVariantColorName; } + public void setShopVariantColorName(String shopVariantColorName) { this.shopVariantColorName = shopVariantColorName; } + + public String getShopVariantColorHex() { return shopVariantColorHex; } + public void setShopVariantColorHex(String shopVariantColorHex) { this.shopVariantColorHex = shopVariantColorHex; } + public String getFilamentVariantDisplayName() { return filamentVariantDisplayName; } public void setFilamentVariantDisplayName(String filamentVariantDisplayName) { this.filamentVariantDisplayName = filamentVariantDisplayName; } diff --git a/backend/src/main/java/com/printcalculator/dto/ShopCartAddItemRequest.java b/backend/src/main/java/com/printcalculator/dto/ShopCartAddItemRequest.java new file mode 100644 index 0000000..5999f4f --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopCartAddItemRequest.java @@ -0,0 +1,30 @@ +package com.printcalculator.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.util.UUID; + +public class ShopCartAddItemRequest { + @NotNull + private UUID shopProductVariantId; + + @Min(1) + private Integer quantity = 1; + + public UUID getShopProductVariantId() { + return shopProductVariantId; + } + + public void setShopProductVariantId(UUID shopProductVariantId) { + this.shopProductVariantId = shopProductVariantId; + } + + public Integer getQuantity() { + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/ShopCartUpdateItemRequest.java b/backend/src/main/java/com/printcalculator/dto/ShopCartUpdateItemRequest.java new file mode 100644 index 0000000..5607ea9 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopCartUpdateItemRequest.java @@ -0,0 +1,18 @@ +package com.printcalculator.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public class ShopCartUpdateItemRequest { + @NotNull + @Min(1) + private Integer quantity; + + public Integer getQuantity() { + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/ShopCategoryDetailDto.java b/backend/src/main/java/com/printcalculator/dto/ShopCategoryDetailDto.java new file mode 100644 index 0000000..117ef38 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopCategoryDetailDto.java @@ -0,0 +1,23 @@ +package com.printcalculator.dto; + +import java.util.List; +import java.util.UUID; + +public record ShopCategoryDetailDto( + UUID id, + String slug, + String name, + String description, + String seoTitle, + String seoDescription, + String ogTitle, + String ogDescription, + Boolean indexable, + Integer sortOrder, + Integer productCount, + List breadcrumbs, + PublicMediaUsageDto primaryImage, + List images, + List children +) { +} diff --git a/backend/src/main/java/com/printcalculator/dto/ShopCategoryRefDto.java b/backend/src/main/java/com/printcalculator/dto/ShopCategoryRefDto.java new file mode 100644 index 0000000..785198d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopCategoryRefDto.java @@ -0,0 +1,10 @@ +package com.printcalculator.dto; + +import java.util.UUID; + +public record ShopCategoryRefDto( + UUID id, + String slug, + String name +) { +} diff --git a/backend/src/main/java/com/printcalculator/dto/ShopCategoryTreeDto.java b/backend/src/main/java/com/printcalculator/dto/ShopCategoryTreeDto.java new file mode 100644 index 0000000..94ffecc --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopCategoryTreeDto.java @@ -0,0 +1,22 @@ +package com.printcalculator.dto; + +import java.util.List; +import java.util.UUID; + +public record ShopCategoryTreeDto( + UUID id, + UUID parentCategoryId, + String slug, + String name, + String description, + String seoTitle, + String seoDescription, + String ogTitle, + String ogDescription, + Boolean indexable, + Integer sortOrder, + Integer productCount, + PublicMediaUsageDto primaryImage, + List children +) { +} diff --git a/backend/src/main/java/com/printcalculator/dto/ShopProductCatalogResponseDto.java b/backend/src/main/java/com/printcalculator/dto/ShopProductCatalogResponseDto.java new file mode 100644 index 0000000..2de21e5 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopProductCatalogResponseDto.java @@ -0,0 +1,11 @@ +package com.printcalculator.dto; + +import java.util.List; + +public record ShopProductCatalogResponseDto( + String categorySlug, + Boolean featuredOnly, + ShopCategoryDetailDto category, + List products +) { +} diff --git a/backend/src/main/java/com/printcalculator/dto/ShopProductDetailDto.java b/backend/src/main/java/com/printcalculator/dto/ShopProductDetailDto.java new file mode 100644 index 0000000..265cb8b --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopProductDetailDto.java @@ -0,0 +1,30 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +public record ShopProductDetailDto( + UUID id, + String slug, + String name, + String excerpt, + String description, + String seoTitle, + String seoDescription, + String ogTitle, + String ogDescription, + Boolean indexable, + Boolean isFeatured, + Integer sortOrder, + ShopCategoryRefDto category, + List breadcrumbs, + BigDecimal priceFromChf, + BigDecimal priceToChf, + ShopProductVariantOptionDto defaultVariant, + List variants, + PublicMediaUsageDto primaryImage, + List images, + ShopProductModelDto model3d +) { +} diff --git a/backend/src/main/java/com/printcalculator/dto/ShopProductModelDto.java b/backend/src/main/java/com/printcalculator/dto/ShopProductModelDto.java new file mode 100644 index 0000000..e590987 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopProductModelDto.java @@ -0,0 +1,14 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; + +public record ShopProductModelDto( + String url, + String originalFilename, + String mimeType, + Long fileSizeBytes, + BigDecimal boundingBoxXMm, + BigDecimal boundingBoxYMm, + BigDecimal boundingBoxZMm +) { +} diff --git a/backend/src/main/java/com/printcalculator/dto/ShopProductSummaryDto.java b/backend/src/main/java/com/printcalculator/dto/ShopProductSummaryDto.java new file mode 100644 index 0000000..d563a07 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopProductSummaryDto.java @@ -0,0 +1,20 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +public record ShopProductSummaryDto( + UUID id, + String slug, + String name, + String excerpt, + Boolean isFeatured, + Integer sortOrder, + ShopCategoryRefDto category, + BigDecimal priceFromChf, + BigDecimal priceToChf, + ShopProductVariantOptionDto defaultVariant, + PublicMediaUsageDto primaryImage, + ShopProductModelDto model3d +) { +} diff --git a/backend/src/main/java/com/printcalculator/dto/ShopProductVariantOptionDto.java b/backend/src/main/java/com/printcalculator/dto/ShopProductVariantOptionDto.java new file mode 100644 index 0000000..318a87c --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/ShopProductVariantOptionDto.java @@ -0,0 +1,15 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +public record ShopProductVariantOptionDto( + UUID id, + String sku, + String variantLabel, + String colorName, + String colorHex, + BigDecimal priceChf, + Boolean isDefault +) { +} diff --git a/backend/src/main/java/com/printcalculator/entity/Order.java b/backend/src/main/java/com/printcalculator/entity/Order.java index 1b01df1..71ec184 100644 --- a/backend/src/main/java/com/printcalculator/entity/Order.java +++ b/backend/src/main/java/com/printcalculator/entity/Order.java @@ -20,6 +20,10 @@ public class Order { @JoinColumn(name = "source_quote_session_id") private QuoteSession sourceQuoteSession; + @ColumnDefault("'CALCULATOR'") + @Column(name = "source_type", nullable = false, length = Integer.MAX_VALUE) + private String sourceType; + @Column(name = "status", nullable = false, length = Integer.MAX_VALUE) private String status; @@ -151,6 +155,34 @@ public class Order { @Column(name = "paid_at") private OffsetDateTime paidAt; + @PrePersist + private void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + if (shippingSameAsBilling == null) { + shippingSameAsBilling = true; + } + if (sourceType == null || sourceType.isBlank()) { + sourceType = "CALCULATOR"; + } + } + + @PreUpdate + private void onUpdate() { + updatedAt = OffsetDateTime.now(); + if (shippingSameAsBilling == null) { + shippingSameAsBilling = true; + } + if (sourceType == null || sourceType.isBlank()) { + sourceType = "CALCULATOR"; + } + } + public UUID getId() { return id; } @@ -177,6 +209,14 @@ public class Order { this.sourceQuoteSession = sourceQuoteSession; } + public String getSourceType() { + return sourceType; + } + + public void setSourceType(String sourceType) { + this.sourceType = sourceType; + } + public String getStatus() { return status; } diff --git a/backend/src/main/java/com/printcalculator/entity/OrderItem.java b/backend/src/main/java/com/printcalculator/entity/OrderItem.java index b77573d..e9035c5 100644 --- a/backend/src/main/java/com/printcalculator/entity/OrderItem.java +++ b/backend/src/main/java/com/printcalculator/entity/OrderItem.java @@ -23,9 +23,16 @@ public class OrderItem { @JoinColumn(name = "order_id", nullable = false) private Order order; + @ColumnDefault("'PRINT_FILE'") + @Column(name = "item_type", nullable = false, length = Integer.MAX_VALUE) + private String itemType; + @Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE) private String originalFilename; + @Column(name = "display_name", length = Integer.MAX_VALUE) + private String displayName; + @Column(name = "stored_relative_path", nullable = false, length = Integer.MAX_VALUE) private String storedRelativePath; @@ -66,6 +73,29 @@ public class OrderItem { @JoinColumn(name = "filament_variant_id") private FilamentVariant filamentVariant; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shop_product_id") + private ShopProduct shopProduct; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shop_product_variant_id") + private ShopProductVariant shopProductVariant; + + @Column(name = "shop_product_slug", length = Integer.MAX_VALUE) + private String shopProductSlug; + + @Column(name = "shop_product_name", length = Integer.MAX_VALUE) + private String shopProductName; + + @Column(name = "shop_variant_label", length = Integer.MAX_VALUE) + private String shopVariantLabel; + + @Column(name = "shop_variant_color_name", length = Integer.MAX_VALUE) + private String shopVariantColorName; + + @Column(name = "shop_variant_color_hex", length = Integer.MAX_VALUE) + private String shopVariantColorHex; + @Column(name = "color_code", length = Integer.MAX_VALUE) private String colorCode; @@ -106,6 +136,14 @@ public class OrderItem { if (quantity == null) { quantity = 1; } + if (itemType == null || itemType.isBlank()) { + itemType = "PRINT_FILE"; + } + if ((displayName == null || displayName.isBlank()) && originalFilename != null && !originalFilename.isBlank()) { + displayName = originalFilename; + } else if ((displayName == null || displayName.isBlank()) && shopProductName != null && !shopProductName.isBlank()) { + displayName = shopProductName; + } } public UUID getId() { @@ -124,6 +162,14 @@ public class OrderItem { this.order = order; } + public String getItemType() { + return itemType; + } + + public void setItemType(String itemType) { + this.itemType = itemType; + } + public String getOriginalFilename() { return originalFilename; } @@ -132,6 +178,14 @@ public class OrderItem { this.originalFilename = originalFilename; } + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + public String getStoredRelativePath() { return storedRelativePath; } @@ -236,6 +290,62 @@ public class OrderItem { this.filamentVariant = filamentVariant; } + public ShopProduct getShopProduct() { + return shopProduct; + } + + public void setShopProduct(ShopProduct shopProduct) { + this.shopProduct = shopProduct; + } + + public ShopProductVariant getShopProductVariant() { + return shopProductVariant; + } + + public void setShopProductVariant(ShopProductVariant shopProductVariant) { + this.shopProductVariant = shopProductVariant; + } + + public String getShopProductSlug() { + return shopProductSlug; + } + + public void setShopProductSlug(String shopProductSlug) { + this.shopProductSlug = shopProductSlug; + } + + public String getShopProductName() { + return shopProductName; + } + + public void setShopProductName(String shopProductName) { + this.shopProductName = shopProductName; + } + + public String getShopVariantLabel() { + return shopVariantLabel; + } + + public void setShopVariantLabel(String shopVariantLabel) { + this.shopVariantLabel = shopVariantLabel; + } + + public String getShopVariantColorName() { + return shopVariantColorName; + } + + public void setShopVariantColorName(String shopVariantColorName) { + this.shopVariantColorName = shopVariantColorName; + } + + public String getShopVariantColorHex() { + return shopVariantColorHex; + } + + public void setShopVariantColorHex(String shopVariantColorHex) { + this.shopVariantColorHex = shopVariantColorHex; + } + public String getColorCode() { return colorCode; } diff --git a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java index 4103f8c..f1e3042 100644 --- a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java +++ b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java @@ -30,9 +30,16 @@ public class QuoteLineItem { @Column(name = "status", nullable = false, length = Integer.MAX_VALUE) private String status; + @ColumnDefault("'PRINT_FILE'") + @Column(name = "line_item_type", nullable = false, length = Integer.MAX_VALUE) + private String lineItemType; + @Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE) private String originalFilename; + @Column(name = "display_name", length = Integer.MAX_VALUE) + private String displayName; + @ColumnDefault("1") @Column(name = "quantity", nullable = false) private Integer quantity; @@ -45,6 +52,31 @@ public class QuoteLineItem { @com.fasterxml.jackson.annotation.JsonIgnore private FilamentVariant filamentVariant; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shop_product_id") + @com.fasterxml.jackson.annotation.JsonIgnore + private ShopProduct shopProduct; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shop_product_variant_id") + @com.fasterxml.jackson.annotation.JsonIgnore + private ShopProductVariant shopProductVariant; + + @Column(name = "shop_product_slug", length = Integer.MAX_VALUE) + private String shopProductSlug; + + @Column(name = "shop_product_name", length = Integer.MAX_VALUE) + private String shopProductName; + + @Column(name = "shop_variant_label", length = Integer.MAX_VALUE) + private String shopVariantLabel; + + @Column(name = "shop_variant_color_name", length = Integer.MAX_VALUE) + private String shopVariantColorName; + + @Column(name = "shop_variant_color_hex", length = Integer.MAX_VALUE) + private String shopVariantColorHex; + @Column(name = "material_code", length = Integer.MAX_VALUE) private String materialCode; @@ -102,6 +134,41 @@ public class QuoteLineItem { @Column(name = "updated_at", nullable = false) private OffsetDateTime updatedAt; + @PrePersist + private void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + if (quantity == null) { + quantity = 1; + } + if (lineItemType == null || lineItemType.isBlank()) { + lineItemType = "PRINT_FILE"; + } + if ((displayName == null || displayName.isBlank()) && originalFilename != null && !originalFilename.isBlank()) { + displayName = originalFilename; + } else if ((displayName == null || displayName.isBlank()) && shopProductName != null && !shopProductName.isBlank()) { + displayName = shopProductName; + } + } + + @PreUpdate + private void onUpdate() { + updatedAt = OffsetDateTime.now(); + if (lineItemType == null || lineItemType.isBlank()) { + lineItemType = "PRINT_FILE"; + } + if ((displayName == null || displayName.isBlank()) && originalFilename != null && !originalFilename.isBlank()) { + displayName = originalFilename; + } else if ((displayName == null || displayName.isBlank()) && shopProductName != null && !shopProductName.isBlank()) { + displayName = shopProductName; + } + } + public UUID getId() { return id; } @@ -126,6 +193,14 @@ public class QuoteLineItem { this.status = status; } + public String getLineItemType() { + return lineItemType; + } + + public void setLineItemType(String lineItemType) { + this.lineItemType = lineItemType; + } + public String getOriginalFilename() { return originalFilename; } @@ -134,6 +209,14 @@ public class QuoteLineItem { this.originalFilename = originalFilename; } + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + public Integer getQuantity() { return quantity; } @@ -158,6 +241,62 @@ public class QuoteLineItem { this.filamentVariant = filamentVariant; } + public ShopProduct getShopProduct() { + return shopProduct; + } + + public void setShopProduct(ShopProduct shopProduct) { + this.shopProduct = shopProduct; + } + + public ShopProductVariant getShopProductVariant() { + return shopProductVariant; + } + + public void setShopProductVariant(ShopProductVariant shopProductVariant) { + this.shopProductVariant = shopProductVariant; + } + + public String getShopProductSlug() { + return shopProductSlug; + } + + public void setShopProductSlug(String shopProductSlug) { + this.shopProductSlug = shopProductSlug; + } + + public String getShopProductName() { + return shopProductName; + } + + public void setShopProductName(String shopProductName) { + this.shopProductName = shopProductName; + } + + public String getShopVariantLabel() { + return shopVariantLabel; + } + + public void setShopVariantLabel(String shopVariantLabel) { + this.shopVariantLabel = shopVariantLabel; + } + + public String getShopVariantColorName() { + return shopVariantColorName; + } + + public void setShopVariantColorName(String shopVariantColorName) { + this.shopVariantColorName = shopVariantColorName; + } + + public String getShopVariantColorHex() { + return shopVariantColorHex; + } + + public void setShopVariantColorHex(String shopVariantColorHex) { + this.shopVariantColorHex = shopVariantColorHex; + } + public String getMaterialCode() { return materialCode; } diff --git a/backend/src/main/java/com/printcalculator/entity/QuoteSession.java b/backend/src/main/java/com/printcalculator/entity/QuoteSession.java index e9746ef..6b0faad 100644 --- a/backend/src/main/java/com/printcalculator/entity/QuoteSession.java +++ b/backend/src/main/java/com/printcalculator/entity/QuoteSession.java @@ -22,6 +22,10 @@ public class QuoteSession { @Column(name = "status", nullable = false, length = Integer.MAX_VALUE) private String status; + @ColumnDefault("'PRINT_QUOTE'") + @Column(name = "session_type", nullable = false, length = Integer.MAX_VALUE) + private String sessionType; + @Column(name = "pricing_version", nullable = false, length = Integer.MAX_VALUE) private String pricingVersion; @@ -70,6 +74,19 @@ public class QuoteSession { @Column(name = "cad_hourly_rate_chf", precision = 10, scale = 2) private BigDecimal cadHourlyRateChf; + @PrePersist + private void onCreate() { + if (sessionType == null || sessionType.isBlank()) { + sessionType = "PRINT_QUOTE"; + } + if (supportsEnabled == null) { + supportsEnabled = false; + } + if (createdAt == null) { + createdAt = OffsetDateTime.now(); + } + } + public UUID getId() { return id; } @@ -86,6 +103,14 @@ public class QuoteSession { this.status = status; } + public String getSessionType() { + return sessionType; + } + + public void setSessionType(String sessionType) { + this.sessionType = sessionType; + } + public String getPricingVersion() { return pricingVersion; } diff --git a/backend/src/main/java/com/printcalculator/entity/ShopCategory.java b/backend/src/main/java/com/printcalculator/entity/ShopCategory.java new file mode 100644 index 0000000..a018a97 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/ShopCategory.java @@ -0,0 +1,221 @@ +package com.printcalculator.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import org.hibernate.annotations.ColumnDefault; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "shop_category", indexes = { + @Index(name = "ix_shop_category_parent_sort", columnList = "parent_category_id, sort_order"), + @Index(name = "ix_shop_category_active_sort", columnList = "is_active, sort_order") +}) +public class ShopCategory { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "shop_category_id", nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_category_id") + private ShopCategory parentCategory; + + @Column(name = "slug", nullable = false, unique = true, length = Integer.MAX_VALUE) + private String slug; + + @Column(name = "name", nullable = false, length = Integer.MAX_VALUE) + private String name; + + @Column(name = "description", length = Integer.MAX_VALUE) + private String description; + + @Column(name = "seo_title", length = Integer.MAX_VALUE) + private String seoTitle; + + @Column(name = "seo_description", length = Integer.MAX_VALUE) + private String seoDescription; + + @Column(name = "og_title", length = Integer.MAX_VALUE) + private String ogTitle; + + @Column(name = "og_description", length = Integer.MAX_VALUE) + private String ogDescription; + + @ColumnDefault("true") + @Column(name = "indexable", nullable = false) + private Boolean indexable; + + @ColumnDefault("true") + @Column(name = "is_active", nullable = false) + private Boolean isActive; + + @ColumnDefault("0") + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @PrePersist + private void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + if (indexable == null) { + indexable = true; + } + if (isActive == null) { + isActive = true; + } + if (sortOrder == null) { + sortOrder = 0; + } + } + + @PreUpdate + private void onUpdate() { + updatedAt = OffsetDateTime.now(); + if (indexable == null) { + indexable = true; + } + if (isActive == null) { + isActive = true; + } + if (sortOrder == null) { + sortOrder = 0; + } + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public ShopCategory getParentCategory() { + return parentCategory; + } + + public void setParentCategory(ShopCategory parentCategory) { + this.parentCategory = parentCategory; + } + + public String getSlug() { + return slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getSeoTitle() { + return seoTitle; + } + + public void setSeoTitle(String seoTitle) { + this.seoTitle = seoTitle; + } + + public String getSeoDescription() { + return seoDescription; + } + + public void setSeoDescription(String seoDescription) { + this.seoDescription = seoDescription; + } + + public String getOgTitle() { + return ogTitle; + } + + public void setOgTitle(String ogTitle) { + this.ogTitle = ogTitle; + } + + public String getOgDescription() { + return ogDescription; + } + + public void setOgDescription(String ogDescription) { + this.ogDescription = ogDescription; + } + + public Boolean getIndexable() { + return indexable; + } + + public void setIndexable(Boolean indexable) { + this.indexable = indexable; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/ShopProduct.java b/backend/src/main/java/com/printcalculator/entity/ShopProduct.java new file mode 100644 index 0000000..d5fd86d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/ShopProduct.java @@ -0,0 +1,475 @@ +package com.printcalculator.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import org.hibernate.annotations.ColumnDefault; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +@Entity +@Table(name = "shop_product", indexes = { + @Index(name = "ix_shop_product_category_active_sort", columnList = "shop_category_id, is_active, sort_order"), + @Index(name = "ix_shop_product_featured_sort", columnList = "is_featured, is_active, sort_order") +}) +public class ShopProduct { + public static final List SUPPORTED_LANGUAGES = List.of("it", "en", "de", "fr"); + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "shop_product_id", nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "shop_category_id", nullable = false) + private ShopCategory category; + + @Column(name = "slug", nullable = false, unique = true, length = Integer.MAX_VALUE) + private String slug; + + @Column(name = "name", nullable = false, length = Integer.MAX_VALUE) + private String name; + + @Column(name = "name_it", length = Integer.MAX_VALUE) + private String nameIt; + + @Column(name = "name_en", length = Integer.MAX_VALUE) + private String nameEn; + + @Column(name = "name_de", length = Integer.MAX_VALUE) + private String nameDe; + + @Column(name = "name_fr", length = Integer.MAX_VALUE) + private String nameFr; + + @Column(name = "excerpt", length = Integer.MAX_VALUE) + private String excerpt; + + @Column(name = "excerpt_it", length = Integer.MAX_VALUE) + private String excerptIt; + + @Column(name = "excerpt_en", length = Integer.MAX_VALUE) + private String excerptEn; + + @Column(name = "excerpt_de", length = Integer.MAX_VALUE) + private String excerptDe; + + @Column(name = "excerpt_fr", length = Integer.MAX_VALUE) + private String excerptFr; + + @Column(name = "description", length = Integer.MAX_VALUE) + private String description; + + @Column(name = "description_it", length = Integer.MAX_VALUE) + private String descriptionIt; + + @Column(name = "description_en", length = Integer.MAX_VALUE) + private String descriptionEn; + + @Column(name = "description_de", length = Integer.MAX_VALUE) + private String descriptionDe; + + @Column(name = "description_fr", length = Integer.MAX_VALUE) + private String descriptionFr; + + @Column(name = "seo_title", length = Integer.MAX_VALUE) + private String seoTitle; + + @Column(name = "seo_description", length = Integer.MAX_VALUE) + private String seoDescription; + + @Column(name = "og_title", length = Integer.MAX_VALUE) + private String ogTitle; + + @Column(name = "og_description", length = Integer.MAX_VALUE) + private String ogDescription; + + @ColumnDefault("true") + @Column(name = "indexable", nullable = false) + private Boolean indexable; + + @ColumnDefault("false") + @Column(name = "is_featured", nullable = false) + private Boolean isFeatured; + + @ColumnDefault("true") + @Column(name = "is_active", nullable = false) + private Boolean isActive; + + @ColumnDefault("0") + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @PrePersist + private void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + if (indexable == null) { + indexable = true; + } + if (isFeatured == null) { + isFeatured = false; + } + if (isActive == null) { + isActive = true; + } + if (sortOrder == null) { + sortOrder = 0; + } + } + + @PreUpdate + private void onUpdate() { + updatedAt = OffsetDateTime.now(); + if (indexable == null) { + indexable = true; + } + if (isFeatured == null) { + isFeatured = false; + } + if (isActive == null) { + isActive = true; + } + if (sortOrder == null) { + sortOrder = 0; + } + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public ShopCategory getCategory() { + return category; + } + + public void setCategory(ShopCategory category) { + this.category = category; + } + + public String getSlug() { + return slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getNameIt() { + return nameIt; + } + + public void setNameIt(String nameIt) { + this.nameIt = nameIt; + } + + public String getNameEn() { + return nameEn; + } + + public void setNameEn(String nameEn) { + this.nameEn = nameEn; + } + + public String getNameDe() { + return nameDe; + } + + public void setNameDe(String nameDe) { + this.nameDe = nameDe; + } + + public String getNameFr() { + return nameFr; + } + + public void setNameFr(String nameFr) { + this.nameFr = nameFr; + } + + public String getExcerpt() { + return excerpt; + } + + public void setExcerpt(String excerpt) { + this.excerpt = excerpt; + } + + public String getExcerptIt() { + return excerptIt; + } + + public void setExcerptIt(String excerptIt) { + this.excerptIt = excerptIt; + } + + public String getExcerptEn() { + return excerptEn; + } + + public void setExcerptEn(String excerptEn) { + this.excerptEn = excerptEn; + } + + public String getExcerptDe() { + return excerptDe; + } + + public void setExcerptDe(String excerptDe) { + this.excerptDe = excerptDe; + } + + public String getExcerptFr() { + return excerptFr; + } + + public void setExcerptFr(String excerptFr) { + this.excerptFr = excerptFr; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getDescriptionIt() { + return descriptionIt; + } + + public void setDescriptionIt(String descriptionIt) { + this.descriptionIt = descriptionIt; + } + + public String getDescriptionEn() { + return descriptionEn; + } + + public void setDescriptionEn(String descriptionEn) { + this.descriptionEn = descriptionEn; + } + + public String getDescriptionDe() { + return descriptionDe; + } + + public void setDescriptionDe(String descriptionDe) { + this.descriptionDe = descriptionDe; + } + + public String getDescriptionFr() { + return descriptionFr; + } + + public void setDescriptionFr(String descriptionFr) { + this.descriptionFr = descriptionFr; + } + + public String getSeoTitle() { + return seoTitle; + } + + public void setSeoTitle(String seoTitle) { + this.seoTitle = seoTitle; + } + + public String getSeoDescription() { + return seoDescription; + } + + public void setSeoDescription(String seoDescription) { + this.seoDescription = seoDescription; + } + + public String getOgTitle() { + return ogTitle; + } + + public void setOgTitle(String ogTitle) { + this.ogTitle = ogTitle; + } + + public String getOgDescription() { + return ogDescription; + } + + public void setOgDescription(String ogDescription) { + this.ogDescription = ogDescription; + } + + public Boolean getIndexable() { + return indexable; + } + + public void setIndexable(Boolean indexable) { + this.indexable = indexable; + } + + public Boolean getIsFeatured() { + return isFeatured; + } + + public void setIsFeatured(Boolean featured) { + isFeatured = featured; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public String getNameForLanguage(String language) { + return resolveLocalizedValue(language, name, nameIt, nameEn, nameDe, nameFr); + } + + public void setNameForLanguage(String language, String value) { + switch (normalizeLanguage(language)) { + case "it" -> nameIt = value; + case "en" -> nameEn = value; + case "de" -> nameDe = value; + case "fr" -> nameFr = value; + default -> { + } + } + } + + public String getExcerptForLanguage(String language) { + return resolveLocalizedValue(language, excerpt, excerptIt, excerptEn, excerptDe, excerptFr); + } + + public void setExcerptForLanguage(String language, String value) { + switch (normalizeLanguage(language)) { + case "it" -> excerptIt = value; + case "en" -> excerptEn = value; + case "de" -> excerptDe = value; + case "fr" -> excerptFr = value; + default -> { + } + } + } + + public String getDescriptionForLanguage(String language) { + return resolveLocalizedValue(language, description, descriptionIt, descriptionEn, descriptionDe, descriptionFr); + } + + public void setDescriptionForLanguage(String language, String value) { + switch (normalizeLanguage(language)) { + case "it" -> descriptionIt = value; + case "en" -> descriptionEn = value; + case "de" -> descriptionDe = value; + case "fr" -> descriptionFr = value; + default -> { + } + } + } + + private String resolveLocalizedValue(String language, + String fallback, + String valueIt, + String valueEn, + String valueDe, + String valueFr) { + String normalizedLanguage = normalizeLanguage(language); + String preferred = switch (normalizedLanguage) { + case "it" -> valueIt; + case "en" -> valueEn; + case "de" -> valueDe; + case "fr" -> valueFr; + default -> null; + }; + String resolved = firstNonBlank(preferred, fallback); + if (resolved != null) { + return resolved; + } + return firstNonBlank(valueIt, valueEn, valueDe, valueFr); + } + + private String normalizeLanguage(String language) { + if (language == null) { + return ""; + } + String normalized = language.trim().toLowerCase(); + int separatorIndex = normalized.indexOf('-'); + if (separatorIndex > 0) { + normalized = normalized.substring(0, separatorIndex); + } + return normalized; + } + + private String firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/ShopProductModelAsset.java b/backend/src/main/java/com/printcalculator/entity/ShopProductModelAsset.java new file mode 100644 index 0000000..9287281 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/ShopProductModelAsset.java @@ -0,0 +1,189 @@ +package com.printcalculator.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import org.hibernate.annotations.ColumnDefault; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "shop_product_model_asset", indexes = { + @Index(name = "ix_shop_product_model_asset_product", columnList = "shop_product_id") +}) +public class ShopProductModelAsset { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "shop_product_model_asset_id", nullable = false) + private UUID id; + + @OneToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "shop_product_id", nullable = false, unique = true) + private ShopProduct product; + + @Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE) + private String originalFilename; + + @Column(name = "stored_relative_path", nullable = false, length = Integer.MAX_VALUE) + private String storedRelativePath; + + @Column(name = "stored_filename", nullable = false, length = Integer.MAX_VALUE) + private String storedFilename; + + @Column(name = "file_size_bytes") + private Long fileSizeBytes; + + @Column(name = "mime_type", length = Integer.MAX_VALUE) + private String mimeType; + + @Column(name = "sha256_hex", length = Integer.MAX_VALUE) + private String sha256Hex; + + @Column(name = "bounding_box_x_mm", precision = 10, scale = 3) + private BigDecimal boundingBoxXMm; + + @Column(name = "bounding_box_y_mm", precision = 10, scale = 3) + private BigDecimal boundingBoxYMm; + + @Column(name = "bounding_box_z_mm", precision = 10, scale = 3) + private BigDecimal boundingBoxZMm; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @PrePersist + private void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + } + + @PreUpdate + private void onUpdate() { + updatedAt = OffsetDateTime.now(); + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public ShopProduct getProduct() { + return product; + } + + public void setProduct(ShopProduct product) { + this.product = product; + } + + public String getOriginalFilename() { + return originalFilename; + } + + public void setOriginalFilename(String originalFilename) { + this.originalFilename = originalFilename; + } + + public String getStoredRelativePath() { + return storedRelativePath; + } + + public void setStoredRelativePath(String storedRelativePath) { + this.storedRelativePath = storedRelativePath; + } + + public String getStoredFilename() { + return storedFilename; + } + + public void setStoredFilename(String storedFilename) { + this.storedFilename = storedFilename; + } + + public Long getFileSizeBytes() { + return fileSizeBytes; + } + + public void setFileSizeBytes(Long fileSizeBytes) { + this.fileSizeBytes = fileSizeBytes; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public String getSha256Hex() { + return sha256Hex; + } + + public void setSha256Hex(String sha256Hex) { + this.sha256Hex = sha256Hex; + } + + public BigDecimal getBoundingBoxXMm() { + return boundingBoxXMm; + } + + public void setBoundingBoxXMm(BigDecimal boundingBoxXMm) { + this.boundingBoxXMm = boundingBoxXMm; + } + + public BigDecimal getBoundingBoxYMm() { + return boundingBoxYMm; + } + + public void setBoundingBoxYMm(BigDecimal boundingBoxYMm) { + this.boundingBoxYMm = boundingBoxYMm; + } + + public BigDecimal getBoundingBoxZMm() { + return boundingBoxZMm; + } + + public void setBoundingBoxZMm(BigDecimal boundingBoxZMm) { + this.boundingBoxZMm = boundingBoxZMm; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/ShopProductVariant.java b/backend/src/main/java/com/printcalculator/entity/ShopProductVariant.java new file mode 100644 index 0000000..d1d6d03 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/ShopProductVariant.java @@ -0,0 +1,218 @@ +package com.printcalculator.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import org.hibernate.annotations.ColumnDefault; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "shop_product_variant", indexes = { + @Index(name = "ix_shop_product_variant_product_active_sort", columnList = "shop_product_id, is_active, sort_order"), + @Index(name = "ix_shop_product_variant_sku", columnList = "sku") +}) +public class ShopProductVariant { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "shop_product_variant_id", nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "shop_product_id", nullable = false) + private ShopProduct product; + + @Column(name = "sku", unique = true, length = Integer.MAX_VALUE) + private String sku; + + @Column(name = "variant_label", nullable = false, length = Integer.MAX_VALUE) + private String variantLabel; + + @Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE) + private String colorName; + + @Column(name = "color_hex", length = Integer.MAX_VALUE) + private String colorHex; + + @Column(name = "internal_material_code", nullable = false, length = Integer.MAX_VALUE) + private String internalMaterialCode; + + @ColumnDefault("0.00") + @Column(name = "price_chf", nullable = false, precision = 12, scale = 2) + private BigDecimal priceChf; + + @ColumnDefault("false") + @Column(name = "is_default", nullable = false) + private Boolean isDefault; + + @ColumnDefault("true") + @Column(name = "is_active", nullable = false) + private Boolean isActive; + + @ColumnDefault("0") + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @PrePersist + private void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + if (priceChf == null) { + priceChf = BigDecimal.ZERO; + } + if (isDefault == null) { + isDefault = false; + } + if (isActive == null) { + isActive = true; + } + if (sortOrder == null) { + sortOrder = 0; + } + } + + @PreUpdate + private void onUpdate() { + updatedAt = OffsetDateTime.now(); + if (priceChf == null) { + priceChf = BigDecimal.ZERO; + } + if (isDefault == null) { + isDefault = false; + } + if (isActive == null) { + isActive = true; + } + if (sortOrder == null) { + sortOrder = 0; + } + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public ShopProduct getProduct() { + return product; + } + + public void setProduct(ShopProduct product) { + this.product = product; + } + + public String getSku() { + return sku; + } + + public void setSku(String sku) { + this.sku = sku; + } + + public String getVariantLabel() { + return variantLabel; + } + + public void setVariantLabel(String variantLabel) { + this.variantLabel = variantLabel; + } + + public String getColorName() { + return colorName; + } + + public void setColorName(String colorName) { + this.colorName = colorName; + } + + public String getColorHex() { + return colorHex; + } + + public void setColorHex(String colorHex) { + this.colorHex = colorHex; + } + + public String getInternalMaterialCode() { + return internalMaterialCode; + } + + public void setInternalMaterialCode(String internalMaterialCode) { + this.internalMaterialCode = internalMaterialCode; + } + + public BigDecimal getPriceChf() { + return priceChf; + } + + public void setPriceChf(BigDecimal priceChf) { + this.priceChf = priceChf; + } + + public Boolean getIsDefault() { + return isDefault; + } + + public void setIsDefault(Boolean aDefault) { + isDefault = aDefault; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java b/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java index 08bea80..c0beaba 100644 --- a/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java @@ -17,6 +17,19 @@ public interface MediaUsageRepository extends JpaRepository { List findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc(String usageType, String usageKey); + List findByUsageTypeAndUsageKeyOrderBySortOrderAscCreatedAtAsc(String usageType, + String usageKey); + + @Query(""" + select usage from MediaUsage usage + where usage.usageType = :usageType + and usage.usageKey in :usageKeys + and usage.isActive = true + order by usage.usageKey asc, usage.sortOrder asc, usage.createdAt asc + """) + List findActiveByUsageTypeAndUsageKeys(@Param("usageType") String usageType, + @Param("usageKeys") Collection usageKeys); + @Query(""" select usage from MediaUsage usage where usage.usageType = :usageType diff --git a/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java b/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java index 3503bb2..4005d5f 100644 --- a/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java @@ -9,4 +9,6 @@ import java.util.UUID; public interface OrderItemRepository extends JpaRepository { List findByOrder_Id(UUID orderId); boolean existsByFilamentVariant_Id(Long filamentVariantId); + boolean existsByShopProduct_Id(UUID shopProductId); + boolean existsByShopProductVariant_Id(UUID shopProductVariantId); } diff --git a/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java b/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java index 7d39175..5b51980 100644 --- a/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java @@ -4,9 +4,19 @@ import com.printcalculator.entity.QuoteLineItem; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; import java.util.UUID; public interface QuoteLineItemRepository extends JpaRepository { List findByQuoteSessionId(UUID quoteSessionId); + List findByQuoteSessionIdOrderByCreatedAtAsc(UUID quoteSessionId); + Optional findByIdAndQuoteSession_Id(UUID lineItemId, UUID quoteSessionId); + Optional findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id( + UUID quoteSessionId, + String lineItemType, + UUID shopProductVariantId + ); boolean existsByFilamentVariant_Id(Long filamentVariantId); + boolean existsByShopProduct_Id(UUID shopProductId); + boolean existsByShopProductVariant_Id(UUID shopProductVariantId); } diff --git a/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java b/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java index 3811d32..c0e64ae 100644 --- a/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java @@ -4,10 +4,13 @@ import com.printcalculator.entity.QuoteSession; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; +import java.util.Optional; import java.util.UUID; public interface QuoteSessionRepository extends JpaRepository { List findByCreatedAtBefore(java.time.OffsetDateTime cutoff); List findByStatusInOrderByCreatedAtDesc(List statuses); + + Optional findByIdAndSessionType(UUID id, String sessionType); } diff --git a/backend/src/main/java/com/printcalculator/repository/ShopCategoryRepository.java b/backend/src/main/java/com/printcalculator/repository/ShopCategoryRepository.java new file mode 100644 index 0000000..26e1887 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/ShopCategoryRepository.java @@ -0,0 +1,24 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.ShopCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ShopCategoryRepository extends JpaRepository { + Optional findBySlug(String slug); + + Optional findBySlugIgnoreCase(String slug); + + Optional findBySlugAndIsActiveTrue(String slug); + + boolean existsBySlugIgnoreCase(String slug); + + boolean existsByParentCategory_Id(UUID parentCategoryId); + + List findAllByOrderBySortOrderAscNameAsc(); + + List findAllByIsActiveTrueOrderBySortOrderAscNameAsc(); +} diff --git a/backend/src/main/java/com/printcalculator/repository/ShopProductModelAssetRepository.java b/backend/src/main/java/com/printcalculator/repository/ShopProductModelAssetRepository.java new file mode 100644 index 0000000..f381943 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/ShopProductModelAssetRepository.java @@ -0,0 +1,17 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.ShopProductModelAsset; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ShopProductModelAssetRepository extends JpaRepository { + Optional findByProduct_Id(UUID productId); + + List findByProduct_IdIn(Collection productIds); + + void deleteByProduct_Id(UUID productId); +} diff --git a/backend/src/main/java/com/printcalculator/repository/ShopProductRepository.java b/backend/src/main/java/com/printcalculator/repository/ShopProductRepository.java new file mode 100644 index 0000000..6b180c5 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/ShopProductRepository.java @@ -0,0 +1,28 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.ShopProduct; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ShopProductRepository extends JpaRepository { + Optional findBySlug(String slug); + + Optional findBySlugIgnoreCase(String slug); + + Optional findBySlugAndIsActiveTrue(String slug); + + boolean existsBySlugIgnoreCase(String slug); + + List findAllByOrderBySortOrderAscNameAsc(); + + List findAllByOrderByIsFeaturedDescSortOrderAscNameAsc(); + + List findByCategory_IdOrderBySortOrderAscNameAsc(UUID categoryId); + + List findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc(); + + boolean existsByCategory_Id(UUID categoryId); +} diff --git a/backend/src/main/java/com/printcalculator/repository/ShopProductVariantRepository.java b/backend/src/main/java/com/printcalculator/repository/ShopProductVariantRepository.java new file mode 100644 index 0000000..4e285cb --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/ShopProductVariantRepository.java @@ -0,0 +1,25 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.ShopProductVariant; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ShopProductVariantRepository extends JpaRepository { + List findByProduct_IdOrderBySortOrderAscColorNameAsc(UUID productId); + + List findByProduct_IdInOrderBySortOrderAscColorNameAsc(Collection productIds); + + List findByProduct_IdAndIsActiveTrueOrderBySortOrderAscColorNameAsc(UUID productId); + + List findByProduct_IdInAndIsActiveTrueOrderBySortOrderAscColorNameAsc(Collection productIds); + + Optional findFirstByProduct_IdAndIsDefaultTrue(UUID productId); + + boolean existsBySkuIgnoreCase(String sku); + + boolean existsBySkuIgnoreCaseAndIdNot(String sku, UUID variantId); +} diff --git a/backend/src/main/java/com/printcalculator/service/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java index ebef7d8..9179f7d 100644 --- a/backend/src/main/java/com/printcalculator/service/OrderService.java +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -104,6 +104,7 @@ public class OrderService { Order order = new Order(); order.setSourceQuoteSession(session); + order.setSourceType(resolveOrderSourceType(session)); order.setCustomer(customer); order.setCustomerEmail(request.getCustomer().getEmail()); order.setCustomerPhone(request.getCustomer().getPhone()); @@ -172,12 +173,27 @@ public class OrderService { for (QuoteLineItem qItem : quoteItems) { OrderItem oItem = new OrderItem(); oItem.setOrder(order); + oItem.setItemType(qItem.getLineItemType() != null ? qItem.getLineItemType() : "PRINT_FILE"); oItem.setOriginalFilename(qItem.getOriginalFilename()); + oItem.setDisplayName( + qItem.getDisplayName() != null && !qItem.getDisplayName().isBlank() + ? qItem.getDisplayName() + : qItem.getOriginalFilename() + ); int quantity = qItem.getQuantity() != null && qItem.getQuantity() > 0 ? qItem.getQuantity() : 1; oItem.setQuantity(quantity); oItem.setColorCode(qItem.getColorCode()); oItem.setFilamentVariant(qItem.getFilamentVariant()); - if (qItem.getFilamentVariant() != null + oItem.setShopProduct(qItem.getShopProduct()); + oItem.setShopProductVariant(qItem.getShopProductVariant()); + oItem.setShopProductSlug(qItem.getShopProductSlug()); + oItem.setShopProductName(qItem.getShopProductName()); + oItem.setShopVariantLabel(qItem.getShopVariantLabel()); + oItem.setShopVariantColorName(qItem.getShopVariantColorName()); + oItem.setShopVariantColorHex(qItem.getShopVariantColorHex()); + if (qItem.getMaterialCode() != null && !qItem.getMaterialCode().isBlank()) { + oItem.setMaterialCode(qItem.getMaterialCode()); + } else if (qItem.getFilamentVariant() != null && qItem.getFilamentVariant().getFilamentMaterialType() != null && qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode() != null) { oItem.setMaterialCode(qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode()); @@ -319,6 +335,13 @@ public class OrderService { } } + private String resolveOrderSourceType(QuoteSession session) { + if (session != null && "SHOP_CART".equalsIgnoreCase(session.getSessionType())) { + return "SHOP"; + } + return "CALCULATOR"; + } + private String getDisplayOrderNumber(Order order) { String orderNumber = order.getOrderNumber(); if (orderNumber != null && !orderNumber.isBlank()) { diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java index c0bf0ac..0210f73 100644 --- a/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java @@ -330,6 +330,18 @@ public class AdminMediaControllerService { mediaUsageRepository.delete(getUsageOrThrow(mediaUsageId)); } + public List getUsages(String usageType, String usageKey, UUID ownerId) { + String normalizedUsageType = requireUsageType(usageType); + String normalizedUsageKey = requireUsageKey(usageKey); + return mediaUsageRepository.findByUsageScope(normalizedUsageType, normalizedUsageKey, ownerId) + .stream() + .sorted(Comparator + .comparing(MediaUsage::getSortOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(MediaUsage::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo))) + .map(this::toUsageDto) + .toList(); + } + private List generateDerivedVariants(MediaAsset asset, Path sourceFile, Path tempDirectory) throws IOException { Path generatedDirectory = Files.createDirectories(tempDirectory.resolve("generated")); String storageFolder = extractStorageFolder(asset.getStorageKey()); diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminOperationsControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminOperationsControllerService.java index 1291c1a..2aef314 100644 --- a/backend/src/main/java/com/printcalculator/service/admin/AdminOperationsControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminOperationsControllerService.java @@ -301,6 +301,7 @@ public class AdminOperationsControllerService { } else { session = new QuoteSession(); session.setStatus("CAD_ACTIVE"); + session.setSessionType("PRINT_QUOTE"); session.setPricingVersion("v1"); session.setMaterialCode("PLA"); session.setNozzleDiameterMm(BigDecimal.valueOf(0.4)); @@ -398,6 +399,7 @@ public class AdminOperationsControllerService { AdminQuoteSessionDto dto = new AdminQuoteSessionDto(); dto.setId(session.getId()); dto.setStatus(session.getStatus()); + dto.setSessionType(session.getSessionType() != null ? session.getSessionType() : "PRINT_QUOTE"); dto.setMaterialCode(session.getMaterialCode()); dto.setCreatedAt(session.getCreatedAt()); dto.setExpiresAt(session.getExpiresAt()); diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminShopCategoryControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminShopCategoryControllerService.java new file mode 100644 index 0000000..e327ac6 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminShopCategoryControllerService.java @@ -0,0 +1,334 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.dto.AdminShopCategoryDto; +import com.printcalculator.dto.AdminShopCategoryRefDto; +import com.printcalculator.dto.AdminUpsertShopCategoryRequest; +import com.printcalculator.entity.ShopCategory; +import com.printcalculator.entity.ShopProduct; +import com.printcalculator.repository.ShopCategoryRepository; +import com.printcalculator.repository.ShopProductRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.text.Normalizer; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@Service +@Transactional(readOnly = true) +public class AdminShopCategoryControllerService { + private static final String SHOP_CATEGORY_MEDIA_USAGE_TYPE = "SHOP_CATEGORY"; + private static final Pattern DIACRITICS_PATTERN = Pattern.compile("\\p{M}+"); + private static final Pattern NON_ALPHANUMERIC_PATTERN = Pattern.compile("[^a-z0-9]+"); + private static final Pattern EDGE_DASH_PATTERN = Pattern.compile("(^-+|-+$)"); + + private final ShopCategoryRepository shopCategoryRepository; + private final ShopProductRepository shopProductRepository; + + public AdminShopCategoryControllerService(ShopCategoryRepository shopCategoryRepository, + ShopProductRepository shopProductRepository) { + this.shopCategoryRepository = shopCategoryRepository; + this.shopProductRepository = shopProductRepository; + } + + public List getCategories() { + CategoryContext context = buildContext(); + List result = new ArrayList<>(); + appendFlatCategories(null, 0, context, result); + return result; + } + + public List getCategoryTree() { + return buildCategoryTree(null, 0, buildContext()); + } + + public AdminShopCategoryDto getCategory(UUID categoryId) { + CategoryContext context = buildContext(); + ShopCategory category = context.categoriesById().get(categoryId); + if (category == null) { + throw new ResponseStatusException(NOT_FOUND, "Shop category not found"); + } + return toDto(category, resolveDepth(category), context, true); + } + + @Transactional + public AdminShopCategoryDto createCategory(AdminUpsertShopCategoryRequest payload) { + ensurePayload(payload); + String normalizedName = normalizeRequiredName(payload.getName()); + String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), normalizedName); + ensureSlugAvailable(normalizedSlug, null); + + ShopCategory category = new ShopCategory(); + category.setCreatedAt(OffsetDateTime.now()); + applyPayload(category, payload, normalizedName, normalizedSlug, null); + + ShopCategory saved = shopCategoryRepository.save(category); + return getCategory(saved.getId()); + } + + @Transactional + public AdminShopCategoryDto updateCategory(UUID categoryId, AdminUpsertShopCategoryRequest payload) { + ensurePayload(payload); + + ShopCategory category = shopCategoryRepository.findById(categoryId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Shop category not found")); + + String normalizedName = normalizeRequiredName(payload.getName()); + String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), normalizedName); + ensureSlugAvailable(normalizedSlug, category.getId()); + + applyPayload(category, payload, normalizedName, normalizedSlug, category.getId()); + ShopCategory saved = shopCategoryRepository.save(category); + return getCategory(saved.getId()); + } + + @Transactional + public void deleteCategory(UUID categoryId) { + ShopCategory category = shopCategoryRepository.findById(categoryId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Shop category not found")); + + if (shopCategoryRepository.existsByParentCategory_Id(categoryId)) { + throw new ResponseStatusException(CONFLICT, "Category has child categories and cannot be deleted"); + } + if (shopProductRepository.existsByCategory_Id(categoryId)) { + throw new ResponseStatusException(CONFLICT, "Category has products and cannot be deleted"); + } + + shopCategoryRepository.delete(category); + } + + private void applyPayload(ShopCategory category, + AdminUpsertShopCategoryRequest payload, + String normalizedName, + String normalizedSlug, + UUID currentCategoryId) { + ShopCategory parentCategory = resolveParentCategory(payload.getParentCategoryId(), currentCategoryId); + + category.setParentCategory(parentCategory); + category.setSlug(normalizedSlug); + category.setName(normalizedName); + category.setDescription(normalizeOptional(payload.getDescription())); + category.setSeoTitle(normalizeOptional(payload.getSeoTitle())); + category.setSeoDescription(normalizeOptional(payload.getSeoDescription())); + category.setOgTitle(normalizeOptional(payload.getOgTitle())); + category.setOgDescription(normalizeOptional(payload.getOgDescription())); + category.setIndexable(payload.getIndexable() == null || payload.getIndexable()); + category.setIsActive(payload.getIsActive() == null || payload.getIsActive()); + category.setSortOrder(payload.getSortOrder() != null ? payload.getSortOrder() : 0); + category.setUpdatedAt(OffsetDateTime.now()); + } + + private ShopCategory resolveParentCategory(UUID parentCategoryId, UUID currentCategoryId) { + if (parentCategoryId == null) { + return null; + } + if (currentCategoryId != null && currentCategoryId.equals(parentCategoryId)) { + throw new ResponseStatusException(BAD_REQUEST, "Category cannot be its own parent"); + } + + ShopCategory parentCategory = shopCategoryRepository.findById(parentCategoryId) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Parent category not found")); + + if (currentCategoryId != null) { + ShopCategory ancestor = parentCategory; + while (ancestor != null) { + if (currentCategoryId.equals(ancestor.getId())) { + throw new ResponseStatusException(BAD_REQUEST, "Category hierarchy would create a cycle"); + } + ancestor = ancestor.getParentCategory(); + } + } + + return parentCategory; + } + + private void ensurePayload(AdminUpsertShopCategoryRequest payload) { + if (payload == null) { + throw new ResponseStatusException(BAD_REQUEST, "Payload is required"); + } + } + + private String normalizeRequiredName(String name) { + String normalized = normalizeOptional(name); + if (normalized == null) { + throw new ResponseStatusException(BAD_REQUEST, "Category name is required"); + } + return normalized; + } + + private String normalizeAndValidateSlug(String slug, String fallbackName) { + String source = normalizeOptional(slug); + if (source == null) { + source = fallbackName; + } + + String normalized = Normalizer.normalize(source, Normalizer.Form.NFD); + normalized = DIACRITICS_PATTERN.matcher(normalized).replaceAll(""); + normalized = normalized.toLowerCase(Locale.ROOT); + normalized = NON_ALPHANUMERIC_PATTERN.matcher(normalized).replaceAll("-"); + normalized = EDGE_DASH_PATTERN.matcher(normalized).replaceAll(""); + + if (normalized.isBlank()) { + throw new ResponseStatusException(BAD_REQUEST, "Slug is invalid"); + } + return normalized; + } + + private void ensureSlugAvailable(String slug, UUID currentCategoryId) { + shopCategoryRepository.findBySlugIgnoreCase(slug).ifPresent(existing -> { + if (currentCategoryId == null || !existing.getId().equals(currentCategoryId)) { + throw new ResponseStatusException(BAD_REQUEST, "Category slug already exists"); + } + }); + } + + private String normalizeOptional(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isBlank() ? null : normalized; + } + + private CategoryContext buildContext() { + List categories = shopCategoryRepository.findAllByOrderBySortOrderAscNameAsc(); + List products = shopProductRepository.findAll(); + + Map categoriesById = categories.stream() + .collect(Collectors.toMap(ShopCategory::getId, category -> category, (left, right) -> left, LinkedHashMap::new)); + Map> childrenByParentId = new LinkedHashMap<>(); + for (ShopCategory category : categories) { + UUID parentId = category.getParentCategory() != null ? category.getParentCategory().getId() : null; + childrenByParentId.computeIfAbsent(parentId, ignored -> new ArrayList<>()).add(category); + } + Comparator comparator = Comparator + .comparing(ShopCategory::getSortOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(ShopCategory::getName, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)); + childrenByParentId.values().forEach(children -> children.sort(comparator)); + + Map directProductCounts = new LinkedHashMap<>(); + for (ShopProduct product : products) { + if (product.getCategory() == null || product.getCategory().getId() == null) { + continue; + } + directProductCounts.merge(product.getCategory().getId(), 1, Integer::sum); + } + + Map descendantProductCounts = new LinkedHashMap<>(); + for (ShopCategory category : categories) { + resolveDescendantProductCount(category.getId(), childrenByParentId, directProductCounts, descendantProductCounts); + } + + return new CategoryContext(categoriesById, childrenByParentId, directProductCounts, descendantProductCounts); + } + + private int resolveDescendantProductCount(UUID categoryId, + Map> childrenByParentId, + Map directProductCounts, + Map descendantProductCounts) { + Integer cached = descendantProductCounts.get(categoryId); + if (cached != null) { + return cached; + } + + int total = directProductCounts.getOrDefault(categoryId, 0); + for (ShopCategory child : childrenByParentId.getOrDefault(categoryId, List.of())) { + total += resolveDescendantProductCount(child.getId(), childrenByParentId, directProductCounts, descendantProductCounts); + } + descendantProductCounts.put(categoryId, total); + return total; + } + + private void appendFlatCategories(UUID parentId, + int depth, + CategoryContext context, + List result) { + for (ShopCategory category : context.childrenByParentId().getOrDefault(parentId, List.of())) { + result.add(toDto(category, depth, context, false)); + appendFlatCategories(category.getId(), depth + 1, context, result); + } + } + + private List buildCategoryTree(UUID parentId, int depth, CategoryContext context) { + return context.childrenByParentId().getOrDefault(parentId, List.of()).stream() + .map(category -> toDto(category, depth, context, true)) + .toList(); + } + + private AdminShopCategoryDto toDto(ShopCategory category, + int depth, + CategoryContext context, + boolean includeChildren) { + AdminShopCategoryDto dto = new AdminShopCategoryDto(); + dto.setId(category.getId()); + dto.setParentCategoryId(category.getParentCategory() != null ? category.getParentCategory().getId() : null); + dto.setParentCategoryName(category.getParentCategory() != null ? category.getParentCategory().getName() : null); + dto.setSlug(category.getSlug()); + dto.setName(category.getName()); + dto.setDescription(category.getDescription()); + dto.setSeoTitle(category.getSeoTitle()); + dto.setSeoDescription(category.getSeoDescription()); + dto.setOgTitle(category.getOgTitle()); + dto.setOgDescription(category.getOgDescription()); + dto.setIndexable(category.getIndexable()); + dto.setIsActive(category.getIsActive()); + dto.setSortOrder(category.getSortOrder()); + dto.setDepth(depth); + dto.setChildCount(context.childrenByParentId().getOrDefault(category.getId(), List.of()).size()); + dto.setDirectProductCount(context.directProductCounts().getOrDefault(category.getId(), 0)); + dto.setDescendantProductCount(context.descendantProductCounts().getOrDefault(category.getId(), 0)); + dto.setMediaUsageType(SHOP_CATEGORY_MEDIA_USAGE_TYPE); + dto.setMediaUsageKey(category.getId().toString()); + dto.setBreadcrumbs(buildBreadcrumbs(category)); + dto.setChildren(includeChildren ? buildCategoryTree(category.getId(), depth + 1, context) : List.of()); + dto.setCreatedAt(category.getCreatedAt()); + dto.setUpdatedAt(category.getUpdatedAt()); + return dto; + } + + private List buildBreadcrumbs(ShopCategory category) { + List breadcrumbs = new ArrayList<>(); + ShopCategory current = category; + while (current != null) { + AdminShopCategoryRefDto ref = new AdminShopCategoryRefDto(); + ref.setId(current.getId()); + ref.setSlug(current.getSlug()); + ref.setName(current.getName()); + breadcrumbs.add(ref); + current = current.getParentCategory(); + } + java.util.Collections.reverse(breadcrumbs); + return breadcrumbs; + } + + private int resolveDepth(ShopCategory category) { + int depth = 0; + ShopCategory current = category != null ? category.getParentCategory() : null; + while (current != null) { + depth++; + current = current.getParentCategory(); + } + return depth; + } + + private record CategoryContext( + Map categoriesById, + Map> childrenByParentId, + Map directProductCounts, + Map descendantProductCounts + ) { + } +} diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java new file mode 100644 index 0000000..c90ef08 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java @@ -0,0 +1,794 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.dto.AdminMediaUsageDto; +import com.printcalculator.dto.AdminShopProductDto; +import com.printcalculator.dto.AdminShopProductVariantDto; +import com.printcalculator.dto.AdminUpsertShopProductRequest; +import com.printcalculator.dto.AdminUpsertShopProductVariantRequest; +import com.printcalculator.dto.PublicMediaUsageDto; +import com.printcalculator.dto.ShopProductModelDto; +import com.printcalculator.entity.ShopCategory; +import com.printcalculator.entity.ShopProduct; +import com.printcalculator.entity.ShopProductModelAsset; +import com.printcalculator.entity.ShopProductVariant; +import com.printcalculator.model.ModelDimensions; +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.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.Normalizer; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +public class AdminShopProductControllerService { + private static final String SHOP_PRODUCT_MEDIA_USAGE_TYPE = "SHOP_PRODUCT"; + private static final Set SUPPORTED_MODEL_EXTENSIONS = Set.of("stl", "3mf"); + private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#[0-9A-Fa-f]{6}$"); + private static final Pattern DIACRITICS_PATTERN = Pattern.compile("\\p{M}+"); + private static final Pattern NON_ALPHANUMERIC_PATTERN = Pattern.compile("[^a-z0-9]+"); + private static final Pattern EDGE_DASH_PATTERN = Pattern.compile("(^-+|-+$)"); + + private final ShopProductRepository shopProductRepository; + private final ShopCategoryRepository shopCategoryRepository; + private final ShopProductVariantRepository shopProductVariantRepository; + private final ShopProductModelAssetRepository shopProductModelAssetRepository; + private final QuoteLineItemRepository quoteLineItemRepository; + private final OrderItemRepository orderItemRepository; + private final PublicMediaQueryService publicMediaQueryService; + private final AdminMediaControllerService adminMediaControllerService; + private final ShopStorageService shopStorageService; + private final SlicerService slicerService; + private final ClamAVService clamAVService; + private final long maxModelFileSizeBytes; + + public AdminShopProductControllerService(ShopProductRepository shopProductRepository, + ShopCategoryRepository shopCategoryRepository, + ShopProductVariantRepository shopProductVariantRepository, + ShopProductModelAssetRepository shopProductModelAssetRepository, + QuoteLineItemRepository quoteLineItemRepository, + OrderItemRepository orderItemRepository, + PublicMediaQueryService publicMediaQueryService, + AdminMediaControllerService adminMediaControllerService, + ShopStorageService shopStorageService, + SlicerService slicerService, + ClamAVService clamAVService, + @Value("${shop.model.max-file-size-bytes:104857600}") long maxModelFileSizeBytes) { + this.shopProductRepository = shopProductRepository; + this.shopCategoryRepository = shopCategoryRepository; + this.shopProductVariantRepository = shopProductVariantRepository; + this.shopProductModelAssetRepository = shopProductModelAssetRepository; + this.quoteLineItemRepository = quoteLineItemRepository; + this.orderItemRepository = orderItemRepository; + this.publicMediaQueryService = publicMediaQueryService; + this.adminMediaControllerService = adminMediaControllerService; + this.shopStorageService = shopStorageService; + this.slicerService = slicerService; + this.clamAVService = clamAVService; + this.maxModelFileSizeBytes = maxModelFileSizeBytes; + } + + public List getProducts() { + return toProductDtos(shopProductRepository.findAllByOrderByIsFeaturedDescSortOrderAscNameAsc()); + } + + public AdminShopProductDto getProduct(UUID productId) { + ShopProduct product = shopProductRepository.findById(productId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product not found")); + return toProductDtos(List.of(product)).get(0); + } + + @Transactional + public AdminShopProductDto createProduct(AdminUpsertShopProductRequest payload) { + ensurePayload(payload); + LocalizedProductContent localizedContent = normalizeLocalizedProductContent(payload); + String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName()); + ensureSlugAvailable(normalizedSlug, null); + + ShopProduct product = new ShopProduct(); + product.setCreatedAt(OffsetDateTime.now()); + applyProductPayload(product, payload, localizedContent, normalizedSlug, resolveCategory(payload.getCategoryId())); + ShopProduct saved = shopProductRepository.save(product); + syncVariants(saved, payload.getVariants()); + return getProduct(saved.getId()); + } + + @Transactional + public AdminShopProductDto updateProduct(UUID productId, AdminUpsertShopProductRequest payload) { + ensurePayload(payload); + ShopProduct product = shopProductRepository.findById(productId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product not found")); + + LocalizedProductContent localizedContent = normalizeLocalizedProductContent(payload); + String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName()); + ensureSlugAvailable(normalizedSlug, productId); + + applyProductPayload(product, payload, localizedContent, normalizedSlug, resolveCategory(payload.getCategoryId())); + ShopProduct saved = shopProductRepository.save(product); + syncVariants(saved, payload.getVariants()); + return getProduct(saved.getId()); + } + + @Transactional + public void deleteProduct(UUID productId) { + ShopProduct product = shopProductRepository.findById(productId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product not found")); + + if (quoteLineItemRepository.existsByShopProduct_Id(productId) + || orderItemRepository.existsByShopProduct_Id(productId)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Product is already used in carts or orders and cannot be deleted"); + } + + List variants = shopProductVariantRepository.findByProduct_IdOrderBySortOrderAscColorNameAsc(productId); + for (ShopProductVariant variant : variants) { + if (quoteLineItemRepository.existsByShopProductVariant_Id(variant.getId()) + || orderItemRepository.existsByShopProductVariant_Id(variant.getId())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "One or more variants are already used in carts or orders and cannot be deleted"); + } + } + + shopProductModelAssetRepository.findByProduct_Id(productId).ifPresent(asset -> deleteExistingModelFile(asset, productId)); + shopProductRepository.delete(product); + } + + @Transactional + public AdminShopProductDto uploadProductModel(UUID productId, MultipartFile file) throws IOException { + ShopProduct product = shopProductRepository.findById(productId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product not found")); + validateModelUpload(file); + + Path tempDirectory = Files.createTempDirectory("shop-product-model-"); + Path destination = null; + try { + String cleanedFilename = sanitizeOriginalFilename(file.getOriginalFilename()); + String extension = resolveExtension(cleanedFilename); + Path uploadPath = tempDirectory.resolve("upload." + extension); + file.transferTo(uploadPath); + + try (InputStream inputStream = Files.newInputStream(uploadPath)) { + clamAVService.scan(inputStream); + } + + Path storageDir = shopStorageService.productModelStorageDir(productId); + destination = storageDir.resolve(UUID.randomUUID() + ".stl"); + if ("3mf".equals(extension)) { + slicerService.convert3mfToPersistentStl(uploadPath.toFile(), destination); + } else { + Files.copy(uploadPath, destination, StandardCopyOption.REPLACE_EXISTING); + } + + ModelDimensions dimensions = slicerService.inspectModelDimensions(destination.toFile()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unable to extract model dimensions")); + + ShopProductModelAsset asset = shopProductModelAssetRepository.findByProduct_Id(productId) + .orElseGet(ShopProductModelAsset::new); + String previousStoredRelativePath = asset.getStoredRelativePath(); + + asset.setProduct(product); + asset.setOriginalFilename(buildDownloadFilename(cleanedFilename)); + asset.setStoredFilename(destination.getFileName().toString()); + asset.setStoredRelativePath(shopStorageService.toStoredPath(destination)); + asset.setMimeType("model/stl"); + asset.setFileSizeBytes(Files.size(destination)); + asset.setSha256Hex(computeSha256(destination)); + asset.setBoundingBoxXMm(BigDecimal.valueOf(dimensions.xMm())); + asset.setBoundingBoxYMm(BigDecimal.valueOf(dimensions.yMm())); + asset.setBoundingBoxZMm(BigDecimal.valueOf(dimensions.zMm())); + if (asset.getCreatedAt() == null) { + asset.setCreatedAt(OffsetDateTime.now()); + } + asset.setUpdatedAt(OffsetDateTime.now()); + shopProductModelAssetRepository.save(asset); + deleteStoredRelativePath(previousStoredRelativePath, productId, asset.getStoredRelativePath()); + + return getProduct(productId); + } catch (IOException | RuntimeException e) { + deletePathQuietly(destination); + throw e; + } finally { + deleteRecursively(tempDirectory); + } + } + + @Transactional + public void deleteProductModel(UUID productId) { + shopProductRepository.findById(productId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product not found")); + ShopProductModelAsset asset = shopProductModelAssetRepository.findByProduct_Id(productId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product model not found")); + + deleteExistingModelFile(asset, productId); + shopProductModelAssetRepository.delete(asset); + } + + public ProductModelDownload getProductModel(UUID productId) { + ShopProduct product = shopProductRepository.findById(productId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product not found")); + ShopProductModelAsset asset = shopProductModelAssetRepository.findByProduct_Id(productId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product model not found")); + + Path path = shopStorageService.resolveStoredProductPath(asset.getStoredRelativePath(), product.getId()); + if (path == null || !Files.exists(path)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product model not found"); + } + + return new ProductModelDownload(path, asset.getOriginalFilename(), asset.getMimeType()); + } + + private void syncVariants(ShopProduct product, List variantPayloads) { + List normalizedPayloads = normalizeVariantPayloads(variantPayloads); + List existingVariants = shopProductVariantRepository.findByProduct_IdOrderBySortOrderAscColorNameAsc(product.getId()); + Map existingById = existingVariants.stream() + .collect(Collectors.toMap(ShopProductVariant::getId, variant -> variant, (left, right) -> left, LinkedHashMap::new)); + + Set retainedIds = new LinkedHashSet<>(); + List variantsToSave = new ArrayList<>(); + + for (AdminUpsertShopProductVariantRequest payload : normalizedPayloads) { + ShopProductVariant variant; + if (payload.getId() != null) { + variant = existingById.get(payload.getId()); + if (variant == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant does not belong to the product"); + } + retainedIds.add(variant.getId()); + } else { + variant = new ShopProductVariant(); + variant.setCreatedAt(OffsetDateTime.now()); + } + + applyVariantPayload(variant, product, payload); + variantsToSave.add(variant); + } + + List variantsToDelete = existingVariants.stream() + .filter(variant -> !retainedIds.contains(variant.getId())) + .toList(); + for (ShopProductVariant variant : variantsToDelete) { + if (quoteLineItemRepository.existsByShopProductVariant_Id(variant.getId()) + || orderItemRepository.existsByShopProductVariant_Id(variant.getId())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Variant is already used in carts or orders and cannot be removed"); + } + } + + if (!variantsToDelete.isEmpty()) { + shopProductVariantRepository.deleteAll(variantsToDelete); + } + shopProductVariantRepository.saveAll(variantsToSave); + } + + private void applyProductPayload(ShopProduct product, + AdminUpsertShopProductRequest payload, + LocalizedProductContent localizedContent, + String normalizedSlug, + ShopCategory category) { + product.setCategory(category); + product.setSlug(normalizedSlug); + product.setName(localizedContent.defaultName()); + product.setNameIt(localizedContent.names().get("it")); + product.setNameEn(localizedContent.names().get("en")); + product.setNameDe(localizedContent.names().get("de")); + product.setNameFr(localizedContent.names().get("fr")); + product.setExcerpt(localizedContent.defaultExcerpt()); + product.setExcerptIt(localizedContent.excerpts().get("it")); + product.setExcerptEn(localizedContent.excerpts().get("en")); + product.setExcerptDe(localizedContent.excerpts().get("de")); + product.setExcerptFr(localizedContent.excerpts().get("fr")); + product.setDescription(localizedContent.defaultDescription()); + product.setDescriptionIt(localizedContent.descriptions().get("it")); + product.setDescriptionEn(localizedContent.descriptions().get("en")); + product.setDescriptionDe(localizedContent.descriptions().get("de")); + product.setDescriptionFr(localizedContent.descriptions().get("fr")); + product.setSeoTitle(normalizeOptional(payload.getSeoTitle())); + product.setSeoDescription(normalizeOptional(payload.getSeoDescription())); + product.setOgTitle(normalizeOptional(payload.getOgTitle())); + product.setOgDescription(normalizeOptional(payload.getOgDescription())); + product.setIndexable(payload.getIndexable() == null || payload.getIndexable()); + product.setIsFeatured(Boolean.TRUE.equals(payload.getIsFeatured())); + product.setIsActive(payload.getIsActive() == null || payload.getIsActive()); + product.setSortOrder(payload.getSortOrder() != null ? payload.getSortOrder() : 0); + product.setUpdatedAt(OffsetDateTime.now()); + } + + private void applyVariantPayload(ShopProductVariant variant, + ShopProduct product, + AdminUpsertShopProductVariantRequest payload) { + String normalizedColorName = normalizeRequired(payload.getColorName(), "Variant colorName is required"); + String normalizedVariantLabel = normalizeOptional(payload.getVariantLabel()); + String normalizedSku = normalizeOptional(payload.getSku()); + String normalizedMaterialCode = normalizeRequired( + payload.getInternalMaterialCode(), + "Variant internalMaterialCode is required" + ).toUpperCase(Locale.ROOT); + + BigDecimal price = payload.getPriceChf(); + if (price == null || price.compareTo(BigDecimal.ZERO) < 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant priceChf must be >= 0"); + } + if (price.scale() > 2) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant priceChf must have at most 2 decimal places"); + } + + if (normalizedSku != null) { + if (variant.getId() == null) { + if (shopProductVariantRepository.existsBySkuIgnoreCase(normalizedSku)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant SKU already exists"); + } + } else if (shopProductVariantRepository.existsBySkuIgnoreCaseAndIdNot(normalizedSku, variant.getId())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant SKU already exists"); + } + } + + variant.setProduct(product); + variant.setSku(normalizedSku); + variant.setVariantLabel(normalizedVariantLabel != null ? normalizedVariantLabel : normalizedColorName); + variant.setColorName(normalizedColorName); + variant.setColorHex(normalizeColorHex(payload.getColorHex())); + variant.setInternalMaterialCode(normalizedMaterialCode); + variant.setPriceChf(price); + variant.setIsDefault(Boolean.TRUE.equals(payload.getIsDefault())); + variant.setIsActive(payload.getIsActive() == null || payload.getIsActive()); + variant.setSortOrder(payload.getSortOrder() != null ? payload.getSortOrder() : 0); + variant.setUpdatedAt(OffsetDateTime.now()); + } + + private List normalizeVariantPayloads(List payloads) { + if (payloads == null || payloads.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one variant is required"); + } + + List normalized = new ArrayList<>(payloads); + Set colorKeys = 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); + } + if (Boolean.TRUE.equals(payload.getIsDefault())) { + defaultCount++; + } + } + + if (defaultCount > 1) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Only one variant can be default"); + } + if (defaultCount == 0) { + AdminUpsertShopProductVariantRequest fallbackDefault = normalized.stream() + .filter(payload -> payload.getIsActive() == null || payload.getIsActive()) + .findFirst() + .orElse(normalized.get(0)); + fallbackDefault.setIsDefault(true); + } + return normalized; + } + + private List toProductDtos(List products) { + if (products == null || products.isEmpty()) { + return List.of(); + } + + List productIds = products.stream().map(ShopProduct::getId).toList(); + Map> variantsByProductId = shopProductVariantRepository + .findByProduct_IdInOrderBySortOrderAscColorNameAsc(productIds) + .stream() + .collect(Collectors.groupingBy( + variant -> variant.getProduct().getId(), + LinkedHashMap::new, + Collectors.toList() + )); + Map modelAssetsByProductId = shopProductModelAssetRepository.findByProduct_IdIn(productIds) + .stream() + .collect(Collectors.toMap(asset -> asset.getProduct().getId(), asset -> asset, (left, right) -> left, LinkedHashMap::new)); + Map> publicImagesByUsageKey = publicMediaQueryService.getUsageMediaMap( + SHOP_PRODUCT_MEDIA_USAGE_TYPE, + products.stream().map(this::mediaUsageKey).toList(), + null + ); + + return products.stream() + .map(product -> { + String usageKey = mediaUsageKey(product); + return toProductDto( + product, + variantsByProductId.getOrDefault(product.getId(), List.of()), + modelAssetsByProductId.get(product.getId()), + publicImagesByUsageKey.getOrDefault(usageKey, List.of()), + adminMediaControllerService.getUsages(SHOP_PRODUCT_MEDIA_USAGE_TYPE, usageKey, null) + ); + }) + .toList(); + } + + private AdminShopProductDto toProductDto(ShopProduct product, + List variants, + ShopProductModelAsset modelAsset, + List images, + List mediaUsages) { + AdminShopProductDto dto = new AdminShopProductDto(); + dto.setId(product.getId()); + dto.setCategoryId(product.getCategory() != null ? product.getCategory().getId() : null); + dto.setCategoryName(product.getCategory() != null ? product.getCategory().getName() : null); + dto.setCategorySlug(product.getCategory() != null ? product.getCategory().getSlug() : null); + dto.setSlug(product.getSlug()); + dto.setName(product.getName()); + dto.setNameIt(product.getNameIt()); + dto.setNameEn(product.getNameEn()); + dto.setNameDe(product.getNameDe()); + dto.setNameFr(product.getNameFr()); + dto.setExcerpt(product.getExcerpt()); + dto.setExcerptIt(product.getExcerptIt()); + dto.setExcerptEn(product.getExcerptEn()); + dto.setExcerptDe(product.getExcerptDe()); + dto.setExcerptFr(product.getExcerptFr()); + dto.setDescription(product.getDescription()); + dto.setDescriptionIt(product.getDescriptionIt()); + dto.setDescriptionEn(product.getDescriptionEn()); + dto.setDescriptionDe(product.getDescriptionDe()); + dto.setDescriptionFr(product.getDescriptionFr()); + dto.setSeoTitle(product.getSeoTitle()); + dto.setSeoDescription(product.getSeoDescription()); + dto.setOgTitle(product.getOgTitle()); + dto.setOgDescription(product.getOgDescription()); + dto.setIndexable(product.getIndexable()); + dto.setIsFeatured(product.getIsFeatured()); + dto.setIsActive(product.getIsActive()); + dto.setSortOrder(product.getSortOrder()); + dto.setVariantCount(variants.size()); + dto.setActiveVariantCount((int) variants.stream().filter(variant -> Boolean.TRUE.equals(variant.getIsActive())).count()); + dto.setPriceFromChf(resolvePriceFrom(variants)); + dto.setPriceToChf(resolvePriceTo(variants)); + dto.setMediaUsageType(SHOP_PRODUCT_MEDIA_USAGE_TYPE); + dto.setMediaUsageKey(mediaUsageKey(product)); + dto.setMediaUsages(mediaUsages); + dto.setImages(images); + dto.setModel3d(toModelDto(product, modelAsset)); + dto.setVariants(variants.stream().map(this::toVariantDto).toList()); + dto.setCreatedAt(product.getCreatedAt()); + dto.setUpdatedAt(product.getUpdatedAt()); + return dto; + } + + private AdminShopProductVariantDto toVariantDto(ShopProductVariant variant) { + AdminShopProductVariantDto dto = new AdminShopProductVariantDto(); + dto.setId(variant.getId()); + dto.setSku(variant.getSku()); + dto.setVariantLabel(variant.getVariantLabel()); + dto.setColorName(variant.getColorName()); + dto.setColorHex(variant.getColorHex()); + dto.setInternalMaterialCode(variant.getInternalMaterialCode()); + dto.setPriceChf(variant.getPriceChf()); + dto.setIsDefault(variant.getIsDefault()); + dto.setIsActive(variant.getIsActive()); + dto.setSortOrder(variant.getSortOrder()); + dto.setCreatedAt(variant.getCreatedAt()); + dto.setUpdatedAt(variant.getUpdatedAt()); + return dto; + } + + private ShopProductModelDto toModelDto(ShopProduct product, ShopProductModelAsset asset) { + if (asset == null) { + return null; + } + return new ShopProductModelDto( + "/api/admin/shop/products/" + product.getId() + "/model", + asset.getOriginalFilename(), + asset.getMimeType(), + asset.getFileSizeBytes(), + asset.getBoundingBoxXMm(), + asset.getBoundingBoxYMm(), + asset.getBoundingBoxZMm() + ); + } + + private BigDecimal resolvePriceFrom(List variants) { + return variants.stream() + .map(ShopProductVariant::getPriceChf) + .filter(Objects::nonNull) + .min(BigDecimal::compareTo) + .orElse(BigDecimal.ZERO); + } + + private BigDecimal resolvePriceTo(List variants) { + return variants.stream() + .map(ShopProductVariant::getPriceChf) + .filter(Objects::nonNull) + .max(BigDecimal::compareTo) + .orElse(BigDecimal.ZERO); + } + + private ShopCategory resolveCategory(UUID categoryId) { + if (categoryId == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "categoryId is required"); + } + return shopCategoryRepository.findById(categoryId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Category not found")); + } + + private void ensurePayload(AdminUpsertShopProductRequest payload) { + if (payload == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Payload is required"); + } + } + + private LocalizedProductContent normalizeLocalizedProductContent(AdminUpsertShopProductRequest payload) { + String legacyName = normalizeOptional(payload.getName()); + String fallbackName = firstNonBlank( + legacyName, + normalizeOptional(payload.getNameIt()), + normalizeOptional(payload.getNameEn()), + normalizeOptional(payload.getNameDe()), + normalizeOptional(payload.getNameFr()) + ); + if (fallbackName == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Product name is required"); + } + + Map names = new LinkedHashMap<>(); + names.put("it", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameIt()), fallbackName), "Italian product name is required")); + names.put("en", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameEn()), fallbackName), "English product name is required")); + names.put("de", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameDe()), fallbackName), "German product name is required")); + names.put("fr", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameFr()), fallbackName), "French product name is required")); + + String fallbackExcerpt = firstNonBlank( + normalizeOptional(payload.getExcerpt()), + normalizeOptional(payload.getExcerptIt()), + normalizeOptional(payload.getExcerptEn()), + normalizeOptional(payload.getExcerptDe()), + normalizeOptional(payload.getExcerptFr()) + ); + Map excerpts = new LinkedHashMap<>(); + excerpts.put("it", firstNonBlank(normalizeOptional(payload.getExcerptIt()), fallbackExcerpt)); + excerpts.put("en", firstNonBlank(normalizeOptional(payload.getExcerptEn()), fallbackExcerpt)); + excerpts.put("de", firstNonBlank(normalizeOptional(payload.getExcerptDe()), fallbackExcerpt)); + excerpts.put("fr", firstNonBlank(normalizeOptional(payload.getExcerptFr()), fallbackExcerpt)); + + String fallbackDescription = firstNonBlank( + normalizeOptional(payload.getDescription()), + normalizeOptional(payload.getDescriptionIt()), + normalizeOptional(payload.getDescriptionEn()), + normalizeOptional(payload.getDescriptionDe()), + normalizeOptional(payload.getDescriptionFr()) + ); + Map descriptions = new LinkedHashMap<>(); + descriptions.put("it", firstNonBlank(normalizeOptional(payload.getDescriptionIt()), fallbackDescription)); + descriptions.put("en", firstNonBlank(normalizeOptional(payload.getDescriptionEn()), fallbackDescription)); + descriptions.put("de", firstNonBlank(normalizeOptional(payload.getDescriptionDe()), fallbackDescription)); + descriptions.put("fr", firstNonBlank(normalizeOptional(payload.getDescriptionFr()), fallbackDescription)); + + return new LocalizedProductContent( + names.get("it"), + firstNonBlank(excerpts.get("it"), fallbackExcerpt), + firstNonBlank(descriptions.get("it"), fallbackDescription), + names, + excerpts, + descriptions + ); + } + + private void ensureSlugAvailable(String slug, UUID currentProductId) { + shopProductRepository.findBySlugIgnoreCase(slug).ifPresent(existing -> { + if (currentProductId == null || !existing.getId().equals(currentProductId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Product slug already exists"); + } + }); + } + + private String normalizeRequired(String value, String message) { + String normalized = normalizeOptional(value); + if (normalized == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + return normalized; + } + + private String normalizeOptional(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isBlank() ? null : normalized; + } + + private String firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + + private String normalizeAndValidateSlug(String slug, String fallbackName) { + String source = normalizeOptional(slug); + if (source == null) { + source = fallbackName; + } + + String normalized = Normalizer.normalize(source, Normalizer.Form.NFD); + normalized = DIACRITICS_PATTERN.matcher(normalized).replaceAll(""); + normalized = normalized.toLowerCase(Locale.ROOT); + normalized = NON_ALPHANUMERIC_PATTERN.matcher(normalized).replaceAll("-"); + normalized = EDGE_DASH_PATTERN.matcher(normalized).replaceAll(""); + if (normalized.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Slug is invalid"); + } + return normalized; + } + + private String normalizeColorHex(String value) { + String normalized = normalizeOptional(value); + if (normalized == null) { + return null; + } + if (!HEX_COLOR_PATTERN.matcher(normalized).matches()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant colorHex must be in format #RRGGBB"); + } + return normalized.toUpperCase(Locale.ROOT); + } + + private void validateModelUpload(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "3D model file is required"); + } + if (maxModelFileSizeBytes > 0 && file.getSize() > maxModelFileSizeBytes) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "3D model file exceeds size limit"); + } + String extension = resolveExtension(sanitizeOriginalFilename(file.getOriginalFilename())); + if (!SUPPORTED_MODEL_EXTENSIONS.contains(extension)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported 3D model type. Allowed: stl, 3mf"); + } + } + + private String sanitizeOriginalFilename(String originalFilename) { + String cleaned = StringUtils.cleanPath(originalFilename == null ? "" : originalFilename); + int separatorIndex = Math.max(cleaned.lastIndexOf('/'), cleaned.lastIndexOf('\\')); + String basename = separatorIndex >= 0 ? cleaned.substring(separatorIndex + 1) : cleaned; + basename = basename.replace("\r", "_").replace("\n", "_"); + return basename.isBlank() ? "model.stl" : basename; + } + + private String resolveExtension(String filename) { + int dotIndex = filename.lastIndexOf('.'); + if (dotIndex < 0 || dotIndex == filename.length() - 1) { + return ""; + } + return filename.substring(dotIndex + 1).toLowerCase(Locale.ROOT); + } + + private String buildDownloadFilename(String originalFilename) { + int dotIndex = originalFilename.lastIndexOf('.'); + String base = dotIndex > 0 ? originalFilename.substring(0, dotIndex) : originalFilename; + return base + ".stl"; + } + + private String mediaUsageKey(ShopProduct product) { + return product.getId().toString(); + } + + private void deleteExistingModelFile(ShopProductModelAsset asset, UUID productId) { + if (asset == null || asset.getStoredRelativePath() == null || asset.getStoredRelativePath().isBlank()) { + return; + } + Path existingPath = shopStorageService.resolveStoredProductPath(asset.getStoredRelativePath(), productId); + if (existingPath == null) { + return; + } + try { + Files.deleteIfExists(existingPath); + } catch (IOException ignored) { + } + } + + private void deleteStoredRelativePath(String storedRelativePath, UUID productId, String excludeStoredRelativePath) { + if (storedRelativePath == null || storedRelativePath.isBlank()) { + return; + } + if (Objects.equals(storedRelativePath, excludeStoredRelativePath)) { + return; + } + Path existingPath = shopStorageService.resolveStoredProductPath(storedRelativePath, productId); + deletePathQuietly(existingPath); + } + + private String computeSha256(Path file) throws IOException { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IOException("SHA-256 digest unavailable", e); + } + + try (InputStream inputStream = Files.newInputStream(file)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = inputStream.read(buffer)) >= 0) { + if (read > 0) { + digest.update(buffer, 0, read); + } + } + } + return HexFormat.of().formatHex(digest.digest()); + } + + private void deletePathQuietly(Path path) { + if (path == null) { + return; + } + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + } + } + + private void deleteRecursively(Path path) { + if (path == null || !Files.exists(path)) { + return; + } + try (var walk = Files.walk(path)) { + walk.sorted(Comparator.reverseOrder()).forEach(current -> { + try { + Files.deleteIfExists(current); + } catch (IOException ignored) { + } + }); + } catch (IOException ignored) { + } + } + + public record ProductModelDownload(Path path, String filename, String mimeType) { + } + + private record LocalizedProductContent( + String defaultName, + String defaultExcerpt, + String defaultDescription, + Map names, + Map excerpts, + Map descriptions + ) { + } +} diff --git a/backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java b/backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java index bbf0fe7..f7e65b3 100644 --- a/backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java +++ b/backend/src/main/java/com/printcalculator/service/media/PublicMediaQueryService.java @@ -14,6 +14,7 @@ import org.springframework.web.server.ResponseStatusException; import java.time.OffsetDateTime; import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -48,22 +49,40 @@ public class PublicMediaQueryService { public List getUsageMedia(String usageType, String usageKey, String language) { String normalizedUsageType = normalizeUsageType(usageType); String normalizedUsageKey = normalizeUsageKey(usageKey); + return getUsageMediaMap(normalizedUsageType, List.of(normalizedUsageKey), language) + .getOrDefault(normalizedUsageKey, List.of()); + } + + public Map> getUsageMediaMap(String usageType, + List usageKeys, + String language) { + String normalizedUsageType = normalizeUsageType(usageType); String normalizedLanguage = normalizeLanguage(language); + List normalizedUsageKeys = (usageKeys == null + ? List.of() + : usageKeys) + .stream() + .filter(Objects::nonNull) + .map(this::normalizeUsageKey) + .distinct() + .toList(); + + if (normalizedUsageKeys.isEmpty()) { + return Map.of(); + } List usages = mediaUsageRepository - .findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc( - normalizedUsageType, - normalizedUsageKey - ) + .findActiveByUsageTypeAndUsageKeys(normalizedUsageType, normalizedUsageKeys) .stream() .filter(this::isPublicReadyUsage) .sorted(Comparator - .comparing(MediaUsage::getSortOrder, Comparator.nullsLast(Integer::compareTo)) + .comparing(MediaUsage::getUsageKey, Comparator.nullsLast(String::compareTo)) + .thenComparing(MediaUsage::getSortOrder, Comparator.nullsLast(Integer::compareTo)) .thenComparing(MediaUsage::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo))) .toList(); if (usages.isEmpty()) { - return List.of(); + return Map.of(); } List assetIds = usages.stream() @@ -79,13 +98,16 @@ public class PublicMediaQueryService { .filter(variant -> !Objects.equals("ORIGINAL", variant.getFormat())) .collect(Collectors.groupingBy(variant -> variant.getMediaAsset().getId())); - return usages.stream() - .map(usage -> toDto( - usage, - variantsByAssetId.getOrDefault(usage.getMediaAsset().getId(), List.of()), - normalizedLanguage - )) - .toList(); + Map> result = new LinkedHashMap<>(); + for (MediaUsage usage : usages) { + result.computeIfAbsent(usage.getUsageKey(), ignored -> new java.util.ArrayList<>()) + .add(toDto( + usage, + variantsByAssetId.getOrDefault(usage.getMediaAsset().getId(), List.of()), + normalizedLanguage + )); + } + return result; } private boolean isPublicReadyUsage(MediaUsage usage) { diff --git a/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java b/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java index dfda322..164ac74 100644 --- a/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java @@ -197,6 +197,7 @@ public class AdminOrderControllerService { OrderDto dto = new OrderDto(); dto.setId(order.getId()); dto.setOrderNumber(getDisplayOrderNumber(order)); + dto.setSourceType(order.getSourceType() != null ? order.getSourceType() : "CALCULATOR"); dto.setStatus(order.getStatus()); paymentRepo.findByOrder_Id(order.getId()).ifPresent(payment -> { @@ -260,9 +261,26 @@ public class AdminOrderControllerService { List itemDtos = items.stream().map(item -> { OrderItemDto itemDto = new OrderItemDto(); itemDto.setId(item.getId()); + itemDto.setItemType(item.getItemType() != null ? item.getItemType() : "PRINT_FILE"); itemDto.setOriginalFilename(item.getOriginalFilename()); + itemDto.setDisplayName( + item.getDisplayName() != null && !item.getDisplayName().isBlank() + ? item.getDisplayName() + : item.getOriginalFilename() + ); itemDto.setMaterialCode(item.getMaterialCode()); itemDto.setColorCode(item.getColorCode()); + if (item.getShopProduct() != null) { + itemDto.setShopProductId(item.getShopProduct().getId()); + } + if (item.getShopProductVariant() != null) { + itemDto.setShopProductVariantId(item.getShopProductVariant().getId()); + } + itemDto.setShopProductSlug(item.getShopProductSlug()); + itemDto.setShopProductName(item.getShopProductName()); + itemDto.setShopVariantLabel(item.getShopVariantLabel()); + itemDto.setShopVariantColorName(item.getShopVariantColorName()); + itemDto.setShopVariantColorHex(item.getShopVariantColorHex()); if (item.getFilamentVariant() != null) { itemDto.setFilamentVariantId(item.getFilamentVariant().getId()); itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName()); diff --git a/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java b/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java index 4baca4b..9b1ae40 100644 --- a/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java @@ -255,6 +255,7 @@ public class OrderControllerService { OrderDto dto = new OrderDto(); dto.setId(order.getId()); dto.setOrderNumber(getDisplayOrderNumber(order)); + dto.setSourceType(order.getSourceType() != null ? order.getSourceType() : "CALCULATOR"); dto.setStatus(order.getStatus()); paymentRepo.findByOrder_Id(order.getId()).ifPresent(payment -> { @@ -314,9 +315,26 @@ public class OrderControllerService { List itemDtos = items.stream().map(item -> { OrderItemDto itemDto = new OrderItemDto(); itemDto.setId(item.getId()); + itemDto.setItemType(item.getItemType() != null ? item.getItemType() : "PRINT_FILE"); itemDto.setOriginalFilename(item.getOriginalFilename()); + itemDto.setDisplayName( + item.getDisplayName() != null && !item.getDisplayName().isBlank() + ? item.getDisplayName() + : item.getOriginalFilename() + ); itemDto.setMaterialCode(item.getMaterialCode()); itemDto.setColorCode(item.getColorCode()); + if (item.getShopProduct() != null) { + itemDto.setShopProductId(item.getShopProduct().getId()); + } + if (item.getShopProductVariant() != null) { + itemDto.setShopProductVariantId(item.getShopProductVariant().getId()); + } + itemDto.setShopProductSlug(item.getShopProductSlug()); + itemDto.setShopProductName(item.getShopProductName()); + itemDto.setShopVariantLabel(item.getShopVariantLabel()); + itemDto.setShopVariantColorName(item.getShopVariantColorName()); + itemDto.setShopVariantColorHex(item.getShopVariantColorHex()); if (item.getFilamentVariant() != null) { itemDto.setFilamentVariantId(item.getFilamentVariant().getId()); itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName()); diff --git a/backend/src/main/java/com/printcalculator/service/payment/InvoicePdfRenderingService.java b/backend/src/main/java/com/printcalculator/service/payment/InvoicePdfRenderingService.java index 96ec578..0709c9c 100644 --- a/backend/src/main/java/com/printcalculator/service/payment/InvoicePdfRenderingService.java +++ b/backend/src/main/java/com/printcalculator/service/payment/InvoicePdfRenderingService.java @@ -88,14 +88,9 @@ public class InvoicePdfRenderingService { vars.put("shippingAddressLine2", order.getShippingZip() + " " + order.getShippingCity() + ", " + order.getShippingCountryCode()); } - List> invoiceLineItems = items.stream().map(i -> { - Map line = new HashMap<>(); - line.put("description", "Stampa 3D: " + i.getOriginalFilename()); - line.put("quantity", i.getQuantity()); - line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf())); - line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf())); - return line; - }).collect(Collectors.toList()); + List> invoiceLineItems = items.stream() + .map(this::toInvoiceLineItem) + .collect(Collectors.toList()); if (order.getCadTotalChf() != null && order.getCadTotalChf().compareTo(BigDecimal.ZERO) > 0) { BigDecimal cadHours = order.getCadHours() != null ? order.getCadHours() : BigDecimal.ZERO; @@ -157,4 +152,45 @@ public class InvoicePdfRenderingService { private String formatCadHours(BigDecimal hours) { return hours.setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString(); } + + private Map toInvoiceLineItem(OrderItem item) { + Map line = new HashMap<>(); + line.put("description", buildLineDescription(item)); + line.put("quantity", item.getQuantity()); + line.put("unitPriceFormatted", String.format("CHF %.2f", item.getUnitPriceChf())); + line.put("lineTotalFormatted", String.format("CHF %.2f", item.getLineTotalChf())); + return line; + } + + private String buildLineDescription(OrderItem item) { + if (item == null) { + return "Articolo"; + } + + if ("SHOP_PRODUCT".equalsIgnoreCase(item.getItemType())) { + String productName = firstNonBlank( + item.getDisplayName(), + item.getShopProductName(), + item.getOriginalFilename(), + "Prodotto shop" + ); + String variantLabel = firstNonBlank(item.getShopVariantLabel(), item.getShopVariantColorName(), null); + return variantLabel != null ? productName + " - " + variantLabel : productName; + } + + String fileName = firstNonBlank(item.getDisplayName(), item.getOriginalFilename(), "File 3D"); + return "Stampa 3D: " + fileName; + } + + private String firstNonBlank(String... values) { + if (values == null || values.length == 0) { + return null; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } } diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java index cae1ec5..50496b3 100644 --- a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java @@ -237,7 +237,9 @@ public class QuoteSessionItemService { Path convertedPersistentPath) { QuoteLineItem item = new QuoteLineItem(); item.setQuoteSession(session); + item.setLineItemType("PRINT_FILE"); item.setOriginalFilename(originalFilename); + item.setDisplayName(originalFilename); item.setStoredPath(quoteStorageService.toStoredPath(persistentPath)); item.setQuantity(normalizeQuantity(settings.getQuantity())); item.setColorCode(selectedVariant.getColorName()); diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java index 3652586..555ecc5 100644 --- a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java @@ -43,15 +43,45 @@ public class QuoteSessionResponseAssembler { return response; } + public Map emptyCart() { + Map response = new HashMap<>(); + response.put("session", null); + response.put("items", List.of()); + response.put("printItemsTotalChf", BigDecimal.ZERO); + response.put("cadTotalChf", BigDecimal.ZERO); + response.put("itemsTotalChf", BigDecimal.ZERO); + response.put("baseSetupCostChf", BigDecimal.ZERO); + response.put("nozzleChangeCostChf", BigDecimal.ZERO); + response.put("setupCostChf", BigDecimal.ZERO); + response.put("shippingCostChf", BigDecimal.ZERO); + response.put("globalMachineCostChf", BigDecimal.ZERO); + response.put("grandTotalChf", BigDecimal.ZERO); + return response; + } + private Map toItemDto(QuoteLineItem item, QuoteSessionTotalsService.QuoteSessionTotals totals) { Map dto = new HashMap<>(); dto.put("id", item.getId()); + dto.put("lineItemType", item.getLineItemType() != null ? item.getLineItemType() : "PRINT_FILE"); dto.put("originalFilename", item.getOriginalFilename()); + dto.put( + "displayName", + item.getDisplayName() != null && !item.getDisplayName().isBlank() + ? item.getDisplayName() + : item.getOriginalFilename() + ); dto.put("quantity", item.getQuantity()); dto.put("printTimeSeconds", item.getPrintTimeSeconds()); dto.put("materialGrams", item.getMaterialGrams()); dto.put("colorCode", item.getColorCode()); dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null); + dto.put("shopProductId", item.getShopProduct() != null ? item.getShopProduct().getId() : null); + dto.put("shopProductVariantId", item.getShopProductVariant() != null ? item.getShopProductVariant().getId() : null); + dto.put("shopProductSlug", item.getShopProductSlug()); + dto.put("shopProductName", item.getShopProductName()); + dto.put("shopVariantLabel", item.getShopVariantLabel()); + dto.put("shopVariantColorName", item.getShopVariantColorName()); + dto.put("shopVariantColorHex", item.getShopVariantColorHex()); dto.put("materialCode", item.getMaterialCode()); dto.put("quality", item.getQuality()); dto.put("nozzleDiameterMm", item.getNozzleDiameterMm()); diff --git a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java new file mode 100644 index 0000000..10f8fc0 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java @@ -0,0 +1,506 @@ +package com.printcalculator.service.shop; + +import com.printcalculator.dto.PublicMediaUsageDto; +import com.printcalculator.dto.ShopCategoryDetailDto; +import com.printcalculator.dto.ShopCategoryRefDto; +import com.printcalculator.dto.ShopCategoryTreeDto; +import com.printcalculator.dto.ShopProductCatalogResponseDto; +import com.printcalculator.dto.ShopProductDetailDto; +import com.printcalculator.dto.ShopProductModelDto; +import com.printcalculator.dto.ShopProductSummaryDto; +import com.printcalculator.dto.ShopProductVariantOptionDto; +import com.printcalculator.entity.ShopCategory; +import com.printcalculator.entity.ShopProduct; +import com.printcalculator.entity.ShopProductModelAsset; +import com.printcalculator.entity.ShopProductVariant; +import com.printcalculator.repository.ShopCategoryRepository; +import com.printcalculator.repository.ShopProductModelAssetRepository; +import com.printcalculator.repository.ShopProductRepository; +import com.printcalculator.repository.ShopProductVariantRepository; +import com.printcalculator.service.media.PublicMediaQueryService; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +public class PublicShopCatalogService { + private static final String SHOP_CATEGORY_MEDIA_USAGE_TYPE = "SHOP_CATEGORY"; + private static final String SHOP_PRODUCT_MEDIA_USAGE_TYPE = "SHOP_PRODUCT"; + + private final ShopCategoryRepository shopCategoryRepository; + private final ShopProductRepository shopProductRepository; + private final ShopProductVariantRepository shopProductVariantRepository; + private final ShopProductModelAssetRepository shopProductModelAssetRepository; + private final PublicMediaQueryService publicMediaQueryService; + private final ShopStorageService shopStorageService; + + public PublicShopCatalogService(ShopCategoryRepository shopCategoryRepository, + ShopProductRepository shopProductRepository, + ShopProductVariantRepository shopProductVariantRepository, + ShopProductModelAssetRepository shopProductModelAssetRepository, + PublicMediaQueryService publicMediaQueryService, + ShopStorageService shopStorageService) { + this.shopCategoryRepository = shopCategoryRepository; + this.shopProductRepository = shopProductRepository; + this.shopProductVariantRepository = shopProductVariantRepository; + this.shopProductModelAssetRepository = shopProductModelAssetRepository; + this.publicMediaQueryService = publicMediaQueryService; + this.shopStorageService = shopStorageService; + } + + public List getCategories(String language) { + CategoryContext categoryContext = loadCategoryContext(language); + return buildCategoryTree(null, categoryContext); + } + + public ShopCategoryDetailDto getCategory(String slug, String language) { + ShopCategory category = shopCategoryRepository.findBySlugAndIsActiveTrue(slug) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Category not found")); + + CategoryContext categoryContext = loadCategoryContext(language); + if (!categoryContext.categoriesById().containsKey(category.getId())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Category not found"); + } + + return buildCategoryDetail(category, categoryContext); + } + + public ShopProductCatalogResponseDto getProductCatalog(String categorySlug, Boolean featuredOnly, String language) { + CategoryContext categoryContext = loadCategoryContext(language); + PublicProductContext productContext = loadPublicProductContext(categoryContext, language); + + ShopCategory selectedCategory = null; + if (categorySlug != null && !categorySlug.isBlank()) { + selectedCategory = categoryContext.categoriesBySlug().get(categorySlug.trim()); + if (selectedCategory == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Category not found"); + } + } + + Collection allowedCategoryIds = selectedCategory == null + ? categoryContext.categoriesById().keySet() + : resolveDescendantCategoryIds(selectedCategory.getId(), categoryContext.childrenByParentId()); + + List products = productContext.entries().stream() + .filter(entry -> allowedCategoryIds.contains(entry.product().getCategory().getId())) + .filter(entry -> !Boolean.TRUE.equals(featuredOnly) || Boolean.TRUE.equals(entry.product().getIsFeatured())) + .map(entry -> toProductSummaryDto(entry, productContext.productMediaBySlug(), language)) + .toList(); + + ShopCategoryDetailDto selectedCategoryDetail = selectedCategory != null + ? buildCategoryDetail(selectedCategory, categoryContext) + : null; + + return new ShopProductCatalogResponseDto( + selectedCategory != null ? selectedCategory.getSlug() : null, + Boolean.TRUE.equals(featuredOnly), + selectedCategoryDetail, + products + ); + } + + public ShopProductDetailDto getProduct(String slug, String language) { + CategoryContext categoryContext = loadCategoryContext(language); + PublicProductContext productContext = loadPublicProductContext(categoryContext, language); + + ProductEntry entry = productContext.entriesBySlug().get(slug); + if (entry == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); + } + + ShopCategory category = entry.product().getCategory(); + if (category == null || !categoryContext.categoriesById().containsKey(category.getId())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); + } + + return toProductDetailDto(entry, productContext.productMediaBySlug(), language); + } + + public ProductModelDownload getProductModelDownload(String slug) { + CategoryContext categoryContext = loadCategoryContext(null); + PublicProductContext productContext = loadPublicProductContext(categoryContext, null); + ProductEntry entry = productContext.entriesBySlug().get(slug); + if (entry == null || entry.modelAsset() == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product model not found"); + } + + Path path = shopStorageService.resolveStoredProductPath( + entry.modelAsset().getStoredRelativePath(), + entry.product().getId() + ); + if (path == null || !Files.exists(path)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product model not found"); + } + + return new ProductModelDownload( + path, + entry.modelAsset().getOriginalFilename(), + entry.modelAsset().getMimeType() + ); + } + + private CategoryContext loadCategoryContext(String language) { + List categories = shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc(); + + Map categoriesById = categories.stream() + .collect(Collectors.toMap(ShopCategory::getId, category -> category, (left, right) -> left, LinkedHashMap::new)); + Map categoriesBySlug = categories.stream() + .collect(Collectors.toMap(ShopCategory::getSlug, category -> category, (left, right) -> left, LinkedHashMap::new)); + Map> childrenByParentId = buildChildrenByParentId(categories); + + List publicProducts = loadPublicProducts(categoriesById.keySet()); + Map descendantProductCounts = resolveDescendantProductCounts(categories, childrenByParentId, publicProducts); + Map> categoryMediaBySlug = publicMediaQueryService.getUsageMediaMap( + SHOP_CATEGORY_MEDIA_USAGE_TYPE, + categories.stream().map(this::categoryMediaUsageKey).toList(), + language + ); + + return new CategoryContext( + categoriesById, + categoriesBySlug, + childrenByParentId, + descendantProductCounts, + categoryMediaBySlug + ); + } + + private PublicProductContext loadPublicProductContext(CategoryContext categoryContext, String language) { + List entries = loadPublicProducts(categoryContext.categoriesById().keySet()); + Map> productMediaBySlug = publicMediaQueryService.getUsageMediaMap( + SHOP_PRODUCT_MEDIA_USAGE_TYPE, + entries.stream().map(entry -> productMediaUsageKey(entry.product())).toList(), + language + ); + + Map entriesBySlug = entries.stream() + .collect(Collectors.toMap(entry -> entry.product().getSlug(), entry -> entry, (left, right) -> left, LinkedHashMap::new)); + + return new PublicProductContext(entries, entriesBySlug, productMediaBySlug); + } + + private List loadPublicProducts(Collection activeCategoryIds) { + List products = shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc(); + if (products.isEmpty()) { + return List.of(); + } + + List productIds = products.stream().map(ShopProduct::getId).toList(); + Map> variantsByProductId = shopProductVariantRepository + .findByProduct_IdInAndIsActiveTrueOrderBySortOrderAscColorNameAsc(productIds) + .stream() + .collect(Collectors.groupingBy( + variant -> variant.getProduct().getId(), + LinkedHashMap::new, + Collectors.toList() + )); + Map modelAssetByProductId = shopProductModelAssetRepository.findByProduct_IdIn(productIds) + .stream() + .collect(Collectors.toMap(asset -> asset.getProduct().getId(), asset -> asset, (left, right) -> left, LinkedHashMap::new)); + + return products.stream() + .filter(product -> product.getCategory() != null) + .filter(product -> activeCategoryIds.contains(product.getCategory().getId())) + .map(product -> { + List activeVariants = variantsByProductId.getOrDefault(product.getId(), List.of()); + if (activeVariants.isEmpty()) { + return null; + } + ShopProductVariant defaultVariant = pickDefaultVariant(activeVariants); + return new ProductEntry( + product, + activeVariants, + defaultVariant, + modelAssetByProductId.get(product.getId()) + ); + }) + .filter(Objects::nonNull) + .toList(); + } + + private Map> buildChildrenByParentId(List categories) { + Map> childrenByParentId = new LinkedHashMap<>(); + for (ShopCategory category : categories) { + UUID parentId = category.getParentCategory() != null ? category.getParentCategory().getId() : null; + childrenByParentId.computeIfAbsent(parentId, ignored -> new ArrayList<>()).add(category); + } + Comparator comparator = Comparator + .comparing(ShopCategory::getSortOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(ShopCategory::getName, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)); + childrenByParentId.values().forEach(children -> children.sort(comparator)); + return childrenByParentId; + } + + private Map resolveDescendantProductCounts(List categories, + Map> childrenByParentId, + List publicProducts) { + Map directProductCounts = new LinkedHashMap<>(); + for (ProductEntry entry : publicProducts) { + UUID categoryId = entry.product().getCategory().getId(); + directProductCounts.merge(categoryId, 1, Integer::sum); + } + + Map descendantCounts = new LinkedHashMap<>(); + for (ShopCategory category : categories) { + resolveCategoryProductCount(category.getId(), childrenByParentId, directProductCounts, descendantCounts); + } + return descendantCounts; + } + + private int resolveCategoryProductCount(UUID categoryId, + Map> childrenByParentId, + Map directProductCounts, + Map descendantCounts) { + Integer cached = descendantCounts.get(categoryId); + if (cached != null) { + return cached; + } + + int total = directProductCounts.getOrDefault(categoryId, 0); + for (ShopCategory child : childrenByParentId.getOrDefault(categoryId, List.of())) { + total += resolveCategoryProductCount(child.getId(), childrenByParentId, directProductCounts, descendantCounts); + } + descendantCounts.put(categoryId, total); + return total; + } + + private List buildCategoryTree(UUID parentId, CategoryContext categoryContext) { + return categoryContext.childrenByParentId().getOrDefault(parentId, List.of()).stream() + .map(category -> new ShopCategoryTreeDto( + category.getId(), + category.getParentCategory() != null ? category.getParentCategory().getId() : null, + category.getSlug(), + category.getName(), + category.getDescription(), + category.getSeoTitle(), + category.getSeoDescription(), + category.getOgTitle(), + category.getOgDescription(), + category.getIndexable(), + category.getSortOrder(), + categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0), + selectPrimaryMedia(categoryContext.categoryMediaBySlug().get(categoryMediaUsageKey(category))), + buildCategoryTree(category.getId(), categoryContext) + )) + .toList(); + } + + private ShopCategoryDetailDto buildCategoryDetail(ShopCategory category, CategoryContext categoryContext) { + List images = categoryContext.categoryMediaBySlug().getOrDefault(categoryMediaUsageKey(category), List.of()); + return new ShopCategoryDetailDto( + category.getId(), + category.getSlug(), + category.getName(), + category.getDescription(), + category.getSeoTitle(), + category.getSeoDescription(), + category.getOgTitle(), + category.getOgDescription(), + category.getIndexable(), + category.getSortOrder(), + categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0), + buildCategoryBreadcrumbs(category), + selectPrimaryMedia(images), + images, + buildCategoryTree(category.getId(), categoryContext) + ); + } + + private List buildCategoryBreadcrumbs(ShopCategory category) { + List breadcrumbs = new ArrayList<>(); + ShopCategory current = category; + while (current != null) { + breadcrumbs.add(new ShopCategoryRefDto(current.getId(), current.getSlug(), current.getName())); + current = current.getParentCategory(); + } + java.util.Collections.reverse(breadcrumbs); + return breadcrumbs; + } + + private List resolveDescendantCategoryIds(UUID rootId, Map> childrenByParentId) { + List ids = new ArrayList<>(); + collectDescendantCategoryIds(rootId, childrenByParentId, ids); + return ids; + } + + private void collectDescendantCategoryIds(UUID categoryId, + Map> childrenByParentId, + List accumulator) { + accumulator.add(categoryId); + for (ShopCategory child : childrenByParentId.getOrDefault(categoryId, List.of())) { + collectDescendantCategoryIds(child.getId(), childrenByParentId, accumulator); + } + } + + private ShopProductSummaryDto toProductSummaryDto(ProductEntry entry, + Map> productMediaBySlug, + String language) { + List images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); + return new ShopProductSummaryDto( + entry.product().getId(), + entry.product().getSlug(), + entry.product().getNameForLanguage(language), + entry.product().getExcerptForLanguage(language), + entry.product().getIsFeatured(), + entry.product().getSortOrder(), + new ShopCategoryRefDto( + entry.product().getCategory().getId(), + entry.product().getCategory().getSlug(), + entry.product().getCategory().getName() + ), + resolvePriceFrom(entry.variants()), + resolvePriceTo(entry.variants()), + toVariantDto(entry.defaultVariant(), entry.defaultVariant()), + selectPrimaryMedia(images), + toProductModelDto(entry) + ); + } + + private ShopProductDetailDto toProductDetailDto(ProductEntry entry, + Map> productMediaBySlug, + String language) { + List images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); + 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(), + entry.product().getIndexable(), + entry.product().getIsFeatured(), + entry.product().getSortOrder(), + new ShopCategoryRefDto( + entry.product().getCategory().getId(), + entry.product().getCategory().getSlug(), + entry.product().getCategory().getName() + ), + buildCategoryBreadcrumbs(entry.product().getCategory()), + resolvePriceFrom(entry.variants()), + resolvePriceTo(entry.variants()), + toVariantDto(entry.defaultVariant(), entry.defaultVariant()), + entry.variants().stream() + .map(variant -> toVariantDto(variant, entry.defaultVariant())) + .toList(), + selectPrimaryMedia(images), + images, + toProductModelDto(entry) + ); + } + + private ShopProductVariantOptionDto toVariantDto(ShopProductVariant variant, ShopProductVariant defaultVariant) { + if (variant == null) { + return null; + } + return new ShopProductVariantOptionDto( + variant.getId(), + variant.getSku(), + variant.getVariantLabel(), + variant.getColorName(), + variant.getColorHex(), + variant.getPriceChf(), + defaultVariant != null && Objects.equals(defaultVariant.getId(), variant.getId()) + ); + } + + private ShopProductModelDto toProductModelDto(ProductEntry entry) { + if (entry.modelAsset() == null) { + return null; + } + return new ShopProductModelDto( + "/api/shop/products/" + entry.product().getSlug() + "/model", + entry.modelAsset().getOriginalFilename(), + entry.modelAsset().getMimeType(), + entry.modelAsset().getFileSizeBytes(), + entry.modelAsset().getBoundingBoxXMm(), + entry.modelAsset().getBoundingBoxYMm(), + entry.modelAsset().getBoundingBoxZMm() + ); + } + + private ShopProductVariant pickDefaultVariant(List variants) { + return variants.stream() + .filter(variant -> Boolean.TRUE.equals(variant.getIsDefault())) + .findFirst() + .orElseGet(() -> variants.isEmpty() ? null : variants.get(0)); + } + + private BigDecimal resolvePriceFrom(List variants) { + return variants.stream() + .map(ShopProductVariant::getPriceChf) + .filter(Objects::nonNull) + .min(BigDecimal::compareTo) + .orElse(BigDecimal.ZERO); + } + + private BigDecimal resolvePriceTo(List variants) { + return variants.stream() + .map(ShopProductVariant::getPriceChf) + .filter(Objects::nonNull) + .max(BigDecimal::compareTo) + .orElse(BigDecimal.ZERO); + } + + private PublicMediaUsageDto selectPrimaryMedia(List images) { + if (images == null || images.isEmpty()) { + return null; + } + return images.stream() + .filter(image -> Boolean.TRUE.equals(image.getIsPrimary())) + .findFirst() + .orElse(images.get(0)); + } + + private String categoryMediaUsageKey(ShopCategory category) { + return category.getId().toString(); + } + + private String productMediaUsageKey(ShopProduct product) { + return product.getId().toString(); + } + + public record ProductModelDownload(Path path, String filename, String mimeType) { + } + + private record CategoryContext( + Map categoriesById, + Map categoriesBySlug, + Map> childrenByParentId, + Map descendantProductCounts, + Map> categoryMediaBySlug + ) { + } + + private record PublicProductContext( + List entries, + Map entriesBySlug, + Map> productMediaBySlug + ) { + } + + private record ProductEntry( + ShopProduct product, + List variants, + ShopProductVariant defaultVariant, + ShopProductModelAsset modelAsset + ) { + } +} diff --git a/backend/src/main/java/com/printcalculator/service/shop/ShopCartCookieService.java b/backend/src/main/java/com/printcalculator/service/shop/ShopCartCookieService.java new file mode 100644 index 0000000..e3e317a --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/shop/ShopCartCookieService.java @@ -0,0 +1,91 @@ +package com.printcalculator.service.shop; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; + +@Service +public class ShopCartCookieService { + public static final String COOKIE_NAME = "shop_cart_session"; + private static final String COOKIE_PATH = "/api/shop"; + + private final long cookieTtlDays; + private final boolean secureCookie; + private final String sameSite; + + public ShopCartCookieService( + @Value("${shop.cart.cookie.ttl-days:30}") long cookieTtlDays, + @Value("${shop.cart.cookie.secure:false}") boolean secureCookie, + @Value("${shop.cart.cookie.same-site:Lax}") String sameSite + ) { + this.cookieTtlDays = cookieTtlDays; + this.secureCookie = secureCookie; + this.sameSite = sameSite; + } + + public Optional extractSessionId(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null || cookies.length == 0) { + return Optional.empty(); + } + + for (Cookie cookie : cookies) { + if (!COOKIE_NAME.equals(cookie.getName())) { + continue; + } + try { + String value = cookie.getValue(); + if (value == null || value.isBlank()) { + return Optional.empty(); + } + return Optional.of(UUID.fromString(value.trim())); + } catch (IllegalArgumentException ignored) { + return Optional.empty(); + } + } + return Optional.empty(); + } + + public boolean hasCartCookie(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null || cookies.length == 0) { + return false; + } + for (Cookie cookie : cookies) { + if (COOKIE_NAME.equals(cookie.getName())) { + return true; + } + } + return false; + } + + public ResponseCookie buildSessionCookie(UUID sessionId) { + return ResponseCookie.from(COOKIE_NAME, sessionId.toString()) + .path(COOKIE_PATH) + .httpOnly(true) + .secure(secureCookie) + .sameSite(sameSite) + .maxAge(Duration.ofDays(Math.max(cookieTtlDays, 1))) + .build(); + } + + public ResponseCookie buildClearCookie() { + return ResponseCookie.from(COOKIE_NAME, "") + .path(COOKIE_PATH) + .httpOnly(true) + .secure(secureCookie) + .sameSite(sameSite) + .maxAge(Duration.ZERO) + .build(); + } + + public long getCookieTtlDays() { + return cookieTtlDays; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/shop/ShopCartService.java b/backend/src/main/java/com/printcalculator/service/shop/ShopCartService.java new file mode 100644 index 0000000..98c27fd --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/shop/ShopCartService.java @@ -0,0 +1,362 @@ +package com.printcalculator.service.shop; + +import com.printcalculator.dto.ShopCartAddItemRequest; +import com.printcalculator.dto.ShopCartUpdateItemRequest; +import com.printcalculator.entity.QuoteLineItem; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.entity.ShopCategory; +import com.printcalculator.entity.ShopProduct; +import com.printcalculator.entity.ShopProductModelAsset; +import com.printcalculator.entity.ShopProductVariant; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.repository.QuoteSessionRepository; +import com.printcalculator.repository.ShopProductModelAssetRepository; +import com.printcalculator.repository.ShopProductVariantRepository; +import com.printcalculator.service.QuoteSessionTotalsService; +import com.printcalculator.service.quote.QuoteSessionResponseAssembler; +import com.printcalculator.service.quote.QuoteStorageService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +@Service +@Transactional(readOnly = true) +public class ShopCartService { + private static final String SHOP_CART_SESSION_TYPE = "SHOP_CART"; + private static final String SHOP_LINE_ITEM_TYPE = "SHOP_PRODUCT"; + private static final String ACTIVE_STATUS = "ACTIVE"; + private static final String EXPIRED_STATUS = "EXPIRED"; + private static final String CONVERTED_STATUS = "CONVERTED"; + + private final QuoteSessionRepository quoteSessionRepository; + private final QuoteLineItemRepository quoteLineItemRepository; + private final ShopProductVariantRepository shopProductVariantRepository; + private final ShopProductModelAssetRepository shopProductModelAssetRepository; + private final QuoteSessionTotalsService quoteSessionTotalsService; + private final QuoteSessionResponseAssembler quoteSessionResponseAssembler; + private final QuoteStorageService quoteStorageService; + private final ShopStorageService shopStorageService; + private final ShopCartCookieService shopCartCookieService; + + public ShopCartService( + QuoteSessionRepository quoteSessionRepository, + QuoteLineItemRepository quoteLineItemRepository, + ShopProductVariantRepository shopProductVariantRepository, + ShopProductModelAssetRepository shopProductModelAssetRepository, + QuoteSessionTotalsService quoteSessionTotalsService, + QuoteSessionResponseAssembler quoteSessionResponseAssembler, + QuoteStorageService quoteStorageService, + ShopStorageService shopStorageService, + ShopCartCookieService shopCartCookieService + ) { + this.quoteSessionRepository = quoteSessionRepository; + this.quoteLineItemRepository = quoteLineItemRepository; + this.shopProductVariantRepository = shopProductVariantRepository; + this.shopProductModelAssetRepository = shopProductModelAssetRepository; + this.quoteSessionTotalsService = quoteSessionTotalsService; + this.quoteSessionResponseAssembler = quoteSessionResponseAssembler; + this.quoteStorageService = quoteStorageService; + this.shopStorageService = shopStorageService; + this.shopCartCookieService = shopCartCookieService; + } + + public CartResult loadCart(HttpServletRequest request) { + boolean hadCookie = shopCartCookieService.hasCartCookie(request); + Optional session = resolveValidCartSession(request); + if (session.isEmpty()) { + return CartResult.empty(quoteSessionResponseAssembler.emptyCart(), hadCookie); + } + + QuoteSession validSession = session.get(); + touchSession(validSession); + return CartResult.withSession(buildCartResponse(validSession), validSession.getId(), false); + } + + @Transactional + public CartResult addItem(HttpServletRequest request, ShopCartAddItemRequest payload) { + int quantityToAdd = normalizeQuantity(payload != null ? payload.getQuantity() : null); + ShopProductVariant variant = getPurchasableVariant(payload != null ? payload.getShopProductVariantId() : null); + QuoteSession session = resolveValidCartSession(request).orElseGet(this::createCartSession); + touchSession(session); + + QuoteLineItem lineItem = quoteLineItemRepository + .findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id( + session.getId(), + SHOP_LINE_ITEM_TYPE, + variant.getId() + ) + .orElseGet(() -> buildShopLineItem(session, variant)); + + int existingQuantity = lineItem.getQuantity() != null && lineItem.getQuantity() > 0 + ? lineItem.getQuantity() + : 0; + int newQuantity = existingQuantity + quantityToAdd; + lineItem.setQuantity(newQuantity); + refreshLineItemSnapshot(lineItem, variant); + lineItem.setUpdatedAt(OffsetDateTime.now()); + quoteLineItemRepository.save(lineItem); + + return CartResult.withSession(buildCartResponse(session), session.getId(), false); + } + + @Transactional + public CartResult updateItem(HttpServletRequest request, UUID lineItemId, ShopCartUpdateItemRequest payload) { + QuoteSession session = resolveValidCartSession(request) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Cart session not found")); + + QuoteLineItem item = quoteLineItemRepository.findByIdAndQuoteSession_Id(lineItemId, session.getId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Cart item not found")); + + if (!SHOP_LINE_ITEM_TYPE.equals(item.getLineItemType())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid cart item type"); + } + + item.setQuantity(normalizeQuantity(payload != null ? payload.getQuantity() : null)); + item.setUpdatedAt(OffsetDateTime.now()); + + if (item.getShopProductVariant() != null) { + refreshLineItemSnapshot(item, item.getShopProductVariant()); + } + + quoteLineItemRepository.save(item); + touchSession(session); + return CartResult.withSession(buildCartResponse(session), session.getId(), false); + } + + @Transactional + public CartResult removeItem(HttpServletRequest request, UUID lineItemId) { + QuoteSession session = resolveValidCartSession(request) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Cart session not found")); + + QuoteLineItem item = quoteLineItemRepository.findByIdAndQuoteSession_Id(lineItemId, session.getId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Cart item not found")); + + quoteLineItemRepository.delete(item); + touchSession(session); + return CartResult.withSession(buildCartResponse(session), session.getId(), false); + } + + @Transactional + public CartResult clearCart(HttpServletRequest request) { + boolean hadCookie = shopCartCookieService.hasCartCookie(request); + Optional session = resolveValidCartSession(request); + if (session.isPresent()) { + QuoteSession current = session.get(); + quoteSessionRepository.delete(current); + } + return CartResult.empty(quoteSessionResponseAssembler.emptyCart(), hadCookie); + } + + private Optional resolveValidCartSession(HttpServletRequest request) { + Optional sessionId = shopCartCookieService.extractSessionId(request); + if (sessionId.isEmpty()) { + return Optional.empty(); + } + + Optional session = quoteSessionRepository.findByIdAndSessionType(sessionId.get(), SHOP_CART_SESSION_TYPE); + if (session.isEmpty()) { + return Optional.empty(); + } + + QuoteSession quoteSession = session.get(); + if (isSessionUnavailable(quoteSession)) { + if (!EXPIRED_STATUS.equals(quoteSession.getStatus()) && !CONVERTED_STATUS.equals(quoteSession.getStatus())) { + quoteSession.setStatus(EXPIRED_STATUS); + quoteSessionRepository.save(quoteSession); + } + return Optional.empty(); + } + return Optional.of(quoteSession); + } + + private QuoteSession createCartSession() { + QuoteSession session = new QuoteSession(); + session.setStatus(ACTIVE_STATUS); + session.setSessionType(SHOP_CART_SESSION_TYPE); + session.setPricingVersion("v1"); + session.setMaterialCode("SHOP"); + session.setSupportsEnabled(false); + session.setCreatedAt(OffsetDateTime.now()); + session.setExpiresAt(nowPlusCookieTtl()); + session.setSetupCostChf(BigDecimal.ZERO); + return quoteSessionRepository.save(session); + } + + private Map buildCartResponse(QuoteSession session) { + List items = quoteLineItemRepository.findByQuoteSessionIdOrderByCreatedAtAsc(session.getId()); + QuoteSessionTotalsService.QuoteSessionTotals totals = quoteSessionTotalsService.compute(session, items); + return quoteSessionResponseAssembler.assemble(session, items, totals); + } + + private QuoteLineItem buildShopLineItem(QuoteSession session, ShopProductVariant variant) { + ShopProduct product = variant.getProduct(); + ShopProductModelAsset modelAsset = product != null ? shopProductModelAssetRepository.findByProduct_Id(product.getId()).orElse(null) : null; + + QuoteLineItem item = new QuoteLineItem(); + item.setQuoteSession(session); + item.setStatus("READY"); + item.setLineItemType(SHOP_LINE_ITEM_TYPE); + item.setQuantity(0); + item.setCreatedAt(OffsetDateTime.now()); + item.setUpdatedAt(OffsetDateTime.now()); + item.setSupportsEnabled(false); + item.setInfillPercent(0); + item.setPricingBreakdown(new HashMap<>()); + + refreshLineItemSnapshot(item, variant); + applyModelAssetSnapshot(item, session, modelAsset); + return item; + } + + private void refreshLineItemSnapshot(QuoteLineItem item, ShopProductVariant variant) { + ShopProduct product = variant.getProduct(); + ShopCategory category = product != null ? product.getCategory() : null; + + item.setShopProduct(product); + item.setShopProductVariant(variant); + item.setShopProductSlug(product != null ? product.getSlug() : null); + item.setShopProductName(product != null ? product.getName() : null); + item.setShopVariantLabel(variant.getVariantLabel()); + item.setShopVariantColorName(variant.getColorName()); + item.setShopVariantColorHex(variant.getColorHex()); + item.setDisplayName(product != null ? product.getName() : item.getDisplayName()); + item.setColorCode(variant.getColorName()); + item.setMaterialCode(variant.getInternalMaterialCode()); + item.setQuality(null); + item.setUnitPriceChf(variant.getPriceChf() != null ? variant.getPriceChf() : BigDecimal.ZERO); + + Map breakdown = item.getPricingBreakdown() != null + ? new HashMap<>(item.getPricingBreakdown()) + : new HashMap<>(); + breakdown.put("type", SHOP_LINE_ITEM_TYPE); + breakdown.put("unitPriceChf", item.getUnitPriceChf()); + item.setPricingBreakdown(breakdown); + } + + private void applyModelAssetSnapshot(QuoteLineItem item, QuoteSession session, ShopProductModelAsset modelAsset) { + if (modelAsset == null) { + if (item.getOriginalFilename() == null || item.getOriginalFilename().isBlank()) { + item.setOriginalFilename(item.getShopProductSlug() != null ? item.getShopProductSlug() : "shop-product"); + } + item.setBoundingBoxXMm(BigDecimal.ZERO); + item.setBoundingBoxYMm(BigDecimal.ZERO); + item.setBoundingBoxZMm(BigDecimal.ZERO); + item.setStoredPath(null); + return; + } + + item.setOriginalFilename(modelAsset.getOriginalFilename()); + item.setBoundingBoxXMm(modelAsset.getBoundingBoxXMm() != null ? modelAsset.getBoundingBoxXMm() : BigDecimal.ZERO); + item.setBoundingBoxYMm(modelAsset.getBoundingBoxYMm() != null ? modelAsset.getBoundingBoxYMm() : BigDecimal.ZERO); + item.setBoundingBoxZMm(modelAsset.getBoundingBoxZMm() != null ? modelAsset.getBoundingBoxZMm() : BigDecimal.ZERO); + + String copiedStoredPath = copyModelAssetIntoSession(session, modelAsset); + item.setStoredPath(copiedStoredPath); + } + + private String copyModelAssetIntoSession(QuoteSession session, ShopProductModelAsset modelAsset) { + if (session == null || modelAsset == null || modelAsset.getProduct() == null) { + return null; + } + + Path source = shopStorageService.resolveStoredProductPath( + modelAsset.getStoredRelativePath(), + modelAsset.getProduct().getId() + ); + if (source == null || !Files.exists(source)) { + return null; + } + + try { + Path sessionDir = quoteStorageService.sessionStorageDir(session.getId()); + String extension = quoteStorageService.getSafeExtension(modelAsset.getOriginalFilename(), "stl"); + Path destination = quoteStorageService.resolveSessionPath( + sessionDir, + UUID.randomUUID() + "." + extension + ); + Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); + return quoteStorageService.toStoredPath(destination); + } catch (IOException e) { + return null; + } + } + + private ShopProductVariant getPurchasableVariant(UUID variantId) { + if (variantId == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "shopProductVariantId is required"); + } + + ShopProductVariant variant = shopProductVariantRepository.findById(variantId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Variant not found")); + + ShopProduct product = variant.getProduct(); + ShopCategory category = product != null ? product.getCategory() : null; + if (product == null + || category == null + || !Boolean.TRUE.equals(variant.getIsActive()) + || !Boolean.TRUE.equals(product.getIsActive()) + || !Boolean.TRUE.equals(category.getIsActive())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Variant not available"); + } + + return variant; + } + + private void touchSession(QuoteSession session) { + session.setStatus(ACTIVE_STATUS); + session.setExpiresAt(nowPlusCookieTtl()); + quoteSessionRepository.save(session); + } + + private OffsetDateTime nowPlusCookieTtl() { + return OffsetDateTime.now().plusDays(Math.max(shopCartCookieService.getCookieTtlDays(), 1)); + } + + private boolean isSessionUnavailable(QuoteSession session) { + if (session == null) { + return true; + } + if (!SHOP_CART_SESSION_TYPE.equalsIgnoreCase(session.getSessionType())) { + return true; + } + if (!ACTIVE_STATUS.equalsIgnoreCase(session.getStatus())) { + return true; + } + if (CONVERTED_STATUS.equalsIgnoreCase(session.getStatus())) { + return true; + } + OffsetDateTime expiresAt = session.getExpiresAt(); + return expiresAt != null && expiresAt.isBefore(OffsetDateTime.now()); + } + + private int normalizeQuantity(Integer quantity) { + if (quantity == null || quantity < 1) { + return 1; + } + return quantity; + } + + public record CartResult(Map response, UUID sessionId, boolean clearCookie) { + public static CartResult withSession(Map response, UUID sessionId, boolean clearCookie) { + return new CartResult(response, sessionId, clearCookie); + } + + public static CartResult empty(Map response, boolean clearCookie) { + return new CartResult(response, null, clearCookie); + } + } +} diff --git a/backend/src/main/java/com/printcalculator/service/shop/ShopStorageService.java b/backend/src/main/java/com/printcalculator/service/shop/ShopStorageService.java new file mode 100644 index 0000000..fbf2ef8 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/shop/ShopStorageService.java @@ -0,0 +1,50 @@ +package com.printcalculator.service.shop; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; + +@Service +public class ShopStorageService { + private final Path storageRoot; + + public ShopStorageService(@Value("${shop.storage.root:storage_shop}") String storageRoot) { + this.storageRoot = Paths.get(storageRoot).toAbsolutePath().normalize(); + } + + public Path productModelStorageDir(UUID productId) throws IOException { + Path dir = storageRoot.resolve(Path.of("products", productId.toString(), "3d-models")).normalize(); + if (!dir.startsWith(storageRoot)) { + throw new IOException("Invalid shop product storage path"); + } + Files.createDirectories(dir); + return dir; + } + + public Path resolveStoredProductPath(String storedRelativePath, UUID expectedProductId) { + if (storedRelativePath == null || storedRelativePath.isBlank()) { + return null; + } + try { + Path raw = Path.of(storedRelativePath).normalize(); + Path resolved = raw.isAbsolute() ? raw : storageRoot.resolve(raw).normalize(); + Path expectedPrefix = storageRoot.resolve(Path.of("products", expectedProductId.toString())).normalize(); + if (!resolved.startsWith(expectedPrefix)) { + return null; + } + return resolved; + } catch (InvalidPathException e) { + return null; + } + } + + public String toStoredPath(Path absolutePath) { + return storageRoot.relativize(absolutePath.toAbsolutePath().normalize()).toString(); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 4edba9a..a9def27 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -31,6 +31,11 @@ media.storage.root=${MEDIA_STORAGE_ROOT:storage_media} media.public.base-url=${MEDIA_PUBLIC_BASE_URL:http://localhost:8080/media} media.ffmpeg.path=${MEDIA_FFMPEG_PATH:ffmpeg} media.upload.max-file-size-bytes=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:26214400} +shop.model.max-file-size-bytes=${SHOP_MODEL_MAX_FILE_SIZE_BYTES:104857600} +shop.storage.root=${SHOP_STORAGE_ROOT:storage_shop} +shop.cart.cookie.ttl-days=${SHOP_CART_COOKIE_TTL_DAYS:30} +shop.cart.cookie.secure=${SHOP_CART_COOKIE_SECURE:false} +shop.cart.cookie.same-site=${SHOP_CART_COOKIE_SAME_SITE:Lax} # TWINT Configuration payment.twint.url=${TWINT_PAYMENT_URL:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.} diff --git a/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerSecurityTest.java b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerSecurityTest.java new file mode 100644 index 0000000..799526d --- /dev/null +++ b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerSecurityTest.java @@ -0,0 +1,138 @@ +package com.printcalculator.controller.admin; + +import com.printcalculator.config.SecurityConfig; +import com.printcalculator.service.order.AdminOrderControllerService; +import com.printcalculator.security.AdminLoginThrottleService; +import com.printcalculator.security.AdminSessionAuthenticationFilter; +import com.printcalculator.security.AdminSessionService; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.AbstractPlatformTransactionManager; +import org.springframework.transaction.support.DefaultTransactionStatus; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = {AdminAuthController.class, AdminOrderController.class}) +@Import({ + SecurityConfig.class, + AdminSessionAuthenticationFilter.class, + AdminSessionService.class, + AdminLoginThrottleService.class, + AdminOrderControllerSecurityTest.TransactionTestConfig.class +}) +@TestPropertySource(properties = { + "admin.password=test-admin-password", + "admin.session.secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "admin.session.ttl-minutes=60" +}) +class AdminOrderControllerSecurityTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AdminOrderControllerService adminOrderControllerService; + + @Test + void confirmationDocument_withoutAdminCookie_shouldReturn401() throws Exception { + UUID orderId = UUID.randomUUID(); + + mockMvc.perform(get("/api/admin/orders/{orderId}/documents/confirmation", orderId)) + .andExpect(status().isUnauthorized()); + } + + @Test + void confirmationDocument_withAdminCookie_shouldReturnPdf() throws Exception { + UUID orderId = UUID.randomUUID(); + when(adminOrderControllerService.downloadOrderConfirmation(orderId)) + .thenReturn(ResponseEntity.ok() + .contentType(MediaType.APPLICATION_PDF) + .body("confirmation".getBytes())); + + mockMvc.perform(get("/api/admin/orders/{orderId}/documents/confirmation", orderId) + .cookie(loginAndExtractCookie())) + .andExpect(status().isOk()) + .andExpect(content().bytes("confirmation".getBytes())); + } + + @Test + void invoiceDocument_withAdminCookie_shouldReturnPdf() throws Exception { + UUID orderId = UUID.randomUUID(); + when(adminOrderControllerService.downloadOrderInvoice(orderId)) + .thenReturn(ResponseEntity.ok() + .contentType(MediaType.APPLICATION_PDF) + .body("invoice".getBytes())); + + mockMvc.perform(get("/api/admin/orders/{orderId}/documents/invoice", orderId) + .cookie(loginAndExtractCookie())) + .andExpect(status().isOk()) + .andExpect(content().bytes("invoice".getBytes())); + } + + private Cookie loginAndExtractCookie() throws Exception { + MvcResult login = mockMvc.perform(post("/api/admin/auth/login") + .with(req -> { + req.setRemoteAddr("10.0.0.44"); + return req; + }) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"password\":\"test-admin-password\"}")) + .andExpect(status().isOk()) + .andReturn(); + + String setCookie = login.getResponse().getHeader(HttpHeaders.SET_COOKIE); + assertNotNull(setCookie); + String[] parts = setCookie.split(";", 2); + String[] keyValue = parts[0].split("=", 2); + return new Cookie(keyValue[0], keyValue.length > 1 ? keyValue[1] : ""); + } + + @TestConfiguration + static class TransactionTestConfig { + @Bean + PlatformTransactionManager transactionManager() { + return new AbstractPlatformTransactionManager() { + @Override + protected Object doGetTransaction() { + return new Object(); + } + + @Override + protected void doBegin(Object transaction, TransactionDefinition definition) { + // No-op transaction manager for WebMvc security tests. + } + + @Override + protected void doCommit(DefaultTransactionStatus status) { + // No-op transaction manager for WebMvc security tests. + } + + @Override + protected void doRollback(DefaultTransactionStatus status) { + // No-op transaction manager for WebMvc security tests. + } + }; + } + } +} diff --git a/backend/src/test/java/com/printcalculator/entity/ShopProductTest.java b/backend/src/test/java/com/printcalculator/entity/ShopProductTest.java new file mode 100644 index 0000000..2a07ec2 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/entity/ShopProductTest.java @@ -0,0 +1,48 @@ +package com.printcalculator.entity; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ShopProductTest { + + @Test + void localizedAccessorsShouldReturnLanguageSpecificValues() { + ShopProduct product = new ShopProduct(); + product.setName("Desk Cable Clip"); + product.setNameIt("Fermacavo da scrivania"); + product.setNameEn("Desk Cable Clip"); + product.setNameDe("Schreibtisch-Kabelclip"); + product.setNameFr("Clip de cable de bureau"); + product.setExcerpt("Legacy excerpt"); + product.setExcerptIt("Clip compatta per i cavi sulla scrivania."); + product.setExcerptEn("Compact clip to keep desk cables in place."); + product.setExcerptDe("Kompakter Clip fur ordentliche Kabel auf dem Schreibtisch."); + product.setExcerptFr("Clip compact pour garder les cables du bureau en ordre."); + product.setDescription("Legacy description"); + product.setDescriptionIt("Supporto con base stabile e passaggio cavi frontale."); + product.setDescriptionEn("Stable desk clip with front cable routing."); + product.setDescriptionDe("Stabiler Tischclip mit frontaler Kabelfuhrung."); + product.setDescriptionFr("Clip de bureau stable avec passage frontal des cables."); + + assertEquals("Fermacavo da scrivania", product.getNameForLanguage("it")); + assertEquals("Desk Cable Clip", product.getNameForLanguage("en")); + assertEquals("Schreibtisch-Kabelclip", product.getNameForLanguage("de")); + assertEquals("Clip de cable de bureau", product.getNameForLanguage("fr")); + assertEquals("Compact clip to keep desk cables in place.", product.getExcerptForLanguage("en")); + assertEquals("Clip compact pour garder les cables du bureau en ordre.", product.getExcerptForLanguage("fr")); + assertEquals("Stabiler Tischclip mit frontaler Kabelfuhrung.", product.getDescriptionForLanguage("de")); + } + + @Test + void localizedAccessorsShouldFallbackToLegacyValues() { + ShopProduct product = new ShopProduct(); + product.setName("Desk Cable Clip"); + product.setExcerpt("Compact desk cable clip."); + product.setDescription("Stable clip with front cable channel."); + + assertEquals("Desk Cable Clip", product.getNameForLanguage("it")); + assertEquals("Compact desk cable clip.", product.getExcerptForLanguage("de")); + assertEquals("Stable clip with front cable channel.", product.getDescriptionForLanguage("fr-CH")); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java b/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java new file mode 100644 index 0000000..aa90829 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java @@ -0,0 +1,248 @@ +package com.printcalculator.service; + +import com.printcalculator.dto.AddressDto; +import com.printcalculator.dto.CreateOrderRequest; +import com.printcalculator.dto.CustomerDto; +import com.printcalculator.entity.Customer; +import com.printcalculator.entity.Order; +import com.printcalculator.entity.OrderItem; +import com.printcalculator.entity.Payment; +import com.printcalculator.entity.QuoteLineItem; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.entity.ShopCategory; +import com.printcalculator.entity.ShopProduct; +import com.printcalculator.entity.ShopProductVariant; +import com.printcalculator.event.OrderCreatedEvent; +import com.printcalculator.repository.CustomerRepository; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.repository.QuoteSessionRepository; +import com.printcalculator.service.payment.InvoicePdfRenderingService; +import com.printcalculator.service.payment.PaymentService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.storage.StorageService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OrderServiceTest { + + @Mock + private OrderRepository orderRepo; + @Mock + private OrderItemRepository orderItemRepo; + @Mock + private QuoteSessionRepository quoteSessionRepo; + @Mock + private QuoteLineItemRepository quoteLineItemRepo; + @Mock + private CustomerRepository customerRepo; + @Mock + private StorageService storageService; + @Mock + private InvoicePdfRenderingService invoiceService; + @Mock + private QrBillService qrBillService; + @Mock + private ApplicationEventPublisher eventPublisher; + @Mock + private PaymentService paymentService; + @Mock + private QuoteSessionTotalsService quoteSessionTotalsService; + + @InjectMocks + private OrderService service; + + @Test + void createOrderFromQuote_withShopCart_shouldPreserveShopSnapshotAndMaterialCode() throws Exception { + UUID sessionId = UUID.randomUUID(); + UUID orderId = UUID.randomUUID(); + UUID orderItemId = UUID.randomUUID(); + + QuoteSession session = new QuoteSession(); + session.setId(sessionId); + session.setStatus("ACTIVE"); + session.setSessionType("SHOP_CART"); + session.setMaterialCode("SHOP"); + session.setPricingVersion("v1"); + session.setSetupCostChf(BigDecimal.ZERO); + session.setExpiresAt(OffsetDateTime.now().plusDays(30)); + + ShopCategory category = new ShopCategory(); + category.setId(UUID.randomUUID()); + category.setSlug("cable-management"); + category.setName("Cable Management"); + + ShopProduct product = new ShopProduct(); + product.setId(UUID.randomUUID()); + product.setCategory(category); + product.setSlug("desk-cable-clip"); + product.setName("Desk Cable Clip"); + + ShopProductVariant variant = new ShopProductVariant(); + variant.setId(UUID.randomUUID()); + variant.setProduct(product); + variant.setVariantLabel("Coral Red"); + variant.setColorName("Coral Red"); + variant.setColorHex("#ff6b6b"); + variant.setInternalMaterialCode("PLA-MATTE"); + variant.setPriceChf(new BigDecimal("14.90")); + + Path sourceDir = Path.of("storage_quotes").toAbsolutePath().normalize().resolve(sessionId.toString()); + Files.createDirectories(sourceDir); + Path sourceFile = sourceDir.resolve("shop-product.stl"); + Files.writeString(sourceFile, "solid product\nendsolid product\n", StandardCharsets.UTF_8); + + QuoteLineItem qItem = new QuoteLineItem(); + qItem.setId(UUID.randomUUID()); + qItem.setQuoteSession(session); + qItem.setStatus("READY"); + qItem.setLineItemType("SHOP_PRODUCT"); + qItem.setOriginalFilename("shop-product.stl"); + qItem.setDisplayName("Desk Cable Clip"); + qItem.setQuantity(2); + qItem.setColorCode("Coral Red"); + qItem.setMaterialCode("PLA-MATTE"); + qItem.setShopProduct(product); + qItem.setShopProductVariant(variant); + qItem.setShopProductSlug(product.getSlug()); + qItem.setShopProductName(product.getName()); + qItem.setShopVariantLabel("Coral Red"); + qItem.setShopVariantColorName("Coral Red"); + qItem.setShopVariantColorHex("#ff6b6b"); + qItem.setBoundingBoxXMm(new BigDecimal("60.000")); + qItem.setBoundingBoxYMm(new BigDecimal("40.000")); + qItem.setBoundingBoxZMm(new BigDecimal("20.000")); + qItem.setUnitPriceChf(new BigDecimal("14.90")); + qItem.setStoredPath(sourceFile.toString()); + + Customer customer = new Customer(); + customer.setId(UUID.randomUUID()); + customer.setEmail("buyer@example.com"); + + when(quoteSessionRepo.findById(sessionId)).thenReturn(Optional.of(session)); + when(customerRepo.findByEmail("buyer@example.com")).thenReturn(Optional.empty()); + when(customerRepo.save(any(Customer.class))).thenAnswer(invocation -> { + Customer saved = invocation.getArgument(0); + if (saved.getId() == null) { + saved.setId(customer.getId()); + } + return saved; + }); + when(quoteLineItemRepo.findByQuoteSessionId(sessionId)).thenReturn(List.of(qItem)); + when(quoteSessionTotalsService.compute(eq(session), eq(List.of(qItem)))).thenReturn( + new QuoteSessionTotalsService.QuoteSessionTotals( + new BigDecimal("29.80"), + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("29.80"), + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("2.00"), + new BigDecimal("31.80"), + BigDecimal.ZERO + ) + ); + when(orderRepo.save(any(Order.class))).thenAnswer(invocation -> { + Order saved = invocation.getArgument(0); + if (saved.getId() == null) { + saved.setId(orderId); + } + return saved; + }); + when(orderItemRepo.save(any(OrderItem.class))).thenAnswer(invocation -> { + OrderItem saved = invocation.getArgument(0); + if (saved.getId() == null) { + saved.setId(orderItemId); + } + return saved; + }); + when(qrBillService.generateQrBillSvg(any(Order.class))).thenReturn("".getBytes(StandardCharsets.UTF_8)); + when(invoiceService.generateDocumentPdf(any(Order.class), any(List.class), eq(true), eq(qrBillService), isNull())) + .thenReturn("pdf".getBytes(StandardCharsets.UTF_8)); + when(paymentService.getOrCreatePaymentForOrder(any(Order.class), eq("OTHER"))).thenReturn(new Payment()); + + Order order = service.createOrderFromQuote(sessionId, buildRequest()); + + assertEquals(orderId, order.getId()); + assertEquals("SHOP", order.getSourceType()); + assertEquals("CONVERTED", session.getStatus()); + assertEquals(orderId, session.getConvertedOrderId()); + assertAmountEquals("29.80", order.getSubtotalChf()); + assertAmountEquals("31.80", order.getTotalChf()); + + ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(OrderItem.class); + verify(orderItemRepo, times(2)).save(itemCaptor.capture()); + OrderItem savedItem = itemCaptor.getAllValues().getLast(); + assertEquals("SHOP_PRODUCT", savedItem.getItemType()); + assertEquals("Desk Cable Clip", savedItem.getDisplayName()); + assertEquals("PLA-MATTE", savedItem.getMaterialCode()); + assertEquals("desk-cable-clip", savedItem.getShopProductSlug()); + assertEquals("Desk Cable Clip", savedItem.getShopProductName()); + assertEquals("Coral Red", savedItem.getShopVariantLabel()); + assertEquals("Coral Red", savedItem.getShopVariantColorName()); + assertEquals("#ff6b6b", savedItem.getShopVariantColorHex()); + assertAmountEquals("14.90", savedItem.getUnitPriceChf()); + assertAmountEquals("29.80", savedItem.getLineTotalChf()); + + verify(storageService).store(eq(sourceFile), eq(Path.of( + "orders", orderId.toString(), "3d-files", orderItemId.toString(), savedItem.getStoredFilename() + ))); + verify(paymentService).getOrCreatePaymentForOrder(order, "OTHER"); + verify(eventPublisher).publishEvent(any(OrderCreatedEvent.class)); + } + + private CreateOrderRequest buildRequest() { + CustomerDto customer = new CustomerDto(); + customer.setEmail("buyer@example.com"); + customer.setPhone("+41790000000"); + customer.setCustomerType("PRIVATE"); + + AddressDto billing = new AddressDto(); + billing.setFirstName("Joe"); + billing.setLastName("Buyer"); + billing.setAddressLine1("Via Test 1"); + billing.setZip("6900"); + billing.setCity("Lugano"); + billing.setCountryCode("CH"); + + CreateOrderRequest request = new CreateOrderRequest(); + request.setCustomer(customer); + request.setBillingAddress(billing); + request.setShippingSameAsBilling(true); + request.setLanguage("it"); + request.setAcceptTerms(true); + request.setAcceptPrivacy(true); + return request; + } + + private void assertAmountEquals(String expected, BigDecimal actual) { + assertTrue(new BigDecimal(expected).compareTo(actual) == 0, + "Expected " + expected + " but got " + actual); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java b/backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java index 85288c7..b3be679 100644 --- a/backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java @@ -65,8 +65,8 @@ class PublicMediaQueryServiceTest { MediaUsage usageDraft = buildUsage(draftAsset, "HOME_SECTION", "shop-gallery", 0, false, true); MediaUsage usagePrivate = buildUsage(privateAsset, "HOME_SECTION", "shop-gallery", 3, false, true); - when(mediaUsageRepository.findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc( - "HOME_SECTION", "shop-gallery" + when(mediaUsageRepository.findActiveByUsageTypeAndUsageKeys( + "HOME_SECTION", List.of("shop-gallery") )).thenReturn(List.of(usageSecond, usageFirst, usageDraft, usagePrivate)); when(mediaVariantRepository.findByMediaAsset_IdIn(List.of(readyPublicAsset.getId()))) .thenReturn(List.of( @@ -93,8 +93,8 @@ class PublicMediaQueryServiceTest { MediaAsset asset = buildAsset("READY", "PUBLIC", "Joe portrait", "Joe portrait fallback"); MediaUsage usage = buildUsage(asset, "ABOUT_MEMBER", "joe", 0, true, true); - when(mediaUsageRepository.findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc( - "ABOUT_MEMBER", "joe" + when(mediaUsageRepository.findActiveByUsageTypeAndUsageKeys( + "ABOUT_MEMBER", List.of("joe") )).thenReturn(List.of(usage)); when(mediaVariantRepository.findByMediaAsset_IdIn(List.of(asset.getId()))) .thenReturn(List.of(buildVariant(asset, "card", "JPEG", "joe/card.jpg"))); diff --git a/backend/src/test/java/com/printcalculator/service/payment/InvoicePdfRenderingServiceTest.java b/backend/src/test/java/com/printcalculator/service/payment/InvoicePdfRenderingServiceTest.java new file mode 100644 index 0000000..086c319 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/payment/InvoicePdfRenderingServiceTest.java @@ -0,0 +1,85 @@ +package com.printcalculator.service.payment; + +import com.printcalculator.entity.Order; +import com.printcalculator.entity.OrderItem; +import org.junit.jupiter.api.Test; +import org.thymeleaf.TemplateEngine; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class InvoicePdfRenderingServiceTest { + + @Test + void generateDocumentPdf_shouldDescribeShopItemsWithProductAndVariant() { + CapturingInvoicePdfRenderingService service = new CapturingInvoicePdfRenderingService(); + QrBillService qrBillService = mock(QrBillService.class); + when(qrBillService.generateQrBillSvg(org.mockito.ArgumentMatchers.any(Order.class))) + .thenReturn("".getBytes(StandardCharsets.UTF_8)); + + Order order = new Order(); + order.setId(UUID.randomUUID()); + order.setCreatedAt(OffsetDateTime.parse("2026-03-10T10:15:30+01:00")); + order.setBillingCustomerType("PRIVATE"); + order.setBillingFirstName("Joe"); + order.setBillingLastName("Buyer"); + order.setBillingAddressLine1("Via Test 1"); + order.setBillingZip("6900"); + order.setBillingCity("Lugano"); + order.setBillingCountryCode("CH"); + order.setSetupCostChf(BigDecimal.ZERO); + order.setShippingCostChf(new BigDecimal("2.00")); + order.setSubtotalChf(new BigDecimal("36.80")); + order.setTotalChf(new BigDecimal("38.80")); + order.setCadTotalChf(BigDecimal.ZERO); + + OrderItem shopItem = new OrderItem(); + shopItem.setItemType("SHOP_PRODUCT"); + shopItem.setDisplayName("Desk Cable Clip"); + shopItem.setOriginalFilename("desk-cable-clip.stl"); + shopItem.setShopProductName("Desk Cable Clip"); + shopItem.setShopVariantLabel("Coral Red"); + shopItem.setQuantity(2); + shopItem.setUnitPriceChf(new BigDecimal("14.90")); + shopItem.setLineTotalChf(new BigDecimal("29.80")); + + OrderItem printItem = new OrderItem(); + printItem.setItemType("PRINT_FILE"); + printItem.setDisplayName("gear-cover.stl"); + printItem.setOriginalFilename("gear-cover.stl"); + printItem.setQuantity(1); + printItem.setUnitPriceChf(new BigDecimal("7.00")); + printItem.setLineTotalChf(new BigDecimal("7.00")); + + byte[] pdf = service.generateDocumentPdf(order, List.of(shopItem, printItem), true, qrBillService, null); + + assertNotNull(pdf); + @SuppressWarnings("unchecked") + List> invoiceLineItems = (List>) service.capturedVariables.get("invoiceLineItems"); + assertEquals("Desk Cable Clip - Coral Red", invoiceLineItems.getFirst().get("description")); + assertEquals("Stampa 3D: gear-cover.stl", invoiceLineItems.get(1).get("description")); + } + + private static class CapturingInvoicePdfRenderingService extends InvoicePdfRenderingService { + private Map capturedVariables; + + private CapturingInvoicePdfRenderingService() { + super(mock(TemplateEngine.class)); + } + + @Override + public byte[] generateInvoicePdfBytesFromTemplate(Map invoiceTemplateVariables, String qrBillSvg) { + this.capturedVariables = invoiceTemplateVariables; + return new byte[]{1, 2, 3}; + } + } +} diff --git a/backend/src/test/java/com/printcalculator/service/shop/ShopCartServiceTest.java b/backend/src/test/java/com/printcalculator/service/shop/ShopCartServiceTest.java new file mode 100644 index 0000000..41e3154 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/shop/ShopCartServiceTest.java @@ -0,0 +1,220 @@ +package com.printcalculator.service.shop; + +import com.printcalculator.dto.ShopCartAddItemRequest; +import com.printcalculator.entity.QuoteLineItem; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.entity.ShopCategory; +import com.printcalculator.entity.ShopProduct; +import com.printcalculator.entity.ShopProductVariant; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.repository.QuoteSessionRepository; +import com.printcalculator.repository.ShopProductModelAssetRepository; +import com.printcalculator.repository.ShopProductVariantRepository; +import com.printcalculator.service.QuoteSessionTotalsService; +import com.printcalculator.service.quote.QuoteSessionResponseAssembler; +import com.printcalculator.service.quote.QuoteStorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ShopCartServiceTest { + + @Mock + private QuoteSessionRepository quoteSessionRepository; + @Mock + private QuoteLineItemRepository quoteLineItemRepository; + @Mock + private ShopProductVariantRepository shopProductVariantRepository; + @Mock + private ShopProductModelAssetRepository shopProductModelAssetRepository; + @Mock + private QuoteSessionTotalsService quoteSessionTotalsService; + @Mock + private QuoteSessionResponseAssembler quoteSessionResponseAssembler; + @Mock + private ShopStorageService shopStorageService; + @Mock + private ShopCartCookieService shopCartCookieService; + + private ShopCartService service; + + @BeforeEach + void setUp() { + service = new ShopCartService( + quoteSessionRepository, + quoteLineItemRepository, + shopProductVariantRepository, + shopProductModelAssetRepository, + quoteSessionTotalsService, + quoteSessionResponseAssembler, + new QuoteStorageService(), + shopStorageService, + shopCartCookieService + ); + } + + @Test + void addItem_shouldCreateServerCartAndPersistVariantPricingSnapshot() { + UUID sessionId = UUID.randomUUID(); + UUID lineItemId = UUID.randomUUID(); + UUID variantId = UUID.randomUUID(); + List savedItems = new ArrayList<>(); + + ShopProductVariant variant = buildVariant(variantId); + + when(shopCartCookieService.extractSessionId(any())).thenReturn(Optional.empty()); + when(shopCartCookieService.getCookieTtlDays()).thenReturn(30L); + when(shopProductVariantRepository.findById(variantId)).thenReturn(Optional.of(variant)); + when(shopProductModelAssetRepository.findByProduct_Id(variant.getProduct().getId())).thenReturn(Optional.empty()); + when(quoteSessionRepository.save(any(QuoteSession.class))).thenAnswer(invocation -> { + QuoteSession session = invocation.getArgument(0); + if (session.getId() == null) { + session.setId(sessionId); + } + return session; + }); + when(quoteLineItemRepository.findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id( + eq(sessionId), + eq("SHOP_PRODUCT"), + eq(variantId) + )).thenReturn(Optional.empty()); + when(quoteLineItemRepository.save(any(QuoteLineItem.class))).thenAnswer(invocation -> { + QuoteLineItem item = invocation.getArgument(0); + if (item.getId() == null) { + item.setId(lineItemId); + } + savedItems.clear(); + savedItems.add(item); + return item; + }); + when(quoteLineItemRepository.findByQuoteSessionIdOrderByCreatedAtAsc(sessionId)).thenAnswer(invocation -> List.copyOf(savedItems)); + when(quoteSessionTotalsService.compute(any(), any())).thenReturn( + new QuoteSessionTotalsService.QuoteSessionTotals( + new BigDecimal("22.80"), + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("22.80"), + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("2.00"), + new BigDecimal("24.80"), + BigDecimal.ZERO + ) + ); + when(quoteSessionResponseAssembler.assemble(any(), any(), any())).thenAnswer(invocation -> { + QuoteSession session = invocation.getArgument(0); + Map response = new HashMap<>(); + response.put("session", session); + response.put("items", List.of()); + response.put("grandTotalChf", new BigDecimal("24.80")); + return response; + }); + + ShopCartAddItemRequest payload = new ShopCartAddItemRequest(); + payload.setShopProductVariantId(variantId); + payload.setQuantity(2); + + ShopCartService.CartResult result = service.addItem(new MockHttpServletRequest(), payload); + + assertEquals(sessionId, result.sessionId()); + assertFalse(result.clearCookie()); + assertEquals(new BigDecimal("24.80"), result.response().get("grandTotalChf")); + + QuoteLineItem savedItem = savedItems.getFirst(); + assertEquals("SHOP_PRODUCT", savedItem.getLineItemType()); + assertEquals("Desk Cable Clip", savedItem.getDisplayName()); + assertEquals("desk-cable-clip", savedItem.getOriginalFilename()); + assertEquals(2, savedItem.getQuantity()); + assertEquals("PLA", savedItem.getMaterialCode()); + assertEquals("Coral Red", savedItem.getColorCode()); + assertEquals("Desk Cable Clip", savedItem.getShopProductName()); + assertEquals("Coral Red", savedItem.getShopVariantLabel()); + assertEquals("Coral Red", savedItem.getShopVariantColorName()); + assertAmountEquals("11.40", savedItem.getUnitPriceChf()); + assertNull(savedItem.getStoredPath()); + } + + @Test + void loadCart_withExpiredCookieSession_shouldExpireSessionAndAskCookieClear() { + UUID sessionId = UUID.randomUUID(); + QuoteSession session = new QuoteSession(); + session.setId(sessionId); + session.setSessionType("SHOP_CART"); + session.setStatus("ACTIVE"); + session.setExpiresAt(OffsetDateTime.now().minusHours(1)); + + Map emptyResponse = new HashMap<>(); + emptyResponse.put("session", null); + emptyResponse.put("items", List.of()); + + when(shopCartCookieService.hasCartCookie(any())).thenReturn(true); + when(shopCartCookieService.extractSessionId(any())).thenReturn(Optional.of(sessionId)); + when(quoteSessionRepository.findByIdAndSessionType(sessionId, "SHOP_CART")).thenReturn(Optional.of(session)); + when(quoteSessionRepository.save(any(QuoteSession.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(quoteSessionResponseAssembler.emptyCart()).thenReturn(emptyResponse); + + ShopCartService.CartResult result = service.loadCart(new MockHttpServletRequest()); + + assertTrue(result.clearCookie()); + assertNull(result.sessionId()); + assertEquals(emptyResponse, result.response()); + assertEquals("EXPIRED", session.getStatus()); + verify(quoteSessionRepository).save(session); + } + + private ShopProductVariant buildVariant(UUID variantId) { + ShopCategory category = new ShopCategory(); + category.setId(UUID.randomUUID()); + category.setSlug("cable-management"); + category.setName("Cable Management"); + category.setIsActive(true); + + ShopProduct product = new ShopProduct(); + product.setId(UUID.randomUUID()); + product.setCategory(category); + product.setSlug("desk-cable-clip"); + product.setName("Desk Cable Clip"); + product.setIsActive(true); + + ShopProductVariant variant = new ShopProductVariant(); + variant.setId(variantId); + variant.setProduct(product); + variant.setSku("DEMO-CLIP-CORAL"); + variant.setVariantLabel("Coral Red"); + variant.setColorName("Coral Red"); + variant.setColorHex("#ff6b6b"); + variant.setInternalMaterialCode("PLA"); + variant.setPriceChf(new BigDecimal("11.40")); + variant.setIsActive(true); + variant.setIsDefault(false); + return variant; + } + + private void assertAmountEquals(String expected, BigDecimal actual) { + assertTrue(new BigDecimal(expected).compareTo(actual) == 0, + "Expected " + expected + " but got " + actual); + } +} diff --git a/db.sql b/db.sql index d587f31..4d424a0 100644 --- a/db.sql +++ b/db.sql @@ -1007,6 +1007,319 @@ ALTER TABLE media_usage ALTER TABLE media_usage ADD COLUMN IF NOT EXISTS alt_text_fr text; +CREATE TABLE IF NOT EXISTS shop_category +( + shop_category_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + parent_category_id uuid REFERENCES shop_category (shop_category_id) ON DELETE SET NULL, + slug text NOT NULL UNIQUE, + name text NOT NULL, + description text, + seo_title text, + seo_description text, + og_title text, + og_description text, + indexable boolean NOT NULL DEFAULT true, + is_active boolean NOT NULL DEFAULT true, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT chk_shop_category_not_self_parent CHECK ( + parent_category_id IS NULL OR parent_category_id <> shop_category_id + ) +); + +CREATE INDEX IF NOT EXISTS ix_shop_category_parent_sort + ON shop_category (parent_category_id, sort_order, created_at DESC); + +CREATE INDEX IF NOT EXISTS ix_shop_category_active_sort + ON shop_category (is_active, sort_order, created_at DESC); + +CREATE TABLE IF NOT EXISTS shop_product +( + shop_product_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + shop_category_id uuid NOT NULL REFERENCES shop_category (shop_category_id), + slug text NOT NULL UNIQUE, + name text NOT NULL, + name_it text, + name_en text, + name_de text, + name_fr text, + excerpt text, + excerpt_it text, + excerpt_en text, + excerpt_de text, + excerpt_fr text, + description text, + description_it text, + description_en text, + description_de text, + description_fr text, + seo_title text, + seo_description text, + og_title text, + og_description text, + indexable boolean NOT NULL DEFAULT true, + is_featured boolean NOT NULL DEFAULT false, + is_active boolean NOT NULL DEFAULT true, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_shop_product_category_active_sort + ON shop_product (shop_category_id, is_active, sort_order, created_at DESC); + +CREATE INDEX IF NOT EXISTS ix_shop_product_featured_sort + ON shop_product (is_featured, is_active, sort_order, created_at DESC); + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS name_it text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS name_en text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS name_de text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS name_fr text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS excerpt_it text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS excerpt_en text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS excerpt_de text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS excerpt_fr text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS description_it text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS description_en text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS description_de text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS description_fr text; + +UPDATE shop_product +SET + name_it = COALESCE(NULLIF(btrim(name_it), ''), name), + name_en = COALESCE(NULLIF(btrim(name_en), ''), name), + name_de = COALESCE(NULLIF(btrim(name_de), ''), name), + name_fr = COALESCE(NULLIF(btrim(name_fr), ''), name), + excerpt_it = COALESCE(NULLIF(btrim(excerpt_it), ''), excerpt), + excerpt_en = COALESCE(NULLIF(btrim(excerpt_en), ''), excerpt), + excerpt_de = COALESCE(NULLIF(btrim(excerpt_de), ''), excerpt), + excerpt_fr = COALESCE(NULLIF(btrim(excerpt_fr), ''), excerpt), + description_it = COALESCE(NULLIF(btrim(description_it), ''), description), + description_en = COALESCE(NULLIF(btrim(description_en), ''), description), + description_de = COALESCE(NULLIF(btrim(description_de), ''), description), + description_fr = COALESCE(NULLIF(btrim(description_fr), ''), description) +WHERE + NULLIF(btrim(name_it), '') IS NULL + OR NULLIF(btrim(name_en), '') IS NULL + OR NULLIF(btrim(name_de), '') IS NULL + OR NULLIF(btrim(name_fr), '') IS NULL + OR (excerpt IS NOT NULL AND ( + NULLIF(btrim(excerpt_it), '') IS NULL + OR NULLIF(btrim(excerpt_en), '') IS NULL + OR NULLIF(btrim(excerpt_de), '') IS NULL + OR NULLIF(btrim(excerpt_fr), '') IS NULL + )) + OR (description IS NOT NULL AND ( + NULLIF(btrim(description_it), '') IS NULL + OR NULLIF(btrim(description_en), '') IS NULL + OR NULLIF(btrim(description_de), '') IS NULL + OR NULLIF(btrim(description_fr), '') IS NULL + )); + +CREATE TABLE IF NOT EXISTS shop_product_variant +( + shop_product_variant_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + shop_product_id uuid NOT NULL REFERENCES shop_product (shop_product_id) ON DELETE CASCADE, + sku text UNIQUE, + variant_label text NOT NULL, + color_name text NOT NULL, + color_hex text, + internal_material_code text NOT NULL, + price_chf numeric(12, 2) NOT NULL DEFAULT 0.00 CHECK (price_chf >= 0), + is_default boolean NOT NULL DEFAULT false, + is_active boolean NOT NULL DEFAULT true, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_shop_product_variant_product_active_sort + ON shop_product_variant (shop_product_id, is_active, sort_order, created_at DESC); + +CREATE INDEX IF NOT EXISTS ix_shop_product_variant_sku + ON shop_product_variant (sku); + +CREATE TABLE IF NOT EXISTS shop_product_model_asset +( + shop_product_model_asset_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + shop_product_id uuid NOT NULL UNIQUE REFERENCES shop_product (shop_product_id) ON DELETE CASCADE, + original_filename text NOT NULL, + stored_relative_path text NOT NULL, + stored_filename text NOT NULL, + file_size_bytes bigint CHECK (file_size_bytes >= 0), + mime_type text, + sha256_hex text, + bounding_box_x_mm numeric(10, 3), + bounding_box_y_mm numeric(10, 3), + bounding_box_z_mm numeric(10, 3), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_shop_product_model_asset_product + ON shop_product_model_asset (shop_product_id); + +ALTER TABLE quote_sessions + ADD COLUMN IF NOT EXISTS session_type text NOT NULL DEFAULT 'PRINT_QUOTE'; + +CREATE INDEX IF NOT EXISTS ix_quote_sessions_session_type + ON quote_sessions (session_type); + +ALTER TABLE quote_sessions + DROP CONSTRAINT IF EXISTS quote_sessions_session_type_check; + +ALTER TABLE quote_sessions + ADD CONSTRAINT quote_sessions_session_type_check + CHECK (session_type IN ('PRINT_QUOTE', 'SHOP_CART')); + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS line_item_type text NOT NULL DEFAULT 'PRINT_FILE'; + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS display_name text; + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS shop_product_id uuid; + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS shop_product_variant_id uuid; + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS shop_product_slug text; + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS shop_product_name text; + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS shop_variant_label text; + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS shop_variant_color_name text; + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS shop_variant_color_hex text; + +ALTER TABLE quote_line_items + ADD COLUMN IF NOT EXISTS stored_path text; + +CREATE INDEX IF NOT EXISTS ix_quote_line_items_shop_product + ON quote_line_items (shop_product_id); + +CREATE INDEX IF NOT EXISTS ix_quote_line_items_shop_product_variant + ON quote_line_items (shop_product_variant_id); + +ALTER TABLE quote_line_items + DROP CONSTRAINT IF EXISTS quote_line_items_line_item_type_check; + +ALTER TABLE quote_line_items + ADD CONSTRAINT quote_line_items_line_item_type_check + CHECK (line_item_type IN ('PRINT_FILE', 'SHOP_PRODUCT')); + +ALTER TABLE quote_line_items + DROP CONSTRAINT IF EXISTS fk_quote_line_items_shop_product; + +ALTER TABLE quote_line_items + ADD CONSTRAINT fk_quote_line_items_shop_product + FOREIGN KEY (shop_product_id) REFERENCES shop_product (shop_product_id); + +ALTER TABLE quote_line_items + DROP CONSTRAINT IF EXISTS fk_quote_line_items_shop_product_variant; + +ALTER TABLE quote_line_items + ADD CONSTRAINT fk_quote_line_items_shop_product_variant + FOREIGN KEY (shop_product_variant_id) REFERENCES shop_product_variant (shop_product_variant_id); + +ALTER TABLE orders + ADD COLUMN IF NOT EXISTS source_type text NOT NULL DEFAULT 'CALCULATOR'; + +CREATE INDEX IF NOT EXISTS ix_orders_source_type + ON orders (source_type); + +ALTER TABLE orders + DROP CONSTRAINT IF EXISTS orders_source_type_check; + +ALTER TABLE orders + ADD CONSTRAINT orders_source_type_check + CHECK (source_type IN ('CALCULATOR', 'SHOP')); + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS item_type text NOT NULL DEFAULT 'PRINT_FILE'; + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS display_name text; + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS shop_product_id uuid; + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS shop_product_variant_id uuid; + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS shop_product_slug text; + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS shop_product_name text; + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS shop_variant_label text; + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS shop_variant_color_name text; + +ALTER TABLE order_items + ADD COLUMN IF NOT EXISTS shop_variant_color_hex text; + +CREATE INDEX IF NOT EXISTS ix_order_items_shop_product + ON order_items (shop_product_id); + +CREATE INDEX IF NOT EXISTS ix_order_items_shop_product_variant + ON order_items (shop_product_variant_id); + +ALTER TABLE order_items + DROP CONSTRAINT IF EXISTS order_items_item_type_check; + +ALTER TABLE order_items + ADD CONSTRAINT order_items_item_type_check + CHECK (item_type IN ('PRINT_FILE', 'SHOP_PRODUCT')); + +ALTER TABLE order_items + DROP CONSTRAINT IF EXISTS fk_order_items_shop_product; + +ALTER TABLE order_items + ADD CONSTRAINT fk_order_items_shop_product + FOREIGN KEY (shop_product_id) REFERENCES shop_product (shop_product_id); + +ALTER TABLE order_items + DROP CONSTRAINT IF EXISTS fk_order_items_shop_product_variant; + +ALTER TABLE order_items + ADD CONSTRAINT fk_order_items_shop_product_variant + FOREIGN KEY (shop_product_variant_id) REFERENCES shop_product_variant (shop_product_variant_id); + ALTER TABLE quote_sessions DROP CONSTRAINT IF EXISTS fk_quote_sessions_source_request; diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index c423e3b..c12362d 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -31,7 +31,6 @@ const appChildRoutes: Routes = [ seoTitle: 'Shop 3D fab', seoDescription: 'Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.', - seoRobots: 'noindex, nofollow', }, }, { diff --git a/frontend/src/app/core/layout/navbar.component.html b/frontend/src/app/core/layout/navbar.component.html index 6703ac7..46d2bd8 100644 --- a/frontend/src/app/core/layout/navbar.component.html +++ b/frontend/src/app/core/layout/navbar.component.html @@ -42,6 +42,35 @@
+ + +
+ @@ -86,6 +101,15 @@ (click)="openDetails(order.id)" > + @@ -94,7 +118,7 @@ - @@ -105,7 +129,18 @@
-

Dettaglio ordine {{ selectedOrder.orderNumber }}

+
+

Dettaglio ordine {{ selectedOrder.orderNumber }}

+ + {{ orderKindLabel(selectedOrder) }} + +

UUID: Stato ordine{{ selectedOrder.status }}

+
+ Tipo ordine{{ orderKindLabel(selectedOrder) }} +
Totale{{ @@ -207,6 +246,7 @@ type="button" class="ui-button ui-button--ghost" (click)="openPrintDetails()" + [disabled]="!hasPrintItems(selectedOrder)" > Dettagli stampa @@ -215,38 +255,67 @@
-

- {{ item.originalFilename }} -

-

- Qta: {{ item.quantity }} | Materiale: - {{ getItemMaterialLabel(item) }} | Colore: +

+

+ {{ itemDisplayName(item) }} +

- - {{ getItemColorLabel(item) }} - - ({{ colorCode }}) - + class="item-kind-badge" + [class.item-kind-badge--shop]="isShopItem(item)" + > + {{ isShopItem(item) ? "Shop" : "Calcolatore" }} - | Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer: +
+

+ Qta: {{ item.quantity }} + + Materiale: {{ getItemMaterialLabel(item) }} + + + Variante: {{ variantLabel }} + + + Colore: + + + {{ getItemColorLabel(item) }} + + ({{ colorCode }}) + + + +

+

+ Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer: {{ item.layerHeightMm ?? "-" }} mm | Infill: {{ item.infillPercent ?? "-" }}% | Supporti: {{ formatSupports(item.supportsEnabled) }} - | Riga: +

+

+ Riga: {{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }}

- +
+ +
@@ -315,7 +384,10 @@

Parametri per file

-
+
{{ item.originalFilename }} {{ getItemMaterialLabel(item) }} | Colore: 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 622215f..3b6c1f1 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.scss +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.scss @@ -4,6 +4,22 @@ gap: var(--space-5); } +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); +} + +.dashboard-header h1 { + margin: 0; +} + +.dashboard-header p { + margin: var(--space-2) 0 0; + color: var(--color-text-muted); +} + .header-actions { display: flex; gap: var(--space-2); @@ -21,10 +37,11 @@ .list-toolbar { display: grid; - grid-template-columns: minmax(230px, 1.6fr) minmax(170px, 1fr) minmax( - 190px, - 1fr - ); + grid-template-columns: + minmax(230px, 1.6fr) + minmax(170px, 1fr) + minmax(190px, 1fr) + minmax(170px, 1fr); gap: var(--space-2); margin-bottom: var(--space-3); } @@ -69,6 +86,13 @@ tbody tr.no-results:hover { margin: 0 0 var(--space-2); } +.detail-title-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); +} + .actions-block { display: flex; flex-wrap: wrap; @@ -113,6 +137,15 @@ tbody tr.no-results:hover { .item-main { min-width: 0; + display: grid; + gap: var(--space-2); +} + +.item-heading { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); } .file-name { @@ -124,7 +157,7 @@ tbody tr.no-results:hover { } .item-meta { - margin: var(--space-1) 0 0; + margin: 0; font-size: 0.84rem; color: var(--color-text-muted); display: flex; @@ -133,7 +166,33 @@ tbody tr.no-results:hover { flex-wrap: wrap; } -.item button { +.item-meta__color { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.item-tech, +.item-total { + margin: 0; + font-size: 0.84rem; +} + +.item-tech { + color: var(--color-text-muted); +} + +.item-total { + font-weight: 600; + color: var(--color-text); +} + +.item-actions { + display: flex; + align-items: flex-start; +} + +.item-actions button { justify-self: start; } @@ -150,6 +209,32 @@ tbody tr.no-results:hover { margin: 0; } +.order-type-badge, +.item-kind-badge { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 0.2rem 0.65rem; + background: var(--color-neutral-100); + color: var(--color-text-muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + 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; +} + .modal-backdrop { position: fixed; inset: 0; @@ -239,6 +324,11 @@ h4 { gap: var(--space-4); } + .section-header { + flex-direction: column; + align-items: stretch; + } + .list-toolbar { grid-template-columns: 1fr; } @@ -247,6 +337,10 @@ h4 { align-items: flex-start; } + .item-actions { + width: 100%; + } + .actions-block { flex-direction: column; align-items: stretch; diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.ts b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts index 836425e..466dcb5 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.ts +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts @@ -26,6 +26,7 @@ export class AdminDashboardComponent implements OnInit { orderSearchTerm = ''; paymentStatusFilter = 'ALL'; orderStatusFilter = 'ALL'; + orderTypeFilter = 'ALL'; showPrintDetails = false; loading = false; detailLoading = false; @@ -62,6 +63,7 @@ export class AdminDashboardComponent implements OnInit { 'COMPLETED', 'CANCELLED', ]; + readonly orderTypeFilterOptions = ['ALL', 'SHOP', 'CALCULATOR', 'MIXED']; ngOnInit(): void { this.loadOrders(); @@ -117,6 +119,11 @@ export class AdminDashboardComponent implements OnInit { this.applyListFiltersAndSelection(); } + onOrderTypeFilterChange(value: string): void { + this.orderTypeFilter = value || 'ALL'; + this.applyListFiltersAndSelection(); + } + openDetails(orderId: string): void { this.detailLoading = true; this.adminOrdersService.getOrder(orderId).subscribe({ @@ -124,6 +131,8 @@ export class AdminDashboardComponent implements OnInit { this.selectedOrder = order; this.selectedStatus = order.status; this.selectedPaymentMethod = order.paymentMethod || 'OTHER'; + this.showPrintDetails = + this.showPrintDetails && this.hasPrintItems(order); this.detailLoading = false; }, error: () => { @@ -247,6 +256,9 @@ export class AdminDashboardComponent implements OnInit { } openPrintDetails(): void { + if (!this.selectedOrder || !this.hasPrintItems(this.selectedOrder)) { + return; + } this.showPrintDetails = true; } @@ -267,6 +279,34 @@ export class AdminDashboardComponent implements OnInit { return 'Bozza'; } + isShopItem(item: AdminOrderItem): boolean { + return String(item?.itemType ?? '').toUpperCase() === 'SHOP_PRODUCT'; + } + + itemDisplayName(item: AdminOrderItem): string { + const displayName = (item.displayName || '').trim(); + if (displayName) { + return displayName; + } + + const shopName = (item.shopProductName || '').trim(); + if (shopName) { + return shopName; + } + + return (item.originalFilename || '').trim() || '-'; + } + + itemVariantLabel(item: AdminOrderItem): string | null { + const variantLabel = (item.shopVariantLabel || '').trim(); + if (variantLabel) { + return variantLabel; + } + + const colorName = (item.shopVariantColorName || '').trim(); + return colorName || null; + } + isHexColor(value?: string): boolean { return ( typeof value === 'string' && @@ -291,12 +331,22 @@ export class AdminDashboardComponent implements OnInit { } getItemColorLabel(item: AdminOrderItem): string { + const shopColorName = (item.shopVariantColorName || '').trim(); + if (shopColorName) { + return shopColorName; + } + const colorName = (item.filamentColorName || '').trim(); const colorCode = (item.colorCode || '').trim(); return colorName || colorCode || '-'; } getItemColorHex(item: AdminOrderItem): string | null { + const shopColorHex = (item.shopVariantColorHex || '').trim(); + if (this.isHexColor(shopColorHex)) { + return shopColorHex; + } + const variantHex = (item.filamentColorHex || '').trim(); if (this.isHexColor(variantHex)) { return variantHex; @@ -336,6 +386,54 @@ export class AdminDashboardComponent implements OnInit { return 'Supporti -'; } + showItemMaterial(item: AdminOrderItem): boolean { + return !this.isShopItem(item); + } + + showItemPrintDetails(item: AdminOrderItem): boolean { + return !this.isShopItem(item); + } + + hasShopItems(order: AdminOrder | null): boolean { + return (order?.items || []).some((item) => this.isShopItem(item)); + } + + hasPrintItems(order: AdminOrder | null): boolean { + return (order?.items || []).some((item) => !this.isShopItem(item)); + } + + printItems(order: AdminOrder | null): AdminOrderItem[] { + return (order?.items || []).filter((item) => !this.isShopItem(item)); + } + + orderKind(order: AdminOrder | null): 'SHOP' | 'CALCULATOR' | 'MIXED' { + const hasShop = this.hasShopItems(order); + const hasPrint = this.hasPrintItems(order); + + if (hasShop && hasPrint) { + return 'MIXED'; + } + if (hasShop) { + return 'SHOP'; + } + return 'CALCULATOR'; + } + + orderKindLabel(order: AdminOrder | null): string { + switch (this.orderKind(order)) { + case 'SHOP': + return 'Shop'; + case 'MIXED': + return 'Misto'; + default: + return 'Calcolatore'; + } + } + + downloadItemLabel(item: AdminOrderItem): string { + return this.isShopItem(item) ? 'Scarica modello' : 'Scarica file'; + } + isSelected(orderId: string): boolean { return this.selectedOrder?.id === orderId; } @@ -349,6 +447,8 @@ export class AdminDashboardComponent implements OnInit { this.selectedStatus = updatedOrder.status; this.selectedPaymentMethod = updatedOrder.paymentMethod || this.selectedPaymentMethod; + this.showPrintDetails = + this.showPrintDetails && this.hasPrintItems(updatedOrder); } private applyListFiltersAndSelection(): void { @@ -384,8 +484,16 @@ export class AdminDashboardComponent implements OnInit { const matchesOrderStatus = this.orderStatusFilter === 'ALL' || orderStatus === this.orderStatusFilter; + const matchesOrderType = + this.orderTypeFilter === 'ALL' || + this.orderKind(order) === this.orderTypeFilter; - return matchesSearch && matchesPayment && matchesOrderStatus; + return ( + matchesSearch && + matchesPayment && + matchesOrderStatus && + matchesOrderType + ); }); } diff --git a/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss b/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss index 38275b1..7dbeb18 100644 --- a/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss +++ b/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss @@ -27,18 +27,18 @@ .content { display: grid; - gap: var(--space-4); + gap: var(--space-3); } .panel { border: 1px solid var(--color-border); border-radius: var(--radius-lg); - padding: var(--space-4); + padding: var(--space-3); background: var(--color-bg-card); } .panel > h3 { - margin: 0 0 var(--space-3); + margin: 0 0 var(--space-2); } .panel-header { @@ -46,7 +46,7 @@ justify-content: space-between; align-items: center; gap: var(--space-2); - margin-bottom: var(--space-3); + margin-bottom: var(--space-2); } .panel-header h3 { @@ -56,25 +56,26 @@ .create-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-3); + gap: var(--space-2); + align-items: start; } .subpanel { border: 1px solid var(--color-border); border-radius: var(--radius-md); - padding: var(--space-3); + padding: var(--space-2); background: var(--color-neutral-100); } .subpanel h4 { - margin: 0 0 var(--space-3); + margin: 0 0 var(--space-2); } .form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-2) var(--space-3); - margin-bottom: var(--space-3); + gap: var(--space-2); + margin-bottom: var(--space-2); } .form-field { @@ -87,7 +88,7 @@ } .form-field > span { - font-size: 0.8rem; + font-size: 0.9rem; color: var(--color-text-muted); font-weight: 600; } @@ -97,9 +98,10 @@ select { width: 100%; border: 1px solid var(--color-border); border-radius: var(--radius-md); - padding: var(--space-2) var(--space-3); + padding: var(--space-2); background: var(--color-bg-card); font: inherit; + font-size: 1rem; color: var(--color-text); } @@ -111,8 +113,8 @@ select:disabled { .toggle-group { display: flex; flex-wrap: wrap; - gap: var(--space-2); - margin-bottom: var(--space-3); + gap: 0.55rem; + margin-bottom: var(--space-2); } .toggle { @@ -121,7 +123,7 @@ select:disabled { gap: 0.4rem; border: 1px solid var(--color-border); border-radius: 999px; - padding: 0.35rem 0.65rem; + padding: 0.28rem 0.55rem; background: var(--color-bg-card); } @@ -132,14 +134,14 @@ select:disabled { } .toggle span { - font-size: 0.88rem; + font-size: 0.84rem; font-weight: 600; } .material-grid, .variant-list { display: grid; - gap: var(--space-3); + gap: var(--space-2); } .material-card, @@ -147,7 +149,7 @@ select:disabled { border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-neutral-100); - padding: var(--space-3); + padding: var(--space-2); } .material-grid { @@ -162,7 +164,7 @@ select:disabled { display: flex; align-items: flex-start; gap: var(--space-2); - margin-bottom: var(--space-3); + margin-bottom: var(--space-2); } .variant-header strong { @@ -179,9 +181,9 @@ select:disabled { .variant-collapsed-summary { display: flex; flex-wrap: wrap; - gap: var(--space-3); + gap: var(--space-2); color: var(--color-text-muted); - font-size: 0.92rem; + font-size: 0.88rem; } .color-summary { @@ -222,8 +224,8 @@ select:disabled { } .variant-meta { - margin: 0 0 var(--space-3); - font-size: 0.9rem; + margin: 0 0 var(--space-2); + font-size: 0.88rem; color: var(--color-text-muted); } @@ -233,7 +235,10 @@ button { background: var(--color-brand); color: var(--color-neutral-900); padding: var(--space-2) var(--space-4); + font: inherit; + font-size: 1rem; font-weight: 600; + line-height: 1.2; cursor: pointer; transition: background-color 0.2s ease; } @@ -326,10 +331,10 @@ button:disabled { border: 1px solid var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); - padding: var(--space-4); + padding: var(--space-3); z-index: 1101; display: grid; - gap: var(--space-3); + gap: var(--space-2); } .confirm-dialog h4 { diff --git a/frontend/src/app/features/admin/pages/admin-home-media.component.html b/frontend/src/app/features/admin/pages/admin-home-media.component.html index 54e503d..50474eb 100644 --- a/frontend/src/app/features/admin/pages/admin-home-media.component.html +++ b/frontend/src/app/features/admin/pages/admin-home-media.component.html @@ -1,8 +1,8 @@ -
-
-
+
+
+

Back-office media

-

Media home

+

Media home

diff --git a/frontend/src/app/features/admin/pages/admin-home-media.component.scss b/frontend/src/app/features/admin/pages/admin-home-media.component.scss index b667f8e..7f70af2 100644 --- a/frontend/src/app/features/admin/pages/admin-home-media.component.scss +++ b/frontend/src/app/features/admin/pages/admin-home-media.component.scss @@ -1,3 +1,32 @@ +.section-card { + display: flex; + flex-direction: column; + gap: var(--space-5); +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); +} + +.section-header h2 { + margin: 0; +} +.media-panel-header { + margin-bottom: var(--space-3); +} + +.group-header { + margin-bottom: var(--space-3); +} + +.header-copy { + display: grid; + gap: var(--space-1); +} + .form-field--wide { grid-column: 1 / -1; } @@ -100,6 +129,11 @@ } @media (max-width: 720px) { + .section-header { + flex-direction: column; + align-items: stretch; + } + .media-panel-meta { justify-content: flex-start; } diff --git a/frontend/src/app/features/admin/pages/admin-login.component.scss b/frontend/src/app/features/admin/pages/admin-login.component.scss index c5bc32c..fbf3563 100644 --- a/frontend/src/app/features/admin/pages/admin-login.component.scss +++ b/frontend/src/app/features/admin/pages/admin-login.component.scss @@ -33,23 +33,36 @@ form { } label { + font-size: 0.9rem; font-weight: 600; + color: var(--color-text-muted); } input { border: 1px solid var(--color-border); - border-radius: var(--radius-md); - padding: var(--space-3); + border-radius: var(--radius-sm); + padding: var(--space-2); + background: var(--color-bg-card); + color: var(--color-text); + font: inherit; font-size: 1rem; } +input:focus { + outline: none; + border-color: var(--color-brand); + box-shadow: var(--focus-ring); +} + button { border: 0; border-radius: var(--radius-md); background: var(--color-brand); color: var(--color-neutral-900); - padding: var(--space-3) var(--space-4); + padding: var(--space-2) var(--space-4); + font: inherit; font-weight: 600; + line-height: 1.2; cursor: pointer; transition: background-color 0.2s ease; } diff --git a/frontend/src/app/features/admin/pages/admin-sessions.component.html b/frontend/src/app/features/admin/pages/admin-sessions.component.html index 33b6579..db0bb05 100644 --- a/frontend/src/app/features/admin/pages/admin-sessions.component.html +++ b/frontend/src/app/features/admin/pages/admin-sessions.component.html @@ -1,10 +1,8 @@ -
-
-
-

Sessioni quote

-

- Sessioni create dal configuratore con stato e conversione ordine. -

+
+
+
+

Sessioni quote

+

Sessioni create dal configuratore con stato e conversione ordine.

diff --git a/frontend/src/app/features/admin/pages/admin-shell.component.scss b/frontend/src/app/features/admin/pages/admin-shell.component.scss index 8b5fda9..651b149 100644 --- a/frontend/src/app/features/admin/pages/admin-shell.component.scss +++ b/frontend/src/app/features/admin/pages/admin-shell.component.scss @@ -97,6 +97,168 @@ min-width: 0; } +:host ::ng-deep .content .ui-form-control, +:host + ::ng-deep + .content + input:not([type="checkbox"]):not([type="radio"]):not([type="file"]), +:host ::ng-deep .content select, +:host ::ng-deep .content textarea { + width: 100%; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--space-2); + background: var(--color-bg-card); + color: var(--color-text); + font: inherit; + font-size: 1rem; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + background-color 0.2s ease; +} + +:host ::ng-deep .content .ui-form-control:focus, +:host + ::ng-deep + .content + input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):focus, +:host ::ng-deep .content select:focus, +:host ::ng-deep .content textarea:focus { + outline: none; + border-color: var(--color-brand); + box-shadow: var(--focus-ring); +} + +:host ::ng-deep .content .ui-form-control:disabled, +:host + ::ng-deep + .content + input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):disabled, +:host ::ng-deep .content select:disabled, +:host ::ng-deep .content textarea:disabled { + background: var(--color-surface-muted); + cursor: not-allowed; +} + +:host ::ng-deep .content select, +:host ::ng-deep .content select.ui-form-control { + appearance: auto; + background-image: none; + background-position: initial; + background-size: initial; + padding-right: var(--space-2); +} + +:host ::ng-deep .content .ui-form-caption, +:host ::ng-deep .content .form-field > span, +:host ::ng-deep .content .toolbar-field > span, +:host ::ng-deep .content .form-grid label > span, +:host ::ng-deep .content .status-editor label, +:host ::ng-deep .content .status-editor-field label { + font-size: 0.9rem; + font-weight: 600; + color: var(--color-text-muted); +} + +:host ::ng-deep .content button, +:host ::ng-deep .content .ui-button { + border: 0; + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-4); + background: var(--color-brand); + color: var(--color-neutral-900); + font: inherit; + font-size: 1rem; + font-weight: 600; + line-height: 1.2; + cursor: pointer; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + color 0.2s ease, + opacity 0.2s ease; +} + +:host ::ng-deep .content button:hover:not(:disabled), +:host ::ng-deep .content .ui-button:hover:not(:disabled) { + background: var(--color-brand-hover); +} + +:host ::ng-deep .content button:disabled, +:host ::ng-deep .content .ui-button:disabled { + opacity: 0.65; + cursor: default; +} + +:host ::ng-deep .content .ui-button--ghost, +:host ::ng-deep .content .ghost, +:host ::ng-deep .content .btn-secondary, +:host ::ng-deep .content .panel-toggle { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + color: var(--color-text); +} + +:host ::ng-deep .content .ui-button--ghost:hover:not(:disabled), +:host ::ng-deep .content .ghost:hover:not(:disabled), +:host ::ng-deep .content .btn-secondary:hover:not(:disabled), +:host ::ng-deep .content .panel-toggle:hover:not(:disabled) { + background: var(--color-surface-muted); +} + +:host ::ng-deep .content .ui-button--danger, +:host ::ng-deep .content .btn-delete { + background: var(--color-danger-500); + color: #ffffff; +} + +:host ::ng-deep .content .ui-button--danger:hover:not(:disabled), +:host ::ng-deep .content .btn-delete:hover:not(:disabled) { + background: #dc2626; +} + +:host ::ng-deep .content .ui-button--ghost-danger { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + color: var(--color-text); +} + +:host ::ng-deep .content .ui-button--ghost-danger:hover:not(:disabled) { + background: #fff0f0; + border-color: #d9534f; +} + +:host ::ng-deep .content .ui-checkbox { + gap: 0.85rem; + font-size: 1rem; +} + +:host ::ng-deep .content .ui-checkbox__mark { + width: 1.25rem; + height: 1.25rem; +} + +:host ::ng-deep .content .ui-file-picker__button, +:host ::ng-deep .content .ui-file-picker__name { + font-size: 1rem; +} + +:host ::ng-deep .content .ui-language-toolbar { + padding: var(--space-2); +} + +:host ::ng-deep .content .ui-language-toolbar__copy span, +:host ::ng-deep .content .ui-language-toolbar__copy p, +:host ::ng-deep .content .ui-language-toolbar__button { + font-size: 0.9rem; +} + +:host ::ng-deep .content .ui-language-toolbar__button { + min-width: 2.8rem; + padding: 0.4rem 0.6rem; +} + @media (max-width: 1240px) { .admin-shell { grid-template-columns: 220px minmax(0, 1fr); diff --git a/frontend/src/app/features/admin/pages/admin-shop.component.html b/frontend/src/app/features/admin/pages/admin-shop.component.html new file mode 100644 index 0000000..59a19d3 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-shop.component.html @@ -0,0 +1,1231 @@ +
+
+
+

Back-office shop

+

Catalogo prodotti

+

+ Filtra i prodotti a sinistra e modifica o crea nuove schede a destra. +

+
+ +
+
+ {{ products.length }} + prodotti +
+
+ {{ categories.length }} + categorie +
+ + +
+
+ +

+ {{ errorMessage }} +

+

+ {{ successMessage }} +

+ +
+
+
+
+
+

Lista prodotti

+

{{ filteredProducts.length }} risultati con i filtri attivi.

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

Categorie shop

+

Gestione tassonomia e filtro del catalogo.

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

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

+

+ Aggiorna struttura, SEO e visibilità. +

+

+ Crea una nuova categoria del catalogo. +

+
+
+ +
+ + + + + + + + + + + + + + + + + +
+ +
+ + + +
+ +
+ + + +
+ +
+
+ +
+
OrdineTipo Email Pagamento Stato ordine{{ order.orderNumber }} + + {{ orderKindLabel(order) }} + + {{ order.customerEmail }} {{ order.paymentStatus || "PENDING" }} {{ order.status }}
+ Nessun ordine trovato per i filtri selezionati.
+ + + + + + + + + + + + + + + + + + + + + +
ProdottoCategoriaVariantiPrezzo CHFStato
+
+ {{ product.name }} + {{ product.slug }} +
+
{{ product.categoryName }} + {{ product.activeVariantCount }} / {{ product.variantCount }} + + {{ + product.priceFromChf | currency: "CHF" : "symbol" : "1.2-2" + }} + + - + {{ + product.priceToChf | currency: "CHF" : "symbol" : "1.2-2" + }} + + + + {{ + product.isActive + ? product.isFeatured + ? "Featured" + : "Attivo" + : "Inattivo" + }} + +
+ Nessun prodotto trovato con i filtri correnti. +
+
+ + + + +
+
+
+

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

+

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

+

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

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

+ Caricamento dettaglio... +

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

Dati base

+

Slug, categoria, ordinamento e visibilità del prodotto.

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

Contenuti localizzati

+

+ Nome obbligatorio in tutte le lingue. Descrizioni opzionali. +

+
+
+ +
+
+ Lingua editor +

IT / EN / DE / FR

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

SEO e social

+

Metadati globali per risultato organico e preview.

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

Varianti

+

Colori, materiale interno, SKU e prezzi.

+
+ +
+ +
+
+
+
+

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

+

Ordine {{ variant.sortOrder }}

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

Immagini e modello 3D

+

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

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

Nuova immagine prodotto

+

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

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

Titolo e alt text obbligatori

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

Immagini attive

+

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

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

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

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

Modello 3D

+

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

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

{{ "CALC.TITLE" | translate }}

-

{{ "CALC.SUBTITLE" | translate }}

+
+

{{ "CALC.TITLE" | translate }}

+

{{ "CALC.SUBTITLE" | translate }}

@if (error()) { {{ errorKey() | translate }} @@ -8,7 +8,7 @@
@if (step() === "success") { -
+
- {{ item.originalFilename }} + {{ itemDisplayName(item) }}
{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }} - + {{ "CHECKOUT.MATERIAL" | translate }}: {{ itemMaterial(item) }} + + {{ "SHOP.VARIANT" | translate }}: + {{ variantLabel }} + {{ itemColorLabel(item) }}
-
+
{{ item.printTimeSeconds / 3600 | number: "1.1-1" }}h | {{ item.materialGrams | number: "1.0-0" }}g
-
+
diff --git a/frontend/src/app/features/checkout/checkout.component.spec.ts b/frontend/src/app/features/checkout/checkout.component.spec.ts new file mode 100644 index 0000000..c85f46e --- /dev/null +++ b/frontend/src/app/features/checkout/checkout.component.spec.ts @@ -0,0 +1,64 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { of } from 'rxjs'; +import { FormBuilder } from '@angular/forms'; +import { CheckoutComponent } from './checkout.component'; +import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service'; +import { LanguageService } from '../../core/services/language.service'; + +describe('CheckoutComponent', () => { + let component: CheckoutComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + FormBuilder, + { + provide: QuoteEstimatorService, + useValue: jasmine.createSpyObj( + 'QuoteEstimatorService', + ['getQuoteSession'], + ), + }, + { + provide: Router, + useValue: jasmine.createSpyObj('Router', ['navigate']), + }, + { + provide: ActivatedRoute, + useValue: { + queryParams: of({}), + }, + }, + { + provide: LanguageService, + useValue: { + selectedLang: () => 'it', + }, + }, + ], + }); + + component = TestBed.runInInjectionContext(() => new CheckoutComponent()); + }); + + it('prefers shop variant metadata for labels and swatches', () => { + const item = { + lineItemType: 'SHOP_PRODUCT', + displayName: 'Desk Cable Clip', + shopProductName: 'Desk Cable Clip', + shopVariantLabel: 'Coral Red', + shopVariantColorName: 'Coral Red', + shopVariantColorHex: '#ff6b6b', + colorCode: 'Rosso', + }; + + expect(component.isShopItem(item)).toBeTrue(); + expect(component.itemDisplayName(item)).toBe('Desk Cable Clip'); + expect(component.itemVariantLabel(item)).toBe('Coral Red'); + expect(component.itemColorLabel(item)).toBe('Coral Red'); + expect(component.itemColorSwatch(item)).toBe('#ff6b6b'); + expect(component.showItemMaterial(item)).toBeFalse(); + expect(component.showItemPrintMetrics(item)).toBeFalse(); + }); +}); diff --git a/frontend/src/app/features/checkout/checkout.component.ts b/frontend/src/app/features/checkout/checkout.component.ts index 039a8e2..c00abc7 100644 --- a/frontend/src/app/features/checkout/checkout.component.ts +++ b/frontend/src/app/features/checkout/checkout.component.ts @@ -172,7 +172,7 @@ export class CheckoutComponent implements OnInit { this.quoteService.getQuoteSession(this.sessionId).subscribe({ next: (session) => { this.quoteSession.set(session); - if (this.isCadSessionData(session)) { + if (Array.isArray(session?.items) && session.items.length > 0) { this.loadStlPreviews(session); } else { this.resetPreviewState(); @@ -231,6 +231,39 @@ export class CheckoutComponent implements OnInit { ); } + isShopItem(item: any): boolean { + return String(item?.lineItemType ?? '').toUpperCase() === 'SHOP_PRODUCT'; + } + + itemDisplayName(item: any): string { + const displayName = String(item?.displayName ?? '').trim(); + if (displayName) { + return displayName; + } + const shopName = String(item?.shopProductName ?? '').trim(); + if (shopName) { + return shopName; + } + return String(item?.originalFilename ?? '-'); + } + + itemVariantLabel(item: any): string | null { + const variantLabel = String(item?.shopVariantLabel ?? '').trim(); + if (variantLabel) { + return variantLabel; + } + const colorName = String(item?.shopVariantColorName ?? '').trim(); + return colorName || null; + } + + showItemMaterial(item: any): boolean { + return !this.isShopItem(item); + } + + showItemPrintMetrics(item: any): boolean { + return !this.isShopItem(item); + } + isStlItem(item: any): boolean { const name = String(item?.originalFilename ?? '').toLowerCase(); return name.endsWith('.stl'); @@ -249,11 +282,20 @@ export class CheckoutComponent implements OnInit { } itemColorLabel(item: any): string { + const shopColor = String(item?.shopVariantColorName ?? '').trim(); + if (shopColor) { + return shopColor; + } const raw = String(item?.colorCode ?? '').trim(); return raw || '-'; } itemColorSwatch(item: any): string { + const shopHex = String(item?.shopVariantColorHex ?? '').trim(); + if (this.isHexColor(shopHex)) { + return shopHex; + } + const variantId = Number(item?.filamentVariantId); if (Number.isFinite(variantId) && this.variantHexById.has(variantId)) { return this.variantHexById.get(variantId)!; @@ -303,7 +345,7 @@ export class CheckoutComponent implements OnInit { return; } this.selectedPreviewFile.set(file); - this.selectedPreviewName.set(String(item?.originalFilename ?? file.name)); + this.selectedPreviewName.set(this.itemDisplayName(item)); this.selectedPreviewColor.set(this.previewColor(item)); this.previewModalOpen.set(true); } @@ -351,11 +393,7 @@ export class CheckoutComponent implements OnInit { } private loadStlPreviews(session: any): void { - if ( - !this.sessionId || - !this.isCadSessionData(session) || - !Array.isArray(session?.items) - ) { + if (!this.sessionId || !Array.isArray(session?.items)) { return; } diff --git a/frontend/src/app/features/contact/contact-page.component.html b/frontend/src/app/features/contact/contact-page.component.html index 0586034..18321ed 100644 --- a/frontend/src/app/features/contact/contact-page.component.html +++ b/frontend/src/app/features/contact/contact-page.component.html @@ -1,9 +1,9 @@ -
-
-

{{ "CONTACT.TITLE" | translate }}

-

{{ "CONTACT.HERO_SUBTITLE" | translate }}

-
-
+
+

{{ "CONTACT.TITLE" | translate }}

+

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

+
diff --git a/frontend/src/app/features/contact/contact-page.component.scss b/frontend/src/app/features/contact/contact-page.component.scss index f495fe5..4d2b687 100644 --- a/frontend/src/app/features/contact/contact-page.component.scss +++ b/frontend/src/app/features/contact/contact-page.component.scss @@ -1,13 +1,7 @@ .contact-hero { - padding: 3rem 0 2rem; background: var(--color-bg); - text-align: center; -} -.subtitle { - color: var(--color-text-muted); - max-width: 640px; - margin: var(--space-3) auto 0; } + .content { padding: 2rem 0 5rem; max-width: 800px; diff --git a/frontend/src/app/features/order/order.component.html b/frontend/src/app/features/order/order.component.html index 48842c3..47a0a68 100644 --- a/frontend/src/app/features/order/order.component.html +++ b/frontend/src/app/features/order/order.component.html @@ -58,146 +58,222 @@
- - -
-

{{ "PAYMENT.STATUS_REPORTED_TITLE" | translate }}

-

{{ "PAYMENT.STATUS_REPORTED_DESC" | translate }}

-
-
+ +
+

{{ "PAYMENT.STATUS_REPORTED_TITLE" | translate }}

+

{{ "PAYMENT.STATUS_REPORTED_DESC" | translate }}

+
+
-
-
- -
-

{{ "PAYMENT.METHOD" | translate }}

-
+
+
+ +
+

{{ "PAYMENT.METHOD" | translate }}

+
-
-
-
- {{ - "PAYMENT.METHOD_TWINT" | translate - }} -
-
- {{ - "PAYMENT.METHOD_BANK" | translate - }} -
-
-
- -
-
-

{{ "PAYMENT.TWINT_TITLE" | translate }}

-
-
- -

{{ "PAYMENT.TWINT_DESC" | translate }}

-

- {{ "PAYMENT.BILLING_INFO_HINT" | translate }} -

-
- -
-

- {{ "PAYMENT.TOTAL" | translate }}: - {{ o.totalChf | currency: "CHF" }} -

-
-
- -
-
-

{{ "PAYMENT.BANK_TITLE" | translate }}

-
-
-

- {{ "PAYMENT.BILLING_INFO_HINT" | translate }} -

-
-
- - {{ "PAYMENT.DOWNLOAD_QR" | translate }} - -
-
-
- -
- +
+
- {{ - o.paymentStatus === "REPORTED" - ? ("PAYMENT.IN_VERIFICATION" | translate) - : ("PAYMENT.CONFIRM" | translate) - }} - + {{ + "PAYMENT.METHOD_TWINT" | translate + }} +
+
+ {{ + "PAYMENT.METHOD_BANK" | translate + }} +
- -
+
-
- -
-

- {{ "PAYMENT.SUMMARY_TITLE" | translate }} -

-

- #{{ getDisplayOrderNumber(o) }} +

+
+

{{ "PAYMENT.TWINT_TITLE" | translate }}

+
+
+ +

{{ "PAYMENT.TWINT_DESC" | translate }}

+

+ {{ "PAYMENT.BILLING_INFO_HINT" | translate }} +

+
+ +
+

+ {{ "PAYMENT.TOTAL" | translate }}: + {{ o.totalChf | currency: "CHF" }}

+
- - -
+
+
+

{{ "PAYMENT.BANK_TITLE" | translate }}

+
+
+

+ {{ "PAYMENT.BILLING_INFO_HINT" | translate }} +

+
+
+ + {{ "PAYMENT.DOWNLOAD_QR" | translate }} + +
+
+
+ +
+ + {{ + o.paymentStatus === "REPORTED" + ? ("PAYMENT.IN_VERIFICATION" | translate) + : ("PAYMENT.CONFIRM" | translate) + }} + +
+
+ + +
+

{{ "ORDER.ITEMS_TITLE" | translate }}

+

+ {{ orderKindLabel(o) }} +

+
+ +
+
+
+
+ {{ + itemDisplayName(item) + }} + + {{ + isShopItem(item) + ? ("ORDER.TYPE_SHOP" | translate) + : ("ORDER.TYPE_CALCULATOR" | translate) + }} + +
+ +
+ {{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }} + + {{ "CHECKOUT.MATERIAL" | translate }}: + {{ + item.materialCode || ("ORDER.NOT_AVAILABLE" | translate) + }} + + + {{ "SHOP.VARIANT" | translate }}: {{ variantLabel }} + + + + {{ itemColorLabel(item) }} + +
+ +
+ {{ item.printTimeSeconds || 0 | number: "1.0-0" }}s | + {{ item.materialGrams || 0 | number: "1.0-0" }}g +
+
+ + + {{ item.lineTotalChf || 0 | currency: "CHF" }} + +
+
+
- + +
+ +
+

+ {{ "PAYMENT.SUMMARY_TITLE" | translate }} +

+

+ #{{ getDisplayOrderNumber(o) }} +

+
+ +
+
+ {{ + "ORDER.ORDER_TYPE_LABEL" | translate + }} + {{ orderKindLabel(o) }} +
+
+ {{ + "ORDER.ITEM_COUNT" | translate + }} + {{ (o.items || []).length }} +
+
+ + +
+
+
diff --git a/frontend/src/app/features/order/order.component.scss b/frontend/src/app/features/order/order.component.scss index 1646bec..14737d1 100644 --- a/frontend/src/app/features/order/order.component.scss +++ b/frontend/src/app/features/order/order.component.scss @@ -115,6 +115,107 @@ top: var(--space-6); } +.order-items { + display: grid; + gap: var(--space-3); +} + +.order-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); +} + +.order-item-copy { + min-width: 0; + display: grid; + gap: var(--space-2); +} + +.order-item-name-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); +} + +.order-item-name { + font-size: 1rem; + line-height: 1.35; +} + +.order-item-kind { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 0.2rem 0.65rem; + background: var(--color-neutral-100); + color: var(--color-text-muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.order-item-kind-shop { + background: color-mix(in srgb, var(--color-brand) 12%, white); + color: var(--color-brand); +} + +.order-item-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.9rem; + color: var(--color-text-muted); + font-size: 0.92rem; +} + +.item-color-chip { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.color-swatch { + width: 12px; + height: 12px; + border-radius: 999px; + border: 1px solid var(--color-border); + flex: 0 0 auto; +} + +.order-item-tech { + font-size: 0.86rem; + color: var(--color-text-muted); +} + +.order-item-total { + white-space: nowrap; + font-size: 1rem; +} + +.order-summary-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); + margin-bottom: var(--space-4); +} + +.summary-label { + display: block; + margin-bottom: 0.2rem; + font-size: 0.76rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.02em; + color: var(--color-text-muted); +} + .fade-in { animation: fadeIn 0.4s ease-out; } @@ -236,4 +337,16 @@ } } } + + .order-item { + flex-direction: column; + } + + .order-item-total { + width: 100%; + } + + .order-summary-meta { + grid-template-columns: 1fr; + } } diff --git a/frontend/src/app/features/order/order.component.ts b/frontend/src/app/features/order/order.component.ts index 18d8b86..16837e6 100644 --- a/frontend/src/app/features/order/order.component.ts +++ b/frontend/src/app/features/order/order.component.ts @@ -11,6 +11,52 @@ import { PriceBreakdownRow, } from '../../shared/components/price-breakdown/price-breakdown.component'; +interface PublicOrderItem { + id: string; + itemType?: string; + originalFilename?: string; + displayName?: string; + materialCode?: string; + colorCode?: string; + filamentVariantId?: number; + shopProductId?: string; + shopProductVariantId?: string; + shopProductSlug?: string; + shopProductName?: string; + shopVariantLabel?: string; + shopVariantColorName?: string; + shopVariantColorHex?: string; + filamentVariantDisplayName?: string; + filamentColorName?: string; + filamentColorHex?: string; + quality?: string; + nozzleDiameterMm?: number; + layerHeightMm?: number; + infillPercent?: number; + infillPattern?: string; + supportsEnabled?: boolean; + quantity: number; + printTimeSeconds?: number; + materialGrams?: number; + unitPriceChf?: number; + lineTotalChf?: number; +} + +interface PublicOrder { + id: string; + orderNumber?: string; + status?: string; + paymentStatus?: string; + paymentMethod?: string; + subtotalChf?: number; + shippingCostChf?: number; + setupCostChf?: number; + totalChf?: number; + cadHours?: number; + cadTotalChf?: number; + items?: PublicOrderItem[]; +} + @Component({ selector: 'app-order', standalone: true, @@ -32,7 +78,7 @@ export class OrderComponent implements OnInit { orderId: string | null = null; selectedPaymentMethod: 'twint' | 'bill' | null = 'twint'; - order = signal(null); + order = signal(null); loading = signal(true); error = signal(null); twintOpenUrl = signal(null); @@ -201,4 +247,108 @@ export class OrderComponent implements OnInit { private extractOrderNumber(orderId: string): string { return orderId.split('-')[0]; } + + isShopItem(item: PublicOrderItem): boolean { + return String(item?.itemType ?? '').toUpperCase() === 'SHOP_PRODUCT'; + } + + itemDisplayName(item: PublicOrderItem): string { + const displayName = String(item?.displayName ?? '').trim(); + if (displayName) { + return displayName; + } + + const shopName = String(item?.shopProductName ?? '').trim(); + if (shopName) { + return shopName; + } + + return String( + item?.originalFilename ?? this.translate.instant('ORDER.NOT_AVAILABLE'), + ); + } + + itemVariantLabel(item: PublicOrderItem): string | null { + const variantLabel = String(item?.shopVariantLabel ?? '').trim(); + if (variantLabel) { + return variantLabel; + } + + const colorName = String(item?.shopVariantColorName ?? '').trim(); + return colorName || null; + } + + itemColorLabel(item: PublicOrderItem): string { + const shopColor = String(item?.shopVariantColorName ?? '').trim(); + if (shopColor) { + return shopColor; + } + + const filamentColor = String(item?.filamentColorName ?? '').trim(); + if (filamentColor) { + return filamentColor; + } + + const rawColor = String(item?.colorCode ?? '').trim(); + return rawColor || this.translate.instant('ORDER.NOT_AVAILABLE'); + } + + itemColorHex(item: PublicOrderItem): string | null { + const shopHex = String(item?.shopVariantColorHex ?? '').trim(); + if (this.isHexColor(shopHex)) { + return shopHex; + } + + const filamentHex = String(item?.filamentColorHex ?? '').trim(); + if (this.isHexColor(filamentHex)) { + return filamentHex; + } + + const rawColor = String(item?.colorCode ?? '').trim(); + if (this.isHexColor(rawColor)) { + return rawColor; + } + + return null; + } + + showItemMaterial(item: PublicOrderItem): boolean { + return !this.isShopItem(item); + } + + showItemPrintMetrics(item: PublicOrderItem): boolean { + return !this.isShopItem(item); + } + + orderKind(order: PublicOrder | null): 'SHOP' | 'CALCULATOR' | 'MIXED' { + const items = order?.items ?? []; + const hasShop = items.some((item) => this.isShopItem(item)); + const hasPrint = items.some((item) => !this.isShopItem(item)); + + if (hasShop && hasPrint) { + return 'MIXED'; + } + if (hasShop) { + return 'SHOP'; + } + return 'CALCULATOR'; + } + + orderKindLabel(order: PublicOrder | null): string { + switch (this.orderKind(order)) { + case 'SHOP': + return this.translate.instant('ORDER.TYPE_SHOP'); + case 'MIXED': + return this.translate.instant('ORDER.TYPE_MIXED'); + default: + return this.translate.instant('ORDER.TYPE_CALCULATOR'); + } + } + + private isHexColor(value?: string): boolean { + return ( + typeof value === 'string' && + /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value) + ); + } } 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 293d1d6..bf32fb5 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,17 +1,53 @@ -
-
+
+ + @if (imageUrl(); as imageUrl) { + + } @else { +
+ {{ product().category.name }} +
+ } + +
+ @if (cartQuantity() > 0) { + + {{ "SHOP.IN_CART_SHORT" | translate: { count: cartQuantity() } }} + + } +
+
+
- {{ product().category | translate }} +
+ {{ product().category.name }} + @if (product().model3d) { + {{ "SHOP.MODEL_3D" | translate }} + } +
+

- {{ - product().name | translate - }} + {{ product().name }}

+ +

+ {{ product().excerpt || ("SHOP.EXCERPT_FALLBACK" | 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 5819227..ddda67b 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 @@ -1,48 +1,186 @@ .product-card { - background: var(--color-bg-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); + display: grid; + height: 100%; + border: 1px solid rgba(16, 24, 32, 0.08); + border-radius: 1.1rem; overflow: hidden; - transition: box-shadow 0.2s; + background: #fff; + box-shadow: 0 10px 24px rgba(16, 24, 32, 0.04); + transition: + transform 0.2s ease, + box-shadow 0.2s ease, + border-color 0.2s ease; + &:hover { - box-shadow: var(--shadow-md); + transform: translateY(-2px); + box-shadow: 0 16px 30px rgba(16, 24, 32, 0.08); + border-color: rgba(16, 24, 32, 0.14); } } -.image-placeholder { - height: 200px; - background-color: var(--color-neutral-200); + +.media { + position: relative; + display: block; + min-height: 244px; + background: #f2eee5; } -.content { - padding: var(--space-4); + +.media img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; } -.category { - font-size: 0.75rem; - color: var(--color-text-muted); + +.image-fallback { + width: 100%; + height: 100%; + min-height: 244px; + display: flex; + align-items: flex-end; + padding: var(--space-5); + background: + radial-gradient( + circle at top right, + rgba(250, 207, 10, 0.24), + transparent 36% + ), + linear-gradient(160deg, #f7f4ed 0%, #ece7db 100%); +} + +.image-fallback span { + display: inline-flex; + align-items: center; + min-height: 2rem; + padding: 0.4rem 0.8rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(16, 24, 32, 0.08); + color: var(--color-neutral-900); + font-size: 0.78rem; + font-weight: 700; +} + +.card-badges { + position: absolute; + inset: var(--space-4) var(--space-4) auto auto; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.45rem; +} + +.badge { + display: inline-flex; + align-items: center; + min-height: 1.9rem; + padding: 0.3rem 0.7rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; text-transform: uppercase; - letter-spacing: 0.05em; } + +.badge-cart { + background: rgba(16, 24, 32, 0.82); + color: #fff; +} + +.content { + display: grid; + gap: var(--space-4); + padding: var(--space-5); +} + +.meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.55rem; +} + +.category, +.model-pill { + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.category { + color: var(--color-secondary-600); + 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 { - font-size: 1.125rem; - margin: var(--space-2) 0; - a { - color: var(--color-text); - text-decoration: none; - &:hover { - color: var(--color-brand); - } - } + margin: 0; + font-size: 1.2rem; + line-height: 1.16; } + +.name a { + color: var(--color-text); + text-decoration: none; +} + +.excerpt { + margin: 0; + color: var(--color-text-muted); + line-height: 1.55; +} + .footer { display: flex; + align-items: flex-end; justify-content: space-between; - align-items: center; - margin-top: var(--space-4); + gap: var(--space-4); } + +.pricing { + display: grid; + gap: 0.1rem; +} + .price { + font-size: 1.35rem; font-weight: 700; - color: var(--color-brand); + color: var(--color-neutral-900); } + +.price-note { + color: var(--color-text-muted); +} + .view-btn { - font-size: 0.875rem; - font-weight: 500; + display: inline-flex; + align-items: 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; + text-decoration: none; +} + +@media (max-width: 640px) { + .media, + .image-fallback { + min-height: 220px; + } + + .footer { + align-items: start; + flex-direction: column; + } } 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 79e7db8..c38d99c 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,8 @@ -import { Component, input } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { Component, computed, inject, input } from '@angular/core'; import { RouterLink } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; -import { Product } from '../../services/shop.service'; +import { ShopProductSummary, ShopService } from '../../services/shop.service'; @Component({ selector: 'app-product-card', @@ -12,5 +12,31 @@ import { Product } from '../../services/shop.service'; styleUrl: './product-card.component.scss', }) export class ProductCardComponent { - product = input.required(); + private readonly shopService = inject(ShopService); + + readonly product = input.required(); + readonly cartQuantity = input(0); + + readonly productLink = computed(() => [ + '/shop', + this.product().category.slug, + this.product().slug, + ]); + + readonly imageUrl = computed(() => { + const image = this.product().primaryImage; + return ( + this.shopService.resolveMediaUrl(image?.card) ?? + this.shopService.resolveMediaUrl(image?.hero) ?? + this.shopService.resolveMediaUrl(image?.thumb) + ); + }); + + priceLabel(): number { + return this.product().priceFromChf; + } + + hasPriceRange(): boolean { + return this.product().priceFromChf !== this.product().priceToChf; + } } diff --git a/frontend/src/app/features/shop/product-detail.component.html b/frontend/src/app/features/shop/product-detail.component.html index 604c453..6a05961 100644 --- a/frontend/src/app/features/shop/product-detail.component.html +++ b/frontend/src/app/features/shop/product-detail.component.html @@ -1,25 +1,220 @@ -
- ← {{ "SHOP.BACK" | translate }} +
+
+ + ← {{ "SHOP.BACK" | translate }} + - @if (product(); as p) { -
-
- -
- {{ p.category | translate }} -

{{ p.name | translate }}

-

{{ p.price | currency: "EUR" }}

- -

{{ p.description | translate }}

- -
- - {{ "SHOP.ADD_CART" | translate }} - -
+ @if (loading()) { +
+
+
-
- } @else { -

{{ "SHOP.NOT_FOUND" | translate }}

- } -
+ } @else { + @if (error()) { +
{{ error() | translate }}
+ } @else { + @if (product(); as p) { + + +
+
+
+ @if (imageUrl(selectedImage()); as imageUrl) { + + } @else { +
+ {{ p.category.name }} +
+ } +
+ + @if (galleryImages().length > 1) { +
+ @for (image of galleryImages(); track image.mediaAssetId) { + + } +
+ } + + @if (p.model3d) { + +
+
+

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

+

{{ "SHOP.MODEL_TITLE" | translate }}

+
+
+ + X + {{ p.model3d.boundingBoxXMm || 0 | number: "1.0-1" }} mm + + + Y + {{ p.model3d.boundingBoxYMm || 0 | number: "1.0-1" }} mm + + + Z + {{ p.model3d.boundingBoxZMm || 0 | number: "1.0-1" }} mm + +
+
+ + @if (modelLoading()) { +
+ {{ "SHOP.MODEL_LOADING" | translate }} +
+ } @else if (modelError()) { +
+ {{ "SHOP.MODEL_UNAVAILABLE" | translate }} +
+ } @else { + @if (modelFile(); as modelPreviewFile) { + + } + } +
+ } +
+ +
+
+
+ {{ p.category.name }} +
+

{{ p.name }}

+

+ {{ + p.excerpt || + p.description || + ("SHOP.EXCERPT_FALLBACK" | translate) + }} +

+
+ + +
+
+
+

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

+

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

+
+ @if (selectedVariantCartQuantity() > 0) { + + {{ + "SHOP.IN_CART_LONG" + | translate + : { count: selectedVariantCartQuantity() } + }} + + } +
+ +
+ @for (variant of p.variants; track variant.id) { + + } +
+ +
+ {{ "SHOP.QUANTITY" | translate }} +
+ + {{ quantity() }} + +
+
+ +
+ + {{ + (isAddingToCart() ? "SHOP.ADDING" : "SHOP.ADD_CART") + | translate + }} + + + @if (shopService.cartItemCount() > 0) { + + {{ "SHOP.GO_TO_CHECKOUT" | translate }} + + } +
+ + @if (addSuccess()) { +

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

+ } +
+
+ + @if (p.description) { +
+

{{ "SHOP.DESCRIPTION_TITLE" | translate }}

+

{{ p.description }}

+
+ } +
+
+ } + } + } +
+
diff --git a/frontend/src/app/features/shop/product-detail.component.scss b/frontend/src/app/features/shop/product-detail.component.scss index 5fc4e68..e5d66af 100644 --- a/frontend/src/app/features/shop/product-detail.component.scss +++ b/frontend/src/app/features/shop/product-detail.component.scss @@ -1,40 +1,356 @@ -.wrapper { - padding-top: var(--space-8); +.product-page { + padding: var(--space-8) 0 var(--space-12); + background: linear-gradient(180deg, #faf7ef 0%, var(--color-bg) 15rem); } -.back-link { - display: inline-block; - margin-bottom: var(--space-6); + +.wrapper { + display: grid; + gap: var(--space-6); +} + +.back-link, +.breadcrumbs { color: var(--color-text-muted); } +.breadcrumbs { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + font-size: 0.9rem; +} + .detail-grid { display: grid; gap: var(--space-8); - @media (min-width: 768px) { - grid-template-columns: 1fr 1fr; - } + grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr); } -.image-box { - background-color: var(--color-neutral-200); +.visual-column, +.info-column { + display: grid; + gap: var(--space-5); +} + +.hero-media { + min-height: 480px; + overflow: hidden; + border-radius: 1.25rem; + border: 1px solid rgba(16, 24, 32, 0.08); + background: #f2eee5; + box-shadow: 0 12px 28px rgba(16, 24, 32, 0.05); +} + +.hero-image { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.image-fallback { + width: 100%; + height: 100%; + min-height: 480px; + display: flex; + align-items: flex-end; + padding: var(--space-6); + background: + radial-gradient( + circle at top right, + rgba(250, 207, 10, 0.24), + transparent 34% + ), + linear-gradient(160deg, #f8f4ea 0%, #eee8db 100%); +} + +.image-fallback span { + display: inline-flex; + min-height: 2rem; + align-items: center; + padding: 0.4rem 0.8rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(16, 24, 32, 0.08); + font-weight: 700; +} + +.thumb-grid { + display: grid; + gap: var(--space-3); + grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); +} + +.thumb { + min-height: 92px; + overflow: hidden; + border-radius: 0.85rem; + border: 1px solid var(--color-border); + background: rgba(255, 255, 255, 0.78); + 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); +} + +.thumb img, +.thumb span { + width: 100%; + height: 100%; + display: grid; + place-items: center; + object-fit: cover; +} + +.viewer-card { + display: block; +} + +.viewer-head { + display: flex; + justify-content: space-between; + gap: var(--space-4); + margin-bottom: var(--space-4); +} + +.viewer-kicker, +.panel-kicker { + margin: 0 0 0.2rem; + color: var(--color-secondary-600); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.dimensions { + display: grid; + gap: 0.25rem; + color: var(--color-text-muted); + font-size: 0.82rem; + text-align: right; +} + +.viewer-state { + display: grid; + place-items: center; + min-height: 220px; border-radius: var(--radius-lg); - aspect-ratio: 1; + background: rgba(16, 24, 32, 0.04); + color: var(--color-text-muted); +} + +.viewer-state-error { + color: var(--color-danger-600); +} + +.title-block { + display: grid; + gap: var(--space-3); +} + +.title-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.7rem; } .category { - color: var(--color-brand); - font-weight: 600; text-transform: uppercase; - font-size: 0.875rem; + font-size: 0.76rem; + letter-spacing: 0.08em; } -.price { - font-size: 1.5rem; + +.category { + color: var(--color-secondary-600); font-weight: 700; - color: var(--color-text); - margin: var(--space-4) 0; } -.desc { + +h1 { + font-size: clamp(2rem, 2vw + 1.2rem, 3.2rem); +} + +.excerpt, +.description-block p { + margin: 0; color: var(--color-text-muted); - line-height: 1.6; - margin-bottom: var(--space-8); + line-height: 1.7; +} + +.purchase-card { + display: grid; + gap: var(--space-5); +} + +.price-row, +.quantity-row { + display: flex; + justify-content: space-between; + gap: var(--space-4); + align-items: center; +} + +.cart-pill { + display: inline-flex; + align-items: center; + min-height: 1.9rem; + padding: 0.3rem 0.7rem; + border-radius: 999px; + background: rgba(16, 24, 32, 0.08); + color: var(--color-text); + font-size: 0.8rem; + font-weight: 600; +} + +.variant-grid { + display: grid; + gap: 0.7rem; +} + +.variant-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); + cursor: pointer; + text-align: left; + transition: + border-color 0.18s ease, + transform 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); +} + +.variant-swatch { + width: 1.2rem; + height: 1.2rem; + border-radius: 50%; + border: 1px solid rgba(16, 24, 32, 0.12); + flex: 0 0 auto; +} + +.variant-copy { + display: grid; + gap: 0.12rem; + flex: 1; +} + +.variant-copy small { + color: var(--color-text-muted); +} + +.qty-control { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.2rem; + border-radius: 999px; + border: 1px solid var(--color-border); + background: rgba(255, 255, 255, 0.82); +} + +.qty-control button { + width: 2rem; + height: 2rem; + border: 0; + border-radius: 50%; + background: rgba(16, 24, 32, 0.08); + color: var(--color-text); + cursor: pointer; +} + +.qty-control span { + min-width: 1.5rem; + text-align: center; + font-weight: 700; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); +} + +.success-note { + margin: 0; + color: #047857; + font-weight: 600; +} + +.description-block { + display: grid; + gap: var(--space-3); +} + +.description-block h2 { + font-size: 1.2rem; +} + +.state-card, +.skeleton-block { + min-height: 320px; + border-radius: 1.1rem; + border: 1px solid var(--color-border); + background: rgba(255, 255, 255, 0.8); +} + +.state-card { + display: grid; + place-items: center; + color: var(--color-text-muted); +} + +.skeleton-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.skeleton-block { + background: linear-gradient( + 110deg, + rgba(255, 255, 255, 0.7) 8%, + rgba(238, 235, 226, 0.95) 18%, + rgba(255, 255, 255, 0.7) 33% + ); + background-size: 220% 100%; + animation: skeleton 1.35s linear infinite; +} + +@keyframes skeleton { + to { + background-position-x: -220%; + } +} + +@media (max-width: 960px) { + .detail-grid, + .skeleton-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .viewer-head, + .price-row, + .quantity-row { + flex-direction: column; + align-items: start; + } + + .hero-media, + .image-fallback { + min-height: 320px; + } } diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts index 45124b8..e9e74c5 100644 --- a/frontend/src/app/features/shop/product-detail.component.ts +++ b/frontend/src/app/features/shop/product-detail.component.ts @@ -1,38 +1,321 @@ -import { Component, input, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { RouterLink } from '@angular/router'; +import { + Component, + DestroyRef, + Injector, + computed, + inject, + input, + signal, +} from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { Router, RouterLink } from '@angular/router'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { ShopService, Product } from './services/shop.service'; +import { catchError, combineLatest, finalize, of, switchMap, tap } from 'rxjs'; +import { SeoService } from '../../core/services/seo.service'; +import { LanguageService } from '../../core/services/language.service'; import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; +import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; +import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component'; +import { + ShopProductDetail, + ShopProductVariantOption, + ShopService, +} from './services/shop.service'; @Component({ selector: 'app-product-detail', standalone: true, - imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent], + imports: [ + CommonModule, + RouterLink, + TranslateModule, + AppButtonComponent, + AppCardComponent, + StlViewerComponent, + ], templateUrl: './product-detail.component.html', styleUrl: './product-detail.component.scss', }) export class ProductDetailComponent { - // Input binding from router - id = input(); + private readonly destroyRef = inject(DestroyRef); + private readonly injector = inject(Injector); + private readonly router = inject(Router); + private readonly translate = inject(TranslateService); + private readonly seoService = inject(SeoService); + private readonly languageService = inject(LanguageService); + readonly shopService = inject(ShopService); - product = signal(undefined); + readonly categorySlug = input(); + readonly productSlug = input(); - constructor( - private shopService: ShopService, - private translate: TranslateService, - ) {} + readonly loading = signal(true); + readonly error = signal(null); + readonly product = signal(null); + readonly selectedVariantId = signal(null); + readonly selectedImageAssetId = signal(null); + readonly quantity = signal(1); + readonly isAddingToCart = signal(false); + readonly addSuccess = signal(false); - ngOnInit() { - const productId = this.id(); - if (productId) { - this.shopService - .getProductById(productId) - .subscribe((p) => this.product.set(p)); + readonly modelLoading = signal(false); + readonly modelError = signal(false); + readonly modelFile = signal(null); + + readonly selectedVariant = computed(() => { + const product = this.product(); + const variantId = this.selectedVariantId(); + if (!product) { + return null; } + return ( + product.variants.find((variant) => variant.id === variantId) ?? + product.defaultVariant ?? + product.variants[0] ?? + null + ); + }); + + readonly galleryImages = computed(() => { + const product = this.product(); + if (!product) { + return []; + } + + const images = [...(product.images ?? [])]; + const primary = product.primaryImage; + if ( + primary && + !images.some((image) => image.mediaAssetId === primary.mediaAssetId) + ) { + images.unshift(primary); + } + return images; + }); + + readonly selectedImage = computed(() => { + const images = this.galleryImages(); + const selectedAssetId = this.selectedImageAssetId(); + return ( + images.find((image) => image.mediaAssetId === selectedAssetId) ?? + images[0] ?? + null + ); + }); + + readonly selectedVariantCartQuantity = computed(() => + this.shopService.quantityForVariant(this.selectedVariant()?.id), + ); + + constructor() { + if (!this.shopService.cartLoaded()) { + this.shopService + .loadCart() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + error: () => { + this.shopService.cart.set(null); + }, + }); + } + + combineLatest([ + toObservable(this.productSlug, { injector: this.injector }), + toObservable(this.languageService.currentLang, { + injector: this.injector, + }), + ]) + .pipe( + tap(() => { + this.loading.set(true); + this.error.set(null); + this.addSuccess.set(false); + this.modelError.set(false); + }), + switchMap(([productSlug]) => { + if (!productSlug) { + this.error.set('SHOP.NOT_FOUND'); + this.loading.set(false); + return of(null); + } + + return this.shopService.getProduct(productSlug).pipe( + catchError((error) => { + this.product.set(null); + this.selectedVariantId.set(null); + this.selectedImageAssetId.set(null); + this.modelFile.set(null); + this.error.set( + error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR', + ); + this.applyFallbackSeo(); + return of(null); + }), + finalize(() => this.loading.set(false)), + ); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((product) => { + if (!product) { + return; + } + + this.product.set(product); + this.selectedVariantId.set( + product.defaultVariant?.id ?? product.variants[0]?.id ?? null, + ); + this.selectedImageAssetId.set( + product.primaryImage?.mediaAssetId ?? + product.images[0]?.mediaAssetId ?? + null, + ); + this.quantity.set(1); + 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); + } + }); } - addToCart() { - alert(this.translate.instant('SHOP.MOCK_ADD_CART')); + imageUrl(image: ShopProductDetail['images'][number] | null): string | null { + if (!image) { + return null; + } + return ( + this.shopService.resolveMediaUrl(image.hero) ?? + this.shopService.resolveMediaUrl(image.card) ?? + this.shopService.resolveMediaUrl(image.thumb) + ); + } + + selectImage(mediaAssetId: string): void { + this.selectedImageAssetId.set(mediaAssetId); + } + + selectVariant(variant: ShopProductVariantOption): void { + this.selectedVariantId.set(variant.id); + this.addSuccess.set(false); + } + + decreaseQuantity(): void { + this.quantity.update((value) => Math.max(1, value - 1)); + this.addSuccess.set(false); + } + + increaseQuantity(): void { + this.quantity.update((value) => value + 1); + this.addSuccess.set(false); + } + + addToCart(): void { + const variant = this.selectedVariant(); + if (!variant) { + return; + } + + this.isAddingToCart.set(true); + this.shopService + .addToCart(variant.id, this.quantity()) + .pipe( + finalize(() => this.isAddingToCart.set(false)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: () => { + this.addSuccess.set(true); + }, + error: () => { + this.error.set('SHOP.CART_UPDATE_ERROR'); + }, + }); + } + + goToCheckout(): void { + const sessionId = this.shopService.cartSessionId(); + if (!sessionId) { + return; + } + this.router.navigate(['/checkout'], { + queryParams: { session: sessionId }, + }); + } + + priceLabel(): number { + return ( + this.selectedVariant()?.priceChf ?? this.product()?.priceFromChf ?? 0 + ); + } + + colorLabel(variant: ShopProductVariantOption): string { + return variant.colorName || variant.variantLabel || '-'; + } + + colorHex(variant: ShopProductVariantOption): string { + return variant.colorHex || '#d5d8de'; + } + + productLinkRoot(): string[] { + const categorySlug = this.product()?.category.slug || this.categorySlug(); + return categorySlug ? ['/shop', categorySlug] : ['/shop']; + } + + private loadModelPreview(urlOrPath: string, filename: string): void { + this.modelLoading.set(true); + this.modelError.set(false); + + this.shopService + .getProductModelFile(urlOrPath, filename) + .pipe( + finalize(() => this.modelLoading.set(false)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: (file) => { + this.modelFile.set(file); + }, + error: () => { + this.modelFile.set(null); + this.modelError.set(true); + }, + }); + } + + private applySeo(product: ShopProductDetail): void { + const title = product.seoTitle || `${product.name} | 3D fab`; + const description = + product.seoDescription || + product.excerpt || + this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); + const robots = + product.indexable === false ? 'noindex, nofollow' : 'index, follow'; + + this.seoService.applyPageSeo({ + title, + description, + robots, + ogTitle: product.ogTitle || title, + ogDescription: product.ogDescription || description, + }); + } + + private applyFallbackSeo(): void { + const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`; + const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); + this.seoService.applyPageSeo({ + title, + description, + robots: 'index, follow', + ogTitle: title, + ogDescription: description, + }); } } diff --git a/frontend/src/app/features/shop/services/shop.service.spec.ts b/frontend/src/app/features/shop/services/shop.service.spec.ts new file mode 100644 index 0000000..c8858d2 --- /dev/null +++ b/frontend/src/app/features/shop/services/shop.service.spec.ts @@ -0,0 +1,146 @@ +import { TestBed } from '@angular/core/testing'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { ShopCartResponse, ShopService } from './shop.service'; +import { LanguageService } from '../../../core/services/language.service'; + +describe('ShopService', () => { + let service: ShopService; + let httpMock: HttpTestingController; + + const buildCart = (): ShopCartResponse => ({ + session: { + id: 'session-1', + status: 'ACTIVE', + sessionType: 'SHOP_CART', + }, + items: [ + { + id: 'line-1', + lineItemType: 'SHOP_PRODUCT', + originalFilename: 'desk-cable-clip.stl', + displayName: 'Desk Cable Clip', + quantity: 2, + printTimeSeconds: null, + materialGrams: null, + colorCode: 'Coral Red', + filamentVariantId: null, + shopProductId: 'product-1', + shopProductVariantId: 'variant-red', + shopProductSlug: 'desk-cable-clip', + shopProductName: 'Desk Cable Clip', + shopVariantLabel: 'Coral Red', + shopVariantColorName: 'Coral Red', + shopVariantColorHex: '#ff6b6b', + materialCode: 'PLA', + quality: null, + nozzleDiameterMm: null, + layerHeightMm: null, + infillPercent: null, + infillPattern: null, + supportsEnabled: false, + status: 'READY', + convertedStoredPath: '/storage/items/desk-cable-clip.stl', + unitPriceChf: 11.4, + }, + { + id: 'line-2', + lineItemType: 'SHOP_PRODUCT', + originalFilename: 'desk-cable-clip.stl', + displayName: 'Desk Cable Clip', + quantity: 1, + printTimeSeconds: null, + materialGrams: null, + colorCode: 'Sand Beige', + filamentVariantId: null, + shopProductId: 'product-1', + shopProductVariantId: 'variant-sand', + shopProductSlug: 'desk-cable-clip', + shopProductName: 'Desk Cable Clip', + shopVariantLabel: 'Sand Beige', + shopVariantColorName: 'Sand Beige', + shopVariantColorHex: '#d8c3a5', + materialCode: 'PLA', + quality: null, + nozzleDiameterMm: null, + layerHeightMm: null, + infillPercent: null, + infillPattern: null, + supportsEnabled: false, + status: 'READY', + convertedStoredPath: '/storage/items/desk-cable-clip.stl', + unitPriceChf: 12.0, + }, + ], + printItemsTotalChf: 34.8, + cadTotalChf: 0, + itemsTotalChf: 34.8, + baseSetupCostChf: 0, + nozzleChangeCostChf: 0, + setupCostChf: 0, + shippingCostChf: 2, + globalMachineCostChf: 0, + grandTotalChf: 36.8, + }); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + ShopService, + { + provide: LanguageService, + useValue: { + selectedLang: () => 'it', + }, + }, + ], + }); + + service = TestBed.inject(ShopService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('loads the server-side cart and updates quantity indexes', () => { + let response: ShopCartResponse | undefined; + service.loadCart().subscribe((cart) => { + response = cart; + }); + + const request = httpMock.expectOne('http://localhost:8000/api/shop/cart'); + expect(request.request.method).toBe('GET'); + expect(request.request.withCredentials).toBeTrue(); + request.flush(buildCart()); + + expect(response?.grandTotalChf).toBe(36.8); + expect(service.cartLoaded()).toBeTrue(); + expect(service.cartItemCount()).toBe(3); + expect(service.quantityForProduct('product-1')).toBe(3); + expect(service.quantityForVariant('variant-red')).toBe(2); + expect(service.quantityForVariant('variant-sand')).toBe(1); + }); + + it('posts add-to-cart with credentials and replaces local cart state', () => { + service.addToCart('variant-red', 2).subscribe(); + + const request = httpMock.expectOne( + 'http://localhost:8000/api/shop/cart/items', + ); + expect(request.request.method).toBe('POST'); + expect(request.request.withCredentials).toBeTrue(); + expect(request.request.body).toEqual({ + shopProductVariantId: 'variant-red', + quantity: 2, + }); + request.flush(buildCart()); + + expect(service.cart()?.session?.id).toBe('session-1'); + expect(service.cartItemCount()).toBe(3); + }); +}); diff --git a/frontend/src/app/features/shop/services/shop.service.ts b/frontend/src/app/features/shop/services/shop.service.ts index e50a4ec..d5715cc 100644 --- a/frontend/src/app/features/shop/services/shop.service.ts +++ b/frontend/src/app/features/shop/services/shop.service.ts @@ -1,48 +1,430 @@ -import { Injectable } from '@angular/core'; -import { Observable, of } from 'rxjs'; +import { computed, inject, Injectable, signal } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { map, Observable, tap } from 'rxjs'; +import { environment } from '../../../../environments/environment'; +import { + PublicMediaUsageDto, + PublicMediaVariantDto, +} from '../../../core/services/public-media.service'; +import { LanguageService } from '../../../core/services/language.service'; -export interface Product { +export interface ShopCategoryRef { id: string; + slug: string; name: string; - description: string; - price: number; - category: string; +} + +export interface ShopCategoryTree { + id: string; + parentCategoryId: string | null; + slug: string; + name: string; + description: string | null; + seoTitle: string | null; + seoDescription: string | null; + ogTitle: string | null; + ogDescription: string | null; + indexable: boolean | null; + sortOrder: number | null; + productCount: number; + primaryImage: PublicMediaUsageDto | null; + children: ShopCategoryTree[]; +} + +export interface ShopCategoryDetail { + id: string; + slug: string; + name: string; + description: string | null; + seoTitle: string | null; + seoDescription: string | null; + ogTitle: string | null; + ogDescription: string | null; + indexable: boolean | null; + sortOrder: number | null; + productCount: number; + breadcrumbs: ShopCategoryRef[]; + primaryImage: PublicMediaUsageDto | null; + images: PublicMediaUsageDto[]; + children: ShopCategoryTree[]; +} + +export interface ShopProductVariantOption { + id: string; + sku: string | null; + variantLabel: string | null; + colorName: string | null; + colorHex: string | null; + priceChf: number; + isDefault: boolean; +} + +export interface ShopProductModel { + url: string; + originalFilename: string; + mimeType: string | null; + fileSizeBytes: number | null; + boundingBoxXMm: number | null; + boundingBoxYMm: number | null; + boundingBoxZMm: number | null; +} + +export interface ShopProductSummary { + id: string; + slug: string; + name: string; + excerpt: string | null; + isFeatured: boolean | null; + sortOrder: number | null; + category: ShopCategoryRef; + priceFromChf: number; + priceToChf: number; + defaultVariant: ShopProductVariantOption | null; + primaryImage: PublicMediaUsageDto | null; + model3d: ShopProductModel | null; +} + +export interface ShopProductDetail { + id: string; + slug: string; + name: string; + excerpt: string | null; + description: string | null; + seoTitle: string | null; + seoDescription: string | null; + ogTitle: string | null; + ogDescription: string | null; + indexable: boolean | null; + isFeatured: boolean | null; + sortOrder: number | null; + category: ShopCategoryRef; + breadcrumbs: ShopCategoryRef[]; + priceFromChf: number; + priceToChf: number; + defaultVariant: ShopProductVariantOption | null; + variants: ShopProductVariantOption[]; + primaryImage: PublicMediaUsageDto | null; + images: PublicMediaUsageDto[]; + model3d: ShopProductModel | null; +} + +export interface ShopProductCatalogResponse { + categorySlug: string | null; + featuredOnly: boolean | null; + category: ShopCategoryDetail | null; + products: ShopProductSummary[]; +} + +export interface ShopCartSession { + id: string | null; + status: string | null; + sessionType: string | null; +} + +export interface ShopCartItem { + id: string; + lineItemType: string; + originalFilename: string | null; + displayName: string | null; + quantity: number; + printTimeSeconds: number | null; + materialGrams: number | null; + colorCode: string | null; + filamentVariantId: number | null; + shopProductId: string | null; + shopProductVariantId: string | null; + shopProductSlug: string | null; + shopProductName: string | null; + shopVariantLabel: string | null; + shopVariantColorName: string | null; + shopVariantColorHex: string | null; + materialCode: string | null; + quality: string | null; + nozzleDiameterMm: number | null; + layerHeightMm: number | null; + infillPercent: number | null; + infillPattern: string | null; + supportsEnabled: boolean | null; + status: string | null; + convertedStoredPath: string | null; + unitPriceChf: number; +} + +export interface ShopCartResponse { + session: ShopCartSession | null; + items: ShopCartItem[]; + printItemsTotalChf: number; + cadTotalChf: number; + itemsTotalChf: number; + baseSetupCostChf: number; + nozzleChangeCostChf: number; + setupCostChf: number; + shippingCostChf: number; + globalMachineCostChf: number; + grandTotalChf: number; +} + +export interface ShopCategoryNavNode { + id: string; + slug: string; + name: string; + depth: number; + productCount: number; + current: boolean; } @Injectable({ providedIn: 'root', }) export class ShopService { - // Dati statici per ora - private staticProducts: Product[] = [ - { - id: '1', - name: 'SHOP.PRODUCTS.P1.NAME', - description: 'SHOP.PRODUCTS.P1.DESC', - price: 24.9, - category: 'SHOP.CATEGORIES.FILAMENTS', - }, - { - id: '2', - name: 'SHOP.PRODUCTS.P2.NAME', - description: 'SHOP.PRODUCTS.P2.DESC', - price: 29.9, - category: 'SHOP.CATEGORIES.FILAMENTS', - }, - { - id: '3', - name: 'SHOP.PRODUCTS.P3.NAME', - description: 'SHOP.PRODUCTS.P3.DESC', - price: 15.0, - category: 'SHOP.CATEGORIES.ACCESSORIES', - }, - ]; + private readonly http = inject(HttpClient); + private readonly languageService = inject(LanguageService); + private readonly apiUrl = `${environment.apiUrl}/api/shop`; - getProducts(): Observable { - return of(this.staticProducts); + readonly cart = signal(null); + readonly cartLoading = signal(false); + readonly cartLoaded = signal(false); + + readonly cartItemCount = computed(() => + (this.cart()?.items ?? []).reduce( + (total, item) => total + (Number(item.quantity) || 0), + 0, + ), + ); + + readonly cartSessionId = computed(() => this.cart()?.session?.id ?? null); + + readonly cartQuantityByProductId = computed(() => { + const quantities = new Map(); + for (const item of this.cart()?.items ?? []) { + const productId = item.shopProductId; + if (!productId) { + continue; + } + quantities.set( + productId, + (quantities.get(productId) ?? 0) + (Number(item.quantity) || 0), + ); + } + return quantities; + }); + + readonly cartQuantityByVariantId = computed(() => { + const quantities = new Map(); + for (const item of this.cart()?.items ?? []) { + const variantId = item.shopProductVariantId; + if (!variantId) { + continue; + } + quantities.set( + variantId, + (quantities.get(variantId) ?? 0) + (Number(item.quantity) || 0), + ); + } + return quantities; + }); + + getCategories(): Observable { + return this.http.get(`${this.apiUrl}/categories`, { + params: this.buildLangParams(), + }); } - getProductById(id: string): Observable { - return of(this.staticProducts.find((p) => p.id === id)); + getCategory(slug: string): Observable { + return this.http.get( + `${this.apiUrl}/categories/${encodeURIComponent(slug)}`, + { + params: this.buildLangParams(), + }, + ); + } + + getProductCatalog( + categorySlug?: string | null, + featured?: boolean | null, + ): Observable { + let params = this.buildLangParams(); + if (categorySlug) { + params = params.set('categorySlug', categorySlug); + } + if (featured !== null && featured !== undefined) { + params = params.set('featured', String(featured)); + } + + return this.http.get( + `${this.apiUrl}/products`, + { + params, + }, + ); + } + + getProduct(slug: string): Observable { + return this.http.get( + `${this.apiUrl}/products/${encodeURIComponent(slug)}`, + { + params: this.buildLangParams(), + }, + ); + } + + loadCart(): Observable { + this.cartLoading.set(true); + return this.http + .get(`${this.apiUrl}/cart`, { + withCredentials: true, + }) + .pipe( + tap({ + next: (cart) => { + this.cart.set(cart); + this.cartLoaded.set(true); + this.cartLoading.set(false); + }, + error: () => { + this.cartLoading.set(false); + }, + }), + ); + } + + addToCart( + shopProductVariantId: string, + quantity = 1, + ): Observable { + return this.http + .post( + `${this.apiUrl}/cart/items`, + { + shopProductVariantId, + quantity, + }, + { + withCredentials: true, + }, + ) + .pipe(tap((cart) => this.setCart(cart))); + } + + updateCartItem( + lineItemId: string, + quantity: number, + ): Observable { + return this.http + .patch( + `${this.apiUrl}/cart/items/${encodeURIComponent(lineItemId)}`, + { quantity }, + { + withCredentials: true, + }, + ) + .pipe(tap((cart) => this.setCart(cart))); + } + + removeCartItem(lineItemId: string): Observable { + return this.http + .delete( + `${this.apiUrl}/cart/items/${encodeURIComponent(lineItemId)}`, + { + withCredentials: true, + }, + ) + .pipe(tap((cart) => this.setCart(cart))); + } + + clearCart(): Observable { + return this.http + .delete(`${this.apiUrl}/cart`, { + withCredentials: true, + }) + .pipe(tap((cart) => this.setCart(cart))); + } + + getProductModelFile(urlOrPath: string, filename: string): Observable { + return this.http + .get(this.resolveApiUrl(urlOrPath), { + responseType: 'blob', + }) + .pipe( + map( + (blob) => + new File([blob], filename, { + type: blob.type || 'model/stl', + }), + ), + ); + } + + quantityForProduct(productId: string | null | undefined): number { + if (!productId) { + return 0; + } + return this.cartQuantityByProductId().get(productId) ?? 0; + } + + quantityForVariant(variantId: string | null | undefined): number { + if (!variantId) { + return 0; + } + return this.cartQuantityByVariantId().get(variantId) ?? 0; + } + + flattenCategoryTree( + categories: ShopCategoryTree[], + activeSlug: string | null, + ): ShopCategoryNavNode[] { + const nodes: ShopCategoryNavNode[] = []; + + const walk = (items: ShopCategoryTree[], depth: number) => { + for (const item of items) { + nodes.push({ + id: item.id, + slug: item.slug, + name: item.name, + depth, + productCount: item.productCount, + current: item.slug === activeSlug, + }); + walk(item.children ?? [], depth + 1); + } + }; + + walk(categories, 0); + return nodes; + } + + resolveMediaUrl( + variant: PublicMediaVariantDto | null | undefined, + ): string | null { + if (!variant) { + return null; + } + return variant.jpegUrl ?? variant.webpUrl ?? variant.avifUrl ?? null; + } + + resolveApiUrl(urlOrPath: string | null | undefined): string { + if (!urlOrPath) { + return ''; + } + if ( + urlOrPath.startsWith('http://') || + urlOrPath.startsWith('https://') || + urlOrPath.startsWith('blob:') + ) { + return urlOrPath; + } + const base = (environment.apiUrl || '').replace(/\/$/, ''); + const path = urlOrPath.startsWith('/') ? urlOrPath : `/${urlOrPath}`; + return `${base}${path}`; + } + + private buildLangParams(): HttpParams { + return new HttpParams().set('lang', this.languageService.selectedLang()); + } + + private setCart(cart: ShopCartResponse): void { + this.cart.set(cart); + this.cartLoaded.set(true); + this.cartLoading.set(false); } } diff --git a/frontend/src/app/features/shop/shop-page.component.html b/frontend/src/app/features/shop/shop-page.component.html index f6998b6..dc63721 100644 --- a/frontend/src/app/features/shop/shop-page.component.html +++ b/frontend/src/app/features/shop/shop-page.component.html @@ -1,18 +1,230 @@ -
-
-
-

{{ "SHOP.WIP_EYEBROW" | translate }}

-

{{ "SHOP.WIP_TITLE" | translate }}

-

{{ "SHOP.WIP_SUBTITLE" | translate }}

- -
- - {{ "SHOP.WIP_CTA_CALC" | translate }} - -
- -

{{ "SHOP.WIP_RETURN_LATER" | translate }}

-

{{ "SHOP.WIP_NOTE" | translate }}

+
+
+

{{ "NAV.SHOP" | translate }}

+

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

+
+ + {{ "NAV.CONTACT" | translate }} +
+ +
+ + +
+ @if (error()) { +
+ {{ error() | translate }} +
+ } @else { +
+
+
+

+ {{ + selectedCategory() + ? ("SHOP.SELECTED_CATEGORY" | translate) + : ("SHOP.CATALOG_LABEL" | translate) + }} +

+

+ {{ + selectedCategory()?.name || ("SHOP.CATALOG_TITLE" | translate) + }} +

+
+ + {{ products().length }} + {{ "SHOP.ITEMS_FOUND" | translate }} + +
+ + @if (loading()) { +
+ @for (ghost of [1, 2, 3, 4]; track ghost) { +
+ } +
+ } @else if (products().length === 0) { +
+ {{ "SHOP.EMPTY_CATEGORY" | translate }} +
+ } @else { +
+ @for ( + product of products(); + track trackByProduct($index, product) + ) { + + } +
+ } +
+ } +
+
diff --git a/frontend/src/app/features/shop/shop-page.component.scss b/frontend/src/app/features/shop/shop-page.component.scss index 3288aec..1ea292d 100644 --- a/frontend/src/app/features/shop/shop-page.component.scss +++ b/frontend/src/app/features/shop/shop-page.component.scss @@ -1,72 +1,293 @@ -.wip-section { - position: relative; - padding: var(--space-12) 0; - background-color: var(--color-bg); +.shop-page { + background: linear-gradient(180deg, #faf7ef 0%, var(--color-bg) 13rem); } -.wip-card { - max-width: 760px; - margin: 0 auto; - padding: clamp(1.4rem, 3vw, 2.4rem); - border: 1px solid var(--color-border); - border-radius: var(--radius-xl); - background: rgba(255, 255, 255, 0.95); - box-shadow: var(--shadow-lg); - text-align: center; +.shop-hero .ui-simple-hero__subtitle { + max-width: 52rem; + line-height: 1.5; } -.wip-eyebrow { - display: inline-block; - margin-bottom: var(--space-3); - padding: 0.3rem 0.7rem; - border-radius: 999px; - border: 1px solid rgba(16, 24, 32, 0.14); - font-size: 0.78rem; - letter-spacing: 0.12em; +.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-top: var(--space-6); +} + +.shop-sidebar { + position: sticky; + top: var(--space-6); + display: grid; + gap: var(--space-5); +} + +.panel-head { + display: flex; + justify-content: space-between; + gap: var(--space-4); + margin-bottom: var(--space-4); +} + +.panel-kicker { + margin: 0 0 var(--space-1); + font-size: 0.72rem; + letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-secondary-600); - background: rgba(250, 207, 10, 0.28); + font-weight: 700; } -h1 { - font-size: clamp(1.7rem, 4vw, 2.5rem); - margin-bottom: var(--space-4); +.panel-title { + margin: 0; + font-size: 1.1rem; +} + +.category-list { + display: grid; + gap: 0.4rem; +} + +.category-link { + --depth: 0; + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: 0.8rem 0.95rem 0.8rem calc(0.95rem + (var(--depth) * 0.95rem)); + border: 1px solid transparent; + border-radius: 0.9rem; + background: transparent; color: var(--color-text); + text-align: left; + cursor: pointer; + transition: + background-color 0.18s ease, + border-color 0.18s ease, + transform 0.18s ease; } -.wip-subtitle { - max-width: 60ch; - margin: 0 auto var(--space-8); +.category-link:hover, +.category-link.active { + background: rgba(250, 207, 10, 0.12); + border-color: rgba(16, 24, 32, 0.12); + transform: translateX(1px); +} + +.category-link small { color: var(--color-text-muted); } -.wip-actions { - display: flex; +.cart-card { + display: block; +} + +.panel-empty, +.catalog-state { + margin: 0; + padding: 1rem; + border-radius: 0.9rem; + background: rgba(16, 24, 32, 0.04); + color: var(--color-text-muted); +} + +.catalog-state-error { + background: rgba(239, 68, 68, 0.08); + color: var(--color-danger-600); +} + +.text-action, +.line-remove { + padding: 0; + border: 0; + background: transparent; + color: var(--color-text-muted); + font: inherit; + cursor: pointer; +} + +.cart-lines { + display: grid; gap: var(--space-4); - justify-content: center; - flex-wrap: wrap; + margin-bottom: var(--space-5); } -.wip-note { - margin: var(--space-4) auto 0; - max-width: 62ch; - font-size: 0.95rem; - color: var(--color-secondary-600); +.cart-line { + display: grid; + gap: var(--space-3); + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--color-border); } -.wip-return-later { - margin: var(--space-6) 0 0; +.cart-line:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.cart-line-copy { + display: grid; + gap: 0.25rem; +} + +.cart-line-copy strong { + font-size: 0.96rem; +} + +.cart-line-meta, +.cart-line-color { + color: var(--color-text-muted); + font-size: 0.86rem; +} + +.cart-line-color { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.color-dot { + width: 0.8rem; + height: 0.8rem; + border-radius: 50%; + border: 1px solid rgba(16, 24, 32, 0.12); +} + +.cart-line-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); +} + +.qty-control { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.2rem; + border-radius: 999px; + border: 1px solid var(--color-border); + background: #fff; +} + +.qty-control button { + width: 1.9rem; + height: 1.9rem; + border: 0; + border-radius: 50%; + background: rgba(16, 24, 32, 0.06); + color: var(--color-text); + cursor: pointer; +} + +.qty-control span { + min-width: 1.4rem; + text-align: center; font-weight: 600; - color: var(--color-secondary-600); } -@media (max-width: 640px) { - .wip-section { - padding: var(--space-10) 0; +.line-total { + white-space: nowrap; +} + +.cart-totals { + display: grid; + gap: 0.55rem; + margin-bottom: var(--space-5); + padding-top: var(--space-4); + border-top: 1px solid var(--color-border); +} + +.cart-total-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + color: var(--color-text-muted); +} + +.cart-total-row-final { + color: var(--color-text); + font-size: 1.02rem; +} + +.catalog-content { + display: grid; + gap: var(--space-6); +} + +.catalog-panel { + display: grid; + gap: var(--space-5); +} + +.section-title { + margin: 0; + font-size: clamp(1.5rem, 1vw + 1.2rem, 2rem); +} + +.catalog-head { + display: flex; + justify-content: space-between; + align-items: end; + gap: var(--space-4); +} + +.catalog-counter { + color: var(--color-text-muted); + font-size: 0.9rem; + white-space: nowrap; +} + +.product-grid { + display: grid; + gap: var(--space-5); + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.skeleton-card { + min-height: 400px; + border-radius: 1.1rem; + background: linear-gradient( + 110deg, + rgba(255, 255, 255, 0.7) 8%, + rgba(238, 235, 226, 0.95) 18%, + rgba(255, 255, 255, 0.7) 33% + ); + background-size: 220% 100%; + animation: skeleton 1.35s linear infinite; +} + +@keyframes skeleton { + to { + background-position-x: -220%; + } +} + +@media (max-width: 1080px) { + .shop-layout { + grid-template-columns: 1fr; } - .wip-actions { + .shop-sidebar { + position: static; + } +} + +@media (max-width: 760px) { + .product-grid { + grid-template-columns: 1fr; + } + + .catalog-head, + .cart-line-controls, + .panel-head { + align-items: start; flex-direction: column; - align-items: stretch; } } diff --git a/frontend/src/app/features/shop/shop-page.component.ts b/frontend/src/app/features/shop/shop-page.component.ts index 589e743..83de945 100644 --- a/frontend/src/app/features/shop/shop-page.component.ts +++ b/frontend/src/app/features/shop/shop-page.component.ts @@ -1,14 +1,300 @@ -import { Component } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { RouterLink } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; +import { + Component, + DestroyRef, + Injector, + computed, + inject, + input, + signal, +} from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { Router, RouterLink } from '@angular/router'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { + catchError, + combineLatest, + finalize, + forkJoin, + of, + switchMap, + tap, +} from 'rxjs'; +import { SeoService } from '../../core/services/seo.service'; +import { LanguageService } from '../../core/services/language.service'; import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; +import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; +import { ProductCardComponent } from './components/product-card/product-card.component'; +import { + ShopCategoryDetail, + ShopCategoryNavNode, + ShopCategoryTree, + ShopCartItem, + ShopProductSummary, + ShopService, +} from './services/shop.service'; @Component({ selector: 'app-shop-page', standalone: true, - imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent], + imports: [ + CommonModule, + TranslateModule, + RouterLink, + AppButtonComponent, + AppCardComponent, + ProductCardComponent, + ], templateUrl: './shop-page.component.html', styleUrl: './shop-page.component.scss', }) -export class ShopPageComponent {} +export class ShopPageComponent { + private readonly destroyRef = inject(DestroyRef); + private readonly injector = inject(Injector); + private readonly router = inject(Router); + private readonly translate = inject(TranslateService); + private readonly seoService = inject(SeoService); + private readonly languageService = inject(LanguageService); + readonly shopService = inject(ShopService); + + readonly categorySlug = input(); + + readonly loading = signal(true); + readonly error = signal(null); + readonly categories = signal([]); + readonly categoryNodes = signal([]); + readonly selectedCategory = signal(null); + readonly products = signal([]); + + readonly cartMutating = signal(false); + readonly busyLineItemId = signal(null); + + readonly cart = this.shopService.cart; + readonly cartLoading = this.shopService.cartLoading; + readonly cartItemCount = this.shopService.cartItemCount; + readonly currentCategorySlug = computed( + () => this.selectedCategory()?.slug ?? this.categorySlug() ?? null, + ); + readonly cartItems = computed(() => + (this.cart()?.items ?? []).filter( + (item) => item.lineItemType === 'SHOP_PRODUCT', + ), + ); + readonly cartHasItems = computed(() => this.cartItems().length > 0); + + constructor() { + if (!this.shopService.cartLoaded()) { + this.shopService + .loadCart() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + error: () => { + this.shopService.cart.set(null); + }, + }); + } + + combineLatest([ + toObservable(this.categorySlug, { injector: this.injector }), + toObservable(this.languageService.currentLang, { + injector: this.injector, + }), + ]) + .pipe( + tap(() => { + this.loading.set(true); + this.error.set(null); + }), + switchMap(([categorySlug]) => + forkJoin({ + categories: this.shopService.getCategories(), + catalog: this.shopService.getProductCatalog(categorySlug ?? null), + }).pipe( + catchError((error) => { + this.categories.set([]); + this.categoryNodes.set([]); + this.selectedCategory.set(null); + this.products.set([]); + this.error.set( + error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR', + ); + this.applyDefaultSeo(); + return of(null); + }), + finalize(() => this.loading.set(false)), + ), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((result) => { + if (!result) { + return; + } + + this.categories.set(result.categories); + this.categoryNodes.set( + this.shopService.flattenCategoryTree( + result.categories, + result.catalog.category?.slug ?? this.categorySlug() ?? null, + ), + ); + this.selectedCategory.set(result.catalog.category ?? null); + this.products.set(result.catalog.products); + this.applySeo(result.catalog.category ?? null); + }); + } + + productCartQuantity(productId: string): number { + return this.shopService.quantityForProduct(productId); + } + + cartItemName(item: ShopCartItem): string { + return ( + item.displayName || item.shopProductName || item.originalFilename || '-' + ); + } + + cartItemVariant(item: ShopCartItem): string | null { + return item.shopVariantLabel || item.shopVariantColorName || null; + } + + cartItemColor(item: ShopCartItem): string | null { + return item.shopVariantColorName || item.colorCode || null; + } + + cartItemColorHex(item: ShopCartItem): string { + return item.shopVariantColorHex || '#c9ced6'; + } + + navigateToCategory(slug?: string | null): void { + const commands = slug ? ['/shop', slug] : ['/shop']; + this.router.navigate(commands); + } + + increaseQuantity(item: ShopCartItem): void { + this.updateItemQuantity(item, (item.quantity ?? 0) + 1); + } + + decreaseQuantity(item: ShopCartItem): void { + const nextQuantity = Math.max(1, (item.quantity ?? 1) - 1); + this.updateItemQuantity(item, nextQuantity); + } + + removeItem(item: ShopCartItem): void { + this.cartMutating.set(true); + this.busyLineItemId.set(item.id); + this.shopService + .removeCartItem(item.id) + .pipe( + finalize(() => { + this.cartMutating.set(false); + this.busyLineItemId.set(null); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + error: () => { + this.error.set('SHOP.CART_UPDATE_ERROR'); + }, + }); + } + + clearCart(): void { + this.cartMutating.set(true); + this.busyLineItemId.set(null); + this.shopService + .clearCart() + .pipe( + finalize(() => { + this.cartMutating.set(false); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + error: () => { + this.error.set('SHOP.CART_UPDATE_ERROR'); + }, + }); + } + + goToCheckout(): void { + const sessionId = this.shopService.cartSessionId(); + if (!sessionId) { + return; + } + this.router.navigate(['/checkout'], { + queryParams: { + session: sessionId, + }, + }); + } + + trackByCategory(_index: number, item: ShopCategoryNavNode): string { + return item.id; + } + + trackByProduct(_index: number, product: ShopProductSummary): string { + return product.id; + } + + trackByCartItem(_index: number, item: ShopCartItem): string { + return item.id; + } + + private updateItemQuantity(item: ShopCartItem, quantity: number): void { + this.cartMutating.set(true); + this.busyLineItemId.set(item.id); + this.shopService + .updateCartItem(item.id, quantity) + .pipe( + finalize(() => { + this.cartMutating.set(false); + this.busyLineItemId.set(null); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + error: () => { + this.error.set('SHOP.CART_UPDATE_ERROR'); + }, + }); + } + + private applySeo(category: ShopCategoryDetail | null): void { + if (!category) { + this.applyDefaultSeo(); + return; + } + + const title = + category.seoTitle || + `${category.name} | ${this.translate.instant('SHOP.TITLE')} | 3D fab`; + const description = + category.seoDescription || + category.description || + this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); + const robots = + category.indexable === false ? 'noindex, nofollow' : 'index, follow'; + + this.seoService.applyPageSeo({ + title, + description, + robots, + ogTitle: category.ogTitle || title, + ogDescription: category.ogDescription || description, + }); + } + + private applyDefaultSeo(): void { + const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`; + const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); + + this.seoService.applyPageSeo({ + title, + description, + robots: 'index, follow', + ogTitle: title, + ogDescription: description, + }); + } +} diff --git a/frontend/src/app/features/shop/shop.routes.ts b/frontend/src/app/features/shop/shop.routes.ts index b94949b..9654817 100644 --- a/frontend/src/app/features/shop/shop.routes.ts +++ b/frontend/src/app/features/shop/shop.routes.ts @@ -9,16 +9,21 @@ export const SHOP_ROUTES: Routes = [ data: { seoTitle: 'Shop 3D fab', seoDescription: - 'Lo shop 3D fab e in allestimento. Intanto puoi usare il calcolatore per ottenere un preventivo.', - seoRobots: 'noindex, nofollow', + 'Catalogo prodotti stampati in 3D, accessori tecnici e soluzioni pratiche pronte all uso.', }, }, { - path: ':id', + path: ':categorySlug/:productSlug', component: ProductDetailComponent, data: { seoTitle: 'Prodotto | 3D fab', - seoRobots: 'noindex, nofollow', + }, + }, + { + path: ':categorySlug', + component: ShopPageComponent, + data: { + seoTitle: 'Categoria Shop | 3D fab', }, }, ]; diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index 435a606..f59f113 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -155,6 +155,7 @@ "SHOP": { "TITLE": "Soluzioni tecniche", "SUBTITLE": "Prodotti pronti che risolvono problemi pratici", + "HERO_EYEBROW": "Shop tecnico", "WIP_EYEBROW": "Work in progress", "WIP_TITLE": "Shop in allestimento", "WIP_SUBTITLE": "Stiamo preparando uno shop con prodotti selezionati e funzionalità di creazione automatica!", @@ -162,6 +163,8 @@ "WIP_RETURN_LATER": "Torna tra un po'", "WIP_NOTE": "Ci teniamo a fare le cose fatte bene: nel frattempo puoi calcolare subito prezzo e tempi di un file 3D con il nostro calcolatore.", "ADD_CART": "Aggiungi al Carrello", + "ADDING": "Aggiunta in corso", + "ADD_SUCCESS": "Prodotto aggiunto al carrello.", "BACK": "Torna allo Shop", "NOT_FOUND": "Prodotto non trovato.", "DETAILS": "Dettagli", @@ -169,6 +172,48 @@ "SUCCESS_TITLE": "Aggiunto al carrello", "SUCCESS_DESC": "Il prodotto è stato aggiunto correttamente al carrello.", "CONTINUE": "Continua", + "VIEW_ALL": "Vedi tutto lo shop", + "ALL_CATEGORIES": "Tutte le categorie", + "CATALOG_LABEL": "Catalogo", + "CATALOG_TITLE": "Tutti i prodotti", + "CATALOG_META_DESCRIPTION": "Scopri prodotti stampati in 3D, accessori tecnici e soluzioni pronte all uso con lo stesso checkout del calcolatore.", + "CUSTOM_PART_CTA": "Non trovi quello che cerchi? Richiedi un pezzo personalizzato.", + "CATEGORY_META": "{{count}} prodotti disponibili in questa categoria", + "CATEGORY_PANEL_KICKER": "Navigazione", + "CATEGORY_PANEL_TITLE": "Categorie", + "SELECTED_CATEGORY": "Categoria selezionata", + "ITEMS_FOUND": "prodotti", + "EMPTY_CATEGORY": "Nessun prodotto disponibile in questa categoria al momento.", + "FEATURED_KICKER": "In evidenza", + "FEATURED_TITLE": "Prodotti da tenere d occhio", + "FEATURED_BADGE": "Featured", + "HIGHLIGHT_PRODUCTS": "Prodotti", + "HIGHLIGHT_CART": "Nel carrello", + "HIGHLIGHT_READY": "Preview", + "PRICE_FROM": "Prezzo da", + "EXCERPT_FALLBACK": "Scheda prodotto in preparazione.", + "MODEL_3D": "3D preview", + "MODEL_TITLE": "Anteprima del modello", + "MODEL_LOADING": "Stiamo caricando il modello 3D.", + "MODEL_UNAVAILABLE": "Preview 3D non disponibile.", + "BREADCRUMB_ROOT": "Shop", + "SELECT_COLOR": "Colore", + "VARIANT": "Variante", + "QUANTITY": "Quantità", + "GO_TO_CHECKOUT": "Vai al checkout", + "IN_CART_SHORT": "Nel carrello x{{count}}", + "IN_CART_LONG": "Già nel carrello x{{count}}", + "DESCRIPTION_TITLE": "Descrizione", + "CART_TITLE": "Carrello", + "CART_SUMMARY_TITLE": "Riepilogo attuale", + "CART_LOADING": "Caricamento carrello in corso.", + "CART_EMPTY": "Il carrello è vuoto. Aggiungi un prodotto.", + "CART_SUBTOTAL": "Subtotale prodotti", + "CART_SHIPPING": "Spedizione", + "CART_TOTAL": "Totale stimato", + "CLEAR_CART": "Svuota", + "REMOVE": "Rimuovi", + "CART_UPDATE_ERROR": "Non siamo riusciti ad aggiornare il carrello. Riprova.", "CATEGORIES": { "FILAMENTS": "Filamenti", "ACCESSORIES": "Accessori" @@ -531,6 +576,12 @@ "ERR_ID_NOT_FOUND": "ID ordine non trovato.", "ERR_LOAD_ORDER": "Impossibile caricare i dettagli dell'ordine.", "ERR_REPORT_PAYMENT": "Impossibile segnalare il pagamento. Riprova.", + "ITEMS_TITLE": "Articoli dell'ordine", + "ORDER_TYPE_LABEL": "Tipo ordine", + "ITEM_COUNT": "Righe", + "TYPE_SHOP": "Shop", + "TYPE_CALCULATOR": "Calcolatore", + "TYPE_MIXED": "Misto", "NOT_AVAILABLE": "N/D" }, "DROPZONE": { diff --git a/frontend/src/styles/_ui.scss b/frontend/src/styles/_ui.scss index bf6c542..eb7fd89 100644 --- a/frontend/src/styles/_ui.scss +++ b/frontend/src/styles/_ui.scss @@ -19,6 +19,30 @@ font-size: 1.05rem; } +.ui-simple-hero { + padding: var(--space-12) 0; + text-align: center; +} + +.ui-simple-hero__title { + margin: 0 0 var(--space-2); + font-size: clamp(2rem, 4vw, 2.75rem); + margin-top: var(--space-6); +} + +.ui-simple-hero__subtitle { + max-width: 600px; + margin: var(--space-6) auto; + color: var(--color-text-muted); + font-size: 1.25rem; +} + +.ui-simple-hero__actions { + display: flex; + justify-content: center; + margin-top: var(--space-6); +} + .ui-stack { display: grid; gap: var(--space-4); @@ -153,6 +177,7 @@ font-size: 1.1rem; line-height: 1.6; color: var(--color-text-muted); + margin-bottom: var(--space-4); } .ui-inline-actions {