23 Commits

Author SHA1 Message Date
c1652798b4 feat(back-end front-end): new UX for
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 32s
Build, Test and Deploy / build-and-push (push) Successful in 39s
Build, Test and Deploy / deploy (push) Successful in 10s
2026-02-23 18:56:24 +01:00
ec4d512136 feat(back-end front-end): uuid truncated for better UX
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 36s
Build, Test and Deploy / build-and-push (push) Successful in 41s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-23 17:30:43 +01:00
abf47e0003 feat(back-end): email service and test
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 33s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-23 16:20:11 +01:00
0438ba3ae5 feat(back-end): email service and test
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 49s
Build, Test and Deploy / build-and-push (push) Successful in 55s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-23 15:23:11 +01:00
c3f9539988 feat(front-enc):
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 23s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-20 17:47:34 +01:00
1d82230564 feat(front-enc): fix back-ground
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 37s
Build, Test and Deploy / build-and-push (push) Successful in 39s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-20 17:11:44 +01:00
15d5d31d06 feat(back-end, front-enc): twint payment 2026-02-20 17:09:42 +01:00
ccc53b7d4f feat(back-end): bill and qr
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 1m8s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-20 14:54:28 +01:00
8e12b3bcdf feat(back-end): add ClamAV service remember to add env and compose.deploy on server 2026-02-20 10:32:07 +01:00
0d23521cac feat(back-end): add ClamAV service remember to add env and compose.deploy on server 2026-02-19 15:59:33 +01:00
2189e58cc6 fix(deploy): update env
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 26s
Build, Test and Deploy / build-and-push (push) Successful in 13s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-18 22:04:22 +01:00
87f43f2239 fix(deploy): update env
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 27s
Build, Test and Deploy / build-and-push (push) Successful in 13s
Build, Test and Deploy / deploy (push) Failing after 9s
2026-02-18 22:00:06 +01:00
0ddfed4f07 fix(back-end): try fix profile manager
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 26s
Build, Test and Deploy / build-and-push (push) Successful in 14s
Build, Test and Deploy / deploy (push) Failing after 5s
2026-02-18 21:57:23 +01:00
e7daf79394 fix(back-end): try fix profile manager
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 26s
Build, Test and Deploy / build-and-push (push) Successful in 14s
Build, Test and Deploy / deploy (push) Failing after 5s
2026-02-18 21:53:34 +01:00
7bb94da45b fix(back-end): try fix profile manager
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 28s
Build, Test and Deploy / build-and-push (push) Successful in 17s
Build, Test and Deploy / deploy (push) Failing after 4s
2026-02-18 21:48:09 +01:00
d28609ee95 fix(back-end): try fix profile manager
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 28s
Build, Test and Deploy / build-and-push (push) Successful in 20s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-18 20:07:41 +01:00
8364ad0671 fix(back-end): try fix profile manager
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 27s
Build, Test and Deploy / build-and-push (push) Successful in 18s
Build, Test and Deploy / deploy (push) Failing after 7s
2026-02-18 19:50:21 +01:00
797b10e4ad fix(back-end): try fix profile manager
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 27s
Build, Test and Deploy / build-and-push (push) Has been cancelled
Build, Test and Deploy / deploy (push) Has been cancelled
2026-02-18 19:49:56 +01:00
ec77b76abb fix(back-end): try fix profile manager
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 25s
Build, Test and Deploy / build-and-push (push) Successful in 28s
Build, Test and Deploy / deploy (push) Successful in 10s
2026-02-18 19:33:57 +01:00
bb269d84a5 fix(back-end): shift model
Some checks failed
Build, Test and Deploy / build-and-push (push) Has been cancelled
Build, Test and Deploy / deploy (push) Has been cancelled
Build, Test and Deploy / test-backend (push) Has been cancelled
2026-02-17 16:34:40 +01:00
46eb980e24 fix(back-end): shift model
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m13s
Build, Test and Deploy / build-and-push (push) Successful in 28s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-17 16:32:46 +01:00
85a4db1630 fix(back-end): shift model
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m13s
Build, Test and Deploy / build-and-push (push) Successful in 29s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-17 16:25:21 +01:00
701a10e886 fix(back-end): shift model
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 2m31s
Build, Test and Deploy / build-and-push (push) Successful in 1m47s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-17 15:35:17 +01:00
87 changed files with 3100 additions and 2331 deletions

View File

@@ -125,13 +125,23 @@ jobs:
ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
- name: Write env to server
- name: Write env and compose to server
shell: bash
run: |
# 1. Start with the static env file content
# 1. Recalculate TAG and OWNER_LOWER (jobs don't share ENV)
if [[ "${{ gitea.ref }}" == "refs/heads/main" ]]; then
DEPLOY_TAG="prod"
elif [[ "${{ gitea.ref }}" == "refs/heads/int" ]]; then
DEPLOY_TAG="int"
else
DEPLOY_TAG="dev"
fi
DEPLOY_OWNER=$(echo '${{ gitea.repository_owner }}' | tr '[:upper:]' '[:lower:]')
# 2. Start with the static env file content
cat "deploy/envs/${{ env.ENV }}.env" > /tmp/full_env.env
# 2. Determine DB credentials
# 3. Determine DB credentials
if [[ "${{ env.ENV }}" == "prod" ]]; then
DB_URL="${{ secrets.DB_URL_PROD }}"
DB_USER="${{ secrets.DB_USERNAME_PROD }}"
@@ -146,18 +156,25 @@ jobs:
DB_PASS="${{ secrets.DB_PASSWORD_DEV }}"
fi
# 3. Append DB credentials
printf '\nDB_URL=%s\nDB_USERNAME=%s\nDB_PASSWORD=%s\n' \
# 4. Append DB and Docker credentials (quoted)
printf '\nDB_URL="%s"\nDB_USERNAME="%s"\nDB_PASSWORD="%s"\n' \
"$DB_URL" "$DB_USER" "$DB_PASS" >> /tmp/full_env.env
# 4. Debug: print content (for debug purposes)
printf 'REGISTRY_URL="%s"\nREPO_OWNER="%s"\nTAG="%s"\n' \
"${{ secrets.REGISTRY_URL }}" "$DEPLOY_OWNER" "$DEPLOY_TAG" >> /tmp/full_env.env
# 5. Debug: print content (for debug purposes)
echo "Preparing to send env file with variables:"
grep -v "PASSWORD" /tmp/full_env.env || true
# 5. Send to server
# 5. Send env to server
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
"setenv ${{ env.ENV }}" < /tmp/full_env.env
# 6. Send docker-compose.deploy.yml to server
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
"setcompose ${{ env.ENV }}" < docker-compose.deploy.yml
- name: Trigger deploy on Unraid (forced command key)

5
.gitignore vendored
View File

@@ -41,3 +41,8 @@ target/
build/
.gradle/
.mvn/
./storage_orders
./storage_quotes
storage_orders
storage_quotes

View File

@@ -11,7 +11,7 @@ RUN ./gradlew bootJar -x test --no-daemon
# Stage 2: Runtime Environment
FROM eclipse-temurin:21-jre-jammy
# Install system dependencies for OrcaSlicer
# Install system dependencies for OrcaSlicer (same as before)
RUN apt-get update && apt-get install -y \
wget \
p7zip-full \
@@ -20,14 +20,6 @@ RUN apt-get update && apt-get install -y \
libgtk-3-0 \
libdbus-1-3 \
libwebkit2gtk-4.0-37 \
libx11-xcb1 \
libxcb-dri3-0 \
libxtst6 \
libnss3 \
libatk-bridge2.0-0 \
libxss1 \
libasound2 \
libgbm1 \
&& rm -rf /var/lib/apt/lists/*
# Install OrcaSlicer

View File

@@ -37,6 +37,7 @@ dependencies {
implementation 'io.github.openhtmltopdf:openhtmltopdf-svg-support:1.1.37'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0'
implementation 'org.springframework.boot:spring-boot-starter-mail'

View File

@@ -3,13 +3,25 @@ echo "----------------------------------------------------------------"
echo "Starting Backend Application"
echo "DB_URL: $DB_URL"
echo "DB_USERNAME: $DB_USERNAME"
echo "SPRING_DATASOURCE_URL: $SPRING_DATASOURCE_URL"
echo "SLICER_PATH: $SLICER_PATH"
echo "--- ALL ENV VARS ---"
env
echo "----------------------------------------------------------------"
# Exec java with explicit properties from env
exec java -jar app.jar \
--spring.datasource.url="${DB_URL}" \
--spring.datasource.username="${DB_USERNAME}" \
--spring.datasource.password="${DB_PASSWORD}"
# Determine which environment variables to use for database connection
# This allows compatibility with different docker-compose configurations
FINAL_DB_URL="${DB_URL:-$SPRING_DATASOURCE_URL}"
FINAL_DB_USER="${DB_USERNAME:-$SPRING_DATASOURCE_USERNAME}"
FINAL_DB_PASS="${DB_PASSWORD:-$SPRING_DATASOURCE_PASSWORD}"
if [ -n "$FINAL_DB_URL" ]; then
echo "Using database URL: $FINAL_DB_URL"
exec java -jar app.jar \
--spring.datasource.url="${FINAL_DB_URL}" \
--spring.datasource.username="${FINAL_DB_USER}" \
--spring.datasource.password="${FINAL_DB_PASS}"
else
echo "No database URL specified in environment, relying on application.properties defaults."
exec java -jar app.jar
fi

File diff suppressed because one or more lines are too long

View File

@@ -3,12 +3,14 @@ package com.printcalculator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication
@EnableTransactionManagement
@EnableScheduling
@EnableAsync
public class BackendApplication {
public static void main(String[] args) {

View File

@@ -25,15 +25,17 @@ public class CustomQuoteRequestController {
private final CustomQuoteRequestRepository requestRepo;
private final CustomQuoteRequestAttachmentRepository attachmentRepo;
private final com.printcalculator.service.ClamAVService clamAVService;
private final com.printcalculator.service.StorageService storageService;
// TODO: Inject Storage Service
private static final String STORAGE_ROOT = "storage_requests";
public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
CustomQuoteRequestAttachmentRepository attachmentRepo,
com.printcalculator.service.StorageService storageService) {
com.printcalculator.service.ClamAVService clamAVService) {
this.requestRepo = requestRepo;
this.attachmentRepo = attachmentRepo;
this.storageService = storageService;
this.clamAVService = clamAVService;
}
// 1. Create Custom Quote Request
@@ -69,6 +71,9 @@ public class CustomQuoteRequestController {
for (MultipartFile file : files) {
if (file.isEmpty()) continue;
// Scan for virus
clamAVService.scan(file.getInputStream());
CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment();
attachment.setRequest(request);
attachment.setOriginalFilename(file.getOriginalFilename());
@@ -92,8 +97,10 @@ public class CustomQuoteRequestController {
attachment.setStoredRelativePath(relativePath);
attachmentRepo.save(attachment);
// Save file to disk via StorageService
storageService.store(file, Paths.get(relativePath));
// Save file to disk
Path absolutePath = Paths.get(STORAGE_ROOT, relativePath);
Files.createDirectories(absolutePath.getParent());
Files.copy(file.getInputStream(), absolutePath);
}
}

View File

@@ -0,0 +1,47 @@
package com.printcalculator.controller;
import org.springframework.context.annotation.Profile;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/dev/email")
@Profile("local")
public class DevEmailTestController {
private final TemplateEngine templateEngine;
public DevEmailTestController(TemplateEngine templateEngine) {
this.templateEngine = templateEngine;
}
@GetMapping("/test-template")
public ResponseEntity<String> testTemplate() {
Context context = new Context();
Map<String, Object> templateData = new HashMap<>();
UUID orderId = UUID.randomUUID();
templateData.put("customerName", "Mario Rossi");
templateData.put("orderId", orderId);
templateData.put("orderNumber", orderId.toString().split("-")[0]);
templateData.put("orderDetailsUrl", "https://tuosito.it/ordine/" + orderId);
templateData.put("orderDate", OffsetDateTime.now().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")));
templateData.put("totalCost", "45.50");
context.setVariables(templateData);
String html = templateEngine.process("email/order-confirmation", context);
return ResponseEntity.ok()
.header("Content-Type", "text/html; charset=utf-8")
.body(html);
}
}

View File

@@ -65,20 +65,6 @@ public class OptionsController {
.filter(m -> m != null)
.collect(Collectors.toList());
// Sort: PLA first, then PETG, then others alphabetically
materialOptions.sort((a, b) -> {
String codeA = a.code();
String codeB = b.code();
if (codeA.equals("pla_basic")) return -1;
if (codeB.equals("pla_basic")) return 1;
if (codeA.equals("petg_basic")) return -1;
if (codeB.equals("petg_basic")) return 1;
return codeA.compareTo(codeB);
});
// 2. Qualities (Static as per user request)
List<OptionsResponse.QualityOption> qualities = List.of(
new OptionsResponse.QualityOption("draft", "Draft"),

View File

@@ -5,8 +5,10 @@ import com.printcalculator.entity.*;
import com.printcalculator.repository.*;
import com.printcalculator.service.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 org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
@@ -24,7 +26,9 @@ import java.util.List;
import java.util.UUID;
import java.util.Map;
import java.util.HashMap;
import java.util.Base64;
import java.util.stream.Collectors;
import java.net.URI;
@RestController
@RequestMapping("/api/orders")
@@ -39,6 +43,9 @@ public class OrderController {
private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService;
private final TwintPaymentService twintPaymentService;
private final PaymentService paymentService;
private final PaymentRepository paymentRepo;
public OrderController(OrderService orderService,
@@ -49,7 +56,10 @@ public class OrderController {
CustomerRepository customerRepo,
StorageService storageService,
InvoicePdfRenderingService invoiceService,
QrBillService qrBillService) {
QrBillService qrBillService,
TwintPaymentService twintPaymentService,
PaymentService paymentService,
PaymentRepository paymentRepo) {
this.orderService = orderService;
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
@@ -59,6 +69,9 @@ public class OrderController {
this.storageService = storageService;
this.invoiceService = invoiceService;
this.qrBillService = qrBillService;
this.twintPaymentService = twintPaymentService;
this.paymentService = paymentService;
this.paymentRepo = paymentRepo;
}
@@ -116,6 +129,17 @@ public class OrderController {
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/{orderId}/payments/report")
@Transactional
public ResponseEntity<OrderDto> reportPayment(
@PathVariable UUID orderId,
@RequestBody Map<String, String> payload
) {
String method = payload.get("method");
paymentService.reportPayment(orderId, method);
return getOrder(orderId);
}
@GetMapping("/{orderId}/invoice")
public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) {
Order order = orderRepo.findById(orderId)
@@ -129,7 +153,7 @@ public class OrderController {
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
vars.put("sellerEmail", "info@3dfab.ch");
vars.put("invoiceNumber", "INV-" + order.getId().toString().substring(0, 8).toUpperCase());
vars.put("invoiceNumber", "INV-" + getDisplayOrderNumber(order).toUpperCase());
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
@@ -181,11 +205,56 @@ public class OrderController {
byte[] pdf = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"invoice-" + orderId + ".pdf\"")
.header("Content-Disposition", "attachment; filename=\"invoice-" + getDisplayOrderNumber(order) + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
@GetMapping("/{orderId}/twint")
public ResponseEntity<Map<String, String>> getTwintPayment(@PathVariable UUID orderId) {
if (!orderRepo.existsById(orderId)) {
return ResponseEntity.notFound().build();
}
byte[] qrPng = twintPaymentService.generateQrPng(360);
String qrDataUri = "data:image/png;base64," + Base64.getEncoder().encodeToString(qrPng);
Map<String, String> data = new HashMap<>();
data.put("paymentUrl", twintPaymentService.getTwintPaymentUrl());
data.put("openUrl", "/api/orders/" + orderId + "/twint/open");
data.put("qrImageUrl", "/api/orders/" + orderId + "/twint/qr");
data.put("qrImageDataUri", qrDataUri);
return ResponseEntity.ok(data);
}
@GetMapping("/{orderId}/twint/open")
public ResponseEntity<Void> openTwintPayment(@PathVariable UUID orderId) {
if (!orderRepo.existsById(orderId)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.status(302)
.location(URI.create(twintPaymentService.getTwintPaymentUrl()))
.build();
}
@GetMapping("/{orderId}/twint/qr")
public ResponseEntity<byte[]> getTwintQr(
@PathVariable UUID orderId,
@RequestParam(defaultValue = "320") int size
) {
if (!orderRepo.existsById(orderId)) {
return ResponseEntity.notFound().build();
}
int normalizedSize = Math.max(200, Math.min(size, 600));
byte[] png = twintPaymentService.generateQrPng(normalizedSize);
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_PNG)
.body(png);
}
private String getExtension(String filename) {
if (filename == null) return "stl";
int i = filename.lastIndexOf('.');
@@ -198,7 +267,14 @@ public class OrderController {
private OrderDto convertToDto(Order order, List<OrderItem> items) {
OrderDto dto = new OrderDto();
dto.setId(order.getId());
dto.setOrderNumber(getDisplayOrderNumber(order));
dto.setStatus(order.getStatus());
paymentRepo.findByOrder_Id(order.getId()).ifPresent(p -> {
dto.setPaymentStatus(p.getStatus());
dto.setPaymentMethod(p.getMethod());
});
dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone());
dto.setBillingCustomerType(order.getBillingCustomerType());
@@ -255,4 +331,12 @@ public class OrderController {
return dto;
}
private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) {
return orderNumber;
}
return order.getId() != null ? order.getId().toString() : "unknown";
}
}

View File

@@ -1,15 +1,11 @@
package com.printcalculator.controller;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.exception.ModelTooLargeException;
import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
import com.printcalculator.model.StlBounds;
import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.ProfileManager;
import com.printcalculator.service.SlicerService;
import com.printcalculator.service.StlService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@@ -19,29 +15,24 @@ import java.util.HashMap;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.logging.Logger;
@RestController
public class QuoteController {
private static final Logger logger = Logger.getLogger(QuoteController.class.getName());
private final SlicerService slicerService;
private final StlService stlService;
private final QuoteCalculator quoteCalculator;
private final PrinterMachineRepository machineRepo;
private final ProfileManager profileManager;
private final com.printcalculator.service.ClamAVService clamAVService;
// 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, StlService stlService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, ProfileManager profileManager) {
public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, com.printcalculator.service.ClamAVService clamAVService) {
this.slicerService = slicerService;
this.stlService = stlService;
this.quoteCalculator = quoteCalculator;
this.machineRepo = machineRepo;
this.profileManager = profileManager;
this.clamAVService = clamAVService;
}
@PostMapping("/api/quote")
@@ -55,7 +46,7 @@ public class QuoteController {
@RequestParam(value = "infill_pattern", required = false) String infillPattern,
@RequestParam(value = "layer_height", required = false) Double layerHeight,
@RequestParam(value = "nozzle_diameter", required = false) Double nozzleDiameter,
@RequestParam(value = "support_enabled", required = false, defaultValue = "false") Boolean supportEnabled
@RequestParam(value = "support_enabled", required = false) Boolean supportEnabled
) throws IOException {
// ... process selection logic ...
@@ -83,9 +74,6 @@ public class QuoteController {
}
if (supportEnabled != null) {
processOverrides.put("enable_support", supportEnabled ? "1" : "0");
if (supportEnabled) {
processOverrides.put("support_threshold_angle", "45");
}
}
if (nozzleDiameter != null) {
@@ -95,7 +83,7 @@ public class QuoteController {
// For now, we trust the override key works on the base profile.
}
return processRequest(file, filament, actualProcess, machineOverrides, processOverrides, nozzleDiameter);
return processRequest(file, filament, actualProcess, machineOverrides, processOverrides);
}
@PostMapping("/calculate/stl")
@@ -103,91 +91,42 @@ public class QuoteController {
@RequestParam("file") MultipartFile file
) throws IOException {
// Legacy endpoint uses defaults
return processRequest(file, DEFAULT_FILAMENT, DEFAULT_PROCESS, null, null, null);
return processRequest(file, DEFAULT_FILAMENT, DEFAULT_PROCESS, null, null);
}
private ResponseEntity<QuoteResult> processRequest(MultipartFile file, String filament, String process,
Map<String, String> machineOverrides,
Map<String, String> processOverrides,
Double nozzleDiameter) throws IOException {
Map<String, String> processOverrides) throws IOException {
if (file.isEmpty()) {
return ResponseEntity.badRequest().build();
}
// Scan for virus
clamAVService.scan(file.getInputStream());
// Fetch Default Active Machine
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new IOException("No active printer found in database"));
// Save uploaded file temporarily
Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename());
com.printcalculator.model.StlShiftResult shift = null;
try {
file.transferTo(tempInput.toFile());
// Use profile from machine or fallback
String slicerMachineProfile = machine.getSlicerMachineProfile();
if (slicerMachineProfile == null || slicerMachineProfile.isEmpty()) {
slicerMachineProfile = "bambu_a1";
}
slicerMachineProfile = profileManager.resolveMachineProfileName(slicerMachineProfile, nozzleDiameter);
String slicerMachineProfile = "bambu_a1"; // TODO: Add to PrinterMachine entity
// Validate model size against machine volume
StlBounds bounds = validateModelSize(tempInput.toFile(), machine);
// Auto-center if needed
shift = stlService.shiftToFitIfNeeded(
tempInput.toFile(),
bounds,
machine.getBuildVolumeXMm(),
machine.getBuildVolumeYMm(),
machine.getBuildVolumeZMm()
);
java.io.File sliceInput = shift.shifted() ? shift.shiftedPath().toFile() : tempInput.toFile();
if (shift.shifted()) {
logger.info(String.format("Auto-centered STL by offset (mm): x=%.3f y=%.3f z=%.3f",
shift.offsetX(), shift.offsetY(), shift.offsetZ()));
}
PrintStats stats = slicerService.slice(sliceInput, slicerMachineProfile, filament, process, machineOverrides, processOverrides);
PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides);
// Calculate Quote (Pass machine display name for pricing lookup)
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filament);
return ResponseEntity.ok(result);
} catch (Exception e) {
e.printStackTrace();
return ResponseEntity.internalServerError().build();
} finally {
Files.deleteIfExists(tempInput);
if (shift != null && shift.shifted()) {
try {
Files.deleteIfExists(shift.shiftedPath());
} catch (Exception ignored) {}
}
}
}
private StlBounds validateModelSize(java.io.File stlFile, PrinterMachine machine) throws IOException {
StlBounds bounds = stlService.readBounds(stlFile);
double x = bounds.sizeX();
double y = bounds.sizeY();
double z = bounds.sizeZ();
int bx = machine.getBuildVolumeXMm();
int by = machine.getBuildVolumeYMm();
int bz = machine.getBuildVolumeZMm();
logger.info(String.format(
"STL bounds (mm): min(%.3f,%.3f,%.3f) max(%.3f,%.3f,%.3f) size(%.3f,%.3f,%.3f) bed(%d,%d,%d)",
bounds.minX(), bounds.minY(), bounds.minZ(),
bounds.maxX(), bounds.maxY(), bounds.maxZ(),
x, y, z, bx, by, bz
));
double eps = 0.01;
boolean fits = (x <= bx + eps && y <= by + eps && z <= bz + eps)
|| (y <= bx + eps && x <= by + eps && z <= bz + eps);
if (!fits) {
throw new ModelTooLargeException(x, y, z, bx, by, bz);
}
return bounds;
}
}

View File

@@ -3,17 +3,13 @@ package com.printcalculator.controller;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.exception.ModelTooLargeException;
import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
import com.printcalculator.model.StlBounds;
import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.ProfileManager;
import com.printcalculator.service.SlicerService;
import com.printcalculator.service.StlService;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
@@ -32,24 +28,19 @@ import java.util.Map;
import java.util.UUID;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import java.util.logging.Logger;
@RestController
@RequestMapping("/api/quote-sessions")
public class QuoteSessionController {
private static final Logger logger = Logger.getLogger(QuoteSessionController.class.getName());
private final QuoteSessionRepository sessionRepo;
private final QuoteLineItemRepository lineItemRepo;
private final SlicerService slicerService;
private final StlService stlService;
private final QuoteCalculator quoteCalculator;
private final ProfileManager profileManager;
private final PrinterMachineRepository machineRepo;
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
private final com.printcalculator.service.StorageService storageService;
private final com.printcalculator.service.ClamAVService clamAVService;
// Defaults
private static final String DEFAULT_FILAMENT = "pla_basic";
@@ -58,21 +49,17 @@ public class QuoteSessionController {
public QuoteSessionController(QuoteSessionRepository sessionRepo,
QuoteLineItemRepository lineItemRepo,
SlicerService slicerService,
StlService stlService,
QuoteCalculator quoteCalculator,
ProfileManager profileManager,
PrinterMachineRepository machineRepo,
com.printcalculator.repository.PricingPolicyRepository pricingRepo,
com.printcalculator.service.StorageService storageService) {
com.printcalculator.service.ClamAVService clamAVService) {
this.sessionRepo = sessionRepo;
this.lineItemRepo = lineItemRepo;
this.slicerService = slicerService;
this.stlService = stlService;
this.quoteCalculator = quoteCalculator;
this.profileManager = profileManager;
this.machineRepo = machineRepo;
this.pricingRepo = pricingRepo;
this.storageService = storageService;
this.clamAVService = clamAVService;
}
// 1. Start a new empty session
@@ -115,8 +102,13 @@ public class QuoteSessionController {
private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException {
if (file.isEmpty()) throw new IOException("File is empty");
// Scan for virus
clamAVService.scan(file.getInputStream());
// 1. Define Persistent Storage Path
// Structure: quotes/{sessionId}/{uuid}.{ext} (inside storage root)
// Structure: storage_quotes/{sessionId}/{uuid}.{ext}
String storageDir = "storage_quotes/" + session.getId();
Files.createDirectories(Paths.get(storageDir));
String originalFilename = file.getOriginalFilename();
String ext = originalFilename != null && originalFilename.contains(".")
@@ -124,15 +116,11 @@ public class QuoteSessionController {
: ".stl";
String storedFilename = UUID.randomUUID() + ext;
Path relativePath = Paths.get("quotes", session.getId().toString(), storedFilename);
Path persistentPath = Paths.get(storageDir, storedFilename);
// Save file
storageService.store(file, relativePath);
Files.copy(file.getInputStream(), persistentPath);
// Resolve absolute path for slicing and storage usage
Path persistentPath = storageService.loadAsResource(relativePath).getFile().toPath();
com.printcalculator.model.StlShiftResult shift = null;
try {
// Apply Basic/Advanced Logic
applyPrintSettings(settings);
@@ -142,32 +130,12 @@ public class QuoteSessionController {
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new RuntimeException("No active printer found"));
// 2. Validate model size against machine volume
StlBounds bounds = validateModelSize(persistentPath.toFile(), machine);
// 2b. Auto-center if needed (keeps the stored STL unchanged)
shift = stlService.shiftToFitIfNeeded(
persistentPath.toFile(),
bounds,
machine.getBuildVolumeXMm(),
machine.getBuildVolumeYMm(),
machine.getBuildVolumeZMm()
);
java.io.File sliceInput = shift.shifted() ? shift.shiftedPath().toFile() : persistentPath.toFile();
if (shift.shifted()) {
logger.info(String.format("Auto-centered STL by offset (mm): x=%.3f y=%.3f z=%.3f",
shift.offsetX(), shift.offsetY(), shift.offsetZ()));
}
// 3. Pick Profiles
String machineProfile = machine.getSlicerMachineProfile();
if (machineProfile == null || machineProfile.isBlank()) {
machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle"
}
if (machineProfile == null || machineProfile.isBlank()) {
machineProfile = "bambu_a1"; // final fallback (alias handled in ProfileManager)
}
machineProfile = profileManager.resolveMachineProfileName(machineProfile, settings.getNozzleDiameter());
// 2. Pick Profiles
String machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle"
// If the display name doesn't match the json profile name, we might need a mapping key in DB.
// For now assuming display name works or we use a tough default
machineProfile = "Bambu Lab A1 0.4 nozzle"; // Force known good for now? Or use DB field if exists.
// Ideally: machine.getSlicerProfileName();
String filamentProfile = "Generic " + (settings.getMaterial() != null ? settings.getMaterial().toUpperCase() : "PLA");
// Mapping: "pla_basic" -> "Generic PLA", "petg_basic" -> "Generic PETG"
@@ -176,25 +144,8 @@ public class QuoteSessionController {
else if (settings.getMaterial().toLowerCase().contains("petg")) filamentProfile = "Generic PETG";
else if (settings.getMaterial().toLowerCase().contains("tpu")) filamentProfile = "Generic TPU";
else if (settings.getMaterial().toLowerCase().contains("abs")) filamentProfile = "Generic ABS";
// Update Session Material
session.setMaterialCode(settings.getMaterial());
} else {
// Fallback if null?
session.setMaterialCode("pla_basic");
}
// Update Session Settings for Persistence
if (settings.getNozzleDiameter() != null) session.setNozzleDiameterMm(BigDecimal.valueOf(settings.getNozzleDiameter()));
if (settings.getLayerHeight() != null) session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight()));
if (settings.getInfillDensity() != null) session.setInfillPercent(settings.getInfillDensity().intValue());
if (settings.getInfillPattern() != null) session.setInfillPattern(settings.getInfillPattern());
if (settings.getSupportsEnabled() != null) session.setSupportsEnabled(settings.getSupportsEnabled());
if (settings.getNotes() != null) session.setNotes(settings.getNotes());
// Save session updates
sessionRepo.save(session);
String processProfile = "0.20mm Standard @BBL A1";
// Mapping quality to process
// "standard" -> "0.20mm Standard @BBL A1"
@@ -206,40 +157,26 @@ public class QuoteSessionController {
else if (settings.getLayerHeight() <= 0.12) processProfile = "0.12mm Fine @BBL A1";
}
// Build overrides map from settings
// Build overrides map from settings
Map<String, String> processOverrides = new HashMap<>();
if (settings.getLayerHeight() != null) processOverrides.put("layer_height", String.valueOf(settings.getLayerHeight()));
if (settings.getInfillDensity() != null) processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%");
if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern());
if (settings.getSupportsEnabled() != null) {
processOverrides.put("enable_support", settings.getSupportsEnabled() ? "1" : "0");
// If enabled, use a more permissive threshold (45 deg) by default
// to avoid expensive supports on things that don't strictly need them
if (settings.getSupportsEnabled()) {
processOverrides.put("support_threshold_angle", "45");
}
}
Map<String, String> machineOverrides = new HashMap<>();
if (settings.getNozzleDiameter() != null) {
machineOverrides.put("nozzle_diameter", String.valueOf(settings.getNozzleDiameter()));
}
// 4. Slice (Use persistent path)
// 3. Slice (Use persistent path)
PrintStats stats = slicerService.slice(
sliceInput,
persistentPath.toFile(),
machineProfile,
filamentProfile,
processProfile,
machineOverrides, // machine overrides
null, // machine overrides
processOverrides
);
// 5. Calculate Quote
// 4. Calculate Quote
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
// 6. Create Line Item
// 5. Create Line Item
QuoteLineItem item = new QuoteLineItem();
item.setQuoteSession(session);
item.setOriginalFilename(file.getOriginalFilename());
@@ -248,8 +185,8 @@ public class QuoteSessionController {
item.setColorCode(settings.getColor() != null ? settings.getColor() : "#FFFFFF");
item.setStatus("READY"); // or CALCULATED
item.setPrintTimeSeconds((int) stats.getPrintTimeSeconds());
item.setMaterialGrams(BigDecimal.valueOf(stats.getFilamentWeightGrams()));
item.setPrintTimeSeconds((int) stats.printTimeSeconds());
item.setMaterialGrams(BigDecimal.valueOf(stats.filamentWeightGrams()));
item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice()));
// Store breakdown
@@ -259,10 +196,14 @@ public class QuoteSessionController {
breakdown.put("setup_fee", result.getSetupCost());
item.setPricingBreakdown(breakdown);
// Dimensions from STL
item.setBoundingBoxXMm(BigDecimal.valueOf(bounds.sizeX()));
item.setBoundingBoxYMm(BigDecimal.valueOf(bounds.sizeY()));
item.setBoundingBoxZMm(BigDecimal.valueOf(bounds.sizeZ()));
// Dimensions
// Cannot get bb from GCodeParser yet?
// If GCodeParser doesn't return size, we might defaults or 0.
// Stats has filament used.
// Let's set dummy for now or upgrade parser later.
item.setBoundingBoxXMm(BigDecimal.ZERO);
item.setBoundingBoxYMm(BigDecimal.ZERO);
item.setBoundingBoxZMm(BigDecimal.ZERO);
item.setCreatedAt(OffsetDateTime.now());
item.setUpdatedAt(OffsetDateTime.now());
@@ -271,46 +212,11 @@ public class QuoteSessionController {
} catch (Exception e) {
// Cleanup if failed
try {
storageService.delete(Paths.get("quotes", session.getId().toString(), storedFilename));
} catch (Exception ignored) {}
Files.deleteIfExists(persistentPath);
throw e;
} finally {
if (shift != null && shift.shifted()) {
try {
Files.deleteIfExists(shift.shiftedPath());
} catch (Exception ignored) {}
}
}
}
private StlBounds validateModelSize(java.io.File stlFile, PrinterMachine machine) throws IOException {
StlBounds bounds = stlService.readBounds(stlFile);
double x = bounds.sizeX();
double y = bounds.sizeY();
double z = bounds.sizeZ();
int bx = machine.getBuildVolumeXMm();
int by = machine.getBuildVolumeYMm();
int bz = machine.getBuildVolumeZMm();
logger.info(String.format(
"STL bounds (mm): min(%.3f,%.3f,%.3f) max(%.3f,%.3f,%.3f) size(%.3f,%.3f,%.3f) bed(%d,%d,%d)",
bounds.minX(), bounds.minY(), bounds.minZ(),
bounds.maxX(), bounds.maxY(), bounds.maxZ(),
x, y, z, bx, by, bz
));
double eps = 0.01;
boolean fits = (x <= bx + eps && y <= by + eps && z <= bz + eps)
|| (y <= bx + eps && x <= by + eps && z <= bz + eps);
if (!fits) {
throw new ModelTooLargeException(x, y, z, bx, by, bz);
}
return bounds;
}
private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) {
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
// Set defaults based on Quality
@@ -321,30 +227,24 @@ public class QuoteSessionController {
settings.setLayerHeight(0.28);
settings.setInfillDensity(15.0);
settings.setInfillPattern("grid");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
break;
case "high":
settings.setLayerHeight(0.12);
settings.setInfillDensity(20.0);
settings.setInfillPattern("gyroid");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
break;
case "standard":
default:
settings.setLayerHeight(0.20);
settings.setInfillDensity(20.0);
settings.setInfillPattern("grid");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
break;
}
if (settings.getSupportsEnabled() == null) settings.setSupportsEnabled(false);
} 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");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
if (settings.getSupportsEnabled() == null) settings.setSupportsEnabled(false);
}
}
@@ -436,24 +336,6 @@ public class QuoteSessionController {
}
Path path = Paths.get(item.getStoredPath());
// Since storedPath is absolute, we can't directly use loadAsResource with it unless we resolve relative.
// But loadAsResource expects relative path?
// Actually FileSystemStorageService.loadAsResource uses rootLocation.resolve(path).
// If path is absolute, resolve might fail or behave weirdly.
// But wait, we stored absolute path in DB: item.setStoredPath(persistentPath.toString());
// If we want to use storageService.loadAsResource, we need the relative path.
// Or we just access the file directly if we trust the absolute path.
// But we want to use StorageService abstraction.
// Option 1: Reconstruct relative path.
// We know structure: quotes/{sessionId}/{filename}...
// But filename is UUID+ext. We don't have storedFilename in QuoteLineItem easily?
// QuoteLineItem doesn't seem to have storedFilename field, only storedPath.
// If we trust the file is on disk, we can use UrlResource directly here as before,
// relying on the fact that storedPath is the absolute path to the file.
// But we should verify it exists.
if (!Files.exists(path)) {
return ResponseEntity.notFound().build();
}

View File

@@ -7,7 +7,10 @@ import java.util.UUID;
public class OrderDto {
private UUID id;
private String orderNumber;
private String status;
private String paymentStatus;
private String paymentMethod;
private String customerEmail;
private String customerPhone;
private String billingCustomerType;
@@ -27,9 +30,18 @@ public class OrderDto {
public UUID getId() { return id; }
public void setId(UUID id) { this.id = id; }
public String getOrderNumber() { return orderNumber; }
public void setOrderNumber(String orderNumber) { this.orderNumber = orderNumber; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getPaymentStatus() { return paymentStatus; }
public void setPaymentStatus(String paymentStatus) { this.paymentStatus = paymentStatus; }
public String getPaymentMethod() { return paymentMethod; }
public void setPaymentMethod(String paymentMethod) { this.paymentMethod = paymentMethod; }
public String getCustomerEmail() { return customerEmail; }
public void setCustomerEmail(String customerEmail) { this.customerEmail = customerEmail; }

View File

@@ -18,7 +18,6 @@ public class PrintSettingsDto {
private Double layerHeight;
private Double infillDensity;
private String infillPattern;
private Boolean supportsEnabled = true;
private Double nozzleDiameter;
private Boolean supportsEnabled;
private String notes;
}

View File

@@ -138,6 +138,16 @@ public class Order {
this.id = id;
}
@Transient
public String getOrderNumber() {
if (id == null) {
return null;
}
String rawId = id.toString();
int dashIndex = rawId.indexOf('-');
return dashIndex > 0 ? rawId.substring(0, dashIndex) : rawId;
}
public QuoteSession getSourceQuoteSession() {
return sourceQuoteSession;
}
@@ -410,5 +420,4 @@ public class Order {
this.paidAt = paidAt;
}
}

View File

@@ -67,6 +67,16 @@ public class OrderItem {
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@PrePersist
private void onCreate() {
if (createdAt == null) {
createdAt = OffsetDateTime.now();
}
if (quantity == null) {
quantity = 1;
}
}
public UUID getId() {
return id;
}

View File

@@ -52,6 +52,9 @@ public class Payment {
@Column(name = "initiated_at", nullable = false)
private OffsetDateTime initiatedAt;
@Column(name = "reported_at")
private OffsetDateTime reportedAt;
@Column(name = "received_at")
private OffsetDateTime receivedAt;
@@ -135,6 +138,14 @@ public class Payment {
this.initiatedAt = initiatedAt;
}
public OffsetDateTime getReportedAt() {
return reportedAt;
}
public void setReportedAt(OffsetDateTime reportedAt) {
this.reportedAt = reportedAt;
}
public OffsetDateTime getReceivedAt() {
return receivedAt;
}

View File

@@ -41,9 +41,6 @@ public class PrinterMachine {
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "slicer_machine_profile")
private String slicerMachineProfile;
public Long getId() {
return id;
}
@@ -60,14 +57,6 @@ public class PrinterMachine {
this.printerDisplayName = printerDisplayName;
}
public String getSlicerMachineProfile() {
return slicerMachineProfile;
}
public void setSlicerMachineProfile(String slicerMachineProfile) {
this.slicerMachineProfile = slicerMachineProfile;
}
public Integer getBuildVolumeXMm() {
return buildVolumeXMm;
}

View File

@@ -0,0 +1,16 @@
package com.printcalculator.event;
import com.printcalculator.entity.Order;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
@Getter
public class OrderCreatedEvent extends ApplicationEvent {
private final Order order;
public OrderCreatedEvent(Object source, Order order) {
super(source);
this.order = order;
}
}

View File

@@ -0,0 +1,25 @@
package com.printcalculator.event;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.Payment;
import org.springframework.context.ApplicationEvent;
public class PaymentReportedEvent extends ApplicationEvent {
private final Order order;
private final Payment payment;
public PaymentReportedEvent(Object source, Order order, Payment payment) {
super(source);
this.order = order;
this.payment = payment;
}
public Order getOrder() {
return order;
}
public Payment getPayment() {
return payment;
}
}

View File

@@ -0,0 +1,127 @@
package com.printcalculator.event.listener;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.Payment;
import com.printcalculator.event.OrderCreatedEvent;
import com.printcalculator.event.PaymentReportedEvent;
import com.printcalculator.service.email.EmailNotificationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderEmailListener {
private final EmailNotificationService emailNotificationService;
@Value("${app.mail.admin.enabled:true}")
private boolean adminMailEnabled;
@Value("${app.mail.admin.address:}")
private String adminMailAddress;
@Value("${app.frontend.base-url:http://localhost:4200}")
private String frontendBaseUrl;
@Async
@EventListener
public void handleOrderCreatedEvent(OrderCreatedEvent event) {
Order order = event.getOrder();
log.info("Processing OrderCreatedEvent for order id: {}", order.getId());
try {
sendCustomerConfirmationEmail(order);
if (adminMailEnabled && adminMailAddress != null && !adminMailAddress.isEmpty()) {
sendAdminNotificationEmail(order);
}
} catch (Exception e) {
log.error("Failed to process email notifications for order id: {}", order.getId(), e);
}
}
@Async
@EventListener
public void handlePaymentReportedEvent(PaymentReportedEvent event) {
Order order = event.getOrder();
log.info("Processing PaymentReportedEvent for order id: {}", order.getId());
try {
sendPaymentReportedEmail(order);
} catch (Exception e) {
log.error("Failed to send payment reported email for order id: {}", order.getId(), e);
}
}
private void sendCustomerConfirmationEmail(Order order) {
Map<String, Object> templateData = new HashMap<>();
templateData.put("customerName", order.getCustomer().getFirstName());
templateData.put("orderId", order.getId());
templateData.put("orderNumber", getDisplayOrderNumber(order));
templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order));
templateData.put("orderDate", order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")));
templateData.put("totalCost", String.format("%.2f", order.getTotalChf()));
emailNotificationService.sendEmail(
order.getCustomer().getEmail(),
"Conferma Ordine #" + getDisplayOrderNumber(order) + " - 3D-Fab",
"order-confirmation",
templateData
);
}
private void sendPaymentReportedEmail(Order order) {
Map<String, Object> templateData = new HashMap<>();
templateData.put("customerName", order.getCustomer().getFirstName());
templateData.put("orderId", order.getId());
templateData.put("orderNumber", getDisplayOrderNumber(order));
templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order));
emailNotificationService.sendEmail(
order.getCustomer().getEmail(),
"Stiamo verificando il tuo pagamento (Ordine #" + getDisplayOrderNumber(order) + ")",
"payment-reported",
templateData
);
}
private void sendAdminNotificationEmail(Order order) {
Map<String, Object> templateData = new HashMap<>();
templateData.put("customerName", order.getCustomer().getFirstName() + " " + order.getCustomer().getLastName());
templateData.put("orderId", order.getId());
templateData.put("orderNumber", getDisplayOrderNumber(order));
templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order));
templateData.put("orderDate", order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")));
templateData.put("totalCost", String.format("%.2f", order.getTotalChf()));
// Possiamo riutilizzare lo stesso template per ora o crearne uno ad-hoc in futuro
emailNotificationService.sendEmail(
adminMailAddress,
"Nuovo Ordine Ricevuto #" + getDisplayOrderNumber(order) + " - " + order.getCustomer().getLastName(),
"order-confirmation",
templateData
);
}
private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) {
return orderNumber;
}
return order.getId() != null ? order.getId().toString() : "unknown";
}
private String buildOrderDetailsUrl(Order order) {
String baseUrl = frontendBaseUrl == null ? "" : frontendBaseUrl.replaceAll("/+$", "");
return baseUrl + "/ordine/" + order.getId();
}
}

View File

@@ -1,71 +1,27 @@
package com.printcalculator.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.context.request.WebRequest;
import java.util.HashMap;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.math.BigDecimal;
import java.math.RoundingMode;
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(StorageException.class)
public ResponseEntity<?> handleStorageException(StorageException exc) {
// Log the full exception for internal debugging
log.error("Storage Exception occurred", exc);
@ExceptionHandler(VirusDetectedException.class)
public ResponseEntity<Object> handleVirusDetectedException(
VirusDetectedException ex, WebRequest request) {
Map<String, String> response = new HashMap<>();
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("message", ex.getMessage());
body.put("error", "Virus Detected");
// Check for specific virus case
if (exc.getMessage() != null && exc.getMessage().contains("antivirus scanner")) {
response.put("error", "Security Violation");
// Safe message for client
response.put("message", "File rejected by security policy.");
response.put("code", "VIRUS_DETECTED");
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(response);
}
// Generic fallback for other storage errors to avoid leaking internal paths/details
response.put("error", "Storage Operation Failed");
response.put("message", "Unable to process the file upload.");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<?> handleMaxSizeException(MaxUploadSizeExceededException exc) {
Map<String, String> response = new HashMap<>();
response.put("error", "File too large");
response.put("message", "The uploaded file exceeds the maximum allowed size.");
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(response);
}
@ExceptionHandler(ModelTooLargeException.class)
public ResponseEntity<?> handleModelTooLarge(ModelTooLargeException exc) {
Map<String, String> response = new HashMap<>();
response.put("error", "Model too large");
response.put("code", "MODEL_TOO_LARGE");
response.put("message", String.format(
"Model size %.2fx%.2fx%.2f mm exceeds build volume %dx%dx%d mm.",
exc.getModelX(), exc.getModelY(), exc.getModelZ(),
exc.getBuildX(), exc.getBuildY(), exc.getBuildZ()
));
response.put("model_x_mm", formatMm(exc.getModelX()));
response.put("model_y_mm", formatMm(exc.getModelY()));
response.put("model_z_mm", formatMm(exc.getModelZ()));
response.put("build_x_mm", String.valueOf(exc.getBuildX()));
response.put("build_y_mm", String.valueOf(exc.getBuildY()));
response.put("build_z_mm", String.valueOf(exc.getBuildZ()));
return ResponseEntity.unprocessableEntity().body(response);
}
private String formatMm(double value) {
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).toPlainString();
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
}
}

View File

@@ -1,45 +0,0 @@
package com.printcalculator.exception;
public class ModelTooLargeException extends RuntimeException {
private final double modelX;
private final double modelY;
private final double modelZ;
private final int buildX;
private final int buildY;
private final int buildZ;
public ModelTooLargeException(double modelX, double modelY, double modelZ,
int buildX, int buildY, int buildZ) {
super("Model size exceeds build volume");
this.modelX = modelX;
this.modelY = modelY;
this.modelZ = modelZ;
this.buildX = buildX;
this.buildY = buildY;
this.buildZ = buildZ;
}
public double getModelX() {
return modelX;
}
public double getModelY() {
return modelY;
}
public double getModelZ() {
return modelZ;
}
public int getBuildX() {
return buildX;
}
public int getBuildY() {
return buildY;
}
public int getBuildZ() {
return buildZ;
}
}

View File

@@ -0,0 +1,7 @@
package com.printcalculator.exception;
public class VirusDetectedException extends RuntimeException {
public VirusDetectedException(String message) {
super(message);
}
}

View File

@@ -1,29 +1,8 @@
package com.printcalculator.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class PrintStats {
private long printTimeSeconds;
private String printTimeFormatted;
private double filamentWeightGrams;
private double filamentLengthMm;
// Breakdown if available
private Double modelWeightGrams;
private Double supportWeightGrams;
// Legacy constructor for compatibility
public PrintStats(long printTimeSeconds, String printTimeFormatted, double filamentWeightGrams, double filamentLengthMm) {
this.printTimeSeconds = printTimeSeconds;
this.printTimeFormatted = printTimeFormatted;
this.filamentWeightGrams = filamentWeightGrams;
this.filamentLengthMm = filamentLengthMm;
}
}
public record PrintStats(
long printTimeSeconds,
String printTimeFormatted,
double filamentWeightGrams,
double filamentLengthMm
) {}

View File

@@ -1,16 +0,0 @@
package com.printcalculator.model;
public record StlBounds(double minX, double minY, double minZ,
double maxX, double maxY, double maxZ) {
public double sizeX() {
return maxX - minX;
}
public double sizeY() {
return maxY - minY;
}
public double sizeZ() {
return maxZ - minZ;
}
}

View File

@@ -1,10 +0,0 @@
package com.printcalculator.model;
import java.nio.file.Path;
public record StlShiftResult(Path shiftedPath,
double offsetX,
double offsetY,
double offsetZ,
boolean shifted) {
}

View File

@@ -3,7 +3,9 @@ package com.printcalculator.repository;
import com.printcalculator.entity.Payment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface PaymentRepository extends JpaRepository<Payment, UUID> {
Optional<Payment> findByOrder_Id(UUID orderId);
}

View File

@@ -1,5 +1,6 @@
package com.printcalculator.service;
import com.printcalculator.exception.VirusDetectedException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@@ -22,18 +23,15 @@ public class ClamAVService {
public ClamAVService(
@Value("${clamav.host:clamav}") String host,
@Value("${clamav.port:3310}") int port,
@Value("${clamav.enabled:false}") boolean enabled
@Value("${clamav.enabled:true}") boolean enabled
) {
this.enabled = enabled;
if (!enabled) {
logger.info("ClamAV is DISABLED");
this.clamavClient = null;
return;
}
logger.info("Initializing ClamAV client at {}:{}", host, port);
ClamavClient client = null;
try {
client = new ClamavClient(host, port);
if (enabled) {
logger.info("Initializing ClamAV client at {}:{}", host, port);
client = new ClamavClient(host, port);
}
} catch (Exception e) {
logger.error("Failed to initialize ClamAV client: " + e.getMessage());
}
@@ -51,11 +49,13 @@ public class ClamAVService {
} else if (result instanceof ScanResult.VirusFound) {
Map<String, Collection<String>> viruses = ((ScanResult.VirusFound) result).getFoundViruses();
logger.warn("VIRUS DETECTED: {}", viruses);
return false;
throw new VirusDetectedException("Virus detected in the uploaded file: " + viruses);
} else {
logger.warn("Unknown scan result: {}. Allowing file (FAIL-OPEN)", result);
return true;
}
} catch (VirusDetectedException e) {
throw e;
} catch (Exception e) {
logger.error("Error scanning file with ClamAV. Allowing file (FAIL-OPEN)", e);
return true;

View File

@@ -26,15 +26,13 @@ public class GCodeParser {
private static final Pattern TIME_PATTERN = Pattern.compile(
";\\s*(?:estimated\\s+printing\\s+time|estimated\\s+print\\s+time|print\\s+time).*?[:=]\\s*(.*)",
Pattern.CASE_INSENSITIVE);
private static final Pattern FILAMENT_G_PATTERN = Pattern.compile(";\\s*filament used \\[g\\]\\s*=\\s*([^;\\(\\n\\r]+)(?:\\s*\\(([^,]+) model,\\s*([^ ]+) support\\))?");
private static final Pattern FILAMENT_G_PATTERN = Pattern.compile(";\\s*filament used \\[g\\]\\s*=\\s*(.*)");
private static final Pattern FILAMENT_MM_PATTERN = Pattern.compile(";\\s*filament used \\[mm\\]\\s*=\\s*(.*)");
public PrintStats parse(File gcodeFile) throws IOException {
long seconds = 0;
double weightG = 0;
double lengthMm = 0;
Double modelWeightG = null;
Double supportWeightG = null;
String timeFormatted = "";
try (BufferedReader reader = new BufferedReader(new FileReader(gcodeFile))) {
@@ -80,14 +78,7 @@ public class GCodeParser {
if (weightMatcher.find()) {
try {
weightG = Double.parseDouble(weightMatcher.group(1).trim());
System.out.println("GCodeParser: Found total weight: " + weightG + "g");
// Check if we have groups 2 and 3 for breakdown
if (weightMatcher.groupCount() >= 3 && weightMatcher.group(2) != null) {
modelWeightG = Double.parseDouble(weightMatcher.group(2).trim());
supportWeightG = Double.parseDouble(weightMatcher.group(3).trim());
System.out.println("GCodeParser: Found breakdown - Model: " + modelWeightG + "g, Support: " + supportWeightG + "g");
}
System.out.println("GCodeParser: Found weight: " + weightG + "g");
} catch (NumberFormatException ignored) {}
}
@@ -101,14 +92,7 @@ public class GCodeParser {
}
}
return PrintStats.builder()
.printTimeSeconds(seconds)
.printTimeFormatted(timeFormatted)
.filamentWeightGrams(weightG)
.filamentLengthMm(lengthMm)
.modelWeightGrams(modelWeightG)
.supportWeightGrams(supportWeightG)
.build();
return new PrintStats(seconds, timeFormatted, weightG, lengthMm);
}
private long parseTimeString(String timeStr) {

View File

@@ -8,9 +8,10 @@ import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.event.OrderCreatedEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.math.BigDecimal;
@@ -34,6 +35,8 @@ public class OrderService {
private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService;
private final ApplicationEventPublisher eventPublisher;
private final PaymentService paymentService;
public OrderService(OrderRepository orderRepo,
OrderItemRepository orderItemRepo,
@@ -42,7 +45,9 @@ public class OrderService {
CustomerRepository customerRepo,
StorageService storageService,
InvoicePdfRenderingService invoiceService,
QrBillService qrBillService) {
QrBillService qrBillService,
ApplicationEventPublisher eventPublisher,
PaymentService paymentService) {
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
this.quoteSessionRepo = quoteSessionRepo;
@@ -51,6 +56,8 @@ public class OrderService {
this.storageService = storageService;
this.invoiceService = invoiceService;
this.qrBillService = qrBillService;
this.eventPublisher = eventPublisher;
this.paymentService = paymentService;
}
@Transactional
@@ -195,7 +202,14 @@ public class OrderService {
// Generate Invoice and QR Bill
generateAndSaveDocuments(order, savedItems);
return orderRepo.save(order);
Order savedOrder = orderRepo.save(order);
// ALWAYS initialize payment as PENDING
paymentService.getOrCreatePaymentForOrder(savedOrder, "OTHER");
eventPublisher.publishEvent(new OrderCreatedEvent(this, savedOrder));
return savedOrder;
}
private void generateAndSaveDocuments(Order order, List<OrderItem> items) {
@@ -223,7 +237,7 @@ public class OrderService {
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
vars.put("sellerEmail", "info@3dfab.ch");
vars.put("invoiceNumber", "INV-" + order.getId().toString().substring(0, 8).toUpperCase());
vars.put("invoiceNumber", "INV-" + getDisplayOrderNumber(order).toUpperCase());
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
@@ -297,4 +311,12 @@ public class OrderService {
}
return "stl";
}
private String getDisplayOrderNumber(Order order) {
String orderNumber = order.getOrderNumber();
if (orderNumber != null && !orderNumber.isBlank()) {
return orderNumber;
}
return order.getId() != null ? order.getId().toString() : "unknown";
}
}

View File

@@ -0,0 +1,74 @@
package com.printcalculator.service;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.Payment;
import com.printcalculator.event.PaymentReportedEvent;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.Optional;
import java.util.UUID;
@Service
public class PaymentService {
private final PaymentRepository paymentRepo;
private final OrderRepository orderRepo;
private final ApplicationEventPublisher eventPublisher;
public PaymentService(PaymentRepository paymentRepo,
OrderRepository orderRepo,
ApplicationEventPublisher eventPublisher) {
this.paymentRepo = paymentRepo;
this.orderRepo = orderRepo;
this.eventPublisher = eventPublisher;
}
@Transactional
public Payment getOrCreatePaymentForOrder(Order order, String defaultMethod) {
Optional<Payment> existing = paymentRepo.findByOrder_Id(order.getId());
if (existing.isPresent()) {
return existing.get();
}
Payment payment = new Payment();
payment.setOrder(order);
payment.setMethod(defaultMethod != null ? defaultMethod : "OTHER");
payment.setStatus("PENDING");
payment.setCurrency(order.getCurrency() != null ? order.getCurrency() : "CHF");
payment.setAmountChf(order.getTotalChf() != null ? order.getTotalChf() : BigDecimal.ZERO);
payment.setInitiatedAt(OffsetDateTime.now());
return paymentRepo.save(payment);
}
@Transactional
public Payment reportPayment(UUID orderId, String method) {
Order order = orderRepo.findById(orderId)
.orElseThrow(() -> new RuntimeException("Order not found with id " + orderId));
Payment payment = paymentRepo.findByOrder_Id(orderId)
.orElseThrow(() -> new RuntimeException("No active payment found for order " + orderId));
if (!"PENDING".equals(payment.getStatus())) {
throw new IllegalStateException("Payment is not in PENDING state. Current state: " + payment.getStatus());
}
payment.setStatus("REPORTED");
payment.setReportedAt(OffsetDateTime.now());
if (method != null && !method.isBlank()) {
payment.setMethod(method);
}
payment = paymentRepo.save(payment);
eventPublisher.publishEvent(new PaymentReportedEvent(this, order, payment));
return payment;
}
}

View File

@@ -10,19 +10,23 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.Optional;
import java.util.logging.Logger;
import java.util.stream.Stream;
import java.util.Map;
import java.util.HashMap;
import java.math.BigDecimal;
import java.util.List;
import java.util.LinkedHashSet;
import java.util.Set;
@Service
public class ProfileManager {
private static final Logger logger = Logger.getLogger(ProfileManager.class.getName());
private final String profilesRoot;
private final Path resolvedProfilesRoot;
private final ObjectMapper mapper;
private final Map<String, String> profileAliases;
@@ -32,6 +36,8 @@ public class ProfileManager {
this.mapper = mapper;
this.profileAliases = new HashMap<>();
initializeAliases();
this.resolvedProfilesRoot = resolveProfilesRoot(profilesRoot);
logger.info("Profiles root configured as '" + this.profilesRoot + "', resolved to '" + this.resolvedProfilesRoot + "'");
}
private void initializeAliases() {
@@ -55,42 +61,82 @@ public class ProfileManager {
public ObjectNode getMergedProfile(String profileName, String type) throws IOException {
Path profilePath = findProfileFile(profileName, type);
if (profilePath == null) {
throw new IOException("Profile not found: " + profileName);
throw new IOException("Profile not found: " + profileName + " (root=" + resolvedProfilesRoot + ")");
}
logger.info("Resolved " + type + " profile '" + profileName + "' -> " + profilePath);
return resolveInheritance(profilePath);
}
public String resolveMachineProfileName(String machineName, Double nozzleDiameter) {
String resolvedName = profileAliases.getOrDefault(machineName, machineName);
if (nozzleDiameter == null) return resolvedName;
String base = resolvedName.replaceAll("\\s*\\d+(?:\\.\\d+)?\\s*nozzle$", "").trim();
String formatted = BigDecimal.valueOf(nozzleDiameter).stripTrailingZeros().toPlainString();
String candidate = base + " " + formatted + " nozzle";
Path exists = findProfileFile(candidate, "machine");
return exists != null ? candidate : resolvedName;
}
private Path findProfileFile(String name, String type) {
if (!Files.isDirectory(resolvedProfilesRoot)) {
logger.severe("Profiles root does not exist or is not a directory: " + resolvedProfilesRoot);
return null;
}
// Check aliases first
String resolvedName = profileAliases.getOrDefault(name, name);
// Simple search: look for name.json in the profiles_root recursively
// Type could be "machine", "process", "filament" to narrow down, but for now global search
String filename = resolvedName.endsWith(".json") ? resolvedName : resolvedName + ".json";
// Look for name.json under the expected type directory first to avoid
// collisions across vendors/profile families with same filename.
String filename = toJsonFilename(resolvedName);
try (Stream<Path> stream = Files.walk(Paths.get(profilesRoot))) {
Optional<Path> found = stream
try (Stream<Path> stream = Files.walk(resolvedProfilesRoot)) {
List<Path> candidates = stream
.filter(p -> p.getFileName().toString().equals(filename))
.findFirst();
return found.orElse(null);
.sorted()
.toList();
if (candidates.isEmpty()) {
return null;
}
if (type != null && !type.isBlank() && !"any".equalsIgnoreCase(type)) {
Optional<Path> typed = candidates.stream()
.filter(p -> pathContainsSegment(p, type))
.findFirst();
if (typed.isPresent()) {
return typed.get();
}
}
return candidates.get(0);
} catch (IOException e) {
logger.severe("Error searching for profile: " + e.getMessage());
return null;
}
}
private Path resolveProfilesRoot(String configuredRoot) {
Set<Path> candidates = new LinkedHashSet<>();
Path cwd = Paths.get("").toAbsolutePath().normalize();
if (configuredRoot != null && !configuredRoot.isBlank()) {
Path configured = Paths.get(configuredRoot);
candidates.add(configured.toAbsolutePath().normalize());
if (!configured.isAbsolute()) {
candidates.add(cwd.resolve(configuredRoot).normalize());
}
}
candidates.add(cwd.resolve("profiles").normalize());
candidates.add(cwd.resolve("backend/profiles").normalize());
candidates.add(Paths.get("/app/profiles").toAbsolutePath().normalize());
List<String> checkedPaths = new ArrayList<>();
for (Path candidate : candidates) {
checkedPaths.add(candidate.toString());
if (Files.isDirectory(candidate)) {
return candidate;
}
}
logger.warning("No profiles directory found. Checked: " + String.join(", ", checkedPaths));
if (configuredRoot != null && !configuredRoot.isBlank()) {
return Paths.get(configuredRoot).toAbsolutePath().normalize();
}
return cwd.resolve("profiles").normalize();
}
private ObjectNode resolveInheritance(Path currentPath) throws IOException {
// 1. Load current
JsonNode currentNode = mapper.readTree(currentPath.toFile());
@@ -98,14 +144,20 @@ public class ProfileManager {
// 2. Check inherits
if (currentNode.has("inherits")) {
String parentName = currentNode.get("inherits").asText();
// Try to find parent in same directory or standard search
Path parentPath = currentPath.getParent().resolve(parentName);
// Try local directory first with explicit .json filename.
String parentFilename = toJsonFilename(parentName);
Path parentPath = currentPath.getParent().resolve(parentFilename);
if (!Files.exists(parentPath)) {
// If not in same dir, search globally
// Fallback to the same profile type directory before global.
String inferredType = inferTypeFromPath(currentPath);
parentPath = findProfileFile(parentName, inferredType);
}
if (parentPath == null || !Files.exists(parentPath)) {
parentPath = findProfileFile(parentName, "any");
}
if (parentPath != null && Files.exists(parentPath)) {
logger.info("Resolved inherits '" + parentName + "' for " + currentPath + " -> " + parentPath);
// Recursive call
ObjectNode parentNode = resolveInheritance(parentPath);
// Merge current into parent (child overrides parent)
@@ -136,4 +188,30 @@ public class ProfileManager {
mainNode.set(fieldName, jsonNode);
}
}
private String toJsonFilename(String name) {
return name.endsWith(".json") ? name : name + ".json";
}
private boolean pathContainsSegment(Path path, String segment) {
String normalized = path.toString().replace('\\', '/');
String needle = "/" + segment + "/";
return normalized.contains(needle);
}
private String inferTypeFromPath(Path path) {
if (path == null) {
return "any";
}
if (pathContainsSegment(path, "machine")) {
return "machine";
}
if (pathContainsSegment(path, "process")) {
return "process";
}
if (pathContainsSegment(path, "filament")) {
return "filament";
}
return "any";
}
}

View File

@@ -51,7 +51,8 @@ public class QrBillService {
// Reference
// bill.setReference(QRBill.createCreditorReference("...")); // If using QRR
bill.setUnstructuredMessage("Order " + order.getId());
String orderRef = order.getOrderNumber() != null ? order.getOrderNumber() : order.getId().toString();
bill.setUnstructuredMessage("Order " + orderRef);
return bill;
}

View File

@@ -76,21 +76,11 @@ public class QuoteCalculator {
// --- CALCULATIONS ---
// Material Cost: (weight / 1000) * costPerKg
// DISCOUNTED Support material to avoid penalizing users for default supports
BigDecimal weightToCharge;
if (stats.getModelWeightGrams() != null && stats.getSupportWeightGrams() != null) {
// Charge 100% for model + 20% for support
weightToCharge = BigDecimal.valueOf(stats.getModelWeightGrams())
.add(BigDecimal.valueOf(stats.getSupportWeightGrams()).multiply(BigDecimal.valueOf(0.2)));
} else {
weightToCharge = BigDecimal.valueOf(stats.getFilamentWeightGrams());
}
BigDecimal weightKg = weightToCharge.divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg());
// Machine Cost: Tiered
BigDecimal totalHours = BigDecimal.valueOf(stats.getPrintTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
BigDecimal machineCost = calculateMachineCost(policy, totalHours);
// Energy Cost: (watts / 1000) * hours * costPerKwh

View File

@@ -10,13 +10,13 @@ import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
import java.util.stream.Stream;
@Service
public class SlicerService {
@@ -41,15 +41,27 @@ public class SlicerService {
public PrintStats slice(File inputStl, String machineName, String filamentName, String processName,
Map<String, String> machineOverrides, Map<String, String> processOverrides) throws IOException {
// 1. Prepare Profiles
ObjectNode machineProfile = profileManager.getMergedProfile(machineName, "machine");
ObjectNode filamentProfile = profileManager.getMergedProfile(filamentName, "filament");
ObjectNode processProfile = profileManager.getMergedProfile(processName, "process");
if (machineOverrides != null) machineOverrides.forEach(machineProfile::put);
if (processOverrides != null) processOverrides.forEach(processProfile::put);
logger.info("Slicer profiles: machine='" + machineName + "', filament='" + filamentName + "', process='" + processName + "'");
logger.info("Machine limits: printable_area=" + machineProfile.path("printable_area")
+ ", printable_height=" + machineProfile.path("printable_height")
+ ", bed_exclude_area=" + machineProfile.path("bed_exclude_area")
+ ", head_wrap_detect_zone=" + machineProfile.path("head_wrap_detect_zone"));
// Apply Overrides
if (machineOverrides != null) {
machineOverrides.forEach(machineProfile::put);
}
if (processOverrides != null) {
processOverrides.forEach(processProfile::put);
}
// 2. Create Temp Dir
Path tempDir = Files.createTempDirectory("slicer_job_");
try {
File mFile = tempDir.resolve("machine.json").toFile();
File fFile = tempDir.resolve("filament.json").toFile();
@@ -59,61 +71,110 @@ public class SlicerService {
mapper.writeValue(fFile, filamentProfile);
mapper.writeValue(pFile, processProfile);
List<String> command = new ArrayList<>();
command.add(slicerPath);
command.add("--load-settings");
command.add(mFile.getAbsolutePath());
command.add("--load-settings");
command.add(pFile.getAbsolutePath());
command.add("--load-filaments");
command.add(fFile.getAbsolutePath());
command.add("--ensure-on-bed");
command.add("--arrange");
command.add("1");
command.add("--outputdir");
command.add(tempDir.toAbsolutePath().toString());
command.add("--slice");
command.add("0");
command.add(inputStl.getAbsolutePath());
logger.info("Executing Slicer: " + String.join(" ", command));
runSlicerCommand(command, tempDir);
try (Stream<Path> s = Files.list(tempDir)) {
Optional<Path> found = s.filter(p -> p.toString().endsWith(".gcode")).findFirst();
if (found.isPresent()) return gCodeParser.parse(found.get().toFile());
else throw new IOException("No GCode found in " + tempDir);
String basename = inputStl.getName();
if (basename.toLowerCase().endsWith(".stl")) {
basename = basename.substring(0, basename.length() - 4);
}
Path slicerLogPath = tempDir.resolve("orcaslicer.log");
// 3. Run slicer. Retry with arrange only for out-of-volume style failures.
for (boolean useArrange : new boolean[]{false, true}) {
List<String> command = new ArrayList<>();
command.add(slicerPath);
command.add("--load-settings");
command.add(mFile.getAbsolutePath());
command.add("--load-settings");
command.add(pFile.getAbsolutePath());
command.add("--load-filaments");
command.add(fFile.getAbsolutePath());
command.add("--ensure-on-bed");
if (useArrange) {
command.add("--arrange");
command.add("1");
}
command.add("--slice");
command.add("0");
command.add("--outputdir");
command.add(tempDir.toAbsolutePath().toString());
command.add(inputStl.getAbsolutePath());
logger.info("Executing Slicer" + (useArrange ? " (retry with arrange)" : "") + ": " + String.join(" ", command));
Files.deleteIfExists(slicerLogPath);
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(tempDir.toFile());
pb.redirectErrorStream(true);
pb.redirectOutput(slicerLogPath.toFile());
Process process = pb.start();
boolean finished = process.waitFor(5, TimeUnit.MINUTES);
if (!finished) {
process.destroyForcibly();
throw new IOException("Slicer timed out");
}
if (process.exitValue() != 0) {
String error = "";
if (Files.exists(slicerLogPath)) {
error = Files.readString(slicerLogPath, StandardCharsets.UTF_8);
}
if (!useArrange && isOutOfVolumeError(error)) {
logger.warning("Slicer reported model out of printable area, retrying with arrange.");
continue;
}
throw new IOException("Slicer failed with exit code " + process.exitValue() + ": " + error);
}
File gcodeFile = tempDir.resolve(basename + ".gcode").toFile();
if (!gcodeFile.exists()) {
File alt = tempDir.resolve("plate_1.gcode").toFile();
if (alt.exists()) {
gcodeFile = alt;
} else {
throw new IOException("GCode output not found in " + tempDir);
}
}
return gCodeParser.parse(gcodeFile);
}
throw new IOException("Slicer failed after retry");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException(e);
throw new IOException("Interrupted during slicing", e);
} finally {
deleteRecursively(tempDir);
}
}
protected void runSlicerCommand(List<String> command, Path tempDir) throws IOException, InterruptedException {
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(tempDir.toFile());
Map<String, String> env = pb.environment();
env.put("HOME", "/tmp");
env.put("QT_QPA_PLATFORM", "offscreen");
Process process = pb.start();
if (!process.waitFor(5, TimeUnit.MINUTES)) {
process.destroy();
throw new IOException("Slicer timeout");
private void deleteRecursively(Path path) {
if (path == null || !Files.exists(path)) {
return;
}
if (process.exitValue() != 0) {
String out = new String(process.getInputStream().readAllBytes());
String err = new String(process.getErrorStream().readAllBytes());
throw new IOException("Slicer failed with exit code " + process.exitValue() + "\nERR: " + err + "\nOUT: " + out);
try (var walk = Files.walk(path)) {
walk.sorted(Comparator.reverseOrder()).forEach(p -> {
try {
Files.deleteIfExists(p);
} catch (IOException e) {
logger.warning("Failed to delete temp path " + p + ": " + e.getMessage());
}
});
} catch (IOException e) {
logger.warning("Failed to walk temp directory " + path + ": " + e.getMessage());
}
}
private boolean isOutOfVolumeError(String errorLog) {
if (errorLog == null || errorLog.isBlank()) {
return false;
}
String normalized = errorLog.toLowerCase();
return normalized.contains("nothing to be sliced")
|| normalized.contains("no object is fully inside the print volume")
|| normalized.contains("calc_exclude_triangles");
}
}

View File

@@ -1,255 +0,0 @@
package com.printcalculator.service;
import com.printcalculator.model.StlBounds;
import com.printcalculator.model.StlShiftResult;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
@Service
public class StlService {
public StlBounds readBounds(File stlFile) throws IOException {
long size = stlFile.length();
if (size >= 84 && isBinaryStl(stlFile, size)) {
return readBinaryBounds(stlFile);
}
return readAsciiBounds(stlFile);
}
public StlShiftResult shiftToFitIfNeeded(File stlFile, StlBounds bounds,
int bedX, int bedY, int bedZ) throws IOException {
double sizeX = bounds.sizeX();
double sizeY = bounds.sizeY();
double sizeZ = bounds.sizeZ();
double targetMinX = (bedX - sizeX) / 2.0;
double targetMinY = (bedY - sizeY) / 2.0;
double targetMinZ = 0.0;
double offsetX = targetMinX - bounds.minX();
double offsetY = targetMinY - bounds.minY();
double offsetZ = targetMinZ - bounds.minZ();
boolean needsShift = Math.abs(offsetX) > 1e-6 || Math.abs(offsetY) > 1e-6 || Math.abs(offsetZ) > 1e-6;
if (!needsShift) {
return new StlShiftResult(null, offsetX, offsetY, offsetZ, false);
}
Path shiftedPath = Files.createTempFile("stl_shifted_", ".stl");
writeShifted(stlFile, shiftedPath.toFile(), offsetX, offsetY, offsetZ);
return new StlShiftResult(shiftedPath, offsetX, offsetY, offsetZ, true);
}
private boolean isBinaryStl(File stlFile, long size) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(stlFile, "r")) {
raf.seek(80);
long triangleCount = readLEUInt32(raf);
long expected = 84L + triangleCount * 50L;
return expected == size;
}
}
private StlBounds readBinaryBounds(File stlFile) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(stlFile, "r")) {
raf.seek(80);
long triangleCount = readLEUInt32(raf);
raf.seek(84);
BoundsAccumulator acc = new BoundsAccumulator();
for (long i = 0; i < triangleCount; i++) {
// skip normal
readLEFloat(raf);
readLEFloat(raf);
readLEFloat(raf);
// 3 vertices
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
// skip attribute byte count
raf.skipBytes(2);
}
return acc.toBounds();
}
}
private StlBounds readAsciiBounds(File stlFile) throws IOException {
BoundsAccumulator acc = new BoundsAccumulator();
try (BufferedReader reader = Files.newBufferedReader(stlFile.toPath(), StandardCharsets.US_ASCII)) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (!line.startsWith("vertex")) continue;
String[] parts = line.split("\\s+");
if (parts.length < 4) continue;
double x = Double.parseDouble(parts[1]);
double y = Double.parseDouble(parts[2]);
double z = Double.parseDouble(parts[3]);
acc.accept(x, y, z);
}
}
return acc.toBounds();
}
private void writeShifted(File input, File output, double offsetX, double offsetY, double offsetZ) throws IOException {
long size = input.length();
if (size >= 84 && isBinaryStl(input, size)) {
writeShiftedBinary(input, output, offsetX, offsetY, offsetZ);
} else {
writeShiftedAscii(input, output, offsetX, offsetY, offsetZ);
}
}
private void writeShiftedAscii(File input, File output, double offsetX, double offsetY, double offsetZ) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(input.toPath(), StandardCharsets.US_ASCII);
BufferedWriter writer = Files.newBufferedWriter(output.toPath(), StandardCharsets.US_ASCII)) {
String line;
while ((line = reader.readLine()) != null) {
String trimmed = line.trim();
if (!trimmed.startsWith("vertex")) {
writer.write(line);
writer.newLine();
continue;
}
String[] parts = trimmed.split("\\s+");
if (parts.length < 4) {
writer.write(line);
writer.newLine();
continue;
}
double x = Double.parseDouble(parts[1]) + offsetX;
double y = Double.parseDouble(parts[2]) + offsetY;
double z = Double.parseDouble(parts[3]) + offsetZ;
int idx = line.indexOf("vertex");
String indent = idx > 0 ? line.substring(0, idx) : "";
writer.write(indent + String.format(Locale.US, "vertex %.6f %.6f %.6f", x, y, z));
writer.newLine();
}
}
}
private void writeShiftedBinary(File input, File output, double offsetX, double offsetY, double offsetZ) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(input, "r");
OutputStream out = new FileOutputStream(output)) {
byte[] header = new byte[80];
raf.readFully(header);
out.write(header);
long triangleCount = readLEUInt32(raf);
writeLEUInt32(out, triangleCount);
for (long i = 0; i < triangleCount; i++) {
// normal
writeLEFloat(out, readLEFloat(raf));
writeLEFloat(out, readLEFloat(raf));
writeLEFloat(out, readLEFloat(raf));
// vertices
writeLEFloat(out, (float) (readLEFloat(raf) + offsetX));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetY));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetZ));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetX));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetY));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetZ));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetX));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetY));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetZ));
// attribute byte count
int b1 = raf.read();
int b2 = raf.read();
if ((b1 | b2) < 0) throw new IOException("Unexpected EOF while reading STL");
out.write(b1);
out.write(b2);
}
}
}
private long readLEUInt32(RandomAccessFile raf) throws IOException {
int b1 = raf.read();
int b2 = raf.read();
int b3 = raf.read();
int b4 = raf.read();
if ((b1 | b2 | b3 | b4) < 0) throw new IOException("Unexpected EOF while reading STL");
return ((long) b1 & 0xFF)
| (((long) b2 & 0xFF) << 8)
| (((long) b3 & 0xFF) << 16)
| (((long) b4 & 0xFF) << 24);
}
private int readLEInt(RandomAccessFile raf) throws IOException {
int b1 = raf.read();
int b2 = raf.read();
int b3 = raf.read();
int b4 = raf.read();
if ((b1 | b2 | b3 | b4) < 0) throw new IOException("Unexpected EOF while reading STL");
return (b1 & 0xFF)
| ((b2 & 0xFF) << 8)
| ((b3 & 0xFF) << 16)
| ((b4 & 0xFF) << 24);
}
private float readLEFloat(RandomAccessFile raf) throws IOException {
return Float.intBitsToFloat(readLEInt(raf));
}
private void writeLEUInt32(OutputStream out, long value) throws IOException {
out.write((int) (value & 0xFF));
out.write((int) ((value >> 8) & 0xFF));
out.write((int) ((value >> 16) & 0xFF));
out.write((int) ((value >> 24) & 0xFF));
}
private void writeLEFloat(OutputStream out, float value) throws IOException {
int bits = Float.floatToIntBits(value);
out.write(bits & 0xFF);
out.write((bits >> 8) & 0xFF);
out.write((bits >> 16) & 0xFF);
out.write((bits >> 24) & 0xFF);
}
private static class BoundsAccumulator {
private boolean hasPoint = false;
private double minX;
private double minY;
private double minZ;
private double maxX;
private double maxY;
private double maxZ;
void accept(double x, double y, double z) {
if (!hasPoint) {
minX = maxX = x;
minY = maxY = y;
minZ = maxZ = z;
hasPoint = true;
return;
}
if (x < minX) minX = x;
if (y < minY) minY = y;
if (z < minZ) minZ = z;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
if (z > maxZ) maxZ = z;
}
StlBounds toBounds() throws IOException {
if (!hasPoint) {
throw new IOException("STL appears to contain no vertices");
}
return new StlBounds(minX, minY, minZ, maxX, maxY, maxZ);
}
}
}

View File

@@ -0,0 +1,66 @@
package com.printcalculator.service;
import io.nayuki.qrcodegen.QrCode;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
@Service
public class TwintPaymentService {
private final String twintPaymentUrl;
public TwintPaymentService(
@Value("${payment.twint.url:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}")
String twintPaymentUrl
) {
this.twintPaymentUrl = twintPaymentUrl;
}
public String getTwintPaymentUrl() {
return twintPaymentUrl;
}
public byte[] generateQrPng(int sizePx) {
try {
// Use High Error Correction for financial QR codes
QrCode qrCode = QrCode.encodeText(twintPaymentUrl, QrCode.Ecc.HIGH);
// Standard QR quiet zone is 4 modules
int borderModules = 4;
int fullModules = qrCode.size + borderModules * 2;
int scale = Math.max(1, sizePx / fullModules);
int imageSize = fullModules * scale;
BufferedImage image = new BufferedImage(imageSize, imageSize, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = image.createGraphics();
try {
graphics.setColor(Color.WHITE);
graphics.fillRect(0, 0, imageSize, imageSize);
graphics.setColor(Color.BLACK);
for (int y = 0; y < qrCode.size; y++) {
for (int x = 0; x < qrCode.size; x++) {
if (qrCode.getModule(x, y)) {
int px = (x + borderModules) * scale;
int py = (y + borderModules) * scale;
graphics.fillRect(px, py, scale, scale);
}
}
}
} finally {
graphics.dispose();
}
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ImageIO.write(image, "png", outputStream);
return outputStream.toByteArray();
} catch (Exception ex) {
throw new IllegalStateException("Unable to generate TWINT QR image.", ex);
}
}
}

View File

@@ -0,0 +1,17 @@
package com.printcalculator.service.email;
import java.util.Map;
public interface EmailNotificationService {
/**
* Sends an HTML email using a Thymeleaf template.
*
* @param to The recipient email address.
* @param subject The subject of the email.
* @param templateName The name of the Thymeleaf template (e.g., "order-confirmation").
* @param contextData The data to populate the template with.
*/
void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData);
}

View File

@@ -0,0 +1,62 @@
package com.printcalculator.service.email;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class SmtpEmailNotificationService implements EmailNotificationService {
private final JavaMailSender emailSender;
private final TemplateEngine templateEngine;
@Value("${app.mail.from}")
private String fromAddress;
@Value("${app.mail.enabled:true}")
private boolean mailEnabled;
@Override
public void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData) {
if (!mailEnabled) {
log.info("Email sending disabled (app.mail.enabled=false). Skipping email to {}", to);
return;
}
log.info("Preparing to send email to {} with template {}", to, templateName);
try {
Context context = new Context();
context.setVariables(contextData);
String process = templateEngine.process("email/" + templateName, context);
MimeMessage mimeMessage = emailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8");
helper.setFrom(fromAddress);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(process, true); // true indicates HTML format
emailSender.send(mimeMessage);
log.info("Email successfully sent to {}", to);
} catch (MessagingException e) {
log.error("Failed to send email to {}", to, e);
// Non blocco l'ordine se l'email fallisce, ma loggo l'errore adeguatamente.
} catch (Exception e) {
log.error("Unexpected error while sending email to {}", to, e);
}
}
}

View File

@@ -0,0 +1,2 @@
app.mail.enabled=false
app.mail.admin.enabled=false

View File

@@ -24,3 +24,20 @@ clamav.host=${CLAMAV_HOST:clamav}
clamav.port=${CLAMAV_PORT:3310}
clamav.enabled=${CLAMAV_ENABLED:false}
# TWINT Configuration
payment.twint.url=${TWINT_PAYMENT_URL:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}
# Mail Configuration
spring.mail.host=${MAIL_HOST:mail.infomaniak.com}
spring.mail.port=${MAIL_PORT:587}
spring.mail.username=${MAIL_USERNAME:info@3d-fab.ch}
spring.mail.password=${MAIL_PASSWORD:ht*44k+Tq39R+R-O}
spring.mail.properties.mail.smtp.auth=${MAIL_SMTP_AUTH:false}
spring.mail.properties.mail.smtp.starttls.enable=${MAIL_SMTP_STARTTLS:false}
# Application Mail Settings
app.mail.enabled=${APP_MAIL_ENABLED:true}
app.mail.from=${APP_MAIL_FROM:${MAIL_USERNAME:noreply@printcalculator.local}}
app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true}
app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local}
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}

View File

@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Conferma Ordine</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #eeeeee;
padding-bottom: 20px;
margin-bottom: 20px;
}
.header h1 {
color: #333333;
}
.content {
color: #555555;
line-height: 1.6;
}
.order-details {
background-color: #f9f9f9;
padding: 15px;
border-radius: 5px;
margin-top: 20px;
}
.order-details th {
text-align: left;
padding-right: 20px;
color: #333333;
}
.footer {
text-align: center;
font-size: 0.9em;
color: #999999;
margin-top: 30px;
border-top: 1px solid #eeeeee;
padding-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Grazie per il tuo ordine #<span th:text="${orderNumber}">00000000</span></h1>
</div>
<div class="content">
<p>Ciao <span th:text="${customerName}">Cliente</span>,</p>
<p>Abbiamo ricevuto il tuo ordine e stiamo iniziando a elaborarlo. Ecco un riepilogo dei dettagli:</p>
<div class="order-details">
<table>
<tr>
<th>Numero Ordine:</th>
<td th:text="${orderNumber}">00000000</td>
</tr>
<tr>
<th>Data:</th>
<td th:text="${orderDate}">01/01/2026</td>
</tr>
<tr>
<th>Costo totale:</th>
<td th:text="${totalCost} + ' CHF'">0.00 CHF</td>
</tr>
</table>
</div>
<p>
Clicca qui per i dettagli:
<a th:href="${orderDetailsUrl}" th:text="${orderDetailsUrl}">https://tuosito.it/ordine/00000000-0000-0000-0000-000000000000</a>
</p>
<p>Se hai domande o dubbi, non esitare a contattarci.</p>
</div>
<div class="footer">
<p>&copy; 2026 3D-Fab. Tutti i diritti riservati.</p>
</div>
</div>
</body>
</html>

View File

@@ -3,81 +3,356 @@
<head>
<meta charset="utf-8"/>
<style>
@page { size: A4; margin: 18mm 15mm; }
body { font-family: sans-serif; font-size: 10.5pt; }
.header { display: flex; justify-content: space-between; }
.addresses { margin-top: 10mm; display: flex; justify-content: space-between; }
table { width: 100%; border-collapse: collapse; margin-top: 8mm; }
th, td { padding: 6px; border-bottom: 1px solid #ccc; }
th { text-align: left; }
.totals { margin-top: 6mm; width: 40%; margin-left: auto; }
.totals td { border: none; }
.page-break { page-break-before: always; }
@page invoice { size: A4; margin: 14mm 14mm 12mm 14mm; }
@page qrpage { size: A4; margin: 0; }
body {
page: invoice;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 9.5pt;
margin: 0;
padding: 0;
background: #fff;
color: #191919;
line-height: 1.35;
}
.invoice-page {
page: invoice;
width: 100%;
}
.top-layout {
width: 100%;
border-collapse: collapse;
margin-bottom: 8mm;
}
.top-layout td {
vertical-align: top;
padding: 0;
}
.doc-title {
font-size: 18pt;
font-weight: 700;
margin: 0 0 1.5mm 0;
letter-spacing: 0.2px;
}
.doc-subtitle {
color: #4b4b4b;
font-size: 10pt;
}
.seller-block {
text-align: right;
line-height: 1.45;
width: 42%;
}
.seller-name {
font-size: 11pt;
font-weight: 700;
}
.meta-layout {
width: 100%;
border-collapse: collapse;
margin-bottom: 8mm;
}
.meta-layout td {
vertical-align: top;
padding: 0;
}
.order-details {
width: 60%;
padding-right: 5mm;
}
.customer-box {
width: 40%;
background: #f7f7f7;
border: 1px solid #e2e2e2;
padding: 3mm 3.2mm;
}
.box-title {
font-size: 8.8pt;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #5a5a5a;
margin-bottom: 2mm;
font-weight: 700;
}
.details-table {
width: 100%;
border-collapse: collapse;
}
.details-table td {
padding: 1.1mm 0;
border-bottom: 1px solid #ececec;
vertical-align: top;
}
.details-label {
color: #636363;
width: 56%;
white-space: nowrap;
padding-right: 3mm;
}
.details-value {
text-align: left;
font-weight: 600;
}
.line-items {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-top: 3mm;
border-top: 1px solid #cfcfcf;
}
.line-items th,
.line-items td {
border-bottom: 1px solid #dedede;
padding: 2.4mm 2mm;
vertical-align: top;
word-wrap: break-word;
}
.line-items th {
text-align: left;
font-weight: 700;
background: #f2f2f2;
color: #2c2c2c;
font-size: 9pt;
text-transform: uppercase;
letter-spacing: 0.25px;
}
.line-items th:nth-child(1),
.line-items td:nth-child(1) {
width: 50%;
}
.line-items th:nth-child(2),
.line-items td:nth-child(2) {
width: 10%;
text-align: right;
white-space: nowrap;
}
.line-items th:nth-child(3),
.line-items td:nth-child(3) {
width: 20%;
text-align: right;
white-space: nowrap;
}
.line-items th:nth-child(4),
.line-items td:nth-child(4) {
width: 20%;
text-align: right;
white-space: nowrap;
font-weight: 600;
}
.summary-layout {
width: 100%;
border-collapse: collapse;
margin-top: 6mm;
}
.summary-layout td {
vertical-align: top;
padding: 0;
}
.notes {
width: 58%;
padding-right: 5mm;
color: #383838;
line-height: 1.45;
}
.notes .section-caption {
font-weight: 700;
margin: 0 0 1.2mm 0;
color: #2a2a2a;
}
.totals {
width: 42%;
margin-left: auto;
border-collapse: collapse;
}
.totals td {
border: none;
padding: 1.3mm 0;
}
.totals-label {
text-align: left;
color: #4a4a4a;
}
.totals-value {
text-align: right;
white-space: nowrap;
font-weight: 600;
}
.total-strong td {
font-size: 10.5pt;
font-weight: 700;
padding-top: 2mm;
border-top: 1px solid #cfcfcf;
}
.due-row td {
font-size: 10pt;
font-weight: 700;
border-top: 1px solid #cfcfcf;
padding-top: 2.2mm;
}
.qr-only-page {
page: qrpage;
position: relative;
width: 210mm;
height: 297mm;
background: #fff;
page-break-before: always;
}
.qr-bill-bottom {
position: absolute;
left: 0;
bottom: 0;
width: 210mm;
height: 105mm;
overflow: hidden;
background: #fff;
}
.qr-bill-bottom svg {
width: 210mm !important;
height: 105mm !important;
display: block;
}
</style>
</head>
<body>
<div class="invoice-page">
<div class="header">
<div>
<div><strong th:text="${sellerDisplayName}">Nome Cognome</strong></div>
<div th:text="${sellerAddressLine1}">Via Esempio 12</div>
<div th:text="${sellerAddressLine2}">6500 Bellinzona, CH</div>
<div th:text="${sellerEmail}">email@example.com</div>
</div>
<table class="top-layout">
<tr>
<td>
<div class="doc-title">Conferma ordine</div>
<div class="doc-subtitle">Ricevuta semplificata</div>
</td>
<td class="seller-block">
<div class="seller-name" th:text="${sellerDisplayName}">3D Fab Switzerland</div>
<div th:text="${sellerAddressLine1}">Sede Ticino, Svizzera</div>
<div th:text="${sellerAddressLine2}">Sede Bienne, Svizzera</div>
<div th:text="${sellerEmail}">info@3dfab.ch</div>
</td>
</tr>
</table>
<div>
<div><strong>Fattura</strong></div>
<div>Numero: <span th:text="${invoiceNumber}">2026-000123</span></div>
<div>Data: <span th:text="${invoiceDate}">2026-02-13</span></div>
<div>Scadenza: <span th:text="${dueDate}">2026-02-20</span></div>
</div>
</div>
<table class="meta-layout">
<tr>
<td class="order-details">
<table class="details-table">
<tr>
<td class="details-label">Data ordine / fattura</td>
<td class="details-value" th:text="${invoiceDate}">2026-02-13</td>
</tr>
<tr>
<td class="details-label">Numero documento</td>
<td class="details-value" th:text="${invoiceNumber}">INV-2026-000123</td>
</tr>
<tr>
<td class="details-label">Data di scadenza</td>
<td class="details-value" th:text="${dueDate}">2026-02-20</td>
</tr>
<tr>
<td class="details-label">Valuta</td>
<td class="details-value">CHF</td>
</tr>
</table>
</td>
<td class="customer-box">
<div class="box-title">Cliente</div>
<div th:text="${buyerDisplayName}">Cliente SA</div>
<div th:text="${buyerAddressLine1}">Via Cliente 7</div>
<div th:text="${buyerAddressLine2}">8000 Zürich, CH</div>
</td>
</tr>
</table>
<div class="addresses">
<div>
<div><strong>Fatturare a</strong></div>
<div th:text="${buyerDisplayName}">Cliente SA</div>
<div th:text="${buyerAddressLine1}">Via Cliente 7</div>
<div th:text="${buyerAddressLine2}">8000 Zürich, CH</div>
</div>
</div>
<table>
<table class="line-items">
<thead>
<tr>
<th>Descrizione</th>
<th style="text-align:right;">Qtà</th>
<th style="text-align:right;">Prezzo</th>
<th style="text-align:right;">Totale</th>
<th>Qtà</th>
<th>Prezzo unit.</th>
<th>Totale</th>
</tr>
</thead>
<tbody>
<tr th:each="lineItem : ${invoiceLineItems}">
<td th:text="${lineItem.description}">Stampa 3D pezzo X</td>
<td style="text-align:right;" th:text="${lineItem.quantity}">1</td>
<td style="text-align:right;" th:text="${lineItem.unitPriceFormatted}">CHF 10.00</td>
<td style="text-align:right;" th:text="${lineItem.lineTotalFormatted}">CHF 10.00</td>
<td th:text="${lineItem.quantity}">1</td>
<td th:text="${lineItem.unitPriceFormatted}">CHF 10.00</td>
<td th:text="${lineItem.lineTotalFormatted}">CHF 10.00</td>
</tr>
</tbody>
</table>
<table class="totals">
<table class="summary-layout">
<tr>
<td>Subtotale</td>
<td style="text-align:right;" th:text="${subtotalFormatted}">CHF 10.00</td>
</tr>
<tr>
<td><strong>Totale</strong></td>
<td style="text-align:right;"><strong th:text="${grandTotalFormatted}">CHF 10.00</strong></td>
<td class="notes">
<div class="section-caption">Informazioni</div>
<div th:text="${paymentTermsText}">
Appena riceviamo il pagamento l'ordine entra nella coda di stampa. Grazie per la fiducia.
</div>
<div style="margin-top: 2.5mm;">
Verifica i dettagli dell'ordine al ricevimento. Per assistenza, rispondi alla nostra email di conferma.
</div>
</td>
<td>
<table class="totals">
<tr>
<td class="totals-label">Subtotale</td>
<td class="totals-value" th:text="${subtotalFormatted}">CHF 10.00</td>
</tr>
<tr class="total-strong">
<td class="totals-label">Totale ordine</td>
<td class="totals-value" th:text="${grandTotalFormatted}">CHF 10.00</td>
</tr>
<tr class="due-row">
<td class="totals-label">Importo dovuto</td>
<td class="totals-value" th:text="${grandTotalFormatted}">CHF 10.00</td>
</tr>
</table>
</td>
</tr>
</table>
<div style="margin-top:6mm;" th:text="${paymentTermsText}">
Pagamento entro 7 giorni. Grazie.
</div>
<div style="page-break-before: always;"></div>
<div style="position: absolute; bottom: 0; left: 0; width: 210mm; height: 105mm;" th:utext="${qrBillSvg}">
<div class="qr-only-page">
<div class="qr-bill-bottom" th:utext="${qrBillSvg}">
</div>
</div>
</body>

View File

@@ -1,151 +0,0 @@
package com.printcalculator;
import com.printcalculator.controller.QuoteSessionController;
import com.printcalculator.dto.PrintSettingsDto;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.service.SlicerService;
import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.StorageService;
import com.printcalculator.service.StlService;
import com.printcalculator.service.ProfileManager;
import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.model.StlBounds;
import com.printcalculator.model.StlShiftResult;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import java.io.File;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.util.Optional;
import java.util.Map;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
import static org.junit.jupiter.api.Assertions.*;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
@WebMvcTest(QuoteSessionController.class)
public class ManualSessionPersistenceTest {
@Autowired
private QuoteSessionController controller;
@MockitoBean
private QuoteSessionRepository sessionRepo;
@MockitoBean
private QuoteLineItemRepository lineItemRepo; // Mock this too
@MockitoBean
private SlicerService slicerService;
@MockitoBean
private StorageService storageService;
@MockitoBean
private StlService stlService;
@MockitoBean
private ProfileManager profileManager;
@MockitoBean
private QuoteCalculator quoteCalculator;
@MockitoBean
private PrinterMachineRepository machineRepo;
@MockitoBean
private com.printcalculator.repository.PricingPolicyRepository pricingRepo; // Add this if needed by controller
@Test
public void testSettingsPersistence() throws Exception {
// Prepare
UUID sessionId = UUID.randomUUID();
QuoteSession session = new QuoteSession();
session.setId(sessionId);
session.setMaterialCode("pla_basic"); // Initial state
when(sessionRepo.findById(sessionId)).thenReturn(Optional.of(session));
when(sessionRepo.save(any(QuoteSession.class))).thenAnswer(i -> i.getArguments()[0]);
when(lineItemRepo.save(any(QuoteLineItem.class))).thenAnswer(i -> i.getArguments()[0]);
// 2. Add Item with Custom Settings
PrintSettingsDto settings = new PrintSettingsDto();
settings.setComplexityMode("ADVANCED");
settings.setMaterial("petg_basic");
settings.setLayerHeight(0.12);
settings.setInfillDensity(50.0);
settings.setInfillPattern("gyroid");
settings.setSupportsEnabled(true);
settings.setNozzleDiameter(0.6);
settings.setNotes("Test Notes");
MockMultipartFile file = new MockMultipartFile("file", "test.stl", "application/octet-stream", "dummy content".getBytes());
// Mock dependencies
when(machineRepo.findFirstByIsActiveTrue()).thenReturn(Optional.of(new PrinterMachine(){{
setPrinterDisplayName("TestPrinter");
setSlicerMachineProfile("TestProfile");
setBuildVolumeXMm(256);
setBuildVolumeYMm(256);
setBuildVolumeZMm(256);
}}));
when(slicerService.slice(any(), any(), any(), any(), any(), any())).thenReturn(new PrintStats(100, "1m", 10.0, 100));
when(quoteCalculator.calculate(any(), any(), any())).thenReturn(
new QuoteResult(10.0, "CHF", new PrintStats(100, "1m", 10.0, 100), 0.0)
);
when(stlService.readBounds(any())).thenReturn(new StlBounds(0, 0, 0, 10, 10, 10));
when(stlService.shiftToFitIfNeeded(any(), any(), anyInt(), anyInt(), anyInt()))
.thenReturn(new StlShiftResult(null, 0, 0, 0, false));
when(profileManager.resolveMachineProfileName(any(), any())).thenAnswer(i -> i.getArguments()[0]);
when(storageService.loadAsResource(any())).thenReturn(new org.springframework.core.io.ByteArrayResource("dummy".getBytes()){
@Override
public File getFile() { return new File("dummy"); }
});
controller.addItemToExistingSession(sessionId, settings, file);
// 3. Verify Session Updated via Save Call capture
ArgumentCaptor<QuoteSession> captor = ArgumentCaptor.forClass(QuoteSession.class);
verify(sessionRepo).save(captor.capture());
QuoteSession updatedSession = captor.getValue();
assertEquals("petg_basic", updatedSession.getMaterialCode());
assertEquals(0, BigDecimal.valueOf(0.12).compareTo(updatedSession.getLayerHeightMm()));
assertEquals(50, updatedSession.getInfillPercent());
assertEquals("gyroid", updatedSession.getInfillPattern());
assertTrue(updatedSession.getSupportsEnabled());
assertEquals(0, BigDecimal.valueOf(0.6).compareTo(updatedSession.getNozzleDiameterMm()));
assertEquals("Test Notes", updatedSession.getNotes());
System.out.println("Verification Passed: Settings were persisted to Session.");
}
@org.springframework.boot.test.context.TestConfiguration
static class TestConfig {
@org.springframework.context.annotation.Bean
public org.springframework.transaction.PlatformTransactionManager transactionManager() {
return org.mockito.Mockito.mock(org.springframework.transaction.PlatformTransactionManager.class);
}
}
}

View File

@@ -1,23 +0,0 @@
package com.printcalculator.config;
import com.printcalculator.service.ClamAVService;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import java.io.InputStream;
@TestConfiguration
public class TestConfig {
@Bean
@Primary
public ClamAVService mockClamAVService() {
return new ClamAVService("localhost", 3310, true) {
@Override
public boolean scan(InputStream inputStream) {
return true; // Always clean for tests
}
};
}
}

View File

@@ -1,176 +0,0 @@
package com.printcalculator.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.printcalculator.dto.CreateOrderRequest;
import com.printcalculator.dto.CustomerDto;
import com.printcalculator.dto.AddressDto;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.util.FileSystemUtils;
import java.io.File;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.OffsetDateTime;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import com.printcalculator.service.ClamAVService;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@SpringBootTest
@AutoConfigureMockMvc
@org.springframework.test.context.TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL",
"spring.datasource.driverClassName=org.h2.Driver",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
"spring.jpa.hibernate.ddl-auto=create-drop"
})
class OrderIntegrationTest {
@MockitoBean
private ClamAVService clamAVService;
@Autowired
private MockMvc mockMvc;
@Autowired
private QuoteSessionRepository sessionRepository;
@Autowired
private QuoteLineItemRepository lineItemRepository;
@Autowired
private OrderRepository orderRepository;
@Autowired
private ObjectMapper objectMapper;
private UUID sessionId;
private UUID lineItemId;
private final String TEST_FILENAME = "test_model.stl";
@BeforeEach
void setup() throws Exception {
// Mock ClamAV to always return true (safe)
when(clamAVService.scan(any())).thenReturn(true);
// 1. Create Quote Session
QuoteSession session = new QuoteSession();
session.setStatus("ACTIVE");
session.setMaterialCode("PLA");
session.setPricingVersion("v1");
session.setCreatedAt(OffsetDateTime.now());
session.setExpiresAt(OffsetDateTime.now().plusDays(7));
session.setSetupCostChf(BigDecimal.valueOf(5.00));
session.setSupportsEnabled(false);
session = sessionRepository.save(session);
this.sessionId = session.getId();
// 2. Create Dummy File on Disk (storage_quotes)
Path sessionDir = Paths.get("storage_quotes", sessionId.toString());
Files.createDirectories(sessionDir);
Path filePath = sessionDir.resolve(UUID.randomUUID() + ".stl");
Files.writeString(filePath, "dummy content");
// 3. Create Quote Line Item
QuoteLineItem item = new QuoteLineItem();
item.setQuoteSession(session);
item.setStatus("READY");
item.setOriginalFilename(TEST_FILENAME);
item.setStoredPath(filePath.toString());
item.setQuantity(2);
item.setPrintTimeSeconds(120);
item.setMaterialGrams(BigDecimal.valueOf(10.5));
item.setUnitPriceChf(BigDecimal.valueOf(10.00));
item.setCreatedAt(OffsetDateTime.now());
item.setUpdatedAt(OffsetDateTime.now());
item = lineItemRepository.save(item);
this.lineItemId = item.getId();
}
@AfterEach
void cleanup() throws Exception {
// Cleanup generated files
FileSystemUtils.deleteRecursively(Paths.get("storage_quotes"));
FileSystemUtils.deleteRecursively(Paths.get("storage_orders"));
// Clean DB
orderRepository.deleteAll();
lineItemRepository.deleteAll();
sessionRepository.deleteAll();
}
@Test
void testCreateOrderFromQuote_ShouldCopyFilesAndUpdateStatus() throws Exception {
// Prepare Request
CreateOrderRequest request = new CreateOrderRequest();
CustomerDto customer = new CustomerDto();
customer.setEmail("integration@test.com");
customer.setCustomerType("PRIVATE");
request.setCustomer(customer);
AddressDto billing = new AddressDto();
billing.setFirstName("John");
billing.setLastName("Doe");
billing.setAddressLine1("Street 1");
billing.setCity("City");
billing.setZip("1000");
billing.setCountryCode("CH");
request.setBillingAddress(billing);
request.setShippingSameAsBilling(true);
// Execute Request
mockMvc.perform(post("/api/orders/from-quote/" + sessionId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk());
// Verify Session Status
QuoteSession updatedSession = sessionRepository.findById(sessionId).orElseThrow();
assertEquals("CONVERTED", updatedSession.getStatus(), "Session status should be CONVERTED");
assertNotNull(updatedSession.getConvertedOrderId(), "Converted Order ID should be set");
UUID orderId = updatedSession.getConvertedOrderId();
// Verify File Copy
Path orderStorageDir = Paths.get("storage_orders");
// We need to find the specific file. Structure: storage_orders/orderId/3d-files/orderItemId/filename
// Since we don't know OrderItemId easily without querying DB, let's walk the dir.
try (var stream = Files.walk(orderStorageDir)) {
boolean fileFound = stream
.filter(Files::isRegularFile)
.anyMatch(path -> {
try {
return Files.readString(path).equals("dummy content");
} catch (Exception e) {
return false;
}
});
assertTrue(fileFound, "The file should have been copied to storage_orders with correct content");
}
}
}

View File

@@ -0,0 +1,131 @@
package com.printcalculator.event.listener;
import com.printcalculator.entity.Customer;
import com.printcalculator.entity.Order;
import com.printcalculator.event.OrderCreatedEvent;
import com.printcalculator.service.email.EmailNotificationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class OrderEmailListenerTest {
@Mock
private EmailNotificationService emailNotificationService;
@InjectMocks
private OrderEmailListener orderEmailListener;
@Captor
private ArgumentCaptor<Map<String, Object>> templateDataCaptor;
private Order order;
private OrderCreatedEvent event;
@BeforeEach
void setUp() {
Customer customer = new Customer();
customer.setFirstName("John");
customer.setLastName("Doe");
customer.setEmail("john.doe@test.com");
order = new Order();
order.setId(UUID.randomUUID());
order.setCustomer(customer);
order.setCreatedAt(OffsetDateTime.parse("2026-02-21T10:00:00Z"));
order.setTotalChf(new BigDecimal("150.50"));
event = new OrderCreatedEvent(this, order);
ReflectionTestUtils.setField(orderEmailListener, "adminMailEnabled", true);
ReflectionTestUtils.setField(orderEmailListener, "adminMailAddress", "admin@printcalculator.local");
ReflectionTestUtils.setField(orderEmailListener, "frontendBaseUrl", "https://tuosito.it");
}
@Test
void handleOrderCreatedEvent_ShouldSendCustomerAndAdminEmails() {
// Act
orderEmailListener.handleOrderCreatedEvent(event);
// Assert Customer Email
verify(emailNotificationService, times(1)).sendEmail(
eq("john.doe@test.com"),
eq("Conferma Ordine #" + order.getOrderNumber() + " - 3D-Fab"),
eq("order-confirmation"),
templateDataCaptor.capture()
);
Map<String, Object> customerData = templateDataCaptor.getAllValues().get(0);
assertEquals("John", customerData.get("customerName"));
assertEquals(order.getId(), customerData.get("orderId"));
assertEquals(order.getOrderNumber(), customerData.get("orderNumber"));
assertEquals("https://tuosito.it/ordine/" + order.getId(), customerData.get("orderDetailsUrl"));
assertEquals(order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")), customerData.get("orderDate"));
assertEquals("150.50", customerData.get("totalCost"));
// Assert Admin Email
verify(emailNotificationService, times(1)).sendEmail(
eq("admin@printcalculator.local"),
eq("Nuovo Ordine Ricevuto #" + order.getOrderNumber() + " - Doe"),
eq("order-confirmation"),
templateDataCaptor.capture()
);
Map<String, Object> adminData = templateDataCaptor.getAllValues().get(1);
assertEquals("John Doe", adminData.get("customerName"));
}
@Test
void handleOrderCreatedEvent_WithAdminDisabled_ShouldOnlySendCustomerEmail() {
// Arrange
ReflectionTestUtils.setField(orderEmailListener, "adminMailEnabled", false);
// Act
orderEmailListener.handleOrderCreatedEvent(event);
// Assert
verify(emailNotificationService, times(1)).sendEmail(
eq("john.doe@test.com"),
anyString(),
anyString(),
any()
);
verify(emailNotificationService, never()).sendEmail(
eq("admin@printcalculator.local"),
anyString(),
anyString(),
any()
);
}
@Test
void handleOrderCreatedEvent_ExceptionHandling_ShouldNotPropagate() {
// Arrange
doThrow(new RuntimeException("Simulated Mail Failure"))
.when(emailNotificationService).sendEmail(anyString(), anyString(), anyString(), any());
// Act & Assert
// Event listener shouldn't throw exception back, thus passing the test.
orderEmailListener.handleOrderCreatedEvent(event);
verify(emailNotificationService, times(1)).sendEmail(anyString(), anyString(), anyString(), any());
}
}

View File

@@ -27,10 +27,10 @@ class GCodeParserTest {
PrintStats stats = parser.parse(tempFile);
// Assert
assertEquals(3723L, stats.getPrintTimeSeconds()); // 3600 + 120 + 3
assertEquals("1h 2m 3s", stats.getPrintTimeFormatted());
assertEquals(10.5, stats.getFilamentWeightGrams(), 0.001);
assertEquals(3000.0, stats.getFilamentLengthMm(), 0.001);
assertEquals(3723, stats.printTimeSeconds()); // 3600 + 120 + 3
assertEquals("1h 2m 3s", stats.printTimeFormatted());
assertEquals(10.5, stats.filamentWeightGrams(), 0.001);
assertEquals(3000.0, stats.filamentLengthMm(), 0.001);
tempFile.delete();
}
@@ -49,8 +49,8 @@ class GCodeParserTest {
GCodeParser parser = new GCodeParser();
PrintStats stats = parser.parse(tempFile);
assertEquals(750L, stats.getPrintTimeSeconds()); // 12*60 + 30
assertEquals(5.0, stats.getFilamentWeightGrams(), 0.001);
assertEquals(750, stats.printTimeSeconds()); // 12*60 + 30
assertEquals(5.0, stats.filamentWeightGrams(), 0.001);
tempFile.delete();
}
@@ -69,8 +69,8 @@ class GCodeParserTest {
GCodeParser parser = new GCodeParser();
PrintStats stats = parser.parse(tempFile);
assertEquals(3723L, stats.getPrintTimeSeconds());
assertEquals("1h 2m 3s", stats.getPrintTimeFormatted());
assertEquals(3723L, stats.printTimeSeconds());
assertEquals("1h 2m 3s", stats.printTimeFormatted());
tempFile.delete();
}
@@ -87,8 +87,8 @@ class GCodeParserTest {
GCodeParser parser = new GCodeParser();
PrintStats stats = parser.parse(tempFile);
assertEquals(3723L, stats.getPrintTimeSeconds());
assertEquals("01:02:03", stats.getPrintTimeFormatted());
assertEquals(3723L, stats.printTimeSeconds());
assertEquals("01:02:03", stats.printTimeFormatted());
tempFile.delete();
}
@@ -105,8 +105,8 @@ class GCodeParserTest {
GCodeParser parser = new GCodeParser();
PrintStats stats = parser.parse(tempFile);
assertEquals(321L, stats.getPrintTimeSeconds());
assertEquals("5m 21s", stats.getPrintTimeFormatted());
assertEquals(321L, stats.printTimeSeconds());
assertEquals("5m 21s", stats.printTimeFormatted());
tempFile.delete();
}

View File

@@ -1,123 +0,0 @@
package com.printcalculator.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.printcalculator.model.PrintStats;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.any;
class SlicerServiceTest {
@Mock
private ProfileManager profileManager;
@Mock
private GCodeParser gCodeParser;
private ObjectMapper mapper = new ObjectMapper();
private SlicerService slicerService;
@TempDir
Path tempDir;
// Captured execution details
private List<String> lastCommand;
private Path lastTempDir;
@BeforeEach
void setUp() throws IOException {
MockitoAnnotations.openMocks(this);
// Subclass to override runSlicerCommand
slicerService = new SlicerService("orca-slicer", profileManager, gCodeParser, mapper) {
@Override
protected void runSlicerCommand(List<String> command, Path tempDir) throws IOException, InterruptedException {
lastCommand = command;
lastTempDir = tempDir;
// Don't run actual process.
// Simulate GCode output creation for the parser to find?
// Or just let it fail at parser step since we only care about JSON generation here?
// For a full test, we should create a dummy GCode file.
File stl = new File(command.get(command.size() - 1));
String basename = stl.getName().replace(".stl", "");
Files.createFile(tempDir.resolve(basename + ".gcode"));
}
};
// Mock Profile Responses
ObjectNode emptyNode = mapper.createObjectNode();
when(profileManager.getMergedProfile(anyString(), eq("machine"))).thenReturn(emptyNode.deepCopy());
when(profileManager.getMergedProfile(anyString(), eq("filament"))).thenReturn(emptyNode.deepCopy());
when(profileManager.getMergedProfile(anyString(), eq("process"))).thenReturn(emptyNode.deepCopy());
// Mock Parser
when(gCodeParser.parse(any(File.class))).thenReturn(new PrintStats(100, "1m 40s", 10.5, 1000));
}
@Test
void testSlice_WithDefaults_ShouldGenerateConfig() throws IOException {
File dummyStl = tempDir.resolve("test.stl").toFile();
Files.createFile(dummyStl.toPath());
slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, null);
assertNotNull(lastTempDir);
assertTrue(Files.exists(lastTempDir.resolve("process.json")));
assertTrue(Files.exists(lastTempDir.resolve("machine.json")));
assertTrue(Files.exists(lastTempDir.resolve("filament.json")));
}
@Test
void testSlice_WithLayerHeightOverride_ShouldUpdateProcessJson() throws IOException {
File dummyStl = tempDir.resolve("test.stl").toFile();
Files.createFile(dummyStl.toPath());
Map<String, String> processOverrides = new HashMap<>();
processOverrides.put("layer_height", "0.12");
slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, processOverrides);
File processJsonFile = lastTempDir.resolve("process.json").toFile();
ObjectNode processJson = (ObjectNode) mapper.readTree(processJsonFile);
assertTrue(processJson.has("layer_height"));
assertEquals("0.12", processJson.get("layer_height").asText());
}
@Test
void testSlice_WithInfillAndSupportOverrides_ShouldUpdateProcessJson() throws IOException {
File dummyStl = tempDir.resolve("test.stl").toFile();
Files.createFile(dummyStl.toPath());
Map<String, String> processOverrides = new HashMap<>();
processOverrides.put("sparse_infill_density", "25%");
processOverrides.put("enable_support", "1");
slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, processOverrides);
File processJsonFile = lastTempDir.resolve("process.json").toFile();
ObjectNode processJson = (ObjectNode) mapper.readTree(processJsonFile);
assertEquals("25%", processJson.get("sparse_infill_density").asText());
assertEquals("1", processJson.get("enable_support").asText());
}
}

View File

@@ -0,0 +1,83 @@
package com.printcalculator.service.email;
import jakarta.mail.internet.MimeMessage;
import org.junit.jupiter.api.BeforeEach;
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.mail.javamail.JavaMailSender;
import org.springframework.test.util.ReflectionTestUtils;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class SmtpEmailNotificationServiceTest {
@Mock
private JavaMailSender emailSender;
@Mock
private TemplateEngine templateEngine;
@Mock
private MimeMessage mimeMessage;
@InjectMocks
private SmtpEmailNotificationService emailNotificationService;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(emailNotificationService, "fromAddress", "noreply@test.com");
ReflectionTestUtils.setField(emailNotificationService, "mailEnabled", true);
}
@Test
void sendEmail_Success() {
// Arrange
String to = "user@test.com";
String subject = "Test Subject";
String templateName = "test-template";
Map<String, Object> contextData = new HashMap<>();
contextData.put("key", "value");
when(templateEngine.process(eq("email/" + templateName), any(Context.class))).thenReturn("<html>Test</html>");
when(emailSender.createMimeMessage()).thenReturn(mimeMessage);
// Act
emailNotificationService.sendEmail(to, subject, templateName, contextData);
// Assert
verify(templateEngine, times(1)).process(eq("email/" + templateName), any(Context.class));
verify(emailSender, times(1)).createMimeMessage();
verify(emailSender, times(1)).send(mimeMessage);
}
@Test
void sendEmail_Exception_ShouldNotThrow() {
// Arrange
String to = "user@test.com";
String subject = "Test Subject";
String templateName = "test-template";
Map<String, Object> contextData = new HashMap<>();
when(templateEngine.process(eq("email/" + templateName), any(Context.class))).thenThrow(new RuntimeException("Template error"));
// Act & Assert
// We expect the exception to be caught and logged, not propagated
assertDoesNotThrow(() -> emailNotificationService.sendEmail(to, subject, templateName, contextData));
verify(emailSender, never()).createMimeMessage();
verify(emailSender, never()).send(any(MimeMessage.class));
}
}

4
db.sql
View File

@@ -12,7 +12,6 @@ create table printer_machine
fleet_weight numeric(6, 3) not null default 1.000,
is_active boolean not null default true,
slicer_machine_profile varchar(255),
created_at timestamptz not null default now()
);
@@ -554,7 +553,7 @@ CREATE TABLE IF NOT EXISTS payments
order_id uuid NOT NULL REFERENCES orders (order_id) ON DELETE CASCADE,
method text NOT NULL CHECK (method IN ('QR_BILL', 'BANK_TRANSFER', 'TWINT', 'CARD', 'OTHER')),
status text NOT NULL CHECK (status IN ('PENDING', 'RECEIVED', 'FAILED', 'CANCELLED', 'REFUNDED')),
status text NOT NULL CHECK (status IN ('PENDING', 'REPORTED', 'RECEIVED', 'FAILED', 'CANCELLED', 'REFUNDED')),
currency char(3) NOT NULL DEFAULT 'CHF',
amount_chf numeric(12, 2) NOT NULL CHECK (amount_chf >= 0),
@@ -565,6 +564,7 @@ CREATE TABLE IF NOT EXISTS payments
qr_payload text, -- se vuoi salvare contenuto QR/Swiss QR bill
initiated_at timestamptz NOT NULL DEFAULT now(),
reported_at timestamptz,
received_at timestamptz
);

View File

@@ -7,4 +7,7 @@ TAG=dev
BACKEND_PORT=18002
FRONTEND_PORT=18082
CLAMAV_HOST=192.168.1.147
CLAMAV_PORT=3310
CLAMAV_ENABLED=true

View File

@@ -7,4 +7,7 @@ TAG=int
BACKEND_PORT=18001
FRONTEND_PORT=18081
CLAMAV_HOST=192.168.1.147
CLAMAV_PORT=3310
CLAMAV_ENABLED=true

View File

@@ -7,4 +7,7 @@ TAG=prod
BACKEND_PORT=8000
FRONTEND_PORT=80
CLAMAV_HOST=192.168.1.147
CLAMAV_PORT=3310
CLAMAV_ENABLED=true

View File

@@ -1,5 +1,3 @@
version: '3.8'
services:
backend:
# L'immagine usa il tag specificato nel file .env o passato da riga di comando
@@ -7,26 +5,39 @@ services:
container_name: print-calculator-backend-${ENV}
ports:
- "${BACKEND_PORT}:8000"
env_file:
- .env
environment:
- SPRING_PROFILES_ACTIVE=${ENV}
- DB_URL=${DB_URL}
- DB_USERNAME=${DB_USERNAME}
- DB_PASSWORD=${DB_PASSWORD}
- CLAMAV_HOST=${CLAMAV_HOST}
- CLAMAV_PORT=${CLAMAV_PORT}
- CLAMAV_ENABLED=${CLAMAV_ENABLED}
- MAIL_HOST=${MAIL_HOST:-mail.infomaniak.com}
- MAIL_PORT=${MAIL_PORT:-587}
- MAIL_USERNAME=${MAIL_USERNAME:-info@3d-fab.ch}
- MAIL_PASSWORD=${MAIL_PASSWORD:-}
- MAIL_SMTP_AUTH=${MAIL_SMTP_AUTH:-true}
- MAIL_SMTP_STARTTLS=${MAIL_SMTP_STARTTLS:-true}
- APP_MAIL_FROM=${APP_MAIL_FROM:-info@3d-fab.ch}
- APP_MAIL_ADMIN_ENABLED=${APP_MAIL_ADMIN_ENABLED:-true}
- APP_MAIL_ADMIN_ADDRESS=${APP_MAIL_ADMIN_ADDRESS:-info@3d-fab.ch}
- TEMP_DIR=/app/temp
- PROFILES_DIR=/app/profiles
- CLAMAV_HOST=host.docker.internal
- CLAMAV_PORT=3310
- STORAGE_LOCATION=/app/storage
restart: always
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes:
- backend_profiles_${ENV}:/app/profiles
- /mnt/cache/appdata/print-calculator/${ENV}/storage_quotes:/app/storage/quotes
- /mnt/cache/appdata/print-calculator/${ENV}/storage_orders:/app/storage/orders
- /mnt/cache/appdata/print-calculator/${ENV}/storage_quotes:/app/storage_quotes
- /mnt/cache/appdata/print-calculator/${ENV}/storage_orders:/app/storage_orders
- /mnt/cache/appdata/print-calculator/${ENV}/storage_requests:/app/storage_requests
extra_hosts:
- "host.docker.internal:host-gateway"
frontend:
image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-frontend:${TAG}
container_name: print-calculator-frontend-${ENV}
@@ -35,6 +46,11 @@ services:
depends_on:
- backend
restart: always
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes:
backend_profiles_prod:

View File

@@ -1,48 +1,4 @@
services:
backend:
platform: linux/amd64
build:
context: ./backend
platforms:
- linux/amd64
container_name: print-calculator-backend
ports:
- "8000:8000"
environment:
- DB_URL=jdbc:postgresql://db:5432/printcalc
- DB_USERNAME=printcalc
- DB_PASSWORD=printcalc_secret
- SPRING_PROFILES_ACTIVE=local
- TEMP_DIR=/app/temp
- PROFILES_DIR=/app/profiles
- CLAMAV_HOST=clamav
- CLAMAV_PORT=3310
- STORAGE_LOCATION=/app/storage
depends_on:
- db
- clamav
restart: unless-stopped
clamav:
platform: linux/amd64
image: clamav/clamav:latest
container_name: print-calculator-clamav
ports:
- "3310:3310"
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
container_name: print-calculator-frontend
ports:
- "80:80"
depends_on:
- backend
- db
restart: unless-stopped
db:
image: postgres:15-alpine
container_name: print-calculator-db
@@ -56,5 +12,16 @@ services:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
clamav:
platform: linux/amd64
image: clamav/clamav:latest
container_name: print-calculator-clamav
ports:
- "3310:3310"
volumes:
- clamav_db:/var/lib/clamav
restart: unless-stopped
volumes:
postgres_data:
clamav_db:

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,14 @@ export const routes: Routes = [
path: 'payment/:orderId',
loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent)
},
{
path: 'ordine/:orderId',
loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent)
},
{
path: 'order-confirmed/:orderId',
loadComponent: () => import('./features/order-confirmed/order-confirmed.component').then(m => m.OrderConfirmedComponent)
},
{
path: '',
loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES)

View File

@@ -2,10 +2,8 @@
<h1>{{ 'CALC.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p>
@if (error() === 'VIRUS_DETECTED') {
<app-alert type="error">{{ 'CALC.ERROR_VIRUS_DETECTED' | translate }}</app-alert>
} @else if (error()) {
<app-alert type="error">{{ 'CALC.ERROR_' + error() | translate }}</app-alert>
@if (error()) {
<app-alert type="error">{{ 'CALC.ERROR_GENERIC' | translate }}</app-alert>
}
</div>
@@ -21,12 +19,12 @@
<div class="mode-selector">
<div class="mode-option"
[class.active]="mode() === 'easy'"
(click)="setMode('easy')">
(click)="mode.set('easy')">
{{ 'CALC.MODE_EASY' | translate }}
</div>
<div class="mode-option"
[class.active]="mode() === 'advanced'"
(click)="setMode('advanced')">
(click)="mode.set('advanced')">
{{ 'CALC.MODE_ADVANCED' | translate }}
</div>
</div>
@@ -37,7 +35,6 @@
[loading]="loading()"
[uploadProgress]="uploadProgress()"
(submitRequest)="onCalculate($event)"
(itemRemoved)="onItemRemoved($event)"
></app-upload-form>
</app-card>
</div>
@@ -45,25 +42,15 @@
<!-- Right Column: Result or Info -->
<div class="col-result" #resultCol>
@if (loading() && !result()) {
<!-- Initial Loading State (before first result) -->
@if (loading()) {
<app-card class="loading-state">
<div class="loader-content">
<div class="spinner"></div>
<h3 class="loading-title">Analisi in corso...</h3>
<p class="loading-text">Stiamo analizzando la geometria e calcolando il percorso utensile.</p>
<h3 class="loading-title">{{ 'CALC.ANALYZING_TITLE' | translate }}</h3>
<p class="loading-text">{{ 'CALC.ANALYZING_TEXT' | translate }}</p>
</div>
</app-card>
} @else if (result()) {
<!-- Result State (Active or Finished) -->
@if (loading()) {
<!-- Small loader indicator when refining results -->
<div class="analyzing-bar">
<div class="spinner-small"></div>
<span>Analisi in corso... ({{ uploadProgress() }}%)</span>
</div>
}
<app-quote-result
[result]="result()!"
(consult)="onConsult()"

View File

@@ -26,7 +26,7 @@ export class CalculatorPageComponent implements OnInit {
loading = signal(false);
uploadProgress = signal(0);
result = signal<QuoteResult | null>(null);
error = signal<string | null>(null);
error = signal<boolean>(false);
orderSuccess = signal(false);
@@ -48,7 +48,7 @@ export class CalculatorPageComponent implements OnInit {
this.route.queryParams.subscribe(params => {
const sessionId = params['session'];
if (sessionId && sessionId !== this.result()?.sessionId) {
if (sessionId) {
this.loadSession(sessionId);
}
});
@@ -75,7 +75,7 @@ export class CalculatorPageComponent implements OnInit {
},
error: (err) => {
console.error('Failed to load session', err);
this.error.set('Failed to load session');
this.error.set(true);
this.loading.set(false);
}
});
@@ -106,14 +106,14 @@ export class CalculatorPageComponent implements OnInit {
forkJoin(downloads).subscribe({
next: (results: any[]) => {
const files = results.map(res => new File([res.blob], res.fileName, { type: 'application/octet-stream' }));
const colors = items.map(i => i.colorCode || 'Black');
if (this.uploadForm) {
this.uploadForm.setFiles(files, colors);
this.uploadForm.setFiles(files);
this.uploadForm.patchSettings(session);
// Also restore colors?
// setFiles inits with correct colors now.
// setFiles inits with 'Black'. We need to update them if they differ.
// items has colorCode.
setTimeout(() => {
if (this.uploadForm) {
items.forEach((item, index) => {
@@ -122,11 +122,7 @@ export class CalculatorPageComponent implements OnInit {
if (item.colorCode) {
this.uploadForm.updateItemColor(index, item.colorCode);
}
if (item.quantity) {
this.uploadForm.updateItemQuantityAtIndex(index, item.quantity);
}
});
this.uploadForm.updateItemIdsByIndex(items.map(i => i.id));
}
});
}
@@ -145,7 +141,7 @@ export class CalculatorPageComponent implements OnInit {
this.currentRequest = req;
this.loading.set(true);
this.uploadProgress.set(0);
this.error.set(null);
this.error.set(false);
this.result.set(null);
this.orderSuccess.set(false);
@@ -161,45 +157,26 @@ export class CalculatorPageComponent implements OnInit {
if (typeof event === 'number') {
this.uploadProgress.set(event);
} else {
// It's the result (partial or final)
// It's the result
const res = event as QuoteResult;
this.result.set(res);
// Show result immediately if not already showing
if (this.step() !== 'quote') {
this.step.set('quote');
}
// Sync IDs back to upload form for future updates
if (this.uploadForm) {
this.uploadForm.updateItemIdsByIndex(res.items.map(i => i.id));
}
this.loading.set(false);
this.uploadProgress.set(100);
this.step.set('quote');
// Update URL with session ID without reloading
if (res.sessionId) {
// Check if we need to update URL to avoid redundant navigations
const currentSession = this.route.snapshot.queryParamMap.get('session');
if (currentSession !== res.sessionId) {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { session: res.sessionId },
queryParamsHandling: 'merge',
replaceUrl: true
});
}
this.router.navigate([], {
relativeTo: this.route,
queryParams: { session: res.sessionId },
queryParamsHandling: 'merge', // merge with existing params like 'mode' if any
replaceUrl: true // prevent cluttering history, or false if we want back button to work. replaceUrl seems safer for "state update"
});
}
}
},
complete: () => {
this.loading.set(false);
this.uploadProgress.set(100);
},
error: (err) => {
if (typeof err === 'string') {
this.error.set(err);
} else {
this.error.set('GENERIC');
}
error: () => {
this.error.set(true);
this.loading.set(false);
}
});
@@ -219,10 +196,10 @@ export class CalculatorPageComponent implements OnInit {
this.step.set('quote');
}
onItemChange(event: {id?: string, fileName: string, quantity: number, index: number}) {
onItemChange(event: {id?: string, fileName: string, quantity: number}) {
// 1. Update local form for consistency (UI feedback)
if (this.uploadForm) {
this.uploadForm.updateItemQuantityAtIndex(event.index, event.quantity);
this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity);
}
// 2. Update backend session if ID exists
@@ -234,43 +211,6 @@ export class CalculatorPageComponent implements OnInit {
}
}
onItemRemoved(event: {index: number, id?: string}) {
// 1. Update local result if exists to keep UI in sync
const currentRes = this.result();
if (currentRes) {
const updatedItems = [...currentRes.items];
updatedItems.splice(event.index, 1);
// Recalculate totals locally for immediate feedback
let totalTime = 0;
let totalWeight = 0;
let itemsPrice = 0;
updatedItems.forEach(i => {
totalTime += i.unitTime * i.quantity;
totalWeight += i.unitWeight * i.quantity;
itemsPrice += i.unitPrice * i.quantity;
});
this.result.set({
...currentRes,
items: updatedItems,
totalPrice: Math.round((itemsPrice + currentRes.setupCost) * 100) / 100,
totalTimeHours: Math.floor(totalTime / 3600),
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
totalWeight: Math.ceil(totalWeight)
});
}
// 2. Delete from backend if ID exists
if (event.id && currentRes?.sessionId) {
this.estimator.deleteLineItem(currentRes.sessionId, event.id).subscribe({
next: () => console.log('Line item deleted from backend'),
error: (err) => console.error('Failed to delete line item', err)
});
}
}
onSubmitOrder(orderData: any) {
console.log('Order Submitted:', orderData);
this.orderSuccess.set(true);
@@ -316,12 +256,4 @@ export class CalculatorPageComponent implements OnInit {
this.router.navigate(['/contact']);
}
setMode(mode: 'easy' | 'advanced') {
const path = mode === 'easy' ? 'basic' : 'advanced';
this.router.navigate(['../', path], {
relativeTo: this.route,
queryParamsHandling: 'merge'
});
}
}

View File

@@ -21,7 +21,7 @@
</div>
<div class="setup-note">
<small>* Include {{ result().setupCost | currency:result().currency }} Setup Cost</small>
<small>{{ 'CALC.SETUP_NOTE' | translate:{cost: (result().setupCost | currency:result().currency)} }}</small>
</div>
@if (result().notes) {
@@ -35,45 +35,28 @@
<!-- Detailed Items List (NOW ON BOTTOM) -->
<div class="items-list">
@for (item of items(); track item; let i = $index) {
<div class="item-row" [class.has-error]="item.error">
@for (item of items(); track item.fileName; let i = $index) {
<div class="item-row">
<div class="item-info">
<span class="file-name">{{ item.fileName }}</span>
@if (item.error) {
<span class="file-error">{{ 'CALC.ERROR_' + item.error | translate }}</span>
} @else if (item.status === 'pending') {
<span class="file-details pending">
<div class="spinner-mini"></div> Analisi...
</span>
} @else {
<span class="file-details">
<span class="color-badge" [title]="item.color" [style.background-color]="getColorHex(item.color!)"></span>
{{ (item.unitTime / 3600) | number:'1.1-1' }}h | {{ item.unitWeight | number:'1.0-0' }}g
</span>
}
<span class="file-details">
{{ (item.unitTime / 3600) | number:'1.1-1' }}h | {{ item.unitWeight | number:'1.0-0' }}g
</span>
</div>
<div class="item-controls">
@if (!item.error) {
<div class="qty-control">
<label>Qtà:</label>
<input
type="number"
min="1"
[ngModel]="item.quantity"
(ngModelChange)="updateQuantity(i, $event)"
class="qty-input">
</div>
<div class="item-price">
{{ (item.unitPrice * item.quantity) | currency:result().currency }}
</div>
} @else if (item.status === 'pending') {
<div class="item-price pending">
<div class="spinner-mini"></div>
</div>
} @else {
<div class="item-price error">-</div>
}
<div class="qty-control">
<label>{{ 'CHECKOUT.QTY' | translate }}:</label>
<input
type="number"
min="1"
[ngModel]="item.quantity"
(ngModelChange)="updateQuantity(i, $event)"
class="qty-input">
</div>
<div class="item-price">
{{ (item.unitPrice * item.quantity) | currency:result().currency }}
</div>
</div>
</div>
}

View File

@@ -21,11 +21,6 @@
background: var(--color-neutral-50);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
&.has-error {
border-color: #ef4444;
background: #fef2f2;
}
}
.item-info {
@@ -36,21 +31,7 @@
}
.file-name { font-weight: 500; font-size: 0.9rem; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-details {
font-size: 0.8rem;
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: var(--space-2);
}
.color-badge {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid var(--color-border);
display: inline-block;
}
.file-error { font-size: 0.8rem; color: #ef4444; font-weight: 500; }
.file-details { font-size: 0.8rem; color: var(--color-text-muted); }
.item-controls {
display: flex;

View File

@@ -6,7 +6,6 @@ import { AppCardComponent } from '../../../../shared/components/app-card/app-car
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component';
import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
import { getColorHex } from '../../../../core/constants/colors.const';
@Component({
selector: 'app-quote-result',
@@ -19,13 +18,11 @@ export class QuoteResultComponent {
result = input.required<QuoteResult>();
consult = output<void>();
proceed = output<void>();
itemChange = output<{id?: string, fileName: string, quantity: number, index: number}>();
itemChange = output<{id?: string, fileName: string, quantity: number}>();
// Local mutable state for items to handle quantity changes
items = signal<QuoteItem[]>([]);
getColorHex = getColorHex;
constructor() {
effect(() => {
// Initialize local items when result inputs change
@@ -47,8 +44,7 @@ export class QuoteResultComponent {
this.itemChange.emit({
id: this.items()[index].id,
fileName: this.items()[index].fileName,
quantity: qty,
index: index
quantity: qty
});
}
@@ -61,11 +57,9 @@ export class QuoteResultComponent {
let weight = 0;
currentItems.forEach(i => {
if (i.status === 'done' && !i.error) {
price += i.unitPrice * i.quantity;
time += i.unitTime * i.quantity;
weight += i.unitWeight * i.quantity;
}
price += i.unitPrice * i.quantity;
time += i.unitTime * i.quantity;
weight += i.unitWeight * i.quantity;
});
const hours = Math.floor(time / 3600);

View File

@@ -25,7 +25,7 @@
<!-- New File List with Details -->
@if (items().length > 0) {
<div class="items-grid">
@for (item of items(); track item; let i = $index) {
@for (item of items(); track item.file.name; let i = $index) {
<div class="file-card" [class.active]="item.file === selectedFile()" (click)="selectFile(item.file)">
<div class="card-header">
<span class="file-name" [title]="item.file.name">{{ item.file.name }}</span>
@@ -34,7 +34,7 @@
<div class="card-body">
<div class="card-controls">
<div class="qty-group">
<label>QTÀ</label>
<label>{{ 'CALC.QTY_SHORT' | translate }}</label>
<input
type="number"
min="1"
@@ -45,7 +45,7 @@
</div>
<div class="color-group">
<label>COLORE</label>
<label>{{ 'CALC.COLOR_LABEL' | translate }}</label>
<app-color-selector
[selectedColor]="item.color"
[variants]="currentMaterialVariants()"
@@ -134,7 +134,7 @@
<app-input
formControlName="notes"
[label]="'CALC.NOTES' | translate"
placeholder="Istruzioni specifiche..."
[placeholder]="'CALC.NOTES_PLACEHOLDER' | translate"
></app-input>
<div class="actions">
@@ -151,7 +151,7 @@
type="submit"
[disabled]="items().length === 0 || loading()"
[fullWidth]="true">
{{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
{{ loading() ? (uploadProgress() < 100 ? ('CALC.UPLOADING' | translate) : ('CALC.PROCESSING' | translate)) : ('CALC.CALCULATE' | translate) }}
</app-button>
</div>
</form>

View File

@@ -12,7 +12,6 @@ import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, Mat
import { getColorHex } from '../../../../core/constants/colors.const';
interface FormItem {
id?: string;
file: File;
quantity: number;
color: string;
@@ -30,7 +29,6 @@ export class UploadFormComponent implements OnInit {
loading = input<boolean>(false);
uploadProgress = input<number>(0);
submitRequest = output<QuoteRequest>();
itemRemoved = output<{index: number, id?: string}>();
private estimator = inject(QuoteEstimatorService);
private fb = inject(FormBuilder);
@@ -77,7 +75,7 @@ export class UploadFormComponent implements OnInit {
layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]],
nozzleDiameter: [0.4, Validators.required],
infillPattern: ['grid'],
supportEnabled: [true]
supportEnabled: [false]
});
// Listen to material changes to update variants
@@ -114,9 +112,7 @@ export class UploadFormComponent implements OnInit {
private setDefaults() {
// Set Defaults if available
if (this.materials().length > 0 && !this.form.get('material')?.value) {
// Prefer PLA Basic, otherwise first available
const pla = this.materials().find(m => m.value === 'pla_basic');
this.form.get('material')?.setValue(pla ? pla.value : this.materials()[0].value);
this.form.get('material')?.setValue(this.materials()[0].value);
}
if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
// Try to find 'standard' or use first
@@ -180,37 +176,6 @@ export class UploadFormComponent implements OnInit {
});
}
updateItemQuantityAtIndex(index: number, quantity: number) {
this.items.update(current => {
const updated = [...current];
if (updated[index]) {
updated[index] = { ...updated[index], quantity };
}
return updated;
});
}
updateItemIds(itemsWithIds: { fileName: string, id: string }[]) {
this.items.update(current => {
return current.map(item => {
const match = itemsWithIds.find(i => i.fileName === item.file.name && !i.id); // This matching is weak
// Better: matching should be based on index if we trust order
return item;
});
});
}
updateItemIdsByIndex(ids: (string | undefined)[]) {
this.items.update(current => {
return current.map((item, i) => {
if (ids[i]) {
return { ...item, id: ids[i] };
}
return item;
});
});
}
selectFile(file: File) {
if (this.selectedFile() === file) {
// toggle off? no, keep active
@@ -241,7 +206,11 @@ export class UploadFormComponent implements OnInit {
let val = parseInt(input.value, 10);
if (isNaN(val) || val < 1) val = 1;
this.updateItemQuantityAtIndex(index, val);
this.items.update(current => {
const updated = [...current];
updated[index] = { ...updated[index], quantity: val };
return updated;
});
}
updateItemColor(index: number, newColor: string) {
@@ -253,7 +222,6 @@ export class UploadFormComponent implements OnInit {
}
removeItem(index: number) {
const itemToRemove = this.items()[index];
this.items.update(current => {
const updated = [...current];
const removed = updated.splice(index, 1)[0];
@@ -262,15 +230,14 @@ export class UploadFormComponent implements OnInit {
}
return updated;
});
this.itemRemoved.emit({ index, id: itemToRemove.id });
}
setFiles(files: File[], colors?: string[]) {
setFiles(files: File[]) {
const validItems: FormItem[] = [];
files.forEach((file, i) => {
const color = (colors && colors[i]) ? colors[i] : 'Black';
validItems.push({ file, quantity: 1, color: color });
});
for (const file of files) {
// Default color is Black or derive from somewhere if possible, but here we just init
validItems.push({ file, quantity: 1, color: 'Black' });
}
if (validItems.length > 0) {
this.items.set(validItems);

View File

@@ -9,8 +9,8 @@
<div class="col-md-6">
<app-input
formControlName="name"
label="USER_DETAILS.NAME"
placeholder="USER_DETAILS.NAME_PLACEHOLDER"
[label]="'USER_DETAILS.NAME' | translate"
[placeholder]="'USER_DETAILS.NAME_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('name')?.invalid && form.get('name')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input>
@@ -18,8 +18,8 @@
<div class="col-md-6">
<app-input
formControlName="surname"
label="USER_DETAILS.SURNAME"
placeholder="USER_DETAILS.SURNAME_PLACEHOLDER"
[label]="'USER_DETAILS.SURNAME' | translate"
[placeholder]="'USER_DETAILS.SURNAME_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('surname')?.invalid && form.get('surname')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input>
@@ -31,9 +31,9 @@
<div class="col-md-6">
<app-input
formControlName="email"
label="USER_DETAILS.EMAIL"
[label]="'USER_DETAILS.EMAIL' | translate"
type="email"
placeholder="USER_DETAILS.EMAIL_PLACEHOLDER"
[placeholder]="'USER_DETAILS.EMAIL_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('email')?.invalid && form.get('email')?.touched ? ('COMMON.INVALID_EMAIL' | translate) : null">
</app-input>
@@ -41,9 +41,9 @@
<div class="col-md-6">
<app-input
formControlName="phone"
label="USER_DETAILS.PHONE"
[label]="'USER_DETAILS.PHONE' | translate"
type="tel"
placeholder="USER_DETAILS.PHONE_PLACEHOLDER"
[placeholder]="'USER_DETAILS.PHONE_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('phone')?.invalid && form.get('phone')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input>
@@ -53,8 +53,8 @@
<!-- Address -->
<app-input
formControlName="address"
label="USER_DETAILS.ADDRESS"
placeholder="USER_DETAILS.ADDRESS_PLACEHOLDER"
[label]="'USER_DETAILS.ADDRESS' | translate"
[placeholder]="'USER_DETAILS.ADDRESS_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('address')?.invalid && form.get('address')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input>
@@ -64,8 +64,8 @@
<div class="col-md-4">
<app-input
formControlName="zip"
label="USER_DETAILS.ZIP"
placeholder="USER_DETAILS.ZIP_PLACEHOLDER"
[label]="'USER_DETAILS.ZIP' | translate"
[placeholder]="'USER_DETAILS.ZIP_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('zip')?.invalid && form.get('zip')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input>
@@ -73,8 +73,8 @@
<div class="col-md-8">
<app-input
formControlName="city"
label="USER_DETAILS.CITY"
placeholder="USER_DETAILS.CITY_PLACEHOLDER"
[label]="'USER_DETAILS.CITY' | translate"
[placeholder]="'USER_DETAILS.CITY_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('city')?.invalid && form.get('city')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input>

View File

@@ -26,8 +26,6 @@ export interface QuoteItem {
quantity: number;
material?: string;
color?: string;
error?: string;
status: 'pending' | 'done' | 'error';
}
export interface QuoteResult {
@@ -140,13 +138,6 @@ export class QuoteEstimatorService {
return this.http.patch(`${environment.apiUrl}/api/quote-sessions/line-items/${lineItemId}`, changes, { headers });
}
deleteLineItem(sessionId: string, lineItemId: string): Observable<any> {
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.delete(`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}`, { headers });
}
createOrder(sessionId: string, orderDetails: any): Observable<any> {
const headers: any = {};
// @ts-ignore
@@ -161,6 +152,13 @@ export class QuoteEstimatorService {
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, { headers });
}
reportPayment(orderId: string, method: string): Observable<any> {
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.post(`${environment.apiUrl}/api/orders/${orderId}/payments/report`, { method }, { headers });
}
getOrderInvoice(orderId: string): Observable<Blob> {
const headers: any = {};
// @ts-ignore
@@ -171,6 +169,13 @@ export class QuoteEstimatorService {
});
}
getTwintPayment(orderId: string): Observable<any> {
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/twint`, { headers });
}
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
console.log('QuoteEstimatorService: Calculating quote...', request);
if (request.items.length === 0) {
@@ -189,74 +194,18 @@ export class QuoteEstimatorService {
const sessionId = sessionRes.id;
const sessionSetupCost = sessionRes.setupCostChf || 0;
// Initialize items in pending state
const currentItems: QuoteItem[] = request.items.map(item => ({
fileName: item.file.name,
unitPrice: 0,
unitTime: 0,
unitWeight: 0,
quantity: item.quantity,
status: 'pending',
color: item.color || 'White' // Default color for UI
}));
// Emit initial state
const initialResult: QuoteResult = {
sessionId: sessionId,
items: [...currentItems],
setupCost: sessionSetupCost,
currency: 'CHF',
totalPrice: 0, // Will be calculated dynamically
totalTimeHours: 0,
totalTimeMinutes: 0,
totalWeight: 0,
notes: request.notes
};
observer.next(initialResult);
// 2. Upload files to this session
const totalItems = request.items.length;
const allProgress: number[] = new Array(totalItems).fill(0);
const finalResponses: any[] = [];
let completedRequests = 0;
const emitUpdate = () => {
const checkCompletion = () => {
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
observer.next(avg);
// Helper to calculate totals for current items
let grandTotal = 0;
let totalTime = 0;
let totalWeight = 0;
let validCount = 0;
currentItems.forEach(item => {
if (item.status === 'done') {
grandTotal += item.unitPrice * item.quantity;
totalTime += item.unitTime * item.quantity;
totalWeight += item.unitWeight * item.quantity;
validCount++;
}
});
if (validCount > 0) {
grandTotal += sessionSetupCost;
}
const result: QuoteResult = {
sessionId: sessionId,
items: [...currentItems], // Create copy to trigger change detection
setupCost: sessionSetupCost,
currency: 'CHF',
totalPrice: Math.round(grandTotal * 100) / 100,
totalTimeHours: Math.floor(totalTime / 3600),
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
totalWeight: Math.ceil(totalWeight),
notes: request.notes
};
observer.next(result);
if (completedRequests === totalItems) {
observer.complete();
finalize(finalResponses, sessionSetupCost, sessionId);
}
};
@@ -286,42 +235,20 @@ export class QuoteEstimatorService {
}).subscribe({
next: (event) => {
if (event.type === HttpEventType.UploadProgress && event.total) {
allProgress[index] = Math.round((70 * event.loaded) / event.total); // Upload is 70% of "progress" for user perception
emitUpdate();
allProgress[index] = Math.round((100 * event.loaded) / event.total);
checkCompletion();
} else if (event.type === HttpEventType.Response) {
allProgress[index] = 100;
const resBody = event.body as any;
// Update item in list
currentItems[index] = {
id: resBody.id,
fileName: resBody.originalFilename, // use returned filename
unitPrice: resBody.unitPriceChf || 0,
unitTime: resBody.printTimeSeconds || 0,
unitWeight: resBody.materialGrams || 0,
quantity: item.quantity, // Keep original quantity
material: request.material,
color: item.color || 'White',
status: 'done'
};
finalResponses[index] = { ...event.body, success: true, fileName: item.file.name, originalQty: item.quantity, originalItem: item };
completedRequests++;
emitUpdate();
checkCompletion();
}
},
error: (err) => {
console.error('Item upload failed', err);
const errorMsg = err.error?.code === 'VIRUS_DETECTED' ? 'VIRUS_DETECTED' : 'UPLOAD_FAILED';
currentItems[index] = {
...currentItems[index],
status: 'error',
error: errorMsg
};
allProgress[index] = 100; // Mark as done despite error
finalResponses[index] = { success: false, fileName: item.file.name };
completedRequests++;
emitUpdate();
checkCompletion();
}
});
});
@@ -331,6 +258,62 @@ export class QuoteEstimatorService {
observer.error('Could not initialize quote session');
}
});
const finalize = (responses: any[], setupCost: number, sessionId: string) => {
observer.next(100);
const items: QuoteItem[] = [];
let grandTotal = 0;
let totalTime = 0;
let totalWeight = 0;
let validCount = 0;
responses.forEach((res, idx) => {
if (!res || !res.success) return;
validCount++;
const unitPrice = res.unitPriceChf || 0;
const quantity = res.originalQty || 1;
items.push({
id: res.id,
fileName: res.fileName,
unitPrice: unitPrice,
unitTime: res.printTimeSeconds || 0,
unitWeight: res.materialGrams || 0,
quantity: quantity,
material: request.material,
color: res.originalItem.color || 'Default'
// Store ID if needed for updates? QuoteItem interface might need update
// or we map it in component
});
grandTotal += unitPrice * quantity;
totalTime += (res.printTimeSeconds || 0) * quantity;
totalWeight += (res.materialGrams || 0) * quantity;
});
if (validCount === 0) {
observer.error('All calculations failed.');
return;
}
grandTotal += setupCost;
const result: QuoteResult = {
sessionId: sessionId,
items,
setupCost: setupCost,
currency: 'CHF',
totalPrice: Math.round(grandTotal * 100) / 100,
totalTimeHours: Math.floor(totalTime / 3600),
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
totalWeight: Math.ceil(totalWeight),
notes: request.notes
};
observer.next(result);
observer.complete();
};
});
}
@@ -384,8 +367,7 @@ export class QuoteEstimatorService {
material: session.materialCode, // Assumption: session has one material for all? or items have it?
// Backend model QuoteSession has materialCode.
// But line items might have different colors.
color: item.colorCode,
status: 'done'
color: item.colorCode
})),
setupCost: session.setupCostChf,
currency: 'CHF', // Fixed for now

View File

@@ -1,154 +1,161 @@
<div class="container hero">
<h1>{{ 'CHECKOUT.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'CHECKOUT.SUBTITLE' | translate }}</p>
</div>
<div class="checkout-page">
<div class="container hero">
<h1 class="section-title">{{ 'CHECKOUT.TITLE' | translate }}</h1>
</div>
<div class="container">
<div class="checkout-layout">
<div class="container">
<div class="checkout-layout">
<!-- LEFT COLUMN: Form -->
<div class="checkout-form-section">
<!-- Error Message -->
<div *ngIf="error" class="error-message">
{{ error }}
</div>
<!-- LEFT COLUMN: Form -->
<div class="checkout-form-section">
<!-- Error Message -->
<div *ngIf="error" class="error-message">
{{ error }}
</div>
<form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error">
<form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error">
<!-- Contact Info Card -->
<app-card class="mb-6">
<div class="form-row">
<app-input formControlName="email" type="email" [label]="'CHECKOUT.EMAIL' | translate" [required]="true" [error]="checkoutForm.get('email')?.hasError('email') ? 'Invalid email' : null"></app-input>
<app-input formControlName="phone" type="tel" [label]="'CHECKOUT.PHONE' | translate" [required]="true"></app-input>
</div>
<div class="user-type-selector">
<div class="type-option" [class.selected]="!isCompany" (click)="setCustomerType(false)">
{{ 'CHECKOUT.PRIVATE' | translate }}
<!-- Contact Info Card -->
<app-card class="mb-6">
<div class="card-header-simple">
<h3>{{ 'CHECKOUT.CONTACT_INFO' | translate }}</h3>
</div>
<div class="type-option" [class.selected]="isCompany" (click)="setCustomerType(true)">
{{ 'CHECKOUT.COMPANY' | translate }}
<div class="form-row">
<app-input formControlName="email" type="email" [label]="'CHECKOUT.EMAIL' | translate" [required]="true" [error]="checkoutForm.get('email')?.hasError('email') ? ('CHECKOUT.INVALID_EMAIL' | translate) : null"></app-input>
<app-input formControlName="phone" type="tel" [label]="'CHECKOUT.PHONE' | translate" [required]="true"></app-input>
</div>
</div>
</app-card>
<div formGroupName="billingAddress">
<div *ngIf="isCompany" class="company-fields">
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate" [required]="true"></app-input>
<div class="form-row no-margin">
<!-- Billing Address Card -->
<app-card class="mb-6">
<div class="card-header-simple">
<h3>{{ 'CHECKOUT.BILLING_ADDR' | translate }}</h3>
</div>
<div formGroupName="billingAddress">
<!-- Private Person Fields -->
<div *ngIf="!isCompany" class="form-row">
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate" [required]="true"></app-input>
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate" [required]="true"></app-input>
</div>
</div>
<div *ngIf="!isCompany" class="form-row no-margin">
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate" [required]="true"></app-input>
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate" [required]="true"></app-input>
<!-- Company Fields -->
<div *ngIf="isCompany" class="company-fields mb-4">
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate" [required]="true" [placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"></app-input>
<app-input formControlName="referencePerson" [label]="'CONTACT.REF_PERSON' | translate" [required]="true" [placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"></app-input>
</div>
<!-- User Type Selector -->
<div class="user-type-selector">
<div class="type-option" [class.selected]="!isCompany" (click)="setCustomerType(false)">
{{ 'CONTACT.TYPE_PRIVATE' | translate }}
</div>
<div class="type-option" [class.selected]="isCompany" (click)="setCustomerType(true)">
{{ 'CONTACT.TYPE_COMPANY' | translate }}
</div>
</div>
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate" [required]="true"></app-input>
<app-input formControlName="addressLine2" [label]="'CHECKOUT.ADDRESS_2' | translate"></app-input>
<div class="form-row three-cols">
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate" [required]="true"></app-input>
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field" [required]="true"></app-input>
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true" [required]="true"></app-input>
</div>
</div>
</app-card>
<!-- Shipping Option -->
<div class="shipping-option">
<label class="checkbox-container">
<input type="checkbox" formControlName="shippingSameAsBilling">
<span class="checkmark"></span>
{{ 'CHECKOUT.SHIPPING_SAME' | translate }}
</label>
</div>
</app-card>
<!-- Billing Address Card -->
<app-card class="mb-6">
<!-- Shipping Address Card (Conditional) -->
<app-card *ngIf="!checkoutForm.get('shippingSameAsBilling')?.value" class="mb-6">
<div class="card-header-simple">
<h3>{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}</h3>
</div>
<div formGroupName="shippingAddress">
<div class="form-row">
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate"></app-input>
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate"></app-input>
</div>
<div *ngIf="isCompany" class="company-fields">
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_OPTIONAL' | translate"></app-input>
<app-input formControlName="referencePerson" [label]="'CHECKOUT.REF_PERSON_OPTIONAL' | translate"></app-input>
</div>
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate"></app-input>
<div class="form-row three-cols">
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate"></app-input>
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field"></app-input>
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true"></app-input>
</div>
</div>
</app-card>
<div class="actions">
<app-button type="submit" [disabled]="checkoutForm.invalid || isSubmitting()" [fullWidth]="true">
{{ (isSubmitting() ? 'CHECKOUT.PROCESSING' : 'CHECKOUT.PLACE_ORDER') | translate }}
</app-button>
</div>
</form>
</div>
<!-- RIGHT COLUMN: Order Summary -->
<div class="checkout-summary-section">
<app-card class="sticky-card">
<div class="card-header-simple">
<h3>{{ 'CHECKOUT.BILLING_ADDR' | translate }}</h3>
<h3>{{ 'CHECKOUT.SUMMARY_TITLE' | translate }}</h3>
</div>
<div formGroupName="billingAddress">
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate" [required]="true"></app-input>
<app-input formControlName="addressLine2" [label]="'CHECKOUT.ADDRESS_2' | translate"></app-input>
<div class="form-row three-cols">
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate" [required]="true"></app-input>
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field" [required]="true"></app-input>
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true" [required]="true"></app-input>
<div class="summary-items" *ngIf="quoteSession() as session">
<div class="summary-item" *ngFor="let item of session.items">
<div class="item-details">
<span class="item-name">{{ item.originalFilename }}</span>
<div class="item-specs">
<span>{{ 'CHECKOUT.QTY' | translate }}: {{ item.quantity }}</span>
<span *ngIf="item.colorCode" class="color-dot" [style.background-color]="item.colorCode"></span>
</div>
<div class="item-specs-sub">
{{ (item.printTimeSeconds / 3600) | number:'1.1-1' }}h | {{ item.materialGrams | number:'1.0-0' }}g
</div>
</div>
<div class="item-price">
{{ (item.unitPriceChf * item.quantity) | currency:'CHF' }}
</div>
</div>
</div>
<div class="summary-totals" *ngIf="quoteSession() as session">
<div class="total-row">
<span>{{ 'CHECKOUT.SUBTOTAL' | translate }}</span>
<span>{{ session.itemsTotalChf | currency:'CHF' }}</span>
</div>
<div class="total-row">
<span>{{ 'CHECKOUT.SETUP_FEE' | translate }}</span>
<span>{{ session.session.setupCostChf | currency:'CHF' }}</span>
</div>
<div class="total-row">
<span>{{ 'CHECKOUT.SHIPPING' | translate }}</span>
<span>{{ 9.00 | currency:'CHF' }}</span>
</div>
<div class="grand-total">
<span>{{ 'CHECKOUT.TOTAL' | translate }}</span>
<span>{{ (session.grandTotalChf + 9.00) | currency:'CHF' }}</span>
</div>
</div>
</app-card>
</div>
<!-- Shipping Option -->
<div class="shipping-option">
<label class="checkbox-container">
<input type="checkbox" formControlName="shippingSameAsBilling">
<span class="checkmark"></span>
{{ 'CHECKOUT.SHIPPING_SAME' | translate }}
</label>
</div>
<!-- Shipping Address Card (Conditional) -->
<app-card *ngIf="!checkoutForm.get('shippingSameAsBilling')?.value" class="mb-6">
<div class="card-header-simple">
<h3>{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}</h3>
</div>
<div formGroupName="shippingAddress">
<div class="form-row">
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate"></app-input>
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate"></app-input>
</div>
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate"></app-input>
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate"></app-input>
<div class="form-row three-cols">
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate"></app-input>
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field"></app-input>
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true"></app-input>
</div>
</div>
</app-card>
<div class="actions">
<app-button type="submit" [disabled]="checkoutForm.invalid || isSubmitting()" [fullWidth]="true">
{{ (isSubmitting() ? 'CHECKOUT.PROCESSING' : 'CHECKOUT.PLACE_ORDER') | translate }}
</app-button>
</div>
</form>
</div>
<!-- RIGHT COLUMN: Order Summary -->
<div class="checkout-summary-section">
<app-card class="sticky-card">
<div class="card-header-simple">
<h3>{{ 'CHECKOUT.ORDER_SUMMARY' | translate }}</h3>
</div>
<div class="summary-items" *ngIf="quoteSession() as session">
<div class="summary-item" *ngFor="let item of session.items">
<div class="item-details">
<span class="item-name">{{ item.originalFilename }}</span>
<div class="item-specs">
<span>Qty: {{ item.quantity }}</span>
<span *ngIf="item.colorCode" class="color-dot" [style.background-color]="item.colorCode"></span>
</div>
<div class="item-specs-sub">
{{ (item.printTimeSeconds / 3600) | number:'1.1-1' }}h | {{ item.materialGrams | number:'1.0-0' }}g
</div>
</div>
<div class="item-price">
{{ (item.unitPriceChf * item.quantity) | currency:'CHF' }}
</div>
</div>
</div>
<div class="summary-totals" *ngIf="quoteSession() as session">
<div class="total-row">
<span>{{ 'CHECKOUT.SUBTOTAL' | translate }}</span>
<span>{{ session.itemsTotalChf | currency:'CHF' }}</span>
</div>
<div class="total-row">
<span>{{ 'CHECKOUT.SETUP_FEE' | translate }}</span>
<span>{{ session.session.setupCostChf | currency:'CHF' }}</span>
</div>
<div class="total-row">
<span>{{ 'CHECKOUT.SHIPPING' | translate }}</span>
<span>{{ 9.0 | currency:'CHF' }}</span>
</div>
<div class="grand-total-row">
<span>{{ 'CHECKOUT.TOTAL' | translate }}</span>
<span>{{ (session.grandTotalChf + 9.0) | currency:'CHF' }}</span>
</div>
</div>
</app-card>
</div>
</div>
</div>

View File

@@ -1,23 +1,16 @@
.hero {
padding: var(--space-12) 0 var(--space-8);
padding: var(--space-8) 0;
text-align: center;
h1 {
.section-title {
font-size: 2.5rem;
margin-bottom: var(--space-2);
}
}
.subtitle {
font-size: 1.125rem;
color: var(--color-text-muted);
max-width: 600px;
margin: 0 auto;
}
.checkout-layout {
display: grid;
grid-template-columns: 1fr 400px;
grid-template-columns: 1fr 420px;
gap: var(--space-8);
align-items: start;
margin-bottom: var(--space-12);
@@ -77,7 +70,7 @@
background-color: var(--color-neutral-100);
border-radius: var(--radius-md);
padding: 4px;
margin-bottom: var(--space-4);
margin: var(--space-6) 0;
gap: 4px;
width: 100%;
max-width: 400px;
@@ -195,6 +188,7 @@
max-height: 450px;
overflow-y: auto;
padding-right: var(--space-2);
padding-top: var(--space-2);
&::-webkit-scrollbar {
width: 4px;
@@ -260,19 +254,19 @@
.summary-totals {
background: var(--color-neutral-100);
padding: var(--space-6);
padding: var(--space-4);
border-radius: var(--radius-md);
margin-top: var(--space-4);
margin-top: var(--space-6);
.total-row {
display: flex;
justify-content: space-between;
margin-bottom: var(--space-2);
font-size: 0.95rem;
color: var(--color-text-muted);
color: var(--color-text);
}
.grand-total-row {
.grand-total {
display: flex;
justify-content: space-between;
color: var(--color-text);
@@ -303,4 +297,3 @@
}
.mb-6 { margin-bottom: var(--space-6); }

View File

@@ -20,7 +20,7 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
AppCardComponent
],
templateUrl: './checkout.component.html',
styleUrl: './checkout.component.scss'
styleUrls: ['./checkout.component.scss']
})
export class CheckoutComponent implements OnInit {
private fb = inject(FormBuilder);
@@ -47,6 +47,7 @@ export class CheckoutComponent implements OnInit {
firstName: ['', Validators.required],
lastName: ['', Validators.required],
companyName: [''],
referencePerson: [''],
addressLine1: ['', Validators.required],
addressLine2: [''],
zip: ['', Validators.required],
@@ -58,6 +59,7 @@ export class CheckoutComponent implements OnInit {
firstName: [''],
lastName: [''],
companyName: [''],
referencePerson: [''],
addressLine1: [''],
addressLine2: [''],
zip: [''],
@@ -75,16 +77,27 @@ export class CheckoutComponent implements OnInit {
const type = isCompany ? 'BUSINESS' : 'PRIVATE';
this.checkoutForm.patchValue({ customerType: type });
// Update validators based on type
const billingGroup = this.checkoutForm.get('billingAddress') as FormGroup;
const companyControl = billingGroup.get('companyName');
const referenceControl = billingGroup.get('referencePerson');
const firstNameControl = billingGroup.get('firstName');
const lastNameControl = billingGroup.get('lastName');
if (isCompany) {
companyControl?.setValidators([Validators.required]);
referenceControl?.setValidators([Validators.required]);
firstNameControl?.clearValidators();
lastNameControl?.clearValidators();
} else {
companyControl?.clearValidators();
referenceControl?.clearValidators();
firstNameControl?.setValidators([Validators.required]);
lastNameControl?.setValidators([Validators.required]);
}
companyControl?.updateValueAndValidity();
referenceControl?.updateValueAndValidity();
firstNameControl?.updateValueAndValidity();
lastNameControl?.updateValueAndValidity();
}
ngOnInit(): void {
@@ -151,6 +164,7 @@ export class CheckoutComponent implements OnInit {
firstName: formVal.billingAddress.firstName,
lastName: formVal.billingAddress.lastName,
companyName: formVal.billingAddress.companyName,
contactPerson: formVal.billingAddress.referencePerson,
addressLine1: formVal.billingAddress.addressLine1,
addressLine2: formVal.billingAddress.addressLine2,
zip: formVal.billingAddress.zip,
@@ -161,6 +175,7 @@ export class CheckoutComponent implements OnInit {
firstName: formVal.shippingAddress.firstName,
lastName: formVal.shippingAddress.lastName,
companyName: formVal.shippingAddress.companyName,
contactPerson: formVal.shippingAddress.referencePerson,
addressLine1: formVal.shippingAddress.addressLine1,
addressLine2: formVal.shippingAddress.addressLine2,
zip: formVal.shippingAddress.zip,

View File

@@ -40,7 +40,7 @@
<div class="form-group">
<label>{{ 'CONTACT.LABEL_MESSAGE' | translate }}</label>
<textarea formControlName="message" class="form-control" rows="10"></textarea>
<textarea formControlName="message" class="form-control" rows="4"></textarea>
</div>
<!-- File Upload Section -->
@@ -60,8 +60,8 @@
<button type="button" class="remove-btn" (click)="removeFile(i)">×</button>
<img *ngIf="file.type === 'image'" [src]="file.url" class="preview-img">
<div *ngIf="file.type !== 'image'" class="file-icon">
<span *ngIf="file.type === 'pdf'">PDF</span>
<span *ngIf="file.type === '3d'">3D</span>
<span *ngIf="file.type === 'pdf'">{{ 'CONTACT.FILE_TYPE_PDF' | translate }}</span>
<span *ngIf="file.type === '3d'">{{ 'CONTACT.FILE_TYPE_3D' | translate }}</span>
</div>
<div class="file-name" [title]="file.file.name">{{ file.file.name }}</div>
</div>

View File

@@ -1,7 +1,7 @@
<section class="contact-hero">
<div class="container">
<h1>{{ 'CONTACT.TITLE' | translate }}</h1>
<p class="subtitle">Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.</p>
<p class="subtitle">{{ 'CONTACT.HERO_SUBTITLE' | translate }}</p>
</div>
</section>

View File

@@ -2,22 +2,18 @@
<section class="hero">
<div class="container hero-grid">
<div class="hero-copy">
<p class="eyebrow">Stampa 3D tecnica per aziende, freelance e maker</p>
<h1 class="hero-title">
Prezzo e tempi in pochi secondi.<br>
Dal file 3D al pezzo finito.
</h1>
<p class="eyebrow">{{ 'HOME.HERO_EYEBROW' | translate }}</p>
<h1 class="hero-title" [innerHTML]="'HOME.HERO_TITLE' | translate"></h1>
<p class="hero-lead">
Il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.
{{ 'HOME.HERO_LEAD' | translate }}
</p>
<p class="hero-subtitle">
Realizziamo pezzi unici e produzioni in serie. Se hai il file, il preventivo è istantaneo.
Se devi ancora crearlo, il nostro team di design lo progetterà per te.
{{ 'HOME.HERO_SUBTITLE' | translate }}
</p>
<div class="hero-actions">
<app-button variant="primary" routerLink="/calculator/basic">Calcola Preventivo</app-button>
<app-button variant="outline" routerLink="/shop">Vai allo shop</app-button>
<app-button variant="text" routerLink="/contact">Parla con noi</app-button>
<app-button variant="primary" routerLink="/calculator/basic">{{ 'HOME.BTN_CALCULATE' | translate }}</app-button>
<app-button variant="outline" routerLink="/shop">{{ 'HOME.BTN_SHOP' | translate }}</app-button>
<app-button variant="text" routerLink="/contact">{{ 'HOME.BTN_CONTACT' | translate }}</app-button>
</div>
</div>
</div>
@@ -26,31 +22,31 @@
<section class="section calculator">
<div class="container calculator-grid">
<div class="calculator-copy">
<h2 class="section-title">Preventivo immediato in pochi secondi</h2>
<h2 class="section-title">{{ 'HOME.SEC_CALC_TITLE' | translate }}</h2>
<p class="section-subtitle">
Nessuna registrazione necessaria. Non salviamo il tuo file fino al momento dell'ordine e la stima è effettuata tramite vero slicing.
{{ 'HOME.SEC_CALC_SUBTITLE' | translate }}
</p>
<ul class="calculator-list">
<li>Formati supportati: STL, 3MF, STEP, OBJ</li>
<li>Qualità: bozza, standard, alta definizione</li>
<li>{{ 'HOME.SEC_CALC_LIST_1' | translate }}</li>
<li>{{ 'HOME.SEC_CALC_LIST_2' | translate }}</li>
</ul>
</div>
<app-card class="quote-card">
<div class="quote-header">
<div>
<p class="quote-eyebrow">Calcolo automatico</p>
<h3 class="quote-title">Prezzo e tempi in un click</h3>
<p class="quote-eyebrow">{{ 'HOME.CARD_CALC_EYEBROW' | translate }}</p>
<h3 class="quote-title">{{ 'HOME.CARD_CALC_TITLE' | translate }}</h3>
</div>
<span class="quote-tag">Senza registrazione</span>
<span class="quote-tag">{{ 'HOME.CARD_CALC_TAG' | translate }}</span>
</div>
<ul class="quote-steps">
<li>Carica il file 3D</li>
<li>Scegli materiale e qualità</li>
<li>Ricevi subito costo e tempo</li>
<li>{{ 'HOME.CARD_CALC_STEP_1' | translate }}</li>
<li>{{ 'HOME.CARD_CALC_STEP_2' | translate }}</li>
<li>{{ 'HOME.CARD_CALC_STEP_3' | translate }}</li>
</ul>
<div class="quote-actions">
<app-button variant="primary" [fullWidth]="true" routerLink="/calculator/basic">Apri calcolatore</app-button>
<app-button variant="outline" [fullWidth]="true" routerLink="/contact">Parla con noi</app-button>
<app-button variant="primary" [fullWidth]="true" routerLink="/calculator/basic">{{ 'HOME.BTN_OPEN_CALC' | translate }}</app-button>
<app-button variant="outline" [fullWidth]="true" routerLink="/contact">{{ 'HOME.BTN_CONTACT' | translate }}</app-button>
</div>
</app-card>
</div>
@@ -60,9 +56,9 @@
<div class="capabilities-bg"></div>
<div class="container">
<div class="section-head">
<h2 class="section-title">Cosa puoi ottenere</h2>
<h2 class="section-title">{{ 'HOME.SEC_CAP_TITLE' | translate }}</h2>
<p class="section-subtitle">
Produzione su misura per prototipi, piccole serie e pezzi personalizzati.
{{ 'HOME.SEC_CAP_SUBTITLE' | translate }}
</p>
</div>
<div class="cap-cards">
@@ -70,29 +66,29 @@
<div class="card-image-placeholder">
<!-- <img src="..." alt="..."> -->
</div>
<h3>Prototipazione veloce</h3>
<p class="text-muted">Dal file digitale al modello fisico in 24/48 ore. Verifica ergonomia, incastri e funzionamento prima dello stampo definitivo.</p>
<h3>{{ 'HOME.CAP_1_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CAP_1_TEXT' | translate }}</p>
</app-card>
<app-card>
<div class="card-image-placeholder">
<!-- <img src="..." alt="..."> -->
</div>
<h3>Pezzi personalizzati</h3>
<p class="text-muted">Componenti unici impossibili da trovare in commercio. Riproduciamo parti rotte o creiamo adattamenti su misura.</p>
<h3>{{ 'HOME.CAP_2_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CAP_2_TEXT' | translate }}</p>
</app-card>
<app-card>
<div class="card-image-placeholder">
<!-- <img src="..." alt="..."> -->
</div>
<h3>Piccole serie</h3>
<p class="text-muted">Produzione ponte o serie limitate (10-500 pezzi) con qualità ripetibile. Ideale per validazione di mercato.</p>
<h3>{{ 'HOME.CAP_3_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CAP_3_TEXT' | translate }}</p>
</app-card>
<app-card>
<div class="card-image-placeholder">
<!-- <img src="..." alt="..."> -->
</div>
<h3>Consulenza e CAD</h3>
<p class="text-muted">Non hai il file 3D? Ti aiutiamo a progettarlo, ottimizzarlo per la stampa e scegliere il materiale giusto.</p>
<h3>{{ 'HOME.CAP_4_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CAP_4_TEXT' | translate }}</p>
</app-card>
</div>
</div>
@@ -101,33 +97,32 @@
<section class="section shop">
<div class="container split">
<div class="shop-copy">
<h2 class="section-title">Shop di soluzioni tecniche pronte</h2>
<h2 class="section-title">{{ 'HOME.SEC_SHOP_TITLE' | translate }}</h2>
<p>
Prodotti selezionati, testati in laboratorio e pronti all'uso. Risolvono problemi reali con
funzionalità concrete.
{{ 'HOME.SEC_SHOP_TEXT' | translate }}
</p>
<ul class="shop-list">
<li>Accessori funzionali per officine e laboratori</li>
<li>Ricambi e componenti difficili da reperire</li>
<li>Supporti e organizzatori per migliorare i flussi di lavoro</li>
<li>{{ 'HOME.SEC_SHOP_LIST_1' | translate }}</li>
<li>{{ 'HOME.SEC_SHOP_LIST_2' | translate }}</li>
<li>{{ 'HOME.SEC_SHOP_LIST_3' | translate }}</li>
</ul>
<div class="shop-actions">
<app-button variant="primary" routerLink="/shop">Scopri i prodotti</app-button>
<app-button variant="outline" routerLink="/contact">Richiedi una soluzione</app-button>
<app-button variant="primary" routerLink="/shop">{{ 'HOME.BTN_DISCOVER' | translate }}</app-button>
<app-button variant="outline" routerLink="/contact">{{ 'HOME.BTN_REQ_SOLUTION' | translate }}</app-button>
</div>
</div>
<div class="shop-cards">
<app-card>
<h3>Best seller tecnici</h3>
<p class="text-muted">Soluzioni provate sul campo e già pronte alla spedizione.</p>
<h3>{{ 'HOME.CARD_SHOP_1_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CARD_SHOP_1_TEXT' | translate }}</p>
</app-card>
<app-card>
<h3>Kit pronti all'uso</h3>
<p class="text-muted">Componenti compatibili e facili da montare senza sorprese.</p>
<h3>{{ 'HOME.CARD_SHOP_2_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CARD_SHOP_2_TEXT' | translate }}</p>
</app-card>
<app-card>
<h3>Su richiesta</h3>
<p class="text-muted">Non trovi quello che serve? Lo progettiamo e lo produciamo per te.</p>
<h3>{{ 'HOME.CARD_SHOP_3_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CARD_SHOP_3_TEXT' | translate }}</p>
</app-card>
</div>
</div>
@@ -136,17 +131,16 @@
<section class="section about">
<div class="container about-grid">
<div class="about-copy">
<h2 class="section-title">Su di noi</h2>
<h2 class="section-title">{{ 'HOME.SEC_ABOUT_TITLE' | translate }}</h2>
<p>
3D fab è un laboratorio tecnico di stampa 3D. Seguiamo progetti dalla consulenza iniziale
alla produzione, con tempi chiari e supporto diretto.
{{ 'HOME.SEC_ABOUT_TEXT' | translate }}
</p>
<app-button variant="outline" routerLink="/contact">Contattaci</app-button>
<app-button variant="outline" routerLink="/contact">{{ 'HOME.BTN_CONTACT' | translate }}</app-button>
</div>
<div class="about-media">
<div class="about-feature-image">
<!-- Foto founders -->
<span class="text-sm">Foto Founders</span>
<span class="text-sm">{{ 'HOME.FOUNDERS_PHOTO' | translate }}</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,52 @@
<div class="container hero">
<h1>{{ 'ORDER_CONFIRMED.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'ORDER_CONFIRMED.SUBTITLE' | translate }}</p>
</div>
<div class="container">
<div class="confirmation-layout" *ngIf="order() as o">
<app-card class="status-card">
<div class="status-badge">{{ o.status === 'SHIPPED' ? ('TRACKING.STEP_SHIPPED' | translate) : ('ORDER_CONFIRMED.STATUS' | translate) }}</div>
<h2>{{ 'ORDER_CONFIRMED.HEADING' | translate }}</h2>
<p class="order-ref" *ngIf="orderNumber">
{{ 'ORDER_CONFIRMED.ORDER_REF' | translate }}: <strong>#{{ orderNumber }}</strong>
</p>
<div class="status-timeline">
<div class="timeline-step"
[class.active]="o.status === 'PENDING_PAYMENT' && o.paymentStatus !== 'REPORTED'"
[class.completed]="o.paymentStatus === 'REPORTED' || o.status !== 'PENDING_PAYMENT'">
<div class="circle">1</div>
<div class="label">{{ 'TRACKING.STEP_PENDING' | translate }}</div>
</div>
<div class="timeline-step"
[class.active]="o.paymentStatus === 'REPORTED' && o.status === 'PENDING_PAYMENT'"
[class.completed]="o.status === 'PAID' || o.status === 'IN_PRODUCTION' || o.status === 'SHIPPED' || o.status === 'COMPLETED'">
<div class="circle">2</div>
<div class="label">{{ 'TRACKING.STEP_REPORTED' | translate }}</div>
</div>
<div class="timeline-step"
[class.active]="o.status === 'PAID' || o.status === 'IN_PRODUCTION'"
[class.completed]="o.status === 'SHIPPED' || o.status === 'COMPLETED'">
<div class="circle">3</div>
<div class="label">{{ 'TRACKING.STEP_PRODUCTION' | translate }}</div>
</div>
<div class="timeline-step"
[class.active]="o.status === 'SHIPPED'"
[class.completed]="o.status === 'COMPLETED'">
<div class="circle">4</div>
<div class="label">{{ 'TRACKING.STEP_SHIPPED' | translate }}</div>
</div>
</div>
<div class="message-block">
<p>{{ 'ORDER_CONFIRMED.PROCESSING_TEXT' | translate }}</p>
<p>{{ 'ORDER_CONFIRMED.EMAIL_TEXT' | translate }}</p>
</div>
<div class="actions">
<app-button (click)="goHome()">{{ 'ORDER_CONFIRMED.BACK_HOME' | translate }}</app-button>
</div>
</app-card>
</div>
</div>

View File

@@ -0,0 +1,159 @@
.hero {
padding: var(--space-12) 0 var(--space-8);
text-align: center;
h1 {
font-size: 2.4rem;
margin-bottom: var(--space-2);
}
}
.subtitle {
font-size: 1.1rem;
color: var(--color-text-muted);
max-width: 720px;
margin: 0 auto;
}
.confirmation-layout {
max-width: 760px;
margin: 0 auto var(--space-12);
}
.status-badge {
display: inline-block;
padding: 0.35rem 0.65rem;
border-radius: 999px;
background: #eef8f0;
color: #136f2d;
font-weight: 700;
font-size: 0.85rem;
margin-bottom: var(--space-4);
}
h2 {
margin: 0 0 var(--space-3);
}
.order-ref {
margin: 0 0 var(--space-4);
color: var(--color-text-muted);
}
.message-block {
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-5);
margin-bottom: var(--space-6);
p {
margin: 0;
line-height: 1.45;
}
p + p {
margin-top: var(--space-3);
}
}
.actions {
max-width: 320px;
}
.status-timeline {
display: flex;
justify-content: space-between;
margin-bottom: var(--space-6);
position: relative;
&::before {
content: '';
position: absolute;
top: 15px;
left: 20px;
right: 20px;
height: 2px;
background: var(--color-border);
z-index: 1;
}
}
.timeline-step {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 2;
flex: 1;
text-align: center;
.circle {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--color-neutral-100);
border: 2px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
margin-bottom: var(--space-2);
color: var(--color-text-muted);
transition: all 0.3s ease;
}
.label {
font-size: 0.85rem;
color: var(--color-text-muted);
font-weight: 500;
}
&.active {
.circle {
border-color: var(--color-primary);
background: var(--color-primary-light);
color: var(--color-primary);
}
.label {
color: var(--color-text);
font-weight: 600;
}
}
&.completed {
.circle {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
.label {
color: var(--color-text);
}
}
}
@media (max-width: 600px) {
.status-timeline {
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
&::before {
top: 10px;
bottom: 10px;
left: 15px;
width: 2px;
height: auto;
}
.timeline-step {
flex-direction: row;
gap: var(--space-3);
.circle {
margin-bottom: 0;
}
}
}
}

View File

@@ -0,0 +1,50 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
@Component({
selector: 'app-order-confirmed',
standalone: true,
imports: [CommonModule, TranslateModule, AppButtonComponent, AppCardComponent],
templateUrl: './order-confirmed.component.html',
styleUrl: './order-confirmed.component.scss'
})
export class OrderConfirmedComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private quoteService = inject(QuoteEstimatorService);
orderId: string | null = null;
orderNumber: string | null = null;
order = signal<any>(null);
ngOnInit(): void {
this.orderId = this.route.snapshot.paramMap.get('orderId');
if (!this.orderId) {
return;
}
this.orderNumber = this.extractOrderNumber(this.orderId);
this.quoteService.getOrder(this.orderId).subscribe({
next: (order) => {
this.order.set(order);
this.orderNumber = order?.orderNumber ?? this.orderNumber;
},
error: () => {
// Keep fallback derived from UUID when API is unavailable.
}
});
}
goHome(): void {
this.router.navigate(['/']);
}
private extractOrderNumber(orderId: string): string {
return orderId.split('-')[0];
}
}

View File

@@ -5,8 +5,14 @@
<div class="container">
<div class="payment-layout" *ngIf="order() as o">
<div class="payment-main">
<app-card class="mb-6 status-reported-card" *ngIf="o.paymentStatus === 'REPORTED'">
<div class="status-content text-center">
<h3>{{ 'PAYMENT.STATUS_REPORTED_TITLE' | translate }}</h3>
<p>{{ 'PAYMENT.STATUS_REPORTED_DESC' | translate }}</p>
</div>
</app-card>
<app-card class="mb-6">
<div class="card-header-simple">
<h3>{{ 'PAYMENT.METHOD' | translate }}</h3>
@@ -18,32 +24,39 @@
class="type-option"
[class.selected]="selectedPaymentMethod === 'twint'"
(click)="selectPayment('twint')">
<span class="method-name">TWINT</span>
<span class="method-name">{{ 'PAYMENT.METHOD_TWINT' | translate }}</span>
</div>
<div
class="type-option"
[class.selected]="selectedPaymentMethod === 'bill'"
(click)="selectPayment('bill')">
<span class="method-name">QR Bill / Bank Transfer</span>
<span class="method-name">{{ 'PAYMENT.METHOD_BANK' | translate }}</span>
</div>
</div>
</div>
<!-- TWINT Details -->
<div class="payment-details fade-in" *ngIf="selectedPaymentMethod === 'twint'">
<div class="payment-details fade-in text-center" *ngIf="selectedPaymentMethod === 'twint'">
<div class="details-header">
<h4>{{ 'PAYMENT.TWINT_TITLE' | translate }}</h4>
</div>
<div class="qr-placeholder">
<div class="qr-box">
<span>QR CODE</span>
</div>
<img
*ngIf="twintQrUrl()"
class="twint-qr"
[src]="getTwintQrUrl()"
(error)="onTwintQrError()"
alt="TWINT payment QR" />
<p>{{ 'PAYMENT.TWINT_DESC' | translate }}</p>
<p class="billing-hint">{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}</p>
<div class="twint-mobile-action">
<app-button variant="outline" (click)="openTwintPayment()" [fullWidth]="true">
{{ 'PAYMENT.TWINT_OPEN' | translate }}
</app-button>
</div>
<p class="amount">{{ 'PAYMENT.TOTAL' | translate }}: {{ o.totalChf | currency:'CHF' }}</p>
</div>
</div>
<!-- QR Bill Details -->
<div class="payment-details fade-in" *ngIf="selectedPaymentMethod === 'bill'">
<div class="details-header">
<h4>{{ 'PAYMENT.BANK_TITLE' | translate }}</h4>
@@ -51,7 +64,8 @@
<div class="bank-details">
<p><strong>{{ 'PAYMENT.BANK_OWNER' | translate }}:</strong> 3D Fab Switzerland</p>
<p><strong>{{ 'PAYMENT.BANK_IBAN' | translate }}:</strong> CH98 0000 0000 0000 0000 0</p>
<p><strong>{{ 'PAYMENT.BANK_REF' | translate }}:</strong> {{ o.id }}</p>
<p><strong>{{ 'PAYMENT.BANK_REF' | translate }}:</strong> {{ getDisplayOrderNumber(o) }}</p>
<p class="billing-hint">{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}</p>
<div class="qr-bill-actions">
<app-button variant="outline" (click)="downloadInvoice()">
@@ -62,8 +76,11 @@
</div>
<div class="actions">
<app-button (click)="completeOrder()" [disabled]="!selectedPaymentMethod" [fullWidth]="true">
{{ 'PAYMENT.CONFIRM' | translate }}
<app-button
(click)="completeOrder()"
[disabled]="!selectedPaymentMethod || o.paymentStatus === 'REPORTED'"
[fullWidth]="true">
{{ o.paymentStatus === 'REPORTED' ? ('PAYMENT.IN_VERIFICATION' | translate) : ('PAYMENT.CONFIRM' | translate) }}
</app-button>
</div>
</app-card>
@@ -73,7 +90,7 @@
<app-card class="sticky-card">
<div class="card-header-simple">
<h3>{{ 'PAYMENT.SUMMARY_TITLE' | translate }}</h3>
<p class="order-id">#{{ o.id.substring(0, 8) }}</p>
<p class="order-id">#{{ getDisplayOrderNumber(o) }}</p>
</div>
<div class="summary-totals">
@@ -96,7 +113,6 @@
</div>
</app-card>
</div>
</div>
<div *ngIf="loading()" class="loading-state">

View File

@@ -84,7 +84,7 @@
border-color: var(--color-brand);
background-color: var(--color-neutral-100);
color: #000;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
@@ -95,6 +95,28 @@
margin-bottom: var(--space-6);
border: 1px solid var(--color-border);
&.text-center {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
.details-header {
width: 100%;
text-align: center;
}
.qr-placeholder {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
}
.details-header {
margin-bottom: var(--space-4);
h4 {
@@ -105,23 +127,29 @@
}
}
.qr-placeholder {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
.qr-box {
width: 180px;
height: 180px;
background-color: white;
border: 2px solid var(--color-neutral-900);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-bottom: var(--space-4);
.twint-qr {
width: 240px;
height: 240px;
background-color: #fff;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-2);
margin-bottom: var(--space-4);
object-fit: contain;
box-shadow: 0 6px 18px rgba(44, 37, 84, 0.08);
}
.twint-mobile-action {
width: 100%;
max-width: 320px;
margin-top: var(--space-3);
}
.amount {
@@ -132,6 +160,12 @@
}
}
.billing-hint {
margin-top: var(--space-3);
font-size: 0.95rem;
color: var(--color-text-muted);
}
.bank-details {
p {
margin-bottom: var(--space-2);
@@ -183,13 +217,20 @@
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.mb-6 { margin-bottom: var(--space-6); }
.error-message, .loading-state {
.error-message,
.loading-state {
margin-top: var(--space-12);
text-align: center;
}

View File

@@ -5,6 +5,7 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
import { TranslateModule } from '@ngx-translate/core';
import { environment } from '../../../environments/environment';
@Component({
selector: 'app-payment',
@@ -23,11 +24,14 @@ export class PaymentComponent implements OnInit {
order = signal<any>(null);
loading = signal(true);
error = signal<string | null>(null);
twintOpenUrl = signal<string | null>(null);
twintQrUrl = signal<string | null>(null);
ngOnInit(): void {
this.orderId = this.route.snapshot.paramMap.get('orderId');
if (this.orderId) {
this.loadOrder();
this.loadTwintPayment();
} else {
this.error.set('Order ID not found.');
this.loading.set(false);
@@ -54,13 +58,16 @@ export class PaymentComponent implements OnInit {
}
downloadInvoice() {
if (!this.orderId) return;
this.quoteService.getOrderInvoice(this.orderId).subscribe({
const orderId = this.orderId;
if (!orderId) return;
this.quoteService.getOrderInvoice(orderId).subscribe({
next: (blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `invoice-${this.orderId}.pdf`;
const fallbackOrderNumber = this.extractOrderNumber(orderId);
const orderNumber = this.order()?.orderNumber ?? fallbackOrderNumber;
a.download = `invoice-${orderNumber}.pdf`;
a.click();
window.URL.revokeObjectURL(url);
},
@@ -68,9 +75,76 @@ export class PaymentComponent implements OnInit {
});
}
loadTwintPayment() {
if (!this.orderId) return;
this.quoteService.getTwintPayment(this.orderId).subscribe({
next: (res) => {
const qrPath = typeof res.qrImageUrl === 'string' ? `${res.qrImageUrl}?size=360` : null;
const qrDataUri = typeof res.qrImageDataUri === 'string' ? res.qrImageDataUri : null;
this.twintOpenUrl.set(this.resolveApiUrl(res.openUrl));
this.twintQrUrl.set(qrDataUri ?? this.resolveApiUrl(qrPath));
},
error: (err) => {
console.error('Failed to load TWINT payment details', err);
}
});
}
openTwintPayment(): void {
const openUrl = this.twintOpenUrl();
if (typeof window !== 'undefined' && openUrl) {
window.open(openUrl, '_blank');
}
}
getTwintQrUrl(): string {
return this.twintQrUrl() ?? '';
}
onTwintQrError(): void {
this.twintQrUrl.set(null);
}
private resolveApiUrl(urlOrPath: string | null | undefined): string | null {
if (!urlOrPath) return null;
if (urlOrPath.startsWith('http://') || urlOrPath.startsWith('https://')) {
return urlOrPath;
}
const base = (environment.apiUrl || '').replace(/\/$/, '');
const path = urlOrPath.startsWith('/') ? urlOrPath : `/${urlOrPath}`;
return `${base}${path}`;
}
completeOrder(): void {
// Simulate payment completion
alert('Payment Simulated! Order marked as PAID.');
this.router.navigate(['/']);
if (!this.orderId || !this.selectedPaymentMethod) {
return;
}
this.quoteService.reportPayment(this.orderId, this.selectedPaymentMethod).subscribe({
next: (order) => {
this.order.set(order);
// The UI will re-render and show the 'REPORTED' state.
// We stay on this page to let the user see the "In verifica"
// status along with payment instructions.
},
error: (err) => {
console.error('Failed to report payment', err);
this.error.set('Failed to report payment. Please try again.');
}
});
}
getDisplayOrderNumber(order: any): string {
if (order?.orderNumber) {
return order.orderNumber;
}
if (order?.id) {
return this.extractOrderNumber(order.id);
}
return 'N/A';
}
private extractOrderNumber(orderId: string): string {
return orderId.split('-')[0];
}
}

View File

@@ -20,6 +20,6 @@
</div>
</div>
} @else {
<p>Prodotto non trovato.</p>
<p>{{ 'SHOP.NOT_FOUND' | translate }}</p>
}
</div>

View File

@@ -32,12 +32,14 @@
.btn-outline {
background-color: transparent;
border-color: var(--color-border);
color: var(--color-text);
border-color: var(--color-brand);
border-width: 2px;
padding: calc(0.5rem - 1px) calc(1rem - 1px);
color: var(--color-neutral-900);
font-weight: 600;
&:hover:not(:disabled) {
border-color: var(--color-brand);
background-color: var(--color-brand);
color: var(--color-neutral-900);
background-color: rgba(250, 207, 10, 0.1); /* Low opacity brand color */
}
}

View File

@@ -25,6 +25,7 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
private controls!: OrbitControls;
private animationId: number | null = null;
private currentMesh: THREE.Mesh | null = null;
private autoRotate = true;
loading = false;
@@ -38,14 +39,14 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
}
if (changes['color'] && this.currentMesh && !changes['file']) {
// Update existing mesh color if only color changed
const mat = this.currentMesh.material as THREE.MeshPhongMaterial;
mat.color.set(this.color);
this.applyColorStyle(this.color);
}
}
ngOnDestroy() {
if (this.animationId) cancelAnimationFrame(this.animationId);
this.clearCurrentMesh();
if (this.controls) this.controls.dispose();
if (this.renderer) this.renderer.dispose();
}
@@ -54,28 +55,51 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
const height = this.rendererContainer.nativeElement.clientHeight;
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0xf7f6f2); // Neutral-50
this.scene.background = new THREE.Color(0xf4f8fc);
// Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.75);
this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(1, 1, 1);
this.scene.add(directionalLight);
const hemiLight = new THREE.HemisphereLight(0xf8fbff, 0xc8d3df, 0.95);
hemiLight.position.set(0, 30, 0);
this.scene.add(hemiLight);
const directionalLight1 = new THREE.DirectionalLight(0xffffff, 1.35);
directionalLight1.position.set(6, 8, 6);
this.scene.add(directionalLight1);
const directionalLight2 = new THREE.DirectionalLight(0xe8f0ff, 0.85);
directionalLight2.position.set(-7, 4, -5);
this.scene.add(directionalLight2);
const directionalLight3 = new THREE.DirectionalLight(0xffffff, 0.55);
directionalLight3.position.set(0, 5, -9);
this.scene.add(directionalLight3);
// Camera
this.camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000);
this.camera.position.z = 100;
// Renderer
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.2;
this.renderer.setSize(width, height);
this.rendererContainer.nativeElement.appendChild(this.renderer.domElement);
// Controls
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.06;
this.controls.enablePan = false;
this.controls.minDistance = 10;
this.controls.maxDistance = 600;
this.controls.addEventListener('start', () => {
this.autoRotate = false;
});
this.animate();
@@ -95,24 +119,27 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
private loadFile(file: File) {
this.loading = true;
this.autoRotate = true;
const reader = new FileReader();
reader.onload = (event) => {
try {
const loader = new STLLoader();
const geometry = loader.parse(event.target?.result as ArrayBuffer);
if (this.currentMesh) {
this.scene.remove(this.currentMesh);
this.currentMesh.geometry.dispose();
}
this.clearCurrentMesh();
const material = new THREE.MeshPhongMaterial({
geometry.computeVertexNormals();
const material = new THREE.MeshStandardMaterial({
color: this.color,
specular: 0x111111,
shininess: 200
roughness: 0.42,
metalness: 0.05,
emissive: 0x000000,
emissiveIntensity: 0
});
this.currentMesh = new THREE.Mesh(geometry, material);
this.applyColorStyle(this.color);
// Center geometry
geometry.computeBoundingBox();
@@ -140,9 +167,10 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
// Calculate distance towards camera (z-axis)
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
cameraZ *= 1.5; // Tighter zoom (reduced from 2.5)
cameraZ *= 1.72;
this.camera.position.z = cameraZ;
this.camera.position.set(cameraZ * 0.65, cameraZ * 0.95, cameraZ * 1.1);
this.camera.lookAt(0, 0, 0);
this.camera.updateProjectionMatrix();
this.controls.update();
@@ -157,9 +185,63 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
private animate() {
this.animationId = requestAnimationFrame(() => this.animate());
if (this.currentMesh && this.autoRotate) {
this.currentMesh.rotation.z += 0.0025;
}
if (this.controls) this.controls.update();
if (this.renderer && this.scene && this.camera) {
this.renderer.render(this.scene, this.camera);
}
}
private clearCurrentMesh() {
if (!this.currentMesh) {
return;
}
this.scene.remove(this.currentMesh);
this.currentMesh.geometry.dispose();
const meshMaterial = this.currentMesh.material;
if (Array.isArray(meshMaterial)) {
meshMaterial.forEach((m) => m.dispose());
} else {
meshMaterial.dispose();
}
this.currentMesh = null;
}
private applyColorStyle(color: string) {
if (!this.currentMesh) {
return;
}
const darkColor = this.isDarkColor(color);
const meshMaterial = this.currentMesh.material;
if (meshMaterial instanceof THREE.MeshStandardMaterial) {
meshMaterial.color.set(color);
if (darkColor) {
meshMaterial.emissive.set(0x2a2f36);
meshMaterial.emissiveIntensity = 0.28;
meshMaterial.roughness = 0.5;
meshMaterial.metalness = 0.03;
} else {
meshMaterial.emissive.set(0x000000);
meshMaterial.emissiveIntensity = 0;
meshMaterial.roughness = 0.42;
meshMaterial.metalness = 0.05;
}
meshMaterial.needsUpdate = true;
}
}
private isDarkColor(color: string): boolean {
const c = new THREE.Color(color);
const luminance = 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b;
return luminance < 0.22;
}
}

View File

@@ -40,7 +40,7 @@
"CTA_START": "Start Now",
"BUSINESS": "Business",
"PRIVATE": "Private",
"MODE_EASY": "Easy Print",
"MODE_EASY": "Quick",
"MODE_ADVANCED": "Advanced",
"UPLOAD_LABEL": "Drag your 3D file here",
"UPLOAD_SUB": "Supports STL, 3MF, STEP, OBJ up to 50MB",
@@ -61,9 +61,6 @@
"ORDER": "Order Now",
"CONSULT": "Request Consultation",
"ERROR_GENERIC": "An error occurred while calculating the quote.",
"ERROR_UPLOAD_FAILED": "File upload failed. Please try again.",
"ERROR_VIRUS_DETECTED": "File removed (virus detected)",
"ERROR_SLICING_FAILED": "Slicing error (complex geometry?)",
"NEW_QUOTE": "Calculate New Quote",
"ORDER_SUCCESS_TITLE": "Order Submitted Successfully",
"ORDER_SUCCESS_DESC": "We have received your order details. You will receive a confirmation email shortly with the payment details.",
@@ -158,16 +155,6 @@
"CONTACT_INFO": "Contact Information",
"BILLING_ADDR": "Billing Address",
"SHIPPING_ADDR": "Shipping Address",
"SHIPPING_SAME": "Shipping address same as billing",
"ORDER_SUMMARY": "Order Summary",
"SUBTOTAL": "Subtotal",
"SETUP_FEE": "Setup Fee",
"SHIPPING": "Shipping",
"TOTAL": "Total",
"PLACE_ORDER": "Place Order",
"PROCESSING": "Processing...",
"PRIVATE": "Private",
"COMPANY": "Company",
"FIRST_NAME": "First Name",
"LAST_NAME": "Last Name",
"EMAIL": "Email",
@@ -177,17 +164,29 @@
"ADDRESS_2": "Address Line 2 (Optional)",
"ZIP": "ZIP Code",
"CITY": "City",
"COUNTRY": "Country"
"COUNTRY": "Country",
"SHIPPING_SAME": "Shipping address same as billing",
"PLACE_ORDER": "Place Order",
"PROCESSING": "Processing...",
"SUMMARY_TITLE": "Order Summary",
"SUBTOTAL": "Subtotal",
"SETUP_FEE": "Setup Fee",
"TOTAL": "Total",
"QTY": "Qty",
"SHIPPING": "Shipping"
},
"PAYMENT": {
"TITLE": "Payment",
"METHOD": "Payment Method",
"TWINT_TITLE": "Pay with TWINT",
"TWINT_DESC": "Scan the code with your TWINT app",
"TWINT_OPEN": "Open directly in TWINT",
"TWINT_LINK": "Open payment link",
"BANK_TITLE": "Bank Transfer",
"BANK_OWNER": "Owner",
"BANK_IBAN": "IBAN",
"BANK_REF": "Reference",
"BILLING_INFO_HINT": "Add the same information used in billing.",
"DOWNLOAD_QR": "Download QR-Invoice (PDF)",
"CONFIRM": "Confirm Order",
"SUMMARY_TITLE": "Order Summary",
@@ -195,6 +194,27 @@
"SHIPPING": "Shipping",
"SETUP_FEE": "Setup Fee",
"TOTAL": "Total",
"LOADING": "Loading order details..."
"LOADING": "Loading order details...",
"METHOD_TWINT": "TWINT",
"METHOD_BANK": "Bank Transfer / QR",
"STATUS_REPORTED_TITLE": "Payment Reported",
"STATUS_REPORTED_DESC": "We are verifying your transaction. Your order will move to production as soon as the payment is confirmed.",
"IN_VERIFICATION": "Verifying Payment"
},
"TRACKING": {
"STEP_PENDING": "Pending",
"STEP_REPORTED": "Verifying",
"STEP_PRODUCTION": "Production",
"STEP_SHIPPED": "Shipped"
},
"ORDER_CONFIRMED": {
"TITLE": "Order Confirmed",
"SUBTITLE": "Payment received. Your order is now being processed.",
"STATUS": "Processing",
"HEADING": "We are preparing your order",
"ORDER_REF": "Order reference",
"PROCESSING_TEXT": "As soon as payment is confirmed, your order will move to production.",
"EMAIL_TEXT": "We will send you an email update with status and next steps.",
"BACK_HOME": "Back to Home"
}
}

View File

@@ -11,13 +11,59 @@
"TERMS": "Termini & Condizioni",
"CONTACT": "Contattaci"
},
"HOME": {
"HERO_EYEBROW": "Stampa 3D tecnica per aziende, freelance e maker",
"HERO_TITLE": "Prezzo e tempi in pochi secondi.<br>Dal file 3D al pezzo finito.",
"HERO_LEAD": "Il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.",
"HERO_SUBTITLE": "Realizziamo pezzi unici e produzioni in serie. Se hai il file, il preventivo è istantaneo. Se devi ancora crearlo, il nostro team di design lo progetterà per te.",
"BTN_CALCULATE": "Calcola Preventivo",
"BTN_SHOP": "Vai allo shop",
"BTN_CONTACT": "Parla con noi",
"SEC_CALC_TITLE": "Preventivo immediato in pochi secondi",
"SEC_CALC_SUBTITLE": "Nessuna registrazione necessaria. Non salviamo il tuo file fino al momento dell'ordine e la stima è effettuata tramite vero slicing.",
"SEC_CALC_LIST_1": "Formati supportati: STL, 3MF, STEP, OBJ",
"SEC_CALC_LIST_2": "Qualità: bozza, standard, alta definizione",
"CARD_CALC_EYEBROW": "Calcolo automatico",
"CARD_CALC_TITLE": "Prezzo e tempi in un click",
"CARD_CALC_TAG": "Senza registrazione",
"CARD_CALC_STEP_1": "Carica il file 3D",
"CARD_CALC_STEP_2": "Scegli materiale e qualità",
"CARD_CALC_STEP_3": "Ricevi subito costo e tempo",
"BTN_OPEN_CALC": "Apri calcolatore",
"SEC_CAP_TITLE": "Cosa puoi ottenere",
"SEC_CAP_SUBTITLE": "Produzione su misura per prototipi, piccole serie e pezzi personalizzati.",
"CAP_1_TITLE": "Prototipazione veloce",
"CAP_1_TEXT": "Dal file digitale al modello fisico in 24/48 ore. Verifica ergonomia, incastri e funzionamento prima dello stampo definitivo.",
"CAP_2_TITLE": "Pezzi personalizzati",
"CAP_2_TEXT": "Componenti unici impossibili da trovare in commercio. Riproduciamo parti rotte o creiamo adattamenti su misura.",
"CAP_3_TITLE": "Piccole serie",
"CAP_3_TEXT": "Produzione ponte o serie limitate (10-500 pezzi) con qualità ripetibile. Ideale per validazione di mercato.",
"CAP_4_TITLE": "Consulenza e CAD",
"CAP_4_TEXT": "Non hai il file 3D? Ti aiutiamo a progettarlo, ottimizzarlo per la stampa e scegliere il materiale giusto.",
"SEC_SHOP_TITLE": "Shop di soluzioni tecniche pronte",
"SEC_SHOP_TEXT": "Prodotti selezionati, testati in laboratorio e pronti all'uso. Risolvono problemi reali con funzionalità concrete.",
"SEC_SHOP_LIST_1": "Accessori funzionali per officine e laboratori",
"SEC_SHOP_LIST_2": "Ricambi e componenti difficili da reperire",
"SEC_SHOP_LIST_3": "Supporti e organizzatori per migliorare i flussi di lavoro",
"BTN_DISCOVER": "Scopri i prodotti",
"BTN_REQ_SOLUTION": "Richiedi una soluzione",
"CARD_SHOP_1_TITLE": "Best seller tecnici",
"CARD_SHOP_1_TEXT": "Soluzioni provate sul campo e già pronte alla spedizione.",
"CARD_SHOP_2_TITLE": "Kit pronti all'uso",
"CARD_SHOP_2_TEXT": "Componenti compatibili e facili da montare senza sorprese.",
"CARD_SHOP_3_TITLE": "Su richiesta",
"CARD_SHOP_3_TEXT": "Non trovi quello che serve? Lo progettiamo e lo produciamo per te.",
"SEC_ABOUT_TITLE": "Su di noi",
"SEC_ABOUT_TEXT": "3D fab è un laboratorio tecnico di stampa 3D. Seguiamo progetti dalla consulenza iniziale alla produzione, con tempi chiari e supporto diretto.",
"FOUNDERS_PHOTO": "Foto Founders"
},
"CALC": {
"TITLE": "Calcola Preventivo 3D",
"SUBTITLE": "Carica il tuo file 3D (STL, 3MF, STEP...) e ricevi una stima immediata di costi e tempi di stampa.",
"CTA_START": "Inizia Ora",
"BUSINESS": "Aziende",
"PRIVATE": "Privati",
"MODE_EASY": "Stampa Facile",
"MODE_EASY": "Base",
"MODE_ADVANCED": "Avanzata",
"UPLOAD_LABEL": "Trascina il tuo file 3D qui",
"UPLOAD_SUB": "Supportiamo STL, 3MF, STEP, OBJ fino a 50MB",
@@ -40,9 +86,6 @@
"ORDER": "Ordina Ora",
"CONSULT": "Richiedi Consulenza",
"ERROR_GENERIC": "Si è verificato un errore durante il calcolo del preventivo.",
"ERROR_UPLOAD_FAILED": "Caricamento file fallito. Riprova.",
"ERROR_VIRUS_DETECTED": "File rimosso (virus rilevato)",
"ERROR_SLICING_FAILED": "Errore slicing (geometria complessa?)",
"NEW_QUOTE": "Calcola Nuovo Preventivo",
"ORDER_SUCCESS_TITLE": "Ordine Inviato con Successo",
"ORDER_SUCCESS_DESC": "Abbiamo ricevuto i dettagli del tuo ordine. Riceverai a breve una email di conferma con i dettagli per il pagamento.",
@@ -50,13 +93,53 @@
"BENEFITS_1": "Preventivo automatico con costo e tempo immediati",
"BENEFITS_2": "Materiali selezionati e qualità controllata",
"BENEFITS_3": "Consulenza CAD se il file ha bisogno di modifiche",
"ERR_FILE_REQUIRED": "Il file è obbligatorio."
"ERR_FILE_REQUIRED": "Il file è obbligatorio.",
"ANALYZING_TITLE": "Analisi in corso...",
"ANALYZING_TEXT": "Stiamo analizzando la geometria e calcolando il percorso utensile.",
"QTY_SHORT": "QTÀ",
"COLOR_LABEL": "COLORE",
"ADD_FILES": "Aggiungi file",
"UPLOADING": "Caricamento...",
"PROCESSING": "Elaborazione...",
"NOTES_PLACEHOLDER": "Istruzioni specifiche...",
"SETUP_NOTE": "* Include {{cost}} Costo di Setup"
},
"QUOTE": {
"PROCEED_ORDER": "Procedi con l'ordine",
"CONSULT": "Richiedi Consulenza",
"TOTAL": "Totale"
},
"USER_DETAILS": {
"TITLE": "I tuoi dati",
"NAME": "Nome",
"NAME_PLACEHOLDER": "Il tuo nome",
"SURNAME": "Cognome",
"SURNAME_PLACEHOLDER": "Il tuo cognome",
"EMAIL": "Email",
"EMAIL_PLACEHOLDER": "tua@email.com",
"PHONE": "Telefono",
"PHONE_PLACEHOLDER": "+41 ...",
"ADDRESS": "Indirizzo",
"ADDRESS_PLACEHOLDER": "Via e numero",
"ZIP": "CAP",
"ZIP_PLACEHOLDER": "0000",
"CITY": "Città",
"CITY_PLACEHOLDER": "Città",
"SUBMIT": "Procedi",
"SUMMARY_TITLE": "Riepilogo"
},
"COMMON": {
"REQUIRED": "Campo obbligatorio",
"INVALID_EMAIL": "Email non valida",
"BACK": "Indietro",
"OPTIONAL": "(Opzionale)"
},
"SHOP": {
"TITLE": "Soluzioni tecniche",
"SUBTITLE": "Prodotti pronti che risolvono problemi pratici",
"ADD_CART": "Aggiungi al Carrello",
"BACK": "Torna allo Shop"
"BACK": "Torna allo Shop",
"NOT_FOUND": "Prodotto non trovato."
},
"ABOUT": {
"TITLE": "Chi Siamo",
@@ -129,7 +212,10 @@
"ERR_MAX_FILES": "Limite massimo di 15 file raggiunto.",
"SUCCESS_TITLE": "Messaggio Inviato con Successo",
"SUCCESS_DESC": "Grazie per averci contattato. Abbiamo ricevuto il tuo messaggio e ti invieremo una email di riepilogo a breve.",
"SEND_ANOTHER": "Invia un altro messaggio"
"SEND_ANOTHER": "Invia un altro messaggio",
"HERO_SUBTITLE": "Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.",
"FILE_TYPE_PDF": "PDF",
"FILE_TYPE_3D": "3D"
},
"CHECKOUT": {
"TITLE": "Checkout",
@@ -137,36 +223,41 @@
"CONTACT_INFO": "Informazioni di Contatto",
"BILLING_ADDR": "Indirizzo di Fatturazione",
"SHIPPING_ADDR": "Indirizzo di Spedizione",
"SHIPPING_SAME": "Indirizzo di spedizione uguale a quello di fatturazione",
"ORDER_SUMMARY": "Riepilogo Ordine",
"SUBTOTAL": "Subtotale",
"SETUP_FEE": "Costo Setup",
"SHIPPING": "Spedizione",
"TOTAL": "Totale",
"PLACE_ORDER": "Conferma Ordine",
"PROCESSING": "Elaborazione...",
"PRIVATE": "Privato",
"COMPANY": "Azienda",
"FIRST_NAME": "Nome",
"LAST_NAME": "Cognome",
"EMAIL": "Email",
"PHONE": "Telefono",
"COMPANY_NAME": "Nome Azienda",
"ADDRESS_1": "Indirizzo riga 1",
"ADDRESS_2": "Indirizzo riga 2 (Opzionale)",
"ADDRESS_1": "Indirizzo (Via e numero)",
"ADDRESS_2": "Informazioni aggiuntive (opzionale)",
"ZIP": "CAP",
"CITY": "Città",
"COUNTRY": "Paese"
"COUNTRY": "Paese",
"SHIPPING_SAME": "L'indirizzo di spedizione è lo stesso di quello di fatturazione",
"PLACE_ORDER": "Invia Ordine",
"PROCESSING": "Elaborazione...",
"SUMMARY_TITLE": "Riepilogo Ordine",
"SUBTOTAL": "Subtotale",
"SETUP_FEE": "Costo di Avvio",
"TOTAL": "Totale",
"QTY": "Qtà",
"SHIPPING": "Spedizione",
"INVALID_EMAIL": "Email non valida",
"COMPANY_OPTIONAL": "Nome Azienda (Opzionale)",
"REF_PERSON_OPTIONAL": "Persona di Riferimento (Opzionale)"
},
"PAYMENT": {
"TITLE": "Pagamento",
"METHOD": "Metodo di Pagamento",
"TWINT_TITLE": "Paga con TWINT",
"TWINT_DESC": "Inquadra il codice con l'app TWINT",
"TWINT_OPEN": "Apri direttamente in TWINT",
"TWINT_LINK": "Apri link di pagamento",
"BANK_TITLE": "Bonifico Bancario",
"BANK_OWNER": "Titolare",
"BANK_IBAN": "IBAN",
"BANK_REF": "Riferimento",
"BILLING_INFO_HINT": "Aggiungi le informazioni uguali a quelle della fatturazione.",
"DOWNLOAD_QR": "Scarica QR-Fattura (PDF)",
"CONFIRM": "Conferma Ordine",
"SUMMARY_TITLE": "Riepilogo Ordine",
@@ -174,6 +265,27 @@
"SHIPPING": "Spedizione",
"SETUP_FEE": "Costo Setup",
"TOTAL": "Totale",
"LOADING": "Caricamento dettagli ordine..."
"LOADING": "Caricamento dettagli ordine...",
"METHOD_TWINT": "TWINT",
"METHOD_BANK": "Fattura QR / Bonifico",
"STATUS_REPORTED_TITLE": "Abbiamo ricevuto la tua segnalazione",
"STATUS_REPORTED_DESC": "Stiamo verificando la transazione. Il tuo ordine passerà in produzione non appena l'accredito sarà confermato.",
"IN_VERIFICATION": "Pagamento in verifica"
},
"TRACKING": {
"STEP_PENDING": "In attesa",
"STEP_REPORTED": "In verifica",
"STEP_PRODUCTION": "In Produzione",
"STEP_SHIPPED": "Spedito"
},
"ORDER_CONFIRMED": {
"TITLE": "Ordine Confermato",
"SUBTITLE": "Pagamento registrato. Il tuo ordine è ora in elaborazione.",
"STATUS": "In elaborazione",
"HEADING": "Stiamo preparando il tuo ordine",
"ORDER_REF": "Riferimento ordine",
"PROCESSING_TEXT": "Non appena confermiamo il pagamento, il tuo ordine passerà in produzione.",
"EMAIL_TEXT": "Ti invieremo una email con aggiornamento stato e prossimi step.",
"BACK_HOME": "Torna alla Home"
}
}