diff --git a/frontend/README.md b/frontend/README.md
index 2cc40f5..7e92d3a 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -1,59 +1,53 @@
-# Frontend
+# Print Calculator Frontend
-This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.12.
+This is a modern Angular application designed with a Clean Architecture approach (Core, Shared, Features) and Design Tokens for easy theming.
-## Development server
+## Project Structure
-To start a local development server, run:
+- **Core**: Singleton services, global layout components (Navbar, Footer), guards.
+- **Shared**: Reusable dumb UI components (Buttons, Cards, Inputs). No business logic.
+- **Features**: Lazy-loaded modules (Calculator, Shop, About). Each contains its own pages, components, and services.
+- **Styles**: Design tokens and theming layer.
-```bash
-ng serve
-```
+## Getting Started
-Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
+1. **Install Dependencies**:
+ ```bash
+ npm install
+ ```
-## Code scaffolding
+2. **Run Development Server**:
+ ```bash
+ ng serve
+ ```
+ Navigate to `http://localhost:4200/`.
-Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
+## Theming
-```bash
-ng generate component component-name
-```
+The application uses CSS Variables defined in `src/styles/tokens.scss` and mapped in `src/styles/theme.scss`.
-For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
+- **Change Colors**: Edit `src/styles/tokens.scss`.
+- **Create New Theme**:
+ 1. Duplicate `src/styles/theme.scss` (e.g., `theme-dark.scss`).
+ 2. Override the semantic variables (e.g., `--color-bg`, `--color-text`).
+ 3. Load the new theme file or switch classes on the body tag.
-```bash
-ng generate --help
-```
+## Adding a New Feature
-## Building
+1. **Create Directory**: `src/app/features/my-feature`.
+2. **Create Routes**: Create `my-feature.routes.ts` exporting a `Routes` array.
+3. **Register Route**: Add to `src/app/app.routes.ts` using lazy loading:
+ ```typescript
+ {
+ path: 'my-feature',
+ loadChildren: () => import('./features/my-feature/my-feature.routes').then(m => m.MY_FEATURE_ROUTES)
+ }
+ ```
-To build the project run:
+## Internationalization (i18n)
-```bash
-ng build
-```
+Translations are stored in `src/assets/i18n/`.
+- `it.json` (Italian - Default)
+- `en.json` (English)
-This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
-
-## Running unit tests
-
-To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
-
-```bash
-ng test
-```
-
-## Running end-to-end tests
-
-For end-to-end (e2e) testing, run:
-
-```bash
-ng e2e
-```
-
-Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
-
-## Additional Resources
-
-For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
+To add a language, create the JSON file and update `LanguageService` in `src/app/core/services/language.service.ts`.
\ No newline at end of file
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 05a10ef..e3aaedd 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -17,6 +17,8 @@
"@angular/platform-browser": "^19.2.18",
"@angular/platform-browser-dynamic": "^19.2.18",
"@angular/router": "^19.2.18",
+ "@ngx-translate/core": "^17.0.0",
+ "@ngx-translate/http-loader": "^17.0.0",
"@types/three": "^0.182.0",
"rxjs": "~7.8.0",
"three": "^0.182.0",
@@ -4305,6 +4307,32 @@
"webpack": "^5.54.0"
}
},
+ "node_modules/@ngx-translate/core": {
+ "version": "17.0.0",
+ "resolved": "https://registry.npmjs.org/@ngx-translate/core/-/core-17.0.0.tgz",
+ "integrity": "sha512-Rft2D5ns2pq4orLZjEtx1uhNuEBerUdpFUG1IcqtGuipj6SavgB8SkxtNQALNDA+EVlvsNCCjC2ewZVtUeN6rg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "peerDependencies": {
+ "@angular/common": ">=16",
+ "@angular/core": ">=16"
+ }
+ },
+ "node_modules/@ngx-translate/http-loader": {
+ "version": "17.0.0",
+ "resolved": "https://registry.npmjs.org/@ngx-translate/http-loader/-/http-loader-17.0.0.tgz",
+ "integrity": "sha512-hgS8sa0ARjH9ll3PhkLTufeVXNI2DNR2uFKDhBgq13siUXzzVr/a31M6zgecrtwbA34iaBV01hsTMbMS8V7iIw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "peerDependencies": {
+ "@angular/common": ">=16",
+ "@angular/core": ">=16"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index bf6f79e..bd5b43d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -22,6 +22,8 @@
"@angular/platform-browser": "^19.2.18",
"@angular/platform-browser-dynamic": "^19.2.18",
"@angular/router": "^19.2.18",
+ "@ngx-translate/core": "^17.0.0",
+ "@ngx-translate/http-loader": "^17.0.0",
"@types/three": "^0.182.0",
"rxjs": "~7.8.0",
"three": "^0.182.0",
diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html
index 0680b43..90c6b64 100644
--- a/frontend/src/app/app.component.html
+++ b/frontend/src/app/app.component.html
@@ -1 +1 @@
-
+
\ No newline at end of file
diff --git a/frontend/src/app/app.component.scss b/frontend/src/app/app.component.scss
deleted file mode 100644
index e69de29..0000000
diff --git a/frontend/src/app/app.component.spec.ts b/frontend/src/app/app.component.spec.ts
deleted file mode 100644
index a6b0ab9..0000000
--- a/frontend/src/app/app.component.spec.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { TestBed } from '@angular/core/testing';
-import { AppComponent } from './app.component';
-
-describe('AppComponent', () => {
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- imports: [AppComponent],
- }).compileComponents();
- });
-
- it('should create the app', () => {
- const fixture = TestBed.createComponent(AppComponent);
- const app = fixture.componentInstance;
- expect(app).toBeTruthy();
- });
-
- it(`should have the 'frontend' title`, () => {
- const fixture = TestBed.createComponent(AppComponent);
- const app = fixture.componentInstance;
- expect(app.title).toEqual('frontend');
- });
-
- it('should render title', () => {
- const fixture = TestBed.createComponent(AppComponent);
- fixture.detectChanges();
- const compiled = fixture.nativeElement as HTMLElement;
- expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
- });
-});
diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts
index 0555c0c..3c3d410 100644
--- a/frontend/src/app/app.component.ts
+++ b/frontend/src/app/app.component.ts
@@ -5,9 +5,6 @@ import { RouterOutlet } from '@angular/router';
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
- templateUrl: './app.component.html',
- styleUrls: ['./app.component.scss']
+ template: ` `
})
-export class AppComponent {
- title = 'frontend';
-}
+export class AppComponent {}
diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts
index 484db14..bb34c85 100644
--- a/frontend/src/app/app.config.ts
+++ b/frontend/src/app/app.config.ts
@@ -1,28 +1,23 @@
-import { ApplicationConfig, LOCALE_ID, provideZoneChangeDetection } from '@angular/core';
-import { provideRouter } from '@angular/router';
-
+import { ApplicationConfig, provideZoneChangeDetection, importProvidersFrom } from '@angular/core';
+import { provideRouter, withComponentInputBinding, withViewTransitions } from '@angular/router';
import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
-
-const resolveLocale = () => {
- if (typeof navigator === 'undefined') {
- return 'de-CH';
- }
- const languages = navigator.languages ?? [];
- if (navigator.language === 'it-CH' || languages.includes('it-CH')) {
- return 'it-CH';
- }
- if (navigator.language === 'de-CH' || languages.includes('de-CH')) {
- return 'de-CH';
- }
- return 'de-CH';
-};
+import { TranslateModule } from '@ngx-translate/core';
+import { provideTranslateHttpLoader } from '@ngx-translate/http-loader';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
- provideRouter(routes),
+ provideRouter(routes, withComponentInputBinding(), withViewTransitions()),
provideHttpClient(),
- { provide: LOCALE_ID, useFactory: resolveLocale }
+ provideTranslateHttpLoader({
+ prefix: './assets/i18n/',
+ suffix: '.json'
+ }),
+ importProvidersFrom(
+ TranslateModule.forRoot({
+ defaultLanguage: 'it'
+ })
+ )
]
-};
+};
\ No newline at end of file
diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts
index 402dc64..d206854 100644
--- a/frontend/src/app/app.routes.ts
+++ b/frontend/src/app/app.routes.ts
@@ -1,13 +1,23 @@
import { Routes } from '@angular/router';
-import { HomeComponent } from './home/home.component';
-import { BasicQuoteComponent } from './quote/basic-quote/basic-quote.component';
-import { AdvancedQuoteComponent } from './quote/advanced-quote/advanced-quote.component';
-import { ContactComponent } from './contact/contact.component';
export const routes: Routes = [
- { path: '', component: HomeComponent },
- { path: 'quote/basic', component: BasicQuoteComponent },
- { path: 'quote/advanced', component: AdvancedQuoteComponent },
- { path: 'contact', component: ContactComponent },
- { path: '**', redirectTo: '' }
+ {
+ path: '',
+ loadComponent: () => import('./core/layout/layout.component').then(m => m.LayoutComponent),
+ children: [
+ { path: '', redirectTo: 'cal', pathMatch: 'full' },
+ {
+ path: 'cal',
+ loadChildren: () => import('./features/calculator/calculator.routes').then(m => m.CALCULATOR_ROUTES)
+ },
+ {
+ path: 'shop',
+ loadChildren: () => import('./features/shop/shop.routes').then(m => m.SHOP_ROUTES)
+ },
+ {
+ path: 'about',
+ loadChildren: () => import('./features/about/about.routes').then(m => m.ABOUT_ROUTES)
+ }
+ ]
+ }
];
diff --git a/frontend/src/app/calculator/calculator.component.html b/frontend/src/app/calculator/calculator.component.html
deleted file mode 100644
index 9f93d6c..0000000
--- a/frontend/src/app/calculator/calculator.component.html
+++ /dev/null
@@ -1,69 +0,0 @@
-
-
-
- 3D Print Quote Calculator
- Bambu Lab A1 Estimation
-
-
-
-
-
-
- {{ file ? file.name : 'Select STL File' }}
-
-
-
- Calculate Quote
-
-
-
-
-
-
Slicing model... this may take a minute...
-
-
-
- {{ error }}
-
-
-
-
-
Total Estimate: {{ results?.cost?.total | currency:'CHF' }}
-
-
-
-
- Print Time:
- {{ results?.print_time_formatted }}
-
-
- Material Used:
- {{ results?.material_grams | number:'1.1-1' }} g
-
-
-
-
Cost Breakdown
-
-
- Material
- {{ results?.cost?.material | currency:'CHF' }}
-
-
- Machine Time
- {{ results?.cost?.machine | currency:'CHF' }}
-
-
- Energy
- {{ results?.cost?.energy | currency:'CHF' }}
-
-
- Service/Markup
- {{ results?.cost?.markup | currency:'CHF' }}
-
-
-
-
-
-
diff --git a/frontend/src/app/calculator/calculator.component.scss b/frontend/src/app/calculator/calculator.component.scss
deleted file mode 100644
index e69de29..0000000
diff --git a/frontend/src/app/calculator/calculator.component.spec.ts b/frontend/src/app/calculator/calculator.component.spec.ts
deleted file mode 100644
index 6ad2d10..0000000
--- a/frontend/src/app/calculator/calculator.component.spec.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-
-import { CalculatorComponent } from './calculator.component';
-
-describe('CalculatorComponent', () => {
- let component: CalculatorComponent;
- let fixture: ComponentFixture;
-
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- imports: [CalculatorComponent]
- })
- .compileComponents();
-
- fixture = TestBed.createComponent(CalculatorComponent);
- component = fixture.componentInstance;
- fixture.detectChanges();
- });
-
- it('should create', () => {
- expect(component).toBeTruthy();
- });
-});
diff --git a/frontend/src/app/calculator/calculator.component.ts b/frontend/src/app/calculator/calculator.component.ts
deleted file mode 100644
index fff56aa..0000000
--- a/frontend/src/app/calculator/calculator.component.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-// calculator.component.ts
-import { Component } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { FormsModule } from '@angular/forms';
-import { HttpClient } from '@angular/common/http';
-import { MatCardModule } from '@angular/material/card';
-import { MatButtonModule } from '@angular/material/button';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-
-interface QuoteResponse {
- printer: string;
- print_time_formatted: string;
- material_grams: number;
- cost: {
- material: number;
- machine: number;
- energy: number;
- markup: number;
- total: number;
- };
-}
-
-import { environment } from '../../environments/environment';
-
-@Component({
- selector: 'app-calculator',
- standalone: true,
- imports: [
- CommonModule,
- FormsModule,
- MatCardModule,
- MatButtonModule,
- MatProgressSpinnerModule
- ],
- templateUrl: './calculator.component.html',
- styleUrls: ['./calculator.component.scss']
-})
-export class CalculatorComponent {
- file: File | null = null;
- results: QuoteResponse | null = null;
- error = '';
- loading = false;
-
- constructor(private http: HttpClient) {}
-
- onFileSelected(event: Event): void {
- const input = event.target as HTMLInputElement;
- if (input.files && input.files.length > 0) {
- this.file = input.files[0];
- this.results = null;
- this.error = '';
- }
- }
-
- uploadAndCalculate(): void {
- if (!this.file) {
- this.error = 'Please select a file first.';
- return;
- }
- const formData = new FormData();
- formData.append('file', this.file);
- this.loading = true;
- this.error = '';
- this.results = null;
-
- this.http.post(`${environment.apiUrl}/calculate/stl`, formData)
- .subscribe({
- next: res => {
- this.results = res;
- this.loading = false;
- },
- error: err => {
- console.error(err);
- this.error = err.error?.detail || "An error occurred during calculation.";
- this.loading = false;
- }
- });
- }
-}
\ No newline at end of file
diff --git a/frontend/src/app/common/stl-viewer/stl-viewer.component.ts b/frontend/src/app/common/stl-viewer/stl-viewer.component.ts
deleted file mode 100644
index b498ebd..0000000
--- a/frontend/src/app/common/stl-viewer/stl-viewer.component.ts
+++ /dev/null
@@ -1,218 +0,0 @@
-import { Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, ViewChild, SimpleChanges } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import * as THREE from 'three';
-import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js';
-import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
-
-@Component({
- selector: 'app-stl-viewer',
- standalone: true,
- imports: [CommonModule],
- template: `
-
-
-
autorenew
-
Loading 3D Model...
-
-
-
Size: {{ dimensions.x | number:'1.1-1' }} x {{ dimensions.y | number:'1.1-1' }} x {{ dimensions.z | number:'1.1-1' }} mm
-
-
- `,
- styles: [`
- .viewer-container {
- width: 100%;
- height: 100%;
- min-height: 300px;
- position: relative;
- background: #0f172a; /* Match app bg approx */
- overflow: hidden;
- border-radius: inherit;
- }
- .loading-overlay {
- position: absolute;
- top: 0; left: 0; right: 0; bottom: 0;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- background: rgba(15, 23, 42, 0.8);
- color: white;
- z-index: 10;
- }
- .spin {
- animation: spin 1s linear infinite;
- font-size: 2rem;
- margin-bottom: 0.5rem;
- }
- @keyframes spin { 100% { transform: rotate(360deg); } }
-
- .dimensions-overlay {
- position: absolute;
- bottom: 10px;
- right: 10px;
- background: rgba(0,0,0,0.6);
- color: white;
- padding: 4px 8px;
- border-radius: 4px;
- font-size: 0.8rem;
- pointer-events: none;
- }
- `]
-})
-export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
- @Input() file: File | null = null;
- @ViewChild('rendererContainer', { static: true }) rendererContainer!: ElementRef;
-
- isLoading = false;
- dimensions: { x: number, y: number, z: number } | null = null;
-
- private scene!: THREE.Scene;
- private camera!: THREE.PerspectiveCamera;
- private renderer!: THREE.WebGLRenderer;
- private mesh!: THREE.Mesh;
- private controls!: OrbitControls;
- private animationId: number | null = null;
-
- ngOnInit() {
- this.initThree();
- }
-
- ngOnChanges(changes: SimpleChanges) {
- if (changes['file'] && this.file) {
- this.loadSTL(this.file);
- }
- }
-
- ngOnDestroy() {
- this.stopAnimation();
- if (this.renderer) {
- this.renderer.dispose();
- }
- if (this.mesh) {
- this.mesh.geometry.dispose();
- (this.mesh.material as THREE.Material).dispose();
- }
- }
-
- private initThree() {
- const container = this.rendererContainer.nativeElement;
- const width = container.clientWidth;
- const height = container.clientHeight;
-
- // Scene
- this.scene = new THREE.Scene();
- this.scene.background = new THREE.Color(0x1e293b); // Slate 800
-
- // Camera
- this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
- this.camera.position.set(100, 100, 100);
-
- // Renderer
- this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
- this.renderer.setSize(width, height);
- this.renderer.setPixelRatio(window.devicePixelRatio);
- container.appendChild(this.renderer.domElement);
-
- // Controls
- this.controls = new OrbitControls(this.camera, this.renderer.domElement);
- this.controls.enableDamping = true;
- this.controls.dampingFactor = 0.05;
- this.controls.autoRotate = true;
- this.controls.autoRotateSpeed = 2.0;
-
- // Lights
- const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
- this.scene.add(ambientLight);
-
- const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
- dirLight.position.set(50, 50, 50);
- this.scene.add(dirLight);
-
- const backLight = new THREE.DirectionalLight(0xffffff, 0.4);
- backLight.position.set(-50, -50, -50);
- this.scene.add(backLight);
-
- // Grid (Printer Bed attempt)
- const gridHelper = new THREE.GridHelper(256, 20, 0x4f46e5, 0x334155);
- this.scene.add(gridHelper);
-
- // Resize listener
- const resizeObserver = new ResizeObserver(() => this.onWindowResize());
- resizeObserver.observe(container);
-
- this.animate();
- }
-
- private loadSTL(file: File) {
- this.isLoading = true;
-
- // Remove previous mesh
- if (this.mesh) {
- this.scene.remove(this.mesh);
- this.mesh.geometry.dispose();
- (this.mesh.material as THREE.Material).dispose();
- }
-
- const loader = new STLLoader();
- const reader = new FileReader();
-
- reader.onload = (event) => {
- const buffer = event.target?.result as ArrayBuffer;
- const geometry = loader.parse(buffer);
-
- geometry.computeBoundingBox();
- const center = new THREE.Vector3();
- geometry.boundingBox?.getCenter(center);
- geometry.center(); // Center geometry
-
- // Calculate dimensions
- const size = new THREE.Vector3();
- geometry.boundingBox?.getSize(size);
- this.dimensions = { x: size.x, y: size.y, z: size.z };
-
- // Re-position camera based on size
- const maxDim = Math.max(size.x, size.y, size.z);
- this.camera.position.set(maxDim * 1.5, maxDim * 1.5, maxDim * 1.5);
- this.camera.lookAt(0, 0, 0);
-
- // Material
- const material = new THREE.MeshStandardMaterial({
- color: 0x6366f1, // Indigo 500
- roughness: 0.5,
- metalness: 0.1
- });
-
- this.mesh = new THREE.Mesh(geometry, material);
- this.mesh.rotation.x = -Math.PI / 2; // STL usually needs this
- this.scene.add(this.mesh);
-
- this.isLoading = false;
- };
-
- reader.readAsArrayBuffer(file);
- }
-
- private animate() {
- this.animationId = requestAnimationFrame(() => this.animate());
- this.controls.update();
- this.renderer.render(this.scene, this.camera);
- }
-
- private stopAnimation() {
- if (this.animationId !== null) {
- cancelAnimationFrame(this.animationId);
- }
- }
-
- private onWindowResize() {
- if (!this.rendererContainer) return;
- const container = this.rendererContainer.nativeElement;
- const width = container.clientWidth;
- const height = container.clientHeight;
-
- this.camera.aspect = width / height;
- this.camera.updateProjectionMatrix();
- this.renderer.setSize(width, height);
- }
-}
diff --git a/frontend/src/app/contact/contact.component.html b/frontend/src/app/contact/contact.component.html
deleted file mode 100644
index 4c16996..0000000
--- a/frontend/src/app/contact/contact.component.html
+++ /dev/null
@@ -1,50 +0,0 @@
-
diff --git a/frontend/src/app/contact/contact.component.scss b/frontend/src/app/contact/contact.component.scss
deleted file mode 100644
index b20bb64..0000000
--- a/frontend/src/app/contact/contact.component.scss
+++ /dev/null
@@ -1,97 +0,0 @@
-.section-header {
- margin-bottom: 2rem;
- text-align: center;
-
- .back-link {
- position: absolute;
- left: 2rem;
- top: 2rem;
- display: inline-flex;
- align-items: center;
- color: var(--text-muted);
-
- .material-icons { margin-right: 0.5rem; }
- &:hover { color: var(--primary-color); }
- }
-
- @media (max-width: 768px) {
- .back-link {
- position: static;
- display: block;
- margin-bottom: 1rem;
- }
- }
-}
-
-.contact-card {
- max-width: 600px;
- margin: 0 auto;
-}
-
-.contact-info {
- display: flex;
- gap: 2rem;
- margin-bottom: 2rem;
-
- .info-item {
- display: flex;
- align-items: flex-start;
- gap: 1rem;
-
- .material-icons {
- color: var(--secondary-color);
- background: rgba(236, 72, 153, 0.1);
- padding: 0.5rem;
- border-radius: 50%;
- }
-
- h3 { margin: 0 0 0.25rem 0; font-size: 1rem; }
- p { margin: 0; color: var(--text-muted); }
- }
-}
-
-.divider {
- height: 1px;
- background: var(--border-color);
- margin: 2rem 0;
-}
-
-.form-group {
- margin-bottom: 1.5rem;
-
- label {
- display: block;
- margin-bottom: 0.5rem;
- color: var(--text-muted);
- font-size: 0.9rem;
- }
-
- input, textarea {
- width: 100%;
- padding: 0.75rem;
- background: var(--bg-color);
- border: 1px solid var(--border-color);
- border-radius: var(--radius-md);
- color: var(--text-main);
- font-family: inherit;
- box-sizing: border-box;
-
- &:focus {
- outline: none;
- border-color: var(--secondary-color);
- }
- }
-}
-
-.btn-block {
- width: 100%;
-}
-
-.fade-in {
- animation: fadeIn 0.4s ease-out;
-}
-
-@keyframes fadeIn {
- from { opacity: 0; transform: translateY(10px); }
- to { opacity: 1; transform: translateY(0); }
-}
diff --git a/frontend/src/app/contact/contact.component.ts b/frontend/src/app/contact/contact.component.ts
deleted file mode 100644
index bac336c..0000000
--- a/frontend/src/app/contact/contact.component.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { Component } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { RouterLink } from '@angular/router';
-
-@Component({
- selector: 'app-contact',
- standalone: true,
- imports: [CommonModule, RouterLink],
- templateUrl: './contact.component.html',
- styleUrls: ['./contact.component.scss']
-})
-export class ContactComponent {
- onSubmit(event: Event) {
- event.preventDefault();
- alert("Thanks for your message! This is a demo form.");
- }
-}
diff --git a/frontend/src/app/core/layout/footer.component.ts b/frontend/src/app/core/layout/footer.component.ts
new file mode 100644
index 0000000..16bbb2e
--- /dev/null
+++ b/frontend/src/app/core/layout/footer.component.ts
@@ -0,0 +1,65 @@
+import { Component } from '@angular/core';
+import { TranslateModule } from '@ngx-translate/core';
+import { RouterLink } from '@angular/router';
+
+@Component({
+ selector: 'app-footer',
+ standalone: true,
+ imports: [TranslateModule, RouterLink],
+ template: `
+
+ `,
+ styles: [`
+ .footer {
+ background-color: var(--color-neutral-900);
+ color: var(--color-neutral-300);
+ padding: var(--space-8) 0;
+ margin-top: auto; /* Push to bottom if content is short */
+ }
+ .footer-inner {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+ .brand { font-weight: 700; color: white; display: block; margin-bottom: var(--space-2); }
+ .copyright { font-size: 0.875rem; color: var(--color-secondary-500); margin: 0; }
+
+ .links {
+ display: flex;
+ gap: var(--space-6);
+ a {
+ color: var(--color-neutral-300);
+ font-size: 0.875rem;
+ &:hover { color: white; text-decoration: underline; }
+ }
+ }
+
+ .social { display: flex; gap: var(--space-3); }
+ .social-icon {
+ width: 24px; height: 24px;
+ background-color: var(--color-neutral-800);
+ border-radius: 50%;
+ }
+ `]
+})
+export class FooterComponent {}
diff --git a/frontend/src/app/core/layout/layout.component.ts b/frontend/src/app/core/layout/layout.component.ts
new file mode 100644
index 0000000..ac27e33
--- /dev/null
+++ b/frontend/src/app/core/layout/layout.component.ts
@@ -0,0 +1,31 @@
+import { Component } from '@angular/core';
+import { RouterOutlet } from '@angular/router';
+import { NavbarComponent } from './navbar.component';
+import { FooterComponent } from './footer.component';
+
+@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);
+ }
+ `]
+})
+export class LayoutComponent {}
diff --git a/frontend/src/app/core/layout/navbar.component.ts b/frontend/src/app/core/layout/navbar.component.ts
new file mode 100644
index 0000000..6eec639
--- /dev/null
+++ b/frontend/src/app/core/layout/navbar.component.ts
@@ -0,0 +1,111 @@
+import { Component } from '@angular/core';
+import { RouterLink, RouterLinkActive } from '@angular/router';
+import { TranslateModule } from '@ngx-translate/core';
+import { LanguageService } from '../services/language.service';
+import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
+
+@Component({
+ selector: 'app-navbar',
+ standalone: true,
+ imports: [RouterLink, RouterLinkActive, TranslateModule],
+ template: `
+
+ `,
+ styles: [`
+ .navbar {
+ height: 64px;
+ border-bottom: 1px solid var(--color-border);
+ background-color: var(--color-bg-card);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ }
+ .navbar-inner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+ .brand {
+ font-size: 1.25rem;
+ font-weight: 700;
+ color: var(--color-text);
+ text-decoration: none;
+ }
+ .highlight { color: var(--color-brand); }
+
+ .nav-links {
+ display: flex;
+ gap: var(--space-6);
+
+ a {
+ color: var(--color-text-muted);
+ font-weight: 500;
+ text-decoration: none;
+ transition: color 0.2s;
+
+ &:hover, &.active {
+ color: var(--color-brand);
+ }
+ }
+ }
+
+ .actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+ }
+
+ .lang-switch {
+ background: none;
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ padding: 2px 6px;
+ cursor: pointer;
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--color-text-muted);
+ &:hover { color: var(--color-text); border-color: var(--color-text); }
+ }
+
+ .icon-placeholder {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background-color: var(--color-neutral-100);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--color-text-muted);
+ }
+ `]
+})
+export class NavbarComponent {
+ constructor(public langService: LanguageService) {}
+
+ toggleLang() {
+ const newLang = this.langService.currentLang() === 'it' ? 'en' : 'it';
+ this.langService.switchLang(newLang);
+ }
+}
diff --git a/frontend/src/app/core/services/language.service.ts b/frontend/src/app/core/services/language.service.ts
new file mode 100644
index 0000000..ead4dc0
--- /dev/null
+++ b/frontend/src/app/core/services/language.service.ts
@@ -0,0 +1,20 @@
+import { Injectable, signal } from '@angular/core';
+import { TranslateService } from '@ngx-translate/core';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class LanguageService {
+ currentLang = signal('it');
+
+ constructor(private translate: TranslateService) {
+ this.translate.addLangs(['it', 'en']);
+ this.translate.setDefaultLang('it');
+ this.translate.use('it');
+ }
+
+ switchLang(lang: string) {
+ this.translate.use(lang);
+ this.currentLang.set(lang);
+ }
+}
diff --git a/frontend/src/app/features/about/about-page.component.ts b/frontend/src/app/features/about/about-page.component.ts
new file mode 100644
index 0000000..085acb2
--- /dev/null
+++ b/frontend/src/app/features/about/about-page.component.ts
@@ -0,0 +1,54 @@
+import { Component } from '@angular/core';
+import { TranslateModule } from '@ngx-translate/core';
+import { ContactFormComponent } from './components/contact-form/contact-form.component';
+import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
+
+@Component({
+ selector: 'app-about-page',
+ standalone: true,
+ imports: [TranslateModule, ContactFormComponent, AppCardComponent],
+ template: `
+
+
{{ 'ABOUT.TITLE' | translate }}
+
+
+
+
+
Our Mission
+
+ We make high-quality 3D printing accessible to everyone.
+ Whether you are a hobbyist or an industrial designer,
+ PrintCalc provides instant quotes and professional production.
+
+
+
How it Works
+
+ Upload your STL file
+ Choose material & quality
+ Get instant quote
+ We print & ship
+
+
+
+
+
+ `,
+ styles: [`
+ .hero { padding: var(--space-8) 0; text-align: center; }
+ .content {
+ display: grid;
+ gap: var(--space-12);
+ @media(min-width: 768px) { grid-template-columns: 1fr 1fr; }
+ }
+ .steps {
+ padding-left: var(--space-4);
+ li { margin-bottom: var(--space-2); color: var(--color-text-muted); }
+ }
+ `]
+})
+export class AboutPageComponent {}
diff --git a/frontend/src/app/features/about/about.routes.ts b/frontend/src/app/features/about/about.routes.ts
new file mode 100644
index 0000000..9ff57a6
--- /dev/null
+++ b/frontend/src/app/features/about/about.routes.ts
@@ -0,0 +1,6 @@
+import { Routes } from '@angular/router';
+import { AboutPageComponent } from './about-page.component';
+
+export const ABOUT_ROUTES: Routes = [
+ { path: '', component: AboutPageComponent }
+];
diff --git a/frontend/src/app/features/about/components/contact-form/contact-form.component.ts b/frontend/src/app/features/about/components/contact-form/contact-form.component.ts
new file mode 100644
index 0000000..942d393
--- /dev/null
+++ b/frontend/src/app/features/about/components/contact-form/contact-form.component.ts
@@ -0,0 +1,66 @@
+import { Component, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
+import { TranslateModule } from '@ngx-translate/core';
+import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
+import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
+
+@Component({
+ selector: 'app-contact-form',
+ standalone: true,
+ imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppButtonComponent],
+ template: `
+
+
+
+
+
+ Message
+
+
+
+
+
+ {{ sent() ? 'Sent!' : ('ABOUT.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); }
+ .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); }
+ }
+ `]
+})
+export class ContactFormComponent {
+ form: FormGroup;
+ sent = signal(false);
+
+ constructor(private fb: FormBuilder) {
+ this.form = this.fb.group({
+ name: ['', Validators.required],
+ email: ['', [Validators.required, Validators.email]],
+ message: ['', Validators.required]
+ });
+ }
+
+ onSubmit() {
+ if (this.form.valid) {
+ // Mock submit
+ this.sent.set(true);
+ setTimeout(() => {
+ this.sent.set(false);
+ this.form.reset();
+ }, 3000);
+ }
+ }
+}
diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts
new file mode 100644
index 0000000..76629dd
--- /dev/null
+++ b/frontend/src/app/features/calculator/calculator-page.component.ts
@@ -0,0 +1,139 @@
+import { Component, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { TranslateModule } from '@ngx-translate/core';
+import { AppTabsComponent } from '../../shared/components/app-tabs/app-tabs.component';
+import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
+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';
+
+@Component({
+ selector: 'app-calculator-page',
+ standalone: true,
+ imports: [CommonModule, TranslateModule, AppTabsComponent, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent],
+ template: `
+
+
{{ 'CALC.TITLE' | translate }}
+
{{ 'CALC.SUBTITLE' | translate }}
+
+
+
+
+
+
+
+
+ @if (error()) {
+
An error occurred while calculating quote.
+ }
+
+ @if (result()) {
+
+ } @else {
+
+ Why choose PrintCalc?
+
+ Instant AI-powered quotes
+ Industrial grade materials
+ Fast shipping worldwide
+
+
+ }
+
+
+ `,
+ 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;
+ }
+ }
+
+ .tabs-wrapper {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: var(--space-6);
+ border-bottom: 1px solid var(--color-border);
+ padding-bottom: var(--space-2);
+ }
+
+ .sub-tabs { font-size: 0.875rem; color: var(--color-text-muted); }
+ .mode-switch { cursor: pointer; &:hover { color: var(--color-text); } }
+ .mode-switch.active { font-weight: 700; color: var(--color-brand); }
+ .divider { margin: 0 var(--space-2); }
+
+ .benefits { padding-left: var(--space-4); color: var(--color-text-muted); line-height: 2; }
+ `]
+})
+export class CalculatorPageComponent {
+ clientType = signal('private');
+ mode = signal('easy');
+ loading = signal(false);
+ result = signal(null);
+ error = signal(false);
+
+ clientTabs = [
+ { label: 'Private', value: 'private' },
+ { label: 'Business', value: 'business' }
+ ];
+
+ constructor(private estimator: QuoteEstimatorService) {}
+
+ onCalculate(req: QuoteRequest) {
+ this.loading.set(true);
+ this.error.set(false);
+ this.result.set(null);
+
+ this.estimator.calculate(req).subscribe({
+ next: (res) => {
+ this.result.set(res);
+ this.loading.set(false);
+ },
+ error: () => {
+ this.error.set(true);
+ this.loading.set(false);
+ }
+ });
+ }
+}
diff --git a/frontend/src/app/features/calculator/calculator.routes.ts b/frontend/src/app/features/calculator/calculator.routes.ts
new file mode 100644
index 0000000..39a0c2e
--- /dev/null
+++ b/frontend/src/app/features/calculator/calculator.routes.ts
@@ -0,0 +1,6 @@
+import { Routes } from '@angular/router';
+import { CalculatorPageComponent } from './calculator-page.component';
+
+export const CALCULATOR_ROUTES: Routes = [
+ { path: '', component: CalculatorPageComponent }
+];
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
new file mode 100644
index 0000000..ba620a1
--- /dev/null
+++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts
@@ -0,0 +1,63 @@
+import { Component, input, output } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { TranslateModule } from '@ngx-translate/core';
+import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component';
+import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
+import { QuoteResult } from '../../services/quote-estimator.service';
+
+@Component({
+ selector: 'app-quote-result',
+ standalone: true,
+ imports: [CommonModule, TranslateModule, AppCardComponent, AppButtonComponent],
+ template: `
+
+ {{ 'CALC.RESULT' | translate }}
+
+
+
+ {{ 'CALC.COST' | translate }}
+ {{ result().price | currency:result().currency }}
+
+
+ {{ 'CALC.TIME' | translate }}
+ {{ result().printTimeHours }}h
+
+
+ Material
+ {{ 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);
+ }
+ .item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: var(--space-3);
+ background: var(--color-neutral-50);
+ border-radius: var(--radius-md);
+ }
+ .item:first-child { grid-column: span 2; background: var(--color-neutral-100); }
+ .label { font-size: 0.875rem; color: var(--color-text-muted); }
+ .value { font-size: 1.25rem; font-weight: 700; }
+ .price { font-size: 2rem; color: var(--color-brand); }
+
+ .actions { display: flex; flex-direction: column; gap: var(--space-3); }
+ `]
+})
+export class QuoteResultComponent {
+ result = input.required();
+}
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
new file mode 100644
index 0000000..a6a0ee7
--- /dev/null
+++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts
@@ -0,0 +1,120 @@
+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 { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
+import { AppSelectComponent } from '../../../../shared/components/app-select/app-select.component';
+import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone/app-dropzone.component';
+import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
+import { QuoteRequest } from '../../services/quote-estimator.service';
+
+@Component({
+ selector: 'app-upload-form',
+ standalone: true,
+ imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppSelectComponent, AppDropzoneComponent, AppButtonComponent],
+ template: `
+
+
+
+
+
+ @if (form.get('file')?.invalid && form.get('file')?.touched) {
+
File required
+ }
+
+
+
+
+
+
+ @if (mode() === 'advanced') {
+
+ }
+
+
+
+ {{ loading() ? '...' : ('CALC.CALCULATE' | translate) }}
+
+
+
+ `,
+ 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; }
+ `]
+})
+export class UploadFormComponent {
+ clientType = input<'business' | 'private'>('private');
+ mode = input<'easy' | 'advanced'>('easy');
+ loading = input(false);
+ submitRequest = output();
+
+ form: FormGroup;
+
+ materials = [
+ { label: 'PLA (Standard)', value: 'PLA' },
+ { label: 'PETG (Durable)', value: 'PETG' },
+ { label: 'TPU (Flexible)', value: 'TPU' }
+ ];
+
+ qualities = [
+ { label: 'Draft (Fast)', value: 'Draft' },
+ { label: 'Standard', value: 'Standard' },
+ { label: 'High Detail', value: 'High' }
+ ];
+
+ constructor(private fb: FormBuilder) {
+ this.form = this.fb.group({
+ file: [null, Validators.required],
+ material: ['PLA', Validators.required],
+ quality: ['Standard', Validators.required],
+ quantity: [1, [Validators.required, Validators.min(1)]],
+ notes: ['']
+ });
+ }
+
+ onFileDropped(file: File) {
+ this.form.patchValue({ file });
+ this.form.get('file')?.markAsTouched();
+ }
+
+ onSubmit() {
+ if (this.form.valid) {
+ this.submitRequest.emit({
+ ...this.form.value,
+ clientType: this.clientType(),
+ mode: this.mode()
+ });
+ } else {
+ this.form.markAllAsTouched();
+ }
+ }
+}
diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts
new file mode 100644
index 0000000..410d554
--- /dev/null
+++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts
@@ -0,0 +1,44 @@
+import { Injectable } from '@angular/core';
+import { Observable, of } from 'rxjs';
+import { delay } from 'rxjs/operators';
+
+export interface QuoteRequest {
+ file: File;
+ material: string;
+ quality: string;
+ quantity: number;
+ notes?: string;
+ clientType: 'business' | 'private';
+ mode: 'easy' | 'advanced';
+}
+
+export interface QuoteResult {
+ price: number;
+ currency: string;
+ printTimeHours: number;
+ materialUsageGrams: number;
+ setupCost: number;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class QuoteEstimatorService {
+
+ calculate(request: QuoteRequest): Observable {
+ // Mock logic
+ const basePrice = request.clientType === 'business' ? 50 : 20;
+ const materialCost = request.material === 'PETG' ? 1.5 : (request.material === 'TPU' ? 2 : 1);
+ const qualityMult = request.quality === 'High' ? 1.5 : (request.quality === 'Draft' ? 0.8 : 1);
+
+ const estimatedPrice = (basePrice * materialCost * qualityMult * request.quantity) + 10; // +10 setup
+
+ return of({
+ price: Math.round(estimatedPrice * 100) / 100,
+ currency: 'EUR',
+ printTimeHours: Math.floor(Math.random() * 24) + 2,
+ materialUsageGrams: Math.floor(Math.random() * 500) + 50,
+ setupCost: 10
+ }).pipe(delay(1500)); // Simulate network latency
+ }
+}
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
new file mode 100644
index 0000000..0b5dc08
--- /dev/null
+++ b/frontend/src/app/features/shop/components/product-card/product-card.component.ts
@@ -0,0 +1,48 @@
+import { Component, input } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { RouterLink } from '@angular/router';
+import { Product } from '../../services/shop.service';
+
+@Component({
+ 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; }
+ `]
+})
+export class ProductCardComponent {
+ product = input.required();
+}
diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts
new file mode 100644
index 0000000..c2b212c
--- /dev/null
+++ b/frontend/src/app/features/shop/product-detail.component.ts
@@ -0,0 +1,80 @@
+import { Component, input, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { RouterLink } from '@angular/router';
+import { TranslateModule } from '@ngx-translate/core';
+import { ShopService, Product } from './services/shop.service';
+import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
+
+@Component({
+ 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 {
+
Product not found.
+ }
+
+ `,
+ 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); }
+ `]
+})
+export class ProductDetailComponent {
+ // Input binding from router
+ id = input();
+
+ product = signal(undefined);
+
+ constructor(private shopService: ShopService) {}
+
+ ngOnInit() {
+ const productId = this.id();
+ if (productId) {
+ this.shopService.getProductById(productId).subscribe(p => this.product.set(p));
+ }
+ }
+
+ addToCart() {
+ alert('Added to cart (Mock)');
+ }
+}
diff --git a/frontend/src/app/features/shop/services/shop.service.ts b/frontend/src/app/features/shop/services/shop.service.ts
new file mode 100644
index 0000000..644ec71
--- /dev/null
+++ b/frontend/src/app/features/shop/services/shop.service.ts
@@ -0,0 +1,48 @@
+import { Injectable } from '@angular/core';
+import { Observable, of } from 'rxjs';
+
+export interface Product {
+ id: string;
+ name: string;
+ description: string;
+ price: number;
+ category: string;
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class ShopService {
+ // Dati statici per ora
+ private staticProducts: Product[] = [
+ {
+ id: '1',
+ name: 'Filamento PLA Standard',
+ description: 'Il classico per ogni stampa, facile e affidabile.',
+ price: 24.90,
+ category: 'Filamenti'
+ },
+ {
+ id: '2',
+ name: 'Filamento PETG Tough',
+ description: 'Resistente agli urti e alle temperature.',
+ price: 29.90,
+ category: 'Filamenti'
+ },
+ {
+ id: '3',
+ name: 'Kit Ugelli (0.4mm)',
+ description: 'Set di ricambio per estrusore FDM.',
+ price: 15.00,
+ category: 'Accessori'
+ }
+ ];
+
+ getProducts(): Observable {
+ return of(this.staticProducts);
+ }
+
+ getProductById(id: string): Observable {
+ return of(this.staticProducts.find(p => p.id === id));
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/app/features/shop/shop-page.component.ts b/frontend/src/app/features/shop/shop-page.component.ts
new file mode 100644
index 0000000..5da913b
--- /dev/null
+++ b/frontend/src/app/features/shop/shop-page.component.ts
@@ -0,0 +1,43 @@
+import { Component, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { TranslateModule } from '@ngx-translate/core';
+import { ShopService, Product } from './services/shop.service';
+import { ProductCardComponent } from './components/product-card/product-card.component';
+
+@Component({
+ selector: 'app-shop-page',
+ standalone: true,
+ imports: [CommonModule, TranslateModule, ProductCardComponent],
+ template: `
+
+
{{ 'SHOP.TITLE' | translate }}
+
Componenti e materiali selezionati per la tua stampa 3D.
+
+
+
+
+ @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);
+ }
+ `]
+})
+export class ShopPageComponent {
+ products = signal([]);
+
+ constructor(private shopService: ShopService) {
+ this.shopService.getProducts().subscribe(data => {
+ this.products.set(data);
+ });
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/app/features/shop/shop.routes.ts b/frontend/src/app/features/shop/shop.routes.ts
new file mode 100644
index 0000000..744d165
--- /dev/null
+++ b/frontend/src/app/features/shop/shop.routes.ts
@@ -0,0 +1,8 @@
+import { Routes } from '@angular/router';
+import { ShopPageComponent } from './shop-page.component';
+import { ProductDetailComponent } from './product-detail.component';
+
+export const SHOP_ROUTES: Routes = [
+ { path: '', component: ShopPageComponent },
+ { path: ':id', component: ProductDetailComponent }
+];
diff --git a/frontend/src/app/home/home.component.html b/frontend/src/app/home/home.component.html
deleted file mode 100644
index ea9f0ea..0000000
--- a/frontend/src/app/home/home.component.html
+++ /dev/null
@@ -1,38 +0,0 @@
-
diff --git a/frontend/src/app/home/home.component.scss b/frontend/src/app/home/home.component.scss
deleted file mode 100644
index 3c51701..0000000
--- a/frontend/src/app/home/home.component.scss
+++ /dev/null
@@ -1,81 +0,0 @@
-.home-container {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- min-height: 80vh;
- text-align: center;
-}
-
-.hero {
- margin-bottom: 4rem;
-
- h1 {
- font-size: 3.5rem;
- margin-bottom: 1rem;
- background: linear-gradient(to right, var(--primary-color), var(--secondary-color));
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
- }
-
- .subtitle {
- font-size: 1.25rem;
- color: var(--text-muted);
- max-width: 600px;
- margin: 0 auto;
- }
-}
-
-.cards-wrapper {
- width: 100%;
- max-width: 900px;
-}
-
-.action-card {
- display: flex;
- flex-direction: column;
- align-items: center;
- text-align: center;
- text-decoration: none;
- color: var(--text-main);
- background: var(--surface-color);
-
- .icon-wrapper {
- background: rgba(99, 102, 241, 0.1);
- padding: 1rem;
- border-radius: 50%;
- margin-bottom: 1.5rem;
-
- .material-icons {
- font-size: 2.5rem;
- color: var(--primary-color);
- }
- }
-
- h2 {
- font-size: 1.5rem;
- margin-bottom: 0.5rem;
- }
-
- p {
- color: var(--text-muted);
- margin-bottom: 2rem;
- line-height: 1.6;
- }
-
- .btn-secondary {
- background-color: transparent;
- border: 1px solid var(--border-color);
- color: var(--text-main);
-
- &:hover {
- background-color: var(--surface-hover);
- }
- }
-
- &:hover {
- .icon-wrapper {
- background: rgba(99, 102, 241, 0.2);
- }
- }
-}
diff --git a/frontend/src/app/home/home.component.ts b/frontend/src/app/home/home.component.ts
deleted file mode 100644
index 7895791..0000000
--- a/frontend/src/app/home/home.component.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { Component } from '@angular/core';
-import { RouterLink } from '@angular/router';
-import { CommonModule } from '@angular/common';
-
-@Component({
- selector: 'app-home',
- standalone: true,
- imports: [RouterLink, CommonModule],
- templateUrl: './home.component.html',
- styleUrls: ['./home.component.scss']
-})
-export class HomeComponent {}
diff --git a/frontend/src/app/print.service.ts b/frontend/src/app/print.service.ts
deleted file mode 100644
index 36e11a0..0000000
--- a/frontend/src/app/print.service.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { Injectable, inject } from '@angular/core';
-import { HttpClient } from '@angular/common/http';
-import { Observable } from 'rxjs';
-import { environment } from '../environments/environment';
-
-@Injectable({
- providedIn: 'root'
-})
-export class PrintService {
- private http = inject(HttpClient);
- private apiUrl = environment.apiUrl;
-
- calculateQuote(file: File, params?: any): Observable {
- const formData = new FormData();
- formData.append('file', file);
-
- // Append extra params if meant for backend
- if (params) {
- Object.keys(params).forEach(key => {
- if (params[key] !== null && params[key] !== undefined) {
- formData.append(key, params[key]);
- }
- });
- }
-
- return this.http.post(`${this.apiUrl}/api/quote`, formData);
- }
-
- getProfiles(): Observable {
- return this.http.get(`${this.apiUrl}/api/profiles/available`);
- }
-}
-
diff --git a/frontend/src/app/quote/advanced-quote/advanced-quote.component.html b/frontend/src/app/quote/advanced-quote/advanced-quote.component.html
deleted file mode 100644
index ad42eb4..0000000
--- a/frontend/src/app/quote/advanced-quote/advanced-quote.component.html
+++ /dev/null
@@ -1,152 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
cloud_upload
-
Click or Drop STL here
-
-
-
-
-
-
-
- description
- {{ selectedFile.name }}
-
-
- delete
-
-
-
-
-
-
-
-
-
-
- Calculate Price
- Calculating...
-
-
-
-
-
-
-
-
Estimated Cost
-
- {{ quoteResult?.cost?.total | currency:'CHF' }}
-
-
-
-
- Print Time
- {{ quoteResult?.print_time_formatted }}
-
-
- Material Weight
- {{ quoteResult?.material_grams | number:'1.0-0' }}g
-
-
-
-
-
-
-
- Infill
- {{ params.infill_density }}%
-
-
- Infill Pattern
- {{ infillPatternLabel }}
-
-
- Material
- {{ materialLabel }}
-
-
- Color
- {{ params.material_color }}
-
-
-
-
-
Note: Color does not affect the estimate. Printer is fixed to Bambu Lab A1.
-
-
-
-
-
-
-
-
science
-
Advanced Quote
-
Configure settings and calculate.
-
-
-
-
diff --git a/frontend/src/app/quote/advanced-quote/advanced-quote.component.scss b/frontend/src/app/quote/advanced-quote/advanced-quote.component.scss
deleted file mode 100644
index 6ab5ca5..0000000
--- a/frontend/src/app/quote/advanced-quote/advanced-quote.component.scss
+++ /dev/null
@@ -1,269 +0,0 @@
-.section-header {
- margin-bottom: 2rem;
-
- .back-link {
- display: inline-flex;
- align-items: center;
- color: var(--text-muted);
- margin-bottom: 1rem;
- font-size: 0.9rem;
- cursor: pointer;
-
- .material-icons {
- font-size: 1.1rem;
- margin-right: 0.25rem;
- }
-
- &:hover {
- color: var(--primary-color);
- }
- }
-
- h1 {
- font-size: 2.5rem;
- margin-bottom: 0.5rem;
- }
-
- p {
- color: var(--text-muted);
- }
-}
-
-.upload-area {
- border-bottom: 1px solid var(--border-color);
- background: var(--surface-color);
- transition: all 0.2s;
-
- &.drag-over {
- background: rgba(99, 102, 241, 0.1);
- }
-
- &:not(.has-file) {
- cursor: pointer;
- }
-
- .empty-state {
- padding: 1.5rem;
- text-align: center;
- .material-icons {
- font-size: 2rem;
- color: var(--primary-color);
- margin-bottom: 0.5rem;
- }
- p { margin: 0; color: var(--text-muted);}
- }
-}
-
-.viewer-wrapper {
- height: 250px;
- width: 100%;
- background: #0f172a;
-}
-
-.file-action-bar {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 0.75rem 1rem;
- background: var(--surface-color);
-
- &.border-top { border-top: 1px solid var(--border-color); }
-
- .file-info {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- color: var(--text-main);
-
- .material-icons { color: var(--primary-color); font-size: 1.2rem; }
- .file-name { font-size: 0.9rem; }
- }
-
- .btn-icon {
- background: none;
- border: none;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0.25rem;
- border-radius: 50%;
-
- .material-icons {
- font-size: 1.2rem;
- color: var(--text-muted);
- }
-
- &.danger:hover {
- background: rgba(239, 68, 68, 0.2);
- .material-icons { color: #ef4444; }
- }
- }
-}
-
-.params-form {
- padding: 1.5rem;
-
- h3 {
- font-size: 1.1rem;
- margin-bottom: 1.5rem;
- color: var(--text-main);
- }
-}
-
-.form-group {
- margin-bottom: 1.25rem;
-
- label {
- display: block;
- color: var(--text-muted);
- font-size: 0.9rem;
- margin-bottom: 0.5rem;
- }
-
- input[type="text"],
- input[type="number"],
- select {
- width: 100%;
- padding: 0.75rem;
- background: var(--bg-color);
- border: 1px solid var(--border-color);
- border-radius: var(--radius-md);
- color: var(--text-main);
- font-family: inherit;
- font-size: 0.95rem;
-
- &:focus {
- outline: none;
- border-color: var(--primary-color);
- box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
- }
- }
-}
-
-
-.range-wrapper {
- display: flex;
- align-items: center;
- gap: 1rem;
-
- input[type="range"] {
- flex: 1;
- accent-color: var(--primary-color);
- }
-
- span {
- width: 3rem;
- text-align: right;
- font-variant-numeric: tabular-nums;
- }
-}
-
-.form-row {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 1rem;
-}
-
-.actions {
- padding: 1.5rem;
- border-top: 1px solid var(--border-color);
- background: var(--surface-color);
-}
-
-.btn-block {
- width: 100%;
- display: flex;
-}
-
-/* Results - Reused mostly but tweaked */
-.result-card {
- text-align: center;
- background: linear-gradient(135deg, var(--surface-color) 0%, rgba(30, 41, 59, 0.8) 100%);
-
- h2 {
- color: var(--text-muted);
- font-size: 1.25rem;
- margin-bottom: 1rem;
- }
-
- .price-big {
- font-size: 3.5rem;
- font-weight: 700;
- color: var(--primary-color);
- margin-bottom: 2rem;
- }
-}
-
-.specs-list {
- text-align: left;
- margin-bottom: 1rem;
- background: rgba(0,0,0,0.2);
- border-radius: var(--radius-md);
- padding: 1rem;
-
- .spec-header {
- font-size: 0.8rem;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- color: var(--text-muted);
- margin-bottom: 0.5rem;
- opacity: 0.7;
- }
-
- .spec-item {
- display: flex;
- justify-content: space-between;
- padding: 0.75rem 0;
- border-bottom: 1px solid rgba(255,255,255,0.05);
-
- &:last-child { border-bottom: none; }
-
- &.compact {
- padding: 0.25rem 0;
- font-size: 0.9rem;
- }
-
- span:first-child { color: var(--text-muted); }
- }
-
- &.secondary {
- background: transparent;
- border: 1px solid var(--border-color);
- }
-}
-
-.note-box {
- background: rgba(99, 102, 241, 0.1);
- color: var(--primary-color); // Blueish info for advanced note
- padding: 0.75rem;
- border-radius: var(--radius-sm);
- font-size: 0.9rem;
- margin-top: 1rem;
-}
-
-.placeholder-card {
- height: 100%;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- color: var(--text-muted);
- border-style: dashed;
-
- .material-icons {
- font-size: 4rem;
- margin-bottom: 1rem;
- opacity: 0.5;
- }
-}
-
-/* Animation */
-.fade-in {
- animation: fadeIn 0.4s ease-out;
-}
-
-@keyframes fadeIn {
- from { opacity: 0; transform: translateY(10px); }
- to { opacity: 1; transform: translateY(0); }
-}
diff --git a/frontend/src/app/quote/advanced-quote/advanced-quote.component.ts b/frontend/src/app/quote/advanced-quote/advanced-quote.component.ts
deleted file mode 100644
index 2c29f0e..0000000
--- a/frontend/src/app/quote/advanced-quote/advanced-quote.component.ts
+++ /dev/null
@@ -1,151 +0,0 @@
-import { Component, inject } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { RouterLink } from '@angular/router';
-import { FormsModule } from '@angular/forms';
-import { PrintService } from '../../print.service';
-import { StlViewerComponent } from '../../common/stl-viewer/stl-viewer.component';
-
-@Component({
- selector: 'app-advanced-quote',
- standalone: true,
- imports: [CommonModule, RouterLink, FormsModule, StlViewerComponent],
- templateUrl: './advanced-quote.component.html',
- styleUrls: ['./advanced-quote.component.scss']
-})
-export class AdvancedQuoteComponent {
- printService = inject(PrintService);
-
- selectedFile: File | null = null;
- isDragOver = false;
- isCalculating = false;
- quoteResult: any = null;
-
- // Selectable options (mapped to backend profile ids where needed)
- readonly materialOptions = [
- { value: 'pla_basic', label: 'PLA' },
- { value: 'petg_basic', label: 'PETG' },
- { value: 'abs_basic', label: 'ABS' },
- { value: 'tpu_95a', label: 'TPU 95A' }
- ];
- readonly colorOptions = [
- 'Black',
- 'White',
- 'Gray',
- 'Red',
- 'Blue',
- 'Green',
- 'Yellow'
- ];
- readonly qualityOptions = [
- { value: 'draft', label: 'Draft' },
- { value: 'standard', label: 'Standard' },
- { value: 'fine', label: 'Fine' }
- ];
- readonly infillPatternOptions = [
- { value: 'grid', label: 'Grid' },
- { value: 'gyroid', label: 'Gyroid' },
- { value: 'cubic', label: 'Cubic' },
- { value: 'triangles', label: 'Triangles' },
- { value: 'rectilinear', label: 'Rectilinear' },
- { value: 'crosshatch', label: 'Crosshatch' },
- { value: 'zig-zag', label: 'Zig-zag' },
- { value: 'alignedrectilinear', label: 'Aligned Rectilinear' }
- ];
-
- // Parameters
- params = {
- filament: 'pla_basic',
- material_color: 'Black',
- quality: 'standard',
- infill_density: 15,
- infill_pattern: 'grid',
- support_enabled: false
- };
-
- get materialLabel(): string {
- const match = this.materialOptions.find(option => option.value === this.params.filament);
- return match ? match.label : this.params.filament;
- }
-
- get infillPatternLabel(): string {
- const match = this.infillPatternOptions.find(option => option.value === this.params.infill_pattern);
- return match ? match.label : this.params.infill_pattern;
- }
-
- onDragOver(event: DragEvent) {
- event.preventDefault();
- event.stopPropagation();
- this.isDragOver = true;
- }
-
- onDragLeave(event: DragEvent) {
- event.preventDefault();
- event.stopPropagation();
- this.isDragOver = false;
- }
-
- onDrop(event: DragEvent) {
- event.preventDefault();
- event.stopPropagation();
- this.isDragOver = false;
-
- const files = event.dataTransfer?.files;
- if (files && files.length > 0) {
- if (files[0].name.toLowerCase().endsWith('.stl')) {
- this.selectedFile = files[0];
- this.quoteResult = null;
- } else {
- alert('Please upload an STL file.');
- }
- }
- }
-
- onFileSelected(event: any) {
- const file = event.target.files[0];
- if (file) {
- this.selectedFile = file;
- this.quoteResult = null;
- }
- }
-
- removeFile(event: Event) {
- event.stopPropagation();
- this.selectedFile = null;
- this.quoteResult = null;
- }
-
- calculate() {
- if (!this.selectedFile) return;
-
- this.isCalculating = true;
-
- // Use PrintService
- this.printService.calculateQuote(this.selectedFile, {
- filament: this.params.filament,
- quality: this.params.quality,
- infill_density: this.params.infill_density,
- infill_pattern: this.params.infill_pattern,
- // Optional mappings if user selected overrides
- // layer_height: this.params.layer_height,
- // support_enabled: this.params.support_enabled
- })
- .subscribe({
- next: (res) => {
- console.log('API Response:', res);
- if (res.success) {
- this.quoteResult = res.data;
- console.log('Quote Result set to:', this.quoteResult);
- } else {
- console.error('API succeeded but returned error flag:', res.error);
- alert('Error: ' + res.error);
- }
- this.isCalculating = false;
- },
- error: (err) => {
- console.error(err);
- alert('Calculation failed: ' + (err.error?.detail || err.message));
- this.isCalculating = false;
- }
- });
- }
-}
diff --git a/frontend/src/app/quote/basic-quote/basic-quote.component.html b/frontend/src/app/quote/basic-quote/basic-quote.component.html
deleted file mode 100644
index cfd28ac..0000000
--- a/frontend/src/app/quote/basic-quote/basic-quote.component.html
+++ /dev/null
@@ -1,129 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
cloud_upload
-
Drop your STL file here
-
or click to browse
-
-
-
-
-
-
-
- description
- {{ selectedFile.name }}
-
-
-
- delete
-
-
-
-
-
-
-
-
-
Select Strength
-
-
-
🥚
-
-
Standard
-
Balanced strength
-
-
-
-
-
🧱
-
-
Strong
-
Higher infill
-
-
-
-
-
-
-
-
-
- Calculate Price
- Calculating...
-
-
-
-
-
-
-
-
Estimated Cost
-
- {{ quoteResult?.cost?.total | currency:'CHF' }}
-
-
-
-
- Print Time
- {{ quoteResult?.print_time_formatted }}
-
-
- Material
- {{ quoteResult?.material_grams | number:'1.0-0' }}g
-
-
-
-
-
Note: This is an estimation. Final price may vary slightly.
-
-
-
-
-
-
-
-
receipt_long
-
Your Quote
-
Results will appear here after calculation.
-
-
-
-
diff --git a/frontend/src/app/quote/basic-quote/basic-quote.component.scss b/frontend/src/app/quote/basic-quote/basic-quote.component.scss
deleted file mode 100644
index a75d9bf..0000000
--- a/frontend/src/app/quote/basic-quote/basic-quote.component.scss
+++ /dev/null
@@ -1,254 +0,0 @@
-.section-header {
- margin-bottom: 2rem;
-
- .back-link {
- display: inline-flex;
- align-items: center;
- color: var(--text-muted);
- margin-bottom: 1rem;
- font-size: 0.9rem;
-
- .material-icons {
- font-size: 1.1rem;
- margin-right: 0.25rem;
- }
-
- &:hover {
- color: var(--primary-color);
- }
- }
-
- h1 {
- font-size: 2.5rem;
- margin-bottom: 0.5rem;
- }
-
- p {
- color: var(--text-muted);
- }
-}
-
-.upload-area {
- border-bottom: 1px solid var(--border-color);
- background: var(--surface-color);
- transition: all 0.2s;
-
- &.drag-over {
- background: rgba(99, 102, 241, 0.1);
- box-shadow: inset 0 0 0 2px var(--primary-color);
- }
-
- /* Only show pointer on empty state or drag */
- &:not(.has-file) {
- cursor: pointer;
- }
-}
-
-.empty-state {
- padding: 3rem;
- text-align: center;
-
- .upload-icon {
- font-size: 4rem;
- color: var(--text-muted);
- margin-bottom: 1rem;
- }
-
- h3 { margin-bottom: 0.5rem; }
- p { color: var(--text-muted); margin: 0; }
-}
-
-.viewer-wrapper {
- height: 350px;
- width: 100%;
- background: #0f172a;
- border-bottom: 1px solid var(--border-color);
-}
-
-.file-action-bar {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 0.75rem 1.5rem;
- background: var(--surface-color);
-
- .file-info {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- color: var(--text-main);
-
- .material-icons { color: var(--primary-color); }
- .file-name { font-weight: 500; }
- }
-
- .btn-icon {
- background: none;
- border: none;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0.5rem;
- border-radius: 50%;
- transition: background 0.2s;
-
- .material-icons {
- font-size: 1.5rem;
- color: var(--text-muted);
- }
-
- &:hover {
- background: var(--surface-hover);
- .material-icons { color: var(--text-main); }
- }
-
- &.danger {
- &:hover {
- background: rgba(239, 68, 68, 0.2);
- .material-icons { color: #ef4444; } /* Red-500 */
- }
- }
- }
-}
-
-.strength-selector {
- padding: 1.5rem;
-
- h3 {
- font-size: 1.1rem;
- margin-bottom: 1rem;
- }
-}
-
-.strength-options {
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
-}
-
-.strength-card {
- display: flex;
- align-items: center;
- padding: 1rem;
- border: 1px solid var(--border-color);
- border-radius: var(--radius-md);
- cursor: pointer;
- transition: all 0.2s;
-
- .emoji {
- font-size: 2rem;
- margin-right: 1rem;
- }
-
- .info {
- h4 {
- margin: 0 0 0.25rem 0;
- }
- p {
- margin: 0;
- font-size: 0.9rem;
- color: var(--text-muted);
- }
- }
-
- &:hover {
- border-color: var(--primary-color);
- background: var(--surface-hover);
- }
-
- &.active {
- border-color: var(--primary-color);
- background: rgba(99, 102, 241, 0.1);
-
- .emoji {
- transform: scale(1.1);
- }
- }
-}
-
-.actions {
- padding: 1.5rem;
- border-top: 1px solid var(--border-color);
-}
-
-.btn-block {
- width: 100%;
- display: flex;
-}
-
-/* Results */
-.result-card {
- text-align: center;
- background: linear-gradient(135deg, var(--surface-color) 0%, rgba(30, 41, 59, 0.8) 100%);
-
- h2 {
- color: var(--text-muted);
- font-size: 1.25rem;
- margin-bottom: 1rem;
- }
-
- .price-big {
- font-size: 3.5rem;
- font-weight: 700;
- color: var(--primary-color);
- margin-bottom: 2rem;
- }
-}
-
-.specs-list {
- text-align: left;
- margin-bottom: 2rem;
- background: rgba(0,0,0,0.2);
- border-radius: var(--radius-md);
- padding: 1rem;
-
- .spec-item {
- display: flex;
- justify-content: space-between;
- padding: 0.75rem 0;
- border-bottom: 1px solid rgba(255,255,255,0.05);
-
- &:last-child {
- border-bottom: none;
- }
-
- span {
- color: var(--text-muted);
- }
- }
-}
-
-.note-box {
- background: rgba(236, 72, 153, 0.1);
- color: var(--secondary-color);
- padding: 0.75rem;
- border-radius: var(--radius-sm);
- font-size: 0.9rem;
-}
-
-.placeholder-card {
- height: 100%;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- color: var(--text-muted);
- border-style: dashed;
-
- .material-icons {
- font-size: 4rem;
- margin-bottom: 1rem;
- opacity: 0.5;
- }
-}
-
-/* Animation */
-.fade-in {
- animation: fadeIn 0.4s ease-out;
-}
-
-@keyframes fadeIn {
- from { opacity: 0; transform: translateY(10px); }
- to { opacity: 1; transform: translateY(0); }
-}
diff --git a/frontend/src/app/quote/basic-quote/basic-quote.component.ts b/frontend/src/app/quote/basic-quote/basic-quote.component.ts
deleted file mode 100644
index 8905da1..0000000
--- a/frontend/src/app/quote/basic-quote/basic-quote.component.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { Component, inject } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { RouterLink } from '@angular/router';
-import { PrintService } from '../../print.service';
-import { StlViewerComponent } from '../../common/stl-viewer/stl-viewer.component';
-
-@Component({
- selector: 'app-basic-quote',
- standalone: true,
- imports: [CommonModule, RouterLink, StlViewerComponent],
- templateUrl: './basic-quote.component.html',
- styleUrls: ['./basic-quote.component.scss']
-})
-export class BasicQuoteComponent {
- printService = inject(PrintService);
-
- selectedFile: File | null = null;
- selectedStrength: 'standard' | 'strong' | 'ultra' = 'standard';
- isDragOver = false;
- isCalculating = false;
- quoteResult: any = null;
- private strengthToSettings: Record<'standard' | 'strong' | 'ultra', { infill_density: number; quality: 'draft' | 'standard' | 'fine' }> = {
- standard: { infill_density: 15, quality: 'standard' },
- strong: { infill_density: 30, quality: 'standard' },
- ultra: { infill_density: 50, quality: 'standard' }
- };
-
- onDragOver(event: DragEvent) {
- event.preventDefault();
- event.stopPropagation();
- this.isDragOver = true;
- }
-
- onDragLeave(event: DragEvent) {
- event.preventDefault();
- event.stopPropagation();
- this.isDragOver = false;
- }
-
- onDrop(event: DragEvent) {
- event.preventDefault();
- event.stopPropagation();
- this.isDragOver = false;
-
- const files = event.dataTransfer?.files;
- if (files && files.length > 0) {
- if (files[0].name.toLowerCase().endsWith('.stl')) {
- this.selectedFile = files[0];
- this.quoteResult = null;
- } else {
- alert('Please upload an STL file.');
- }
- }
- }
-
- onFileSelected(event: any) {
- const file = event.target.files[0];
- if (file) {
- this.selectedFile = file;
- this.quoteResult = null;
- }
- }
-
- removeFile(event: Event) {
- event.stopPropagation();
- this.selectedFile = null;
- this.quoteResult = null;
- }
-
- selectStrength(strength: 'standard' | 'strong' | 'ultra') {
- this.selectedStrength = strength;
- }
-
- calculate() {
- if (!this.selectedFile) return;
-
- this.isCalculating = true;
-
- const settings = this.strengthToSettings[this.selectedStrength];
-
- this.printService.calculateQuote(this.selectedFile, {
- quality: settings.quality,
- infill_density: settings.infill_density
- })
- .subscribe({
- next: (res) => {
- if (res?.success) {
- this.quoteResult = res.data;
- } else {
- console.error('Quote API returned error:', res?.error);
- alert('Calculation failed: ' + (res?.error || 'Unknown error'));
- this.quoteResult = null;
- }
- this.isCalculating = false;
- },
- error: (err) => {
- console.error(err);
- alert('Calculation failed: ' + (err.error?.detail || err.message));
- this.isCalculating = false;
- }
- });
- }
-}
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
new file mode 100644
index 0000000..c187d52
--- /dev/null
+++ b/frontend/src/app/shared/components/app-alert/app-alert.component.ts
@@ -0,0 +1,36 @@
+import { Component, input } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+@Component({
+ 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; }
+ `]
+})
+export class AppAlertComponent {
+ type = input<'info' | 'warning' | 'error' | 'success'>('info');
+}
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
new file mode 100644
index 0000000..9806066
--- /dev/null
+++ b/frontend/src/app/shared/components/app-button/app-button.component.ts
@@ -0,0 +1,80 @@
+import { Component, input } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+@Component({
+ 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: 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: white;
+ &: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-brand);
+ }
+ }
+
+ .btn-text {
+ background-color: transparent;
+ color: var(--color-text-muted);
+ padding: 0.5rem;
+ &:hover:not(:disabled) { color: var(--color-text); }
+ }
+ `]
+})
+export class AppButtonComponent {
+ variant = input<'primary' | 'secondary' | 'outline' | 'text'>('primary');
+ type = input<'button' | 'submit' | 'reset'>('button');
+ disabled = input(false);
+ fullWidth = input(false);
+
+ handleClick(event: Event) {
+ if (this.disabled()) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }
+}
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
new file mode 100644
index 0000000..05dc74b
--- /dev/null
+++ b/frontend/src/app/shared/components/app-card/app-card.component.ts
@@ -0,0 +1,26 @@
+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);
+ }
+ }
+ `]
+})
+export class AppCardComponent {}
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
new file mode 100644
index 0000000..5cfe140
--- /dev/null
+++ b/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.ts
@@ -0,0 +1,104 @@
+import { Component, input, output, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+@Component({
+ selector: 'app-dropzone',
+ standalone: true,
+ imports: [CommonModule],
+ template: `
+
+
+
+
+
+
{{ label() }}
+
{{ subtext() }}
+ @if (fileName()) {
+
+ {{ fileName() }}
+
+ }
+
+
+ `,
+ 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-badge {
+ margin-top: var(--space-4);
+ display: inline-block;
+ 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);
+ }
+ `]
+})
+export class AppDropzoneComponent {
+ label = input('Drop file here or click to upload');
+ subtext = input('Supports .stl, .obj');
+ accept = input('.stl,.obj');
+
+ fileDropped = output();
+
+ isDragOver = signal(false);
+ fileName = signal(null);
+
+ onDragOver(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.isDragOver.set(true);
+ }
+
+ onDragLeave(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.isDragOver.set(false);
+ }
+
+ onDrop(e: DragEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.isDragOver.set(false);
+ if (e.dataTransfer?.files.length) {
+ this.handleFile(e.dataTransfer.files[0]);
+ }
+ }
+
+ onFileSelected(e: Event) {
+ const input = e.target as HTMLInputElement;
+ if (input.files?.length) {
+ this.handleFile(input.files[0]);
+ }
+ }
+
+ handleFile(file: File) {
+ this.fileName.set(file.name);
+ this.fileDropped.emit(file);
+ }
+}
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
new file mode 100644
index 0000000..974495b
--- /dev/null
+++ b/frontend/src/app/shared/components/app-input/app-input.component.ts
@@ -0,0 +1,72 @@
+import { Component, input, forwardRef } from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
+import { CommonModule } from '@angular/common';
+
+@Component({
+ selector: 'app-input',
+ standalone: true,
+ imports: [CommonModule, ReactiveFormsModule],
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => AppInputComponent),
+ 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(59, 130, 246, 0.2); }
+ &: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); }
+ `]
+})
+export class AppInputComponent implements ControlValueAccessor {
+ label = input('');
+ id = input('input-' + Math.random().toString(36).substr(2, 9));
+ type = input('text');
+ placeholder = input('');
+ error = input(null);
+
+ value: string = '';
+ disabled = false;
+
+ onChange: any = () => {};
+ onTouched: any = () => {};
+
+ writeValue(obj: any): void { this.value = obj || ''; }
+ registerOnChange(fn: any): void { this.onChange = fn; }
+ registerOnTouched(fn: any): void { this.onTouched = fn; }
+ setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; }
+
+ onInput(event: Event) {
+ const val = (event.target as HTMLInputElement).value;
+ this.value = val;
+ this.onChange(val);
+ }
+}
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
new file mode 100644
index 0000000..84f0ead
--- /dev/null
+++ b/frontend/src/app/shared/components/app-select/app-select.component.ts
@@ -0,0 +1,72 @@
+import { Component, input, output, forwardRef } from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
+import { CommonModule } from '@angular/common';
+
+@Component({
+ selector: 'app-select',
+ standalone: true,
+ imports: [CommonModule, ReactiveFormsModule],
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => AppSelectComponent),
+ 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); }
+ `]
+})
+export class AppSelectComponent implements ControlValueAccessor {
+ label = input('');
+ id = input('select-' + Math.random().toString(36).substr(2, 9));
+ options = input<{label: string, value: any}[]>([]);
+ error = input(null);
+
+ value: any = '';
+ disabled = false;
+
+ onChange: any = () => {};
+ onTouched: any = () => {};
+
+ writeValue(obj: any): void { this.value = obj; }
+ registerOnChange(fn: any): void { this.onChange = fn; }
+ 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);
+ }
+}
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
new file mode 100644
index 0000000..9bf07ce
--- /dev/null
+++ b/frontend/src/app/shared/components/app-tabs/app-tabs.component.ts
@@ -0,0 +1,52 @@
+import { Component, input, output } from '@angular/core';
+import { CommonModule } from '@angular/common';
+
+@Component({
+ selector: 'app-tabs',
+ standalone: true,
+ imports: [CommonModule],
+ template: `
+
+ @for (tab of tabs(); track tab.value) {
+
+ {{ tab.label }}
+
+ }
+
+ `,
+ 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);
+ }
+ }
+ `]
+})
+export class AppTabsComponent {
+ tabs = input<{label: string, value: string}[]>([]);
+ activeTab = input('');
+ tabChange = output();
+
+ selectTab(val: string) {
+ this.tabChange.emit(val);
+ }
+}
diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json
new file mode 100644
index 0000000..c62326e
--- /dev/null
+++ b/frontend/src/assets/i18n/en.json
@@ -0,0 +1,43 @@
+{
+ "NAV": {
+ "CALCULATOR": "Calculator",
+ "SHOP": "Shop",
+ "ABOUT": "About"
+ },
+ "FOOTER": {
+ "PRIVACY": "Privacy",
+ "TERMS": "Terms & Conditions",
+ "CONTACT": "Contact Us"
+ },
+ "CALC": {
+ "TITLE": "3D Print Calculator",
+ "SUBTITLE": "Upload your STL file and get an instant estimate of printing costs and time.",
+ "CTA_START": "Start Now",
+ "BUSINESS": "Business",
+ "PRIVATE": "Private",
+ "MODE_EASY": "Easy",
+ "MODE_ADVANCED": "Advanced",
+ "UPLOAD_LABEL": "Drag your STL file here",
+ "UPLOAD_SUB": "Supports STL, OBJ up to 50MB",
+ "MATERIAL": "Material",
+ "QUALITY": "Quality",
+ "QUANTITY": "Quantity",
+ "NOTES": "Additional Notes",
+ "CALCULATE": "Calculate Quote",
+ "RESULT": "Estimated Quote",
+ "TIME": "Print Time",
+ "COST": "Total Cost",
+ "ORDER": "Order Now",
+ "CONSULT": "Request Consultation"
+ },
+ "SHOP": {
+ "TITLE": "Filaments & Accessories",
+ "ADD_CART": "Add to Cart",
+ "BACK": "Back to Shop"
+ },
+ "ABOUT": {
+ "TITLE": "About Us",
+ "CONTACT_US": "Contact Us",
+ "SEND": "Send Message"
+ }
+}
diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json
new file mode 100644
index 0000000..e6a7201
--- /dev/null
+++ b/frontend/src/assets/i18n/it.json
@@ -0,0 +1,43 @@
+{
+ "NAV": {
+ "CALCULATOR": "Calcolatore",
+ "SHOP": "Shop",
+ "ABOUT": "Chi Siamo"
+ },
+ "FOOTER": {
+ "PRIVACY": "Privacy",
+ "TERMS": "Termini & Condizioni",
+ "CONTACT": "Contattaci"
+ },
+ "CALC": {
+ "TITLE": "Calcola Preventivo 3D",
+ "SUBTITLE": "Carica il tuo file STL e ricevi una stima immediata dei costi e tempi di stampa.",
+ "CTA_START": "Inizia Ora",
+ "BUSINESS": "Aziende",
+ "PRIVATE": "Privati",
+ "MODE_EASY": "Easy",
+ "MODE_ADVANCED": "Advanced",
+ "UPLOAD_LABEL": "Trascina il tuo file STL qui",
+ "UPLOAD_SUB": "Supportiamo STL, OBJ fino a 50MB",
+ "MATERIAL": "Materiale",
+ "QUALITY": "Qualità",
+ "QUANTITY": "Quantità",
+ "NOTES": "Note aggiuntive",
+ "CALCULATE": "Calcola Preventivo",
+ "RESULT": "Preventivo Stimato",
+ "TIME": "Tempo Stampa",
+ "COST": "Costo Totale",
+ "ORDER": "Ordina Ora",
+ "CONSULT": "Richiedi Consulenza"
+ },
+ "SHOP": {
+ "TITLE": "Filamenti & Accessori",
+ "ADD_CART": "Aggiungi al Carrello",
+ "BACK": "Torna allo Shop"
+ },
+ "ABOUT": {
+ "TITLE": "Chi Siamo",
+ "CONTACT_US": "Contattaci",
+ "SEND": "Invia Messaggio"
+ }
+}
diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss
index cac4953..63b43b8 100644
--- a/frontend/src/styles.scss
+++ b/frontend/src/styles.scss
@@ -1,119 +1,47 @@
-/* Global Styles & Variables */
-:root {
- /* Color Palette - Modern Dark Theme */
- --primary-color: #6366f1; /* Indigo 500 */
- --primary-hover: #4f46e5; /* Indigo 600 */
- --secondary-color: #ec4899; /* Pink 500 */
-
- --bg-color: #0f172a; /* Slate 900 */
- --surface-color: #1e293b; /* Slate 800 */
- --surface-hover: #334155; /* Slate 700 */
-
- --text-main: #f8fafc; /* Slate 50 */
- --text-muted: #94a3b8; /* Slate 400 */
-
- --border-color: #334155;
-
- /* Spacing & Radius */
- --radius-sm: 0.375rem;
- --radius-md: 0.5rem;
- --radius-lg: 0.75rem;
- --radius-xl: 1rem;
-
- /* Shadows */
- --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
- --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
- --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
-
- /* Transitions */
- --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
-}
+/* src/styles.scss */
+@use './styles/theme';
-html, body {
- height: 100%;
+/* Reset / Base */
+*, *::before, *::after {
+ box-sizing: border-box;
}
body {
margin: 0;
- font-family: 'Roboto', 'Helvetica Neue', sans-serif;
- background-color: var(--bg-color);
- color: var(--text-main);
+ font-family: var(--font-family-sans);
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ line-height: 1.5;
-webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
}
-/* Common Layout Utilities */
-.container {
- max-width: 1200px;
- margin: 0 auto;
- padding: 2rem;
+h1, h2, h3, h4, h5, h6 {
+ margin: 0;
+ font-weight: 600;
+ line-height: 1.2;
}
-.card {
- background-color: var(--surface-color);
- border: 1px solid var(--border-color);
- border-radius: var(--radius-lg);
- padding: 2rem;
- box-shadow: var(--shadow-md);
- transition: transform var(--transition-fast), box-shadow var(--transition-fast);
-}
-
-.card:hover {
- transform: translateY(-2px);
- box-shadow: var(--shadow-lg);
-}
-
-.btn {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- padding: 0.75rem 1.5rem;
- border-radius: var(--radius-md);
- font-weight: 500;
- cursor: pointer;
- transition: background-color var(--transition-fast);
- border: none;
- font-size: 1rem;
- text-decoration: none;
-}
-
-.btn-primary {
- background-color: var(--primary-color);
- color: white;
-
- &:hover {
- background-color: var(--primary-hover);
- }
-}
-
-.btn-secondary {
- background-color: transparent;
- border: 1px solid var(--border-color);
- color: var(--text-main);
-
- &:hover {
- background-color: var(--surface-hover);
- }
-}
-
-.grid-2 {
- display: grid;
- grid-template-columns: 1fr;
- gap: 2rem;
-
- @media (min-width: 768px) {
- grid-template-columns: repeat(2, 1fr);
- }
-}
-
-h1, h2, h3 {
+p {
margin-top: 0;
- font-weight: 700;
- letter-spacing: -0.025em;
+ margin-bottom: var(--space-4);
}
a {
- color: var(--primary-color);
+ color: var(--color-brand);
text-decoration: none;
- cursor: pointer;
+ &:hover {
+ text-decoration: underline;
+ }
}
+
+/* Utilities */
+.container {
+ width: 100%;
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 0 var(--space-4);
+}
+
+.text-center { text-align: center; }
+.mb-4 { margin-bottom: var(--space-4); }
+.mt-4 { margin-top: var(--space-4); }
\ No newline at end of file
diff --git a/frontend/src/styles/theme.scss b/frontend/src/styles/theme.scss
new file mode 100644
index 0000000..9f67b3c
--- /dev/null
+++ b/frontend/src/styles/theme.scss
@@ -0,0 +1,18 @@
+/* src/styles/theme.scss */
+@use 'tokens';
+
+:root {
+ /* Semantic Colors - Theming Layer */
+ --color-bg: var(--color-neutral-50);
+ --color-bg-card: #ffffff;
+ --color-text: var(--color-neutral-900);
+ --color-text-muted: var(--color-secondary-500);
+
+ --color-brand: var(--color-primary-600);
+ --color-brand-hover: var(--color-primary-700);
+
+ --color-border: var(--color-neutral-200);
+
+ /* Font */
+ --font-family-sans: 'Inter', system-ui, -apple-system, sans-serif;
+}
diff --git a/frontend/src/styles/tokens.scss b/frontend/src/styles/tokens.scss
new file mode 100644
index 0000000..1e9dbc6
--- /dev/null
+++ b/frontend/src/styles/tokens.scss
@@ -0,0 +1,41 @@
+/* src/styles/tokens.scss */
+:root {
+ /* Colors - Palette */
+ --color-primary-500: #3b82f6;
+ --color-primary-600: #2563eb;
+ --color-primary-700: #1d4ed8;
+
+ --color-secondary-500: #64748b;
+ --color-secondary-600: #475569;
+
+ --color-success-500: #22c55e;
+ --color-warning-500: #eab308;
+ --color-danger-500: #ef4444;
+
+ --color-neutral-50: #f8fafc;
+ --color-neutral-100: #f1f5f9;
+ --color-neutral-200: #e2e8f0;
+ --color-neutral-300: #cbd5e1;
+ --color-neutral-800: #1e293b;
+ --color-neutral-900: #0f172a;
+
+ /* Spacing */
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+ --space-12: 3rem;
+
+ /* Radius */
+ --radius-sm: 0.25rem;
+ --radius-md: 0.375rem;
+ --radius-lg: 0.5rem;
+ --radius-xl: 1rem;
+
+ /* Shadows */
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
+}