feat/calculator-options #23
@@ -1,5 +1,6 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
import { SeoService } from './core/services/seo.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -8,4 +9,6 @@ import { RouterOutlet } from '@angular/router';
|
|||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrl: './app.component.scss',
|
styleUrl: './app.component.scss',
|
||||||
})
|
})
|
||||||
export class AppComponent {}
|
export class AppComponent {
|
||||||
|
private readonly seoService = inject(SeoService);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ const appChildRoutes: Routes = [
|
|||||||
path: '',
|
path: '',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/home/home.component').then((m) => m.HomeComponent),
|
import('./features/home/home.component').then((m) => m.HomeComponent),
|
||||||
|
data: {
|
||||||
|
seoTitle: '3D fab | Stampa 3D su misura',
|
||||||
|
seoDescription:
|
||||||
|
'Servizio di stampa 3D con preventivo online immediato per prototipi, piccole serie e pezzi personalizzati.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'calculator',
|
path: 'calculator',
|
||||||
@@ -12,21 +17,42 @@ const appChildRoutes: Routes = [
|
|||||||
import('./features/calculator/calculator.routes').then(
|
import('./features/calculator/calculator.routes').then(
|
||||||
(m) => m.CALCULATOR_ROUTES,
|
(m) => m.CALCULATOR_ROUTES,
|
||||||
),
|
),
|
||||||
|
data: {
|
||||||
|
seoTitle: 'Calcolatore preventivo stampa 3D | 3D fab',
|
||||||
|
seoDescription:
|
||||||
|
'Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'shop',
|
path: 'shop',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES),
|
import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES),
|
||||||
|
data: {
|
||||||
|
seoTitle: 'Shop 3D fab',
|
||||||
|
seoDescription:
|
||||||
|
'Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.',
|
||||||
|
seoRobots: 'noindex, nofollow',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'about',
|
path: 'about',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES),
|
import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES),
|
||||||
|
data: {
|
||||||
|
seoTitle: 'Chi siamo | 3D fab',
|
||||||
|
seoDescription:
|
||||||
|
'Scopri il team 3D fab e il laboratorio di stampa 3D con sedi in Ticino e Bienne.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'contact',
|
path: 'contact',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES),
|
import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES),
|
||||||
|
data: {
|
||||||
|
seoTitle: 'Contatti | 3D fab',
|
||||||
|
seoDescription:
|
||||||
|
'Contatta 3D fab per preventivi, supporto tecnico e richieste personalizzate di stampa 3D.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'checkout/cad',
|
path: 'checkout/cad',
|
||||||
@@ -34,6 +60,10 @@ const appChildRoutes: Routes = [
|
|||||||
import('./features/checkout/checkout.component').then(
|
import('./features/checkout/checkout.component').then(
|
||||||
(m) => m.CheckoutComponent,
|
(m) => m.CheckoutComponent,
|
||||||
),
|
),
|
||||||
|
data: {
|
||||||
|
seoTitle: 'Checkout | 3D fab',
|
||||||
|
seoRobots: 'noindex, nofollow',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'checkout',
|
path: 'checkout',
|
||||||
@@ -41,16 +71,28 @@ const appChildRoutes: Routes = [
|
|||||||
import('./features/checkout/checkout.component').then(
|
import('./features/checkout/checkout.component').then(
|
||||||
(m) => m.CheckoutComponent,
|
(m) => m.CheckoutComponent,
|
||||||
),
|
),
|
||||||
|
data: {
|
||||||
|
seoTitle: 'Checkout | 3D fab',
|
||||||
|
seoRobots: 'noindex, nofollow',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'order/:orderId',
|
path: 'order/:orderId',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/order/order.component').then((m) => m.OrderComponent),
|
import('./features/order/order.component').then((m) => m.OrderComponent),
|
||||||
|
data: {
|
||||||
|
seoTitle: 'Ordine | 3D fab',
|
||||||
|
seoRobots: 'noindex, nofollow',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'co/:orderId',
|
path: 'co/:orderId',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/order/order.component').then((m) => m.OrderComponent),
|
import('./features/order/order.component').then((m) => m.OrderComponent),
|
||||||
|
data: {
|
||||||
|
seoTitle: 'Ordine | 3D fab',
|
||||||
|
seoRobots: 'noindex, nofollow',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
@@ -61,6 +103,10 @@ const appChildRoutes: Routes = [
|
|||||||
path: 'admin',
|
path: 'admin',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES),
|
import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES),
|
||||||
|
data: {
|
||||||
|
seoTitle: 'Admin | 3D fab',
|
||||||
|
seoRobots: 'noindex, nofollow',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '**',
|
path: '**',
|
||||||
|
|||||||
@@ -2,5 +2,13 @@ import { Routes } from '@angular/router';
|
|||||||
import { AboutPageComponent } from './about-page.component';
|
import { AboutPageComponent } from './about-page.component';
|
||||||
|
|
||||||
export const ABOUT_ROUTES: Routes = [
|
export const ABOUT_ROUTES: Routes = [
|
||||||
{ path: '', component: AboutPageComponent },
|
{
|
||||||
|
path: '',
|
||||||
|
component: AboutPageComponent,
|
||||||
|
data: {
|
||||||
|
seoTitle: 'Chi siamo | 3D fab',
|
||||||
|
seoDescription:
|
||||||
|
'Siamo un laboratorio di stampa 3D orientato a prototipi, ricambi e produzioni su misura.',
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,10 +3,24 @@ import { CalculatorPageComponent } from './calculator-page.component';
|
|||||||
|
|
||||||
export const CALCULATOR_ROUTES: Routes = [
|
export const CALCULATOR_ROUTES: Routes = [
|
||||||
{ path: '', redirectTo: 'basic', pathMatch: 'full' },
|
{ path: '', redirectTo: 'basic', pathMatch: 'full' },
|
||||||
{ path: 'basic', component: CalculatorPageComponent, data: { mode: 'easy' } },
|
{
|
||||||
|
path: 'basic',
|
||||||
|
component: CalculatorPageComponent,
|
||||||
|
data: {
|
||||||
|
mode: 'easy',
|
||||||
|
seoTitle: 'Calcolatore stampa 3D base | 3D fab',
|
||||||
|
seoDescription:
|
||||||
|
'Calcola rapidamente il prezzo della tua stampa 3D in modalita base.',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'advanced',
|
path: 'advanced',
|
||||||
component: CalculatorPageComponent,
|
component: CalculatorPageComponent,
|
||||||
data: { mode: 'advanced' },
|
data: {
|
||||||
|
mode: 'advanced',
|
||||||
|
seoTitle: 'Calcolatore stampa 3D avanzato | 3D fab',
|
||||||
|
seoDescription:
|
||||||
|
'Configura parametri avanzati e ottieni un preventivo preciso con slicing reale.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -45,6 +45,12 @@
|
|||||||
<p>{{ result().notes }}</p>
|
<p>{{ result().notes }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (recalculationRequired()) {
|
||||||
|
<div class="recalc-banner">
|
||||||
|
Hai modificato i parametri di stampa. Ricalcola il preventivo prima di
|
||||||
|
procedere con l'ordine.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
|
||||||
@@ -104,7 +110,10 @@
|
|||||||
|
|
||||||
<div class="actions-right">
|
<div class="actions-right">
|
||||||
@if (!hasQuantityOverLimit()) {
|
@if (!hasQuantityOverLimit()) {
|
||||||
<app-button (click)="proceed.emit()">
|
<app-button
|
||||||
|
[disabled]="recalculationRequired()"
|
||||||
|
(click)="proceed.emit()"
|
||||||
|
>
|
||||||
{{ "QUOTE.PROCEED_ORDER" | translate }}
|
{{ "QUOTE.PROCEED_ORDER" | translate }}
|
||||||
</app-button>
|
</app-button>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -112,6 +121,11 @@
|
|||||||
"QUOTE.MAX_QTY_NOTICE" | translate: { max: directOrderLimit }
|
"QUOTE.MAX_QTY_NOTICE" | translate: { max: directOrderLimit }
|
||||||
}}</small>
|
}}</small>
|
||||||
}
|
}
|
||||||
|
@if (recalculationRequired()) {
|
||||||
|
<small class="limit-note">
|
||||||
|
Ricalcola il preventivo per riattivare il checkout.
|
||||||
|
</small>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</app-card>
|
</app-card>
|
||||||
|
|||||||
@@ -184,3 +184,14 @@
|
|||||||
white-space: pre-wrap; /* Preserve line breaks */
|
white-space: pre-wrap; /* Preserve line breaks */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recalc-banner {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid #f0c95a;
|
||||||
|
background: #fff8e1;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: #6f5b1a;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export class QuoteResultComponent implements OnDestroy {
|
|||||||
readonly quantityAutoRefreshMs = 2000;
|
readonly quantityAutoRefreshMs = 2000;
|
||||||
|
|
||||||
result = input.required<QuoteResult>();
|
result = input.required<QuoteResult>();
|
||||||
|
recalculationRequired = input<boolean>(false);
|
||||||
consult = output<void>();
|
consult = output<void>();
|
||||||
proceed = output<void>();
|
proceed = output<void>();
|
||||||
itemChange = output<{
|
itemChange = output<{
|
||||||
@@ -43,6 +44,12 @@ export class QuoteResultComponent implements OnDestroy {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
}>();
|
}>();
|
||||||
|
itemQuantityPreviewChange = output<{
|
||||||
|
id?: string;
|
||||||
|
index: number;
|
||||||
|
fileName: string;
|
||||||
|
quantity: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
// Local mutable state for items to handle quantity changes
|
// Local mutable state for items to handle quantity changes
|
||||||
items = signal<QuoteItem[]>([]);
|
items = signal<QuoteItem[]>([]);
|
||||||
@@ -87,6 +94,13 @@ export class QuoteResultComponent implements OnDestroy {
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.itemQuantityPreviewChange.emit({
|
||||||
|
id: item.id,
|
||||||
|
index,
|
||||||
|
fileName: item.fileName,
|
||||||
|
quantity: normalizedQty,
|
||||||
|
});
|
||||||
|
|
||||||
this.scheduleQuantityRefresh(index, key);
|
this.scheduleQuantityRefresh(index, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,5 +5,10 @@ export const CONTACT_ROUTES: Routes = [
|
|||||||
path: '',
|
path: '',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./contact-page.component').then((m) => m.ContactPageComponent),
|
import('./contact-page.component').then((m) => m.ContactPageComponent),
|
||||||
|
data: {
|
||||||
|
seoTitle: 'Contatti | 3D fab',
|
||||||
|
seoDescription:
|
||||||
|
'Richiedi informazioni, preventivi personalizzati o supporto per progetti di stampa 3D.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -37,28 +37,31 @@
|
|||||||
<div class="cap-cards">
|
<div class="cap-cards">
|
||||||
<app-card>
|
<app-card>
|
||||||
<div class="card-image-placeholder">
|
<div class="card-image-placeholder">
|
||||||
<img src="assets/images/home/prototipi.jpg" alt="" />
|
<img src="assets/images/home/prototipi.jpg" alt="Prototipazione 3D" />
|
||||||
</div>
|
</div>
|
||||||
<h3>{{ "HOME.CAP_1_TITLE" | translate }}</h3>
|
<h3>{{ "HOME.CAP_1_TITLE" | translate }}</h3>
|
||||||
<p class="text-muted">{{ "HOME.CAP_1_TEXT" | translate }}</p>
|
<p class="text-muted">{{ "HOME.CAP_1_TEXT" | translate }}</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
<app-card>
|
<app-card>
|
||||||
<div class="card-image-placeholder">
|
<div class="card-image-placeholder">
|
||||||
<img src="assets/images/home/original-vs-3dprinted.jpg" alt="" />
|
<img
|
||||||
|
src="assets/images/home/original-vs-3dprinted.jpg"
|
||||||
|
alt="Confronto pezzo originale e stampato in 3D"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h3>{{ "HOME.CAP_2_TITLE" | translate }}</h3>
|
<h3>{{ "HOME.CAP_2_TITLE" | translate }}</h3>
|
||||||
<p class="text-muted">{{ "HOME.CAP_2_TEXT" | translate }}</p>
|
<p class="text-muted">{{ "HOME.CAP_2_TEXT" | translate }}</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
<app-card>
|
<app-card>
|
||||||
<div class="card-image-placeholder">
|
<div class="card-image-placeholder">
|
||||||
<img src="assets/images/home/serie.jpg" alt="" />
|
<img src="assets/images/home/serie.jpg" alt="Piccole serie stampate in 3D" />
|
||||||
</div>
|
</div>
|
||||||
<h3>{{ "HOME.CAP_3_TITLE" | translate }}</h3>
|
<h3>{{ "HOME.CAP_3_TITLE" | translate }}</h3>
|
||||||
<p class="text-muted">{{ "HOME.CAP_3_TEXT" | translate }}</p>
|
<p class="text-muted">{{ "HOME.CAP_3_TEXT" | translate }}</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
<app-card>
|
<app-card>
|
||||||
<div class="card-image-placeholder">
|
<div class="card-image-placeholder">
|
||||||
<img src="assets/images/home/cad.jpg" alt="" />
|
<img src="assets/images/home/cad.jpg" alt="Servizio CAD per stampa 3D" />
|
||||||
</div>
|
</div>
|
||||||
<h3>{{ "HOME.CAP_4_TITLE" | translate }}</h3>
|
<h3>{{ "HOME.CAP_4_TITLE" | translate }}</h3>
|
||||||
<p class="text-muted">{{ "HOME.CAP_4_TEXT" | translate }}</p>
|
<p class="text-muted">{{ "HOME.CAP_4_TEXT" | translate }}</p>
|
||||||
|
|||||||
@@ -5,10 +5,20 @@ export const LEGAL_ROUTES: Routes = [
|
|||||||
path: 'privacy',
|
path: 'privacy',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./privacy/privacy.component').then((m) => m.PrivacyComponent),
|
import('./privacy/privacy.component').then((m) => m.PrivacyComponent),
|
||||||
|
data: {
|
||||||
|
seoTitle: 'Privacy Policy | 3D fab',
|
||||||
|
seoDescription:
|
||||||
|
'Informativa privacy di 3D fab: trattamento dati, finalita e contatti.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'terms',
|
path: 'terms',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./terms/terms.component').then((m) => m.TermsComponent),
|
import('./terms/terms.component').then((m) => m.TermsComponent),
|
||||||
|
data: {
|
||||||
|
seoTitle: 'Termini e condizioni | 3D fab',
|
||||||
|
seoDescription:
|
||||||
|
'Termini e condizioni del servizio di stampa 3D e del calcolatore preventivi.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -3,6 +3,22 @@ import { ShopPageComponent } from './shop-page.component';
|
|||||||
import { ProductDetailComponent } from './product-detail.component';
|
import { ProductDetailComponent } from './product-detail.component';
|
||||||
|
|
||||||
export const SHOP_ROUTES: Routes = [
|
export const SHOP_ROUTES: Routes = [
|
||||||
{ path: '', component: ShopPageComponent },
|
{
|
||||||
{ path: ':id', component: ProductDetailComponent },
|
path: '',
|
||||||
|
component: ShopPageComponent,
|
||||||
|
data: {
|
||||||
|
seoTitle: 'Shop 3D fab',
|
||||||
|
seoDescription:
|
||||||
|
'Lo shop 3D fab e in allestimento. Intanto puoi usare il calcolatore per ottenere un preventivo.',
|
||||||
|
seoRobots: 'noindex, nofollow',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':id',
|
||||||
|
component: ProductDetailComponent,
|
||||||
|
data: {
|
||||||
|
seoTitle: 'Prodotto | 3D fab',
|
||||||
|
seoRobots: 'noindex, nofollow',
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
<html lang="it">
|
<html lang="it">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<title>3D fab | Stampa 3D su misura</title>
|
||||||
<title>3D fab</title>
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Stampa 3D su misura con preventivo online immediato. Carica il file, scegli materiale e qualità, ricevi prezzo e tempi in pochi secondi."
|
||||||
|
/>
|
||||||
|
<meta name="robots" content="index, follow" />
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/png" href="assets/images/Fav-icon.png" />
|
<link rel="icon" type="image/png" href="assets/images/Fav-icon.png" />
|
||||||
|
|||||||
Reference in New Issue
Block a user