chore(web): refractor
This commit is contained in:
@@ -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 {}
|
||||||
|
|||||||
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 {}
|
||||||
|
|||||||
42
frontend/src/app/features/about/about-page.component.html
Normal file
42
frontend/src/app/features/about/about-page.component.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<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>
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -6,209 +6,8 @@ import { TranslateModule } from '@ngx-translate/core';
|
|||||||
selector: 'app-about-page',
|
selector: 'app-about-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [TranslateModule],
|
imports: [TranslateModule],
|
||||||
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,64 @@
|
|||||||
|
<div class="container hero">
|
||||||
|
<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
|
||||||
|
#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 (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()"
|
||||||
|
(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,99 @@
|
|||||||
|
.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, .col-result {
|
||||||
|
min-width: 0; /* Prevent grid blowout */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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); }
|
||||||
|
}
|
||||||
@@ -13,173 +13,8 @@ import { Router } from '@angular/router';
|
|||||||
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],
|
||||||
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
|
|
||||||
#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 (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()"
|
|
||||||
(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>
|
|
||||||
`,
|
|
||||||
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-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, .col-result {
|
|
||||||
min-width: 0; /* Prevent grid blowout */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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');
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<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="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>
|
||||||
@@ -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); }
|
||||||
@@ -11,157 +11,8 @@ import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
|
|||||||
selector: 'app-quote-result',
|
selector: 'app-quote-result',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, 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>
|
|
||||||
|
|
||||||
<!-- 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="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; }
|
|
||||||
|
|
||||||
.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); }
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class QuoteResultComponent {
|
export class QuoteResultComponent {
|
||||||
result = input.required<QuoteResult>();
|
result = input.required<QuoteResult>();
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
@if (selectedFile()) {
|
||||||
|
<div class="viewer-wrapper">
|
||||||
|
<app-stl-viewer [file]="selectedFile()"></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="qty-group">
|
||||||
|
<label>Qtà</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
[value]="item.quantity"
|
||||||
|
(change)="updateItemQuantity(i, $event)"
|
||||||
|
class="qty-input"
|
||||||
|
(click)="$event.stopPropagation()">
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<app-select
|
||||||
|
formControlName="quality"
|
||||||
|
[label]="'CALC.QUALITY' | translate"
|
||||||
|
[options]="qualities"
|
||||||
|
></app-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Global quantity removed, now per item -->
|
||||||
|
|
||||||
|
@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 || items().length === 0 || loading()"
|
||||||
|
[fullWidth]="true">
|
||||||
|
{{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
|
||||||
|
</app-button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
.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;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
|
||||||
|
@media(min-width: 640px) {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-card {
|
||||||
|
padding: var(--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: var(--space-2);
|
||||||
|
|
||||||
|
&: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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
label { font-size: 0.75rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.qty-input {
|
||||||
|
width: 40px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: white;
|
||||||
|
&:focus { outline: none; border-color: var(--color-brand); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid transparent; // var(--color-border);
|
||||||
|
background: transparent; // white;
|
||||||
|
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.9rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-danger-100);
|
||||||
|
color: var(--color-danger-500);
|
||||||
|
border-color: var(--color-danger-200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
@@ -18,318 +18,8 @@ interface FormItem {
|
|||||||
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],
|
||||||
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>
|
|
||||||
<!-- 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="qty-group">
|
|
||||||
<label>Qtà</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
[value]="item.quantity"
|
|
||||||
(change)="updateItemQuantity(i, $event)"
|
|
||||||
class="qty-input"
|
|
||||||
(click)="$event.stopPropagation()">
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<app-select
|
|
||||||
formControlName="quality"
|
|
||||||
[label]="'CALC.QUALITY' | translate"
|
|
||||||
[options]="qualities"
|
|
||||||
></app-select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Global quantity removed, now per item -->
|
|
||||||
|
|
||||||
@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 || items().length === 0 || 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;
|
|
||||||
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;
|
|
||||||
gap: var(--space-3);
|
|
||||||
margin-top: var(--space-4);
|
|
||||||
margin-bottom: var(--space-4);
|
|
||||||
|
|
||||||
@media(min-width: 640px) {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-card {
|
|
||||||
padding: var(--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: var(--space-2);
|
|
||||||
|
|
||||||
&: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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-name {
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--color-text);
|
|
||||||
display: block;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qty-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
label { font-size: 0.75rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.qty-input {
|
|
||||||
width: 40px;
|
|
||||||
padding: 2px 4px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
background: white;
|
|
||||||
&:focus { outline: none; border-color: var(--color-brand); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-remove {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid transparent; // var(--color-border);
|
|
||||||
background: transparent; // white;
|
|
||||||
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.9rem;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-danger-100);
|
|
||||||
color: var(--color-danger-500);
|
|
||||||
border-color: var(--color-danger-200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
`]
|
|
||||||
})
|
})
|
||||||
export class UploadFormComponent {
|
export class UploadFormComponent {
|
||||||
mode = input<'easy' | 'advanced'>('easy');
|
mode = input<'easy' | 'advanced'>('easy');
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<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,133 @@
|
|||||||
|
.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; }
|
||||||
|
}
|
||||||
@@ -16,216 +16,8 @@ interface FilePreview {
|
|||||||
selector: 'app-contact-form',
|
selector: 'app-contact-form',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppButtonComponent],
|
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppButtonComponent],
|
||||||
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;
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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,12 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,16 @@
|
|||||||
|
<div class="form-group">
|
||||||
|
@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>
|
||||||
@@ -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); }
|
||||||
@@ -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>('');
|
||||||
|
|||||||
@@ -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,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,67 +10,8 @@ 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;
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
Reference in New Issue
Block a user