dev #13

Merged
JoeKung merged 23 commits from dev into main 2026-03-03 18:28:30 +01:00
131 changed files with 5674 additions and 3482 deletions
Showing only changes of commit 20293cc044 - Show all commits

View File

@@ -18,8 +18,8 @@ jobs:
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
java-version: "21"
distribution: "temurin"
- name: Run Tests with Gradle
run: |

View File

@@ -20,7 +20,7 @@ jobs:
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: '22'
node-version: "22"
- name: Apply formatting with Prettier
shell: bash
@@ -104,8 +104,8 @@ jobs:
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
java-version: "21"
distribution: "temurin"
- name: Run Tests with Gradle
run: |

View File

@@ -1 +1 @@
<router-outlet></router-outlet>
<router-outlet></router-outlet>

View File

@@ -6,6 +6,6 @@ import { RouterOutlet } from '@angular/router';
standalone: true,
imports: [RouterOutlet],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
styleUrl: './app.component.scss',
})
export class AppComponent {}

View File

@@ -1,9 +1,21 @@
import { ApplicationConfig, provideZoneChangeDetection, importProvidersFrom } from '@angular/core';
import { provideRouter, withComponentInputBinding, withInMemoryScrolling, withViewTransitions } from '@angular/router';
import {
ApplicationConfig,
provideZoneChangeDetection,
importProvidersFrom,
} from '@angular/core';
import {
provideRouter,
withComponentInputBinding,
withInMemoryScrolling,
withViewTransitions,
} from '@angular/router';
import { routes } from './app.routes';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { provideTranslateHttpLoader, TranslateHttpLoader } from '@ngx-translate/http-loader';
import {
provideTranslateHttpLoader,
TranslateHttpLoader,
} from '@ngx-translate/http-loader';
import { adminAuthInterceptor } from './core/interceptors/admin-auth.interceptor';
export const appConfig: ApplicationConfig = {
@@ -14,24 +26,22 @@ export const appConfig: ApplicationConfig = {
withComponentInputBinding(),
withViewTransitions(),
withInMemoryScrolling({
scrollPositionRestoration: 'top'
})
),
provideHttpClient(
withInterceptors([adminAuthInterceptor])
scrollPositionRestoration: 'top',
}),
),
provideHttpClient(withInterceptors([adminAuthInterceptor])),
provideTranslateHttpLoader({
prefix: './assets/i18n/',
suffix: '.json'
suffix: '.json',
}),
importProvidersFrom(
TranslateModule.forRoot({
defaultLanguage: 'it',
loader: {
provide: TranslateLoader,
useClass: TranslateHttpLoader
}
})
)
]
useClass: TranslateHttpLoader,
},
}),
),
],
};

View File

@@ -3,63 +3,79 @@ import { Routes } from '@angular/router';
const appChildRoutes: Routes = [
{
path: '',
loadComponent: () => import('./features/home/home.component').then(m => m.HomeComponent)
loadComponent: () =>
import('./features/home/home.component').then((m) => m.HomeComponent),
},
{
path: 'calculator',
loadChildren: () => import('./features/calculator/calculator.routes').then(m => m.CALCULATOR_ROUTES)
loadChildren: () =>
import('./features/calculator/calculator.routes').then(
(m) => m.CALCULATOR_ROUTES,
),
},
{
path: 'shop',
loadChildren: () => import('./features/shop/shop.routes').then(m => m.SHOP_ROUTES)
loadChildren: () =>
import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES),
},
{
path: 'about',
loadChildren: () => import('./features/about/about.routes').then(m => m.ABOUT_ROUTES)
loadChildren: () =>
import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES),
},
{
path: 'contact',
loadChildren: () => import('./features/contact/contact.routes').then(m => m.CONTACT_ROUTES)
loadChildren: () =>
import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES),
},
{
path: 'checkout',
loadComponent: () => import('./features/checkout/checkout.component').then(m => m.CheckoutComponent)
loadComponent: () =>
import('./features/checkout/checkout.component').then(
(m) => m.CheckoutComponent,
),
},
{
path: 'order/:orderId',
loadComponent: () => import('./features/order/order.component').then(m => m.OrderComponent)
loadComponent: () =>
import('./features/order/order.component').then((m) => m.OrderComponent),
},
{
path: 'co/:orderId',
loadComponent: () => import('./features/order/order.component').then(m => m.OrderComponent)
loadComponent: () =>
import('./features/order/order.component').then((m) => m.OrderComponent),
},
{
path: '',
loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES)
loadChildren: () =>
import('./features/legal/legal.routes').then((m) => m.LEGAL_ROUTES),
},
{
path: 'admin',
loadChildren: () => import('./features/admin/admin.routes').then(m => m.ADMIN_ROUTES)
loadChildren: () =>
import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES),
},
{
path: '**',
redirectTo: ''
}
redirectTo: '',
},
];
export const routes: Routes = [
{
path: ':lang',
loadComponent: () => import('./core/layout/layout.component').then(m => m.LayoutComponent),
children: appChildRoutes
loadComponent: () =>
import('./core/layout/layout.component').then((m) => m.LayoutComponent),
children: appChildRoutes,
},
{
path: '',
loadComponent: () => import('./core/layout/layout.component').then(m => m.LayoutComponent),
children: appChildRoutes
loadComponent: () =>
import('./core/layout/layout.component').then((m) => m.LayoutComponent),
children: appChildRoutes,
},
{
path: '**',
redirectTo: ''
}
redirectTo: '',
},
];

View File

@@ -17,26 +17,31 @@ export const PRODUCT_COLORS: ColorCategory[] = [
colors: [
{ label: 'COLOR.NAME.BLACK', value: 'Black', hex: '#1a1a1a' }, // Not pure black for visibility
{ label: 'COLOR.NAME.WHITE', value: 'White', hex: '#f5f5f5' },
{ label: 'COLOR.NAME.RED', value: 'Red', hex: '#d32f2f', outOfStock: true },
{
label: 'COLOR.NAME.RED',
value: 'Red',
hex: '#d32f2f',
outOfStock: true,
},
{ label: 'COLOR.NAME.BLUE', value: 'Blue', hex: '#1976d2' },
{ label: 'COLOR.NAME.GREEN', value: 'Green', hex: '#388e3c' },
{ label: 'COLOR.NAME.YELLOW', value: 'Yellow', hex: '#fbc02d' }
]
{ label: 'COLOR.NAME.YELLOW', value: 'Yellow', hex: '#fbc02d' },
],
},
{
name: 'COLOR.CATEGORY_MATTE',
colors: [
{ label: 'COLOR.NAME.MATTE_BLACK', value: 'Matte Black', hex: '#2c2c2c' }, // Lighter charcoal for matte
{ label: 'COLOR.NAME.MATTE_WHITE', value: 'Matte White', hex: '#e0e0e0' },
{ label: 'COLOR.NAME.MATTE_GRAY', value: 'Matte Gray', hex: '#757575' }
]
}
{ label: 'COLOR.NAME.MATTE_GRAY', value: 'Matte Gray', hex: '#757575' },
],
},
];
export function getColorHex(value: string): string {
for (const cat of PRODUCT_COLORS) {
const found = cat.colors.find(c => c.value === value);
if (found) return found.hex;
}
return '#facf0a'; // Default Brand Color if not found
for (const cat of PRODUCT_COLORS) {
const found = cat.colors.find((c) => c.value === value);
if (found) return found.hex;
}
return '#facf0a'; // Default Brand Color if not found
}

View File

@@ -25,13 +25,17 @@ export const adminAuthInterceptor: HttpInterceptorFn = (req, next) => {
return next(request).pipe(
catchError((error: unknown) => {
if (!isLoginRequest && error instanceof HttpErrorResponse && error.status === 401) {
if (
!isLoginRequest &&
error instanceof HttpErrorResponse &&
error.status === 401
) {
const lang = resolveLangFromUrl(router.url);
if (!router.url.includes('/admin/login')) {
void router.navigate(['/', lang, 'admin', 'login']);
}
}
return throwError(() => error);
})
}),
);
};

View File

@@ -1,21 +1,21 @@
<footer class="footer">
<div class="container footer-inner">
<div class="col">
<span class="brand">3D fab</span>
<p class="copyright">&copy; 2026 3D fab.</p>
</div>
<footer class="footer">
<div class="container footer-inner">
<div class="col">
<span class="brand">3D fab</span>
<p class="copyright">&copy; 2026 3D fab.</p>
</div>
<div class="col links">
<a routerLink="/privacy">{{ 'FOOTER.PRIVACY' | translate }}</a>
<a routerLink="/terms">{{ 'FOOTER.TERMS' | translate }}</a>
<a routerLink="/contact">{{ 'FOOTER.CONTACT' | translate }}</a>
</div>
<div class="col links">
<a routerLink="/privacy">{{ "FOOTER.PRIVACY" | translate }}</a>
<a routerLink="/terms">{{ "FOOTER.TERMS" | translate }}</a>
<a routerLink="/contact">{{ "FOOTER.CONTACT" | translate }}</a>
</div>
<div class="col social">
<!-- Social Placeholders -->
<div class="social-icon"></div>
<div class="social-icon"></div>
<div class="social-icon"></div>
</div>
</div>
</footer>
<div class="col social">
<!-- Social Placeholders -->
<div class="social-icon"></div>
<div class="social-icon"></div>
<div class="social-icon"></div>
</div>
</div>
</footer>

View File

@@ -1,59 +1,75 @@
@use '../../../styles/patterns';
@use "../../../styles/patterns";
.footer {
background: var(--color-neutral-900);
color: var(--color-neutral-50);
padding: var(--space-8) 0 var(--space-4);
font-size: 0.9rem;
position: relative;
margin-top: auto; /* Push to bottom if content is short */
// Cross Hatch Pattern
&::before {
content: '';
position: absolute;
inset: 0;
@include patterns.pattern-cross-hatch(var(--color-neutral-50), 20px, 1px);
opacity: 0.05;
pointer-events: none;
}
}
.footer-inner {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-6);
}
.footer {
background: var(--color-neutral-900);
color: var(--color-neutral-50);
padding: var(--space-8) 0 var(--space-4);
font-size: 0.9rem;
position: relative;
margin-top: auto; /* Push to bottom if content is short */
// Cross Hatch Pattern
&::before {
content: "";
position: absolute;
inset: 0;
@include patterns.pattern-cross-hatch(var(--color-neutral-50), 20px, 1px);
opacity: 0.05;
pointer-events: none;
}
}
.footer-inner {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-6);
}
@media (max-width: 768px) {
.footer-inner {
flex-direction: column;
text-align: center;
gap: var(--space-8);
}
.links {
flex-direction: column;
gap: var(--space-3);
}
}
@media (max-width: 768px) {
.footer-inner {
flex-direction: column;
text-align: center;
gap: var(--space-8);
}
.brand { font-weight: 700; color: white; display: block; margin-bottom: var(--space-2); }
.copyright { font-size: 0.875rem; color: var(--color-secondary-500); margin: 0; }
.links {
display: flex;
gap: var(--space-6);
a {
color: var(--color-neutral-300);
font-size: 0.875rem;
transition: color 0.2s;
&:hover { color: white; text-decoration: underline; }
}
}
.links {
flex-direction: column;
gap: var(--space-3);
}
}
.social { display: flex; gap: var(--space-3); }
.social-icon {
width: 24px; height: 24px;
background-color: var(--color-neutral-800);
border-radius: 50%;
.brand {
font-weight: 700;
color: white;
display: block;
margin-bottom: var(--space-2);
}
.copyright {
font-size: 0.875rem;
color: var(--color-secondary-500);
margin: 0;
}
.links {
display: flex;
gap: var(--space-6);
a {
color: var(--color-neutral-300);
font-size: 0.875rem;
transition: color 0.2s;
&:hover {
color: white;
text-decoration: underline;
}
}
}
.social {
display: flex;
gap: var(--space-3);
}
.social-icon {
width: 24px;
height: 24px;
background-color: var(--color-neutral-800);
border-radius: 50%;
}

View File

@@ -7,6 +7,6 @@ import { RouterLink } from '@angular/router';
standalone: true,
imports: [TranslateModule, RouterLink],
templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss']
styleUrls: ['./footer.component.scss'],
})
export class FooterComponent {}

View File

@@ -8,6 +8,6 @@ import { FooterComponent } from './footer.component';
standalone: true,
imports: [RouterOutlet, NavbarComponent, FooterComponent],
templateUrl: './layout.component.html',
styleUrl: './layout.component.scss'
styleUrl: './layout.component.scss',
})
export class LayoutComponent {}

View File

@@ -1,35 +1,74 @@
<header class="navbar">
<div class="container navbar-inner">
<a routerLink="/" class="brand">3D <span class="highlight">fab</span></a>
<div class="mobile-toggle" (click)="toggleMenu()" [class.active]="isMenuOpen">
<span></span>
<span></span>
<span></span>
</div>
<header class="navbar">
<div class="container navbar-inner">
<a routerLink="/" class="brand">3D <span class="highlight">fab</span></a>
<nav class="nav-links" [class.open]="isMenuOpen">
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" (click)="closeMenu()">{{ 'NAV.HOME' | translate }}</a>
<a routerLink="/calculator/basic" routerLinkActive="active" [routerLinkActiveOptions]="{exact: false}" (click)="closeMenu()">{{ 'NAV.CALCULATOR' | translate }}</a>
<a routerLink="/shop" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.SHOP' | translate }}</a>
<a routerLink="/about" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.ABOUT' | translate }}</a>
<a routerLink="/contact" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.CONTACT' | translate }}</a>
</nav>
<div
class="mobile-toggle"
(click)="toggleMenu()"
[class.active]="isMenuOpen"
>
<span></span>
<span></span>
<span></span>
</div>
<div class="actions">
<select
class="lang-switch"
[value]="langService.selectedLang()"
(change)="onLanguageChange($event)"
[attr.aria-label]="'NAV.LANGUAGE_SELECTOR' | translate">
@for (option of languageOptions; track option.value) {
<option [value]="option.value">{{ option.label }}</option>
}
</select>
<div class="icon-placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
</div>
</div>
<nav class="nav-links" [class.open]="isMenuOpen">
<a
routerLink="/"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"
(click)="closeMenu()"
>{{ "NAV.HOME" | translate }}</a
>
<a
routerLink="/calculator/basic"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: false }"
(click)="closeMenu()"
>{{ "NAV.CALCULATOR" | translate }}</a
>
<a routerLink="/shop" routerLinkActive="active" (click)="closeMenu()">{{
"NAV.SHOP" | translate
}}</a>
<a routerLink="/about" routerLinkActive="active" (click)="closeMenu()">{{
"NAV.ABOUT" | translate
}}</a>
<a
routerLink="/contact"
routerLinkActive="active"
(click)="closeMenu()"
>{{ "NAV.CONTACT" | translate }}</a
>
</nav>
<div class="actions">
<select
class="lang-switch"
[value]="langService.selectedLang()"
(change)="onLanguageChange($event)"
[attr.aria-label]="'NAV.LANGUAGE_SELECTOR' | translate"
>
@for (option of languageOptions; track option.value) {
<option [value]="option.value">{{ option.label }}</option>
}
</select>
<div class="icon-placeholder">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>
</header>
</div>
</div>
</header>

View File

@@ -1,155 +1,175 @@
.navbar {
height: 64px;
border-bottom: 1px solid var(--color-border);
background-color: var(--color-bg-card);
position: sticky;
top: 0;
z-index: 100;
.navbar {
height: 64px;
border-bottom: 1px solid var(--color-border);
background-color: var(--color-bg-card);
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
}
.navbar-inner {
display: flex;
align-items: center;
justify-content: space-between;
}
.brand {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
text-decoration: none;
}
.highlight {
color: var(--color-brand);
}
.nav-links {
display: flex;
gap: var(--space-6);
a {
color: var(--color-text-muted);
font-weight: 500;
text-decoration: none;
transition: color 0.2s;
&:hover,
&.active {
color: var(--color-brand);
}
}
}
.actions {
display: flex;
align-items: center;
gap: var(--space-4);
}
.lang-switch {
background-color: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 2px 22px 2px 8px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-muted);
appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position:
calc(100% - 10px) calc(50% - 2px),
calc(100% - 5px) calc(50% - 2px);
background-size:
5px 5px,
5px 5px;
background-repeat: no-repeat;
&:hover {
color: var(--color-text);
border-color: var(--color-text);
}
&:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 1px;
}
}
.icon-placeholder {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: var(--color-neutral-100);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
}
/* Mobile Toggle */
.mobile-toggle {
display: none;
flex-direction: column;
justify-content: space-between;
width: 24px;
height: 18px;
cursor: pointer;
z-index: 101;
span {
display: block;
height: 2px;
width: 100%;
background-color: var(--color-text);
border-radius: 2px;
transition: all 0.3s ease;
}
&.active {
span:nth-child(1) {
transform: translateY(8px) rotate(45deg);
}
span:nth-child(2) {
opacity: 0;
}
span:nth-child(3) {
transform: translateY(-8px) rotate(-45deg);
}
}
}
/* Responsive Design */
@media (max-width: 768px) {
.mobile-toggle {
display: flex;
order: 2; /* Place after actions */
margin-left: var(--space-4);
}
.actions {
order: 1; /* Place before toggle */
margin-left: auto; /* Push to right */
}
.nav-links {
position: absolute;
top: 64px;
left: 0;
right: 0;
background-color: var(--color-bg-card);
flex-direction: column;
padding: var(--space-4);
border-bottom: 1px solid var(--color-border);
gap: var(--space-4);
display: none;
z-index: 1000;
&.open {
display: flex;
align-items: center;
animation: slideDown 0.3s ease forwards;
}
.navbar-inner {
display: flex;
align-items: center;
justify-content: space-between;
}
.brand {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
text-decoration: none;
}
.highlight { color: var(--color-brand); }
.nav-links {
display: flex;
gap: var(--space-6);
a {
color: var(--color-text-muted);
font-weight: 500;
text-decoration: none;
transition: color 0.2s;
&:hover, &.active {
color: var(--color-brand);
}
a {
font-size: 1.1rem;
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-neutral-100);
&:last-child {
border-bottom: none;
}
}
}
}
.actions {
display: flex;
align-items: center;
gap: var(--space-4);
}
.lang-switch {
background-color: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 2px 22px 2px 8px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-muted);
appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, currentColor 50%),
linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position:
calc(100% - 10px) calc(50% - 2px),
calc(100% - 5px) calc(50% - 2px);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
&:hover { color: var(--color-text); border-color: var(--color-text); }
&:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 1px;
}
}
.icon-placeholder {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: var(--color-neutral-100);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
}
/* Mobile Toggle */
.mobile-toggle {
display: none;
flex-direction: column;
justify-content: space-between;
width: 24px;
height: 18px;
cursor: pointer;
z-index: 101;
span {
display: block;
height: 2px;
width: 100%;
background-color: var(--color-text);
border-radius: 2px;
transition: all 0.3s ease;
}
&.active {
span:nth-child(1) { transform: translateY(8px) rotate(45deg); }
span:nth-child(2) { opacity: 0; }
span:nth-child(3) { transform: translateY(-8px) rotate(-45deg); }
}
}
/* Responsive Design */
@media (max-width: 768px) {
.mobile-toggle {
display: flex;
order: 2; /* Place after actions */
margin-left: var(--space-4);
}
.actions {
order: 1; /* Place before toggle */
margin-left: auto; /* Push to right */
}
.nav-links {
position: absolute;
top: 64px;
left: 0;
right: 0;
background-color: var(--color-bg-card);
flex-direction: column;
padding: var(--space-4);
border-bottom: 1px solid var(--color-border);
gap: var(--space-4);
display: none;
z-index: 1000;
&.open {
display: flex;
animation: slideDown 0.3s ease forwards;
}
a {
font-size: 1.1rem;
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-neutral-100);
&:last-child {
border-bottom: none;
}
}
}
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -8,15 +8,18 @@ import { LanguageService } from '../services/language.service';
standalone: true,
imports: [RouterLink, RouterLinkActive, TranslateModule],
templateUrl: './navbar.component.html',
styleUrls: ['./navbar.component.scss']
styleUrls: ['./navbar.component.scss'],
})
export class NavbarComponent {
isMenuOpen = false;
readonly languageOptions: Array<{ value: 'it' | 'en' | 'de' | 'fr'; label: string }> = [
readonly languageOptions: Array<{
value: 'it' | 'en' | 'de' | 'fr';
label: string;
}> = [
{ value: 'it', label: 'IT' },
{ value: 'en', label: 'EN' },
{ value: 'de', label: 'DE' },
{ value: 'fr', label: 'FR' }
{ value: 'fr', label: 'FR' },
];
constructor(public langService: LanguageService) {}

View File

@@ -1,22 +1,33 @@
import { Injectable, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { NavigationEnd, PRIMARY_OUTLET, Router, UrlTree } from '@angular/router';
import {
NavigationEnd,
PRIMARY_OUTLET,
Router,
UrlTree,
} from '@angular/router';
@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class LanguageService {
currentLang = signal<'it' | 'en' | 'de' | 'fr'>('it');
private readonly supportedLangs: Array<'it' | 'en' | 'de' | 'fr'> = ['it', 'en', 'de', 'fr'];
private readonly supportedLangs: Array<'it' | 'en' | 'de' | 'fr'> = [
'it',
'en',
'de',
'fr',
];
constructor(
private translate: TranslateService,
private router: Router
private router: Router,
) {
this.translate.addLangs(this.supportedLangs);
this.translate.setDefaultLang('it');
this.translate.onLangChange.subscribe(event => {
const lang = typeof event.lang === 'string' ? event.lang.toLowerCase() : null;
this.translate.onLangChange.subscribe((event) => {
const lang =
typeof event.lang === 'string' ? event.lang.toLowerCase() : null;
if (this.isSupportedLang(lang) && lang !== this.currentLang()) {
this.currentLang.set(lang);
}
@@ -27,11 +38,13 @@ export class LanguageService {
const queryLang = this.getQueryLang(initialTree);
const initialLang = this.isSupportedLang(initialSegments[0])
? initialSegments[0]
: (this.isSupportedLang(queryLang) ? queryLang : 'it');
: this.isSupportedLang(queryLang)
? queryLang
: 'it';
this.applyLanguage(initialLang);
this.ensureLanguageInPath(initialTree);
this.router.events.subscribe(event => {
this.router.events.subscribe((event) => {
if (!(event instanceof NavigationEnd)) {
return;
}
@@ -52,7 +65,10 @@ export class LanguageService {
let targetSegments: string[];
if (segments.length === 0) {
targetSegments = [lang];
} else if (this.isSupportedLang(segments[0]) || this.looksLikeLangToken(segments[0])) {
} else if (
this.isSupportedLang(segments[0]) ||
this.looksLikeLangToken(segments[0])
) {
targetSegments = [lang, ...segments.slice(1)];
} else {
targetSegments = [lang, ...segments];
@@ -62,9 +78,10 @@ export class LanguageService {
}
selectedLang(): 'it' | 'en' | 'de' | 'fr' {
const activeLang = typeof this.translate.currentLang === 'string'
? this.translate.currentLang.toLowerCase()
: null;
const activeLang =
typeof this.translate.currentLang === 'string'
? this.translate.currentLang.toLowerCase()
: null;
return this.isSupportedLang(activeLang) ? activeLang : this.currentLang();
}
@@ -77,7 +94,9 @@ export class LanguageService {
}
const queryLang = this.getQueryLang(urlTree);
const activeLang = this.isSupportedLang(queryLang) ? queryLang : this.currentLang();
const activeLang = this.isSupportedLang(queryLang)
? queryLang
: this.currentLang();
if (activeLang !== this.currentLang()) {
this.applyLanguage(activeLang);
}
@@ -99,7 +118,7 @@ export class LanguageService {
if (!primaryGroup) {
return [];
}
return primaryGroup.segments.map(segment => segment.path.toLowerCase());
return primaryGroup.segments.map((segment) => segment.path.toLowerCase());
}
private getQueryLang(urlTree: UrlTree): string | null {
@@ -107,12 +126,19 @@ export class LanguageService {
return typeof lang === 'string' ? lang.toLowerCase() : null;
}
private isSupportedLang(lang: string | null | undefined): lang is 'it' | 'en' | 'de' | 'fr' {
return typeof lang === 'string' && this.supportedLangs.includes(lang as 'it' | 'en' | 'de' | 'fr');
private isSupportedLang(
lang: string | null | undefined,
): lang is 'it' | 'en' | 'de' | 'fr' {
return (
typeof lang === 'string' &&
this.supportedLangs.includes(lang as 'it' | 'en' | 'de' | 'fr')
);
}
private looksLikeLangToken(segment: string | null | undefined): boolean {
return typeof segment === 'string' && /^[a-z]{2}(?:-[a-z]{2})?$/i.test(segment);
return (
typeof segment === 'string' && /^[a-z]{2}(?:-[a-z]{2})?$/i.test(segment)
);
}
private applyLanguage(lang: 'it' | 'en' | 'de' | 'fr'): void {
@@ -123,14 +149,20 @@ export class LanguageService {
this.currentLang.set(lang);
}
private navigateIfChanged(currentTree: UrlTree, targetSegments: string[]): void {
private navigateIfChanged(
currentTree: UrlTree,
targetSegments: string[],
): void {
const { lang: _unusedLang, ...queryParams } = currentTree.queryParams;
const targetTree = this.router.createUrlTree(['/', ...targetSegments], {
queryParams,
fragment: currentTree.fragment ?? undefined
fragment: currentTree.fragment ?? undefined,
});
if (this.router.serializeUrl(targetTree) === this.router.serializeUrl(currentTree)) {
if (
this.router.serializeUrl(targetTree) ===
this.router.serializeUrl(currentTree)
) {
return;
}

View File

@@ -17,7 +17,7 @@ export interface QuoteRequestDto {
}
@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class QuoteRequestService {
private http = inject(HttpClient);
@@ -25,15 +25,15 @@ export class QuoteRequestService {
createRequest(request: QuoteRequestDto, files: File[]): Observable<any> {
const formData = new FormData();
// Append Request DTO as JSON Blob
const requestBlob = new Blob([JSON.stringify(request)], {
type: 'application/json'
type: 'application/json',
});
formData.append('request', requestBlob);
// Append Files
files.forEach(file => {
files.forEach((file) => {
formData.append('files', file);
});

View File

@@ -1,17 +1,16 @@
<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>
<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>
<br>
<h2 class="passions-title">{{ 'ABOUT.PASSIONS_TITLE' | translate }}</h2>
<p class="description">{{ "ABOUT.HOW_TEXT" | translate }}</p>
<br />
<h2 class="passions-title">{{ "ABOUT.PASSIONS_TITLE" | translate }}</h2>
<div class="tags-container">
@for (passion of passions; track passion.id) {
@@ -40,11 +39,18 @@
(keydown.space)="toggleSelectedMember('joe'); $event.preventDefault()"
>
<div class="placeholder-img">
<img src="assets/images/joe.jpg" [attr.alt]="'ABOUT.MEMBER_JOE_ALT' | translate">
<img
src="assets/images/joe.jpg"
[attr.alt]="'ABOUT.MEMBER_JOE_ALT' | translate"
/>
</div>
<div class="member-info">
<span class="member-name">{{ 'ABOUT.MEMBER_JOE_NAME' | translate }}</span>
<span class="member-role">{{ 'ABOUT.MEMBER_JOE_ROLE' | translate }}</span>
<span class="member-name">{{
"ABOUT.MEMBER_JOE_NAME" | translate
}}</span>
<span class="member-role">{{
"ABOUT.MEMBER_JOE_ROLE" | translate
}}</span>
</div>
</div>
<div
@@ -60,18 +66,26 @@
(blur)="setHoveredMember(null)"
(click)="toggleSelectedMember('matteo')"
(keydown.enter)="toggleSelectedMember('matteo')"
(keydown.space)="toggleSelectedMember('matteo'); $event.preventDefault()"
(keydown.space)="
toggleSelectedMember('matteo'); $event.preventDefault()
"
>
<div class="placeholder-img">
<img src="assets/images/matteo.jpg" [attr.alt]="'ABOUT.MEMBER_MATTEO_ALT' | translate">
<img
src="assets/images/matteo.jpg"
[attr.alt]="'ABOUT.MEMBER_MATTEO_ALT' | translate"
/>
</div>
<div class="member-info">
<span class="member-name">{{ 'ABOUT.MEMBER_MATTEO_NAME' | translate }}</span>
<span class="member-role">{{ 'ABOUT.MEMBER_MATTEO_ROLE' | translate }}</span>
<span class="member-name">{{
"ABOUT.MEMBER_MATTEO_NAME" | translate
}}</span>
<span class="member-role">{{
"ABOUT.MEMBER_MATTEO_ROLE" | translate
}}</span>
</div>
</div>
</div>
</div>
</section>

View File

@@ -12,8 +12,8 @@
gap: 4rem;
align-items: center;
text-align: center; /* Center on mobile */
@media(min-width: 992px) {
@media (min-width: 992px) {
grid-template-columns: 1fr 1fr;
gap: 6rem;
text-align: left; /* Reset to left on desktop */
@@ -59,7 +59,7 @@ h1 {
margin-left: auto;
margin-right: auto;
@media(min-width: 992px) {
@media (min-width: 992px) {
margin-left: 0;
margin-right: 0;
}
@@ -86,8 +86,8 @@ h1 {
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center; /* Center tags on mobile */
@media(min-width: 992px) {
@media (min-width: 992px) {
justify-content: flex-start;
}
}
@@ -101,7 +101,11 @@ h1 {
font-weight: 500;
font-size: 0.9rem;
box-shadow: var(--shadow-sm);
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
transition:
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease;
}
.tag.is-active {
@@ -119,8 +123,8 @@ h1 {
justify-content: center;
align-items: center;
gap: 2rem;
@media(min-width: 768px) {
@media (min-width: 768px) {
display: grid;
grid-template-columns: repeat(2, 1fr);
align-items: start;
@@ -137,7 +141,11 @@ h1 {
width: 100%;
max-width: 260px;
position: relative;
transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease, outline-color 0.2s ease;
transition:
box-shadow 0.2s ease,
transform 0.2s ease,
border-color 0.2s ease,
outline-color 0.2s ease;
cursor: pointer;
outline: 2px solid transparent;
outline-offset: 2px;
@@ -155,7 +163,9 @@ h1 {
.photo-card.is-active {
border-color: var(--color-primary-600);
box-shadow: 0 0 0 3px rgb(250 207 10 / 30%), var(--shadow-md);
box-shadow:
0 0 0 3px rgb(250 207 10 / 30%),
var(--shadow-md);
}
.photo-card.is-selected {
@@ -165,7 +175,11 @@ h1 {
.placeholder-img {
width: 100%;
aspect-ratio: 3/4;
background: linear-gradient(45deg, var(--color-neutral-200), var(--color-neutral-100));
background: linear-gradient(
45deg,
var(--color-neutral-200),
var(--color-neutral-100)
);
border-radius: var(--radius-md);
margin-bottom: 1rem;
border-bottom: 1px solid var(--color-neutral-300);

View File

@@ -28,7 +28,7 @@ interface PassionChip {
standalone: true,
imports: [TranslateModule, AppLocationsComponent],
templateUrl: './about-page.component.html',
styleUrl: './about-page.component.scss'
styleUrl: './about-page.component.scss',
})
export class AboutPageComponent {
selectedMember: MemberId | null = null;
@@ -43,14 +43,22 @@ export class AboutPageComponent {
{ id: 'woodworking', labelKey: 'ABOUT.PASSION_WOODWORKING' },
{ id: 'print-3d', labelKey: 'ABOUT.PASSION_PRINT_3D' },
{ id: 'ski', labelKey: 'ABOUT.PASSION_SKI' },
{ id: 'software-development', labelKey: 'ABOUT.PASSION_SOFTWARE_DEVELOPMENT' },
{
id: 'software-development',
labelKey: 'ABOUT.PASSION_SOFTWARE_DEVELOPMENT',
},
{ id: 'snowboard', labelKey: 'ABOUT.PASSION_SNOWBOARD' },
{ id: 'van-life', labelKey: 'ABOUT.PASSION_VAN_LIFE' },
{ id: 'self-hosting', labelKey: 'ABOUT.PASSION_SELF_HOSTING' },
{ id: 'snowboard-instructor', labelKey: 'ABOUT.PASSION_SNOWBOARD_INSTRUCTOR' }
{
id: 'snowboard-instructor',
labelKey: 'ABOUT.PASSION_SNOWBOARD_INSTRUCTOR',
},
];
private readonly memberPassions: Readonly<Record<MemberId, ReadonlyArray<PassionId>>> = {
private readonly memberPassions: Readonly<
Record<MemberId, ReadonlyArray<PassionId>>
> = {
joe: [
'bike-trial',
'mountain',
@@ -59,7 +67,7 @@ export class AboutPageComponent {
'print-3d',
'travel',
'coffee',
'software-development'
'software-development',
],
matteo: [
'bike-trial',
@@ -69,8 +77,8 @@ export class AboutPageComponent {
'electronics',
'print-3d',
'woodworking',
'van-life'
]
'van-life',
],
};
get activeMember(): MemberId | null {

View File

@@ -2,5 +2,5 @@ import { Routes } from '@angular/router';
import { AboutPageComponent } from './about-page.component';
export const ABOUT_ROUTES: Routes = [
{ path: '', component: AboutPageComponent }
{ path: '', component: AboutPageComponent },
];

View File

@@ -4,34 +4,52 @@ import { adminAuthGuard } from './guards/admin-auth.guard';
export const ADMIN_ROUTES: Routes = [
{
path: 'login',
loadComponent: () => import('./pages/admin-login.component').then(m => m.AdminLoginComponent)
loadComponent: () =>
import('./pages/admin-login.component').then(
(m) => m.AdminLoginComponent,
),
},
{
path: '',
canActivate: [adminAuthGuard],
loadComponent: () => import('./pages/admin-shell.component').then(m => m.AdminShellComponent),
loadComponent: () =>
import('./pages/admin-shell.component').then(
(m) => m.AdminShellComponent,
),
children: [
{
path: '',
pathMatch: 'full',
redirectTo: 'orders'
redirectTo: 'orders',
},
{
path: 'orders',
loadComponent: () => import('./pages/admin-dashboard.component').then(m => m.AdminDashboardComponent)
loadComponent: () =>
import('./pages/admin-dashboard.component').then(
(m) => m.AdminDashboardComponent,
),
},
{
path: 'filament-stock',
loadComponent: () => import('./pages/admin-filament-stock.component').then(m => m.AdminFilamentStockComponent)
loadComponent: () =>
import('./pages/admin-filament-stock.component').then(
(m) => m.AdminFilamentStockComponent,
),
},
{
path: 'contact-requests',
loadComponent: () => import('./pages/admin-contact-requests.component').then(m => m.AdminContactRequestsComponent)
loadComponent: () =>
import('./pages/admin-contact-requests.component').then(
(m) => m.AdminContactRequestsComponent,
),
},
{
path: 'sessions',
loadComponent: () => import('./pages/admin-sessions.component').then(m => m.AdminSessionsComponent)
}
]
}
loadComponent: () =>
import('./pages/admin-sessions.component').then(
(m) => m.AdminSessionsComponent,
),
},
],
},
];

View File

@@ -1,5 +1,11 @@
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
import {
ActivatedRouteSnapshot,
CanActivateFn,
Router,
RouterStateSnapshot,
UrlTree,
} from '@angular/router';
import { catchError, map, Observable, of } from 'rxjs';
import { AdminAuthService } from '../services/admin-auth.service';
@@ -17,7 +23,7 @@ function resolveLang(route: ActivatedRouteSnapshot): string {
export const adminAuthGuard: CanActivateFn = (
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
state: RouterStateSnapshot,
): Observable<boolean | UrlTree> => {
const authService = inject(AdminAuthService);
const router = inject(Router);
@@ -29,13 +35,15 @@ export const adminAuthGuard: CanActivateFn = (
return true;
}
return router.createUrlTree(['/', lang, 'admin', 'login'], {
queryParams: { redirect: state.url }
queryParams: { redirect: state.url },
});
}),
catchError(() => of(
router.createUrlTree(['/', lang, 'admin', 'login'], {
queryParams: { redirect: state.url }
})
))
catchError(() =>
of(
router.createUrlTree(['/', lang, 'admin', 'login'], {
queryParams: { redirect: state.url },
}),
),
),
);
};

View File

@@ -5,7 +5,9 @@
<p>Richieste preventivo personalizzato ricevute dal sito.</p>
<span class="total-pill">{{ requests.length }} richieste</span>
</div>
<button type="button" (click)="loadRequests()" [disabled]="loading">Aggiorna</button>
<button type="button" (click)="loadRequests()" [disabled]="loading">
Aggiorna
</button>
</header>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
@@ -32,10 +34,19 @@
[class.selected]="isSelected(request.id)"
(click)="openDetails(request.id)"
>
<td class="created-at">{{ request.createdAt | date:'short' }}</td>
<td class="created-at">
{{ request.createdAt | date: "short" }}
</td>
<td class="name-cell">
<p class="primary">{{ request.name || request.companyName || '-' }}</p>
<p class="secondary" *ngIf="request.name && request.companyName">{{ request.companyName }}</p>
<p class="primary">
{{ request.name || request.companyName || "-" }}
</p>
<p
class="secondary"
*ngIf="request.name && request.companyName"
>
{{ request.companyName }}
</p>
</td>
<td class="email-cell">{{ request.email }}</td>
<td>
@@ -45,7 +56,11 @@
<span class="chip chip-light">{{ request.customerType }}</span>
</td>
<td>
<span class="chip" [ngClass]="getStatusChipClass(request.status)">{{ request.status }}</span>
<span
class="chip"
[ngClass]="getStatusChipClass(request.status)"
>{{ request.status }}</span
>
</td>
</tr>
<tr class="empty-row" *ngIf="requests.length === 0">
@@ -60,25 +75,58 @@
<header class="detail-header">
<div>
<h3>Dettaglio richiesta</h3>
<p class="request-id"><span>ID</span><code>{{ selectedRequest.id }}</code></p>
<p class="request-id">
<span>ID</span><code>{{ selectedRequest.id }}</code>
</p>
</div>
<div class="detail-chips">
<span class="chip" [ngClass]="getStatusChipClass(selectedRequest.status)">{{ selectedRequest.status }}</span>
<span class="chip chip-neutral">{{ selectedRequest.requestType }}</span>
<span class="chip chip-light">{{ selectedRequest.customerType }}</span>
<span
class="chip"
[ngClass]="getStatusChipClass(selectedRequest.status)"
>{{ selectedRequest.status }}</span
>
<span class="chip chip-neutral">{{
selectedRequest.requestType
}}</span>
<span class="chip chip-light">{{
selectedRequest.customerType
}}</span>
</div>
</header>
<p class="loading-detail" *ngIf="detailLoading">Caricamento dettaglio...</p>
<p class="loading-detail" *ngIf="detailLoading">
Caricamento dettaglio...
</p>
<dl class="meta-grid">
<div class="meta-item"><dt>Creata</dt><dd>{{ selectedRequest.createdAt | date:'medium' }}</dd></div>
<div class="meta-item"><dt>Aggiornata</dt><dd>{{ selectedRequest.updatedAt | date:'medium' }}</dd></div>
<div class="meta-item"><dt>Email</dt><dd>{{ selectedRequest.email }}</dd></div>
<div class="meta-item"><dt>Telefono</dt><dd>{{ selectedRequest.phone || '-' }}</dd></div>
<div class="meta-item"><dt>Nome</dt><dd>{{ selectedRequest.name || '-' }}</dd></div>
<div class="meta-item"><dt>Azienda</dt><dd>{{ selectedRequest.companyName || '-' }}</dd></div>
<div class="meta-item"><dt>Referente</dt><dd>{{ selectedRequest.contactPerson || '-' }}</dd></div>
<div class="meta-item">
<dt>Creata</dt>
<dd>{{ selectedRequest.createdAt | date: "medium" }}</dd>
</div>
<div class="meta-item">
<dt>Aggiornata</dt>
<dd>{{ selectedRequest.updatedAt | date: "medium" }}</dd>
</div>
<div class="meta-item">
<dt>Email</dt>
<dd>{{ selectedRequest.email }}</dd>
</div>
<div class="meta-item">
<dt>Telefono</dt>
<dd>{{ selectedRequest.phone || "-" }}</dd>
</div>
<div class="meta-item">
<dt>Nome</dt>
<dd>{{ selectedRequest.name || "-" }}</dd>
</div>
<div class="meta-item">
<dt>Azienda</dt>
<dd>{{ selectedRequest.companyName || "-" }}</dd>
</div>
<div class="meta-item">
<dt>Referente</dt>
<dd>{{ selectedRequest.contactPerson || "-" }}</dd>
</div>
</dl>
<div class="status-editor">
@@ -87,36 +135,61 @@
<select
id="contact-request-status"
[ngModel]="selectedStatus"
(ngModelChange)="selectedStatus = $event">
<option *ngFor="let status of statusOptions" [ngValue]="status">{{ status }}</option>
(ngModelChange)="selectedStatus = $event"
>
<option *ngFor="let status of statusOptions" [ngValue]="status">
{{ status }}
</option>
</select>
</div>
<button
type="button"
(click)="updateRequestStatus()"
[disabled]="!selectedRequest || updatingStatus || !selectedStatus || selectedStatus === selectedRequest.status">
{{ updatingStatus ? 'Salvataggio...' : 'Aggiorna stato' }}
[disabled]="
!selectedRequest ||
updatingStatus ||
!selectedStatus ||
selectedStatus === selectedRequest.status
"
>
{{ updatingStatus ? "Salvataggio..." : "Aggiorna stato" }}
</button>
</div>
<div class="message-box">
<h4>Messaggio</h4>
<p>{{ selectedRequest.message || '-' }}</p>
<p>{{ selectedRequest.message || "-" }}</p>
</div>
<div class="attachments">
<h4>Allegati</h4>
<div class="attachment-list" *ngIf="selectedRequest.attachments.length > 0; else noAttachmentsTpl">
<article class="attachment-item" *ngFor="let attachment of selectedRequest.attachments">
<div
class="attachment-list"
*ngIf="selectedRequest.attachments.length > 0; else noAttachmentsTpl"
>
<article
class="attachment-item"
*ngFor="let attachment of selectedRequest.attachments"
>
<div>
<p class="filename">{{ attachment.originalFilename }}</p>
<p class="meta">
{{ formatFileSize(attachment.fileSizeBytes) }}
<span *ngIf="attachment.mimeType"> | {{ attachment.mimeType }}</span>
<span *ngIf="attachment.createdAt"> | {{ attachment.createdAt | date:'short' }}</span>
<span *ngIf="attachment.mimeType">
| {{ attachment.mimeType }}</span
>
<span *ngIf="attachment.createdAt">
| {{ attachment.createdAt | date: "short" }}</span
>
</p>
</div>
<button type="button" class="ghost" (click)="downloadAttachment(attachment)">Scarica file</button>
<button
type="button"
class="ghost"
(click)="downloadAttachment(attachment)"
>
Scarica file
</button>
</article>
</div>
</div>

View File

@@ -62,7 +62,9 @@ button {
padding: var(--space-2) var(--space-4);
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease, opacity 0.2s ease;
transition:
background-color 0.2s ease,
opacity 0.2s ease;
line-height: 1.2;
}

View File

@@ -5,7 +5,7 @@ import {
AdminContactRequest,
AdminContactRequestAttachment,
AdminContactRequestDetail,
AdminOperationsService
AdminOperationsService,
} from '../services/admin-operations.service';
@Component({
@@ -13,7 +13,7 @@ import {
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './admin-contact-requests.component.html',
styleUrl: './admin-contact-requests.component.scss'
styleUrl: './admin-contact-requests.component.scss',
})
export class AdminContactRequestsComponent implements OnInit {
private readonly adminOperationsService = inject(AdminOperationsService);
@@ -43,7 +43,10 @@ export class AdminContactRequestsComponent implements OnInit {
if (requests.length === 0) {
this.selectedRequest = null;
this.selectedRequestId = null;
} else if (this.selectedRequestId && requests.some(r => r.id === this.selectedRequestId)) {
} else if (
this.selectedRequestId &&
requests.some((r) => r.id === this.selectedRequestId)
) {
this.openDetails(this.selectedRequestId);
} else {
this.openDetails(requests[0].id);
@@ -53,7 +56,7 @@ export class AdminContactRequestsComponent implements OnInit {
error: () => {
this.loading = false;
this.errorMessage = 'Impossibile caricare le richieste di contatto.';
}
},
});
}
@@ -70,7 +73,7 @@ export class AdminContactRequestsComponent implements OnInit {
error: () => {
this.detailLoading = false;
this.errorMessage = 'Impossibile caricare il dettaglio richiesta.';
}
},
});
}
@@ -83,12 +86,18 @@ export class AdminContactRequestsComponent implements OnInit {
return;
}
this.adminOperationsService.downloadContactRequestAttachment(this.selectedRequest.id, attachment.id).subscribe({
next: (blob) => this.downloadBlob(blob, attachment.originalFilename || `attachment-${attachment.id}`),
error: () => {
this.errorMessage = 'Download allegato non riuscito.';
}
});
this.adminOperationsService
.downloadContactRequestAttachment(this.selectedRequest.id, attachment.id)
.subscribe({
next: (blob) =>
this.downloadBlob(
blob,
attachment.originalFilename || `attachment-${attachment.id}`,
),
error: () => {
this.errorMessage = 'Download allegato non riuscito.';
},
});
}
formatFileSize(bytes?: number): string {
@@ -120,7 +129,12 @@ export class AdminContactRequestsComponent implements OnInit {
}
updateRequestStatus(): void {
if (!this.selectedRequest || !this.selectedRequestId || !this.selectedStatus || this.updatingStatus) {
if (
!this.selectedRequest ||
!this.selectedRequestId ||
!this.selectedStatus ||
this.updatingStatus
) {
return;
}
@@ -128,26 +142,31 @@ export class AdminContactRequestsComponent implements OnInit {
this.successMessage = null;
this.updatingStatus = true;
this.adminOperationsService.updateContactRequestStatus(this.selectedRequestId, { status: this.selectedStatus }).subscribe({
next: (updated) => {
this.selectedRequest = updated;
this.selectedStatus = updated.status || this.selectedStatus;
this.requests = this.requests.map(request =>
request.id === updated.id
? {
...request,
status: updated.status
}
: request
);
this.updatingStatus = false;
this.successMessage = 'Stato richiesta aggiornato.';
},
error: () => {
this.updatingStatus = false;
this.errorMessage = 'Impossibile aggiornare lo stato della richiesta.';
}
});
this.adminOperationsService
.updateContactRequestStatus(this.selectedRequestId, {
status: this.selectedStatus,
})
.subscribe({
next: (updated) => {
this.selectedRequest = updated;
this.selectedStatus = updated.status || this.selectedStatus;
this.requests = this.requests.map((request) =>
request.id === updated.id
? {
...request,
status: updated.status,
}
: request,
);
this.updatingStatus = false;
this.successMessage = 'Stato richiesta aggiornato.';
},
error: () => {
this.updatingStatus = false;
this.errorMessage =
'Impossibile aggiornare lo stato della richiesta.';
},
});
}
private downloadBlob(blob: Blob, filename: string): void {

View File

@@ -5,7 +5,9 @@
<p>Seleziona un ordine a sinistra e gestiscilo nel dettaglio a destra.</p>
</div>
<div class="header-actions">
<button type="button" (click)="loadOrders()" [disabled]="loading">Aggiorna</button>
<button type="button" (click)="loadOrders()" [disabled]="loading">
Aggiorna
</button>
</div>
</header>
@@ -32,7 +34,12 @@
[ngModel]="paymentStatusFilter"
(ngModelChange)="onPaymentStatusFilterChange($event)"
>
<option *ngFor="let option of paymentStatusFilterOptions" [ngValue]="option">{{ option }}</option>
<option
*ngFor="let option of paymentStatusFilterOptions"
[ngValue]="option"
>
{{ option }}
</option>
</select>
</label>
<label class="toolbar-field" for="order-status-filter">
@@ -42,7 +49,12 @@
[ngModel]="orderStatusFilter"
(ngModelChange)="onOrderStatusFilterChange($event)"
>
<option *ngFor="let option of orderStatusFilterOptions" [ngValue]="option">{{ option }}</option>
<option
*ngFor="let option of orderStatusFilterOptions"
[ngValue]="option"
>
{{ option }}
</option>
</select>
</label>
</div>
@@ -65,12 +77,16 @@
>
<td>{{ order.orderNumber }}</td>
<td>{{ order.customerEmail }}</td>
<td>{{ order.paymentStatus || 'PENDING' }}</td>
<td>{{ order.paymentStatus || "PENDING" }}</td>
<td>{{ order.status }}</td>
<td>{{ order.totalChf | currency:'CHF':'symbol':'1.2-2' }}</td>
<td>
{{ order.totalChf | currency: "CHF" : "symbol" : "1.2-2" }}
</td>
</tr>
<tr class="no-results" *ngIf="filteredOrders.length === 0">
<td colspan="5">Nessun ordine trovato per i filtri selezionati.</td>
<td colspan="5">
Nessun ordine trovato per i filtri selezionati.
</td>
</tr>
</tbody>
</table>
@@ -80,39 +96,74 @@
<section class="detail-panel" *ngIf="selectedOrder">
<div class="detail-header">
<h2>Dettaglio ordine {{ selectedOrder.orderNumber }}</h2>
<p class="order-uuid">UUID: <code>{{ selectedOrder.id }}</code></p>
<p class="order-uuid">
UUID: <code>{{ selectedOrder.id }}</code>
</p>
<p *ngIf="detailLoading">Caricamento dettaglio...</p>
</div>
<div class="meta-grid">
<div><strong>Cliente</strong><span>{{ selectedOrder.customerEmail }}</span></div>
<div><strong>Stato pagamento</strong><span>{{ selectedOrder.paymentStatus || 'PENDING' }}</span></div>
<div><strong>Stato ordine</strong><span>{{ selectedOrder.status }}</span></div>
<div><strong>Totale</strong><span>{{ selectedOrder.totalChf | currency:'CHF':'symbol':'1.2-2' }}</span></div>
<div>
<strong>Cliente</strong><span>{{ selectedOrder.customerEmail }}</span>
</div>
<div>
<strong>Stato pagamento</strong
><span>{{ selectedOrder.paymentStatus || "PENDING" }}</span>
</div>
<div>
<strong>Stato ordine</strong><span>{{ selectedOrder.status }}</span>
</div>
<div>
<strong>Totale</strong
><span>{{
selectedOrder.totalChf | currency: "CHF" : "symbol" : "1.2-2"
}}</span>
</div>
</div>
<div class="actions-block">
<div class="status-editor">
<label for="order-status">Stato ordine</label>
<select id="order-status" [value]="selectedStatus" (change)="onStatusChange($event)">
<option *ngFor="let option of orderStatusOptions" [value]="option">{{ option }}</option>
<select
id="order-status"
[value]="selectedStatus"
(change)="onStatusChange($event)"
>
<option *ngFor="let option of orderStatusOptions" [value]="option">
{{ option }}
</option>
</select>
<button type="button" (click)="updateStatus()" [disabled]="updatingStatus">
{{ updatingStatus ? 'Salvataggio...' : 'Aggiorna stato' }}
<button
type="button"
(click)="updateStatus()"
[disabled]="updatingStatus"
>
{{ updatingStatus ? "Salvataggio..." : "Aggiorna stato" }}
</button>
</div>
<div class="status-editor">
<label for="payment-method">Metodo pagamento</label>
<select id="payment-method" [value]="selectedPaymentMethod" (change)="onPaymentMethodChange($event)">
<option *ngFor="let option of paymentMethodOptions" [value]="option">{{ option }}</option>
<select
id="payment-method"
[value]="selectedPaymentMethod"
(change)="onPaymentMethodChange($event)"
>
<option
*ngFor="let option of paymentMethodOptions"
[value]="option"
>
{{ option }}
</option>
</select>
<button
type="button"
(click)="confirmPayment()"
[disabled]="confirmingPayment || selectedOrder.paymentStatus === 'COMPLETED'"
[disabled]="
confirmingPayment || selectedOrder.paymentStatus === 'COMPLETED'
"
>
{{ confirmingPayment ? 'Invio...' : 'Conferma pagamento' }}
{{ confirmingPayment ? "Invio..." : "Conferma pagamento" }}
</button>
</div>
</div>
@@ -132,17 +183,26 @@
<div class="items">
<div class="item" *ngFor="let item of selectedOrder.items">
<div class="item-main">
<p class="file-name"><strong>{{ item.originalFilename }}</strong></p>
<p class="file-name">
<strong>{{ item.originalFilename }}</strong>
</p>
<p class="item-meta">
Qta: {{ item.quantity }} |
Colore:
<span class="color-swatch" *ngIf="isHexColor(item.colorCode)" [style.background-color]="item.colorCode"></span>
<span>{{ item.colorCode || '-' }}</span>
|
Riga: {{ item.lineTotalChf | currency:'CHF':'symbol':'1.2-2' }}
Qta: {{ item.quantity }} | Colore:
<span
class="color-swatch"
*ngIf="isHexColor(item.colorCode)"
[style.background-color]="item.colorCode"
></span>
<span>{{ item.colorCode || "-" }}</span>
| Riga:
{{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }}
</p>
</div>
<button type="button" class="ghost" (click)="downloadItemFile(item.id, item.originalFilename)">
<button
type="button"
class="ghost"
(click)="downloadItemFile(item.id, item.originalFilename)"
>
Scarica file
</button>
</div>
@@ -160,21 +220,52 @@
<p>Caricamento ordini...</p>
</ng-template>
<div class="modal-backdrop" *ngIf="showPrintDetails && selectedOrder" (click)="closePrintDetails()">
<div
class="modal-backdrop"
*ngIf="showPrintDetails && selectedOrder"
(click)="closePrintDetails()"
>
<div class="modal-card" (click)="$event.stopPropagation()">
<header class="modal-header">
<h3>Dettagli stampa ordine {{ selectedOrder.orderNumber }}</h3>
<button type="button" class="ghost close-btn" (click)="closePrintDetails()">Chiudi</button>
<button
type="button"
class="ghost close-btn"
(click)="closePrintDetails()"
>
Chiudi
</button>
</header>
<div class="modal-grid">
<div><strong>Qualità</strong><span>{{ getQualityLabel(selectedOrder.printLayerHeightMm) }}</span></div>
<div><strong>Materiale</strong><span>{{ selectedOrder.printMaterialCode || '-' }}</span></div>
<div><strong>Layer height</strong><span>{{ selectedOrder.printLayerHeightMm || '-' }} mm</span></div>
<div><strong>Nozzle</strong><span>{{ selectedOrder.printNozzleDiameterMm || '-' }} mm</span></div>
<div><strong>Infill pattern</strong><span>{{ selectedOrder.printInfillPattern || '-' }}</span></div>
<div><strong>Infill %</strong><span>{{ selectedOrder.printInfillPercent ?? '-' }}</span></div>
<div><strong>Supporti</strong><span>{{ selectedOrder.printSupportsEnabled ? 'Sì' : 'No' }}</span></div>
<div>
<strong>Qualità</strong
><span>{{ getQualityLabel(selectedOrder.printLayerHeightMm) }}</span>
</div>
<div>
<strong>Materiale</strong
><span>{{ selectedOrder.printMaterialCode || "-" }}</span>
</div>
<div>
<strong>Layer height</strong
><span>{{ selectedOrder.printLayerHeightMm || "-" }} mm</span>
</div>
<div>
<strong>Nozzle</strong
><span>{{ selectedOrder.printNozzleDiameterMm || "-" }} mm</span>
</div>
<div>
<strong>Infill pattern</strong
><span>{{ selectedOrder.printInfillPattern || "-" }}</span>
</div>
<div>
<strong>Infill %</strong
><span>{{ selectedOrder.printInfillPercent ?? "-" }}</span>
</div>
<div>
<strong>Supporti</strong
><span>{{ selectedOrder.printSupportsEnabled ? "Sì" : "No" }}</span>
</div>
</div>
<h4>Colori file</h4>
@@ -182,8 +273,12 @@
<div class="file-color-row" *ngFor="let item of selectedOrder.items">
<span class="filename">{{ item.originalFilename }}</span>
<span class="file-color">
<span class="color-swatch" *ngIf="isHexColor(item.colorCode)" [style.background-color]="item.colorCode"></span>
{{ item.colorCode || '-' }}
<span
class="color-swatch"
*ngIf="isHexColor(item.colorCode)"
[style.background-color]="item.colorCode"
></span>
{{ item.colorCode || "-" }}
</span>
</div>
</div>

View File

@@ -75,7 +75,10 @@ button:disabled {
.list-toolbar {
display: grid;
grid-template-columns: minmax(230px, 1.6fr) minmax(170px, 1fr) minmax(190px, 1fr);
grid-template-columns: minmax(230px, 1.6fr) minmax(170px, 1fr) minmax(
190px,
1fr
);
gap: var(--space-2);
margin-bottom: var(--space-3);
}

View File

@@ -1,14 +1,17 @@
import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AdminOrder, AdminOrdersService } from '../services/admin-orders.service';
import {
AdminOrder,
AdminOrdersService,
} from '../services/admin-orders.service';
@Component({
selector: 'app-admin-dashboard',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './admin-dashboard.component.html',
styleUrl: './admin-dashboard.component.scss'
styleUrl: './admin-dashboard.component.scss',
})
export class AdminDashboardComponent implements OnInit {
private readonly adminOrdersService = inject(AdminOrdersService);
@@ -33,10 +36,21 @@ export class AdminDashboardComponent implements OnInit {
'IN_PRODUCTION',
'SHIPPED',
'COMPLETED',
'CANCELLED'
'CANCELLED',
];
readonly paymentMethodOptions = [
'TWINT',
'BANK_TRANSFER',
'CARD',
'CASH',
'OTHER',
];
readonly paymentStatusFilterOptions = [
'ALL',
'PENDING',
'REPORTED',
'COMPLETED',
];
readonly paymentMethodOptions = ['TWINT', 'BANK_TRANSFER', 'CARD', 'CASH', 'OTHER'];
readonly paymentStatusFilterOptions = ['ALL', 'PENDING', 'REPORTED', 'COMPLETED'];
readonly orderStatusFilterOptions = [
'ALL',
'PENDING_PAYMENT',
@@ -44,7 +58,7 @@ export class AdminDashboardComponent implements OnInit {
'IN_PRODUCTION',
'SHIPPED',
'COMPLETED',
'CANCELLED'
'CANCELLED',
];
ngOnInit(): void {
@@ -62,8 +76,12 @@ export class AdminDashboardComponent implements OnInit {
if (!this.selectedOrder && this.filteredOrders.length > 0) {
this.openDetails(this.filteredOrders[0].id);
} else if (this.selectedOrder) {
const exists = orders.find(order => order.id === this.selectedOrder?.id);
const selectedIsVisible = this.filteredOrders.some(order => order.id === this.selectedOrder?.id);
const exists = orders.find(
(order) => order.id === this.selectedOrder?.id,
);
const selectedIsVisible = this.filteredOrders.some(
(order) => order.id === this.selectedOrder?.id,
);
if (exists && selectedIsVisible) {
this.openDetails(exists.id);
} else if (this.filteredOrders.length > 0) {
@@ -78,7 +96,7 @@ export class AdminDashboardComponent implements OnInit {
error: () => {
this.loading = false;
this.errorMessage = 'Impossibile caricare gli ordini.';
}
},
});
}
@@ -109,7 +127,7 @@ export class AdminDashboardComponent implements OnInit {
error: () => {
this.detailLoading = false;
this.errorMessage = 'Impossibile caricare il dettaglio ordine.';
}
},
});
}
@@ -119,36 +137,44 @@ export class AdminDashboardComponent implements OnInit {
}
this.confirmingPayment = true;
this.adminOrdersService.confirmPayment(this.selectedOrder.id, this.selectedPaymentMethod).subscribe({
next: (updatedOrder) => {
this.confirmingPayment = false;
this.applyOrderUpdate(updatedOrder);
},
error: () => {
this.confirmingPayment = false;
this.errorMessage = 'Conferma pagamento non riuscita.';
}
});
this.adminOrdersService
.confirmPayment(this.selectedOrder.id, this.selectedPaymentMethod)
.subscribe({
next: (updatedOrder) => {
this.confirmingPayment = false;
this.applyOrderUpdate(updatedOrder);
},
error: () => {
this.confirmingPayment = false;
this.errorMessage = 'Conferma pagamento non riuscita.';
},
});
}
updateStatus(): void {
if (!this.selectedOrder || this.updatingStatus || !this.selectedStatus.trim()) {
if (
!this.selectedOrder ||
this.updatingStatus ||
!this.selectedStatus.trim()
) {
return;
}
this.updatingStatus = true;
this.adminOrdersService.updateOrderStatus(this.selectedOrder.id, {
status: this.selectedStatus.trim()
}).subscribe({
next: (updatedOrder) => {
this.updatingStatus = false;
this.applyOrderUpdate(updatedOrder);
},
error: () => {
this.updatingStatus = false;
this.errorMessage = 'Aggiornamento stato ordine non riuscito.';
}
});
this.adminOrdersService
.updateOrderStatus(this.selectedOrder.id, {
status: this.selectedStatus.trim(),
})
.subscribe({
next: (updatedOrder) => {
this.updatingStatus = false;
this.applyOrderUpdate(updatedOrder);
},
error: () => {
this.updatingStatus = false;
this.errorMessage = 'Aggiornamento stato ordine non riuscito.';
},
});
}
downloadItemFile(itemId: string, filename: string): void {
@@ -156,14 +182,16 @@ export class AdminDashboardComponent implements OnInit {
return;
}
this.adminOrdersService.downloadOrderItemFile(this.selectedOrder.id, itemId).subscribe({
next: (blob) => {
this.downloadBlob(blob, filename || `order-item-${itemId}`);
},
error: () => {
this.errorMessage = 'Download file non riuscito.';
}
});
this.adminOrdersService
.downloadOrderItemFile(this.selectedOrder.id, itemId)
.subscribe({
next: (blob) => {
this.downloadBlob(blob, filename || `order-item-${itemId}`);
},
error: () => {
this.errorMessage = 'Download file non riuscito.';
},
});
}
downloadConfirmation(): void {
@@ -171,14 +199,19 @@ export class AdminDashboardComponent implements OnInit {
return;
}
this.adminOrdersService.downloadOrderConfirmation(this.selectedOrder.id).subscribe({
next: (blob) => {
this.downloadBlob(blob, `conferma-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`);
},
error: () => {
this.errorMessage = 'Download conferma ordine non riuscito.';
}
});
this.adminOrdersService
.downloadOrderConfirmation(this.selectedOrder.id)
.subscribe({
next: (blob) => {
this.downloadBlob(
blob,
`conferma-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`,
);
},
error: () => {
this.errorMessage = 'Download conferma ordine non riuscito.';
},
});
}
downloadInvoice(): void {
@@ -186,14 +219,19 @@ export class AdminDashboardComponent implements OnInit {
return;
}
this.adminOrdersService.downloadOrderInvoice(this.selectedOrder.id).subscribe({
next: (blob) => {
this.downloadBlob(blob, `fattura-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`);
},
error: () => {
this.errorMessage = 'Download fattura non riuscito.';
}
});
this.adminOrdersService
.downloadOrderInvoice(this.selectedOrder.id)
.subscribe({
next: (blob) => {
this.downloadBlob(
blob,
`fattura-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`,
);
},
error: () => {
this.errorMessage = 'Download fattura non riuscito.';
},
});
}
onStatusChange(event: Event): void {
@@ -228,7 +266,10 @@ export class AdminDashboardComponent implements OnInit {
}
isHexColor(value?: string): boolean {
return typeof value === 'string' && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value);
return (
typeof value === 'string' &&
/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value)
);
}
isSelected(orderId: string): boolean {
@@ -236,11 +277,14 @@ export class AdminDashboardComponent implements OnInit {
}
private applyOrderUpdate(updatedOrder: AdminOrder): void {
this.orders = this.orders.map((order) => order.id === updatedOrder.id ? updatedOrder : order);
this.orders = this.orders.map((order) =>
order.id === updatedOrder.id ? updatedOrder : order,
);
this.applyListFiltersAndSelection();
this.selectedOrder = updatedOrder;
this.selectedStatus = updatedOrder.status;
this.selectedPaymentMethod = updatedOrder.paymentMethod || this.selectedPaymentMethod;
this.selectedPaymentMethod =
updatedOrder.paymentMethod || this.selectedPaymentMethod;
}
private applyListFiltersAndSelection(): void {
@@ -252,7 +296,10 @@ export class AdminDashboardComponent implements OnInit {
return;
}
if (!this.selectedOrder || !this.filteredOrders.some(order => order.id === this.selectedOrder?.id)) {
if (
!this.selectedOrder ||
!this.filteredOrders.some((order) => order.id === this.selectedOrder?.id)
) {
this.openDetails(this.filteredOrders[0].id);
}
}
@@ -265,9 +312,14 @@ export class AdminDashboardComponent implements OnInit {
const paymentStatus = (order.paymentStatus || 'PENDING').toUpperCase();
const orderStatus = (order.status || '').toUpperCase();
const matchesSearch = !term || fullUuid.includes(term) || shortUuid.includes(term);
const matchesPayment = this.paymentStatusFilter === 'ALL' || paymentStatus === this.paymentStatusFilter;
const matchesOrderStatus = this.orderStatusFilter === 'ALL' || orderStatus === this.orderStatusFilter;
const matchesSearch =
!term || fullUuid.includes(term) || shortUuid.includes(term);
const matchesPayment =
this.paymentStatusFilter === 'ALL' ||
paymentStatus === this.paymentStatusFilter;
const matchesOrderStatus =
this.orderStatusFilter === 'ALL' ||
orderStatus === this.orderStatusFilter;
return matchesSearch && matchesPayment && matchesOrderStatus;
});

View File

@@ -4,7 +4,9 @@
<h2>Stock filamenti</h2>
<p>Gestione materiali, varianti e stock per il calcolatore.</p>
</div>
<button type="button" (click)="loadData()" [disabled]="loading">Aggiorna</button>
<button type="button" (click)="loadData()" [disabled]="loading">
Aggiorna
</button>
</header>
<div class="alerts">
@@ -16,8 +18,12 @@
<section class="panel">
<div class="panel-header">
<h3>Inserimento rapido</h3>
<button type="button" class="panel-toggle" (click)="toggleQuickInsertCollapsed()">
{{ quickInsertCollapsed ? 'Espandi' : 'Collassa' }}
<button
type="button"
class="panel-toggle"
(click)="toggleQuickInsertCollapsed()"
>
{{ quickInsertCollapsed ? "Espandi" : "Collassa" }}
</button>
</div>
@@ -28,7 +34,11 @@
<div class="form-grid">
<label class="form-field form-field--wide">
<span>Codice materiale</span>
<input type="text" [(ngModel)]="newMaterial.materialCode" placeholder="PLA, PETG, TPU..." />
<input
type="text"
[(ngModel)]="newMaterial.materialCode"
placeholder="PLA, PETG, TPU..."
/>
</label>
<label class="form-field form-field--wide">
<span>Etichetta tecnico</span>
@@ -52,8 +62,12 @@
</label>
</div>
<button type="button" (click)="createMaterial()" [disabled]="creatingMaterial">
{{ creatingMaterial ? 'Salvataggio...' : 'Aggiungi materiale' }}
<button
type="button"
(click)="createMaterial()"
[disabled]="creatingMaterial"
>
{{ creatingMaterial ? "Salvataggio..." : "Aggiungi materiale" }}
</button>
</section>
@@ -63,22 +77,37 @@
<label class="form-field">
<span>Materiale</span>
<select [(ngModel)]="newVariant.materialTypeId">
<option *ngFor="let material of materials; trackBy: trackById" [ngValue]="material.id">
<option
*ngFor="let material of materials; trackBy: trackById"
[ngValue]="material.id"
>
{{ material.materialCode }}
</option>
</select>
</label>
<label class="form-field">
<span>Nome variante</span>
<input type="text" [(ngModel)]="newVariant.variantDisplayName" placeholder="PLA Nero Opaco BrandX" />
<input
type="text"
[(ngModel)]="newVariant.variantDisplayName"
placeholder="PLA Nero Opaco BrandX"
/>
</label>
<label class="form-field">
<span>Colore</span>
<input type="text" [(ngModel)]="newVariant.colorName" placeholder="Nero, Bianco..." />
<input
type="text"
[(ngModel)]="newVariant.colorName"
placeholder="Nero, Bianco..."
/>
</label>
<label class="form-field">
<span>Hex colore</span>
<input type="text" [(ngModel)]="newVariant.colorHex" placeholder="#1A1A1A" />
<input
type="text"
[(ngModel)]="newVariant.colorHex"
placeholder="#1A1A1A"
/>
</label>
<label class="form-field">
<span>Finitura</span>
@@ -93,19 +122,40 @@
</label>
<label class="form-field">
<span>Brand</span>
<input type="text" [(ngModel)]="newVariant.brand" placeholder="Bambu, SUNLU..." />
<input
type="text"
[(ngModel)]="newVariant.brand"
placeholder="Bambu, SUNLU..."
/>
</label>
<label class="form-field">
<span>Costo CHF/kg</span>
<input type="number" step="0.01" min="0" [(ngModel)]="newVariant.costChfPerKg" />
<input
type="number"
step="0.01"
min="0"
[(ngModel)]="newVariant.costChfPerKg"
/>
</label>
<label class="form-field">
<span>Stock spool</span>
<input type="number" step="0.001" min="0" max="999.999" [(ngModel)]="newVariant.stockSpools" />
<input
type="number"
step="0.001"
min="0"
max="999.999"
[(ngModel)]="newVariant.stockSpools"
/>
</label>
<label class="form-field">
<span>Spool netto kg</span>
<input type="number" step="0.001" min="0.001" max="999.999" [(ngModel)]="newVariant.spoolNetKg" />
<input
type="number"
step="0.001"
min="0.001"
max="999.999"
[(ngModel)]="newVariant.spoolNetKg"
/>
</label>
</div>
@@ -125,12 +175,26 @@
</div>
<p class="variant-meta">
Stock spools: <strong>{{ newVariant.stockSpools | number:'1.0-3' }}</strong> |
Filamento totale: <strong>{{ computeStockFilamentGrams(newVariant.stockSpools, newVariant.spoolNetKg) | number:'1.0-0' }} g</strong>
Stock spools:
<strong>{{ newVariant.stockSpools | number: "1.0-3" }}</strong> |
Filamento totale:
<strong
>{{
computeStockFilamentGrams(
newVariant.stockSpools,
newVariant.spoolNetKg
) | number: "1.0-0"
}}
g</strong
>
</p>
<button type="button" (click)="createVariant()" [disabled]="creatingVariant || !materials.length">
{{ creatingVariant ? 'Salvataggio...' : 'Aggiungi variante' }}
<button
type="button"
(click)="createVariant()"
[disabled]="creatingVariant || !materials.length"
>
{{ creatingVariant ? "Salvataggio..." : "Aggiungi variante" }}
</button>
</section>
</div>
@@ -140,37 +204,68 @@
<section class="panel">
<h3>Varianti filamento</h3>
<div class="variant-list">
<article class="variant-row" *ngFor="let variant of variants; trackBy: trackById">
<article
class="variant-row"
*ngFor="let variant of variants; trackBy: trackById"
>
<div class="variant-header">
<button
type="button"
class="expand-toggle"
(click)="toggleVariantExpanded(variant.id)"
[attr.aria-expanded]="isVariantExpanded(variant.id)">
{{ isVariantExpanded(variant.id) ? '▾' : '▸' }}
[attr.aria-expanded]="isVariantExpanded(variant.id)"
>
{{ isVariantExpanded(variant.id) ? "▾" : "▸" }}
</button>
<div class="variant-head-main">
<strong>{{ variant.variantDisplayName }}</strong>
<div class="variant-collapsed-summary" *ngIf="!isVariantExpanded(variant.id)">
<div
class="variant-collapsed-summary"
*ngIf="!isVariantExpanded(variant.id)"
>
<span class="color-summary">
<span class="color-dot" [style.background-color]="getVariantColorHex(variant)"></span>
{{ variant.colorName || 'N/D' }}
<span
class="color-dot"
[style.background-color]="getVariantColorHex(variant)"
></span>
{{ variant.colorName || "N/D" }}
</span>
<span>Stock spools: {{ variant.stockSpools | number:'1.0-3' }}</span>
<span>Filamento: {{ computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) | number:'1.0-0' }} g</span>
<span
>Stock spools:
{{ variant.stockSpools | number: "1.0-3" }}</span
>
<span
>Filamento:
{{
computeStockFilamentGrams(
variant.stockSpools,
variant.spoolNetKg
) | number: "1.0-0"
}}
g</span
>
</div>
</div>
<div class="variant-head-actions">
<span class="badge low" *ngIf="isLowStock(variant)">Stock basso</span>
<span class="badge ok" *ngIf="!isLowStock(variant)">Stock ok</span>
<span class="badge low" *ngIf="isLowStock(variant)"
>Stock basso</span
>
<span class="badge ok" *ngIf="!isLowStock(variant)"
>Stock ok</span
>
<button
type="button"
class="btn-delete"
(click)="openDeleteVariant(variant)"
[disabled]="deletingVariantIds.has(variant.id)">
{{ deletingVariantIds.has(variant.id) ? 'Eliminazione...' : 'Elimina' }}
[disabled]="deletingVariantIds.has(variant.id)"
>
{{
deletingVariantIds.has(variant.id)
? "Eliminazione..."
: "Elimina"
}}
</button>
</div>
</div>
@@ -179,7 +274,10 @@
<label class="form-field">
<span>Materiale</span>
<select [(ngModel)]="variant.materialTypeId">
<option *ngFor="let material of materials; trackBy: trackById" [ngValue]="material.id">
<option
*ngFor="let material of materials; trackBy: trackById"
[ngValue]="material.id"
>
{{ material.materialCode }}
</option>
</select>
@@ -213,15 +311,32 @@
</label>
<label class="form-field">
<span>Costo CHF/kg</span>
<input type="number" step="0.01" min="0" [(ngModel)]="variant.costChfPerKg" />
<input
type="number"
step="0.01"
min="0"
[(ngModel)]="variant.costChfPerKg"
/>
</label>
<label class="form-field">
<span>Stock spool</span>
<input type="number" step="0.001" min="0" max="999.999" [(ngModel)]="variant.stockSpools" />
<input
type="number"
step="0.001"
min="0"
max="999.999"
[(ngModel)]="variant.stockSpools"
/>
</label>
<label class="form-field">
<span>Spool netto kg</span>
<input type="number" step="0.001" min="0.001" max="999.999" [(ngModel)]="variant.spoolNetKg" />
<input
type="number"
step="0.001"
min="0.001"
max="999.999"
[(ngModel)]="variant.spoolNetKg"
/>
</label>
</div>
@@ -241,33 +356,57 @@
</div>
<p class="variant-meta" *ngIf="isVariantExpanded(variant.id)">
Stock spools: <strong>{{ variant.stockSpools | number:'1.0-3' }}</strong> |
Filamento totale: <strong>{{ computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) | number:'1.0-0' }} g</strong>
Stock spools:
<strong>{{ variant.stockSpools | number: "1.0-3" }}</strong> |
Filamento totale:
<strong
>{{
computeStockFilamentGrams(
variant.stockSpools,
variant.spoolNetKg
) | number: "1.0-0"
}}
g</strong
>
</p>
<button
type="button"
*ngIf="isVariantExpanded(variant.id)"
(click)="saveVariant(variant)"
[disabled]="savingVariantIds.has(variant.id)">
{{ savingVariantIds.has(variant.id) ? 'Salvataggio...' : 'Salva variante' }}
[disabled]="savingVariantIds.has(variant.id)"
>
{{
savingVariantIds.has(variant.id)
? "Salvataggio..."
: "Salva variante"
}}
</button>
</article>
</div>
<p class="muted" *ngIf="variants.length === 0">Nessuna variante configurata.</p>
<p class="muted" *ngIf="variants.length === 0">
Nessuna variante configurata.
</p>
</section>
<section class="panel">
<div class="panel-header">
<h3>Materiali</h3>
<button type="button" class="panel-toggle" (click)="toggleMaterialsCollapsed()">
{{ materialsCollapsed ? 'Espandi' : 'Collassa' }}
<button
type="button"
class="panel-toggle"
(click)="toggleMaterialsCollapsed()"
>
{{ materialsCollapsed ? "Espandi" : "Collassa" }}
</button>
</div>
<div *ngIf="!materialsCollapsed; else materialsCollapsedTpl">
<div class="material-grid">
<article class="material-card" *ngFor="let material of materials; trackBy: trackById">
<article
class="material-card"
*ngFor="let material of materials; trackBy: trackById"
>
<div class="form-grid">
<label class="form-field form-field--wide">
<span>Codice</span>
@@ -275,7 +414,11 @@
</label>
<label class="form-field form-field--wide">
<span>Etichetta tecnico</span>
<input type="text" [(ngModel)]="material.technicalTypeLabel" [disabled]="!material.isTechnical" />
<input
type="text"
[(ngModel)]="material.technicalTypeLabel"
[disabled]="!material.isTechnical"
/>
</label>
</div>
@@ -290,12 +433,22 @@
</label>
</div>
<button type="button" (click)="saveMaterial(material)" [disabled]="savingMaterialIds.has(material.id)">
{{ savingMaterialIds.has(material.id) ? 'Salvataggio...' : 'Salva materiale' }}
<button
type="button"
(click)="saveMaterial(material)"
[disabled]="savingMaterialIds.has(material.id)"
>
{{
savingMaterialIds.has(material.id)
? "Salvataggio..."
: "Salva materiale"
}}
</button>
</article>
</div>
<p class="muted" *ngIf="materials.length === 0">Nessun materiale configurato.</p>
<p class="muted" *ngIf="materials.length === 0">
Nessun materiale configurato.
</p>
</div>
</section>
</div>
@@ -313,19 +466,38 @@
<p class="muted">Sezione collassata.</p>
</ng-template>
<div class="dialog-backdrop" *ngIf="variantToDelete" (click)="closeDeleteVariantDialog()"></div>
<div
class="dialog-backdrop"
*ngIf="variantToDelete"
(click)="closeDeleteVariantDialog()"
></div>
<div class="confirm-dialog" *ngIf="variantToDelete">
<h4>Sei sicuro?</h4>
<p>Vuoi eliminare la variante <strong>{{ variantToDelete.variantDisplayName }}</strong>?</p>
<p>
Vuoi eliminare la variante
<strong>{{ variantToDelete.variantDisplayName }}</strong
>?
</p>
<p class="muted">L'operazione non è reversibile.</p>
<div class="dialog-actions">
<button type="button" class="btn-secondary" (click)="closeDeleteVariantDialog()">Annulla</button>
<button
type="button"
class="btn-secondary"
(click)="closeDeleteVariantDialog()"
>
Annulla
</button>
<button
type="button"
class="btn-delete"
(click)="confirmDeleteVariant()"
[disabled]="variantToDelete && deletingVariantIds.has(variantToDelete.id)">
{{ variantToDelete && deletingVariantIds.has(variantToDelete.id) ? 'Eliminazione...' : 'Conferma elimina' }}
[disabled]="variantToDelete && deletingVariantIds.has(variantToDelete.id)"
>
{{
variantToDelete && deletingVariantIds.has(variantToDelete.id)
? "Eliminazione..."
: "Conferma elimina"
}}
</button>
</div>
</div>

View File

@@ -6,7 +6,7 @@ import {
AdminFilamentVariant,
AdminOperationsService,
AdminUpsertFilamentMaterialTypePayload,
AdminUpsertFilamentVariantPayload
AdminUpsertFilamentVariantPayload,
} from '../services/admin-operations.service';
import { forkJoin } from 'rxjs';
import { getColorHex } from '../../../core/constants/colors.const';
@@ -16,7 +16,7 @@ import { getColorHex } from '../../../core/constants/colors.const';
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './admin-filament-stock.component.html',
styleUrl: './admin-filament-stock.component.scss'
styleUrl: './admin-filament-stock.component.scss',
})
export class AdminFilamentStockComponent implements OnInit {
private readonly adminOperationsService = inject(AdminOperationsService);
@@ -40,7 +40,7 @@ export class AdminFilamentStockComponent implements OnInit {
materialCode: '',
isFlexible: false,
isTechnical: false,
technicalTypeLabel: ''
technicalTypeLabel: '',
};
newVariant: AdminUpsertFilamentVariantPayload = {
@@ -55,7 +55,7 @@ export class AdminFilamentStockComponent implements OnInit {
costChfPerKg: 0,
stockSpools: 0,
spoolNetKg: 1,
isActive: true
isActive: true,
};
ngOnInit(): void {
@@ -69,13 +69,13 @@ export class AdminFilamentStockComponent implements OnInit {
forkJoin({
materials: this.adminOperationsService.getFilamentMaterials(),
variants: this.adminOperationsService.getFilamentVariants()
variants: this.adminOperationsService.getFilamentVariants(),
}).subscribe({
next: ({ materials, variants }) => {
this.materials = this.sortMaterials(materials);
this.variants = this.sortVariants(variants);
const existingIds = new Set(this.variants.map(v => v.id));
this.expandedVariantIds.forEach(id => {
const existingIds = new Set(this.variants.map((v) => v.id));
this.expandedVariantIds.forEach((id) => {
if (!existingIds.has(id)) {
this.expandedVariantIds.delete(id);
}
@@ -87,8 +87,11 @@ export class AdminFilamentStockComponent implements OnInit {
},
error: (err) => {
this.loading = false;
this.errorMessage = this.extractErrorMessage(err, 'Impossibile caricare i filamenti.');
}
this.errorMessage = this.extractErrorMessage(
err,
'Impossibile caricare i filamenti.',
);
},
});
}
@@ -107,7 +110,7 @@ export class AdminFilamentStockComponent implements OnInit {
isTechnical: !!this.newMaterial.isTechnical,
technicalTypeLabel: this.newMaterial.isTechnical
? (this.newMaterial.technicalTypeLabel || '').trim()
: ''
: '',
};
this.adminOperationsService.createFilamentMaterial(payload).subscribe({
@@ -120,15 +123,18 @@ export class AdminFilamentStockComponent implements OnInit {
materialCode: '',
isFlexible: false,
isTechnical: false,
technicalTypeLabel: ''
technicalTypeLabel: '',
};
this.creatingMaterial = false;
this.successMessage = 'Materiale aggiunto.';
},
error: (err) => {
this.creatingMaterial = false;
this.errorMessage = this.extractErrorMessage(err, 'Creazione materiale non riuscita.');
}
this.errorMessage = this.extractErrorMessage(
err,
'Creazione materiale non riuscita.',
);
},
});
}
@@ -145,34 +151,41 @@ export class AdminFilamentStockComponent implements OnInit {
materialCode: (material.materialCode || '').trim(),
isFlexible: !!material.isFlexible,
isTechnical: !!material.isTechnical,
technicalTypeLabel: material.isTechnical ? (material.technicalTypeLabel || '').trim() : ''
technicalTypeLabel: material.isTechnical
? (material.technicalTypeLabel || '').trim()
: '',
};
this.adminOperationsService.updateFilamentMaterial(material.id, payload).subscribe({
next: (updated) => {
this.materials = this.sortMaterials(
this.materials.map((m) => (m.id === updated.id ? updated : m))
);
this.variants = this.variants.map((variant) => {
if (variant.materialTypeId !== updated.id) {
return variant;
}
return {
...variant,
materialCode: updated.materialCode,
materialIsFlexible: updated.isFlexible,
materialIsTechnical: updated.isTechnical,
materialTechnicalTypeLabel: updated.technicalTypeLabel
};
});
this.savingMaterialIds.delete(material.id);
this.successMessage = 'Materiale aggiornato.';
},
error: (err) => {
this.savingMaterialIds.delete(material.id);
this.errorMessage = this.extractErrorMessage(err, 'Aggiornamento materiale non riuscito.');
}
});
this.adminOperationsService
.updateFilamentMaterial(material.id, payload)
.subscribe({
next: (updated) => {
this.materials = this.sortMaterials(
this.materials.map((m) => (m.id === updated.id ? updated : m)),
);
this.variants = this.variants.map((variant) => {
if (variant.materialTypeId !== updated.id) {
return variant;
}
return {
...variant,
materialCode: updated.materialCode,
materialIsFlexible: updated.isFlexible,
materialIsTechnical: updated.isTechnical,
materialTechnicalTypeLabel: updated.technicalTypeLabel,
};
});
this.savingMaterialIds.delete(material.id);
this.successMessage = 'Materiale aggiornato.';
},
error: (err) => {
this.savingMaterialIds.delete(material.id);
this.errorMessage = this.extractErrorMessage(
err,
'Aggiornamento materiale non riuscito.',
);
},
});
}
createVariant(): void {
@@ -189,7 +202,8 @@ export class AdminFilamentStockComponent implements OnInit {
next: (created) => {
this.variants = this.sortVariants([...this.variants, created]);
this.newVariant = {
materialTypeId: this.newVariant.materialTypeId || this.materials[0]?.id || 0,
materialTypeId:
this.newVariant.materialTypeId || this.materials[0]?.id || 0,
variantDisplayName: '',
colorName: '',
colorHex: '',
@@ -200,15 +214,18 @@ export class AdminFilamentStockComponent implements OnInit {
costChfPerKg: 0,
stockSpools: 0,
spoolNetKg: 1,
isActive: true
isActive: true,
};
this.creatingVariant = false;
this.successMessage = 'Variante aggiunta.';
},
error: (err) => {
this.creatingVariant = false;
this.errorMessage = this.extractErrorMessage(err, 'Creazione variante non riuscita.');
}
this.errorMessage = this.extractErrorMessage(
err,
'Creazione variante non riuscita.',
);
},
});
}
@@ -222,30 +239,43 @@ export class AdminFilamentStockComponent implements OnInit {
this.savingVariantIds.add(variant.id);
const payload = this.toVariantPayload(variant);
this.adminOperationsService.updateFilamentVariant(variant.id, payload).subscribe({
next: (updated) => {
this.variants = this.sortVariants(
this.variants.map((v) => (v.id === updated.id ? updated : v))
);
this.savingVariantIds.delete(variant.id);
this.successMessage = 'Variante aggiornata.';
},
error: (err) => {
this.savingVariantIds.delete(variant.id);
this.errorMessage = this.extractErrorMessage(err, 'Aggiornamento variante non riuscito.');
}
});
this.adminOperationsService
.updateFilamentVariant(variant.id, payload)
.subscribe({
next: (updated) => {
this.variants = this.sortVariants(
this.variants.map((v) => (v.id === updated.id ? updated : v)),
);
this.savingVariantIds.delete(variant.id);
this.successMessage = 'Variante aggiornata.';
},
error: (err) => {
this.savingVariantIds.delete(variant.id);
this.errorMessage = this.extractErrorMessage(
err,
'Aggiornamento variante non riuscito.',
);
},
});
}
isLowStock(variant: AdminFilamentVariant): boolean {
return this.computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) < 1000;
return (
this.computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) <
1000
);
}
computeStockKg(stockSpools?: number, spoolNetKg?: number): number {
const spools = Number(stockSpools ?? 0);
const netKg = Number(spoolNetKg ?? 0);
if (!Number.isFinite(spools) || !Number.isFinite(netKg) || spools < 0 || netKg < 0) {
if (
!Number.isFinite(spools) ||
!Number.isFinite(netKg) ||
spools < 0 ||
netKg < 0
) {
return 0;
}
return spools * netKg;
@@ -298,7 +328,7 @@ export class AdminFilamentStockComponent implements OnInit {
this.adminOperationsService.deleteFilamentVariant(variant.id).subscribe({
next: () => {
this.variants = this.variants.filter(v => v.id !== variant.id);
this.variants = this.variants.filter((v) => v.id !== variant.id);
this.expandedVariantIds.delete(variant.id);
this.deletingVariantIds.delete(variant.id);
this.variantToDelete = null;
@@ -306,8 +336,11 @@ export class AdminFilamentStockComponent implements OnInit {
},
error: (err) => {
this.deletingVariantIds.delete(variant.id);
this.errorMessage = this.extractErrorMessage(err, 'Eliminazione variante non riuscita.');
}
this.errorMessage = this.extractErrorMessage(
err,
'Eliminazione variante non riuscita.',
);
},
});
}
@@ -319,7 +352,9 @@ export class AdminFilamentStockComponent implements OnInit {
this.quickInsertCollapsed = !this.quickInsertCollapsed;
}
private toVariantPayload(source: AdminUpsertFilamentVariantPayload | AdminFilamentVariant): AdminUpsertFilamentVariantPayload {
private toVariantPayload(
source: AdminUpsertFilamentVariantPayload | AdminFilamentVariant,
): AdminUpsertFilamentVariantPayload {
return {
materialTypeId: Number(source.materialTypeId),
variantDisplayName: (source.variantDisplayName || '').trim(),
@@ -332,21 +367,31 @@ export class AdminFilamentStockComponent implements OnInit {
costChfPerKg: Number(source.costChfPerKg ?? 0),
stockSpools: Number(source.stockSpools ?? 0),
spoolNetKg: Number(source.spoolNetKg ?? 0),
isActive: source.isActive !== false
isActive: source.isActive !== false,
};
}
private sortMaterials(materials: AdminFilamentMaterialType[]): AdminFilamentMaterialType[] {
return [...materials].sort((a, b) => a.materialCode.localeCompare(b.materialCode));
private sortMaterials(
materials: AdminFilamentMaterialType[],
): AdminFilamentMaterialType[] {
return [...materials].sort((a, b) =>
a.materialCode.localeCompare(b.materialCode),
);
}
private sortVariants(variants: AdminFilamentVariant[]): AdminFilamentVariant[] {
private sortVariants(
variants: AdminFilamentVariant[],
): AdminFilamentVariant[] {
return [...variants].sort((a, b) => {
const byMaterial = (a.materialCode || '').localeCompare(b.materialCode || '');
const byMaterial = (a.materialCode || '').localeCompare(
b.materialCode || '',
);
if (byMaterial !== 0) {
return byMaterial;
}
return (a.variantDisplayName || '').localeCompare(b.variantDisplayName || '');
return (a.variantDisplayName || '').localeCompare(
b.variantDisplayName || '',
);
});
}

View File

@@ -15,8 +15,11 @@
required
/>
<button type="submit" [disabled]="loading || !password.trim() || lockSecondsRemaining > 0">
{{ loading ? 'Accesso...' : 'Accedi' }}
<button
type="submit"
[disabled]="loading || !password.trim() || lockSecondsRemaining > 0"
>
{{ loading ? "Accesso..." : "Accedi" }}
</button>
</form>

View File

@@ -3,7 +3,10 @@ import { Component, inject, OnDestroy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { AdminAuthResponse, AdminAuthService } from '../services/admin-auth.service';
import {
AdminAuthResponse,
AdminAuthService,
} from '../services/admin-auth.service';
const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
@@ -12,7 +15,7 @@ const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './admin-login.component.html',
styleUrl: './admin-login.component.scss'
styleUrl: './admin-login.component.scss',
})
export class AdminLoginComponent implements OnDestroy {
private readonly authService = inject(AdminAuthService);
@@ -26,7 +29,11 @@ export class AdminLoginComponent implements OnDestroy {
private lockTimer: ReturnType<typeof setInterval> | null = null;
submit(): void {
if (!this.password.trim() || this.loading || this.lockSecondsRemaining > 0) {
if (
!this.password.trim() ||
this.loading ||
this.lockSecondsRemaining > 0
) {
return;
}
@@ -53,7 +60,7 @@ export class AdminLoginComponent implements OnDestroy {
error: (error: HttpErrorResponse) => {
this.loading = false;
this.handleLoginFailure(this.extractRetryAfterSeconds(error));
}
},
});
}

View File

@@ -4,7 +4,14 @@
<h2>Sessioni quote</h2>
<p>Sessioni create dal configuratore con stato e conversione ordine.</p>
</div>
<button type="button" class="btn-primary" (click)="loadSessions()" [disabled]="loading">Aggiorna</button>
<button
type="button"
class="btn-primary"
(click)="loadSessions()"
[disabled]="loading"
>
Aggiorna
</button>
</header>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
@@ -26,41 +33,73 @@
<tbody>
<ng-container *ngFor="let session of sessions">
<tr>
<td [title]="session.id">{{ session.id | slice:0:8 }}</td>
<td>{{ session.createdAt | date:'short' }}</td>
<td>{{ session.expiresAt | date:'short' }}</td>
<td [title]="session.id">{{ session.id | slice: 0 : 8 }}</td>
<td>{{ session.createdAt | date: "short" }}</td>
<td>{{ session.expiresAt | date: "short" }}</td>
<td>{{ session.materialCode }}</td>
<td>{{ session.status }}</td>
<td>{{ session.convertedOrderId || '-' }}</td>
<td>{{ session.convertedOrderId || "-" }}</td>
<td class="actions">
<button
type="button"
class="btn-secondary"
(click)="toggleSessionDetail(session)">
{{ isDetailOpen(session.id) ? 'Nascondi' : 'Vedi' }}
(click)="toggleSessionDetail(session)"
>
{{ isDetailOpen(session.id) ? "Nascondi" : "Vedi" }}
</button>
<button
type="button"
class="btn-danger"
(click)="deleteSession(session)"
[disabled]="isDeletingSession(session.id) || !!session.convertedOrderId"
[title]="session.convertedOrderId ? 'Sessione collegata a un ordine, non eliminabile.' : ''">
{{ isDeletingSession(session.id) ? 'Eliminazione...' : 'Elimina' }}
[disabled]="
isDeletingSession(session.id) || !!session.convertedOrderId
"
[title]="
session.convertedOrderId
? 'Sessione collegata a un ordine, non eliminabile.'
: ''
"
>
{{
isDeletingSession(session.id) ? "Eliminazione..." : "Elimina"
}}
</button>
</td>
</tr>
<tr *ngIf="isDetailOpen(session.id)">
<td colspan="7" class="detail-cell">
<div *ngIf="isLoadingDetail(session.id)">Caricamento dettaglio...</div>
<div *ngIf="!isLoadingDetail(session.id) && getSessionDetail(session.id) as detail" class="detail-box">
<div *ngIf="isLoadingDetail(session.id)">
Caricamento dettaglio...
</div>
<div
*ngIf="
!isLoadingDetail(session.id) &&
getSessionDetail(session.id) as detail
"
class="detail-box"
>
<div class="detail-summary">
<div><strong>Elementi:</strong> {{ detail.items.length }}</div>
<div><strong>Totale articoli:</strong> {{ detail.itemsTotalChf | currency:'CHF' }}</div>
<div><strong>Spedizione:</strong> {{ detail.shippingCostChf | currency:'CHF' }}</div>
<div><strong>Totale sessione:</strong> {{ detail.grandTotalChf | currency:'CHF' }}</div>
<div>
<strong>Elementi:</strong> {{ detail.items.length }}
</div>
<div>
<strong>Totale articoli:</strong>
{{ detail.itemsTotalChf | currency: "CHF" }}
</div>
<div>
<strong>Spedizione:</strong>
{{ detail.shippingCostChf | currency: "CHF" }}
</div>
<div>
<strong>Totale sessione:</strong>
{{ detail.grandTotalChf | currency: "CHF" }}
</div>
</div>
<table class="detail-table" *ngIf="detail.items.length > 0; else noItemsTpl">
<table
class="detail-table"
*ngIf="detail.items.length > 0; else noItemsTpl"
>
<thead>
<tr>
<th>File</th>
@@ -76,9 +115,15 @@
<td>{{ item.originalFilename }}</td>
<td>{{ item.quantity }}</td>
<td>{{ formatPrintTime(item.printTimeSeconds) }}</td>
<td>{{ item.materialGrams ? (item.materialGrams | number:'1.0-2') + ' g' : '-' }}</td>
<td>
{{
item.materialGrams
? (item.materialGrams | number: "1.0-2") + " g"
: "-"
}}
</td>
<td>{{ item.status }}</td>
<td>{{ item.unitPriceChf | currency:'CHF' }}</td>
<td>{{ item.unitPriceChf | currency: "CHF" }}</td>
</tr>
</tbody>
</table>

View File

@@ -3,7 +3,7 @@ import { Component, inject, OnInit } from '@angular/core';
import {
AdminOperationsService,
AdminQuoteSession,
AdminQuoteSessionDetail
AdminQuoteSessionDetail,
} from '../services/admin-operations.service';
@Component({
@@ -11,7 +11,7 @@ import {
standalone: true,
imports: [CommonModule],
templateUrl: './admin-sessions.component.html',
styleUrl: './admin-sessions.component.scss'
styleUrl: './admin-sessions.component.scss',
})
export class AdminSessionsComponent implements OnInit {
private readonly adminOperationsService = inject(AdminOperationsService);
@@ -41,7 +41,7 @@ export class AdminSessionsComponent implements OnInit {
error: () => {
this.loading = false;
this.errorMessage = 'Impossibile caricare le sessioni.';
}
},
});
}
@@ -51,7 +51,7 @@ export class AdminSessionsComponent implements OnInit {
}
const confirmed = window.confirm(
`Vuoi eliminare la sessione ${session.id}? Questa azione non si puo annullare.`
`Vuoi eliminare la sessione ${session.id}? Questa azione non si puo annullare.`,
);
if (!confirmed) {
return;
@@ -69,8 +69,11 @@ export class AdminSessionsComponent implements OnInit {
},
error: (err) => {
this.deletingSessionIds.delete(session.id);
this.errorMessage = this.extractErrorMessage(err, 'Impossibile eliminare la sessione.');
}
this.errorMessage = this.extractErrorMessage(
err,
'Impossibile eliminare la sessione.',
);
},
});
}
@@ -85,7 +88,10 @@ export class AdminSessionsComponent implements OnInit {
}
this.expandedSessionId = session.id;
if (this.sessionDetailsById[session.id] || this.loadingDetailSessionIds.has(session.id)) {
if (
this.sessionDetailsById[session.id] ||
this.loadingDetailSessionIds.has(session.id)
) {
return;
}
@@ -94,14 +100,17 @@ export class AdminSessionsComponent implements OnInit {
next: (detail) => {
this.sessionDetailsById = {
...this.sessionDetailsById,
[session.id]: detail
[session.id]: detail,
};
this.loadingDetailSessionIds.delete(session.id);
},
error: (err) => {
this.loadingDetailSessionIds.delete(session.id);
this.errorMessage = this.extractErrorMessage(err, 'Impossibile caricare il dettaglio sessione.');
}
this.errorMessage = this.extractErrorMessage(
err,
'Impossibile caricare il dettaglio sessione.',
);
},
});
}

View File

@@ -9,8 +9,12 @@
<div class="menu-scroll">
<nav class="menu">
<a routerLink="orders" routerLinkActive="active">Ordini</a>
<a routerLink="filament-stock" routerLinkActive="active">Stock filamenti</a>
<a routerLink="contact-requests" routerLinkActive="active">Richieste contatto</a>
<a routerLink="filament-stock" routerLinkActive="active"
>Stock filamenti</a
>
<a routerLink="contact-requests" routerLinkActive="active"
>Richieste contatto</a
>
<a routerLink="sessions" routerLinkActive="active">Sessioni</a>
</nav>
</div>

View File

@@ -54,7 +54,10 @@
font-weight: 600;
border: 1px solid var(--color-border);
background: var(--color-bg-card);
transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease;
transition:
border-color 0.2s ease,
background-color 0.2s ease,
color 0.2s ease;
white-space: nowrap;
}
@@ -78,7 +81,9 @@
padding: var(--space-3) var(--space-4);
font-weight: 600;
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
}
.logout:hover {

View File

@@ -1,6 +1,12 @@
import { CommonModule } from '@angular/common';
import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import {
ActivatedRoute,
Router,
RouterLink,
RouterLinkActive,
RouterOutlet,
} from '@angular/router';
import { AdminAuthService } from '../services/admin-auth.service';
const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
@@ -10,7 +16,7 @@ const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
standalone: true,
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
templateUrl: './admin-shell.component.html',
styleUrl: './admin-shell.component.scss'
styleUrl: './admin-shell.component.scss',
})
export class AdminShellComponent {
private readonly adminAuthService = inject(AdminAuthService);
@@ -24,7 +30,7 @@ export class AdminShellComponent {
},
error: () => {
void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']);
}
},
});
}

View File

@@ -11,23 +11,31 @@ export interface AdminAuthResponse {
}
@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class AdminAuthService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/api/admin/auth`;
login(password: string): Observable<AdminAuthResponse> {
return this.http.post<AdminAuthResponse>(`${this.baseUrl}/login`, { password }, { withCredentials: true });
return this.http.post<AdminAuthResponse>(
`${this.baseUrl}/login`,
{ password },
{ withCredentials: true },
);
}
logout(): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/logout`, {}, { withCredentials: true });
return this.http.post<void>(
`${this.baseUrl}/logout`,
{},
{ withCredentials: true },
);
}
me(): Observable<boolean> {
return this.http.get<AdminAuthResponse>(`${this.baseUrl}/me`, { withCredentials: true }).pipe(
map((response) => Boolean(response?.authenticated))
);
return this.http
.get<AdminAuthResponse>(`${this.baseUrl}/me`, { withCredentials: true })
.pipe(map((response) => Boolean(response?.authenticated)));
}
}

View File

@@ -145,82 +145,138 @@ export interface AdminQuoteSessionDetail {
}
@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class AdminOperationsService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/api/admin`;
getFilamentStock(): Observable<AdminFilamentStockRow[]> {
return this.http.get<AdminFilamentStockRow[]>(`${this.baseUrl}/filament-stock`, { withCredentials: true });
return this.http.get<AdminFilamentStockRow[]>(
`${this.baseUrl}/filament-stock`,
{ withCredentials: true },
);
}
getFilamentMaterials(): Observable<AdminFilamentMaterialType[]> {
return this.http.get<AdminFilamentMaterialType[]>(`${this.baseUrl}/filaments/materials`, { withCredentials: true });
return this.http.get<AdminFilamentMaterialType[]>(
`${this.baseUrl}/filaments/materials`,
{ withCredentials: true },
);
}
getFilamentVariants(): Observable<AdminFilamentVariant[]> {
return this.http.get<AdminFilamentVariant[]>(`${this.baseUrl}/filaments/variants`, { withCredentials: true });
return this.http.get<AdminFilamentVariant[]>(
`${this.baseUrl}/filaments/variants`,
{ withCredentials: true },
);
}
createFilamentMaterial(payload: AdminUpsertFilamentMaterialTypePayload): Observable<AdminFilamentMaterialType> {
return this.http.post<AdminFilamentMaterialType>(`${this.baseUrl}/filaments/materials`, payload, { withCredentials: true });
createFilamentMaterial(
payload: AdminUpsertFilamentMaterialTypePayload,
): Observable<AdminFilamentMaterialType> {
return this.http.post<AdminFilamentMaterialType>(
`${this.baseUrl}/filaments/materials`,
payload,
{ withCredentials: true },
);
}
updateFilamentMaterial(materialId: number, payload: AdminUpsertFilamentMaterialTypePayload): Observable<AdminFilamentMaterialType> {
return this.http.put<AdminFilamentMaterialType>(`${this.baseUrl}/filaments/materials/${materialId}`, payload, { withCredentials: true });
updateFilamentMaterial(
materialId: number,
payload: AdminUpsertFilamentMaterialTypePayload,
): Observable<AdminFilamentMaterialType> {
return this.http.put<AdminFilamentMaterialType>(
`${this.baseUrl}/filaments/materials/${materialId}`,
payload,
{ withCredentials: true },
);
}
createFilamentVariant(payload: AdminUpsertFilamentVariantPayload): Observable<AdminFilamentVariant> {
return this.http.post<AdminFilamentVariant>(`${this.baseUrl}/filaments/variants`, payload, { withCredentials: true });
createFilamentVariant(
payload: AdminUpsertFilamentVariantPayload,
): Observable<AdminFilamentVariant> {
return this.http.post<AdminFilamentVariant>(
`${this.baseUrl}/filaments/variants`,
payload,
{ withCredentials: true },
);
}
updateFilamentVariant(variantId: number, payload: AdminUpsertFilamentVariantPayload): Observable<AdminFilamentVariant> {
return this.http.put<AdminFilamentVariant>(`${this.baseUrl}/filaments/variants/${variantId}`, payload, { withCredentials: true });
updateFilamentVariant(
variantId: number,
payload: AdminUpsertFilamentVariantPayload,
): Observable<AdminFilamentVariant> {
return this.http.put<AdminFilamentVariant>(
`${this.baseUrl}/filaments/variants/${variantId}`,
payload,
{ withCredentials: true },
);
}
deleteFilamentVariant(variantId: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/filaments/variants/${variantId}`, { withCredentials: true });
return this.http.delete<void>(
`${this.baseUrl}/filaments/variants/${variantId}`,
{ withCredentials: true },
);
}
getContactRequests(): Observable<AdminContactRequest[]> {
return this.http.get<AdminContactRequest[]>(`${this.baseUrl}/contact-requests`, { withCredentials: true });
return this.http.get<AdminContactRequest[]>(
`${this.baseUrl}/contact-requests`,
{ withCredentials: true },
);
}
getContactRequestDetail(requestId: string): Observable<AdminContactRequestDetail> {
return this.http.get<AdminContactRequestDetail>(`${this.baseUrl}/contact-requests/${requestId}`, { withCredentials: true });
getContactRequestDetail(
requestId: string,
): Observable<AdminContactRequestDetail> {
return this.http.get<AdminContactRequestDetail>(
`${this.baseUrl}/contact-requests/${requestId}`,
{ withCredentials: true },
);
}
updateContactRequestStatus(
requestId: string,
payload: AdminUpdateContactRequestStatusPayload
payload: AdminUpdateContactRequestStatusPayload,
): Observable<AdminContactRequestDetail> {
return this.http.patch<AdminContactRequestDetail>(
`${this.baseUrl}/contact-requests/${requestId}/status`,
payload,
{ withCredentials: true }
{ withCredentials: true },
);
}
downloadContactRequestAttachment(requestId: string, attachmentId: string): Observable<Blob> {
return this.http.get(`${this.baseUrl}/contact-requests/${requestId}/attachments/${attachmentId}/file`, {
withCredentials: true,
responseType: 'blob'
});
downloadContactRequestAttachment(
requestId: string,
attachmentId: string,
): Observable<Blob> {
return this.http.get(
`${this.baseUrl}/contact-requests/${requestId}/attachments/${attachmentId}/file`,
{
withCredentials: true,
responseType: 'blob',
},
);
}
getSessions(): Observable<AdminQuoteSession[]> {
return this.http.get<AdminQuoteSession[]>(`${this.baseUrl}/sessions`, { withCredentials: true });
return this.http.get<AdminQuoteSession[]>(`${this.baseUrl}/sessions`, {
withCredentials: true,
});
}
deleteSession(sessionId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/sessions/${sessionId}`, { withCredentials: true });
return this.http.delete<void>(`${this.baseUrl}/sessions/${sessionId}`, {
withCredentials: true,
});
}
getSessionDetail(sessionId: string): Observable<AdminQuoteSessionDetail> {
return this.http.get<AdminQuoteSessionDetail>(
`${environment.apiUrl}/api/quote-sessions/${sessionId}`,
{ withCredentials: true }
{ withCredentials: true },
);
}
}

View File

@@ -38,7 +38,7 @@ export interface AdminUpdateOrderStatusPayload {
}
@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class AdminOrdersService {
private readonly http = inject(HttpClient);
@@ -49,35 +49,54 @@ export class AdminOrdersService {
}
getOrder(orderId: string): Observable<AdminOrder> {
return this.http.get<AdminOrder>(`${this.baseUrl}/${orderId}`, { withCredentials: true });
return this.http.get<AdminOrder>(`${this.baseUrl}/${orderId}`, {
withCredentials: true,
});
}
confirmPayment(orderId: string, method: string): Observable<AdminOrder> {
return this.http.post<AdminOrder>(`${this.baseUrl}/${orderId}/payments/confirm`, { method }, { withCredentials: true });
return this.http.post<AdminOrder>(
`${this.baseUrl}/${orderId}/payments/confirm`,
{ method },
{ withCredentials: true },
);
}
updateOrderStatus(orderId: string, payload: AdminUpdateOrderStatusPayload): Observable<AdminOrder> {
return this.http.post<AdminOrder>(`${this.baseUrl}/${orderId}/status`, payload, { withCredentials: true });
updateOrderStatus(
orderId: string,
payload: AdminUpdateOrderStatusPayload,
): Observable<AdminOrder> {
return this.http.post<AdminOrder>(
`${this.baseUrl}/${orderId}/status`,
payload,
{ withCredentials: true },
);
}
downloadOrderItemFile(orderId: string, orderItemId: string): Observable<Blob> {
return this.http.get(`${this.baseUrl}/${orderId}/items/${orderItemId}/file`, {
withCredentials: true,
responseType: 'blob'
});
downloadOrderItemFile(
orderId: string,
orderItemId: string,
): Observable<Blob> {
return this.http.get(
`${this.baseUrl}/${orderId}/items/${orderItemId}/file`,
{
withCredentials: true,
responseType: 'blob',
},
);
}
downloadOrderConfirmation(orderId: string): Observable<Blob> {
return this.http.get(`${this.baseUrl}/${orderId}/documents/confirmation`, {
withCredentials: true,
responseType: 'blob'
responseType: 'blob',
});
}
downloadOrderInvoice(orderId: string): Observable<Blob> {
return this.http.get(`${this.baseUrl}/${orderId}/documents/invoice`, {
withCredentials: true,
responseType: 'blob'
responseType: 'blob',
});
}
}

View File

@@ -1,72 +1,80 @@
<div class="container hero">
<h1>{{ 'CALC.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p>
<h1>{{ "CALC.TITLE" | translate }}</h1>
<p class="subtitle">{{ "CALC.SUBTITLE" | translate }}</p>
@if (error()) {
<app-alert type="error">{{ errorKey() | translate }}</app-alert>
}
</div>
@if (step() === 'success') {
<div class="container hero">
<app-success-state context="calc" (action)="onNewQuote()"></app-success-state>
</div>
@if (step() === "success") {
<div class="container hero">
<app-success-state
context="calc"
(action)="onNewQuote()"
></app-success-state>
</div>
} @else {
<div class="container content-grid">
<!-- Left Column: Input -->
<div class="col-input">
<app-card>
<div class="mode-selector">
<div class="mode-option"
[class.active]="mode() === 'easy'"
(click)="mode.set('easy')">
{{ 'CALC.MODE_EASY' | translate }}
</div>
<div class="mode-option"
[class.active]="mode() === 'advanced'"
(click)="mode.set('advanced')">
{{ 'CALC.MODE_ADVANCED' | translate }}
</div>
<div 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>
<app-upload-form
#uploadForm
[mode]="mode()"
[loading]="loading()"
[uploadProgress]="uploadProgress()"
(submitRequest)="onCalculate($event)"
></app-upload-form>
</app-card>
</div>
<!-- Right Column: Result or Info -->
<div class="col-result" #resultCol>
@if (loading()) {
<app-card class="loading-state">
<div class="loader-content">
<div class="spinner"></div>
<h3 class="loading-title">{{ 'CALC.ANALYZING_TITLE' | translate }}</h3>
<p class="loading-text">{{ 'CALC.ANALYZING_TEXT' | translate }}</p>
</div>
</app-card>
} @else if (result()) {
<app-quote-result
[result]="result()!"
(consult)="onConsult()"
(proceed)="onProceed()"
(itemChange)="onItemChange($event)"
></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
class="mode-option"
[class.active]="mode() === 'advanced'"
(click)="mode.set('advanced')"
>
{{ "CALC.MODE_ADVANCED" | translate }}
</div>
</div>
<app-upload-form
#uploadForm
[mode]="mode()"
[loading]="loading()"
[uploadProgress]="uploadProgress()"
(submitRequest)="onCalculate($event)"
></app-upload-form>
</app-card>
</div>
<!-- Right Column: Result or Info -->
<div class="col-result" #resultCol>
@if (loading()) {
<app-card class="loading-state">
<div class="loader-content">
<div class="spinner"></div>
<h3 class="loading-title">
{{ "CALC.ANALYZING_TITLE" | translate }}
</h3>
<p class="loading-text">{{ "CALC.ANALYZING_TEXT" | translate }}</p>
</div>
</app-card>
} @else if (result()) {
<app-quote-result
[result]="result()!"
(consult)="onConsult()"
(proceed)="onProceed()"
(itemChange)="onItemChange($event)"
></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>
}

View File

@@ -1,36 +1,44 @@
.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; }
.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) {
@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;
}
align-self: flex-start; /* Default */
@media (min-width: 768px) {
align-self: center;
}
}
.col-input {
min-width: 0;
min-width: 0;
}
.col-result {
min-width: 0;
display: flex;
flex-direction: column;
min-width: 0;
display: flex;
flex-direction: column;
}
/* Stretch only the loading card so the spinner stays centered */
.col-result > .loading-state {
flex: 1;
flex: 1;
}
/* Mode Selector (Segmented Control style) */
@@ -56,55 +64,64 @@
transition: all 0.2s ease;
user-select: none;
&:hover { color: var(--color-text); }
&: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);
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; }
.benefits {
padding-left: var(--space-4);
color: var(--color-text-muted);
line-height: 2;
}
.loader-content {
text-align: center;
max-width: 300px;
margin: 0 auto;
/* Center content vertically within the stretched card */
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
max-width: 300px;
margin: 0 auto;
/* Center content vertically within the stretched card */
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.loading-title {
font-size: 1.1rem;
font-weight: 600;
margin: var(--space-4) 0 var(--space-2);
color: var(--color-text);
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;
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;
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); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -1,4 +1,10 @@
import { Component, signal, ViewChild, ElementRef, OnInit } from '@angular/core';
import {
Component,
signal,
ViewChild,
ElementRef,
OnInit,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { forkJoin } from 'rxjs';
@@ -8,7 +14,11 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
import { UploadFormComponent } from './components/upload-form/upload-form.component';
import { QuoteResultComponent } from './components/quote-result/quote-result.component';
import { QuoteRequest, QuoteResult, QuoteEstimatorService } from './services/quote-estimator.service';
import {
QuoteRequest,
QuoteResult,
QuoteEstimatorService,
} from './services/quote-estimator.service';
import { SuccessStateComponent } from '../../shared/components/success-state/success-state.component';
import { Router, ActivatedRoute } from '@angular/router';
import { LanguageService } from '../../core/services/language.service';
@@ -16,142 +26,155 @@ import { LanguageService } from '../../core/services/language.service';
@Component({
selector: 'app-calculator-page',
standalone: true,
imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, SuccessStateComponent],
imports: [
CommonModule,
TranslateModule,
AppCardComponent,
AppAlertComponent,
UploadFormComponent,
QuoteResultComponent,
SuccessStateComponent,
],
templateUrl: './calculator-page.component.html',
styleUrl: './calculator-page.component.scss'
styleUrl: './calculator-page.component.scss',
})
export class CalculatorPageComponent implements OnInit {
mode = signal<any>('easy');
step = signal<'upload' | 'quote' | 'details' | 'success'>('upload');
loading = signal(false);
uploadProgress = signal(0);
result = signal<QuoteResult | null>(null);
error = signal<boolean>(false);
errorKey = signal<string>('CALC.ERROR_GENERIC');
orderSuccess = signal(false);
@ViewChild('uploadForm') uploadForm!: UploadFormComponent;
@ViewChild('resultCol') resultCol!: ElementRef;
constructor(
private estimator: QuoteEstimatorService,
private estimator: QuoteEstimatorService,
private router: Router,
private route: ActivatedRoute,
private languageService: LanguageService
private languageService: LanguageService,
) {}
ngOnInit() {
this.route.data.subscribe(data => {
this.route.data.subscribe((data) => {
if (data['mode']) {
this.mode.set(data['mode']);
}
});
this.route.queryParams.subscribe(params => {
const sessionId = params['session'];
if (sessionId) {
// Avoid reloading if we just calculated this session
const currentRes = this.result();
if (!currentRes || currentRes.sessionId !== sessionId) {
this.loadSession(sessionId);
}
this.route.queryParams.subscribe((params) => {
const sessionId = params['session'];
if (sessionId) {
// Avoid reloading if we just calculated this session
const currentRes = this.result();
if (!currentRes || currentRes.sessionId !== sessionId) {
this.loadSession(sessionId);
}
}
});
}
loadSession(sessionId: string) {
this.loading.set(true);
this.estimator.getQuoteSession(sessionId).subscribe({
next: (data) => {
// 1. Map to Result
const result = this.estimator.mapSessionToQuoteResult(data);
if (this.isInvalidQuote(result)) {
this.setQuoteError('CALC.ERROR_ZERO_PRICE');
this.loading.set(false);
return;
}
this.loading.set(true);
this.estimator.getQuoteSession(sessionId).subscribe({
next: (data) => {
// 1. Map to Result
const result = this.estimator.mapSessionToQuoteResult(data);
if (this.isInvalidQuote(result)) {
this.setQuoteError('CALC.ERROR_ZERO_PRICE');
this.loading.set(false);
return;
}
this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(result);
this.step.set('quote');
// 2. Determine Mode (Heuristic)
// If we have custom settings, maybe Advanced?
// For now, let's stick to current mode or infer from URL if possible.
// Actually, we can check if settings deviate from Easy defaults.
// But let's leave it as is or default to Advanced if not sure.
// data.session.materialCode etc.
// 3. Download Files & Restore Form
this.restoreFilesAndSettings(data.session, data.items);
},
error: (err) => {
console.error('Failed to load session', err);
this.setQuoteError('CALC.ERROR_GENERIC');
this.loading.set(false);
}
});
this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(result);
this.step.set('quote');
// 2. Determine Mode (Heuristic)
// If we have custom settings, maybe Advanced?
// For now, let's stick to current mode or infer from URL if possible.
// Actually, we can check if settings deviate from Easy defaults.
// But let's leave it as is or default to Advanced if not sure.
// data.session.materialCode etc.
// 3. Download Files & Restore Form
this.restoreFilesAndSettings(data.session, data.items);
},
error: (err) => {
console.error('Failed to load session', err);
this.setQuoteError('CALC.ERROR_GENERIC');
this.loading.set(false);
},
});
}
restoreFilesAndSettings(session: any, items: any[]) {
if (!items || items.length === 0) {
this.loading.set(false);
return;
}
if (!items || items.length === 0) {
this.loading.set(false);
return;
}
// Download all files
const downloads = items.map(item =>
this.estimator.getLineItemContent(session.id, item.id).pipe(
map((blob: Blob) => {
return {
blob,
fileName: item.originalFilename,
// We need to match the file object to the item so we can set colors ideally.
// UploadForm.setFiles takes File[].
// We might need to handle matching but UploadForm just pushes them.
// If order is preserved, we are good. items from backend are list.
};
})
)
);
forkJoin(downloads).subscribe({
next: (results: any[]) => {
const files = results.map(res => new File([res.blob], res.fileName, { type: 'application/octet-stream' }));
if (this.uploadForm) {
this.uploadForm.setFiles(files);
this.uploadForm.patchSettings(session);
// Also restore colors?
// setFiles inits with 'Black'. We need to update them if they differ.
// items has colorCode.
setTimeout(() => {
if (this.uploadForm) {
items.forEach((item, index) => {
// Assuming index matches.
// Need to be careful if items order changed, but usually ID sort or insert order.
if (item.colorCode) {
this.uploadForm.updateItemColor(index, {
colorName: item.colorCode,
filamentVariantId: item.filamentVariantId
});
}
});
}
// Download all files
const downloads = items.map((item) =>
this.estimator.getLineItemContent(session.id, item.id).pipe(
map((blob: Blob) => {
return {
blob,
fileName: item.originalFilename,
// We need to match the file object to the item so we can set colors ideally.
// UploadForm.setFiles takes File[].
// We might need to handle matching but UploadForm just pushes them.
// If order is preserved, we are good. items from backend are list.
};
}),
),
);
forkJoin(downloads).subscribe({
next: (results: any[]) => {
const files = results.map(
(res) =>
new File([res.blob], res.fileName, {
type: 'application/octet-stream',
}),
);
if (this.uploadForm) {
this.uploadForm.setFiles(files);
this.uploadForm.patchSettings(session);
// Also restore colors?
// setFiles inits with 'Black'. We need to update them if they differ.
// items has colorCode.
setTimeout(() => {
if (this.uploadForm) {
items.forEach((item, index) => {
// Assuming index matches.
// Need to be careful if items order changed, but usually ID sort or insert order.
if (item.colorCode) {
this.uploadForm.updateItemColor(index, {
colorName: item.colorCode,
filamentVariantId: item.filamentVariantId,
});
}
this.loading.set(false);
},
error: (err: any) => {
console.error('Failed to download files', err);
this.loading.set(false);
// Still show result? Yes.
}
});
}
});
}
});
}
this.loading.set(false);
},
error: (err: any) => {
console.error('Failed to download files', err);
this.loading.set(false);
// Still show result? Yes.
},
});
}
onCalculate(req: QuoteRequest) {
@@ -166,46 +189,49 @@ export class CalculatorPageComponent implements OnInit {
// Auto-scroll on mobile to make analysis visible
setTimeout(() => {
if (this.resultCol && window.innerWidth < 768) {
this.resultCol.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
if (this.resultCol && window.innerWidth < 768) {
this.resultCol.nativeElement.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
}, 100);
this.estimator.calculate(req).subscribe({
next: (event) => {
if (typeof event === 'number') {
this.uploadProgress.set(event);
this.uploadProgress.set(event);
} else {
// It's the result
const res = event as QuoteResult;
if (this.isInvalidQuote(res)) {
this.setQuoteError('CALC.ERROR_ZERO_PRICE');
this.loading.set(false);
return;
}
this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(res);
// It's the result
const res = event as QuoteResult;
if (this.isInvalidQuote(res)) {
this.setQuoteError('CALC.ERROR_ZERO_PRICE');
this.loading.set(false);
this.uploadProgress.set(100);
this.step.set('quote');
return;
}
// Update URL with session ID without reloading
if (res.sessionId) {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { session: res.sessionId },
queryParamsHandling: 'merge', // merge with existing params like 'mode' if any
replaceUrl: true // prevent cluttering history, or false if we want back button to work. replaceUrl seems safer for "state update"
});
}
this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(res);
this.loading.set(false);
this.uploadProgress.set(100);
this.step.set('quote');
// Update URL with session ID without reloading
if (res.sessionId) {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { session: res.sessionId },
queryParamsHandling: 'merge', // merge with existing params like 'mode' if any
replaceUrl: true, // prevent cluttering history, or false if we want back button to work. replaceUrl seems safer for "state update"
});
}
}
},
error: () => {
this.setQuoteError('CALC.ERROR_GENERIC');
this.loading.set(false);
}
},
});
}
@@ -214,7 +240,7 @@ export class CalculatorPageComponent implements OnInit {
if (res && res.sessionId) {
this.router.navigate(
['/', this.languageService.selectedLang(), 'checkout'],
{ queryParams: { session: res.sessionId } }
{ queryParams: { session: res.sessionId } },
);
} else {
console.error('No session ID found in quote result');
@@ -226,59 +252,67 @@ export class CalculatorPageComponent implements OnInit {
this.step.set('quote');
}
onItemChange(event: {id?: string, index: number, fileName: string, quantity: number}) {
// 1. Update local form for consistency (UI feedback)
if (this.uploadForm) {
this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity);
this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity);
}
// 2. Update backend session if ID exists
if (event.id) {
const currentSessionId = this.result()?.sessionId;
if (!currentSessionId) return;
onItemChange(event: {
id?: string;
index: number;
fileName: string;
quantity: number;
}) {
// 1. Update local form for consistency (UI feedback)
if (this.uploadForm) {
this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity);
this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity);
}
this.estimator.updateLineItem(event.id, { quantity: event.quantity }).subscribe({
next: () => {
// 3. Fetch the updated session totals from the backend
this.estimator.getQuoteSession(currentSessionId).subscribe({
next: (sessionData) => {
const newResult = this.estimator.mapSessionToQuoteResult(sessionData);
// Preserve notes
newResult.notes = this.result()?.notes;
// 2. Update backend session if ID exists
if (event.id) {
const currentSessionId = this.result()?.sessionId;
if (!currentSessionId) return;
if (this.isInvalidQuote(newResult)) {
this.setQuoteError('CALC.ERROR_ZERO_PRICE');
return;
}
this.estimator
.updateLineItem(event.id, { quantity: event.quantity })
.subscribe({
next: () => {
// 3. Fetch the updated session totals from the backend
this.estimator.getQuoteSession(currentSessionId).subscribe({
next: (sessionData) => {
const newResult =
this.estimator.mapSessionToQuoteResult(sessionData);
// Preserve notes
newResult.notes = this.result()?.notes;
this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(newResult);
},
error: (err) => {
console.error('Failed to refresh session totals', err);
}
});
if (this.isInvalidQuote(newResult)) {
this.setQuoteError('CALC.ERROR_ZERO_PRICE');
return;
}
this.error.set(false);
this.errorKey.set('CALC.ERROR_GENERIC');
this.result.set(newResult);
},
error: (err) => {
console.error('Failed to update line item', err);
}
});
}
console.error('Failed to refresh session totals', err);
},
});
},
error: (err) => {
console.error('Failed to update line item', err);
},
});
}
}
onSubmitOrder(orderData: any) {
console.log('Order Submitted:', orderData);
this.orderSuccess.set(true);
this.step.set('success');
this.step.set('success');
}
onNewQuote() {
this.step.set('upload');
this.result.set(null);
this.orderSuccess.set(false);
this.mode.set('easy'); // Reset to default
this.step.set('upload');
this.result.set(null);
this.orderSuccess.set(false);
this.mode.set('easy'); // Reset to default
}
private currentRequest: QuoteRequest | null = null;
@@ -290,25 +324,25 @@ export class CalculatorPageComponent implements OnInit {
let details = `Richiesta Preventivo:\n`;
details += `- Materiale: ${req.material}\n`;
details += `- Qualità: ${req.quality}\n`;
details += `- File:\n`;
req.items.forEach(item => {
details += ` * ${item.file.name} (Qtà: ${item.quantity}`;
if (item.color) {
details += `, Colore: ${item.color}`;
}
details += `)\n`;
req.items.forEach((item) => {
details += ` * ${item.file.name} (Qtà: ${item.quantity}`;
if (item.color) {
details += `, Colore: ${item.color}`;
}
details += `)\n`;
});
if (req.mode === 'advanced') {
if (req.infillDensity) details += `- Infill: ${req.infillDensity}%\n`;
if (req.infillDensity) details += `- Infill: ${req.infillDensity}%\n`;
}
if (req.notes) details += `\nNote: ${req.notes}`;
this.estimator.setPendingConsultation({
files: req.items.map(i => i.file),
message: details
files: req.items.map((i) => i.file),
message: details,
});
this.router.navigate(['/', this.languageService.selectedLang(), 'contact']);

View File

@@ -4,5 +4,9 @@ import { CalculatorPageComponent } from './calculator-page.component';
export const CALCULATOR_ROUTES: Routes = [
{ path: '', redirectTo: 'basic', pathMatch: 'full' },
{ path: 'basic', component: CalculatorPageComponent, data: { mode: 'easy' } },
{ path: 'advanced', component: CalculatorPageComponent, data: { mode: 'advanced' } }
{
path: 'advanced',
component: CalculatorPageComponent,
data: { mode: 'advanced' },
},
];

View File

@@ -1,14 +1,15 @@
<app-card>
<h3 class="title">{{ 'CALC.RESULT' | translate }}</h3>
<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
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">
@@ -21,19 +22,26 @@
</div>
<div class="setup-note">
<small>{{ 'CALC.SETUP_NOTE' | translate:{cost: (result().setupCost | currency:result().currency)} }}</small><br>
<small class="shipping-note" style="color: #666;">{{ 'CALC.SHIPPING_NOTE' | translate }}</small>
<small>{{
"CALC.SETUP_NOTE"
| translate
: { cost: (result().setupCost | currency: result().currency) }
}}</small
><br />
<small class="shipping-note" style="color: #666">{{
"CALC.SHIPPING_NOTE" | translate
}}</small>
</div>
@if (result().notes) {
<div class="notes-section">
<label>{{ 'CALC.NOTES' | translate }}:</label>
<p>{{ result().notes }}</p>
<label>{{ "CALC.NOTES" | translate }}:</label>
<p>{{ result().notes }}</p>
</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) {
@@ -41,33 +49,41 @@
<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
{{ 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>{{ 'CHECKOUT.QTY' | translate }}:</label>
<input
type="number"
min="1"
[max]="maxInputQuantity"
[ngModel]="item.quantity"
(ngModelChange)="updateQuantity(i, $event)"
(blur)="flushQuantityUpdate(i)"
class="qty-input">
</div>
<div class="item-price">
<span class="item-total-price">
{{ (item.unitPrice * item.quantity) | currency:result().currency }}
</span>
<small class="item-unit-price" *ngIf="item.quantity > 1; else unitPricePlaceholder">
{{ item.unitPrice | currency:result().currency }} {{ 'CHECKOUT.PER_PIECE' | translate }}
</small>
<ng-template #unitPricePlaceholder>
<small class="item-unit-price item-unit-price--placeholder">&nbsp;</small>
</ng-template>
</div>
<div class="qty-control">
<label>{{ "CHECKOUT.QTY" | translate }}:</label>
<input
type="number"
min="1"
[max]="maxInputQuantity"
[ngModel]="item.quantity"
(ngModelChange)="updateQuantity(i, $event)"
(blur)="flushQuantityUpdate(i)"
class="qty-input"
/>
</div>
<div class="item-price">
<span class="item-total-price">
{{ item.unitPrice * item.quantity | currency: result().currency }}
</span>
<small
class="item-unit-price"
*ngIf="item.quantity > 1; else unitPricePlaceholder"
>
{{ item.unitPrice | currency: result().currency }}
{{ "CHECKOUT.PER_PIECE" | translate }}
</small>
<ng-template #unitPricePlaceholder>
<small class="item-unit-price item-unit-price--placeholder"
>&nbsp;</small
>
</ng-template>
</div>
</div>
</div>
}
@@ -75,15 +91,17 @@
<div class="actions">
<app-button variant="outline" (click)="consult.emit()">
{{ 'QUOTE.CONSULT' | translate }}
{{ "QUOTE.CONSULT" | translate }}
</app-button>
@if (!hasQuantityOverLimit()) {
<app-button (click)="proceed.emit()">
{{ 'QUOTE.PROCEED_ORDER' | translate }}
{{ "QUOTE.PROCEED_ORDER" | translate }}
</app-button>
} @else {
<small class="limit-note">{{ 'QUOTE.MAX_QTY_NOTICE' | translate:{ max: directOrderLimit } }}</small>
<small class="limit-note">{{
"QUOTE.MAX_QTY_NOTICE" | translate: { max: directOrderLimit }
}}</small>
}
</div>
</app-card>

View File

@@ -1,86 +1,105 @@
.title { margin-bottom: var(--space-6); text-align: center; }
.title {
margin-bottom: var(--space-6);
text-align: center;
}
.divider {
height: 1px;
background: var(--color-border);
margin: var(--space-4) 0;
.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);
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);
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 */
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); }
.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);
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); }
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); }
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;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
min-height: 2.1rem;
font-weight: 600;
min-width: 60px;
text-align: right;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
min-height: 2.1rem;
}
.item-total-price {
line-height: 1.1;
line-height: 1.1;
}
.item-unit-price {
margin-top: 2px;
font-size: 0.72rem;
font-weight: 400;
color: var(--color-text-muted);
line-height: 1.2;
margin-top: 2px;
font-size: 0.72rem;
font-weight: 400;
color: var(--color-text-muted);
line-height: 1.2;
}
.item-unit-price--placeholder {
visibility: hidden;
visibility: hidden;
}
.result-grid {
@@ -88,50 +107,56 @@
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);
@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;
.full-width {
grid-column: span 2;
}
.actions { display: flex; flex-direction: column; gap: var(--space-3); }
.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);
}
.limit-note {
font-size: 0.8rem;
color: var(--color-text-muted);
text-align: center;
margin-top: calc(var(--space-2) * -1);
font-size: 0.8rem;
color: var(--color-text-muted);
text-align: center;
margin-top: calc(var(--space-2) * -1);
}
.notes-section {
margin-top: var(--space-4);
margin-bottom: var(--space-4);
padding: var(--space-3);
background: var(--color-neutral-50);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
label {
font-weight: 500;
font-size: 0.9rem;
color: var(--color-text-muted);
display: block;
margin-bottom: var(--space-2);
}
p {
margin: 0;
font-size: 0.95rem;
color: var(--color-text);
white-space: pre-wrap; /* Preserve line breaks */
}
margin-top: var(--space-4);
margin-bottom: var(--space-4);
padding: var(--space-3);
background: var(--color-neutral-50);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
label {
font-weight: 500;
font-size: 0.9rem;
color: var(--color-text-muted);
display: block;
margin-bottom: var(--space-2);
}
p {
margin: 0;
font-size: 0.95rem;
color: var(--color-text);
white-space: pre-wrap; /* Preserve line breaks */
}
}

View File

@@ -1,4 +1,12 @@
import { Component, OnDestroy, input, output, signal, computed, effect } from '@angular/core';
import {
Component,
OnDestroy,
input,
output,
signal,
computed,
effect,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
@@ -10,9 +18,16 @@ import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
@Component({
selector: 'app-quote-result',
standalone: true,
imports: [CommonModule, FormsModule, TranslateModule, AppCardComponent, AppButtonComponent, SummaryCardComponent],
imports: [
CommonModule,
FormsModule,
TranslateModule,
AppCardComponent,
AppButtonComponent,
SummaryCardComponent,
],
templateUrl: './quote-result.component.html',
styleUrl: './quote-result.component.scss'
styleUrl: './quote-result.component.scss',
})
export class QuoteResultComponent implements OnDestroy {
readonly maxInputQuantity = 500;
@@ -22,7 +37,12 @@ export class QuoteResultComponent implements OnDestroy {
result = input.required<QuoteResult>();
consult = output<void>();
proceed = output<void>();
itemChange = output<{id?: string, index: number, fileName: string, quantity: number}>();
itemChange = output<{
id?: string;
index: number;
fileName: string;
quantity: number;
}>();
// Local mutable state for items to handle quantity changes
items = signal<QuoteItem[]>([]);
@@ -30,120 +50,124 @@ export class QuoteResultComponent implements OnDestroy {
private quantityTimers = new Map<string, ReturnType<typeof setTimeout>>();
constructor() {
effect(() => {
this.clearAllQuantityTimers();
effect(
() => {
this.clearAllQuantityTimers();
// Initialize local items when result inputs change
// We map to new objects to avoid mutating the input directly if it was a reference
const nextItems = this.result().items.map(i => ({...i}));
this.items.set(nextItems);
// Initialize local items when result inputs change
// We map to new objects to avoid mutating the input directly if it was a reference
const nextItems = this.result().items.map((i) => ({ ...i }));
this.items.set(nextItems);
this.lastSentQuantities.clear();
nextItems.forEach(item => {
const key = item.id ?? item.fileName;
this.lastSentQuantities.set(key, item.quantity);
});
}, { allowSignalWrites: true });
this.lastSentQuantities.clear();
nextItems.forEach((item) => {
const key = item.id ?? item.fileName;
this.lastSentQuantities.set(key, item.quantity);
});
},
{ allowSignalWrites: true },
);
}
ngOnDestroy(): void {
this.clearAllQuantityTimers();
this.clearAllQuantityTimers();
}
updateQuantity(index: number, newQty: number | string) {
const normalizedQty = this.normalizeQuantity(newQty);
if (normalizedQty === null) return;
const normalizedQty = this.normalizeQuantity(newQty);
if (normalizedQty === null) return;
const item = this.items()[index];
if (!item) return;
const key = item.id ?? item.fileName;
const item = this.items()[index];
if (!item) return;
const key = item.id ?? item.fileName;
this.items.update(current => {
const updated = [...current];
updated[index] = { ...updated[index], quantity: normalizedQty };
return updated;
});
this.items.update((current) => {
const updated = [...current];
updated[index] = { ...updated[index], quantity: normalizedQty };
return updated;
});
this.scheduleQuantityRefresh(index, key);
this.scheduleQuantityRefresh(index, key);
}
flushQuantityUpdate(index: number): void {
const item = this.items()[index];
if (!item) return;
const item = this.items()[index];
if (!item) return;
const key = item.id ?? item.fileName;
this.clearQuantityRefreshTimer(key);
const key = item.id ?? item.fileName;
this.clearQuantityRefreshTimer(key);
const normalizedQty = this.normalizeQuantity(item.quantity);
if (normalizedQty === null) return;
const normalizedQty = this.normalizeQuantity(item.quantity);
if (normalizedQty === null) return;
if (this.lastSentQuantities.get(key) === normalizedQty) {
return;
}
if (this.lastSentQuantities.get(key) === normalizedQty) {
return;
}
this.itemChange.emit({
id: item.id,
index,
fileName: item.fileName,
quantity: normalizedQty
});
this.lastSentQuantities.set(key, normalizedQty);
this.itemChange.emit({
id: item.id,
index,
fileName: item.fileName,
quantity: normalizedQty,
});
this.lastSentQuantities.set(key, normalizedQty);
}
hasQuantityOverLimit = computed(() => this.items().some(item => item.quantity > this.directOrderLimit));
hasQuantityOverLimit = computed(() =>
this.items().some((item) => item.quantity > this.directOrderLimit),
);
totals = computed(() => {
const currentItems = this.items();
const setup = this.result().setupCost;
let price = setup;
let time = 0;
let weight = 0;
currentItems.forEach(i => {
price += i.unitPrice * i.quantity;
time += i.unitTime * i.quantity;
weight += i.unitWeight * i.quantity;
});
const hours = Math.floor(time / 3600);
const minutes = Math.ceil((time % 3600) / 60);
return {
price: Math.round(price * 100) / 100,
hours,
minutes,
weight: Math.ceil(weight)
};
const currentItems = this.items();
const setup = this.result().setupCost;
let price = setup;
let time = 0;
let weight = 0;
currentItems.forEach((i) => {
price += i.unitPrice * i.quantity;
time += i.unitTime * i.quantity;
weight += i.unitWeight * i.quantity;
});
const hours = Math.floor(time / 3600);
const minutes = Math.ceil((time % 3600) / 60);
return {
price: Math.round(price * 100) / 100,
hours,
minutes,
weight: Math.ceil(weight),
};
});
private normalizeQuantity(newQty: number | string): number | null {
const qty = typeof newQty === 'string' ? parseInt(newQty, 10) : newQty;
if (!Number.isFinite(qty) || qty < 1) {
return null;
}
return Math.min(qty, this.maxInputQuantity);
const qty = typeof newQty === 'string' ? parseInt(newQty, 10) : newQty;
if (!Number.isFinite(qty) || qty < 1) {
return null;
}
return Math.min(qty, this.maxInputQuantity);
}
private scheduleQuantityRefresh(index: number, key: string): void {
this.clearQuantityRefreshTimer(key);
const timer = setTimeout(() => {
this.quantityTimers.delete(key);
this.flushQuantityUpdate(index);
}, this.quantityAutoRefreshMs);
this.quantityTimers.set(key, timer);
this.clearQuantityRefreshTimer(key);
const timer = setTimeout(() => {
this.quantityTimers.delete(key);
this.flushQuantityUpdate(index);
}, this.quantityAutoRefreshMs);
this.quantityTimers.set(key, timer);
}
private clearQuantityRefreshTimer(key: string): void {
const timer = this.quantityTimers.get(key);
if (!timer) return;
clearTimeout(timer);
this.quantityTimers.delete(key);
const timer = this.quantityTimers.get(key);
if (!timer) return;
clearTimeout(timer);
this.quantityTimers.delete(key);
}
private clearAllQuantityTimers(): void {
this.quantityTimers.forEach(timer => clearTimeout(timer));
this.quantityTimers.clear();
this.quantityTimers.forEach((timer) => clearTimeout(timer));
this.quantityTimers.clear();
}
}

View File

@@ -1,95 +1,119 @@
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="section">
@if (selectedFile()) {
<div class="viewer-wrapper">
@if (!isStepFile(selectedFile())) {
<div class="step-warning">
<p>{{ 'CALC.STEP_WARNING' | translate }}</p>
</div>
} @else {
<app-stl-viewer
[file]="selectedFile()"
[color]="getSelectedFileColor()">
</app-stl-viewer>
}
<!-- Close button removed as requested -->
@if (!isStepFile(selectedFile())) {
<div class="step-warning">
<p>{{ "CALC.STEP_WARNING" | translate }}</p>
</div>
} @else {
<app-stl-viewer
[file]="selectedFile()"
[color]="getSelectedFileColor()"
>
</app-stl-viewer>
}
<!-- Close button removed as requested -->
</div>
}
<!-- Initial Dropzone (Visible only when no files) -->
@if (items().length === 0) {
<app-dropzone
[label]="'CALC.UPLOAD_LABEL'"
[subtext]="'CALC.UPLOAD_SUB'"
[accept]="acceptedFormats"
[multiple]="true"
(filesDropped)="onFilesDropped($event)">
</app-dropzone>
<app-dropzone
[label]="'CALC.UPLOAD_LABEL'"
[subtext]="'CALC.UPLOAD_SUB'"
[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="items-grid">
@for (item of items(); track item.file.name; let i = $index) {
<div
class="file-card"
[class.active]="item.file === selectedFile()"
(click)="selectFile(item.file)"
>
<div class="card-header">
<span class="file-name" [title]="item.file.name">{{
item.file.name
}}</span>
</div>
<div class="card-body">
<div class="card-controls">
<div class="qty-group">
<label>{{ 'CALC.QTY_SHORT' | translate }}</label>
<input
type="number"
min="1"
[value]="item.quantity"
(change)="updateItemQuantity(i, $event)"
class="qty-input"
(click)="$event.stopPropagation()">
</div>
<div class="color-group">
<label>{{ 'CALC.COLOR_LABEL' | translate }}</label>
<app-color-selector
[selectedColor]="item.color"
[selectedVariantId]="item.filamentVariantId ?? null"
[variants]="currentMaterialVariants()"
(colorSelected)="updateItemColor(i, $event)">
</app-color-selector>
</div>
</div>
<button
type="button"
class="btn-remove"
(click)="removeItem(i); $event.stopPropagation()"
[attr.title]="'CALC.REMOVE_FILE' | translate">
X
</button>
</div>
<div class="card-body">
<div class="card-controls">
<div class="qty-group">
<label>{{ "CALC.QTY_SHORT" | translate }}</label>
<input
type="number"
min="1"
[value]="item.quantity"
(change)="updateItemQuantity(i, $event)"
class="qty-input"
(click)="$event.stopPropagation()"
/>
</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)">
<div class="color-group">
<label>{{ "CALC.COLOR_LABEL" | translate }}</label>
<app-color-selector
[selectedColor]="item.color"
[selectedVariantId]="item.filamentVariantId ?? null"
[variants]="currentMaterialVariants()"
(colorSelected)="updateItemColor(i, $event)"
>
</app-color-selector>
</div>
</div>
<button type="button" class="btn-add-more" (click)="additionalInput.click()">
+ {{ 'CALC.ADD_FILES' | translate }}
</button>
</div>
<button
type="button"
class="btn-remove"
(click)="removeItem(i); $event.stopPropagation()"
[attr.title]="'CALC.REMOVE_FILE' | translate"
>
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>
@if (items().length === 0 && form.get("itemsTouched")?.value) {
<div class="error-msg">{{ "CALC.ERR_FILE_REQUIRED" | translate }}</div>
}
<p class="upload-privacy-note">
{{ 'LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX' | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.UPLOAD_NOTICE_LINK' | translate }}</a>.
{{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate
}}</a
>.
</p>
</div>
@@ -100,51 +124,50 @@
[options]="materials()"
></app-select>
@if (mode() === 'easy') {
<app-select
formControlName="quality"
[label]="'CALC.QUALITY' | translate"
[options]="qualities()"
></app-select>
@if (mode() === "easy") {
<app-select
formControlName="quality"
[label]="'CALC.QUALITY' | translate"
[options]="qualities()"
></app-select>
} @else {
<app-select
formControlName="nozzleDiameter"
[label]="'CALC.NOZZLE' | translate"
[options]="nozzleDiameters()"
></app-select>
<app-select
formControlName="nozzleDiameter"
[label]="'CALC.NOZZLE' | translate"
[options]="nozzleDiameters()"
></app-select>
}
</div>
<!-- Global quantity removed, now per item -->
@if (mode() === 'advanced') {
@if (mode() === "advanced") {
<div class="grid">
<app-select
formControlName="infillPattern"
[label]="'CALC.PATTERN' | translate"
[options]="infillPatterns()"
></app-select>
<app-select
formControlName="infillPattern"
[label]="'CALC.PATTERN' | translate"
[options]="infillPatterns()"
></app-select>
<app-select
formControlName="layerHeight"
[label]="'CALC.LAYER_HEIGHT' | translate"
[options]="layerHeights()"
></app-select>
<app-select
formControlName="layerHeight"
[label]="'CALC.LAYER_HEIGHT' | translate"
[options]="layerHeights()"
></app-select>
</div>
<div class="grid">
<app-input
formControlName="infillDensity"
type="number"
[label]="'CALC.INFILL' | translate"
></app-input>
<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 class="checkbox-row">
<input type="checkbox" formControlName="supportEnabled" id="support" />
<label for="support">{{ "CALC.SUPPORT" | translate }}</label>
</div>
</div>
}
<app-input
@@ -156,18 +179,25 @@
<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 class="progress-container">
<div class="progress-bar">
<div class="progress-fill" [style.width.%]="uploadProgress()"></div>
</div>
</div>
}
<app-button
type="submit"
[disabled]="items().length === 0 || loading()"
[fullWidth]="true">
{{ loading() ? (uploadProgress() < 100 ? ('CALC.UPLOADING' | translate) : ('CALC.PROCESSING' | translate)) : ('CALC.CALCULATE' | translate) }}
[fullWidth]="true"
>
{{
loading()
? uploadProgress() < 100
? ("CALC.UPLOADING" | translate)
: ("CALC.PROCESSING" | translate)
: ("CALC.CALCULATE" | translate)
}}
</app-button>
</div>
</form>

View File

@@ -1,226 +1,246 @@
.section { margin-bottom: var(--space-6); }
.section {
margin-bottom: var(--space-6);
}
.upload-privacy-note {
margin-top: var(--space-3);
margin-bottom: 0;
font-size: 0.78rem;
color: var(--color-text-muted);
text-align: left;
margin-top: var(--space-3);
margin-bottom: 0;
font-size: 0.78rem;
color: var(--color-text-muted);
text-align: left;
}
.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; }
.grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-4);
.viewer-wrapper { position: relative; margin-bottom: var(--space-4); }
@media (min-width: 640px) {
grid-template-columns: 1fr 1fr;
}
}
.actions {
margin-top: var(--space-6);
}
.error-msg {
color: var(--color-danger-500);
font-size: 0.875rem;
margin-top: var(--space-2);
text-align: center;
}
.viewer-wrapper {
position: relative;
margin-bottom: var(--space-4);
}
/* Grid Layout for Files */
.items-grid {
display: grid;
grid-template-columns: 1fr 1fr; /* Force 2 columns on mobile */
gap: var(--space-2); /* Tighten gap for mobile */
margin-top: var(--space-4);
margin-bottom: var(--space-4);
@media(min-width: 640px) {
gap: var(--space-3);
}
display: grid;
grid-template-columns: 1fr 1fr; /* Force 2 columns on mobile */
gap: var(--space-2); /* Tighten gap for mobile */
margin-top: var(--space-4);
margin-bottom: var(--space-4);
@media (min-width: 640px) {
gap: var(--space-3);
}
}
.file-card {
padding: var(--space-2); /* Reduced from space-3 */
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition: all 0.2s;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 4px; /* Reduced gap */
position: relative; /* For absolute positioning of remove btn */
min-width: 0; /* Allow flex item to shrink below content size if needed */
&:hover { border-color: var(--color-neutral-300); }
&.active {
border-color: var(--color-brand);
background: rgba(250, 207, 10, 0.05);
box-shadow: 0 0 0 1px var(--color-brand);
}
padding: var(--space-2); /* Reduced from space-3 */
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition: all 0.2s;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 4px; /* Reduced gap */
position: relative; /* For absolute positioning of remove btn */
min-width: 0; /* Allow flex item to shrink below content size if needed */
&:hover {
border-color: var(--color-neutral-300);
}
&.active {
border-color: var(--color-brand);
background: rgba(250, 207, 10, 0.05);
box-shadow: 0 0 0 1px var(--color-brand);
}
}
.card-header {
overflow: hidden;
padding-right: 25px; /* Adjusted */
margin-bottom: 2px;
overflow: hidden;
padding-right: 25px; /* Adjusted */
margin-bottom: 2px;
}
.file-name {
font-weight: 500;
font-size: 0.8rem; /* Smaller font */
color: var(--color-text);
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.file-name {
font-weight: 500;
font-size: 0.8rem; /* Smaller font */
color: var(--color-text);
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-body {
display: flex;
align-items: center;
padding-top: 0;
display: flex;
align-items: center;
padding-top: 0;
}
.card-controls {
display: flex;
align-items: flex-end; /* Align bottom of input and color circle */
gap: 16px; /* Space between Qty and Color */
width: 100%;
display: flex;
align-items: flex-end; /* Align bottom of input and color circle */
gap: 16px; /* Space between Qty and Color */
width: 100%;
}
.qty-group, .color-group {
display: flex;
flex-direction: column; /* Stack label and input */
align-items: flex-start;
gap: 0px;
label {
font-size: 0.6rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
margin-bottom: 2px;
}
.qty-group,
.color-group {
display: flex;
flex-direction: column; /* Stack label and input */
align-items: flex-start;
gap: 0px;
label {
font-size: 0.6rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
margin-bottom: 2px;
}
}
.color-group {
align-items: flex-start; /* Align label left */
/* margin-right removed */
/* Override margin in selector for this context */
::ng-deep .color-selector-container {
margin-left: 0;
}
align-items: flex-start; /* Align label left */
/* margin-right removed */
/* Override margin in selector for this context */
::ng-deep .color-selector-container {
margin-left: 0;
}
}
.qty-input {
width: 36px; /* Slightly smaller */
padding: 1px 2px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
text-align: center;
font-size: 0.85rem;
background: white;
height: 24px; /* Explicit height to match color circle somewhat */
&:focus { outline: none; border-color: var(--color-brand); }
width: 36px; /* Slightly smaller */
padding: 1px 2px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
text-align: center;
font-size: 0.85rem;
background: white;
height: 24px; /* Explicit height to match color circle somewhat */
&:focus {
outline: none;
border-color: var(--color-brand);
}
}
.btn-remove {
position: absolute;
top: 4px;
right: 4px;
width: 18px;
height: 18px;
border-radius: 4px;
border: none;
background: transparent;
color: var(--color-text-muted);
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
font-size: 0.8rem;
&:hover {
background: var(--color-danger-100);
color: var(--color-danger-500);
}
position: absolute;
top: 4px;
right: 4px;
width: 18px;
height: 18px;
border-radius: 4px;
border: none;
background: transparent;
color: var(--color-text-muted);
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
font-size: 0.8rem;
&:hover {
background: var(--color-danger-100);
color: var(--color-danger-500);
}
}
/* Prominent Add Button */
.add-more-container {
margin-top: var(--space-2);
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); }
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;
}
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%;
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%;
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);
height: 100%;
background: var(--color-brand);
}
.step-warning {
display: flex;
justify-content: center;
align-items: center;
height: 300px;
background: var(--color-neutral-100);
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-4);
text-align: center;
color: var(--color-text-muted);
font-weight: 500;
display: flex;
justify-content: center;
align-items: center;
height: 300px;
background: var(--color-neutral-100);
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-4);
text-align: center;
color: var(--color-text-muted);
font-weight: 500;
}

View File

@@ -1,6 +1,18 @@
import { Component, input, output, signal, OnInit, inject } from '@angular/core';
import {
Component,
input,
output,
signal,
OnInit,
inject,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import {
ReactiveFormsModule,
FormBuilder,
FormGroup,
Validators,
} from '@angular/forms';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
import { AppSelectComponent } from '../../../../shared/components/app-select/app-select.component';
@@ -8,22 +20,39 @@ import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component';
import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component';
import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, MaterialOption, VariantOption } from '../../services/quote-estimator.service';
import {
QuoteRequest,
QuoteEstimatorService,
OptionsResponse,
SimpleOption,
MaterialOption,
VariantOption,
} from '../../services/quote-estimator.service';
import { getColorHex } from '../../../../core/constants/colors.const';
interface FormItem {
file: File;
quantity: number;
color: string;
filamentVariantId?: number;
file: File;
quantity: number;
color: string;
filamentVariantId?: number;
}
@Component({
selector: 'app-upload-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppSelectComponent, AppDropzoneComponent, AppButtonComponent, StlViewerComponent, ColorSelectorComponent],
imports: [
CommonModule,
ReactiveFormsModule,
TranslateModule,
AppInputComponent,
AppSelectComponent,
AppDropzoneComponent,
AppButtonComponent,
StlViewerComponent,
ColorSelectorComponent,
],
templateUrl: './upload-form.component.html',
styleUrl: './upload-form.component.scss'
styleUrl: './upload-form.component.scss',
})
export class UploadFormComponent implements OnInit {
mode = input<'easy' | 'advanced'>('easy');
@@ -55,22 +84,22 @@ export class UploadFormComponent implements OnInit {
currentMaterialVariants = signal<VariantOption[]>([]);
private updateVariants() {
const matCode = this.form.get('material')?.value;
if (matCode && this.fullMaterialOptions.length > 0) {
const found = this.fullMaterialOptions.find(m => m.code === matCode);
this.currentMaterialVariants.set(found ? found.variants : []);
this.syncItemVariantSelections();
} else {
this.currentMaterialVariants.set([]);
}
const matCode = this.form.get('material')?.value;
if (matCode && this.fullMaterialOptions.length > 0) {
const found = this.fullMaterialOptions.find((m) => m.code === matCode);
this.currentMaterialVariants.set(found ? found.variants : []);
this.syncItemVariantSelections();
} else {
this.currentMaterialVariants.set([]);
}
}
acceptedFormats = '.stl,.3mf,.step,.stp';
isStepFile(file: File | null): boolean {
if (!file) return false;
const name = file.name.toLowerCase();
return name.endsWith('.stl');
if (!file) return false;
const name = file.name.toLowerCase();
return name.endsWith('.stl');
}
constructor() {
@@ -85,78 +114,140 @@ export class UploadFormComponent implements OnInit {
layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]],
nozzleDiameter: [0.4, Validators.required],
infillPattern: ['grid'],
supportEnabled: [false]
supportEnabled: [false],
});
// Listen to material changes to update variants
this.form.get('material')?.valueChanges.subscribe(() => {
this.updateVariants();
this.updateVariants();
});
this.form.get('quality')?.valueChanges.subscribe((quality) => {
if (this.mode() !== 'easy' || this.isPatchingSettings) return;
this.applyAdvancedPresetFromQuality(quality);
if (this.mode() !== 'easy' || this.isPatchingSettings) return;
this.applyAdvancedPresetFromQuality(quality);
});
}
private applyAdvancedPresetFromQuality(quality: string | null | undefined) {
const normalized = (quality || 'standard').toLowerCase();
const normalized = (quality || 'standard').toLowerCase();
const presets: Record<string, { nozzleDiameter: number; layerHeight: number; infillDensity: number; infillPattern: string }> = {
standard: { nozzleDiameter: 0.4, layerHeight: 0.2, infillDensity: 15, infillPattern: 'grid' },
extra_fine: { nozzleDiameter: 0.4, layerHeight: 0.12, infillDensity: 20, infillPattern: 'grid' },
high: { nozzleDiameter: 0.4, layerHeight: 0.12, infillDensity: 20, infillPattern: 'grid' }, // Legacy alias
draft: { nozzleDiameter: 0.4, layerHeight: 0.24, infillDensity: 12, infillPattern: 'grid' }
};
const presets: Record<
string,
{
nozzleDiameter: number;
layerHeight: number;
infillDensity: number;
infillPattern: string;
}
> = {
standard: {
nozzleDiameter: 0.4,
layerHeight: 0.2,
infillDensity: 15,
infillPattern: 'grid',
},
extra_fine: {
nozzleDiameter: 0.4,
layerHeight: 0.12,
infillDensity: 20,
infillPattern: 'grid',
},
high: {
nozzleDiameter: 0.4,
layerHeight: 0.12,
infillDensity: 20,
infillPattern: 'grid',
}, // Legacy alias
draft: {
nozzleDiameter: 0.4,
layerHeight: 0.24,
infillDensity: 12,
infillPattern: 'grid',
},
};
const preset = presets[normalized] || presets['standard'];
this.form.patchValue(preset, { emitEvent: false });
const preset = presets[normalized] || presets['standard'];
this.form.patchValue(preset, { emitEvent: false });
}
ngOnInit() {
this.estimator.getOptions().subscribe({
next: (options: OptionsResponse) => {
this.fullMaterialOptions = options.materials;
this.updateVariants(); // Trigger initial update
this.estimator.getOptions().subscribe({
next: (options: OptionsResponse) => {
this.fullMaterialOptions = options.materials;
this.updateVariants(); // Trigger initial update
this.materials.set(options.materials.map(m => ({ label: m.label, value: m.code })));
this.qualities.set(options.qualities.map(q => ({ label: q.label, value: q.id })));
this.infillPatterns.set(options.infillPatterns.map(p => ({ label: p.label, value: p.id })));
this.layerHeights.set(options.layerHeights.map(l => ({ label: l.label, value: l.value })));
this.nozzleDiameters.set(options.nozzleDiameters.map(n => ({ label: n.label, value: n.value })));
this.materials.set(
options.materials.map((m) => ({ label: m.label, value: m.code })),
);
this.qualities.set(
options.qualities.map((q) => ({ label: q.label, value: q.id })),
);
this.infillPatterns.set(
options.infillPatterns.map((p) => ({ label: p.label, value: p.id })),
);
this.layerHeights.set(
options.layerHeights.map((l) => ({ label: l.label, value: l.value })),
);
this.nozzleDiameters.set(
options.nozzleDiameters.map((n) => ({
label: n.label,
value: n.value,
})),
);
this.setDefaults();
this.setDefaults();
},
error: (err) => {
console.error('Failed to load options', err);
// Fallback for debugging/offline dev
this.materials.set([
{
label: this.translate.instant('CALC.FALLBACK_MATERIAL'),
value: 'PLA',
},
error: (err) => {
console.error('Failed to load options', err);
// Fallback for debugging/offline dev
this.materials.set([{ label: this.translate.instant('CALC.FALLBACK_MATERIAL'), value: 'PLA' }]);
this.qualities.set([{ label: this.translate.instant('CALC.FALLBACK_QUALITY_STANDARD'), value: 'standard' }]);
this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]);
this.setDefaults();
}
});
]);
this.qualities.set([
{
label: this.translate.instant('CALC.FALLBACK_QUALITY_STANDARD'),
value: 'standard',
},
]);
this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]);
this.setDefaults();
},
});
}
private setDefaults() {
// Set Defaults if available
if (this.materials().length > 0 && !this.form.get('material')?.value) {
this.form.get('material')?.setValue(this.materials()[0].value);
}
if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
// Try to find 'standard' or use first
const std = this.qualities().find(q => q.value === 'standard');
this.form.get('quality')?.setValue(std ? std.value : this.qualities()[0].value);
}
if (this.nozzleDiameters().length > 0 && !this.form.get('nozzleDiameter')?.value) {
this.form.get('nozzleDiameter')?.setValue(0.4); // Prefer 0.4
}
if (this.layerHeights().length > 0 && !this.form.get('layerHeight')?.value) {
this.form.get('layerHeight')?.setValue(0.2); // Prefer 0.2
}
if (this.infillPatterns().length > 0 && !this.form.get('infillPattern')?.value) {
this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value);
}
// Set Defaults if available
if (this.materials().length > 0 && !this.form.get('material')?.value) {
this.form.get('material')?.setValue(this.materials()[0].value);
}
if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
// Try to find 'standard' or use first
const std = this.qualities().find((q) => q.value === 'standard');
this.form
.get('quality')
?.setValue(std ? std.value : this.qualities()[0].value);
}
if (
this.nozzleDiameters().length > 0 &&
!this.form.get('nozzleDiameter')?.value
) {
this.form.get('nozzleDiameter')?.setValue(0.4); // Prefer 0.4
}
if (
this.layerHeights().length > 0 &&
!this.form.get('layerHeight')?.value
) {
this.form.get('layerHeight')?.setValue(0.2); // Prefer 0.2
}
if (
this.infillPatterns().length > 0 &&
!this.form.get('infillPattern')?.value
) {
this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value);
}
}
onFilesDropped(newFiles: File[]) {
@@ -165,214 +256,233 @@ export class UploadFormComponent implements OnInit {
let hasError = false;
for (const file of newFiles) {
if (file.size > MAX_SIZE) {
hasError = true;
} else {
const defaultSelection = this.getDefaultVariantSelection();
validItems.push({
file,
quantity: 1,
color: defaultSelection.colorName,
filamentVariantId: defaultSelection.filamentVariantId
});
}
if (file.size > MAX_SIZE) {
hasError = true;
} else {
const defaultSelection = this.getDefaultVariantSelection();
validItems.push({
file,
quantity: 1,
color: defaultSelection.colorName,
filamentVariantId: defaultSelection.filamentVariantId,
});
}
}
if (hasError) {
alert(this.translate.instant('CALC.ERR_FILE_TOO_LARGE'));
alert(this.translate.instant('CALC.ERR_FILE_TOO_LARGE'));
}
if (validItems.length > 0) {
this.items.update(current => [...current, ...validItems]);
this.form.get('itemsTouched')?.setValue(true);
// Auto select last added
this.selectedFile.set(validItems[validItems.length - 1].file);
this.items.update((current) => [...current, ...validItems]);
this.form.get('itemsTouched')?.setValue(true);
// Auto select last added
this.selectedFile.set(validItems[validItems.length - 1].file);
}
}
onAdditionalFilesSelected(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.onFilesDropped(Array.from(input.files));
// Reset input so same files can be selected again if needed
input.value = '';
}
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.onFilesDropped(Array.from(input.files));
// Reset input so same files can be selected again if needed
input.value = '';
}
}
updateItemQuantityByIndex(index: number, quantity: number) {
if (!Number.isInteger(index) || index < 0) return;
const normalizedQty = this.normalizeQuantity(quantity);
if (!Number.isInteger(index) || index < 0) return;
const normalizedQty = this.normalizeQuantity(quantity);
this.items.update(current => {
if (index >= current.length) return current;
const updated = [...current];
updated[index] = { ...updated[index], quantity: normalizedQty };
return updated;
});
this.items.update((current) => {
if (index >= current.length) return current;
const updated = [...current];
updated[index] = { ...updated[index], quantity: normalizedQty };
return updated;
});
}
updateItemQuantityByName(fileName: string, quantity: number) {
const targetName = this.normalizeFileName(fileName);
const normalizedQty = this.normalizeQuantity(quantity);
const targetName = this.normalizeFileName(fileName);
const normalizedQty = this.normalizeQuantity(quantity);
this.items.update(current => {
let matched = false;
return current.map(item => {
if (!matched && this.normalizeFileName(item.file.name) === targetName) {
matched = true;
return { ...item, quantity: normalizedQty };
}
return item;
});
this.items.update((current) => {
let matched = false;
return current.map((item) => {
if (!matched && this.normalizeFileName(item.file.name) === targetName) {
matched = true;
return { ...item, quantity: normalizedQty };
}
return item;
});
});
}
selectFile(file: File) {
if (this.selectedFile() === file) {
// toggle off? no, keep active
} else {
this.selectedFile.set(file);
}
if (this.selectedFile() === file) {
// toggle off? no, keep active
} else {
this.selectedFile.set(file);
}
}
// Helper to get color of currently selected file
getSelectedFileColor(): string {
const file = this.selectedFile();
if (!file) return '#facf0a'; // Default
const file = this.selectedFile();
if (!file) return '#facf0a'; // Default
const item = this.items().find(i => i.file === file);
if (item) {
const vars = this.currentMaterialVariants();
if (vars && vars.length > 0) {
const found = item.filamentVariantId
? vars.find(v => v.id === item.filamentVariantId)
: vars.find(v => v.colorName === item.color);
if (found) return found.hexColor;
}
return getColorHex(item.color);
const item = this.items().find((i) => i.file === file);
if (item) {
const vars = this.currentMaterialVariants();
if (vars && vars.length > 0) {
const found = item.filamentVariantId
? vars.find((v) => v.id === item.filamentVariantId)
: vars.find((v) => v.colorName === item.color);
if (found) return found.hexColor;
}
return '#facf0a';
return getColorHex(item.color);
}
return '#facf0a';
}
updateItemQuantity(index: number, event: Event) {
const input = event.target as HTMLInputElement;
const parsed = parseInt(input.value, 10);
const quantity = Number.isFinite(parsed) ? parsed : 1;
this.updateItemQuantityByIndex(index, quantity);
const input = event.target as HTMLInputElement;
const parsed = parseInt(input.value, 10);
const quantity = Number.isFinite(parsed) ? parsed : 1;
this.updateItemQuantityByIndex(index, quantity);
}
updateItemColor(index: number, newSelection: string | { colorName: string; filamentVariantId?: number }) {
const colorName = typeof newSelection === 'string' ? newSelection : newSelection.colorName;
const filamentVariantId = typeof newSelection === 'string' ? undefined : newSelection.filamentVariantId;
this.items.update(current => {
const updated = [...current];
updated[index] = { ...updated[index], color: colorName, filamentVariantId };
return updated;
});
updateItemColor(
index: number,
newSelection: string | { colorName: string; filamentVariantId?: number },
) {
const colorName =
typeof newSelection === 'string' ? newSelection : newSelection.colorName;
const filamentVariantId =
typeof newSelection === 'string'
? undefined
: newSelection.filamentVariantId;
this.items.update((current) => {
const updated = [...current];
updated[index] = {
...updated[index],
color: colorName,
filamentVariantId,
};
return updated;
});
}
removeItem(index: number) {
this.items.update(current => {
const updated = [...current];
const removed = updated.splice(index, 1)[0];
if (this.selectedFile() === removed.file) {
this.selectedFile.set(null);
}
return updated;
});
this.items.update((current) => {
const updated = [...current];
const removed = updated.splice(index, 1)[0];
if (this.selectedFile() === removed.file) {
this.selectedFile.set(null);
}
return updated;
});
}
setFiles(files: File[]) {
const validItems: FormItem[] = [];
const defaultSelection = this.getDefaultVariantSelection();
for (const file of files) {
validItems.push({
file,
quantity: 1,
color: defaultSelection.colorName,
filamentVariantId: defaultSelection.filamentVariantId
});
}
const validItems: FormItem[] = [];
const defaultSelection = this.getDefaultVariantSelection();
for (const file of files) {
validItems.push({
file,
quantity: 1,
color: defaultSelection.colorName,
filamentVariantId: defaultSelection.filamentVariantId,
});
}
if (validItems.length > 0) {
this.items.set(validItems);
this.form.get('itemsTouched')?.setValue(true);
// Auto select last added
this.selectedFile.set(validItems[validItems.length - 1].file);
}
if (validItems.length > 0) {
this.items.set(validItems);
this.form.get('itemsTouched')?.setValue(true);
// Auto select last added
this.selectedFile.set(validItems[validItems.length - 1].file);
}
}
private getDefaultVariantSelection(): { colorName: string; filamentVariantId?: number } {
const vars = this.currentMaterialVariants();
if (vars && vars.length > 0) {
const preferred = vars.find(v => !v.isOutOfStock) || vars[0];
return {
colorName: preferred.colorName,
filamentVariantId: preferred.id
};
}
return { colorName: 'Black' };
private getDefaultVariantSelection(): {
colorName: string;
filamentVariantId?: number;
} {
const vars = this.currentMaterialVariants();
if (vars && vars.length > 0) {
const preferred = vars.find((v) => !v.isOutOfStock) || vars[0];
return {
colorName: preferred.colorName,
filamentVariantId: preferred.id,
};
}
return { colorName: 'Black' };
}
private syncItemVariantSelections(): void {
const vars = this.currentMaterialVariants();
if (!vars || vars.length === 0) {
return;
}
const vars = this.currentMaterialVariants();
if (!vars || vars.length === 0) {
return;
}
const fallback = vars.find(v => !v.isOutOfStock) || vars[0];
this.items.update(current => current.map(item => {
const byId = item.filamentVariantId != null
? vars.find(v => v.id === item.filamentVariantId)
: null;
const byColor = vars.find(v => v.colorName === item.color);
const selected = byId || byColor || fallback;
return {
...item,
color: selected.colorName,
filamentVariantId: selected.id
};
}));
const fallback = vars.find((v) => !v.isOutOfStock) || vars[0];
this.items.update((current) =>
current.map((item) => {
const byId =
item.filamentVariantId != null
? vars.find((v) => v.id === item.filamentVariantId)
: null;
const byColor = vars.find((v) => v.colorName === item.color);
const selected = byId || byColor || fallback;
return {
...item,
color: selected.colorName,
filamentVariantId: selected.id,
};
}),
);
}
patchSettings(settings: any) {
if (!settings) return;
// settings object matches keys in our form?
// Session has: materialCode, etc. derived from QuoteSession entity properties
// We need to map them if names differ.
if (!settings) return;
// settings object matches keys in our form?
// Session has: materialCode, etc. derived from QuoteSession entity properties
// We need to map them if names differ.
const patch: any = {};
if (settings.materialCode) patch.material = settings.materialCode;
const patch: any = {};
if (settings.materialCode) patch.material = settings.materialCode;
// Heuristic for Quality if not explicitly stored as "draft/standard/high"
// But we stored it in session creation?
// QuoteSession entity does NOT store "quality" string directly, only layerHeight/infill.
// So we might need to deduce it or just set Custom/Advanced.
// But for Easy mode, we want to show "Standard" etc.
// Heuristic for Quality if not explicitly stored as "draft/standard/high"
// But we stored it in session creation?
// QuoteSession entity does NOT store "quality" string directly, only layerHeight/infill.
// So we might need to deduce it or just set Custom/Advanced.
// But for Easy mode, we want to show "Standard" etc.
// Actually, let's look at what we have in QuoteSession.
// layerHeightMm, infillPercent, etc.
// If we are in Easy mode, we might just set the "quality" dropdown to match approx?
// Or if we stored "quality" in notes or separate field? We didn't.
// Actually, let's look at what we have in QuoteSession.
// layerHeightMm, infillPercent, etc.
// If we are in Easy mode, we might just set the "quality" dropdown to match approx?
// Or if we stored "quality" in notes or separate field? We didn't.
// Let's try to reverse map or defaults.
if (settings.layerHeightMm) {
if (settings.layerHeightMm >= 0.24) patch.quality = 'draft';
else if (settings.layerHeightMm <= 0.12) patch.quality = 'extra_fine';
else patch.quality = 'standard';
// Let's try to reverse map or defaults.
if (settings.layerHeightMm) {
if (settings.layerHeightMm >= 0.24) patch.quality = 'draft';
else if (settings.layerHeightMm <= 0.12) patch.quality = 'extra_fine';
else patch.quality = 'standard';
patch.layerHeight = settings.layerHeightMm;
}
patch.layerHeight = settings.layerHeightMm;
}
if (settings.nozzleDiameterMm) patch.nozzleDiameter = settings.nozzleDiameterMm;
if (settings.infillPercent) patch.infillDensity = settings.infillPercent;
if (settings.infillPattern) patch.infillPattern = settings.infillPattern;
if (settings.supportsEnabled !== undefined) patch.supportEnabled = settings.supportsEnabled;
if (settings.notes) patch.notes = settings.notes;
if (settings.nozzleDiameterMm)
patch.nozzleDiameter = settings.nozzleDiameterMm;
if (settings.infillPercent) patch.infillDensity = settings.infillPercent;
if (settings.infillPattern) patch.infillPattern = settings.infillPattern;
if (settings.supportsEnabled !== undefined)
patch.supportEnabled = settings.supportsEnabled;
if (settings.notes) patch.notes = settings.notes;
this.isPatchingSettings = true;
this.form.patchValue(patch, { emitEvent: false });
this.isPatchingSettings = false;
this.isPatchingSettings = true;
this.form.patchValue(patch, { emitEvent: false });
this.isPatchingSettings = false;
}
onSubmit() {
@@ -380,20 +490,29 @@ export class UploadFormComponent implements OnInit {
console.log('Form Valid:', this.form.valid, 'Items:', this.items().length);
if (this.form.valid && this.items().length > 0) {
console.log('UploadFormComponent: Emitting submitRequest', this.form.value);
console.log(
'UploadFormComponent: Emitting submitRequest',
this.form.value,
);
this.submitRequest.emit({
...this.form.value,
items: this.items(), // Pass the items array explicitly AFTER form value to prevent overwrite
mode: this.mode()
mode: this.mode(),
});
} else {
console.warn('UploadFormComponent: Form Invalid or No Items');
console.log('Form Errors:', this.form.errors);
Object.keys(this.form.controls).forEach(key => {
const control = this.form.get(key);
if (control?.invalid) {
console.log('Invalid Control:', key, control.errors, 'Value:', control.value);
}
Object.keys(this.form.controls).forEach((key) => {
const control = this.form.get(key);
if (control?.invalid) {
console.log(
'Invalid Control:',
key,
control.errors,
'Value:',
control.value,
);
}
});
this.form.markAllAsTouched();
this.form.get('itemsTouched')?.setValue(true);
@@ -401,17 +520,13 @@ export class UploadFormComponent implements OnInit {
}
private normalizeQuantity(quantity: number): number {
if (!Number.isFinite(quantity) || quantity < 1) {
return 1;
}
return Math.floor(quantity);
if (!Number.isFinite(quantity) || quantity < 1) {
return 1;
}
return Math.floor(quantity);
}
private normalizeFileName(fileName: string): string {
return (fileName || '')
.split(/[\\/]/)
.pop()
?.trim()
.toLowerCase() ?? '';
return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? '';
}
}

View File

@@ -3,25 +3,34 @@
<div class="col-md-6">
<app-card [title]="'USER_DETAILS.TITLE' | translate">
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<!-- Name & Surname -->
<div class="row">
<div class="col-md-6">
<app-input
<app-input
formControlName="name"
[label]="'USER_DETAILS.NAME' | translate"
[label]="'USER_DETAILS.NAME' | translate"
[placeholder]="'USER_DETAILS.NAME_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('name')?.invalid && form.get('name')?.touched ? ('COMMON.REQUIRED' | translate) : null">
[error]="
form.get('name')?.invalid && form.get('name')?.touched
? ('COMMON.REQUIRED' | translate)
: null
"
>
</app-input>
</div>
<div class="col-md-6">
<app-input
<app-input
formControlName="surname"
[label]="'USER_DETAILS.SURNAME' | translate"
[label]="'USER_DETAILS.SURNAME' | translate"
[placeholder]="'USER_DETAILS.SURNAME_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('surname')?.invalid && form.get('surname')?.touched ? ('COMMON.REQUIRED' | translate) : null">
[error]="
form.get('surname')?.invalid && form.get('surname')?.touched
? ('COMMON.REQUIRED' | translate)
: null
"
>
</app-input>
</div>
</div>
@@ -29,87 +38,117 @@
<!-- Email & Phone -->
<div class="row">
<div class="col-md-6">
<app-input
<app-input
formControlName="email"
[label]="'USER_DETAILS.EMAIL' | translate"
[label]="'USER_DETAILS.EMAIL' | translate"
type="email"
[placeholder]="'USER_DETAILS.EMAIL_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('email')?.invalid && form.get('email')?.touched ? ('COMMON.INVALID_EMAIL' | translate) : null">
[error]="
form.get('email')?.invalid && form.get('email')?.touched
? ('COMMON.INVALID_EMAIL' | translate)
: null
"
>
</app-input>
</div>
<div class="col-md-6">
<app-input
<app-input
formControlName="phone"
[label]="'USER_DETAILS.PHONE' | translate"
[label]="'USER_DETAILS.PHONE' | translate"
type="tel"
[placeholder]="'USER_DETAILS.PHONE_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('phone')?.invalid && form.get('phone')?.touched ? ('COMMON.REQUIRED' | translate) : null">
[error]="
form.get('phone')?.invalid && form.get('phone')?.touched
? ('COMMON.REQUIRED' | translate)
: null
"
>
</app-input>
</div>
</div>
<!-- Address -->
<app-input
<app-input
formControlName="address"
[label]="'USER_DETAILS.ADDRESS' | translate"
[label]="'USER_DETAILS.ADDRESS' | translate"
[placeholder]="'USER_DETAILS.ADDRESS_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('address')?.invalid && form.get('address')?.touched ? ('COMMON.REQUIRED' | translate) : null">
[error]="
form.get('address')?.invalid && form.get('address')?.touched
? ('COMMON.REQUIRED' | translate)
: null
"
>
</app-input>
<!-- Zip & City -->
<div class="row">
<div class="col-md-4">
<app-input
<app-input
formControlName="zip"
[label]="'USER_DETAILS.ZIP' | translate"
[label]="'USER_DETAILS.ZIP' | translate"
[placeholder]="'USER_DETAILS.ZIP_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('zip')?.invalid && form.get('zip')?.touched ? ('COMMON.REQUIRED' | translate) : null">
[error]="
form.get('zip')?.invalid && form.get('zip')?.touched
? ('COMMON.REQUIRED' | translate)
: null
"
>
</app-input>
</div>
<div class="col-md-8">
<app-input
<app-input
formControlName="city"
[label]="'USER_DETAILS.CITY' | translate"
[label]="'USER_DETAILS.CITY' | translate"
[placeholder]="'USER_DETAILS.CITY_PLACEHOLDER' | translate"
[required]="true"
[error]="form.get('city')?.invalid && form.get('city')?.touched ? ('COMMON.REQUIRED' | translate) : null">
[error]="
form.get('city')?.invalid && form.get('city')?.touched
? ('COMMON.REQUIRED' | translate)
: null
"
>
</app-input>
</div>
</div>
<div class="legal-consent">
<label>
<input type="checkbox" formControlName="acceptLegal">
<input type="checkbox" formControlName="acceptLegal" />
<span>
{{ 'LEGAL.CONSENT.LABEL_PREFIX' | translate }}
<a href="/terms" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.TERMS_LINK' | translate }}</a>
{{ 'LEGAL.CONSENT.AND' | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.PRIVACY_LINK' | translate }}</a>.
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
<a href="/terms" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.TERMS_LINK" | translate
}}</a>
{{ "LEGAL.CONSENT.AND" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.PRIVACY_LINK" | translate
}}</a
>.
</span>
</label>
<div class="consent-error" *ngIf="form.get('acceptLegal')?.invalid && form.get('acceptLegal')?.touched">
{{ 'LEGAL.CONSENT.REQUIRED_ERROR' | translate }}
<div
class="consent-error"
*ngIf="
form.get('acceptLegal')?.invalid &&
form.get('acceptLegal')?.touched
"
>
{{ "LEGAL.CONSENT.REQUIRED_ERROR" | translate }}
</div>
</div>
<div class="actions">
<app-button
type="button"
variant="outline"
(click)="onCancel()">
{{ 'COMMON.BACK' | translate }}
<app-button type="button" variant="outline" (click)="onCancel()">
{{ "COMMON.BACK" | translate }}
</app-button>
<app-button
type="submit"
[disabled]="form.invalid || submitting()">
{{ 'USER_DETAILS.SUBMIT' | translate }}
<app-button type="submit" [disabled]="form.invalid || submitting()">
{{ "USER_DETAILS.SUBMIT" | translate }}
</app-button>
</div>
</form>
</app-card>
</div>
@@ -117,30 +156,38 @@
<!-- Order Summary Column -->
<div class="col-md-6">
<app-card [title]="'USER_DETAILS.SUMMARY_TITLE' | translate">
<div class="summary-content" *ngIf="quote()">
<div class="summary-item" *ngFor="let item of quote()!.items">
<div class="item-info">
<span class="item-name">{{ item.fileName }}</span>
<span class="item-meta">{{ item.material }} - {{ item.color || ('USER_DETAILS.DEFAULT_COLOR' | translate) }}</span>
<span class="item-meta"
>{{ item.material }} -
{{
item.color || ("USER_DETAILS.DEFAULT_COLOR" | translate)
}}</span
>
</div>
<div class="item-qty">x{{ item.quantity }}</div>
<div class="item-price">
<span class="item-total-price">{{ (item.unitPrice * item.quantity) | currency:'CHF' }}</span>
<span class="item-total-price">{{
item.unitPrice * item.quantity | currency: "CHF"
}}</span>
<small class="item-unit-price" *ngIf="item.quantity > 1">
{{ item.unitPrice | currency:'CHF' }} {{ 'CHECKOUT.PER_PIECE' | translate }}
{{ item.unitPrice | currency: "CHF" }}
{{ "CHECKOUT.PER_PIECE" | translate }}
</small>
</div>
</div>
<hr>
<hr />
<div class="total-row">
<span>{{ 'QUOTE.TOTAL' | translate }}</span>
<span class="total-price">{{ quote()!.totalPrice | currency:'CHF' }}</span>
<span>{{ "QUOTE.TOTAL" | translate }}</span>
<span class="total-price">{{
quote()!.totalPrice | currency: "CHF"
}}</span>
</div>
</div>
</app-card>
</div>
</div>

View File

@@ -6,15 +6,15 @@
display: flex;
flex-wrap: wrap;
margin: 0 -0.5rem;
> [class*='col-'] {
> [class*="col-"] {
padding: 0 0.5rem;
}
}
.col-md-6 {
width: 100%;
@media (min-width: 768px) {
width: 50%;
}
@@ -22,7 +22,7 @@
.col-md-4 {
width: 100%;
@media (min-width: 768px) {
width: 33.333%;
}
@@ -30,7 +30,7 @@
.col-md-8 {
width: 100%;
@media (min-width: 768px) {
width: 66.666%;
}
@@ -55,7 +55,7 @@
line-height: 1.4;
}
input[type='checkbox'] {
input[type="checkbox"] {
margin-top: 0.2rem;
}
@@ -84,7 +84,7 @@
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
&:last-child {
border-bottom: none;
}
@@ -134,8 +134,8 @@
margin-top: 1rem;
padding-top: 1rem;
border-top: 2px solid rgba(255, 255, 255, 0.2);
.total-price {
color: var(--primary-color, #00C853); // Fallback color
color: var(--primary-color, #00c853); // Fallback color
}
}

View File

@@ -1,6 +1,11 @@
import { Component, input, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import {
ReactiveFormsModule,
FormBuilder,
FormGroup,
Validators,
} from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component';
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
@@ -10,9 +15,16 @@ import { QuoteResult } from '../../services/quote-estimator.service';
@Component({
selector: 'app-user-details',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppCardComponent, AppInputComponent, AppButtonComponent],
imports: [
CommonModule,
ReactiveFormsModule,
TranslateModule,
AppCardComponent,
AppInputComponent,
AppButtonComponent,
],
templateUrl: './user-details.component.html',
styleUrl: './user-details.component.scss'
styleUrl: './user-details.component.scss',
})
export class UserDetailsComponent {
quote = input<QuoteResult>();
@@ -31,17 +43,17 @@ export class UserDetailsComponent {
address: ['', Validators.required],
zip: ['', Validators.required],
city: ['', Validators.required],
acceptLegal: [false, Validators.requiredTrue]
acceptLegal: [false, Validators.requiredTrue],
});
}
onSubmit() {
if (this.form.valid) {
this.submitting.set(true);
const orderData = {
customer: this.form.value,
quote: this.quote()
quote: this.quote(),
};
// Simulate API delay

View File

@@ -5,7 +5,12 @@ import { map, catchError, tap } from 'rxjs/operators';
import { environment } from '../../../../environments/environment';
export interface QuoteRequest {
items: { file: File, quantity: number, color?: string, filamentVariantId?: number }[];
items: {
file: File;
quantity: number;
color?: string;
filamentVariantId?: number;
}[];
material: string;
quality: string;
notes?: string;
@@ -68,306 +73,400 @@ interface BackendQuoteResult {
// Options Interfaces
export interface MaterialOption {
code: string;
label: string;
variants: VariantOption[];
code: string;
label: string;
variants: VariantOption[];
}
export interface VariantOption {
id: number;
name: string;
colorName: string;
hexColor: string;
finishType: string;
stockSpools: number;
stockFilamentGrams: number;
isOutOfStock: boolean;
id: number;
name: string;
colorName: string;
hexColor: string;
finishType: string;
stockSpools: number;
stockFilamentGrams: number;
isOutOfStock: boolean;
}
export interface QualityOption {
id: string;
label: string;
id: string;
label: string;
}
export interface InfillOption {
id: string;
label: string;
id: string;
label: string;
}
export interface NumericOption {
value: number;
label: string;
value: number;
label: string;
}
export interface OptionsResponse {
materials: MaterialOption[];
qualities: QualityOption[];
infillPatterns: InfillOption[];
layerHeights: NumericOption[];
nozzleDiameters: NumericOption[];
materials: MaterialOption[];
qualities: QualityOption[];
infillPatterns: InfillOption[];
layerHeights: NumericOption[];
nozzleDiameters: NumericOption[];
}
// UI Option for Select Component
export interface SimpleOption {
value: string | number;
label: string;
value: string | number;
label: string;
}
@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class QuoteEstimatorService {
private http = inject(HttpClient);
private buildEasyModePreset(quality: string | undefined): {
quality: string;
layerHeight: number;
infillDensity: number;
infillPattern: string;
nozzleDiameter: number;
quality: string;
layerHeight: number;
infillDensity: number;
infillPattern: string;
nozzleDiameter: number;
} {
const normalized = (quality || 'standard').toLowerCase();
// Legacy alias support.
if (normalized === 'high' || normalized === 'extra_fine') {
return {
quality: 'extra_fine',
layerHeight: 0.12,
infillDensity: 20,
infillPattern: 'grid',
nozzleDiameter: 0.4
};
}
if (normalized === 'draft') {
return {
quality: 'extra_fine',
layerHeight: 0.24,
infillDensity: 12,
infillPattern: 'grid',
nozzleDiameter: 0.4
};
}
const normalized = (quality || 'standard').toLowerCase();
// Legacy alias support.
if (normalized === 'high' || normalized === 'extra_fine') {
return {
quality: 'standard',
layerHeight: 0.2,
infillDensity: 15,
infillPattern: 'grid',
nozzleDiameter: 0.4
quality: 'extra_fine',
layerHeight: 0.12,
infillDensity: 20,
infillPattern: 'grid',
nozzleDiameter: 0.4,
};
}
if (normalized === 'draft') {
return {
quality: 'extra_fine',
layerHeight: 0.24,
infillDensity: 12,
infillPattern: 'grid',
nozzleDiameter: 0.4,
};
}
return {
quality: 'standard',
layerHeight: 0.2,
infillDensity: 15,
infillPattern: 'grid',
nozzleDiameter: 0.4,
};
}
getOptions(): Observable<OptionsResponse> {
console.log('QuoteEstimatorService: Requesting options...');
const headers: any = {};
return this.http.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, { headers }).pipe(
tap({
next: (res) => console.log('QuoteEstimatorService: Options loaded', res),
error: (err) => console.error('QuoteEstimatorService: Options failed', err)
})
console.log('QuoteEstimatorService: Requesting options...');
const headers: any = {};
return this.http
.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, {
headers,
})
.pipe(
tap({
next: (res) =>
console.log('QuoteEstimatorService: Options loaded', res),
error: (err) =>
console.error('QuoteEstimatorService: Options failed', err),
}),
);
}
// NEW METHODS for Order Flow
getQuoteSession(sessionId: string): Observable<any> {
const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { headers });
const headers: any = {};
return this.http.get(
`${environment.apiUrl}/api/quote-sessions/${sessionId}`,
{ headers },
);
}
updateLineItem(lineItemId: string, changes: any): Observable<any> {
const headers: any = {};
return this.http.patch(`${environment.apiUrl}/api/quote-sessions/line-items/${lineItemId}`, changes, { headers });
const headers: any = {};
return this.http.patch(
`${environment.apiUrl}/api/quote-sessions/line-items/${lineItemId}`,
changes,
{ headers },
);
}
createOrder(sessionId: string, orderDetails: any): Observable<any> {
const headers: any = {};
return this.http.post(`${environment.apiUrl}/api/orders/from-quote/${sessionId}`, orderDetails, { headers });
const headers: any = {};
return this.http.post(
`${environment.apiUrl}/api/orders/from-quote/${sessionId}`,
orderDetails,
{ headers },
);
}
getOrder(orderId: string): Observable<any> {
const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, { headers });
const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, {
headers,
});
}
reportPayment(orderId: string, method: string): Observable<any> {
const headers: any = {};
return this.http.post(`${environment.apiUrl}/api/orders/${orderId}/payments/report`, { method }, { headers });
const headers: any = {};
return this.http.post(
`${environment.apiUrl}/api/orders/${orderId}/payments/report`,
{ method },
{ headers },
);
}
getOrderInvoice(orderId: string): Observable<Blob> {
const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/invoice`, {
headers,
responseType: 'blob'
});
const headers: any = {};
return this.http.get(
`${environment.apiUrl}/api/orders/${orderId}/invoice`,
{
headers,
responseType: 'blob',
},
);
}
getOrderConfirmation(orderId: string): Observable<Blob> {
const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/confirmation`, {
headers,
responseType: 'blob'
});
const headers: any = {};
return this.http.get(
`${environment.apiUrl}/api/orders/${orderId}/confirmation`,
{
headers,
responseType: 'blob',
},
);
}
getTwintPayment(orderId: string): Observable<any> {
const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/twint`, { headers });
const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/twint`, {
headers,
});
}
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
console.log('QuoteEstimatorService: Calculating quote...', request);
if (request.items.length === 0) {
console.warn('QuoteEstimatorService: No items to calculate');
return of();
console.warn('QuoteEstimatorService: No items to calculate');
return of();
}
return new Observable(observer => {
// 1. Create Session first
const headers: any = {};
this.http.post<any>(`${environment.apiUrl}/api/quote-sessions`, {}, { headers }).subscribe({
next: (sessionRes) => {
const sessionId = sessionRes.id;
const sessionSetupCost = sessionRes.setupCostChf || 0;
// 2. Upload files to this session
const totalItems = request.items.length;
const allProgress: number[] = new Array(totalItems).fill(0);
const finalResponses: any[] = [];
let completedRequests = 0;
return new Observable((observer) => {
// 1. Create Session first
const headers: any = {};
const checkCompletion = () => {
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
observer.next(avg);
if (completedRequests === totalItems) {
finalize(finalResponses, sessionSetupCost, sessionId);
}
};
this.http
.post<any>(`${environment.apiUrl}/api/quote-sessions`, {}, { headers })
.subscribe({
next: (sessionRes) => {
const sessionId = sessionRes.id;
const sessionSetupCost = sessionRes.setupCostChf || 0;
request.items.forEach((item, index) => {
const formData = new FormData();
formData.append('file', item.file);
// 2. Upload files to this session
const totalItems = request.items.length;
const allProgress: number[] = new Array(totalItems).fill(0);
const finalResponses: any[] = [];
let completedRequests = 0;
const easyPreset = request.mode === 'easy'
? this.buildEasyModePreset(request.quality)
: null;
const settings = {
complexityMode: request.mode === 'easy' ? 'ADVANCED' : request.mode.toUpperCase(),
material: request.material,
filamentVariantId: item.filamentVariantId,
quality: easyPreset ? easyPreset.quality : request.quality,
supportsEnabled: request.supportEnabled,
color: item.color || '#FFFFFF',
layerHeight: easyPreset ? easyPreset.layerHeight : request.layerHeight,
infillDensity: easyPreset ? easyPreset.infillDensity : request.infillDensity,
infillPattern: easyPreset ? easyPreset.infillPattern : request.infillPattern,
nozzleDiameter: easyPreset ? easyPreset.nozzleDiameter : request.nozzleDiameter
};
const settingsBlob = new Blob([JSON.stringify(settings)], { type: 'application/json' });
formData.append('settings', settingsBlob);
const checkCompletion = () => {
const avg = Math.round(
allProgress.reduce((a, b) => a + b, 0) / totalItems,
);
observer.next(avg);
this.http.post<any>(`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items`, formData, {
headers,
reportProgress: true,
observe: 'events'
}).subscribe({
next: (event) => {
if (event.type === HttpEventType.UploadProgress && event.total) {
allProgress[index] = Math.round((100 * event.loaded) / event.total);
checkCompletion();
} else if (event.type === HttpEventType.Response) {
allProgress[index] = 100;
finalResponses[index] = { ...event.body, success: true, fileName: item.file.name, originalQty: item.quantity, originalItem: item };
completedRequests++;
checkCompletion();
}
},
error: (err) => {
console.error('Item upload failed', err);
finalResponses[index] = { success: false, fileName: item.file.name };
completedRequests++;
checkCompletion();
}
});
if (completedRequests === totalItems) {
finalize(finalResponses, sessionSetupCost, sessionId);
}
};
request.items.forEach((item, index) => {
const formData = new FormData();
formData.append('file', item.file);
const easyPreset =
request.mode === 'easy'
? this.buildEasyModePreset(request.quality)
: null;
const settings = {
complexityMode:
request.mode === 'easy'
? 'ADVANCED'
: request.mode.toUpperCase(),
material: request.material,
filamentVariantId: item.filamentVariantId,
quality: easyPreset ? easyPreset.quality : request.quality,
supportsEnabled: request.supportEnabled,
color: item.color || '#FFFFFF',
layerHeight: easyPreset
? easyPreset.layerHeight
: request.layerHeight,
infillDensity: easyPreset
? easyPreset.infillDensity
: request.infillDensity,
infillPattern: easyPreset
? easyPreset.infillPattern
: request.infillPattern,
nozzleDiameter: easyPreset
? easyPreset.nozzleDiameter
: request.nozzleDiameter,
};
const settingsBlob = new Blob([JSON.stringify(settings)], {
type: 'application/json',
});
formData.append('settings', settingsBlob);
this.http
.post<any>(
`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items`,
formData,
{
headers,
reportProgress: true,
observe: 'events',
},
)
.subscribe({
next: (event) => {
if (
event.type === HttpEventType.UploadProgress &&
event.total
) {
allProgress[index] = Math.round(
(100 * event.loaded) / event.total,
);
checkCompletion();
} else if (event.type === HttpEventType.Response) {
allProgress[index] = 100;
finalResponses[index] = {
...event.body,
success: true,
fileName: item.file.name,
originalQty: item.quantity,
originalItem: item,
};
completedRequests++;
checkCompletion();
}
},
error: (err) => {
console.error('Item upload failed', err);
finalResponses[index] = {
success: false,
fileName: item.file.name,
};
completedRequests++;
checkCompletion();
},
});
},
error: (err) => {
console.error('Failed to create session', err);
observer.error('Could not initialize quote session');
}
});
},
error: (err) => {
console.error('Failed to create session', err);
observer.error('Could not initialize quote session');
},
});
const finalize = (responses: any[], setupCost: number, sessionId: string) => {
this.http.get<any>(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { headers }).subscribe({
next: (sessionData) => {
observer.next(100);
const result = this.mapSessionToQuoteResult(sessionData);
result.notes = request.notes;
observer.next(result);
observer.complete();
},
error: (err) => {
console.error('Failed to fetch final session calculation', err);
observer.error('Failed to calculate final quote');
}
});
};
const finalize = (
responses: any[],
setupCost: number,
sessionId: string,
) => {
this.http
.get<any>(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, {
headers,
})
.subscribe({
next: (sessionData) => {
observer.next(100);
const result = this.mapSessionToQuoteResult(sessionData);
result.notes = request.notes;
observer.next(result);
observer.complete();
},
error: (err) => {
console.error('Failed to fetch final session calculation', err);
observer.error('Failed to calculate final quote');
},
});
};
});
}
// Consultation Data Transfer
private pendingConsultation = signal<{files: File[], message: string} | null>(null);
private pendingConsultation = signal<{
files: File[];
message: string;
} | null>(null);
setPendingConsultation(data: {files: File[], message: string}) {
this.pendingConsultation.set(data);
setPendingConsultation(data: { files: File[]; message: string }) {
this.pendingConsultation.set(data);
}
getPendingConsultation() {
const data = this.pendingConsultation();
this.pendingConsultation.set(null); // Clear after reading
return data;
const data = this.pendingConsultation();
this.pendingConsultation.set(null); // Clear after reading
return data;
}
// Session File Retrieval
getLineItemContent(sessionId: string, lineItemId: string): Observable<Blob> {
const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/content`, {
headers,
responseType: 'blob'
});
const headers: any = {};
return this.http.get(
`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/content`,
{
headers,
responseType: 'blob',
},
);
}
mapSessionToQuoteResult(sessionData: any): QuoteResult {
const session = sessionData.session;
const items = sessionData.items || [];
const totalTime = items.reduce((acc: number, item: any) => acc + (item.printTimeSeconds || 0) * item.quantity, 0);
const totalWeight = items.reduce((acc: number, item: any) => acc + (item.materialGrams || 0) * item.quantity, 0);
const session = sessionData.session;
const items = sessionData.items || [];
const totalTime = items.reduce(
(acc: number, item: any) =>
acc + (item.printTimeSeconds || 0) * item.quantity,
0,
);
const totalWeight = items.reduce(
(acc: number, item: any) =>
acc + (item.materialGrams || 0) * item.quantity,
0,
);
return {
sessionId: session.id,
items: items.map((item: any) => ({
id: item.id,
fileName: item.originalFilename,
unitPrice: item.unitPriceChf,
unitTime: item.printTimeSeconds,
unitWeight: item.materialGrams,
quantity: item.quantity,
material: session.materialCode, // Assumption: session has one material for all? or items have it?
// Backend model QuoteSession has materialCode.
// But line items might have different colors.
color: item.colorCode,
filamentVariantId: item.filamentVariantId
})),
return {
sessionId: session.id,
items: items.map((item: any) => ({
id: item.id,
fileName: item.originalFilename,
unitPrice: item.unitPriceChf,
unitTime: item.printTimeSeconds,
unitWeight: item.materialGrams,
quantity: item.quantity,
material: session.materialCode, // Assumption: session has one material for all? or items have it?
// Backend model QuoteSession has materialCode.
// But line items might have different colors.
color: item.colorCode,
filamentVariantId: item.filamentVariantId,
})),
setupCost: session.setupCostChf || 0,
globalMachineCost: sessionData.globalMachineCostChf || 0,
currency: 'CHF', // Fixed for now
totalPrice: (sessionData.itemsTotalChf || 0) + (session.setupCostChf || 0) + (sessionData.shippingCostChf || 0),
totalPrice:
(sessionData.itemsTotalChf || 0) +
(session.setupCostChf || 0) +
(sessionData.shippingCostChf || 0),
totalTimeHours: Math.floor(totalTime / 3600),
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
totalWeight: Math.ceil(totalWeight),
notes: session.notes
};
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
totalWeight: Math.ceil(totalWeight),
notes: session.notes,
};
}
}

View File

@@ -1,11 +1,10 @@
<div class="checkout-page">
<div class="container hero">
<h1 class="section-title">{{ 'CHECKOUT.TITLE' | translate }}</h1>
<h1 class="section-title">{{ "CHECKOUT.TITLE" | translate }}</h1>
</div>
<div class="container">
<div class="checkout-layout">
<!-- LEFT COLUMN: Form -->
<div class="checkout-form-section">
<!-- Error Message -->
@@ -14,50 +13,105 @@
</div>
<form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error">
<!-- Contact Info Card -->
<app-card class="mb-6">
<div class="card-header-simple">
<h3>{{ 'CHECKOUT.CONTACT_INFO' | translate }}</h3>
<h3>{{ "CHECKOUT.CONTACT_INFO" | translate }}</h3>
</div>
<div class="form-row">
<app-input formControlName="email" type="email" [label]="'CHECKOUT.EMAIL' | translate" [required]="true" [error]="checkoutForm.get('email')?.hasError('email') ? ('CHECKOUT.INVALID_EMAIL' | translate) : null"></app-input>
<app-input formControlName="phone" type="tel" [label]="'CHECKOUT.PHONE' | translate" [required]="true"></app-input>
<app-input
formControlName="email"
type="email"
[label]="'CHECKOUT.EMAIL' | translate"
[required]="true"
[error]="
checkoutForm.get('email')?.hasError('email')
? ('CHECKOUT.INVALID_EMAIL' | translate)
: null
"
></app-input>
<app-input
formControlName="phone"
type="tel"
[label]="'CHECKOUT.PHONE' | translate"
[required]="true"
></app-input>
</div>
</app-card>
<!-- Billing Address Card -->
<app-card class="mb-6">
<div class="card-header-simple">
<h3>{{ 'CHECKOUT.BILLING_ADDR' | translate }}</h3>
<h3>{{ "CHECKOUT.BILLING_ADDR" | translate }}</h3>
</div>
<div formGroupName="billingAddress">
<!-- User Type Selector -->
<app-toggle-selector class="mb-4 user-type-selector-compact"
<app-toggle-selector
class="mb-4 user-type-selector-compact"
[options]="userTypeOptions"
[selectedValue]="checkoutForm.get('customerType')?.value"
(selectionChange)="setCustomerType($event)">
(selectionChange)="setCustomerType($event)"
>
</app-toggle-selector>
<!-- Private Person Fields -->
<div *ngIf="!isCompany" class="form-row">
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate" [required]="true"></app-input>
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate" [required]="true"></app-input>
<app-input
formControlName="firstName"
[label]="'CHECKOUT.FIRST_NAME' | translate"
[required]="true"
></app-input>
<app-input
formControlName="lastName"
[label]="'CHECKOUT.LAST_NAME' | translate"
[required]="true"
></app-input>
</div>
<!-- Company Fields -->
<div *ngIf="isCompany" class="company-fields mb-4">
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate" [required]="true" [placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"></app-input>
<app-input formControlName="referencePerson" [label]="'CONTACT.REF_PERSON' | translate" [required]="true" [placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"></app-input>
<app-input
formControlName="companyName"
[label]="'CHECKOUT.COMPANY_NAME' | translate"
[required]="true"
[placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"
></app-input>
<app-input
formControlName="referencePerson"
[label]="'CONTACT.REF_PERSON' | translate"
[required]="true"
[placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"
></app-input>
</div>
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate" [required]="true"></app-input>
<app-input formControlName="addressLine2" [label]="'CHECKOUT.ADDRESS_2' | translate"></app-input>
<app-input
formControlName="addressLine1"
[label]="'CHECKOUT.ADDRESS_1' | translate"
[required]="true"
></app-input>
<app-input
formControlName="addressLine2"
[label]="'CHECKOUT.ADDRESS_2' | translate"
></app-input>
<div class="form-row three-cols">
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate" [required]="true"></app-input>
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field" [required]="true"></app-input>
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true" [required]="true"></app-input>
<app-input
formControlName="zip"
[label]="'CHECKOUT.ZIP' | translate"
[required]="true"
></app-input>
<app-input
formControlName="city"
[label]="'CHECKOUT.CITY' | translate"
class="city-field"
[required]="true"
></app-input>
<app-input
formControlName="countryCode"
[label]="'CHECKOUT.COUNTRY' | translate"
[disabled]="true"
[required]="true"
></app-input>
</div>
</div>
</app-card>
@@ -65,60 +119,108 @@
<!-- Shipping Option -->
<div class="shipping-option">
<label class="checkbox-container">
<input type="checkbox" formControlName="shippingSameAsBilling">
<input type="checkbox" formControlName="shippingSameAsBilling" />
<span class="checkmark"></span>
{{ 'CHECKOUT.SHIPPING_SAME' | translate }}
{{ "CHECKOUT.SHIPPING_SAME" | translate }}
</label>
</div>
<!-- Shipping Address Card (Conditional) -->
<app-card *ngIf="!checkoutForm.get('shippingSameAsBilling')?.value" class="mb-6">
<div class="card-header-simple">
<h3>{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}</h3>
<app-card
*ngIf="!checkoutForm.get('shippingSameAsBilling')?.value"
class="mb-6"
>
<div class="card-header-simple">
<h3>{{ "CHECKOUT.SHIPPING_ADDR" | translate }}</h3>
</div>
<div formGroupName="shippingAddress">
<div class="form-row">
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate"></app-input>
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate"></app-input>
</div>
<div *ngIf="isCompany" class="company-fields">
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_OPTIONAL' | translate"></app-input>
<app-input formControlName="referencePerson" [label]="'CHECKOUT.REF_PERSON_OPTIONAL' | translate"></app-input>
<div class="form-row">
<app-input
formControlName="firstName"
[label]="'CHECKOUT.FIRST_NAME' | translate"
></app-input>
<app-input
formControlName="lastName"
[label]="'CHECKOUT.LAST_NAME' | translate"
></app-input>
</div>
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate"></app-input>
<div *ngIf="isCompany" class="company-fields">
<app-input
formControlName="companyName"
[label]="'CHECKOUT.COMPANY_OPTIONAL' | translate"
></app-input>
<app-input
formControlName="referencePerson"
[label]="'CHECKOUT.REF_PERSON_OPTIONAL' | translate"
></app-input>
</div>
<app-input
formControlName="addressLine1"
[label]="'CHECKOUT.ADDRESS_1' | translate"
></app-input>
<div class="form-row three-cols">
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate"></app-input>
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field"></app-input>
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true"></app-input>
<app-input
formControlName="zip"
[label]="'CHECKOUT.ZIP' | translate"
></app-input>
<app-input
formControlName="city"
[label]="'CHECKOUT.CITY' | translate"
class="city-field"
></app-input>
<app-input
formControlName="countryCode"
[label]="'CHECKOUT.COUNTRY' | translate"
[disabled]="true"
></app-input>
</div>
</div>
</app-card>
<div class="legal-consent">
<label class="checkbox-container">
<input type="checkbox" formControlName="acceptLegal">
<input type="checkbox" formControlName="acceptLegal" />
<span class="checkmark"></span>
<span>
{{ 'LEGAL.CONSENT.LABEL_PREFIX' | translate }}
<a href="/terms" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.TERMS_LINK' | translate }}</a>
{{ 'LEGAL.CONSENT.AND' | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.PRIVACY_LINK' | translate }}</a>.
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
<a href="/terms" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.TERMS_LINK" | translate
}}</a>
{{ "LEGAL.CONSENT.AND" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.PRIVACY_LINK" | translate
}}</a
>.
</span>
</label>
<div class="consent-error" *ngIf="checkoutForm.get('acceptLegal')?.invalid && checkoutForm.get('acceptLegal')?.touched">
{{ 'LEGAL.CONSENT.REQUIRED_ERROR' | translate }}
<div
class="consent-error"
*ngIf="
checkoutForm.get('acceptLegal')?.invalid &&
checkoutForm.get('acceptLegal')?.touched
"
>
{{ "LEGAL.CONSENT.REQUIRED_ERROR" | translate }}
</div>
</div>
<div class="actions">
<app-button type="submit" [disabled]="checkoutForm.invalid || isSubmitting()" [fullWidth]="true">
{{ (isSubmitting() ? 'CHECKOUT.PROCESSING' : 'CHECKOUT.PLACE_ORDER') | translate }}
<app-button
type="submit"
[disabled]="checkoutForm.invalid || isSubmitting()"
[fullWidth]="true"
>
{{
(isSubmitting()
? "CHECKOUT.PROCESSING"
: "CHECKOUT.PLACE_ORDER"
) | translate
}}
</app-button>
</div>
</form>
</div>
@@ -126,53 +228,60 @@
<div class="checkout-summary-section">
<app-card class="sticky-card">
<div class="card-header-simple">
<h3>{{ 'CHECKOUT.SUMMARY_TITLE' | translate }}</h3>
<h3>{{ "CHECKOUT.SUMMARY_TITLE" | translate }}</h3>
</div>
<div class="summary-items" *ngIf="quoteSession() as session">
<div class="summary-item" *ngFor="let item of session.items">
<div class="item-details">
<span class="item-name">{{ item.originalFilename }}</span>
<div class="item-specs">
<span>{{ 'CHECKOUT.QTY' | translate }}: {{ item.quantity }}</span>
<span *ngIf="item.colorCode" class="color-dot" [style.background-color]="item.colorCode"></span>
</div>
<div class="item-specs-sub">
{{ (item.printTimeSeconds / 3600) | number:'1.1-1' }}h | {{ item.materialGrams | number:'1.0-0' }}g
</div>
</div>
<div class="item-price">
<span class="item-total-price">
{{ (item.unitPriceChf * item.quantity) | currency:'CHF' }}
</span>
<small class="item-unit-price" *ngIf="item.quantity > 1">
{{ item.unitPriceChf | currency:'CHF' }} {{ 'CHECKOUT.PER_PIECE' | translate }}
</small>
</div>
</div>
<div class="summary-item" *ngFor="let item of session.items">
<div class="item-details">
<span class="item-name">{{ item.originalFilename }}</span>
<div class="item-specs">
<span
>{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span
>
<span
*ngIf="item.colorCode"
class="color-dot"
[style.background-color]="item.colorCode"
></span>
</div>
<div class="item-specs-sub">
{{ item.printTimeSeconds / 3600 | number: "1.1-1" }}h |
{{ item.materialGrams | number: "1.0-0" }}g
</div>
</div>
<div class="item-price">
<span class="item-total-price">
{{ item.unitPriceChf * item.quantity | currency: "CHF" }}
</span>
<small class="item-unit-price" *ngIf="item.quantity > 1">
{{ item.unitPriceChf | currency: "CHF" }}
{{ "CHECKOUT.PER_PIECE" | translate }}
</small>
</div>
</div>
</div>
<div class="summary-totals" *ngIf="quoteSession() as session">
<div class="total-row">
<span>{{ 'CHECKOUT.SUBTOTAL' | translate }}</span>
<span>{{ session.itemsTotalChf | currency:'CHF' }}</span>
<span>{{ "CHECKOUT.SUBTOTAL" | translate }}</span>
<span>{{ session.itemsTotalChf | currency: "CHF" }}</span>
</div>
<div class="total-row">
<span>{{ 'CHECKOUT.SETUP_FEE' | translate }}</span>
<span>{{ session.session.setupCostChf | currency:'CHF' }}</span>
<span>{{ "CHECKOUT.SETUP_FEE" | translate }}</span>
<span>{{ session.session.setupCostChf | currency: "CHF" }}</span>
</div>
<div class="total-row">
<span>{{ 'CHECKOUT.SHIPPING' | translate }}</span>
<span>{{ session.shippingCostChf | currency:'CHF' }}</span>
<span>{{ "CHECKOUT.SHIPPING" | translate }}</span>
<span>{{ session.shippingCostChf | currency: "CHF" }}</span>
</div>
<div class="grand-total">
<span>{{ 'CHECKOUT.TOTAL' | translate }}</span>
<span>{{ session.grandTotalChf | currency:'CHF' }}</span>
<span>{{ "CHECKOUT.TOTAL" | translate }}</span>
<span>{{ session.grandTotalChf | currency: "CHF" }}</span>
</div>
</div>
</app-card>
</div>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
.hero {
.hero {
padding: var(--space-8) 0;
text-align: center;
text-align: center;
.section-title {
font-size: 2.5rem;
@@ -25,7 +25,7 @@
margin-bottom: var(--space-6);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--color-border);
h3 {
font-size: 1.25rem;
font-weight: 600;
@@ -39,10 +39,12 @@
flex-direction: column;
gap: var(--space-4);
margin-bottom: var(--space-4);
@media(min-width: 768px) {
@media (min-width: 768px) {
flex-direction: row;
& > * { flex: 1; }
& > * {
flex: 1;
}
}
&.no-margin {
@@ -53,7 +55,7 @@
display: grid;
grid-template-columns: 1.5fr 2fr 1fr;
gap: var(--space-4);
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
@@ -120,7 +122,7 @@ app-toggle-selector.user-type-selector-compact {
&:checked ~ .checkmark {
background-color: var(--color-brand);
border-color: var(--color-brand);
&:after {
display: block;
}
@@ -197,12 +199,16 @@ app-toggle-selector.user-type-selector-compact {
padding: var(--space-4) 0;
border-bottom: 1px solid var(--color-border);
&:first-child { padding-top: 0; }
&:last-child { border-bottom: none; }
&:first-child {
padding-top: 0;
}
&:last-child {
border-bottom: none;
}
.item-details {
flex: 1;
.item-name {
display: block;
font-weight: 600;
@@ -218,7 +224,7 @@ app-toggle-selector.user-type-selector-compact {
gap: var(--space-2);
font-size: 0.85rem;
color: var(--color-text-muted);
.color-dot {
width: 14px;
height: 14px;
@@ -227,11 +233,11 @@ app-toggle-selector.user-type-selector-compact {
border: 1px solid var(--color-border);
}
}
.item-specs-sub {
font-size: 0.8rem;
color: var(--color-text-muted);
margin-top: 2px;
color: var(--color-text-muted);
margin-top: 2px;
}
}
@@ -282,7 +288,7 @@ app-toggle-selector.user-type-selector-compact {
.actions {
margin-top: var(--space-8);
app-button {
width: 100%;
}
@@ -298,4 +304,6 @@ app-toggle-selector.user-type-selector-compact {
font-weight: 500;
}
.mb-6 { margin-bottom: var(--space-6); }
.mb-6 {
margin-bottom: var(--space-6);
}

View File

@@ -1,29 +1,37 @@
import { Component, inject, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import {
FormBuilder,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
import { AppInputComponent } from '../../shared/components/app-input/app-input.component';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { AppToggleSelectorComponent, ToggleOption } from '../../shared/components/app-toggle-selector/app-toggle-selector.component';
import {
AppToggleSelectorComponent,
ToggleOption,
} from '../../shared/components/app-toggle-selector/app-toggle-selector.component';
import { LanguageService } from '../../core/services/language.service';
@Component({
selector: 'app-checkout',
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
CommonModule,
ReactiveFormsModule,
TranslateModule,
AppInputComponent,
AppButtonComponent,
AppCardComponent,
AppToggleSelectorComponent
AppToggleSelectorComponent,
],
templateUrl: './checkout.component.html',
styleUrls: ['./checkout.component.scss']
styleUrls: ['./checkout.component.scss'],
})
export class CheckoutComponent implements OnInit {
private fb = inject(FormBuilder);
@@ -41,7 +49,7 @@ export class CheckoutComponent implements OnInit {
userTypeOptions: ToggleOption[] = [
{ label: 'CONTACT.TYPE_PRIVATE', value: 'PRIVATE' },
{ label: 'CONTACT.TYPE_COMPANY', value: 'BUSINESS' }
{ label: 'CONTACT.TYPE_COMPANY', value: 'BUSINESS' },
];
constructor() {
@@ -49,22 +57,22 @@ export class CheckoutComponent implements OnInit {
email: ['', [Validators.required, Validators.email]],
phone: ['', Validators.required],
customerType: ['PRIVATE', Validators.required], // Default to PRIVATE
shippingSameAsBilling: [true],
acceptLegal: [false, Validators.requiredTrue],
billingAddress: this.fb.group({
firstName: ['', Validators.required],
lastName: ['', Validators.required],
companyName: [''],
companyName: [''],
referencePerson: [''],
addressLine1: ['', Validators.required],
addressLine2: [''],
zip: ['', Validators.required],
city: ['', Validators.required],
countryCode: ['CH', Validators.required]
countryCode: ['CH', Validators.required],
}),
shippingAddress: this.fb.group({
firstName: [''],
lastName: [''],
@@ -74,8 +82,8 @@ export class CheckoutComponent implements OnInit {
addressLine2: [''],
zip: [''],
city: [''],
countryCode: ['CH']
})
countryCode: ['CH'],
}),
});
}
@@ -86,13 +94,13 @@ export class CheckoutComponent implements OnInit {
setCustomerType(type: string) {
this.checkoutForm.patchValue({ customerType: type });
const isCompany = type === 'BUSINESS';
const billingGroup = this.checkoutForm.get('billingAddress') as FormGroup;
const companyControl = billingGroup.get('companyName');
const referenceControl = billingGroup.get('referencePerson');
const firstNameControl = billingGroup.get('firstName');
const lastNameControl = billingGroup.get('lastName');
if (isCompany) {
companyControl?.setValidators([Validators.required]);
referenceControl?.setValidators([Validators.required]);
@@ -111,43 +119,47 @@ export class CheckoutComponent implements OnInit {
}
ngOnInit(): void {
this.route.queryParams.subscribe(params => {
this.route.queryParams.subscribe((params) => {
this.sessionId = params['session'];
if (!this.sessionId) {
this.error = 'CHECKOUT.ERR_NO_SESSION_START';
this.router.navigate(['/']); // Redirect if no session
return;
}
this.loadSessionDetails();
});
// Toggle shipping validation based on checkbox
this.checkoutForm.get('shippingSameAsBilling')?.valueChanges.subscribe(isSame => {
const shippingGroup = this.checkoutForm.get('shippingAddress') as FormGroup;
if (isSame) {
shippingGroup.disable();
} else {
shippingGroup.enable();
}
});
this.checkoutForm
.get('shippingSameAsBilling')
?.valueChanges.subscribe((isSame) => {
const shippingGroup = this.checkoutForm.get(
'shippingAddress',
) as FormGroup;
if (isSame) {
shippingGroup.disable();
} else {
shippingGroup.enable();
}
});
// Initial state
this.checkoutForm.get('shippingAddress')?.disable();
}
loadSessionDetails() {
if (!this.sessionId) return; // Ensure sessionId is present before fetching
this.quoteService.getQuoteSession(this.sessionId).subscribe({
next: (session) => {
this.quoteSession.set(session);
console.log('Loaded session:', session);
},
error: (err) => {
console.error('Failed to load session', err);
this.error = 'CHECKOUT.ERR_LOAD_SESSION';
}
});
if (!this.sessionId) return; // Ensure sessionId is present before fetching
this.quoteService.getQuoteSession(this.sessionId).subscribe({
next: (session) => {
this.quoteSession.set(session);
console.log('Loaded session:', session);
},
error: (err) => {
console.error('Failed to load session', err);
this.error = 'CHECKOUT.ERR_LOAD_SESSION';
},
});
}
onSubmit() {
@@ -168,7 +180,7 @@ export class CheckoutComponent implements OnInit {
// Assuming firstName, lastName, companyName for customer come from billingAddress if not explicitly in contact group
firstName: formVal.billingAddress.firstName,
lastName: formVal.billingAddress.lastName,
companyName: formVal.billingAddress.companyName
companyName: formVal.billingAddress.companyName,
},
billingAddress: {
firstName: formVal.billingAddress.firstName,
@@ -179,23 +191,25 @@ export class CheckoutComponent implements OnInit {
addressLine2: formVal.billingAddress.addressLine2,
zip: formVal.billingAddress.zip,
city: formVal.billingAddress.city,
countryCode: formVal.billingAddress.countryCode
},
shippingAddress: formVal.shippingSameAsBilling ? null : {
firstName: formVal.shippingAddress.firstName,
lastName: formVal.shippingAddress.lastName,
companyName: formVal.shippingAddress.companyName,
contactPerson: formVal.shippingAddress.referencePerson,
addressLine1: formVal.shippingAddress.addressLine1,
addressLine2: formVal.shippingAddress.addressLine2,
zip: formVal.shippingAddress.zip,
city: formVal.shippingAddress.city,
countryCode: formVal.shippingAddress.countryCode
countryCode: formVal.billingAddress.countryCode,
},
shippingAddress: formVal.shippingSameAsBilling
? null
: {
firstName: formVal.shippingAddress.firstName,
lastName: formVal.shippingAddress.lastName,
companyName: formVal.shippingAddress.companyName,
contactPerson: formVal.shippingAddress.referencePerson,
addressLine1: formVal.shippingAddress.addressLine1,
addressLine2: formVal.shippingAddress.addressLine2,
zip: formVal.shippingAddress.zip,
city: formVal.shippingAddress.city,
countryCode: formVal.shippingAddress.countryCode,
},
shippingSameAsBilling: formVal.shippingSameAsBilling,
language: this.languageService.selectedLang(),
acceptTerms: formVal.acceptLegal,
acceptPrivacy: formVal.acceptLegal
acceptPrivacy: formVal.acceptLegal,
};
if (!this.sessionId) {
@@ -212,13 +226,18 @@ export class CheckoutComponent implements OnInit {
this.error = 'CHECKOUT.ERR_CREATE_ORDER';
return;
}
this.router.navigate(['/', this.languageService.selectedLang(), 'order', orderId]);
this.router.navigate([
'/',
this.languageService.selectedLang(),
'order',
orderId,
]);
},
error: (err) => {
console.error('Order creation failed', err);
this.isSubmitting.set(false);
this.error = 'CHECKOUT.ERR_CREATE_ORDER';
}
},
});
}
}

View File

@@ -1,10 +1,13 @@
@if (sent()) {
<app-success-state context="contact" (action)="resetForm()"></app-success-state>
<app-success-state
context="contact"
(action)="resetForm()"
></app-success-state>
} @else {
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<!-- Request Type -->
<div class="form-group">
<label>{{ 'CONTACT.REQ_TYPE_LABEL' | translate }} *</label>
<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 }}
@@ -14,49 +17,99 @@
<div class="row">
<!-- Phone -->
<app-input formControlName="email" type="email" [label]="'CONTACT.LABEL_EMAIL' | translate" [placeholder]="'CONTACT.PLACEHOLDER_EMAIL' | translate" class="col"></app-input>
<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>
<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
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
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>
<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>
<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>
<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>
<label>{{ "CONTACT.UPLOAD_LABEL" | translate }}</label>
<p class="hint">{{ "CONTACT.UPLOAD_HINT" | translate }}</p>
<p class="hint upload-privacy-note">
{{ 'LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX' | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.UPLOAD_NOTICE_LINK' | translate }}</a>.
{{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate
}}</a
>.
</p>
<div class="drop-zone" (click)="fileInput.click()"
(dragover)="onDragOver($event)" (drop)="onDrop($event)">
<input #fileInput type="file" multiple (change)="onFileSelected($event)" hidden
[accept]="acceptedFormats">
<p>{{ 'CONTACT.DROP_FILES' | 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]="acceptedFormats"
/>
<p>{{ "CONTACT.DROP_FILES" | translate }}</p>
</div>
<div class="file-grid" *ngIf="files().length > 0">
@@ -65,39 +118,80 @@
type="button"
class="remove-btn"
(click)="removeFile(i)"
[attr.aria-label]="'CONTACT.REMOVE_FILE' | translate">×</button>
<img *ngIf="file.type === 'image'" [src]="file.url" class="preview-img">
<video *ngIf="file.type === 'video'" [src]="file.url" class="preview-video" muted playsinline preload="metadata"></video>
<div *ngIf="file.type !== 'image' && file.type !== 'video'" class="file-icon">
<span *ngIf="file.type === 'pdf'">{{ 'CONTACT.FILE_TYPE_PDF' | translate }}</span>
<span *ngIf="file.type === '3d'">{{ 'CONTACT.FILE_TYPE_3D' | translate }}</span>
<span *ngIf="file.type === 'document'">{{ 'CONTACT.FILE_TYPE_DOC' | translate }}</span>
<span *ngIf="file.type === 'other'">{{ 'CONTACT.FILE_TYPE_FILE' | translate }}</span>
[attr.aria-label]="'CONTACT.REMOVE_FILE' | translate"
>
×
</button>
<img
*ngIf="file.type === 'image'"
[src]="file.url"
class="preview-img"
/>
<video
*ngIf="file.type === 'video'"
[src]="file.url"
class="preview-video"
muted
playsinline
preload="metadata"
></video>
<div
*ngIf="file.type !== 'image' && file.type !== 'video'"
class="file-icon"
>
<span *ngIf="file.type === 'pdf'">{{
"CONTACT.FILE_TYPE_PDF" | translate
}}</span>
<span *ngIf="file.type === '3d'">{{
"CONTACT.FILE_TYPE_3D" | translate
}}</span>
<span *ngIf="file.type === 'document'">{{
"CONTACT.FILE_TYPE_DOC" | translate
}}</span>
<span *ngIf="file.type === 'other'">{{
"CONTACT.FILE_TYPE_FILE" | translate
}}</span>
</div>
<div class="file-name" [title]="file.file.name">
{{ file.file.name }}
</div>
<div class="file-name" [title]="file.file.name">{{ file.file.name }}</div>
</div>
</div>
</div>
<div class="legal-consent">
<label class="checkbox-container">
<input type="checkbox" formControlName="acceptLegal">
<input type="checkbox" formControlName="acceptLegal" />
<span class="checkmark"></span>
<span>
{{ 'LEGAL.CONSENT.LABEL_PREFIX' | translate }}
<a href="/terms" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.TERMS_LINK' | translate }}</a>
{{ 'LEGAL.CONSENT.AND' | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.PRIVACY_LINK' | translate }}</a>.
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
<a href="/terms" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.TERMS_LINK" | translate
}}</a>
{{ "LEGAL.CONSENT.AND" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.PRIVACY_LINK" | translate
}}</a
>.
</span>
</label>
<div class="consent-error" *ngIf="form.get('acceptLegal')?.invalid && form.get('acceptLegal')?.touched">
{{ 'LEGAL.CONSENT.REQUIRED_ERROR' | translate }}
<div
class="consent-error"
*ngIf="
form.get('acceptLegal')?.invalid && form.get('acceptLegal')?.touched
"
>
{{ "LEGAL.CONSENT.REQUIRED_ERROR" | translate }}
</div>
</div>
<div class="actions">
<app-button type="submit" [disabled]="form.invalid || sent()">
{{ sent() ? ('CONTACT.MSG_SENT' | translate) : ('CONTACT.SEND' | translate) }}
{{
sent()
? ("CONTACT.MSG_SENT" | translate)
: ("CONTACT.SEND" | translate)
}}
</app-button>
</div>
</form>

View File

@@ -1,7 +1,23 @@
.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); }
.upload-privacy-note { margin-top: calc(var(--space-2) * -1); font-size: 0.78rem; }
.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);
}
.upload-privacy-note {
margin-top: calc(var(--space-2) * -1);
font-size: 0.78rem;
}
.form-control {
padding: 0.5rem 0.75rem;
@@ -11,7 +27,10 @@ label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); co
background: var(--color-bg-card);
color: var(--color-text);
font-family: inherit;
&:focus { outline: none; border-color: var(--color-brand); }
&:focus {
outline: none;
border-color: var(--color-brand);
}
}
select.form-control {
@@ -27,13 +46,18 @@ select.form-control {
flex-direction: column;
gap: var(--space-4);
margin-bottom: var(--space-4);
@media(min-width: 768px) {
@media (min-width: 768px) {
flex-direction: row;
.col { flex: 1; margin-bottom: 0; }
.col {
flex: 1;
margin-bottom: 0;
}
}
}
app-input.col { width: 100%; }
app-input.col {
width: 100%;
}
/* User Type Selector Styles */
.user-type-selector {
@@ -58,14 +82,16 @@ app-input.col { width: 100%; }
color: var(--color-text-muted);
transition: all 0.2s ease;
user-select: none;
&:hover { color: var(--color-text); }
&: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);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
@@ -87,7 +113,10 @@ app-input.col { width: 100%; }
cursor: pointer;
color: var(--color-text-muted);
transition: all 0.2s;
&:hover { border-color: var(--color-brand); color: var(--color-brand); }
&:hover {
border-color: var(--color-brand);
color: var(--color-brand);
}
}
.file-grid {
@@ -111,8 +140,13 @@ app-input.col { width: 100%; }
}
.preview-img {
width: 100%; height: 100%; object-fit: cover; position: absolute; top:0; left:0;
border-radius: var(--radius-sm);
width: 100%;
height: 100%;
object-fit: cover;
position: absolute;
top: 0;
left: 0;
border-radius: var(--radius-sm);
}
.preview-video {
@@ -126,21 +160,46 @@ app-input.col { width: 100%; }
}
.file-icon {
font-weight: 700; color: var(--color-text-muted); font-size: 0.8rem;
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);
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; }
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;
}
}
.legal-consent {
@@ -169,7 +228,7 @@ app-input.col { width: 100%; }
&:checked ~ .checkmark {
background-color: var(--color-brand);
border-color: var(--color-brand);
&:after {
display: block;
}

View File

@@ -1,6 +1,11 @@
import { Component, signal, effect, inject, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import {
ReactiveFormsModule,
FormBuilder,
FormGroup,
Validators,
} from '@angular/forms';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
@@ -18,33 +23,41 @@ import { SuccessStateComponent } from '../../../../shared/components/success-sta
@Component({
selector: 'app-contact-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppButtonComponent, SuccessStateComponent],
imports: [
CommonModule,
ReactiveFormsModule,
TranslateModule,
AppInputComponent,
AppButtonComponent,
SuccessStateComponent,
],
templateUrl: './contact-form.component.html',
styleUrl: './contact-form.component.scss'
styleUrl: './contact-form.component.scss',
})
export class ContactFormComponent implements OnDestroy {
form: FormGroup;
sent = signal(false);
files = signal<FilePreview[]>([]);
readonly acceptedFormats = '.jpg,.jpeg,.png,.webp,.gif,.bmp,.svg,.heic,.heif,.pdf,.stl,.step,.stp,.3mf,.obj,.iges,.igs,.dwg,.dxf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.rtf,.csv,.mp4,.mov,.avi,.mkv,.webm,.m4v,.wmv';
readonly acceptedFormats =
'.jpg,.jpeg,.png,.webp,.gif,.bmp,.svg,.heic,.heif,.pdf,.stl,.step,.stp,.3mf,.obj,.iges,.igs,.dwg,.dxf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.rtf,.csv,.mp4,.mov,.avi,.mkv,.webm,.m4v,.wmv';
get isCompany(): boolean {
return this.form.get('isCompany')?.value;
}
requestTypes = [
{ value: 'custom', label: 'CONTACT.REQ_TYPE_CUSTOM' },
{ value: 'series', label: 'CONTACT.REQ_TYPE_SERIES' },
{ value: 'consult', label: 'CONTACT.REQ_TYPE_CONSULT' },
{ value: 'question', label: 'CONTACT.REQ_TYPE_QUESTION' }
{ value: 'question', label: 'CONTACT.REQ_TYPE_QUESTION' },
];
private quoteRequestService = inject(QuoteRequestService);
constructor(
private fb: FormBuilder,
private translate: TranslateService,
private estimator: QuoteEstimatorService
private fb: FormBuilder,
private translate: TranslateService,
private estimator: QuoteEstimatorService,
) {
this.form = this.fb.group({
requestType: ['custom', Validators.required],
@@ -55,11 +68,11 @@ export class ContactFormComponent implements OnDestroy {
isCompany: [false],
companyName: [''],
referencePerson: [''],
acceptLegal: [false, Validators.requiredTrue]
acceptLegal: [false, Validators.requiredTrue],
});
// Handle conditional validation for Company fields
this.form.get('isCompany')?.valueChanges.subscribe(isCompany => {
this.form.get('isCompany')?.valueChanges.subscribe((isCompany) => {
const nameControl = this.form.get('name');
const companyNameControl = this.form.get('companyName');
const refPersonControl = this.form.get('referencePerson');
@@ -68,45 +81,47 @@ export class ContactFormComponent implements OnDestroy {
// Company Mode: Name not required / cleared, Company defaults required
nameControl?.clearValidators();
nameControl?.setValue(''); // Optional: clear value
companyNameControl?.setValidators([Validators.required]);
refPersonControl?.setValidators([Validators.required]);
} else {
// Private Mode: Name required
nameControl?.setValidators([Validators.required]);
companyNameControl?.clearValidators();
refPersonControl?.clearValidators();
}
nameControl?.updateValueAndValidity();
companyNameControl?.updateValueAndValidity();
refPersonControl?.updateValueAndValidity();
});
// Check for pending consultation data
effect(() => {
// Use timeout or run in constructor to ensure dependency availability?
// Actually best in constructor or ngOnInit. Let's stick to constructor logic but executed immediately.
// Use timeout or run in constructor to ensure dependency availability?
// Actually best in constructor or ngOnInit. Let's stick to constructor logic but executed immediately.
});
const pending = this.estimator.getPendingConsultation();
if (pending) {
this.form.patchValue({
requestType: 'consult',
message: pending.message
});
// Process files
const filePreviews: FilePreview[] = pending.files.map(f => {
const type = this.getFileType(f);
return {
file: f,
type,
url: this.shouldCreatePreview(type) ? URL.createObjectURL(f) : undefined
};
});
this.files.set(filePreviews);
this.form.patchValue({
requestType: 'consult',
message: pending.message,
});
// Process files
const filePreviews: FilePreview[] = pending.files.map((f) => {
const type = this.getFileType(f);
return {
file: f,
type,
url: this.shouldCreatePreview(type)
? URL.createObjectURL(f)
: undefined,
};
});
this.files.set(filePreviews);
}
}
@@ -124,22 +139,29 @@ export class ContactFormComponent implements OnDestroy {
}
onDragOver(event: DragEvent) {
event.preventDefault(); event.stopPropagation();
event.preventDefault();
event.stopPropagation();
}
onDrop(event: DragEvent) {
event.preventDefault(); event.stopPropagation();
if (event.dataTransfer?.files) this.handleFiles(Array.from(event.dataTransfer.files));
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer?.files)
this.handleFiles(Array.from(event.dataTransfer.files));
}
handleFiles(newFiles: File[]) {
const currentFiles = this.files();
const blockedCompressed = newFiles.filter(file => this.isCompressedFile(file));
const blockedCompressed = newFiles.filter((file) =>
this.isCompressedFile(file),
);
if (blockedCompressed.length > 0) {
alert(this.translate.instant('CONTACT.ERR_COMPRESSED_FILES'));
}
const allowedFiles = newFiles.filter(file => !this.isCompressedFile(file));
const allowedFiles = newFiles.filter(
(file) => !this.isCompressedFile(file),
);
if (allowedFiles.length === 0) return;
if (currentFiles.length + allowedFiles.length > 15) {
@@ -147,39 +169,83 @@ export class ContactFormComponent implements OnDestroy {
return;
}
allowedFiles.forEach(file => {
allowedFiles.forEach((file) => {
const type = this.getFileType(file);
const preview: FilePreview = {
file,
type,
url: this.shouldCreatePreview(type) ? URL.createObjectURL(file) : undefined
url: this.shouldCreatePreview(type)
? URL.createObjectURL(file)
: undefined,
};
this.files.update(files => [...files, preview]);
this.files.update((files) => [...files, preview]);
});
}
removeFile(index: number) {
this.files.update(files => {
this.files.update((files) => {
const fileToRemove = files[index];
if (fileToRemove) this.revokePreviewUrl(fileToRemove);
return files.filter((_, i) => i !== index);
});
}
getFileType(file: File): 'image' | 'video' | 'pdf' | '3d' | 'document' | 'other' {
getFileType(
file: File,
): 'image' | 'video' | 'pdf' | '3d' | 'document' | 'other' {
const ext = this.getExtension(file.name);
if (file.type.startsWith('image/') || ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'svg', 'heic', 'heif'].includes(ext)) {
if (
file.type.startsWith('image/') ||
[
'jpg',
'jpeg',
'png',
'webp',
'gif',
'bmp',
'svg',
'heic',
'heif',
].includes(ext)
) {
return 'image';
}
if (file.type.startsWith('video/') || ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'wmv'].includes(ext)) {
if (
file.type.startsWith('video/') ||
['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'wmv'].includes(ext)
) {
return 'video';
}
if (file.type === 'application/pdf' || ext === 'pdf') return 'pdf';
if (['stl', 'step', 'stp', '3mf', 'obj', 'iges', 'igs', 'dwg', 'dxf'].includes(ext)) return '3d';
if ([
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf', 'csv',
].includes(ext)) return 'document';
if (
[
'stl',
'step',
'stp',
'3mf',
'obj',
'iges',
'igs',
'dwg',
'dxf',
].includes(ext)
)
return '3d';
if (
[
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'txt',
'rtf',
'csv',
].includes(ext)
)
return 'document';
return 'other';
}
@@ -187,7 +253,7 @@ export class ContactFormComponent implements OnDestroy {
if (this.form.valid) {
const formVal = this.form.value;
const isCompany = formVal.isCompany;
const requestDto: any = {
requestType: formVal.requestType,
customerType: isCompany ? 'BUSINESS' : 'PRIVATE',
@@ -195,7 +261,7 @@ export class ContactFormComponent implements OnDestroy {
phone: formVal.phone,
message: formVal.message,
acceptTerms: formVal.acceptLegal,
acceptPrivacy: formVal.acceptLegal
acceptPrivacy: formVal.acceptLegal,
};
if (isCompany) {
@@ -205,16 +271,20 @@ export class ContactFormComponent implements OnDestroy {
requestDto.name = formVal.name;
}
this.quoteRequestService.createRequest(requestDto, this.files().map(f => f.file)).subscribe({
next: () => {
this.sent.set(true);
},
error: (err) => {
console.error('Submission failed', err);
alert(this.translate.instant('CONTACT.ERROR_SUBMIT'));
}
});
this.quoteRequestService
.createRequest(
requestDto,
this.files().map((f) => f.file),
)
.subscribe({
next: () => {
this.sent.set(true);
},
error: (err) => {
console.error('Submission failed', err);
alert(this.translate.instant('CONTACT.ERROR_SUBMIT'));
},
});
} else {
this.form.markAllAsTouched();
}
@@ -239,7 +309,17 @@ export class ContactFormComponent implements OnDestroy {
private isCompressedFile(file: File): boolean {
const ext = this.getExtension(file.name);
const compressedExtensions = [
'zip', 'rar', '7z', 'tar', 'gz', 'tgz', 'bz2', 'tbz2', 'xz', 'txz', 'zst'
'zip',
'rar',
'7z',
'tar',
'gz',
'tgz',
'bz2',
'tbz2',
'xz',
'txz',
'zst',
];
const compressedMimeTypes = [
'application/zip',
@@ -253,9 +333,12 @@ export class ContactFormComponent implements OnDestroy {
'application/x-bzip2',
'application/x-xz',
'application/zstd',
'application/x-zstd'
'application/x-zstd',
];
return compressedExtensions.includes(ext) || compressedMimeTypes.includes((file.type || '').toLowerCase());
return (
compressedExtensions.includes(ext) ||
compressedMimeTypes.includes((file.type || '').toLowerCase())
);
}
private revokePreviewUrl(file: FilePreview): void {
@@ -265,6 +348,6 @@ export class ContactFormComponent implements OnDestroy {
}
private revokeAllPreviewUrls(): void {
this.files().forEach(file => this.revokePreviewUrl(file));
this.files().forEach((file) => this.revokePreviewUrl(file));
}
}

View File

@@ -1,7 +1,7 @@
<section class="contact-hero">
<div class="container">
<h1>{{ 'CONTACT.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'CONTACT.HERO_SUBTITLE' | translate }}</p>
<h1>{{ "CONTACT.TITLE" | translate }}</h1>
<p class="subtitle">{{ "CONTACT.HERO_SUBTITLE" | translate }}</p>
</div>
</section>

View File

@@ -7,8 +7,13 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
@Component({
selector: 'app-contact-page',
standalone: true,
imports: [CommonModule, TranslateModule, ContactFormComponent, AppCardComponent],
imports: [
CommonModule,
TranslateModule,
ContactFormComponent,
AppCardComponent,
],
templateUrl: './contact-page.component.html',
styleUrl: './contact-page.component.scss'
styleUrl: './contact-page.component.scss',
})
export class ContactPageComponent {}

View File

@@ -3,6 +3,7 @@ import { Routes } from '@angular/router';
export const CONTACT_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./contact-page.component').then(m => m.ContactPageComponent)
}
loadComponent: () =>
import('./contact-page.component').then((m) => m.ContactPageComponent),
},
];

View File

@@ -1,176 +1,211 @@
<main class="home-page">
<section class="hero">
<div class="container hero-grid">
<div class="hero-copy">
<p class="eyebrow">{{ 'HOME.HERO_EYEBROW' | translate }}</p>
<h1 class="hero-title" [innerHTML]="'HOME.HERO_TITLE' | translate"></h1>
<p class="hero-lead">
{{ 'HOME.HERO_LEAD' | translate }}
</p>
<p class="hero-subtitle">
{{ 'HOME.HERO_SUBTITLE' | translate }}
</p>
<div class="hero-actions">
<app-button variant="primary" routerLink="/calculator/basic">{{ 'HOME.BTN_CALCULATE' | translate }}</app-button>
<app-button variant="outline" routerLink="/shop">{{ 'HOME.BTN_SHOP' | translate }}</app-button>
<app-button variant="text" routerLink="/contact">{{ 'HOME.BTN_CONTACT' | translate }}</app-button>
</div>
</div>
<main class="home-page">
<section class="hero">
<div class="container hero-grid">
<div class="hero-copy">
<p class="eyebrow">{{ "HOME.HERO_EYEBROW" | translate }}</p>
<h1 class="hero-title" [innerHTML]="'HOME.HERO_TITLE' | translate"></h1>
<p class="hero-lead">
{{ "HOME.HERO_LEAD" | translate }}
</p>
<p class="hero-subtitle">
{{ "HOME.HERO_SUBTITLE" | translate }}
</p>
<div class="hero-actions">
<app-button variant="primary" routerLink="/calculator/basic">{{
"HOME.BTN_CALCULATE" | translate
}}</app-button>
<app-button variant="outline" routerLink="/shop">{{
"HOME.BTN_SHOP" | translate
}}</app-button>
<app-button variant="text" routerLink="/contact">{{
"HOME.BTN_CONTACT" | translate
}}</app-button>
</div>
</section>
</div>
</div>
</section>
<section class="section capabilities">
<div class="capabilities-bg"></div>
<div class="container">
<div class="section-head">
<h2 class="section-title">{{ 'HOME.SEC_CAP_TITLE' | translate }}</h2>
<p class="section-subtitle">
{{ 'HOME.SEC_CAP_SUBTITLE' | translate }}
</p>
<section class="section capabilities">
<div class="capabilities-bg"></div>
<div class="container">
<div class="section-head">
<h2 class="section-title">{{ "HOME.SEC_CAP_TITLE" | translate }}</h2>
<p class="section-subtitle">
{{ "HOME.SEC_CAP_SUBTITLE" | translate }}
</p>
</div>
<div class="cap-cards">
<app-card>
<div class="card-image-placeholder">
<img src="assets/images/home/prototipi.jpg" alt="" />
</div>
<div class="cap-cards">
<app-card>
<div class="card-image-placeholder">
<img src="assets/images/home/prototipi.jpg" alt="">
</div>
<h3>{{ 'HOME.CAP_1_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CAP_1_TEXT' | translate }}</p>
</app-card>
<app-card>
<div class="card-image-placeholder">
<img src="assets/images/home/original-vs-3dprinted.jpg" alt="">
</div>
<h3>{{ 'HOME.CAP_2_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CAP_2_TEXT' | translate }}</p>
</app-card>
<app-card>
<div class="card-image-placeholder">
<img src="assets/images/home/serie.jpg" alt="">
</div>
<h3>{{ 'HOME.CAP_3_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CAP_3_TEXT' | translate }}</p>
</app-card>
<app-card>
<div class="card-image-placeholder">
<img src="assets/images/home/cad.jpg" alt="">
</div>
<h3>{{ 'HOME.CAP_4_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CAP_4_TEXT' | translate }}</p>
</app-card>
<h3>{{ "HOME.CAP_1_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_1_TEXT" | translate }}</p>
</app-card>
<app-card>
<div class="card-image-placeholder">
<img src="assets/images/home/original-vs-3dprinted.jpg" alt="" />
</div>
</div>
</section>
<h3>{{ "HOME.CAP_2_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_2_TEXT" | translate }}</p>
</app-card>
<app-card>
<div class="card-image-placeholder">
<img src="assets/images/home/serie.jpg" alt="" />
</div>
<h3>{{ "HOME.CAP_3_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_3_TEXT" | translate }}</p>
</app-card>
<app-card>
<div class="card-image-placeholder">
<img src="assets/images/home/cad.jpg" alt="" />
</div>
<h3>{{ "HOME.CAP_4_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CAP_4_TEXT" | translate }}</p>
</app-card>
</div>
</div>
</section>
<section class="section calculator">
<div class="container calculator-grid">
<div class="calculator-copy">
<h2 class="section-title">{{ 'HOME.SEC_CALC_TITLE' | translate }}</h2>
<p class="section-subtitle">
{{ 'HOME.SEC_CALC_SUBTITLE' | translate }}
<section class="section calculator">
<div class="container calculator-grid">
<div class="calculator-copy">
<h2 class="section-title">{{ "HOME.SEC_CALC_TITLE" | translate }}</h2>
<p class="section-subtitle">
{{ "HOME.SEC_CALC_SUBTITLE" | translate }}
</p>
<ul class="calculator-list">
<li>{{ "HOME.SEC_CALC_LIST_1" | translate }}</li>
</ul>
</div>
<app-card class="quote-card">
<div class="quote-header">
<div>
<p class="quote-eyebrow">
{{ "HOME.CARD_CALC_EYEBROW" | translate }}
</p>
<ul class="calculator-list">
<li>{{ 'HOME.SEC_CALC_LIST_1' | translate }}</li>
</ul>
<h3 class="quote-title">
{{ "HOME.CARD_CALC_TITLE" | translate }}
</h3>
</div>
<app-card class="quote-card">
<div class="quote-header">
<div>
<p class="quote-eyebrow">{{ 'HOME.CARD_CALC_EYEBROW' | translate }}</p>
<h3 class="quote-title">{{ 'HOME.CARD_CALC_TITLE' | translate }}</h3>
</div>
<span class="quote-tag">{{ 'HOME.CARD_CALC_TAG' | translate }}</span>
</div>
<ul class="quote-steps">
<li>{{ 'HOME.CARD_CALC_STEP_1' | translate }}</li>
<li>{{ 'HOME.CARD_CALC_STEP_2' | translate }}</li>
<li>{{ 'HOME.CARD_CALC_STEP_3' | translate }}</li>
</ul>
<div class="quote-actions">
<app-button variant="primary" [fullWidth]="true" routerLink="/calculator/basic">{{ 'HOME.BTN_OPEN_CALC' | translate }}</app-button>
<app-button variant="outline" [fullWidth]="true" routerLink="/contact">{{ 'HOME.BTN_CONTACT' | translate }}</app-button>
</div>
</app-card>
<span class="quote-tag">{{ "HOME.CARD_CALC_TAG" | translate }}</span>
</div>
</section>
<ul class="quote-steps">
<li>{{ "HOME.CARD_CALC_STEP_1" | translate }}</li>
<li>{{ "HOME.CARD_CALC_STEP_2" | translate }}</li>
<li>{{ "HOME.CARD_CALC_STEP_3" | translate }}</li>
</ul>
<div class="quote-actions">
<app-button
variant="primary"
[fullWidth]="true"
routerLink="/calculator/basic"
>{{ "HOME.BTN_OPEN_CALC" | translate }}</app-button
>
<app-button
variant="outline"
[fullWidth]="true"
routerLink="/contact"
>{{ "HOME.BTN_CONTACT" | translate }}</app-button
>
</div>
</app-card>
</div>
</section>
<section class="section shop">
<div class="container split">
<div class="shop-copy">
<h2 class="section-title">{{ 'HOME.SEC_SHOP_TITLE' | translate }}</h2>
<p>
{{ 'HOME.SEC_SHOP_TEXT' | translate }}
</p>
<ul class="shop-list">
<li>{{ 'HOME.SEC_SHOP_LIST_1' | translate }}</li>
<li>{{ 'HOME.SEC_SHOP_LIST_2' | translate }}</li>
<li>{{ 'HOME.SEC_SHOP_LIST_3' | translate }}</li>
</ul>
<div class="shop-actions">
<app-button variant="primary" routerLink="/shop">{{ 'HOME.BTN_DISCOVER' | translate }}</app-button>
<app-button variant="outline" routerLink="/contact">{{ 'HOME.BTN_REQ_SOLUTION' | translate }}</app-button>
</div>
</div>
<div class="shop-gallery" tabindex="0" [attr.aria-label]="'HOME.SHOP_GALLERY_ARIA' | translate">
<figure class="shop-gallery-item" *ngFor="let image of shopGalleryImages">
<img [src]="image.src" [alt]="image.alt | translate">
</figure>
</div>
<div class="shop-cards">
<app-card>
<h3>{{ 'HOME.CARD_SHOP_1_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CARD_SHOP_1_TEXT' | translate }}</p>
</app-card>
<app-card>
<h3>{{ 'HOME.CARD_SHOP_2_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CARD_SHOP_2_TEXT' | translate }}</p>
</app-card>
<app-card>
<h3>{{ 'HOME.CARD_SHOP_3_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'HOME.CARD_SHOP_3_TEXT' | translate }}</p>
</app-card>
</div>
<section class="section shop">
<div class="container split">
<div class="shop-copy">
<h2 class="section-title">{{ "HOME.SEC_SHOP_TITLE" | translate }}</h2>
<p>
{{ "HOME.SEC_SHOP_TEXT" | translate }}
</p>
<ul class="shop-list">
<li>{{ "HOME.SEC_SHOP_LIST_1" | translate }}</li>
<li>{{ "HOME.SEC_SHOP_LIST_2" | translate }}</li>
<li>{{ "HOME.SEC_SHOP_LIST_3" | translate }}</li>
</ul>
<div class="shop-actions">
<app-button variant="primary" routerLink="/shop">{{
"HOME.BTN_DISCOVER" | translate
}}</app-button>
<app-button variant="outline" routerLink="/contact">{{
"HOME.BTN_REQ_SOLUTION" | translate
}}</app-button>
</div>
</section>
</div>
<div
class="shop-gallery"
tabindex="0"
[attr.aria-label]="'HOME.SHOP_GALLERY_ARIA' | translate"
>
<figure
class="shop-gallery-item"
*ngFor="let image of shopGalleryImages"
>
<img [src]="image.src" [alt]="image.alt | translate" />
</figure>
</div>
<div class="shop-cards">
<app-card>
<h3>{{ "HOME.CARD_SHOP_1_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CARD_SHOP_1_TEXT" | translate }}</p>
</app-card>
<app-card>
<h3>{{ "HOME.CARD_SHOP_2_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CARD_SHOP_2_TEXT" | translate }}</p>
</app-card>
<app-card>
<h3>{{ "HOME.CARD_SHOP_3_TITLE" | translate }}</h3>
<p class="text-muted">{{ "HOME.CARD_SHOP_3_TEXT" | translate }}</p>
</app-card>
</div>
</div>
</section>
<section class="section about">
<div class="container about-grid">
<div class="about-copy">
<h2 class="section-title">{{ 'HOME.SEC_ABOUT_TITLE' | translate }}</h2>
<p>
{{ 'HOME.SEC_ABOUT_TEXT' | translate }}
</p>
<div class="about-actions">
<app-button variant="primary" routerLink="/about">{{ 'HOME.SEC_ABOUT_TITLE' | translate }}</app-button>
<app-button variant="outline" routerLink="/contact">{{ 'HOME.BTN_CONTACT' | translate }}</app-button>
</div>
</div>
<div class="about-media">
<div class="about-feature-image">
<img
class="about-feature-photo"
[src]="founderImages[founderImageIndex].src"
[alt]="founderImages[founderImageIndex].alt | translate"
width="1200"
height="900"
>
<button
type="button"
class="founder-nav founder-nav-prev"
(click)="prevFounderImage()"
[attr.aria-label]="'HOME.FOUNDER_PREV_ARIA' | translate"
>
</button>
<button
type="button"
class="founder-nav founder-nav-next"
(click)="nextFounderImage()"
[attr.aria-label]="'HOME.FOUNDER_NEXT_ARIA' | translate"
>
</button>
</div>
</div>
<section class="section about">
<div class="container about-grid">
<div class="about-copy">
<h2 class="section-title">{{ "HOME.SEC_ABOUT_TITLE" | translate }}</h2>
<p>
{{ "HOME.SEC_ABOUT_TEXT" | translate }}
</p>
<div class="about-actions">
<app-button variant="primary" routerLink="/about">{{
"HOME.SEC_ABOUT_TITLE" | translate
}}</app-button>
<app-button variant="outline" routerLink="/contact">{{
"HOME.BTN_CONTACT" | translate
}}</app-button>
</div>
</section>
</main>
</div>
<div class="about-media">
<div class="about-feature-image">
<img
class="about-feature-photo"
[src]="founderImages[founderImageIndex].src"
[alt]="founderImages[founderImageIndex].alt | translate"
width="1200"
height="900"
/>
<button
type="button"
class="founder-nav founder-nav-prev"
(click)="prevFounderImage()"
[attr.aria-label]="'HOME.FOUNDER_PREV_ARIA' | translate"
>
</button>
<button
type="button"
class="founder-nav founder-nav-next"
(click)="nextFounderImage()"
[attr.aria-label]="'HOME.FOUNDER_NEXT_ARIA' | translate"
>
</button>
</div>
</div>
</div>
</section>
</main>

View File

@@ -1,457 +1,529 @@
@use '../../../styles/patterns';
@use "../../../styles/patterns";
.home-page {
--home-bg: #faf9f6;
--color-bg-card: #ffffff;
background: var(--home-bg);
}
.home-page {
--home-bg: #faf9f6;
--color-bg-card: #ffffff;
background: var(--home-bg);
}
.hero {
position: relative;
padding: 6rem 0 5rem;
overflow: hidden;
background: var(--home-bg);
// Enhanced Grid Pattern
&::after {
content: '';
position: absolute;
inset: 0;
@include patterns.pattern-grid(var(--color-neutral-900), 40px, 1px);
opacity: 0.06;
z-index: 0;
pointer-events: none;
mask-image: linear-gradient(to bottom, black 70%, transparent 100%);
}
}
.hero {
position: relative;
padding: 6rem 0 5rem;
overflow: hidden;
background: var(--home-bg);
// Enhanced Grid Pattern
&::after {
content: "";
position: absolute;
inset: 0;
@include patterns.pattern-grid(var(--color-neutral-900), 40px, 1px);
opacity: 0.06;
z-index: 0;
pointer-events: none;
mask-image: linear-gradient(to bottom, black 70%, transparent 100%);
}
}
// Keep the accent blob
.hero::before {
content: '';
position: absolute;
width: 420px;
height: 420px;
right: -120px;
top: -160px;
background: radial-gradient(circle at 30% 30%, rgba(0, 0, 0, 0.03), transparent 70%);
opacity: 0.8;
z-index: 0;
animation: floatGlow 12s ease-in-out infinite;
}
// Keep the accent blob
.hero::before {
content: "";
position: absolute;
width: 420px;
height: 420px;
right: -120px;
top: -160px;
background: radial-gradient(
circle at 30% 30%,
rgba(0, 0, 0, 0.03),
transparent 70%
);
opacity: 0.8;
z-index: 0;
animation: floatGlow 12s ease-in-out infinite;
}
.hero-grid {
display: grid;
gap: var(--space-12);
align-items: center;
position: relative;
z-index: 1;
}
.hero-grid {
display: grid;
gap: var(--space-12);
align-items: center;
position: relative;
z-index: 1;
}
.hero-copy { animation: fadeUp 0.8s ease both; }
.hero-panel { animation: fadeUp 0.8s ease 0.15s both; }
.hero-copy {
animation: fadeUp 0.8s ease both;
}
.hero-panel {
animation: fadeUp 0.8s ease 0.15s both;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.75rem;
color: var(--color-secondary-600);
margin-bottom: var(--space-3);
font-weight: 600;
}
.hero-title {
font-size: clamp(2.5rem, 2.4vw + 1.8rem, 4rem);
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.02em;
margin-bottom: var(--space-4);
}
.hero-lead {
font-size: 1.35rem;
font-weight: 500;
color: var(--color-neutral-900);
margin-bottom: var(--space-3);
max-width: 600px;
}
.hero-subtitle {
font-size: 1.1rem;
color: var(--color-text-muted);
max-width: 560px;
line-height: 1.6;
}
.hero-actions {
display: flex;
gap: var(--space-4);
flex-wrap: wrap;
margin: var(--space-6) 0 var(--space-4);
}
.hero-badges {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.hero-badges span {
display: inline-flex;
padding: 0.35rem 0.75rem;
border-radius: 999px;
background: var(--color-neutral-100);
color: var(--color-neutral-900);
font-size: 0.85rem;
font-weight: 600;
border: 1px solid var(--color-border);
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.75rem;
color: var(--color-secondary-600);
margin-bottom: var(--space-3);
font-weight: 600;
}
.hero-title {
font-size: clamp(2.5rem, 2.4vw + 1.8rem, 4rem);
font-weight: 700;
line-height: 1.05;
letter-spacing: -0.02em;
margin-bottom: var(--space-4);
}
.hero-lead {
font-size: 1.35rem;
font-weight: 500;
color: var(--color-neutral-900);
margin-bottom: var(--space-3);
max-width: 600px;
}
.hero-subtitle {
font-size: 1.1rem;
color: var(--color-text-muted);
max-width: 560px;
line-height: 1.6;
}
.hero-actions {
display: flex;
gap: var(--space-4);
flex-wrap: wrap;
margin: var(--space-6) 0 var(--space-4);
}
.hero-badges {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.hero-badges span {
display: inline-flex;
padding: 0.35rem 0.75rem;
border-radius: 999px;
background: var(--color-neutral-100);
color: var(--color-neutral-900);
font-size: 0.85rem;
font-weight: 600;
border: 1px solid var(--color-border);
}
.quote-card {
display: block;
}
.focus-card {
display: grid;
gap: var(--space-4);
}
.focus-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: var(--space-2);
color: var(--color-text-muted);
}
.focus-list li::before {
content: '';
color: var(--color-brand);
margin-right: var(--space-2);
}
.focus-list li {
display: flex;
align-items: baseline;
gap: var(--space-2);
}
.quote-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.quote-eyebrow {
text-transform: uppercase;
font-size: 0.7rem;
letter-spacing: 0.12em;
color: var(--color-secondary-600);
margin: 0 0 var(--space-2);
}
.quote-title { margin: 0; font-size: 1.35rem; }
.quote-tag {
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--color-brand-600);
background: var(--color-brand-50);
border-color: var(--color-brand-200);
}
.quote-steps {
list-style: none;
padding: 0;
margin: 0 0 var(--space-5);
display: grid;
gap: var(--space-2);
}
.quote-steps li {
position: relative;
padding-left: 1.5rem;
color: var(--color-text-muted);
}
.quote-steps li::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-brand);
position: absolute;
left: 0.25rem;
top: 0.5rem;
}
.quote-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
margin-bottom: var(--space-5);
}
.meta-label {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-secondary-600);
margin-bottom: var(--space-1);
}
.meta-value { font-weight: 600; }
.quote-actions { display: grid; gap: var(--space-3); }
.quote-card {
display: block;
}
.focus-card {
display: grid;
gap: var(--space-4);
}
.focus-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: var(--space-2);
color: var(--color-text-muted);
}
.focus-list li::before {
content: "";
color: var(--color-brand);
margin-right: var(--space-2);
}
.focus-list li {
display: flex;
align-items: baseline;
gap: var(--space-2);
}
.quote-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.quote-eyebrow {
text-transform: uppercase;
font-size: 0.7rem;
letter-spacing: 0.12em;
color: var(--color-secondary-600);
margin: 0 0 var(--space-2);
}
.quote-title {
margin: 0;
font-size: 1.35rem;
}
.quote-tag {
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--color-brand-600);
background: var(--color-brand-50);
border-color: var(--color-brand-200);
}
.quote-steps {
list-style: none;
padding: 0;
margin: 0 0 var(--space-5);
display: grid;
gap: var(--space-2);
}
.quote-steps li {
position: relative;
padding-left: 1.5rem;
color: var(--color-text-muted);
}
.quote-steps li::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-brand);
position: absolute;
left: 0.25rem;
top: 0.5rem;
}
.quote-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
margin-bottom: var(--space-5);
}
.meta-label {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-secondary-600);
margin-bottom: var(--space-1);
}
.meta-value {
font-weight: 600;
}
.quote-actions {
display: grid;
gap: var(--space-3);
}
.capabilities {
position: relative;
border-bottom: 1px solid var(--color-border);
padding-top: 3rem;
}
.capabilities-bg {
display: none;
}
.capabilities-bg {
display: none;
}
.section { padding: 5.5rem 0; position: relative; }
.section-head { margin-bottom: var(--space-8); }
.section-title { font-size: clamp(2rem, 1.8vw + 1.2rem, 2.8rem); margin-bottom: var(--space-3); }
.section-subtitle { color: var(--color-text-muted); max-width: 620px; }
.text-muted { color: var(--color-text-muted); }
.section {
padding: 5.5rem 0;
position: relative;
}
.section-head {
margin-bottom: var(--space-8);
}
.section-title {
font-size: clamp(2rem, 1.8vw + 1.2rem, 2.8rem);
margin-bottom: var(--space-3);
}
.section-subtitle {
color: var(--color-text-muted);
max-width: 620px;
}
.text-muted {
color: var(--color-text-muted);
}
.calculator {
position: relative;
border-bottom: 1px solid var(--color-border);
}
.calculator-grid {
display: grid;
gap: var(--space-10);
align-items: start;
position: relative;
z-index: 1;
}
.calculator-list {
padding-left: var(--space-4);
color: var(--color-text-muted);
margin: var(--space-6) 0 0;
}
.cap-cards {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.calculator {
position: relative;
border-bottom: 1px solid var(--color-border);
}
.calculator-grid {
display: grid;
gap: var(--space-10);
align-items: start;
position: relative;
z-index: 1;
}
.calculator-list {
padding-left: var(--space-4);
color: var(--color-text-muted);
margin: var(--space-6) 0 0;
}
.cap-cards {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.card-image-placeholder {
width: 100%;
height: 160px;
background: #f5f5f5;
margin: -1.5rem -1.5rem 1.5rem -1.5rem; /* Negative margins to bleed to edge */
width: calc(100% + 3rem);
border-top-left-radius: var(--radius-lg);
border-top-right-radius: var(--radius-lg);
border-bottom: 1px solid var(--color-neutral-300);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-neutral-400);
overflow: hidden;
}
.card-image-placeholder {
width: 100%;
height: 160px;
background: #f5f5f5;
margin: -1.5rem -1.5rem 1.5rem -1.5rem; /* Negative margins to bleed to edge */
width: calc(100% + 3rem);
border-top-left-radius: var(--radius-lg);
border-top-right-radius: var(--radius-lg);
border-bottom: 1px solid var(--color-neutral-300);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-neutral-400);
overflow: hidden;
}
.card-image-placeholder img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.card-image-placeholder img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.shop {
background: var(--home-bg);
position: relative;
}
.shop .split { align-items: start; }
.shop-copy {
max-width: 760px;
}
.split {
display: grid;
gap: var(--space-10);
align-items: center;
position: relative;
z-index: 1;
}
.shop-list {
padding-left: var(--space-4);
color: var(--color-text-muted);
margin-bottom: var(--space-6);
}
.shop-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.shop-gallery {
display: flex;
gap: var(--space-4);
overflow-x: auto;
scroll-snap-type: x mandatory;
padding-bottom: var(--space-2);
scrollbar-width: thin;
width: min(100%, 440px);
justify-self: end;
aspect-ratio: 16 / 11;
}
.shop {
background: var(--home-bg);
position: relative;
}
.shop .split {
align-items: start;
}
.shop-copy {
max-width: 760px;
}
.split {
display: grid;
gap: var(--space-10);
align-items: center;
position: relative;
z-index: 1;
}
.shop-list {
padding-left: var(--space-4);
color: var(--color-text-muted);
margin-bottom: var(--space-6);
}
.shop-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.shop-gallery {
display: flex;
gap: var(--space-4);
overflow-x: auto;
scroll-snap-type: x mandatory;
padding-bottom: var(--space-2);
scrollbar-width: thin;
width: min(100%, 440px);
justify-self: end;
aspect-ratio: 16 / 11;
}
.shop-gallery-item {
flex: 0 0 100%;
margin: 0;
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--color-border);
background: var(--color-neutral-100);
box-shadow: var(--shadow-sm);
scroll-snap-align: start;
aspect-ratio: 16 / 10;
}
.shop-gallery-item {
flex: 0 0 100%;
margin: 0;
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--color-border);
background: var(--color-neutral-100);
box-shadow: var(--shadow-sm);
scroll-snap-align: start;
aspect-ratio: 16 / 10;
}
.shop-gallery-item img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.shop-gallery-item img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.shop-cards {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.shop-cards {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.shop-cards h3 {
margin-top: 0;
margin-bottom: var(--space-2);
}
.shop-cards h3 {
margin-top: 0;
margin-bottom: var(--space-2);
}
.shop-cards p {
margin: 0;
}
.shop-cards p {
margin: 0;
}
.about {
background: transparent;
border-top: 1px solid var(--color-border);
position: relative;
}
.about-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.about-grid {
display: grid;
gap: var(--space-10);
align-items: center;
}
.about-media {
position: relative;
display: flex;
justify-content: flex-end;
}
.about {
background: transparent;
border-top: 1px solid var(--color-border);
position: relative;
}
.about-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.about-grid {
display: grid;
gap: var(--space-10);
align-items: center;
}
.about-media {
position: relative;
display: flex;
justify-content: flex-end;
}
.about-feature-image {
width: 100%;
max-width: 620px;
aspect-ratio: 16 / 10;
border-radius: var(--radius-lg);
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
position: relative;
overflow: hidden;
contain: layout paint;
}
.about-feature-image {
width: 100%;
max-width: 620px;
aspect-ratio: 16 / 10;
border-radius: var(--radius-lg);
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
position: relative;
overflow: hidden;
contain: layout paint;
}
.about-feature-photo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.about-feature-photo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.founder-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.6);
background: rgba(17, 24, 39, 0.45);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
z-index: 1;
transition: background-color 0.2s ease;
}
.founder-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.6);
background: rgba(17, 24, 39, 0.45);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
z-index: 1;
transition: background-color 0.2s ease;
}
.founder-nav:hover {
background: rgba(17, 24, 39, 0.7);
}
.founder-nav:hover {
background: rgba(17, 24, 39, 0.7);
}
.founder-nav-prev { left: 0.75rem; }
.founder-nav-next { right: 0.75rem; }
.founder-nav-prev {
left: 0.75rem;
}
.founder-nav-next {
right: 0.75rem;
}
.founder-nav:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
.media-tile p {
margin: 0;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.about-note {
padding: var(--space-5);
}
.founder-nav:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 2px;
}
.media-tile p {
margin: 0;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.about-note {
padding: var(--space-5);
}
@media (min-width: 960px) {
.hero-grid { grid-template-columns: 1.1fr 0.9fr; }
.calculator-grid { grid-template-columns: 1.1fr 0.9fr; }
.calculator-grid { grid-template-columns: 1.1fr 0.9fr; }
.split { grid-template-columns: 1.1fr 0.9fr; }
.shop-copy { grid-column: 1; }
.shop-gallery { grid-column: 2; }
.shop-cards {
grid-column: 1 / -1;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.about-grid { grid-template-columns: 1.1fr 0.9fr; }
}
@media (min-width: 960px) {
.hero-grid {
grid-template-columns: 1.1fr 0.9fr;
}
.calculator-grid {
grid-template-columns: 1.1fr 0.9fr;
}
.calculator-grid {
grid-template-columns: 1.1fr 0.9fr;
}
.split {
grid-template-columns: 1.1fr 0.9fr;
}
.shop-copy {
grid-column: 1;
}
.shop-gallery {
grid-column: 2;
}
.shop-cards {
grid-column: 1 / -1;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.about-grid {
grid-template-columns: 1.1fr 0.9fr;
}
}
@media (max-width: 640px) {
.hero-actions { flex-direction: column; align-items: stretch; }
.quote-meta { grid-template-columns: 1fr; }
.shop-gallery {
width: 100%;
max-width: none;
justify-self: stretch;
}
.shop-gallery-item {
aspect-ratio: 16 / 11;
}
.shop-cards { grid-template-columns: 1fr; }
.about-media {
justify-content: flex-start;
}
.about-feature-image {
max-width: min(100%, 360px);
aspect-ratio: 16 / 11;
}
.founder-nav {
width: 2rem;
height: 2rem;
font-size: 1.25rem;
}
}
@media (max-width: 640px) {
.hero-actions {
flex-direction: column;
align-items: stretch;
}
.quote-meta {
grid-template-columns: 1fr;
}
.shop-gallery {
width: 100%;
max-width: none;
justify-self: stretch;
}
.shop-gallery-item {
aspect-ratio: 16 / 11;
}
.shop-cards {
grid-template-columns: 1fr;
}
.about-media {
justify-content: flex-start;
}
.about-feature-image {
max-width: min(100%, 360px);
aspect-ratio: 16 / 11;
}
.founder-nav {
width: 2rem;
height: 2rem;
font-size: 1.25rem;
}
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(18px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes floatGlow {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(20px); }
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes floatGlow {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(20px);
}
}
@media (prefers-reduced-motion: reduce) {
.hero-copy, .hero-panel { animation: none; }
.hero::before { animation: none; }
}
@media (prefers-reduced-motion: reduce) {
.hero-copy,
.hero-panel {
animation: none;
}
.hero::before {
animation: none;
}
}

View File

@@ -8,27 +8,33 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
@Component({
selector: 'app-home-page',
standalone: true,
imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent, AppCardComponent],
imports: [
CommonModule,
RouterLink,
TranslateModule,
AppButtonComponent,
AppCardComponent,
],
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss']
styleUrls: ['./home.component.scss'],
})
export class HomeComponent {
readonly shopGalleryImages = [
{
src: 'assets/images/home/supporto-bici.jpg',
alt: 'HOME.SHOP_IMAGE_ALT_1'
}
alt: 'HOME.SHOP_IMAGE_ALT_1',
},
];
readonly founderImages = [
{
src: 'assets/images/home/da-cambiare.jpg',
alt: 'HOME.FOUNDER_IMAGE_ALT_1'
alt: 'HOME.FOUNDER_IMAGE_ALT_1',
},
{
src: 'assets/images/home/vino.JPG',
alt: 'HOME.FOUNDER_IMAGE_ALT_2'
}
alt: 'HOME.FOUNDER_IMAGE_ALT_2',
},
];
founderImageIndex = 0;

View File

@@ -3,10 +3,12 @@ import { Routes } from '@angular/router';
export const LEGAL_ROUTES: Routes = [
{
path: 'privacy',
loadComponent: () => import('./privacy/privacy.component').then(m => m.PrivacyComponent)
loadComponent: () =>
import('./privacy/privacy.component').then((m) => m.PrivacyComponent),
},
{
path: 'terms',
loadComponent: () => import('./terms/terms.component').then(m => m.TermsComponent)
}
loadComponent: () =>
import('./terms/terms.component').then((m) => m.TermsComponent),
},
];

View File

@@ -1,38 +1,39 @@
<section class="legal-page">
<div class="container narrow">
<h1>{{ 'LEGAL.PRIVACY_TITLE' | translate }}</h1>
<h1>{{ "LEGAL.PRIVACY_TITLE" | translate }}</h1>
<div class="content">
<p class="intro">
{{ 'LEGAL.LAST_UPDATE' | translate }}: {{ 'LEGAL.PRIVACY_UPDATE_DATE' | translate }}
{{ "LEGAL.LAST_UPDATE" | translate }}:
{{ "LEGAL.PRIVACY_UPDATE_DATE" | translate }}
</p>
<p>{{ 'LEGAL.PRIVACY.META.CONTROLLER' | translate }}</p>
<p>{{ 'LEGAL.PRIVACY.META.CONTACT' | translate }}</p>
<p>{{ "LEGAL.PRIVACY.META.CONTROLLER" | translate }}</p>
<p>{{ "LEGAL.PRIVACY.META.CONTACT" | translate }}</p>
<h2>{{ 'LEGAL.PRIVACY.S1.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.PRIVACY.S1.P1' | translate }}</p>
<p>{{ 'LEGAL.PRIVACY.S1.P2' | translate }}</p>
<h2>{{ "LEGAL.PRIVACY.S1.TITLE" | translate }}</h2>
<p>{{ "LEGAL.PRIVACY.S1.P1" | translate }}</p>
<p>{{ "LEGAL.PRIVACY.S1.P2" | translate }}</p>
<h2>{{ 'LEGAL.PRIVACY.S2.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.PRIVACY.S2.P1' | translate }}</p>
<p>{{ 'LEGAL.PRIVACY.S2.P2' | translate }}</p>
<h2>{{ "LEGAL.PRIVACY.S2.TITLE" | translate }}</h2>
<p>{{ "LEGAL.PRIVACY.S2.P1" | translate }}</p>
<p>{{ "LEGAL.PRIVACY.S2.P2" | translate }}</p>
<h2>{{ 'LEGAL.PRIVACY.S3.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.PRIVACY.S3.P1' | translate }}</p>
<p>{{ 'LEGAL.PRIVACY.S3.P2' | translate }}</p>
<p>{{ 'LEGAL.PRIVACY.S3.P3' | translate }}</p>
<h2>{{ "LEGAL.PRIVACY.S3.TITLE" | translate }}</h2>
<p>{{ "LEGAL.PRIVACY.S3.P1" | translate }}</p>
<p>{{ "LEGAL.PRIVACY.S3.P2" | translate }}</p>
<p>{{ "LEGAL.PRIVACY.S3.P3" | translate }}</p>
<h2>{{ 'LEGAL.PRIVACY.S4.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.PRIVACY.S4.P1' | translate }}</p>
<p>{{ 'LEGAL.PRIVACY.S4.P2' | translate }}</p>
<h2>{{ "LEGAL.PRIVACY.S4.TITLE" | translate }}</h2>
<p>{{ "LEGAL.PRIVACY.S4.P1" | translate }}</p>
<p>{{ "LEGAL.PRIVACY.S4.P2" | translate }}</p>
<h2>{{ 'LEGAL.PRIVACY.S5.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.PRIVACY.S5.P1' | translate }}</p>
<p>{{ 'LEGAL.PRIVACY.S5.P2' | translate }}</p>
<h2>{{ "LEGAL.PRIVACY.S5.TITLE" | translate }}</h2>
<p>{{ "LEGAL.PRIVACY.S5.P1" | translate }}</p>
<p>{{ "LEGAL.PRIVACY.S5.P2" | translate }}</p>
<h2>{{ 'LEGAL.PRIVACY.S6.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.PRIVACY.S6.P1' | translate }}</p>
<p>{{ 'LEGAL.PRIVACY.S6.P2' | translate }}</p>
<h2>{{ "LEGAL.PRIVACY.S6.TITLE" | translate }}</h2>
<p>{{ "LEGAL.PRIVACY.S6.P1" | translate }}</p>
<p>{{ "LEGAL.PRIVACY.S6.P2" | translate }}</p>
</div>
</div>
</section>

View File

@@ -29,7 +29,7 @@
.content {
line-height: 1.8;
color: var(--color-text-main);
p {
margin-bottom: 1.5rem;
}

View File

@@ -6,6 +6,6 @@ import { TranslateModule } from '@ngx-translate/core';
standalone: true,
imports: [TranslateModule],
templateUrl: './privacy.component.html',
styleUrl: './privacy.component.scss'
styleUrl: './privacy.component.scss',
})
export class PrivacyComponent {}

View File

@@ -1,100 +1,101 @@
<section class="legal-page">
<div class="container narrow">
<h1>{{ 'LEGAL.TERMS_TITLE' | translate }}</h1>
<h1>{{ "LEGAL.TERMS_TITLE" | translate }}</h1>
<div class="content">
<p class="intro">
{{ 'LEGAL.LAST_UPDATE' | translate }}: {{ 'LEGAL.TERMS_UPDATE_DATE' | translate }}
{{ "LEGAL.LAST_UPDATE" | translate }}:
{{ "LEGAL.TERMS_UPDATE_DATE" | translate }}
</p>
<p>{{ 'LEGAL.TERMS.META.PROVIDER' | translate }}</p>
<p>{{ 'LEGAL.TERMS.META.VERSION' | translate }}</p>
<p>{{ 'LEGAL.TERMS.META.SCOPE' | translate }}</p>
<p>{{ "LEGAL.TERMS.META.PROVIDER" | translate }}</p>
<p>{{ "LEGAL.TERMS.META.VERSION" | translate }}</p>
<p>{{ "LEGAL.TERMS.META.SCOPE" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S1.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S1.P1' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S1.P2' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S1.P3' | translate }}</p>
<h2>{{ "LEGAL.TERMS.S1.TITLE" | translate }}</h2>
<p>{{ "LEGAL.TERMS.S1.P1" | translate }}</p>
<p>{{ "LEGAL.TERMS.S1.P2" | translate }}</p>
<p>{{ "LEGAL.TERMS.S1.P3" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S2.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S2.P1' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S2.P2' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S2.P3' | translate }}</p>
<h2>{{ "LEGAL.TERMS.S2.TITLE" | translate }}</h2>
<p>{{ "LEGAL.TERMS.S2.P1" | translate }}</p>
<p>{{ "LEGAL.TERMS.S2.P2" | translate }}</p>
<p>{{ "LEGAL.TERMS.S2.P3" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S3.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S3.P1' | translate }}</p>
<h2>{{ "LEGAL.TERMS.S3.TITLE" | translate }}</h2>
<p>{{ "LEGAL.TERMS.S3.P1" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S4.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S4.P1' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S4.P2' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S4.P3' | translate }}</p>
<h2>{{ "LEGAL.TERMS.S4.TITLE" | translate }}</h2>
<p>{{ "LEGAL.TERMS.S4.P1" | translate }}</p>
<p>{{ "LEGAL.TERMS.S4.P2" | translate }}</p>
<p>{{ "LEGAL.TERMS.S4.P3" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S5.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S5.P1' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S5.P2' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S5.P3' | translate }}</p>
<h2>{{ "LEGAL.TERMS.S5.TITLE" | translate }}</h2>
<p>{{ "LEGAL.TERMS.S5.P1" | translate }}</p>
<p>{{ "LEGAL.TERMS.S5.P2" | translate }}</p>
<p>{{ "LEGAL.TERMS.S5.P3" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S6.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S6.P1' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S6.P2' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S6.P3' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S6.P4' | translate }}</p>
<h2>{{ "LEGAL.TERMS.S6.TITLE" | translate }}</h2>
<p>{{ "LEGAL.TERMS.S6.P1" | translate }}</p>
<p>{{ "LEGAL.TERMS.S6.P2" | translate }}</p>
<p>{{ "LEGAL.TERMS.S6.P3" | translate }}</p>
<p>{{ "LEGAL.TERMS.S6.P4" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S7.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S7.P1' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S7.P2' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S7.P3' | translate }}</p>
<h2>{{ "LEGAL.TERMS.S7.TITLE" | translate }}</h2>
<p>{{ "LEGAL.TERMS.S7.P1" | translate }}</p>
<p>{{ "LEGAL.TERMS.S7.P2" | translate }}</p>
<p>{{ "LEGAL.TERMS.S7.P3" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S8.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S8.P1' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S8.P2' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S8.P3' | translate }}</p>
<h2>{{ "LEGAL.TERMS.S8.TITLE" | translate }}</h2>
<p>{{ "LEGAL.TERMS.S8.P1" | translate }}</p>
<p>{{ "LEGAL.TERMS.S8.P2" | translate }}</p>
<p>{{ "LEGAL.TERMS.S8.P3" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S9.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S9.P1' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S9.P2' | translate }}</p>
<h2>{{ "LEGAL.TERMS.S9.TITLE" | translate }}</h2>
<p>{{ "LEGAL.TERMS.S9.P1" | translate }}</p>
<p>{{ "LEGAL.TERMS.S9.P2" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S10.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S10.P1' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S10.P2' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S10.P3' | translate }}</p>
<h2>{{ "LEGAL.TERMS.S10.TITLE" | translate }}</h2>
<p>{{ "LEGAL.TERMS.S10.P1" | translate }}</p>
<p>{{ "LEGAL.TERMS.S10.P2" | translate }}</p>
<p>{{ "LEGAL.TERMS.S10.P3" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S11.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S11.P1' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S11.P2' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S11.P3' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S11.P4' | translate }}</p>
<h2>{{ "LEGAL.TERMS.S11.TITLE" | translate }}</h2>
<p>{{ "LEGAL.TERMS.S11.P1" | translate }}</p>
<p>{{ "LEGAL.TERMS.S11.P2" | translate }}</p>
<p>{{ "LEGAL.TERMS.S11.P3" | translate }}</p>
<p>{{ "LEGAL.TERMS.S11.P4" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S12.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S12.P1' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S12.P2' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S12.P3' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S12.P4' | translate }}</p>
<h2>{{ "LEGAL.TERMS.S12.TITLE" | translate }}</h2>
<p>{{ "LEGAL.TERMS.S12.P1" | translate }}</p>
<p>{{ "LEGAL.TERMS.S12.P2" | translate }}</p>
<p>{{ "LEGAL.TERMS.S12.P3" | translate }}</p>
<p>{{ "LEGAL.TERMS.S12.P4" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S13.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S13.P1' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S13.P2' | translate }}</p>
<h2>{{ "LEGAL.TERMS.S13.TITLE" | translate }}</h2>
<p>{{ "LEGAL.TERMS.S13.P1" | translate }}</p>
<p>{{ "LEGAL.TERMS.S13.P2" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S14.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S14.P1' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S14.P2' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S14.P3' | translate }}</p>
<h2>{{ "LEGAL.TERMS.S14.TITLE" | translate }}</h2>
<p>{{ "LEGAL.TERMS.S14.P1" | translate }}</p>
<p>{{ "LEGAL.TERMS.S14.P2" | translate }}</p>
<p>{{ "LEGAL.TERMS.S14.P3" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S15.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S15.P1' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S15.P2' | translate }}</p>
<h2>{{ "LEGAL.TERMS.S15.TITLE" | translate }}</h2>
<p>{{ "LEGAL.TERMS.S15.P1" | translate }}</p>
<p>{{ "LEGAL.TERMS.S15.P2" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S16.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S16.P1' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S16.P2' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S16.P3' | translate }}</p>
<h2>{{ "LEGAL.TERMS.S16.TITLE" | translate }}</h2>
<p>{{ "LEGAL.TERMS.S16.P1" | translate }}</p>
<p>{{ "LEGAL.TERMS.S16.P2" | translate }}</p>
<p>{{ "LEGAL.TERMS.S16.P3" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S17.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S17.P1' | translate }}</p>
<h2>{{ "LEGAL.TERMS.S17.TITLE" | translate }}</h2>
<p>{{ "LEGAL.TERMS.S17.P1" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S18.TITLE' | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S18.P1' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S18.P2' | translate }}</p>
<p>{{ 'LEGAL.TERMS.S18.P3' | translate }}</p>
<h2>{{ "LEGAL.TERMS.S18.TITLE" | translate }}</h2>
<p>{{ "LEGAL.TERMS.S18.P1" | translate }}</p>
<p>{{ "LEGAL.TERMS.S18.P2" | translate }}</p>
<p>{{ "LEGAL.TERMS.S18.P3" | translate }}</p>
</div>
</div>
</section>

View File

@@ -29,7 +29,7 @@
.content {
line-height: 1.8;
color: var(--color-text-main);
p {
margin-bottom: 1.5rem;
}

View File

@@ -6,6 +6,6 @@ import { TranslateModule } from '@ngx-translate/core';
standalone: true,
imports: [TranslateModule],
templateUrl: './terms.component.html',
styleUrl: './terms.component.scss'
styleUrl: './terms.component.scss',
})
export class TermsComponent {}

View File

@@ -1,71 +1,110 @@
<div class="container hero">
<h1>
{{ 'TRACKING.TITLE' | translate }}
{{ "TRACKING.TITLE" | translate }}
<ng-container *ngIf="order()">
<br/><span class="order-id-title">#{{ getDisplayOrderNumber(order()) }}</span>
<br /><span class="order-id-title"
>#{{ getDisplayOrderNumber(order()) }}</span
>
</ng-container>
</h1>
<p class="subtitle">{{ 'TRACKING.SUBTITLE' | translate }}</p>
<p class="subtitle">{{ "TRACKING.SUBTITLE" | translate }}</p>
</div>
<div class="container">
<ng-container *ngIf="order() as o">
<div class="status-timeline mb-6">
<div class="timeline-step"
[class.active]="o.status === 'PENDING_PAYMENT' && o.paymentStatus !== 'REPORTED'"
[class.completed]="o.paymentStatus === 'REPORTED' || o.status !== 'PENDING_PAYMENT'">
<div class="circle">1</div>
<div class="label">{{ 'TRACKING.STEP_PENDING' | translate }}</div>
</div>
<div class="timeline-step"
[class.active]="o.paymentStatus === 'REPORTED' && o.status === 'PENDING_PAYMENT'"
[class.completed]="o.status === 'PAID' || o.status === 'IN_PRODUCTION' || o.status === 'SHIPPED' || o.status === 'COMPLETED'">
<div class="circle">2</div>
<div class="label">{{ 'TRACKING.STEP_REPORTED' | translate }}</div>
</div>
<div class="timeline-step"
[class.active]="o.status === 'PAID' || o.status === 'IN_PRODUCTION'"
[class.completed]="o.status === 'SHIPPED' || o.status === 'COMPLETED'">
<div class="circle">3</div>
<div class="label">{{ 'TRACKING.STEP_PRODUCTION' | translate }}</div>
</div>
<div class="timeline-step"
[class.active]="o.status === 'SHIPPED'"
[class.completed]="o.status === 'COMPLETED'">
<div class="circle">4</div>
<div class="label">{{ 'TRACKING.STEP_SHIPPED' | translate }}</div>
</div>
<div
class="timeline-step"
[class.active]="
o.status === 'PENDING_PAYMENT' && o.paymentStatus !== 'REPORTED'
"
[class.completed]="
o.paymentStatus === 'REPORTED' || o.status !== 'PENDING_PAYMENT'
"
>
<div class="circle">1</div>
<div class="label">{{ "TRACKING.STEP_PENDING" | translate }}</div>
</div>
<div
class="timeline-step"
[class.active]="
o.paymentStatus === 'REPORTED' && o.status === 'PENDING_PAYMENT'
"
[class.completed]="
o.status === 'PAID' ||
o.status === 'IN_PRODUCTION' ||
o.status === 'SHIPPED' ||
o.status === 'COMPLETED'
"
>
<div class="circle">2</div>
<div class="label">{{ "TRACKING.STEP_REPORTED" | translate }}</div>
</div>
<div
class="timeline-step"
[class.active]="o.status === 'PAID' || o.status === 'IN_PRODUCTION'"
[class.completed]="o.status === 'SHIPPED' || o.status === 'COMPLETED'"
>
<div class="circle">3</div>
<div class="label">{{ "TRACKING.STEP_PRODUCTION" | translate }}</div>
</div>
<div
class="timeline-step"
[class.active]="o.status === 'SHIPPED'"
[class.completed]="o.status === 'COMPLETED'"
>
<div class="circle">4</div>
<div class="label">{{ "TRACKING.STEP_SHIPPED" | translate }}</div>
</div>
</div>
<ng-container *ngIf="o.status === 'PENDING_PAYMENT'">
<app-card class="mb-6 status-reported-card" *ngIf="o.paymentStatus === 'REPORTED'">
<div class="status-content text-center">
<h3>{{ 'PAYMENT.STATUS_REPORTED_TITLE' | translate }}</h3>
<p>{{ 'PAYMENT.STATUS_REPORTED_DESC' | translate }}</p>
</div>
<app-card
class="mb-6 status-reported-card"
*ngIf="o.paymentStatus === 'REPORTED'"
>
<div class="status-content text-center">
<h3>{{ "PAYMENT.STATUS_REPORTED_TITLE" | translate }}</h3>
<p>{{ "PAYMENT.STATUS_REPORTED_DESC" | translate }}</p>
</div>
</app-card>
<div class="payment-layout">
<div class="payment-main">
<app-card class="mb-6">
<div class="card-header-simple">
<h3>{{ 'PAYMENT.METHOD' | translate }}</h3>
<h3>{{ "PAYMENT.METHOD" | translate }}</h3>
</div>
<div class="payment-selection">
<div class="methods-grid">
<div class="type-option" [class.selected]="selectedPaymentMethod === 'twint'" (click)="selectPayment('twint')">
<span class="method-name">{{ 'PAYMENT.METHOD_TWINT' | translate }}</span>
<div
class="type-option"
[class.selected]="selectedPaymentMethod === 'twint'"
(click)="selectPayment('twint')"
>
<span class="method-name">{{
"PAYMENT.METHOD_TWINT" | translate
}}</span>
</div>
<div class="type-option" [class.selected]="selectedPaymentMethod === 'bill'" (click)="selectPayment('bill')">
<span class="method-name">{{ 'PAYMENT.METHOD_BANK' | translate }}</span>
<div
class="type-option"
[class.selected]="selectedPaymentMethod === 'bill'"
(click)="selectPayment('bill')"
>
<span class="method-name">{{
"PAYMENT.METHOD_BANK" | translate
}}</span>
</div>
</div>
</div>
<div class="payment-details fade-in text-center" *ngIf="selectedPaymentMethod === 'twint'">
<div
class="payment-details fade-in text-center"
*ngIf="selectedPaymentMethod === 'twint'"
>
<div class="details-header">
<h4>{{ 'PAYMENT.TWINT_TITLE' | translate }}</h4>
<h4>{{ "PAYMENT.TWINT_TITLE" | translate }}</h4>
</div>
<div class="qr-placeholder">
<img
@@ -73,46 +112,75 @@
class="twint-qr"
[src]="getTwintQrUrl()"
(error)="onTwintQrError()"
[attr.alt]="'PAYMENT.TWINT_QR_ALT' | translate" />
<p>{{ 'PAYMENT.TWINT_DESC' | translate }}</p>
<p class="billing-hint">{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}</p>
[attr.alt]="'PAYMENT.TWINT_QR_ALT' | translate"
/>
<p>{{ "PAYMENT.TWINT_DESC" | translate }}</p>
<p class="billing-hint">
{{ "PAYMENT.BILLING_INFO_HINT" | translate }}
</p>
<div class="twint-mobile-action twint-button-container">
<button style="width: auto; height: 58px;
border-radius: 6px;
display: flex;
justify-content: center;
cursor: pointer;
background-color: transparent;
border: none;
align-items: center;" (click)="openTwintPayment()">
<button
style="
width: auto;
height: 58px;
border-radius: 6px;
display: flex;
justify-content: center;
cursor: pointer;
background-color: transparent;
border: none;
align-items: center;
"
(click)="openTwintPayment()"
>
<img
style="width: auto; height: 58px"
[attr.alt]="'PAYMENT.TWINT_BUTTON_ALT' | translate"
[src]="getTwintButtonImageUrl()"/>
[src]="getTwintButtonImageUrl()"
/>
</button>
</div>
<p class="amount">{{ 'PAYMENT.TOTAL' | translate }}: {{ o.totalChf | currency:'CHF' }}</p>
<p class="amount">
{{ "PAYMENT.TOTAL" | translate }}:
{{ o.totalChf | currency: "CHF" }}
</p>
</div>
</div>
<div class="payment-details fade-in text-center" *ngIf="selectedPaymentMethod === 'bill'">
<div
class="payment-details fade-in text-center"
*ngIf="selectedPaymentMethod === 'bill'"
>
<div class="details-header">
<h4>{{ 'PAYMENT.BANK_TITLE' | translate }}</h4>
<h4>{{ "PAYMENT.BANK_TITLE" | translate }}</h4>
</div>
<div class="bank-details">
<p class="billing-hint">{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}</p>
<br>
<p class="billing-hint">
{{ "PAYMENT.BILLING_INFO_HINT" | translate }}
</p>
<br />
<div class="qr-bill-actions">
<app-button (click)="downloadQrInvoice()">
{{ 'PAYMENT.DOWNLOAD_QR' | translate }}
{{ "PAYMENT.DOWNLOAD_QR" | translate }}
</app-button>
</div>
</div>
</div>
<div class="actions">
<app-button variant="outline" (click)="completeOrder()" [disabled]="!selectedPaymentMethod || o.paymentStatus === 'REPORTED'" [fullWidth]="true">
{{ o.paymentStatus === 'REPORTED' ? ('PAYMENT.IN_VERIFICATION' | translate) : ('PAYMENT.CONFIRM' | translate) }}
<app-button
variant="outline"
(click)="completeOrder()"
[disabled]="
!selectedPaymentMethod || o.paymentStatus === 'REPORTED'
"
[fullWidth]="true"
>
{{
o.paymentStatus === "REPORTED"
? ("PAYMENT.IN_VERIFICATION" | translate)
: ("PAYMENT.CONFIRM" | translate)
}}
</app-button>
</div>
</app-card>
@@ -121,29 +189,28 @@ align-items: center;" (click)="openTwintPayment()">
<div class="payment-summary">
<app-card class="sticky-card">
<div class="card-header-simple">
<h3>{{ 'PAYMENT.SUMMARY_TITLE' | translate }}</h3>
<h3>{{ "PAYMENT.SUMMARY_TITLE" | translate }}</h3>
<p class="order-id">#{{ getDisplayOrderNumber(o) }}</p>
</div>
<div class="summary-totals">
<div class="total-row">
<span>{{ 'PAYMENT.SUBTOTAL' | translate }}</span>
<span>{{ o.subtotalChf | currency:'CHF' }}</span>
<span>{{ "PAYMENT.SUBTOTAL" | translate }}</span>
<span>{{ o.subtotalChf | currency: "CHF" }}</span>
</div>
<div class="total-row">
<span>{{ 'PAYMENT.SHIPPING' | translate }}</span>
<span>{{ o.shippingCostChf | currency:'CHF' }}</span>
<span>{{ "PAYMENT.SHIPPING" | translate }}</span>
<span>{{ o.shippingCostChf | currency: "CHF" }}</span>
</div>
<div class="total-row">
<span>{{ 'PAYMENT.SETUP_FEE' | translate }}</span>
<span>{{ o.setupCostChf | currency:'CHF' }}</span>
<span>{{ "PAYMENT.SETUP_FEE" | translate }}</span>
<span>{{ o.setupCostChf | currency: "CHF" }}</span>
</div>
<div class="grand-total-row">
<span>{{ 'PAYMENT.TOTAL' | translate }}</span>
<span>{{ o.totalChf | currency:'CHF' }}</span>
<span>{{ "PAYMENT.TOTAL" | translate }}</span>
<span>{{ o.totalChf | currency: "CHF" }}</span>
</div>
</div>
</app-card>
</div>
</div>
@@ -152,7 +219,7 @@ align-items: center;" (click)="openTwintPayment()">
<div *ngIf="loading()" class="loading-state">
<app-card>
<p>{{ 'PAYMENT.LOADING' | translate }}</p>
<p>{{ "PAYMENT.LOADING" | translate }}</p>
</app-card>
</div>

View File

@@ -107,7 +107,7 @@
width: 100%;
text-align: center;
}
.qr-placeholder {
width: 100%;
display: flex;
@@ -127,7 +127,6 @@
}
}
.qr-placeholder {
display: flex;
flex-direction: column;
@@ -229,7 +228,9 @@
}
}
.mb-6 { margin-bottom: var(--space-6); }
.mb-6 {
margin-bottom: var(--space-6);
}
.error-message,
.loading-state {
@@ -243,9 +244,9 @@
margin-bottom: var(--space-8);
position: relative;
/* padding: var(--space-6); */ /* Removed if it was here to match non-card layout */
&::before {
content: '';
content: "";
position: absolute;
top: 15px;
left: 12.5%;
@@ -315,7 +316,7 @@
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
&::before {
top: 10px;
bottom: 10px;
@@ -327,7 +328,7 @@
.timeline-step {
flex-direction: row;
gap: var(--space-3);
.circle {
margin-bottom: 0;
}

View File

@@ -10,9 +10,14 @@ import { environment } from '../../../environments/environment';
@Component({
selector: 'app-order',
standalone: true,
imports: [CommonModule, AppButtonComponent, AppCardComponent, TranslateModule],
imports: [
CommonModule,
AppButtonComponent,
AppCardComponent,
TranslateModule,
],
templateUrl: './order.component.html',
styleUrl: './order.component.scss'
styleUrl: './order.component.scss',
})
export class OrderComponent implements OnInit {
private route = inject(ActivatedRoute);
@@ -50,7 +55,7 @@ export class OrderComponent implements OnInit {
console.error('Failed to load order', err);
this.error.set('ORDER.ERR_LOAD_ORDER');
this.loading.set(false);
}
},
});
}
@@ -72,7 +77,7 @@ export class OrderComponent implements OnInit {
a.click();
window.URL.revokeObjectURL(url);
},
error: (err) => console.error('Failed to download QR invoice', err)
error: (err) => console.error('Failed to download QR invoice', err),
});
}
@@ -80,14 +85,18 @@ export class OrderComponent implements OnInit {
if (!this.orderId) return;
this.quoteService.getTwintPayment(this.orderId).subscribe({
next: (res) => {
const qrPath = typeof res.qrImageUrl === 'string' ? `${res.qrImageUrl}?size=360` : null;
const qrDataUri = typeof res.qrImageDataUri === 'string' ? res.qrImageDataUri : null;
const qrPath =
typeof res.qrImageUrl === 'string'
? `${res.qrImageUrl}?size=360`
: null;
const qrDataUri =
typeof res.qrImageDataUri === 'string' ? res.qrImageDataUri : null;
this.twintOpenUrl.set(this.resolveApiUrl(res.openUrl));
this.twintQrUrl.set(qrDataUri ?? this.resolveApiUrl(qrPath));
},
error: (err) => {
console.error('Failed to load TWINT payment details', err);
}
},
});
}
@@ -107,10 +116,10 @@ export class OrderComponent implements OnInit {
if (lang === 'de') {
return 'https://go.twint.ch/static/img/button_dark_de.svg';
}
if (lang === 'it'){
if (lang === 'it') {
return 'https://go.twint.ch/static/img/button_dark_it.svg';
}
if (lang === 'fr'){
if (lang === 'fr') {
return 'https://go.twint.ch/static/img/button_dark_fr.svg';
}
// Default to EN for everything else (it, fr, en) as instructed or if not DE
@@ -135,19 +144,21 @@ export class OrderComponent implements OnInit {
if (!this.orderId || !this.selectedPaymentMethod) {
return;
}
this.quoteService.reportPayment(this.orderId, this.selectedPaymentMethod).subscribe({
next: (order) => {
this.order.set(order);
// The UI will re-render and show the 'REPORTED' state.
// We stay on this page to let the user see the "In verifica"
// status along with payment instructions.
},
error: (err) => {
console.error('Failed to report payment', err);
this.error.set('ORDER.ERR_REPORT_PAYMENT');
}
});
this.quoteService
.reportPayment(this.orderId, this.selectedPaymentMethod)
.subscribe({
next: (order) => {
this.order.set(order);
// The UI will re-render and show the 'REPORTED' state.
// We stay on this page to let the user see the "In verifica"
// status along with payment instructions.
},
error: (err) => {
console.error('Failed to report payment', err);
this.error.set('ORDER.ERR_REPORT_PAYMENT');
},
});
}
getDisplayOrderNumber(order: any): string {

View File

@@ -3,11 +3,15 @@
<div class="content">
<span class="category">{{ product().category | translate }}</span>
<h3 class="name">
<a [routerLink]="['/shop', product().id]">{{ product().name | translate }}</a>
<a [routerLink]="['/shop', product().id]">{{
product().name | translate
}}</a>
</h3>
<div class="footer">
<span class="price">{{ product().price | currency:'EUR' }}</span>
<a [routerLink]="['/shop', product().id]" class="view-btn">{{ 'SHOP.DETAILS' | translate }}</a>
<span class="price">{{ product().price | currency: "EUR" }}</span>
<a [routerLink]="['/shop', product().id]" class="view-btn">{{
"SHOP.DETAILS" | translate
}}</a>
</div>
</div>
</div>

View File

@@ -4,15 +4,45 @@
border-radius: var(--radius-lg);
overflow: hidden;
transition: box-shadow 0.2s;
&:hover { box-shadow: var(--shadow-md); }
&: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; }
.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;
}

View File

@@ -9,7 +9,7 @@ import { Product } from '../../services/shop.service';
standalone: true,
imports: [CommonModule, RouterLink, TranslateModule],
templateUrl: './product-card.component.html',
styleUrl: './product-card.component.scss'
styleUrl: './product-card.component.scss',
})
export class ProductCardComponent {
product = input.required<Product>();

View File

@@ -1,25 +1,25 @@
<div class="container wrapper">
<a routerLink="/shop" class="back-link">← {{ 'SHOP.BACK' | translate }}</a>
<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 | translate }}</span>
<h1>{{ p.name | translate }}</h1>
<p class="price">{{ p.price | currency:'EUR' }}</p>
<p class="price">{{ p.price | currency: "EUR" }}</p>
<p class="desc">{{ p.description | translate }}</p>
<div class="actions">
<app-button variant="primary" (click)="addToCart()">
{{ 'SHOP.ADD_CART' | translate }}
{{ "SHOP.ADD_CART" | translate }}
</app-button>
</div>
</div>
</div>
} @else {
<p>{{ 'SHOP.NOT_FOUND' | translate }}</p>
<p>{{ "SHOP.NOT_FOUND" | translate }}</p>
}
</div>

View File

@@ -1,10 +1,16 @@
.wrapper { padding-top: var(--space-8); }
.back-link { display: inline-block; margin-bottom: var(--space-6); color: var(--color-text-muted); }
.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) {
@media (min-width: 768px) {
grid-template-columns: 1fr 1fr;
}
}
@@ -15,6 +21,20 @@
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); }
.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);
}

View File

@@ -10,23 +10,25 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto
standalone: true,
imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent],
templateUrl: './product-detail.component.html',
styleUrl: './product-detail.component.scss'
styleUrl: './product-detail.component.scss',
})
export class ProductDetailComponent {
// Input binding from router
id = input<string>();
product = signal<Product | undefined>(undefined);
constructor(
private shopService: ShopService,
private translate: TranslateService
private translate: TranslateService,
) {}
ngOnInit() {
const productId = this.id();
if (productId) {
this.shopService.getProductById(productId).subscribe(p => this.product.set(p));
this.shopService
.getProductById(productId)
.subscribe((p) => this.product.set(p));
}
}

View File

@@ -10,7 +10,7 @@ export interface Product {
}
@Injectable({
providedIn: 'root'
providedIn: 'root',
})
export class ShopService {
// Dati statici per ora
@@ -19,23 +19,23 @@ export class ShopService {
id: '1',
name: 'SHOP.PRODUCTS.P1.NAME',
description: 'SHOP.PRODUCTS.P1.DESC',
price: 24.90,
category: 'SHOP.CATEGORIES.FILAMENTS'
price: 24.9,
category: 'SHOP.CATEGORIES.FILAMENTS',
},
{
id: '2',
name: 'SHOP.PRODUCTS.P2.NAME',
description: 'SHOP.PRODUCTS.P2.DESC',
price: 29.90,
category: 'SHOP.CATEGORIES.FILAMENTS'
price: 29.9,
category: 'SHOP.CATEGORIES.FILAMENTS',
},
{
id: '3',
name: 'SHOP.PRODUCTS.P3.NAME',
description: 'SHOP.PRODUCTS.P3.DESC',
price: 15.00,
category: 'SHOP.CATEGORIES.ACCESSORIES'
}
price: 15.0,
category: 'SHOP.CATEGORIES.ACCESSORIES',
},
];
getProducts(): Observable<Product[]> {
@@ -43,6 +43,6 @@ export class ShopService {
}
getProductById(id: string): Observable<Product | undefined> {
return of(this.staticProducts.find(p => p.id === id));
return of(this.staticProducts.find((p) => p.id === id));
}
}

View File

@@ -1,18 +1,18 @@
<section class="wip-section">
<div class="container">
<div class="wip-card">
<p class="wip-eyebrow">{{ 'SHOP.WIP_EYEBROW' | translate }}</p>
<h1>{{ 'SHOP.WIP_TITLE' | translate }}</h1>
<p class="wip-subtitle">{{ 'SHOP.WIP_SUBTITLE' | translate }}</p>
<p class="wip-eyebrow">{{ "SHOP.WIP_EYEBROW" | translate }}</p>
<h1>{{ "SHOP.WIP_TITLE" | translate }}</h1>
<p class="wip-subtitle">{{ "SHOP.WIP_SUBTITLE" | translate }}</p>
<div class="wip-actions">
<app-button variant="primary" routerLink="/calculator/basic">
{{ 'SHOP.WIP_CTA_CALC' | translate }}
{{ "SHOP.WIP_CTA_CALC" | translate }}
</app-button>
</div>
<p class="wip-return-later">{{ 'SHOP.WIP_RETURN_LATER' | translate }}</p>
<p class="wip-note">{{ 'SHOP.WIP_NOTE' | translate }}</p>
<p class="wip-return-later">{{ "SHOP.WIP_RETURN_LATER" | translate }}</p>
<p class="wip-note">{{ "SHOP.WIP_NOTE" | translate }}</p>
</div>
</div>
</section>

View File

@@ -9,6 +9,6 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto
standalone: true,
imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent],
templateUrl: './shop-page.component.html',
styleUrl: './shop-page.component.scss'
styleUrl: './shop-page.component.scss',
})
export class ShopPageComponent {}

View File

@@ -4,5 +4,5 @@ import { ProductDetailComponent } from './product-detail.component';
export const SHOP_ROUTES: Routes = [
{ path: '', component: ShopPageComponent },
{ path: ':id', component: ProductDetailComponent }
{ path: ':id', component: ProductDetailComponent },
];

View File

@@ -1,9 +1,17 @@
<div class="alert" [ngClass]="type()">
<div class="icon">
@if(type() === 'info') { }
@if(type() === 'warning') { ⚠️ }
@if(type() === 'error') { ❌ }
@if(type() === 'success') { ✅ }
@if (type() === "info") {
}
@if (type() === "warning") {
⚠️
}
@if (type() === "error") {
}
@if (type() === "success") {
}
</div>
<div class="content"><ng-content></ng-content></div>
</div>

View File

@@ -6,7 +6,22 @@
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; }
.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;
}

View File

@@ -6,7 +6,7 @@ import { CommonModule } from '@angular/common';
standalone: true,
imports: [CommonModule],
templateUrl: './app-alert.component.html',
styleUrl: './app-alert.component.scss'
styleUrl: './app-alert.component.scss',
})
export class AppAlertComponent {
type = input<'info' | 'warning' | 'error' | 'success'>('info');

View File

@@ -1,7 +1,8 @@
<button
[type]="type()"
<button
[type]="type()"
[class]="'btn btn-' + variant() + ' ' + (fullWidth() ? 'w-full' : '')"
[disabled]="disabled()"
(click)="handleClick($event)">
(click)="handleClick($event)"
>
<ng-content></ng-content>
</button>

View File

@@ -6,28 +6,37 @@
border-radius: var(--radius-md);
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, color 0.2s, border-color 0.2s;
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%; }
.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); }
&: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); }
&:hover:not(:disabled) {
background-color: var(--color-neutral-300);
}
}
.btn-outline {
@@ -37,8 +46,8 @@
padding: calc(0.5rem - 1px) calc(1rem - 1px);
color: var(--color-neutral-900);
font-weight: 600;
&:hover:not(:disabled) {
background-color: var(--color-brand);
&:hover:not(:disabled) {
background-color: var(--color-brand);
color: var(--color-neutral-900);
}
}
@@ -47,5 +56,7 @@
background-color: transparent;
color: var(--color-text-muted);
padding: 0.5rem;
&:hover:not(:disabled) { color: var(--color-text); }
&:hover:not(:disabled) {
color: var(--color-text);
}
}

View File

@@ -6,7 +6,7 @@ import { CommonModule } from '@angular/common';
standalone: true,
imports: [CommonModule],
templateUrl: './app-button.component.html',
styleUrl: './app-button.component.scss'
styleUrl: './app-button.component.scss',
})
export class AppButtonComponent {
variant = input<'primary' | 'secondary' | 'outline' | 'text'>('primary');

View File

@@ -9,10 +9,13 @@
border: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
padding: var(--space-6);
transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
transition:
box-shadow 0.2s ease,
transform 0.2s ease,
border-color 0.2s ease;
height: 100%;
box-sizing: border-box;
&:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-md);

View File

@@ -4,6 +4,6 @@ import { Component } from '@angular/core';
selector: 'app-card',
standalone: true,
templateUrl: './app-card.component.html',
styleUrl: './app-card.component.scss'
styleUrl: './app-card.component.scss',
})
export class AppCardComponent {}

View File

@@ -1,4 +1,4 @@
<div
<div
class="dropzone"
[class.dragover]="isDragOver()"
(dragover)="onDragOver($event)"
@@ -6,21 +6,44 @@
(drop)="onDrop($event)"
(click)="fileInput.click()"
>
<input #fileInput type="file" (change)="onFileSelected($event)" hidden [accept]="accept()" [multiple]="multiple()">
<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>
<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() | translate }}</p>
<p class="subtext">{{ subtext() | translate }}</p>
@if (fileNames().length > 0) {
<div class="file-badges">
@for (name of fileNames(); track name) {
<div class="file-badge">{{ name }}</div>
}
</div>
<div class="file-badges">
@for (name of fileNames(); track name) {
<div class="file-badge">{{ name }}</div>
}
</div>
}
</div>
</div>

View File

@@ -6,27 +6,37 @@
cursor: pointer;
transition: all 0.2s;
background-color: var(--color-neutral-50);
&:hover, &.dragover {
&: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); }
.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;
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;
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;
}

View File

@@ -7,16 +7,16 @@ import { TranslateModule } from '@ngx-translate/core';
standalone: true,
imports: [CommonModule, TranslateModule],
templateUrl: './app-dropzone.component.html',
styleUrl: './app-dropzone.component.scss'
styleUrl: './app-dropzone.component.scss',
})
export class AppDropzoneComponent {
label = input<string>('DROPZONE.DEFAULT_LABEL');
subtext = input<string>('DROPZONE.DEFAULT_SUBTEXT');
accept = input<string>('.stl,.3mf,.step,.stp');
multiple = input<boolean>(true);
filesDropped = output<File[]>();
isDragOver = signal(false);
fileNames = signal<string[]>([]);
@@ -51,8 +51,8 @@ export class AppDropzoneComponent {
}
handleFiles(files: File[]) {
const newNames = files.map(f => f.name);
this.fileNames.update(current => [...current, ...newNames]);
const newNames = files.map((f) => f.name);
this.fileNames.update((current) => [...current, ...newNames]);
this.filesDropped.emit(files);
}
}

View File

@@ -1,9 +1,11 @@
<div class="form-group">
@if (label()) {
@if (label()) {
<label [for]="id()">
{{ label() }}
@if (required()) { <span class="required-mark">*</span> }
</label>
@if (required()) {
<span class="required-mark">*</span>
}
</label>
}
<input
[id]="id()"
@@ -15,5 +17,7 @@
[disabled]="disabled"
class="form-control"
/>
@if (error()) { <span class="error-text">{{ error() }}</span> }
@if (error()) {
<span class="error-text">{{ error() }}</span>
}
</div>

View File

@@ -1,6 +1,18 @@
.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); }
.required-mark { color: var(--color-text); margin-left: 2px; }
.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);
}
.required-mark {
color: var(--color-text);
margin-left: 2px;
}
.form-control {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
@@ -9,7 +21,18 @@ label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); co
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; }
&: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);
}
.error-text { color: var(--color-danger-500); font-size: 0.75rem; margin-top: var(--space-1); }

Some files were not shown because too many files have changed in this diff Show More