diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteController.java b/backend/src/main/java/com/printcalculator/controller/QuoteController.java index 8675415..d848d4c 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteController.java @@ -18,6 +18,7 @@ import java.math.BigDecimal; import java.nio.file.Files; import java.nio.file.Path; import java.util.HashMap; +import java.util.Locale; import java.util.Map; import static org.springframework.http.HttpStatus.BAD_REQUEST; @@ -124,6 +125,9 @@ public class QuoteController { if (file.isEmpty()) { return ResponseEntity.badRequest().build(); } + if (!isSupportedInputFile(file)) { + throw new ResponseStatusException(BAD_REQUEST, "Unsupported file type. Allowed: stl, 3mf"); + } // Scan for virus clamAVService.scan(file.getInputStream()); @@ -153,4 +157,14 @@ public class QuoteController { Files.deleteIfExists(tempInput); } } + + private boolean isSupportedInputFile(MultipartFile file) { + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null || originalFilename.isBlank()) { + return false; + } + + String normalized = originalFilename.toLowerCase(Locale.ROOT); + return normalized.endsWith(".stl") || normalized.endsWith(".3mf"); + } } diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java index 50496b3..24a2737 100644 --- a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java @@ -72,10 +72,14 @@ public class QuoteSessionItemService { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot modify a converted session"); } + String ext = quoteStorageService.getSafeExtension(file.getOriginalFilename(), ""); + if (ext.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported file type. Allowed: stl, 3mf"); + } + clamAVService.scan(file.getInputStream()); Path sessionStorageDir = quoteStorageService.sessionStorageDir(session.getId()); - String ext = quoteStorageService.getSafeExtension(file.getOriginalFilename(), "stl"); String storedFilename = UUID.randomUUID() + "." + ext; Path persistentPath = quoteStorageService.resolveSessionPath(sessionStorageDir, storedFilename); diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteStorageService.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteStorageService.java index 87e5e44..b1359df 100644 --- a/backend/src/main/java/com/printcalculator/service/quote/QuoteStorageService.java +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteStorageService.java @@ -54,7 +54,6 @@ public class QuoteStorageService { return switch (ext) { case "stl" -> "stl"; case "3mf" -> "3mf"; - case "step", "stp" -> "step"; default -> fallback; }; } diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index 57614f9..6be3dac 100644 Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest new file mode 100644 index 0000000..cfd690f --- /dev/null +++ b/frontend/public/site.webmanifest @@ -0,0 +1,23 @@ +{ + "name": "3D fab", + "short_name": "3D fab", + "description": "Stampa 3D su misura con preventivo online immediato.", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#ffffff", + "icons": [ + { + "src": "/assets/images/Fav-icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/images/Fav-icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + } + ] +} diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 0680b43..ee34adf 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -1 +1,14 @@ + +@if (siteIntroState() !== 'hidden') { + +} diff --git a/frontend/src/app/app.component.scss b/frontend/src/app/app.component.scss index e69de29..137cdc6 100644 --- a/frontend/src/app/app.component.scss +++ b/frontend/src/app/app.component.scss @@ -0,0 +1,40 @@ +.site-intro { + position: fixed; + inset: 0; + z-index: 2000; + display: grid; + place-items: center; + background: var(--color-bg); + pointer-events: none; + opacity: 1; + transition: opacity 0.24s ease-out; +} + +.site-intro--closing { + opacity: 0; +} + +.site-intro__logo { + width: min(calc(100vw - 2rem), 23rem); + --brand-animation-width: 23rem; + --brand-animation-height: 7.1rem; + --brand-animation-letter-width: 3.75rem; + --brand-animation-scale: 0.88; + --brand-animation-width-mobile: 16.8rem; + --brand-animation-height-mobile: 5.3rem; + --brand-animation-letter-width-mobile: 2.8rem; + --brand-animation-scale-mobile: 0.68; + --brand-animation-site-intro-duration: 1.05s; + justify-self: center; + align-self: center; + opacity: 1; + transform: scale(1); + transition: + opacity 0.24s ease-out, + transform 0.24s ease-out; +} + +.site-intro--closing .site-intro__logo { + opacity: 0; + transform: scale(0.985); +} diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 53a2fdb..a7d31cc 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -1,14 +1,50 @@ -import { Component, inject } from '@angular/core'; +import { + afterNextRender, + Component, + DestroyRef, + Inject, + Optional, + PLATFORM_ID, + inject, + signal, +} from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; import { RouterOutlet } from '@angular/router'; import { SeoService } from './core/services/seo.service'; +import { BrandAnimationLogoComponent } from './shared/components/brand-animation-logo/brand-animation-logo.component'; @Component({ selector: 'app-root', standalone: true, - imports: [RouterOutlet], + imports: [RouterOutlet, BrandAnimationLogoComponent], templateUrl: './app.component.html', styleUrl: './app.component.scss', }) export class AppComponent { private readonly seoService = inject(SeoService); + private readonly destroyRef = inject(DestroyRef); + readonly siteIntroState = signal<'hidden' | 'active' | 'closing'>('hidden'); + + constructor(@Optional() @Inject(PLATFORM_ID) platformId?: Object) { + if (!isPlatformBrowser(platformId ?? 'browser')) { + return; + } + + afterNextRender(() => { + this.siteIntroState.set('active'); + + const closeTimeoutId = window.setTimeout(() => { + this.siteIntroState.set('closing'); + }, 1020); + + const hideTimeoutId = window.setTimeout(() => { + this.siteIntroState.set('hidden'); + }, 1280); + + this.destroyRef.onDestroy(() => { + window.clearTimeout(closeTimeoutId); + window.clearTimeout(hideTimeoutId); + }); + }); + } } diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index a18875a..a72432c 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -28,21 +28,12 @@ import { import { serverOriginInterceptor } from './core/interceptors/server-origin.interceptor'; import { catchError, firstValueFrom, of } from 'rxjs'; import { StaticTranslateLoader } from './core/i18n/static-translate.loader'; - -type SupportedLang = 'it' | 'en' | 'de' | 'fr'; -const SUPPORTED_LANGS: readonly SupportedLang[] = ['it', 'en', 'de', 'fr']; - -function resolveLangFromUrl(url: string): SupportedLang { - const firstSegment = (url || '/') - .split('?')[0] - .split('#')[0] - .split('/') - .filter(Boolean)[0] - ?.toLowerCase(); - return SUPPORTED_LANGS.includes(firstSegment as SupportedLang) - ? (firstSegment as SupportedLang) - : 'it'; -} +import { + getNavigatorLanguagePreferences, + parseAcceptLanguage, + resolveInitialLanguage, + SUPPORTED_LANGS, +} from './core/i18n/language-resolution'; export const appConfig: ApplicationConfig = { providers: [ @@ -52,7 +43,7 @@ export const appConfig: ApplicationConfig = { withComponentInputBinding(), withViewTransitions(), withInMemoryScrolling({ - scrollPositionRestoration: 'top', + scrollPositionRestoration: 'enabled', }), ), provideHttpClient( @@ -72,13 +63,21 @@ export const appConfig: ApplicationConfig = { const router = inject(Router); const request = inject(REQUEST, { optional: true }) as { url?: string; + headers?: Record; } | null; translate.addLangs([...SUPPORTED_LANGS]); translate.setFallbackLang('it'); const requestedUrl = (typeof request?.url === 'string' && request.url) || router.url || '/'; - const lang = resolveLangFromUrl(requestedUrl); + const lang = resolveInitialLanguage({ + url: requestedUrl, + preferredLanguages: request + ? parseAcceptLanguage(readRequestHeader(request, 'accept-language')) + : getNavigatorLanguagePreferences( + typeof navigator === 'undefined' ? null : navigator, + ), + }); return firstValueFrom( translate.use(lang).pipe( @@ -96,3 +95,21 @@ export const appConfig: ApplicationConfig = { provideClientHydration(withEventReplay()), ], }; + +function readRequestHeader( + request: { + headers?: Record; + } | null, + headerName: string, +): string | null { + if (!request?.headers) { + return null; + } + + const headerValue = request.headers[headerName.toLowerCase()]; + if (Array.isArray(headerValue)) { + return headerValue[0] ?? null; + } + + return typeof headerValue === 'string' ? headerValue : null; +} diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 0830d96..ba77270 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -134,6 +134,31 @@ const appChildRoutes: Routes = [ ]; export const routes: Routes = [ + { + path: ':lang/calculator/animation-test', + canMatch: [langPrefixCanMatch], + loadComponent: () => + import('./features/calculator/calculator-animation-test.component').then( + (m) => m.CalculatorAnimationTestComponent, + ), + data: { + seoTitleKey: 'SEO.ROUTES.CALCULATOR.TITLE', + seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.DESCRIPTION', + seoRobots: 'noindex, nofollow', + }, + }, + { + path: 'calculator/animation-test', + loadComponent: () => + import('./features/calculator/calculator-animation-test.component').then( + (m) => m.CalculatorAnimationTestComponent, + ), + data: { + seoTitleKey: 'SEO.ROUTES.CALCULATOR.TITLE', + seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.DESCRIPTION', + seoRobots: 'noindex, nofollow', + }, + }, { path: ':lang', canMatch: [langPrefixCanMatch], diff --git a/frontend/src/app/core/i18n/language-resolution.ts b/frontend/src/app/core/i18n/language-resolution.ts new file mode 100644 index 0000000..130387d --- /dev/null +++ b/frontend/src/app/core/i18n/language-resolution.ts @@ -0,0 +1,133 @@ +export type SupportedLang = 'it' | 'en' | 'de' | 'fr'; + +export const SUPPORTED_LANGS: readonly SupportedLang[] = [ + 'it', + 'en', + 'de', + 'fr', +]; + +type InitialLanguageOptions = { + url?: string | null; + preferredLanguages?: readonly string[] | null; + fallbackLang?: SupportedLang; +}; + +type NavigatorLike = { + language?: string; + languages?: readonly string[]; +}; + +export function resolveInitialLanguage({ + url, + preferredLanguages, + fallbackLang = 'it', +}: InitialLanguageOptions): SupportedLang { + const explicitLang = resolveExplicitLanguageFromUrl(url); + if (explicitLang) { + return explicitLang; + } + + for (const candidate of preferredLanguages ?? []) { + const normalized = normalizeSupportedLanguage(candidate); + if (normalized) { + return normalized; + } + } + + return fallbackLang; +} + +export function parseAcceptLanguage( + header: string | null | undefined, +): string[] { + if (!header) { + return []; + } + + return header + .split(',') + .map((entry, index) => { + const [rawTag, ...params] = entry.split(';').map((part) => part.trim()); + if (!rawTag) { + return null; + } + + const qualityParam = params.find((param) => param.startsWith('q=')); + const quality = qualityParam ? Number.parseFloat(qualityParam.slice(2)) : 1; + return { + tag: rawTag, + quality: Number.isFinite(quality) ? quality : 0, + index, + }; + }) + .filter( + ( + entry, + ): entry is { + tag: string; + quality: number; + index: number; + } => entry !== null && entry.quality > 0, + ) + .sort((left, right) => right.quality - left.quality || left.index - right.index) + .map((entry) => entry.tag); +} + +export function getNavigatorLanguagePreferences( + navigatorLike: NavigatorLike | null | undefined, +): string[] { + if (!navigatorLike) { + return []; + } + + const orderedLanguages = [ + ...(Array.isArray(navigatorLike.languages) ? navigatorLike.languages : []), + ]; + + if ( + typeof navigatorLike.language === 'string' && + navigatorLike.language && + !orderedLanguages.includes(navigatorLike.language) + ) { + orderedLanguages.push(navigatorLike.language); + } + + return orderedLanguages; +} + +function resolveExplicitLanguageFromUrl( + url: string | null | undefined, +): SupportedLang | null { + const normalizedUrl = String(url ?? '/'); + const [pathAndQuery] = normalizedUrl.split('#', 1); + const [rawPath, rawQuery] = pathAndQuery.split('?', 2); + const firstSegment = rawPath + .split('/') + .filter(Boolean)[0]; + const pathLanguage = normalizeSupportedLanguage(firstSegment); + if (pathLanguage) { + return pathLanguage; + } + + const queryLanguage = new URLSearchParams(rawQuery ?? '').get('lang'); + return normalizeSupportedLanguage(queryLanguage); +} + +function normalizeSupportedLanguage( + rawLanguage: string | null | undefined, +): SupportedLang | null { + if (typeof rawLanguage !== 'string') { + return null; + } + + const normalized = rawLanguage.trim().toLowerCase(); + if (!normalized || normalized === '*') { + return null; + } + + const [baseLanguage] = normalized.split('-', 1); + return SUPPORTED_LANGS.includes(baseLanguage as SupportedLang) + ? (baseLanguage as SupportedLang) + : null; +} diff --git a/frontend/src/app/core/layout/layout.component.ts b/frontend/src/app/core/layout/layout.component.ts index 873f795..0e67b30 100644 --- a/frontend/src/app/core/layout/layout.component.ts +++ b/frontend/src/app/core/layout/layout.component.ts @@ -10,4 +10,5 @@ import { FooterComponent } from './footer.component'; templateUrl: './layout.component.html', styleUrl: './layout.component.scss', }) -export class LayoutComponent {} +export class LayoutComponent { +} diff --git a/frontend/src/app/core/services/language.service.spec.ts b/frontend/src/app/core/services/language.service.spec.ts index 78576b1..43eebc0 100644 --- a/frontend/src/app/core/services/language.service.spec.ts +++ b/frontend/src/app/core/services/language.service.spec.ts @@ -2,6 +2,7 @@ import { Subject } from 'rxjs'; import { DefaultUrlSerializer, Router, UrlTree } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { LanguageService } from './language.service'; +import { RequestLike } from '../../../core/request-origin'; describe('LanguageService', () => { function createTranslateMock() { @@ -70,9 +71,14 @@ describe('LanguageService', () => { const translate = createTranslateMock(); const router = createRouterMock('/calculator?session=abc'); const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy; + const request: RequestLike = { + headers: { + 'accept-language': 'it-CH,it;q=0.9,en;q=0.8', + }, + }; // eslint-disable-next-line @typescript-eslint/no-unused-vars - const service = new LanguageService(translate, router); + const service = new LanguageService(translate, router, request); expect(translate.use).toHaveBeenCalledWith('it'); expect((translate as any).setFallbackLang).toHaveBeenCalledWith('it'); @@ -85,6 +91,27 @@ describe('LanguageService', () => { expect(navOptions.replaceUrl).toBeTrue(); }); + it('uses the preferred browser language when the URL has no language prefix', () => { + const translate = createTranslateMock(); + const router = createRouterMock('/calculator?session=abc'); + const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy; + const request: RequestLike = { + headers: { + 'accept-language': 'de-CH,de;q=0.9,en;q=0.8,it;q=0.7', + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const service = new LanguageService(translate, router, request); + + expect(translate.use).toHaveBeenCalledWith('de'); + expect(navigateSpy).toHaveBeenCalledTimes(1); + + const firstCall = navigateSpy.calls.mostRecent(); + const tree = firstCall.args[0] as UrlTree; + expect(router.serializeUrl(tree)).toBe('/de/calculator?session=abc'); + }); + it('switches language while preserving path and query params', () => { const translate = createTranslateMock(); const router = createRouterMock('/it/calculator?session=abc&mode=advanced'); diff --git a/frontend/src/app/core/services/language.service.ts b/frontend/src/app/core/services/language.service.ts index 449c339..fd27954 100644 --- a/frontend/src/app/core/services/language.service.ts +++ b/frontend/src/app/core/services/language.service.ts @@ -1,4 +1,4 @@ -import { Injectable, signal } from '@angular/core'; +import { Inject, Injectable, Optional, REQUEST, signal } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { NavigationEnd, @@ -6,6 +6,12 @@ import { Router, UrlTree, } from '@angular/router'; +import { + getNavigatorLanguagePreferences, + parseAcceptLanguage, + resolveInitialLanguage, +} from '../i18n/language-resolution'; +import { RequestLike } from '../../../core/request-origin'; @Injectable({ providedIn: 'root', @@ -22,6 +28,7 @@ export class LanguageService { constructor( private translate: TranslateService, private router: Router, + @Optional() @Inject(REQUEST) private request: RequestLike | null = null, ) { this.translate.addLangs(this.supportedLangs); this.translate.setFallbackLang('it'); @@ -34,13 +41,14 @@ export class LanguageService { }); const initialTree = this.router.parseUrl(this.router.url); - const initialSegments = this.getPrimarySegments(initialTree); - const queryLang = this.getQueryLang(initialTree); - const initialLang = this.isSupportedLang(initialSegments[0]) - ? initialSegments[0] - : this.isSupportedLang(queryLang) - ? queryLang - : 'it'; + const initialLang = resolveInitialLanguage({ + url: this.router.url, + preferredLanguages: this.request + ? parseAcceptLanguage(this.readRequestHeader('accept-language')) + : getNavigatorLanguagePreferences( + typeof navigator === 'undefined' ? null : navigator, + ), + }); this.applyLanguage(initialLang); this.ensureLanguageInPath(initialTree); @@ -151,6 +159,17 @@ export class LanguageService { return typeof lang === 'string' ? lang.toLowerCase() : null; } + private readRequestHeader(headerName: string): string | null { + const headerValue = + this.request?.headers?.[headerName.toLowerCase()] ?? + this.request?.get?.(headerName.toLowerCase()); + if (Array.isArray(headerValue)) { + return headerValue[0] ?? null; + } + + return typeof headerValue === 'string' ? headerValue : null; + } + private isSupportedLang( lang: string | null | undefined, ): lang is 'it' | 'en' | 'de' | 'fr' { diff --git a/frontend/src/app/core/services/seo.service.spec.ts b/frontend/src/app/core/services/seo.service.spec.ts index 4c00ba1..62b279e 100644 --- a/frontend/src/app/core/services/seo.service.spec.ts +++ b/frontend/src/app/core/services/seo.service.spec.ts @@ -94,14 +94,14 @@ describe('SeoService', () => { })); expect(alternates).toContain({ - hreflang: 'en', + hreflang: 'en-CH', href: `${document.location.origin}/en/privacy`, }); expect(alternates).toContain({ hreflang: 'x-default', href: `${document.location.origin}/it/privacy`, }); - expect(document.documentElement.lang).toBe('it'); + expect(document.documentElement.lang).toBe('it-CH'); const ogUrlCall = meta.updateTag.calls .allArgs() @@ -109,6 +109,11 @@ describe('SeoService', () => { expect(ogUrlCall?.[0].content).toBe( `${document.location.origin}/it/privacy`, ); + + const ogLocaleCall = meta.updateTag.calls + .allArgs() + .find(([tag]) => tag.property === 'og:locale'); + expect(ogLocaleCall?.[0].content).toBe('it_CH'); }); it('resolves translated route metadata for the active language', () => { @@ -130,6 +135,6 @@ describe('SeoService', () => { .allArgs() .find(([tag]) => tag.name === 'description'); expect(descriptionCall?.[0].content).toBe('About description'); - expect(document.documentElement.lang).toBe('en'); + expect(document.documentElement.lang).toBe('en-CH'); }); }); diff --git a/frontend/src/app/core/services/seo.service.ts b/frontend/src/app/core/services/seo.service.ts index 71db8f1..da7e35c 100644 --- a/frontend/src/app/core/services/seo.service.ts +++ b/frontend/src/app/core/services/seo.service.ts @@ -51,10 +51,16 @@ export class SeoService { this.supportedLangs, ); private readonly ogLocaleByLang: Record = { - it: 'it_IT', - en: 'en_US', - de: 'de_DE', - fr: 'fr_FR', + it: 'it_CH', + en: 'en_CH', + de: 'de_CH', + fr: 'fr_CH', + }; + private readonly seoLocaleByLang: Record = { + it: 'it-CH', + en: 'en-CH', + de: 'de-CH', + fr: 'fr-CH', }; constructor( @@ -315,7 +321,7 @@ export class SeoService { const suffix = suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : ''; - this.document.documentElement.lang = lang; + this.document.documentElement.lang = this.seoLocaleByLang[lang]; this.document.head .querySelectorAll('link[rel="alternate"][data-seo-managed="true"]') @@ -323,7 +329,7 @@ export class SeoService { for (const alt of this.supportedLangs) { this.appendAlternateLink( - alt, + this.seoLocaleByLang[alt], `${this.document.location.origin}/${alt}${suffix}`, ); } diff --git a/frontend/src/app/features/calculator/calculator-animation-test.component.html b/frontend/src/app/features/calculator/calculator-animation-test.component.html new file mode 100644 index 0000000..9b040e2 --- /dev/null +++ b/frontend/src/app/features/calculator/calculator-animation-test.component.html @@ -0,0 +1,31 @@ +
+
+ + +
+ +
+ +
+
diff --git a/frontend/src/app/features/calculator/calculator-animation-test.component.scss b/frontend/src/app/features/calculator/calculator-animation-test.component.scss new file mode 100644 index 0000000..e669d62 --- /dev/null +++ b/frontend/src/app/features/calculator/calculator-animation-test.component.scss @@ -0,0 +1,60 @@ +:host { + display: block; +} + +.animation-test-page { + min-height: 100vh; + display: grid; + align-content: center; + justify-items: center; + gap: 1.5rem; + padding: 2rem 1.5rem 3rem; + background: #fff; +} + +.animation-toolbar { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem; + border: 1px solid rgba(16, 24, 32, 0.12); + border-radius: 999px; + background: #f7f5ef; +} + +.variant-toggle { + min-height: 2.4rem; + padding: 0 1rem; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--color-text-muted); + font: inherit; + font-weight: 600; + cursor: pointer; + transition: + background-color 0.18s ease, + color 0.18s ease, + box-shadow 0.18s ease; +} + +.variant-toggle.active { + background: #fff; + color: var(--color-text); + box-shadow: 0 6px 16px rgba(16, 24, 32, 0.08); +} + +.animation-stage { + width: min(100%, 26rem); +} + +@media (max-width: 640px) { + .animation-toolbar { + flex-wrap: wrap; + justify-content: center; + } + + .animation-stage { + width: min(100%, 19rem); + } +} diff --git a/frontend/src/app/features/calculator/calculator-animation-test.component.ts b/frontend/src/app/features/calculator/calculator-animation-test.component.ts new file mode 100644 index 0000000..23379eb --- /dev/null +++ b/frontend/src/app/features/calculator/calculator-animation-test.component.ts @@ -0,0 +1,21 @@ +import { CommonModule } from '@angular/common'; +import { Component, signal } from '@angular/core'; +import { + BrandAnimationLogoComponent, + BrandAnimationVariant, +} from '../../shared/components/brand-animation-logo/brand-animation-logo.component'; + +@Component({ + selector: 'app-calculator-animation-test', + standalone: true, + imports: [CommonModule, BrandAnimationLogoComponent], + templateUrl: './calculator-animation-test.component.html', + styleUrl: './calculator-animation-test.component.scss', +}) +export class CalculatorAnimationTestComponent { + readonly variant = signal('site-intro'); + + setVariant(variant: BrandAnimationVariant): void { + this.variant.set(variant); + } +} diff --git a/frontend/src/app/features/calculator/calculator-page.component.html b/frontend/src/app/features/calculator/calculator-page.component.html index 9f3ca0f..03ea450 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.html +++ b/frontend/src/app/features/calculator/calculator-page.component.html @@ -57,7 +57,10 @@ @if (loading()) {
-
+

{{ "CALC.ANALYZING_TITLE" | translate }}

diff --git a/frontend/src/app/features/calculator/calculator-page.component.scss b/frontend/src/app/features/calculator/calculator-page.component.scss index 2d5bae9..791df8e 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.scss +++ b/frontend/src/app/features/calculator/calculator-page.component.scss @@ -93,7 +93,7 @@ .loader-content { text-align: center; - max-width: 300px; + max-width: 22rem; margin: 0 auto; /* Center content vertically within the stretched card */ @@ -101,12 +101,14 @@ display: flex; flex-direction: column; justify-content: center; + align-items: center; + gap: var(--space-3); } .loading-title { font-size: 1.1rem; font-weight: 600; - margin: var(--space-4) 0 var(--space-2); + margin: 0; color: var(--color-text); } @@ -114,23 +116,21 @@ font-size: 0.9rem; color: var(--color-text-muted); line-height: 1.5; + margin: 0; } -.spinner { - border: 3px solid var(--color-neutral-200); - border-left-color: var(--color-brand); - border-radius: 50%; - width: 48px; - height: 48px; - animation: spin 1s linear infinite; +.loader-logo { + display: block; + width: min(100%, 16rem); margin: 0 auto; -} - -@keyframes spin { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } + --brand-animation-width: 16rem; + --brand-animation-height: 4.8rem; + --brand-animation-letter-width: 2.85rem; + --brand-animation-scale: 0.84; + --brand-animation-word-spacing: 0.97; + --brand-animation-width-mobile: 14rem; + --brand-animation-height-mobile: 4.1rem; + --brand-animation-letter-width-mobile: 2.45rem; + --brand-animation-scale-mobile: 0.84; + --brand-animation-loader-loop-duration: 2.5s; } diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts index 992bbe6..0904f78 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.ts @@ -17,6 +17,7 @@ import { catchError, map } from 'rxjs/operators'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component'; import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; +import { BrandAnimationLogoComponent } from '../../shared/components/brand-animation-logo/brand-animation-logo.component'; import { UploadFormComponent } from './components/upload-form/upload-form.component'; import { QuoteResultComponent } from './components/quote-result/quote-result.component'; import { @@ -48,6 +49,7 @@ type TrackedPrintSettings = { AppCardComponent, AppAlertComponent, AppButtonComponent, + BrandAnimationLogoComponent, UploadFormComponent, QuoteResultComponent, SuccessStateComponent, diff --git a/frontend/src/app/features/calculator/calculator.routes.ts b/frontend/src/app/features/calculator/calculator.routes.ts index c9696d2..93aceee 100644 --- a/frontend/src/app/features/calculator/calculator.routes.ts +++ b/frontend/src/app/features/calculator/calculator.routes.ts @@ -3,6 +3,18 @@ import { CalculatorPageComponent } from './calculator-page.component'; export const CALCULATOR_ROUTES: Routes = [ { path: '', redirectTo: 'basic', pathMatch: 'full' }, + { + path: 'animation-test', + loadComponent: () => + import('./calculator-animation-test.component').then( + (m) => m.CalculatorAnimationTestComponent, + ), + data: { + seoTitleKey: 'SEO.ROUTES.CALCULATOR.TITLE', + seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.DESCRIPTION', + seoRobots: 'noindex, nofollow', + }, + }, { path: 'basic', component: CalculatorPageComponent, diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts index 210406a..24f2256 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts @@ -127,7 +127,8 @@ export class UploadFormComponent implements OnInit { private layerHeightsByNozzle: Record = {}; private isPatchingSettings = false; - acceptedFormats = '.stl,.3mf,.step,.stp'; + acceptedFormats = '.stl,.3mf'; + private readonly allowedExtensions = ['stl', '3mf'] as const; constructor() { this.form = this.fb.group({ @@ -286,6 +287,13 @@ export class UploadFormComponent implements OnInit { return name.endsWith('.stl'); } + isSupportedFile(file: File | null): boolean { + if (!file) return false; + + const name = file.name.toLowerCase().trim(); + return this.allowedExtensions.some((ext) => name.endsWith(`.${ext}`)); + } + canPreviewSelectedFile(): boolean { return this.isStlFile(this.getSelectedPreviewFile()); } @@ -340,13 +348,19 @@ export class UploadFormComponent implements OnInit { onFilesDropped(newFiles: File[]) { const MAX_SIZE = 200 * 1024 * 1024; const validItems: FormItem[] = []; - let hasError = false; + let hasInvalidType = false; + let hasOversize = false; const defaults = this.getCurrentGlobalItemDefaults(); for (const file of newFiles) { + if (!this.isSupportedFile(file)) { + hasInvalidType = true; + continue; + } + if (file.size > MAX_SIZE) { - hasError = true; + hasOversize = true; continue; } @@ -367,7 +381,11 @@ export class UploadFormComponent implements OnInit { }); } - if (hasError) { + if (hasInvalidType) { + alert(this.translate.instant('CALC.ERR_INVALID_FILE_TYPE')); + } + + if (hasOversize) { alert(this.translate.instant('CALC.ERR_FILE_TOO_LARGE')); } diff --git a/frontend/src/app/features/shop/components/product-card/product-card.component.html b/frontend/src/app/features/shop/components/product-card/product-card.component.html index 9dda995..1b9d788 100644 --- a/frontend/src/app/features/shop/components/product-card/product-card.component.html +++ b/frontend/src/app/features/shop/components/product-card/product-card.component.html @@ -1,5 +1,10 @@
- + @if (imageUrl(); as imageUrl) {

- {{ - product().name - }} + {{ product().name }}

@@ -62,6 +70,7 @@ {{ "SHOP.DETAILS" | translate }} diff --git a/frontend/src/app/features/shop/components/product-card/product-card.component.ts b/frontend/src/app/features/shop/components/product-card/product-card.component.ts index 81e1b65..7b73182 100644 --- a/frontend/src/app/features/shop/components/product-card/product-card.component.ts +++ b/frontend/src/app/features/shop/components/product-card/product-card.component.ts @@ -74,4 +74,16 @@ export class ProductCardComponent { shopReturnUrl: this.router.url, }; } + + rememberCatalogScroll(): void { + if (typeof window === 'undefined') { + return; + } + + const nextState = { + ...(history.state ?? {}), + shopRestoreScrollY: Math.max(0, Math.round(window.scrollY)), + }; + history.replaceState(nextState, ''); + } } diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts index 0ee935b..0f023c3 100644 --- a/frontend/src/app/features/shop/product-detail.component.ts +++ b/frontend/src/app/features/shop/product-detail.component.ts @@ -1,4 +1,4 @@ -import { CommonModule, isPlatformBrowser } from '@angular/common'; +import { CommonModule, Location, isPlatformBrowser } from '@angular/common'; import { afterNextRender, Component, @@ -59,6 +59,7 @@ export class ProductDetailComponent { /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; private readonly destroyRef = inject(DestroyRef); private readonly injector = inject(Injector); + private readonly location = inject(Location); private readonly router = inject(Router); private readonly translate = inject(TranslateService); private readonly seoService = inject(SeoService); @@ -489,6 +490,11 @@ export class ProductDetailComponent { : null; if (returnUrl && this.shopRouteService.isCatalogUrl(returnUrl)) { + if (this.isBrowser && window.history.length > 1) { + this.location.back(); + return; + } + void this.router.navigateByUrl(returnUrl); return; } diff --git a/frontend/src/app/features/shop/shop-page.component.scss b/frontend/src/app/features/shop/shop-page.component.scss index 2182f7b..4ee4b32 100644 --- a/frontend/src/app/features/shop/shop-page.component.scss +++ b/frontend/src/app/features/shop/shop-page.component.scss @@ -332,6 +332,10 @@ } @media (max-width: 760px) { + .cart-card { + display: none; + } + .product-grid { grid-template-columns: 1fr; } diff --git a/frontend/src/app/features/shop/shop-page.component.ts b/frontend/src/app/features/shop/shop-page.component.ts index d8e4e32..5fac9b2 100644 --- a/frontend/src/app/features/shop/shop-page.component.ts +++ b/frontend/src/app/features/shop/shop-page.component.ts @@ -141,6 +141,7 @@ export class ShopPageComponent { this.selectedCategory.set(result.catalog.category ?? null); this.products.set(result.catalog.products); this.applySeo(result.catalog.category ?? null); + this.restoreCatalogScrollIfNeeded(); }); } @@ -353,4 +354,24 @@ export class ShopPageComponent { ogDescription: description, }); } + + private restoreCatalogScrollIfNeeded(): void { + if (typeof window === 'undefined') { + return; + } + + const scrollY = Number(history.state?.shopRestoreScrollY); + if (!Number.isFinite(scrollY) || scrollY < 0) { + return; + } + + const { shopRestoreScrollY: _ignored, ...nextState } = history.state ?? {}; + const restore = () => window.scrollTo({ left: 0, top: scrollY }); + + history.replaceState(nextState, ''); + window.requestAnimationFrame(() => { + restore(); + window.setTimeout(restore, 60); + }); + } } diff --git a/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.ts b/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.ts index a04f704..79decb9 100644 --- a/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.ts +++ b/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.ts @@ -12,7 +12,7 @@ import { TranslateModule } from '@ngx-translate/core'; export class AppDropzoneComponent { label = input('DROPZONE.DEFAULT_LABEL'); subtext = input('DROPZONE.DEFAULT_SUBTEXT'); - accept = input('.stl,.3mf,.step,.stp'); + accept = input('.stl,.3mf'); multiple = input(true); filesDropped = output(); diff --git a/frontend/src/app/shared/components/brand-animation-logo/brand-animation-logo.component.html b/frontend/src/app/shared/components/brand-animation-logo/brand-animation-logo.component.html new file mode 100644 index 0000000..07820ab --- /dev/null +++ b/frontend/src/app/shared/components/brand-animation-logo/brand-animation-logo.component.html @@ -0,0 +1,17 @@ +

diff --git a/frontend/src/app/shared/components/brand-animation-logo/brand-animation-logo.component.scss b/frontend/src/app/shared/components/brand-animation-logo/brand-animation-logo.component.scss new file mode 100644 index 0000000..218bdb2 --- /dev/null +++ b/frontend/src/app/shared/components/brand-animation-logo/brand-animation-logo.component.scss @@ -0,0 +1,221 @@ +:host { + display: block; + width: 100%; +} + +.brand-animation { + --three-anchor-x: -9.4rem; + --bee-anchor-x: 10.2rem; + --word-scale: var(--brand-animation-scale, 1); + --word-spacing-factor: var(--brand-animation-word-spacing, 1); + --loader-pull-scale-x: 0.9; + --loader-pull-scale-y: 1.08; + --loader-release-scale-x: 1.04; + --loader-release-scale-y: 0.97; + --loader-overshoot-left: 0.58rem; + --loader-overshoot-right: -0.54rem; + position: relative; + width: min(100%, var(--brand-animation-width, 26rem)); + height: var(--brand-animation-height, 8rem); + margin-inline: auto; +} + +.brand-animation__letter { + --word-x: 0rem; + position: absolute; + top: 50%; + left: 50%; + width: var(--brand-animation-letter-width, clamp(2.7rem, 6vw, 4rem)); + height: auto; + transform: translate(-50%, -50%); + transform-origin: center center; + will-change: transform; +} + +.brand-animation[data-variant='site-intro'] .brand-animation__letter { + animation: site-intro-preview var(--brand-animation-site-intro-duration, 1s) linear 1 forwards; +} + +.brand-animation[data-variant='calculator-loader'] .brand-animation__letter { + animation: calculator-loader-loop + var(--brand-animation-loader-loop-duration, 2.5s) + infinite; +} + +.brand-animation[data-variant='calculator-loader'] + .brand-animation__letter[data-letter='3'] { + --loader-overshoot-left: 0.18rem; + --loader-overshoot-right: -0.3rem; +} + +.brand-animation[data-variant='calculator-loader'] + .brand-animation__letter[data-letter='d'] { + --loader-overshoot-left: 0.42rem; + --loader-overshoot-right: -0.4rem; +} + +.brand-animation[data-variant='calculator-loader'] + .brand-animation__letter[data-letter='F'] { + --loader-overshoot-left: 0.66rem; + --loader-overshoot-right: -0.62rem; +} + +.brand-animation[data-variant='calculator-loader'] + .brand-animation__letter[data-letter='A'] { + --loader-overshoot-left: 0.52rem; + --loader-overshoot-right: -0.46rem; +} + +.brand-animation[data-variant='calculator-loader'] + .brand-animation__letter[data-letter='B'] { + --loader-overshoot-left: 0.28rem; + --loader-overshoot-right: -0.16rem; +} + +@keyframes site-intro-preview { + 0% { + transform: translate(-50%, -50%) translateX(0) scale(0.92); + } + + 20% { + transform: translate(-50%, -50%) translateX(0) scale(0.92); + } + + 80% { + transform: translate(-50%, -50%) + translateX( + calc(var(--word-x) * var(--word-scale) * var(--word-spacing-factor)) + ) + scale(1); + } + + 100% { + transform: translate(-50%, -50%) + translateX( + calc(var(--word-x) * var(--word-scale) * var(--word-spacing-factor)) + ) + scale(1); + } +} + +@keyframes calculator-loader-loop { + 0%, + 16% { + transform: translate(-50%, -50%) + translateX( + calc(var(--word-x) * var(--word-scale) * var(--word-spacing-factor)) + ) + scale(1); + } + + 16% { + animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); + } + + 27% { + transform: translate(-50%, -50%) + translateX( + calc( + var(--three-anchor-x) * var(--word-scale) * var(--word-spacing-factor) + ) + ) + scaleX(var(--loader-pull-scale-x)) + scaleY(var(--loader-pull-scale-y)); + } + + 27% { + animation-timing-function: cubic-bezier(0.18, 1.15, 0.32, 1); + } + + 39% { + transform: translate(-50%, -50%) + translateX( + calc( + (var(--word-x) * var(--word-scale) * var(--word-spacing-factor)) + + var(--loader-overshoot-left) + ) + ) + scaleX(var(--loader-release-scale-x)) + scaleY(var(--loader-release-scale-y)); + } + + 39% { + animation-timing-function: cubic-bezier(0.2, 0.85, 0.26, 1); + } + + 50%, + 60% { + transform: translate(-50%, -50%) + translateX( + calc(var(--word-x) * var(--word-scale) * var(--word-spacing-factor)) + ) + scale(1); + } + + 60% { + animation-timing-function: cubic-bezier(0.3, 0, 0.7, 1); + } + + 71% { + transform: translate(-50%, -50%) + translateX( + calc( + var(--bee-anchor-x) * var(--word-scale) * var(--word-spacing-factor) + ) + ) + scaleX(var(--loader-pull-scale-x)) + scaleY(var(--loader-pull-scale-y)); + } + + 71% { + animation-timing-function: cubic-bezier(0.18, 1.15, 0.32, 1); + } + + 83% { + transform: translate(-50%, -50%) + translateX( + calc( + (var(--word-x) * var(--word-scale) * var(--word-spacing-factor)) + + var(--loader-overshoot-right) + ) + ) + scaleX(var(--loader-release-scale-x)) + scaleY(var(--loader-release-scale-y)); + } + + 83% { + animation-timing-function: cubic-bezier(0.2, 0.85, 0.26, 1); + } + + 92%, + 100% { + transform: translate(-50%, -50%) + translateX( + calc(var(--word-x) * var(--word-scale) * var(--word-spacing-factor)) + ) + scale(1); + } +} + +@media (max-width: 640px) { + .brand-animation { + width: min(100%, var(--brand-animation-width-mobile, 19rem)); + height: var(--brand-animation-height-mobile, 6rem); + --word-scale: var(--brand-animation-scale-mobile, 0.74); + } + + .brand-animation__letter { + width: var(--brand-animation-letter-width-mobile, 2.8rem); + } +} + +@media (prefers-reduced-motion: reduce) { + .brand-animation__letter { + animation: none !important; + transform: translate(-50%, -50%) + translateX( + calc(var(--word-x) * var(--word-scale) * var(--word-spacing-factor)) + ) + scale(1); + } +} diff --git a/frontend/src/app/shared/components/brand-animation-logo/brand-animation-logo.component.ts b/frontend/src/app/shared/components/brand-animation-logo/brand-animation-logo.component.ts new file mode 100644 index 0000000..94112c9 --- /dev/null +++ b/frontend/src/app/shared/components/brand-animation-logo/brand-animation-logo.component.ts @@ -0,0 +1,70 @@ +import { Component, computed, input } from '@angular/core'; + +export type BrandAnimationVariant = 'site-intro' | 'calculator-loader'; + +interface AnimationLetter { + key: string; + darkSrc: string; + yellowSrc: string; + wordX: string; +} + +interface ResolvedAnimationLetter { + key: string; + src: string; + wordX: string; +} + +const LETTERS: readonly AnimationLetter[] = [ + { + key: '3', + darkSrc: '/assets/images/animation/31200.svg', + yellowSrc: '/assets/images/animation/3g1200.svg', + wordX: '-9.4rem', + }, + { + key: 'd', + darkSrc: '/assets/images/animation/d1200.svg', + yellowSrc: '/assets/images/animation/Dg1200.svg', + wordX: '-4.9rem', + }, + { + key: 'F', + darkSrc: '/assets/images/animation/F1200.svg', + yellowSrc: '/assets/images/animation/Fg1200.svg', + wordX: '1rem', + }, + { + key: 'A', + darkSrc: '/assets/images/animation/A1200.svg', + yellowSrc: '/assets/images/animation/Ag1200.svg', + wordX: '5.6rem', + }, + { + key: 'B', + darkSrc: '/assets/images/animation/B1200.svg', + yellowSrc: '/assets/images/animation/Bg1200.svg', + wordX: '10.2rem', + }, +] as const; + +@Component({ + selector: 'app-brand-animation-logo', + standalone: true, + templateUrl: './brand-animation-logo.component.html', + styleUrl: './brand-animation-logo.component.scss', +}) +export class BrandAnimationLogoComponent { + readonly variant = input('site-intro'); + readonly decorative = input(true); + readonly ariaLabel = input('3D fab animated logo'); + + readonly resolvedLetters = computed(() => + LETTERS.map((letter) => ({ + key: letter.key, + src: + this.variant() === 'site-intro' ? letter.yellowSrc : letter.darkSrc, + wordX: letter.wordX, + })), + ); +} diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index 38792a7..2e207c1 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -107,14 +107,14 @@ }, "CALC": { "TITLE": "3D-Angebot berechnen", - "SUBTITLE": "Laden Sie Ihre 3D-Datei (STL, 3MF, STEP) hoch, stellen Sie Qualität und Farbe ein und berechnen Sie sofort Preis und Lieferzeit.", + "SUBTITLE": "Laden Sie Ihre 3D-Datei (STL, 3MF) hoch, stellen Sie Qualität und Farbe ein und berechnen Sie sofort Preis und Lieferzeit.", "CTA_START": "Jetzt starten", "BUSINESS": "Unternehmen", "PRIVATE": "Privat", "MODE_EASY": "Basis", "MODE_ADVANCED": "Erweitert", "UPLOAD_LABEL": "Ziehen Sie Ihre 3D-Datei hierher", - "UPLOAD_SUB": "Wir unterstützen STL, 3MF, STEP bis 50MB", + "UPLOAD_SUB": "Wir unterstützen STL, 3MF bis 50MB", "MATERIAL": "Material", "QUALITY": "Qualität", "QUANTITY": "Menge", @@ -141,11 +141,12 @@ "BENEFITS_2": "Ausgewählte Materialien und Qualitätskontrolle", "BENEFITS_3": "CAD-Beratung, falls die Datei Änderungen benötigt", "ERR_FILE_REQUIRED": "Die Datei ist erforderlich.", - "STEP_WARNING": "Die 3D-Ansicht ist mit STEP- und 3MF-Dateien nicht kompatibel", + "STEP_WARNING": "Die 3D-Vorschau ist nur für STL-Dateien verfügbar.", "REMOVE_FILE": "Datei entfernen", "FALLBACK_MATERIAL": "PLA (Fallback)", "FALLBACK_QUALITY_STANDARD": "Standard", "ERR_FILE_TOO_LARGE": "Einige Dateien überschreiten das 200MB-Limit und wurden nicht hinzugefügt.", + "ERR_INVALID_FILE_TYPE": "Sie können nur Dateien vom Typ .stl oder .3mf hochladen.", "PRINT_SPEED": "Druckgeschwindigkeit", "COLOR": "Farbe", "ANALYZING_TITLE": "Analyse läuft...", @@ -624,7 +625,7 @@ "BTN_CONTACT": "Mit uns sprechen", "SEC_CALC_TITLE": "Korrekte Preisberechnung in wenigen Sekunden", "SEC_CALC_SUBTITLE": "Keine Registrierung erforderlich. Die Schätzung basiert auf echtem Slicing.", - "SEC_CALC_LIST_1": "Unterstützte Formate: STL, 3MF, STEP", + "SEC_CALC_LIST_1": "Unterstützte Formate: STL, 3MF", "CARD_CALC_EYEBROW": "Automatische Berechnung", "CARD_CALC_TITLE": "Preis und Lieferzeit mit einem Klick", "CARD_CALC_TAG": "Ohne Registrierung", @@ -674,7 +675,7 @@ }, "DROPZONE": { "DEFAULT_LABEL": "Dateien hierher ziehen oder klicken zum Hochladen", - "DEFAULT_SUBTEXT": "Unterstützt .STL, .3MF, .STEP" + "DEFAULT_SUBTEXT": "Unterstützt .STL, .3MF" }, "COLOR": { "AVAILABLE_COLORS": "Verfügbare Farben", diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index b447eac..ad023ec 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -107,14 +107,14 @@ }, "CALC": { "TITLE": "3D Print Calculator", - "SUBTITLE": "Upload your 3D file (STL, 3MF, STEP...) and get an instant estimate of costs and print time.", + "SUBTITLE": "Upload your 3D file (STL, 3MF) and get an instant estimate of costs and print time.", "CTA_START": "Start Now", "BUSINESS": "Business", "PRIVATE": "Private", "MODE_EASY": "Quick", "MODE_ADVANCED": "Advanced", "UPLOAD_LABEL": "Drag your 3D file here", - "UPLOAD_SUB": "Supports STL, 3MF, STEP up to 50MB", + "UPLOAD_SUB": "Supports STL, 3MF up to 50MB", "MATERIAL": "Material", "QUALITY": "Quality", "QUANTITY": "Quantity", @@ -141,11 +141,12 @@ "BENEFITS_2": "Selected materials and quality control", "BENEFITS_3": "CAD consultation if file needs modifications", "ERR_FILE_REQUIRED": "File is required.", - "STEP_WARNING": "3D preview is not available for STEP files, but the calculator works perfectly. You can proceed with the quotation.", + "STEP_WARNING": "3D preview is available only for STL files.", "REMOVE_FILE": "Remove file", "FALLBACK_MATERIAL": "PLA (fallback)", "FALLBACK_QUALITY_STANDARD": "Standard", "ERR_FILE_TOO_LARGE": "Some files exceed the 200MB limit and were not added.", + "ERR_INVALID_FILE_TYPE": "You can upload only .stl or .3mf files.", "PRINT_SPEED": "Print speed", "COLOR": "Color", "ANALYZING_TITLE": "Analysis in progress...", @@ -624,7 +625,7 @@ "BTN_CONTACT": "Talk to us", "SEC_CALC_TITLE": "Accurate pricing in a few seconds", "SEC_CALC_SUBTITLE": "No registration required. The estimate is calculated through real slicing.", - "SEC_CALC_LIST_1": "Supported formats: STL, 3MF, STEP", + "SEC_CALC_LIST_1": "Supported formats: STL, 3MF", "CARD_CALC_EYEBROW": "Automatic calculation", "CARD_CALC_TITLE": "Price and lead time in one click", "CARD_CALC_TAG": "No registration", @@ -674,7 +675,7 @@ }, "DROPZONE": { "DEFAULT_LABEL": "Drop files here or click to upload", - "DEFAULT_SUBTEXT": "Supports .stl, .3mf, .step" + "DEFAULT_SUBTEXT": "Supports .stl, .3mf" }, "COLOR": { "AVAILABLE_COLORS": "Available colors", diff --git a/frontend/src/assets/i18n/fr.json b/frontend/src/assets/i18n/fr.json index 49b9d4e..7d3d95e 100644 --- a/frontend/src/assets/i18n/fr.json +++ b/frontend/src/assets/i18n/fr.json @@ -96,7 +96,7 @@ "BTN_CONTACT": "Parlez avec nous", "SEC_CALC_TITLE": "Prix correct en quelques secondes", "SEC_CALC_SUBTITLE": "Aucune inscription requise. L'estimation est effectuée via un vrai slicing.", - "SEC_CALC_LIST_1": "Formats pris en charge : STL, 3MF, STEP", + "SEC_CALC_LIST_1": "Formats pris en charge : STL, 3MF", "CARD_CALC_EYEBROW": "Calcul automatique", "CARD_CALC_TITLE": "Prix et délais en un clic", "CARD_CALC_TAG": "Sans inscription", @@ -139,14 +139,14 @@ }, "CALC": { "TITLE": "Calculer un devis 3D", - "SUBTITLE": "Chargez votre fichier 3D (STL, 3MF, STEP), réglez la qualité et la couleur puis calculez immédiatement prix et délais.", + "SUBTITLE": "Chargez votre fichier 3D (STL, 3MF), réglez la qualité et la couleur puis calculez immédiatement prix et délais.", "CTA_START": "Commencer maintenant", "BUSINESS": "Entreprises", "PRIVATE": "Particuliers", "MODE_EASY": "Base", "MODE_ADVANCED": "Avancée", "UPLOAD_LABEL": "Glissez votre fichier 3D ici", - "UPLOAD_SUB": "Nous prenons en charge STL, 3MF, STEP jusqu'à 50MB", + "UPLOAD_SUB": "Nous prenons en charge STL, 3MF jusqu'à 50MB", "MATERIAL": "Matériau", "QUALITY": "Qualité", "PRINT_SPEED": "Vitesse d'impression", @@ -185,11 +185,12 @@ "NOTES_PLACEHOLDER": "Instructions spécifiques...", "SETUP_NOTE": "* Inclut {{cost}} comme coût de setup", "SHIPPING_NOTE": "* Frais d'expédition exclus, calculés à l'étape suivante", - "STEP_WARNING": "La visualisation 3D n'est pas compatible avec les fichiers STEP et 3MF", + "STEP_WARNING": "La prévisualisation 3D est disponible uniquement pour les fichiers STL.", "REMOVE_FILE": "Supprimer le fichier", "FALLBACK_MATERIAL": "PLA (fallback)", "FALLBACK_QUALITY_STANDARD": "Standard", "ERR_FILE_TOO_LARGE": "Certains fichiers dépassent la limite de 200MB et n'ont pas été ajoutés.", + "ERR_INVALID_FILE_TYPE": "Vous pouvez téléverser uniquement des fichiers .stl ou .3mf.", "ERROR_ZERO_PRICE": "Un problème est survenu pendant le calcul. Essayez un autre format ou contactez-nous directement via Demander une consultation.", "ZERO_RESULT_TITLE": "Résultat invalide", "ZERO_RESULT_HELP": "Le calcul a renvoyé des valeurs nulles invalides. Essayez un autre format de fichier ou contactez-nous directement via Demander une consultation." @@ -680,7 +681,7 @@ }, "DROPZONE": { "DEFAULT_LABEL": "Glissez les fichiers ici ou cliquez pour téléverser", - "DEFAULT_SUBTEXT": "Prend en charge .STL, .3MF, .STEP" + "DEFAULT_SUBTEXT": "Prend en charge .STL, .3MF" }, "COLOR": { "AVAILABLE_COLORS": "Couleurs disponibles", diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index 371531a..f1296cd 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -96,7 +96,7 @@ "BTN_CONTACT": "Parla con noi", "SEC_CALC_TITLE": "Prezzo corretto in pochi secondi", "SEC_CALC_SUBTITLE": "Nessuna registrazione necessaria. La stima è effettuata tramite vero slicing.", - "SEC_CALC_LIST_1": "Formati supportati: STL, 3MF, STEP", + "SEC_CALC_LIST_1": "Formati supportati: STL, 3MF", "CARD_CALC_EYEBROW": "Calcolo automatico", "CARD_CALC_TITLE": "Prezzo e tempi in un click", "CARD_CALC_TAG": "Senza registrazione", @@ -139,14 +139,14 @@ }, "CALC": { "TITLE": "Calcola Preventivo 3D", - "SUBTITLE": "Carica il tuo file 3D (STL, 3MF, STEP), imposta la qualità, il colore e calcola immediatamente prezzo e tempi.", + "SUBTITLE": "Carica il tuo file 3D (STL, 3MF), imposta la qualità, il colore e calcola immediatamente prezzo e tempi.", "CTA_START": "Inizia Ora", "BUSINESS": "Aziende", "PRIVATE": "Privati", "MODE_EASY": "Base", "MODE_ADVANCED": "Avanzata", "UPLOAD_LABEL": "Trascina il tuo file 3D qui", - "UPLOAD_SUB": "Supportiamo STL, 3MF, STEP fino a 50MB", + "UPLOAD_SUB": "Supportiamo STL, 3MF fino a 50MB", "MATERIAL": "Materiale", "QUALITY": "Qualità", "PRINT_SPEED": "Velocità di Stampa", @@ -185,11 +185,12 @@ "NOTES_PLACEHOLDER": "Istruzioni specifiche...", "SETUP_NOTE": "* Include {{cost}} come costo di setup", "SHIPPING_NOTE": "* Costi di spedizione esclusi, calcolati al passaggio successivo", - "STEP_WARNING": "La visualizzazione 3D non è compatibile con i file step e 3mf", + "STEP_WARNING": "La visualizzazione 3D è disponibile solo per i file STL", "REMOVE_FILE": "Rimuovi file", "FALLBACK_MATERIAL": "PLA (fallback)", "FALLBACK_QUALITY_STANDARD": "Standard", "ERR_FILE_TOO_LARGE": "Alcuni file superano il limite di 200MB e non sono stati aggiunti.", + "ERR_INVALID_FILE_TYPE": "Puoi caricare solo file .stl o .3mf.", "ERROR_ZERO_PRICE": "Qualcosa è andato storto nel calcolo. Prova un altro formato o contattaci direttamente con Richiedi Consulenza.", "ZERO_RESULT_TITLE": "Risultato non valido", "ZERO_RESULT_HELP": "Il calcolo ha restituito valori non validi (0). Prova con un altro formato file oppure contattaci direttamente con Richiedi Consulenza." @@ -680,7 +681,7 @@ }, "DROPZONE": { "DEFAULT_LABEL": "Trascina i file qui o clicca per caricare", - "DEFAULT_SUBTEXT": "Supporta .STL, .3MF, .STEP" + "DEFAULT_SUBTEXT": "Supporta .STL, .3MF" }, "COLOR": { "AVAILABLE_COLORS": "Colori disponibili", diff --git a/frontend/src/assets/images/Fav-icon-192x192.png b/frontend/src/assets/images/Fav-icon-192x192.png new file mode 100644 index 0000000..0e74c58 Binary files /dev/null and b/frontend/src/assets/images/Fav-icon-192x192.png differ diff --git a/frontend/src/assets/images/Fav-icon-512x512.png b/frontend/src/assets/images/Fav-icon-512x512.png new file mode 100644 index 0000000..a8e2196 Binary files /dev/null and b/frontend/src/assets/images/Fav-icon-512x512.png differ diff --git a/frontend/src/assets/images/Fav-icon-browser-192x192.png b/frontend/src/assets/images/Fav-icon-browser-192x192.png new file mode 100644 index 0000000..ffccd8b Binary files /dev/null and b/frontend/src/assets/images/Fav-icon-browser-192x192.png differ diff --git a/frontend/src/assets/images/Fav-icon.png b/frontend/src/assets/images/Fav-icon.png deleted file mode 100644 index ff6550e..0000000 Binary files a/frontend/src/assets/images/Fav-icon.png and /dev/null differ diff --git a/frontend/src/assets/images/animation/31200.svg b/frontend/src/assets/images/animation/31200.svg new file mode 100644 index 0000000..520d6a8 --- /dev/null +++ b/frontend/src/assets/images/animation/31200.svg @@ -0,0 +1,207 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/images/animation/3g1200.svg b/frontend/src/assets/images/animation/3g1200.svg new file mode 100644 index 0000000..a0ebb46 --- /dev/null +++ b/frontend/src/assets/images/animation/3g1200.svg @@ -0,0 +1,214 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/images/animation/A1200.svg b/frontend/src/assets/images/animation/A1200.svg new file mode 100644 index 0000000..4596526 --- /dev/null +++ b/frontend/src/assets/images/animation/A1200.svg @@ -0,0 +1,225 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/images/animation/Ag1200.svg b/frontend/src/assets/images/animation/Ag1200.svg new file mode 100644 index 0000000..d752b33 --- /dev/null +++ b/frontend/src/assets/images/animation/Ag1200.svg @@ -0,0 +1,236 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/images/animation/B1200.svg b/frontend/src/assets/images/animation/B1200.svg new file mode 100644 index 0000000..3d05a26 --- /dev/null +++ b/frontend/src/assets/images/animation/B1200.svg @@ -0,0 +1,232 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/images/animation/Bg1200.svg b/frontend/src/assets/images/animation/Bg1200.svg new file mode 100644 index 0000000..1212b4a --- /dev/null +++ b/frontend/src/assets/images/animation/Bg1200.svg @@ -0,0 +1,243 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/images/animation/Dg1200.svg b/frontend/src/assets/images/animation/Dg1200.svg new file mode 100644 index 0000000..1e13051 --- /dev/null +++ b/frontend/src/assets/images/animation/Dg1200.svg @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/images/animation/F1200.svg b/frontend/src/assets/images/animation/F1200.svg new file mode 100644 index 0000000..e7eaa00 --- /dev/null +++ b/frontend/src/assets/images/animation/F1200.svg @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/images/animation/Fg1200.svg b/frontend/src/assets/images/animation/Fg1200.svg new file mode 100644 index 0000000..43e1197 --- /dev/null +++ b/frontend/src/assets/images/animation/Fg1200.svg @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/images/animation/d1200.svg b/frontend/src/assets/images/animation/d1200.svg new file mode 100644 index 0000000..58177de --- /dev/null +++ b/frontend/src/assets/images/animation/d1200.svg @@ -0,0 +1,241 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/assets/images/home/cad.jpg b/frontend/src/assets/images/home/cad.jpg deleted file mode 100644 index 1c5dfe2..0000000 Binary files a/frontend/src/assets/images/home/cad.jpg and /dev/null differ diff --git a/frontend/src/assets/images/home/da-cambiare.jpg b/frontend/src/assets/images/home/da-cambiare.jpg deleted file mode 100644 index b0eb618..0000000 Binary files a/frontend/src/assets/images/home/da-cambiare.jpg and /dev/null differ diff --git a/frontend/src/assets/images/home/original-vs-3dprinted.jpg b/frontend/src/assets/images/home/original-vs-3dprinted.jpg deleted file mode 100644 index a1230e0..0000000 Binary files a/frontend/src/assets/images/home/original-vs-3dprinted.jpg and /dev/null differ diff --git a/frontend/src/assets/images/home/prototipi.jpg b/frontend/src/assets/images/home/prototipi.jpg deleted file mode 100644 index 3efc5d7..0000000 Binary files a/frontend/src/assets/images/home/prototipi.jpg and /dev/null differ diff --git a/frontend/src/assets/images/home/serie.jpg b/frontend/src/assets/images/home/serie.jpg deleted file mode 100644 index 668b3b6..0000000 Binary files a/frontend/src/assets/images/home/serie.jpg and /dev/null differ diff --git a/frontend/src/assets/images/home/supporto-bici.jpg b/frontend/src/assets/images/home/supporto-bici.jpg deleted file mode 100644 index 71ba26e..0000000 Binary files a/frontend/src/assets/images/home/supporto-bici.jpg and /dev/null differ diff --git a/frontend/src/assets/images/home/vino.JPG b/frontend/src/assets/images/home/vino.JPG deleted file mode 100644 index eec2da5..0000000 Binary files a/frontend/src/assets/images/home/vino.JPG and /dev/null differ diff --git a/frontend/src/index.html b/frontend/src/index.html index d2aabc3..ba17b06 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -10,7 +10,26 @@ - + + + + + +