diff --git a/backend/build.gradle b/backend/build.gradle index 0266e36..aef0eae 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -33,6 +33,8 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' compileOnly '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') { diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index 34b71af..7729620 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -2,12 +2,13 @@ package com.printcalculator.controller; import com.printcalculator.entity.*; import com.printcalculator.repository.*; +import com.printcalculator.service.InvoicePdfRenderingService; +import com.printcalculator.service.StorageService; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import com.fasterxml.jackson.annotation.JsonProperty; import java.io.IOException; import java.math.BigDecimal; @@ -15,9 +16,12 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.UUID; -import java.util.Optional; +import java.util.Map; +import java.util.HashMap; +import java.util.stream.Collectors; @RestController @RequestMapping("/api/orders") @@ -28,7 +32,8 @@ public class OrderController { private final QuoteSessionRepository quoteSessionRepo; private final QuoteLineItemRepository quoteLineItemRepo; private final CustomerRepository customerRepo; - private final com.printcalculator.service.StorageService storageService; + private final StorageService storageService; + private final InvoicePdfRenderingService invoiceService; public OrderController(OrderRepository orderRepo, @@ -36,13 +41,15 @@ public class OrderController { QuoteSessionRepository quoteSessionRepo, QuoteLineItemRepository quoteLineItemRepo, CustomerRepository customerRepo, - com.printcalculator.service.StorageService storageService) { + StorageService storageService, + InvoicePdfRenderingService invoiceService) { this.orderRepo = orderRepo; this.orderItemRepo = orderItemRepo; this.quoteSessionRepo = quoteSessionRepo; this.quoteLineItemRepo = quoteLineItemRepo; this.customerRepo = customerRepo; this.storageService = storageService; + this.invoiceService = invoiceService; } @@ -53,19 +60,13 @@ public class OrderController { @PathVariable UUID quoteSessionId, @RequestBody com.printcalculator.dto.CreateOrderRequest request ) { - // 1. Fetch Quote Session QuoteSession session = quoteSessionRepo.findById(quoteSessionId) .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) { - 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()) .orElseGet(() -> { Customer newC = new Customer(); @@ -75,13 +76,12 @@ public class OrderController { newC.setUpdatedAt(OffsetDateTime.now()); return customerRepo.save(newC); }); - // Update customer details? + customer.setPhone(request.getCustomer().getPhone()); customer.setCustomerType(request.getCustomer().getCustomerType()); customer.setUpdatedAt(OffsetDateTime.now()); customerRepo.save(customer); - // 3. Create Order Order order = new Order(); order.setSourceQuoteSession(session); order.setCustomer(customer); @@ -92,7 +92,6 @@ public class OrderController { order.setUpdatedAt(OffsetDateTime.now()); order.setCurrency("CHF"); - // Billing order.setBillingCustomerType(request.getCustomer().getCustomerType()); if (request.getBillingAddress() != null) { order.setBillingFirstName(request.getBillingAddress().getFirstName()); @@ -106,7 +105,6 @@ public class OrderController { order.setBillingCountryCode(request.getBillingAddress().getCountryCode() != null ? request.getBillingAddress().getCountryCode() : "CH"); } - // Shipping order.setShippingSameAsBilling(request.isShippingSameAsBilling()); if (!request.isShippingSameAsBilling() && request.getShippingAddress() != null) { order.setShippingFirstName(request.getShippingAddress().getFirstName()); @@ -119,8 +117,6 @@ public class OrderController { order.setShippingCity(request.getShippingAddress().getCity()); order.setShippingCountryCode(request.getShippingAddress().getCountryCode() != null ? request.getShippingAddress().getCountryCode() : "CH"); } else { - // Copy billing to shipping? Or leave empty and rely on flag? - // Usually explicit copy is safer for queries order.setShippingFirstName(order.getBillingFirstName()); order.setShippingLastName(order.getBillingLastName()); order.setShippingCompanyName(order.getBillingCompanyName()); @@ -132,81 +128,61 @@ public class OrderController { order.setShippingCountryCode(order.getBillingCountryCode()); } - // Financials from Session (Assuming mocked/calculated in session) - // We re-calculate totals from line items to be safe List quoteItems = quoteLineItemRepo.findByQuoteSessionId(quoteSessionId); BigDecimal subtotal = BigDecimal.ZERO; - - // Initialize financial fields to defaults to satisfy DB constraints order.setSubtotalChf(BigDecimal.ZERO); order.setTotalChf(BigDecimal.ZERO); order.setDiscountChf(BigDecimal.ZERO); - order.setSetupCostChf(session.getSetupCostChf()); // Or 0 if null, but session has it - order.setShippingCostChf(BigDecimal.valueOf(9.00)); // Default + order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO); + order.setShippingCostChf(BigDecimal.valueOf(9.00)); - // Save Order first to get ID order = orderRepo.save(order); - // 4. Create Order Items for (QuoteLineItem qItem : quoteItems) { OrderItem oItem = new OrderItem(); oItem.setOrder(order); oItem.setOriginalFilename(qItem.getOriginalFilename()); oItem.setQuantity(qItem.getQuantity()); oItem.setColorCode(qItem.getColorCode()); - oItem.setMaterialCode(session.getMaterialCode()); // Or per item if supported + oItem.setMaterialCode(session.getMaterialCode()); - // Pricing oItem.setUnitPriceChf(qItem.getUnitPriceChf()); oItem.setLineTotalChf(qItem.getUnitPriceChf().multiply(BigDecimal.valueOf(qItem.getQuantity()))); oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds()); oItem.setMaterialGrams(qItem.getMaterialGrams()); - // File Handling Check - // "orders/{orderId}/3d-files/{orderItemId}/{uuid}.{ext}" UUID fileUuid = UUID.randomUUID(); String ext = getExtension(qItem.getOriginalFilename()); String storedFilename = fileUuid.toString() + "." + ext; oItem.setStoredFilename(storedFilename); - oItem.setStoredRelativePath("PENDING"); // Placeholder - oItem.setMimeType("application/octet-stream"); // specific type if known + oItem.setStoredRelativePath("PENDING"); + oItem.setMimeType("application/octet-stream"); oItem.setCreatedAt(OffsetDateTime.now()); oItem = orderItemRepo.save(oItem); - // Update Path now that we have ID String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename; oItem.setStoredRelativePath(relativePath); - // COPY FILE from Quote to Order if (qItem.getStoredPath() != null) { try { Path sourcePath = Paths.get(qItem.getStoredPath()); if (Files.exists(sourcePath)) { storageService.store(sourcePath, Paths.get(relativePath)); - oItem.setFileSizeBytes(Files.size(sourcePath)); } } catch (IOException e) { - e.printStackTrace(); // Log error but allow order creation? Or fail? - // Ideally fail or mark as error + e.printStackTrace(); } } orderItemRepo.save(oItem); - subtotal = subtotal.add(oItem.getLineTotalChf()); } - // Update Order Totals 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) { 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); order.setTotalChf(total); - // Link session session.setConvertedOrderId(order.getId()); - session.setStatus("CONVERTED"); // or CLOSED + session.setStatus("CONVERTED"); quoteSessionRepo.save(session); return ResponseEntity.ok(orderRepo.save(order)); } - // 2. Upload file for Order Item @PostMapping(value = "/{orderId}/items/{orderItemId}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Transactional public ResponseEntity uploadOrderItemFile( @@ -238,31 +212,92 @@ public class OrderController { return ResponseEntity.badRequest().build(); } - // Ensure path logic String relativePath = item.getStoredRelativePath(); 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 storedFilename = UUID.randomUUID().toString() + "." + ext; relativePath = "orders/" + orderId + "/3d-files/" + orderItemId + "/" + storedFilename; item.setStoredRelativePath(relativePath); item.setStoredFilename(storedFilename); - // Update item } - // Save file to disk storageService.store(file, Paths.get(relativePath)); - item.setFileSizeBytes(file.getSize()); item.setMimeType(file.getContentType()); - // Calculate SHA256? (Optional) - orderItemRepo.save(item); return ResponseEntity.ok().build(); } + + @GetMapping("/{orderId}") + public ResponseEntity getOrder(@PathVariable UUID orderId) { + return orderRepo.findById(orderId) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping("/{orderId}/invoice") + public ResponseEntity getInvoice(@PathVariable UUID orderId) { + Order order = orderRepo.findById(orderId) + .orElseThrow(() -> new RuntimeException("Order not found")); + + List items = orderItemRepo.findByOrder_Id(orderId); + + Map 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> invoiceLineItems = items.stream().map(i -> { + Map line = new HashMap<>(); + line.put("description", "Stampa 3D: " + i.getOriginalFilename()); + line.put("quantity", i.getQuantity()); + line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf())); + line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf())); + return line; + }).collect(Collectors.toList()); + + // 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 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 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) { if (filename == null) return "stl"; diff --git a/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java b/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java index b43a305..0068809 100644 --- a/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java @@ -3,7 +3,9 @@ package com.printcalculator.repository; import com.printcalculator.entity.OrderItem; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.UUID; public interface OrderItemRepository extends JpaRepository { + List findByOrder_Id(UUID orderId); } \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java b/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java new file mode 100644 index 0000000..d0e0682 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java @@ -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 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); + } + } +} diff --git a/backend/src/main/resources/templates/invoice.html b/backend/src/main/resources/templates/invoice.html new file mode 100644 index 0000000..dc8a549 --- /dev/null +++ b/backend/src/main/resources/templates/invoice.html @@ -0,0 +1,83 @@ + + + + + + + + +
+
+
Nome Cognome
+
Via Esempio 12
+
6500 Bellinzona, CH
+
email@example.com
+
+ +
+
Fattura
+
Numero: 2026-000123
+
Data: 2026-02-13
+
Scadenza: 2026-02-20
+
+
+ +
+
+
Fatturare a
+
Cliente SA
+
Via Cliente 7
+
8000 Zürich, CH
+
+
+ + + + + + + + + + + + + + + + + + +
DescrizioneQtàPrezzoTotale
Stampa 3D pezzo X1CHF 10.00CHF 10.00
+ + + + + + + + + + +
SubtotaleCHF 10.00
TotaleCHF 10.00
+ +
+ Pagamento entro 7 giorni. Grazie. +
+ + +
+ + + diff --git a/frontend/src/app/features/calculator/calculator-page.component.html b/frontend/src/app/features/calculator/calculator-page.component.html index 813c7f1..cf9b981 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.html +++ b/frontend/src/app/features/calculator/calculator-page.component.html @@ -21,12 +21,12 @@
+ (click)="setMode('easy')"> {{ 'CALC.MODE_EASY' | translate }}
+ (click)="setMode('advanced')"> {{ 'CALC.MODE_ADVANCED' | translate }}
diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts index 8314293..9a57fc7 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.ts @@ -260,4 +260,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' + }); + } } diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index 3dd1ad4..93a5f80 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -144,6 +144,23 @@ export class QuoteEstimatorService { if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); return this.http.post(`${environment.apiUrl}/api/orders/from-quote/${sessionId}`, orderDetails, { headers }); } + + getOrder(orderId: string): Observable { + 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 { + 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 { console.log('QuoteEstimatorService: Calculating quote...', request); diff --git a/frontend/src/app/features/checkout/checkout.component.html b/frontend/src/app/features/checkout/checkout.component.html index 1b8168d..7f7a07b 100644 --- a/frontend/src/app/features/checkout/checkout.component.html +++ b/frontend/src/app/features/checkout/checkout.component.html @@ -1,6 +1,9 @@ -
-

Checkout

- +
+

{{ 'CHECKOUT.TITLE' | translate }}

+

{{ 'CHECKOUT.SUBTITLE' | translate }}

+
+ +
@@ -13,89 +16,88 @@
-
-
-

Contact Information

+ +
+ +
-
- - -
-
- Private -
-
- Company -
-
-
- - +
+
+ {{ 'CHECKOUT.PRIVATE' | translate }} +
+
+ {{ 'CHECKOUT.COMPANY' | translate }}
-
+ +
+
+ +
+ + +
+
+ +
+ + +
+
+ -
-
-

Billing Address

+ +
+

{{ 'CHECKOUT.BILLING_ADDR' | translate }}

-
-
- - -
- - - - - - +
+ +
- - - + + +
-
+
-
-
-

Shipping Address

+ +
+

{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}

-
+
- - + +
- - - + +
- - - + + +
-
+
- {{ isSubmitting() ? 'Processing...' : 'Place Order' }} + {{ (isSubmitting() ? 'CHECKOUT.PROCESSING' : 'CHECKOUT.PLACE_ORDER') | translate }}
@@ -104,47 +106,44 @@
-
-
-

Order Summary

+ +
+

{{ 'CHECKOUT.ORDER_SUMMARY' | translate }}

-
-
-
-
- {{ item.originalFilename }} -
- Qty: {{ item.quantity }} - -
-
- {{ (item.printTimeSeconds / 3600) | number:'1.1-1' }}h | {{ item.materialGrams | number:'1.0-0' }}g -
+
+
+
+ {{ item.originalFilename }} +
+ Qty: {{ item.quantity }} +
-
- {{ (item.unitPriceChf * item.quantity) | currency:'CHF' }} +
+ {{ (item.printTimeSeconds / 3600) | number:'1.1-1' }}h | {{ item.materialGrams | number:'1.0-0' }}g
+
+ {{ (item.unitPriceChf * item.quantity) | currency:'CHF' }} +
+
+
+ +
+
+ {{ 'CHECKOUT.SUBTOTAL' | translate }} + {{ (session.totalPrice - session.setupCostChf) | currency:'CHF' }}
- -
-
- Subtotal - {{ (session.totalPrice - session.setupCostChf) | currency:'CHF' }} -
-
- Setup Fee - {{ session.setupCostChf | currency:'CHF' }} -
-
-
- Total - {{ session.totalPrice | currency:'CHF' }} -
+
+ {{ 'CHECKOUT.SETUP_FEE' | translate }} + {{ session.setupCostChf | currency:'CHF' }} +
+
+ {{ 'CHECKOUT.TOTAL' | translate }} + {{ session.totalPrice | currency:'CHF' }}
-
+
diff --git a/frontend/src/app/features/checkout/checkout.component.scss b/frontend/src/app/features/checkout/checkout.component.scss index c4e5074..b1c3087 100644 --- a/frontend/src/app/features/checkout/checkout.component.scss +++ b/frontend/src/app/features/checkout/checkout.component.scss @@ -1,86 +1,86 @@ -.checkout-page { - padding: 3rem 1rem; - max-width: 1200px; - margin: 0 auto; +.hero { + padding: var(--space-12) 0 var(--space-8); + 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; } .checkout-layout { display: grid; - grid-template-columns: 1fr 380px; + grid-template-columns: 1fr 400px; gap: var(--space-8); align-items: start; + margin-bottom: var(--space-12); - @media (max-width: 900px) { + @media (max-width: 1024px) { grid-template-columns: 1fr; - gap: var(--space-6); + gap: var(--space-8); } } -.section-title { - font-size: 2rem; - font-weight: 700; +.card-header-simple { margin-bottom: var(--space-6); - color: var(--color-heading); -} - -.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); - background: var(--color-bg-subtle); - - h3 { - font-size: 1.1rem; - font-weight: 600; - color: var(--color-heading); - margin: 0; - } - } - - .card-content { - padding: var(--space-6); + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--color-border); + + h3 { + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text); + margin: 0; } } .form-row { display: flex; + flex-direction: column; gap: var(--space-4); margin-bottom: var(--space-4); + @media(min-width: 768px) { + flex-direction: row; + & > * { flex: 1; } + } + + &.no-margin { + margin-bottom: 0; + } + &.three-cols { display: grid; - grid-template-columns: 1fr 2fr 1fr; + grid-template-columns: 1.5fr 2fr 1fr; gap: var(--space-4); - } - - app-input { - flex: 1; - width: 100%; - } - - @media (max-width: 600px) { - flex-direction: column; - &.three-cols { + + @media (max-width: 768px) { grid-template-columns: 1fr; } } + + app-input { + width: 100%; + } } -/* User Type Selector Styles */ +/* User Type Selector - Matching Contact Form Style */ .user-type-selector { display: flex; - background-color: var(--color-bg-subtle); + background-color: var(--color-neutral-100); border-radius: var(--radius-md); padding: 4px; margin-bottom: var(--space-4); gap: 4px; width: 100%; + max-width: 400px; } .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 { margin: var(--space-6) 0; + padding: var(--space-4); + background: var(--color-neutral-100); + border-radius: var(--radius-md); } /* Custom Checkbox */ @@ -114,9 +126,10 @@ display: flex; align-items: center; position: relative; - padding-left: 30px; + padding-left: 36px; cursor: pointer; font-size: 1rem; + font-weight: 500; user-select: none; color: var(--color-text); @@ -142,10 +155,10 @@ top: 50%; left: 0; transform: translateY(-50%); - height: 20px; - width: 20px; - background-color: var(--color-bg-surface); - border: 1px solid var(--color-border); + height: 24px; + width: 24px; + background-color: var(--color-bg-card); + border: 2px solid var(--color-border); border-radius: var(--radius-sm); transition: all 0.2s; @@ -153,12 +166,12 @@ content: ""; position: absolute; display: none; - left: 6px; - top: 2px; + left: 7px; + top: 3px; width: 6px; height: 12px; - border: solid white; - border-width: 0 2px 2px 0; + border: solid #000; + border-width: 0 2.5px 2.5px 0; transform: rotate(45deg); } } @@ -168,40 +181,47 @@ } } - .checkout-summary-section { position: relative; } .sticky-card { position: sticky; - top: 0; - /* Inherits styles from .form-card */ + top: var(--space-6); } .summary-items { margin-bottom: var(--space-6); - max-height: 400px; + max-height: 450px; overflow-y: auto; + padding-right: var(--space-2); + + &::-webkit-scrollbar { + width: 4px; + } + &::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 4px; + } } .summary-item { display: flex; justify-content: space-between; align-items: flex-start; - padding: var(--space-3) 0; + padding: var(--space-4) 0; border-bottom: 1px solid var(--color-border); - &:last-child { - border-bottom: none; - } + &:first-child { padding-top: 0; } + &:last-child { border-bottom: none; } .item-details { flex: 1; .item-name { display: block; - font-weight: 500; + font-weight: 600; + font-size: 0.95rem; margin-bottom: var(--space-1); word-break: break-all; color: var(--color-text); @@ -215,8 +235,8 @@ color: var(--color-text-muted); .color-dot { - width: 12px; - height: 12px; + width: 14px; + height: 14px; border-radius: 50%; display: inline-block; border: 1px solid var(--color-border); @@ -234,13 +254,13 @@ font-weight: 600; margin-left: var(--space-3); white-space: nowrap; - color: var(--color-heading); + color: var(--color-text); } } .summary-totals { - background: var(--color-bg-subtle); - padding: var(--space-4); + background: var(--color-neutral-100); + padding: var(--space-6); border-radius: var(--radius-md); margin-top: var(--space-4); @@ -248,45 +268,39 @@ display: flex; justify-content: space-between; margin-bottom: var(--space-2); - color: var(--color-text); - - &.grand-total { - color: var(--color-heading); - font-weight: 700; - font-size: 1.25rem; - margin-top: var(--space-3); - padding-top: var(--space-3); - border-top: 1px solid var(--color-border); - margin-bottom: 0; - } + font-size: 0.95rem; + color: var(--color-text-muted); } - .divider { - display: none; // Handled by border-top in grand-total + .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-6); - display: flex; - justify-content: flex-end; + margin-top: var(--space-8); app-button { width: 100%; - - @media (min-width: 900px) { - width: auto; - min-width: 200px; - } } } .error-message { - color: var(--color-danger); - background: var(--color-danger-subtle); + color: var(--color-error); + background: #fef2f2; padding: var(--space-4); border-radius: var(--radius-md); 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); } + diff --git a/frontend/src/app/features/checkout/checkout.component.ts b/frontend/src/app/features/checkout/checkout.component.ts index 8336061..0f2c369 100644 --- a/frontend/src/app/features/checkout/checkout.component.ts +++ b/frontend/src/app/features/checkout/checkout.component.ts @@ -2,9 +2,11 @@ import { Component, inject, OnInit, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { Router, ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service'; import { AppInputComponent } from '../../shared/components/app-input/app-input.component'; import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; +import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; @Component({ selector: 'app-checkout', @@ -12,11 +14,13 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto imports: [ CommonModule, ReactiveFormsModule, + TranslateModule, AppInputComponent, - AppButtonComponent + AppButtonComponent, + AppCardComponent ], templateUrl: './checkout.component.html', - styleUrls: ['./checkout.component.scss'] + styleUrl: './checkout.component.scss' }) export class CheckoutComponent implements OnInit { private fb = inject(FormBuilder); diff --git a/frontend/src/app/features/payment/payment.component.html b/frontend/src/app/features/payment/payment.component.html index 733e839..2a1d74a 100644 --- a/frontend/src/app/features/payment/payment.component.html +++ b/frontend/src/app/features/payment/payment.component.html @@ -1,21 +1,113 @@ -
- - - payment - Payment Integration - Order #{{ orderId }} - - -
-

Coming Soon

-

The online payment system is currently under development.

-

Your order has been saved. Please contact us to arrange payment.

-
-
- - - -
+
+

{{ 'PAYMENT.TITLE' | translate }}

+

{{ 'CHECKOUT.SUBTITLE' | translate }}

+
+ +
+
+ +
+ +
+

{{ 'PAYMENT.METHOD' | translate }}

+
+ +
+
+
+ TWINT +
+
+ QR Bill / Bank Transfer +
+
+
+ + +
+
+

{{ 'PAYMENT.TWINT_TITLE' | translate }}

+
+
+
+ QR CODE +
+

{{ 'PAYMENT.TWINT_DESC' | translate }}

+

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

+
+
+ + +
+
+

{{ 'PAYMENT.BANK_TITLE' | translate }}

+
+
+

{{ 'PAYMENT.BANK_OWNER' | translate }}: 3D Fab Switzerland

+

{{ 'PAYMENT.BANK_IBAN' | translate }}: CH98 0000 0000 0000 0000 0

+

{{ 'PAYMENT.BANK_REF' | translate }}: {{ o.id }}

+ +
+ + {{ 'PAYMENT.DOWNLOAD_QR' | translate }} + +
+
+
+ +
+ + {{ 'PAYMENT.CONFIRM' | translate }} + +
+
+
+ +
+ +
+

{{ 'PAYMENT.SUMMARY_TITLE' | translate }}

+

#{{ o.id.substring(0, 8) }}

+
+ +
+
+ {{ 'PAYMENT.SUBTOTAL' | translate }} + {{ o.subtotalChf | currency:'CHF' }} +
+
+ {{ 'PAYMENT.SHIPPING' | translate }} + {{ o.shippingCostChf | currency:'CHF' }} +
+
+ {{ 'PAYMENT.SETUP_FEE' | translate }} + {{ o.setupCostChf | currency:'CHF' }} +
+
+ {{ 'PAYMENT.TOTAL' | translate }} + {{ o.totalChf | currency:'CHF' }} +
+
+
+
+ +
+ +
+ +

{{ 'PAYMENT.LOADING' | translate }}

+
+
+ +
+ +

{{ error() }}

+
+
diff --git a/frontend/src/app/features/payment/payment.component.scss b/frontend/src/app/features/payment/payment.component.scss index d4475db..8c05687 100644 --- a/frontend/src/app/features/payment/payment.component.scss +++ b/frontend/src/app/features/payment/payment.component.scss @@ -1,35 +1,195 @@ -.payment-container { - display: flex; - justify-content: center; - align-items: center; - min-height: 80vh; - padding: 2rem; - background-color: #f5f5f5; +.hero { + padding: var(--space-12) 0 var(--space-8); + text-align: center; + + h1 { + font-size: 2.5rem; + margin-bottom: var(--space-2); + } } -.payment-card { - max-width: 500px; - width: 100%; +.subtitle { + font-size: 1.125rem; + color: var(--color-text-muted); + max-width: 600px; + margin: 0 auto; } -.coming-soon { - text-align: center; - padding: 2rem 0; +.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 { - margin-bottom: 1rem; - color: #555; + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text); + margin: 0; } - - p { - color: #777; - margin-bottom: 0.5rem; + + .order-id { + font-size: 0.875rem; + color: var(--color-text-muted); + margin-top: 2px; } } -mat-icon { - font-size: 40px; - width: 40px; - height: 40px; - color: #3f51b5; +.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 { + margin-bottom: var(--space-2); + font-size: 1rem; + color: var(--color-text); + } +} + +.qr-bill-actions { + margin-top: var(--space-4); +} + +.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; } diff --git a/frontend/src/app/features/payment/payment.component.ts b/frontend/src/app/features/payment/payment.component.ts index 671a36e..98fdbf8 100644 --- a/frontend/src/app/features/payment/payment.component.ts +++ b/frontend/src/app/features/payment/payment.component.ts @@ -1,34 +1,76 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; -import { MatButtonModule } from '@angular/material/button'; -import { MatCardModule } from '@angular/material/card'; -import { MatIconModule } from '@angular/material/icon'; +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'; +import { TranslateModule } from '@ngx-translate/core'; @Component({ selector: 'app-payment', standalone: true, - imports: [CommonModule, MatButtonModule, MatCardModule, MatIconModule], + imports: [CommonModule, AppButtonComponent, AppCardComponent, TranslateModule], templateUrl: './payment.component.html', styleUrl: './payment.component.scss' }) export class PaymentComponent implements OnInit { - orderId: string | null = null; + private route = inject(ActivatedRoute); + private router = inject(Router); + private quoteService = inject(QuoteEstimatorService); - constructor( - private route: ActivatedRoute, - private router: Router - ) {} + orderId: string | null = null; + selectedPaymentMethod: 'twint' | 'bill' | null = null; + order = signal(null); + loading = signal(true); + error = signal(null); ngOnInit(): void { 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 { // Simulate payment completion 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(['/']); } } diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index 6089657..82850d4 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -148,5 +148,49 @@ "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.", "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..." } } diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index b160b15..82b1581 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -127,5 +127,49 @@ "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" + }, + "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..." } }