feat(web): new style and calculator revisited
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 14s
Build, Test and Deploy / build-and-push (push) Failing after 20s
Build, Test and Deploy / deploy (push) Has been skipped

This commit is contained in:
2026-02-02 18:38:25 +01:00
parent 0a538b0d88
commit 32b9b2ef8d
25 changed files with 1084 additions and 299 deletions

View File

@@ -23,12 +23,6 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
<div class="col-input">
<app-card>
<div class="tabs-wrapper">
<app-tabs
[tabs]="clientTabs"
[activeTab]="clientType()"
(tabChange)="clientType.set($event)">
</app-tabs>
<div class="sub-tabs">
<span
class="mode-switch"
@@ -47,7 +41,6 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
</div>
<app-upload-form
[clientType]="clientType()"
[mode]="mode()"
[loading]="loading()"
(submitRequest)="onCalculate($event)"
@@ -58,18 +51,18 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
<!-- Right Column: Result or Info -->
<div class="col-result">
@if (error()) {
<app-alert type="error">An error occurred while calculating quote.</app-alert>
<app-alert type="error">Si è verificato un errore durante il calcolo del preventivo.</app-alert>
}
@if (result()) {
<app-quote-result [result]="result()!"></app-quote-result>
} @else {
<app-card>
<h3>Why choose PrintCalc?</h3>
<h3>{{ 'CALC.BENEFITS_TITLE' | translate }}</h3>
<ul class="benefits">
<li>Instant AI-powered quotes</li>
<li>Industrial grade materials</li>
<li>Fast shipping worldwide</li>
<li>{{ 'CALC.BENEFITS_1' | translate }}</li>
<li>{{ 'CALC.BENEFITS_2' | translate }}</li>
<li>{{ 'CALC.BENEFITS_3' | translate }}</li>
</ul>
</app-card>
}
@@ -107,17 +100,11 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
`]
})
export class CalculatorPageComponent {
clientType = signal<any>('private');
mode = signal<any>('easy');
loading = signal(false);
result = signal<QuoteResult | null>(null);
error = signal<boolean>(false);
clientTabs = [
{ label: 'Private', value: 'private' },
{ label: 'Business', value: 'business' }
];
constructor(private estimator: QuoteEstimatorService) {}
onCalculate(req: QuoteRequest) {

View File

@@ -3,29 +3,33 @@ import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component';
import { QuoteResult } from '../../services/quote-estimator.service';
@Component({
selector: 'app-quote-result',
standalone: true,
imports: [CommonModule, TranslateModule, AppCardComponent, AppButtonComponent],
imports: [CommonModule, TranslateModule, AppCardComponent, AppButtonComponent, SummaryCardComponent],
template: `
<app-card>
<h3 class="title">{{ 'CALC.RESULT' | translate }}</h3>
<div class="result-grid">
<div class="item">
<span class="label">{{ 'CALC.COST' | translate }}</span>
<span class="value price">{{ result().price | currency:result().currency }}</span>
</div>
<div class="item">
<span class="label">{{ 'CALC.TIME' | translate }}</span>
<span class="value">{{ result().printTimeHours }}h</span>
</div>
<div class="item">
<span class="label">Material</span>
<span class="value">{{ result().materialUsageGrams }}g</span>
</div>
<app-summary-card
class="item full-width"
[label]="'CALC.COST' | translate"
[large]="true"
[highlight]="true">
{{ result().price | currency:result().currency }}
</app-summary-card>
<app-summary-card [label]="'CALC.TIME' | translate">
{{ result().printTimeHours }}h
</app-summary-card>
<app-summary-card [label]="'CALC.MATERIAL' | translate">
{{ result().materialUsageGrams }}g
</app-summary-card>
</div>
<div class="actions">
@@ -42,18 +46,7 @@ import { QuoteResult } from '../../services/quote-estimator.service';
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.item {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-3);
background: var(--color-neutral-50);
border-radius: var(--radius-md);
}
.item:first-child { grid-column: span 2; background: var(--color-neutral-100); }
.label { font-size: 0.875rem; color: var(--color-text-muted); }
.value { font-size: 1.25rem; font-weight: 700; }
.price { font-size: 2rem; color: var(--color-brand); }
.full-width { grid-column: span 2; }
.actions { display: flex; flex-direction: column; gap: var(--space-3); }
`]

View File

@@ -6,23 +6,43 @@ import { AppInputComponent } from '../../../../shared/components/app-input/app-i
import { AppSelectComponent } from '../../../../shared/components/app-select/app-select.component';
import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone/app-dropzone.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component';
import { QuoteRequest } from '../../services/quote-estimator.service';
@Component({
selector: 'app-upload-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppSelectComponent, AppDropzoneComponent, AppButtonComponent],
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppSelectComponent, AppDropzoneComponent, AppButtonComponent, StlViewerComponent],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="section">
<app-dropzone
[label]="'CALC.UPLOAD_LABEL' | translate"
[subtext]="'CALC.UPLOAD_SUB' | translate"
(fileDropped)="onFileDropped($event)">
</app-dropzone>
@if (form.get('file')?.invalid && form.get('file')?.touched) {
<div class="error-msg">File required</div>
@if (selectedFile()) {
<div class="viewer-wrapper">
<app-stl-viewer [file]="selectedFile()"></app-stl-viewer>
<button type="button" class="btn-clear" (click)="clearFiles()">
X
</button>
</div>
<div class="file-list">
@for (f of files(); track f.name) {
<div class="file-item" [class.active]="f === selectedFile()" (click)="selectFile(f)">
{{ f.name }}
</div>
}
</div>
} @else {
<app-dropzone
[label]="'CALC.UPLOAD_LABEL' | translate"
[subtext]="'CALC.UPLOAD_SUB' | translate"
[accept]="acceptedFormats"
[multiple]="true"
(filesDropped)="onFilesDropped($event)">
</app-dropzone>
}
@if (form.get('files')?.invalid && form.get('files')?.touched) {
<div class="error-msg">{{ 'CALC.ERR_FILE_REQUIRED' | translate }}</div>
}
</div>
@@ -50,7 +70,7 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
<app-input
formControlName="notes"
[label]="'CALC.NOTES' | translate"
placeholder="Specific instructions..."
placeholder="Istruzioni specifiche..."
></app-input>
}
@@ -69,31 +89,72 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); }
.actions { margin-top: var(--space-6); }
.error-msg { color: var(--color-danger-500); font-size: 0.875rem; margin-top: var(--space-2); text-align: center; }
.viewer-wrapper { position: relative; margin-bottom: var(--space-4); }
.btn-clear {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0,0,0,0.5);
color: white;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
z-index: 10;
&:hover { background: rgba(0,0,0,0.7); }
}
.file-list {
display: flex;
gap: var(--space-2);
overflow-x: auto;
padding-bottom: var(--space-2);
}
.file-item {
padding: 0.5rem 1rem;
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 0.85rem;
cursor: pointer;
white-space: nowrap;
&:hover { background: var(--color-neutral-200); }
&.active {
border-color: var(--color-brand);
background: rgba(250, 207, 10, 0.1);
font-weight: 600;
}
}
`]
})
export class UploadFormComponent {
clientType = input<'business' | 'private'>('private');
mode = input<'easy' | 'advanced'>('easy');
loading = input<boolean>(false);
submitRequest = output<QuoteRequest>();
form: FormGroup;
files = signal<File[]>([]);
selectedFile = signal<File | null>(null);
materials = [
{ label: 'PLA (Standard)', value: 'PLA' },
{ label: 'PETG (Durable)', value: 'PETG' },
{ label: 'TPU (Flexible)', value: 'TPU' }
{ label: 'PETG (Resistente)', value: 'PETG' },
{ label: 'TPU (Flessibile)', value: 'TPU' }
];
qualities = [
{ label: 'Draft (Fast)', value: 'Draft' },
{ label: 'Bozza (Veloce)', value: 'Draft' },
{ label: 'Standard', value: 'Standard' },
{ label: 'High Detail', value: 'High' }
{ label: 'Alta definizione', value: 'High' }
];
acceptedFormats = '.stl,.3mf,.step,.stp,.obj,.amf,.ply,.igs,.iges';
constructor(private fb: FormBuilder) {
this.form = this.fb.group({
file: [null, Validators.required],
files: [[], Validators.required],
material: ['PLA', Validators.required],
quality: ['Standard', Validators.required],
quantity: [1, [Validators.required, Validators.min(1)]],
@@ -101,16 +162,31 @@ export class UploadFormComponent {
});
}
onFileDropped(file: File) {
this.form.patchValue({ file });
this.form.get('file')?.markAsTouched();
onFilesDropped(newFiles: File[]) {
this.files.update(current => [...current, ...newFiles]);
this.form.patchValue({ files: this.files() });
this.form.get('files')?.markAsTouched();
// Select the last added file by default if none selected
if (newFiles.length > 0) {
this.selectedFile.set(newFiles[newFiles.length - 1]);
}
}
selectFile(file: File) {
this.selectedFile.set(file);
}
clearFiles() {
this.files.set([]);
this.selectedFile.set(null);
this.form.patchValue({ files: [] });
}
onSubmit() {
if (this.form.valid) {
this.submitRequest.emit({
...this.form.value,
clientType: this.clientType(),
mode: this.mode()
});
} else {

View File

@@ -1,14 +1,15 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, forkJoin, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { environment } from '../../../../environments/environment';
export interface QuoteRequest {
file: File;
files: File[];
material: string;
quality: string;
quantity: number;
notes?: string;
clientType: 'business' | 'private';
mode: 'easy' | 'advanced';
}
@@ -20,25 +21,94 @@ export interface QuoteResult {
setupCost: 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<QuoteResult> {
// Mock logic
const basePrice = request.clientType === 'business' ? 50 : 20;
const materialCost = request.material === 'PETG' ? 1.5 : (request.material === 'TPU' ? 2 : 1);
const qualityMult = request.quality === 'High' ? 1.5 : (request.quality === 'Draft' ? 0.8 : 1);
const estimatedPrice = (basePrice * materialCost * qualityMult * request.quantity) + 10; // +10 setup
return of({
price: Math.round(estimatedPrice * 100) / 100,
currency: 'EUR',
printTimeHours: Math.floor(Math.random() * 24) + 2,
materialUsageGrams: Math.floor(Math.random() * 500) + 50,
setupCost: 10
}).pipe(delay(1500)); // Simulate network latency
const requests: Observable<BackendResponse>[] = request.files.map(file => {
const formData = new FormData();
formData.append('file', file);
formData.append('machine', 'bambu_a1'); // Hardcoded for now
formData.append('filament', this.mapMaterial(request.material));
formData.append('quality', this.mapQuality(request.quality));
console.log(`Sending file: ${file.name} to ${environment.apiUrl}/api/quote`);
return this.http.post<BackendResponse>(`${environment.apiUrl}/api/quote`, formData).pipe(
map(res => {
console.log('Response for', file.name, res);
return res;
}),
catchError(err => {
console.error('Error calculating quote for', file.name, err);
return of({ success: false, data: { print_time_seconds: 0, material_grams: 0, cost: { total: 0 } }, error: err.message });
})
);
});
return forkJoin(requests).pipe(
map(responses => {
console.log('All responses:', responses);
const validResponses = responses.filter(r => r.success);
if (validResponses.length === 0 && responses.length > 0) {
throw new Error('All calculations failed. Check backend connection.');
}
let totalPrice = 0;
let totalTime = 0;
let totalWeight = 0;
let setupCost = 10; // Base setup
validResponses.forEach(res => {
totalPrice += res.data.cost.total;
totalTime += res.data.print_time_seconds;
totalWeight += res.data.material_grams;
});
// Apply quantity multiplier
totalPrice = (totalPrice * request.quantity) + setupCost;
totalWeight = totalWeight * request.quantity;
// Total time usually parallel if we have multiple printers, but let's sum for now
totalTime = totalTime * request.quantity;
return {
price: Math.round(totalPrice * 100) / 100,
currency: 'EUR',
printTimeHours: Math.ceil(totalTime / 3600), // Ceil hours
materialUsageGrams: Math.ceil(totalWeight),
setupCost
};
})
);
}
private mapMaterial(mat: string): string {
const m = mat.toUpperCase();
if (m.includes('PLA')) return 'pla_basic';
if (m.includes('PETG')) return 'petg_basic';
if (m.includes('TPU')) return 'tpu_95a';
return 'pla_basic';
}
private mapQuality(qual: string): string {
const q = qual.toLowerCase();
if (q.includes('draft')) return 'draft';
if (q.includes('high')) return 'extra_fine';
return 'standard';
}
}