dev #13
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
@@ -99,8 +99,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: |
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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: '',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -17,25 +17,30 @@ 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);
|
||||
const found = cat.colors.find((c) => c.value === value);
|
||||
if (found) return found.hex;
|
||||
}
|
||||
return '#facf0a'; // Default Brand Color if not found
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
</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>
|
||||
<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">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/patterns';
|
||||
@use "../../../styles/patterns";
|
||||
|
||||
.footer {
|
||||
background: var(--color-neutral-900);
|
||||
@@ -9,7 +9,7 @@
|
||||
margin-top: auto; /* Push to bottom if content is short */
|
||||
// Cross Hatch Pattern
|
||||
&::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@include patterns.pattern-cross-hatch(var(--color-neutral-50), 20px, 1px);
|
||||
@@ -37,8 +37,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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; }
|
||||
.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;
|
||||
@@ -47,13 +56,20 @@
|
||||
color: var(--color-neutral-300);
|
||||
font-size: 0.875rem;
|
||||
transition: color 0.2s;
|
||||
&:hover { color: white; text-decoration: underline; }
|
||||
&:hover {
|
||||
color: white;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.social { display: flex; gap: var(--space-3); }
|
||||
.social {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.social-icon {
|
||||
width: 24px; height: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: var(--color-neutral-800);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -2,18 +2,43 @@
|
||||
<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">
|
||||
<div
|
||||
class="mobile-toggle"
|
||||
(click)="toggleMenu()"
|
||||
[class.active]="isMenuOpen"
|
||||
>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</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>
|
||||
<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">
|
||||
@@ -21,14 +46,28 @@
|
||||
class="lang-switch"
|
||||
[value]="langService.selectedLang()"
|
||||
(change)="onLanguageChange($event)"
|
||||
[attr.aria-label]="'NAV.LANGUAGE_SELECTOR' | translate">
|
||||
[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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.highlight { color: var(--color-brand); }
|
||||
.highlight {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
@@ -31,7 +33,8 @@
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover, &.active {
|
||||
&:hover,
|
||||
&.active {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
@@ -59,10 +62,15 @@
|
||||
background-position:
|
||||
calc(100% - 10px) calc(50% - 2px),
|
||||
calc(100% - 5px) calc(50% - 2px);
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-size:
|
||||
5px 5px,
|
||||
5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
&:hover { color: var(--color-text); border-color: var(--color-text); }
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-text);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 1px;
|
||||
@@ -100,9 +108,15 @@
|
||||
}
|
||||
|
||||
&.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); }
|
||||
span:nth-child(1) {
|
||||
transform: translateY(8px) rotate(45deg);
|
||||
}
|
||||
span:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
span:nth-child(3) {
|
||||
transform: translateY(-8px) rotate(-45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +164,12 @@
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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,7 +78,8 @@ export class LanguageService {
|
||||
}
|
||||
|
||||
selectedLang(): 'it' | 'en' | 'de' | 'fr' {
|
||||
const activeLang = typeof this.translate.currentLang === 'string'
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface QuoteRequestDto {
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class QuoteRequestService {
|
||||
private http = inject(HttpClient);
|
||||
@@ -28,12 +28,12 @@ export class QuoteRequestService {
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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(
|
||||
catchError(() =>
|
||||
of(
|
||||
router.createUrlTree(['/', lang, 'admin', 'login'], {
|
||||
queryParams: { redirect: state.url }
|
||||
})
|
||||
))
|
||||
queryParams: { redirect: state.url },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,11 +86,17 @@ 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}`),
|
||||
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.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,25 +142,30 @@ export class AdminContactRequestsComponent implements OnInit {
|
||||
this.successMessage = null;
|
||||
this.updatingStatus = true;
|
||||
|
||||
this.adminOperationsService.updateContactRequestStatus(this.selectedRequestId, { status: this.selectedStatus }).subscribe({
|
||||
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 =>
|
||||
this.requests = this.requests.map((request) =>
|
||||
request.id === updated.id
|
||||
? {
|
||||
...request,
|
||||
status: updated.status
|
||||
status: updated.status,
|
||||
}
|
||||
: request
|
||||
: request,
|
||||
);
|
||||
this.updatingStatus = false;
|
||||
this.successMessage = 'Stato richiesta aggiornato.';
|
||||
},
|
||||
error: () => {
|
||||
this.updatingStatus = false;
|
||||
this.errorMessage = 'Impossibile aggiornare lo stato della richiesta.';
|
||||
}
|
||||
this.errorMessage =
|
||||
'Impossibile aggiornare lo stato della richiesta.';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,7 +137,9 @@ export class AdminDashboardComponent implements OnInit {
|
||||
}
|
||||
|
||||
this.confirmingPayment = true;
|
||||
this.adminOrdersService.confirmPayment(this.selectedOrder.id, this.selectedPaymentMethod).subscribe({
|
||||
this.adminOrdersService
|
||||
.confirmPayment(this.selectedOrder.id, this.selectedPaymentMethod)
|
||||
.subscribe({
|
||||
next: (updatedOrder) => {
|
||||
this.confirmingPayment = false;
|
||||
this.applyOrderUpdate(updatedOrder);
|
||||
@@ -127,19 +147,25 @@ export class AdminDashboardComponent implements OnInit {
|
||||
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({
|
||||
this.adminOrdersService
|
||||
.updateOrderStatus(this.selectedOrder.id, {
|
||||
status: this.selectedStatus.trim(),
|
||||
})
|
||||
.subscribe({
|
||||
next: (updatedOrder) => {
|
||||
this.updatingStatus = false;
|
||||
this.applyOrderUpdate(updatedOrder);
|
||||
@@ -147,7 +173,7 @@ export class AdminDashboardComponent implements OnInit {
|
||||
error: () => {
|
||||
this.updatingStatus = false;
|
||||
this.errorMessage = 'Aggiornamento stato ordine non riuscito.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,13 +182,15 @@ export class AdminDashboardComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.adminOrdersService.downloadOrderItemFile(this.selectedOrder.id, itemId).subscribe({
|
||||
this.adminOrdersService
|
||||
.downloadOrderItemFile(this.selectedOrder.id, itemId)
|
||||
.subscribe({
|
||||
next: (blob) => {
|
||||
this.downloadBlob(blob, filename || `order-item-${itemId}`);
|
||||
},
|
||||
error: () => {
|
||||
this.errorMessage = 'Download file non riuscito.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,13 +199,18 @@ export class AdminDashboardComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.adminOrdersService.downloadOrderConfirmation(this.selectedOrder.id).subscribe({
|
||||
this.adminOrdersService
|
||||
.downloadOrderConfirmation(this.selectedOrder.id)
|
||||
.subscribe({
|
||||
next: (blob) => {
|
||||
this.downloadBlob(blob, `conferma-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`);
|
||||
this.downloadBlob(
|
||||
blob,
|
||||
`conferma-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`,
|
||||
);
|
||||
},
|
||||
error: () => {
|
||||
this.errorMessage = 'Download conferma ordine non riuscito.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -186,13 +219,18 @@ export class AdminDashboardComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.adminOrdersService.downloadOrderInvoice(this.selectedOrder.id).subscribe({
|
||||
this.adminOrdersService
|
||||
.downloadOrderInvoice(this.selectedOrder.id)
|
||||
.subscribe({
|
||||
next: (blob) => {
|
||||
this.downloadBlob(blob, `fattura-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`);
|
||||
this.downloadBlob(
|
||||
blob,
|
||||
`fattura-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`,
|
||||
);
|
||||
},
|
||||
error: () => {
|
||||
this.errorMessage = 'Download fattura non riuscito.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,13 +151,17 @@ 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({
|
||||
this.adminOperationsService
|
||||
.updateFilamentMaterial(material.id, payload)
|
||||
.subscribe({
|
||||
next: (updated) => {
|
||||
this.materials = this.sortMaterials(
|
||||
this.materials.map((m) => (m.id === updated.id ? updated : m))
|
||||
this.materials.map((m) => (m.id === updated.id ? updated : m)),
|
||||
);
|
||||
this.variants = this.variants.map((variant) => {
|
||||
if (variant.materialTypeId !== updated.id) {
|
||||
@@ -162,7 +172,7 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
materialCode: updated.materialCode,
|
||||
materialIsFlexible: updated.isFlexible,
|
||||
materialIsTechnical: updated.isTechnical,
|
||||
materialTechnicalTypeLabel: updated.technicalTypeLabel
|
||||
materialTechnicalTypeLabel: updated.technicalTypeLabel,
|
||||
};
|
||||
});
|
||||
this.savingMaterialIds.delete(material.id);
|
||||
@@ -170,8 +180,11 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
},
|
||||
error: (err) => {
|
||||
this.savingMaterialIds.delete(material.id);
|
||||
this.errorMessage = this.extractErrorMessage(err, 'Aggiornamento materiale non riuscito.');
|
||||
}
|
||||
this.errorMessage = this.extractErrorMessage(
|
||||
err,
|
||||
'Aggiornamento materiale non riuscito.',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
this.adminOperationsService
|
||||
.updateFilamentVariant(variant.id, payload)
|
||||
.subscribe({
|
||||
next: (updated) => {
|
||||
this.variants = this.sortVariants(
|
||||
this.variants.map((v) => (v.id === updated.id ? updated : v))
|
||||
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.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 || '',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -27,40 +34,72 @@
|
||||
<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>{{ 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>
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`, {
|
||||
downloadContactRequestAttachment(
|
||||
requestId: string,
|
||||
attachmentId: string,
|
||||
): Observable<Blob> {
|
||||
return this.http.get(
|
||||
`${this.baseUrl}/contact-requests/${requestId}/attachments/${attachmentId}/file`,
|
||||
{
|
||||
withCredentials: true,
|
||||
responseType: 'blob'
|
||||
});
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`, {
|
||||
downloadOrderItemFile(
|
||||
orderId: string,
|
||||
orderItemId: string,
|
||||
): Observable<Blob> {
|
||||
return this.http.get(
|
||||
`${this.baseUrl}/${orderId}/items/${orderItemId}/file`,
|
||||
{
|
||||
withCredentials: true,
|
||||
responseType: 'blob'
|
||||
});
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<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') {
|
||||
@if (step() === "success") {
|
||||
<div class="container hero">
|
||||
<app-success-state context="calc" (action)="onNewQuote()"></app-success-state>
|
||||
<app-success-state
|
||||
context="calc"
|
||||
(action)="onNewQuote()"
|
||||
></app-success-state>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="container content-grid">
|
||||
@@ -17,15 +20,19 @@
|
||||
<div class="col-input">
|
||||
<app-card>
|
||||
<div class="mode-selector">
|
||||
<div class="mode-option"
|
||||
<div
|
||||
class="mode-option"
|
||||
[class.active]="mode() === 'easy'"
|
||||
(click)="mode.set('easy')">
|
||||
{{ 'CALC.MODE_EASY' | translate }}
|
||||
(click)="mode.set('easy')"
|
||||
>
|
||||
{{ "CALC.MODE_EASY" | translate }}
|
||||
</div>
|
||||
<div class="mode-option"
|
||||
<div
|
||||
class="mode-option"
|
||||
[class.active]="mode() === 'advanced'"
|
||||
(click)="mode.set('advanced')">
|
||||
{{ 'CALC.MODE_ADVANCED' | translate }}
|
||||
(click)="mode.set('advanced')"
|
||||
>
|
||||
{{ "CALC.MODE_ADVANCED" | translate }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,13 +48,14 @@
|
||||
|
||||
<!-- 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>
|
||||
<h3 class="loading-title">
|
||||
{{ "CALC.ANALYZING_TITLE" | translate }}
|
||||
</h3>
|
||||
<p class="loading-text">{{ "CALC.ANALYZING_TEXT" | translate }}</p>
|
||||
</div>
|
||||
</app-card>
|
||||
} @else if (result()) {
|
||||
@@ -59,11 +67,11 @@
|
||||
></app-quote-result>
|
||||
} @else {
|
||||
<app-card>
|
||||
<h3>{{ 'CALC.BENEFITS_TITLE' | translate }}</h3>
|
||||
<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>
|
||||
<li>{{ "CALC.BENEFITS_1" | translate }}</li>
|
||||
<li>{{ "CALC.BENEFITS_2" | translate }}</li>
|
||||
<li>{{ "CALC.BENEFITS_3" | translate }}</li>
|
||||
</ul>
|
||||
</app-card>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
.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;
|
||||
@@ -56,7 +64,9 @@
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
|
||||
&:hover { color: var(--color-text); }
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--color-brand);
|
||||
@@ -66,8 +76,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -105,6 +118,10 @@
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +26,17 @@ 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');
|
||||
@@ -39,17 +57,17 @@ export class CalculatorPageComponent implements OnInit {
|
||||
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 => {
|
||||
this.route.queryParams.subscribe((params) => {
|
||||
const sessionId = params['session'];
|
||||
if (sessionId) {
|
||||
// Avoid reloading if we just calculated this session
|
||||
@@ -92,7 +110,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
console.error('Failed to load session', err);
|
||||
this.setQuoteError('CALC.ERROR_GENERIC');
|
||||
this.loading.set(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,7 +121,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
}
|
||||
|
||||
// Download all files
|
||||
const downloads = items.map(item =>
|
||||
const downloads = items.map((item) =>
|
||||
this.estimator.getLineItemContent(session.id, item.id).pipe(
|
||||
map((blob: Blob) => {
|
||||
return {
|
||||
@@ -114,13 +132,18 @@ export class CalculatorPageComponent implements OnInit {
|
||||
// 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' }));
|
||||
const files = results.map(
|
||||
(res) =>
|
||||
new File([res.blob], res.fileName, {
|
||||
type: 'application/octet-stream',
|
||||
}),
|
||||
);
|
||||
|
||||
if (this.uploadForm) {
|
||||
this.uploadForm.setFiles(files);
|
||||
@@ -137,7 +160,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
if (item.colorCode) {
|
||||
this.uploadForm.updateItemColor(index, {
|
||||
colorName: item.colorCode,
|
||||
filamentVariantId: item.filamentVariantId
|
||||
filamentVariantId: item.filamentVariantId,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -150,7 +173,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
console.error('Failed to download files', err);
|
||||
this.loading.set(false);
|
||||
// Still show result? Yes.
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -167,7 +190,10 @@ 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' });
|
||||
this.resultCol.nativeElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
|
||||
@@ -197,7 +223,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
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"
|
||||
replaceUrl: true, // prevent cluttering history, or false if we want back button to work. replaceUrl seems safer for "state update"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -205,7 +231,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
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,7 +252,12 @@ export class CalculatorPageComponent implements OnInit {
|
||||
this.step.set('quote');
|
||||
}
|
||||
|
||||
onItemChange(event: {id?: string, index: number, fileName: string, quantity: number}) {
|
||||
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);
|
||||
@@ -238,12 +269,15 @@ export class CalculatorPageComponent implements OnInit {
|
||||
const currentSessionId = this.result()?.sessionId;
|
||||
if (!currentSessionId) return;
|
||||
|
||||
this.estimator.updateLineItem(event.id, { quantity: event.quantity }).subscribe({
|
||||
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);
|
||||
const newResult =
|
||||
this.estimator.mapSessionToQuoteResult(sessionData);
|
||||
// Preserve notes
|
||||
newResult.notes = this.result()?.notes;
|
||||
|
||||
@@ -258,12 +292,12 @@ export class CalculatorPageComponent implements OnInit {
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to refresh session totals', err);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to update line item', err);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -292,7 +326,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
details += `- Qualità: ${req.quality}\n`;
|
||||
|
||||
details += `- File:\n`;
|
||||
req.items.forEach(item => {
|
||||
req.items.forEach((item) => {
|
||||
details += ` * ${item.file.name} (Qtà: ${item.quantity}`;
|
||||
if (item.color) {
|
||||
details += `, Colore: ${item.color}`;
|
||||
@@ -307,8 +341,8 @@ export class CalculatorPageComponent implements OnInit {
|
||||
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']);
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
@@ -7,7 +7,8 @@
|
||||
class="item full-width"
|
||||
[label]="'CALC.COST' | translate"
|
||||
[large]="true"
|
||||
[highlight]="true">
|
||||
[highlight]="true"
|
||||
>
|
||||
{{ totals().price | currency: result().currency }}
|
||||
</app-summary-card>
|
||||
|
||||
@@ -21,13 +22,20 @@
|
||||
</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>
|
||||
<label>{{ "CALC.NOTES" | translate }}:</label>
|
||||
<p>{{ result().notes }}</p>
|
||||
</div>
|
||||
}
|
||||
@@ -41,13 +49,14 @@
|
||||
<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>
|
||||
<label>{{ "CHECKOUT.QTY" | translate }}:</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -55,17 +64,24 @@
|
||||
[ngModel]="item.quantity"
|
||||
(ngModelChange)="updateQuantity(i, $event)"
|
||||
(blur)="flushQuantityUpdate(i)"
|
||||
class="qty-input">
|
||||
class="qty-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="item-price">
|
||||
<span class="item-total-price">
|
||||
{{ (item.unitPrice * item.quantity) | currency:result().currency }}
|
||||
{{ 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
|
||||
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"> </small>
|
||||
<small class="item-unit-price item-unit-price--placeholder"
|
||||
> </small
|
||||
>
|
||||
</ng-template>
|
||||
</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>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.title { margin-bottom: var(--space-6); text-align: center; }
|
||||
.title {
|
||||
margin-bottom: var(--space-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
@@ -30,8 +33,18 @@
|
||||
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;
|
||||
@@ -44,7 +57,10 @@
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
|
||||
label { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||
label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.qty-input {
|
||||
@@ -53,7 +69,10 @@
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: center;
|
||||
&:focus { outline: none; border-color: var(--color-brand); }
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.item-price {
|
||||
@@ -94,7 +113,9 @@
|
||||
gap: var(--space-4);
|
||||
}
|
||||
}
|
||||
.full-width { grid-column: span 2; }
|
||||
.full-width {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.setup-note {
|
||||
text-align: center;
|
||||
@@ -103,7 +124,11 @@
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.actions { display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.limit-note {
|
||||
font-size: 0.8rem;
|
||||
|
||||
@@ -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,20 +50,23 @@ export class QuoteResultComponent implements OnDestroy {
|
||||
private quantityTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
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}));
|
||||
const nextItems = this.result().items.map((i) => ({ ...i }));
|
||||
this.items.set(nextItems);
|
||||
|
||||
this.lastSentQuantities.clear();
|
||||
nextItems.forEach(item => {
|
||||
nextItems.forEach((item) => {
|
||||
const key = item.id ?? item.fileName;
|
||||
this.lastSentQuantities.set(key, item.quantity);
|
||||
});
|
||||
}, { allowSignalWrites: true });
|
||||
},
|
||||
{ allowSignalWrites: true },
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -58,7 +81,7 @@ export class QuoteResultComponent implements OnDestroy {
|
||||
if (!item) return;
|
||||
const key = item.id ?? item.fileName;
|
||||
|
||||
this.items.update(current => {
|
||||
this.items.update((current) => {
|
||||
const updated = [...current];
|
||||
updated[index] = { ...updated[index], quantity: normalizedQty };
|
||||
return updated;
|
||||
@@ -85,12 +108,14 @@ export class QuoteResultComponent implements OnDestroy {
|
||||
id: item.id,
|
||||
index,
|
||||
fileName: item.fileName,
|
||||
quantity: normalizedQty
|
||||
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();
|
||||
@@ -100,7 +125,7 @@ export class QuoteResultComponent implements OnDestroy {
|
||||
let time = 0;
|
||||
let weight = 0;
|
||||
|
||||
currentItems.forEach(i => {
|
||||
currentItems.forEach((i) => {
|
||||
price += i.unitPrice * i.quantity;
|
||||
time += i.unitTime * i.quantity;
|
||||
weight += i.unitWeight * i.quantity;
|
||||
@@ -113,7 +138,7 @@ export class QuoteResultComponent implements OnDestroy {
|
||||
price: Math.round(price * 100) / 100,
|
||||
hours,
|
||||
minutes,
|
||||
weight: Math.ceil(weight)
|
||||
weight: Math.ceil(weight),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -142,8 +167,7 @@ export class QuoteResultComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
private clearAllQuantityTimers(): void {
|
||||
this.quantityTimers.forEach(timer => clearTimeout(timer));
|
||||
this.quantityTimers.forEach((timer) => clearTimeout(timer));
|
||||
this.quantityTimers.clear();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<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>
|
||||
<p>{{ "CALC.STEP_WARNING" | translate }}</p>
|
||||
</div>
|
||||
} @else {
|
||||
<app-stl-viewer
|
||||
[file]="selectedFile()"
|
||||
[color]="getSelectedFileColor()">
|
||||
[color]="getSelectedFileColor()"
|
||||
>
|
||||
</app-stl-viewer>
|
||||
}
|
||||
<!-- Close button removed as requested -->
|
||||
@@ -24,7 +24,8 @@
|
||||
[subtext]="'CALC.UPLOAD_SUB'"
|
||||
[accept]="acceptedFormats"
|
||||
[multiple]="true"
|
||||
(filesDropped)="onFilesDropped($event)">
|
||||
(filesDropped)="onFilesDropped($event)"
|
||||
>
|
||||
</app-dropzone>
|
||||
}
|
||||
|
||||
@@ -32,31 +33,39 @@
|
||||
@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="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>
|
||||
<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>
|
||||
<label>{{ "CALC.QTY_SHORT" | translate }}</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
[value]="item.quantity"
|
||||
(change)="updateItemQuantity(i, $event)"
|
||||
class="qty-input"
|
||||
(click)="$event.stopPropagation()">
|
||||
(click)="$event.stopPropagation()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="color-group">
|
||||
<label>{{ 'CALC.COLOR_LABEL' | translate }}</label>
|
||||
<label>{{ "CALC.COLOR_LABEL" | translate }}</label>
|
||||
<app-color-selector
|
||||
[selectedColor]="item.color"
|
||||
[selectedVariantId]="item.filamentVariantId ?? null"
|
||||
[variants]="currentMaterialVariants()"
|
||||
(colorSelected)="updateItemColor(i, $event)">
|
||||
(colorSelected)="updateItemColor(i, $event)"
|
||||
>
|
||||
</app-color-selector>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,7 +74,8 @@
|
||||
type="button"
|
||||
class="btn-remove"
|
||||
(click)="removeItem(i); $event.stopPropagation()"
|
||||
[attr.title]="'CALC.REMOVE_FILE' | translate">
|
||||
[attr.title]="'CALC.REMOVE_FILE' | translate"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
@@ -75,21 +85,35 @@
|
||||
|
||||
<!-- "Add Files" Button (Visible only when files exist) -->
|
||||
<div class="add-more-container">
|
||||
<input #additionalInput type="file" [accept]="acceptedFormats" multiple hidden (change)="onAdditionalFilesSelected($event)">
|
||||
<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
|
||||
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,7 +124,7 @@
|
||||
[options]="materials()"
|
||||
></app-select>
|
||||
|
||||
@if (mode() === 'easy') {
|
||||
@if (mode() === "easy") {
|
||||
<app-select
|
||||
formControlName="quality"
|
||||
[label]="'CALC.QUALITY' | translate"
|
||||
@@ -117,7 +141,7 @@
|
||||
|
||||
<!-- Global quantity removed, now per item -->
|
||||
|
||||
@if (mode() === 'advanced') {
|
||||
@if (mode() === "advanced") {
|
||||
<div class="grid">
|
||||
<app-select
|
||||
formControlName="infillPattern"
|
||||
@@ -140,11 +164,10 @@
|
||||
></app-input>
|
||||
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" formControlName="supportEnabled" id="support">
|
||||
<label for="support">{{ 'CALC.SUPPORT' | translate }}</label>
|
||||
<input type="checkbox" formControlName="supportEnabled" id="support" />
|
||||
<label for="support">{{ "CALC.SUPPORT" | translate }}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
<app-input
|
||||
@@ -166,8 +189,15 @@
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
.section { margin-bottom: var(--space-6); }
|
||||
.section {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
.upload-privacy-note {
|
||||
margin-top: var(--space-3);
|
||||
margin-bottom: 0;
|
||||
@@ -15,10 +17,20 @@
|
||||
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; }
|
||||
.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); }
|
||||
.viewer-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* Grid Layout for Files */
|
||||
.items-grid {
|
||||
@@ -46,7 +58,9 @@
|
||||
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); }
|
||||
&:hover {
|
||||
border-color: var(--color-neutral-300);
|
||||
}
|
||||
&.active {
|
||||
border-color: var(--color-brand);
|
||||
background: rgba(250, 207, 10, 0.05);
|
||||
@@ -83,7 +97,8 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qty-group, .color-group {
|
||||
.qty-group,
|
||||
.color-group {
|
||||
display: flex;
|
||||
flex-direction: column; /* Stack label and input */
|
||||
align-items: flex-start;
|
||||
@@ -118,7 +133,10 @@
|
||||
font-size: 0.85rem;
|
||||
background: white;
|
||||
height: 24px; /* Explicit height to match color circle somewhat */
|
||||
&:focus { outline: none; border-color: var(--color-brand); }
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
@@ -170,7 +188,9 @@
|
||||
background: var(--color-neutral-900);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
&:active { transform: translateY(0); }
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
|
||||
@@ -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,7 +20,14 @@ 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 {
|
||||
@@ -21,9 +40,19 @@ interface FormItem {
|
||||
@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');
|
||||
@@ -57,7 +86,7 @@ export class UploadFormComponent implements OnInit {
|
||||
private updateVariants() {
|
||||
const matCode = this.form.get('material')?.value;
|
||||
if (matCode && this.fullMaterialOptions.length > 0) {
|
||||
const found = this.fullMaterialOptions.find(m => m.code === matCode);
|
||||
const found = this.fullMaterialOptions.find((m) => m.code === matCode);
|
||||
this.currentMaterialVariants.set(found ? found.variants : []);
|
||||
this.syncItemVariantSelections();
|
||||
} else {
|
||||
@@ -85,7 +114,7 @@ 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
|
||||
@@ -102,11 +131,39 @@ export class UploadFormComponent implements OnInit {
|
||||
private applyAdvancedPresetFromQuality(quality: string | null | undefined) {
|
||||
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'];
|
||||
@@ -119,22 +176,45 @@ export class UploadFormComponent implements OnInit {
|
||||
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();
|
||||
},
|
||||
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.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();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,16 +225,27 @@ export class UploadFormComponent implements OnInit {
|
||||
}
|
||||
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);
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
if (
|
||||
this.infillPatterns().length > 0 &&
|
||||
!this.form.get('infillPattern')?.value
|
||||
) {
|
||||
this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value);
|
||||
}
|
||||
}
|
||||
@@ -173,7 +264,7 @@ export class UploadFormComponent implements OnInit {
|
||||
file,
|
||||
quantity: 1,
|
||||
color: defaultSelection.colorName,
|
||||
filamentVariantId: defaultSelection.filamentVariantId
|
||||
filamentVariantId: defaultSelection.filamentVariantId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -183,7 +274,7 @@ export class UploadFormComponent implements OnInit {
|
||||
}
|
||||
|
||||
if (validItems.length > 0) {
|
||||
this.items.update(current => [...current, ...validItems]);
|
||||
this.items.update((current) => [...current, ...validItems]);
|
||||
this.form.get('itemsTouched')?.setValue(true);
|
||||
// Auto select last added
|
||||
this.selectedFile.set(validItems[validItems.length - 1].file);
|
||||
@@ -203,7 +294,7 @@ export class UploadFormComponent implements OnInit {
|
||||
if (!Number.isInteger(index) || index < 0) return;
|
||||
const normalizedQty = this.normalizeQuantity(quantity);
|
||||
|
||||
this.items.update(current => {
|
||||
this.items.update((current) => {
|
||||
if (index >= current.length) return current;
|
||||
const updated = [...current];
|
||||
updated[index] = { ...updated[index], quantity: normalizedQty };
|
||||
@@ -215,9 +306,9 @@ export class UploadFormComponent implements OnInit {
|
||||
const targetName = this.normalizeFileName(fileName);
|
||||
const normalizedQty = this.normalizeQuantity(quantity);
|
||||
|
||||
this.items.update(current => {
|
||||
this.items.update((current) => {
|
||||
let matched = false;
|
||||
return current.map(item => {
|
||||
return current.map((item) => {
|
||||
if (!matched && this.normalizeFileName(item.file.name) === targetName) {
|
||||
matched = true;
|
||||
return { ...item, quantity: normalizedQty };
|
||||
@@ -240,13 +331,13 @@ export class UploadFormComponent implements OnInit {
|
||||
const file = this.selectedFile();
|
||||
if (!file) return '#facf0a'; // Default
|
||||
|
||||
const item = this.items().find(i => i.file === file);
|
||||
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);
|
||||
? vars.find((v) => v.id === item.filamentVariantId)
|
||||
: vars.find((v) => v.colorName === item.color);
|
||||
if (found) return found.hexColor;
|
||||
}
|
||||
return getColorHex(item.color);
|
||||
@@ -261,18 +352,29 @@ export class UploadFormComponent implements OnInit {
|
||||
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 => {
|
||||
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 };
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
color: colorName,
|
||||
filamentVariantId,
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
removeItem(index: number) {
|
||||
this.items.update(current => {
|
||||
this.items.update((current) => {
|
||||
const updated = [...current];
|
||||
const removed = updated.splice(index, 1)[0];
|
||||
if (this.selectedFile() === removed.file) {
|
||||
@@ -290,7 +392,7 @@ export class UploadFormComponent implements OnInit {
|
||||
file,
|
||||
quantity: 1,
|
||||
color: defaultSelection.colorName,
|
||||
filamentVariantId: defaultSelection.filamentVariantId
|
||||
filamentVariantId: defaultSelection.filamentVariantId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -302,13 +404,16 @@ export class UploadFormComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
private getDefaultVariantSelection(): { colorName: string; filamentVariantId?: number } {
|
||||
private getDefaultVariantSelection(): {
|
||||
colorName: string;
|
||||
filamentVariantId?: number;
|
||||
} {
|
||||
const vars = this.currentMaterialVariants();
|
||||
if (vars && vars.length > 0) {
|
||||
const preferred = vars.find(v => !v.isOutOfStock) || vars[0];
|
||||
const preferred = vars.find((v) => !v.isOutOfStock) || vars[0];
|
||||
return {
|
||||
colorName: preferred.colorName,
|
||||
filamentVariantId: preferred.id
|
||||
filamentVariantId: preferred.id,
|
||||
};
|
||||
}
|
||||
return { colorName: 'Black' };
|
||||
@@ -320,19 +425,22 @@ export class UploadFormComponent implements OnInit {
|
||||
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)
|
||||
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 byColor = vars.find((v) => v.colorName === item.color);
|
||||
const selected = byId || byColor || fallback;
|
||||
return {
|
||||
...item,
|
||||
color: selected.colorName,
|
||||
filamentVariantId: selected.id
|
||||
filamentVariantId: selected.id,
|
||||
};
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
patchSettings(settings: any) {
|
||||
@@ -364,10 +472,12 @@ export class UploadFormComponent implements OnInit {
|
||||
patch.layerHeight = settings.layerHeightMm;
|
||||
}
|
||||
|
||||
if (settings.nozzleDiameterMm) patch.nozzleDiameter = settings.nozzleDiameterMm;
|
||||
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.supportsEnabled !== undefined)
|
||||
patch.supportEnabled = settings.supportsEnabled;
|
||||
if (settings.notes) patch.notes = settings.notes;
|
||||
|
||||
this.isPatchingSettings = true;
|
||||
@@ -380,19 +490,28 @@ 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 => {
|
||||
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);
|
||||
console.log(
|
||||
'Invalid Control:',
|
||||
key,
|
||||
control.errors,
|
||||
'Value:',
|
||||
control.value,
|
||||
);
|
||||
}
|
||||
});
|
||||
this.form.markAllAsTouched();
|
||||
@@ -408,10 +527,6 @@ export class UploadFormComponent implements OnInit {
|
||||
}
|
||||
|
||||
private normalizeFileName(fileName: string): string {
|
||||
return (fileName || '')
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.trim()
|
||||
.toLowerCase() ?? '';
|
||||
return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<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">
|
||||
@@ -12,7 +11,12 @@
|
||||
[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">
|
||||
@@ -21,7 +25,12 @@
|
||||
[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>
|
||||
@@ -35,7 +44,12 @@
|
||||
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">
|
||||
@@ -45,7 +59,12 @@
|
||||
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>
|
||||
@@ -56,7 +75,12 @@
|
||||
[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 -->
|
||||
@@ -67,7 +91,12 @@
|
||||
[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">
|
||||
@@ -76,40 +105,50 @@
|
||||
[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>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
flex-wrap: wrap;
|
||||
margin: 0 -0.5rem;
|
||||
|
||||
> [class*='col-'] {
|
||||
> [class*="col-"] {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
input[type="checkbox"] {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
@@ -136,6 +136,6 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +43,7 @@ export class UserDetailsComponent {
|
||||
address: ['', Validators.required],
|
||||
zip: ['', Validators.required],
|
||||
city: ['', Validators.required],
|
||||
acceptLegal: [false, Validators.requiredTrue]
|
||||
acceptLegal: [false, Validators.requiredTrue],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,7 +53,7 @@ export class UserDetailsComponent {
|
||||
|
||||
const orderData = {
|
||||
customer: this.form.value,
|
||||
quote: this.quote()
|
||||
quote: this.quote(),
|
||||
};
|
||||
|
||||
// Simulate API delay
|
||||
|
||||
@@ -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;
|
||||
@@ -110,7 +115,7 @@ export interface SimpleOption {
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class QuoteEstimatorService {
|
||||
private http = inject(HttpClient);
|
||||
@@ -131,7 +136,7 @@ export class QuoteEstimatorService {
|
||||
layerHeight: 0.12,
|
||||
infillDensity: 20,
|
||||
infillPattern: 'grid',
|
||||
nozzleDiameter: 0.4
|
||||
nozzleDiameter: 0.4,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,7 +146,7 @@ export class QuoteEstimatorService {
|
||||
layerHeight: 0.24,
|
||||
infillDensity: 12,
|
||||
infillPattern: 'grid',
|
||||
nozzleDiameter: 0.4
|
||||
nozzleDiameter: 0.4,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,18 +155,24 @@ export class QuoteEstimatorService {
|
||||
layerHeight: 0.2,
|
||||
infillDensity: 15,
|
||||
infillPattern: 'grid',
|
||||
nozzleDiameter: 0.4
|
||||
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)
|
||||
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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,48 +180,73 @@ export class QuoteEstimatorService {
|
||||
|
||||
getQuoteSession(sessionId: string): Observable<any> {
|
||||
const headers: any = {};
|
||||
return this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { headers });
|
||||
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 });
|
||||
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 });
|
||||
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 });
|
||||
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 });
|
||||
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`, {
|
||||
return this.http.get(
|
||||
`${environment.apiUrl}/api/orders/${orderId}/invoice`,
|
||||
{
|
||||
headers,
|
||||
responseType: 'blob'
|
||||
});
|
||||
responseType: 'blob',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
getOrderConfirmation(orderId: string): Observable<Blob> {
|
||||
const headers: any = {};
|
||||
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/confirmation`, {
|
||||
return this.http.get(
|
||||
`${environment.apiUrl}/api/orders/${orderId}/confirmation`,
|
||||
{
|
||||
headers,
|
||||
responseType: 'blob'
|
||||
});
|
||||
responseType: 'blob',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
getTwintPayment(orderId: string): Observable<any> {
|
||||
const headers: any = {};
|
||||
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/twint`, { headers });
|
||||
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/twint`, {
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
|
||||
@@ -220,11 +256,13 @@ export class QuoteEstimatorService {
|
||||
return of();
|
||||
}
|
||||
|
||||
return new Observable(observer => {
|
||||
return new Observable((observer) => {
|
||||
// 1. Create Session first
|
||||
const headers: any = {};
|
||||
|
||||
this.http.post<any>(`${environment.apiUrl}/api/quote-sessions`, {}, { headers }).subscribe({
|
||||
this.http
|
||||
.post<any>(`${environment.apiUrl}/api/quote-sessions`, {}, { headers })
|
||||
.subscribe({
|
||||
next: (sessionRes) => {
|
||||
const sessionId = sessionRes.id;
|
||||
const sessionSetupCost = sessionRes.setupCostChf || 0;
|
||||
@@ -236,7 +274,9 @@ export class QuoteEstimatorService {
|
||||
let completedRequests = 0;
|
||||
|
||||
const checkCompletion = () => {
|
||||
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
|
||||
const avg = Math.round(
|
||||
allProgress.reduce((a, b) => a + b, 0) / totalItems,
|
||||
);
|
||||
observer.next(avg);
|
||||
|
||||
if (completedRequests === totalItems) {
|
||||
@@ -248,59 +288,101 @@ export class QuoteEstimatorService {
|
||||
const formData = new FormData();
|
||||
formData.append('file', item.file);
|
||||
|
||||
const easyPreset = request.mode === 'easy'
|
||||
const easyPreset =
|
||||
request.mode === 'easy'
|
||||
? this.buildEasyModePreset(request.quality)
|
||||
: null;
|
||||
|
||||
const settings = {
|
||||
complexityMode: request.mode === 'easy' ? 'ADVANCED' : request.mode.toUpperCase(),
|
||||
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
|
||||
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' });
|
||||
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, {
|
||||
this.http
|
||||
.post<any>(
|
||||
`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items`,
|
||||
formData,
|
||||
{
|
||||
headers,
|
||||
reportProgress: true,
|
||||
observe: 'events'
|
||||
}).subscribe({
|
||||
observe: 'events',
|
||||
},
|
||||
)
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
if (event.type === HttpEventType.UploadProgress && event.total) {
|
||||
allProgress[index] = Math.round((100 * event.loaded) / event.total);
|
||||
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 };
|
||||
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 };
|
||||
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');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const finalize = (responses: any[], setupCost: number, sessionId: string) => {
|
||||
this.http.get<any>(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { headers }).subscribe({
|
||||
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);
|
||||
@@ -311,16 +393,19 @@ export class QuoteEstimatorService {
|
||||
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}) {
|
||||
setPendingConsultation(data: { files: File[]; message: string }) {
|
||||
this.pendingConsultation.set(data);
|
||||
}
|
||||
|
||||
@@ -333,17 +418,28 @@ export class QuoteEstimatorService {
|
||||
// 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`, {
|
||||
return this.http.get(
|
||||
`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/content`,
|
||||
{
|
||||
headers,
|
||||
responseType: 'blob'
|
||||
});
|
||||
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 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,
|
||||
@@ -358,16 +454,19 @@ export class QuoteEstimatorService {
|
||||
// Backend model QuoteSession has materialCode.
|
||||
// But line items might have different colors.
|
||||
color: item.colorCode,
|
||||
filamentVariantId: item.filamentVariantId
|
||||
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
|
||||
notes: session.notes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<app-card
|
||||
*ngIf="!checkoutForm.get('shippingSameAsBilling')?.value"
|
||||
class="mb-6"
|
||||
>
|
||||
<div class="card-header-simple">
|
||||
<h3>{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}</h3>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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,7 +228,7 @@
|
||||
<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">
|
||||
@@ -134,19 +236,27 @@
|
||||
<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>
|
||||
<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
|
||||
{{ 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' }}
|
||||
{{ item.unitPriceChf * item.quantity | currency: "CHF" }}
|
||||
</span>
|
||||
<small class="item-unit-price" *ngIf="item.quantity > 1">
|
||||
{{ item.unitPriceChf | currency:'CHF' }} {{ 'CHECKOUT.PER_PIECE' | translate }}
|
||||
{{ item.unitPriceChf | currency: "CHF" }}
|
||||
{{ "CHECKOUT.PER_PIECE" | translate }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,25 +264,24 @@
|
||||
|
||||
<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>
|
||||
|
||||
@@ -42,7 +42,9 @@
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-direction: row;
|
||||
& > * { flex: 1; }
|
||||
& > * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.no-margin {
|
||||
@@ -197,8 +199,12 @@ 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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
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({
|
||||
@@ -20,10 +28,10 @@ import { LanguageService } from '../../core/services/language.service';
|
||||
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() {
|
||||
@@ -62,7 +70,7 @@ export class CheckoutComponent implements OnInit {
|
||||
addressLine2: [''],
|
||||
zip: ['', Validators.required],
|
||||
city: ['', Validators.required],
|
||||
countryCode: ['CH', Validators.required]
|
||||
countryCode: ['CH', Validators.required],
|
||||
}),
|
||||
|
||||
shippingAddress: this.fb.group({
|
||||
@@ -74,8 +82,8 @@ export class CheckoutComponent implements OnInit {
|
||||
addressLine2: [''],
|
||||
zip: [''],
|
||||
city: [''],
|
||||
countryCode: ['CH']
|
||||
})
|
||||
countryCode: ['CH'],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,7 +119,7 @@ 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';
|
||||
@@ -123,8 +131,12 @@ export class CheckoutComponent implements OnInit {
|
||||
});
|
||||
|
||||
// Toggle shipping validation based on checkbox
|
||||
this.checkoutForm.get('shippingSameAsBilling')?.valueChanges.subscribe(isSame => {
|
||||
const shippingGroup = this.checkoutForm.get('shippingAddress') as FormGroup;
|
||||
this.checkoutForm
|
||||
.get('shippingSameAsBilling')
|
||||
?.valueChanges.subscribe((isSame) => {
|
||||
const shippingGroup = this.checkoutForm.get(
|
||||
'shippingAddress',
|
||||
) as FormGroup;
|
||||
if (isSame) {
|
||||
shippingGroup.disable();
|
||||
} else {
|
||||
@@ -146,7 +158,7 @@ export class CheckoutComponent implements OnInit {
|
||||
error: (err) => {
|
||||
console.error('Failed to load session', err);
|
||||
this.error = 'CHECKOUT.ERR_LOAD_SESSION';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,9 +191,11 @@ export class CheckoutComponent implements OnInit {
|
||||
addressLine2: formVal.billingAddress.addressLine2,
|
||||
zip: formVal.billingAddress.zip,
|
||||
city: formVal.billingAddress.city,
|
||||
countryCode: formVal.billingAddress.countryCode
|
||||
countryCode: formVal.billingAddress.countryCode,
|
||||
},
|
||||
shippingAddress: formVal.shippingSameAsBilling ? null : {
|
||||
shippingAddress: formVal.shippingSameAsBilling
|
||||
? null
|
||||
: {
|
||||
firstName: formVal.shippingAddress.firstName,
|
||||
lastName: formVal.shippingAddress.lastName,
|
||||
companyName: formVal.shippingAddress.companyName,
|
||||
@@ -190,12 +204,12 @@ export class CheckoutComponent implements OnInit {
|
||||
addressLine2: formVal.shippingAddress.addressLine2,
|
||||
zip: formVal.shippingAddress.zip,
|
||||
city: formVal.shippingAddress.city,
|
||||
countryCode: formVal.shippingAddress.countryCode
|
||||
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';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
@@ -29,11 +48,16 @@ select.form-control {
|
||||
margin-bottom: var(--space-4);
|
||||
@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 {
|
||||
@@ -59,7 +83,9 @@ app-input.col { width: 100%; }
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
|
||||
&:hover { color: var(--color-text); }
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--color-brand);
|
||||
@@ -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,7 +140,12 @@ app-input.col { width: 100%; }
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
width: 100%; height: 100%; object-fit: cover; position: absolute; top:0; left:0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,15 +23,23 @@ 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;
|
||||
@@ -36,7 +49,7 @@ export class ContactFormComponent implements OnDestroy {
|
||||
{ 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);
|
||||
@@ -44,7 +57,7 @@ export class ContactFormComponent implements OnDestroy {
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private translate: TranslateService,
|
||||
private estimator: QuoteEstimatorService
|
||||
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');
|
||||
@@ -94,16 +107,18 @@ export class ContactFormComponent implements OnDestroy {
|
||||
if (pending) {
|
||||
this.form.patchValue({
|
||||
requestType: 'consult',
|
||||
message: pending.message
|
||||
message: pending.message,
|
||||
});
|
||||
|
||||
// Process files
|
||||
const filePreviews: FilePreview[] = pending.files.map(f => {
|
||||
const filePreviews: FilePreview[] = pending.files.map((f) => {
|
||||
const type = this.getFileType(f);
|
||||
return {
|
||||
file: f,
|
||||
type,
|
||||
url: this.shouldCreatePreview(type) ? URL.createObjectURL(f) : undefined
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,18 +2,24 @@
|
||||
<section class="hero">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">{{ 'HOME.HERO_EYEBROW' | translate }}</p>
|
||||
<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 }}
|
||||
{{ "HOME.HERO_LEAD" | translate }}
|
||||
</p>
|
||||
<p class="hero-subtitle">
|
||||
{{ 'HOME.HERO_SUBTITLE' | translate }}
|
||||
{{ "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>
|
||||
<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>
|
||||
</div>
|
||||
@@ -23,39 +29,39 @@
|
||||
<div class="capabilities-bg"></div>
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<h2 class="section-title">{{ 'HOME.SEC_CAP_TITLE' | translate }}</h2>
|
||||
<h2 class="section-title">{{ "HOME.SEC_CAP_TITLE" | translate }}</h2>
|
||||
<p class="section-subtitle">
|
||||
{{ 'HOME.SEC_CAP_SUBTITLE' | translate }}
|
||||
{{ "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="">
|
||||
<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>
|
||||
<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="">
|
||||
<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>
|
||||
<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="">
|
||||
<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>
|
||||
<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="">
|
||||
<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>
|
||||
<h3>{{ "HOME.CAP_4_TITLE" | translate }}</h3>
|
||||
<p class="text-muted">{{ "HOME.CAP_4_TEXT" | translate }}</p>
|
||||
</app-card>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,30 +70,44 @@
|
||||
<section class="section calculator">
|
||||
<div class="container calculator-grid">
|
||||
<div class="calculator-copy">
|
||||
<h2 class="section-title">{{ 'HOME.SEC_CALC_TITLE' | translate }}</h2>
|
||||
<h2 class="section-title">{{ "HOME.SEC_CALC_TITLE" | translate }}</h2>
|
||||
<p class="section-subtitle">
|
||||
{{ 'HOME.SEC_CALC_SUBTITLE' | translate }}
|
||||
{{ "HOME.SEC_CALC_SUBTITLE" | translate }}
|
||||
</p>
|
||||
<ul class="calculator-list">
|
||||
<li>{{ 'HOME.SEC_CALC_LIST_1' | translate }}</li>
|
||||
<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>
|
||||
<h3 class="quote-title">{{ 'HOME.CARD_CALC_TITLE' | translate }}</h3>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -96,37 +116,48 @@
|
||||
<section class="section shop">
|
||||
<div class="container split">
|
||||
<div class="shop-copy">
|
||||
<h2 class="section-title">{{ 'HOME.SEC_SHOP_TITLE' | translate }}</h2>
|
||||
<h2 class="section-title">{{ "HOME.SEC_SHOP_TITLE" | translate }}</h2>
|
||||
<p>
|
||||
{{ 'HOME.SEC_SHOP_TEXT' | translate }}
|
||||
{{ "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>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<h3>{{ "HOME.CARD_SHOP_3_TITLE" | translate }}</h3>
|
||||
<p class="text-muted">{{ "HOME.CARD_SHOP_3_TEXT" | translate }}</p>
|
||||
</app-card>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,13 +166,17 @@
|
||||
<section class="section about">
|
||||
<div class="container about-grid">
|
||||
<div class="about-copy">
|
||||
<h2 class="section-title">{{ 'HOME.SEC_ABOUT_TITLE' | translate }}</h2>
|
||||
<h2 class="section-title">{{ "HOME.SEC_ABOUT_TITLE" | translate }}</h2>
|
||||
<p>
|
||||
{{ 'HOME.SEC_ABOUT_TEXT' | translate }}
|
||||
{{ "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>
|
||||
<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">
|
||||
@@ -152,7 +187,7 @@
|
||||
[alt]="founderImages[founderImageIndex].alt | translate"
|
||||
width="1200"
|
||||
height="900"
|
||||
>
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="founder-nav founder-nav-prev"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@use '../../../styles/patterns';
|
||||
@use "../../../styles/patterns";
|
||||
|
||||
.home-page {
|
||||
--home-bg: #faf9f6;
|
||||
@@ -13,7 +13,7 @@
|
||||
background: var(--home-bg);
|
||||
// Enhanced Grid Pattern
|
||||
&::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@include patterns.pattern-grid(var(--color-neutral-900), 40px, 1px);
|
||||
@@ -26,13 +26,17 @@
|
||||
|
||||
// Keep the accent blob
|
||||
.hero::before {
|
||||
content: '';
|
||||
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%);
|
||||
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;
|
||||
@@ -46,8 +50,12 @@
|
||||
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;
|
||||
@@ -115,7 +123,7 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.focus-list li::before {
|
||||
content: '•';
|
||||
content: "•";
|
||||
color: var(--color-brand);
|
||||
margin-right: var(--space-2);
|
||||
}
|
||||
@@ -138,7 +146,10 @@
|
||||
color: var(--color-secondary-600);
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
.quote-title { margin: 0; font-size: 1.35rem; }
|
||||
.quote-title {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
.quote-tag {
|
||||
background: var(--color-neutral-100);
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -163,7 +174,7 @@
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.quote-steps li::before {
|
||||
content: '';
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
@@ -186,8 +197,13 @@
|
||||
color: var(--color-secondary-600);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
.meta-value { font-weight: 600; }
|
||||
.quote-actions { display: grid; gap: var(--space-3); }
|
||||
.meta-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
.quote-actions {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.capabilities {
|
||||
position: relative;
|
||||
@@ -198,11 +214,24 @@
|
||||
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;
|
||||
@@ -253,7 +282,9 @@
|
||||
background: var(--home-bg);
|
||||
position: relative;
|
||||
}
|
||||
.shop .split { align-items: start; }
|
||||
.shop .split {
|
||||
align-items: start;
|
||||
}
|
||||
.shop-copy {
|
||||
max-width: 760px;
|
||||
}
|
||||
@@ -386,8 +417,12 @@
|
||||
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);
|
||||
@@ -403,22 +438,41 @@
|
||||
}
|
||||
|
||||
@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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.quote-meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.shop-gallery {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
@@ -427,7 +481,9 @@
|
||||
.shop-gallery-item {
|
||||
aspect-ratio: 16 / 11;
|
||||
}
|
||||
.shop-cards { grid-template-columns: 1fr; }
|
||||
.shop-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.about-media {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -443,15 +499,31 @@
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(18px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes floatGlow {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(20px); }
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(20px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-copy, .hero-panel { animation: none; }
|
||||
.hero::before { animation: none; }
|
||||
.hero-copy,
|
||||
.hero-panel {
|
||||
animation: none;
|
||||
}
|
||||
.hero::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -1,47 +1,71 @@
|
||||
<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="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 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="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 class="label">{{ "TRACKING.STEP_REPORTED" | translate }}</div>
|
||||
</div>
|
||||
<div class="timeline-step"
|
||||
<div
|
||||
class="timeline-step"
|
||||
[class.active]="o.status === 'PAID' || o.status === 'IN_PRODUCTION'"
|
||||
[class.completed]="o.status === 'SHIPPED' || o.status === 'COMPLETED'">
|
||||
[class.completed]="o.status === 'SHIPPED' || o.status === 'COMPLETED'"
|
||||
>
|
||||
<div class="circle">3</div>
|
||||
<div class="label">{{ 'TRACKING.STEP_PRODUCTION' | translate }}</div>
|
||||
<div class="label">{{ "TRACKING.STEP_PRODUCTION" | translate }}</div>
|
||||
</div>
|
||||
<div class="timeline-step"
|
||||
<div
|
||||
class="timeline-step"
|
||||
[class.active]="o.status === 'SHIPPED'"
|
||||
[class.completed]="o.status === 'COMPLETED'">
|
||||
[class.completed]="o.status === 'COMPLETED'"
|
||||
>
|
||||
<div class="circle">4</div>
|
||||
<div class="label">{{ 'TRACKING.STEP_SHIPPED' | translate }}</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'">
|
||||
<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>
|
||||
<h3>{{ "PAYMENT.STATUS_REPORTED_TITLE" | translate }}</h3>
|
||||
<p>{{ "PAYMENT.STATUS_REPORTED_DESC" | translate }}</p>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
@@ -49,23 +73,38 @@
|
||||
<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;
|
||||
<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()">
|
||||
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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -245,7 +246,7 @@
|
||||
/* padding: var(--space-6); */ /* Removed if it was here to match non-card layout */
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: 12.5%;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,7 +145,9 @@ export class OrderComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.quoteService.reportPayment(this.orderId, this.selectedPaymentMethod).subscribe({
|
||||
this.quoteService
|
||||
.reportPayment(this.orderId, this.selectedPaymentMethod)
|
||||
.subscribe({
|
||||
next: (order) => {
|
||||
this.order.set(order);
|
||||
// The UI will re-render and show the 'REPORTED' state.
|
||||
@@ -146,7 +157,7 @@ export class OrderComponent implements OnInit {
|
||||
error: (err) => {
|
||||
console.error('Failed to report payment', err);
|
||||
this.error.set('ORDER.ERR_REPORT_PAYMENT');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
@@ -8,18 +8,18 @@
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
.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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ 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
|
||||
@@ -20,13 +20,15 @@ export class ProductDetailComponent {
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
[type]="type()"
|
||||
[class]="'btn btn-' + variant() + ' ' + (fullWidth() ? 'w-full' : '')"
|
||||
[disabled]="disabled()"
|
||||
(click)="handleClick($event)">
|
||||
(click)="handleClick($event)"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</button>
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
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;
|
||||
@@ -16,18 +19,24 @@
|
||||
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 {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
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;
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -6,11 +6,34 @@
|
||||
(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>
|
||||
|
||||
@@ -7,14 +7,24 @@
|
||||
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;
|
||||
|
||||
@@ -7,7 +7,7 @@ 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');
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
@if (label()) {
|
||||
<label [for]="id()">
|
||||
{{ label() }}
|
||||
@if (required()) { <span class="required-mark">*</span> }
|
||||
@if (required()) {
|
||||
<span class="required-mark">*</span>
|
||||
}
|
||||
</label>
|
||||
}
|
||||
<input
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Component, input, forwardRef } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
|
||||
import {
|
||||
ControlValueAccessor,
|
||||
NG_VALUE_ACCESSOR,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
@@ -10,11 +14,11 @@ import { CommonModule } from '@angular/common';
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => AppInputComponent),
|
||||
multi: true
|
||||
}
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
templateUrl: './app-input.component.html',
|
||||
styleUrl: './app-input.component.scss'
|
||||
styleUrl: './app-input.component.scss',
|
||||
})
|
||||
export class AppInputComponent implements ControlValueAccessor {
|
||||
label = input<string>('');
|
||||
@@ -30,10 +34,18 @@ export class AppInputComponent implements ControlValueAccessor {
|
||||
onChange: any = () => {};
|
||||
onTouched: any = () => {};
|
||||
|
||||
writeValue(obj: any): void { this.value = obj || ''; }
|
||||
registerOnChange(fn: any): void { this.onChange = fn; }
|
||||
registerOnTouched(fn: any): void { this.onTouched = fn; }
|
||||
setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; }
|
||||
writeValue(obj: any): void {
|
||||
this.value = obj || '';
|
||||
}
|
||||
registerOnChange(fn: any): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
registerOnTouched(fn: any): void {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
setDisabledState(isDisabled: boolean): void {
|
||||
this.disabled = isDisabled;
|
||||
}
|
||||
|
||||
onInput(event: Event) {
|
||||
const val = (event.target as HTMLInputElement).value;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<section class="locations-section">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2>{{ 'LOCATIONS.TITLE' | translate }}</h2>
|
||||
<p class="subtitle">{{ 'LOCATIONS.SUBTITLE' | translate }}</p>
|
||||
<h2>{{ "LOCATIONS.TITLE" | translate }}</h2>
|
||||
<p class="subtitle">{{ "LOCATIONS.SUBTITLE" | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="locations-grid">
|
||||
@@ -11,23 +11,24 @@
|
||||
<app-toggle-selector
|
||||
[options]="locationOptions"
|
||||
[selectedValue]="selectedLocation"
|
||||
(selectionChange)="selectLocation($event)">
|
||||
(selectionChange)="selectLocation($event)"
|
||||
>
|
||||
</app-toggle-selector>
|
||||
</div>
|
||||
|
||||
<div class="location-details">
|
||||
<div *ngIf="selectedLocation === 'ticino'" class="details-card">
|
||||
<h3>{{ 'LOCATIONS.BIASCA' | translate }}</h3>
|
||||
<p>{{ 'LOCATIONS.ADDRESS_TICINO' | translate }}</p>
|
||||
<h3>{{ "LOCATIONS.BIASCA" | translate }}</h3>
|
||||
<p>{{ "LOCATIONS.ADDRESS_TICINO" | translate }}</p>
|
||||
</div>
|
||||
<div *ngIf="selectedLocation === 'bienne'" class="details-card">
|
||||
<h3>{{ 'LOCATIONS.BIENNE' | translate }}</h3>
|
||||
<p>{{ 'LOCATIONS.ADDRESS_BIENNE' | translate }}</p>
|
||||
<h3>{{ "LOCATIONS.BIENNE" | translate }}</h3>
|
||||
<p>{{ "LOCATIONS.ADDRESS_BIENNE" | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a routerLink="/contact" class="contact-btn">
|
||||
{{ 'LOCATIONS.CONTACT_US' | translate }}
|
||||
{{ "LOCATIONS.CONTACT_US" | translate }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,12 +38,24 @@
|
||||
<iframe
|
||||
*ngIf="selectedLocation === 'ticino'"
|
||||
src="https://www.google.com/maps?q=Via%20G.%20Pioda%2029a%2C%20Biasca&output=embed"
|
||||
width="100%" height="450" style="border:0;" allowfullscreen="" loading="lazy" referrerpolicy="no-referrer-when-downgrade">
|
||||
width="100%"
|
||||
height="450"
|
||||
style="border: 0"
|
||||
allowfullscreen=""
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
>
|
||||
</iframe>
|
||||
<iframe
|
||||
*ngIf="selectedLocation === 'bienne'"
|
||||
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d2729.0438104193587!2d7.240752176735282!3d47.126435979155985!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x478e1eb84efba295%3A0x95924d5ba8b6f3b0!2sLyss-Strasse%2071%2C%202560%20Nidau%2C%20Switzerland!5e0!3m2!1sen!2sch!4v1700000000000!5m2!1sen!2sch"
|
||||
width="100%" height="450" style="border:0;" allowfullscreen="" loading="lazy" referrerpolicy="no-referrer-when-downgrade">
|
||||
width="100%"
|
||||
height="450"
|
||||
style="border: 0"
|
||||
allowfullscreen=""
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
>
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.location-details {
|
||||
padding: 2rem;
|
||||
background: var(--color-bg);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user