diff --git a/GEMINI.md b/GEMINI.md
index 043579f..997d781 100644
--- a/GEMINI.md
+++ b/GEMINI.md
@@ -36,3 +36,7 @@ Questo file serve a dare contesto all'AI (Antigravity/Gemini) sulla struttura e
- Per eseguire il backend serve `uvicorn`.
- Il frontend richiede `npm install` al primo avvio.
- Le configurazioni di stampa (layer height, wall thickness, infill) sono attualmente hardcoded o con valori di default nel backend, ma potrebbero essere esposte come parametri API in futuro.
+
+## AI Agent Rules
+- **No Inline Code**: Tutti i componenti Angular DEVONO usare file separati per HTML (`templateUrl`) e SCSS (`styleUrl`). È vietato usare `template` o `styles` inline nel decoratore `@Component`.
+
diff --git a/frontend/src/app/app.component.scss b/frontend/src/app/app.component.scss
new file mode 100644
index 0000000..e69de29
diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts
index 3c3d410..966ecc2 100644
--- a/frontend/src/app/app.component.ts
+++ b/frontend/src/app/app.component.ts
@@ -5,6 +5,7 @@ import { RouterOutlet } from '@angular/router';
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
- template: ` `
+ templateUrl: './app.component.html',
+ styleUrl: './app.component.scss'
})
export class AppComponent {}
diff --git a/frontend/src/app/core/constants/colors.const.ts b/frontend/src/app/core/constants/colors.const.ts
new file mode 100644
index 0000000..66b1336
--- /dev/null
+++ b/frontend/src/app/core/constants/colors.const.ts
@@ -0,0 +1,41 @@
+export interface ColorOption {
+ label: string;
+ value: string;
+ hex: string;
+ outOfStock?: boolean;
+}
+
+export interface ColorCategory {
+ name: string; // 'Glossy' | 'Matte'
+ colors: ColorOption[];
+}
+
+export const PRODUCT_COLORS: ColorCategory[] = [
+ {
+ name: 'Lucidi', // Glossy
+ colors: [
+ { label: 'Black', value: 'Black', hex: '#1a1a1a' }, // Not pure black for visibility
+ { label: 'White', value: 'White', hex: '#f5f5f5' },
+ { label: 'Red', value: 'Red', hex: '#d32f2f', outOfStock: true },
+ { label: 'Blue', value: 'Blue', hex: '#1976d2' },
+ { label: 'Green', value: 'Green', hex: '#388e3c' },
+ { label: 'Yellow', value: 'Yellow', hex: '#fbc02d' }
+ ]
+ },
+ {
+ name: 'Opachi', // Matte
+ colors: [
+ { label: 'Matte Black', value: 'Matte Black', hex: '#2c2c2c' }, // Lighter charcoal for matte
+ { label: 'Matte White', value: 'Matte White', hex: '#e0e0e0' },
+ { label: 'Matte Gray', value: 'Matte Gray', hex: '#757575' }
+ ]
+ }
+];
+
+export function getColorHex(value: string): string {
+ for (const cat of PRODUCT_COLORS) {
+ const found = cat.colors.find(c => c.value === value);
+ if (found) return found.hex;
+ }
+ return '#facf0a'; // Default Brand Color if not found
+}
diff --git a/frontend/src/app/core/layout/layout.component.html b/frontend/src/app/core/layout/layout.component.html
new file mode 100644
index 0000000..a1775b8
--- /dev/null
+++ b/frontend/src/app/core/layout/layout.component.html
@@ -0,0 +1,7 @@
+
diff --git a/frontend/src/app/core/layout/layout.component.scss b/frontend/src/app/core/layout/layout.component.scss
new file mode 100644
index 0000000..dbb9226
--- /dev/null
+++ b/frontend/src/app/core/layout/layout.component.scss
@@ -0,0 +1,9 @@
+.layout-wrapper {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+.main-content {
+ flex: 1;
+ padding-bottom: var(--space-12);
+}
diff --git a/frontend/src/app/core/layout/layout.component.ts b/frontend/src/app/core/layout/layout.component.ts
index ac27e33..7e90e14 100644
--- a/frontend/src/app/core/layout/layout.component.ts
+++ b/frontend/src/app/core/layout/layout.component.ts
@@ -7,25 +7,7 @@ import { FooterComponent } from './footer.component';
selector: 'app-layout',
standalone: true,
imports: [RouterOutlet, NavbarComponent, FooterComponent],
- template: `
-
- `,
- styles: [`
- .layout-wrapper {
- display: flex;
- flex-direction: column;
- min-height: 100vh;
- }
- .main-content {
- flex: 1;
- padding-bottom: var(--space-12);
- }
- `]
+ templateUrl: './layout.component.html',
+ styleUrl: './layout.component.scss'
})
export class LayoutComponent {}
diff --git a/frontend/src/app/features/about/about-page.component.html b/frontend/src/app/features/about/about-page.component.html
new file mode 100644
index 0000000..9dd3f12
--- /dev/null
+++ b/frontend/src/app/features/about/about-page.component.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
{{ 'ABOUT.EYEBROW' | translate }}
+
{{ 'ABOUT.TITLE' | translate }}
+
{{ 'ABOUT.SUBTITLE' | translate }}
+
+
+
+
{{ 'ABOUT.HOW_TEXT' | translate }}
+
+
+ {{ 'ABOUT.PILL_1' | translate }}
+ {{ 'ABOUT.PILL_2' | translate }}
+ {{ 'ABOUT.PILL_3' | translate }}
+ {{ 'ABOUT.SERVICE_1' | translate }}
+ {{ 'ABOUT.SERVICE_2' | translate }}
+
+
+
+
+
+
+
+
+ Member 1
+ Founder
+
+
+
+
+
+ Member 2
+ Co-Founder
+
+
+
+
+
+
+
+
diff --git a/frontend/src/app/features/about/about-page.component.scss b/frontend/src/app/features/about/about-page.component.scss
new file mode 100644
index 0000000..1a926a5
--- /dev/null
+++ b/frontend/src/app/features/about/about-page.component.scss
@@ -0,0 +1,157 @@
+.about-section {
+ padding: 6rem 0;
+ background: var(--color-bg);
+ min-height: 80vh;
+ display: flex;
+ align-items: center;
+}
+
+.split-layout {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 4rem;
+ align-items: center;
+ text-align: center; /* Center on mobile */
+
+ @media(min-width: 992px) {
+ grid-template-columns: 1fr 1fr;
+ gap: 6rem;
+ text-align: left; /* Reset to left on desktop */
+ }
+}
+
+/* Left Column */
+.text-content {
+ /* text-align: left; Removed to inherit from parent */
+}
+
+.eyebrow {
+ text-transform: uppercase;
+ letter-spacing: 0.15em;
+ font-size: 0.875rem;
+ color: var(--color-primary-500);
+ font-weight: 700;
+ margin-bottom: var(--space-2);
+ display: block;
+}
+
+h1 {
+ font-size: 3rem;
+ line-height: 1.1;
+ margin-bottom: var(--space-4);
+ color: var(--color-text-main);
+}
+
+.subtitle {
+ font-size: 1.25rem;
+ color: var(--color-text-muted);
+ margin-bottom: var(--space-6);
+ font-weight: 300;
+}
+
+.divider {
+ height: 4px;
+ width: 60px;
+ background: var(--color-primary-500);
+ border-radius: 2px;
+ margin-bottom: var(--space-6);
+ /* Center divider on mobile */
+ margin-left: auto;
+ margin-right: auto;
+
+ @media(min-width: 992px) {
+ margin-left: 0;
+ margin-right: 0;
+ }
+}
+
+.description {
+ font-size: 1.1rem;
+ line-height: 1.7;
+ color: var(--color-text-main);
+ margin-bottom: var(--space-8);
+}
+
+.tags-container {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ justify-content: center; /* Center tags on mobile */
+
+ @media(min-width: 992px) {
+ justify-content: flex-start;
+ }
+}
+
+.tag {
+ padding: 0.5rem 1rem;
+ border-radius: 99px;
+ background: var(--color-surface-card);
+ border: 1px solid var(--color-border);
+ color: var(--color-text-main);
+ font-weight: 500;
+ font-size: 0.9rem;
+ box-shadow: var(--shadow-sm);
+ transition: all 0.2s ease;
+}
+
+.tag:hover {
+ transform: translateY(-2px);
+ border-color: var(--color-primary-500);
+ color: var(--color-primary-500);
+ box-shadow: var(--shadow-md);
+}
+
+/* Right Column */
+.visual-content {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 2rem;
+
+ @media(min-width: 768px) {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ align-items: start;
+ justify-items: center;
+ }
+}
+
+.photo-card {
+ background: var(--color-surface-card);
+ padding: 1rem;
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-lg);
+ width: 100%;
+ max-width: 260px;
+ position: relative;
+}
+
+.placeholder-img {
+ width: 100%;
+ aspect-ratio: 3/4;
+ background: linear-gradient(45deg, var(--color-neutral-200), var(--color-neutral-100));
+ border-radius: var(--radius-md);
+ margin-bottom: 1rem;
+}
+
+.member-info {
+ text-align: center;
+}
+
+.member-name {
+ display: block;
+ font-weight: 700;
+ color: var(--color-text-main);
+ font-size: 1.1rem;
+}
+
+.member-role {
+ display: block;
+ font-size: 0.85rem;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-top: 0.25rem;
+}
diff --git a/frontend/src/app/features/about/about-page.component.ts b/frontend/src/app/features/about/about-page.component.ts
index e06f003..dcb93f8 100644
--- a/frontend/src/app/features/about/about-page.component.ts
+++ b/frontend/src/app/features/about/about-page.component.ts
@@ -1,214 +1,13 @@
import { Component } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
-
+import { AppLocationsComponent } from '../../shared/components/app-locations/app-locations.component';
@Component({
selector: 'app-about-page',
standalone: true,
- imports: [TranslateModule],
- template: `
-
-
-
-
-
-
{{ 'ABOUT.EYEBROW' | translate }}
-
{{ 'ABOUT.TITLE' | translate }}
-
{{ 'ABOUT.SUBTITLE' | translate }}
-
-
-
-
{{ 'ABOUT.HOW_TEXT' | translate }}
-
-
- {{ 'ABOUT.PILL_1' | translate }}
- {{ 'ABOUT.PILL_2' | translate }}
- {{ 'ABOUT.PILL_3' | translate }}
- {{ 'ABOUT.SERVICE_1' | translate }}
- {{ 'ABOUT.SERVICE_2' | translate }}
-
-
-
-
-
-
-
-
- Member 1
- Founder
-
-
-
-
-
- Member 2
- Co-Founder
-
-
-
-
-
-
- `,
- styles: [`
- .about-section {
- padding: 6rem 0;
- background: var(--color-bg);
- min-height: 80vh;
- display: flex;
- align-items: center;
- }
-
- .split-layout {
- display: grid;
- grid-template-columns: 1fr;
- gap: 4rem;
- align-items: center;
- text-align: center; /* Center on mobile */
-
- @media(min-width: 992px) {
- grid-template-columns: 1fr 1fr;
- gap: 6rem;
- text-align: left; /* Reset to left on desktop */
- }
- }
-
- /* Left Column */
- .text-content {
- /* text-align: left; Removed to inherit from parent */
- }
-
- .eyebrow {
- text-transform: uppercase;
- letter-spacing: 0.15em;
- font-size: 0.875rem;
- color: var(--color-primary-500);
- font-weight: 700;
- margin-bottom: var(--space-2);
- display: block;
- }
-
- h1 {
- font-size: 3rem;
- line-height: 1.1;
- margin-bottom: var(--space-4);
- color: var(--color-text-main);
- }
-
- .subtitle {
- font-size: 1.25rem;
- color: var(--color-text-muted);
- margin-bottom: var(--space-6);
- font-weight: 300;
- }
-
- .divider {
- height: 4px;
- width: 60px;
- background: var(--color-primary-500);
- border-radius: 2px;
- margin-bottom: var(--space-6);
- /* Center divider on mobile */
- margin-left: auto;
- margin-right: auto;
-
- @media(min-width: 992px) {
- margin-left: 0;
- margin-right: 0;
- }
- }
-
- .description {
- font-size: 1.1rem;
- line-height: 1.7;
- color: var(--color-text-main);
- margin-bottom: var(--space-8);
- }
-
- .tags-container {
- display: flex;
- flex-wrap: wrap;
- gap: 0.75rem;
- justify-content: center; /* Center tags on mobile */
-
- @media(min-width: 992px) {
- justify-content: flex-start;
- }
- }
-
- .tag {
- padding: 0.5rem 1rem;
- border-radius: 99px;
- background: var(--color-surface-card);
- border: 1px solid var(--color-border);
- color: var(--color-text-main);
- font-weight: 500;
- font-size: 0.9rem;
- box-shadow: var(--shadow-sm);
- transition: all 0.2s ease;
- }
-
- .tag:hover {
- transform: translateY(-2px);
- border-color: var(--color-primary-500);
- color: var(--color-primary-500);
- box-shadow: var(--shadow-md);
- }
-
- /* Right Column */
- .visual-content {
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: center;
- gap: 2rem;
-
- @media(min-width: 768px) {
- display: grid;
- grid-template-columns: repeat(2, 1fr);
- align-items: start;
- justify-items: center;
- }
- }
-
- .photo-card {
- background: var(--color-surface-card);
- padding: 1rem;
- border-radius: var(--radius-lg);
- box-shadow: var(--shadow-lg);
- width: 100%;
- max-width: 260px;
- position: relative;
- }
-
- .placeholder-img {
- width: 100%;
- aspect-ratio: 3/4;
- background: linear-gradient(45deg, var(--color-neutral-200), var(--color-neutral-100));
- border-radius: var(--radius-md);
- margin-bottom: 1rem;
- }
-
- .member-info {
- text-align: center;
- }
-
- .member-name {
- display: block;
- font-weight: 700;
- color: var(--color-text-main);
- font-size: 1.1rem;
- }
-
- .member-role {
- display: block;
- font-size: 0.85rem;
- color: var(--color-text-muted);
- text-transform: uppercase;
- letter-spacing: 0.05em;
- margin-top: 0.25rem;
- }
- `]
+ imports: [TranslateModule, AppLocationsComponent],
+ templateUrl: './about-page.component.html',
+ styleUrl: './about-page.component.scss'
})
export class AboutPageComponent {}
diff --git a/frontend/src/app/features/calculator/calculator-page.component.html b/frontend/src/app/features/calculator/calculator-page.component.html
new file mode 100644
index 0000000..3de51d9
--- /dev/null
+++ b/frontend/src/app/features/calculator/calculator-page.component.html
@@ -0,0 +1,80 @@
+
+
{{ 'CALC.TITLE' | translate }}
+
{{ 'CALC.SUBTITLE' | translate }}
+
+ @if (error()) {
+
{{ 'CALC.ERROR_GENERIC' | translate }}
+ }
+
+
+@if (step() === 'success') {
+
+} @else if (step() === 'details' && result()) {
+
+} @else {
+
+
+
+
+
+
+
+ @if (loading()) {
+
+
+
+
Analisi in corso...
+
Stiamo analizzando la geometria e calcolando il percorso utensile.
+
+
+ } @else if (result()) {
+
+ } @else {
+
+ {{ 'CALC.BENEFITS_TITLE' | translate }}
+
+ {{ 'CALC.BENEFITS_1' | translate }}
+ {{ 'CALC.BENEFITS_2' | translate }}
+ {{ 'CALC.BENEFITS_3' | translate }}
+
+
+ }
+
+
+}
diff --git a/frontend/src/app/features/calculator/calculator-page.component.scss b/frontend/src/app/features/calculator/calculator-page.component.scss
new file mode 100644
index 0000000..02cdec0
--- /dev/null
+++ b/frontend/src/app/features/calculator/calculator-page.component.scss
@@ -0,0 +1,110 @@
+.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;
+ gap: var(--space-6);
+ @media(min-width: 768px) {
+ grid-template-columns: 1.5fr 1fr;
+ gap: var(--space-8);
+ }
+}
+
+.centered-col {
+ align-self: flex-start; /* Default */
+ @media(min-width: 768px) {
+ align-self: center;
+ }
+}
+
+.col-input {
+ min-width: 0;
+}
+
+.col-result {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+
+ /* Make children (specifically app-card) stretch */
+ > * {
+ flex: 1;
+ }
+}
+
+/* Mode Selector (Segmented Control style) */
+.mode-selector {
+ display: flex;
+ background-color: var(--color-neutral-100);
+ border-radius: var(--radius-md);
+ padding: 4px;
+ margin-bottom: var(--space-6);
+ gap: 4px;
+ width: 100%;
+}
+
+.mode-option {
+ flex: 1;
+ text-align: center;
+ padding: 8px 16px;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ font-size: 0.875rem;
+ font-weight: 500;
+ 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;
+ font-weight: 600;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.05);
+ }
+}
+
+.benefits { padding-left: var(--space-4); color: var(--color-text-muted); line-height: 2; }
+
+
+.loader-content {
+ text-align: center;
+ max-width: 300px;
+ margin: 0 auto;
+
+ /* Center content vertically within the stretched card */
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
+.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); }
+}
diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts
index 5b76d7d..99dfad1 100644
--- a/frontend/src/app/features/calculator/calculator-page.component.ts
+++ b/frontend/src/app/features/calculator/calculator-page.component.ts
@@ -1,4 +1,4 @@
-import { Component, signal } from '@angular/core';
+import { Component, signal, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
@@ -6,186 +6,49 @@ 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 { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quote-estimator.service';
+import { QuoteRequest, QuoteResult, QuoteEstimatorService } from './services/quote-estimator.service';
+import { UserDetailsComponent } from './components/user-details/user-details.component';
+import { SuccessStateComponent } from '../../shared/components/success-state/success-state.component';
import { Router } from '@angular/router';
@Component({
selector: 'app-calculator-page',
standalone: true,
- imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent],
- template: `
-
-
{{ 'CALC.TITLE' | translate }}
-
{{ 'CALC.SUBTITLE' | translate }}
-
-
-
-
-
-
-
-
- @if (error()) {
-
Si è verificato un errore durante il calcolo del preventivo.
- }
-
- @if (loading()) {
-
-
-
-
Analisi in corso...
-
Stiamo analizzando la geometria e calcolando il percorso utensile.
-
-
- } @else if (result()) {
-
- } @else {
-
- {{ 'CALC.BENEFITS_TITLE' | translate }}
-
- {{ 'CALC.BENEFITS_1' | translate }}
- {{ 'CALC.BENEFITS_2' | translate }}
- {{ 'CALC.BENEFITS_3' | translate }}
-
-
- }
-
-
- `,
- 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;
- gap: var(--space-8);
- @media(min-width: 768px) {
- grid-template-columns: 1.5fr 1fr;
- }
- }
-
- .centered-col {
- align-self: flex-start; /* Default */
- @media(min-width: 768px) {
- align-self: center;
- }
- }
-
- /* Mode Selector (Segmented Control style) */
- .mode-selector {
- display: flex;
- background-color: var(--color-neutral-100);
- border-radius: var(--radius-md);
- padding: 4px;
- margin-bottom: var(--space-6);
- gap: 4px;
- width: 100%;
- }
-
- .mode-option {
- flex: 1;
- text-align: center;
- padding: 8px 16px;
- border-radius: var(--radius-sm);
- cursor: pointer;
- font-size: 0.875rem;
- font-weight: 500;
- 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;
- font-weight: 600;
- box-shadow: 0 1px 2px rgba(0,0,0,0.05);
- }
- }
-
- .benefits { padding-left: var(--space-4); color: var(--color-text-muted); line-height: 2; }
-
- .loading-state {
- 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); }
- }
- `]
+ imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, UserDetailsComponent, SuccessStateComponent],
+ templateUrl: './calculator-page.component.html',
+ styleUrl: './calculator-page.component.scss'
})
export class CalculatorPageComponent {
mode = signal('easy');
+ step = signal<'upload' | 'quote' | 'details' | 'success'>('upload');
+
loading = signal(false);
uploadProgress = signal(0);
result = signal(null);
error = signal(false);
+
+ orderSuccess = signal(false);
+
+ @ViewChild('uploadForm') uploadForm!: UploadFormComponent;
+ @ViewChild('resultCol') resultCol!: ElementRef;
constructor(private estimator: QuoteEstimatorService, private router: Router) {}
onCalculate(req: QuoteRequest) {
+ // ... (logic remains the same, simplified for diff)
this.currentRequest = req;
this.loading.set(true);
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(() => {
+ if (this.resultCol && window.innerWidth < 768) {
+ this.resultCol.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ }, 100);
this.estimator.calculate(req).subscribe({
next: (event) => {
@@ -196,6 +59,7 @@ export class CalculatorPageComponent {
this.result.set(event as QuoteResult);
this.loading.set(false);
this.uploadProgress.set(100);
+ this.step.set('quote');
}
},
error: () => {
@@ -205,6 +69,27 @@ 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('success');
+ }
+
+ onNewQuote() {
+ this.step.set('upload');
+ this.result.set(null);
+ this.orderSuccess.set(false);
+ this.mode.set('easy'); // Reset to default
+ }
+
private currentRequest: QuoteRequest | null = null;
onConsult() {
@@ -214,17 +99,24 @@ export class CalculatorPageComponent {
let details = `Richiesta Preventivo:\n`;
details += `- Materiale: ${req.material}\n`;
details += `- Qualità: ${req.quality}\n`;
- details += `- Quantità: ${req.quantity}\n`;
+
+ details += `- File:\n`;
+ req.items.forEach(item => {
+ details += ` * ${item.file.name} (Qtà: ${item.quantity}`;
+ if (item.color) {
+ details += `, Colore: ${item.color}`;
+ }
+ details += `)\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,
+ files: req.items.map(i => i.file),
message: details
});
diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html
new file mode 100644
index 0000000..f41ecb8
--- /dev/null
+++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html
@@ -0,0 +1,67 @@
+
+ {{ 'CALC.RESULT' | translate }}
+
+
+
+
+ {{ totals().price | currency:result().currency }}
+
+
+
+ {{ totals().hours }}h {{ totals().minutes }}m
+
+
+
+ {{ totals().weight }}g
+
+
+
+
+ * Include {{ result().setupCost | currency:result().currency }} Setup Cost
+
+
+
+
+
+
+ @for (item of items(); track item.fileName; let i = $index) {
+
+
+ {{ item.fileName }}
+
+ {{ (item.unitTime / 3600) | number:'1.1-1' }}h | {{ item.unitWeight | number:'1.0-0' }}g
+
+
+
+
+
+ Qtà:
+
+
+
+ {{ (item.unitPrice * item.quantity) | currency:result().currency }}
+
+
+
+ }
+
+
+
+
+ {{ 'QUOTE.CONSULT' | translate }}
+
+
+
+ {{ 'QUOTE.PROCEED_ORDER' | translate }}
+
+
+
diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss
new file mode 100644
index 0000000..0c16042
--- /dev/null
+++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss
@@ -0,0 +1,85 @@
+.title { margin-bottom: var(--space-6); text-align: center; }
+
+.divider {
+ height: 1px;
+ background: var(--color-border);
+ margin: var(--space-4) 0;
+}
+
+.items-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ margin-bottom: var(--space-4);
+}
+
+.item-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--space-3);
+ background: var(--color-neutral-50);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--color-border);
+}
+
+.item-info {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ flex: 1; /* Ensure it takes available space */
+}
+
+.file-name { font-weight: 500; font-size: 0.9rem; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.file-details { font-size: 0.8rem; color: var(--color-text-muted); }
+
+.item-controls {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+}
+
+.qty-control {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+
+ label { font-size: 0.8rem; color: var(--color-text-muted); }
+}
+
+.qty-input {
+ width: 60px;
+ padding: 4px 8px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ text-align: center;
+ &:focus { outline: none; border-color: var(--color-brand); }
+}
+
+.item-price {
+ font-weight: 600;
+ min-width: 60px;
+ text-align: right;
+}
+
+.result-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: var(--space-3);
+ margin-bottom: var(--space-2);
+
+ @media(min-width: 500px) {
+ grid-template-columns: 1fr 1fr;
+ gap: var(--space-4);
+ }
+}
+.full-width { grid-column: span 2; }
+
+.setup-note {
+ text-align: center;
+ margin-bottom: var(--space-6);
+ color: var(--color-text-muted);
+ font-size: 0.8rem;
+}
+
+.actions { display: flex; flex-direction: column; gap: var(--space-3); }
diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts
index 1a2e9da..daeb3cd 100644
--- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts
+++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts
@@ -1,57 +1,74 @@
-import { Component, input, output } from '@angular/core';
+import { Component, input, output, signal, computed, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
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';
+import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
@Component({
selector: 'app-quote-result',
standalone: true,
- imports: [CommonModule, TranslateModule, AppCardComponent, AppButtonComponent, SummaryCardComponent],
- template: `
-
- {{ 'CALC.RESULT' | translate }}
-
-
-
- {{ result().price | currency:result().currency }}
-
-
-
- {{ result().printTimeHours }}h {{ result().printTimeMinutes }}m
-
-
-
- {{ result().materialUsageGrams }}g
-
-
-
-
-
{{ 'CALC.ORDER' | translate }}
-
{{ 'CALC.CONSULT' | translate }}
-
-
- `,
- styles: [`
- .title { margin-bottom: var(--space-6); text-align: center; }
- .result-grid {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: var(--space-4);
- margin-bottom: var(--space-6);
- }
- .full-width { grid-column: span 2; }
-
- .actions { display: flex; flex-direction: column; gap: var(--space-3); }
- `]
+ imports: [CommonModule, FormsModule, TranslateModule, AppCardComponent, AppButtonComponent, SummaryCardComponent],
+ templateUrl: './quote-result.component.html',
+ styleUrl: './quote-result.component.scss'
})
export class QuoteResultComponent {
result = input.required();
consult = output();
+ proceed = output();
+ itemChange = output<{fileName: string, quantity: number}>();
+
+ // Local mutable state for items to handle quantity changes
+ items = signal([]);
+
+ constructor() {
+ effect(() => {
+ // Initialize local items when result inputs change
+ // We map to new objects to avoid mutating the input directly if it was a reference
+ this.items.set(this.result().items.map(i => ({...i})));
+ }, { allowSignalWrites: true });
+ }
+
+ updateQuantity(index: number, newQty: number | string) {
+ const qty = typeof newQty === 'string' ? parseInt(newQty, 10) : newQty;
+ if (qty < 1 || isNaN(qty)) return;
+
+ this.items.update(current => {
+ const updated = [...current];
+ updated[index] = { ...updated[index], quantity: qty };
+ return updated;
+ });
+
+ this.itemChange.emit({
+ fileName: this.items()[index].fileName,
+ quantity: qty
+ });
+ }
+
+ totals = computed(() => {
+ const currentItems = this.items();
+ const setup = this.result().setupCost;
+
+ let price = setup;
+ let time = 0;
+ let weight = 0;
+
+ currentItems.forEach(i => {
+ price += i.unitPrice * i.quantity;
+ time += i.unitTime * i.quantity;
+ weight += i.unitWeight * i.quantity;
+ });
+
+ const hours = Math.floor(time / 3600);
+ const minutes = Math.ceil((time % 3600) / 60);
+
+ return {
+ price: Math.round(price * 100) / 100,
+ hours,
+ minutes,
+ weight: Math.ceil(weight)
+ };
+ });
}
diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html
new file mode 100644
index 0000000..594415a
--- /dev/null
+++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html
@@ -0,0 +1,155 @@
+
diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss
new file mode 100644
index 0000000..3331860
--- /dev/null
+++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss
@@ -0,0 +1,207 @@
+.section { margin-bottom: var(--space-6); }
+.grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: var(--space-4);
+
+ @media(min-width: 640px) {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+.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); }
+
+/* Grid Layout for Files */
+.items-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr; /* Force 2 columns on mobile */
+ gap: var(--space-2); /* Tighten gap for mobile */
+ margin-top: var(--space-4);
+ margin-bottom: var(--space-4);
+
+ @media(min-width: 640px) {
+ gap: var(--space-3);
+ }
+}
+
+.file-card {
+ padding: var(--space-2); /* Reduced from space-3 */
+ background: var(--color-neutral-100);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ transition: all 0.2s;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ gap: 4px; /* Reduced gap */
+ position: relative; /* For absolute positioning of remove btn */
+ min-width: 0; /* Allow flex item to shrink below content size if needed */
+
+ &:hover { border-color: var(--color-neutral-300); }
+ &.active {
+ border-color: var(--color-brand);
+ background: rgba(250, 207, 10, 0.05);
+ box-shadow: 0 0 0 1px var(--color-brand);
+ }
+}
+
+.card-header {
+ overflow: hidden;
+ padding-right: 25px; /* Adjusted */
+ margin-bottom: 2px;
+}
+
+.file-name {
+ font-weight: 500;
+ font-size: 0.8rem; /* Smaller font */
+ color: var(--color-text);
+ display: block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.card-body {
+ display: flex;
+ align-items: center;
+ padding-top: 0;
+}
+
+.card-controls {
+ display: flex;
+ align-items: flex-end; /* Align bottom of input and color circle */
+ gap: 16px; /* Space between Qty and Color */
+ width: 100%;
+}
+
+.qty-group, .color-group {
+ display: flex;
+ flex-direction: column; /* Stack label and input */
+ align-items: flex-start;
+ gap: 0px;
+
+ label {
+ font-size: 0.6rem;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-weight: 600;
+ margin-bottom: 2px;
+ }
+}
+
+.color-group {
+ align-items: flex-start; /* Align label left */
+ /* margin-right removed */
+
+ /* Override margin in selector for this context */
+ ::ng-deep .color-selector-container {
+ margin-left: 0;
+ }
+}
+
+.qty-input {
+ width: 36px; /* Slightly smaller */
+ padding: 1px 2px;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ text-align: center;
+ font-size: 0.85rem;
+ background: white;
+ height: 24px; /* Explicit height to match color circle somewhat */
+ &:focus { outline: none; border-color: var(--color-brand); }
+}
+
+.btn-remove {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ width: 18px;
+ height: 18px;
+ border-radius: 4px;
+ border: none;
+ background: transparent;
+ color: var(--color-text-muted);
+ font-weight: bold;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s;
+ font-size: 0.8rem;
+
+ &:hover {
+ background: var(--color-danger-100);
+ color: var(--color-danger-500);
+ }
+}
+
+/* Prominent Add Button */
+.add-more-container {
+ margin-top: var(--space-2);
+}
+
+.btn-add-more {
+ width: 100%;
+ padding: var(--space-3);
+ background: var(--color-neutral-800);
+ color: white;
+ border: none;
+ border-radius: var(--radius-md);
+ font-weight: 600;
+ font-size: 0.9rem;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+
+ &:hover {
+ background: var(--color-neutral-900);
+ transform: translateY(-1px);
+ }
+ &:active { transform: translateY(0); }
+}
+
+.checkbox-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ height: 100%;
+ padding-top: var(--space-4);
+
+ input[type="checkbox"] {
+ width: 20px;
+ height: 20px;
+ accent-color: var(--color-brand);
+ }
+ label {
+ font-weight: 500;
+ cursor: pointer;
+ }
+}
+
+/* Progress Bar */
+.progress-container {
+ margin-bottom: var(--space-3);
+ text-align: center;
+ width: 100%;
+}
+.progress-bar {
+ height: 4px;
+ background: var(--color-border);
+ border-radius: 2px;
+ overflow: hidden;
+ margin-bottom: 0;
+ position: relative;
+ width: 100%;
+}
+.progress-fill {
+ height: 100%;
+ background: var(--color-brand);
+ width: 0%;
+ transition: width 0.2s ease-out;
+}
diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts
index 1a92133..4dd76ab 100644
--- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts
+++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts
@@ -1,4 +1,4 @@
-import { Component, input, output, signal } from '@angular/core';
+import { Component, input, output, signal, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
@@ -7,207 +7,22 @@ import { AppSelectComponent } from '../../../../shared/components/app-select/app
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 { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component';
import { QuoteRequest } from '../../services/quote-estimator.service';
+import { getColorHex } from '../../../../core/constants/colors.const';
+
+interface FormItem {
+ file: File;
+ quantity: number;
+ color: string;
+}
@Component({
selector: 'app-upload-form',
standalone: true,
- imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppSelectComponent, AppDropzoneComponent, AppButtonComponent, StlViewerComponent],
- template: `
-
- `,
- styles: [`
- .section { margin-bottom: var(--space-6); }
- .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;
- }
- }
-
- .checkbox-row {
- display: flex;
- align-items: center;
- gap: var(--space-3);
- height: 100%;
- padding-top: var(--space-4);
-
- input[type="checkbox"] {
- width: 20px;
- height: 20px;
- accent-color: var(--color-brand);
- }
- label {
- font-weight: 500;
- cursor: pointer;
- }
- }
-
- /* Progress Bar */
- .progress-container {
- 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: 4px;
- background: var(--color-border);
- border-radius: 2px;
- overflow: hidden;
- margin-bottom: 0;
- position: relative;
- width: 100%;
- }
- .progress-fill {
- height: 100%;
- background: var(--color-brand);
- width: 0%;
- transition: width 0.2s ease-out;
- }
- .progress-text { font-size: 0.875rem; color: var(--color-text-muted); }
- `]
+ imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppSelectComponent, AppDropzoneComponent, AppButtonComponent, StlViewerComponent, ColorSelectorComponent],
+ templateUrl: './upload-form.component.html',
+ styleUrl: './upload-form.component.scss'
})
export class UploadFormComponent {
mode = input<'easy' | 'advanced'>('easy');
@@ -217,7 +32,7 @@ export class UploadFormComponent {
form: FormGroup;
- files = signal([]);
+ items = signal([]);
selectedFile = signal(null);
materials = [
@@ -231,35 +46,44 @@ export class UploadFormComponent {
{ label: 'Standard', value: 'Standard' },
{ label: 'Alta definizione', value: 'High' }
];
-
- colors = [
- { label: 'Black', value: 'Black' },
- { label: 'White', value: 'White' },
- { label: 'Gray', value: 'Gray' },
- { label: 'Red', value: 'Red' },
- { label: 'Blue', value: 'Blue' },
- { label: 'Green', value: 'Green' },
- { label: 'Yellow', value: 'Yellow' }
+
+ nozzleDiameters = [
+ { label: '0.2 mm (+2 CHF)', value: 0.2 },
+ { label: '0.4 mm (Standard)', value: 0.4 },
+ { label: '0.6 mm (+2 CHF)', value: 0.6 },
+ { label: '0.8 mm (+2 CHF)', value: 0.8 }
];
+
infillPatterns = [
{ label: 'Grid', value: 'grid' },
{ label: 'Gyroid', value: 'gyroid' },
{ label: 'Cubic', value: 'cubic' },
{ label: 'Triangles', value: 'triangles' }
];
+
+ layerHeights = [
+ { label: '0.08 mm', value: 0.08 },
+ { label: '0.12 mm (High Quality - Slow)', value: 0.12 },
+ { label: '0.16 mm', value: 0.16 },
+ { label: '0.20 mm (Standard)', value: 0.20 },
+ { label: '0.24 mm', value: 0.24 },
+ { label: '0.28 mm', value: 0.28 }
+ ];
acceptedFormats = '.stl,.3mf,.step,.stp,.obj,.amf,.ply,.igs,.iges';
constructor(private fb: FormBuilder) {
this.form = this.fb.group({
- files: [[], Validators.required],
+ itemsTouched: [false], // Hack to track touched state for custom items list
material: ['PLA', Validators.required],
quality: ['Standard', Validators.required],
- quantity: [1, [Validators.required, Validators.min(1)]],
+ // Print Speed removed
notes: [''],
// Advanced fields
- color: ['Black'],
+ // Color removed from global form
infillDensity: [20, [Validators.min(0), Validators.max(100)]],
+ layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]],
+ nozzleDiameter: [0.4, Validators.required],
infillPattern: ['grid'],
supportEnabled: [false]
});
@@ -267,14 +91,15 @@ export class UploadFormComponent {
onFilesDropped(newFiles: File[]) {
const MAX_SIZE = 200 * 1024 * 1024; // 200MB
- const validFiles: File[] = [];
+ const validItems: FormItem[] = [];
let hasError = false;
for (const file of newFiles) {
if (file.size > MAX_SIZE) {
hasError = true;
} else {
- validFiles.push(file);
+ // Default color is Black
+ validItems.push({ file, quantity: 1, color: 'Black' });
}
}
@@ -282,32 +107,95 @@ export class UploadFormComponent {
alert("Alcuni file superano il limite di 200MB e non sono stati aggiunti.");
}
- if (validFiles.length > 0) {
- this.files.update(current => [...current, ...validFiles]);
- this.form.patchValue({ files: this.files() });
- this.form.get('files')?.markAsTouched();
- this.selectedFile.set(validFiles[validFiles.length - 1]);
+ if (validItems.length > 0) {
+ this.items.update(current => [...current, ...validItems]);
+ this.form.get('itemsTouched')?.setValue(true);
+ // Auto select last added
+ this.selectedFile.set(validItems[validItems.length - 1].file);
}
}
- selectFile(file: File) {
- this.selectedFile.set(file);
+ onAdditionalFilesSelected(event: Event) {
+ const input = event.target as HTMLInputElement;
+ if (input.files && input.files.length > 0) {
+ this.onFilesDropped(Array.from(input.files));
+ // Reset input so same files can be selected again if needed
+ input.value = '';
+ }
}
- clearFiles() {
- this.files.set([]);
- this.selectedFile.set(null);
- this.form.patchValue({ files: [] });
+ updateItemQuantityByName(fileName: string, quantity: number) {
+ this.items.update(current => {
+ return current.map(item => {
+ if (item.file.name === fileName) {
+ return { ...item, quantity };
+ }
+ return item;
+ });
+ });
+ }
+
+ selectFile(file: File) {
+ if (this.selectedFile() === file) {
+ // toggle off? no, keep active
+ } else {
+ this.selectedFile.set(file);
+ }
+ }
+
+ // Helper to get color of currently selected file
+ getSelectedFileColor(): string {
+ const file = this.selectedFile();
+ if (!file) return '#facf0a'; // Default
+
+ const item = this.items().find(i => i.file === file);
+ if (item) {
+ return getColorHex(item.color);
+ }
+ return '#facf0a';
+ }
+
+ updateItemQuantity(index: number, event: Event) {
+ const input = event.target as HTMLInputElement;
+ let val = parseInt(input.value, 10);
+ if (isNaN(val) || val < 1) val = 1;
+
+ this.items.update(current => {
+ const updated = [...current];
+ updated[index] = { ...updated[index], quantity: val };
+ return updated;
+ });
+ }
+
+ updateItemColor(index: number, newColor: string) {
+ this.items.update(current => {
+ const updated = [...current];
+ updated[index] = { ...updated[index], color: newColor };
+ return updated;
+ });
+ }
+
+ removeItem(index: number) {
+ this.items.update(current => {
+ const updated = [...current];
+ const removed = updated.splice(index, 1)[0];
+ if (this.selectedFile() === removed.file) {
+ this.selectedFile.set(null);
+ }
+ return updated;
+ });
}
onSubmit() {
- if (this.form.valid) {
+ if (this.form.valid && this.items().length > 0) {
this.submitRequest.emit({
+ items: this.items(), // Pass the items array including colors
...this.form.value,
mode: this.mode()
});
} else {
this.form.markAllAsTouched();
+ this.form.get('itemsTouched')?.setValue(true);
}
}
}
diff --git a/frontend/src/app/features/calculator/components/user-details/user-details.component.html b/frontend/src/app/features/calculator/components/user-details/user-details.component.html
new file mode 100644
index 0000000..a080cd6
--- /dev/null
+++ b/frontend/src/app/features/calculator/components/user-details/user-details.component.html
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ item.fileName }}
+ {{ item.material }} - {{ item.color || 'Default' }}
+
+
x{{ item.quantity }}
+
{{ (item.unitPrice * item.quantity) | currency:'CHF' }}
+
+
+
+
+
+ {{ 'QUOTE.TOTAL' | translate }}
+ {{ quote()!.totalPrice | currency:'CHF' }}
+
+
+
+
+
+
+
diff --git a/frontend/src/app/features/calculator/components/user-details/user-details.component.scss b/frontend/src/app/features/calculator/components/user-details/user-details.component.scss
new file mode 100644
index 0000000..734880a
--- /dev/null
+++ b/frontend/src/app/features/calculator/components/user-details/user-details.component.scss
@@ -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
+ }
+}
diff --git a/frontend/src/app/features/calculator/components/user-details/user-details.component.ts b/frontend/src/app/features/calculator/components/user-details/user-details.component.ts
new file mode 100644
index 0000000..9656001
--- /dev/null
+++ b/frontend/src/app/features/calculator/components/user-details/user-details.component.ts
@@ -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();
+ submitOrder = output();
+ cancel = output();
+
+ 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();
+ }
+}
diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts
index 64d434c..e7c8620 100644
--- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts
+++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts
@@ -1,29 +1,40 @@
import { Injectable, inject, signal } from '@angular/core';
-import { HttpClient } from '@angular/common/http';
-import { Observable, forkJoin, of } from 'rxjs';
+import { HttpClient, HttpEventType } from '@angular/common/http';
+import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { environment } from '../../../../environments/environment';
export interface QuoteRequest {
- files: File[];
+ items: { file: File, quantity: number, color?: string }[];
material: string;
quality: string;
- quantity: number;
notes?: string;
- color?: string;
infillDensity?: number;
infillPattern?: string;
supportEnabled?: boolean;
+ layerHeight?: number;
+ nozzleDiameter?: number;
mode: 'easy' | 'advanced';
}
+export interface QuoteItem {
+ fileName: string;
+ unitPrice: number;
+ unitTime: number; // seconds
+ unitWeight: number; // grams
+ quantity: number;
+ material?: string;
+ color?: string;
+}
+
export interface QuoteResult {
- price: number;
- currency: string;
- printTimeHours: number;
- printTimeMinutes: number;
- materialUsageGrams: number;
+ items: QuoteItem[];
setupCost: number;
+ currency: string;
+ totalPrice: number;
+ totalTimeHours: number;
+ totalTimeMinutes: number;
+ totalWeight: number;
}
interface BackendResponse {
@@ -45,88 +56,30 @@ export class QuoteEstimatorService {
private http = inject(HttpClient);
calculate(request: QuoteRequest): Observable {
- 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` originally, now we need progress.
- // Let's change return type to `Observable` 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();
-
- // 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.
+ if (request.items.length === 0) return of();
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
-
- // 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 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', file);
+ 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.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');
+ if (request.layerHeight) formData.append('layer_height', request.layerHeight.toString());
+ if (request.nozzleDiameter) formData.append('nozzle_diameter', request.nozzleDiameter.toString());
}
const headers: any = {};
@@ -138,92 +91,114 @@ export class QuoteEstimatorService {
reportProgress: true,
observe: 'events'
}).pipe(
- map(event => ({ file, event })),
- catchError(err => of({ file, error: err }))
+ map(event => ({ item, event, index })),
+ catchError(err => of({ item, error: err, index }))
);
});
- // 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[] = [];
-
// Subscribe to all
- uploads.forEach((obs, index) => {
+ uploads.forEach((obs) => {
obs.subscribe({
next: (wrapper: any) => {
+ const idx = wrapper.index;
+
if (wrapper.error) {
- // handled in final calculation
- finalResponses[index] = { success: false, data: { cost: { total:0 }, print_time_seconds:0, material_grams:0 } };
- return;
+ finalResponses[idx] = { success: false, fileName: wrapper.item.file.name };
+ // Even if error, we count as complete
+ // But we need to handle completion logic carefully.
+ // For simplicity, let's treat it as complete but check later.
}
const event = wrapper.event;
- if (event.type === 1) { // HttpEventType.UploadProgress
+ if (event && event.type === HttpEventType.UploadProgress) {
if (event.total) {
const percent = Math.round((100 * event.loaded) / event.total);
- allProgress[index] = percent;
+ allProgress[idx] = percent;
// Emit average progress
- const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / total);
- observer.next(avg); // Emit number for progress
+ const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
+ observer.next(avg);
+ }
+ } else if ((event && event.type === HttpEventType.Response) || wrapper.error) {
+ // It's done (either response or error caught above)
+ if (!finalResponses[idx]) { // only if not already set by error
+ allProgress[idx] = 100;
+ if (wrapper.error) {
+ finalResponses[idx] = { success: false, fileName: wrapper.item.file.name };
+ } else {
+ finalResponses[idx] = { ...event.body, fileName: wrapper.item.file.name, originalQty: wrapper.item.quantity };
+ }
+ completedRequests++;
}
- } else if (event.type === 4) { // HttpEventType.Response
- allProgress[index] = 100;
- finalResponses[index] = event.body;
- completedRequests++;
- if (completedRequests === total) {
+ if (completedRequests === totalItems) {
// All done
- observer.next(100); // Ensure complete
+ observer.next(100);
- // Calculate Totals
- const valid = finalResponses.filter(r => r && r.success);
- if (valid.length === 0 && finalResponses.length > 0) {
+ // Calculate Results
+ let setupCost = 10;
+
+ if (request.nozzleDiameter && request.nozzleDiameter !== 0.4) {
+ setupCost += 2;
+ }
+
+ const items: QuoteItem[] = [];
+
+ finalResponses.forEach((res, idx) => {
+ if (res && res.success) {
+ const originalItem = request.items[idx];
+ 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
+ material: request.material,
+ color: originalItem.color || 'Default'
+ });
+ }
+ });
+
+ if (items.length === 0) {
+ // If at least one failed? Or all?
+ // For now if NO items succeeded, error.
observer.error('All calculations failed.');
return;
}
- let totalPrice = 0;
+ // Initial Aggregation
+ let grandTotal = setupCost;
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;
+
+ items.forEach(item => {
+ grandTotal += item.unitPrice * item.quantity;
+ totalTime += item.unitTime * item.quantity;
+ totalWeight += item.unitWeight * item.quantity;
});
- 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,
+ items,
+ setupCost,
currency: 'CHF',
- printTimeHours: totalHours,
- printTimeMinutes: totalMinutes,
- materialUsageGrams: Math.ceil(totalWeight),
- setupCost
+ totalPrice: Math.round(grandTotal * 100) / 100,
+ totalTimeHours: totalHours,
+ totalTimeMinutes: totalMinutes,
+ totalWeight: Math.ceil(totalWeight)
};
- observer.next(result); // Emit final object
+ observer.next(result);
observer.complete();
}
}
},
error: (err) => {
- console.error('Error in request', err);
- finalResponses[index] = { success: false };
+ console.error('Error in request subscription', err);
+ // Should be caught by inner pipe, but safety net
completedRequests++;
- if (completedRequests === total) {
+ if (completedRequests === totalItems) {
observer.error('Requests failed');
}
}
diff --git a/frontend/src/app/features/contact/components/contact-form/contact-form.component.html b/frontend/src/app/features/contact/components/contact-form/contact-form.component.html
new file mode 100644
index 0000000..b0f7bed
--- /dev/null
+++ b/frontend/src/app/features/contact/components/contact-form/contact-form.component.html
@@ -0,0 +1,77 @@
+@if (sent()) {
+
+} @else {
+
+}
diff --git a/frontend/src/app/features/contact/components/contact-form/contact-form.component.scss b/frontend/src/app/features/contact/components/contact-form/contact-form.component.scss
new file mode 100644
index 0000000..76186ad
--- /dev/null
+++ b/frontend/src/app/features/contact/components/contact-form/contact-form.component.scss
@@ -0,0 +1,135 @@
+.form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); }
+label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); }
+.hint { font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: var(--space-2); }
+
+.form-control {
+ padding: 0.5rem 0.75rem;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ width: 100%;
+ background: var(--color-bg-card);
+ color: var(--color-text);
+ font-family: inherit;
+ &:focus { outline: none; border-color: var(--color-brand); }
+}
+
+select.form-control {
+ appearance: none;
+ background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
+ background-repeat: no-repeat;
+ background-position: right 1rem center;
+ background-size: 1em;
+}
+
+.row {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ margin-bottom: var(--space-4);
+ @media(min-width: 768px) {
+ flex-direction: row;
+ .col { flex: 1; margin-bottom: 0; }
+ }
+}
+
+app-input.col { width: 100%; }
+
+/* User Type Selector Styles */
+.user-type-selector {
+ display: flex;
+ background-color: var(--color-neutral-100);
+ border-radius: var(--radius-md);
+ padding: 4px;
+ margin-bottom: var(--space-4);
+ gap: 4px;
+ width: 100%; /* Full width */
+ max-width: 400px; /* Limit on desktop */
+}
+
+.type-option {
+ flex: 1; /* Equal width */
+ text-align: center;
+ padding: 8px 16px;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--color-text-muted);
+ transition: all 0.2s ease;
+ user-select: none;
+
+ &:hover { color: var(--color-text); }
+
+ &.selected {
+ background-color: var(--color-brand);
+ color: #000;
+ font-weight: 600;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.05);
+ }
+}
+
+.company-fields {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ padding-left: var(--space-4);
+ border-left: 2px solid var(--color-border);
+ margin-bottom: var(--space-4);
+}
+
+/* File Upload Styles */
+.drop-zone {
+ border: 2px dashed var(--color-border);
+ border-radius: var(--radius-md);
+ padding: var(--space-6);
+ text-align: center;
+ cursor: pointer;
+ color: var(--color-text-muted);
+ transition: all 0.2s;
+ &:hover { border-color: var(--color-brand); color: var(--color-brand); }
+}
+
+.file-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
+ gap: var(--space-3);
+ margin-top: var(--space-3);
+}
+
+.file-item {
+ position: relative;
+ background: var(--color-neutral-100);
+ border-radius: var(--radius-sm);
+ padding: var(--space-2);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ aspect-ratio: 1;
+ overflow: hidden;
+}
+
+.preview-img {
+ width: 100%; height: 100%; object-fit: cover; position: absolute; top:0; left:0;
+ border-radius: var(--radius-sm);
+}
+
+.file-icon {
+ font-weight: 700; color: var(--color-text-muted); font-size: 0.8rem;
+}
+
+.file-name {
+ font-size: 0.65rem; color: var(--color-text); white-space: nowrap; overflow: hidden;
+ text-overflow: ellipsis; width: 100%; text-align: center; position: absolute; bottom: 2px;
+ padding: 0 4px; z-index: 2; background: rgba(255,255,255,0.8);
+}
+
+.remove-btn {
+ position: absolute; top: 2px; right: 2px; z-index: 10;
+ background: rgba(0,0,0,0.5); color: white; border: none; border-radius: 50%;
+ width: 18px; height: 18px; font-size: 12px; cursor: pointer;
+ display: flex; align-items: center; justify-content: center; line-height: 1;
+ &:hover { background: red; }
+}
+
+/* Success State styles moved to shared component */
diff --git a/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts b/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts
index 30bf471..30eec60 100644
--- a/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts
+++ b/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts
@@ -12,220 +12,14 @@ interface FilePreview {
type: 'image' | 'pdf' | '3d' | 'other';
}
+import { SuccessStateComponent } from '../../../../shared/components/success-state/success-state.component';
+
@Component({
selector: 'app-contact-form',
standalone: true,
- imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppButtonComponent],
- template: `
-
-
-
- {{ 'CONTACT.REQ_TYPE_LABEL' | translate }} *
-
-
- {{ type.label | translate }}
-
-
-
-
-
-
-
-
-
- {{ 'CONTACT.TYPE_PRIVATE' | translate }}
-
-
- {{ 'CONTACT.TYPE_COMPANY' | translate }}
-
-
-
-
-
-
-
-
-
-
- {{ 'CONTACT.LABEL_MESSAGE' | translate }}
-
-
-
-
-
-
-
-
- {{ sent() ? ('CONTACT.MSG_SENT' | translate) : ('CONTACT.SEND' | translate) }}
-
-
-
- `,
- styles: [`
- .form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); }
- label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); }
- .hint { font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: var(--space-2); }
-
- .form-control {
- padding: 0.5rem 0.75rem;
- border: 1px solid var(--color-border);
- border-radius: var(--radius-md);
- width: 100%;
- background: var(--color-bg-card);
- color: var(--color-text);
- font-family: inherit;
- &:focus { outline: none; border-color: var(--color-brand); }
- }
-
- select.form-control {
- appearance: none;
- background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
- background-repeat: no-repeat;
- background-position: right 1rem center;
- background-size: 1em;
- }
-
- .row {
- display: flex;
- flex-direction: column;
- gap: var(--space-4);
- margin-bottom: var(--space-4);
- @media(min-width: 768px) {
- flex-direction: row;
- .col { flex: 1; margin-bottom: 0; }
- }
- }
-
- app-input.col { width: 100%; }
-
- /* User Type Selector Styles */
- .user-type-selector {
- display: flex;
- background-color: var(--color-neutral-100);
- border-radius: var(--radius-md);
- padding: 4px;
- margin-bottom: var(--space-4);
- gap: 4px;
- width: 100%; /* Full width */
- max-width: 400px; /* Limit on desktop */
- }
-
- .type-option {
- flex: 1; /* Equal width */
- text-align: center;
- padding: 8px 16px;
- border-radius: var(--radius-sm);
- cursor: pointer;
- font-size: 0.875rem;
- font-weight: 500;
- color: var(--color-text-muted);
- transition: all 0.2s ease;
- user-select: none;
-
- &:hover { color: var(--color-text); }
-
- &.selected {
- background-color: var(--color-brand);
- color: #000;
- font-weight: 600;
- box-shadow: 0 1px 2px rgba(0,0,0,0.05);
- }
- }
-
- .company-fields {
- display: flex;
- flex-direction: column;
- gap: var(--space-4);
- padding-left: var(--space-4);
- border-left: 2px solid var(--color-border);
- margin-bottom: var(--space-4);
- }
-
- /* File Upload Styles */
- .drop-zone {
- border: 2px dashed var(--color-border);
- border-radius: var(--radius-md);
- padding: var(--space-6);
- text-align: center;
- cursor: pointer;
- color: var(--color-text-muted);
- transition: all 0.2s;
- &:hover { border-color: var(--color-brand); color: var(--color-brand); }
- }
-
- .file-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
- gap: var(--space-3);
- margin-top: var(--space-3);
- }
-
- .file-item {
- position: relative;
- background: var(--color-neutral-100);
- border-radius: var(--radius-sm);
- padding: var(--space-2);
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- aspect-ratio: 1;
- overflow: hidden;
- }
-
- .preview-img {
- width: 100%; height: 100%; object-fit: cover; position: absolute; top:0; left:0;
- border-radius: var(--radius-sm);
- }
-
- .file-icon {
- font-weight: 700; color: var(--color-text-muted); font-size: 0.8rem;
- }
-
- .file-name {
- font-size: 0.65rem; color: var(--color-text); white-space: nowrap; overflow: hidden;
- text-overflow: ellipsis; width: 100%; text-align: center; position: absolute; bottom: 2px;
- padding: 0 4px; z-index: 2; background: rgba(255,255,255,0.8);
- }
-
- .remove-btn {
- position: absolute; top: 2px; right: 2px; z-index: 10;
- background: rgba(0,0,0,0.5); color: white; border: none; border-radius: 50%;
- width: 18px; height: 18px; font-size: 12px; cursor: pointer;
- display: flex; align-items: center; justify-content: center; line-height: 1;
- &:hover { background: red; }
- }
- `]
+ imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppButtonComponent, SuccessStateComponent],
+ templateUrl: './contact-form.component.html',
+ styleUrl: './contact-form.component.scss'
})
export class ContactFormComponent {
form: FormGroup;
@@ -369,13 +163,14 @@ export class ContactFormComponent {
console.log('Form Submit:', formData);
this.sent.set(true);
- setTimeout(() => {
- this.sent.set(false);
- this.form.reset({ requestType: 'custom', isCompany: false });
- this.files.set([]);
- }, 3000);
} else {
this.form.markAllAsTouched();
}
}
+
+ resetForm() {
+ this.sent.set(false);
+ this.form.reset({ requestType: 'custom', isCompany: false });
+ this.files.set([]);
+ }
}
diff --git a/frontend/src/app/features/contact/contact-page.component.html b/frontend/src/app/features/contact/contact-page.component.html
new file mode 100644
index 0000000..75d5c3c
--- /dev/null
+++ b/frontend/src/app/features/contact/contact-page.component.html
@@ -0,0 +1,12 @@
+
+
+
diff --git a/frontend/src/app/features/contact/contact-page.component.scss b/frontend/src/app/features/contact/contact-page.component.scss
new file mode 100644
index 0000000..f495fe5
--- /dev/null
+++ b/frontend/src/app/features/contact/contact-page.component.scss
@@ -0,0 +1,14 @@
+.contact-hero {
+ padding: 3rem 0 2rem;
+ background: var(--color-bg);
+ text-align: center;
+}
+.subtitle {
+ color: var(--color-text-muted);
+ max-width: 640px;
+ margin: var(--space-3) auto 0;
+}
+.content {
+ padding: 2rem 0 5rem;
+ max-width: 800px;
+}
diff --git a/frontend/src/app/features/contact/contact-page.component.ts b/frontend/src/app/features/contact/contact-page.component.ts
index c0b4630..0a27260 100644
--- a/frontend/src/app/features/contact/contact-page.component.ts
+++ b/frontend/src/app/features/contact/contact-page.component.ts
@@ -8,35 +8,7 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
selector: 'app-contact-page',
standalone: true,
imports: [CommonModule, TranslateModule, ContactFormComponent, AppCardComponent],
- template: `
-
-
-
- `,
- styles: [`
- .contact-hero {
- padding: 3rem 0 2rem;
- background: var(--color-bg);
- text-align: center;
- }
- .subtitle {
- color: var(--color-text-muted);
- max-width: 640px;
- margin: var(--space-3) auto 0;
- }
- .content {
- padding: 2rem 0 5rem;
- max-width: 800px;
- }
- `]
+ templateUrl: './contact-page.component.html',
+ styleUrl: './contact-page.component.scss'
})
export class ContactPageComponent {}
diff --git a/frontend/src/app/features/home/home.component.html b/frontend/src/app/features/home/home.component.html
index 8380ff0..f1c0826 100644
--- a/frontend/src/app/features/home/home.component.html
+++ b/frontend/src/app/features/home/home.component.html
@@ -7,13 +7,17 @@
Prezzo e tempi in pochi secondi.
Dal file 3D al pezzo finito.
+
+ Il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.
+
- Lavoriamo con trasparenza su costi, qualità e tempi. Produciamo prototipi, pezzi personalizzati
- e piccole serie con supporto tecnico reale.
+ Realizziamo pezzi unici e produzioni in serie. Se hai il file, il preventivo è istantaneo.
+ Se devi ancora crearlo, il nostro team di design lo progetterà per te.
-
Parla con noi
+
Calcola Preventivo
Vai allo shop
+
Parla con noi
@@ -22,13 +26,12 @@
-
Preventivo immediato
+
Preventivo immediato in pochi secondi
- Carica il file 3D e ottieni subito costo e tempo di stampa. Nessuna registrazione.
+ Nessuna registrazione necessaria. Non salviamo il tuo file fino al momento dell'ordine e la stima è effettuata tramite vero slicing.
Formati supportati: STL, 3MF, STEP, OBJ
- Materiali disponibili: PLA, PETG, TPU
Qualità: bozza, standard, alta definizione
@@ -45,19 +48,9 @@
Scegli materiale e qualità
Ricevi subito costo e tempo
-
Apri calcolatore
-
Parla con noi
+
Parla con noi
@@ -74,20 +67,32 @@
+
+
+
Prototipazione veloce
- Valida idee e funzioni in pochi giorni con preventivo immediato.
+ Dal file digitale al modello fisico in 24/48 ore. Verifica ergonomia, incastri e funzionamento prima dello stampo definitivo.
+
+
+
Pezzi personalizzati
- Componenti unici o in mini serie per clienti, macchine e prodotti.
+ Componenti unici impossibili da trovare in commercio. Riproduciamo parti rotte o creiamo adattamenti su misura.
+
+
+
Piccole serie
- Produzione controllata fino a 500 pezzi con qualità costante.
+ Produzione ponte o serie limitate (10-500 pezzi) con qualità ripetibile. Ideale per validazione di mercato.
+
+
+
Consulenza e CAD
- Supporto tecnico per progettazione, modifiche e ottimizzazione.
+ Non hai il file 3D? Ti aiutiamo a progettarlo, ottimizzarlo per la stampa e scegliere il materiale giusto.
@@ -108,7 +113,7 @@
Scopri i prodotti
-
Richiedi una soluzione
+
Richiedi una soluzione
@@ -136,25 +141,12 @@
3D fab è un laboratorio tecnico di stampa 3D. Seguiamo progetti dalla consulenza iniziale
alla produzione, con tempi chiari e supporto diretto.
-
- Qui puoi inserire descrizioni più dettagliate del team, del laboratorio e dei progetti in corso.
-
-
Contattaci
+
Contattaci
diff --git a/frontend/src/app/features/home/home.component.scss b/frontend/src/app/features/home/home.component.scss
index 5e406d9..147d469 100644
--- a/frontend/src/app/features/home/home.component.scss
+++ b/frontend/src/app/features/home/home.component.scss
@@ -15,10 +15,10 @@
position: absolute;
inset: 0;
@include patterns.pattern-grid(var(--color-neutral-900), 40px, 1px);
- opacity: 0.12;
+ opacity: 0.06;
z-index: 0;
pointer-events: none;
- mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
+ mask-image: linear-gradient(to bottom, black 70%, transparent 100%);
}
}
@@ -43,6 +43,7 @@
position: relative;
z-index: 1;
}
+
.hero-copy { animation: fadeUp 0.8s ease both; }
.hero-panel { animation: fadeUp 0.8s ease 0.15s both; }
@@ -61,10 +62,18 @@
letter-spacing: -0.02em;
margin-bottom: var(--space-4);
}
+ .hero-lead {
+ font-size: 1.35rem;
+ font-weight: 500;
+ color: var(--color-neutral-900);
+ margin-bottom: var(--space-3);
+ max-width: 600px;
+ }
.hero-subtitle {
- font-size: 1.2rem;
+ font-size: 1.1rem;
color: var(--color-text-muted);
max-width: 560px;
+ line-height: 1.6;
}
.hero-actions {
display: flex;
@@ -135,6 +144,9 @@
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
font-weight: 600;
+ color: var(--color-brand-600);
+ background: var(--color-brand-50);
+ border-color: var(--color-brand-200);
}
.quote-steps {
list-style: none;
@@ -177,14 +189,10 @@
.capabilities {
position: relative;
+ border-bottom: 1px solid var(--color-border);
}
.capabilities-bg {
- position: absolute;
- inset: 0;
- @include patterns.pattern-rectilinear(var(--color-neutral-900), 24px, 1px);
- opacity: 0.05;
- pointer-events: none;
- z-index: 0;
+ display: none;
}
.section { padding: 5.5rem 0; position: relative; }
@@ -194,24 +202,13 @@
.text-muted { color: var(--color-text-muted); }
.calculator {
- background: var(--color-neutral-50);
- border-top: 1px solid var(--color-border);
- border-bottom: 1px solid var(--color-border);
position: relative;
- // Honeycomb Pattern
- &::before {
- content: '';
- position: absolute;
- inset: 0;
- @include patterns.pattern-honeycomb(var(--color-neutral-900), 24px);
- opacity: 0.04;
- pointer-events: none;
- }
+ border-bottom: 1px solid var(--color-border);
}
.calculator-grid {
display: grid;
gap: var(--space-10);
- align-items: center;
+ align-items: start;
position: relative;
z-index: 1;
}
@@ -225,6 +222,19 @@
gap: var(--space-4);
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
+
+ .card-image-placeholder {
+ width: 100%;
+ height: 160px;
+ background: var(--color-neutral-100);
+ margin: -1.5rem -1.5rem 1.5rem -1.5rem; /* Negative margins to bleed to edge */
+ width: calc(100% + 3rem);
+ border-bottom: 1px solid var(--color-border);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-neutral-400);
+ }
.shop {
background: var(--color-neutral-50);
@@ -282,24 +292,21 @@
align-items: center;
}
.about-media {
- display: grid;
- gap: var(--space-4);
+ position: relative;
}
- .media-grid {
- display: grid;
- gap: var(--space-4);
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
- }
- .media-tile {
- display: grid;
- gap: var(--space-2);
- }
- .media-photo {
+
+ .about-feature-image {
width: 100%;
- aspect-ratio: 4 / 3;
+ height: 100%;
+ min-height: 320px;
+ object-fit: cover;
border-radius: var(--radius-lg);
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-text-muted);
}
.media-tile p {
margin: 0;
@@ -313,6 +320,7 @@
@media (min-width: 960px) {
.hero-grid { grid-template-columns: 1.1fr 0.9fr; }
.calculator-grid { grid-template-columns: 1.1fr 0.9fr; }
+ .calculator-grid { grid-template-columns: 1.1fr 0.9fr; }
.split { grid-template-columns: 1.1fr 0.9fr; }
.about-grid { grid-template-columns: 1.1fr 0.9fr; }
}
diff --git a/frontend/src/app/features/shop/components/product-card/product-card.component.html b/frontend/src/app/features/shop/components/product-card/product-card.component.html
new file mode 100644
index 0000000..cc862a0
--- /dev/null
+++ b/frontend/src/app/features/shop/components/product-card/product-card.component.html
@@ -0,0 +1,13 @@
+
+
+
+
{{ product().category }}
+
+
+
+
diff --git a/frontend/src/app/features/shop/components/product-card/product-card.component.scss b/frontend/src/app/features/shop/components/product-card/product-card.component.scss
new file mode 100644
index 0000000..db3d954
--- /dev/null
+++ b/frontend/src/app/features/shop/components/product-card/product-card.component.scss
@@ -0,0 +1,18 @@
+.product-card {
+ background: var(--color-bg-card);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ transition: box-shadow 0.2s;
+ &:hover { box-shadow: var(--shadow-md); }
+}
+.image-placeholder {
+ height: 200px;
+ background-color: var(--color-neutral-200);
+}
+.content { padding: var(--space-4); }
+.category { font-size: 0.75rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
+.name { font-size: 1.125rem; margin: var(--space-2) 0; a { color: var(--color-text); text-decoration: none; &:hover { color: var(--color-brand); } } }
+.footer { display: flex; justify-content: space-between; align-items: center; margin-top: var(--space-4); }
+.price { font-weight: 700; color: var(--color-brand); }
+.view-btn { font-size: 0.875rem; font-weight: 500; }
diff --git a/frontend/src/app/features/shop/components/product-card/product-card.component.ts b/frontend/src/app/features/shop/components/product-card/product-card.component.ts
index 34d61bc..aa7833d 100644
--- a/frontend/src/app/features/shop/components/product-card/product-card.component.ts
+++ b/frontend/src/app/features/shop/components/product-card/product-card.component.ts
@@ -7,41 +7,8 @@ import { Product } from '../../services/shop.service';
selector: 'app-product-card',
standalone: true,
imports: [CommonModule, RouterLink],
- template: `
-
-
-
-
{{ product().category }}
-
-
-
-
- `,
- styles: [`
- .product-card {
- background: var(--color-bg-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-lg);
- overflow: hidden;
- transition: box-shadow 0.2s;
- &:hover { box-shadow: var(--shadow-md); }
- }
- .image-placeholder {
- height: 200px;
- background-color: var(--color-neutral-200);
- }
- .content { padding: var(--space-4); }
- .category { font-size: 0.75rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
- .name { font-size: 1.125rem; margin: var(--space-2) 0; a { color: var(--color-text); text-decoration: none; &:hover { color: var(--color-brand); } } }
- .footer { display: flex; justify-content: space-between; align-items: center; margin-top: var(--space-4); }
- .price { font-weight: 700; color: var(--color-brand); }
- .view-btn { font-size: 0.875rem; font-weight: 500; }
- `]
+ templateUrl: './product-card.component.html',
+ styleUrl: './product-card.component.scss'
})
export class ProductCardComponent {
product = input.required();
diff --git a/frontend/src/app/features/shop/product-detail.component.html b/frontend/src/app/features/shop/product-detail.component.html
new file mode 100644
index 0000000..a08b543
--- /dev/null
+++ b/frontend/src/app/features/shop/product-detail.component.html
@@ -0,0 +1,25 @@
+
+
← {{ 'SHOP.BACK' | translate }}
+
+ @if (product(); as p) {
+
+
+
+
+
{{ p.category }}
+
{{ p.name }}
+
{{ p.price | currency:'EUR' }}
+
+
{{ p.description }}
+
+
+
+ {{ 'SHOP.ADD_CART' | translate }}
+
+
+
+
+ } @else {
+
Prodotto non trovato.
+ }
+
diff --git a/frontend/src/app/features/shop/product-detail.component.scss b/frontend/src/app/features/shop/product-detail.component.scss
new file mode 100644
index 0000000..4e81bb4
--- /dev/null
+++ b/frontend/src/app/features/shop/product-detail.component.scss
@@ -0,0 +1,20 @@
+.wrapper { padding-top: var(--space-8); }
+.back-link { display: inline-block; margin-bottom: var(--space-6); color: var(--color-text-muted); }
+
+.detail-grid {
+ display: grid;
+ gap: var(--space-8);
+ @media(min-width: 768px) {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+.image-box {
+ background-color: var(--color-neutral-200);
+ border-radius: var(--radius-lg);
+ aspect-ratio: 1;
+}
+
+.category { color: var(--color-brand); font-weight: 600; text-transform: uppercase; font-size: 0.875rem; }
+.price { font-size: 1.5rem; font-weight: 700; color: var(--color-text); margin: var(--space-4) 0; }
+.desc { color: var(--color-text-muted); line-height: 1.6; margin-bottom: var(--space-8); }
diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts
index 8049f7d..1e8fa74 100644
--- a/frontend/src/app/features/shop/product-detail.component.ts
+++ b/frontend/src/app/features/shop/product-detail.component.ts
@@ -9,55 +9,8 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto
selector: 'app-product-detail',
standalone: true,
imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent],
- template: `
-
-
← {{ 'SHOP.BACK' | translate }}
-
- @if (product(); as p) {
-
-
-
-
-
{{ p.category }}
-
{{ p.name }}
-
{{ p.price | currency:'EUR' }}
-
-
{{ p.description }}
-
-
-
- {{ 'SHOP.ADD_CART' | translate }}
-
-
-
-
- } @else {
-
Prodotto non trovato.
- }
-
- `,
- styles: [`
- .wrapper { padding-top: var(--space-8); }
- .back-link { display: inline-block; margin-bottom: var(--space-6); color: var(--color-text-muted); }
-
- .detail-grid {
- display: grid;
- gap: var(--space-8);
- @media(min-width: 768px) {
- grid-template-columns: 1fr 1fr;
- }
- }
-
- .image-box {
- background-color: var(--color-neutral-200);
- border-radius: var(--radius-lg);
- aspect-ratio: 1;
- }
-
- .category { color: var(--color-brand); font-weight: 600; text-transform: uppercase; font-size: 0.875rem; }
- .price { font-size: 1.5rem; font-weight: 700; color: var(--color-text); margin: var(--space-4) 0; }
- .desc { color: var(--color-text-muted); line-height: 1.6; margin-bottom: var(--space-8); }
- `]
+ templateUrl: './product-detail.component.html',
+ styleUrl: './product-detail.component.scss'
})
export class ProductDetailComponent {
// Input binding from router
diff --git a/frontend/src/app/features/shop/shop-page.component.html b/frontend/src/app/features/shop/shop-page.component.html
new file mode 100644
index 0000000..f43032e
--- /dev/null
+++ b/frontend/src/app/features/shop/shop-page.component.html
@@ -0,0 +1,12 @@
+
+
{{ 'SHOP.TITLE' | translate }}
+
{{ 'SHOP.SUBTITLE' | translate }}
+
+
+
+
+ @for (product of products(); track product.id) {
+
+ }
+
+
diff --git a/frontend/src/app/features/shop/shop-page.component.scss b/frontend/src/app/features/shop/shop-page.component.scss
new file mode 100644
index 0000000..507fea1
--- /dev/null
+++ b/frontend/src/app/features/shop/shop-page.component.scss
@@ -0,0 +1,7 @@
+.hero { padding: var(--space-8) 0; text-align: center; }
+.subtitle { color: var(--color-text-muted); margin-bottom: var(--space-8); }
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: var(--space-6);
+}
diff --git a/frontend/src/app/features/shop/shop-page.component.ts b/frontend/src/app/features/shop/shop-page.component.ts
index 53bc452..8f89098 100644
--- a/frontend/src/app/features/shop/shop-page.component.ts
+++ b/frontend/src/app/features/shop/shop-page.component.ts
@@ -8,29 +8,8 @@ import { ProductCardComponent } from './components/product-card/product-card.com
selector: 'app-shop-page',
standalone: true,
imports: [CommonModule, TranslateModule, ProductCardComponent],
- template: `
-
-
{{ 'SHOP.TITLE' | translate }}
-
{{ 'SHOP.SUBTITLE' | translate }}
-
-
-
-
- @for (product of products(); track product.id) {
-
- }
-
-
- `,
- styles: [`
- .hero { padding: var(--space-8) 0; text-align: center; }
- .subtitle { color: var(--color-text-muted); margin-bottom: var(--space-8); }
- .grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
- gap: var(--space-6);
- }
- `]
+ templateUrl: './shop-page.component.html',
+ styleUrl: './shop-page.component.scss'
})
export class ShopPageComponent {
products = signal([]);
diff --git a/frontend/src/app/shared/components/app-alert/app-alert.component.html b/frontend/src/app/shared/components/app-alert/app-alert.component.html
new file mode 100644
index 0000000..e377c49
--- /dev/null
+++ b/frontend/src/app/shared/components/app-alert/app-alert.component.html
@@ -0,0 +1,9 @@
+
+
+ @if(type() === 'info') { ℹ️ }
+ @if(type() === 'warning') { ⚠️ }
+ @if(type() === 'error') { ❌ }
+ @if(type() === 'success') { ✅ }
+
+
+
diff --git a/frontend/src/app/shared/components/app-alert/app-alert.component.scss b/frontend/src/app/shared/components/app-alert/app-alert.component.scss
new file mode 100644
index 0000000..2d4285b
--- /dev/null
+++ b/frontend/src/app/shared/components/app-alert/app-alert.component.scss
@@ -0,0 +1,12 @@
+.alert {
+ padding: var(--space-4);
+ border-radius: var(--radius-md);
+ display: flex;
+ gap: var(--space-3);
+ font-size: 0.875rem;
+ margin-bottom: var(--space-4);
+}
+.info { background: var(--color-neutral-100); color: var(--color-neutral-800); }
+.warning { background: #fefce8; color: #854d0e; border: 1px solid #fde047; }
+.error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
+.success { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
diff --git a/frontend/src/app/shared/components/app-alert/app-alert.component.ts b/frontend/src/app/shared/components/app-alert/app-alert.component.ts
index c187d52..9ad0b51 100644
--- a/frontend/src/app/shared/components/app-alert/app-alert.component.ts
+++ b/frontend/src/app/shared/components/app-alert/app-alert.component.ts
@@ -5,31 +5,8 @@ import { CommonModule } from '@angular/common';
selector: 'app-alert',
standalone: true,
imports: [CommonModule],
- template: `
-
-
- @if(type() === 'info') { ℹ️ }
- @if(type() === 'warning') { ⚠️ }
- @if(type() === 'error') { ❌ }
- @if(type() === 'success') { ✅ }
-
-
-
- `,
- styles: [`
- .alert {
- padding: var(--space-4);
- border-radius: var(--radius-md);
- display: flex;
- gap: var(--space-3);
- font-size: 0.875rem;
- margin-bottom: var(--space-4);
- }
- .info { background: var(--color-neutral-100); color: var(--color-neutral-800); }
- .warning { background: #fefce8; color: #854d0e; border: 1px solid #fde047; }
- .error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
- .success { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
- `]
+ templateUrl: './app-alert.component.html',
+ styleUrl: './app-alert.component.scss'
})
export class AppAlertComponent {
type = input<'info' | 'warning' | 'error' | 'success'>('info');
diff --git a/frontend/src/app/shared/components/app-button/app-button.component.html b/frontend/src/app/shared/components/app-button/app-button.component.html
new file mode 100644
index 0000000..c4328a6
--- /dev/null
+++ b/frontend/src/app/shared/components/app-button/app-button.component.html
@@ -0,0 +1,7 @@
+
+
+
diff --git a/frontend/src/app/shared/components/app-button/app-button.component.scss b/frontend/src/app/shared/components/app-button/app-button.component.scss
new file mode 100644
index 0000000..407d667
--- /dev/null
+++ b/frontend/src/app/shared/components/app-button/app-button.component.scss
@@ -0,0 +1,49 @@
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.5rem 1rem;
+ border-radius: var(--radius-md);
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.2s, color 0.2s, border-color 0.2s;
+ border: 1px solid transparent;
+ font-family: inherit;
+ font-size: 1rem;
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+}
+.w-full { width: 100%; }
+
+.btn-primary {
+ background-color: var(--color-brand);
+ color: var(--color-neutral-900);
+ &:hover:not(:disabled) { background-color: var(--color-brand-hover); }
+}
+
+.btn-secondary {
+ background-color: var(--color-neutral-200);
+ color: var(--color-neutral-900);
+ &:hover:not(:disabled) { background-color: var(--color-neutral-300); }
+}
+
+.btn-outline {
+ background-color: transparent;
+ border-color: var(--color-border);
+ color: var(--color-text);
+ &:hover:not(:disabled) {
+ border-color: var(--color-brand);
+ color: var(--color-neutral-900);
+ background-color: rgba(250, 207, 10, 0.1); /* Low opacity brand color */
+ }
+}
+
+.btn-text {
+ background-color: transparent;
+ color: var(--color-text-muted);
+ padding: 0.5rem;
+ &:hover:not(:disabled) { color: var(--color-text); }
+}
diff --git a/frontend/src/app/shared/components/app-button/app-button.component.ts b/frontend/src/app/shared/components/app-button/app-button.component.ts
index ac003a9..7ea1863 100644
--- a/frontend/src/app/shared/components/app-button/app-button.component.ts
+++ b/frontend/src/app/shared/components/app-button/app-button.component.ts
@@ -5,66 +5,8 @@ import { CommonModule } from '@angular/common';
selector: 'app-button',
standalone: true,
imports: [CommonModule],
- template: `
-
-
-
- `,
- styles: [`
- .btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 0.5rem 1rem;
- border-radius: var(--radius-md);
- font-weight: 500;
- cursor: pointer;
- transition: background-color 0.2s, color 0.2s, border-color 0.2s;
- border: 1px solid transparent;
- font-family: inherit;
- font-size: 1rem;
-
- &:disabled {
- opacity: 0.6;
- cursor: not-allowed;
- }
- }
- .w-full { width: 100%; }
-
- .btn-primary {
- background-color: var(--color-brand);
- color: var(--color-neutral-900);
- &:hover:not(:disabled) { background-color: var(--color-brand-hover); }
- }
-
- .btn-secondary {
- background-color: var(--color-neutral-200);
- color: var(--color-neutral-900);
- &:hover:not(:disabled) { background-color: var(--color-neutral-300); }
- }
-
- .btn-outline {
- background-color: transparent;
- border-color: var(--color-border);
- color: var(--color-text);
- &:hover:not(:disabled) {
- border-color: var(--color-brand);
- color: var(--color-neutral-900);
- background-color: rgba(250, 207, 10, 0.1); /* Low opacity brand color */
- }
- }
-
- .btn-text {
- background-color: transparent;
- color: var(--color-text-muted);
- padding: 0.5rem;
- &:hover:not(:disabled) { color: var(--color-text); }
- }
- `]
+ templateUrl: './app-button.component.html',
+ styleUrl: './app-button.component.scss'
})
export class AppButtonComponent {
variant = input<'primary' | 'secondary' | 'outline' | 'text'>('primary');
diff --git a/frontend/src/app/shared/components/app-card/app-card.component.html b/frontend/src/app/shared/components/app-card/app-card.component.html
new file mode 100644
index 0000000..c883bea
--- /dev/null
+++ b/frontend/src/app/shared/components/app-card/app-card.component.html
@@ -0,0 +1,3 @@
+
+
+
diff --git a/frontend/src/app/shared/components/app-card/app-card.component.scss b/frontend/src/app/shared/components/app-card/app-card.component.scss
new file mode 100644
index 0000000..c0e7484
--- /dev/null
+++ b/frontend/src/app/shared/components/app-card/app-card.component.scss
@@ -0,0 +1,19 @@
+:host {
+ display: block;
+ height: 100%;
+}
+
+.card {
+ background-color: var(--color-bg-card);
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--color-border);
+ box-shadow: var(--shadow-sm);
+ padding: var(--space-6);
+ transition: box-shadow 0.2s;
+ height: 100%;
+ box-sizing: border-box;
+
+ &:hover {
+ box-shadow: var(--shadow-md);
+ }
+}
diff --git a/frontend/src/app/shared/components/app-card/app-card.component.ts b/frontend/src/app/shared/components/app-card/app-card.component.ts
index 05dc74b..9c5bcf2 100644
--- a/frontend/src/app/shared/components/app-card/app-card.component.ts
+++ b/frontend/src/app/shared/components/app-card/app-card.component.ts
@@ -3,24 +3,7 @@ import { Component } from '@angular/core';
@Component({
selector: 'app-card',
standalone: true,
- template: `
-
-
-
- `,
- styles: [`
- .card {
- background-color: var(--color-bg-card);
- border-radius: var(--radius-lg);
- border: 1px solid var(--color-border);
- box-shadow: var(--shadow-sm);
- padding: var(--space-6);
- transition: box-shadow 0.2s;
-
- &:hover {
- box-shadow: var(--shadow-md);
- }
- }
- `]
+ templateUrl: './app-card.component.html',
+ styleUrl: './app-card.component.scss'
})
export class AppCardComponent {}
diff --git a/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.html b/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.html
new file mode 100644
index 0000000..692d3a4
--- /dev/null
+++ b/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+
{{ label() }}
+
{{ subtext() }}
+
+ @if (fileNames().length > 0) {
+
+ @for (name of fileNames(); track name) {
+
{{ name }}
+ }
+
+ }
+
+
diff --git a/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.scss b/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.scss
new file mode 100644
index 0000000..42c3b1c
--- /dev/null
+++ b/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.scss
@@ -0,0 +1,32 @@
+.dropzone {
+ border: 2px dashed var(--color-border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-8);
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.2s;
+ background-color: var(--color-neutral-50);
+
+ &:hover, &.dragover {
+ border-color: var(--color-brand);
+ background-color: var(--color-neutral-100);
+ }
+}
+.icon { color: var(--color-brand); margin-bottom: var(--space-4); }
+.text { font-weight: 600; margin-bottom: var(--space-2); }
+.subtext { font-size: 0.875rem; color: var(--color-text-muted); }
+.file-badges {
+ margin-top: var(--space-4);
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+ justify-content: center;
+}
+.file-badge {
+ padding: var(--space-2) var(--space-4);
+ background: var(--color-neutral-200);
+ border-radius: var(--radius-md);
+ font-weight: 600;
+ color: var(--color-primary-700);
+ font-size: 0.85rem;
+}
diff --git a/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.ts b/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.ts
index fd02157..fd1e563 100644
--- a/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.ts
+++ b/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.ts
@@ -5,68 +5,8 @@ import { CommonModule } from '@angular/common';
selector: 'app-dropzone',
standalone: true,
imports: [CommonModule],
- template: `
-
-
-
-
-
-
{{ label() }}
-
{{ subtext() }}
-
- @if (fileNames().length > 0) {
-
- @for (name of fileNames(); track name) {
-
{{ name }}
- }
-
- }
-
-
- `,
- styles: [`
- .dropzone {
- border: 2px dashed var(--color-border);
- border-radius: var(--radius-lg);
- padding: var(--space-8);
- text-align: center;
- cursor: pointer;
- transition: all 0.2s;
- background-color: var(--color-neutral-50);
-
- &:hover, &.dragover {
- border-color: var(--color-brand);
- background-color: var(--color-neutral-100);
- }
- }
- .icon { color: var(--color-brand); margin-bottom: var(--space-4); }
- .text { font-weight: 600; margin-bottom: var(--space-2); }
- .subtext { font-size: 0.875rem; color: var(--color-text-muted); }
- .file-badges {
- margin-top: var(--space-4);
- display: flex;
- flex-wrap: wrap;
- gap: var(--space-2);
- justify-content: center;
- }
- .file-badge {
- padding: var(--space-2) var(--space-4);
- background: var(--color-neutral-200);
- border-radius: var(--radius-md);
- font-weight: 600;
- color: var(--color-primary-700);
- font-size: 0.85rem;
- }
- `]
+ templateUrl: './app-dropzone.component.html',
+ styleUrl: './app-dropzone.component.scss'
})
export class AppDropzoneComponent {
label = input('Drop files here or click to upload');
diff --git a/frontend/src/app/shared/components/app-input/app-input.component.html b/frontend/src/app/shared/components/app-input/app-input.component.html
new file mode 100644
index 0000000..7e30cf2
--- /dev/null
+++ b/frontend/src/app/shared/components/app-input/app-input.component.html
@@ -0,0 +1,14 @@
+
+ @if (label()) { {{ label() }} }
+
+ @if (error()) { {{ error() }} }
+
diff --git a/frontend/src/app/shared/components/app-input/app-input.component.scss b/frontend/src/app/shared/components/app-input/app-input.component.scss
new file mode 100644
index 0000000..8919bfb
--- /dev/null
+++ b/frontend/src/app/shared/components/app-input/app-input.component.scss
@@ -0,0 +1,14 @@
+.form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); }
+label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); }
+.form-control {
+ padding: 0.5rem 0.75rem;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ font-size: 1rem;
+ width: 100%;
+ background: var(--color-bg-card);
+ color: var(--color-text);
+ &:focus { outline: none; border-color: var(--color-brand); box-shadow: 0 0 0 2px rgba(250, 207, 10, 0.25); }
+ &:disabled { background: var(--color-neutral-100); cursor: not-allowed; }
+}
+.error-text { color: var(--color-danger-500); font-size: 0.75rem; margin-top: var(--space-1); }
diff --git a/frontend/src/app/shared/components/app-input/app-input.component.ts b/frontend/src/app/shared/components/app-input/app-input.component.ts
index 929fe11..6723696 100644
--- a/frontend/src/app/shared/components/app-input/app-input.component.ts
+++ b/frontend/src/app/shared/components/app-input/app-input.component.ts
@@ -13,38 +13,8 @@ import { CommonModule } from '@angular/common';
multi: true
}
],
- template: `
-
- @if (label()) { {{ label() }} }
-
- @if (error()) { {{ error() }} }
-
- `,
- styles: [`
- .form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); }
- label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); }
- .form-control {
- padding: 0.5rem 0.75rem;
- border: 1px solid var(--color-border);
- border-radius: var(--radius-md);
- font-size: 1rem;
- width: 100%;
- background: var(--color-bg-card);
- color: var(--color-text);
- &:focus { outline: none; border-color: var(--color-brand); box-shadow: 0 0 0 2px rgba(250, 207, 10, 0.25); }
- &:disabled { background: var(--color-neutral-100); cursor: not-allowed; }
- }
- .error-text { color: var(--color-danger-500); font-size: 0.75rem; margin-top: var(--space-1); }
- `]
+ templateUrl: './app-input.component.html',
+ styleUrl: './app-input.component.scss'
})
export class AppInputComponent implements ControlValueAccessor {
label = input('');
diff --git a/frontend/src/app/shared/components/app-locations/app-locations.component.html b/frontend/src/app/shared/components/app-locations/app-locations.component.html
new file mode 100644
index 0000000..e8a827c
--- /dev/null
+++ b/frontend/src/app/shared/components/app-locations/app-locations.component.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+ {{ 'LOCATIONS.TICINO' | translate }}
+
+
+ {{ 'LOCATIONS.BIENNE' | translate }}
+
+
+
+
+
+
{{ 'LOCATIONS.TICINO' | translate }}
+
{{ 'LOCATIONS.ADDRESS_TICINO' | translate }}
+
+
+
{{ 'LOCATIONS.BIENNE' | translate }}
+
{{ 'LOCATIONS.ADDRESS_BIENNE' | translate }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/app/shared/components/app-locations/app-locations.component.scss b/frontend/src/app/shared/components/app-locations/app-locations.component.scss
new file mode 100644
index 0000000..2396f8d
--- /dev/null
+++ b/frontend/src/app/shared/components/app-locations/app-locations.component.scss
@@ -0,0 +1,116 @@
+.locations-section {
+ padding: 6rem 0;
+ background: var(--color-surface-card);
+ border-top: 1px solid var(--color-border);
+}
+
+.section-header {
+ text-align: center;
+ margin-bottom: 4rem;
+
+ h2 {
+ font-size: 2.5rem;
+ margin-bottom: 1rem;
+ color: var(--color-text-main);
+ }
+
+ .subtitle {
+ font-size: 1.1rem;
+ color: var(--color-text-muted);
+ max-width: 600px;
+ margin: 0 auto;
+ }
+}
+
+.locations-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 3rem;
+ align-items: start;
+
+ @media(min-width: 992px) {
+ grid-template-columns: 1fr 2fr;
+ }
+}
+
+.location-tabs {
+ display: flex;
+ gap: 1rem;
+ margin-bottom: 2rem;
+ background: var(--color-bg);
+ padding: 0.5rem;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--color-border);
+}
+
+.tab-btn {
+ flex: 1;
+ padding: 0.75rem;
+ border: none;
+ background: transparent;
+ border-radius: var(--radius-sm);
+ color: var(--color-text-muted);
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ color: var(--color-text-main);
+ }
+
+ &.active {
+ background: var(--color-primary-500);
+ color: var(--color-neutral-900);
+ box-shadow: var(--shadow-sm);
+ }
+}
+
+.location-details {
+ padding: 2rem;
+ background: var(--color-bg);
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--color-border);
+ box-shadow: var(--shadow-md);
+
+ h3 {
+ margin-bottom: 1rem;
+ font-size: 1.5rem;
+ }
+
+ p {
+ color: var(--color-text-muted);
+ margin-bottom: 2rem;
+ line-height: 1.6;
+ }
+}
+
+.contact-btn {
+ display: inline-block;
+ padding: 0.75rem 2rem;
+ background: var(--color-primary-500);
+ color: var(--color-neutral-900);
+ text-decoration: none;
+ border-radius: var(--radius-md);
+ font-weight: 600;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background: var(--color-primary-600);
+ transform: translateY(-2px);
+ box-shadow: var(--shadow-md);
+ }
+}
+
+.map-container {
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ border: 1px solid var(--color-border);
+ box-shadow: var(--shadow-lg);
+ background: var(--color-bg);
+ height: 450px;
+
+ iframe {
+ width: 100%;
+ height: 100%;
+ }
+}
diff --git a/frontend/src/app/shared/components/app-locations/app-locations.component.ts b/frontend/src/app/shared/components/app-locations/app-locations.component.ts
new file mode 100644
index 0000000..89988ff
--- /dev/null
+++ b/frontend/src/app/shared/components/app-locations/app-locations.component.ts
@@ -0,0 +1,19 @@
+import { Component } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { TranslateModule } from '@ngx-translate/core';
+import { RouterLink } from '@angular/router';
+
+@Component({
+ selector: 'app-locations',
+ standalone: true,
+ imports: [CommonModule, TranslateModule, RouterLink],
+ templateUrl: './app-locations.component.html',
+ styleUrl: './app-locations.component.scss'
+})
+export class AppLocationsComponent {
+ selectedLocation: 'ticino' | 'bienne' = 'ticino';
+
+ selectLocation(location: 'ticino' | 'bienne') {
+ this.selectedLocation = location;
+ }
+}
diff --git a/frontend/src/app/shared/components/app-select/app-select.component.html b/frontend/src/app/shared/components/app-select/app-select.component.html
new file mode 100644
index 0000000..dee40ad
--- /dev/null
+++ b/frontend/src/app/shared/components/app-select/app-select.component.html
@@ -0,0 +1,16 @@
+
+ @if (label()) { {{ label() }} }
+
+ @for (opt of options(); track opt.label) {
+ {{ opt.label }}
+ }
+
+ @if (error()) { {{ error() }} }
+
diff --git a/frontend/src/app/shared/components/app-select/app-select.component.scss b/frontend/src/app/shared/components/app-select/app-select.component.scss
new file mode 100644
index 0000000..bcf488a
--- /dev/null
+++ b/frontend/src/app/shared/components/app-select/app-select.component.scss
@@ -0,0 +1,13 @@
+.form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); }
+label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); }
+.form-control {
+ padding: 0.5rem 0.75rem;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ font-size: 1rem;
+ width: 100%;
+ background: var(--color-bg-card);
+ color: var(--color-text);
+ &:focus { outline: none; border-color: var(--color-brand); }
+}
+.error-text { color: var(--color-danger-500); font-size: 0.75rem; margin-top: var(--space-1); }
diff --git a/frontend/src/app/shared/components/app-select/app-select.component.ts b/frontend/src/app/shared/components/app-select/app-select.component.ts
index 84f0ead..4f49c97 100644
--- a/frontend/src/app/shared/components/app-select/app-select.component.ts
+++ b/frontend/src/app/shared/components/app-select/app-select.component.ts
@@ -1,11 +1,11 @@
import { Component, input, output, forwardRef } from '@angular/core';
-import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule, FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-select',
standalone: true,
- imports: [CommonModule, ReactiveFormsModule],
+ imports: [CommonModule, ReactiveFormsModule, FormsModule],
providers: [
{
provide: NG_VALUE_ACCESSOR,
@@ -13,39 +13,8 @@ import { CommonModule } from '@angular/common';
multi: true
}
],
- template: `
-
- @if (label()) { {{ label() }} }
-
- @for (opt of options(); track opt.value) {
- {{ opt.label }}
- }
-
- @if (error()) { {{ error() }} }
-
- `,
- styles: [`
- .form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); }
- label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); }
- .form-control {
- padding: 0.5rem 0.75rem;
- border: 1px solid var(--color-border);
- border-radius: var(--radius-md);
- font-size: 1rem;
- width: 100%;
- background: var(--color-bg-card);
- color: var(--color-text);
- &:focus { outline: none; border-color: var(--color-brand); }
- }
- .error-text { color: var(--color-danger-500); font-size: 0.75rem; margin-top: var(--space-1); }
- `]
+ templateUrl: './app-select.component.html',
+ styleUrl: './app-select.component.scss'
})
export class AppSelectComponent implements ControlValueAccessor {
label = input('');
@@ -64,9 +33,8 @@ export class AppSelectComponent implements ControlValueAccessor {
registerOnTouched(fn: any): void { this.onTouched = fn; }
setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; }
- onSelect(event: Event) {
- const val = (event.target as HTMLSelectElement).value;
- this.value = val;
- this.onChange(val);
+ onModelChange(val: any) {
+ this.value = val;
+ this.onChange(val);
}
}
diff --git a/frontend/src/app/shared/components/app-tabs/app-tabs.component.html b/frontend/src/app/shared/components/app-tabs/app-tabs.component.html
new file mode 100644
index 0000000..d85c910
--- /dev/null
+++ b/frontend/src/app/shared/components/app-tabs/app-tabs.component.html
@@ -0,0 +1,10 @@
+
+ @for (tab of tabs(); track tab.value) {
+
+ {{ tab.label | translate }}
+
+ }
+
diff --git a/frontend/src/app/shared/components/app-tabs/app-tabs.component.scss b/frontend/src/app/shared/components/app-tabs/app-tabs.component.scss
new file mode 100644
index 0000000..2825a0e
--- /dev/null
+++ b/frontend/src/app/shared/components/app-tabs/app-tabs.component.scss
@@ -0,0 +1,21 @@
+.tabs {
+ display: flex;
+ border-bottom: 1px solid var(--color-border);
+ gap: var(--space-4);
+}
+.tab {
+ background: none;
+ border: none;
+ padding: var(--space-3) var(--space-4);
+ cursor: pointer;
+ font-weight: 500;
+ color: var(--color-text-muted);
+ border-bottom: 2px solid transparent;
+ transition: all 0.2s;
+
+ &:hover { color: var(--color-text); }
+ &.active {
+ color: var(--color-brand);
+ border-bottom-color: var(--color-brand);
+ }
+}
diff --git a/frontend/src/app/shared/components/app-tabs/app-tabs.component.ts b/frontend/src/app/shared/components/app-tabs/app-tabs.component.ts
index 27d093b..28ed4ec 100644
--- a/frontend/src/app/shared/components/app-tabs/app-tabs.component.ts
+++ b/frontend/src/app/shared/components/app-tabs/app-tabs.component.ts
@@ -6,41 +6,8 @@ import { TranslateModule } from '@ngx-translate/core';
selector: 'app-tabs',
standalone: true,
imports: [CommonModule, TranslateModule],
- template: `
-
- @for (tab of tabs(); track tab.value) {
-
- {{ tab.label | translate }}
-
- }
-
- `,
- styles: [`
- .tabs {
- display: flex;
- border-bottom: 1px solid var(--color-border);
- gap: var(--space-4);
- }
- .tab {
- background: none;
- border: none;
- padding: var(--space-3) var(--space-4);
- cursor: pointer;
- font-weight: 500;
- color: var(--color-text-muted);
- border-bottom: 2px solid transparent;
- transition: all 0.2s;
-
- &:hover { color: var(--color-text); }
- &.active {
- color: var(--color-brand);
- border-bottom-color: var(--color-brand);
- }
- }
- `]
+ templateUrl: './app-tabs.component.html',
+ styleUrl: './app-tabs.component.scss'
})
export class AppTabsComponent {
tabs = input<{label: string, value: string}[]>([]);
diff --git a/frontend/src/app/shared/components/color-selector/color-selector.component.html b/frontend/src/app/shared/components/color-selector/color-selector.component.html
new file mode 100644
index 0000000..79df3df
--- /dev/null
+++ b/frontend/src/app/shared/components/color-selector/color-selector.component.html
@@ -0,0 +1,39 @@
+
+ @if (isOpen()) {
+
+ }
+
+
+
+
+ @if (isOpen()) {
+
+ }
+
diff --git a/frontend/src/app/shared/components/color-selector/color-selector.component.scss b/frontend/src/app/shared/components/color-selector/color-selector.component.scss
new file mode 100644
index 0000000..3c487dc
--- /dev/null
+++ b/frontend/src/app/shared/components/color-selector/color-selector.component.scss
@@ -0,0 +1,152 @@
+.color-selector-container {
+ position: relative;
+ display: inline-block;
+ // margin-left: 10px; // Handled by parent now
+}
+
+.backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ z-index: 999;
+ background: transparent;
+ cursor: default;
+}
+
+.color-circle {
+ width: 20px; /* Reduced from 24px */
+ height: 20px;
+ border-radius: 50%;
+ border: 2px solid #ddd;
+ cursor: pointer;
+ transition: transform 0.2s, box-shadow 0.2s;
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
+
+ &.trigger:hover {
+ transform: scale(1.1);
+ box-shadow: 0 2px 4px rgba(0,0,0,0.15);
+ }
+
+ &.small {
+ width: 20px;
+ height: 20px;
+ border: 1px solid #eee;
+ }
+}
+
+.color-popup {
+ position: absolute;
+ top: calc(100% + 8px); // Explicit gap
+ left: -2px; // Align left edge with slight offset
+ background: white;
+ border: 1px solid #eee;
+ border-radius: 8px; /* Slightly tighter radius */
+ padding: 12px;
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
+ z-index: 1000;
+ width: 230px; /* Increased size */
+
+ // Little triangle arrow
+ &::before {
+ content: '';
+ position: absolute;
+ top: -6px;
+ left: 8px; // Align arrow to left side near trigger
+ border-width: 0 6px 6px 6px;
+ border-style: solid;
+ border-color: transparent transparent white transparent;
+ }
+
+ /* Mobile Fixed Positioning */
+ @media (max-width: 768px) {
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 280px; /* Provide enough width for touch targets */
+ max-width: 90vw; /* Safety constraint */
+ box-shadow: 0 10px 25px rgba(0,0,0,0.2); /* Stronger shadow for modal feel */
+
+ /* Hide arrow on mobile since it's detached from trigger */
+ &::before {
+ display: none;
+ }
+ }
+}
+
+.category {
+ margin-bottom: 12px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+}
+
+.category-name {
+ font-size: 11px;
+ text-transform: uppercase;
+ color: #888;
+ margin-bottom: 6px;
+ font-weight: 600;
+ letter-spacing: 0.5px;
+}
+
+.colors-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr); /* 3 columns for better alignment */
+ gap: 12px;
+}
+
+.color-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+ text-align: center;
+
+ &:hover .selection-ring {
+ transform: scale(1.1);
+ }
+
+ &.disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+ &:hover .selection-ring { transform: none; }
+ }
+}
+
+.selection-ring {
+ position: relative;
+ border-radius: 50%;
+ border: 2px solid transparent;
+ transition: all 0.2s;
+ padding: 2px; /* Space for ring */
+
+ &.active {
+ border-color: var(--color-brand, #facf0a);
+ box-shadow: 0 0 0 1px var(--color-brand, #facf0a);
+ }
+
+ &.out-of-stock::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 100%;
+ height: 2px;
+ background: #cc0000;
+ transform: translate(-50%, -50%) rotate(-45deg);
+ }
+}
+
+.color-name {
+ font-size: 0.65rem;
+ color: #444;
+ line-height: 1.1;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
diff --git a/frontend/src/app/shared/components/color-selector/color-selector.component.ts b/frontend/src/app/shared/components/color-selector/color-selector.component.ts
new file mode 100644
index 0000000..cfd8d1a
--- /dev/null
+++ b/frontend/src/app/shared/components/color-selector/color-selector.component.ts
@@ -0,0 +1,40 @@
+import { Component, input, output, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { TranslateModule } from '@ngx-translate/core';
+import { PRODUCT_COLORS, getColorHex, ColorCategory, ColorOption } from '../../../core/constants/colors.const';
+
+@Component({
+ selector: 'app-color-selector',
+ standalone: true,
+ imports: [CommonModule, TranslateModule],
+ templateUrl: './color-selector.component.html',
+ styleUrl: './color-selector.component.scss'
+})
+export class ColorSelectorComponent {
+ selectedColor = input('Black');
+ colorSelected = output();
+
+ isOpen = signal(false);
+
+ categories: ColorCategory[] = PRODUCT_COLORS;
+
+ toggleOpen() {
+ this.isOpen.update(v => !v);
+ }
+
+ selectColor(color: ColorOption) {
+ if (color.outOfStock) return;
+
+ this.colorSelected.emit(color.value);
+ this.isOpen.set(false);
+ }
+
+ // Helper to find hex for the current selected value
+ getCurrentHex(): string {
+ return getColorHex(this.selectedColor());
+ }
+
+ close() {
+ this.isOpen.set(false);
+ }
+}
diff --git a/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.html b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.html
new file mode 100644
index 0000000..afeb836
--- /dev/null
+++ b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.html
@@ -0,0 +1,13 @@
+
+ @if (loading) {
+
+
+
Loading 3D Model...
+
+ }
+ @if (file && !loading) {
+
+ {{ dimensions.x }} x {{ dimensions.y }} x {{ dimensions.z }} mm
+
+ }
+
diff --git a/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.scss b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.scss
new file mode 100644
index 0000000..32c4c4a
--- /dev/null
+++ b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.scss
@@ -0,0 +1,44 @@
+.viewer-container {
+ width: 100%;
+ height: 300px;
+ background: var(--color-neutral-50);
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--color-border);
+ overflow: hidden;
+ position: relative;
+}
+.loading-overlay {
+ position: absolute;
+ inset: 0;
+ background: rgba(255, 255, 255, 0.8);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+ z-index: 10;
+ color: var(--color-text-muted);
+}
+.spinner {
+ width: 32px;
+ height: 32px;
+ border: 3px solid var(--color-neutral-200);
+ border-top-color: var(--color-brand);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+.dims-overlay {
+ position: absolute;
+ bottom: 8px;
+ right: 8px;
+ background: rgba(0,0,0,0.6);
+ color: white;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-family: monospace;
+ pointer-events: none;
+}
diff --git a/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts
index 45a9ee5..b9ce5cc 100644
--- a/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts
+++ b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts
@@ -10,70 +10,13 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
selector: 'app-stl-viewer',
standalone: true,
imports: [CommonModule],
- template: `
-
- @if (loading) {
-
-
-
Loading 3D Model...
-
- }
- @if (file && !loading) {
-
- {{ dimensions.x }} x {{ dimensions.y }} x {{ dimensions.z }} mm
-
- }
-
- `,
- styles: [`
- .viewer-container {
- width: 100%;
- height: 300px;
- background: var(--color-neutral-50);
- border-radius: var(--radius-lg);
- border: 1px solid var(--color-border);
- overflow: hidden;
- position: relative;
- }
- .loading-overlay {
- position: absolute;
- inset: 0;
- background: rgba(255, 255, 255, 0.8);
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 1rem;
- z-index: 10;
- color: var(--color-text-muted);
- }
- .spinner {
- width: 32px;
- height: 32px;
- border: 3px solid var(--color-neutral-200);
- border-top-color: var(--color-brand);
- border-radius: 50%;
- animation: spin 1s linear infinite;
- }
- @keyframes spin {
- to { transform: rotate(360deg); }
- }
- .dims-overlay {
- position: absolute;
- bottom: 8px;
- right: 8px;
- background: rgba(0,0,0,0.6);
- color: white;
- padding: 4px 8px;
- border-radius: 4px;
- font-size: 0.75rem;
- font-family: monospace;
- pointer-events: none;
- }
- `]
+ templateUrl: './stl-viewer.component.html',
+ styleUrl: './stl-viewer.component.scss'
})
export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
@Input() file: File | null = null;
+ @Input() color: string = '#facf0a'; // Default Brand Color
+
@ViewChild('rendererContainer', { static: true }) rendererContainer!: ElementRef;
private scene!: THREE.Scene;
@@ -93,6 +36,12 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
if (changes['file'] && this.file) {
this.loadFile(this.file);
}
+
+ if (changes['color'] && this.currentMesh && !changes['file']) {
+ // Update existing mesh color if only color changed
+ const mat = this.currentMesh.material as THREE.MeshPhongMaterial;
+ mat.color.set(this.color);
+ }
}
ngOnDestroy() {
@@ -158,7 +107,7 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
}
const material = new THREE.MeshPhongMaterial({
- color: 0xFACF0A, // Brand color
+ color: this.color,
specular: 0x111111,
shininess: 200
});
diff --git a/frontend/src/app/shared/components/success-state/success-state.component.html b/frontend/src/app/shared/components/success-state/success-state.component.html
new file mode 100644
index 0000000..d118121
--- /dev/null
+++ b/frontend/src/app/shared/components/success-state/success-state.component.html
@@ -0,0 +1,26 @@
+
+
+
+ @switch (context()) {
+ @case ('contact') {
+
{{ 'CONTACT.SUCCESS_TITLE' | translate }}
+
{{ 'CONTACT.SUCCESS_DESC' | translate }}
+
{{ 'CONTACT.SEND_ANOTHER' | translate }}
+ }
+ @case ('calc') {
+
{{ 'CALC.ORDER_SUCCESS_TITLE' | translate }}
+
{{ 'CALC.ORDER_SUCCESS_DESC' | translate }}
+
{{ 'CALC.NEW_QUOTE' | translate }}
+ }
+ @case ('shop') {
+
{{ 'SHOP.SUCCESS_TITLE' | translate }}
+
{{ 'SHOP.SUCCESS_DESC' | translate }}
+
{{ 'SHOP.CONTINUE' | translate }}
+ }
+ }
+
diff --git a/frontend/src/app/shared/components/success-state/success-state.component.scss b/frontend/src/app/shared/components/success-state/success-state.component.scss
new file mode 100644
index 0000000..dc86115
--- /dev/null
+++ b/frontend/src/app/shared/components/success-state/success-state.component.scss
@@ -0,0 +1,36 @@
+.success-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: var(--space-8) var(--space-4);
+ gap: var(--space-4);
+ min-height: 300px; /* Ensure visual balance */
+
+ .success-icon {
+ width: 64px;
+ height: 64px;
+ color: var(--color-success, #10b981);
+ margin-bottom: var(--space-2);
+
+ svg {
+ width: 100%;
+ height: 100%;
+ }
+ }
+
+ h3 {
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: var(--color-text);
+ margin: 0;
+ }
+
+ p {
+ color: var(--color-text-muted);
+ max-width: 400px;
+ margin-bottom: var(--space-4);
+ line-height: 1.5;
+ }
+}
diff --git a/frontend/src/app/shared/components/success-state/success-state.component.ts b/frontend/src/app/shared/components/success-state/success-state.component.ts
new file mode 100644
index 0000000..3cf4048
--- /dev/null
+++ b/frontend/src/app/shared/components/success-state/success-state.component.ts
@@ -0,0 +1,18 @@
+import { Component, input, output } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { TranslateModule } from '@ngx-translate/core';
+import { AppButtonComponent } from '../app-button/app-button.component';
+
+export type SuccessContext = 'contact' | 'calc' | 'shop';
+
+@Component({
+ selector: 'app-success-state',
+ standalone: true,
+ imports: [CommonModule, TranslateModule, AppButtonComponent],
+ templateUrl: './success-state.component.html',
+ styleUrl: './success-state.component.scss'
+})
+export class SuccessStateComponent {
+ context = input.required();
+ action = output();
+}
diff --git a/frontend/src/app/shared/components/summary-card/summary-card.component.html b/frontend/src/app/shared/components/summary-card/summary-card.component.html
new file mode 100644
index 0000000..adb2df4
--- /dev/null
+++ b/frontend/src/app/shared/components/summary-card/summary-card.component.html
@@ -0,0 +1,6 @@
+
+ {{ label() }}
+
+
+
+
diff --git a/frontend/src/app/shared/components/summary-card/summary-card.component.scss b/frontend/src/app/shared/components/summary-card/summary-card.component.scss
new file mode 100644
index 0000000..3c8c0f9
--- /dev/null
+++ b/frontend/src/app/shared/components/summary-card/summary-card.component.scss
@@ -0,0 +1,29 @@
+.summary-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: var(--space-3);
+ background: var(--color-bg-card);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-md);
+ height: 100%;
+ justify-content: center;
+}
+.highlight {
+ background: var(--color-neutral-100);
+ border-color: var(--color-border);
+}
+.label {
+ font-size: 0.875rem;
+ color: var(--color-text-muted);
+ margin-bottom: var(--space-1);
+}
+.value {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: var(--color-text);
+}
+.large {
+ font-size: 2rem;
+ color: var(--color-brand);
+}
diff --git a/frontend/src/app/shared/components/summary-card/summary-card.component.ts b/frontend/src/app/shared/components/summary-card/summary-card.component.ts
index a15faa4..68a8f3f 100644
--- a/frontend/src/app/shared/components/summary-card/summary-card.component.ts
+++ b/frontend/src/app/shared/components/summary-card/summary-card.component.ts
@@ -5,45 +5,8 @@ import { CommonModule } from '@angular/common';
selector: 'app-summary-card',
standalone: true,
imports: [CommonModule],
- template: `
-
- {{ label() }}
-
-
-
-
- `,
- styles: [`
- .summary-card {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: var(--space-3);
- background: var(--color-bg-card);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-md);
- height: 100%;
- justify-content: center;
- }
- .highlight {
- background: var(--color-neutral-100);
- border-color: var(--color-border);
- }
- .label {
- font-size: 0.875rem;
- color: var(--color-text-muted);
- margin-bottom: var(--space-1);
- }
- .value {
- font-size: 1.25rem;
- font-weight: 700;
- color: var(--color-text);
- }
- .large {
- font-size: 2rem;
- color: var(--color-brand);
- }
- `]
+ templateUrl: './summary-card.component.html',
+ styleUrl: './summary-card.component.scss'
})
export class SummaryCardComponent {
label = input.required();
diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json
index 826df25..fd65237 100644
--- a/frontend/src/assets/i18n/en.json
+++ b/frontend/src/assets/i18n/en.json
@@ -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",
@@ -25,6 +48,12 @@
"QUALITY": "Quality",
"QUANTITY": "Quantity",
"NOTES": "Additional Notes",
+ "NOZZLE": "Nozzle Diameter",
+ "INFILL": "Infill (%)",
+ "PATTERN": "Infill Pattern",
+ "LAYER_HEIGHT": "Layer Height",
+ "SUPPORT": "Supports",
+ "SUPPORT_DESC": "Enable supports for overhangs",
"CALCULATE": "Calculate Quote",
"RESULT": "Estimated Quote",
"TIME": "Print Time",
@@ -32,6 +61,9 @@
"ORDER": "Order Now",
"CONSULT": "Request Consultation",
"ERROR_GENERIC": "An error occurred while calculating the quote.",
+ "NEW_QUOTE": "Calculate New Quote",
+ "ORDER_SUCCESS_TITLE": "Order Submitted Successfully",
+ "ORDER_SUCCESS_DESC": "We have received your order details. You will receive a confirmation email shortly with the payment details.",
"BENEFITS_TITLE": "Why choose us?",
"BENEFITS_1": "Automatic quote with instant cost and time",
"BENEFITS_2": "Selected materials and quality control",
@@ -62,6 +94,15 @@
"TARGET_TEXT": "Small businesses, freelancers, makers and customers looking for a ready-made product from the shop.",
"TEAM_TITLE": "Our Team"
},
+ "LOCATIONS": {
+ "TITLE": "Our Locations",
+ "SUBTITLE": "We have two locations to serve you better. Select a location to see details.",
+ "TICINO": "Ticino",
+ "BIENNE": "Bienne",
+ "ADDRESS_TICINO": "Ticino Office, Switzerland",
+ "ADDRESS_BIENNE": "Bienne Office, Switzerland",
+ "CONTACT_US": "Contact Us"
+ },
"CONTACT": {
"TITLE": "Contact Us",
"SEND": "Send Message",
@@ -88,6 +129,9 @@
"LABEL_EMAIL": "Email *",
"LABEL_NAME": "Name *",
"MSG_SENT": "Sent!",
- "ERR_MAX_FILES": "Max 15 files limit reached."
+ "ERR_MAX_FILES": "Max 15 files limit reached.",
+ "SUCCESS_TITLE": "Message Sent Successfully",
+ "SUCCESS_DESC": "Thank you for contacting us. We have received your message and will send you a recap email shortly.",
+ "SEND_ANOTHER": "Send Another Message"
}
}
diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json
index c7abd1b..0f1221b 100644
--- a/frontend/src/assets/i18n/it.json
+++ b/frontend/src/assets/i18n/it.json
@@ -17,17 +17,20 @@
"CTA_START": "Inizia Ora",
"BUSINESS": "Aziende",
"PRIVATE": "Privati",
- "MODE_EASY": "Rapida",
+ "MODE_EASY": "Base",
"MODE_ADVANCED": "Avanzata",
"UPLOAD_LABEL": "Trascina il tuo file 3D qui",
"UPLOAD_SUB": "Supportiamo STL, 3MF, STEP, OBJ fino a 50MB",
"MATERIAL": "Materiale",
"QUALITY": "Qualità",
+ "PRINT_SPEED": "Velocità di Stampa",
"QUANTITY": "Quantità",
"NOTES": "Note aggiuntive",
"COLOR": "Colore",
"INFILL": "Riempimento (%)",
"PATTERN": "Pattern di riempimento",
+ "LAYER_HEIGHT": "Altezza Layer",
+ "NOZZLE": "Diametro Ugello",
"SUPPORT": "Supporti",
"SUPPORT_DESC": "Abilita supporti per sporgenze",
"CALCULATE": "Calcola Preventivo",
@@ -37,6 +40,9 @@
"ORDER": "Ordina Ora",
"CONSULT": "Richiedi Consulenza",
"ERROR_GENERIC": "Si è verificato un errore durante il calcolo del preventivo.",
+ "NEW_QUOTE": "Calcola Nuovo Preventivo",
+ "ORDER_SUCCESS_TITLE": "Ordine Inviato con Successo",
+ "ORDER_SUCCESS_DESC": "Abbiamo ricevuto i dettagli del tuo ordine. Riceverai a breve una email di conferma con i dettagli per il pagamento.",
"BENEFITS_TITLE": "Perché scegliere noi?",
"BENEFITS_1": "Preventivo automatico con costo e tempo immediati",
"BENEFITS_2": "Materiali selezionati e qualità controllata",
@@ -67,6 +73,15 @@
"TARGET_TEXT": "Piccole aziende, freelance, smanettoni e clienti che cercano un prodotto già pronto dallo shop.",
"TEAM_TITLE": "Il Nostro Team"
},
+ "LOCATIONS": {
+ "TITLE": "Le Nostre Sedi",
+ "SUBTITLE": "Siamo presenti in due sedi per coprire meglio il territorio. Seleziona la sede per vedere i dettagli.",
+ "TICINO": "Ticino",
+ "BIENNE": "Bienne",
+ "ADDRESS_TICINO": "Sede Ticino, Svizzera",
+ "ADDRESS_BIENNE": "Sede Bienne, Svizzera",
+ "CONTACT_US": "Contattaci"
+ },
"CONTACT": {
"TITLE": "Contattaci",
"SEND": "Invia Messaggio",
@@ -93,6 +108,9 @@
"LABEL_EMAIL": "Email *",
"LABEL_NAME": "Nome *",
"MSG_SENT": "Inviato!",
- "ERR_MAX_FILES": "Limite massimo di 15 file raggiunto."
+ "ERR_MAX_FILES": "Limite massimo di 15 file raggiunto.",
+ "SUCCESS_TITLE": "Messaggio Inviato con Successo",
+ "SUCCESS_DESC": "Grazie per averci contattato. Abbiamo ricevuto il tuo messaggio e ti invieremo una email di riepilogo a breve.",
+ "SEND_ANOTHER": "Invia un altro messaggio"
}
}