dev #5
@@ -36,3 +36,7 @@ Questo file serve a dare contesto all'AI (Antigravity/Gemini) sulla struttura e
|
|||||||
- Per eseguire il backend serve `uvicorn`.
|
- Per eseguire il backend serve `uvicorn`.
|
||||||
- Il frontend richiede `npm install` al primo avvio.
|
- Il frontend richiede `npm install` al primo avvio.
|
||||||
- Le configurazioni di stampa (layer height, wall thickness, infill) sono attualmente hardcoded o con valori di default nel backend, ma potrebbero essere esposte come parametri API in futuro.
|
- Le configurazioni di stampa (layer height, wall thickness, infill) sono attualmente hardcoded o con valori di default nel backend, ma potrebbero essere esposte come parametri API in futuro.
|
||||||
|
|
||||||
|
## AI Agent Rules
|
||||||
|
- **No Inline Code**: Tutti i componenti Angular DEVONO usare file separati per HTML (`templateUrl`) e SCSS (`styleUrl`). È vietato usare `template` o `styles` inline nel decoratore `@Component`.
|
||||||
|
|
||||||
|
|||||||
0
frontend/src/app/app.component.scss
Normal file
0
frontend/src/app/app.component.scss
Normal file
@@ -5,6 +5,7 @@ import { RouterOutlet } from '@angular/router';
|
|||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet],
|
imports: [RouterOutlet],
|
||||||
template: `<router-outlet></router-outlet>`
|
templateUrl: './app.component.html',
|
||||||
|
styleUrl: './app.component.scss'
|
||||||
})
|
})
|
||||||
export class AppComponent {}
|
export class AppComponent {}
|
||||||
|
|||||||
41
frontend/src/app/core/constants/colors.const.ts
Normal file
41
frontend/src/app/core/constants/colors.const.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export interface ColorOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hex: string;
|
||||||
|
outOfStock?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColorCategory {
|
||||||
|
name: string; // 'Glossy' | 'Matte'
|
||||||
|
colors: ColorOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRODUCT_COLORS: ColorCategory[] = [
|
||||||
|
{
|
||||||
|
name: 'Lucidi', // Glossy
|
||||||
|
colors: [
|
||||||
|
{ label: 'Black', value: 'Black', hex: '#1a1a1a' }, // Not pure black for visibility
|
||||||
|
{ label: 'White', value: 'White', hex: '#f5f5f5' },
|
||||||
|
{ label: 'Red', value: 'Red', hex: '#d32f2f', outOfStock: true },
|
||||||
|
{ label: 'Blue', value: 'Blue', hex: '#1976d2' },
|
||||||
|
{ label: 'Green', value: 'Green', hex: '#388e3c' },
|
||||||
|
{ label: 'Yellow', value: 'Yellow', hex: '#fbc02d' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Opachi', // Matte
|
||||||
|
colors: [
|
||||||
|
{ label: 'Matte Black', value: 'Matte Black', hex: '#2c2c2c' }, // Lighter charcoal for matte
|
||||||
|
{ label: 'Matte White', value: 'Matte White', hex: '#e0e0e0' },
|
||||||
|
{ label: 'Matte Gray', value: 'Matte Gray', hex: '#757575' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getColorHex(value: string): string {
|
||||||
|
for (const cat of PRODUCT_COLORS) {
|
||||||
|
const found = cat.colors.find(c => c.value === value);
|
||||||
|
if (found) return found.hex;
|
||||||
|
}
|
||||||
|
return '#facf0a'; // Default Brand Color if not found
|
||||||
|
}
|
||||||
7
frontend/src/app/core/layout/layout.component.html
Normal file
7
frontend/src/app/core/layout/layout.component.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<div class="layout-wrapper">
|
||||||
|
<app-navbar></app-navbar>
|
||||||
|
<main class="main-content">
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</main>
|
||||||
|
<app-footer></app-footer>
|
||||||
|
</div>
|
||||||
9
frontend/src/app/core/layout/layout.component.scss
Normal file
9
frontend/src/app/core/layout/layout.component.scss
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.layout-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
padding-bottom: var(--space-12);
|
||||||
|
}
|
||||||
@@ -7,25 +7,7 @@ import { FooterComponent } from './footer.component';
|
|||||||
selector: 'app-layout',
|
selector: 'app-layout',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet, NavbarComponent, FooterComponent],
|
imports: [RouterOutlet, NavbarComponent, FooterComponent],
|
||||||
template: `
|
templateUrl: './layout.component.html',
|
||||||
<div class="layout-wrapper">
|
styleUrl: './layout.component.scss'
|
||||||
<app-navbar></app-navbar>
|
|
||||||
<main class="main-content">
|
|
||||||
<router-outlet></router-outlet>
|
|
||||||
</main>
|
|
||||||
<app-footer></app-footer>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.layout-wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
.main-content {
|
|
||||||
flex: 1;
|
|
||||||
padding-bottom: var(--space-12);
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class LayoutComponent {}
|
export class LayoutComponent {}
|
||||||
|
|||||||
44
frontend/src/app/features/about/about-page.component.html
Normal file
44
frontend/src/app/features/about/about-page.component.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<section class="about-section">
|
||||||
|
<div class="container split-layout">
|
||||||
|
|
||||||
|
<!-- Left Column: Content -->
|
||||||
|
<div class="text-content">
|
||||||
|
<p class="eyebrow">{{ 'ABOUT.EYEBROW' | translate }}</p>
|
||||||
|
<h1>{{ 'ABOUT.TITLE' | translate }}</h1>
|
||||||
|
<p class="subtitle">{{ 'ABOUT.SUBTITLE' | translate }}</p>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<p class="description">{{ 'ABOUT.HOW_TEXT' | translate }}</p>
|
||||||
|
|
||||||
|
<div class="tags-container">
|
||||||
|
<span class="tag">{{ 'ABOUT.PILL_1' | translate }}</span>
|
||||||
|
<span class="tag">{{ 'ABOUT.PILL_2' | translate }}</span>
|
||||||
|
<span class="tag">{{ 'ABOUT.PILL_3' | translate }}</span>
|
||||||
|
<span class="tag">{{ 'ABOUT.SERVICE_1' | translate }}</span>
|
||||||
|
<span class="tag">{{ 'ABOUT.SERVICE_2' | translate }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Visuals -->
|
||||||
|
<div class="visual-content">
|
||||||
|
<div class="photo-card card-1">
|
||||||
|
<div class="placeholder-img"></div>
|
||||||
|
<div class="member-info">
|
||||||
|
<span class="member-name">Member 1</span>
|
||||||
|
<span class="member-role">Founder</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="photo-card card-2">
|
||||||
|
<div class="placeholder-img"></div>
|
||||||
|
<div class="member-info">
|
||||||
|
<span class="member-name">Member 2</span>
|
||||||
|
<span class="member-role">Co-Founder</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-locations></app-locations>
|
||||||
157
frontend/src/app/features/about/about-page.component.scss
Normal file
157
frontend/src/app/features/about/about-page.component.scss
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
.about-section {
|
||||||
|
padding: 6rem 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
min-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 4rem;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center; /* Center on mobile */
|
||||||
|
|
||||||
|
@media(min-width: 992px) {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6rem;
|
||||||
|
text-align: left; /* Reset to left on desktop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left Column */
|
||||||
|
.text-content {
|
||||||
|
/* text-align: left; Removed to inherit from parent */
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-primary-500);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
color: var(--color-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 4px;
|
||||||
|
width: 60px;
|
||||||
|
background: var(--color-primary-500);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
/* Center divider on mobile */
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
|
||||||
|
@media(min-width: 992px) {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--color-text-main);
|
||||||
|
margin-bottom: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center; /* Center tags on mobile */
|
||||||
|
|
||||||
|
@media(min-width: 992px) {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: var(--color-surface-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-main);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--color-primary-500);
|
||||||
|
color: var(--color-primary-500);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right Column */
|
||||||
|
.visual-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
|
||||||
|
@media(min-width: 768px) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
justify-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card {
|
||||||
|
background: var(--color-surface-card);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 260px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 3/4;
|
||||||
|
background: linear-gradient(45deg, var(--color-neutral-200), var(--color-neutral-100));
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-info {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
display: block;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-main);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-role {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
@@ -1,214 +1,13 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { AppLocationsComponent } from '../../shared/components/app-locations/app-locations.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-about-page',
|
selector: 'app-about-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [TranslateModule],
|
imports: [TranslateModule, AppLocationsComponent],
|
||||||
template: `
|
templateUrl: './about-page.component.html',
|
||||||
<section class="about-section">
|
styleUrl: './about-page.component.scss'
|
||||||
<div class="container split-layout">
|
|
||||||
|
|
||||||
<!-- Left Column: Content -->
|
|
||||||
<div class="text-content">
|
|
||||||
<p class="eyebrow">{{ 'ABOUT.EYEBROW' | translate }}</p>
|
|
||||||
<h1>{{ 'ABOUT.TITLE' | translate }}</h1>
|
|
||||||
<p class="subtitle">{{ 'ABOUT.SUBTITLE' | translate }}</p>
|
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<p class="description">{{ 'ABOUT.HOW_TEXT' | translate }}</p>
|
|
||||||
|
|
||||||
<div class="tags-container">
|
|
||||||
<span class="tag">{{ 'ABOUT.PILL_1' | translate }}</span>
|
|
||||||
<span class="tag">{{ 'ABOUT.PILL_2' | translate }}</span>
|
|
||||||
<span class="tag">{{ 'ABOUT.PILL_3' | translate }}</span>
|
|
||||||
<span class="tag">{{ 'ABOUT.SERVICE_1' | translate }}</span>
|
|
||||||
<span class="tag">{{ 'ABOUT.SERVICE_2' | translate }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right Column: Visuals -->
|
|
||||||
<div class="visual-content">
|
|
||||||
<div class="photo-card card-1">
|
|
||||||
<div class="placeholder-img"></div>
|
|
||||||
<div class="member-info">
|
|
||||||
<span class="member-name">Member 1</span>
|
|
||||||
<span class="member-role">Founder</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="photo-card card-2">
|
|
||||||
<div class="placeholder-img"></div>
|
|
||||||
<div class="member-info">
|
|
||||||
<span class="member-name">Member 2</span>
|
|
||||||
<span class="member-role">Co-Founder</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.about-section {
|
|
||||||
padding: 6rem 0;
|
|
||||||
background: var(--color-bg);
|
|
||||||
min-height: 80vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 4rem;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center; /* Center on mobile */
|
|
||||||
|
|
||||||
@media(min-width: 992px) {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 6rem;
|
|
||||||
text-align: left; /* Reset to left on desktop */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Left Column */
|
|
||||||
.text-content {
|
|
||||||
/* text-align: left; Removed to inherit from parent */
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.15em;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-primary-500);
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 3rem;
|
|
||||||
line-height: 1.1;
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
color: var(--color-text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
font-weight: 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
height: 4px;
|
|
||||||
width: 60px;
|
|
||||||
background: var(--color-primary-500);
|
|
||||||
border-radius: 2px;
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
/* Center divider on mobile */
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
|
|
||||||
@media(min-width: 992px) {
|
|
||||||
margin-left: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: var(--color-text-main);
|
|
||||||
margin-bottom: var(--space-8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-container {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: center; /* Center tags on mobile */
|
|
||||||
|
|
||||||
@media(min-width: 992px) {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 99px;
|
|
||||||
background: var(--color-surface-card);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
color: var(--color-text-main);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
border-color: var(--color-primary-500);
|
|
||||||
color: var(--color-primary-500);
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Right Column */
|
|
||||||
.visual-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2rem;
|
|
||||||
|
|
||||||
@media(min-width: 768px) {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
align-items: start;
|
|
||||||
justify-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.photo-card {
|
|
||||||
background: var(--color-surface-card);
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
width: 100%;
|
|
||||||
max-width: 260px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-img {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 3/4;
|
|
||||||
background: linear-gradient(45deg, var(--color-neutral-200), var(--color-neutral-100));
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-info {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-name {
|
|
||||||
display: block;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text-main);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-role {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class AboutPageComponent {}
|
export class AboutPageComponent {}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<div class="container hero">
|
||||||
|
<h1>{{ 'CALC.TITLE' | translate }}</h1>
|
||||||
|
<p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p>
|
||||||
|
|
||||||
|
@if (error()) {
|
||||||
|
<app-alert type="error">{{ 'CALC.ERROR_GENERIC' | translate }}</app-alert>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (step() === 'success') {
|
||||||
|
<div class="container hero">
|
||||||
|
<app-success-state context="calc" (action)="onNewQuote()"></app-success-state>
|
||||||
|
</div>
|
||||||
|
} @else if (step() === 'details' && result()) {
|
||||||
|
<div class="container">
|
||||||
|
<app-user-details
|
||||||
|
[quote]="result()!"
|
||||||
|
(submitOrder)="onSubmitOrder($event)"
|
||||||
|
(cancel)="onCancelDetails()">
|
||||||
|
</app-user-details>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="container content-grid">
|
||||||
|
<!-- Left Column: Input -->
|
||||||
|
<div class="col-input">
|
||||||
|
<app-card>
|
||||||
|
<div class="mode-selector">
|
||||||
|
<div class="mode-option"
|
||||||
|
[class.active]="mode() === 'easy'"
|
||||||
|
(click)="mode.set('easy')">
|
||||||
|
{{ 'CALC.MODE_EASY' | translate }}
|
||||||
|
</div>
|
||||||
|
<div class="mode-option"
|
||||||
|
[class.active]="mode() === 'advanced'"
|
||||||
|
(click)="mode.set('advanced')">
|
||||||
|
{{ 'CALC.MODE_ADVANCED' | translate }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-upload-form
|
||||||
|
#uploadForm
|
||||||
|
[mode]="mode()"
|
||||||
|
[loading]="loading()"
|
||||||
|
[uploadProgress]="uploadProgress()"
|
||||||
|
(submitRequest)="onCalculate($event)"
|
||||||
|
></app-upload-form>
|
||||||
|
</app-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Result or Info -->
|
||||||
|
<div class="col-result" #resultCol>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<app-card class="loading-state">
|
||||||
|
<div class="loader-content">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<h3 class="loading-title">Analisi in corso...</h3>
|
||||||
|
<p class="loading-text">Stiamo analizzando la geometria e calcolando il percorso utensile.</p>
|
||||||
|
</div>
|
||||||
|
</app-card>
|
||||||
|
} @else if (result()) {
|
||||||
|
<app-quote-result
|
||||||
|
[result]="result()!"
|
||||||
|
(consult)="onConsult()"
|
||||||
|
(proceed)="onProceed()"
|
||||||
|
(itemChange)="uploadForm.updateItemQuantityByName($event.fileName, $event.quantity)"
|
||||||
|
></app-quote-result>
|
||||||
|
} @else {
|
||||||
|
<app-card>
|
||||||
|
<h3>{{ 'CALC.BENEFITS_TITLE' | translate }}</h3>
|
||||||
|
<ul class="benefits">
|
||||||
|
<li>{{ 'CALC.BENEFITS_1' | translate }}</li>
|
||||||
|
<li>{{ 'CALC.BENEFITS_2' | translate }}</li>
|
||||||
|
<li>{{ 'CALC.BENEFITS_3' | translate }}</li>
|
||||||
|
</ul>
|
||||||
|
</app-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
.hero { padding: var(--space-12) 0; text-align: center; }
|
||||||
|
.subtitle { font-size: 1.25rem; color: var(--color-text-muted); max-width: 600px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-6);
|
||||||
|
@media(min-width: 768px) {
|
||||||
|
grid-template-columns: 1.5fr 1fr;
|
||||||
|
gap: var(--space-8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.centered-col {
|
||||||
|
align-self: flex-start; /* Default */
|
||||||
|
@media(min-width: 768px) {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-input {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-result {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
/* Make children (specifically app-card) stretch */
|
||||||
|
> * {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mode Selector (Segmented Control style) */
|
||||||
|
.mode-selector {
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--color-neutral-100);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 4px;
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-option {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover { color: var(--color-text); }
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: var(--color-brand);
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.benefits { padding-left: var(--space-4); color: var(--color-text-muted); line-height: 2; }
|
||||||
|
|
||||||
|
|
||||||
|
.loader-content {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
/* Center content vertically within the stretched card */
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: var(--space-4) 0 var(--space-2);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid var(--color-neutral-200);
|
||||||
|
border-left-color: var(--color-brand);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, signal } from '@angular/core';
|
import { Component, signal, ViewChild, ElementRef } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
|
||||||
@@ -6,186 +6,49 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
|
|||||||
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
|
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
|
||||||
import { UploadFormComponent } from './components/upload-form/upload-form.component';
|
import { UploadFormComponent } from './components/upload-form/upload-form.component';
|
||||||
import { QuoteResultComponent } from './components/quote-result/quote-result.component';
|
import { QuoteResultComponent } from './components/quote-result/quote-result.component';
|
||||||
import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quote-estimator.service';
|
import { QuoteRequest, QuoteResult, QuoteEstimatorService } from './services/quote-estimator.service';
|
||||||
|
import { UserDetailsComponent } from './components/user-details/user-details.component';
|
||||||
|
import { SuccessStateComponent } from '../../shared/components/success-state/success-state.component';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-calculator-page',
|
selector: 'app-calculator-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent],
|
imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, UserDetailsComponent, SuccessStateComponent],
|
||||||
template: `
|
templateUrl: './calculator-page.component.html',
|
||||||
<div class="container hero">
|
styleUrl: './calculator-page.component.scss'
|
||||||
<h1>{{ 'CALC.TITLE' | translate }}</h1>
|
|
||||||
<p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container content-grid">
|
|
||||||
<!-- Left Column: Input -->
|
|
||||||
<div class="col-input">
|
|
||||||
<app-card>
|
|
||||||
<div class="mode-selector">
|
|
||||||
<div class="mode-option"
|
|
||||||
[class.active]="mode() === 'easy'"
|
|
||||||
(click)="mode.set('easy')">
|
|
||||||
{{ 'CALC.MODE_EASY' | translate }}
|
|
||||||
</div>
|
|
||||||
<div class="mode-option"
|
|
||||||
[class.active]="mode() === 'advanced'"
|
|
||||||
(click)="mode.set('advanced')">
|
|
||||||
{{ 'CALC.MODE_ADVANCED' | translate }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<app-upload-form
|
|
||||||
[mode]="mode()"
|
|
||||||
[loading]="loading()"
|
|
||||||
[uploadProgress]="uploadProgress()"
|
|
||||||
(submitRequest)="onCalculate($event)"
|
|
||||||
></app-upload-form>
|
|
||||||
</app-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Right Column: Result or Info -->
|
|
||||||
<div class="col-result">
|
|
||||||
@if (error()) {
|
|
||||||
<app-alert type="error">Si è verificato un errore durante il calcolo del preventivo.</app-alert>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (loading()) {
|
|
||||||
<app-card class="loading-state">
|
|
||||||
<div class="loader-content">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<h3 class="loading-title">Analisi in corso...</h3>
|
|
||||||
<p class="loading-text">Stiamo analizzando la geometria e calcolando il percorso utensile.</p>
|
|
||||||
</div>
|
|
||||||
</app-card>
|
|
||||||
} @else if (result()) {
|
|
||||||
<app-quote-result [result]="result()!" (consult)="onConsult()"></app-quote-result>
|
|
||||||
} @else {
|
|
||||||
<app-card>
|
|
||||||
<h3>{{ 'CALC.BENEFITS_TITLE' | translate }}</h3>
|
|
||||||
<ul class="benefits">
|
|
||||||
<li>{{ 'CALC.BENEFITS_1' | translate }}</li>
|
|
||||||
<li>{{ 'CALC.BENEFITS_2' | translate }}</li>
|
|
||||||
<li>{{ 'CALC.BENEFITS_3' | translate }}</li>
|
|
||||||
</ul>
|
|
||||||
</app-card>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.hero { padding: var(--space-12) 0; text-align: center; }
|
|
||||||
.subtitle { font-size: 1.25rem; color: var(--color-text-muted); max-width: 600px; margin: 0 auto; }
|
|
||||||
|
|
||||||
.content-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: var(--space-8);
|
|
||||||
@media(min-width: 768px) {
|
|
||||||
grid-template-columns: 1.5fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.centered-col {
|
|
||||||
align-self: flex-start; /* Default */
|
|
||||||
@media(min-width: 768px) {
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mode Selector (Segmented Control style) */
|
|
||||||
.mode-selector {
|
|
||||||
display: flex;
|
|
||||||
background-color: var(--color-neutral-100);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 4px;
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
gap: 4px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-option {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
&:hover { color: var(--color-text); }
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background-color: var(--color-brand);
|
|
||||||
color: #000;
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.benefits { padding-left: var(--space-4); color: var(--color-text-muted); line-height: 2; }
|
|
||||||
|
|
||||||
.loading-state {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 300px; /* Match typical result height */
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader-content {
|
|
||||||
text-align: center;
|
|
||||||
max-width: 300px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-title {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: var(--space-4) 0 var(--space-2);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-text {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
border: 3px solid var(--color-neutral-200);
|
|
||||||
border-left-color: var(--color-brand);
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class CalculatorPageComponent {
|
export class CalculatorPageComponent {
|
||||||
mode = signal<any>('easy');
|
mode = signal<any>('easy');
|
||||||
|
step = signal<'upload' | 'quote' | 'details' | 'success'>('upload');
|
||||||
|
|
||||||
loading = signal(false);
|
loading = signal(false);
|
||||||
uploadProgress = signal(0);
|
uploadProgress = signal(0);
|
||||||
result = signal<QuoteResult | null>(null);
|
result = signal<QuoteResult | null>(null);
|
||||||
error = signal<boolean>(false);
|
error = signal<boolean>(false);
|
||||||
|
|
||||||
|
orderSuccess = signal(false);
|
||||||
|
|
||||||
|
@ViewChild('uploadForm') uploadForm!: UploadFormComponent;
|
||||||
|
@ViewChild('resultCol') resultCol!: ElementRef;
|
||||||
|
|
||||||
constructor(private estimator: QuoteEstimatorService, private router: Router) {}
|
constructor(private estimator: QuoteEstimatorService, private router: Router) {}
|
||||||
|
|
||||||
onCalculate(req: QuoteRequest) {
|
onCalculate(req: QuoteRequest) {
|
||||||
|
// ... (logic remains the same, simplified for diff)
|
||||||
this.currentRequest = req;
|
this.currentRequest = req;
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.uploadProgress.set(0);
|
this.uploadProgress.set(0);
|
||||||
this.error.set(false);
|
this.error.set(false);
|
||||||
this.result.set(null);
|
this.result.set(null);
|
||||||
|
this.orderSuccess.set(false);
|
||||||
|
|
||||||
|
// Auto-scroll on mobile to make analysis visible
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.resultCol && window.innerWidth < 768) {
|
||||||
|
this.resultCol.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
this.estimator.calculate(req).subscribe({
|
this.estimator.calculate(req).subscribe({
|
||||||
next: (event) => {
|
next: (event) => {
|
||||||
@@ -196,6 +59,7 @@ export class CalculatorPageComponent {
|
|||||||
this.result.set(event as QuoteResult);
|
this.result.set(event as QuoteResult);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.uploadProgress.set(100);
|
this.uploadProgress.set(100);
|
||||||
|
this.step.set('quote');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
@@ -205,6 +69,27 @@ export class CalculatorPageComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onProceed() {
|
||||||
|
this.step.set('details');
|
||||||
|
}
|
||||||
|
|
||||||
|
onCancelDetails() {
|
||||||
|
this.step.set('quote');
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmitOrder(orderData: any) {
|
||||||
|
console.log('Order Submitted:', orderData);
|
||||||
|
this.orderSuccess.set(true);
|
||||||
|
this.step.set('success');
|
||||||
|
}
|
||||||
|
|
||||||
|
onNewQuote() {
|
||||||
|
this.step.set('upload');
|
||||||
|
this.result.set(null);
|
||||||
|
this.orderSuccess.set(false);
|
||||||
|
this.mode.set('easy'); // Reset to default
|
||||||
|
}
|
||||||
|
|
||||||
private currentRequest: QuoteRequest | null = null;
|
private currentRequest: QuoteRequest | null = null;
|
||||||
|
|
||||||
onConsult() {
|
onConsult() {
|
||||||
@@ -214,17 +99,24 @@ export class CalculatorPageComponent {
|
|||||||
let details = `Richiesta Preventivo:\n`;
|
let details = `Richiesta Preventivo:\n`;
|
||||||
details += `- Materiale: ${req.material}\n`;
|
details += `- Materiale: ${req.material}\n`;
|
||||||
details += `- Qualità: ${req.quality}\n`;
|
details += `- Qualità: ${req.quality}\n`;
|
||||||
details += `- Quantità: ${req.quantity}\n`;
|
|
||||||
|
details += `- File:\n`;
|
||||||
|
req.items.forEach(item => {
|
||||||
|
details += ` * ${item.file.name} (Qtà: ${item.quantity}`;
|
||||||
|
if (item.color) {
|
||||||
|
details += `, Colore: ${item.color}`;
|
||||||
|
}
|
||||||
|
details += `)\n`;
|
||||||
|
});
|
||||||
|
|
||||||
if (req.mode === 'advanced') {
|
if (req.mode === 'advanced') {
|
||||||
if (req.color) details += `- Colore: ${req.color}\n`;
|
|
||||||
if (req.infillDensity) details += `- Infill: ${req.infillDensity}%\n`;
|
if (req.infillDensity) details += `- Infill: ${req.infillDensity}%\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.notes) details += `\nNote: ${req.notes}`;
|
if (req.notes) details += `\nNote: ${req.notes}`;
|
||||||
|
|
||||||
this.estimator.setPendingConsultation({
|
this.estimator.setPendingConsultation({
|
||||||
files: req.files,
|
files: req.items.map(i => i.file),
|
||||||
message: details
|
message: details
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<app-card>
|
||||||
|
<h3 class="title">{{ 'CALC.RESULT' | translate }}</h3>
|
||||||
|
|
||||||
|
<!-- Summary Grid (NOW ON TOP) -->
|
||||||
|
<div class="result-grid">
|
||||||
|
<app-summary-card
|
||||||
|
class="item full-width"
|
||||||
|
[label]="'CALC.COST' | translate"
|
||||||
|
[large]="true"
|
||||||
|
[highlight]="true">
|
||||||
|
{{ totals().price | currency:result().currency }}
|
||||||
|
</app-summary-card>
|
||||||
|
|
||||||
|
<app-summary-card [label]="'CALC.TIME' | translate">
|
||||||
|
{{ totals().hours }}h {{ totals().minutes }}m
|
||||||
|
</app-summary-card>
|
||||||
|
|
||||||
|
<app-summary-card [label]="'CALC.MATERIAL' | translate">
|
||||||
|
{{ totals().weight }}g
|
||||||
|
</app-summary-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setup-note">
|
||||||
|
<small>* Include {{ result().setupCost | currency:result().currency }} Setup Cost</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<!-- Detailed Items List (NOW ON BOTTOM) -->
|
||||||
|
<div class="items-list">
|
||||||
|
@for (item of items(); track item.fileName; let i = $index) {
|
||||||
|
<div class="item-row">
|
||||||
|
<div class="item-info">
|
||||||
|
<span class="file-name">{{ item.fileName }}</span>
|
||||||
|
<span class="file-details">
|
||||||
|
{{ (item.unitTime / 3600) | number:'1.1-1' }}h | {{ item.unitWeight | number:'1.0-0' }}g
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-controls">
|
||||||
|
<div class="qty-control">
|
||||||
|
<label>Qtà:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
[ngModel]="item.quantity"
|
||||||
|
(ngModelChange)="updateQuantity(i, $event)"
|
||||||
|
class="qty-input">
|
||||||
|
</div>
|
||||||
|
<div class="item-price">
|
||||||
|
{{ (item.unitPrice * item.quantity) | currency:result().currency }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<app-button variant="outline" (click)="consult.emit()">
|
||||||
|
{{ 'QUOTE.CONSULT' | translate }}
|
||||||
|
</app-button>
|
||||||
|
|
||||||
|
<app-button (click)="proceed.emit()">
|
||||||
|
{{ 'QUOTE.PROCEED_ORDER' | translate }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</app-card>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
.title { margin-bottom: var(--space-6); text-align: center; }
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--color-border);
|
||||||
|
margin: var(--space-4) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--color-neutral-50);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1; /* Ensure it takes available space */
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name { font-weight: 500; font-size: 0.9rem; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.file-details { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
.item-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
|
||||||
|
label { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-input {
|
||||||
|
width: 60px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-align: center;
|
||||||
|
&:focus { outline: none; border-color: var(--color-brand); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-price {
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
|
||||||
|
@media(min-width: 500px) {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.full-width { grid-column: span 2; }
|
||||||
|
|
||||||
|
.setup-note {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||||
@@ -1,57 +1,74 @@
|
|||||||
import { Component, input, output } from '@angular/core';
|
import { Component, input, output, signal, computed, effect } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component';
|
import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component';
|
||||||
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
||||||
import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component';
|
import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component';
|
||||||
import { QuoteResult } from '../../services/quote-estimator.service';
|
import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-quote-result',
|
selector: 'app-quote-result',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, TranslateModule, AppCardComponent, AppButtonComponent, SummaryCardComponent],
|
imports: [CommonModule, FormsModule, TranslateModule, AppCardComponent, AppButtonComponent, SummaryCardComponent],
|
||||||
template: `
|
templateUrl: './quote-result.component.html',
|
||||||
<app-card>
|
styleUrl: './quote-result.component.scss'
|
||||||
<h3 class="title">{{ 'CALC.RESULT' | translate }}</h3>
|
|
||||||
|
|
||||||
<div class="result-grid">
|
|
||||||
<app-summary-card
|
|
||||||
class="item full-width"
|
|
||||||
[label]="'CALC.COST' | translate"
|
|
||||||
[large]="true"
|
|
||||||
[highlight]="true">
|
|
||||||
{{ result().price | currency:result().currency }}
|
|
||||||
</app-summary-card>
|
|
||||||
|
|
||||||
<app-summary-card [label]="'CALC.TIME' | translate">
|
|
||||||
{{ result().printTimeHours }}h {{ result().printTimeMinutes }}m
|
|
||||||
</app-summary-card>
|
|
||||||
|
|
||||||
<app-summary-card [label]="'CALC.MATERIAL' | translate">
|
|
||||||
{{ result().materialUsageGrams }}g
|
|
||||||
</app-summary-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<app-button variant="primary" [fullWidth]="true">{{ 'CALC.ORDER' | translate }}</app-button>
|
|
||||||
<app-button variant="outline" [fullWidth]="true" (click)="consult.emit()">{{ 'CALC.CONSULT' | translate }}</app-button>
|
|
||||||
</div>
|
|
||||||
</app-card>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.title { margin-bottom: var(--space-6); text-align: center; }
|
|
||||||
.result-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: var(--space-4);
|
|
||||||
margin-bottom: var(--space-6);
|
|
||||||
}
|
|
||||||
.full-width { grid-column: span 2; }
|
|
||||||
|
|
||||||
.actions { display: flex; flex-direction: column; gap: var(--space-3); }
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class QuoteResultComponent {
|
export class QuoteResultComponent {
|
||||||
result = input.required<QuoteResult>();
|
result = input.required<QuoteResult>();
|
||||||
consult = output<void>();
|
consult = output<void>();
|
||||||
|
proceed = output<void>();
|
||||||
|
itemChange = output<{fileName: string, quantity: number}>();
|
||||||
|
|
||||||
|
// Local mutable state for items to handle quantity changes
|
||||||
|
items = signal<QuoteItem[]>([]);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
// Initialize local items when result inputs change
|
||||||
|
// We map to new objects to avoid mutating the input directly if it was a reference
|
||||||
|
this.items.set(this.result().items.map(i => ({...i})));
|
||||||
|
}, { allowSignalWrites: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateQuantity(index: number, newQty: number | string) {
|
||||||
|
const qty = typeof newQty === 'string' ? parseInt(newQty, 10) : newQty;
|
||||||
|
if (qty < 1 || isNaN(qty)) return;
|
||||||
|
|
||||||
|
this.items.update(current => {
|
||||||
|
const updated = [...current];
|
||||||
|
updated[index] = { ...updated[index], quantity: qty };
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.itemChange.emit({
|
||||||
|
fileName: this.items()[index].fileName,
|
||||||
|
quantity: qty
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
totals = computed(() => {
|
||||||
|
const currentItems = this.items();
|
||||||
|
const setup = this.result().setupCost;
|
||||||
|
|
||||||
|
let price = setup;
|
||||||
|
let time = 0;
|
||||||
|
let weight = 0;
|
||||||
|
|
||||||
|
currentItems.forEach(i => {
|
||||||
|
price += i.unitPrice * i.quantity;
|
||||||
|
time += i.unitTime * i.quantity;
|
||||||
|
weight += i.unitWeight * i.quantity;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hours = Math.floor(time / 3600);
|
||||||
|
const minutes = Math.ceil((time % 3600) / 60);
|
||||||
|
|
||||||
|
return {
|
||||||
|
price: Math.round(price * 100) / 100,
|
||||||
|
hours,
|
||||||
|
minutes,
|
||||||
|
weight: Math.ceil(weight)
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
@if (selectedFile()) {
|
||||||
|
<div class="viewer-wrapper">
|
||||||
|
<app-stl-viewer
|
||||||
|
[file]="selectedFile()"
|
||||||
|
[color]="getSelectedFileColor()">
|
||||||
|
</app-stl-viewer>
|
||||||
|
<!-- Close button removed as requested -->
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Initial Dropzone (Visible only when no files) -->
|
||||||
|
@if (items().length === 0) {
|
||||||
|
<app-dropzone
|
||||||
|
[label]="'CALC.UPLOAD_LABEL' | translate"
|
||||||
|
[subtext]="'CALC.UPLOAD_SUB' | translate"
|
||||||
|
[accept]="acceptedFormats"
|
||||||
|
[multiple]="true"
|
||||||
|
(filesDropped)="onFilesDropped($event)">
|
||||||
|
</app-dropzone>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- New File List with Details -->
|
||||||
|
@if (items().length > 0) {
|
||||||
|
<div class="items-grid">
|
||||||
|
@for (item of items(); track item.file.name; let i = $index) {
|
||||||
|
<div class="file-card" [class.active]="item.file === selectedFile()" (click)="selectFile(item.file)">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="file-name" [title]="item.file.name">{{ item.file.name }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-controls">
|
||||||
|
<div class="qty-group">
|
||||||
|
<label>QTÀ</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
[value]="item.quantity"
|
||||||
|
(change)="updateItemQuantity(i, $event)"
|
||||||
|
class="qty-input"
|
||||||
|
(click)="$event.stopPropagation()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="color-group">
|
||||||
|
<label>COLORE</label>
|
||||||
|
<app-color-selector
|
||||||
|
[selectedColor]="item.color"
|
||||||
|
(colorSelected)="updateItemColor(i, $event)">
|
||||||
|
</app-color-selector>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn-remove" (click)="removeItem(i); $event.stopPropagation()" title="Remove file">
|
||||||
|
X
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- "Add Files" Button (Visible only when files exist) -->
|
||||||
|
<div class="add-more-container">
|
||||||
|
<input #additionalInput type="file" [accept]="acceptedFormats" multiple hidden (change)="onAdditionalFilesSelected($event)">
|
||||||
|
|
||||||
|
<button type="button" class="btn-add-more" (click)="additionalInput.click()">
|
||||||
|
+ {{ 'CALC.ADD_FILES' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (items().length === 0 && form.get('itemsTouched')?.value) {
|
||||||
|
<div class="error-msg">{{ 'CALC.ERR_FILE_REQUIRED' | translate }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<app-select
|
||||||
|
formControlName="material"
|
||||||
|
[label]="'CALC.MATERIAL' | translate"
|
||||||
|
[options]="materials"
|
||||||
|
></app-select>
|
||||||
|
|
||||||
|
@if (mode() === 'easy') {
|
||||||
|
<app-select
|
||||||
|
formControlName="quality"
|
||||||
|
[label]="'CALC.QUALITY' | translate"
|
||||||
|
[options]="qualities"
|
||||||
|
></app-select>
|
||||||
|
} @else {
|
||||||
|
<app-select
|
||||||
|
formControlName="nozzleDiameter"
|
||||||
|
[label]="'CALC.NOZZLE' | translate"
|
||||||
|
[options]="nozzleDiameters"
|
||||||
|
></app-select>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Global quantity removed, now per item -->
|
||||||
|
|
||||||
|
@if (mode() === 'advanced') {
|
||||||
|
<div class="grid">
|
||||||
|
<app-select
|
||||||
|
formControlName="infillPattern"
|
||||||
|
[label]="'CALC.PATTERN' | translate"
|
||||||
|
[options]="infillPatterns"
|
||||||
|
></app-select>
|
||||||
|
|
||||||
|
<app-select
|
||||||
|
formControlName="layerHeight"
|
||||||
|
[label]="'CALC.LAYER_HEIGHT' | translate"
|
||||||
|
[options]="layerHeights"
|
||||||
|
></app-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<app-input
|
||||||
|
formControlName="infillDensity"
|
||||||
|
type="number"
|
||||||
|
[label]="'CALC.INFILL' | translate"
|
||||||
|
></app-input>
|
||||||
|
|
||||||
|
<div class="checkbox-row">
|
||||||
|
<input type="checkbox" formControlName="supportEnabled" id="support">
|
||||||
|
<label for="support">{{ 'CALC.SUPPORT' | translate }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-input
|
||||||
|
formControlName="notes"
|
||||||
|
[label]="'CALC.NOTES' | translate"
|
||||||
|
placeholder="Istruzioni specifiche..."
|
||||||
|
></app-input>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<!-- Progress Bar (Only when uploading i.e. progress < 100) -->
|
||||||
|
@if (loading() && uploadProgress() < 100) {
|
||||||
|
<div class="progress-container">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" [style.width.%]="uploadProgress()"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<app-button
|
||||||
|
type="submit"
|
||||||
|
[disabled]="form.invalid || items().length === 0 || loading()"
|
||||||
|
[fullWidth]="true">
|
||||||
|
{{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
.section { margin-bottom: var(--space-6); }
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-4);
|
||||||
|
|
||||||
|
@media(min-width: 640px) {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.actions { margin-top: var(--space-6); }
|
||||||
|
.error-msg { color: var(--color-danger-500); font-size: 0.875rem; margin-top: var(--space-2); text-align: center; }
|
||||||
|
|
||||||
|
.viewer-wrapper { position: relative; margin-bottom: var(--space-4); }
|
||||||
|
|
||||||
|
/* Grid Layout for Files */
|
||||||
|
.items-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr; /* Force 2 columns on mobile */
|
||||||
|
gap: var(--space-2); /* Tighten gap for mobile */
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
|
||||||
|
@media(min-width: 640px) {
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card {
|
||||||
|
padding: var(--space-2); /* Reduced from space-3 */
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px; /* Reduced gap */
|
||||||
|
position: relative; /* For absolute positioning of remove btn */
|
||||||
|
min-width: 0; /* Allow flex item to shrink below content size if needed */
|
||||||
|
|
||||||
|
&:hover { border-color: var(--color-neutral-300); }
|
||||||
|
&.active {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
background: rgba(250, 207, 10, 0.05);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
overflow: hidden;
|
||||||
|
padding-right: 25px; /* Adjusted */
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.8rem; /* Smaller font */
|
||||||
|
color: var(--color-text);
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end; /* Align bottom of input and color circle */
|
||||||
|
gap: 16px; /* Space between Qty and Color */
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-group, .color-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column; /* Stack label and input */
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-group {
|
||||||
|
align-items: flex-start; /* Align label left */
|
||||||
|
/* margin-right removed */
|
||||||
|
|
||||||
|
/* Override margin in selector for this context */
|
||||||
|
::ng-deep .color-selector-container {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-input {
|
||||||
|
width: 36px; /* Slightly smaller */
|
||||||
|
padding: 1px 2px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: white;
|
||||||
|
height: 24px; /* Explicit height to match color circle somewhat */
|
||||||
|
&:focus { outline: none; border-color: var(--color-brand); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-danger-100);
|
||||||
|
color: var(--color-danger-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prominent Add Button */
|
||||||
|
.add-more-container {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-more {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--color-neutral-800);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-neutral-900);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
&:active { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
height: 100%;
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
accent-color: var(--color-brand);
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar */
|
||||||
|
.progress-container {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-brand);
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.2s ease-out;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, input, output, signal } from '@angular/core';
|
import { Component, input, output, signal, effect } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
@@ -7,207 +7,22 @@ import { AppSelectComponent } from '../../../../shared/components/app-select/app
|
|||||||
import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone/app-dropzone.component';
|
import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone/app-dropzone.component';
|
||||||
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
||||||
import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component';
|
import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component';
|
||||||
|
import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component';
|
||||||
import { QuoteRequest } from '../../services/quote-estimator.service';
|
import { QuoteRequest } from '../../services/quote-estimator.service';
|
||||||
|
import { getColorHex } from '../../../../core/constants/colors.const';
|
||||||
|
|
||||||
|
interface FormItem {
|
||||||
|
file: File;
|
||||||
|
quantity: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-upload-form',
|
selector: 'app-upload-form',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppSelectComponent, AppDropzoneComponent, AppButtonComponent, StlViewerComponent],
|
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppSelectComponent, AppDropzoneComponent, AppButtonComponent, StlViewerComponent, ColorSelectorComponent],
|
||||||
template: `
|
templateUrl: './upload-form.component.html',
|
||||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
styleUrl: './upload-form.component.scss'
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
@if (selectedFile()) {
|
|
||||||
<div class="viewer-wrapper">
|
|
||||||
<app-stl-viewer [file]="selectedFile()"></app-stl-viewer>
|
|
||||||
<button type="button" class="btn-clear" (click)="clearFiles()">
|
|
||||||
X
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="file-list">
|
|
||||||
@for (f of files(); track f.name) {
|
|
||||||
<div class="file-item" [class.active]="f === selectedFile()" (click)="selectFile(f)">
|
|
||||||
{{ f.name }}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<app-dropzone
|
|
||||||
[label]="'CALC.UPLOAD_LABEL' | translate"
|
|
||||||
[subtext]="'CALC.UPLOAD_SUB' | translate"
|
|
||||||
[accept]="acceptedFormats"
|
|
||||||
[multiple]="true"
|
|
||||||
(filesDropped)="onFilesDropped($event)">
|
|
||||||
</app-dropzone>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (form.get('files')?.invalid && form.get('files')?.touched) {
|
|
||||||
<div class="error-msg">{{ 'CALC.ERR_FILE_REQUIRED' | translate }}</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
<app-select
|
|
||||||
formControlName="material"
|
|
||||||
[label]="'CALC.MATERIAL' | translate"
|
|
||||||
[options]="materials"
|
|
||||||
></app-select>
|
|
||||||
|
|
||||||
<app-select
|
|
||||||
formControlName="quality"
|
|
||||||
[label]="'CALC.QUALITY' | translate"
|
|
||||||
[options]="qualities"
|
|
||||||
></app-select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<app-input
|
|
||||||
formControlName="quantity"
|
|
||||||
type="number"
|
|
||||||
[label]="'CALC.QUANTITY' | translate"
|
|
||||||
></app-input>
|
|
||||||
|
|
||||||
@if (mode() === 'advanced') {
|
|
||||||
<div class="grid">
|
|
||||||
<app-select
|
|
||||||
formControlName="color"
|
|
||||||
[label]="'CALC.COLOR' | translate"
|
|
||||||
[options]="colors"
|
|
||||||
></app-select>
|
|
||||||
|
|
||||||
<app-select
|
|
||||||
formControlName="infillPattern"
|
|
||||||
[label]="'CALC.PATTERN' | translate"
|
|
||||||
[options]="infillPatterns"
|
|
||||||
></app-select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
<app-input
|
|
||||||
formControlName="infillDensity"
|
|
||||||
type="number"
|
|
||||||
[label]="'CALC.INFILL' | translate"
|
|
||||||
></app-input>
|
|
||||||
|
|
||||||
<div class="checkbox-row">
|
|
||||||
<input type="checkbox" formControlName="supportEnabled" id="support">
|
|
||||||
<label for="support">{{ 'CALC.SUPPORT' | translate }}</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<app-input
|
|
||||||
formControlName="notes"
|
|
||||||
[label]="'CALC.NOTES' | translate"
|
|
||||||
placeholder="Istruzioni specifiche..."
|
|
||||||
></app-input>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<!-- Progress Bar (Only when uploading i.e. progress < 100) -->
|
|
||||||
@if (loading() && uploadProgress() < 100) {
|
|
||||||
<div class="progress-container">
|
|
||||||
<div class="progress-bar">
|
|
||||||
<div class="progress-fill" [style.width.%]="uploadProgress()"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<app-button
|
|
||||||
type="submit"
|
|
||||||
[disabled]="form.invalid || loading()"
|
|
||||||
[fullWidth]="true">
|
|
||||||
{{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
|
|
||||||
</app-button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.section { margin-bottom: var(--space-6); }
|
|
||||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); }
|
|
||||||
.actions { margin-top: var(--space-6); }
|
|
||||||
.error-msg { color: var(--color-danger-500); font-size: 0.875rem; margin-top: var(--space-2); text-align: center; }
|
|
||||||
|
|
||||||
.viewer-wrapper { position: relative; margin-bottom: var(--space-4); }
|
|
||||||
.btn-clear {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
background: rgba(0,0,0,0.5);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
cursor: pointer;
|
|
||||||
z-index: 10;
|
|
||||||
&:hover { background: rgba(0,0,0,0.7); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-list {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
.file-item {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: var(--color-neutral-100);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
&:hover { background: var(--color-neutral-200); }
|
|
||||||
&.active {
|
|
||||||
border-color: var(--color-brand);
|
|
||||||
background: rgba(250, 207, 10, 0.1);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
height: 100%;
|
|
||||||
padding-top: var(--space-4);
|
|
||||||
|
|
||||||
input[type="checkbox"] {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
accent-color: var(--color-brand);
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress Bar */
|
|
||||||
.progress-container {
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
/* padding: var(--space-2); */
|
|
||||||
/* background: var(--color-neutral-100); */
|
|
||||||
/* border-radius: var(--radius-md); */
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.progress-bar {
|
|
||||||
height: 4px;
|
|
||||||
background: var(--color-border);
|
|
||||||
border-radius: 2px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 0;
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--color-brand);
|
|
||||||
width: 0%;
|
|
||||||
transition: width 0.2s ease-out;
|
|
||||||
}
|
|
||||||
.progress-text { font-size: 0.875rem; color: var(--color-text-muted); }
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class UploadFormComponent {
|
export class UploadFormComponent {
|
||||||
mode = input<'easy' | 'advanced'>('easy');
|
mode = input<'easy' | 'advanced'>('easy');
|
||||||
@@ -217,7 +32,7 @@ export class UploadFormComponent {
|
|||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
|
|
||||||
files = signal<File[]>([]);
|
items = signal<FormItem[]>([]);
|
||||||
selectedFile = signal<File | null>(null);
|
selectedFile = signal<File | null>(null);
|
||||||
|
|
||||||
materials = [
|
materials = [
|
||||||
@@ -231,35 +46,44 @@ export class UploadFormComponent {
|
|||||||
{ label: 'Standard', value: 'Standard' },
|
{ label: 'Standard', value: 'Standard' },
|
||||||
{ label: 'Alta definizione', value: 'High' }
|
{ label: 'Alta definizione', value: 'High' }
|
||||||
];
|
];
|
||||||
|
|
||||||
colors = [
|
nozzleDiameters = [
|
||||||
{ label: 'Black', value: 'Black' },
|
{ label: '0.2 mm (+2 CHF)', value: 0.2 },
|
||||||
{ label: 'White', value: 'White' },
|
{ label: '0.4 mm (Standard)', value: 0.4 },
|
||||||
{ label: 'Gray', value: 'Gray' },
|
{ label: '0.6 mm (+2 CHF)', value: 0.6 },
|
||||||
{ label: 'Red', value: 'Red' },
|
{ label: '0.8 mm (+2 CHF)', value: 0.8 }
|
||||||
{ label: 'Blue', value: 'Blue' },
|
|
||||||
{ label: 'Green', value: 'Green' },
|
|
||||||
{ label: 'Yellow', value: 'Yellow' }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
infillPatterns = [
|
infillPatterns = [
|
||||||
{ label: 'Grid', value: 'grid' },
|
{ label: 'Grid', value: 'grid' },
|
||||||
{ label: 'Gyroid', value: 'gyroid' },
|
{ label: 'Gyroid', value: 'gyroid' },
|
||||||
{ label: 'Cubic', value: 'cubic' },
|
{ label: 'Cubic', value: 'cubic' },
|
||||||
{ label: 'Triangles', value: 'triangles' }
|
{ label: 'Triangles', value: 'triangles' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
layerHeights = [
|
||||||
|
{ label: '0.08 mm', value: 0.08 },
|
||||||
|
{ label: '0.12 mm (High Quality - Slow)', value: 0.12 },
|
||||||
|
{ label: '0.16 mm', value: 0.16 },
|
||||||
|
{ label: '0.20 mm (Standard)', value: 0.20 },
|
||||||
|
{ label: '0.24 mm', value: 0.24 },
|
||||||
|
{ label: '0.28 mm', value: 0.28 }
|
||||||
|
];
|
||||||
|
|
||||||
acceptedFormats = '.stl,.3mf,.step,.stp,.obj,.amf,.ply,.igs,.iges';
|
acceptedFormats = '.stl,.3mf,.step,.stp,.obj,.amf,.ply,.igs,.iges';
|
||||||
|
|
||||||
constructor(private fb: FormBuilder) {
|
constructor(private fb: FormBuilder) {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
files: [[], Validators.required],
|
itemsTouched: [false], // Hack to track touched state for custom items list
|
||||||
material: ['PLA', Validators.required],
|
material: ['PLA', Validators.required],
|
||||||
quality: ['Standard', Validators.required],
|
quality: ['Standard', Validators.required],
|
||||||
quantity: [1, [Validators.required, Validators.min(1)]],
|
// Print Speed removed
|
||||||
notes: [''],
|
notes: [''],
|
||||||
// Advanced fields
|
// Advanced fields
|
||||||
color: ['Black'],
|
// Color removed from global form
|
||||||
infillDensity: [20, [Validators.min(0), Validators.max(100)]],
|
infillDensity: [20, [Validators.min(0), Validators.max(100)]],
|
||||||
|
layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]],
|
||||||
|
nozzleDiameter: [0.4, Validators.required],
|
||||||
infillPattern: ['grid'],
|
infillPattern: ['grid'],
|
||||||
supportEnabled: [false]
|
supportEnabled: [false]
|
||||||
});
|
});
|
||||||
@@ -267,14 +91,15 @@ export class UploadFormComponent {
|
|||||||
|
|
||||||
onFilesDropped(newFiles: File[]) {
|
onFilesDropped(newFiles: File[]) {
|
||||||
const MAX_SIZE = 200 * 1024 * 1024; // 200MB
|
const MAX_SIZE = 200 * 1024 * 1024; // 200MB
|
||||||
const validFiles: File[] = [];
|
const validItems: FormItem[] = [];
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
|
|
||||||
for (const file of newFiles) {
|
for (const file of newFiles) {
|
||||||
if (file.size > MAX_SIZE) {
|
if (file.size > MAX_SIZE) {
|
||||||
hasError = true;
|
hasError = true;
|
||||||
} else {
|
} else {
|
||||||
validFiles.push(file);
|
// Default color is Black
|
||||||
|
validItems.push({ file, quantity: 1, color: 'Black' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,32 +107,95 @@ export class UploadFormComponent {
|
|||||||
alert("Alcuni file superano il limite di 200MB e non sono stati aggiunti.");
|
alert("Alcuni file superano il limite di 200MB e non sono stati aggiunti.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (validFiles.length > 0) {
|
if (validItems.length > 0) {
|
||||||
this.files.update(current => [...current, ...validFiles]);
|
this.items.update(current => [...current, ...validItems]);
|
||||||
this.form.patchValue({ files: this.files() });
|
this.form.get('itemsTouched')?.setValue(true);
|
||||||
this.form.get('files')?.markAsTouched();
|
// Auto select last added
|
||||||
this.selectedFile.set(validFiles[validFiles.length - 1]);
|
this.selectedFile.set(validItems[validItems.length - 1].file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
selectFile(file: File) {
|
onAdditionalFilesSelected(event: Event) {
|
||||||
this.selectedFile.set(file);
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (input.files && input.files.length > 0) {
|
||||||
|
this.onFilesDropped(Array.from(input.files));
|
||||||
|
// Reset input so same files can be selected again if needed
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearFiles() {
|
updateItemQuantityByName(fileName: string, quantity: number) {
|
||||||
this.files.set([]);
|
this.items.update(current => {
|
||||||
this.selectedFile.set(null);
|
return current.map(item => {
|
||||||
this.form.patchValue({ files: [] });
|
if (item.file.name === fileName) {
|
||||||
|
return { ...item, quantity };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
selectFile(file: File) {
|
||||||
|
if (this.selectedFile() === file) {
|
||||||
|
// toggle off? no, keep active
|
||||||
|
} else {
|
||||||
|
this.selectedFile.set(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get color of currently selected file
|
||||||
|
getSelectedFileColor(): string {
|
||||||
|
const file = this.selectedFile();
|
||||||
|
if (!file) return '#facf0a'; // Default
|
||||||
|
|
||||||
|
const item = this.items().find(i => i.file === file);
|
||||||
|
if (item) {
|
||||||
|
return getColorHex(item.color);
|
||||||
|
}
|
||||||
|
return '#facf0a';
|
||||||
|
}
|
||||||
|
|
||||||
|
updateItemQuantity(index: number, event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
let val = parseInt(input.value, 10);
|
||||||
|
if (isNaN(val) || val < 1) val = 1;
|
||||||
|
|
||||||
|
this.items.update(current => {
|
||||||
|
const updated = [...current];
|
||||||
|
updated[index] = { ...updated[index], quantity: val };
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateItemColor(index: number, newColor: string) {
|
||||||
|
this.items.update(current => {
|
||||||
|
const updated = [...current];
|
||||||
|
updated[index] = { ...updated[index], color: newColor };
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem(index: number) {
|
||||||
|
this.items.update(current => {
|
||||||
|
const updated = [...current];
|
||||||
|
const removed = updated.splice(index, 1)[0];
|
||||||
|
if (this.selectedFile() === removed.file) {
|
||||||
|
this.selectedFile.set(null);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit() {
|
onSubmit() {
|
||||||
if (this.form.valid) {
|
if (this.form.valid && this.items().length > 0) {
|
||||||
this.submitRequest.emit({
|
this.submitRequest.emit({
|
||||||
|
items: this.items(), // Pass the items array including colors
|
||||||
...this.form.value,
|
...this.form.value,
|
||||||
mode: this.mode()
|
mode: this.mode()
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.form.markAllAsTouched();
|
this.form.markAllAsTouched();
|
||||||
|
this.form.get('itemsTouched')?.setValue(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<div class="user-details-container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<app-card [title]="'USER_DETAILS.TITLE' | translate">
|
||||||
|
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||||
|
|
||||||
|
<!-- Name & Surname -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<app-input
|
||||||
|
formControlName="name"
|
||||||
|
label="USER_DETAILS.NAME"
|
||||||
|
placeholder="USER_DETAILS.NAME_PLACEHOLDER"
|
||||||
|
[error]="form.get('name')?.invalid && form.get('name')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
||||||
|
</app-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<app-input
|
||||||
|
formControlName="surname"
|
||||||
|
label="USER_DETAILS.SURNAME"
|
||||||
|
placeholder="USER_DETAILS.SURNAME_PLACEHOLDER"
|
||||||
|
[error]="form.get('surname')?.invalid && form.get('surname')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
||||||
|
</app-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email & Phone -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<app-input
|
||||||
|
formControlName="email"
|
||||||
|
label="USER_DETAILS.EMAIL"
|
||||||
|
type="email"
|
||||||
|
placeholder="USER_DETAILS.EMAIL_PLACEHOLDER"
|
||||||
|
[error]="form.get('email')?.invalid && form.get('email')?.touched ? ('COMMON.INVALID_EMAIL' | translate) : null">
|
||||||
|
</app-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<app-input
|
||||||
|
formControlName="phone"
|
||||||
|
label="USER_DETAILS.PHONE"
|
||||||
|
type="tel"
|
||||||
|
placeholder="USER_DETAILS.PHONE_PLACEHOLDER"
|
||||||
|
[error]="form.get('phone')?.invalid && form.get('phone')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
||||||
|
</app-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Address -->
|
||||||
|
<app-input
|
||||||
|
formControlName="address"
|
||||||
|
label="USER_DETAILS.ADDRESS"
|
||||||
|
placeholder="USER_DETAILS.ADDRESS_PLACEHOLDER"
|
||||||
|
[error]="form.get('address')?.invalid && form.get('address')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
||||||
|
</app-input>
|
||||||
|
|
||||||
|
<!-- Zip & City -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<app-input
|
||||||
|
formControlName="zip"
|
||||||
|
label="USER_DETAILS.ZIP"
|
||||||
|
placeholder="USER_DETAILS.ZIP_PLACEHOLDER"
|
||||||
|
[error]="form.get('zip')?.invalid && form.get('zip')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
||||||
|
</app-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<app-input
|
||||||
|
formControlName="city"
|
||||||
|
label="USER_DETAILS.CITY"
|
||||||
|
placeholder="USER_DETAILS.CITY_PLACEHOLDER"
|
||||||
|
[error]="form.get('city')?.invalid && form.get('city')?.touched ? ('COMMON.REQUIRED' | translate) : null">
|
||||||
|
</app-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<app-button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
(click)="onCancel()">
|
||||||
|
{{ 'COMMON.BACK' | translate }}
|
||||||
|
</app-button>
|
||||||
|
<app-button
|
||||||
|
type="submit"
|
||||||
|
[disabled]="form.invalid || submitting()">
|
||||||
|
{{ 'USER_DETAILS.SUBMIT' | translate }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</app-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Order Summary Column -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<app-card [title]="'USER_DETAILS.SUMMARY_TITLE' | translate">
|
||||||
|
|
||||||
|
<div class="summary-content" *ngIf="quote()">
|
||||||
|
<div class="summary-item" *ngFor="let item of quote()!.items">
|
||||||
|
<div class="item-info">
|
||||||
|
<span class="item-name">{{ item.fileName }}</span>
|
||||||
|
<span class="item-meta">{{ item.material }} - {{ item.color || 'Default' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="item-qty">x{{ item.quantity }}</div>
|
||||||
|
<div class="item-price">{{ (item.unitPrice * item.quantity) | currency:'CHF' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="total-row">
|
||||||
|
<span>{{ 'QUOTE.TOTAL' | translate }}</span>
|
||||||
|
<span class="total-price">{{ quote()!.totalPrice | currency:'CHF' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</app-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
.user-details-container {
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 0 -0.5rem;
|
||||||
|
|
||||||
|
> [class*='col-'] {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-md-6 {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-md-4 {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
width: 33.333%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-md-8 {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
width: 66.666%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary Styles
|
||||||
|
.summary-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-qty {
|
||||||
|
margin: 0 1rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-price {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
|
||||||
|
.total-price {
|
||||||
|
color: var(--primary-color, #00C853); // Fallback color
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Component, input, output, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component';
|
||||||
|
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
|
||||||
|
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
||||||
|
import { QuoteResult } from '../../services/quote-estimator.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-user-details',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppCardComponent, AppInputComponent, AppButtonComponent],
|
||||||
|
templateUrl: './user-details.component.html',
|
||||||
|
styleUrl: './user-details.component.scss'
|
||||||
|
})
|
||||||
|
export class UserDetailsComponent {
|
||||||
|
quote = input<QuoteResult>();
|
||||||
|
submitOrder = output<any>();
|
||||||
|
cancel = output<void>();
|
||||||
|
|
||||||
|
form: FormGroup;
|
||||||
|
submitting = signal(false);
|
||||||
|
|
||||||
|
constructor(private fb: FormBuilder) {
|
||||||
|
this.form = this.fb.group({
|
||||||
|
name: ['', Validators.required],
|
||||||
|
surname: ['', Validators.required],
|
||||||
|
email: ['', [Validators.required, Validators.email]],
|
||||||
|
phone: ['', Validators.required],
|
||||||
|
address: ['', Validators.required],
|
||||||
|
zip: ['', Validators.required],
|
||||||
|
city: ['', Validators.required]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit() {
|
||||||
|
if (this.form.valid) {
|
||||||
|
this.submitting.set(true);
|
||||||
|
|
||||||
|
const orderData = {
|
||||||
|
customer: this.form.value,
|
||||||
|
quote: this.quote()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate API delay
|
||||||
|
setTimeout(() => {
|
||||||
|
this.submitOrder.emit(orderData);
|
||||||
|
this.submitting.set(false);
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
this.form.markAllAsTouched();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onCancel() {
|
||||||
|
this.cancel.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +1,40 @@
|
|||||||
import { Injectable, inject, signal } from '@angular/core';
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient, HttpEventType } from '@angular/common/http';
|
||||||
import { Observable, forkJoin, of } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
import { map, catchError } from 'rxjs/operators';
|
import { map, catchError } from 'rxjs/operators';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
|
|
||||||
export interface QuoteRequest {
|
export interface QuoteRequest {
|
||||||
files: File[];
|
items: { file: File, quantity: number, color?: string }[];
|
||||||
material: string;
|
material: string;
|
||||||
quality: string;
|
quality: string;
|
||||||
quantity: number;
|
|
||||||
notes?: string;
|
notes?: string;
|
||||||
color?: string;
|
|
||||||
infillDensity?: number;
|
infillDensity?: number;
|
||||||
infillPattern?: string;
|
infillPattern?: string;
|
||||||
supportEnabled?: boolean;
|
supportEnabled?: boolean;
|
||||||
|
layerHeight?: number;
|
||||||
|
nozzleDiameter?: number;
|
||||||
mode: 'easy' | 'advanced';
|
mode: 'easy' | 'advanced';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QuoteItem {
|
||||||
|
fileName: string;
|
||||||
|
unitPrice: number;
|
||||||
|
unitTime: number; // seconds
|
||||||
|
unitWeight: number; // grams
|
||||||
|
quantity: number;
|
||||||
|
material?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface QuoteResult {
|
export interface QuoteResult {
|
||||||
price: number;
|
items: QuoteItem[];
|
||||||
currency: string;
|
|
||||||
printTimeHours: number;
|
|
||||||
printTimeMinutes: number;
|
|
||||||
materialUsageGrams: number;
|
|
||||||
setupCost: number;
|
setupCost: number;
|
||||||
|
currency: string;
|
||||||
|
totalPrice: number;
|
||||||
|
totalTimeHours: number;
|
||||||
|
totalTimeMinutes: number;
|
||||||
|
totalWeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BackendResponse {
|
interface BackendResponse {
|
||||||
@@ -45,88 +56,30 @@ export class QuoteEstimatorService {
|
|||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
|
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
|
||||||
const formData = new FormData();
|
if (request.items.length === 0) return of();
|
||||||
// Assuming single file primarily for now, or aggregating.
|
|
||||||
// The current UI seems to select one "active" file or handle multiple.
|
|
||||||
// The logic below was mapping multiple files to multiple requests.
|
|
||||||
// To support progress seamlessly for the "main" action, let's focus on the processing flow.
|
|
||||||
// If multiple files, we might need a more complex progress tracking or just track the first/total.
|
|
||||||
// Given the UI shows one big "Analyse" button, let's treat it as a batch or single.
|
|
||||||
|
|
||||||
// NOTE: The previous logic did `request.files.map(...)`.
|
|
||||||
// If we want a global progress, we can mistakenly complexity it.
|
|
||||||
// Let's assume we upload all files in one request if the API supported it, but the API seems to be 1 file per request from previous code?
|
|
||||||
// "formData.append('file', file)" inside the map implies multiple requests.
|
|
||||||
// To keep it simple and working with the progress bar which is global:
|
|
||||||
// We will emit progress for the *current* file being processed or average them.
|
|
||||||
// OR simpler: The user typically uploads one file for a quote?
|
|
||||||
// The UI `files: File[]` allows multiple.
|
|
||||||
// Let's stick to the previous logic but wrap it to emit progress.
|
|
||||||
// However, forkJoin waits for all. We can't easily get specialized progress for "overall upload" with forkJoin of distinct requests easily without merging.
|
|
||||||
|
|
||||||
// Refined approach:
|
|
||||||
// We will process files IN PARALLEL (forkJoin) but we can't easily track aggregated upload progress of multiple requests in a single simple number without extra code.
|
|
||||||
// BUT, the user wants "la barra di upload".
|
|
||||||
// If we assume standard use case is 1 file, it's easy.
|
|
||||||
// If multiple, we can emit progress as "average of all uploads" or just "uploading...".
|
|
||||||
// Let's modify the signature to return `Observable<{ type: 'progress' | 'result', value: any }>` or similar?
|
|
||||||
// The plan said `Observable<QuoteResult>` originally, now we need progress.
|
|
||||||
// Let's change return type to `Observable<any>` or a specific union.
|
|
||||||
|
|
||||||
// Let's handle just the first file for progress visualization simplicity if multiple are present,
|
|
||||||
// or better, create a wrapper that merges the progress.
|
|
||||||
|
|
||||||
// Actually, looking at the previous code: `const requests = request.files.map(...)`.
|
|
||||||
// If we have 3 files, we have 3 requests.
|
|
||||||
// We can emit progress events.
|
|
||||||
|
|
||||||
// START implementation for generalized progress:
|
|
||||||
|
|
||||||
const file = request.files[0]; // Primary target for now to ensure we have a progress to show.
|
|
||||||
// Ideally we should upload all.
|
|
||||||
|
|
||||||
// For this task, to satisfy "bar disappears after upload", we really need to know when upload finishes.
|
|
||||||
|
|
||||||
// Let's keep it robust:
|
|
||||||
// If multiple files, we likely want to just process them.
|
|
||||||
// Let's stick to the previous logic but capture progress events for at least one or all.
|
|
||||||
|
|
||||||
if (request.files.length === 0) return of();
|
|
||||||
|
|
||||||
// We will change the architecture slightly:
|
|
||||||
// We will execute requests and for EACH, we track progress.
|
|
||||||
// But we only have one boolean 'loading' and one 'progress' bar in UI.
|
|
||||||
// Let's average the progress?
|
|
||||||
|
|
||||||
// Simplification: The user probably uploads one file to check quote.
|
|
||||||
// Let's implement support for the first file's progress to drive the UI bar, handling the rest in background/parallel.
|
|
||||||
|
|
||||||
// Re-implementing the single file logic from the map, but enabled for progress.
|
|
||||||
|
|
||||||
return new Observable(observer => {
|
return new Observable(observer => {
|
||||||
let completed = 0;
|
const totalItems = request.items.length;
|
||||||
let total = request.files.length;
|
const allProgress: number[] = new Array(totalItems).fill(0);
|
||||||
const results: BackendResponse[] = [];
|
const finalResponses: any[] = [];
|
||||||
let grandTotal = 0; // For progress calculation if we wanted to average
|
let completedRequests = 0;
|
||||||
|
|
||||||
// We'll just track the "upload phase" of the bundle.
|
const uploads = request.items.map((item, index) => {
|
||||||
// Actually, let's just use `concat` or `merge`?
|
|
||||||
// Let's simplify: We will only track progress for the first file or "active" file.
|
|
||||||
// But the previous code sent ALL files.
|
|
||||||
|
|
||||||
// Let's change the return type to emit events.
|
|
||||||
|
|
||||||
const uploads = request.files.map(file => {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', item.file);
|
||||||
formData.append('machine', 'bambu_a1');
|
formData.append('machine', 'bambu_a1');
|
||||||
formData.append('filament', this.mapMaterial(request.material));
|
formData.append('filament', this.mapMaterial(request.material));
|
||||||
formData.append('quality', this.mapQuality(request.quality));
|
formData.append('quality', this.mapQuality(request.quality));
|
||||||
|
|
||||||
|
// Send color for both modes if present, defaulting to Black
|
||||||
|
formData.append('material_color', item.color || 'Black');
|
||||||
|
|
||||||
if (request.mode === 'advanced') {
|
if (request.mode === 'advanced') {
|
||||||
if (request.color) formData.append('material_color', request.color);
|
|
||||||
if (request.infillDensity) formData.append('infill_density', request.infillDensity.toString());
|
if (request.infillDensity) formData.append('infill_density', request.infillDensity.toString());
|
||||||
if (request.infillPattern) formData.append('infill_pattern', request.infillPattern);
|
if (request.infillPattern) formData.append('infill_pattern', request.infillPattern);
|
||||||
if (request.supportEnabled) formData.append('support_enabled', 'true');
|
if (request.supportEnabled) formData.append('support_enabled', 'true');
|
||||||
|
if (request.layerHeight) formData.append('layer_height', request.layerHeight.toString());
|
||||||
|
if (request.nozzleDiameter) formData.append('nozzle_diameter', request.nozzleDiameter.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers: any = {};
|
const headers: any = {};
|
||||||
@@ -138,92 +91,114 @@ export class QuoteEstimatorService {
|
|||||||
reportProgress: true,
|
reportProgress: true,
|
||||||
observe: 'events'
|
observe: 'events'
|
||||||
}).pipe(
|
}).pipe(
|
||||||
map(event => ({ file, event })),
|
map(event => ({ item, event, index })),
|
||||||
catchError(err => of({ file, error: err }))
|
catchError(err => of({ item, error: err, index }))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// We process all uploads.
|
|
||||||
// We want to emit:
|
|
||||||
// 1. Progress updates (average of all files?)
|
|
||||||
// 2. Final QuoteResult
|
|
||||||
|
|
||||||
const allProgress: number[] = new Array(request.files.length).fill(0);
|
|
||||||
let completedRequests = 0;
|
|
||||||
const finalResponses: any[] = [];
|
|
||||||
|
|
||||||
// Subscribe to all
|
// Subscribe to all
|
||||||
uploads.forEach((obs, index) => {
|
uploads.forEach((obs) => {
|
||||||
obs.subscribe({
|
obs.subscribe({
|
||||||
next: (wrapper: any) => {
|
next: (wrapper: any) => {
|
||||||
|
const idx = wrapper.index;
|
||||||
|
|
||||||
if (wrapper.error) {
|
if (wrapper.error) {
|
||||||
// handled in final calculation
|
finalResponses[idx] = { success: false, fileName: wrapper.item.file.name };
|
||||||
finalResponses[index] = { success: false, data: { cost: { total:0 }, print_time_seconds:0, material_grams:0 } };
|
// Even if error, we count as complete
|
||||||
return;
|
// But we need to handle completion logic carefully.
|
||||||
|
// For simplicity, let's treat it as complete but check later.
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = wrapper.event;
|
const event = wrapper.event;
|
||||||
if (event.type === 1) { // HttpEventType.UploadProgress
|
if (event && event.type === HttpEventType.UploadProgress) {
|
||||||
if (event.total) {
|
if (event.total) {
|
||||||
const percent = Math.round((100 * event.loaded) / event.total);
|
const percent = Math.round((100 * event.loaded) / event.total);
|
||||||
allProgress[index] = percent;
|
allProgress[idx] = percent;
|
||||||
// Emit average progress
|
// Emit average progress
|
||||||
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / total);
|
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
|
||||||
observer.next(avg); // Emit number for progress
|
observer.next(avg);
|
||||||
|
}
|
||||||
|
} else if ((event && event.type === HttpEventType.Response) || wrapper.error) {
|
||||||
|
// It's done (either response or error caught above)
|
||||||
|
if (!finalResponses[idx]) { // only if not already set by error
|
||||||
|
allProgress[idx] = 100;
|
||||||
|
if (wrapper.error) {
|
||||||
|
finalResponses[idx] = { success: false, fileName: wrapper.item.file.name };
|
||||||
|
} else {
|
||||||
|
finalResponses[idx] = { ...event.body, fileName: wrapper.item.file.name, originalQty: wrapper.item.quantity };
|
||||||
|
}
|
||||||
|
completedRequests++;
|
||||||
}
|
}
|
||||||
} else if (event.type === 4) { // HttpEventType.Response
|
|
||||||
allProgress[index] = 100;
|
|
||||||
finalResponses[index] = event.body;
|
|
||||||
completedRequests++;
|
|
||||||
|
|
||||||
if (completedRequests === total) {
|
if (completedRequests === totalItems) {
|
||||||
// All done
|
// All done
|
||||||
observer.next(100); // Ensure complete
|
observer.next(100);
|
||||||
|
|
||||||
// Calculate Totals
|
// Calculate Results
|
||||||
const valid = finalResponses.filter(r => r && r.success);
|
let setupCost = 10;
|
||||||
if (valid.length === 0 && finalResponses.length > 0) {
|
|
||||||
|
if (request.nozzleDiameter && request.nozzleDiameter !== 0.4) {
|
||||||
|
setupCost += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: QuoteItem[] = [];
|
||||||
|
|
||||||
|
finalResponses.forEach((res, idx) => {
|
||||||
|
if (res && res.success) {
|
||||||
|
const originalItem = request.items[idx];
|
||||||
|
items.push({
|
||||||
|
fileName: res.fileName,
|
||||||
|
unitPrice: res.data.cost.total,
|
||||||
|
unitTime: res.data.print_time_seconds,
|
||||||
|
unitWeight: res.data.material_grams,
|
||||||
|
quantity: res.originalQty, // Use the requested quantity
|
||||||
|
material: request.material,
|
||||||
|
color: originalItem.color || 'Default'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
// If at least one failed? Or all?
|
||||||
|
// For now if NO items succeeded, error.
|
||||||
observer.error('All calculations failed.');
|
observer.error('All calculations failed.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalPrice = 0;
|
// Initial Aggregation
|
||||||
|
let grandTotal = setupCost;
|
||||||
let totalTime = 0;
|
let totalTime = 0;
|
||||||
let totalWeight = 0;
|
let totalWeight = 0;
|
||||||
let setupCost = 10;
|
|
||||||
|
items.forEach(item => {
|
||||||
valid.forEach(res => {
|
grandTotal += item.unitPrice * item.quantity;
|
||||||
totalPrice += res.data.cost.total;
|
totalTime += item.unitTime * item.quantity;
|
||||||
totalTime += res.data.print_time_seconds;
|
totalWeight += item.unitWeight * item.quantity;
|
||||||
totalWeight += res.data.material_grams;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
totalPrice = (totalPrice * request.quantity) + setupCost;
|
|
||||||
totalWeight = totalWeight * request.quantity;
|
|
||||||
totalTime = totalTime * request.quantity;
|
|
||||||
|
|
||||||
const totalHours = Math.floor(totalTime / 3600);
|
const totalHours = Math.floor(totalTime / 3600);
|
||||||
const totalMinutes = Math.ceil((totalTime % 3600) / 60);
|
const totalMinutes = Math.ceil((totalTime % 3600) / 60);
|
||||||
|
|
||||||
const result: QuoteResult = {
|
const result: QuoteResult = {
|
||||||
price: Math.round(totalPrice * 100) / 100,
|
items,
|
||||||
|
setupCost,
|
||||||
currency: 'CHF',
|
currency: 'CHF',
|
||||||
printTimeHours: totalHours,
|
totalPrice: Math.round(grandTotal * 100) / 100,
|
||||||
printTimeMinutes: totalMinutes,
|
totalTimeHours: totalHours,
|
||||||
materialUsageGrams: Math.ceil(totalWeight),
|
totalTimeMinutes: totalMinutes,
|
||||||
setupCost
|
totalWeight: Math.ceil(totalWeight)
|
||||||
};
|
};
|
||||||
|
|
||||||
observer.next(result); // Emit final object
|
observer.next(result);
|
||||||
observer.complete();
|
observer.complete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error('Error in request', err);
|
console.error('Error in request subscription', err);
|
||||||
finalResponses[index] = { success: false };
|
// Should be caught by inner pipe, but safety net
|
||||||
completedRequests++;
|
completedRequests++;
|
||||||
if (completedRequests === total) {
|
if (completedRequests === totalItems) {
|
||||||
observer.error('Requests failed');
|
observer.error('Requests failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
@if (sent()) {
|
||||||
|
<app-success-state context="contact" (action)="resetForm()"></app-success-state>
|
||||||
|
} @else {
|
||||||
|
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||||
|
<!-- Request Type -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{ 'CONTACT.REQ_TYPE_LABEL' | translate }} *</label>
|
||||||
|
<select formControlName="requestType" class="form-control">
|
||||||
|
<option *ngFor="let type of requestTypes" [value]="type.value">
|
||||||
|
{{ type.label | translate }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Phone -->
|
||||||
|
<app-input formControlName="email" type="email" [label]="'CONTACT.LABEL_EMAIL' | translate" [placeholder]="'CONTACT.PLACEHOLDER_EMAIL' | translate" class="col"></app-input>
|
||||||
|
<!-- Phone -->
|
||||||
|
<app-input formControlName="phone" type="tel" [label]="('CONTACT.PHONE' | translate)" [placeholder]="'CONTACT.PLACEHOLDER_PHONE' | translate" class="col"></app-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Type Selector (Segmented Control) -->
|
||||||
|
<div class="user-type-selector">
|
||||||
|
<div class="type-option" [class.selected]="!isCompany" (click)="setCompanyMode(false)">
|
||||||
|
{{ 'CONTACT.TYPE_PRIVATE' | translate }}
|
||||||
|
</div>
|
||||||
|
<div class="type-option" [class.selected]="isCompany" (click)="setCompanyMode(true)">
|
||||||
|
{{ 'CONTACT.TYPE_COMPANY' | translate }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Personal Name (Only if NOT Company) -->
|
||||||
|
<app-input *ngIf="!isCompany" formControlName="name" [label]="'CONTACT.LABEL_NAME' | translate" [placeholder]="'CONTACT.PLACEHOLDER_NAME' | translate"></app-input>
|
||||||
|
|
||||||
|
<!-- Company Fields (Only if Company) -->
|
||||||
|
<div *ngIf="isCompany" class="company-fields">
|
||||||
|
<app-input formControlName="companyName" [label]="('CONTACT.COMPANY_NAME' | translate) + ' *'" [placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"></app-input>
|
||||||
|
<app-input formControlName="referencePerson" [label]="('CONTACT.REF_PERSON' | translate) + ' *'" [placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"></app-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{ 'CONTACT.LABEL_MESSAGE' | translate }}</label>
|
||||||
|
<textarea formControlName="message" class="form-control" rows="4"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Upload Section -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{{ 'CONTACT.UPLOAD_LABEL' | translate }}</label>
|
||||||
|
<p class="hint">{{ 'CONTACT.UPLOAD_HINT' | translate }}</p>
|
||||||
|
|
||||||
|
<div class="drop-zone" (click)="fileInput.click()"
|
||||||
|
(dragover)="onDragOver($event)" (drop)="onDrop($event)">
|
||||||
|
<input #fileInput type="file" multiple (change)="onFileSelected($event)" hidden
|
||||||
|
accept=".jpg,.jpeg,.png,.pdf,.stl,.step,.stp,.3mf,.obj">
|
||||||
|
<p>{{ 'CONTACT.DROP_FILES' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-grid" *ngIf="files().length > 0">
|
||||||
|
<div class="file-item" *ngFor="let file of files(); let i = index">
|
||||||
|
<button type="button" class="remove-btn" (click)="removeFile(i)">×</button>
|
||||||
|
<img *ngIf="file.type === 'image'" [src]="file.url" class="preview-img">
|
||||||
|
<div *ngIf="file.type !== 'image'" class="file-icon">
|
||||||
|
<span *ngIf="file.type === 'pdf'">PDF</span>
|
||||||
|
<span *ngIf="file.type === '3d'">3D</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-name" [title]="file.file.name">{{ file.file.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<app-button type="submit" [disabled]="form.invalid || sent()">
|
||||||
|
{{ sent() ? ('CONTACT.MSG_SENT' | translate) : ('CONTACT.SEND' | translate) }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
.form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); }
|
||||||
|
label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); }
|
||||||
|
.hint { font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: var(--space-2); }
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
width: 100%;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: inherit;
|
||||||
|
&:focus { outline: none; border-color: var(--color-brand); }
|
||||||
|
}
|
||||||
|
|
||||||
|
select.form-control {
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 1rem center;
|
||||||
|
background-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
@media(min-width: 768px) {
|
||||||
|
flex-direction: row;
|
||||||
|
.col { flex: 1; margin-bottom: 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app-input.col { width: 100%; }
|
||||||
|
|
||||||
|
/* User Type Selector Styles */
|
||||||
|
.user-type-selector {
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--color-neutral-100);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 4px;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%; /* Full width */
|
||||||
|
max-width: 400px; /* Limit on desktop */
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-option {
|
||||||
|
flex: 1; /* Equal width */
|
||||||
|
text-align: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover { color: var(--color-text); }
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background-color: var(--color-brand);
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding-left: var(--space-4);
|
||||||
|
border-left: 2px solid var(--color-border);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File Upload Styles */
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-6);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
transition: all 0.2s;
|
||||||
|
&:hover { border-color: var(--color-brand); color: var(--color-brand); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
position: relative;
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--space-2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-img {
|
||||||
|
width: 100%; height: 100%; object-fit: cover; position: absolute; top:0; left:0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-weight: 700; color: var(--color-text-muted); font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-size: 0.65rem; color: var(--color-text); white-space: nowrap; overflow: hidden;
|
||||||
|
text-overflow: ellipsis; width: 100%; text-align: center; position: absolute; bottom: 2px;
|
||||||
|
padding: 0 4px; z-index: 2; background: rgba(255,255,255,0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
position: absolute; top: 2px; right: 2px; z-index: 10;
|
||||||
|
background: rgba(0,0,0,0.5); color: white; border: none; border-radius: 50%;
|
||||||
|
width: 18px; height: 18px; font-size: 12px; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center; line-height: 1;
|
||||||
|
&:hover { background: red; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success State styles moved to shared component */
|
||||||
@@ -12,220 +12,14 @@ interface FilePreview {
|
|||||||
type: 'image' | 'pdf' | '3d' | 'other';
|
type: 'image' | 'pdf' | '3d' | 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { SuccessStateComponent } from '../../../../shared/components/success-state/success-state.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-contact-form',
|
selector: 'app-contact-form',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppButtonComponent],
|
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppButtonComponent, SuccessStateComponent],
|
||||||
template: `
|
templateUrl: './contact-form.component.html',
|
||||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
styleUrl: './contact-form.component.scss'
|
||||||
<!-- Request Type -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label>{{ 'CONTACT.REQ_TYPE_LABEL' | translate }} *</label>
|
|
||||||
<select formControlName="requestType" class="form-control">
|
|
||||||
<option *ngFor="let type of requestTypes" [value]="type.value">
|
|
||||||
{{ type.label | translate }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<!-- Phone -->
|
|
||||||
<app-input formControlName="email" type="email" [label]="'CONTACT.LABEL_EMAIL' | translate" [placeholder]="'CONTACT.PLACEHOLDER_EMAIL' | translate" class="col"></app-input>
|
|
||||||
<!-- Phone -->
|
|
||||||
<app-input formControlName="phone" type="tel" [label]="('CONTACT.PHONE' | translate)" [placeholder]="'CONTACT.PLACEHOLDER_PHONE' | translate" class="col"></app-input>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User Type Selector (Segmented Control) -->
|
|
||||||
<div class="user-type-selector">
|
|
||||||
<div class="type-option" [class.selected]="!isCompany" (click)="setCompanyMode(false)">
|
|
||||||
{{ 'CONTACT.TYPE_PRIVATE' | translate }}
|
|
||||||
</div>
|
|
||||||
<div class="type-option" [class.selected]="isCompany" (click)="setCompanyMode(true)">
|
|
||||||
{{ 'CONTACT.TYPE_COMPANY' | translate }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Personal Name (Only if NOT Company) -->
|
|
||||||
<app-input *ngIf="!isCompany" formControlName="name" [label]="'CONTACT.LABEL_NAME' | translate" [placeholder]="'CONTACT.PLACEHOLDER_NAME' | translate"></app-input>
|
|
||||||
|
|
||||||
<!-- Company Fields (Only if Company) -->
|
|
||||||
<div *ngIf="isCompany" class="company-fields">
|
|
||||||
<app-input formControlName="companyName" [label]="('CONTACT.COMPANY_NAME' | translate) + ' *'" [placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"></app-input>
|
|
||||||
<app-input formControlName="referencePerson" [label]="('CONTACT.REF_PERSON' | translate) + ' *'" [placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"></app-input>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>{{ 'CONTACT.LABEL_MESSAGE' | translate }}</label>
|
|
||||||
<textarea formControlName="message" class="form-control" rows="4"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- File Upload Section -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label>{{ 'CONTACT.UPLOAD_LABEL' | translate }}</label>
|
|
||||||
<p class="hint">{{ 'CONTACT.UPLOAD_HINT' | translate }}</p>
|
|
||||||
|
|
||||||
<div class="drop-zone" (click)="fileInput.click()"
|
|
||||||
(dragover)="onDragOver($event)" (drop)="onDrop($event)">
|
|
||||||
<input #fileInput type="file" multiple (change)="onFileSelected($event)" hidden
|
|
||||||
accept=".jpg,.jpeg,.png,.pdf,.stl,.step,.stp,.3mf,.obj">
|
|
||||||
<p>{{ 'CONTACT.DROP_FILES' | translate }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="file-grid" *ngIf="files().length > 0">
|
|
||||||
<div class="file-item" *ngFor="let file of files(); let i = index">
|
|
||||||
<button type="button" class="remove-btn" (click)="removeFile(i)">×</button>
|
|
||||||
<img *ngIf="file.type === 'image'" [src]="file.url" class="preview-img">
|
|
||||||
<div *ngIf="file.type !== 'image'" class="file-icon">
|
|
||||||
<span *ngIf="file.type === 'pdf'">PDF</span>
|
|
||||||
<span *ngIf="file.type === '3d'">3D</span>
|
|
||||||
</div>
|
|
||||||
<div class="file-name" [title]="file.file.name">{{ file.file.name }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<app-button type="submit" [disabled]="form.invalid || sent()">
|
|
||||||
{{ sent() ? ('CONTACT.MSG_SENT' | translate) : ('CONTACT.SEND' | translate) }}
|
|
||||||
</app-button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); }
|
|
||||||
label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); }
|
|
||||||
.hint { font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: var(--space-2); }
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
width: 100%;
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-family: inherit;
|
|
||||||
&:focus { outline: none; border-color: var(--color-brand); }
|
|
||||||
}
|
|
||||||
|
|
||||||
select.form-control {
|
|
||||||
appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right 1rem center;
|
|
||||||
background-size: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-4);
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
@media(min-width: 768px) {
|
|
||||||
flex-direction: row;
|
|
||||||
.col { flex: 1; margin-bottom: 0; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app-input.col { width: 100%; }
|
|
||||||
|
|
||||||
/* User Type Selector Styles */
|
|
||||||
.user-type-selector {
|
|
||||||
display: flex;
|
|
||||||
background-color: var(--color-neutral-100);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 4px;
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
gap: 4px;
|
|
||||||
width: 100%; /* Full width */
|
|
||||||
max-width: 400px; /* Limit on desktop */
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-option {
|
|
||||||
flex: 1; /* Equal width */
|
|
||||||
text-align: center;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
&:hover { color: var(--color-text); }
|
|
||||||
|
|
||||||
&.selected {
|
|
||||||
background-color: var(--color-brand);
|
|
||||||
color: #000;
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.company-fields {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-4);
|
|
||||||
padding-left: var(--space-4);
|
|
||||||
border-left: 2px solid var(--color-border);
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* File Upload Styles */
|
|
||||||
.drop-zone {
|
|
||||||
border: 2px dashed var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--space-6);
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
transition: all 0.2s;
|
|
||||||
&:hover { border-color: var(--color-brand); color: var(--color-brand); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
|
||||||
gap: var(--space-3);
|
|
||||||
margin-top: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-item {
|
|
||||||
position: relative;
|
|
||||||
background: var(--color-neutral-100);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
padding: var(--space-2);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
aspect-ratio: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-img {
|
|
||||||
width: 100%; height: 100%; object-fit: cover; position: absolute; top:0; left:0;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-icon {
|
|
||||||
font-weight: 700; color: var(--color-text-muted); font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-name {
|
|
||||||
font-size: 0.65rem; color: var(--color-text); white-space: nowrap; overflow: hidden;
|
|
||||||
text-overflow: ellipsis; width: 100%; text-align: center; position: absolute; bottom: 2px;
|
|
||||||
padding: 0 4px; z-index: 2; background: rgba(255,255,255,0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-btn {
|
|
||||||
position: absolute; top: 2px; right: 2px; z-index: 10;
|
|
||||||
background: rgba(0,0,0,0.5); color: white; border: none; border-radius: 50%;
|
|
||||||
width: 18px; height: 18px; font-size: 12px; cursor: pointer;
|
|
||||||
display: flex; align-items: center; justify-content: center; line-height: 1;
|
|
||||||
&:hover { background: red; }
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class ContactFormComponent {
|
export class ContactFormComponent {
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
@@ -369,13 +163,14 @@ export class ContactFormComponent {
|
|||||||
console.log('Form Submit:', formData);
|
console.log('Form Submit:', formData);
|
||||||
|
|
||||||
this.sent.set(true);
|
this.sent.set(true);
|
||||||
setTimeout(() => {
|
|
||||||
this.sent.set(false);
|
|
||||||
this.form.reset({ requestType: 'custom', isCompany: false });
|
|
||||||
this.files.set([]);
|
|
||||||
}, 3000);
|
|
||||||
} else {
|
} else {
|
||||||
this.form.markAllAsTouched();
|
this.form.markAllAsTouched();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetForm() {
|
||||||
|
this.sent.set(false);
|
||||||
|
this.form.reset({ requestType: 'custom', isCompany: false });
|
||||||
|
this.files.set([]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<section class="contact-hero">
|
||||||
|
<div class="container">
|
||||||
|
<h1>{{ 'CONTACT.TITLE' | translate }}</h1>
|
||||||
|
<p class="subtitle">Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="container content">
|
||||||
|
<app-card>
|
||||||
|
<app-contact-form></app-contact-form>
|
||||||
|
</app-card>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
.contact-hero {
|
||||||
|
padding: 3rem 0 2rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
max-width: 640px;
|
||||||
|
margin: var(--space-3) auto 0;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 2rem 0 5rem;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
@@ -8,35 +8,7 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
|
|||||||
selector: 'app-contact-page',
|
selector: 'app-contact-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, TranslateModule, ContactFormComponent, AppCardComponent],
|
imports: [CommonModule, TranslateModule, ContactFormComponent, AppCardComponent],
|
||||||
template: `
|
templateUrl: './contact-page.component.html',
|
||||||
<section class="contact-hero">
|
styleUrl: './contact-page.component.scss'
|
||||||
<div class="container">
|
|
||||||
<h1>{{ 'CONTACT.TITLE' | translate }}</h1>
|
|
||||||
<p class="subtitle">Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="container content">
|
|
||||||
<app-card>
|
|
||||||
<app-contact-form></app-contact-form>
|
|
||||||
</app-card>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.contact-hero {
|
|
||||||
padding: 3rem 0 2rem;
|
|
||||||
background: var(--color-bg);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.subtitle {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
max-width: 640px;
|
|
||||||
margin: var(--space-3) auto 0;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
padding: 2rem 0 5rem;
|
|
||||||
max-width: 800px;
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class ContactPageComponent {}
|
export class ContactPageComponent {}
|
||||||
|
|||||||
@@ -7,13 +7,17 @@
|
|||||||
Prezzo e tempi in pochi secondi.<br>
|
Prezzo e tempi in pochi secondi.<br>
|
||||||
Dal file 3D al pezzo finito.
|
Dal file 3D al pezzo finito.
|
||||||
</h1>
|
</h1>
|
||||||
|
<p class="hero-lead">
|
||||||
|
Il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.
|
||||||
|
</p>
|
||||||
<p class="hero-subtitle">
|
<p class="hero-subtitle">
|
||||||
Lavoriamo con trasparenza su costi, qualità e tempi. Produciamo prototipi, pezzi personalizzati
|
Realizziamo pezzi unici e produzioni in serie. Se hai il file, il preventivo è istantaneo.
|
||||||
e piccole serie con supporto tecnico reale.
|
Se devi ancora crearlo, il nostro team di design lo progetterà per te.
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<app-button variant="primary" routerLink="/about">Parla con noi</app-button>
|
<app-button variant="primary" routerLink="/cal">Calcola Preventivo</app-button>
|
||||||
<app-button variant="outline" routerLink="/shop">Vai allo shop</app-button>
|
<app-button variant="outline" routerLink="/shop">Vai allo shop</app-button>
|
||||||
|
<app-button variant="text" routerLink="/contact">Parla con noi</app-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,13 +26,12 @@
|
|||||||
<section class="section calculator">
|
<section class="section calculator">
|
||||||
<div class="container calculator-grid">
|
<div class="container calculator-grid">
|
||||||
<div class="calculator-copy">
|
<div class="calculator-copy">
|
||||||
<h2 class="section-title">Preventivo immediato</h2>
|
<h2 class="section-title">Preventivo immediato in pochi secondi</h2>
|
||||||
<p class="section-subtitle">
|
<p class="section-subtitle">
|
||||||
Carica il file 3D e ottieni subito costo e tempo di stampa. Nessuna registrazione.
|
Nessuna registrazione necessaria. Non salviamo il tuo file fino al momento dell'ordine e la stima è effettuata tramite vero slicing.
|
||||||
</p>
|
</p>
|
||||||
<ul class="calculator-list">
|
<ul class="calculator-list">
|
||||||
<li>Formati supportati: STL, 3MF, STEP, OBJ</li>
|
<li>Formati supportati: STL, 3MF, STEP, OBJ</li>
|
||||||
<li>Materiali disponibili: PLA, PETG, TPU</li>
|
|
||||||
<li>Qualità: bozza, standard, alta definizione</li>
|
<li>Qualità: bozza, standard, alta definizione</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,19 +48,9 @@
|
|||||||
<li>Scegli materiale e qualità</li>
|
<li>Scegli materiale e qualità</li>
|
||||||
<li>Ricevi subito costo e tempo</li>
|
<li>Ricevi subito costo e tempo</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="quote-meta">
|
|
||||||
<div>
|
|
||||||
<span class="meta-label">Modalità</span>
|
|
||||||
<span class="meta-value">Rapida / Avanzata</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="meta-label">Output</span>
|
|
||||||
<span class="meta-value">Ordina o richiedi consulenza</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="quote-actions">
|
<div class="quote-actions">
|
||||||
<app-button variant="primary" [fullWidth]="true" routerLink="/cal">Apri calcolatore</app-button>
|
<app-button variant="primary" [fullWidth]="true" routerLink="/cal">Apri calcolatore</app-button>
|
||||||
<app-button variant="outline" [fullWidth]="true" routerLink="/about">Parla con noi</app-button>
|
<app-button variant="outline" [fullWidth]="true" routerLink="/contact">Parla con noi</app-button>
|
||||||
</div>
|
</div>
|
||||||
</app-card>
|
</app-card>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,20 +67,32 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="cap-cards">
|
<div class="cap-cards">
|
||||||
<app-card>
|
<app-card>
|
||||||
|
<div class="card-image-placeholder">
|
||||||
|
<!-- <img src="..." alt="..."> -->
|
||||||
|
</div>
|
||||||
<h3>Prototipazione veloce</h3>
|
<h3>Prototipazione veloce</h3>
|
||||||
<p class="text-muted">Valida idee e funzioni in pochi giorni con preventivo immediato.</p>
|
<p class="text-muted">Dal file digitale al modello fisico in 24/48 ore. Verifica ergonomia, incastri e funzionamento prima dello stampo definitivo.</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
<app-card>
|
<app-card>
|
||||||
|
<div class="card-image-placeholder">
|
||||||
|
<!-- <img src="..." alt="..."> -->
|
||||||
|
</div>
|
||||||
<h3>Pezzi personalizzati</h3>
|
<h3>Pezzi personalizzati</h3>
|
||||||
<p class="text-muted">Componenti unici o in mini serie per clienti, macchine e prodotti.</p>
|
<p class="text-muted">Componenti unici impossibili da trovare in commercio. Riproduciamo parti rotte o creiamo adattamenti su misura.</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
<app-card>
|
<app-card>
|
||||||
|
<div class="card-image-placeholder">
|
||||||
|
<!-- <img src="..." alt="..."> -->
|
||||||
|
</div>
|
||||||
<h3>Piccole serie</h3>
|
<h3>Piccole serie</h3>
|
||||||
<p class="text-muted">Produzione controllata fino a 500 pezzi con qualità costante.</p>
|
<p class="text-muted">Produzione ponte o serie limitate (10-500 pezzi) con qualità ripetibile. Ideale per validazione di mercato.</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
<app-card>
|
<app-card>
|
||||||
|
<div class="card-image-placeholder">
|
||||||
|
<!-- <img src="..." alt="..."> -->
|
||||||
|
</div>
|
||||||
<h3>Consulenza e CAD</h3>
|
<h3>Consulenza e CAD</h3>
|
||||||
<p class="text-muted">Supporto tecnico per progettazione, modifiche e ottimizzazione.</p>
|
<p class="text-muted">Non hai il file 3D? Ti aiutiamo a progettarlo, ottimizzarlo per la stampa e scegliere il materiale giusto.</p>
|
||||||
</app-card>
|
</app-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,7 +113,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<div class="shop-actions">
|
<div class="shop-actions">
|
||||||
<app-button variant="primary" routerLink="/shop">Scopri i prodotti</app-button>
|
<app-button variant="primary" routerLink="/shop">Scopri i prodotti</app-button>
|
||||||
<app-button variant="outline" routerLink="/about">Richiedi una soluzione</app-button>
|
<app-button variant="outline" routerLink="/contact">Richiedi una soluzione</app-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="shop-cards">
|
<div class="shop-cards">
|
||||||
@@ -136,25 +141,12 @@
|
|||||||
3D fab è un laboratorio tecnico di stampa 3D. Seguiamo progetti dalla consulenza iniziale
|
3D fab è un laboratorio tecnico di stampa 3D. Seguiamo progetti dalla consulenza iniziale
|
||||||
alla produzione, con tempi chiari e supporto diretto.
|
alla produzione, con tempi chiari e supporto diretto.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-muted">
|
<app-button variant="outline" routerLink="/contact">Contattaci</app-button>
|
||||||
Qui puoi inserire descrizioni più dettagliate del team, del laboratorio e dei progetti in corso.
|
|
||||||
</p>
|
|
||||||
<app-button variant="outline" routerLink="/about">Contattaci</app-button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="about-media">
|
<div class="about-media">
|
||||||
<div class="media-grid">
|
<div class="about-feature-image">
|
||||||
<div class="media-tile">
|
<!-- Foto founders -->
|
||||||
<div class="media-photo"></div>
|
<span class="text-sm">Foto Founders</span>
|
||||||
<p>Foto laboratorio / stampanti</p>
|
|
||||||
</div>
|
|
||||||
<div class="media-tile">
|
|
||||||
<div class="media-photo"></div>
|
|
||||||
<p>Dettagli qualità e finiture</p>
|
|
||||||
</div>
|
|
||||||
<div class="media-tile">
|
|
||||||
<div class="media-photo"></div>
|
|
||||||
<p>Team, prototipi o casi studio</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,10 +15,10 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@include patterns.pattern-grid(var(--color-neutral-900), 40px, 1px);
|
@include patterns.pattern-grid(var(--color-neutral-900), 40px, 1px);
|
||||||
opacity: 0.12;
|
opacity: 0.06;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
|
mask-image: linear-gradient(to bottom, black 70%, transparent 100%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-copy { animation: fadeUp 0.8s ease both; }
|
.hero-copy { animation: fadeUp 0.8s ease both; }
|
||||||
.hero-panel { animation: fadeUp 0.8s ease 0.15s both; }
|
.hero-panel { animation: fadeUp 0.8s ease 0.15s both; }
|
||||||
|
|
||||||
@@ -61,10 +62,18 @@
|
|||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
margin-bottom: var(--space-4);
|
margin-bottom: var(--space-4);
|
||||||
}
|
}
|
||||||
|
.hero-lead {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
.hero-subtitle {
|
.hero-subtitle {
|
||||||
font-size: 1.2rem;
|
font-size: 1.1rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
.hero-actions {
|
.hero-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -135,6 +144,9 @@
|
|||||||
padding: 0.35rem 0.75rem;
|
padding: 0.35rem 0.75rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: var(--color-brand-600);
|
||||||
|
background: var(--color-brand-50);
|
||||||
|
border-color: var(--color-brand-200);
|
||||||
}
|
}
|
||||||
.quote-steps {
|
.quote-steps {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -177,14 +189,10 @@
|
|||||||
|
|
||||||
.capabilities {
|
.capabilities {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
.capabilities-bg {
|
.capabilities-bg {
|
||||||
position: absolute;
|
display: none;
|
||||||
inset: 0;
|
|
||||||
@include patterns.pattern-rectilinear(var(--color-neutral-900), 24px, 1px);
|
|
||||||
opacity: 0.05;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section { padding: 5.5rem 0; position: relative; }
|
.section { padding: 5.5rem 0; position: relative; }
|
||||||
@@ -194,24 +202,13 @@
|
|||||||
.text-muted { color: var(--color-text-muted); }
|
.text-muted { color: var(--color-text-muted); }
|
||||||
|
|
||||||
.calculator {
|
.calculator {
|
||||||
background: var(--color-neutral-50);
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
position: relative;
|
position: relative;
|
||||||
// Honeycomb Pattern
|
border-bottom: 1px solid var(--color-border);
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
@include patterns.pattern-honeycomb(var(--color-neutral-900), 24px);
|
|
||||||
opacity: 0.04;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.calculator-grid {
|
.calculator-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-10);
|
gap: var(--space-10);
|
||||||
align-items: center;
|
align-items: start;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
@@ -225,6 +222,19 @@
|
|||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-image-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
margin: -1.5rem -1.5rem 1.5rem -1.5rem; /* Negative margins to bleed to edge */
|
||||||
|
width: calc(100% + 3rem);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-neutral-400);
|
||||||
|
}
|
||||||
|
|
||||||
.shop {
|
.shop {
|
||||||
background: var(--color-neutral-50);
|
background: var(--color-neutral-50);
|
||||||
@@ -282,24 +292,21 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.about-media {
|
.about-media {
|
||||||
display: grid;
|
position: relative;
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
}
|
||||||
.media-grid {
|
|
||||||
display: grid;
|
.about-feature-image {
|
||||||
gap: var(--space-4);
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
}
|
|
||||||
.media-tile {
|
|
||||||
display: grid;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
.media-photo {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 4 / 3;
|
height: 100%;
|
||||||
|
min-height: 320px;
|
||||||
|
object-fit: cover;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: var(--color-neutral-100);
|
background: var(--color-neutral-100);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
.media-tile p {
|
.media-tile p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -313,6 +320,7 @@
|
|||||||
@media (min-width: 960px) {
|
@media (min-width: 960px) {
|
||||||
.hero-grid { grid-template-columns: 1.1fr 0.9fr; }
|
.hero-grid { grid-template-columns: 1.1fr 0.9fr; }
|
||||||
.calculator-grid { grid-template-columns: 1.1fr 0.9fr; }
|
.calculator-grid { grid-template-columns: 1.1fr 0.9fr; }
|
||||||
|
.calculator-grid { grid-template-columns: 1.1fr 0.9fr; }
|
||||||
.split { grid-template-columns: 1.1fr 0.9fr; }
|
.split { grid-template-columns: 1.1fr 0.9fr; }
|
||||||
.about-grid { grid-template-columns: 1.1fr 0.9fr; }
|
.about-grid { grid-template-columns: 1.1fr 0.9fr; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<div class="product-card">
|
||||||
|
<div class="image-placeholder"></div>
|
||||||
|
<div class="content">
|
||||||
|
<span class="category">{{ product().category }}</span>
|
||||||
|
<h3 class="name">
|
||||||
|
<a [routerLink]="['/shop', product().id]">{{ product().name }}</a>
|
||||||
|
</h3>
|
||||||
|
<div class="footer">
|
||||||
|
<span class="price">{{ product().price | currency:'EUR' }}</span>
|
||||||
|
<a [routerLink]="['/shop', product().id]" class="view-btn">Dettagli</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
.product-card {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
&:hover { box-shadow: var(--shadow-md); }
|
||||||
|
}
|
||||||
|
.image-placeholder {
|
||||||
|
height: 200px;
|
||||||
|
background-color: var(--color-neutral-200);
|
||||||
|
}
|
||||||
|
.content { padding: var(--space-4); }
|
||||||
|
.category { font-size: 0.75rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
.name { font-size: 1.125rem; margin: var(--space-2) 0; a { color: var(--color-text); text-decoration: none; &:hover { color: var(--color-brand); } } }
|
||||||
|
.footer { display: flex; justify-content: space-between; align-items: center; margin-top: var(--space-4); }
|
||||||
|
.price { font-weight: 700; color: var(--color-brand); }
|
||||||
|
.view-btn { font-size: 0.875rem; font-weight: 500; }
|
||||||
@@ -7,41 +7,8 @@ import { Product } from '../../services/shop.service';
|
|||||||
selector: 'app-product-card',
|
selector: 'app-product-card',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink],
|
imports: [CommonModule, RouterLink],
|
||||||
template: `
|
templateUrl: './product-card.component.html',
|
||||||
<div class="product-card">
|
styleUrl: './product-card.component.scss'
|
||||||
<div class="image-placeholder"></div>
|
|
||||||
<div class="content">
|
|
||||||
<span class="category">{{ product().category }}</span>
|
|
||||||
<h3 class="name">
|
|
||||||
<a [routerLink]="['/shop', product().id]">{{ product().name }}</a>
|
|
||||||
</h3>
|
|
||||||
<div class="footer">
|
|
||||||
<span class="price">{{ product().price | currency:'EUR' }}</span>
|
|
||||||
<a [routerLink]="['/shop', product().id]" class="view-btn">Dettagli</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
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 {
|
export class ProductCardComponent {
|
||||||
product = input.required<Product>();
|
product = input.required<Product>();
|
||||||
|
|||||||
25
frontend/src/app/features/shop/product-detail.component.html
Normal file
25
frontend/src/app/features/shop/product-detail.component.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<div class="container wrapper">
|
||||||
|
<a routerLink="/shop" class="back-link">← {{ 'SHOP.BACK' | translate }}</a>
|
||||||
|
|
||||||
|
@if (product(); as p) {
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="image-box"></div>
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<span class="category">{{ p.category }}</span>
|
||||||
|
<h1>{{ p.name }}</h1>
|
||||||
|
<p class="price">{{ p.price | currency:'EUR' }}</p>
|
||||||
|
|
||||||
|
<p class="desc">{{ p.description }}</p>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<app-button variant="primary" (click)="addToCart()">
|
||||||
|
{{ 'SHOP.ADD_CART' | translate }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p>Prodotto non trovato.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
20
frontend/src/app/features/shop/product-detail.component.scss
Normal file
20
frontend/src/app/features/shop/product-detail.component.scss
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
.wrapper { padding-top: var(--space-8); }
|
||||||
|
.back-link { display: inline-block; margin-bottom: var(--space-6); color: var(--color-text-muted); }
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-8);
|
||||||
|
@media(min-width: 768px) {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-box {
|
||||||
|
background-color: var(--color-neutral-200);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category { color: var(--color-brand); font-weight: 600; text-transform: uppercase; font-size: 0.875rem; }
|
||||||
|
.price { font-size: 1.5rem; font-weight: 700; color: var(--color-text); margin: var(--space-4) 0; }
|
||||||
|
.desc { color: var(--color-text-muted); line-height: 1.6; margin-bottom: var(--space-8); }
|
||||||
@@ -9,55 +9,8 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto
|
|||||||
selector: 'app-product-detail',
|
selector: 'app-product-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent],
|
imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent],
|
||||||
template: `
|
templateUrl: './product-detail.component.html',
|
||||||
<div class="container wrapper">
|
styleUrl: './product-detail.component.scss'
|
||||||
<a routerLink="/shop" class="back-link">← {{ 'SHOP.BACK' | translate }}</a>
|
|
||||||
|
|
||||||
@if (product(); as p) {
|
|
||||||
<div class="detail-grid">
|
|
||||||
<div class="image-box"></div>
|
|
||||||
|
|
||||||
<div class="info">
|
|
||||||
<span class="category">{{ p.category }}</span>
|
|
||||||
<h1>{{ p.name }}</h1>
|
|
||||||
<p class="price">{{ p.price | currency:'EUR' }}</p>
|
|
||||||
|
|
||||||
<p class="desc">{{ p.description }}</p>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<app-button variant="primary" (click)="addToCart()">
|
|
||||||
{{ 'SHOP.ADD_CART' | translate }}
|
|
||||||
</app-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<p>Prodotto non trovato.</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
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 {
|
export class ProductDetailComponent {
|
||||||
// Input binding from router
|
// Input binding from router
|
||||||
|
|||||||
12
frontend/src/app/features/shop/shop-page.component.html
Normal file
12
frontend/src/app/features/shop/shop-page.component.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<div class="container hero">
|
||||||
|
<h1>{{ 'SHOP.TITLE' | translate }}</h1>
|
||||||
|
<p class="subtitle">{{ 'SHOP.SUBTITLE' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="grid">
|
||||||
|
@for (product of products(); track product.id) {
|
||||||
|
<app-product-card [product]="product"></app-product-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
7
frontend/src/app/features/shop/shop-page.component.scss
Normal file
7
frontend/src/app/features/shop/shop-page.component.scss
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.hero { padding: var(--space-8) 0; text-align: center; }
|
||||||
|
.subtitle { color: var(--color-text-muted); margin-bottom: var(--space-8); }
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
@@ -8,29 +8,8 @@ import { ProductCardComponent } from './components/product-card/product-card.com
|
|||||||
selector: 'app-shop-page',
|
selector: 'app-shop-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, TranslateModule, ProductCardComponent],
|
imports: [CommonModule, TranslateModule, ProductCardComponent],
|
||||||
template: `
|
templateUrl: './shop-page.component.html',
|
||||||
<div class="container hero">
|
styleUrl: './shop-page.component.scss'
|
||||||
<h1>{{ 'SHOP.TITLE' | translate }}</h1>
|
|
||||||
<p class="subtitle">{{ 'SHOP.SUBTITLE' | translate }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<div class="grid">
|
|
||||||
@for (product of products(); track product.id) {
|
|
||||||
<app-product-card [product]="product"></app-product-card>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
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 {
|
export class ShopPageComponent {
|
||||||
products = signal<Product[]>([]);
|
products = signal<Product[]>([]);
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<div class="alert" [ngClass]="type()">
|
||||||
|
<div class="icon">
|
||||||
|
@if(type() === 'info') { ℹ️ }
|
||||||
|
@if(type() === 'warning') { ⚠️ }
|
||||||
|
@if(type() === 'error') { ❌ }
|
||||||
|
@if(type() === 'success') { ✅ }
|
||||||
|
</div>
|
||||||
|
<div class="content"><ng-content></ng-content></div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.alert {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
.info { background: var(--color-neutral-100); color: var(--color-neutral-800); }
|
||||||
|
.warning { background: #fefce8; color: #854d0e; border: 1px solid #fde047; }
|
||||||
|
.error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
|
||||||
|
.success { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
|
||||||
@@ -5,31 +5,8 @@ import { CommonModule } from '@angular/common';
|
|||||||
selector: 'app-alert',
|
selector: 'app-alert',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
template: `
|
templateUrl: './app-alert.component.html',
|
||||||
<div class="alert" [ngClass]="type()">
|
styleUrl: './app-alert.component.scss'
|
||||||
<div class="icon">
|
|
||||||
@if(type() === 'info') { ℹ️ }
|
|
||||||
@if(type() === 'warning') { ⚠️ }
|
|
||||||
@if(type() === 'error') { ❌ }
|
|
||||||
@if(type() === 'success') { ✅ }
|
|
||||||
</div>
|
|
||||||
<div class="content"><ng-content></ng-content></div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
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 {
|
export class AppAlertComponent {
|
||||||
type = input<'info' | 'warning' | 'error' | 'success'>('info');
|
type = input<'info' | 'warning' | 'error' | 'success'>('info');
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<button
|
||||||
|
[type]="type()"
|
||||||
|
[class]="'btn btn-' + variant() + ' ' + (fullWidth() ? 'w-full' : '')"
|
||||||
|
[disabled]="disabled()"
|
||||||
|
(click)="handleClick($event)">
|
||||||
|
<ng-content></ng-content>
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s, color 0.2s, border-color 0.2s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.w-full { width: 100%; }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--color-brand);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
&:hover:not(:disabled) { background-color: var(--color-brand-hover); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--color-neutral-200);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
&:hover:not(:disabled) { background-color: var(--color-neutral-300); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background-color: transparent;
|
||||||
|
border-color: var(--color-border);
|
||||||
|
color: var(--color-text);
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
background-color: rgba(250, 207, 10, 0.1); /* Low opacity brand color */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 0.5rem;
|
||||||
|
&:hover:not(:disabled) { color: var(--color-text); }
|
||||||
|
}
|
||||||
@@ -5,66 +5,8 @@ import { CommonModule } from '@angular/common';
|
|||||||
selector: 'app-button',
|
selector: 'app-button',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
template: `
|
templateUrl: './app-button.component.html',
|
||||||
<button
|
styleUrl: './app-button.component.scss'
|
||||||
[type]="type()"
|
|
||||||
[class]="'btn btn-' + variant() + ' ' + (fullWidth() ? 'w-full' : '')"
|
|
||||||
[disabled]="disabled()"
|
|
||||||
(click)="handleClick($event)">
|
|
||||||
<ng-content></ng-content>
|
|
||||||
</button>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s, color 0.2s, border-color 0.2s;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 1rem;
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.w-full { width: 100%; }
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--color-brand);
|
|
||||||
color: var(--color-neutral-900);
|
|
||||||
&:hover:not(:disabled) { background-color: var(--color-brand-hover); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background-color: var(--color-neutral-200);
|
|
||||||
color: var(--color-neutral-900);
|
|
||||||
&:hover:not(:disabled) { background-color: var(--color-neutral-300); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline {
|
|
||||||
background-color: transparent;
|
|
||||||
border-color: var(--color-border);
|
|
||||||
color: var(--color-text);
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
border-color: var(--color-brand);
|
|
||||||
color: var(--color-neutral-900);
|
|
||||||
background-color: rgba(250, 207, 10, 0.1); /* Low opacity brand color */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-text {
|
|
||||||
background-color: transparent;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
padding: 0.5rem;
|
|
||||||
&:hover:not(:disabled) { color: var(--color-text); }
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class AppButtonComponent {
|
export class AppButtonComponent {
|
||||||
variant = input<'primary' | 'secondary' | 'outline' | 'text'>('primary');
|
variant = input<'primary' | 'secondary' | 'outline' | 'text'>('primary');
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<div class="card">
|
||||||
|
<ng-content></ng-content>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: var(--space-6);
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,24 +3,7 @@ import { Component } from '@angular/core';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-card',
|
selector: 'app-card',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
template: `
|
templateUrl: './app-card.component.html',
|
||||||
<div class="card">
|
styleUrl: './app-card.component.scss'
|
||||||
<ng-content></ng-content>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
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 {}
|
export class AppCardComponent {}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<div
|
||||||
|
class="dropzone"
|
||||||
|
[class.dragover]="isDragOver()"
|
||||||
|
(dragover)="onDragOver($event)"
|
||||||
|
(dragleave)="onDragLeave($event)"
|
||||||
|
(drop)="onDrop($event)"
|
||||||
|
(click)="fileInput.click()"
|
||||||
|
>
|
||||||
|
<input #fileInput type="file" (change)="onFileSelected($event)" hidden [accept]="accept()" [multiple]="multiple()">
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-upload-cloud"><polyline points="16 16 12 12 8 16"></polyline><line x1="12" y1="12" x2="12" y2="21"></line><path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"></path><polyline points="16 16 12 12 8 16"></polyline></svg>
|
||||||
|
</div>
|
||||||
|
<p class="text">{{ label() }}</p>
|
||||||
|
<p class="subtext">{{ subtext() }}</p>
|
||||||
|
|
||||||
|
@if (fileNames().length > 0) {
|
||||||
|
<div class="file-badges">
|
||||||
|
@for (name of fileNames(); track name) {
|
||||||
|
<div class="file-badge">{{ name }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
.dropzone {
|
||||||
|
border: 2px dashed var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-8);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
background-color: var(--color-neutral-50);
|
||||||
|
|
||||||
|
&:hover, &.dragover {
|
||||||
|
border-color: var(--color-brand);
|
||||||
|
background-color: var(--color-neutral-100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.icon { color: var(--color-brand); margin-bottom: var(--space-4); }
|
||||||
|
.text { font-weight: 600; margin-bottom: var(--space-2); }
|
||||||
|
.subtext { font-size: 0.875rem; color: var(--color-text-muted); }
|
||||||
|
.file-badges {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.file-badge {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: var(--color-neutral-200);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary-700);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
@@ -5,68 +5,8 @@ import { CommonModule } from '@angular/common';
|
|||||||
selector: 'app-dropzone',
|
selector: 'app-dropzone',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
template: `
|
templateUrl: './app-dropzone.component.html',
|
||||||
<div
|
styleUrl: './app-dropzone.component.scss'
|
||||||
class="dropzone"
|
|
||||||
[class.dragover]="isDragOver()"
|
|
||||||
(dragover)="onDragOver($event)"
|
|
||||||
(dragleave)="onDragLeave($event)"
|
|
||||||
(drop)="onDrop($event)"
|
|
||||||
(click)="fileInput.click()"
|
|
||||||
>
|
|
||||||
<input #fileInput type="file" (change)="onFileSelected($event)" hidden [accept]="accept()" [multiple]="multiple()">
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
<div class="icon">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-upload-cloud"><polyline points="16 16 12 12 8 16"></polyline><line x1="12" y1="12" x2="12" y2="21"></line><path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"></path><polyline points="16 16 12 12 8 16"></polyline></svg>
|
|
||||||
</div>
|
|
||||||
<p class="text">{{ label() }}</p>
|
|
||||||
<p class="subtext">{{ subtext() }}</p>
|
|
||||||
|
|
||||||
@if (fileNames().length > 0) {
|
|
||||||
<div class="file-badges">
|
|
||||||
@for (name of fileNames(); track name) {
|
|
||||||
<div class="file-badge">{{ name }}</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.dropzone {
|
|
||||||
border: 2px dashed var(--color-border);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--space-8);
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
background-color: var(--color-neutral-50);
|
|
||||||
|
|
||||||
&:hover, &.dragover {
|
|
||||||
border-color: var(--color-brand);
|
|
||||||
background-color: var(--color-neutral-100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.icon { color: var(--color-brand); margin-bottom: var(--space-4); }
|
|
||||||
.text { font-weight: 600; margin-bottom: var(--space-2); }
|
|
||||||
.subtext { font-size: 0.875rem; color: var(--color-text-muted); }
|
|
||||||
.file-badges {
|
|
||||||
margin-top: var(--space-4);
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-2);
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.file-badge {
|
|
||||||
padding: var(--space-2) var(--space-4);
|
|
||||||
background: var(--color-neutral-200);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-primary-700);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class AppDropzoneComponent {
|
export class AppDropzoneComponent {
|
||||||
label = input<string>('Drop files here or click to upload');
|
label = input<string>('Drop files here or click to upload');
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<div class="form-group">
|
||||||
|
@if (label()) { <label [for]="id()">{{ label() }}</label> }
|
||||||
|
<input
|
||||||
|
[id]="id()"
|
||||||
|
[type]="type()"
|
||||||
|
[placeholder]="placeholder()"
|
||||||
|
[value]="value"
|
||||||
|
(input)="onInput($event)"
|
||||||
|
(blur)="onTouched()"
|
||||||
|
[disabled]="disabled"
|
||||||
|
class="form-control"
|
||||||
|
/>
|
||||||
|
@if (error()) { <span class="error-text">{{ error() }}</span> }
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
.form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); }
|
||||||
|
label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); }
|
||||||
|
.form-control {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text);
|
||||||
|
&:focus { outline: none; border-color: var(--color-brand); box-shadow: 0 0 0 2px rgba(250, 207, 10, 0.25); }
|
||||||
|
&:disabled { background: var(--color-neutral-100); cursor: not-allowed; }
|
||||||
|
}
|
||||||
|
.error-text { color: var(--color-danger-500); font-size: 0.75rem; margin-top: var(--space-1); }
|
||||||
@@ -13,38 +13,8 @@ import { CommonModule } from '@angular/common';
|
|||||||
multi: true
|
multi: true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
template: `
|
templateUrl: './app-input.component.html',
|
||||||
<div class="form-group">
|
styleUrl: './app-input.component.scss'
|
||||||
@if (label()) { <label [for]="id()">{{ label() }}</label> }
|
|
||||||
<input
|
|
||||||
[id]="id()"
|
|
||||||
[type]="type()"
|
|
||||||
[placeholder]="placeholder()"
|
|
||||||
[value]="value"
|
|
||||||
(input)="onInput($event)"
|
|
||||||
(blur)="onTouched()"
|
|
||||||
[disabled]="disabled"
|
|
||||||
class="form-control"
|
|
||||||
/>
|
|
||||||
@if (error()) { <span class="error-text">{{ error() }}</span> }
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); }
|
|
||||||
label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); }
|
|
||||||
.form-control {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 1rem;
|
|
||||||
width: 100%;
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
color: var(--color-text);
|
|
||||||
&:focus { outline: none; border-color: var(--color-brand); box-shadow: 0 0 0 2px rgba(250, 207, 10, 0.25); }
|
|
||||||
&:disabled { background: var(--color-neutral-100); cursor: not-allowed; }
|
|
||||||
}
|
|
||||||
.error-text { color: var(--color-danger-500); font-size: 0.75rem; margin-top: var(--space-1); }
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class AppInputComponent implements ControlValueAccessor {
|
export class AppInputComponent implements ControlValueAccessor {
|
||||||
label = input<string>('');
|
label = input<string>('');
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<section class="locations-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>{{ 'LOCATIONS.TITLE' | translate }}</h2>
|
||||||
|
<p class="subtitle">{{ 'LOCATIONS.SUBTITLE' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="locations-grid">
|
||||||
|
<div class="locations-controls">
|
||||||
|
<div class="location-tabs">
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
[class.active]="selectedLocation === 'ticino'"
|
||||||
|
(click)="selectLocation('ticino')">
|
||||||
|
{{ 'LOCATIONS.TICINO' | translate }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-btn"
|
||||||
|
[class.active]="selectedLocation === 'bienne'"
|
||||||
|
(click)="selectLocation('bienne')">
|
||||||
|
{{ 'LOCATIONS.BIENNE' | translate }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="location-details">
|
||||||
|
<div *ngIf="selectedLocation === 'ticino'" class="details-card">
|
||||||
|
<h3>{{ 'LOCATIONS.TICINO' | translate }}</h3>
|
||||||
|
<p>{{ 'LOCATIONS.ADDRESS_TICINO' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="selectedLocation === 'bienne'" class="details-card">
|
||||||
|
<h3>{{ 'LOCATIONS.BIENNE' | translate }}</h3>
|
||||||
|
<p>{{ 'LOCATIONS.ADDRESS_BIENNE' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<a routerLink="/contact" class="contact-btn">
|
||||||
|
{{ 'LOCATIONS.CONTACT_US' | translate }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="map-container">
|
||||||
|
<iframe
|
||||||
|
*ngIf="selectedLocation === 'ticino'"
|
||||||
|
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d177305.41680509657!2d8.826330925208468!3d46.00511195679905!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x4784407817452d5b%3A0x6a0c0e86b976660!2sTicino%2C%20Svizzera!5e0!3m2!1sit!2sit!4v1700000000000!5m2!1sit!2sit"
|
||||||
|
width="100%" height="450" style="border:0;" allowfullscreen="" loading="lazy" referrerpolicy="no-referrer-when-downgrade">
|
||||||
|
</iframe>
|
||||||
|
<iframe
|
||||||
|
*ngIf="selectedLocation === 'bienne'"
|
||||||
|
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d43521.94247549427!2d7.211756262451172!3d47.13677764958641!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x478e192760f384f9%3A0xcb281f622956f4e6!2sBiel%2C%20Svizzera!5e0!3m2!1sit!2sit!4v1700000000000!5m2!1sit!2sit"
|
||||||
|
width="100%" height="450" style="border:0;" allowfullscreen="" loading="lazy" referrerpolicy="no-referrer-when-downgrade">
|
||||||
|
</iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
.locations-section {
|
||||||
|
padding: 6rem 0;
|
||||||
|
background: var(--color-surface-card);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--color-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.locations-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 3rem;
|
||||||
|
align-items: start;
|
||||||
|
|
||||||
|
@media(min-width: 992px) {
|
||||||
|
grid-template-columns: 1fr 2fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--color-primary-500);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-details {
|
||||||
|
padding: 2rem;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
background: var(--color-primary-500);
|
||||||
|
color: var(--color-neutral-900);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-primary-600);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
background: var(--color-bg);
|
||||||
|
height: 450px;
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-locations',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, TranslateModule, RouterLink],
|
||||||
|
templateUrl: './app-locations.component.html',
|
||||||
|
styleUrl: './app-locations.component.scss'
|
||||||
|
})
|
||||||
|
export class AppLocationsComponent {
|
||||||
|
selectedLocation: 'ticino' | 'bienne' = 'ticino';
|
||||||
|
|
||||||
|
selectLocation(location: 'ticino' | 'bienne') {
|
||||||
|
this.selectedLocation = location;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<div class="form-group">
|
||||||
|
@if (label()) { <label [for]="id()">{{ label() }}</label> }
|
||||||
|
<select
|
||||||
|
[id]="id()"
|
||||||
|
[ngModel]="value"
|
||||||
|
(ngModelChange)="onModelChange($event)"
|
||||||
|
(blur)="onTouched()"
|
||||||
|
[disabled]="disabled"
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
@for (opt of options(); track opt.label) {
|
||||||
|
<option [ngValue]="opt.value">{{ opt.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
@if (error()) { <span class="error-text">{{ error() }}</span> }
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
.form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); }
|
||||||
|
label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); }
|
||||||
|
.form-control {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text);
|
||||||
|
&:focus { outline: none; border-color: var(--color-brand); }
|
||||||
|
}
|
||||||
|
.error-text { color: var(--color-danger-500); font-size: 0.75rem; margin-top: var(--space-1); }
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Component, input, output, forwardRef } from '@angular/core';
|
import { Component, input, output, forwardRef } from '@angular/core';
|
||||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
|
import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule, FormsModule } from '@angular/forms';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-select',
|
selector: 'app-select',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule],
|
imports: [CommonModule, ReactiveFormsModule, FormsModule],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: NG_VALUE_ACCESSOR,
|
provide: NG_VALUE_ACCESSOR,
|
||||||
@@ -13,39 +13,8 @@ import { CommonModule } from '@angular/common';
|
|||||||
multi: true
|
multi: true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
template: `
|
templateUrl: './app-select.component.html',
|
||||||
<div class="form-group">
|
styleUrl: './app-select.component.scss'
|
||||||
@if (label()) { <label [for]="id()">{{ label() }}</label> }
|
|
||||||
<select
|
|
||||||
[id]="id()"
|
|
||||||
[value]="value"
|
|
||||||
(change)="onSelect($event)"
|
|
||||||
(blur)="onTouched()"
|
|
||||||
[disabled]="disabled"
|
|
||||||
class="form-control"
|
|
||||||
>
|
|
||||||
@for (opt of options(); track opt.value) {
|
|
||||||
<option [value]="opt.value">{{ opt.label }}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
@if (error()) { <span class="error-text">{{ error() }}</span> }
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
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 {
|
export class AppSelectComponent implements ControlValueAccessor {
|
||||||
label = input<string>('');
|
label = input<string>('');
|
||||||
@@ -64,9 +33,8 @@ export class AppSelectComponent implements ControlValueAccessor {
|
|||||||
registerOnTouched(fn: any): void { this.onTouched = fn; }
|
registerOnTouched(fn: any): void { this.onTouched = fn; }
|
||||||
setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; }
|
setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; }
|
||||||
|
|
||||||
onSelect(event: Event) {
|
onModelChange(val: any) {
|
||||||
const val = (event.target as HTMLSelectElement).value;
|
this.value = val;
|
||||||
this.value = val;
|
this.onChange(val);
|
||||||
this.onChange(val);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<div class="tabs">
|
||||||
|
@for (tab of tabs(); track tab.value) {
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
[class.active]="activeTab() === tab.value"
|
||||||
|
(click)="selectTab(tab.value)">
|
||||||
|
{{ tab.label | translate }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover { color: var(--color-text); }
|
||||||
|
&.active {
|
||||||
|
color: var(--color-brand);
|
||||||
|
border-bottom-color: var(--color-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,41 +6,8 @@ import { TranslateModule } from '@ngx-translate/core';
|
|||||||
selector: 'app-tabs',
|
selector: 'app-tabs',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, TranslateModule],
|
imports: [CommonModule, TranslateModule],
|
||||||
template: `
|
templateUrl: './app-tabs.component.html',
|
||||||
<div class="tabs">
|
styleUrl: './app-tabs.component.scss'
|
||||||
@for (tab of tabs(); track tab.value) {
|
|
||||||
<button
|
|
||||||
class="tab"
|
|
||||||
[class.active]="activeTab() === tab.value"
|
|
||||||
(click)="selectTab(tab.value)">
|
|
||||||
{{ tab.label | translate }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
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 {
|
export class AppTabsComponent {
|
||||||
tabs = input<{label: string, value: string}[]>([]);
|
tabs = input<{label: string, value: string}[]>([]);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<div class="color-selector-container">
|
||||||
|
@if (isOpen()) {
|
||||||
|
<div class="backdrop" (click)="close()"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="color-circle trigger"
|
||||||
|
[style.background-color]="getCurrentHex()"
|
||||||
|
[title]="selectedColor()"
|
||||||
|
(click)="toggleOpen()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isOpen()) {
|
||||||
|
<div class="color-popup">
|
||||||
|
@for (category of categories; track category.name) {
|
||||||
|
<div class="category">
|
||||||
|
<div class="category-name">{{ category.name }}</div>
|
||||||
|
<div class="colors-grid">
|
||||||
|
@for (color of category.colors; track color.value) {
|
||||||
|
<div
|
||||||
|
class="color-item"
|
||||||
|
(click)="selectColor(color)"
|
||||||
|
[class.disabled]="color.outOfStock">
|
||||||
|
|
||||||
|
<div class="selection-ring"
|
||||||
|
[class.active]="selectedColor() === color.value"
|
||||||
|
[class.out-of-stock]="color.outOfStock">
|
||||||
|
<div class="color-circle small" [style.background-color]="color.hex"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="color-name">{{ color.label }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
.color-selector-container {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
// margin-left: 10px; // Handled by parent now
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 999;
|
||||||
|
background: transparent;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-circle {
|
||||||
|
width: 20px; /* Reduced from 24px */
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||||
|
|
||||||
|
&.trigger:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.small {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-popup {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px); // Explicit gap
|
||||||
|
left: -2px; // Align left edge with slight offset
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 8px; /* Slightly tighter radius */
|
||||||
|
padding: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
z-index: 1000;
|
||||||
|
width: 230px; /* Increased size */
|
||||||
|
|
||||||
|
// Little triangle arrow
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
left: 8px; // Align arrow to left side near trigger
|
||||||
|
border-width: 0 6px 6px 6px;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: transparent transparent white transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Fixed Positioning */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 280px; /* Provide enough width for touch targets */
|
||||||
|
max-width: 90vw; /* Safety constraint */
|
||||||
|
box-shadow: 0 10px 25px rgba(0,0,0,0.2); /* Stronger shadow for modal feel */
|
||||||
|
|
||||||
|
/* Hide arrow on mobile since it's detached from trigger */
|
||||||
|
&::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.category {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-name {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colors-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr); /* 3 columns for better alignment */
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&:hover .selection-ring {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
&:hover .selection-ring { transform: none; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-ring {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
padding: 2px; /* Space for ring */
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--color-brand, #facf0a);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-brand, #facf0a);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.out-of-stock::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: #cc0000;
|
||||||
|
transform: translate(-50%, -50%) rotate(-45deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-name {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #444;
|
||||||
|
line-height: 1.1;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Component, input, output, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { PRODUCT_COLORS, getColorHex, ColorCategory, ColorOption } from '../../../core/constants/colors.const';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-color-selector',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, TranslateModule],
|
||||||
|
templateUrl: './color-selector.component.html',
|
||||||
|
styleUrl: './color-selector.component.scss'
|
||||||
|
})
|
||||||
|
export class ColorSelectorComponent {
|
||||||
|
selectedColor = input<string>('Black');
|
||||||
|
colorSelected = output<string>();
|
||||||
|
|
||||||
|
isOpen = signal(false);
|
||||||
|
|
||||||
|
categories: ColorCategory[] = PRODUCT_COLORS;
|
||||||
|
|
||||||
|
toggleOpen() {
|
||||||
|
this.isOpen.update(v => !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectColor(color: ColorOption) {
|
||||||
|
if (color.outOfStock) return;
|
||||||
|
|
||||||
|
this.colorSelected.emit(color.value);
|
||||||
|
this.isOpen.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to find hex for the current selected value
|
||||||
|
getCurrentHex(): string {
|
||||||
|
return getColorHex(this.selectedColor());
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.isOpen.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<div class="viewer-container" #rendererContainer>
|
||||||
|
@if (loading) {
|
||||||
|
<div class="loading-overlay">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span>Loading 3D Model...</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (file && !loading) {
|
||||||
|
<div class="dims-overlay">
|
||||||
|
{{ dimensions.x }} x {{ dimensions.y }} x {{ dimensions.z }} mm
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
.viewer-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
background: var(--color-neutral-50);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
z-index: 10;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid var(--color-neutral-200);
|
||||||
|
border-top-color: var(--color-brand);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.dims-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(0,0,0,0.6);
|
||||||
|
color: white;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: monospace;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
@@ -10,70 +10,13 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
|||||||
selector: 'app-stl-viewer',
|
selector: 'app-stl-viewer',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
template: `
|
templateUrl: './stl-viewer.component.html',
|
||||||
<div class="viewer-container" #rendererContainer>
|
styleUrl: './stl-viewer.component.scss'
|
||||||
@if (loading) {
|
|
||||||
<div class="loading-overlay">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<span>Loading 3D Model...</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (file && !loading) {
|
|
||||||
<div class="dims-overlay">
|
|
||||||
{{ dimensions.x }} x {{ dimensions.y }} x {{ dimensions.z }} mm
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.viewer-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 300px;
|
|
||||||
background: var(--color-neutral-50);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.loading-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(255, 255, 255, 0.8);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
z-index: 10;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
.spinner {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 3px solid var(--color-neutral-200);
|
|
||||||
border-top-color: var(--color-brand);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
.dims-overlay {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 8px;
|
|
||||||
right: 8px;
|
|
||||||
background: rgba(0,0,0,0.6);
|
|
||||||
color: white;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-family: monospace;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
|
export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
|
||||||
@Input() file: File | null = null;
|
@Input() file: File | null = null;
|
||||||
|
@Input() color: string = '#facf0a'; // Default Brand Color
|
||||||
|
|
||||||
@ViewChild('rendererContainer', { static: true }) rendererContainer!: ElementRef;
|
@ViewChild('rendererContainer', { static: true }) rendererContainer!: ElementRef;
|
||||||
|
|
||||||
private scene!: THREE.Scene;
|
private scene!: THREE.Scene;
|
||||||
@@ -93,6 +36,12 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
if (changes['file'] && this.file) {
|
if (changes['file'] && this.file) {
|
||||||
this.loadFile(this.file);
|
this.loadFile(this.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (changes['color'] && this.currentMesh && !changes['file']) {
|
||||||
|
// Update existing mesh color if only color changed
|
||||||
|
const mat = this.currentMesh.material as THREE.MeshPhongMaterial;
|
||||||
|
mat.color.set(this.color);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
@@ -158,7 +107,7 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const material = new THREE.MeshPhongMaterial({
|
const material = new THREE.MeshPhongMaterial({
|
||||||
color: 0xFACF0A, // Brand color
|
color: this.color,
|
||||||
specular: 0x111111,
|
specular: 0x111111,
|
||||||
shininess: 200
|
shininess: 200
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<div class="success-state">
|
||||||
|
<div class="success-icon">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@switch (context()) {
|
||||||
|
@case ('contact') {
|
||||||
|
<h3>{{ 'CONTACT.SUCCESS_TITLE' | translate }}</h3>
|
||||||
|
<p>{{ 'CONTACT.SUCCESS_DESC' | translate }}</p>
|
||||||
|
<app-button (click)="action.emit()">{{ 'CONTACT.SEND_ANOTHER' | translate }}</app-button>
|
||||||
|
}
|
||||||
|
@case ('calc') {
|
||||||
|
<h3>{{ 'CALC.ORDER_SUCCESS_TITLE' | translate }}</h3>
|
||||||
|
<p>{{ 'CALC.ORDER_SUCCESS_DESC' | translate }}</p>
|
||||||
|
<app-button (click)="action.emit()">{{ 'CALC.NEW_QUOTE' | translate }}</app-button>
|
||||||
|
}
|
||||||
|
@case ('shop') {
|
||||||
|
<h3>{{ 'SHOP.SUCCESS_TITLE' | translate }}</h3>
|
||||||
|
<p>{{ 'SHOP.SUCCESS_DESC' | translate }}</p>
|
||||||
|
<app-button (click)="action.emit()">{{ 'SHOP.CONTINUE' | translate }}</app-button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
.success-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-8) var(--space-4);
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-height: 300px; /* Ensure visual balance */
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
color: var(--color-success, #10b981);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
max-width: 400px;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Component, input, output } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { AppButtonComponent } from '../app-button/app-button.component';
|
||||||
|
|
||||||
|
export type SuccessContext = 'contact' | 'calc' | 'shop';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-success-state',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, TranslateModule, AppButtonComponent],
|
||||||
|
templateUrl: './success-state.component.html',
|
||||||
|
styleUrl: './success-state.component.scss'
|
||||||
|
})
|
||||||
|
export class SuccessStateComponent {
|
||||||
|
context = input.required<SuccessContext>();
|
||||||
|
action = output<void>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<div class="summary-card" [class.highlight]="highlight()">
|
||||||
|
<span class="label">{{ label() }}</span>
|
||||||
|
<span class="value" [class.large]="large()">
|
||||||
|
<ng-content></ng-content>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
.summary-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.highlight {
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
.value {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.large {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--color-brand);
|
||||||
|
}
|
||||||
@@ -5,45 +5,8 @@ import { CommonModule } from '@angular/common';
|
|||||||
selector: 'app-summary-card',
|
selector: 'app-summary-card',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
template: `
|
templateUrl: './summary-card.component.html',
|
||||||
<div class="summary-card" [class.highlight]="highlight()">
|
styleUrl: './summary-card.component.scss'
|
||||||
<span class="label">{{ label() }}</span>
|
|
||||||
<span class="value" [class.large]="large()">
|
|
||||||
<ng-content></ng-content>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
styles: [`
|
|
||||||
.summary-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--space-3);
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
height: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.highlight {
|
|
||||||
background: var(--color-neutral-100);
|
|
||||||
border-color: var(--color-border);
|
|
||||||
}
|
|
||||||
.label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
}
|
|
||||||
.value {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
.large {
|
|
||||||
font-size: 2rem;
|
|
||||||
color: var(--color-brand);
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class SummaryCardComponent {
|
export class SummaryCardComponent {
|
||||||
label = input.required<string>();
|
label = input.required<string>();
|
||||||
|
|||||||
@@ -2,9 +2,32 @@
|
|||||||
"NAV": {
|
"NAV": {
|
||||||
"HOME": "Home",
|
"HOME": "Home",
|
||||||
"CALCULATOR": "Calculator",
|
"CALCULATOR": "Calculator",
|
||||||
"SHOP": "Shop",
|
"SHOP": "Shop"
|
||||||
"ABOUT": "About",
|
},
|
||||||
"CONTACT": "Contact Us"
|
"QUOTE": {
|
||||||
|
"CONSULT": "Request Consultation",
|
||||||
|
"PROCEED_ORDER": "Proceed to Order",
|
||||||
|
"TOTAL": "Total Estimate"
|
||||||
|
},
|
||||||
|
"USER_DETAILS": {
|
||||||
|
"TITLE": "Shipping Details",
|
||||||
|
"SUMMARY_TITLE": "Order Summary",
|
||||||
|
"NAME": "First Name",
|
||||||
|
"NAME_PLACEHOLDER": "Enter your first name",
|
||||||
|
"SURNAME": "Last Name",
|
||||||
|
"SURNAME_PLACEHOLDER": "Enter your last name",
|
||||||
|
"EMAIL": "Email",
|
||||||
|
"EMAIL_PLACEHOLDER": "your@email.com",
|
||||||
|
"PHONE": "Phone",
|
||||||
|
"PHONE_PLACEHOLDER": "+41 79 123 45 67",
|
||||||
|
"ADDRESS": "Address",
|
||||||
|
"ADDRESS_PLACEHOLDER": "Street and Number",
|
||||||
|
"ZIP": "ZIP",
|
||||||
|
"ZIP_PLACEHOLDER": "8000",
|
||||||
|
"CITY": "City",
|
||||||
|
"CITY_PLACEHOLDER": "Zurich",
|
||||||
|
"SUBMIT": "Submit Order",
|
||||||
|
"ORDER_SUCCESS": "Order submitted successfully! We will contact you shortly."
|
||||||
},
|
},
|
||||||
"FOOTER": {
|
"FOOTER": {
|
||||||
"PRIVACY": "Privacy",
|
"PRIVACY": "Privacy",
|
||||||
@@ -25,6 +48,12 @@
|
|||||||
"QUALITY": "Quality",
|
"QUALITY": "Quality",
|
||||||
"QUANTITY": "Quantity",
|
"QUANTITY": "Quantity",
|
||||||
"NOTES": "Additional Notes",
|
"NOTES": "Additional Notes",
|
||||||
|
"NOZZLE": "Nozzle Diameter",
|
||||||
|
"INFILL": "Infill (%)",
|
||||||
|
"PATTERN": "Infill Pattern",
|
||||||
|
"LAYER_HEIGHT": "Layer Height",
|
||||||
|
"SUPPORT": "Supports",
|
||||||
|
"SUPPORT_DESC": "Enable supports for overhangs",
|
||||||
"CALCULATE": "Calculate Quote",
|
"CALCULATE": "Calculate Quote",
|
||||||
"RESULT": "Estimated Quote",
|
"RESULT": "Estimated Quote",
|
||||||
"TIME": "Print Time",
|
"TIME": "Print Time",
|
||||||
@@ -32,6 +61,9 @@
|
|||||||
"ORDER": "Order Now",
|
"ORDER": "Order Now",
|
||||||
"CONSULT": "Request Consultation",
|
"CONSULT": "Request Consultation",
|
||||||
"ERROR_GENERIC": "An error occurred while calculating the quote.",
|
"ERROR_GENERIC": "An error occurred while calculating the quote.",
|
||||||
|
"NEW_QUOTE": "Calculate New Quote",
|
||||||
|
"ORDER_SUCCESS_TITLE": "Order Submitted Successfully",
|
||||||
|
"ORDER_SUCCESS_DESC": "We have received your order details. You will receive a confirmation email shortly with the payment details.",
|
||||||
"BENEFITS_TITLE": "Why choose us?",
|
"BENEFITS_TITLE": "Why choose us?",
|
||||||
"BENEFITS_1": "Automatic quote with instant cost and time",
|
"BENEFITS_1": "Automatic quote with instant cost and time",
|
||||||
"BENEFITS_2": "Selected materials and quality control",
|
"BENEFITS_2": "Selected materials and quality control",
|
||||||
@@ -62,6 +94,15 @@
|
|||||||
"TARGET_TEXT": "Small businesses, freelancers, makers and customers looking for a ready-made product from the shop.",
|
"TARGET_TEXT": "Small businesses, freelancers, makers and customers looking for a ready-made product from the shop.",
|
||||||
"TEAM_TITLE": "Our Team"
|
"TEAM_TITLE": "Our Team"
|
||||||
},
|
},
|
||||||
|
"LOCATIONS": {
|
||||||
|
"TITLE": "Our Locations",
|
||||||
|
"SUBTITLE": "We have two locations to serve you better. Select a location to see details.",
|
||||||
|
"TICINO": "Ticino",
|
||||||
|
"BIENNE": "Bienne",
|
||||||
|
"ADDRESS_TICINO": "Ticino Office, Switzerland",
|
||||||
|
"ADDRESS_BIENNE": "Bienne Office, Switzerland",
|
||||||
|
"CONTACT_US": "Contact Us"
|
||||||
|
},
|
||||||
"CONTACT": {
|
"CONTACT": {
|
||||||
"TITLE": "Contact Us",
|
"TITLE": "Contact Us",
|
||||||
"SEND": "Send Message",
|
"SEND": "Send Message",
|
||||||
@@ -88,6 +129,9 @@
|
|||||||
"LABEL_EMAIL": "Email *",
|
"LABEL_EMAIL": "Email *",
|
||||||
"LABEL_NAME": "Name *",
|
"LABEL_NAME": "Name *",
|
||||||
"MSG_SENT": "Sent!",
|
"MSG_SENT": "Sent!",
|
||||||
"ERR_MAX_FILES": "Max 15 files limit reached."
|
"ERR_MAX_FILES": "Max 15 files limit reached.",
|
||||||
|
"SUCCESS_TITLE": "Message Sent Successfully",
|
||||||
|
"SUCCESS_DESC": "Thank you for contacting us. We have received your message and will send you a recap email shortly.",
|
||||||
|
"SEND_ANOTHER": "Send Another Message"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,17 +17,20 @@
|
|||||||
"CTA_START": "Inizia Ora",
|
"CTA_START": "Inizia Ora",
|
||||||
"BUSINESS": "Aziende",
|
"BUSINESS": "Aziende",
|
||||||
"PRIVATE": "Privati",
|
"PRIVATE": "Privati",
|
||||||
"MODE_EASY": "Rapida",
|
"MODE_EASY": "Base",
|
||||||
"MODE_ADVANCED": "Avanzata",
|
"MODE_ADVANCED": "Avanzata",
|
||||||
"UPLOAD_LABEL": "Trascina il tuo file 3D qui",
|
"UPLOAD_LABEL": "Trascina il tuo file 3D qui",
|
||||||
"UPLOAD_SUB": "Supportiamo STL, 3MF, STEP, OBJ fino a 50MB",
|
"UPLOAD_SUB": "Supportiamo STL, 3MF, STEP, OBJ fino a 50MB",
|
||||||
"MATERIAL": "Materiale",
|
"MATERIAL": "Materiale",
|
||||||
"QUALITY": "Qualità",
|
"QUALITY": "Qualità",
|
||||||
|
"PRINT_SPEED": "Velocità di Stampa",
|
||||||
"QUANTITY": "Quantità",
|
"QUANTITY": "Quantità",
|
||||||
"NOTES": "Note aggiuntive",
|
"NOTES": "Note aggiuntive",
|
||||||
"COLOR": "Colore",
|
"COLOR": "Colore",
|
||||||
"INFILL": "Riempimento (%)",
|
"INFILL": "Riempimento (%)",
|
||||||
"PATTERN": "Pattern di riempimento",
|
"PATTERN": "Pattern di riempimento",
|
||||||
|
"LAYER_HEIGHT": "Altezza Layer",
|
||||||
|
"NOZZLE": "Diametro Ugello",
|
||||||
"SUPPORT": "Supporti",
|
"SUPPORT": "Supporti",
|
||||||
"SUPPORT_DESC": "Abilita supporti per sporgenze",
|
"SUPPORT_DESC": "Abilita supporti per sporgenze",
|
||||||
"CALCULATE": "Calcola Preventivo",
|
"CALCULATE": "Calcola Preventivo",
|
||||||
@@ -37,6 +40,9 @@
|
|||||||
"ORDER": "Ordina Ora",
|
"ORDER": "Ordina Ora",
|
||||||
"CONSULT": "Richiedi Consulenza",
|
"CONSULT": "Richiedi Consulenza",
|
||||||
"ERROR_GENERIC": "Si è verificato un errore durante il calcolo del preventivo.",
|
"ERROR_GENERIC": "Si è verificato un errore durante il calcolo del preventivo.",
|
||||||
|
"NEW_QUOTE": "Calcola Nuovo Preventivo",
|
||||||
|
"ORDER_SUCCESS_TITLE": "Ordine Inviato con Successo",
|
||||||
|
"ORDER_SUCCESS_DESC": "Abbiamo ricevuto i dettagli del tuo ordine. Riceverai a breve una email di conferma con i dettagli per il pagamento.",
|
||||||
"BENEFITS_TITLE": "Perché scegliere noi?",
|
"BENEFITS_TITLE": "Perché scegliere noi?",
|
||||||
"BENEFITS_1": "Preventivo automatico con costo e tempo immediati",
|
"BENEFITS_1": "Preventivo automatico con costo e tempo immediati",
|
||||||
"BENEFITS_2": "Materiali selezionati e qualità controllata",
|
"BENEFITS_2": "Materiali selezionati e qualità controllata",
|
||||||
@@ -67,6 +73,15 @@
|
|||||||
"TARGET_TEXT": "Piccole aziende, freelance, smanettoni e clienti che cercano un prodotto già pronto dallo shop.",
|
"TARGET_TEXT": "Piccole aziende, freelance, smanettoni e clienti che cercano un prodotto già pronto dallo shop.",
|
||||||
"TEAM_TITLE": "Il Nostro Team"
|
"TEAM_TITLE": "Il Nostro Team"
|
||||||
},
|
},
|
||||||
|
"LOCATIONS": {
|
||||||
|
"TITLE": "Le Nostre Sedi",
|
||||||
|
"SUBTITLE": "Siamo presenti in due sedi per coprire meglio il territorio. Seleziona la sede per vedere i dettagli.",
|
||||||
|
"TICINO": "Ticino",
|
||||||
|
"BIENNE": "Bienne",
|
||||||
|
"ADDRESS_TICINO": "Sede Ticino, Svizzera",
|
||||||
|
"ADDRESS_BIENNE": "Sede Bienne, Svizzera",
|
||||||
|
"CONTACT_US": "Contattaci"
|
||||||
|
},
|
||||||
"CONTACT": {
|
"CONTACT": {
|
||||||
"TITLE": "Contattaci",
|
"TITLE": "Contattaci",
|
||||||
"SEND": "Invia Messaggio",
|
"SEND": "Invia Messaggio",
|
||||||
@@ -93,6 +108,9 @@
|
|||||||
"LABEL_EMAIL": "Email *",
|
"LABEL_EMAIL": "Email *",
|
||||||
"LABEL_NAME": "Nome *",
|
"LABEL_NAME": "Nome *",
|
||||||
"MSG_SENT": "Inviato!",
|
"MSG_SENT": "Inviato!",
|
||||||
"ERR_MAX_FILES": "Limite massimo di 15 file raggiunto."
|
"ERR_MAX_FILES": "Limite massimo di 15 file raggiunto.",
|
||||||
|
"SUCCESS_TITLE": "Messaggio Inviato con Successo",
|
||||||
|
"SUCCESS_DESC": "Grazie per averci contattato. Abbiamo ricevuto il tuo messaggio e ti invieremo una email di riepilogo a breve.",
|
||||||
|
"SEND_ANOTHER": "Invia un altro messaggio"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user