diff --git a/backend/src/main/java/com/printcalculator/entity/OrderItem.java b/backend/src/main/java/com/printcalculator/entity/OrderItem.java index 2fa952d..287efcc 100644 --- a/backend/src/main/java/com/printcalculator/entity/OrderItem.java +++ b/backend/src/main/java/com/printcalculator/entity/OrderItem.java @@ -67,6 +67,16 @@ public class OrderItem { @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; + @PrePersist + private void onCreate() { + if (createdAt == null) { + createdAt = OffsetDateTime.now(); + } + if (quantity == null) { + quantity = 1; + } + } + public UUID getId() { return id; } @@ -195,4 +205,4 @@ public class OrderItem { this.createdAt = createdAt; } -} \ No newline at end of file +} diff --git a/frontend/src/app/features/checkout/checkout.component.html b/frontend/src/app/features/checkout/checkout.component.html index 1b8168d..bc21c61 100644 --- a/frontend/src/app/features/checkout/checkout.component.html +++ b/frontend/src/app/features/checkout/checkout.component.html @@ -1,5 +1,5 @@
-

Checkout

+

{{ 'CHECKOUT.TITLE' | translate }}

@@ -15,23 +15,12 @@
-

Contact Information

+

{{ 'CHECKOUT.CONTACT_INFO' | translate }}

- - -
-
- Private -
-
- Company -
-
-
- - + +
@@ -39,24 +28,37 @@
-

Billing Address

+

{{ 'CHECKOUT.BILLING_ADDR' | translate }}

- - + +
- - - - - + +
- - - + + + +
+ + +
+
+ {{ 'CONTACT.TYPE_PRIVATE' | translate }} +
+
+ {{ 'CONTACT.TYPE_COMPANY' | translate }} +
+
+ + +
+ +
@@ -66,36 +68,39 @@
-

Shipping Address

+

{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}

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

Order Summary

+

{{ 'CHECKOUT.SUMMARY_TITLE' | translate }}

@@ -115,7 +120,7 @@
{{ item.originalFilename }}
- Qty: {{ item.quantity }} + {{ 'CHECKOUT.QTY' | translate }}: {{ item.quantity }}
@@ -130,17 +135,17 @@
- Subtotal - {{ (session.totalPrice - session.setupCostChf) | currency:'CHF' }} + {{ 'CHECKOUT.SUBTOTAL' | translate }} + {{ session.itemsTotalChf | currency:'CHF' }}
- Setup Fee - {{ session.setupCostChf | currency:'CHF' }} + {{ 'CHECKOUT.SETUP_FEE' | translate }} + {{ session.session.setupCostChf | currency:'CHF' }}
- Total - {{ session.totalPrice | currency:'CHF' }} + {{ 'CHECKOUT.TOTAL' | translate }} + {{ session.grandTotalChf | currency:'CHF' }}
diff --git a/frontend/src/app/features/checkout/checkout.component.scss b/frontend/src/app/features/checkout/checkout.component.scss index c4e5074..f2a6741 100644 --- a/frontend/src/app/features/checkout/checkout.component.scss +++ b/frontend/src/app/features/checkout/checkout.component.scss @@ -72,15 +72,16 @@ } } -/* User Type Selector Styles */ +/* User Type Selector Styles - Matched with Contact Form */ .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,6 +106,16 @@ } } +.company-fields { + display: flex; + flex-direction: column; + gap: var(--space-4); + padding-left: var(--space-4); + border-left: 2px solid var(--color-border); + margin-top: var(--space-4); + margin-bottom: var(--space-4); +} + .shipping-option { margin: var(--space-6) 0; } @@ -239,9 +250,8 @@ } .summary-totals { - background: var(--color-bg-subtle); - padding: var(--space-4); - border-radius: var(--radius-md); + padding-top: var(--space-4); + border-top: 1px solid var(--color-border); margin-top: var(--space-4); .total-row { @@ -249,21 +259,18 @@ justify-content: space-between; margin-bottom: var(--space-2); color: var(--color-text); + font-size: 0.95rem; &.grand-total { color: var(--color-heading); font-weight: 700; font-size: 1.25rem; - margin-top: var(--space-3); - padding-top: var(--space-3); + margin-top: var(--space-4); + padding-top: var(--space-4); border-top: 1px solid var(--color-border); margin-bottom: 0; } } - - .divider { - display: none; // Handled by border-top in grand-total - } } .actions { diff --git a/frontend/src/app/features/checkout/checkout.component.ts b/frontend/src/app/features/checkout/checkout.component.ts index 8336061..84a5ccf 100644 --- a/frontend/src/app/features/checkout/checkout.component.ts +++ b/frontend/src/app/features/checkout/checkout.component.ts @@ -2,6 +2,7 @@ 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'; @@ -12,6 +13,7 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto imports: [ CommonModule, ReactiveFormsModule, + TranslateModule, AppInputComponent, AppButtonComponent ], @@ -43,6 +45,7 @@ export class CheckoutComponent implements OnInit { firstName: ['', Validators.required], lastName: ['', Validators.required], companyName: [''], + referencePerson: [''], addressLine1: ['', Validators.required], addressLine2: [''], zip: ['', Validators.required], @@ -54,6 +57,7 @@ export class CheckoutComponent implements OnInit { firstName: [''], lastName: [''], companyName: [''], + referencePerson: [''], addressLine1: [''], addressLine2: [''], zip: [''], @@ -74,13 +78,17 @@ export class CheckoutComponent implements OnInit { // Update validators based on type const billingGroup = this.checkoutForm.get('billingAddress') as FormGroup; const companyControl = billingGroup.get('companyName'); + const referenceControl = billingGroup.get('referencePerson'); if (isCompany) { companyControl?.setValidators([Validators.required]); + referenceControl?.setValidators([Validators.required]); } else { companyControl?.clearValidators(); + referenceControl?.clearValidators(); } companyControl?.updateValueAndValidity(); + referenceControl?.updateValueAndValidity(); } ngOnInit(): void { @@ -147,6 +155,7 @@ export class CheckoutComponent implements OnInit { firstName: formVal.billingAddress.firstName, lastName: formVal.billingAddress.lastName, companyName: formVal.billingAddress.companyName, + contactPerson: formVal.billingAddress.referencePerson, addressLine1: formVal.billingAddress.addressLine1, addressLine2: formVal.billingAddress.addressLine2, zip: formVal.billingAddress.zip, @@ -157,6 +166,7 @@ export class CheckoutComponent implements OnInit { firstName: formVal.shippingAddress.firstName, lastName: formVal.shippingAddress.lastName, companyName: formVal.shippingAddress.companyName, + contactPerson: formVal.shippingAddress.referencePerson, addressLine1: formVal.shippingAddress.addressLine1, addressLine2: formVal.shippingAddress.addressLine2, zip: formVal.shippingAddress.zip, diff --git a/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts index b9ce5cc..bdc7c60 100644 --- a/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts +++ b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts @@ -25,6 +25,7 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { private controls!: OrbitControls; private animationId: number | null = null; private currentMesh: THREE.Mesh | null = null; + private autoRotate = true; loading = false; @@ -38,14 +39,14 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { } if (changes['color'] && this.currentMesh && !changes['file']) { - // Update existing mesh color if only color changed - const mat = this.currentMesh.material as THREE.MeshPhongMaterial; - mat.color.set(this.color); + this.applyColorStyle(this.color); } } ngOnDestroy() { if (this.animationId) cancelAnimationFrame(this.animationId); + this.clearCurrentMesh(); + if (this.controls) this.controls.dispose(); if (this.renderer) this.renderer.dispose(); } @@ -54,28 +55,51 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { const height = this.rendererContainer.nativeElement.clientHeight; this.scene = new THREE.Scene(); - this.scene.background = new THREE.Color(0xf7f6f2); // Neutral-50 + this.scene.background = new THREE.Color(0xf4f8fc); // Lights - const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); + const ambientLight = new THREE.AmbientLight(0xffffff, 0.75); this.scene.add(ambientLight); - const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); - directionalLight.position.set(1, 1, 1); - this.scene.add(directionalLight); + const hemiLight = new THREE.HemisphereLight(0xf8fbff, 0xc8d3df, 0.95); + hemiLight.position.set(0, 30, 0); + this.scene.add(hemiLight); + + const directionalLight1 = new THREE.DirectionalLight(0xffffff, 1.35); + directionalLight1.position.set(6, 8, 6); + this.scene.add(directionalLight1); + + const directionalLight2 = new THREE.DirectionalLight(0xe8f0ff, 0.85); + directionalLight2.position.set(-7, 4, -5); + this.scene.add(directionalLight2); + + const directionalLight3 = new THREE.DirectionalLight(0xffffff, 0.55); + directionalLight3.position.set(0, 5, -9); + this.scene.add(directionalLight3); // Camera this.camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000); this.camera.position.z = 100; // Renderer - this.renderer = new THREE.WebGLRenderer({ antialias: true }); + this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' }); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + this.renderer.outputColorSpace = THREE.SRGBColorSpace; + this.renderer.toneMapping = THREE.ACESFilmicToneMapping; + this.renderer.toneMappingExposure = 1.2; this.renderer.setSize(width, height); this.rendererContainer.nativeElement.appendChild(this.renderer.domElement); // Controls this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; + this.controls.dampingFactor = 0.06; + this.controls.enablePan = false; + this.controls.minDistance = 10; + this.controls.maxDistance = 600; + this.controls.addEventListener('start', () => { + this.autoRotate = false; + }); this.animate(); @@ -95,24 +119,27 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { private loadFile(file: File) { this.loading = true; + this.autoRotate = true; const reader = new FileReader(); reader.onload = (event) => { try { const loader = new STLLoader(); const geometry = loader.parse(event.target?.result as ArrayBuffer); - if (this.currentMesh) { - this.scene.remove(this.currentMesh); - this.currentMesh.geometry.dispose(); - } + this.clearCurrentMesh(); - const material = new THREE.MeshPhongMaterial({ - color: this.color, - specular: 0x111111, - shininess: 200 + geometry.computeVertexNormals(); + + const material = new THREE.MeshStandardMaterial({ + color: this.color, + roughness: 0.42, + metalness: 0.05, + emissive: 0x000000, + emissiveIntensity: 0 }); this.currentMesh = new THREE.Mesh(geometry, material); + this.applyColorStyle(this.color); // Center geometry geometry.computeBoundingBox(); @@ -140,9 +167,10 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { // Calculate distance towards camera (z-axis) let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)); - cameraZ *= 1.5; // Tighter zoom (reduced from 2.5) + cameraZ *= 1.72; - this.camera.position.z = cameraZ; + this.camera.position.set(cameraZ * 0.65, cameraZ * 0.95, cameraZ * 1.1); + this.camera.lookAt(0, 0, 0); this.camera.updateProjectionMatrix(); this.controls.update(); @@ -157,9 +185,63 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { private animate() { this.animationId = requestAnimationFrame(() => this.animate()); + + if (this.currentMesh && this.autoRotate) { + this.currentMesh.rotation.z += 0.0025; + } + if (this.controls) this.controls.update(); if (this.renderer && this.scene && this.camera) { this.renderer.render(this.scene, this.camera); } } + + private clearCurrentMesh() { + if (!this.currentMesh) { + return; + } + + this.scene.remove(this.currentMesh); + this.currentMesh.geometry.dispose(); + + const meshMaterial = this.currentMesh.material; + if (Array.isArray(meshMaterial)) { + meshMaterial.forEach((m) => m.dispose()); + } else { + meshMaterial.dispose(); + } + + this.currentMesh = null; + } + + private applyColorStyle(color: string) { + if (!this.currentMesh) { + return; + } + + const darkColor = this.isDarkColor(color); + const meshMaterial = this.currentMesh.material; + + if (meshMaterial instanceof THREE.MeshStandardMaterial) { + meshMaterial.color.set(color); + if (darkColor) { + meshMaterial.emissive.set(0x2a2f36); + meshMaterial.emissiveIntensity = 0.28; + meshMaterial.roughness = 0.5; + meshMaterial.metalness = 0.03; + } else { + meshMaterial.emissive.set(0x000000); + meshMaterial.emissiveIntensity = 0; + meshMaterial.roughness = 0.42; + meshMaterial.metalness = 0.05; + } + meshMaterial.needsUpdate = true; + } + } + + private isDarkColor(color: string): boolean { + const c = new THREE.Color(color); + const luminance = 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b; + return luminance < 0.22; + } } diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index e5d3916..0c8f67f 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -148,5 +148,29 @@ "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", + "CONTACT_INFO": "Contact Information", + "BILLING_ADDR": "Billing Address", + "SHIPPING_ADDR": "Shipping Address", + "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", + "SHIPPING_SAME": "Shipping address same as billing", + "PLACE_ORDER": "Place Order", + "PROCESSING": "Processing...", + "SUMMARY_TITLE": "Order Summary", + "SUBTOTAL": "Subtotal", + "SETUP_FEE": "Setup Fee", + "TOTAL": "Total", + "QTY": "Qty" } } diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index c9a7be4..245b62c 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -127,5 +127,29 @@ "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", + "CONTACT_INFO": "Informazioni di Contatto", + "BILLING_ADDR": "Indirizzo di Fatturazione", + "SHIPPING_ADDR": "Indirizzo di Spedizione", + "FIRST_NAME": "Nome", + "LAST_NAME": "Cognome", + "EMAIL": "Email", + "PHONE": "Telefono", + "COMPANY_NAME": "Nome Azienda", + "ADDRESS_1": "Indirizzo (Via e numero)", + "ADDRESS_2": "Informazioni aggiuntive (opzionale)", + "ZIP": "CAP", + "CITY": "Città", + "COUNTRY": "Paese", + "SHIPPING_SAME": "L'indirizzo di spedizione è lo stesso di quello di fatturazione", + "PLACE_ORDER": "Invia Ordine", + "PROCESSING": "Elaborazione...", + "SUMMARY_TITLE": "Riepilogo Ordine", + "SUBTOTAL": "Subtotale", + "SETUP_FEE": "Costo di Avvio", + "TOTAL": "Totale", + "QTY": "Qtà" } }