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/dto/ShopProductDetailDto.java b/backend/src/main/java/com/printcalculator/dto/ShopProductDetailDto.java index 265cb8b..ba205a4 100644 --- a/backend/src/main/java/com/printcalculator/dto/ShopProductDetailDto.java +++ b/backend/src/main/java/com/printcalculator/dto/ShopProductDetailDto.java @@ -2,6 +2,7 @@ package com.printcalculator.dto; import java.math.BigDecimal; import java.util.List; +import java.util.Map; import java.util.UUID; public record ShopProductDetailDto( @@ -25,6 +26,8 @@ public record ShopProductDetailDto( List variants, PublicMediaUsageDto primaryImage, List images, - ShopProductModelDto model3d + ShopProductModelDto model3d, + String publicPath, + Map localizedPaths ) { } diff --git a/backend/src/main/java/com/printcalculator/dto/ShopProductSummaryDto.java b/backend/src/main/java/com/printcalculator/dto/ShopProductSummaryDto.java index d563a07..2d4e14e 100644 --- a/backend/src/main/java/com/printcalculator/dto/ShopProductSummaryDto.java +++ b/backend/src/main/java/com/printcalculator/dto/ShopProductSummaryDto.java @@ -1,6 +1,7 @@ package com.printcalculator.dto; import java.math.BigDecimal; +import java.util.Map; import java.util.UUID; public record ShopProductSummaryDto( @@ -15,6 +16,8 @@ public record ShopProductSummaryDto( BigDecimal priceToChf, ShopProductVariantOptionDto defaultVariant, PublicMediaUsageDto primaryImage, - ShopProductModelDto model3d + ShopProductModelDto model3d, + String publicPath, + Map localizedPaths ) { } diff --git a/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java b/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java index 27b349c..8772f60 100644 --- a/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java +++ b/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java @@ -223,10 +223,15 @@ public class OrderEmailListener { order.getCreatedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(locale)) ); templateData.put("totalCost", currencyFormatter.format(order.getTotalChf())); + templateData.put("logoUrl", buildLogoUrl()); templateData.put("currentYear", Year.now().getValue()); return templateData; } + private String buildLogoUrl() { + return frontendBaseUrl.replaceAll("/+$", "") + "/assets/images/brand-logo-yellow.svg"; + } + private String applyOrderConfirmationTexts(Map templateData, String language, String orderNumber) { return switch (language) { case "en" -> { 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/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestNotificationService.java b/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestNotificationService.java index 6c6d53c..41ecddd 100644 --- a/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestNotificationService.java +++ b/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestNotificationService.java @@ -30,6 +30,9 @@ public class CustomQuoteRequestNotificationService { @Value("${app.mail.contact-request.customer.enabled:true}") private boolean contactRequestCustomerMailEnabled; + @Value("${app.frontend.base-url:http://localhost:4200}") + private String frontendBaseUrl; + public CustomQuoteRequestNotificationService(EmailNotificationService emailNotificationService, ContactRequestLocalizationService localizationService) { this.emailNotificationService = emailNotificationService; @@ -63,6 +66,7 @@ public class CustomQuoteRequestNotificationService { templateData.put("phone", safeValue(request.getPhone())); templateData.put("message", safeValue(request.getMessage())); templateData.put("attachmentsCount", attachmentsCount); + templateData.put("logoUrl", buildLogoUrl()); templateData.put("currentYear", Year.now().getValue()); emailNotificationService.sendEmail( @@ -101,6 +105,7 @@ public class CustomQuoteRequestNotificationService { templateData.put("phone", safeValue(request.getPhone())); templateData.put("message", safeValue(request.getMessage())); templateData.put("attachmentsCount", attachmentsCount); + templateData.put("logoUrl", buildLogoUrl()); templateData.put("currentYear", Year.now().getValue()); String subject = localizationService.applyCustomerContactRequestTexts(templateData, language, request.getId()); @@ -119,4 +124,11 @@ public class CustomQuoteRequestNotificationService { } return value; } + + private String buildLogoUrl() { + String baseUrl = frontendBaseUrl == null || frontendBaseUrl.isBlank() + ? "http://localhost:4200" + : frontendBaseUrl; + return baseUrl.replaceAll("/+$", "") + "/assets/images/brand-logo-yellow.svg"; + } } diff --git a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java index 62636a1..779258a 100644 --- a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java +++ b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java @@ -399,6 +399,7 @@ public class PublicShopCatalogService { Map variantColorHexByMaterialAndColor, String language) { List images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); + Map localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product()); return new ShopProductSummaryDto( entry.product().getId(), entry.product().getSlug(), @@ -415,7 +416,9 @@ public class PublicShopCatalogService { resolvePriceTo(entry.variants()), toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language), selectPrimaryMedia(images), - toProductModelDto(entry) + toProductModelDto(entry), + localizedPaths.getOrDefault(normalizeLanguage(language), localizedPaths.get("it")), + localizedPaths ); } @@ -426,6 +429,7 @@ public class PublicShopCatalogService { List images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language); String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language); + Map localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product()); return new ShopProductDetailDto( entry.product().getId(), entry.product().getSlug(), @@ -453,7 +457,9 @@ public class PublicShopCatalogService { .toList(), selectPrimaryMedia(images), images, - toProductModelDto(entry) + toProductModelDto(entry), + localizedPaths.getOrDefault(normalizeLanguage(language), localizedPaths.get("it")), + localizedPaths ); } @@ -514,6 +520,22 @@ public class PublicShopCatalogService { return raw; } + private String normalizeLanguage(String language) { + String normalized = trimToNull(language); + if (normalized == null) { + return "it"; + } + normalized = normalized.toLowerCase(Locale.ROOT); + int separatorIndex = normalized.indexOf('-'); + if (separatorIndex > 0) { + normalized = normalized.substring(0, separatorIndex); + } + return switch (normalized) { + case "en", "de", "fr" -> normalized; + default -> "it"; + }; + } + private ShopProductModelDto toProductModelDto(ProductEntry entry) { if (entry.modelAsset() == null) { return null; diff --git a/backend/src/main/java/com/printcalculator/service/shop/ShopPublicPathSupport.java b/backend/src/main/java/com/printcalculator/service/shop/ShopPublicPathSupport.java new file mode 100644 index 0000000..cd16503 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/shop/ShopPublicPathSupport.java @@ -0,0 +1,66 @@ +package com.printcalculator.service.shop; + +import com.printcalculator.entity.ShopProduct; + +import java.text.Normalizer; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +final class ShopPublicPathSupport { + private static final String PRODUCT_ROUTE_PREFIX = "/shop/p/"; + + private ShopPublicPathSupport() { + } + + static String buildProductPathSegment(ShopProduct product, String language) { + String localizedName = product.getNameForLanguage(language); + String idPrefix = productIdPrefix(product.getId()); + String tail = firstNonBlank(slugify(localizedName), slugify(product.getSlug()), "product"); + return idPrefix.isBlank() ? tail : idPrefix + "-" + tail; + } + + static Map buildLocalizedProductPaths(ShopProduct product) { + Map localizedPaths = new LinkedHashMap<>(); + for (String language : ShopProduct.SUPPORTED_LANGUAGES) { + localizedPaths.put(language, "/" + language + PRODUCT_ROUTE_PREFIX + buildProductPathSegment(product, language)); + } + return localizedPaths; + } + + static String productIdPrefix(UUID productId) { + if (productId == null) { + return ""; + } + String raw = productId.toString().trim().toLowerCase(Locale.ROOT); + int dashIndex = raw.indexOf('-'); + if (dashIndex > 0) { + return raw.substring(0, dashIndex); + } + return raw.length() >= 8 ? raw.substring(0, 8) : raw; + } + + static String slugify(String rawValue) { + String safeValue = rawValue == null ? "" : rawValue; + String normalized = Normalizer.normalize(safeValue, Normalizer.Form.NFD) + .replaceAll("\\p{M}+", "") + .toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("^-+|-+$", "") + .replaceAll("-{2,}", "-"); + return normalized; + } + + private static String firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/shop/ShopSitemapService.java b/backend/src/main/java/com/printcalculator/service/shop/ShopSitemapService.java index e3cf38a..54ad68f 100644 --- a/backend/src/main/java/com/printcalculator/service/shop/ShopSitemapService.java +++ b/backend/src/main/java/com/printcalculator/service/shop/ShopSitemapService.java @@ -11,7 +11,6 @@ import org.springframework.transaction.annotation.Transactional; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.text.Normalizer; import java.time.Clock; import java.time.Duration; import java.time.Instant; @@ -19,7 +18,6 @@ import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -31,6 +29,12 @@ public class ShopSitemapService { private static final List SUPPORTED_LANGUAGES = ShopProduct.SUPPORTED_LANGUAGES; private static final String DEFAULT_LANGUAGE = "it"; private static final DateTimeFormatter LASTMOD_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + private static final Map HREFLANG_BY_LANGUAGE = Map.of( + "it", "it-CH", + "en", "en-CH", + "de", "de-CH", + "fr", "fr-CH" + ); private final ShopCategoryRepository shopCategoryRepository; private final ShopProductRepository shopProductRepository; @@ -130,7 +134,7 @@ public class ShopSitemapService { Map hrefByLanguage = new LinkedHashMap<>(); for (String language : SUPPORTED_LANGUAGES) { - String publicSegment = localizedProductPathSegment(product, language); + String publicSegment = ShopPublicPathSupport.buildProductPathSegment(product, language); hrefByLanguage.put(language, frontendBaseUrl + "/" + language + "/shop/p/" + pathEncodeSegment(publicSegment)); } @@ -169,7 +173,7 @@ public class ShopSitemapService { continue; } xml.append(" \n"); @@ -186,48 +190,6 @@ public class ShopSitemapService { xml.append(" \n"); } - private String localizedProductPathSegment(ShopProduct product, String language) { - String localizedName = product.getNameForLanguage(language); - String idPrefix = productIdPrefix(product.getId()); - String tail = firstNonBlank(slugify(localizedName), slugify(product.getSlug()), "product"); - return idPrefix.isBlank() ? tail : idPrefix + "-" + tail; - } - - private String productIdPrefix(UUID productId) { - if (productId == null) { - return ""; - } - String raw = productId.toString().trim().toLowerCase(Locale.ROOT); - int dashIndex = raw.indexOf('-'); - if (dashIndex > 0) { - return raw.substring(0, dashIndex); - } - return raw.length() >= 8 ? raw.substring(0, 8) : raw; - } - - static String slugify(String rawValue) { - String safeValue = rawValue == null ? "" : rawValue; - String normalized = Normalizer.normalize(safeValue, Normalizer.Form.NFD) - .replaceAll("\\p{M}+", "") - .toLowerCase(Locale.ROOT) - .replaceAll("[^a-z0-9]+", "-") - .replaceAll("^-+|-+$", "") - .replaceAll("-{2,}", "-"); - return normalized; - } - - private String firstNonBlank(String... values) { - if (values == null) { - return null; - } - for (String value : values) { - if (value != null && !value.isBlank()) { - return value; - } - } - return null; - } - private String pathEncodeSegment(String rawSegment) { String safeSegment = rawSegment == null ? "" : rawSegment; return URLEncoder.encode(safeSegment, StandardCharsets.UTF_8).replace("+", "%20"); diff --git a/backend/src/main/resources/templates/email/contact-request-admin.html b/backend/src/main/resources/templates/email/contact-request-admin.html index 4341c34..dcecd57 100644 --- a/backend/src/main/resources/templates/email/contact-request-admin.html +++ b/backend/src/main/resources/templates/email/contact-request-admin.html @@ -25,6 +25,21 @@ color: #222222; } + .header { + text-align: center; + border-bottom: 1px solid #eeeeee; + padding-bottom: 20px; + margin-bottom: 20px; + } + + .brand-logo { + display: block; + width: 220px; + max-width: 220px; + height: auto; + margin: 0 auto 16px; + } + p { color: #444444; line-height: 1.5; @@ -63,7 +78,10 @@
-

Nuova richiesta di contatto

+
+ +

Nuova richiesta di contatto

+

E' stata ricevuta una nuova richiesta dal form contatti/su misura.

diff --git a/backend/src/main/resources/templates/email/contact-request-customer.html b/backend/src/main/resources/templates/email/contact-request-customer.html index d308b0c..35def67 100644 --- a/backend/src/main/resources/templates/email/contact-request-customer.html +++ b/backend/src/main/resources/templates/email/contact-request-customer.html @@ -25,6 +25,21 @@ color: #222222; } + .header { + text-align: center; + border-bottom: 1px solid #eeeeee; + padding-bottom: 20px; + margin-bottom: 20px; + } + + .brand-logo { + display: block; + width: 220px; + max-width: 220px; + height: auto; + margin: 0 auto 16px; + } + h2 { margin-top: 18px; color: #222222; @@ -69,7 +84,10 @@
-

We received your contact request

+
+ +

We received your contact request

+

Hi customer,

Thank you for contacting us. Our team will reply as soon as possible.

diff --git a/backend/src/main/resources/templates/email/order-confirmation.html b/backend/src/main/resources/templates/email/order-confirmation.html index 37a6082..d8e4b86 100644 --- a/backend/src/main/resources/templates/email/order-confirmation.html +++ b/backend/src/main/resources/templates/email/order-confirmation.html @@ -27,8 +27,17 @@ margin-bottom: 20px; } + .brand-logo { + display: block; + width: 220px; + max-width: 220px; + height: auto; + margin: 0 auto 16px; + } + .header h1 { color: #333333; + margin: 0; } .content { @@ -67,6 +76,7 @@

+

Thank you for your order #00000000

diff --git a/backend/src/main/resources/templates/email/order-shipped.html b/backend/src/main/resources/templates/email/order-shipped.html index 74f5aa7..1d06f44 100644 --- a/backend/src/main/resources/templates/email/order-shipped.html +++ b/backend/src/main/resources/templates/email/order-shipped.html @@ -27,8 +27,17 @@ margin-bottom: 20px; } + .brand-logo { + display: block; + width: 220px; + max-width: 220px; + height: auto; + margin: 0 auto 16px; + } + .header h1 { color: #333333; + margin: 0; } .content { @@ -70,6 +79,7 @@
+

Your order #00000000 has been shipped

diff --git a/backend/src/main/resources/templates/email/payment-confirmed.html b/backend/src/main/resources/templates/email/payment-confirmed.html index 657f1ef..c8c0f75 100644 --- a/backend/src/main/resources/templates/email/payment-confirmed.html +++ b/backend/src/main/resources/templates/email/payment-confirmed.html @@ -27,8 +27,17 @@ margin-bottom: 20px; } + .brand-logo { + display: block; + width: 220px; + max-width: 220px; + height: auto; + margin: 0 auto 16px; + } + .header h1 { color: #333333; + margin: 0; } .content { @@ -70,6 +79,7 @@
+

Payment confirmed for order #00000000

diff --git a/backend/src/main/resources/templates/email/payment-reported.html b/backend/src/main/resources/templates/email/payment-reported.html index c7d2b72..94abd0f 100644 --- a/backend/src/main/resources/templates/email/payment-reported.html +++ b/backend/src/main/resources/templates/email/payment-reported.html @@ -27,8 +27,17 @@ margin-bottom: 20px; } + .brand-logo { + display: block; + width: 220px; + max-width: 220px; + height: auto; + margin: 0 auto 16px; + } + .header h1 { color: #333333; + margin: 0; } .content { @@ -70,6 +79,7 @@
+

Payment reported for order #00000000

diff --git a/backend/src/test/java/com/printcalculator/service/shop/ShopSitemapServiceTest.java b/backend/src/test/java/com/printcalculator/service/shop/ShopSitemapServiceTest.java index 988b13e..778a150 100644 --- a/backend/src/test/java/com/printcalculator/service/shop/ShopSitemapServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/shop/ShopSitemapServiceTest.java @@ -92,15 +92,15 @@ class ShopSitemapServiceTest { assertTrue(xml.contains("https://3d-fab.ch/en/shop/accessori")); assertTrue(xml.contains("https://3d-fab.ch/de/shop/accessori")); assertTrue(xml.contains("https://3d-fab.ch/fr/shop/accessori")); - assertTrue(xml.contains("hreflang=\"en\" href=\"https://3d-fab.ch/en/shop/accessori\"")); + assertTrue(xml.contains("hreflang=\"en-CH\" href=\"https://3d-fab.ch/en/shop/accessori\"")); assertFalse(xml.contains("https://3d-fab.ch/it/shop/bozza")); assertTrue(xml.contains("https://3d-fab.ch/it/shop/p/123e4567-supporto-bici")); assertTrue(xml.contains("https://3d-fab.ch/en/shop/p/123e4567-bike-holder")); assertTrue(xml.contains("https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter")); assertTrue(xml.contains("https://3d-fab.ch/fr/shop/p/123e4567-support-velo")); - assertTrue(xml.contains("hreflang=\"en\" href=\"https://3d-fab.ch/en/shop/p/123e4567-bike-holder\"")); - assertTrue(xml.contains("hreflang=\"de\" href=\"https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter\"")); + assertTrue(xml.contains("hreflang=\"en-CH\" href=\"https://3d-fab.ch/en/shop/p/123e4567-bike-holder\"")); + assertTrue(xml.contains("hreflang=\"de-CH\" href=\"https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter\"")); assertTrue(xml.contains("hreflang=\"x-default\" href=\"https://3d-fab.ch/it/shop/p/123e4567-supporto-bici\"")); assertTrue(xml.contains("2026-03-11T07:30:00Z")); assertFalse(xml.contains("33333333-draft")); diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index 57614f9..6bfeebb 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/public/sitemap-static.xml b/frontend/public/sitemap-static.xml index b7020d5..795768d 100644 --- a/frontend/public/sitemap-static.xml +++ b/frontend/public/sitemap-static.xml @@ -2,40 +2,40 @@ https://3d-fab.ch/it - - - - + + + + weekly 1.0 https://3d-fab.ch/en - - - - + + + + weekly 1.0 https://3d-fab.ch/de - - - - + + + + weekly 1.0 https://3d-fab.ch/fr - - - - + + + + weekly 1.0 @@ -43,40 +43,40 @@ https://3d-fab.ch/it/calculator/basic - - - - + + + + weekly 0.9 https://3d-fab.ch/en/calculator/basic - - - - + + + + weekly 0.9 https://3d-fab.ch/de/calculator/basic - - - - + + + + weekly 0.9 https://3d-fab.ch/fr/calculator/basic - - - - + + + + weekly 0.9 @@ -84,40 +84,40 @@ https://3d-fab.ch/it/calculator/advanced - - - - + + + + weekly 0.8 https://3d-fab.ch/en/calculator/advanced - - - - + + + + weekly 0.8 https://3d-fab.ch/de/calculator/advanced - - - - + + + + weekly 0.8 https://3d-fab.ch/fr/calculator/advanced - - - - + + + + weekly 0.8 @@ -125,40 +125,40 @@ https://3d-fab.ch/it/shop - - - - + + + + weekly 0.8 https://3d-fab.ch/en/shop - - - - + + + + weekly 0.8 https://3d-fab.ch/de/shop - - - - + + + + weekly 0.8 https://3d-fab.ch/fr/shop - - - - + + + + weekly 0.8 @@ -166,40 +166,40 @@ https://3d-fab.ch/it/about - - - - + + + + monthly 0.7 https://3d-fab.ch/en/about - - - - + + + + monthly 0.7 https://3d-fab.ch/de/about - - - - + + + + monthly 0.7 https://3d-fab.ch/fr/about - - - - + + + + monthly 0.7 @@ -207,40 +207,40 @@ https://3d-fab.ch/it/contact - - - - + + + + monthly 0.7 https://3d-fab.ch/en/contact - - - - + + + + monthly 0.7 https://3d-fab.ch/de/contact - - - - + + + + monthly 0.7 https://3d-fab.ch/fr/contact - - - - + + + + monthly 0.7 @@ -248,40 +248,40 @@ https://3d-fab.ch/it/privacy - - - - + + + + yearly 0.4 https://3d-fab.ch/en/privacy - - - - + + + + yearly 0.4 https://3d-fab.ch/de/privacy - - - - + + + + yearly 0.4 https://3d-fab.ch/fr/privacy - - - - + + + + yearly 0.4 @@ -289,40 +289,40 @@ https://3d-fab.ch/it/terms - - - - + + + + yearly 0.4 https://3d-fab.ch/en/terms - - - - + + + + yearly 0.4 https://3d-fab.ch/de/terms - - - - + + + + yearly 0.4 https://3d-fab.ch/fr/terms - - - - + + + + yearly 0.4 diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 0680b43..b7df367 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 2e93470..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( @@ -60,7 +51,7 @@ export const appConfig: ApplicationConfig = { ), importProvidersFrom( TranslateModule.forRoot({ - defaultLanguage: 'it', + fallbackLang: 'it', loader: { provide: TranslateLoader, useClass: StaticTranslateLoader, @@ -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.setDefaultLang('it'); + 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..842e360 --- /dev/null +++ b/frontend/src/app/core/i18n/language-resolution.ts @@ -0,0 +1,135 @@ +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/i18n/static-translate.loader.ts b/frontend/src/app/core/i18n/static-translate.loader.ts index 3821187..7e25c66 100644 --- a/frontend/src/app/core/i18n/static-translate.loader.ts +++ b/frontend/src/app/core/i18n/static-translate.loader.ts @@ -1,22 +1,93 @@ -import { Injectable } from '@angular/core'; +import { isPlatformBrowser, isPlatformServer } from '@angular/common'; +import { + Injectable, + PLATFORM_ID, + TransferState, + inject, + makeStateKey, +} from '@angular/core'; import { TranslateLoader, TranslationObject } from '@ngx-translate/core'; -import { Observable, of } from 'rxjs'; -import de from '../../../assets/i18n/de.json'; -import en from '../../../assets/i18n/en.json'; -import fr from '../../../assets/i18n/fr.json'; -import it from '../../../assets/i18n/it.json'; +import { from, Observable } from 'rxjs'; -const TRANSLATIONS: Record = { - it: it as TranslationObject, - en: en as TranslationObject, - de: de as TranslationObject, - fr: fr as TranslationObject, +type SupportedLang = 'it' | 'en' | 'de' | 'fr'; + +const FALLBACK_LANG: SupportedLang = 'it'; +const translationCache = new Map>(); + +const translationLoaders: Record< + SupportedLang, + () => Promise +> = { + it: () => + import('../../../assets/i18n/it.json').then( + (module) => module.default as TranslationObject, + ), + en: () => + import('../../../assets/i18n/en.json').then( + (module) => module.default as TranslationObject, + ), + de: () => + import('../../../assets/i18n/de.json').then( + (module) => module.default as TranslationObject, + ), + fr: () => + import('../../../assets/i18n/fr.json').then( + (module) => module.default as TranslationObject, + ), }; @Injectable() export class StaticTranslateLoader implements TranslateLoader { + private readonly platformId = inject(PLATFORM_ID); + private readonly transferState = inject(TransferState); + getTranslation(lang: string): Observable { - const normalized = String(lang || 'it').toLowerCase(); - return of(TRANSLATIONS[normalized] ?? TRANSLATIONS['it']); + const normalized = this.normalizeLanguage(lang); + return from(this.loadTranslation(normalized)); + } + + private normalizeLanguage(lang: string): SupportedLang { + const normalized = String(lang || FALLBACK_LANG).toLowerCase(); + return normalized in translationLoaders + ? (normalized as SupportedLang) + : FALLBACK_LANG; + } + + private loadTranslation(lang: SupportedLang): Promise { + const transferStateKey = makeStateKey( + `i18n:${lang.toLowerCase()}`, + ); + if ( + isPlatformBrowser(this.platformId) && + this.transferState.hasKey(transferStateKey) + ) { + const transferred = this.transferState.get(transferStateKey, {}); + this.transferState.remove(transferStateKey); + return Promise.resolve(transferred); + } + + const cached = translationCache.get(lang); + if (cached) { + return cached; + } + + const pending = translationLoaders[lang]() + .then((translation) => { + if ( + isPlatformServer(this.platformId) && + !this.transferState.hasKey(transferStateKey) + ) { + this.transferState.set(transferStateKey, translation); + } + return translation; + }) + .catch(() => + lang === FALLBACK_LANG + ? Promise.resolve({}) + : this.loadTranslation(FALLBACK_LANG), + ); + + translationCache.set(lang, pending); + return pending; } } diff --git a/frontend/src/app/core/layout/footer.component.html b/frontend/src/app/core/layout/footer.component.html index 2e3116c..164cf80 100644 --- a/frontend/src/app/core/layout/footer.component.html +++ b/frontend/src/app/core/layout/footer.component.html @@ -1,7 +1,11 @@