diff --git a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java index 003ade8..908ef71 100644 --- a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java +++ b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java @@ -5,7 +5,7 @@ import com.printcalculator.entity.CustomQuoteRequest; import com.printcalculator.entity.CustomQuoteRequestAttachment; import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; import com.printcalculator.repository.CustomQuoteRequestRepository; -import com.printcalculator.service.ClamAVService; +import com.printcalculator.service.storage.ClamAVService; import com.printcalculator.service.email.EmailNotificationService; import jakarta.validation.Valid; import org.slf4j.Logger; diff --git a/backend/src/main/java/com/printcalculator/controller/OptionsController.java b/backend/src/main/java/com/printcalculator/controller/OptionsController.java index e187422..56cd26b 100644 --- a/backend/src/main/java/com/printcalculator/controller/OptionsController.java +++ b/backend/src/main/java/com/printcalculator/controller/OptionsController.java @@ -3,17 +3,16 @@ package com.printcalculator.controller; import com.printcalculator.dto.OptionsResponse; import com.printcalculator.entity.FilamentMaterialType; import com.printcalculator.entity.FilamentVariant; -import com.printcalculator.entity.LayerHeightOption; import com.printcalculator.entity.MaterialOrcaProfileMap; import com.printcalculator.entity.NozzleOption; import com.printcalculator.entity.PrinterMachine; import com.printcalculator.entity.PrinterMachineProfile; import com.printcalculator.repository.FilamentMaterialTypeRepository; import com.printcalculator.repository.FilamentVariantRepository; -import com.printcalculator.repository.LayerHeightOptionRepository; import com.printcalculator.repository.MaterialOrcaProfileMapRepository; import com.printcalculator.repository.NozzleOptionRepository; import com.printcalculator.repository.PrinterMachineRepository; +import com.printcalculator.service.NozzleLayerHeightPolicyService; import com.printcalculator.service.OrcaProfileResolver; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; @@ -24,6 +23,7 @@ import org.springframework.web.bind.annotation.RestController; import java.math.BigDecimal; import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -32,26 +32,26 @@ public class OptionsController { private final FilamentMaterialTypeRepository materialRepo; private final FilamentVariantRepository variantRepo; - private final LayerHeightOptionRepository layerHeightRepo; private final NozzleOptionRepository nozzleRepo; private final PrinterMachineRepository printerMachineRepo; private final MaterialOrcaProfileMapRepository materialOrcaMapRepo; private final OrcaProfileResolver orcaProfileResolver; + private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService; public OptionsController(FilamentMaterialTypeRepository materialRepo, FilamentVariantRepository variantRepo, - LayerHeightOptionRepository layerHeightRepo, NozzleOptionRepository nozzleRepo, PrinterMachineRepository printerMachineRepo, MaterialOrcaProfileMapRepository materialOrcaMapRepo, - OrcaProfileResolver orcaProfileResolver) { + OrcaProfileResolver orcaProfileResolver, + NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) { this.materialRepo = materialRepo; this.variantRepo = variantRepo; - this.layerHeightRepo = layerHeightRepo; this.nozzleRepo = nozzleRepo; this.printerMachineRepo = printerMachineRepo; this.materialOrcaMapRepo = materialOrcaMapRepo; this.orcaProfileResolver = orcaProfileResolver; + this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService; } @GetMapping("/api/calculator/options") @@ -116,15 +116,6 @@ public class OptionsController { new OptionsResponse.InfillPatternOption("cubic", "Cubic") ); - List layers = layerHeightRepo.findAll().stream() - .filter(l -> Boolean.TRUE.equals(l.getIsActive())) - .sorted(Comparator.comparing(LayerHeightOption::getLayerHeightMm)) - .map(l -> new OptionsResponse.LayerHeightOptionDTO( - l.getLayerHeightMm().doubleValue(), - String.format("%.2f mm", l.getLayerHeightMm()) - )) - .toList(); - List nozzles = nozzleRepo.findAll().stream() .filter(n -> Boolean.TRUE.equals(n.getIsActive())) .sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm)) @@ -137,7 +128,31 @@ public class OptionsController { )) .toList(); - return ResponseEntity.ok(new OptionsResponse(materialOptions, qualities, patterns, layers, nozzles)); + Map> rulesByNozzle = nozzleLayerHeightPolicyService.getActiveRulesByNozzle(); + BigDecimal selectedNozzle = nozzleLayerHeightPolicyService.resolveNozzle( + nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null + ); + + List layers = toLayerDtos(rulesByNozzle.getOrDefault(selectedNozzle, List.of())); + if (layers.isEmpty()) { + layers = rulesByNozzle.values().stream().findFirst().map(this::toLayerDtos).orElse(List.of()); + } + + List layerHeightsByNozzle = rulesByNozzle.entrySet().stream() + .map(entry -> new OptionsResponse.NozzleLayerHeightOptionsDTO( + entry.getKey().doubleValue(), + toLayerDtos(entry.getValue()) + )) + .toList(); + + return ResponseEntity.ok(new OptionsResponse( + materialOptions, + qualities, + patterns, + layers, + nozzles, + layerHeightsByNozzle + )); } private Set resolveCompatibleMaterialTypeIds(Long printerMachineId, Double nozzleDiameter) { @@ -152,9 +167,9 @@ public class OptionsController { return Set.of(); } - BigDecimal nozzle = nozzleDiameter != null - ? BigDecimal.valueOf(nozzleDiameter) - : BigDecimal.valueOf(0.40); + BigDecimal nozzle = nozzleLayerHeightPolicyService.resolveNozzle( + nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null + ); PrinterMachineProfile machineProfile = orcaProfileResolver .resolveMachineProfile(machine, nozzle) @@ -172,6 +187,16 @@ public class OptionsController { .collect(Collectors.toSet()); } + private List toLayerDtos(List layers) { + return layers.stream() + .sorted(Comparator.naturalOrder()) + .map(layer -> new OptionsResponse.LayerHeightOptionDTO( + layer.doubleValue(), + String.format("%.2f mm", layer) + )) + .toList(); + } + private String resolveHexColor(FilamentVariant variant) { if (variant.getColorHex() != null && !variant.getColorHex().isBlank()) { return variant.getColorHex(); diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index c8a2b5c..eb11a76 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -3,12 +3,12 @@ package com.printcalculator.controller; import com.printcalculator.dto.*; import com.printcalculator.entity.*; import com.printcalculator.repository.*; -import com.printcalculator.service.InvoicePdfRenderingService; +import com.printcalculator.service.payment.InvoicePdfRenderingService; import com.printcalculator.service.OrderService; -import com.printcalculator.service.PaymentService; -import com.printcalculator.service.QrBillService; -import com.printcalculator.service.StorageService; -import com.printcalculator.service.TwintPaymentService; +import com.printcalculator.service.payment.PaymentService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.storage.StorageService; +import com.printcalculator.service.payment.TwintPaymentService; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.util.StringUtils; diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteController.java b/backend/src/main/java/com/printcalculator/controller/QuoteController.java index 45369c1..8675415 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteController.java @@ -4,17 +4,23 @@ import com.printcalculator.entity.PrinterMachine; import com.printcalculator.model.PrintStats; import com.printcalculator.model.QuoteResult; import com.printcalculator.repository.PrinterMachineRepository; +import com.printcalculator.service.NozzleLayerHeightPolicyService; import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.SlicerService; +import com.printcalculator.service.storage.ClamAVService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; -import java.util.Map; -import java.util.HashMap; import java.io.IOException; +import java.math.BigDecimal; import java.nio.file.Files; import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; @RestController public class QuoteController { @@ -22,17 +28,23 @@ public class QuoteController { private final SlicerService slicerService; private final QuoteCalculator quoteCalculator; private final PrinterMachineRepository machineRepo; - private final com.printcalculator.service.ClamAVService clamAVService; + private final ClamAVService clamAVService; + private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService; // Defaults (using aliases defined in ProfileManager) private static final String DEFAULT_FILAMENT = "pla_basic"; private static final String DEFAULT_PROCESS = "standard"; - public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, com.printcalculator.service.ClamAVService clamAVService) { + public QuoteController(SlicerService slicerService, + QuoteCalculator quoteCalculator, + PrinterMachineRepository machineRepo, + ClamAVService clamAVService, + NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService) { this.slicerService = slicerService; this.quoteCalculator = quoteCalculator; this.machineRepo = machineRepo; this.clamAVService = clamAVService; + this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService; } @PostMapping("/api/quote") @@ -69,15 +81,27 @@ public class QuoteController { if (infillPattern != null && !infillPattern.isEmpty()) { processOverrides.put("sparse_infill_pattern", infillPattern); } + BigDecimal normalizedNozzle = nozzleLayerHeightPolicyService.resolveNozzle( + nozzleDiameter != null ? BigDecimal.valueOf(nozzleDiameter) : null + ); if (layerHeight != null) { - processOverrides.put("layer_height", String.valueOf(layerHeight)); + BigDecimal normalizedLayer = nozzleLayerHeightPolicyService.normalizeLayer(BigDecimal.valueOf(layerHeight)); + if (!nozzleLayerHeightPolicyService.isAllowed(normalizedNozzle, normalizedLayer)) { + throw new ResponseStatusException( + BAD_REQUEST, + "Layer height " + normalizedLayer.stripTrailingZeros().toPlainString() + + " is not allowed for nozzle " + normalizedNozzle.stripTrailingZeros().toPlainString() + + ". Allowed: " + nozzleLayerHeightPolicyService.allowedLayersLabel(normalizedNozzle) + ); + } + processOverrides.put("layer_height", normalizedLayer.stripTrailingZeros().toPlainString()); } if (supportEnabled != null) { processOverrides.put("enable_support", supportEnabled ? "1" : "0"); } if (nozzleDiameter != null) { - machineOverrides.put("nozzle_diameter", String.valueOf(nozzleDiameter)); + machineOverrides.put("nozzle_diameter", normalizedNozzle.stripTrailingZeros().toPlainString()); // Also need to ensure the printer profile is compatible or just override? // Usually nozzle diameter changes require a different printer profile or deep overrides. // For now, we trust the override key works on the base profile. diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 70689ac..803a045 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -14,9 +14,11 @@ import com.printcalculator.repository.PrinterMachineRepository; import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteSessionRepository; import com.printcalculator.service.OrcaProfileResolver; +import com.printcalculator.service.NozzleLayerHeightPolicyService; import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.QuoteSessionTotalsService; import com.printcalculator.service.SlicerService; +import com.printcalculator.service.storage.ClamAVService; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; @@ -61,8 +63,9 @@ public class QuoteSessionController { private final FilamentMaterialTypeRepository materialRepo; private final FilamentVariantRepository variantRepo; private final OrcaProfileResolver orcaProfileResolver; + private final NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService; private final com.printcalculator.repository.PricingPolicyRepository pricingRepo; - private final com.printcalculator.service.ClamAVService clamAVService; + private final ClamAVService clamAVService; private final QuoteSessionTotalsService quoteSessionTotalsService; public QuoteSessionController(QuoteSessionRepository sessionRepo, @@ -73,8 +76,9 @@ public class QuoteSessionController { FilamentMaterialTypeRepository materialRepo, FilamentVariantRepository variantRepo, OrcaProfileResolver orcaProfileResolver, + NozzleLayerHeightPolicyService nozzleLayerHeightPolicyService, com.printcalculator.repository.PricingPolicyRepository pricingRepo, - com.printcalculator.service.ClamAVService clamAVService, + ClamAVService clamAVService, QuoteSessionTotalsService quoteSessionTotalsService) { this.sessionRepo = sessionRepo; this.lineItemRepo = lineItemRepo; @@ -84,6 +88,7 @@ public class QuoteSessionController { this.materialRepo = materialRepo; this.variantRepo = variantRepo; this.orcaProfileResolver = orcaProfileResolver; + this.nozzleLayerHeightPolicyService = nozzleLayerHeightPolicyService; this.pricingRepo = pricingRepo; this.clamAVService = clamAVService; this.quoteSessionTotalsService = quoteSessionTotalsService; @@ -167,7 +172,23 @@ public class QuoteSessionController { applyPrintSettings(settings); } - BigDecimal nozzleDiameter = BigDecimal.valueOf(settings.getNozzleDiameter() != null ? settings.getNozzleDiameter() : 0.4); + BigDecimal nozzleDiameter = nozzleLayerHeightPolicyService.resolveNozzle( + settings.getNozzleDiameter() != null ? BigDecimal.valueOf(settings.getNozzleDiameter()) : null + ); + BigDecimal layerHeight = nozzleLayerHeightPolicyService.resolveLayer( + settings.getLayerHeight() != null ? BigDecimal.valueOf(settings.getLayerHeight()) : null, + nozzleDiameter + ); + if (!nozzleLayerHeightPolicyService.isAllowed(nozzleDiameter, layerHeight)) { + throw new ResponseStatusException( + BAD_REQUEST, + "Layer height " + layerHeight.stripTrailingZeros().toPlainString() + + " is not allowed for nozzle " + nozzleDiameter.stripTrailingZeros().toPlainString() + + ". Allowed: " + nozzleLayerHeightPolicyService.allowedLayersLabel(nozzleDiameter) + ); + } + settings.setNozzleDiameter(nozzleDiameter.doubleValue()); + settings.setLayerHeight(layerHeight.doubleValue()); // Pick machine (selected machine if provided, otherwise first active) PrinterMachine machine = resolvePrinterMachine(settings.getPrinterMachineId()); @@ -190,7 +211,7 @@ public class QuoteSessionController { if (!cadSession) { session.setMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode()); session.setNozzleDiameterMm(nozzleDiameter); - session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight() != null ? settings.getLayerHeight() : 0.2)); + session.setLayerHeightMm(layerHeight); session.setInfillPattern(settings.getInfillPattern()); session.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20); session.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false); @@ -209,7 +230,7 @@ public class QuoteSessionController { // Build overrides map from settings Map processOverrides = new HashMap<>(); - if (settings.getLayerHeight() != null) processOverrides.put("layer_height", String.valueOf(settings.getLayerHeight())); + processOverrides.put("layer_height", layerHeight.stripTrailingZeros().toPlainString()); if (settings.getInfillDensity() != null) processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%"); if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern()); @@ -289,6 +310,10 @@ public class QuoteSessionController { } private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) { + if (settings.getNozzleDiameter() == null) { + settings.setNozzleDiameter(0.40); + } + if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) { // Set defaults based on Quality String quality = settings.getQuality() != null ? settings.getQuality().toLowerCase() : "standard"; @@ -313,7 +338,6 @@ public class QuoteSessionController { } } else { // ADVANCED Mode: Use values from Frontend, set defaults if missing - if (settings.getLayerHeight() == null) settings.setLayerHeight(0.20); if (settings.getInfillDensity() == null) settings.setInfillDensity(20.0); if (settings.getInfillPattern() == null) settings.setInfillPattern("grid"); } diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java index 764940d..c41e8c1 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java @@ -12,10 +12,10 @@ import com.printcalculator.event.OrderShippedEvent; import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.PaymentRepository; -import com.printcalculator.service.InvoicePdfRenderingService; -import com.printcalculator.service.PaymentService; -import com.printcalculator.service.QrBillService; -import com.printcalculator.service.StorageService; +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.springframework.context.ApplicationEventPublisher; import org.springframework.core.io.Resource; import org.springframework.http.ContentDisposition; @@ -34,7 +34,6 @@ import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; import java.nio.file.InvalidPathException; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.List; import java.util.Locale; import java.util.Map; diff --git a/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java b/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java index 54d7e87..9b85460 100644 --- a/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java +++ b/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java @@ -7,7 +7,8 @@ public record OptionsResponse( List qualities, List infillPatterns, List layerHeights, - List nozzleDiameters + List nozzleDiameters, + List layerHeightsByNozzle ) { public record MaterialOption(String code, String label, List variants) {} public record VariantOption( @@ -24,4 +25,5 @@ public record OptionsResponse( public record InfillPatternOption(String id, String label) {} public record LayerHeightOptionDTO(double value, String label) {} public record NozzleOptionDTO(double value, String label) {} + public record NozzleLayerHeightOptionsDTO(double nozzleDiameter, List layerHeights) {} } diff --git a/backend/src/main/java/com/printcalculator/entity/NozzleLayerHeightOption.java b/backend/src/main/java/com/printcalculator/entity/NozzleLayerHeightOption.java new file mode 100644 index 0000000..bf2de56 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/NozzleLayerHeightOption.java @@ -0,0 +1,63 @@ +package com.printcalculator.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; + +import java.math.BigDecimal; + +@Entity +@Table( + name = "nozzle_layer_height_option", + uniqueConstraints = @UniqueConstraint( + name = "ux_nozzle_layer_height_option_nozzle_layer", + columnNames = {"nozzle_diameter_mm", "layer_height_mm"} + ) +) +public class NozzleLayerHeightOption { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "nozzle_layer_height_option_id", nullable = false) + private Long id; + + @Column(name = "nozzle_diameter_mm", nullable = false, precision = 4, scale = 2) + private BigDecimal nozzleDiameterMm; + + @Column(name = "layer_height_mm", nullable = false, precision = 5, scale = 3) + private BigDecimal layerHeightMm; + + @ColumnDefault("true") + @Column(name = "is_active", nullable = false) + private Boolean isActive; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public BigDecimal getNozzleDiameterMm() { + return nozzleDiameterMm; + } + + public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) { + this.nozzleDiameterMm = nozzleDiameterMm; + } + + public BigDecimal getLayerHeightMm() { + return layerHeightMm; + } + + public void setLayerHeightMm(BigDecimal layerHeightMm) { + this.layerHeightMm = layerHeightMm; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } +} diff --git a/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java b/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java index a1cfaa2..27b349c 100644 --- a/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java +++ b/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java @@ -8,9 +8,9 @@ import com.printcalculator.event.OrderShippedEvent; import com.printcalculator.event.PaymentConfirmedEvent; import com.printcalculator.event.PaymentReportedEvent; import com.printcalculator.repository.OrderItemRepository; -import com.printcalculator.service.InvoicePdfRenderingService; -import com.printcalculator.service.QrBillService; -import com.printcalculator.service.StorageService; +import com.printcalculator.service.payment.InvoicePdfRenderingService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.storage.StorageService; import com.printcalculator.service.email.EmailNotificationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/backend/src/main/java/com/printcalculator/repository/NozzleLayerHeightOptionRepository.java b/backend/src/main/java/com/printcalculator/repository/NozzleLayerHeightOptionRepository.java new file mode 100644 index 0000000..38973fb --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/NozzleLayerHeightOptionRepository.java @@ -0,0 +1,10 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.NozzleLayerHeightOption; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface NozzleLayerHeightOptionRepository extends JpaRepository { + List findByIsActiveTrueOrderByNozzleDiameterMmAscLayerHeightMmAsc(); +} diff --git a/backend/src/main/java/com/printcalculator/service/NozzleLayerHeightPolicyService.java b/backend/src/main/java/com/printcalculator/service/NozzleLayerHeightPolicyService.java new file mode 100644 index 0000000..4898209 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/NozzleLayerHeightPolicyService.java @@ -0,0 +1,143 @@ +package com.printcalculator.service; + +import com.printcalculator.entity.NozzleLayerHeightOption; +import com.printcalculator.repository.NozzleLayerHeightOptionRepository; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Service +public class NozzleLayerHeightPolicyService { + private static final BigDecimal DEFAULT_NOZZLE = BigDecimal.valueOf(0.40).setScale(2, RoundingMode.HALF_UP); + private static final BigDecimal DEFAULT_LAYER = BigDecimal.valueOf(0.20).setScale(3, RoundingMode.HALF_UP); + + private final NozzleLayerHeightOptionRepository ruleRepo; + + public NozzleLayerHeightPolicyService(NozzleLayerHeightOptionRepository ruleRepo) { + this.ruleRepo = ruleRepo; + } + + public Map> getActiveRulesByNozzle() { + List rules = ruleRepo.findByIsActiveTrueOrderByNozzleDiameterMmAscLayerHeightMmAsc(); + if (rules.isEmpty()) { + return fallbackRules(); + } + + Map> byNozzle = new LinkedHashMap<>(); + for (NozzleLayerHeightOption rule : rules) { + BigDecimal nozzle = normalizeNozzle(rule.getNozzleDiameterMm()); + BigDecimal layer = normalizeLayer(rule.getLayerHeightMm()); + if (nozzle == null || layer == null) { + continue; + } + byNozzle.computeIfAbsent(nozzle, ignored -> new ArrayList<>()).add(layer); + } + + byNozzle.values().forEach(this::sortAndDeduplicate); + return byNozzle; + } + + public BigDecimal normalizeNozzle(BigDecimal value) { + if (value == null) { + return null; + } + return value.setScale(2, RoundingMode.HALF_UP); + } + + public BigDecimal normalizeLayer(BigDecimal value) { + if (value == null) { + return null; + } + return value.setScale(3, RoundingMode.HALF_UP); + } + + public BigDecimal resolveNozzle(BigDecimal requestedNozzle) { + return normalizeNozzle(requestedNozzle != null ? requestedNozzle : DEFAULT_NOZZLE); + } + + public BigDecimal resolveLayer(BigDecimal requestedLayer, BigDecimal nozzleDiameter) { + if (requestedLayer != null) { + return normalizeLayer(requestedLayer); + } + return defaultLayerForNozzle(nozzleDiameter); + } + + public List allowedLayersForNozzle(BigDecimal nozzleDiameter) { + BigDecimal nozzle = resolveNozzle(nozzleDiameter); + List allowed = getActiveRulesByNozzle().get(nozzle); + return allowed != null ? allowed : List.of(); + } + + public boolean isAllowed(BigDecimal nozzleDiameter, BigDecimal layerHeight) { + BigDecimal layer = normalizeLayer(layerHeight); + if (layer == null) { + return false; + } + return allowedLayersForNozzle(nozzleDiameter) + .stream() + .anyMatch(allowed -> allowed.compareTo(layer) == 0); + } + + public BigDecimal defaultLayerForNozzle(BigDecimal nozzleDiameter) { + List allowed = allowedLayersForNozzle(nozzleDiameter); + if (allowed.isEmpty()) { + return DEFAULT_LAYER; + } + + BigDecimal preferred = normalizeLayer(DEFAULT_LAYER); + for (BigDecimal candidate : allowed) { + if (candidate.compareTo(preferred) == 0) { + return candidate; + } + } + return allowed.get(0); + } + + public String allowedLayersLabel(BigDecimal nozzleDiameter) { + List allowed = allowedLayersForNozzle(nozzleDiameter); + if (allowed.isEmpty()) { + return "none"; + } + return allowed.stream() + .map(value -> String.format(Locale.ROOT, "%.2f", value)) + .reduce((a, b) -> a + ", " + b) + .orElse("none"); + } + + private void sortAndDeduplicate(List values) { + values.sort(Comparator.naturalOrder()); + for (int i = values.size() - 1; i > 0; i--) { + if (values.get(i).compareTo(values.get(i - 1)) == 0) { + values.remove(i); + } + } + } + + private Map> fallbackRules() { + Map> fallback = new LinkedHashMap<>(); + fallback.put(scaleNozzle(0.20), scaleLayers(0.04, 0.06, 0.08, 0.10, 0.12)); + fallback.put(scaleNozzle(0.40), scaleLayers(0.08, 0.12, 0.16, 0.20, 0.24, 0.28)); + fallback.put(scaleNozzle(0.60), scaleLayers(0.16, 0.20, 0.24, 0.30, 0.36)); + fallback.put(scaleNozzle(0.80), scaleLayers(0.20, 0.28, 0.36, 0.40, 0.48, 0.56)); + return fallback; + } + + private BigDecimal scaleNozzle(double value) { + return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP); + } + + private List scaleLayers(double... values) { + List scaled = new ArrayList<>(); + for (double value : values) { + scaled.add(BigDecimal.valueOf(value).setScale(3, RoundingMode.HALF_UP)); + } + return scaled; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java index 3a1f606..f42b17c 100644 --- a/backend/src/main/java/com/printcalculator/service/OrderService.java +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -8,6 +8,10 @@ import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteSessionRepository; import com.printcalculator.event.OrderCreatedEvent; +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.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java b/backend/src/main/java/com/printcalculator/service/payment/InvoicePdfRenderingService.java similarity index 99% rename from backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java rename to backend/src/main/java/com/printcalculator/service/payment/InvoicePdfRenderingService.java index 97840bf..96ec578 100644 --- a/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java +++ b/backend/src/main/java/com/printcalculator/service/payment/InvoicePdfRenderingService.java @@ -1,4 +1,4 @@ -package com.printcalculator.service; +package com.printcalculator.service.payment; import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; import com.openhtmltopdf.svgsupport.BatikSVGDrawer; diff --git a/backend/src/main/java/com/printcalculator/service/PaymentService.java b/backend/src/main/java/com/printcalculator/service/payment/PaymentService.java similarity index 96% rename from backend/src/main/java/com/printcalculator/service/PaymentService.java rename to backend/src/main/java/com/printcalculator/service/payment/PaymentService.java index 23b907c..4be56c3 100644 --- a/backend/src/main/java/com/printcalculator/service/PaymentService.java +++ b/backend/src/main/java/com/printcalculator/service/payment/PaymentService.java @@ -1,4 +1,4 @@ -package com.printcalculator.service; +package com.printcalculator.service.payment; import com.printcalculator.entity.Order; import com.printcalculator.entity.Payment; @@ -65,7 +65,7 @@ public class PaymentService { payment.setReportedAt(OffsetDateTime.now()); // We intentionally do not update the payment method here based on user input, - // because the user cannot reliably determine the actual method without an integration. + // because the system cannot reliably determine the actual method without an integration. // It will be updated by the backoffice admin manually. payment = paymentRepo.save(payment); diff --git a/backend/src/main/java/com/printcalculator/service/QrBillService.java b/backend/src/main/java/com/printcalculator/service/payment/QrBillService.java similarity index 94% rename from backend/src/main/java/com/printcalculator/service/QrBillService.java rename to backend/src/main/java/com/printcalculator/service/payment/QrBillService.java index 71eb47c..a6dbed7 100644 --- a/backend/src/main/java/com/printcalculator/service/QrBillService.java +++ b/backend/src/main/java/com/printcalculator/service/payment/QrBillService.java @@ -1,13 +1,10 @@ -package com.printcalculator.service; +package com.printcalculator.service.payment; import com.printcalculator.entity.Order; import net.codecrete.qrbill.generator.Bill; -import net.codecrete.qrbill.generator.GraphicsFormat; import net.codecrete.qrbill.generator.QRBill; import org.springframework.stereotype.Service; -import java.math.BigDecimal; - @Service public class QrBillService { diff --git a/backend/src/main/java/com/printcalculator/service/TwintPaymentService.java b/backend/src/main/java/com/printcalculator/service/payment/TwintPaymentService.java similarity index 98% rename from backend/src/main/java/com/printcalculator/service/TwintPaymentService.java rename to backend/src/main/java/com/printcalculator/service/payment/TwintPaymentService.java index 6ed915b..a7be8eb 100644 --- a/backend/src/main/java/com/printcalculator/service/TwintPaymentService.java +++ b/backend/src/main/java/com/printcalculator/service/payment/TwintPaymentService.java @@ -1,4 +1,4 @@ -package com.printcalculator.service; +package com.printcalculator.service.payment; import io.nayuki.qrcodegen.QrCode; import org.springframework.beans.factory.annotation.Value; diff --git a/backend/src/main/java/com/printcalculator/service/ClamAVService.java b/backend/src/main/java/com/printcalculator/service/storage/ClamAVService.java similarity index 98% rename from backend/src/main/java/com/printcalculator/service/ClamAVService.java rename to backend/src/main/java/com/printcalculator/service/storage/ClamAVService.java index dc6532a..ceed6e7 100644 --- a/backend/src/main/java/com/printcalculator/service/ClamAVService.java +++ b/backend/src/main/java/com/printcalculator/service/storage/ClamAVService.java @@ -1,4 +1,4 @@ -package com.printcalculator.service; +package com.printcalculator.service.storage; import com.printcalculator.exception.VirusDetectedException; import org.slf4j.Logger; diff --git a/backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java b/backend/src/main/java/com/printcalculator/service/storage/FileSystemStorageService.java similarity index 98% rename from backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java rename to backend/src/main/java/com/printcalculator/service/storage/FileSystemStorageService.java index 38e1e25..d4bf9d3 100644 --- a/backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java +++ b/backend/src/main/java/com/printcalculator/service/storage/FileSystemStorageService.java @@ -1,4 +1,4 @@ -package com.printcalculator.service; +package com.printcalculator.service.storage; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; @@ -7,7 +7,6 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import com.printcalculator.exception.StorageException; -import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; diff --git a/backend/src/main/java/com/printcalculator/service/StorageService.java b/backend/src/main/java/com/printcalculator/service/storage/StorageService.java similarity index 91% rename from backend/src/main/java/com/printcalculator/service/StorageService.java rename to backend/src/main/java/com/printcalculator/service/storage/StorageService.java index 5fe2321..89773a8 100644 --- a/backend/src/main/java/com/printcalculator/service/StorageService.java +++ b/backend/src/main/java/com/printcalculator/service/storage/StorageService.java @@ -1,4 +1,4 @@ -package com.printcalculator.service; +package com.printcalculator.service.storage; import org.springframework.core.io.Resource; import org.springframework.web.multipart.MultipartFile; diff --git a/backend/src/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java b/backend/src/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java index b3d6665..5849c12 100644 --- a/backend/src/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java +++ b/backend/src/test/java/com/printcalculator/controller/OrderControllerPrivacyTest.java @@ -8,12 +8,12 @@ import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.PaymentRepository; import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteSessionRepository; -import com.printcalculator.service.InvoicePdfRenderingService; +import com.printcalculator.service.payment.InvoicePdfRenderingService; import com.printcalculator.service.OrderService; -import com.printcalculator.service.PaymentService; -import com.printcalculator.service.QrBillService; -import com.printcalculator.service.StorageService; -import com.printcalculator.service.TwintPaymentService; +import com.printcalculator.service.payment.PaymentService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.storage.StorageService; +import com.printcalculator.service.payment.TwintPaymentService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java index 88701dd..804da16 100644 --- a/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java +++ b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java @@ -6,10 +6,10 @@ import com.printcalculator.entity.Order; import com.printcalculator.repository.OrderItemRepository; import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.PaymentRepository; -import com.printcalculator.service.InvoicePdfRenderingService; -import com.printcalculator.service.PaymentService; -import com.printcalculator.service.QrBillService; -import com.printcalculator.service.StorageService; +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.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/backend/src/test/java/com/printcalculator/event/listener/OrderEmailListenerTest.java b/backend/src/test/java/com/printcalculator/event/listener/OrderEmailListenerTest.java index b0f62c4..6c06bc4 100644 --- a/backend/src/test/java/com/printcalculator/event/listener/OrderEmailListenerTest.java +++ b/backend/src/test/java/com/printcalculator/event/listener/OrderEmailListenerTest.java @@ -4,9 +4,9 @@ import com.printcalculator.entity.Customer; import com.printcalculator.entity.Order; import com.printcalculator.event.OrderCreatedEvent; import com.printcalculator.repository.OrderItemRepository; -import com.printcalculator.service.InvoicePdfRenderingService; -import com.printcalculator.service.QrBillService; -import com.printcalculator.service.StorageService; +import com.printcalculator.service.payment.InvoicePdfRenderingService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.storage.StorageService; import com.printcalculator.service.email.EmailNotificationService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..6bd7b9c --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,21 @@ +User-agent: * +Allow: / + +Disallow: /admin +Disallow: /admin/ +Disallow: /*/admin +Disallow: /*/admin/ +Disallow: /order/ +Disallow: /*/order/ +Disallow: /co/ +Disallow: /*/co/ +Disallow: /checkout +Disallow: /checkout/ +Disallow: /*/checkout +Disallow: /*/checkout/ +Disallow: /shop +Disallow: /shop/ +Disallow: /*/shop +Disallow: /*/shop/ + +Sitemap: https://3d-fab.ch/sitemap.xml diff --git a/frontend/public/sitemap.xml b/frontend/public/sitemap.xml new file mode 100644 index 0000000..a1195d2 --- /dev/null +++ b/frontend/public/sitemap.xml @@ -0,0 +1,144 @@ + + + + https://3d-fab.ch/it + + + + + + weekly + 1.0 + + + https://3d-fab.ch/it/calculator/basic + + + + + + weekly + 0.9 + + + https://3d-fab.ch/it/calculator/advanced + + + + + + weekly + 0.8 + + + https://3d-fab.ch/it/about + + + + + + monthly + 0.7 + + + https://3d-fab.ch/it/contact + + + + + + monthly + 0.7 + + + https://3d-fab.ch/it/privacy + + + + + + yearly + 0.4 + + + https://3d-fab.ch/it/terms + + + + + + yearly + 0.4 + + diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index cb93b2a..53a2fdb 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -1,5 +1,6 @@ -import { Component } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { SeoService } from './core/services/seo.service'; @Component({ selector: 'app-root', @@ -8,4 +9,6 @@ import { RouterOutlet } from '@angular/router'; templateUrl: './app.component.html', styleUrl: './app.component.scss', }) -export class AppComponent {} +export class AppComponent { + private readonly seoService = inject(SeoService); +} diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 2b5024f..c423e3b 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -5,6 +5,11 @@ const appChildRoutes: Routes = [ path: '', loadComponent: () => import('./features/home/home.component').then((m) => m.HomeComponent), + data: { + seoTitle: '3D fab | Stampa 3D su misura', + seoDescription: + 'Servizio di stampa 3D con preventivo online immediato per prototipi, piccole serie e pezzi personalizzati.', + }, }, { path: 'calculator', @@ -12,21 +17,42 @@ const appChildRoutes: Routes = [ import('./features/calculator/calculator.routes').then( (m) => m.CALCULATOR_ROUTES, ), + data: { + seoTitle: 'Calcolatore preventivo stampa 3D | 3D fab', + seoDescription: + 'Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.', + }, }, { path: 'shop', loadChildren: () => import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES), + data: { + seoTitle: 'Shop 3D fab', + seoDescription: + 'Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.', + seoRobots: 'noindex, nofollow', + }, }, { path: 'about', loadChildren: () => import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES), + data: { + seoTitle: 'Chi siamo | 3D fab', + seoDescription: + 'Scopri il team 3D fab e il laboratorio di stampa 3D con sedi in Ticino e Bienne.', + }, }, { path: 'contact', loadChildren: () => import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES), + data: { + seoTitle: 'Contatti | 3D fab', + seoDescription: + 'Contatta 3D fab per preventivi, supporto tecnico e richieste personalizzate di stampa 3D.', + }, }, { path: 'checkout/cad', @@ -34,6 +60,10 @@ const appChildRoutes: Routes = [ import('./features/checkout/checkout.component').then( (m) => m.CheckoutComponent, ), + data: { + seoTitle: 'Checkout | 3D fab', + seoRobots: 'noindex, nofollow', + }, }, { path: 'checkout', @@ -41,16 +71,28 @@ const appChildRoutes: Routes = [ import('./features/checkout/checkout.component').then( (m) => m.CheckoutComponent, ), + data: { + seoTitle: 'Checkout | 3D fab', + seoRobots: 'noindex, nofollow', + }, }, { path: 'order/:orderId', loadComponent: () => import('./features/order/order.component').then((m) => m.OrderComponent), + data: { + seoTitle: 'Ordine | 3D fab', + seoRobots: 'noindex, nofollow', + }, }, { path: 'co/:orderId', loadComponent: () => import('./features/order/order.component').then((m) => m.OrderComponent), + data: { + seoTitle: 'Ordine | 3D fab', + seoRobots: 'noindex, nofollow', + }, }, { path: '', @@ -61,6 +103,10 @@ const appChildRoutes: Routes = [ path: 'admin', loadChildren: () => import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES), + data: { + seoTitle: 'Admin | 3D fab', + seoRobots: 'noindex, nofollow', + }, }, { path: '**', diff --git a/frontend/src/app/core/services/seo.service.ts b/frontend/src/app/core/services/seo.service.ts new file mode 100644 index 0000000..ef26bde --- /dev/null +++ b/frontend/src/app/core/services/seo.service.ts @@ -0,0 +1,129 @@ +import { DOCUMENT } from '@angular/common'; +import { Inject, Injectable } from '@angular/core'; +import { Title, Meta } from '@angular/platform-browser'; +import { ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router'; +import { filter } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root', +}) +export class SeoService { + private readonly defaultTitle = '3D fab | Stampa 3D su misura'; + private readonly defaultDescription = + 'Stampa 3D su misura con preventivo online immediato. Carica il file, scegli materiale e qualità, ricevi prezzo e tempi in pochi secondi.'; + private readonly supportedLangs = new Set(['it', 'en', 'de', 'fr']); + + constructor( + private router: Router, + private titleService: Title, + private metaService: Meta, + @Inject(DOCUMENT) private document: Document, + ) { + this.applyRouteSeo(this.router.routerState.snapshot.root); + this.router.events + .pipe( + filter( + (event): event is NavigationEnd => event instanceof NavigationEnd, + ), + ) + .subscribe(() => { + this.applyRouteSeo(this.router.routerState.snapshot.root); + }); + } + + private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void { + const mergedData = this.getMergedRouteData(rootSnapshot); + const title = this.asString(mergedData['seoTitle']) ?? this.defaultTitle; + const description = + this.asString(mergedData['seoDescription']) ?? this.defaultDescription; + const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow'; + + this.titleService.setTitle(title); + this.metaService.updateTag({ name: 'description', content: description }); + this.metaService.updateTag({ name: 'robots', content: robots }); + this.metaService.updateTag({ property: 'og:title', content: title }); + this.metaService.updateTag({ + property: 'og:description', + content: description, + }); + this.metaService.updateTag({ property: 'og:type', content: 'website' }); + this.metaService.updateTag({ name: 'twitter:card', content: 'summary' }); + + const cleanPath = this.getCleanPath(this.router.url); + const canonical = `${this.document.location.origin}${cleanPath}`; + this.metaService.updateTag({ property: 'og:url', content: canonical }); + this.updateCanonicalTag(canonical); + this.updateLangAndAlternates(cleanPath); + } + + private getMergedRouteData( + snapshot: ActivatedRouteSnapshot, + ): Record { + const merged: Record = {}; + let cursor: ActivatedRouteSnapshot | null = snapshot; + while (cursor) { + Object.assign(merged, cursor.data ?? {}); + cursor = cursor.firstChild; + } + return merged; + } + + private asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; + } + + private getCleanPath(url: string): string { + const path = (url || '/').split('?')[0].split('#')[0]; + return path || '/'; + } + + private updateCanonicalTag(url: string): void { + let link = this.document.head.querySelector( + 'link[rel="canonical"]', + ) as HTMLLinkElement | null; + if (!link) { + link = this.document.createElement('link'); + link.setAttribute('rel', 'canonical'); + this.document.head.appendChild(link); + } + link.setAttribute('href', url); + } + + private updateLangAndAlternates(path: string): void { + const segments = path.split('/').filter(Boolean); + const firstSegment = segments[0]?.toLowerCase(); + const hasLang = Boolean( + firstSegment && this.supportedLangs.has(firstSegment), + ); + const lang = hasLang ? firstSegment : 'it'; + const suffixSegments = hasLang ? segments.slice(1) : segments; + const suffix = + suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : ''; + + this.document.documentElement.lang = lang; + + this.document.head + .querySelectorAll('link[rel="alternate"][data-seo-managed="true"]') + .forEach((node) => node.remove()); + + for (const alt of ['it', 'en', 'de', 'fr']) { + this.appendAlternateLink( + alt, + `${this.document.location.origin}/${alt}${suffix}`, + ); + } + this.appendAlternateLink( + 'x-default', + `${this.document.location.origin}/it${suffix}`, + ); + } + + private appendAlternateLink(hreflang: string, href: string): void { + const link = this.document.createElement('link'); + link.setAttribute('rel', 'alternate'); + link.setAttribute('hreflang', hreflang); + link.setAttribute('href', href); + link.setAttribute('data-seo-managed', 'true'); + this.document.head.appendChild(link); + } +} diff --git a/frontend/src/app/features/about/about.routes.ts b/frontend/src/app/features/about/about.routes.ts index 0128c8e..3d7d210 100644 --- a/frontend/src/app/features/about/about.routes.ts +++ b/frontend/src/app/features/about/about.routes.ts @@ -2,5 +2,13 @@ import { Routes } from '@angular/router'; import { AboutPageComponent } from './about-page.component'; export const ABOUT_ROUTES: Routes = [ - { path: '', component: AboutPageComponent }, + { + path: '', + component: AboutPageComponent, + data: { + seoTitle: 'Chi siamo | 3D fab', + seoDescription: + 'Siamo un laboratorio di stampa 3D orientato a prototipi, ricambi e produzioni su misura.', + }, + }, ]; diff --git a/frontend/src/app/features/calculator/calculator-page.component.html b/frontend/src/app/features/calculator/calculator-page.component.html index 070b68d..8aab319 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.html +++ b/frontend/src/app/features/calculator/calculator-page.component.html @@ -24,7 +24,7 @@ class="mode-option" [class.active]="mode() === 'easy'" [class.disabled]="cadSessionLocked()" - (click)="!cadSessionLocked() && mode.set('easy')" + (click)="switchMode('easy')" > {{ "CALC.MODE_EASY" | translate }} @@ -32,7 +32,7 @@ class="mode-option" [class.active]="mode() === 'advanced'" [class.disabled]="cadSessionLocked()" - (click)="!cadSessionLocked() && mode.set('advanced')" + (click)="switchMode('advanced')" > {{ "CALC.MODE_ADVANCED" | translate }} @@ -45,6 +45,8 @@ [loading]="loading()" [uploadProgress]="uploadProgress()" (submitRequest)="onCalculate($event)" + (itemQuantityChange)="onUploadItemQuantityChange($event)" + (printSettingsChange)="onUploadPrintSettingsChange($event)" > @@ -64,8 +66,10 @@ } @else if (result()) { } @else if (isZeroQuoteError()) { diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts index 6e56a60..10b2aea 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.ts @@ -42,7 +42,7 @@ import { LanguageService } from '../../core/services/language.service'; styleUrl: './calculator-page.component.scss', }) export class CalculatorPageComponent implements OnInit { - mode = signal('easy'); + mode = signal<'easy' | 'advanced'>('easy'); step = signal<'upload' | 'quote' | 'details' | 'success'>('upload'); loading = signal(false); @@ -56,6 +56,17 @@ export class CalculatorPageComponent implements OnInit { ); orderSuccess = signal(false); + requiresRecalculation = signal(false); + private baselinePrintSettings: { + mode: 'easy' | 'advanced'; + material: string; + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + supportEnabled: boolean; + } | null = null; @ViewChild('uploadForm') uploadForm!: UploadFormComponent; @ViewChild('resultCol') resultCol!: ElementRef; @@ -101,6 +112,10 @@ export class CalculatorPageComponent implements OnInit { this.error.set(false); this.errorKey.set('CALC.ERROR_GENERIC'); this.result.set(result); + this.baselinePrintSettings = this.toTrackedSettingsFromSession( + data.session, + ); + this.requiresRecalculation.set(false); const isCadSession = data?.session?.status === 'CAD_ACTIVE'; this.cadSessionLocked.set(isCadSession); this.step.set('quote'); @@ -238,6 +253,8 @@ export class CalculatorPageComponent implements OnInit { this.error.set(false); this.errorKey.set('CALC.ERROR_GENERIC'); this.result.set(res); + this.baselinePrintSettings = this.toTrackedSettingsFromRequest(req); + this.requiresRecalculation.set(false); this.loading.set(false); this.uploadProgress.set(100); this.step.set('quote'); @@ -295,9 +312,10 @@ export class CalculatorPageComponent implements OnInit { index: number; fileName: string; quantity: number; + source?: 'left' | 'right'; }) { // 1. Update local form for consistency (UI feedback) - if (this.uploadForm) { + if (event.source !== 'left' && this.uploadForm) { this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity); this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity); } @@ -340,6 +358,33 @@ export class CalculatorPageComponent implements OnInit { } } + onUploadItemQuantityChange(event: { + index: number; + fileName: string; + quantity: number; + }) { + const resultItems = this.result()?.items || []; + const byIndex = resultItems[event.index]; + const byName = resultItems.find((item) => item.fileName === event.fileName); + const id = byIndex?.id ?? byName?.id; + + this.onItemChange({ + ...event, + id, + source: 'left', + }); + } + + onQuoteItemQuantityPreviewChange(event: { + index: number; + fileName: string; + quantity: number; + }) { + if (!this.uploadForm) return; + this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity); + this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity); + } + onSubmitOrder(orderData: any) { console.log('Order Submitted:', orderData); this.orderSuccess.set(true); @@ -349,15 +394,37 @@ export class CalculatorPageComponent implements OnInit { onNewQuote() { this.step.set('upload'); this.result.set(null); + this.requiresRecalculation.set(false); + this.baselinePrintSettings = null; this.cadSessionLocked.set(false); this.orderSuccess.set(false); - this.mode.set('easy'); // Reset to default + this.switchMode('easy'); // Reset to default and sync URL } private currentRequest: QuoteRequest | null = null; + onUploadPrintSettingsChange(settings: { + mode: 'easy' | 'advanced'; + material: string; + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + supportEnabled: boolean; + }) { + if (!this.result()) return; + if (!this.baselinePrintSettings) return; + this.requiresRecalculation.set( + !this.sameTrackedSettings(this.baselinePrintSettings, settings), + ); + } + onConsult() { - if (!this.currentRequest) { + const currentFormRequest = this.uploadForm?.getCurrentRequestDraft(); + const req = currentFormRequest ?? this.currentRequest; + + if (!req) { this.router.navigate([ '/', this.languageService.selectedLang(), @@ -366,7 +433,6 @@ export class CalculatorPageComponent implements OnInit { return; } - const req = this.currentRequest; let details = `Richiesta Preventivo:\n`; details += `- Materiale: ${req.material}\n`; details += `- Qualità: ${req.quality}\n`; @@ -411,5 +477,126 @@ export class CalculatorPageComponent implements OnInit { this.errorKey.set(key); this.error.set(true); this.result.set(null); + this.requiresRecalculation.set(false); + this.baselinePrintSettings = null; + } + + switchMode(nextMode: 'easy' | 'advanced'): void { + if (this.cadSessionLocked()) return; + + const targetPath = nextMode === 'easy' ? 'basic' : 'advanced'; + const currentPath = this.route.snapshot.routeConfig?.path; + + this.mode.set(nextMode); + + if (currentPath === targetPath) { + return; + } + + this.router.navigate(['..', targetPath], { + relativeTo: this.route, + queryParamsHandling: 'preserve', + }); + } + + private toTrackedSettingsFromRequest(req: QuoteRequest): { + mode: 'easy' | 'advanced'; + material: string; + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + supportEnabled: boolean; + } { + return { + mode: req.mode, + material: this.normalizeString(req.material || 'PLA'), + quality: this.normalizeString(req.quality || 'standard'), + nozzleDiameter: this.normalizeNumber(req.nozzleDiameter, 0.4, 2), + layerHeight: this.normalizeNumber(req.layerHeight, 0.2, 3), + infillDensity: this.normalizeNumber(req.infillDensity, 20, 2), + infillPattern: this.normalizeString(req.infillPattern || 'grid'), + supportEnabled: Boolean(req.supportEnabled), + }; + } + + private toTrackedSettingsFromSession(session: any): { + mode: 'easy' | 'advanced'; + material: string; + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + supportEnabled: boolean; + } { + const layer = this.normalizeNumber(session?.layerHeightMm, 0.2, 3); + return { + mode: this.mode(), + material: this.normalizeString(session?.materialCode || 'PLA'), + quality: + layer >= 0.24 ? 'draft' : layer <= 0.12 ? 'extra_fine' : 'standard', + nozzleDiameter: this.normalizeNumber(session?.nozzleDiameterMm, 0.4, 2), + layerHeight: layer, + infillDensity: this.normalizeNumber(session?.infillPercent, 20, 2), + infillPattern: this.normalizeString(session?.infillPattern || 'grid'), + supportEnabled: Boolean(session?.supportsEnabled), + }; + } + + private sameTrackedSettings( + a: { + mode: 'easy' | 'advanced'; + material: string; + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + supportEnabled: boolean; + }, + b: { + mode: 'easy' | 'advanced'; + material: string; + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + supportEnabled: boolean; + }, + ): boolean { + return ( + a.mode === b.mode && + a.material === this.normalizeString(b.material) && + a.quality === this.normalizeString(b.quality) && + Math.abs( + a.nozzleDiameter - this.normalizeNumber(b.nozzleDiameter, 0.4, 2), + ) < 0.0001 && + Math.abs(a.layerHeight - this.normalizeNumber(b.layerHeight, 0.2, 3)) < + 0.0001 && + Math.abs(a.infillDensity - this.normalizeNumber(b.infillDensity, 20, 2)) < + 0.0001 && + a.infillPattern === this.normalizeString(b.infillPattern) && + a.supportEnabled === Boolean(b.supportEnabled) + ); + } + + private normalizeString(value: string): string { + return String(value || '') + .trim() + .toLowerCase(); + } + + private normalizeNumber( + value: unknown, + fallback: number, + decimals: number, + ): number { + const numeric = Number(value); + const resolved = Number.isFinite(numeric) ? numeric : fallback; + const factor = 10 ** decimals; + return Math.round(resolved * factor) / factor; } } diff --git a/frontend/src/app/features/calculator/calculator.routes.ts b/frontend/src/app/features/calculator/calculator.routes.ts index 5a670cd..134457b 100644 --- a/frontend/src/app/features/calculator/calculator.routes.ts +++ b/frontend/src/app/features/calculator/calculator.routes.ts @@ -3,10 +3,24 @@ import { CalculatorPageComponent } from './calculator-page.component'; export const CALCULATOR_ROUTES: Routes = [ { path: '', redirectTo: 'basic', pathMatch: 'full' }, - { path: 'basic', component: CalculatorPageComponent, data: { mode: 'easy' } }, + { + path: 'basic', + component: CalculatorPageComponent, + data: { + mode: 'easy', + seoTitle: 'Calcolatore stampa 3D base | 3D fab', + seoDescription: + 'Calcola rapidamente il prezzo della tua stampa 3D in modalita base.', + }, + }, { path: 'advanced', component: CalculatorPageComponent, - data: { mode: 'advanced' }, + data: { + mode: 'advanced', + seoTitle: 'Calcolatore stampa 3D avanzato | 3D fab', + seoDescription: + 'Configura parametri avanzati e ottieni un preventivo preciso con slicing reale.', + }, }, ]; diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html index d7141b7..2c81b84 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html @@ -45,6 +45,12 @@

{{ result().notes }}

} + @if (recalculationRequired()) { +
+ Hai modificato i parametri di stampa. Ricalcola il preventivo prima di + procedere con l'ordine. +
+ }
@@ -104,7 +110,10 @@
@if (!hasQuantityOverLimit()) { - + {{ "QUOTE.PROCEED_ORDER" | translate }} } @else { @@ -112,6 +121,11 @@ "QUOTE.MAX_QTY_NOTICE" | translate: { max: directOrderLimit } }} } + @if (recalculationRequired()) { + + Ricalcola il preventivo per riattivare il checkout. + + }
diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss index 38913fa..b4e94b2 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss @@ -184,3 +184,14 @@ white-space: pre-wrap; /* Preserve line breaks */ } } + +.recalc-banner { + margin-top: var(--space-4); + margin-bottom: var(--space-4); + padding: var(--space-3); + border: 1px solid #f0c95a; + background: #fff8e1; + border-radius: var(--radius-md); + color: #6f5b1a; + font-size: 0.9rem; +} diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts index 5da71ec..ce0df34 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts @@ -35,6 +35,7 @@ export class QuoteResultComponent implements OnDestroy { readonly quantityAutoRefreshMs = 2000; result = input.required(); + recalculationRequired = input(false); consult = output(); proceed = output(); itemChange = output<{ @@ -43,6 +44,12 @@ export class QuoteResultComponent implements OnDestroy { fileName: string; quantity: number; }>(); + itemQuantityPreviewChange = output<{ + id?: string; + index: number; + fileName: string; + quantity: number; + }>(); // Local mutable state for items to handle quantity changes items = signal([]); @@ -87,6 +94,13 @@ export class QuoteResultComponent implements OnDestroy { return updated; }); + this.itemQuantityPreviewChange.emit({ + id: item.id, + index, + fileName: item.fileName, + quantity: normalizedQty, + }); + this.scheduleQuantityRefresh(index, key); } diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts index 30d323e..6b9d46c 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts @@ -62,6 +62,21 @@ export class UploadFormComponent implements OnInit { loading = input(false); uploadProgress = input(0); submitRequest = output(); + itemQuantityChange = output<{ + index: number; + fileName: string; + quantity: number; + }>(); + printSettingsChange = output<{ + mode: 'easy' | 'advanced'; + material: string; + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + supportEnabled: boolean; + }>(); private estimator = inject(QuoteEstimatorService); private fb = inject(FormBuilder); @@ -81,6 +96,8 @@ export class UploadFormComponent implements OnInit { // Store full material options to lookup variants/colors if needed later private fullMaterialOptions: MaterialOption[] = []; + private allLayerHeights: SimpleOption[] = []; + private layerHeightsByNozzle: Record = {}; private isPatchingSettings = false; // Computed variants for valid material @@ -141,6 +158,14 @@ export class UploadFormComponent implements OnInit { if (this.mode() !== 'easy' || this.isPatchingSettings) return; this.applyAdvancedPresetFromQuality(quality); }); + this.form.get('nozzleDiameter')?.valueChanges.subscribe((nozzle) => { + if (this.isPatchingSettings) return; + this.updateLayerHeightOptionsForNozzle(nozzle, true); + }); + this.form.valueChanges.subscribe(() => { + if (this.isPatchingSettings) return; + this.emitPrintSettingsChange(); + }); effect(() => { this.applySettingsLock(this.lockedSettings()); @@ -187,6 +212,7 @@ export class UploadFormComponent implements OnInit { const preset = presets[normalized] || presets['standard']; this.form.patchValue(preset, { emitEvent: false }); + this.updateLayerHeightOptionsForNozzle(preset.nozzleDiameter, true); } ngOnInit() { @@ -204,9 +230,19 @@ export class UploadFormComponent implements OnInit { this.infillPatterns.set( options.infillPatterns.map((p) => ({ label: p.label, value: p.id })), ); - this.layerHeights.set( - options.layerHeights.map((l) => ({ label: l.label, value: l.value })), - ); + this.allLayerHeights = options.layerHeights.map((l) => ({ + label: l.label, + value: l.value, + })); + this.layerHeightsByNozzle = {}; + (options.layerHeightsByNozzle || []).forEach((entry) => { + this.layerHeightsByNozzle[this.toNozzleKey(entry.nozzleDiameter)] = + entry.layerHeights.map((layer) => ({ + label: layer.label, + value: layer.value, + })); + }); + this.layerHeights.set(this.allLayerHeights); this.nozzleDiameters.set( options.nozzleDiameters.map((n) => ({ label: n.label, @@ -231,6 +267,11 @@ export class UploadFormComponent implements OnInit { value: 'standard', }, ]); + this.allLayerHeights = [{ label: '0.20 mm', value: 0.2 }]; + this.layerHeightsByNozzle = { + [this.toNozzleKey(0.4)]: this.allLayerHeights, + }; + this.layerHeights.set(this.allLayerHeights); this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]); this.setDefaults(); }, @@ -240,7 +281,16 @@ export class UploadFormComponent implements OnInit { private setDefaults() { // Set Defaults if available if (this.materials().length > 0 && !this.form.get('material')?.value) { - this.form.get('material')?.setValue(this.materials()[0].value); + const exactPla = this.materials().find( + (m) => typeof m.value === 'string' && m.value.toUpperCase() === 'PLA', + ); + const anyPla = this.materials().find( + (m) => + typeof m.value === 'string' && + m.value.toUpperCase().startsWith('PLA'), + ); + const preferredMaterial = exactPla ?? anyPla ?? this.materials()[0]; + this.form.get('material')?.setValue(preferredMaterial.value); } if (this.qualities().length > 0 && !this.form.get('quality')?.value) { // Try to find 'standard' or use first @@ -255,18 +305,20 @@ export class UploadFormComponent implements OnInit { ) { this.form.get('nozzleDiameter')?.setValue(0.4); // Prefer 0.4 } - if ( - this.layerHeights().length > 0 && - !this.form.get('layerHeight')?.value - ) { - this.form.get('layerHeight')?.setValue(0.2); // Prefer 0.2 - } + + this.updateLayerHeightOptionsForNozzle( + this.form.get('nozzleDiameter')?.value, + true, + ); + if ( this.infillPatterns().length > 0 && !this.form.get('infillPattern')?.value ) { this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value); } + + this.emitPrintSettingsChange(); } onFilesDropped(newFiles: File[]) { @@ -369,7 +421,15 @@ export class UploadFormComponent implements OnInit { const input = event.target as HTMLInputElement; const parsed = parseInt(input.value, 10); const quantity = Number.isFinite(parsed) ? parsed : 1; + const currentItem = this.items()[index]; + if (!currentItem) return; + const normalizedQty = this.normalizeQuantity(quantity); this.updateItemQuantityByIndex(index, quantity); + this.itemQuantityChange.emit({ + index, + fileName: currentItem.file.name, + quantity: normalizedQty, + }); } updateItemColor( @@ -514,6 +574,11 @@ export class UploadFormComponent implements OnInit { this.isPatchingSettings = true; this.form.patchValue(patch, { emitEvent: false }); this.isPatchingSettings = false; + this.updateLayerHeightOptionsForNozzle( + this.form.get('nozzleDiameter')?.value, + true, + ); + this.emitPrintSettingsChange(); } onSubmit() { @@ -561,6 +626,86 @@ export class UploadFormComponent implements OnInit { return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? ''; } + private updateLayerHeightOptionsForNozzle( + nozzleValue: unknown, + preserveCurrent: boolean, + ): void { + const key = this.toNozzleKey(nozzleValue); + const nozzleSpecific = this.layerHeightsByNozzle[key] || []; + const available = + nozzleSpecific.length > 0 ? nozzleSpecific : this.allLayerHeights; + this.layerHeights.set(available); + + const control = this.form.get('layerHeight'); + if (!control) return; + + const currentValue = Number(control.value); + const currentAllowed = available.some( + (option) => Math.abs(Number(option.value) - currentValue) < 0.0001, + ); + if (preserveCurrent && currentAllowed) { + return; + } + + const preferred = available.find( + (option) => Math.abs(Number(option.value) - 0.2) < 0.0001, + ); + const next = preferred ?? available[0]; + if (next) { + control.setValue(next.value, { emitEvent: false }); + } + } + + private toNozzleKey(value: unknown): string { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return ''; + return numeric.toFixed(2); + } + + getCurrentRequestDraft(): QuoteRequest | null { + if (this.items().length === 0) return null; + const raw = this.form.getRawValue(); + return { + items: this.items(), + material: raw.material, + quality: raw.quality, + notes: raw.notes, + infillDensity: raw.infillDensity, + infillPattern: raw.infillPattern, + supportEnabled: raw.supportEnabled, + layerHeight: raw.layerHeight, + nozzleDiameter: raw.nozzleDiameter, + mode: this.mode(), + }; + } + + getCurrentPrintSettings(): { + mode: 'easy' | 'advanced'; + material: string; + quality: string; + nozzleDiameter: number; + layerHeight: number; + infillDensity: number; + infillPattern: string; + supportEnabled: boolean; + } { + const raw = this.form.getRawValue(); + return { + mode: this.mode(), + material: String(raw.material || 'PLA'), + quality: String(raw.quality || 'standard'), + nozzleDiameter: Number(raw.nozzleDiameter ?? 0.4), + layerHeight: Number(raw.layerHeight ?? 0.2), + infillDensity: Number(raw.infillDensity ?? 20), + infillPattern: String(raw.infillPattern || 'grid'), + supportEnabled: Boolean(raw.supportEnabled), + }; + } + + private emitPrintSettingsChange(): void { + this.printSettingsChange.emit(this.getCurrentPrintSettings()); + } + private applySettingsLock(locked: boolean): void { const controlsToLock = [ 'material', diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index 7c72852..fed6db7 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -102,12 +102,18 @@ export interface NumericOption { label: string; } +export interface NozzleLayerHeightsOption { + nozzleDiameter: number; + layerHeights: NumericOption[]; +} + export interface OptionsResponse { materials: MaterialOption[]; qualities: QualityOption[]; infillPatterns: InfillOption[]; layerHeights: NumericOption[]; nozzleDiameters: NumericOption[]; + layerHeightsByNozzle?: NozzleLayerHeightsOption[]; } // UI Option for Select Component diff --git a/frontend/src/app/features/contact/contact.routes.ts b/frontend/src/app/features/contact/contact.routes.ts index f8ef5c5..6ee01b5 100644 --- a/frontend/src/app/features/contact/contact.routes.ts +++ b/frontend/src/app/features/contact/contact.routes.ts @@ -5,5 +5,10 @@ export const CONTACT_ROUTES: Routes = [ path: '', loadComponent: () => import('./contact-page.component').then((m) => m.ContactPageComponent), + data: { + seoTitle: 'Contatti | 3D fab', + seoDescription: + 'Richiedi informazioni, preventivi personalizzati o supporto per progetti di stampa 3D.', + }, }, ]; diff --git a/frontend/src/app/features/home/home.component.html b/frontend/src/app/features/home/home.component.html index 2255de4..9284fdf 100644 --- a/frontend/src/app/features/home/home.component.html +++ b/frontend/src/app/features/home/home.component.html @@ -37,28 +37,40 @@
- +

{{ "HOME.CAP_1_TITLE" | translate }}

{{ "HOME.CAP_1_TEXT" | translate }}

- +

{{ "HOME.CAP_2_TITLE" | translate }}

{{ "HOME.CAP_2_TEXT" | translate }}

- +

{{ "HOME.CAP_3_TITLE" | translate }}

{{ "HOME.CAP_3_TEXT" | translate }}

- +

{{ "HOME.CAP_4_TITLE" | translate }}

{{ "HOME.CAP_4_TEXT" | translate }}

diff --git a/frontend/src/app/features/legal/legal.routes.ts b/frontend/src/app/features/legal/legal.routes.ts index 78cc23e..274bfd7 100644 --- a/frontend/src/app/features/legal/legal.routes.ts +++ b/frontend/src/app/features/legal/legal.routes.ts @@ -5,10 +5,20 @@ export const LEGAL_ROUTES: Routes = [ path: 'privacy', loadComponent: () => import('./privacy/privacy.component').then((m) => m.PrivacyComponent), + data: { + seoTitle: 'Privacy Policy | 3D fab', + seoDescription: + 'Informativa privacy di 3D fab: trattamento dati, finalita e contatti.', + }, }, { path: 'terms', loadComponent: () => import('./terms/terms.component').then((m) => m.TermsComponent), + data: { + seoTitle: 'Termini e condizioni | 3D fab', + seoDescription: + 'Termini e condizioni del servizio di stampa 3D e del calcolatore preventivi.', + }, }, ]; diff --git a/frontend/src/app/features/shop/shop.routes.ts b/frontend/src/app/features/shop/shop.routes.ts index 22a7fb3..b94949b 100644 --- a/frontend/src/app/features/shop/shop.routes.ts +++ b/frontend/src/app/features/shop/shop.routes.ts @@ -3,6 +3,22 @@ import { ShopPageComponent } from './shop-page.component'; import { ProductDetailComponent } from './product-detail.component'; export const SHOP_ROUTES: Routes = [ - { path: '', component: ShopPageComponent }, - { path: ':id', component: ProductDetailComponent }, + { + path: '', + component: ShopPageComponent, + 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', + }, + }, + { + path: ':id', + component: ProductDetailComponent, + data: { + seoTitle: 'Prodotto | 3D fab', + seoRobots: 'noindex, nofollow', + }, + }, ]; diff --git a/frontend/src/index.html b/frontend/src/index.html index 91a4429..140c33c 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -2,8 +2,12 @@ - - 3D fab + 3D fab | Stampa 3D su misura + +