Compare commits
5 Commits
text-trasl
...
53e141f8ad
| Author | SHA1 | Date | |
|---|---|---|---|
| 53e141f8ad | |||
| 73ccf8f4de | |||
| 0b4daed512 | |||
| 8a7d736aa9 | |||
| ce179cac62 |
@@ -114,8 +114,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": false
|
||||
}
|
||||
}
|
||||
|
||||
30
frontend/package-lock.json
generated
30
frontend/package-lock.json
generated
@@ -406,7 +406,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.19.tgz",
|
||||
"integrity": "sha512-PCpJagurPBqciqcq4Z8+3OtKLb7rSl4w/qBJoIMua8CgnrjvA1i+SWawhdtfI1zlY8FSwhzLwXV0CmWWfFzQPg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"parse5": "^7.1.2",
|
||||
"tslib": "^2.3.0"
|
||||
@@ -456,7 +455,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.18.tgz",
|
||||
"integrity": "sha512-CrV02Omzw/QtfjlEVXVPJVXipdx83NuA+qSASZYrxrhKFusUZyK3P/Zznqg+wiAeNDbedQwMUVqoAARHf0xQrw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -473,7 +471,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.18.tgz",
|
||||
"integrity": "sha512-3MscvODxRVxc3Cs0ZlHI5Pk5rEvE80otfvxZTMksOZuPlv1B+S8MjWfc3X3jk9SbyUEzODBEH55iCaBHD48V3g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -487,7 +484,6 @@
|
||||
"integrity": "sha512-N4TMtLfImJIoMaRL6mx7885UBeQidywptHH6ACZj71Ar6++DBc1mMlcwuvbeJCd3r3y8MQ5nLv5PZSN/tHr13w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "7.26.9",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||
@@ -564,7 +560,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.18.tgz",
|
||||
"integrity": "sha512-+QRrf0Igt8ccUWXHA+7doK5W6ODyhHdqVyblSlcQ8OciwkzIIGGEYNZom5OZyWMh+oI54lcSeyV2O3xaDepSrQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -581,7 +576,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.18.tgz",
|
||||
"integrity": "sha512-pe40934jWhoS7DyGl7jyZdoj1gvBgur2t1zrJD+csEkTitYnW14+La2Pv6SW1pNX5nIzFsgsS9Nex1KcH5S6Tw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -617,7 +611,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.18.tgz",
|
||||
"integrity": "sha512-eahtsHPyXTYLARs9YOlXhnXGgzw0wcyOcDkBvNWK/3lA0NHIgIHmQgXAmBo+cJ+g9skiEQTD2OmSrrwbFKWJkw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -702,7 +695,6 @@
|
||||
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
@@ -3056,7 +3048,6 @@
|
||||
"integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@inquirer/checkbox": "^4.1.2",
|
||||
"@inquirer/confirm": "^5.1.6",
|
||||
@@ -5701,7 +5692,6 @@
|
||||
"integrity": "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -6120,7 +6110,6 @@
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -6584,7 +6573,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001716",
|
||||
"electron-to-chromium": "^1.5.149",
|
||||
@@ -9580,8 +9568,7 @@
|
||||
"resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.6.0.tgz",
|
||||
"integrity": "sha512-niVlkeYVRwKFpmfWg6suo6H9CrNnydfBLEqefM5UjibYS+UoTjZdmvPJSiuyrRLGnFj1eYRhFd/ch+5hSlsFVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jest-worker": {
|
||||
"version": "27.5.1",
|
||||
@@ -9620,7 +9607,6 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -9728,7 +9714,6 @@
|
||||
"integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@colors/colors": "1.5.0",
|
||||
"body-parser": "^1.19.0",
|
||||
@@ -10077,7 +10062,6 @@
|
||||
"integrity": "sha512-tkuLHQlvWUTeQ3doAqnHbNn8T6WX1KA8yvbKG9x4VtKtIjHsVKQZCH11zRgAfbDAXC2UNIg/K9BYAAcEzUIrNg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"copy-anything": "^2.0.1",
|
||||
"parse-node-version": "^1.0.1",
|
||||
@@ -11893,7 +11877,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.8",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -12543,7 +12526,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
@@ -13662,7 +13644,6 @@
|
||||
"integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.8.2",
|
||||
@@ -13822,8 +13803,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tuf-js": {
|
||||
"version": "3.0.1",
|
||||
@@ -13880,7 +13860,6 @@
|
||||
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -14567,7 +14546,6 @@
|
||||
"integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.7",
|
||||
"@types/estree": "^1.0.6",
|
||||
@@ -14645,7 +14623,6 @@
|
||||
"integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/bonjour": "^3.5.13",
|
||||
"@types/connect-history-api-fallback": "^1.5.4",
|
||||
@@ -15212,8 +15189,7 @@
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.0.tgz",
|
||||
"integrity": "sha512-9oxn0IIjbCZkJ67L+LkhYWRyAy7axphb3VgE2MBDlOqnmHMPWGYMxJxBYFueFq/JGY2GMwS0rU+UCLunEmy5UA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quote-estimator.service';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-calculator-page',
|
||||
@@ -23,12 +24,12 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
|
||||
<div class="col-input">
|
||||
<app-card>
|
||||
<div class="mode-selector">
|
||||
<div class="mode-option"
|
||||
<div class="mode-option"
|
||||
[class.active]="mode() === 'easy'"
|
||||
(click)="mode.set('easy')">
|
||||
{{ 'CALC.MODE_EASY' | translate }}
|
||||
</div>
|
||||
<div class="mode-option"
|
||||
<div class="mode-option"
|
||||
[class.active]="mode() === 'advanced'"
|
||||
(click)="mode.set('advanced')">
|
||||
{{ 'CALC.MODE_ADVANCED' | translate }}
|
||||
@@ -38,6 +39,7 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
|
||||
<app-upload-form
|
||||
[mode]="mode()"
|
||||
[loading]="loading()"
|
||||
[uploadProgress]="uploadProgress()"
|
||||
(submitRequest)="onCalculate($event)"
|
||||
></app-upload-form>
|
||||
</app-card>
|
||||
@@ -51,12 +53,14 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
|
||||
|
||||
@if (loading()) {
|
||||
<app-card class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Analisi geometria e slicing in corso...</p>
|
||||
<small class="text-muted">Potrebbe richiedere qualche secondo.</small>
|
||||
<div class="loader-content">
|
||||
<div class="spinner"></div>
|
||||
<h3 class="loading-title">Analisi in corso...</h3>
|
||||
<p class="loading-text">Stiamo analizzando la geometria e calcolando il percorso utensile.</p>
|
||||
</div>
|
||||
</app-card>
|
||||
} @else if (result()) {
|
||||
<app-quote-result [result]="result()!"></app-quote-result>
|
||||
<app-quote-result [result]="result()!" (consult)="onConsult()"></app-quote-result>
|
||||
} @else {
|
||||
<app-card>
|
||||
<h3>{{ 'CALC.BENEFITS_TITLE' | translate }}</h3>
|
||||
@@ -73,7 +77,7 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
|
||||
styles: [`
|
||||
.hero { padding: var(--space-12) 0; text-align: center; }
|
||||
.subtitle { font-size: 1.25rem; color: var(--color-text-muted); max-width: 600px; margin: 0 auto; }
|
||||
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
@@ -83,6 +87,13 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
|
||||
}
|
||||
}
|
||||
|
||||
.centered-col {
|
||||
align-self: flex-start; /* Default */
|
||||
@media(min-width: 768px) {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mode Selector (Segmented Control style) */
|
||||
.mode-selector {
|
||||
display: flex;
|
||||
@@ -93,7 +104,7 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.mode-option {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
@@ -105,9 +116,9 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
|
||||
color: var(--color-text-muted);
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
|
||||
|
||||
&:hover { color: var(--color-text); }
|
||||
|
||||
|
||||
&.active {
|
||||
background-color: var(--color-brand);
|
||||
color: #000;
|
||||
@@ -117,23 +128,43 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
|
||||
}
|
||||
|
||||
.benefits { padding-left: var(--space-4); color: var(--color-text-muted); line-height: 2; }
|
||||
|
||||
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
padding: var(--space-8);
|
||||
color: var(--color-text-muted);
|
||||
|
||||
.spinner {
|
||||
border: 3px solid rgba(0, 0, 0, 0.1);
|
||||
border-left-color: var(--color-brand);
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto var(--space-4);
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px; /* Match typical result height */
|
||||
}
|
||||
|
||||
|
||||
.loader-content {
|
||||
text-align: center;
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loading-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: var(--space-4) 0 var(--space-2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 3px solid var(--color-neutral-200);
|
||||
border-left-color: var(--color-brand);
|
||||
border-radius: 50%;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
@@ -143,20 +174,29 @@ import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quo
|
||||
export class CalculatorPageComponent {
|
||||
mode = signal<any>('easy');
|
||||
loading = signal(false);
|
||||
uploadProgress = signal(0);
|
||||
result = signal<QuoteResult | null>(null);
|
||||
error = signal<boolean>(false);
|
||||
|
||||
constructor(private estimator: QuoteEstimatorService) {}
|
||||
constructor(private estimator: QuoteEstimatorService, private router: Router) {}
|
||||
|
||||
onCalculate(req: QuoteRequest) {
|
||||
this.currentRequest = req;
|
||||
this.loading.set(true);
|
||||
this.uploadProgress.set(0);
|
||||
this.error.set(false);
|
||||
this.result.set(null);
|
||||
|
||||
|
||||
this.estimator.calculate(req).subscribe({
|
||||
next: (res) => {
|
||||
this.result.set(res);
|
||||
this.loading.set(false);
|
||||
next: (event) => {
|
||||
if (typeof event === 'number') {
|
||||
this.uploadProgress.set(event);
|
||||
} else {
|
||||
// It's the result
|
||||
this.result.set(event as QuoteResult);
|
||||
this.loading.set(false);
|
||||
this.uploadProgress.set(100);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.error.set(true);
|
||||
@@ -164,4 +204,30 @@ export class CalculatorPageComponent {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private currentRequest: QuoteRequest | null = null;
|
||||
|
||||
onConsult() {
|
||||
if (!this.currentRequest) return;
|
||||
|
||||
const req = this.currentRequest;
|
||||
let details = `Richiesta Preventivo:\n`;
|
||||
details += `- Materiale: ${req.material}\n`;
|
||||
details += `- Qualità: ${req.quality}\n`;
|
||||
details += `- Quantità: ${req.quantity}\n`;
|
||||
|
||||
if (req.mode === 'advanced') {
|
||||
if (req.color) details += `- Colore: ${req.color}\n`;
|
||||
if (req.infillDensity) details += `- Infill: ${req.infillDensity}%\n`;
|
||||
}
|
||||
|
||||
if (req.notes) details += `\nNote: ${req.notes}`;
|
||||
|
||||
this.estimator.setPendingConsultation({
|
||||
files: req.files,
|
||||
message: details
|
||||
});
|
||||
|
||||
this.router.navigate(['/contact']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ import { QuoteResult } from '../../services/quote-estimator.service';
|
||||
|
||||
<div class="actions">
|
||||
<app-button variant="primary" [fullWidth]="true">{{ 'CALC.ORDER' | translate }}</app-button>
|
||||
<app-button variant="outline" [fullWidth]="true">{{ 'CALC.CONSULT' | translate }}</app-button>
|
||||
<app-button variant="outline" [fullWidth]="true" (click)="consult.emit()">{{ 'CALC.CONSULT' | translate }}</app-button>
|
||||
</div>
|
||||
</app-card>
|
||||
`,
|
||||
@@ -53,4 +53,5 @@ import { QuoteResult } from '../../services/quote-estimator.service';
|
||||
})
|
||||
export class QuoteResultComponent {
|
||||
result = input.required<QuoteResult>();
|
||||
consult = output<void>();
|
||||
}
|
||||
|
||||
@@ -101,25 +101,21 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
|
||||
></app-input>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill"></div>
|
||||
</div>
|
||||
<p class="progress-text">Uploading & Analyzing...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="actions">
|
||||
<!-- Progress Bar (Only when uploading i.e. progress < 100) -->
|
||||
@if (loading() && uploadProgress() < 100) {
|
||||
<div class="progress-container">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" [style.width.%]="uploadProgress()"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-button
|
||||
type="submit"
|
||||
[disabled]="form.invalid || loading()"
|
||||
[fullWidth]="true">
|
||||
@if (loading()) {
|
||||
Slicing in progress...
|
||||
} @else {
|
||||
{{ 'CALC.CALCULATE' | translate }}
|
||||
}
|
||||
{{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
|
||||
</app-button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -188,38 +184,35 @@ import { QuoteRequest } from '../../services/quote-estimator.service';
|
||||
|
||||
/* Progress Bar */
|
||||
.progress-container {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
background: var(--color-neutral-100);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-3);
|
||||
/* padding: var(--space-2); */
|
||||
/* background: var(--color-neutral-100); */
|
||||
/* border-radius: var(--radius-md); */
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
height: 4px;
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: var(--space-2);
|
||||
margin-bottom: 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-brand);
|
||||
width: 0%;
|
||||
animation: progress 2s ease-in-out infinite;
|
||||
transition: width 0.2s ease-out;
|
||||
}
|
||||
.progress-text { font-size: 0.875rem; color: var(--color-text-muted); }
|
||||
|
||||
@keyframes progress {
|
||||
0% { width: 0%; transform: translateX(-100%); }
|
||||
50% { width: 100%; transform: translateX(0); }
|
||||
100% { width: 100%; transform: translateX(100%); }
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class UploadFormComponent {
|
||||
mode = input<'easy' | 'advanced'>('easy');
|
||||
loading = input<boolean>(false);
|
||||
uploadProgress = input<number>(0);
|
||||
submitRequest = output<QuoteRequest>();
|
||||
|
||||
form: FormGroup;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, forkJoin, of } from 'rxjs';
|
||||
import { map, catchError } from 'rxjs/operators';
|
||||
@@ -44,80 +44,192 @@ interface BackendResponse {
|
||||
export class QuoteEstimatorService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
calculate(request: QuoteRequest): Observable<QuoteResult> {
|
||||
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));
|
||||
|
||||
if (request.mode === 'advanced') {
|
||||
if (request.color) formData.append('material_color', request.color);
|
||||
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');
|
||||
}
|
||||
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
|
||||
const formData = new FormData();
|
||||
// Assuming single file primarily for now, or aggregating.
|
||||
// The current UI seems to select one "active" file or handle multiple.
|
||||
// The logic below was mapping multiple files to multiple requests.
|
||||
// To support progress seamlessly for the "main" action, let's focus on the processing flow.
|
||||
// If multiple files, we might need a more complex progress tracking or just track the first/total.
|
||||
// Given the UI shows one big "Analyse" button, let's treat it as a batch or single.
|
||||
|
||||
// NOTE: The previous logic did `request.files.map(...)`.
|
||||
// If we want a global progress, we can mistakenly complexity it.
|
||||
// Let's assume we upload all files in one request if the API supported it, but the API seems to be 1 file per request from previous code?
|
||||
// "formData.append('file', file)" inside the map implies multiple requests.
|
||||
// To keep it simple and working with the progress bar which is global:
|
||||
// We will emit progress for the *current* file being processed or average them.
|
||||
// OR simpler: The user typically uploads one file for a quote?
|
||||
// The UI `files: File[]` allows multiple.
|
||||
// Let's stick to the previous logic but wrap it to emit progress.
|
||||
// However, forkJoin waits for all. We can't easily get specialized progress for "overall upload" with forkJoin of distinct requests easily without merging.
|
||||
|
||||
// Refined approach:
|
||||
// We will process files IN PARALLEL (forkJoin) but we can't easily track aggregated upload progress of multiple requests in a single simple number without extra code.
|
||||
// BUT, the user wants "la barra di upload".
|
||||
// If we assume standard use case is 1 file, it's easy.
|
||||
// If multiple, we can emit progress as "average of all uploads" or just "uploading...".
|
||||
// Let's modify the signature to return `Observable<{ type: 'progress' | 'result', value: any }>` or similar?
|
||||
// The plan said `Observable<QuoteResult>` originally, now we need progress.
|
||||
// Let's change return type to `Observable<any>` or a specific union.
|
||||
|
||||
// Let's handle just the first file for progress visualization simplicity if multiple are present,
|
||||
// or better, create a wrapper that merges the progress.
|
||||
|
||||
// Actually, looking at the previous code: `const requests = request.files.map(...)`.
|
||||
// If we have 3 files, we have 3 requests.
|
||||
// We can emit progress events.
|
||||
|
||||
// START implementation for generalized progress:
|
||||
|
||||
const file = request.files[0]; // Primary target for now to ensure we have a progress to show.
|
||||
// Ideally we should upload all.
|
||||
|
||||
// For this task, to satisfy "bar disappears after upload", we really need to know when upload finishes.
|
||||
|
||||
// Let's keep it robust:
|
||||
// If multiple files, we likely want to just process them.
|
||||
// Let's stick to the previous logic but capture progress events for at least one or all.
|
||||
|
||||
if (request.files.length === 0) return of();
|
||||
|
||||
const headers: any = {};
|
||||
// @ts-ignore
|
||||
if (environment.basicAuth) {
|
||||
// @ts-ignore
|
||||
headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
||||
}
|
||||
|
||||
console.log(`Sending file: ${file.name} to ${environment.apiUrl}/api/quote`);
|
||||
return this.http.post<BackendResponse>(`${environment.apiUrl}/api/quote`, formData, { headers }).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);
|
||||
// We will change the architecture slightly:
|
||||
// We will execute requests and for EACH, we track progress.
|
||||
// But we only have one boolean 'loading' and one 'progress' bar in UI.
|
||||
// Let's average the progress?
|
||||
|
||||
// Simplification: The user probably uploads one file to check quote.
|
||||
// Let's implement support for the first file's progress to drive the UI bar, handling the rest in background/parallel.
|
||||
|
||||
// Re-implementing the single file logic from the map, but enabled for progress.
|
||||
|
||||
return new Observable(observer => {
|
||||
let completed = 0;
|
||||
let total = request.files.length;
|
||||
const results: BackendResponse[] = [];
|
||||
let grandTotal = 0; // For progress calculation if we wanted to average
|
||||
|
||||
const validResponses = responses.filter(r => r.success);
|
||||
if (validResponses.length === 0 && responses.length > 0) {
|
||||
throw new Error('All calculations failed. Check backend connection.');
|
||||
}
|
||||
// We'll just track the "upload phase" of the bundle.
|
||||
// Actually, let's just use `concat` or `merge`?
|
||||
// Let's simplify: We will only track progress for the first file or "active" file.
|
||||
// But the previous code sent ALL files.
|
||||
|
||||
// Let's change the return type to emit events.
|
||||
|
||||
const uploads = request.files.map(file => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('machine', 'bambu_a1');
|
||||
formData.append('filament', this.mapMaterial(request.material));
|
||||
formData.append('quality', this.mapQuality(request.quality));
|
||||
if (request.mode === 'advanced') {
|
||||
if (request.color) formData.append('material_color', request.color);
|
||||
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');
|
||||
}
|
||||
|
||||
const headers: any = {};
|
||||
// @ts-ignore
|
||||
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
|
||||
|
||||
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;
|
||||
return this.http.post<BackendResponse>(`${environment.apiUrl}/api/quote`, formData, {
|
||||
headers,
|
||||
reportProgress: true,
|
||||
observe: 'events'
|
||||
}).pipe(
|
||||
map(event => ({ file, event })),
|
||||
catchError(err => of({ file, error: err }))
|
||||
);
|
||||
});
|
||||
|
||||
// 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;
|
||||
// We process all uploads.
|
||||
// We want to emit:
|
||||
// 1. Progress updates (average of all files?)
|
||||
// 2. Final QuoteResult
|
||||
|
||||
const allProgress: number[] = new Array(request.files.length).fill(0);
|
||||
let completedRequests = 0;
|
||||
const finalResponses: any[] = [];
|
||||
|
||||
const totalHours = Math.floor(totalTime / 3600);
|
||||
const totalMinutes = Math.ceil((totalTime % 3600) / 60);
|
||||
// Subscribe to all
|
||||
uploads.forEach((obs, index) => {
|
||||
obs.subscribe({
|
||||
next: (wrapper: any) => {
|
||||
if (wrapper.error) {
|
||||
// handled in final calculation
|
||||
finalResponses[index] = { success: false, data: { cost: { total:0 }, print_time_seconds:0, material_grams:0 } };
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
price: Math.round(totalPrice * 100) / 100, // Keep 2 decimals
|
||||
currency: 'CHF',
|
||||
printTimeHours: totalHours,
|
||||
printTimeMinutes: totalMinutes,
|
||||
materialUsageGrams: Math.ceil(totalWeight),
|
||||
setupCost
|
||||
};
|
||||
})
|
||||
);
|
||||
const event = wrapper.event;
|
||||
if (event.type === 1) { // HttpEventType.UploadProgress
|
||||
if (event.total) {
|
||||
const percent = Math.round((100 * event.loaded) / event.total);
|
||||
allProgress[index] = percent;
|
||||
// Emit average progress
|
||||
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / total);
|
||||
observer.next(avg); // Emit number for progress
|
||||
}
|
||||
} else if (event.type === 4) { // HttpEventType.Response
|
||||
allProgress[index] = 100;
|
||||
finalResponses[index] = event.body;
|
||||
completedRequests++;
|
||||
|
||||
if (completedRequests === total) {
|
||||
// All done
|
||||
observer.next(100); // Ensure complete
|
||||
|
||||
// Calculate Totals
|
||||
const valid = finalResponses.filter(r => r && r.success);
|
||||
if (valid.length === 0 && finalResponses.length > 0) {
|
||||
observer.error('All calculations failed.');
|
||||
return;
|
||||
}
|
||||
|
||||
let totalPrice = 0;
|
||||
let totalTime = 0;
|
||||
let totalWeight = 0;
|
||||
let setupCost = 10;
|
||||
|
||||
valid.forEach(res => {
|
||||
totalPrice += res.data.cost.total;
|
||||
totalTime += res.data.print_time_seconds;
|
||||
totalWeight += res.data.material_grams;
|
||||
});
|
||||
|
||||
totalPrice = (totalPrice * request.quantity) + setupCost;
|
||||
totalWeight = totalWeight * request.quantity;
|
||||
totalTime = totalTime * request.quantity;
|
||||
|
||||
const totalHours = Math.floor(totalTime / 3600);
|
||||
const totalMinutes = Math.ceil((totalTime % 3600) / 60);
|
||||
|
||||
const result: QuoteResult = {
|
||||
price: Math.round(totalPrice * 100) / 100,
|
||||
currency: 'CHF',
|
||||
printTimeHours: totalHours,
|
||||
printTimeMinutes: totalMinutes,
|
||||
materialUsageGrams: Math.ceil(totalWeight),
|
||||
setupCost
|
||||
};
|
||||
|
||||
observer.next(result); // Emit final object
|
||||
observer.complete();
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error in request', err);
|
||||
finalResponses[index] = { success: false };
|
||||
completedRequests++;
|
||||
if (completedRequests === total) {
|
||||
observer.error('Requests failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private mapMaterial(mat: string): string {
|
||||
@@ -134,4 +246,17 @@ export class QuoteEstimatorService {
|
||||
if (q.includes('high')) return 'extra_fine';
|
||||
return 'standard';
|
||||
}
|
||||
|
||||
// Consultation Data Transfer
|
||||
private pendingConsultation = signal<{files: File[], message: string} | null>(null);
|
||||
|
||||
setPendingConsultation(data: {files: File[], message: string}) {
|
||||
this.pendingConsultation.set(data);
|
||||
}
|
||||
|
||||
getPendingConsultation() {
|
||||
const data = this.pendingConsultation();
|
||||
this.pendingConsultation.set(null); // Clear after reading
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
|
||||
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
||||
import { QuoteEstimatorService } from '../../../calculator/services/quote-estimator.service';
|
||||
|
||||
interface FilePreview {
|
||||
file: File;
|
||||
@@ -242,7 +243,11 @@ export class ContactFormComponent {
|
||||
{ value: 'question', label: 'CONTACT.REQ_TYPE_QUESTION' }
|
||||
];
|
||||
|
||||
constructor(private fb: FormBuilder, private translate: TranslateService) {
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private translate: TranslateService,
|
||||
private estimator: QuoteEstimatorService
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
requestType: ['custom', Validators.required],
|
||||
name: ['', Validators.required],
|
||||
@@ -279,6 +284,27 @@ export class ContactFormComponent {
|
||||
companyNameControl?.updateValueAndValidity();
|
||||
refPersonControl?.updateValueAndValidity();
|
||||
});
|
||||
|
||||
// Check for pending consultation data
|
||||
effect(() => {
|
||||
// Use timeout or run in constructor to ensure dependency availability?
|
||||
// Actually best in constructor or ngOnInit. Let's stick to constructor logic but executed immediately.
|
||||
});
|
||||
|
||||
const pending = this.estimator.getPendingConsultation();
|
||||
if (pending) {
|
||||
this.form.patchValue({
|
||||
requestType: 'consult',
|
||||
message: pending.message
|
||||
});
|
||||
|
||||
// Process files
|
||||
const filePreviews: FilePreview[] = [];
|
||||
pending.files.forEach(f => {
|
||||
filePreviews.push({ file: f, type: this.getFileType(f) });
|
||||
});
|
||||
this.files.set(filePreviews);
|
||||
}
|
||||
}
|
||||
|
||||
setCompanyMode(isCompany: boolean) {
|
||||
|
||||
@@ -47,19 +47,19 @@
|
||||
"ABOUT": {
|
||||
"TITLE": "About Us",
|
||||
"EYEBROW": "3D Printing Lab",
|
||||
"SUBTITLE": "Transparency on price, quality, and lead time. A technical 3D printing service with CAD support for businesses and individuals.",
|
||||
"SUBTITLE": "Transparency on price, quality and time. Technical and CAD consultation for businesses and individuals.",
|
||||
"HOW_TITLE": "How we work",
|
||||
"HOW_TEXT": "We offer an instant quote for those who already have a ready-to-print 3D file, as well as a consultation path for those who need to design or optimize a model. This project was born from our passion for 3D printing and our desire to gain experience, learn, and deliver useful solutions with care and technical attention.",
|
||||
"HOW_TEXT": "We offer an automatic quote for those who already have the 3D file, and a consultation path for those who need to design or optimize the model.",
|
||||
"PILL_1": "Instant Quote",
|
||||
"PILL_2": "Technical Consultation",
|
||||
"PILL_3": "Small series up to 500 pcs",
|
||||
"SERVICES_TITLE": "Main Services",
|
||||
"SERVICE_1": "FDM 3D printing for prototypes and small series",
|
||||
"SERVICE_2": "Technical materials available on request",
|
||||
"SERVICE_2": "Technical materials on request",
|
||||
"SERVICE_3": "CAD support and post-processing",
|
||||
"SERVICE_4": "File verification and print optimization",
|
||||
"SERVICE_4": "File verification and optimization for printing",
|
||||
"TARGET_TITLE": "Who is it for",
|
||||
"TARGET_TEXT": "Small businesses, freelancers, makers, and customers looking for a ready-made product available in the shop.",
|
||||
"TARGET_TEXT": "Small businesses, freelancers, makers and customers looking for a ready-made product from the shop.",
|
||||
"TEAM_TITLE": "Our Team"
|
||||
},
|
||||
"CONTACT": {
|
||||
|
||||
@@ -52,9 +52,9 @@
|
||||
"ABOUT": {
|
||||
"TITLE": "Chi Siamo",
|
||||
"EYEBROW": "Laboratorio di stampa 3D",
|
||||
"SUBTITLE": "Trasparenza su prezzo, qualità e tempi. Un servizio tecnico di stampa 3D e supporto CAD per aziende e privati.",
|
||||
"SUBTITLE": "Trasparenza su prezzo, qualità e tempi. Consulenza tecnica e CAD per aziende e privati.",
|
||||
"HOW_TITLE": "Come lavoriamo",
|
||||
"HOW_TEXT": "Offriamo un preventivo immediato per chi ha già un file 3D pronto, oppure un percorso di consulenza per chi deve progettare o ottimizzare un modello. Questo progetto nasce dalla nostra passione per la stampa 3D e dalla volontà di fare esperienza, imparare e realizzare soluzioni utili in modo serio e curato.",
|
||||
"HOW_TEXT": "Offriamo un preventivo automatico per chi ha già il file 3D, e un percorso di consulenza per chi deve progettare o ottimizzare il modello.",
|
||||
"PILL_1": "Preventivo immediato",
|
||||
"PILL_2": "Consulenza tecnica",
|
||||
"PILL_3": "Piccole serie fino a 500 pz",
|
||||
@@ -64,7 +64,7 @@
|
||||
"SERVICE_3": "Supporto CAD e post-processing",
|
||||
"SERVICE_4": "Verifica file e ottimizzazione per la stampa",
|
||||
"TARGET_TITLE": "Per chi è",
|
||||
"TARGET_TEXT": "Piccole aziende, freelance, appassionati e clienti che cercano un prodotto già pronto disponibile nello shop.",
|
||||
"TARGET_TEXT": "Piccole aziende, freelance, smanettoni e clienti che cercano un prodotto già pronto dallo shop.",
|
||||
"TEAM_TITLE": "Il Nostro Team"
|
||||
},
|
||||
"CONTACT": {
|
||||
|
||||
Reference in New Issue
Block a user