dev #5

Merged
JoeKung merged 17 commits from dev into int 2026-02-09 18:43:44 +01:00
8 changed files with 353 additions and 108 deletions
Showing only changes of commit 78af87ac3c - Show all commits

View File

@@ -6,23 +6,28 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
import { UploadFormComponent } from './components/upload-form/upload-form.component';
import { QuoteResultComponent } from './components/quote-result/quote-result.component';
import { UserDetailsComponent } from './components/user-details/user-details.component';
import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quote-estimator.service';
import { Router } from '@angular/router';
@Component({
selector: 'app-calculator-page',
standalone: true,
imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent],
imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, UserDetailsComponent],
templateUrl: './calculator-page.component.html',
styleUrl: './calculator-page.component.scss'
})
export class CalculatorPageComponent {
mode = signal<any>('easy');
step = signal<'upload' | 'quote' | 'details'>('upload');
loading = signal(false);
uploadProgress = signal(0);
result = signal<QuoteResult | null>(null);
error = signal<boolean>(false);
orderSuccess = signal(false);
@ViewChild('uploadForm') uploadForm!: UploadFormComponent;
@ViewChild('resultCol') resultCol!: ElementRef;
@@ -34,6 +39,7 @@ export class CalculatorPageComponent {
this.uploadProgress.set(0);
this.error.set(false);
this.result.set(null);
this.orderSuccess.set(false);
// Auto-scroll on mobile to make analysis visible
setTimeout(() => {
@@ -51,6 +57,7 @@ export class CalculatorPageComponent {
this.result.set(event as QuoteResult);
this.loading.set(false);
this.uploadProgress.set(100);
this.step.set('quote');
}
},
error: () => {
@@ -60,6 +67,25 @@ export class CalculatorPageComponent {
});
}
onProceed() {
this.step.set('details');
}
onCancelDetails() {
this.step.set('quote');
}
onSubmitOrder(orderData: any) {
console.log('Order Submitted:', orderData);
this.orderSuccess.set(true);
this.step.set('upload'); // Reset to start, or show success page?
// For now, let's show success message and reset
setTimeout(() => {
this.orderSuccess.set(false);
}, 5000);
this.result.set(null);
}
private currentRequest: QuoteRequest | null = null;
onConsult() {

View File

@@ -56,7 +56,12 @@
</div>
<div class="actions">
<app-button variant="primary" [fullWidth]="true">{{ 'CALC.ORDER' | translate }}</app-button>
<app-button variant="outline" [fullWidth]="true" (click)="consult.emit()">{{ 'CALC.CONSULT' | translate }}</app-button>
<app-button variant="outline" (click)="consult.emit()">
{{ 'QUOTE.CONSULT' | translate }}
</app-button>
<app-button (click)="proceed.emit()">
{{ 'QUOTE.PROCEED_ORDER' | translate }}
</app-button>
</div>
</app-card>

View File

@@ -17,6 +17,7 @@ import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
export class QuoteResultComponent {
result = input.required<QuoteResult>();
consult = output<void>();
proceed = output<void>();
itemChange = output<{fileName: string, quantity: number}>();
// Local mutable state for items to handle quantity changes

View File

@@ -0,0 +1,120 @@
<div class="user-details-container">
<div class="row">
<div class="col-md-6">
<app-card [title]="'USER_DETAILS.TITLE' | translate">
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<!-- Name & Surname -->
<div class="row">
<div class="col-md-6">
<app-input
formControlName="name"
label="USER_DETAILS.NAME"
placeholder="USER_DETAILS.NAME_PLACEHOLDER"
[error]="form.get('name')?.invalid && form.get('name')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input>
</div>
<div class="col-md-6">
<app-input
formControlName="surname"
label="USER_DETAILS.SURNAME"
placeholder="USER_DETAILS.SURNAME_PLACEHOLDER"
[error]="form.get('surname')?.invalid && form.get('surname')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input>
</div>
</div>
<!-- Email & Phone -->
<div class="row">
<div class="col-md-6">
<app-input
formControlName="email"
label="USER_DETAILS.EMAIL"
type="email"
placeholder="USER_DETAILS.EMAIL_PLACEHOLDER"
[error]="form.get('email')?.invalid && form.get('email')?.touched ? ('COMMON.INVALID_EMAIL' | translate) : null">
</app-input>
</div>
<div class="col-md-6">
<app-input
formControlName="phone"
label="USER_DETAILS.PHONE"
type="tel"
placeholder="USER_DETAILS.PHONE_PLACEHOLDER"
[error]="form.get('phone')?.invalid && form.get('phone')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input>
</div>
</div>
<!-- Address -->
<app-input
formControlName="address"
label="USER_DETAILS.ADDRESS"
placeholder="USER_DETAILS.ADDRESS_PLACEHOLDER"
[error]="form.get('address')?.invalid && form.get('address')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input>
<!-- Zip & City -->
<div class="row">
<div class="col-md-4">
<app-input
formControlName="zip"
label="USER_DETAILS.ZIP"
placeholder="USER_DETAILS.ZIP_PLACEHOLDER"
[error]="form.get('zip')?.invalid && form.get('zip')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input>
</div>
<div class="col-md-8">
<app-input
formControlName="city"
label="USER_DETAILS.CITY"
placeholder="USER_DETAILS.CITY_PLACEHOLDER"
[error]="form.get('city')?.invalid && form.get('city')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input>
</div>
</div>
<div class="actions">
<app-button
type="button"
variant="outline"
(click)="onCancel()">
{{ 'COMMON.BACK' | translate }}
</app-button>
<app-button
type="submit"
[disabled]="form.invalid || submitting()">
{{ 'USER_DETAILS.SUBMIT' | translate }}
</app-button>
</div>
</form>
</app-card>
</div>
<!-- Order Summary Column -->
<div class="col-md-6">
<app-card [title]="'USER_DETAILS.SUMMARY_TITLE' | translate">
<div class="summary-content" *ngIf="quote()">
<div class="summary-item" *ngFor="let item of quote()!.items">
<div class="item-info">
<span class="item-name">{{ item.fileName }}</span>
<span class="item-meta">{{ item.material }} - {{ item.color || 'Default' }}</span>
</div>
<div class="item-qty">x{{ item.quantity }}</div>
<div class="item-price">{{ (item.unitPrice * item.quantity) | currency:'CHF' }}</div>
</div>
<hr>
<div class="total-row">
<span>{{ 'QUOTE.TOTAL' | translate }}</span>
<span class="total-price">{{ quote()!.totalPrice | currency:'CHF' }}</span>
</div>
</div>
</app-card>
</div>
</div>
</div>

View File

@@ -0,0 +1,102 @@
.user-details-container {
padding: 1rem 0;
}
.row {
display: flex;
flex-wrap: wrap;
margin: 0 -0.5rem;
> [class*='col-'] {
padding: 0 0.5rem;
}
}
.col-md-6 {
width: 100%;
@media (min-width: 768px) {
width: 50%;
}
}
.col-md-4 {
width: 100%;
@media (min-width: 768px) {
width: 33.333%;
}
}
.col-md-8 {
width: 100%;
@media (min-width: 768px) {
width: 66.666%;
}
}
.actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 1.5rem;
}
// Summary Styles
.summary-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.summary-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
&:last-child {
border-bottom: none;
}
}
.item-info {
display: flex;
flex-direction: column;
flex: 1;
}
.item-name {
font-weight: 500;
}
.item-meta {
font-size: 0.85rem;
opacity: 0.7;
}
.item-qty {
margin: 0 1rem;
opacity: 0.8;
}
.item-price {
font-weight: 600;
}
.total-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 1.2rem;
font-weight: 700;
margin-top: 1rem;
padding-top: 1rem;
border-top: 2px solid rgba(255, 255, 255, 0.2);
.total-price {
color: var(--primary-color, #00C853); // Fallback color
}
}

View File

@@ -0,0 +1,59 @@
import { Component, input, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component';
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { QuoteResult } from '../../services/quote-estimator.service';
@Component({
selector: 'app-user-details',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppCardComponent, AppInputComponent, AppButtonComponent],
templateUrl: './user-details.component.html',
styleUrl: './user-details.component.scss'
})
export class UserDetailsComponent {
quote = input<QuoteResult>();
submitOrder = output<any>();
cancel = output<void>();
form: FormGroup;
submitting = signal(false);
constructor(private fb: FormBuilder) {
this.form = this.fb.group({
name: ['', Validators.required],
surname: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
phone: ['', Validators.required],
address: ['', Validators.required],
zip: ['', Validators.required],
city: ['', Validators.required]
});
}
onSubmit() {
if (this.form.valid) {
this.submitting.set(true);
const orderData = {
customer: this.form.value,
quote: this.quote()
};
// Simulate API delay
setTimeout(() => {
this.submitOrder.emit(orderData);
this.submitting.set(false);
}, 1000);
} else {
this.form.markAllAsTouched();
}
}
onCancel() {
this.cancel.emit();
}
}

View File

@@ -24,6 +24,8 @@ export interface QuoteItem {
unitTime: number; // seconds
unitWeight: number; // grams
quantity: number;
material?: string;
color?: string;
// Computed values for UI convenience (optional, can be done in component)
}
@@ -37,114 +39,21 @@ export interface QuoteResult {
totalTimeMinutes: number;
totalWeight: number;
}
interface BackendResponse {
success: boolean;
data: {
print_time_seconds: number;
material_grams: number;
cost: {
total: number;
};
};
error?: string;
}
@Injectable({
providedIn: 'root'
})
export class QuoteEstimatorService {
private http = inject(HttpClient);
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
if (request.items.length === 0) return of();
return new Observable(observer => {
const totalItems = request.items.length;
const allProgress: number[] = new Array(totalItems).fill(0);
const finalResponses: any[] = [];
let completedRequests = 0;
const uploads = request.items.map((item, index) => {
const formData = new FormData();
formData.append('file', item.file);
formData.append('machine', 'bambu_a1');
formData.append('filament', this.mapMaterial(request.material));
formData.append('quality', this.mapQuality(request.quality));
// Send color for both modes if present, defaulting to Black
formData.append('material_color', item.color || 'Black');
if (request.mode === 'advanced') {
if (request.infillDensity) formData.append('infill_density', request.infillDensity.toString());
if (request.infillPattern) formData.append('infill_pattern', request.infillPattern);
if (request.supportEnabled) formData.append('support_enabled', 'true');
if (request.layerHeight) formData.append('layer_height', request.layerHeight.toString());
if (request.nozzleDiameter) formData.append('nozzle_diameter', request.nozzleDiameter.toString());
}
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.post<BackendResponse>(`${environment.apiUrl}/api/quote`, formData, {
headers,
reportProgress: true,
observe: 'events'
}).pipe(
map(event => ({ item, event, index })),
catchError(err => of({ item, error: err, index }))
);
});
// Subscribe to all
uploads.forEach((obs) => {
obs.subscribe({
next: (wrapper: any) => {
const idx = wrapper.index;
if (wrapper.error) {
finalResponses[idx] = { success: false, fileName: wrapper.item.file.name };
return;
}
const event = wrapper.event;
if (event.type === 1) { // HttpEventType.UploadProgress
if (event.total) {
const percent = Math.round((100 * event.loaded) / event.total);
allProgress[idx] = percent;
// Emit average progress
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
observer.next(avg);
}
} else if (event.type === 4) { // HttpEventType.Response
allProgress[idx] = 100;
finalResponses[idx] = { ...event.body, fileName: wrapper.item.file.name, originalQty: wrapper.item.quantity };
completedRequests++;
if (completedRequests === totalItems) {
// All done
observer.next(100);
// Calculate Results
// Base setup cost
let setupCost = 10;
// Surcharge for non-standard nozzle
if (request.nozzleDiameter && request.nozzleDiameter !== 0.4) {
setupCost += 2;
}
const items: QuoteItem[] = [];
finalResponses.forEach(res => {
// ... (skip down to calculate logic)
finalResponses.forEach((res, idx) => {
if (res && res.success) {
// Find original item to get color
const originalItem = request.items[idx];
// Note: responses and request.items are index-aligned because we mapped them
items.push({
fileName: res.fileName,
unitPrice: res.data.cost.total,
unitTime: res.data.print_time_seconds,
unitWeight: res.data.material_grams,
quantity: res.originalQty // Use the requested quantity
quantity: res.originalQty,
material: request.material,
color: originalItem.color || 'Default'
});
}
});

View File

@@ -2,9 +2,32 @@
"NAV": {
"HOME": "Home",
"CALCULATOR": "Calculator",
"SHOP": "Shop",
"ABOUT": "About",
"CONTACT": "Contact Us"
"SHOP": "Shop"
},
"QUOTE": {
"CONSULT": "Request Consultation",
"PROCEED_ORDER": "Proceed to Order",
"TOTAL": "Total Estimate"
},
"USER_DETAILS": {
"TITLE": "Shipping Details",
"SUMMARY_TITLE": "Order Summary",
"NAME": "First Name",
"NAME_PLACEHOLDER": "Enter your first name",
"SURNAME": "Last Name",
"SURNAME_PLACEHOLDER": "Enter your last name",
"EMAIL": "Email",
"EMAIL_PLACEHOLDER": "your@email.com",
"PHONE": "Phone",
"PHONE_PLACEHOLDER": "+41 79 123 45 67",
"ADDRESS": "Address",
"ADDRESS_PLACEHOLDER": "Street and Number",
"ZIP": "ZIP",
"ZIP_PLACEHOLDER": "8000",
"CITY": "City",
"CITY_PLACEHOLDER": "Zurich",
"SUBMIT": "Submit Order",
"ORDER_SUCCESS": "Order submitted successfully! We will contact you shortly."
},
"FOOTER": {
"PRIVACY": "Privacy",