45 Commits

Author SHA1 Message Date
5f73924572 fix(back-end): shift model
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 15s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-16 17:48:22 +01:00
8edc4af645 fix(back-end): shift model
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 24s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-16 17:47:41 +01:00
e1409d218b fix(back-end): shift model
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 35s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 17:46:00 +01:00
0c92f8b394 fix(back-end): shift model
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 32s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 17:36:53 +01:00
66de93a315 fix(back-end): shift model
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 32s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 17:32:29 +01:00
b337db03c4 fix(back-end): fix process and new feature
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 35s
Build, Test and Deploy / build-and-push (push) Successful in 32s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 17:14:25 +01:00
8c6c1e10b3 fix(back-end): fix process and new feature
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 32s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-16 16:19:18 +01:00
eac3006512 fix(back-end): fix process and new feature
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 38s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 16:16:03 +01:00
b26b582baf fix(back-end): fix process and new feature
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 33s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-16 16:13:42 +01:00
875c6ffd2d fix(back-end): fix process and new feature
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 32s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-16 16:09:36 +01:00
579ac3fcb6 fix(back-end): fix process files
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 29s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-16 16:01:01 +01:00
efa1371ffa fix(back-end): fix process files
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 15:56:55 +01:00
ab7f263aca fix(back-end): fix process files
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 17s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 15:55:16 +01:00
49bae8e186 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 15:51:04 +01:00
e2872c730c fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 32s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 15:40:16 +01:00
86266b31ee fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 32s
Build, Test and Deploy / build-and-push (push) Successful in 1m23s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 15:28:16 +01:00
5d0fb5fe6d fix(back-end): file error handling
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 25s
Build, Test and Deploy / deploy (push) Has been skipped
Build, Test and Deploy / build-and-push (push) Has been skipped
2026-02-16 15:25:15 +01:00
91af8f4f9c fix(back-end): file error handling
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 27s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-16 15:23:02 +01:00
a96c28fb39 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 32s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 15:07:21 +01:00
9b24ca529c fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 15:01:46 +01:00
6216d9a723 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 32s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 14:54:57 +01:00
4aa3f6adf1 fix(back-end): file error handling
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 26s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-16 14:52:14 +01:00
7baad738f5 fix(back-end): file error handling
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 26s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-16 14:48:46 +01:00
9feceb9b3c fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 22s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 14:36:26 +01:00
304ed942b8 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m22s
Build, Test and Deploy / build-and-push (push) Successful in 30s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 14:34:10 +01:00
881bd87392 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m21s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-16 14:28:40 +01:00
3a5e4e3427 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / build-and-push (push) Successful in 39s
Build, Test and Deploy / deploy (push) Successful in 8s
Build, Test and Deploy / test-backend (push) Successful in 1m20s
2026-02-16 14:20:31 +01:00
8c82470401 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m21s
Build, Test and Deploy / build-and-push (push) Successful in 21s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 14:14:47 +01:00
ef6a5278a7 fix(back-end): revert changes in uplad file
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m22s
Build, Test and Deploy / build-and-push (push) Successful in 15s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 14:05:43 +01:00
bb276b6504 fix(back-end): fix exclude object 0
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m22s
Build, Test and Deploy / build-and-push (push) Successful in 57s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-16 13:57:44 +01:00
e351f2c05f fix(back-end): fix exclude object 0
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m23s
Build, Test and Deploy / build-and-push (push) Successful in 20s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 11:44:05 +01:00
165e12f216 fix(back-end): fix test gcode parser
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m22s
Build, Test and Deploy / build-and-push (push) Successful in 33s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-14 19:10:02 +01:00
475bfcc6fb fix(back-end): fix test gcode parser
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m19s
Build, Test and Deploy / build-and-push (push) Successful in 57s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-14 16:53:21 +01:00
becb15da73 fix invoice and support costs
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 49s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-14 16:47:49 +01:00
4d559901eb fix(front-end): cost displayed in order
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m19s
Build, Test and Deploy / build-and-push (push) Successful in 22s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-13 18:22:58 +01:00
06a036810a fix(back-end): forse risolviamo il problema
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m19s
Build, Test and Deploy / build-and-push (push) Successful in 30s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-13 18:19:26 +01:00
0b29aebfcf feat(back-end): invoice rotto ma pusshamolo lo stesso
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m21s
Build, Test and Deploy / build-and-push (push) Successful in 54s
Build, Test and Deploy / deploy (push) Successful in 10s
2026-02-13 18:14:52 +01:00
961109b04c feat: default pla filaments
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m31s
Build, Test and Deploy / build-and-push (push) Successful in 16s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-13 16:30:00 +01:00
b5bd68ed10 fix(back-end): back-end fix mantain settings
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m18s
Build, Test and Deploy / build-and-push (push) Successful in 30s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-13 16:21:28 +01:00
56fb504062 fix(front-end): error handling for session
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m18s
Build, Test and Deploy / build-and-push (push) Successful in 21s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-13 16:08:04 +01:00
f165d191be feat(back-end):improvement
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 1m19s
Build, Test and Deploy / build-and-push (push) Failing after 37s
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-13 16:03:44 +01:00
e1d9823b51 feat(back-end): integration of clamAVS
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m18s
Build, Test and Deploy / build-and-push (push) Successful in 15s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-12 22:11:47 +01:00
f829ccef4a feat(back-end): integration of clamAVS
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m17s
Build, Test and Deploy / build-and-push (push) Successful in 48s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-12 22:07:13 +01:00
59e881c3f4 feat(back-end): integration of clamAVS
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 51s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-12 21:59:48 +01:00
f5aa0f298e feat(back-end): integration of clamAVS
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 51s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-12 21:50:42 +01:00
79 changed files with 2285 additions and 2357 deletions

View File

@@ -125,23 +125,13 @@ jobs:
ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
- name: Write env and compose to server
- name: Write env to server
shell: bash
run: |
# 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
# 1. Start with the static env file content
cat "deploy/envs/${{ env.ENV }}.env" > /tmp/full_env.env
# 3. Determine DB credentials
# 2. Determine DB credentials
if [[ "${{ env.ENV }}" == "prod" ]]; then
DB_URL="${{ secrets.DB_URL_PROD }}"
DB_USER="${{ secrets.DB_USERNAME_PROD }}"
@@ -156,25 +146,18 @@ jobs:
DB_PASS="${{ secrets.DB_PASSWORD_DEV }}"
fi
# 4. Append DB and Docker credentials (quoted)
printf '\nDB_URL="%s"\nDB_USERNAME="%s"\nDB_PASSWORD="%s"\n' \
# 3. Append DB credentials
printf '\nDB_URL=%s\nDB_USERNAME=%s\nDB_PASSWORD=%s\n' \
"$DB_URL" "$DB_USER" "$DB_PASS" >> /tmp/full_env.env
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)
# 4. Debug: print content (for debug purposes)
echo "Preparing to send env file with variables:"
grep -v "PASSWORD" /tmp/full_env.env || true
# 5. Send env to server
# 5. Send 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)

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 (same as before)
# Install system dependencies for OrcaSlicer
RUN apt-get update && apt-get install -y \
wget \
p7zip-full \
@@ -20,6 +20,14 @@ 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,7 +37,6 @@ 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,25 +3,13 @@ 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 "----------------------------------------------------------------"
# 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
# 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}"

File diff suppressed because one or more lines are too long

View File

@@ -3,14 +3,12 @@ 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,17 +25,15 @@ public class CustomQuoteRequestController {
private final CustomQuoteRequestRepository requestRepo;
private final CustomQuoteRequestAttachmentRepository attachmentRepo;
private final com.printcalculator.service.ClamAVService clamAVService;
// TODO: Inject Storage Service
private static final String STORAGE_ROOT = "storage_requests";
private final com.printcalculator.service.StorageService storageService;
public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
CustomQuoteRequestAttachmentRepository attachmentRepo,
com.printcalculator.service.ClamAVService clamAVService) {
com.printcalculator.service.StorageService storageService) {
this.requestRepo = requestRepo;
this.attachmentRepo = attachmentRepo;
this.clamAVService = clamAVService;
this.storageService = storageService;
}
// 1. Create Custom Quote Request
@@ -71,9 +69,6 @@ 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());
@@ -97,10 +92,8 @@ public class CustomQuoteRequestController {
attachment.setStoredRelativePath(relativePath);
attachmentRepo.save(attachment);
// Save file to disk
Path absolutePath = Paths.get(STORAGE_ROOT, relativePath);
Files.createDirectories(absolutePath.getParent());
Files.copy(file.getInputStream(), absolutePath);
// Save file to disk via StorageService
storageService.store(file, Paths.get(relativePath));
}
}

View File

@@ -1,47 +0,0 @@
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,6 +65,20 @@ 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

@@ -7,7 +7,6 @@ import com.printcalculator.service.InvoicePdfRenderingService;
import com.printcalculator.service.OrderService;
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;
@@ -25,9 +24,7 @@ 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")
@@ -42,7 +39,6 @@ public class OrderController {
private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService;
private final TwintPaymentService twintPaymentService;
public OrderController(OrderService orderService,
@@ -53,8 +49,7 @@ public class OrderController {
CustomerRepository customerRepo,
StorageService storageService,
InvoicePdfRenderingService invoiceService,
QrBillService qrBillService,
TwintPaymentService twintPaymentService) {
QrBillService qrBillService) {
this.orderService = orderService;
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
@@ -64,7 +59,6 @@ public class OrderController {
this.storageService = storageService;
this.invoiceService = invoiceService;
this.qrBillService = qrBillService;
this.twintPaymentService = twintPaymentService;
}
@@ -135,7 +129,7 @@ public class OrderController {
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
vars.put("sellerEmail", "info@3dfab.ch");
vars.put("invoiceNumber", "INV-" + getDisplayOrderNumber(order).toUpperCase());
vars.put("invoiceNumber", "INV-" + order.getId().toString().substring(0, 8).toUpperCase());
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
@@ -187,56 +181,11 @@ public class OrderController {
byte[] pdf = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"invoice-" + getDisplayOrderNumber(order) + ".pdf\"")
.header("Content-Disposition", "attachment; filename=\"invoice-" + orderId + ".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('.');
@@ -249,7 +198,6 @@ 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());
dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone());
@@ -307,12 +255,4 @@ 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,11 +1,15 @@
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;
@@ -15,24 +19,29 @@ 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 com.printcalculator.service.ClamAVService clamAVService;
private final ProfileManager profileManager;
// Defaults (using aliases defined in ProfileManager)
private static final String DEFAULT_FILAMENT = "pla_basic";
private static final String DEFAULT_PROCESS = "standard";
public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, com.printcalculator.service.ClamAVService clamAVService) {
public QuoteController(SlicerService slicerService, StlService stlService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, ProfileManager profileManager) {
this.slicerService = slicerService;
this.stlService = stlService;
this.quoteCalculator = quoteCalculator;
this.machineRepo = machineRepo;
this.clamAVService = clamAVService;
this.profileManager = profileManager;
}
@PostMapping("/api/quote")
@@ -46,7 +55,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) Boolean supportEnabled
@RequestParam(value = "support_enabled", required = false, defaultValue = "false") Boolean supportEnabled
) throws IOException {
// ... process selection logic ...
@@ -74,6 +83,9 @@ public class QuoteController {
}
if (supportEnabled != null) {
processOverrides.put("enable_support", supportEnabled ? "1" : "0");
if (supportEnabled) {
processOverrides.put("support_threshold_angle", "45");
}
}
if (nozzleDiameter != null) {
@@ -83,7 +95,7 @@ public class QuoteController {
// For now, we trust the override key works on the base profile.
}
return processRequest(file, filament, actualProcess, machineOverrides, processOverrides);
return processRequest(file, filament, actualProcess, machineOverrides, processOverrides, nozzleDiameter);
}
@PostMapping("/calculate/stl")
@@ -91,42 +103,91 @@ public class QuoteController {
@RequestParam("file") MultipartFile file
) throws IOException {
// Legacy endpoint uses defaults
return processRequest(file, DEFAULT_FILAMENT, DEFAULT_PROCESS, null, null);
return processRequest(file, DEFAULT_FILAMENT, DEFAULT_PROCESS, null, null, null);
}
private ResponseEntity<QuoteResult> processRequest(MultipartFile file, String filament, String process,
Map<String, String> machineOverrides,
Map<String, String> processOverrides) throws IOException {
Map<String, String> processOverrides,
Double nozzleDiameter) 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());
String slicerMachineProfile = "bambu_a1"; // TODO: Add to PrinterMachine entity
// Use profile from machine or fallback
String slicerMachineProfile = machine.getSlicerMachineProfile();
if (slicerMachineProfile == null || slicerMachineProfile.isEmpty()) {
slicerMachineProfile = "bambu_a1";
}
slicerMachineProfile = profileManager.resolveMachineProfileName(slicerMachineProfile, nozzleDiameter);
PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides);
// 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);
// 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,13 +3,17 @@ 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;
@@ -28,19 +32,24 @@ 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.ClamAVService clamAVService;
private final com.printcalculator.service.StorageService storageService;
// Defaults
private static final String DEFAULT_FILAMENT = "pla_basic";
@@ -49,17 +58,21 @@ 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.ClamAVService clamAVService) {
com.printcalculator.service.StorageService storageService) {
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.clamAVService = clamAVService;
this.storageService = storageService;
}
// 1. Start a new empty session
@@ -102,13 +115,8 @@ 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: storage_quotes/{sessionId}/{uuid}.{ext}
String storageDir = "storage_quotes/" + session.getId();
Files.createDirectories(Paths.get(storageDir));
// Structure: quotes/{sessionId}/{uuid}.{ext} (inside storage root)
String originalFilename = file.getOriginalFilename();
String ext = originalFilename != null && originalFilename.contains(".")
@@ -116,11 +124,15 @@ public class QuoteSessionController {
: ".stl";
String storedFilename = UUID.randomUUID() + ext;
Path persistentPath = Paths.get(storageDir, storedFilename);
Path relativePath = Paths.get("quotes", session.getId().toString(), storedFilename);
// Save file
Files.copy(file.getInputStream(), persistentPath);
storageService.store(file, relativePath);
// 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);
@@ -130,12 +142,32 @@ public class QuoteSessionController {
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new RuntimeException("No active printer found"));
// 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();
// 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());
String filamentProfile = "Generic " + (settings.getMaterial() != null ? settings.getMaterial().toUpperCase() : "PLA");
// Mapping: "pla_basic" -> "Generic PLA", "petg_basic" -> "Generic PETG"
@@ -144,8 +176,25 @@ 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"
@@ -157,26 +206,40 @@ 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");
}
}
// 3. Slice (Use persistent path)
Map<String, String> machineOverrides = new HashMap<>();
if (settings.getNozzleDiameter() != null) {
machineOverrides.put("nozzle_diameter", String.valueOf(settings.getNozzleDiameter()));
}
// 4. Slice (Use persistent path)
PrintStats stats = slicerService.slice(
persistentPath.toFile(),
sliceInput,
machineProfile,
filamentProfile,
processProfile,
null, // machine overrides
machineOverrides, // machine overrides
processOverrides
);
// 4. Calculate Quote
// 5. Calculate Quote
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
// 5. Create Line Item
// 6. Create Line Item
QuoteLineItem item = new QuoteLineItem();
item.setQuoteSession(session);
item.setOriginalFilename(file.getOriginalFilename());
@@ -185,8 +248,8 @@ public class QuoteSessionController {
item.setColorCode(settings.getColor() != null ? settings.getColor() : "#FFFFFF");
item.setStatus("READY"); // or CALCULATED
item.setPrintTimeSeconds((int) stats.printTimeSeconds());
item.setMaterialGrams(BigDecimal.valueOf(stats.filamentWeightGrams()));
item.setPrintTimeSeconds((int) stats.getPrintTimeSeconds());
item.setMaterialGrams(BigDecimal.valueOf(stats.getFilamentWeightGrams()));
item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice()));
// Store breakdown
@@ -196,14 +259,10 @@ public class QuoteSessionController {
breakdown.put("setup_fee", result.getSetupCost());
item.setPricingBreakdown(breakdown);
// 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);
// Dimensions from STL
item.setBoundingBoxXMm(BigDecimal.valueOf(bounds.sizeX()));
item.setBoundingBoxYMm(BigDecimal.valueOf(bounds.sizeY()));
item.setBoundingBoxZMm(BigDecimal.valueOf(bounds.sizeZ()));
item.setCreatedAt(OffsetDateTime.now());
item.setUpdatedAt(OffsetDateTime.now());
@@ -212,11 +271,46 @@ public class QuoteSessionController {
} catch (Exception e) {
// Cleanup if failed
Files.deleteIfExists(persistentPath);
try {
storageService.delete(Paths.get("quotes", session.getId().toString(), storedFilename));
} catch (Exception ignored) {}
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
@@ -227,24 +321,30 @@ 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);
}
}
@@ -336,6 +436,24 @@ 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,6 @@ import java.util.UUID;
public class OrderDto {
private UUID id;
private String orderNumber;
private String status;
private String customerEmail;
private String customerPhone;
@@ -28,9 +27,6 @@ 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; }

View File

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

View File

@@ -138,16 +138,6 @@ 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;
}
@@ -420,4 +410,5 @@ public class Order {
this.paidAt = paidAt;
}
}

View File

@@ -67,16 +67,6 @@ 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

@@ -41,6 +41,9 @@ 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;
}
@@ -57,6 +60,14 @@ 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

@@ -1,16 +0,0 @@
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

@@ -1,97 +0,0 @@
package com.printcalculator.event.listener;
import com.printcalculator.entity.Order;
import com.printcalculator.event.OrderCreatedEvent;
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);
}
}
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 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,27 +1,71 @@
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.context.request.WebRequest;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.HashMap;
import java.util.Map;
import java.math.BigDecimal;
import java.math.RoundingMode;
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(VirusDetectedException.class)
public ResponseEntity<Object> handleVirusDetectedException(
VirusDetectedException ex, WebRequest request) {
@ExceptionHandler(StorageException.class)
public ResponseEntity<?> handleStorageException(StorageException exc) {
// Log the full exception for internal debugging
log.error("Storage Exception occurred", exc);
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("message", ex.getMessage());
body.put("error", "Virus Detected");
Map<String, String> response = new HashMap<>();
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
// 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();
}
}

View File

@@ -0,0 +1,45 @@
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

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

View File

@@ -1,8 +1,29 @@
package com.printcalculator.model;
public record PrintStats(
long printTimeSeconds,
String printTimeFormatted,
double filamentWeightGrams,
double filamentLengthMm
) {}
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;
}
}

View File

@@ -0,0 +1,16 @@
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

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

View File

@@ -1,6 +1,5 @@
package com.printcalculator.service;
import com.printcalculator.exception.VirusDetectedException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@@ -23,15 +22,18 @@ public class ClamAVService {
public ClamAVService(
@Value("${clamav.host:clamav}") String host,
@Value("${clamav.port:3310}") int port,
@Value("${clamav.enabled:true}") boolean enabled
@Value("${clamav.enabled:false}") 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 {
if (enabled) {
logger.info("Initializing ClamAV client at {}:{}", host, port);
client = new ClamavClient(host, port);
}
client = new ClamavClient(host, port);
} catch (Exception e) {
logger.error("Failed to initialize ClamAV client: " + e.getMessage());
}
@@ -49,13 +51,11 @@ public class ClamAVService {
} else if (result instanceof ScanResult.VirusFound) {
Map<String, Collection<String>> viruses = ((ScanResult.VirusFound) result).getFoundViruses();
logger.warn("VIRUS DETECTED: {}", viruses);
throw new VirusDetectedException("Virus detected in the uploaded file: " + viruses);
return false;
} 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,13 +26,15 @@ 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*(.*)");
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_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))) {
@@ -78,7 +80,14 @@ public class GCodeParser {
if (weightMatcher.find()) {
try {
weightG = Double.parseDouble(weightMatcher.group(1).trim());
System.out.println("GCodeParser: Found weight: " + weightG + "g");
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");
}
} catch (NumberFormatException ignored) {}
}
@@ -92,7 +101,14 @@ public class GCodeParser {
}
}
return new PrintStats(seconds, timeFormatted, weightG, lengthMm);
return PrintStats.builder()
.printTimeSeconds(seconds)
.printTimeFormatted(timeFormatted)
.filamentWeightGrams(weightG)
.filamentLengthMm(lengthMm)
.modelWeightGrams(modelWeightG)
.supportWeightGrams(supportWeightG)
.build();
}
private long parseTimeString(String timeStr) {

View File

@@ -8,10 +8,9 @@ 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;
@@ -35,7 +34,6 @@ public class OrderService {
private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService;
private final ApplicationEventPublisher eventPublisher;
public OrderService(OrderRepository orderRepo,
OrderItemRepository orderItemRepo,
@@ -44,8 +42,7 @@ public class OrderService {
CustomerRepository customerRepo,
StorageService storageService,
InvoicePdfRenderingService invoiceService,
QrBillService qrBillService,
ApplicationEventPublisher eventPublisher) {
QrBillService qrBillService) {
this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo;
this.quoteSessionRepo = quoteSessionRepo;
@@ -54,7 +51,6 @@ public class OrderService {
this.storageService = storageService;
this.invoiceService = invoiceService;
this.qrBillService = qrBillService;
this.eventPublisher = eventPublisher;
}
@Transactional
@@ -199,11 +195,7 @@ public class OrderService {
// Generate Invoice and QR Bill
generateAndSaveDocuments(order, savedItems);
Order savedOrder = orderRepo.save(order);
eventPublisher.publishEvent(new OrderCreatedEvent(this, savedOrder));
return savedOrder;
return orderRepo.save(order);
}
private void generateAndSaveDocuments(Order order, List<OrderItem> items) {
@@ -231,7 +223,7 @@ public class OrderService {
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
vars.put("sellerEmail", "info@3dfab.ch");
vars.put("invoiceNumber", "INV-" + getDisplayOrderNumber(order).toUpperCase());
vars.put("invoiceNumber", "INV-" + order.getId().toString().substring(0, 8).toUpperCase());
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
@@ -305,12 +297,4 @@ 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

@@ -16,7 +16,7 @@ import java.util.logging.Logger;
import java.util.stream.Stream;
import java.util.Map;
import java.util.HashMap;
import java.util.List;
import java.math.BigDecimal;
@Service
public class ProfileManager {
@@ -57,38 +57,34 @@ public class ProfileManager {
if (profilePath == null) {
throw new IOException("Profile not found: " + profileName);
}
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) {
// Check aliases first
String resolvedName = profileAliases.getOrDefault(name, name);
// Look for name.json under the expected type directory first to avoid
// collisions across vendors/profile families with same filename.
String filename = toJsonFilename(resolvedName);
// 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";
try (Stream<Path> stream = Files.walk(Paths.get(profilesRoot))) {
List<Path> candidates = stream
Optional<Path> found = stream
.filter(p -> p.getFileName().toString().equals(filename))
.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);
.findFirst();
return found.orElse(null);
} catch (IOException e) {
logger.severe("Error searching for profile: " + e.getMessage());
return null;
@@ -102,20 +98,14 @@ public class ProfileManager {
// 2. Check inherits
if (currentNode.has("inherits")) {
String parentName = currentNode.get("inherits").asText();
// Try local directory first with explicit .json filename.
String parentFilename = toJsonFilename(parentName);
Path parentPath = currentPath.getParent().resolve(parentFilename);
// Try to find parent in same directory or standard search
Path parentPath = currentPath.getParent().resolve(parentName);
if (!Files.exists(parentPath)) {
// Fallback to the same profile type directory before global.
String inferredType = inferTypeFromPath(currentPath);
parentPath = findProfileFile(parentName, inferredType);
}
if (parentPath == null || !Files.exists(parentPath)) {
// If not in same dir, search globally
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)
@@ -146,30 +136,4 @@ 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,8 +51,7 @@ public class QrBillService {
// Reference
// bill.setReference(QRBill.createCreditorReference("...")); // If using QRR
String orderRef = order.getOrderNumber() != null ? order.getOrderNumber() : order.getId().toString();
bill.setUnstructuredMessage("Order " + orderRef);
bill.setUnstructuredMessage("Order " + order.getId());
return bill;
}

View File

@@ -76,11 +76,21 @@ public class QuoteCalculator {
// --- CALCULATIONS ---
// Material Cost: (weight / 1000) * costPerKg
BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
// 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 materialCost = weightKg.multiply(variant.getCostChfPerKg());
// Machine Cost: Tiered
BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
BigDecimal totalHours = BigDecimal.valueOf(stats.getPrintTimeSeconds()).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,27 +41,15 @@ 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");
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"));
if (machineOverrides != null) machineOverrides.forEach(machineProfile::put);
if (processOverrides != null) processOverrides.forEach(processProfile::put);
// 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();
@@ -71,110 +59,61 @@ public class SlicerService {
mapper.writeValue(fFile, filamentProfile);
mapper.writeValue(pFile, processProfile);
String basename = inputStl.getName();
if (basename.toLowerCase().endsWith(".stl")) {
basename = basename.substring(0, basename.length() - 4);
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);
}
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("Interrupted during slicing", e);
} finally {
deleteRecursively(tempDir);
throw new IOException(e);
}
}
private void deleteRecursively(Path path) {
if (path == null || !Files.exists(path)) {
return;
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");
}
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());
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);
}
}
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

@@ -0,0 +1,255 @@
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

@@ -1,66 +0,0 @@
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

@@ -1,17 +0,0 @@
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

@@ -1,54 +0,0 @@
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;
@Override
public void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData) {
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

@@ -24,19 +24,3 @@ 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.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

@@ -1,93 +0,0 @@
<!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,246 +3,81 @@
<head>
<meta charset="utf-8"/>
<style>
@page invoice { size: 8.5in 11in; margin: 0.65in; }
@page qrpage { size: A4; margin: 0; }
body {
page: invoice;
font-family: Helvetica, Arial, sans-serif;
font-size: 10pt;
margin: 0;
padding: 0;
background: #fff;
color: #1a1a1a;
}
.invoice-page {
page: invoice;
width: 100%;
}
.header-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.header-table td {
vertical-align: top;
padding: 0;
}
.header-left {
width: 58%;
line-height: 1.35;
}
.header-right {
width: 42%;
text-align: right;
line-height: 1.35;
}
.invoice-title {
font-size: 15pt;
font-weight: 700;
margin: 0 0 4mm 0;
letter-spacing: 0.3px;
}
.section-title {
margin: 9mm 0 2mm 0;
font-size: 10.5pt;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.2px;
}
.buyer-box {
margin: 0;
line-height: 1.4;
min-height: 20mm;
}
.line-items {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-top: 8mm;
}
.line-items th,
.line-items td {
border-bottom: 1px solid #d8d8d8;
padding: 2.8mm 2.2mm;
vertical-align: top;
word-break: break-word;
}
.line-items th {
text-align: left;
font-weight: 700;
background: #f7f7f7;
}
.line-items th:nth-child(1),
.line-items td:nth-child(1) {
width: 54%;
}
.line-items th:nth-child(2),
.line-items td:nth-child(2) {
width: 12%;
text-align: right;
white-space: nowrap;
}
.line-items th:nth-child(3),
.line-items td:nth-child(3) {
width: 17%;
text-align: right;
white-space: nowrap;
}
.line-items th:nth-child(4),
.line-items td:nth-child(4) {
width: 17%;
text-align: right;
white-space: nowrap;
font-weight: 600;
}
.totals {
margin-top: 7mm;
margin-left: auto;
width: 76mm;
border-collapse: collapse;
}
.totals td {
border: none;
padding: 1.6mm 0;
}
.totals-label {
text-align: left;
color: #3a3a3a;
}
.totals-value {
text-align: right;
white-space: nowrap;
font-weight: 600;
}
.total-strong td {
font-size: 11pt;
font-weight: 700;
padding-top: 2.4mm;
border-top: 1px solid #d8d8d8;
}
.payment-terms {
margin-top: 9mm;
line-height: 1.4;
color: #2b2b2b;
}
.qr-only-page {
page: qrpage;
position: relative;
width: 210mm;
height: 297mm;
background: #fff;
page-break-before: always;
break-before: page;
}
.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;
}
@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; }
</style>
</head>
<body>
<div class="invoice-page">
<table class="header-table">
<tr>
<td class="header-left">
<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>
</td>
<td class="header-right">
<div class="invoice-title">Fattura</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>
</td>
</tr>
</table>
<div class="section-title">Fatturare a</div>
<div class="buyer-box">
<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>
<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>
<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 class="line-items">
<table>
<thead>
<tr>
<th>Descrizione</th>
<th>Qtà</th>
<th>Prezzo</th>
<th>Totale</th>
<th style="text-align:right;">Qtà</th>
<th style="text-align:right;">Prezzo</th>
<th style="text-align:right;">Totale</th>
</tr>
</thead>
<tbody>
<tr th:each="lineItem : ${invoiceLineItems}">
<td th:text="${lineItem.description}">Stampa 3D pezzo X</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>
<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>
</tr>
</tbody>
</table>
<table class="totals">
<tr>
<td class="totals-label">Subtotale</td>
<td class="totals-value" th:text="${subtotalFormatted}">CHF 10.00</td>
<td>Subtotale</td>
<td style="text-align:right;" th:text="${subtotalFormatted}">CHF 10.00</td>
</tr>
<tr class="total-strong">
<td class="totals-label">Totale</td>
<td class="totals-value" th:text="${grandTotalFormatted}">CHF 10.00</td>
<tr>
<td><strong>Totale</strong></td>
<td style="text-align:right;"><strong th:text="${grandTotalFormatted}">CHF 10.00</strong></td>
</tr>
</table>
<div class="payment-terms" th:text="${paymentTermsText}">
<div style="margin-top:6mm;" th:text="${paymentTermsText}">
Pagamento entro 7 giorni. Grazie.
</div>
</div>
<div class="qr-only-page">
<div class="qr-bill-bottom" th:utext="${qrBillSvg}">
</div>
<div style="page-break-before: always;"></div>
<div style="position: absolute; bottom: 0; left: 0; width: 210mm; height: 105mm;" th:utext="${qrBillSvg}">
</div>
</body>

View File

@@ -0,0 +1,151 @@
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

@@ -0,0 +1,23 @@
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

@@ -0,0 +1,176 @@
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

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

View File

@@ -0,0 +1,123 @@
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());
}
}

1
db.sql
View File

@@ -12,6 +12,7 @@ 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()
);

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
version: '3.8'
services:
backend:
# L'immagine usa il tag specificato nel file .env o passato da riga di comando
@@ -5,39 +7,26 @@ 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_requests:/app/storage_requests
- /mnt/cache/appdata/print-calculator/${ENV}/storage_quotes:/app/storage/quotes
- /mnt/cache/appdata/print-calculator/${ENV}/storage_orders:/app/storage/orders
extra_hosts:
- "host.docker.internal:host-gateway"
frontend:
image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-frontend:${TAG}
container_name: print-calculator-frontend-${ENV}
@@ -46,11 +35,6 @@ services:
depends_on:
- backend
restart: always
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes:
backend_profiles_prod:

View File

@@ -13,20 +13,24 @@ services:
- DB_USERNAME=printcalc
- DB_PASSWORD=printcalc_secret
- SPRING_PROFILES_ACTIVE=local
- FILAMENT_COST_PER_KG=22.0
- MACHINE_COST_PER_HOUR=2.50
- ENERGY_COST_PER_KWH=0.30
- PRINTER_POWER_WATTS=150
- MARKUP_PERCENT=20
- 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
@@ -52,16 +56,5 @@ 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,14 +33,6 @@ 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,8 +2,10 @@
<h1>{{ 'CALC.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p>
@if (error()) {
<app-alert type="error">{{ 'CALC.ERROR_GENERIC' | translate }}</app-alert>
@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>
}
</div>
@@ -19,12 +21,12 @@
<div class="mode-selector">
<div class="mode-option"
[class.active]="mode() === 'easy'"
(click)="mode.set('easy')">
(click)="setMode('easy')">
{{ 'CALC.MODE_EASY' | translate }}
</div>
<div class="mode-option"
[class.active]="mode() === 'advanced'"
(click)="mode.set('advanced')">
(click)="setMode('advanced')">
{{ 'CALC.MODE_ADVANCED' | translate }}
</div>
</div>
@@ -35,6 +37,7 @@
[loading]="loading()"
[uploadProgress]="uploadProgress()"
(submitRequest)="onCalculate($event)"
(itemRemoved)="onItemRemoved($event)"
></app-upload-form>
</app-card>
</div>
@@ -42,15 +45,25 @@
<!-- Right Column: Result or Info -->
<div class="col-result" #resultCol>
@if (loading()) {
@if (loading() && !result()) {
<!-- Initial Loading State (before first result) -->
<app-card class="loading-state">
<div class="loader-content">
<div class="spinner"></div>
<h3 class="loading-title">{{ 'CALC.ANALYZING_TITLE' | translate }}</h3>
<p class="loading-text">{{ 'CALC.ANALYZING_TEXT' | translate }}</p>
<h3 class="loading-title">Analisi in corso...</h3>
<p class="loading-text">Stiamo analizzando la geometria e calcolando il percorso utensile.</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<boolean>(false);
error = signal<string | null>(null);
orderSuccess = signal(false);
@@ -48,7 +48,7 @@ export class CalculatorPageComponent implements OnInit {
this.route.queryParams.subscribe(params => {
const sessionId = params['session'];
if (sessionId) {
if (sessionId && sessionId !== this.result()?.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(true);
this.error.set('Failed to load session');
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);
this.uploadForm.setFiles(files, colors);
this.uploadForm.patchSettings(session);
// Also restore colors?
// setFiles inits with 'Black'. We need to update them if they differ.
// items has colorCode.
// setFiles inits with correct colors now.
setTimeout(() => {
if (this.uploadForm) {
items.forEach((item, index) => {
@@ -122,7 +122,11 @@ 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));
}
});
}
@@ -141,7 +145,7 @@ export class CalculatorPageComponent implements OnInit {
this.currentRequest = req;
this.loading.set(true);
this.uploadProgress.set(0);
this.error.set(false);
this.error.set(null);
this.result.set(null);
this.orderSuccess.set(false);
@@ -157,26 +161,45 @@ export class CalculatorPageComponent implements OnInit {
if (typeof event === 'number') {
this.uploadProgress.set(event);
} else {
// It's the result
// It's the result (partial or final)
const res = event as QuoteResult;
this.result.set(res);
this.loading.set(false);
this.uploadProgress.set(100);
this.step.set('quote');
// 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));
}
// Update URL with session ID without reloading
if (res.sessionId) {
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"
});
// 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
});
}
}
}
},
error: () => {
this.error.set(true);
complete: () => {
this.loading.set(false);
this.uploadProgress.set(100);
},
error: (err) => {
if (typeof err === 'string') {
this.error.set(err);
} else {
this.error.set('GENERIC');
}
this.loading.set(false);
}
});
@@ -196,10 +219,10 @@ export class CalculatorPageComponent implements OnInit {
this.step.set('quote');
}
onItemChange(event: {id?: string, fileName: string, quantity: number}) {
onItemChange(event: {id?: string, fileName: string, quantity: number, index: number}) {
// 1. Update local form for consistency (UI feedback)
if (this.uploadForm) {
this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity);
this.uploadForm.updateItemQuantityAtIndex(event.index, event.quantity);
}
// 2. Update backend session if ID exists
@@ -211,6 +234,43 @@ 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);
@@ -256,4 +316,12 @@ 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>{{ 'CALC.SETUP_NOTE' | translate:{cost: (result().setupCost | currency:result().currency)} }}</small>
<small>* Include {{ result().setupCost | currency:result().currency }} Setup Cost</small>
</div>
@if (result().notes) {
@@ -35,28 +35,45 @@
<!-- Detailed Items List (NOW ON BOTTOM) -->
<div class="items-list">
@for (item of items(); track item.fileName; let i = $index) {
<div class="item-row">
@for (item of items(); track item; let i = $index) {
<div class="item-row" [class.has-error]="item.error">
<div class="item-info">
<span class="file-name">{{ item.fileName }}</span>
<span class="file-details">
{{ (item.unitTime / 3600) | number:'1.1-1' }}h | {{ item.unitWeight | number:'1.0-0' }}g
</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>
}
</div>
<div class="item-controls">
<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>
@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>
</div>
}

View File

@@ -21,6 +21,11 @@
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 {
@@ -31,7 +36,21 @@
}
.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); }
.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; }
.item-controls {
display: flex;

View File

@@ -6,6 +6,7 @@ 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',
@@ -18,11 +19,13 @@ export class QuoteResultComponent {
result = input.required<QuoteResult>();
consult = output<void>();
proceed = output<void>();
itemChange = output<{id?: string, fileName: string, quantity: number}>();
itemChange = output<{id?: string, fileName: string, quantity: number, index: number}>();
// Local mutable state for items to handle quantity changes
items = signal<QuoteItem[]>([]);
getColorHex = getColorHex;
constructor() {
effect(() => {
// Initialize local items when result inputs change
@@ -44,7 +47,8 @@ export class QuoteResultComponent {
this.itemChange.emit({
id: this.items()[index].id,
fileName: this.items()[index].fileName,
quantity: qty
quantity: qty,
index: index
});
}
@@ -57,9 +61,11 @@ export class QuoteResultComponent {
let weight = 0;
currentItems.forEach(i => {
price += i.unitPrice * i.quantity;
time += i.unitTime * i.quantity;
weight += i.unitWeight * i.quantity;
if (i.status === 'done' && !i.error) {
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.file.name; let i = $index) {
@for (item of items(); track item; 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>{{ 'CALC.QTY_SHORT' | translate }}</label>
<label>QTÀ</label>
<input
type="number"
min="1"
@@ -45,7 +45,7 @@
</div>
<div class="color-group">
<label>{{ 'CALC.COLOR_LABEL' | translate }}</label>
<label>COLORE</label>
<app-color-selector
[selectedColor]="item.color"
[variants]="currentMaterialVariants()"
@@ -134,7 +134,7 @@
<app-input
formControlName="notes"
[label]="'CALC.NOTES' | translate"
[placeholder]="'CALC.NOTES_PLACEHOLDER' | translate"
placeholder="Istruzioni specifiche..."
></app-input>
<div class="actions">
@@ -151,7 +151,7 @@
type="submit"
[disabled]="items().length === 0 || loading()"
[fullWidth]="true">
{{ loading() ? (uploadProgress() < 100 ? ('CALC.UPLOADING' | translate) : ('CALC.PROCESSING' | translate)) : ('CALC.CALCULATE' | translate) }}
{{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
</app-button>
</div>
</form>

View File

@@ -12,6 +12,7 @@ import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, Mat
import { getColorHex } from '../../../../core/constants/colors.const';
interface FormItem {
id?: string;
file: File;
quantity: number;
color: string;
@@ -29,6 +30,7 @@ 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);
@@ -75,7 +77,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: [false]
supportEnabled: [true]
});
// Listen to material changes to update variants
@@ -112,7 +114,9 @@ export class UploadFormComponent implements OnInit {
private setDefaults() {
// Set Defaults if available
if (this.materials().length > 0 && !this.form.get('material')?.value) {
this.form.get('material')?.setValue(this.materials()[0].value);
// 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);
}
if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
// Try to find 'standard' or use first
@@ -176,6 +180,37 @@ 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
@@ -206,11 +241,7 @@ export class UploadFormComponent implements OnInit {
let val = parseInt(input.value, 10);
if (isNaN(val) || val < 1) val = 1;
this.items.update(current => {
const updated = [...current];
updated[index] = { ...updated[index], quantity: val };
return updated;
});
this.updateItemQuantityAtIndex(index, val);
}
updateItemColor(index: number, newColor: string) {
@@ -222,6 +253,7 @@ 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];
@@ -230,14 +262,15 @@ export class UploadFormComponent implements OnInit {
}
return updated;
});
this.itemRemoved.emit({ index, id: itemToRemove.id });
}
setFiles(files: File[]) {
setFiles(files: File[], colors?: string[]) {
const validItems: FormItem[] = [];
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' });
}
files.forEach((file, i) => {
const color = (colors && colors[i]) ? colors[i] : 'Black';
validItems.push({ file, quantity: 1, color: color });
});
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' | translate"
[placeholder]="'USER_DETAILS.NAME_PLACEHOLDER' | translate"
label="USER_DETAILS.NAME"
placeholder="USER_DETAILS.NAME_PLACEHOLDER"
[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' | translate"
[placeholder]="'USER_DETAILS.SURNAME_PLACEHOLDER' | translate"
label="USER_DETAILS.SURNAME"
placeholder="USER_DETAILS.SURNAME_PLACEHOLDER"
[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' | translate"
label="USER_DETAILS.EMAIL"
type="email"
[placeholder]="'USER_DETAILS.EMAIL_PLACEHOLDER' | translate"
placeholder="USER_DETAILS.EMAIL_PLACEHOLDER"
[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' | translate"
label="USER_DETAILS.PHONE"
type="tel"
[placeholder]="'USER_DETAILS.PHONE_PLACEHOLDER' | translate"
placeholder="USER_DETAILS.PHONE_PLACEHOLDER"
[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' | translate"
[placeholder]="'USER_DETAILS.ADDRESS_PLACEHOLDER' | translate"
label="USER_DETAILS.ADDRESS"
placeholder="USER_DETAILS.ADDRESS_PLACEHOLDER"
[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' | translate"
[placeholder]="'USER_DETAILS.ZIP_PLACEHOLDER' | translate"
label="USER_DETAILS.ZIP"
placeholder="USER_DETAILS.ZIP_PLACEHOLDER"
[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' | translate"
[placeholder]="'USER_DETAILS.CITY_PLACEHOLDER' | translate"
label="USER_DETAILS.CITY"
placeholder="USER_DETAILS.CITY_PLACEHOLDER"
[required]="true"
[error]="form.get('city')?.invalid && form.get('city')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input>

View File

@@ -26,6 +26,8 @@ export interface QuoteItem {
quantity: number;
material?: string;
color?: string;
error?: string;
status: 'pending' | 'done' | 'error';
}
export interface QuoteResult {
@@ -138,6 +140,13 @@ 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
@@ -162,13 +171,6 @@ 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) {
@@ -187,18 +189,74 @@ 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 checkCompletion = () => {
const emitUpdate = () => {
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) {
finalize(finalResponses, sessionSetupCost, sessionId);
observer.complete();
}
};
@@ -228,20 +286,42 @@ export class QuoteEstimatorService {
}).subscribe({
next: (event) => {
if (event.type === HttpEventType.UploadProgress && event.total) {
allProgress[index] = Math.round((100 * event.loaded) / event.total);
checkCompletion();
allProgress[index] = Math.round((70 * event.loaded) / event.total); // Upload is 70% of "progress" for user perception
emitUpdate();
} else if (event.type === HttpEventType.Response) {
allProgress[index] = 100;
finalResponses[index] = { ...event.body, success: true, fileName: item.file.name, originalQty: item.quantity, originalItem: item };
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'
};
completedRequests++;
checkCompletion();
emitUpdate();
}
},
error: (err) => {
console.error('Item upload failed', err);
finalResponses[index] = { success: false, fileName: item.file.name };
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
completedRequests++;
checkCompletion();
emitUpdate();
}
});
});
@@ -251,62 +331,6 @@ 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();
};
});
}
@@ -360,7 +384,8 @@ 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
color: item.colorCode,
status: 'done'
})),
setupCost: session.setupCostChf,
currency: 'CHF', // Fixed for now

View File

@@ -1,161 +1,154 @@
<div class="checkout-page">
<div class="container hero">
<h1 class="section-title">{{ 'CHECKOUT.TITLE' | translate }}</h1>
</div>
<div class="container hero">
<h1>{{ 'CHECKOUT.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'CHECKOUT.SUBTITLE' | translate }}</p>
</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="card-header-simple">
<h3>{{ 'CHECKOUT.CONTACT_INFO' | translate }}</h3>
<!-- 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 }}
</div>
<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 class="type-option" [class.selected]="isCompany" (click)="setCustomerType(true)">
{{ 'CHECKOUT.COMPANY' | translate }}
</div>
</app-card>
</div>
<!-- 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">
<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">
<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>
<!-- 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>
<!-- 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.SUMMARY_TITLE' | 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>{{ '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 *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>
</div>
</div>
</app-card>
</div>
<!-- Billing Address Card -->
<app-card class="mb-6">
<div class="card-header-simple">
<h3>{{ 'CHECKOUT.BILLING_ADDR' | 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>
</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>
<!-- 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,16 +1,23 @@
.hero {
padding: var(--space-8) 0;
padding: var(--space-12) 0 var(--space-8);
text-align: center;
.section-title {
h1 {
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 420px;
grid-template-columns: 1fr 400px;
gap: var(--space-8);
align-items: start;
margin-bottom: var(--space-12);
@@ -70,7 +77,7 @@
background-color: var(--color-neutral-100);
border-radius: var(--radius-md);
padding: 4px;
margin: var(--space-6) 0;
margin-bottom: var(--space-4);
gap: 4px;
width: 100%;
max-width: 400px;
@@ -188,7 +195,6 @@
max-height: 450px;
overflow-y: auto;
padding-right: var(--space-2);
padding-top: var(--space-2);
&::-webkit-scrollbar {
width: 4px;
@@ -254,19 +260,19 @@
.summary-totals {
background: var(--color-neutral-100);
padding: var(--space-4);
padding: var(--space-6);
border-radius: var(--radius-md);
margin-top: var(--space-6);
margin-top: var(--space-4);
.total-row {
display: flex;
justify-content: space-between;
margin-bottom: var(--space-2);
font-size: 0.95rem;
color: var(--color-text);
color: var(--color-text-muted);
}
.grand-total {
.grand-total-row {
display: flex;
justify-content: space-between;
color: var(--color-text);
@@ -297,3 +303,4 @@
}
.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',
styleUrls: ['./checkout.component.scss']
styleUrl: './checkout.component.scss'
})
export class CheckoutComponent implements OnInit {
private fb = inject(FormBuilder);
@@ -47,7 +47,6 @@ export class CheckoutComponent implements OnInit {
firstName: ['', Validators.required],
lastName: ['', Validators.required],
companyName: [''],
referencePerson: [''],
addressLine1: ['', Validators.required],
addressLine2: [''],
zip: ['', Validators.required],
@@ -59,7 +58,6 @@ export class CheckoutComponent implements OnInit {
firstName: [''],
lastName: [''],
companyName: [''],
referencePerson: [''],
addressLine1: [''],
addressLine2: [''],
zip: [''],
@@ -77,27 +75,16 @@ 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 {
@@ -164,7 +151,6 @@ 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,
@@ -175,7 +161,6 @@ 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="4"></textarea>
<textarea formControlName="message" class="form-control" rows="10"></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'">{{ 'CONTACT.FILE_TYPE_PDF' | translate }}</span>
<span *ngIf="file.type === '3d'">{{ 'CONTACT.FILE_TYPE_3D' | translate }}</span>
<span *ngIf="file.type === 'pdf'">PDF</span>
<span *ngIf="file.type === '3d'">3D</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">{{ 'CONTACT.HERO_SUBTITLE' | translate }}</p>
<p class="subtitle">Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.</p>
</div>
</section>

View File

@@ -2,18 +2,22 @@
<section class="hero">
<div class="container hero-grid">
<div class="hero-copy">
<p class="eyebrow">{{ 'HOME.HERO_EYEBROW' | translate }}</p>
<h1 class="hero-title" [innerHTML]="'HOME.HERO_TITLE' | translate"></h1>
<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="hero-lead">
{{ 'HOME.HERO_LEAD' | translate }}
Il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.
</p>
<p class="hero-subtitle">
{{ 'HOME.HERO_SUBTITLE' | translate }}
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.
</p>
<div class="hero-actions">
<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>
<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>
</div>
</div>
</div>
@@ -22,31 +26,31 @@
<section class="section calculator">
<div class="container calculator-grid">
<div class="calculator-copy">
<h2 class="section-title">{{ 'HOME.SEC_CALC_TITLE' | translate }}</h2>
<h2 class="section-title">Preventivo immediato in pochi secondi</h2>
<p class="section-subtitle">
{{ 'HOME.SEC_CALC_SUBTITLE' | translate }}
Nessuna registrazione necessaria. Non salviamo il tuo file fino al momento dell'ordine e la stima è effettuata tramite vero slicing.
</p>
<ul class="calculator-list">
<li>{{ 'HOME.SEC_CALC_LIST_1' | translate }}</li>
<li>{{ 'HOME.SEC_CALC_LIST_2' | translate }}</li>
<li>Formati supportati: STL, 3MF, STEP, OBJ</li>
<li>Qualità: bozza, standard, alta definizione</li>
</ul>
</div>
<app-card class="quote-card">
<div class="quote-header">
<div>
<p class="quote-eyebrow">{{ 'HOME.CARD_CALC_EYEBROW' | translate }}</p>
<h3 class="quote-title">{{ 'HOME.CARD_CALC_TITLE' | translate }}</h3>
<p class="quote-eyebrow">Calcolo automatico</p>
<h3 class="quote-title">Prezzo e tempi in un click</h3>
</div>
<span class="quote-tag">{{ 'HOME.CARD_CALC_TAG' | translate }}</span>
<span class="quote-tag">Senza registrazione</span>
</div>
<ul class="quote-steps">
<li>{{ 'HOME.CARD_CALC_STEP_1' | translate }}</li>
<li>{{ 'HOME.CARD_CALC_STEP_2' | translate }}</li>
<li>{{ 'HOME.CARD_CALC_STEP_3' | translate }}</li>
<li>Carica il file 3D</li>
<li>Scegli materiale e qualità</li>
<li>Ricevi subito costo e tempo</li>
</ul>
<div class="quote-actions">
<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>
<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>
</div>
</app-card>
</div>
@@ -56,9 +60,9 @@
<div class="capabilities-bg"></div>
<div class="container">
<div class="section-head">
<h2 class="section-title">{{ 'HOME.SEC_CAP_TITLE' | translate }}</h2>
<h2 class="section-title">Cosa puoi ottenere</h2>
<p class="section-subtitle">
{{ 'HOME.SEC_CAP_SUBTITLE' | translate }}
Produzione su misura per prototipi, piccole serie e pezzi personalizzati.
</p>
</div>
<div class="cap-cards">
@@ -66,29 +70,29 @@
<div class="card-image-placeholder">
<!-- <img src="..." alt="..."> -->
</div>
<h3>{{ 'HOME.CAP_1_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CAP_1_TEXT' | translate }}</p>
<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>
</app-card>
<app-card>
<div class="card-image-placeholder">
<!-- <img src="..." alt="..."> -->
</div>
<h3>{{ 'HOME.CAP_2_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CAP_2_TEXT' | translate }}</p>
<h3>Pezzi personalizzati</h3>
<p class="text-muted">Componenti unici impossibili da trovare in commercio. Riproduciamo parti rotte o creiamo adattamenti su misura.</p>
</app-card>
<app-card>
<div class="card-image-placeholder">
<!-- <img src="..." alt="..."> -->
</div>
<h3>{{ 'HOME.CAP_3_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CAP_3_TEXT' | translate }}</p>
<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>
</app-card>
<app-card>
<div class="card-image-placeholder">
<!-- <img src="..." alt="..."> -->
</div>
<h3>{{ 'HOME.CAP_4_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CAP_4_TEXT' | translate }}</p>
<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>
</app-card>
</div>
</div>
@@ -97,32 +101,33 @@
<section class="section shop">
<div class="container split">
<div class="shop-copy">
<h2 class="section-title">{{ 'HOME.SEC_SHOP_TITLE' | translate }}</h2>
<h2 class="section-title">Shop di soluzioni tecniche pronte</h2>
<p>
{{ 'HOME.SEC_SHOP_TEXT' | translate }}
Prodotti selezionati, testati in laboratorio e pronti all'uso. Risolvono problemi reali con
funzionalità concrete.
</p>
<ul class="shop-list">
<li>{{ 'HOME.SEC_SHOP_LIST_1' | translate }}</li>
<li>{{ 'HOME.SEC_SHOP_LIST_2' | translate }}</li>
<li>{{ 'HOME.SEC_SHOP_LIST_3' | translate }}</li>
<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>
</ul>
<div class="shop-actions">
<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>
<app-button variant="primary" routerLink="/shop">Scopri i prodotti</app-button>
<app-button variant="outline" routerLink="/contact">Richiedi una soluzione</app-button>
</div>
</div>
<div class="shop-cards">
<app-card>
<h3>{{ 'HOME.CARD_SHOP_1_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CARD_SHOP_1_TEXT' | translate }}</p>
<h3>Best seller tecnici</h3>
<p class="text-muted">Soluzioni provate sul campo e già pronte alla spedizione.</p>
</app-card>
<app-card>
<h3>{{ 'HOME.CARD_SHOP_2_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CARD_SHOP_2_TEXT' | translate }}</p>
<h3>Kit pronti all'uso</h3>
<p class="text-muted">Componenti compatibili e facili da montare senza sorprese.</p>
</app-card>
<app-card>
<h3>{{ 'HOME.CARD_SHOP_3_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CARD_SHOP_3_TEXT' | translate }}</p>
<h3>Su richiesta</h3>
<p class="text-muted">Non trovi quello che serve? Lo progettiamo e lo produciamo per te.</p>
</app-card>
</div>
</div>
@@ -131,16 +136,17 @@
<section class="section about">
<div class="container about-grid">
<div class="about-copy">
<h2 class="section-title">{{ 'HOME.SEC_ABOUT_TITLE' | translate }}</h2>
<h2 class="section-title">Su di noi</h2>
<p>
{{ 'HOME.SEC_ABOUT_TEXT' | translate }}
3D fab è un laboratorio tecnico di stampa 3D. Seguiamo progetti dalla consulenza iniziale
alla produzione, con tempi chiari e supporto diretto.
</p>
<app-button variant="outline" routerLink="/contact">{{ 'HOME.BTN_CONTACT' | translate }}</app-button>
<app-button variant="outline" routerLink="/contact">Contattaci</app-button>
</div>
<div class="about-media">
<div class="about-feature-image">
<!-- Foto founders -->
<span class="text-sm">{{ 'HOME.FOUNDERS_PHOTO' | translate }}</span>
<span class="text-sm">Foto Founders</span>
</div>
</div>
</div>

View File

@@ -1,25 +0,0 @@
<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">
<app-card class="status-card">
<div class="status-badge">{{ '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="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

@@ -1,62 +0,0 @@
.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;
}

View File

@@ -1,48 +0,0 @@
import { Component, OnInit, inject } 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;
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.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,6 +5,7 @@
<div class="container">
<div class="payment-layout" *ngIf="order() as o">
<div class="payment-main">
<app-card class="mb-6">
<div class="card-header-simple">
@@ -17,39 +18,32 @@
class="type-option"
[class.selected]="selectedPaymentMethod === 'twint'"
(click)="selectPayment('twint')">
<span class="method-name">{{ 'PAYMENT.METHOD_TWINT' | translate }}</span>
<span class="method-name">TWINT</span>
</div>
<div
class="type-option"
[class.selected]="selectedPaymentMethod === 'bill'"
(click)="selectPayment('bill')">
<span class="method-name">{{ 'PAYMENT.METHOD_BANK' | translate }}</span>
<span class="method-name">QR Bill / Bank Transfer</span>
</div>
</div>
</div>
<div class="payment-details fade-in text-center" *ngIf="selectedPaymentMethod === 'twint'">
<!-- TWINT Details -->
<div class="payment-details fade-in" *ngIf="selectedPaymentMethod === 'twint'">
<div class="details-header">
<h4>{{ 'PAYMENT.TWINT_TITLE' | translate }}</h4>
</div>
<div class="qr-placeholder">
<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 class="qr-box">
<span>QR CODE</span>
</div>
<p>{{ 'PAYMENT.TWINT_DESC' | translate }}</p>
<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>
@@ -57,8 +51,7 @@
<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> {{ getDisplayOrderNumber(o) }}</p>
<p class="billing-hint">{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}</p>
<p><strong>{{ 'PAYMENT.BANK_REF' | translate }}:</strong> {{ o.id }}</p>
<div class="qr-bill-actions">
<app-button variant="outline" (click)="downloadInvoice()">
@@ -80,7 +73,7 @@
<app-card class="sticky-card">
<div class="card-header-simple">
<h3>{{ 'PAYMENT.SUMMARY_TITLE' | translate }}</h3>
<p class="order-id">#{{ getDisplayOrderNumber(o) }}</p>
<p class="order-id">#{{ o.id.substring(0, 8) }}</p>
</div>
<div class="summary-totals">
@@ -103,6 +96,7 @@
</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,28 +95,6 @@
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 {
@@ -127,29 +105,23 @@
}
}
.qr-placeholder {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
.twint-qr {
width: 240px;
height: 240px;
background-color: #fff;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-2);
.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);
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);
border-radius: var(--radius-md);
}
.amount {
@@ -160,12 +132,6 @@
}
}
.billing-hint {
margin-top: var(--space-3);
font-size: 0.95rem;
color: var(--color-text-muted);
}
.bank-details {
p {
margin-bottom: var(--space-2);
@@ -217,20 +183,13 @@
}
@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,7 +5,6 @@ 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',
@@ -24,14 +23,11 @@ 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);
@@ -58,16 +54,13 @@ export class PaymentComponent implements OnInit {
}
downloadInvoice() {
const orderId = this.orderId;
if (!orderId) return;
this.quoteService.getOrderInvoice(orderId).subscribe({
if (!this.orderId) return;
this.quoteService.getOrderInvoice(this.orderId).subscribe({
next: (blob) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const fallbackOrderNumber = this.extractOrderNumber(orderId);
const orderNumber = this.order()?.orderNumber ?? fallbackOrderNumber;
a.download = `invoice-${orderNumber}.pdf`;
a.download = `invoice-${this.orderId}.pdf`;
a.click();
window.URL.revokeObjectURL(url);
},
@@ -75,65 +68,9 @@ 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 {
if (!this.orderId) {
this.router.navigate(['/']);
return;
}
this.router.navigate(['/order-confirmed', this.orderId]);
}
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];
// Simulate payment completion
alert('Payment Simulated! Order marked as PAID.');
this.router.navigate(['/']);
}
}

View File

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

View File

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

View File

@@ -25,7 +25,6 @@ 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;
@@ -39,14 +38,14 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
}
if (changes['color'] && this.currentMesh && !changes['file']) {
this.applyColorStyle(this.color);
// Update existing mesh color if only color changed
const mat = this.currentMesh.material as THREE.MeshPhongMaterial;
mat.color.set(this.color);
}
}
ngOnDestroy() {
if (this.animationId) cancelAnimationFrame(this.animationId);
this.clearCurrentMesh();
if (this.controls) this.controls.dispose();
if (this.renderer) this.renderer.dispose();
}
@@ -55,51 +54,28 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
const height = this.rendererContainer.nativeElement.clientHeight;
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0xf4f8fc);
this.scene.background = new THREE.Color(0xf7f6f2); // Neutral-50
// Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.75);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
this.scene.add(ambientLight);
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);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(1, 1, 1);
this.scene.add(directionalLight);
// 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, 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 = new THREE.WebGLRenderer({ antialias: true });
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();
@@ -119,27 +95,24 @@ 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);
this.clearCurrentMesh();
if (this.currentMesh) {
this.scene.remove(this.currentMesh);
this.currentMesh.geometry.dispose();
}
geometry.computeVertexNormals();
const material = new THREE.MeshStandardMaterial({
const material = new THREE.MeshPhongMaterial({
color: this.color,
roughness: 0.42,
metalness: 0.05,
emissive: 0x000000,
emissiveIntensity: 0
specular: 0x111111,
shininess: 200
});
this.currentMesh = new THREE.Mesh(geometry, material);
this.applyColorStyle(this.color);
// Center geometry
geometry.computeBoundingBox();
@@ -167,10 +140,9 @@ 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.72;
cameraZ *= 1.5; // Tighter zoom (reduced from 2.5)
this.camera.position.set(cameraZ * 0.65, cameraZ * 0.95, cameraZ * 1.1);
this.camera.lookAt(0, 0, 0);
this.camera.position.z = cameraZ;
this.camera.updateProjectionMatrix();
this.controls.update();
@@ -185,63 +157,9 @@ 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": "Quick",
"MODE_EASY": "Easy Print",
"MODE_ADVANCED": "Advanced",
"UPLOAD_LABEL": "Drag your 3D file here",
"UPLOAD_SUB": "Supports STL, 3MF, STEP, OBJ up to 50MB",
@@ -61,6 +61,9 @@
"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.",
@@ -155,6 +158,16 @@
"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",
@@ -164,29 +177,17 @@
"ADDRESS_2": "Address Line 2 (Optional)",
"ZIP": "ZIP Code",
"CITY": "City",
"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"
"COUNTRY": "Country"
},
"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,15 +196,5 @@
"SETUP_FEE": "Setup Fee",
"TOTAL": "Total",
"LOADING": "Loading order details..."
},
"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,59 +11,13 @@
"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": "Base",
"MODE_EASY": "Stampa Facile",
"MODE_ADVANCED": "Avanzata",
"UPLOAD_LABEL": "Trascina il tuo file 3D qui",
"UPLOAD_SUB": "Supportiamo STL, 3MF, STEP, OBJ fino a 50MB",
@@ -86,6 +40,9 @@
"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.",
@@ -93,53 +50,13 @@
"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.",
"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)"
"ERR_FILE_REQUIRED": "Il file è obbligatorio."
},
"SHOP": {
"TITLE": "Soluzioni tecniche",
"SUBTITLE": "Prodotti pronti che risolvono problemi pratici",
"ADD_CART": "Aggiungi al Carrello",
"BACK": "Torna allo Shop",
"NOT_FOUND": "Prodotto non trovato."
"BACK": "Torna allo Shop"
},
"ABOUT": {
"TITLE": "Chi Siamo",
@@ -212,10 +129,7 @@
"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",
"HERO_SUBTITLE": "Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.",
"FILE_TYPE_PDF": "PDF",
"FILE_TYPE_3D": "3D"
"SEND_ANOTHER": "Invia un altro messaggio"
},
"CHECKOUT": {
"TITLE": "Checkout",
@@ -223,41 +137,36 @@
"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 (Via e numero)",
"ADDRESS_2": "Informazioni aggiuntive (opzionale)",
"ADDRESS_1": "Indirizzo riga 1",
"ADDRESS_2": "Indirizzo riga 2 (Opzionale)",
"ZIP": "CAP",
"CITY": "Città",
"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)"
"COUNTRY": "Paese"
},
"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",
@@ -265,18 +174,6 @@
"SHIPPING": "Spedizione",
"SETUP_FEE": "Costo Setup",
"TOTAL": "Totale",
"LOADING": "Caricamento dettagli ordine...",
"METHOD_TWINT": "TWINT",
"METHOD_BANK": "Fattura QR / Bonifico"
},
"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"
"LOADING": "Caricamento dettagli ordine..."
}
}