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

This commit is contained in:
2026-02-13 18:14:52 +01:00
parent 961109b04c
commit 0b29aebfcf
16 changed files with 888 additions and 297 deletions

View File

@@ -33,6 +33,8 @@ dependencies {
testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
compileOnly 'org.projectlombok:lombok' compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok'
implementation 'io.github.openhtmltopdf:openhtmltopdf-pdfbox:1.1.37'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
} }
tasks.named('test') { tasks.named('test') {

View File

@@ -2,12 +2,13 @@ package com.printcalculator.controller;
import com.printcalculator.entity.*; import com.printcalculator.entity.*;
import com.printcalculator.repository.*; import com.printcalculator.repository.*;
import com.printcalculator.service.InvoicePdfRenderingService;
import com.printcalculator.service.StorageService;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -15,9 +16,12 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.Optional; import java.util.Map;
import java.util.HashMap;
import java.util.stream.Collectors;
@RestController @RestController
@RequestMapping("/api/orders") @RequestMapping("/api/orders")
@@ -28,7 +32,8 @@ public class OrderController {
private final QuoteSessionRepository quoteSessionRepo; private final QuoteSessionRepository quoteSessionRepo;
private final QuoteLineItemRepository quoteLineItemRepo; private final QuoteLineItemRepository quoteLineItemRepo;
private final CustomerRepository customerRepo; private final CustomerRepository customerRepo;
private final com.printcalculator.service.StorageService storageService; private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService;
public OrderController(OrderRepository orderRepo, public OrderController(OrderRepository orderRepo,
@@ -36,13 +41,15 @@ public class OrderController {
QuoteSessionRepository quoteSessionRepo, QuoteSessionRepository quoteSessionRepo,
QuoteLineItemRepository quoteLineItemRepo, QuoteLineItemRepository quoteLineItemRepo,
CustomerRepository customerRepo, CustomerRepository customerRepo,
com.printcalculator.service.StorageService storageService) { StorageService storageService,
InvoicePdfRenderingService invoiceService) {
this.orderRepo = orderRepo; this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo; this.orderItemRepo = orderItemRepo;
this.quoteSessionRepo = quoteSessionRepo; this.quoteSessionRepo = quoteSessionRepo;
this.quoteLineItemRepo = quoteLineItemRepo; this.quoteLineItemRepo = quoteLineItemRepo;
this.customerRepo = customerRepo; this.customerRepo = customerRepo;
this.storageService = storageService; this.storageService = storageService;
this.invoiceService = invoiceService;
} }
@@ -53,19 +60,13 @@ public class OrderController {
@PathVariable UUID quoteSessionId, @PathVariable UUID quoteSessionId,
@RequestBody com.printcalculator.dto.CreateOrderRequest request @RequestBody com.printcalculator.dto.CreateOrderRequest request
) { ) {
// 1. Fetch Quote Session
QuoteSession session = quoteSessionRepo.findById(quoteSessionId) QuoteSession session = quoteSessionRepo.findById(quoteSessionId)
.orElseThrow(() -> new RuntimeException("Quote Session not found")); .orElseThrow(() -> new RuntimeException("Quote Session not found"));
if (!"ACTIVE".equals(session.getStatus())) {
// Allow converting only active sessions? Or check if not already converted?
// checking convertedOrderId might be better
}
if (session.getConvertedOrderId() != null) { if (session.getConvertedOrderId() != null) {
return ResponseEntity.badRequest().body(null); // Already converted return ResponseEntity.badRequest().body(null);
} }
// 2. Handle Customer (Find or Create)
Customer customer = customerRepo.findByEmail(request.getCustomer().getEmail()) Customer customer = customerRepo.findByEmail(request.getCustomer().getEmail())
.orElseGet(() -> { .orElseGet(() -> {
Customer newC = new Customer(); Customer newC = new Customer();
@@ -75,13 +76,12 @@ public class OrderController {
newC.setUpdatedAt(OffsetDateTime.now()); newC.setUpdatedAt(OffsetDateTime.now());
return customerRepo.save(newC); return customerRepo.save(newC);
}); });
// Update customer details?
customer.setPhone(request.getCustomer().getPhone()); customer.setPhone(request.getCustomer().getPhone());
customer.setCustomerType(request.getCustomer().getCustomerType()); customer.setCustomerType(request.getCustomer().getCustomerType());
customer.setUpdatedAt(OffsetDateTime.now()); customer.setUpdatedAt(OffsetDateTime.now());
customerRepo.save(customer); customerRepo.save(customer);
// 3. Create Order
Order order = new Order(); Order order = new Order();
order.setSourceQuoteSession(session); order.setSourceQuoteSession(session);
order.setCustomer(customer); order.setCustomer(customer);
@@ -92,7 +92,6 @@ public class OrderController {
order.setUpdatedAt(OffsetDateTime.now()); order.setUpdatedAt(OffsetDateTime.now());
order.setCurrency("CHF"); order.setCurrency("CHF");
// Billing
order.setBillingCustomerType(request.getCustomer().getCustomerType()); order.setBillingCustomerType(request.getCustomer().getCustomerType());
if (request.getBillingAddress() != null) { if (request.getBillingAddress() != null) {
order.setBillingFirstName(request.getBillingAddress().getFirstName()); order.setBillingFirstName(request.getBillingAddress().getFirstName());
@@ -106,7 +105,6 @@ public class OrderController {
order.setBillingCountryCode(request.getBillingAddress().getCountryCode() != null ? request.getBillingAddress().getCountryCode() : "CH"); order.setBillingCountryCode(request.getBillingAddress().getCountryCode() != null ? request.getBillingAddress().getCountryCode() : "CH");
} }
// Shipping
order.setShippingSameAsBilling(request.isShippingSameAsBilling()); order.setShippingSameAsBilling(request.isShippingSameAsBilling());
if (!request.isShippingSameAsBilling() && request.getShippingAddress() != null) { if (!request.isShippingSameAsBilling() && request.getShippingAddress() != null) {
order.setShippingFirstName(request.getShippingAddress().getFirstName()); order.setShippingFirstName(request.getShippingAddress().getFirstName());
@@ -119,8 +117,6 @@ public class OrderController {
order.setShippingCity(request.getShippingAddress().getCity()); order.setShippingCity(request.getShippingAddress().getCity());
order.setShippingCountryCode(request.getShippingAddress().getCountryCode() != null ? request.getShippingAddress().getCountryCode() : "CH"); order.setShippingCountryCode(request.getShippingAddress().getCountryCode() != null ? request.getShippingAddress().getCountryCode() : "CH");
} else { } else {
// Copy billing to shipping? Or leave empty and rely on flag?
// Usually explicit copy is safer for queries
order.setShippingFirstName(order.getBillingFirstName()); order.setShippingFirstName(order.getBillingFirstName());
order.setShippingLastName(order.getBillingLastName()); order.setShippingLastName(order.getBillingLastName());
order.setShippingCompanyName(order.getBillingCompanyName()); order.setShippingCompanyName(order.getBillingCompanyName());
@@ -132,81 +128,61 @@ public class OrderController {
order.setShippingCountryCode(order.getBillingCountryCode()); order.setShippingCountryCode(order.getBillingCountryCode());
} }
// Financials from Session (Assuming mocked/calculated in session)
// We re-calculate totals from line items to be safe
List<QuoteLineItem> quoteItems = quoteLineItemRepo.findByQuoteSessionId(quoteSessionId); List<QuoteLineItem> quoteItems = quoteLineItemRepo.findByQuoteSessionId(quoteSessionId);
BigDecimal subtotal = BigDecimal.ZERO; BigDecimal subtotal = BigDecimal.ZERO;
// Initialize financial fields to defaults to satisfy DB constraints
order.setSubtotalChf(BigDecimal.ZERO); order.setSubtotalChf(BigDecimal.ZERO);
order.setTotalChf(BigDecimal.ZERO); order.setTotalChf(BigDecimal.ZERO);
order.setDiscountChf(BigDecimal.ZERO); order.setDiscountChf(BigDecimal.ZERO);
order.setSetupCostChf(session.getSetupCostChf()); // Or 0 if null, but session has it order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO);
order.setShippingCostChf(BigDecimal.valueOf(9.00)); // Default order.setShippingCostChf(BigDecimal.valueOf(9.00));
// Save Order first to get ID
order = orderRepo.save(order); order = orderRepo.save(order);
// 4. Create Order Items
for (QuoteLineItem qItem : quoteItems) { for (QuoteLineItem qItem : quoteItems) {
OrderItem oItem = new OrderItem(); OrderItem oItem = new OrderItem();
oItem.setOrder(order); oItem.setOrder(order);
oItem.setOriginalFilename(qItem.getOriginalFilename()); oItem.setOriginalFilename(qItem.getOriginalFilename());
oItem.setQuantity(qItem.getQuantity()); oItem.setQuantity(qItem.getQuantity());
oItem.setColorCode(qItem.getColorCode()); oItem.setColorCode(qItem.getColorCode());
oItem.setMaterialCode(session.getMaterialCode()); // Or per item if supported oItem.setMaterialCode(session.getMaterialCode());
// Pricing
oItem.setUnitPriceChf(qItem.getUnitPriceChf()); oItem.setUnitPriceChf(qItem.getUnitPriceChf());
oItem.setLineTotalChf(qItem.getUnitPriceChf().multiply(BigDecimal.valueOf(qItem.getQuantity()))); oItem.setLineTotalChf(qItem.getUnitPriceChf().multiply(BigDecimal.valueOf(qItem.getQuantity())));
oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds()); oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds());
oItem.setMaterialGrams(qItem.getMaterialGrams()); oItem.setMaterialGrams(qItem.getMaterialGrams());
// File Handling Check
// "orders/{orderId}/3d-files/{orderItemId}/{uuid}.{ext}"
UUID fileUuid = UUID.randomUUID(); UUID fileUuid = UUID.randomUUID();
String ext = getExtension(qItem.getOriginalFilename()); String ext = getExtension(qItem.getOriginalFilename());
String storedFilename = fileUuid.toString() + "." + ext; String storedFilename = fileUuid.toString() + "." + ext;
oItem.setStoredFilename(storedFilename); oItem.setStoredFilename(storedFilename);
oItem.setStoredRelativePath("PENDING"); // Placeholder oItem.setStoredRelativePath("PENDING");
oItem.setMimeType("application/octet-stream"); // specific type if known oItem.setMimeType("application/octet-stream");
oItem.setCreatedAt(OffsetDateTime.now()); oItem.setCreatedAt(OffsetDateTime.now());
oItem = orderItemRepo.save(oItem); oItem = orderItemRepo.save(oItem);
// Update Path now that we have ID
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename; String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
oItem.setStoredRelativePath(relativePath); oItem.setStoredRelativePath(relativePath);
// COPY FILE from Quote to Order
if (qItem.getStoredPath() != null) { if (qItem.getStoredPath() != null) {
try { try {
Path sourcePath = Paths.get(qItem.getStoredPath()); Path sourcePath = Paths.get(qItem.getStoredPath());
if (Files.exists(sourcePath)) { if (Files.exists(sourcePath)) {
storageService.store(sourcePath, Paths.get(relativePath)); storageService.store(sourcePath, Paths.get(relativePath));
oItem.setFileSizeBytes(Files.size(sourcePath)); oItem.setFileSizeBytes(Files.size(sourcePath));
} }
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); // Log error but allow order creation? Or fail? e.printStackTrace();
// Ideally fail or mark as error
} }
} }
orderItemRepo.save(oItem); orderItemRepo.save(oItem);
subtotal = subtotal.add(oItem.getLineTotalChf()); subtotal = subtotal.add(oItem.getLineTotalChf());
} }
// Update Order Totals
order.setSubtotalChf(subtotal); order.setSubtotalChf(subtotal);
order.setSetupCostChf(session.getSetupCostChf());
order.setShippingCostChf(BigDecimal.valueOf(9.00)); // Default shipping? or 0?
order.setDiscountChf(BigDecimal.ZERO);
// Calculate Shipping (Basic implementation: Flat rate 9.00 if not pickup)
// Future: Check delivery method from request if available
if (order.getShippingCostChf() == null) { if (order.getShippingCostChf() == null) {
order.setShippingCostChf(BigDecimal.valueOf(9.00)); order.setShippingCostChf(BigDecimal.valueOf(9.00));
} }
@@ -214,15 +190,13 @@ public class OrderController {
BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO); BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO);
order.setTotalChf(total); order.setTotalChf(total);
// Link session
session.setConvertedOrderId(order.getId()); session.setConvertedOrderId(order.getId());
session.setStatus("CONVERTED"); // or CLOSED session.setStatus("CONVERTED");
quoteSessionRepo.save(session); quoteSessionRepo.save(session);
return ResponseEntity.ok(orderRepo.save(order)); return ResponseEntity.ok(orderRepo.save(order));
} }
// 2. Upload file for Order Item
@PostMapping(value = "/{orderId}/items/{orderItemId}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/{orderId}/items/{orderItemId}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Transactional @Transactional
public ResponseEntity<Void> uploadOrderItemFile( public ResponseEntity<Void> uploadOrderItemFile(
@@ -238,32 +212,93 @@ public class OrderController {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
// Ensure path logic
String relativePath = item.getStoredRelativePath(); String relativePath = item.getStoredRelativePath();
if (relativePath == null || relativePath.equals("PENDING")) { if (relativePath == null || relativePath.equals("PENDING")) {
// Should verify consistency
// If we used the logic above, it should have a path.
// If it's "PENDING", regen it.
String ext = getExtension(file.getOriginalFilename()); String ext = getExtension(file.getOriginalFilename());
String storedFilename = UUID.randomUUID().toString() + "." + ext; String storedFilename = UUID.randomUUID().toString() + "." + ext;
relativePath = "orders/" + orderId + "/3d-files/" + orderItemId + "/" + storedFilename; relativePath = "orders/" + orderId + "/3d-files/" + orderItemId + "/" + storedFilename;
item.setStoredRelativePath(relativePath); item.setStoredRelativePath(relativePath);
item.setStoredFilename(storedFilename); item.setStoredFilename(storedFilename);
// Update item
} }
// Save file to disk
storageService.store(file, Paths.get(relativePath)); storageService.store(file, Paths.get(relativePath));
item.setFileSizeBytes(file.getSize()); item.setFileSizeBytes(file.getSize());
item.setMimeType(file.getContentType()); item.setMimeType(file.getContentType());
// Calculate SHA256? (Optional)
orderItemRepo.save(item); orderItemRepo.save(item);
return ResponseEntity.ok().build(); return ResponseEntity.ok().build();
} }
@GetMapping("/{orderId}")
public ResponseEntity<Order> getOrder(@PathVariable UUID orderId) {
return orderRepo.findById(orderId)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/{orderId}/invoice")
public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) {
Order order = orderRepo.findById(orderId)
.orElseThrow(() -> new RuntimeException("Order not found"));
List<OrderItem> items = orderItemRepo.findByOrder_Id(orderId);
Map<String, Object> vars = new HashMap<>();
vars.put("sellerDisplayName", "3D Fab Switzerland");
vars.put("sellerAddressLine1", "Sede Ticino, Svizzera");
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
vars.put("sellerEmail", "info@3dfab.ch");
vars.put("invoiceNumber", "INV-" + order.getId().toString().substring(0, 8).toUpperCase());
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
String buyerName = order.getBillingCustomerType().equals("BUSINESS")
? order.getBillingCompanyName()
: order.getBillingFirstName() + " " + order.getBillingLastName();
vars.put("buyerDisplayName", buyerName);
vars.put("buyerAddressLine1", order.getBillingAddressLine1());
vars.put("buyerAddressLine2", order.getBillingZip() + " " + order.getBillingCity() + ", " + order.getBillingCountryCode());
List<Map<String, Object>> invoiceLineItems = items.stream().map(i -> {
Map<String, Object> line = new HashMap<>();
line.put("description", "Stampa 3D: " + i.getOriginalFilename());
line.put("quantity", i.getQuantity());
line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf()));
line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf()));
return line;
}).collect(Collectors.toList());
// Add Setup and Shipping as line items too? Or separate in template?
// Template has invoiceLineItems loop. Let's add them there for simplicity or separate.
// Let's add them to the list.
Map<String, Object> setupLine = new HashMap<>();
setupLine.put("description", "Costo Setup");
setupLine.put("quantity", 1);
setupLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
setupLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getSetupCostChf()));
invoiceLineItems.add(setupLine);
Map<String, Object> shippingLine = new HashMap<>();
shippingLine.put("description", "Spedizione");
shippingLine.put("quantity", 1);
shippingLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
shippingLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getShippingCostChf()));
invoiceLineItems.add(shippingLine);
vars.put("invoiceLineItems", invoiceLineItems);
vars.put("subtotalFormatted", String.format("CHF %.2f", order.getSubtotalChf()));
vars.put("grandTotalFormatted", String.format("CHF %.2f", order.getTotalChf()));
vars.put("paymentTermsText", "Pagamento entro 7 giorni via Bonifico o TWINT. Grazie.");
byte[] pdf = invoiceService.generateInvoicePdfBytesFromTemplate(vars);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"invoice-" + orderId + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
private String getExtension(String filename) { private String getExtension(String filename) {
if (filename == null) return "stl"; if (filename == null) return "stl";
int i = filename.lastIndexOf('.'); int i = filename.lastIndexOf('.');

View File

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

View File

@@ -0,0 +1,45 @@
package com.printcalculator.service;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Locale;
import java.util.Map;
@Service
public class InvoicePdfRenderingService {
private final TemplateEngine thymeleafTemplateEngine;
public InvoicePdfRenderingService(TemplateEngine thymeleafTemplateEngine) {
this.thymeleafTemplateEngine = thymeleafTemplateEngine;
}
public byte[] generateInvoicePdfBytesFromTemplate(Map<String, Object> invoiceTemplateVariables) {
try {
Context thymeleafContextWithInvoiceData = new Context(Locale.ITALY);
thymeleafContextWithInvoiceData.setVariables(invoiceTemplateVariables);
String renderedInvoiceHtml = thymeleafTemplateEngine.process("invoice", thymeleafContextWithInvoiceData);
String classpathBaseUrlForHtmlResources = new ClassPathResource("templates/").getURL().toExternalForm();
ByteArrayOutputStream generatedPdfByteArrayOutputStream = new ByteArrayOutputStream();
PdfRendererBuilder openHtmlToPdfRendererBuilder = new PdfRendererBuilder();
openHtmlToPdfRendererBuilder.useFastMode();
openHtmlToPdfRendererBuilder.withHtmlContent(renderedInvoiceHtml, classpathBaseUrlForHtmlResources);
openHtmlToPdfRendererBuilder.toStream(generatedPdfByteArrayOutputStream);
openHtmlToPdfRendererBuilder.run();
return generatedPdfByteArrayOutputStream.toByteArray();
} catch (Exception pdfGenerationException) {
throw new IllegalStateException("PDF invoice generation failed", pdfGenerationException);
}
}
}

View File

@@ -0,0 +1,83 @@
<!doctype html>
<html lang="it" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8"/>
<style>
@page { size: A4; margin: 18mm 15mm; }
body { font-family: sans-serif; font-size: 10.5pt; }
.header { display: flex; justify-content: space-between; }
.addresses { margin-top: 10mm; display: flex; justify-content: space-between; }
table { width: 100%; border-collapse: collapse; margin-top: 8mm; }
th, td { padding: 6px; border-bottom: 1px solid #ccc; }
th { text-align: left; }
.totals { margin-top: 6mm; width: 40%; margin-left: auto; }
.totals td { border: none; }
.page-break { page-break-before: always; }
</style>
</head>
<body>
<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>
<thead>
<tr>
<th>Descrizione</th>
<th style="text-align:right;">Qtà</th>
<th style="text-align:right;">Prezzo</th>
<th style="text-align:right;">Totale</th>
</tr>
</thead>
<tbody>
<tr th:each="lineItem : ${invoiceLineItems}">
<td th:text="${lineItem.description}">Stampa 3D pezzo X</td>
<td style="text-align:right;" th:text="${lineItem.quantity}">1</td>
<td style="text-align:right;" th:text="${lineItem.unitPriceFormatted}">CHF 10.00</td>
<td style="text-align:right;" th:text="${lineItem.lineTotalFormatted}">CHF 10.00</td>
</tr>
</tbody>
</table>
<table class="totals">
<tr>
<td>Subtotale</td>
<td style="text-align:right;" th:text="${subtotalFormatted}">CHF 10.00</td>
</tr>
<tr>
<td><strong>Totale</strong></td>
<td style="text-align:right;"><strong th:text="${grandTotalFormatted}">CHF 10.00</strong></td>
</tr>
</table>
<div style="margin-top:6mm;" th:text="${paymentTermsText}">
Pagamento entro 7 giorni. Grazie.
</div>
<!-- Pagina dedicata alla QR-bill (vuota) -->
<div class="page-break"></div>
</body>
</html>

View File

@@ -21,12 +21,12 @@
<div class="mode-selector"> <div class="mode-selector">
<div class="mode-option" <div class="mode-option"
[class.active]="mode() === 'easy'" [class.active]="mode() === 'easy'"
(click)="mode.set('easy')"> (click)="setMode('easy')">
{{ 'CALC.MODE_EASY' | translate }} {{ 'CALC.MODE_EASY' | translate }}
</div> </div>
<div class="mode-option" <div class="mode-option"
[class.active]="mode() === 'advanced'" [class.active]="mode() === 'advanced'"
(click)="mode.set('advanced')"> (click)="setMode('advanced')">
{{ 'CALC.MODE_ADVANCED' | translate }} {{ 'CALC.MODE_ADVANCED' | translate }}
</div> </div>
</div> </div>

View File

@@ -260,4 +260,12 @@ export class CalculatorPageComponent implements OnInit {
this.router.navigate(['/contact']); 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

@@ -145,6 +145,23 @@ export class QuoteEstimatorService {
return this.http.post(`${environment.apiUrl}/api/orders/from-quote/${sessionId}`, orderDetails, { headers }); return this.http.post(`${environment.apiUrl}/api/orders/from-quote/${sessionId}`, orderDetails, { headers });
} }
getOrder(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}`, { headers });
}
getOrderInvoice(orderId: string): Observable<Blob> {
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/invoice`, {
headers,
responseType: 'blob'
});
}
calculate(request: QuoteRequest): Observable<number | QuoteResult> { calculate(request: QuoteRequest): Observable<number | QuoteResult> {
console.log('QuoteEstimatorService: Calculating quote...', request); console.log('QuoteEstimatorService: Calculating quote...', request);
if (request.items.length === 0) { if (request.items.length === 0) {

View File

@@ -1,6 +1,9 @@
<div class="checkout-page"> <div class="container hero">
<h2 class="section-title">Checkout</h2> <h1>{{ 'CHECKOUT.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'CHECKOUT.SUBTITLE' | translate }}</p>
</div>
<div class="container">
<div class="checkout-layout"> <div class="checkout-layout">
<!-- LEFT COLUMN: Form --> <!-- LEFT COLUMN: Form -->
@@ -13,89 +16,88 @@
<form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error"> <form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error">
<!-- Contact Info Card --> <!-- Contact Info Card -->
<div class="form-card"> <app-card class="mb-6">
<div class="card-header"> <div class="form-row">
<h3>Contact Information</h3> <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>
<div class="card-content">
<!-- User Type Selector -->
<div class="user-type-selector"> <div class="user-type-selector">
<div class="type-option" [class.selected]="!isCompany" (click)="setCustomerType(false)"> <div class="type-option" [class.selected]="!isCompany" (click)="setCustomerType(false)">
Private {{ 'CHECKOUT.PRIVATE' | translate }}
</div> </div>
<div class="type-option" [class.selected]="isCompany" (click)="setCustomerType(true)"> <div class="type-option" [class.selected]="isCompany" (click)="setCustomerType(true)">
Company {{ 'CHECKOUT.COMPANY' | translate }}
</div> </div>
</div> </div>
<div class="form-row"> <div formGroupName="billingAddress">
<app-input formControlName="email" type="email" label="Email" [required]="true" [error]="checkoutForm.get('email')?.hasError('email') ? 'Invalid email' : null"></app-input> <div *ngIf="isCompany" class="company-fields">
<app-input formControlName="phone" type="tel" label="Phone" [required]="true"></app-input> <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> </div>
</div> </div>
<div *ngIf="!isCompany" class="form-row no-margin">
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate" [required]="true"></app-input>
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate" [required]="true"></app-input>
</div> </div>
</div>
</app-card>
<!-- Billing Address Card --> <!-- Billing Address Card -->
<div class="form-card"> <app-card class="mb-6">
<div class="card-header"> <div class="card-header-simple">
<h3>Billing Address</h3> <h3>{{ 'CHECKOUT.BILLING_ADDR' | translate }}</h3>
</div> </div>
<div class="card-content" formGroupName="billingAddress"> <div formGroupName="billingAddress">
<div class="form-row"> <app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate" [required]="true"></app-input>
<app-input formControlName="firstName" label="First Name" [required]="true"></app-input> <app-input formControlName="addressLine2" [label]="'CHECKOUT.ADDRESS_2' | translate"></app-input>
<app-input formControlName="lastName" label="Last Name" [required]="true"></app-input>
</div>
<!-- Company Name (Conditional) -->
<app-input *ngIf="isCompany" formControlName="companyName" label="Company Name" [required]="true"></app-input>
<app-input formControlName="addressLine1" label="Address Line 1" [required]="true"></app-input>
<app-input formControlName="addressLine2" label="Address Line 2 (Optional)"></app-input>
<div class="form-row three-cols"> <div class="form-row three-cols">
<app-input formControlName="zip" label="ZIP Code" [required]="true"></app-input> <app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate" [required]="true"></app-input>
<app-input formControlName="city" label="City" class="city-field" [required]="true"></app-input> <app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field" [required]="true"></app-input>
<app-input formControlName="countryCode" label="Country" [disabled]="true" [required]="true"></app-input> <app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true" [required]="true"></app-input>
</div>
</div> </div>
</div> </div>
</app-card>
<!-- Shipping Option --> <!-- Shipping Option -->
<div class="shipping-option"> <div class="shipping-option">
<label class="checkbox-container"> <label class="checkbox-container">
<input type="checkbox" formControlName="shippingSameAsBilling"> <input type="checkbox" formControlName="shippingSameAsBilling">
<span class="checkmark"></span> <span class="checkmark"></span>
Shipping address same as billing {{ 'CHECKOUT.SHIPPING_SAME' | translate }}
</label> </label>
</div> </div>
<!-- Shipping Address Card (Conditional) --> <!-- Shipping Address Card (Conditional) -->
<div class="form-card" *ngIf="!checkoutForm.get('shippingSameAsBilling')?.value"> <app-card *ngIf="!checkoutForm.get('shippingSameAsBilling')?.value" class="mb-6">
<div class="card-header"> <div class="card-header-simple">
<h3>Shipping Address</h3> <h3>{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}</h3>
</div> </div>
<div class="card-content" formGroupName="shippingAddress"> <div formGroupName="shippingAddress">
<div class="form-row"> <div class="form-row">
<app-input formControlName="firstName" label="First Name"></app-input> <app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate"></app-input>
<app-input formControlName="lastName" label="Last Name"></app-input> <app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate"></app-input>
</div> </div>
<app-input formControlName="companyName" label="Company (Optional)"></app-input> <app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate"></app-input>
<app-input formControlName="addressLine1" label="Address Line 1"></app-input> <app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate"></app-input>
<app-input formControlName="zip" label="ZIP Code"></app-input>
<div class="form-row three-cols"> <div class="form-row three-cols">
<app-input formControlName="zip" label="ZIP Code"></app-input> <app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate"></app-input>
<app-input formControlName="city" label="City" class="city-field"></app-input> <app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field"></app-input>
<app-input formControlName="countryCode" label="Country" [disabled]="true"></app-input> <app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true"></app-input>
</div>
</div> </div>
</div> </div>
</app-card>
<div class="actions"> <div class="actions">
<app-button type="submit" [disabled]="checkoutForm.invalid || isSubmitting()" [fullWidth]="true"> <app-button type="submit" [disabled]="checkoutForm.invalid || isSubmitting()" [fullWidth]="true">
{{ isSubmitting() ? 'Processing...' : 'Place Order' }} {{ (isSubmitting() ? 'CHECKOUT.PROCESSING' : 'CHECKOUT.PLACE_ORDER') | translate }}
</app-button> </app-button>
</div> </div>
@@ -104,12 +106,11 @@
<!-- RIGHT COLUMN: Order Summary --> <!-- RIGHT COLUMN: Order Summary -->
<div class="checkout-summary-section"> <div class="checkout-summary-section">
<div class="form-card sticky-card"> <app-card class="sticky-card">
<div class="card-header"> <div class="card-header-simple">
<h3>Order Summary</h3> <h3>{{ 'CHECKOUT.ORDER_SUMMARY' | translate }}</h3>
</div> </div>
<div class="card-content">
<div class="summary-items" *ngIf="quoteSession() as session"> <div class="summary-items" *ngIf="quoteSession() as session">
<div class="summary-item" *ngFor="let item of session.items"> <div class="summary-item" *ngFor="let item of session.items">
<div class="item-details"> <div class="item-details">
@@ -130,21 +131,19 @@
<div class="summary-totals" *ngIf="quoteSession() as session"> <div class="summary-totals" *ngIf="quoteSession() as session">
<div class="total-row"> <div class="total-row">
<span>Subtotal</span> <span>{{ 'CHECKOUT.SUBTOTAL' | translate }}</span>
<span>{{ (session.totalPrice - session.setupCostChf) | currency:'CHF' }}</span> <span>{{ (session.totalPrice - session.setupCostChf) | currency:'CHF' }}</span>
</div> </div>
<div class="total-row"> <div class="total-row">
<span>Setup Fee</span> <span>{{ 'CHECKOUT.SETUP_FEE' | translate }}</span>
<span>{{ session.setupCostChf | currency:'CHF' }}</span> <span>{{ session.setupCostChf | currency:'CHF' }}</span>
</div> </div>
<div class="divider"></div> <div class="grand-total-row">
<div class="total-row grand-total"> <span>{{ 'CHECKOUT.TOTAL' | translate }}</span>
<span>Total</span>
<span>{{ session.totalPrice | currency:'CHF' }}</span> <span>{{ session.totalPrice | currency:'CHF' }}</span>
</div> </div>
</div> </div>
</div> </app-card>
</div>
</div> </div>
</div> </div>

View File

@@ -1,86 +1,86 @@
.checkout-page { .hero {
padding: 3rem 1rem; padding: var(--space-12) 0 var(--space-8);
max-width: 1200px; text-align: center;
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; margin: 0 auto;
} }
.checkout-layout { .checkout-layout {
display: grid; display: grid;
grid-template-columns: 1fr 380px; grid-template-columns: 1fr 400px;
gap: var(--space-8); gap: var(--space-8);
align-items: start; align-items: start;
margin-bottom: var(--space-12);
@media (max-width: 900px) { @media (max-width: 1024px) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: var(--space-6); gap: var(--space-8);
} }
} }
.section-title { .card-header-simple {
font-size: 2rem;
font-weight: 700;
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
color: var(--color-heading); padding-bottom: var(--space-4);
}
.form-card {
margin-bottom: var(--space-6);
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
.card-header {
padding: var(--space-4) var(--space-6);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
background: var(--color-bg-subtle);
h3 { h3 {
font-size: 1.1rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
color: var(--color-heading); color: var(--color-text);
margin: 0; margin: 0;
} }
}
.card-content {
padding: var(--space-6);
}
} }
.form-row { .form-row {
display: flex; display: flex;
flex-direction: column;
gap: var(--space-4); gap: var(--space-4);
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
@media(min-width: 768px) {
flex-direction: row;
& > * { flex: 1; }
}
&.no-margin {
margin-bottom: 0;
}
&.three-cols { &.three-cols {
display: grid; display: grid;
grid-template-columns: 1fr 2fr 1fr; grid-template-columns: 1.5fr 2fr 1fr;
gap: var(--space-4); gap: var(--space-4);
}
app-input { @media (max-width: 768px) {
flex: 1;
width: 100%;
}
@media (max-width: 600px) {
flex-direction: column;
&.three-cols {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
app-input {
width: 100%;
}
} }
/* User Type Selector Styles */ /* User Type Selector - Matching Contact Form Style */
.user-type-selector { .user-type-selector {
display: flex; display: flex;
background-color: var(--color-bg-subtle); background-color: var(--color-neutral-100);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: 4px; padding: 4px;
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
gap: 4px; gap: 4px;
width: 100%; width: 100%;
max-width: 400px;
} }
.type-option { .type-option {
@@ -105,8 +105,20 @@
} }
} }
.company-fields {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding-left: var(--space-4);
border-left: 2px solid var(--color-border);
margin-bottom: var(--space-4);
}
.shipping-option { .shipping-option {
margin: var(--space-6) 0; margin: var(--space-6) 0;
padding: var(--space-4);
background: var(--color-neutral-100);
border-radius: var(--radius-md);
} }
/* Custom Checkbox */ /* Custom Checkbox */
@@ -114,9 +126,10 @@
display: flex; display: flex;
align-items: center; align-items: center;
position: relative; position: relative;
padding-left: 30px; padding-left: 36px;
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 1rem;
font-weight: 500;
user-select: none; user-select: none;
color: var(--color-text); color: var(--color-text);
@@ -142,10 +155,10 @@
top: 50%; top: 50%;
left: 0; left: 0;
transform: translateY(-50%); transform: translateY(-50%);
height: 20px; height: 24px;
width: 20px; width: 24px;
background-color: var(--color-bg-surface); background-color: var(--color-bg-card);
border: 1px solid var(--color-border); border: 2px solid var(--color-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
transition: all 0.2s; transition: all 0.2s;
@@ -153,12 +166,12 @@
content: ""; content: "";
position: absolute; position: absolute;
display: none; display: none;
left: 6px; left: 7px;
top: 2px; top: 3px;
width: 6px; width: 6px;
height: 12px; height: 12px;
border: solid white; border: solid #000;
border-width: 0 2px 2px 0; border-width: 0 2.5px 2.5px 0;
transform: rotate(45deg); transform: rotate(45deg);
} }
} }
@@ -168,40 +181,47 @@
} }
} }
.checkout-summary-section { .checkout-summary-section {
position: relative; position: relative;
} }
.sticky-card { .sticky-card {
position: sticky; position: sticky;
top: 0; top: var(--space-6);
/* Inherits styles from .form-card */
} }
.summary-items { .summary-items {
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
max-height: 400px; max-height: 450px;
overflow-y: auto; overflow-y: auto;
padding-right: var(--space-2);
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 4px;
}
} }
.summary-item { .summary-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
padding: var(--space-3) 0; padding: var(--space-4) 0;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
&:last-child { &:first-child { padding-top: 0; }
border-bottom: none; &:last-child { border-bottom: none; }
}
.item-details { .item-details {
flex: 1; flex: 1;
.item-name { .item-name {
display: block; display: block;
font-weight: 500; font-weight: 600;
font-size: 0.95rem;
margin-bottom: var(--space-1); margin-bottom: var(--space-1);
word-break: break-all; word-break: break-all;
color: var(--color-text); color: var(--color-text);
@@ -215,8 +235,8 @@
color: var(--color-text-muted); color: var(--color-text-muted);
.color-dot { .color-dot {
width: 12px; width: 14px;
height: 12px; height: 14px;
border-radius: 50%; border-radius: 50%;
display: inline-block; display: inline-block;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -234,13 +254,13 @@
font-weight: 600; font-weight: 600;
margin-left: var(--space-3); margin-left: var(--space-3);
white-space: nowrap; white-space: nowrap;
color: var(--color-heading); color: var(--color-text);
} }
} }
.summary-totals { .summary-totals {
background: var(--color-bg-subtle); background: var(--color-neutral-100);
padding: var(--space-4); padding: var(--space-6);
border-radius: var(--radius-md); border-radius: var(--radius-md);
margin-top: var(--space-4); margin-top: var(--space-4);
@@ -248,45 +268,39 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
font-size: 0.95rem;
color: var(--color-text-muted);
}
.grand-total-row {
display: flex;
justify-content: space-between;
color: var(--color-text); color: var(--color-text);
&.grand-total {
color: var(--color-heading);
font-weight: 700; font-weight: 700;
font-size: 1.25rem; font-size: 1.5rem;
margin-top: var(--space-3); margin-top: var(--space-4);
padding-top: var(--space-3); padding-top: var(--space-4);
border-top: 1px solid var(--color-border); border-top: 2px solid var(--color-border);
margin-bottom: 0;
}
}
.divider {
display: none; // Handled by border-top in grand-total
} }
} }
.actions { .actions {
margin-top: var(--space-6); margin-top: var(--space-8);
display: flex;
justify-content: flex-end;
app-button { app-button {
width: 100%; width: 100%;
@media (min-width: 900px) {
width: auto;
min-width: 200px;
}
} }
} }
.error-message { .error-message {
color: var(--color-danger); color: var(--color-error);
background: var(--color-danger-subtle); background: #fef2f2;
padding: var(--space-4); padding: var(--space-4);
border-radius: var(--radius-md); border-radius: var(--radius-md);
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
border: 1px solid var(--color-danger); border: 1px solid #fee2e2;
font-weight: 500;
} }
.mb-6 { margin-bottom: var(--space-6); }

View File

@@ -2,9 +2,11 @@ import { Component, inject, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router'; import { Router, ActivatedRoute } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service'; import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
import { AppInputComponent } from '../../shared/components/app-input/app-input.component'; import { AppInputComponent } from '../../shared/components/app-input/app-input.component';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
@Component({ @Component({
selector: 'app-checkout', selector: 'app-checkout',
@@ -12,11 +14,13 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto
imports: [ imports: [
CommonModule, CommonModule,
ReactiveFormsModule, ReactiveFormsModule,
TranslateModule,
AppInputComponent, AppInputComponent,
AppButtonComponent AppButtonComponent,
AppCardComponent
], ],
templateUrl: './checkout.component.html', templateUrl: './checkout.component.html',
styleUrls: ['./checkout.component.scss'] styleUrl: './checkout.component.scss'
}) })
export class CheckoutComponent implements OnInit { export class CheckoutComponent implements OnInit {
private fb = inject(FormBuilder); private fb = inject(FormBuilder);

View File

@@ -1,21 +1,113 @@
<div class="payment-container"> <div class="container hero">
<mat-card class="payment-card"> <h1>{{ 'PAYMENT.TITLE' | translate }}</h1>
<mat-card-header> <p class="subtitle">{{ 'CHECKOUT.SUBTITLE' | translate }}</p>
<mat-icon mat-card-avatar>payment</mat-icon> </div>
<mat-card-title>Payment Integration</mat-card-title>
<mat-card-subtitle>Order #{{ orderId }}</mat-card-subtitle> <div class="container">
</mat-card-header> <div class="payment-layout" *ngIf="order() as o">
<mat-card-content>
<div class="coming-soon"> <div class="payment-main">
<h3>Coming Soon</h3> <app-card class="mb-6">
<p>The online payment system is currently under development.</p> <div class="card-header-simple">
<p>Your order has been saved. Please contact us to arrange payment.</p> <h3>{{ 'PAYMENT.METHOD' | translate }}</h3>
</div> </div>
</mat-card-content>
<mat-card-actions align="end"> <div class="payment-selection">
<button mat-raised-button color="primary" (click)="completeOrder()"> <div class="methods-grid">
Simulate Payment Completion <div
</button> class="type-option"
</mat-card-actions> [class.selected]="selectedPaymentMethod === 'twint'"
</mat-card> (click)="selectPayment('twint')">
<span class="method-name">TWINT</span>
</div>
<div
class="type-option"
[class.selected]="selectedPaymentMethod === 'bill'"
(click)="selectPayment('bill')">
<span class="method-name">QR Bill / Bank Transfer</span>
</div>
</div>
</div>
<!-- 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">
<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>
</div>
<div class="bank-details">
<p><strong>{{ 'PAYMENT.BANK_OWNER' | translate }}:</strong> 3D Fab Switzerland</p>
<p><strong>{{ 'PAYMENT.BANK_IBAN' | translate }}:</strong> CH98 0000 0000 0000 0000 0</p>
<p><strong>{{ 'PAYMENT.BANK_REF' | translate }}:</strong> {{ o.id }}</p>
<div class="qr-bill-actions">
<app-button variant="outline" (click)="downloadInvoice()">
{{ 'PAYMENT.DOWNLOAD_QR' | translate }}
</app-button>
</div>
</div>
</div>
<div class="actions">
<app-button (click)="completeOrder()" [disabled]="!selectedPaymentMethod" [fullWidth]="true">
{{ 'PAYMENT.CONFIRM' | translate }}
</app-button>
</div>
</app-card>
</div>
<div class="payment-summary">
<app-card class="sticky-card">
<div class="card-header-simple">
<h3>{{ 'PAYMENT.SUMMARY_TITLE' | translate }}</h3>
<p class="order-id">#{{ o.id.substring(0, 8) }}</p>
</div>
<div class="summary-totals">
<div class="total-row">
<span>{{ 'PAYMENT.SUBTOTAL' | translate }}</span>
<span>{{ o.subtotalChf | currency:'CHF' }}</span>
</div>
<div class="total-row">
<span>{{ 'PAYMENT.SHIPPING' | translate }}</span>
<span>{{ o.shippingCostChf | currency:'CHF' }}</span>
</div>
<div class="total-row">
<span>{{ 'PAYMENT.SETUP_FEE' | translate }}</span>
<span>{{ o.setupCostChf | currency:'CHF' }}</span>
</div>
<div class="grand-total-row">
<span>{{ 'PAYMENT.TOTAL' | translate }}</span>
<span>{{ o.totalChf | currency:'CHF' }}</span>
</div>
</div>
</app-card>
</div>
</div>
<div *ngIf="loading()" class="loading-state">
<app-card>
<p>{{ 'PAYMENT.LOADING' | translate }}</p>
</app-card>
</div>
<div *ngIf="error()" class="error-message">
<app-card>
<p>{{ error() }}</p>
</app-card>
</div>
</div> </div>

View File

@@ -1,35 +1,195 @@
.payment-container { .hero {
display: flex; padding: var(--space-12) 0 var(--space-8);
justify-content: center;
align-items: center;
min-height: 80vh;
padding: 2rem;
background-color: #f5f5f5;
}
.payment-card {
max-width: 500px;
width: 100%;
}
.coming-soon {
text-align: center; text-align: center;
padding: 2rem 0;
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;
}
.payment-layout {
display: grid;
grid-template-columns: 1fr 400px;
gap: var(--space-8);
align-items: start;
margin-bottom: var(--space-12);
@media (max-width: 1024px) {
grid-template-columns: 1fr;
gap: var(--space-8);
}
}
.card-header-simple {
margin-bottom: var(--space-6);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--color-border);
h3 { h3 {
margin-bottom: 1rem; font-size: 1.25rem;
color: #555; font-weight: 600;
color: var(--color-text);
margin: 0;
} }
.order-id {
font-size: 0.875rem;
color: var(--color-text-muted);
margin-top: 2px;
}
}
.payment-selection {
margin-bottom: var(--space-6);
}
.methods-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
@media (max-width: 600px) {
grid-template-columns: 1fr;
}
}
.type-option {
border: 2px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-6);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
background: var(--color-bg-card);
text-align: center;
font-weight: 600;
color: var(--color-text-muted);
&:hover {
border-color: var(--color-brand);
color: var(--color-text);
}
&.selected {
border-color: var(--color-brand);
background-color: var(--color-neutral-100);
color: #000;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
}
.payment-details {
background: var(--color-neutral-100);
border-radius: var(--radius-md);
padding: var(--space-6);
margin-bottom: var(--space-6);
border: 1px solid var(--color-border);
.details-header {
margin-bottom: var(--space-4);
h4 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
}
}
.qr-placeholder {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
.qr-box {
width: 180px;
height: 180px;
background-color: white;
border: 2px solid var(--color-neutral-900);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-bottom: var(--space-4);
border-radius: var(--radius-md);
}
.amount {
font-size: 1.25rem;
font-weight: 700;
margin-top: var(--space-2);
color: var(--color-text);
}
}
.bank-details {
p { p {
color: #777; margin-bottom: var(--space-2);
margin-bottom: 0.5rem; font-size: 1rem;
color: var(--color-text);
} }
} }
mat-icon { .qr-bill-actions {
font-size: 40px; margin-top: var(--space-4);
width: 40px; }
height: 40px;
color: #3f51b5; .sticky-card {
position: sticky;
top: var(--space-6);
}
.summary-totals {
background: var(--color-neutral-100);
padding: var(--space-6);
border-radius: var(--radius-md);
.total-row {
display: flex;
justify-content: space-between;
margin-bottom: var(--space-2);
font-size: 0.95rem;
color: var(--color-text-muted);
}
.grand-total-row {
display: flex;
justify-content: space-between;
color: var(--color-text);
font-weight: 700;
font-size: 1.5rem;
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 2px solid var(--color-border);
}
}
.actions {
margin-top: var(--space-8);
}
.fade-in {
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
.mb-6 { margin-bottom: var(--space-6); }
.error-message, .loading-state {
margin-top: var(--space-12);
text-align: center;
} }

View File

@@ -1,34 +1,76 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button'; import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { MatCardModule } from '@angular/material/card'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { MatIconModule } from '@angular/material/icon'; import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
import { TranslateModule } from '@ngx-translate/core';
@Component({ @Component({
selector: 'app-payment', selector: 'app-payment',
standalone: true, standalone: true,
imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule], imports: [CommonModule, AppButtonComponent, AppCardComponent, TranslateModule],
templateUrl: './payment.component.html', templateUrl: './payment.component.html',
styleUrl: './payment.component.scss' styleUrl: './payment.component.scss'
}) })
export class PaymentComponent implements OnInit { export class PaymentComponent implements OnInit {
orderId: string | null = null; private route = inject(ActivatedRoute);
private router = inject(Router);
private quoteService = inject(QuoteEstimatorService);
constructor( orderId: string | null = null;
private route: ActivatedRoute, selectedPaymentMethod: 'twint' | 'bill' | null = null;
private router: Router order = signal<any>(null);
) {} loading = signal(true);
error = signal<string | null>(null);
ngOnInit(): void { ngOnInit(): void {
this.orderId = this.route.snapshot.paramMap.get('orderId'); this.orderId = this.route.snapshot.paramMap.get('orderId');
if (this.orderId) {
this.loadOrder();
} else {
this.error.set('Order ID not found.');
this.loading.set(false);
}
}
loadOrder() {
if (!this.orderId) return;
this.quoteService.getOrder(this.orderId).subscribe({
next: (order) => {
this.order.set(order);
this.loading.set(false);
},
error: (err) => {
console.error('Failed to load order', err);
this.error.set('Failed to load order details.');
this.loading.set(false);
}
});
}
selectPayment(method: 'twint' | 'bill'): void {
this.selectedPaymentMethod = method;
}
downloadInvoice() {
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;
a.download = `invoice-${this.orderId}.pdf`;
a.click();
window.URL.revokeObjectURL(url);
},
error: (err) => console.error('Failed to download invoice', err)
});
} }
completeOrder(): void { completeOrder(): void {
// Simulate payment completion // Simulate payment completion
alert('Payment Simulated! Order marked as PAID.'); alert('Payment Simulated! Order marked as PAID.');
// Here you would call the backend to mark as paid if we had that endpoint ready
// For now, redirect home or show success
this.router.navigate(['/']); this.router.navigate(['/']);
} }
} }

View File

@@ -148,5 +148,49 @@
"SUCCESS_TITLE": "Message Sent Successfully", "SUCCESS_TITLE": "Message Sent Successfully",
"SUCCESS_DESC": "Thank you for contacting us. We have received your message and will send you a recap email shortly.", "SUCCESS_DESC": "Thank you for contacting us. We have received your message and will send you a recap email shortly.",
"SEND_ANOTHER": "Send Another Message" "SEND_ANOTHER": "Send Another Message"
},
"CHECKOUT": {
"TITLE": "Checkout",
"SUBTITLE": "Complete your order by entering the shipping and payment details.",
"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",
"TOTAL": "Total",
"PLACE_ORDER": "Place Order",
"PROCESSING": "Processing...",
"PRIVATE": "Private",
"COMPANY": "Company",
"FIRST_NAME": "First Name",
"LAST_NAME": "Last Name",
"EMAIL": "Email",
"PHONE": "Phone",
"COMPANY_NAME": "Company Name",
"ADDRESS_1": "Address Line 1",
"ADDRESS_2": "Address Line 2 (Optional)",
"ZIP": "ZIP Code",
"CITY": "City",
"COUNTRY": "Country"
},
"PAYMENT": {
"TITLE": "Payment",
"METHOD": "Payment Method",
"TWINT_TITLE": "Pay with TWINT",
"TWINT_DESC": "Scan the code with your TWINT app",
"BANK_TITLE": "Bank Transfer",
"BANK_OWNER": "Owner",
"BANK_IBAN": "IBAN",
"BANK_REF": "Reference",
"DOWNLOAD_QR": "Download QR-Invoice (PDF)",
"CONFIRM": "Confirm Order",
"SUMMARY_TITLE": "Order Summary",
"SUBTOTAL": "Subtotal",
"SHIPPING": "Shipping",
"SETUP_FEE": "Setup Fee",
"TOTAL": "Total",
"LOADING": "Loading order details..."
} }
} }

View File

@@ -127,5 +127,49 @@
"SUCCESS_TITLE": "Messaggio Inviato con Successo", "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.", "SUCCESS_DESC": "Grazie per averci contattato. Abbiamo ricevuto il tuo messaggio e ti invieremo una email di riepilogo a breve.",
"SEND_ANOTHER": "Invia un altro messaggio" "SEND_ANOTHER": "Invia un altro messaggio"
},
"CHECKOUT": {
"TITLE": "Checkout",
"SUBTITLE": "Completa il tuo ordine inserendo i dettagli per la spedizione e il pagamento.",
"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",
"TOTAL": "Totale",
"PLACE_ORDER": "Conferma Ordine",
"PROCESSING": "Elaborazione...",
"PRIVATE": "Privato",
"COMPANY": "Azienda",
"FIRST_NAME": "Nome",
"LAST_NAME": "Cognome",
"EMAIL": "Email",
"PHONE": "Telefono",
"COMPANY_NAME": "Nome Azienda",
"ADDRESS_1": "Indirizzo riga 1",
"ADDRESS_2": "Indirizzo riga 2 (Opzionale)",
"ZIP": "CAP",
"CITY": "Città",
"COUNTRY": "Paese"
},
"PAYMENT": {
"TITLE": "Pagamento",
"METHOD": "Metodo di Pagamento",
"TWINT_TITLE": "Paga con TWINT",
"TWINT_DESC": "Inquadra il codice con l'app TWINT",
"BANK_TITLE": "Bonifico Bancario",
"BANK_OWNER": "Titolare",
"BANK_IBAN": "IBAN",
"BANK_REF": "Riferimento",
"DOWNLOAD_QR": "Scarica QR-Fattura (PDF)",
"CONFIRM": "Conferma Ordine",
"SUMMARY_TITLE": "Riepilogo Ordine",
"SUBTOTAL": "Subtotale",
"SHIPPING": "Spedizione",
"SETUP_FEE": "Costo Setup",
"TOTAL": "Totale",
"LOADING": "Caricamento dettagli ordine..."
} }
} }