feat(web): new step for user details
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user