From 044fba8d5a4b4ec156fadd6f4d7424ca3dc56a1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 12 Feb 2026 19:01:48 +0100 Subject: [PATCH] feat(back-end & front-end): checkout, update form structure, add new DTOs, refactor order logic --- .../printcalculator/BackendApplication.java | 2 + .../CustomQuoteRequestController.java | 1 - .../controller/OrderController.java | 110 +++---- .../controller/QuoteSessionController.java | 30 +- .../com/printcalculator/dto/AddressDto.java | 16 + .../dto/CreateOrderRequest.java | 11 + .../com/printcalculator/dto/CustomerDto.java | 10 + .../printcalculator/entity/QuoteLineItem.java | 12 + frontend/src/app/app.routes.ts | 4 + .../calculator/calculator-page.component.html | 10 +- .../calculator/calculator-page.component.ts | 26 +- .../quote-result/quote-result.component.ts | 3 +- .../services/quote-estimator.service.ts | 33 +- .../features/checkout/checkout.component.html | 151 +++++++++ .../features/checkout/checkout.component.scss | 292 ++++++++++++++++++ .../features/checkout/checkout.component.ts | 187 +++++++++++ .../app-input/app-input.component.scss | 2 +- 17 files changed, 813 insertions(+), 87 deletions(-) create mode 100644 backend/src/main/java/com/printcalculator/dto/AddressDto.java create mode 100644 backend/src/main/java/com/printcalculator/dto/CreateOrderRequest.java create mode 100644 backend/src/main/java/com/printcalculator/dto/CustomerDto.java create mode 100644 frontend/src/app/features/checkout/checkout.component.html create mode 100644 frontend/src/app/features/checkout/checkout.component.scss create mode 100644 frontend/src/app/features/checkout/checkout.component.ts diff --git a/backend/src/main/java/com/printcalculator/BackendApplication.java b/backend/src/main/java/com/printcalculator/BackendApplication.java index f8209a7..7ade0a3 100644 --- a/backend/src/main/java/com/printcalculator/BackendApplication.java +++ b/backend/src/main/java/com/printcalculator/BackendApplication.java @@ -2,8 +2,10 @@ package com.printcalculator; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.transaction.annotation.EnableTransactionManagement; @SpringBootApplication +@EnableTransactionManagement public class BackendApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java index 1d25df6..e67de4f 100644 --- a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java +++ b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java @@ -21,7 +21,6 @@ import java.util.UUID; @RestController @RequestMapping("/api/custom-quote-requests") -@CrossOrigin(origins = "*") public class CustomQuoteRequestController { private final CustomQuoteRequestRepository requestRepo; diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java index 763ccb7..c546d1a 100644 --- a/backend/src/main/java/com/printcalculator/controller/OrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -21,7 +21,6 @@ import java.util.Optional; @RestController @RequestMapping("/api/orders") -@CrossOrigin(origins = "*") public class OrderController { private final OrderRepository orderRepo; @@ -45,38 +44,13 @@ public class OrderController { this.customerRepo = customerRepo; } - // DTOs - public static class CreateOrderRequest { - public CustomerDto customer; - public AddressDto billingAddress; - public AddressDto shippingAddress; - public boolean shippingSameAsBilling; - } - - public static class CustomerDto { - public String email; - public String phone; - public String customerType; // "PRIVATE", "BUSINESS" - } - - public static class AddressDto { - public String firstName; - public String lastName; - public String companyName; - public String contactPerson; - public String addressLine1; - public String addressLine2; - public String zip; - public String city; - public String countryCode; - } // 1. Create Order from Quote @PostMapping("/from-quote/{quoteSessionId}") @Transactional public ResponseEntity createOrderFromQuote( @PathVariable UUID quoteSessionId, - @RequestBody CreateOrderRequest request + @RequestBody com.printcalculator.dto.CreateOrderRequest request ) { // 1. Fetch Quote Session QuoteSession session = quoteSessionRepo.findById(quoteSessionId) @@ -91,16 +65,16 @@ public class OrderController { } // 2. Handle Customer (Find or Create) - Customer customer = customerRepo.findByEmail(request.customer.email) + Customer customer = customerRepo.findByEmail(request.getCustomer().getEmail()) .orElseGet(() -> { Customer newC = new Customer(); - newC.setEmail(request.customer.email); + newC.setEmail(request.getCustomer().getEmail()); newC.setCreatedAt(OffsetDateTime.now()); return customerRepo.save(newC); }); // Update customer details? - customer.setPhone(request.customer.phone); - customer.setCustomerType(request.customer.customerType); + customer.setPhone(request.getCustomer().getPhone()); + customer.setCustomerType(request.getCustomer().getCustomerType()); customer.setUpdatedAt(OffsetDateTime.now()); customerRepo.save(customer); @@ -108,39 +82,39 @@ public class OrderController { Order order = new Order(); order.setSourceQuoteSession(session); order.setCustomer(customer); - order.setCustomerEmail(request.customer.email); - order.setCustomerPhone(request.customer.phone); + order.setCustomerEmail(request.getCustomer().getEmail()); + order.setCustomerPhone(request.getCustomer().getPhone()); order.setStatus("PENDING_PAYMENT"); order.setCreatedAt(OffsetDateTime.now()); order.setUpdatedAt(OffsetDateTime.now()); order.setCurrency("CHF"); // Billing - order.setBillingCustomerType(request.customer.customerType); - if (request.billingAddress != null) { - order.setBillingFirstName(request.billingAddress.firstName); - order.setBillingLastName(request.billingAddress.lastName); - order.setBillingCompanyName(request.billingAddress.companyName); - order.setBillingContactPerson(request.billingAddress.contactPerson); - order.setBillingAddressLine1(request.billingAddress.addressLine1); - order.setBillingAddressLine2(request.billingAddress.addressLine2); - order.setBillingZip(request.billingAddress.zip); - order.setBillingCity(request.billingAddress.city); - order.setBillingCountryCode(request.billingAddress.countryCode != null ? request.billingAddress.countryCode : "CH"); + order.setBillingCustomerType(request.getCustomer().getCustomerType()); + if (request.getBillingAddress() != null) { + order.setBillingFirstName(request.getBillingAddress().getFirstName()); + order.setBillingLastName(request.getBillingAddress().getLastName()); + order.setBillingCompanyName(request.getBillingAddress().getCompanyName()); + order.setBillingContactPerson(request.getBillingAddress().getContactPerson()); + order.setBillingAddressLine1(request.getBillingAddress().getAddressLine1()); + order.setBillingAddressLine2(request.getBillingAddress().getAddressLine2()); + order.setBillingZip(request.getBillingAddress().getZip()); + order.setBillingCity(request.getBillingAddress().getCity()); + order.setBillingCountryCode(request.getBillingAddress().getCountryCode() != null ? request.getBillingAddress().getCountryCode() : "CH"); } // Shipping - order.setShippingSameAsBilling(request.shippingSameAsBilling); - if (!request.shippingSameAsBilling && request.shippingAddress != null) { - order.setShippingFirstName(request.shippingAddress.firstName); - order.setShippingLastName(request.shippingAddress.lastName); - order.setShippingCompanyName(request.shippingAddress.companyName); - order.setShippingContactPerson(request.shippingAddress.contactPerson); - order.setShippingAddressLine1(request.shippingAddress.addressLine1); - order.setShippingAddressLine2(request.shippingAddress.addressLine2); - order.setShippingZip(request.shippingAddress.zip); - order.setShippingCity(request.shippingAddress.city); - order.setShippingCountryCode(request.shippingAddress.countryCode != null ? request.shippingAddress.countryCode : "CH"); + order.setShippingSameAsBilling(request.isShippingSameAsBilling()); + if (!request.isShippingSameAsBilling() && request.getShippingAddress() != null) { + order.setShippingFirstName(request.getShippingAddress().getFirstName()); + order.setShippingLastName(request.getShippingAddress().getLastName()); + order.setShippingCompanyName(request.getShippingAddress().getCompanyName()); + order.setShippingContactPerson(request.getShippingAddress().getContactPerson()); + order.setShippingAddressLine1(request.getShippingAddress().getAddressLine1()); + order.setShippingAddressLine2(request.getShippingAddress().getAddressLine2()); + order.setShippingZip(request.getShippingAddress().getZip()); + 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 @@ -185,14 +159,6 @@ public class OrderController { String ext = getExtension(qItem.getOriginalFilename()); String storedFilename = fileUuid.toString() + "." + ext; - // Note: We don't have the orderItemId yet because we haven't saved it. - // We can pre-generate ID or save order item then update path? - // GeneratedValue strategy AUTO might not let us set ID easily? - // Let's save item first with temporary path, then update? - // OR use a path structure that doesn't depend on ItemId? "orders/{orderId}/3d-files/{uuid}.ext" is also fine? - // User requested: "orders/{orderId}/3d-files/{orderItemId}/{uuid}.{ext}" - // So we need OrderItemId. - oItem.setStoredFilename(storedFilename); oItem.setStoredRelativePath("PENDING"); // Placeholder oItem.setMimeType("application/octet-stream"); // specific type if known @@ -202,6 +168,24 @@ public class OrderController { // 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)) { + Path targetPath = Paths.get(STORAGE_ROOT, relativePath); + Files.createDirectories(targetPath.getParent()); + Files.copy(sourcePath, targetPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + + oItem.setFileSizeBytes(Files.size(targetPath)); + } + } catch (IOException e) { + e.printStackTrace(); // Log error but allow order creation? Or fail? + // Ideally fail or mark as error + } + } + orderItemRepo.save(oItem); subtotal = subtotal.add(oItem.getLineTotalChf()); diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index 0aad79f..540405d 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -20,6 +20,7 @@ import java.io.IOException; import java.math.BigDecimal; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.time.OffsetDateTime; import java.util.HashMap; import java.util.List; @@ -96,9 +97,21 @@ public class QuoteSessionController { private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException { if (file.isEmpty()) throw new IOException("File is empty"); - // 1. Save file temporarily - Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename()); - file.transferTo(tempInput.toFile()); + // 1. Define Persistent Storage Path + // Structure: storage_quotes/{sessionId}/{uuid}.{ext} + String storageDir = "storage_quotes/" + session.getId(); + Files.createDirectories(Paths.get(storageDir)); + + String originalFilename = file.getOriginalFilename(); + String ext = originalFilename != null && originalFilename.contains(".") + ? originalFilename.substring(originalFilename.lastIndexOf(".")) + : ".stl"; + + String storedFilename = UUID.randomUUID() + ext; + Path persistentPath = Paths.get(storageDir, storedFilename); + + // Save file + Files.copy(file.getInputStream(), persistentPath); try { // Apply Basic/Advanced Logic @@ -142,9 +155,9 @@ public class QuoteSessionController { if (settings.getInfillDensity() != null) processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%"); if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern()); - // 3. Slice + // 3. Slice (Use persistent path) PrintStats stats = slicerService.slice( - tempInput.toFile(), + persistentPath.toFile(), machineProfile, filamentProfile, processProfile, @@ -159,6 +172,7 @@ public class QuoteSessionController { QuoteLineItem item = new QuoteLineItem(); item.setQuoteSession(session); item.setOriginalFilename(file.getOriginalFilename()); + item.setStoredPath(persistentPath.toString()); // SAVE PATH item.setQuantity(1); item.setColorCode(settings.getColor() != null ? settings.getColor() : "#FFFFFF"); item.setStatus("READY"); // or CALCULATED @@ -188,8 +202,10 @@ public class QuoteSessionController { return lineItemRepo.save(item); - } finally { - Files.deleteIfExists(tempInput); + } catch (Exception e) { + // Cleanup if failed + Files.deleteIfExists(persistentPath); + throw e; } } diff --git a/backend/src/main/java/com/printcalculator/dto/AddressDto.java b/backend/src/main/java/com/printcalculator/dto/AddressDto.java new file mode 100644 index 0000000..3b1c748 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AddressDto.java @@ -0,0 +1,16 @@ +package com.printcalculator.dto; + +import lombok.Data; + +@Data +public class AddressDto { + private String firstName; + private String lastName; + private String companyName; + private String contactPerson; + private String addressLine1; + private String addressLine2; + private String zip; + private String city; + private String countryCode; +} diff --git a/backend/src/main/java/com/printcalculator/dto/CreateOrderRequest.java b/backend/src/main/java/com/printcalculator/dto/CreateOrderRequest.java new file mode 100644 index 0000000..24f5800 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/CreateOrderRequest.java @@ -0,0 +1,11 @@ +package com.printcalculator.dto; + +import lombok.Data; + +@Data +public class CreateOrderRequest { + private CustomerDto customer; + private AddressDto billingAddress; + private AddressDto shippingAddress; + private boolean shippingSameAsBilling; +} diff --git a/backend/src/main/java/com/printcalculator/dto/CustomerDto.java b/backend/src/main/java/com/printcalculator/dto/CustomerDto.java new file mode 100644 index 0000000..432394f --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/CustomerDto.java @@ -0,0 +1,10 @@ +package com.printcalculator.dto; + +import lombok.Data; + +@Data +public class CustomerDto { + private String email; + private String phone; + private String customerType; // "PRIVATE", "BUSINESS" +} diff --git a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java index ec526b3..c29260c 100644 --- a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java +++ b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java @@ -24,6 +24,7 @@ public class QuoteLineItem { @ManyToOne(fetch = FetchType.LAZY, optional = false) @OnDelete(action = OnDeleteAction.CASCADE) @JoinColumn(name = "quote_session_id", nullable = false) + @com.fasterxml.jackson.annotation.JsonIgnore private QuoteSession quoteSession; @Column(name = "status", nullable = false, length = Integer.MAX_VALUE) @@ -64,6 +65,9 @@ public class QuoteLineItem { @Column(name = "error_message", length = Integer.MAX_VALUE) private String errorMessage; + @Column(name = "stored_path", length = Integer.MAX_VALUE) + private String storedPath; + @ColumnDefault("now()") @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; @@ -184,6 +188,14 @@ public class QuoteLineItem { this.errorMessage = errorMessage; } + public String getStoredPath() { + return storedPath; + } + + public void setStoredPath(String storedPath) { + this.storedPath = storedPath; + } + public OffsetDateTime getCreatedAt() { return createdAt; } diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 4f51782..c74044d 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -25,6 +25,10 @@ export const routes: Routes = [ path: 'contact', loadChildren: () => import('./features/contact/contact.routes').then(m => m.CONTACT_ROUTES) }, + { + path: 'checkout', + loadComponent: () => import('./features/checkout/checkout.component').then(m => m.CheckoutComponent) + }, { path: 'payment/:orderId', loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent) diff --git a/frontend/src/app/features/calculator/calculator-page.component.html b/frontend/src/app/features/calculator/calculator-page.component.html index 3de51d9..928f957 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.html +++ b/frontend/src/app/features/calculator/calculator-page.component.html @@ -11,14 +11,6 @@
-} @else if (step() === 'details' && result()) { -
- - -
} @else {
@@ -63,7 +55,7 @@ [result]="result()!" (consult)="onConsult()" (proceed)="onProceed()" - (itemChange)="uploadForm.updateItemQuantityByName($event.fileName, $event.quantity)" + (itemChange)="onItemChange($event)" > } @else { diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts index dc08f1e..4b0e196 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.ts @@ -7,14 +7,13 @@ import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.c import { UploadFormComponent } from './components/upload-form/upload-form.component'; import { QuoteResultComponent } from './components/quote-result/quote-result.component'; import { QuoteRequest, QuoteResult, QuoteEstimatorService } from './services/quote-estimator.service'; -import { UserDetailsComponent } from './components/user-details/user-details.component'; import { SuccessStateComponent } from '../../shared/components/success-state/success-state.component'; import { Router, ActivatedRoute } from '@angular/router'; @Component({ selector: 'app-calculator-page', standalone: true, - imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, UserDetailsComponent, SuccessStateComponent], + imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, SuccessStateComponent], templateUrl: './calculator-page.component.html', styleUrl: './calculator-page.component.scss' }) @@ -82,13 +81,34 @@ export class CalculatorPageComponent implements OnInit { } onProceed() { - this.step.set('details'); + const res = this.result(); + if (res && res.sessionId) { + this.router.navigate(['/checkout'], { queryParams: { session: res.sessionId } }); + } else { + console.error('No session ID found in quote result'); + // Fallback or error handling + } } onCancelDetails() { this.step.set('quote'); } + onItemChange(event: {id?: string, fileName: string, quantity: number}) { + // 1. Update local form for consistency (UI feedback) + if (this.uploadForm) { + this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity); + } + + // 2. Update backend session if ID exists + if (event.id) { + this.estimator.updateLineItem(event.id, { quantity: event.quantity }).subscribe({ + next: (res) => console.log('Line item updated', res), + error: (err) => console.error('Failed to update line item', err) + }); + } + } + onSubmitOrder(orderData: any) { console.log('Order Submitted:', orderData); this.orderSuccess.set(true); diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts index daeb3cd..8d3e193 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts @@ -18,7 +18,7 @@ export class QuoteResultComponent { result = input.required(); consult = output(); proceed = output(); - itemChange = output<{fileName: string, quantity: number}>(); + itemChange = output<{id?: string, fileName: string, quantity: number}>(); // Local mutable state for items to handle quantity changes items = signal([]); @@ -42,6 +42,7 @@ export class QuoteResultComponent { }); this.itemChange.emit({ + id: this.items()[index].id, fileName: this.items()[index].fileName, quantity: qty }); 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 5298e17..496ab32 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -18,6 +18,7 @@ export interface QuoteRequest { } export interface QuoteItem { + id?: string; fileName: string; unitPrice: number; unitTime: number; // seconds @@ -28,6 +29,7 @@ export interface QuoteItem { } export interface QuoteResult { + sessionId?: string; items: QuoteItem[]; setupCost: number; currency: string; @@ -119,6 +121,29 @@ export class QuoteEstimatorService { }) ); } + + // NEW METHODS for Order Flow + + getQuoteSession(sessionId: string): Observable { + const headers: any = {}; + // @ts-ignore + if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); + return this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { headers }); + } + + updateLineItem(lineItemId: string, changes: any): Observable { + const headers: any = {}; + // @ts-ignore + if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); + return this.http.patch(`${environment.apiUrl}/api/quote-sessions/line-items/${lineItemId}`, changes, { headers }); + } + + createOrder(sessionId: string, orderDetails: any): Observable { + const headers: any = {}; + // @ts-ignore + if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); + return this.http.post(`${environment.apiUrl}/api/orders/from-quote/${sessionId}`, orderDetails, { headers }); + } calculate(request: QuoteRequest): Observable { console.log('QuoteEstimatorService: Calculating quote...', request); @@ -149,7 +174,7 @@ export class QuoteEstimatorService { observer.next(avg); if (completedRequests === totalItems) { - finalize(finalResponses, sessionSetupCost); + finalize(finalResponses, sessionSetupCost, sessionId); } }; @@ -203,7 +228,7 @@ export class QuoteEstimatorService { } }); - const finalize = (responses: any[], setupCost: number) => { + const finalize = (responses: any[], setupCost: number, sessionId: string) => { observer.next(100); const items: QuoteItem[] = []; let grandTotal = 0; @@ -219,6 +244,7 @@ export class QuoteEstimatorService { const quantity = res.originalQty || 1; items.push({ + id: res.id, fileName: res.fileName, unitPrice: unitPrice, unitTime: res.printTimeSeconds || 0, @@ -226,6 +252,8 @@ export class QuoteEstimatorService { quantity: quantity, material: request.material, color: res.originalItem.color || 'Default' + // Store ID if needed for updates? QuoteItem interface might need update + // or we map it in component }); grandTotal += unitPrice * quantity; @@ -241,6 +269,7 @@ export class QuoteEstimatorService { grandTotal += setupCost; const result: QuoteResult = { + sessionId: sessionId, items, setupCost: setupCost, currency: 'CHF', diff --git a/frontend/src/app/features/checkout/checkout.component.html b/frontend/src/app/features/checkout/checkout.component.html new file mode 100644 index 0000000..1b8168d --- /dev/null +++ b/frontend/src/app/features/checkout/checkout.component.html @@ -0,0 +1,151 @@ +
+

Checkout

+ +
+ + +
+ +
+ {{ error }} +
+ +
+ + +
+
+

Contact Information

+
+
+ + +
+
+ Private +
+
+ Company +
+
+ +
+ + +
+
+
+ + +
+
+

Billing Address

+
+
+
+ + +
+ + + + + + + +
+ + + +
+
+
+ + +
+ +
+ + +
+
+

Shipping Address

+
+
+
+ + +
+ + + + + +
+ + + +
+
+
+ +
+ + {{ isSubmitting() ? 'Processing...' : 'Place Order' }} + +
+ +
+
+ + +
+
+
+

Order Summary

+
+ +
+
+
+
+ {{ item.originalFilename }} +
+ Qty: {{ item.quantity }} + +
+
+ {{ (item.printTimeSeconds / 3600) | number:'1.1-1' }}h | {{ item.materialGrams | number:'1.0-0' }}g +
+
+
+ {{ (item.unitPriceChf * item.quantity) | currency:'CHF' }} +
+
+
+ +
+
+ Subtotal + {{ (session.totalPrice - session.setupCostChf) | currency:'CHF' }} +
+
+ Setup Fee + {{ session.setupCostChf | currency:'CHF' }} +
+
+
+ Total + {{ session.totalPrice | currency:'CHF' }} +
+
+
+
+
+ +
+
diff --git a/frontend/src/app/features/checkout/checkout.component.scss b/frontend/src/app/features/checkout/checkout.component.scss new file mode 100644 index 0000000..c4e5074 --- /dev/null +++ b/frontend/src/app/features/checkout/checkout.component.scss @@ -0,0 +1,292 @@ +.checkout-page { + padding: 3rem 1rem; + max-width: 1200px; + margin: 0 auto; +} + +.checkout-layout { + display: grid; + grid-template-columns: 1fr 380px; + gap: var(--space-8); + align-items: start; + + @media (max-width: 900px) { + grid-template-columns: 1fr; + gap: var(--space-6); + } +} + +.section-title { + font-size: 2rem; + font-weight: 700; + 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); + } +} + +.form-row { + display: flex; + gap: var(--space-4); + margin-bottom: var(--space-4); + + &.three-cols { + display: grid; + grid-template-columns: 1fr 2fr 1fr; + gap: var(--space-4); + } + + app-input { + flex: 1; + width: 100%; + } + + @media (max-width: 600px) { + flex-direction: column; + &.three-cols { + grid-template-columns: 1fr; + } + } +} + +/* User Type Selector Styles */ +.user-type-selector { + display: flex; + background-color: var(--color-bg-subtle); + border-radius: var(--radius-md); + padding: 4px; + margin-bottom: var(--space-4); + gap: 4px; + width: 100%; +} + +.type-option { + flex: 1; + text-align: center; + padding: 8px 16px; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text-muted); + transition: all 0.2s ease; + user-select: none; + + &:hover { color: var(--color-text); } + + &.selected { + background-color: var(--color-brand); + color: #000; + font-weight: 600; + box-shadow: 0 1px 2px rgba(0,0,0,0.05); + } +} + +.shipping-option { + margin: var(--space-6) 0; +} + +/* Custom Checkbox */ +.checkbox-container { + display: flex; + align-items: center; + position: relative; + padding-left: 30px; + cursor: pointer; + font-size: 1rem; + user-select: none; + color: var(--color-text); + + input { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; + + &:checked ~ .checkmark { + background-color: var(--color-brand); + border-color: var(--color-brand); + + &:after { + display: block; + } + } + } + + .checkmark { + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); + height: 20px; + width: 20px; + background-color: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + transition: all 0.2s; + + &:after { + content: ""; + position: absolute; + display: none; + left: 6px; + top: 2px; + width: 6px; + height: 12px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } + } + + &:hover input ~ .checkmark { + border-color: var(--color-brand); + } +} + + +.checkout-summary-section { + position: relative; +} + +.sticky-card { + position: sticky; + top: 0; + /* Inherits styles from .form-card */ +} + +.summary-items { + margin-bottom: var(--space-6); + max-height: 400px; + overflow-y: auto; +} + +.summary-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: var(--space-3) 0; + border-bottom: 1px solid var(--color-border); + + &:last-child { + border-bottom: none; + } + + .item-details { + flex: 1; + + .item-name { + display: block; + font-weight: 500; + margin-bottom: var(--space-1); + word-break: break-all; + color: var(--color-text); + } + + .item-specs { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 0.85rem; + color: var(--color-text-muted); + + .color-dot { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; + border: 1px solid var(--color-border); + } + } + + .item-specs-sub { + font-size: 0.8rem; + color: var(--color-text-muted); + margin-top: 2px; + } + } + + .item-price { + font-weight: 600; + margin-left: var(--space-3); + white-space: nowrap; + color: var(--color-heading); + } +} + +.summary-totals { + background: var(--color-bg-subtle); + padding: var(--space-4); + border-radius: var(--radius-md); + margin-top: var(--space-4); + + .total-row { + 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; + } + } + + .divider { + display: none; // Handled by border-top in grand-total + } +} + +.actions { + margin-top: var(--space-6); + display: flex; + justify-content: flex-end; + + app-button { + width: 100%; + + @media (min-width: 900px) { + width: auto; + min-width: 200px; + } + } +} + +.error-message { + color: var(--color-danger); + background: var(--color-danger-subtle); + padding: var(--space-4); + border-radius: var(--radius-md); + margin-bottom: var(--space-6); + border: 1px solid var(--color-danger); +} + diff --git a/frontend/src/app/features/checkout/checkout.component.ts b/frontend/src/app/features/checkout/checkout.component.ts new file mode 100644 index 0000000..8336061 --- /dev/null +++ b/frontend/src/app/features/checkout/checkout.component.ts @@ -0,0 +1,187 @@ +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 { 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'; + +@Component({ + selector: 'app-checkout', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + AppInputComponent, + AppButtonComponent + ], + templateUrl: './checkout.component.html', + styleUrls: ['./checkout.component.scss'] +}) +export class CheckoutComponent implements OnInit { + private fb = inject(FormBuilder); + private quoteService = inject(QuoteEstimatorService); + private router = inject(Router); + private route = inject(ActivatedRoute); + + checkoutForm: FormGroup; + sessionId: string | null = null; + loading = false; + error: string | null = null; + isSubmitting = signal(false); // Add signal for submit state + quoteSession = signal(null); // Add signal for session details + + constructor() { + this.checkoutForm = this.fb.group({ + email: ['', [Validators.required, Validators.email]], + phone: ['', Validators.required], + customerType: ['PRIVATE', Validators.required], // Default to PRIVATE + + shippingSameAsBilling: [true], + + billingAddress: this.fb.group({ + firstName: ['', Validators.required], + lastName: ['', Validators.required], + companyName: [''], + addressLine1: ['', Validators.required], + addressLine2: [''], + zip: ['', Validators.required], + city: ['', Validators.required], + countryCode: ['CH', Validators.required] + }), + + shippingAddress: this.fb.group({ + firstName: [''], + lastName: [''], + companyName: [''], + addressLine1: [''], + addressLine2: [''], + zip: [''], + city: [''], + countryCode: ['CH'] + }) + }); + } + + get isCompany(): boolean { + return this.checkoutForm.get('customerType')?.value === 'BUSINESS'; + } + + setCustomerType(isCompany: boolean) { + const type = isCompany ? 'BUSINESS' : 'PRIVATE'; + this.checkoutForm.patchValue({ customerType: type }); + + // Update validators based on type + const billingGroup = this.checkoutForm.get('billingAddress') as FormGroup; + const companyControl = billingGroup.get('companyName'); + + if (isCompany) { + companyControl?.setValidators([Validators.required]); + } else { + companyControl?.clearValidators(); + } + companyControl?.updateValueAndValidity(); + } + + ngOnInit(): void { + this.route.queryParams.subscribe(params => { + this.sessionId = params['session']; + if (!this.sessionId) { + this.error = 'No active session found. Please start a new quote.'; + this.router.navigate(['/']); // Redirect if no session + return; + } + + this.loadSessionDetails(); + }); + + // Toggle shipping validation based on checkbox + this.checkoutForm.get('shippingSameAsBilling')?.valueChanges.subscribe(isSame => { + const shippingGroup = this.checkoutForm.get('shippingAddress') as FormGroup; + if (isSame) { + shippingGroup.disable(); + } else { + shippingGroup.enable(); + } + }); + + // Initial state + this.checkoutForm.get('shippingAddress')?.disable(); + } + + loadSessionDetails() { + if (!this.sessionId) return; // Ensure sessionId is present before fetching + this.quoteService.getQuoteSession(this.sessionId).subscribe({ + next: (session) => { + this.quoteSession.set(session); + console.log('Loaded session:', session); + }, + error: (err) => { + console.error('Failed to load session', err); + this.error = 'Failed to load session details. Please try again.'; + } + }); + } + + onSubmit() { + if (this.checkoutForm.invalid) { + return; + } + + this.isSubmitting.set(true); + this.error = null; // Clear previous errors + const formVal = this.checkoutForm.getRawValue(); // Use getRawValue to include disabled fields + + // Construct request object matching backend DTO based on original form structure + const orderRequest = { + customer: { + email: formVal.email, + phone: formVal.phone, + customerType: formVal.customerType, + // Assuming firstName, lastName, companyName for customer come from billingAddress if not explicitly in contact group + firstName: formVal.billingAddress.firstName, + lastName: formVal.billingAddress.lastName, + companyName: formVal.billingAddress.companyName + }, + billingAddress: { + firstName: formVal.billingAddress.firstName, + lastName: formVal.billingAddress.lastName, + companyName: formVal.billingAddress.companyName, + addressLine1: formVal.billingAddress.addressLine1, + addressLine2: formVal.billingAddress.addressLine2, + zip: formVal.billingAddress.zip, + city: formVal.billingAddress.city, + countryCode: formVal.billingAddress.countryCode + }, + shippingAddress: formVal.shippingSameAsBilling ? null : { + firstName: formVal.shippingAddress.firstName, + lastName: formVal.shippingAddress.lastName, + companyName: formVal.shippingAddress.companyName, + addressLine1: formVal.shippingAddress.addressLine1, + addressLine2: formVal.shippingAddress.addressLine2, + zip: formVal.shippingAddress.zip, + city: formVal.shippingAddress.city, + countryCode: formVal.shippingAddress.countryCode + }, + shippingSameAsBilling: formVal.shippingSameAsBilling + }; + + if (!this.sessionId) { + this.error = 'No active session found. Cannot create order.'; + this.isSubmitting.set(false); + return; + } + + this.quoteService.createOrder(this.sessionId, orderRequest).subscribe({ + next: (order) => { + console.log('Order created', order); + this.router.navigate(['/payment', order.id]); + }, + error: (err) => { + console.error('Order creation failed', err); + this.isSubmitting.set(false); + this.error = 'Failed to create order. Please try again.'; + } + }); + } +} diff --git a/frontend/src/app/shared/components/app-input/app-input.component.scss b/frontend/src/app/shared/components/app-input/app-input.component.scss index 18eb1d7..e5de85e 100644 --- a/frontend/src/app/shared/components/app-input/app-input.component.scss +++ b/frontend/src/app/shared/components/app-input/app-input.component.scss @@ -1,6 +1,6 @@ .form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); } label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); } -.required-mark { color: var(--color-danger-500); margin-left: 2px; } +.required-mark { color: var(--color-text); margin-left: 2px; } .form-control { padding: 0.5rem 0.75rem; border: 1px solid var(--color-border);