28 Commits

Author SHA1 Message Date
997e770256 Merge remote-tracking branch 'origin/feat/brand-logo' into feat/brand-logo
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-20 11:45:15 +01:00
fb1a6456e6 fix(back-end) base url fix 2026-03-20 11:45:10 +01:00
43cd80600e Merge branch 'dev' into feat/brand-logo
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Failing after 27s
PR Checks / test-frontend (pull_request) Successful in 1m2s
2026-03-20 11:28:10 +01:00
printcalc-ci
23e1abdbbb style: apply prettier formatting 2026-03-20 09:37:56 +00:00
e575021f53 feat(front-end): new logo edited
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 22s
PR Checks / security-sast (pull_request) Successful in 35s
PR Checks / test-backend (pull_request) Failing after 34s
PR Checks / test-frontend (pull_request) Successful in 1m1s
2026-03-20 10:36:50 +01:00
7e8c89ce45 feat(front-end): new logo edited 2026-03-19 14:33:29 +01:00
a40a8df894 feat(animation logo) 2026-03-18 17:30:53 +01:00
printcalc-ci
41f36ed18a style: apply prettier formatting 2026-03-17 08:03:30 +00:00
e04189bbfe Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
PR Checks / prettier-autofix (pull_request) Successful in 14s
Build and Deploy / test-frontend (push) Successful in 1m3s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / build-and-push (push) Successful in 1m46s
Build and Deploy / deploy (push) Successful in 22s
2026-03-17 09:01:34 +01:00
20988e425a fix(front-end): set fallback lang
Some checks failed
Build and Deploy / build-and-push (push) Successful in 30s
Build and Deploy / deploy (push) Successful in 21s
Build and Deploy / test-backend (push) Successful in 36s
Build and Deploy / test-frontend (push) Successful in 1m5s
PR Checks / prettier-autofix (pull_request) Failing after 18s
PR Checks / security-sast (pull_request) Successful in 33s
PR Checks / test-backend (pull_request) Successful in 30s
PR Checks / test-frontend (pull_request) Successful in 1m7s
2026-03-14 20:53:50 +01:00
df63937406 feat(front-end): faster load
All checks were successful
Build and Deploy / test-backend (push) Successful in 31s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 29s
Build and Deploy / deploy (push) Successful in 19s
2026-03-14 19:28:30 +01:00
4ba408859d Merge pull request 'dev' (#48) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 34s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 19s
Reviewed-on: #48
2026-03-14 19:18:15 +01:00
996e95f93c fix(back-end): quote line items
All checks were successful
Build and Deploy / test-frontend (push) Successful in 1m8s
PR Checks / security-sast (pull_request) Successful in 33s
Build and Deploy / test-backend (push) Successful in 32s
PR Checks / prettier-autofix (pull_request) Successful in 10s
PR Checks / test-backend (pull_request) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / build-and-push (push) Successful in 1m25s
Build and Deploy / deploy (push) Successful in 21s
2026-03-14 19:15:44 +01:00
printcalc-ci
c4bd0b5a67 style: apply prettier formatting 2026-03-14 17:58:02 +00:00
5c43873ede Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 29s
PR Checks / prettier-autofix (pull_request) Successful in 15s
Build and Deploy / test-frontend (push) Successful in 1m2s
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Successful in 30s
Build and Deploy / build-and-push (push) Successful in 18s
PR Checks / test-frontend (pull_request) Successful in 1m4s
Build and Deploy / deploy (push) Successful in 27s
2026-03-14 18:56:07 +01:00
249645619e fix(deploy): common..env
Some checks failed
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 22s
Build and Deploy / deploy (push) Successful in 19s
PR Checks / prettier-autofix (pull_request) Failing after 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
2026-03-14 18:52:18 +01:00
be9f303b37 fix(deploy): common..env
Some checks failed
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Failing after 6s
Build and Deploy / test-backend (push) Successful in 29s
Build and Deploy / test-frontend (push) Successful in 1m1s
2026-03-14 18:42:53 +01:00
6da8b3b6e4 feat(back-end): new translation api with openai
Some checks failed
Build and Deploy / test-backend (push) Successful in 35s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Successful in 1m20s
Build and Deploy / deploy (push) Failing after 6s
2026-03-14 18:33:51 +01:00
a3cd451575 Merge pull request 'dev' (#47) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Successful in 31s
Build and Deploy / deploy (push) Successful in 20s
Reviewed-on: #47
2026-03-14 16:13:37 +01:00
printcalc-ci
6f700c923a style: apply prettier formatting
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 9s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 58s
2026-03-14 14:15:10 +00:00
46fd59ed71 Merge branch 'main' into dev
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 12s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / test-backend (push) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / build-and-push (push) Successful in 29s
Build and Deploy / deploy (push) Successful in 22s
2026-03-14 15:14:12 +01:00
ba49463ee7 fix(front-end): seo improvements with SSR
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / test-frontend (push) Has been cancelled
Build and Deploy / test-backend (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Failing after 13s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m6s
2026-03-14 15:13:54 +01:00
576380e9a0 fix(front-end): seo translated 2026-03-14 15:02:00 +01:00
cac534ccbb Merge pull request 'dev' (#46) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 18s
Reviewed-on: #46
2026-03-13 17:44:20 +01:00
2e68105da4 Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 31s
PR Checks / prettier-autofix (pull_request) Successful in 10s
Build and Deploy / test-frontend (push) Successful in 1m6s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / build-and-push (push) Successful in 30s
PR Checks / test-frontend (pull_request) Successful in 1m5s
Build and Deploy / deploy (push) Successful in 24s
2026-03-13 17:41:55 +01:00
ed7ed6636d fix(front-end): al categories translated 2026-03-13 17:41:25 +01:00
e190359041 Merge pull request 'dev' (#45) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 28s
Build and Deploy / deploy (push) Successful in 20s
Reviewed-on: #45
2026-03-13 16:36:42 +01:00
printcalc-ci
bed94790d4 style: apply prettier formatting
All checks were successful
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 59s
PR Checks / prettier-autofix (pull_request) Successful in 8s
2026-03-13 15:30:28 +00:00
134 changed files with 18759 additions and 687 deletions

View File

@@ -217,9 +217,12 @@ jobs:
ADMIN_TTL="${ADMIN_TTL:-480}"
printf 'ADMIN_PASSWORD="%s"\nADMIN_SESSION_SECRET="%s"\nADMIN_SESSION_TTL_MINUTES="%s"\n' \
"${{ secrets.ADMIN_PASSWORD }}" "${{ secrets.ADMIN_SESSION_SECRET }}" "$ADMIN_TTL" >> /tmp/full_env.env
if [[ -n "${{ secrets.OPENAI_API_KEY }}" ]]; then
printf 'OPENAI_API_KEY="%s"\n' "${{ secrets.OPENAI_API_KEY }}" >> /tmp/full_env.env
fi
echo "Preparing to send env file with variables:"
grep -Ev "PASSWORD|SECRET" /tmp/full_env.env || true
grep -Ev "PASSWORD|SECRET|KEY|TOKEN" /tmp/full_env.env || true
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
"setenv ${{ env.ENV }}" < /tmp/full_env.env

View File

@@ -111,3 +111,6 @@ Verifica che `MEDIA_FFMPEG_PATH` punti a un `ffmpeg` con supporto JPEG, WebP e A
### Database connection
Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente.
### Deploy e traduzioni OpenAI
Nel deploy Gitea la chiave OpenAI deve stare nel secret `OPENAI_API_KEY`. La pipeline la aggiunge al file `.env` dell'ambiente durante il deploy e il container backend la riceve come variabile runtime. I file `deploy/envs/*.env` restano per i valori specifici di `dev/int/prod`.

View File

@@ -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");
}
}

View File

@@ -130,6 +130,7 @@ public class QuoteSessionController {
}
@GetMapping("/{id}")
@Transactional(readOnly = true)
public ResponseEntity<Map<String, Object>> getQuoteSession(@PathVariable UUID id) {
QuoteSession session = sessionRepo.findById(id)
.orElseThrow(() -> new RuntimeException("Session not found"));

View File

@@ -1,8 +1,11 @@
package com.printcalculator.controller.admin;
import com.printcalculator.dto.AdminShopProductDto;
import com.printcalculator.dto.AdminTranslateShopProductRequest;
import com.printcalculator.dto.AdminTranslateShopProductResponse;
import com.printcalculator.dto.AdminUpsertShopProductRequest;
import com.printcalculator.service.admin.AdminShopProductControllerService;
import com.printcalculator.service.admin.AdminShopProductTranslationService;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
@@ -29,9 +32,12 @@ import java.util.UUID;
@Transactional(readOnly = true)
public class AdminShopProductController {
private final AdminShopProductControllerService adminShopProductControllerService;
private final AdminShopProductTranslationService adminShopProductTranslationService;
public AdminShopProductController(AdminShopProductControllerService adminShopProductControllerService) {
public AdminShopProductController(AdminShopProductControllerService adminShopProductControllerService,
AdminShopProductTranslationService adminShopProductTranslationService) {
this.adminShopProductControllerService = adminShopProductControllerService;
this.adminShopProductTranslationService = adminShopProductTranslationService;
}
@GetMapping
@@ -50,6 +56,11 @@ public class AdminShopProductController {
return ResponseEntity.ok(adminShopProductControllerService.createProduct(payload));
}
@PostMapping("/translate")
public ResponseEntity<AdminTranslateShopProductResponse> translateProduct(@RequestBody AdminTranslateShopProductRequest payload) {
return ResponseEntity.ok(adminShopProductTranslationService.translateProduct(payload));
}
@PutMapping("/{productId}")
@Transactional
public ResponseEntity<AdminShopProductDto> updateProduct(@PathVariable UUID productId,

View File

@@ -0,0 +1,89 @@
package com.printcalculator.dto;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public class AdminTranslateShopProductRequest {
private UUID categoryId;
private String sourceLanguage;
private Boolean overwriteExisting;
private List<String> materialCodes;
private Map<String, String> names;
private Map<String, String> excerpts;
private Map<String, String> descriptions;
private Map<String, String> seoTitles;
private Map<String, String> seoDescriptions;
public UUID getCategoryId() {
return categoryId;
}
public void setCategoryId(UUID categoryId) {
this.categoryId = categoryId;
}
public String getSourceLanguage() {
return sourceLanguage;
}
public void setSourceLanguage(String sourceLanguage) {
this.sourceLanguage = sourceLanguage;
}
public Boolean getOverwriteExisting() {
return overwriteExisting;
}
public void setOverwriteExisting(Boolean overwriteExisting) {
this.overwriteExisting = overwriteExisting;
}
public List<String> getMaterialCodes() {
return materialCodes;
}
public void setMaterialCodes(List<String> materialCodes) {
this.materialCodes = materialCodes;
}
public Map<String, String> getNames() {
return names;
}
public void setNames(Map<String, String> names) {
this.names = names;
}
public Map<String, String> getExcerpts() {
return excerpts;
}
public void setExcerpts(Map<String, String> excerpts) {
this.excerpts = excerpts;
}
public Map<String, String> getDescriptions() {
return descriptions;
}
public void setDescriptions(Map<String, String> descriptions) {
this.descriptions = descriptions;
}
public Map<String, String> getSeoTitles() {
return seoTitles;
}
public void setSeoTitles(Map<String, String> seoTitles) {
this.seoTitles = seoTitles;
}
public Map<String, String> getSeoDescriptions() {
return seoDescriptions;
}
public void setSeoDescriptions(Map<String, String> seoDescriptions) {
this.seoDescriptions = seoDescriptions;
}
}

View File

@@ -0,0 +1,70 @@
package com.printcalculator.dto;
import java.util.List;
import java.util.Map;
public class AdminTranslateShopProductResponse {
private String sourceLanguage;
private List<String> targetLanguages;
private Map<String, String> names;
private Map<String, String> excerpts;
private Map<String, String> descriptions;
private Map<String, String> seoTitles;
private Map<String, String> seoDescriptions;
public String getSourceLanguage() {
return sourceLanguage;
}
public void setSourceLanguage(String sourceLanguage) {
this.sourceLanguage = sourceLanguage;
}
public List<String> getTargetLanguages() {
return targetLanguages;
}
public void setTargetLanguages(List<String> targetLanguages) {
this.targetLanguages = targetLanguages;
}
public Map<String, String> getNames() {
return names;
}
public void setNames(Map<String, String> names) {
this.names = names;
}
public Map<String, String> getExcerpts() {
return excerpts;
}
public void setExcerpts(Map<String, String> excerpts) {
this.excerpts = excerpts;
}
public Map<String, String> getDescriptions() {
return descriptions;
}
public void setDescriptions(Map<String, String> descriptions) {
this.descriptions = descriptions;
}
public Map<String, String> getSeoTitles() {
return seoTitles;
}
public void setSeoTitles(Map<String, String> seoTitles) {
this.seoTitles = seoTitles;
}
public Map<String, String> getSeoDescriptions() {
return seoDescriptions;
}
public void setSeoDescriptions(Map<String, String> seoDescriptions) {
this.seoDescriptions = seoDescriptions;
}
}

View File

@@ -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<ShopProductVariantOptionDto> variants,
PublicMediaUsageDto primaryImage,
List<PublicMediaUsageDto> images,
ShopProductModelDto model3d
ShopProductModelDto model3d,
String publicPath,
Map<String, String> localizedPaths
) {
}

View File

@@ -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<String, String> localizedPaths
) {
}

View File

@@ -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<String, Object> templateData, String language, String orderNumber) {
return switch (language) {
case "en" -> {

View File

@@ -1,6 +1,7 @@
package com.printcalculator.repository;
import com.printcalculator.entity.QuoteLineItem;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
@@ -8,9 +9,16 @@ import java.util.Optional;
import java.util.UUID;
public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> {
@EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"})
List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId);
@EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"})
List<QuoteLineItem> findByQuoteSessionIdOrderByCreatedAtAsc(UUID quoteSessionId);
@EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"})
Optional<QuoteLineItem> findByIdAndQuoteSession_Id(UUID lineItemId, UUID quoteSessionId);
@EntityGraph(attributePaths = {"shopProductVariant"})
Optional<QuoteLineItem> findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id(
UUID quoteSessionId,
String lineItemType,

View File

@@ -0,0 +1,685 @@
package com.printcalculator.service.admin;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.printcalculator.dto.AdminTranslateShopProductRequest;
import com.printcalculator.dto.AdminTranslateShopProductResponse;
import com.printcalculator.entity.ShopCategory;
import com.printcalculator.repository.ShopCategoryRepository;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.safety.Safelist;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@Service
@Transactional(readOnly = true)
public class AdminShopProductTranslationService {
private static final List<String> SUPPORTED_LANGUAGES = List.of("it", "en", "de", "fr");
private static final Safelist PRODUCT_DESCRIPTION_SAFELIST = Safelist.none()
.addTags("p", "div", "br", "strong", "b", "em", "i", "u", "ul", "ol", "li", "a")
.addAttributes("a", "href")
.addProtocols("a", "href", "http", "https", "mailto", "tel");
private static final String DEFAULT_SHOP_CONTEXT = """
3D fab is a Swiss-based 3D printing shop and technical service.
The tone must be practical, clear, technical, and trustworthy.
Avoid hype, avoid invented claims, and avoid vague marketing filler.
Preserve all brand names, measurements, materials, SKUs, codes, and technical terminology exactly when they should not be translated.
When the source field is empty, return an empty string rather than inventing content.
For descriptions, preserve safe HTML structure when present and keep output ready for an ecommerce/admin form.
For SEO, prefer concise, natural phrases suitable for ecommerce and search snippets.
""";
private final ShopCategoryRepository shopCategoryRepository;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
private final String apiKey;
private final String baseUrl;
private final String model;
private final Duration timeout;
private final String promptCacheKeyPrefix;
private final String additionalBusinessContext;
public AdminShopProductTranslationService(ShopCategoryRepository shopCategoryRepository,
ObjectMapper objectMapper,
@Value("${openai.translation.api-key:}") String apiKey,
@Value("${openai.translation.base-url:https://api.openai.com/v1}") String baseUrl,
@Value("${openai.translation.model:gpt-5.4}") String model,
@Value("${openai.translation.timeout-seconds:45}") long timeoutSeconds,
@Value("${openai.translation.prompt-cache-key-prefix:printcalc-shop-product-translation-v1}") String promptCacheKeyPrefix,
@Value("${openai.translation.business-context:}") String additionalBusinessContext) {
this.shopCategoryRepository = shopCategoryRepository;
this.objectMapper = objectMapper;
this.apiKey = apiKey != null ? apiKey.trim() : "";
this.baseUrl = normalizeBaseUrl(baseUrl);
this.model = model != null ? model.trim() : "";
this.timeout = Duration.ofSeconds(Math.max(timeoutSeconds, 5));
this.promptCacheKeyPrefix = promptCacheKeyPrefix != null && !promptCacheKeyPrefix.isBlank()
? promptCacheKeyPrefix.trim()
: "printcalc-shop-product-translation-v1";
this.additionalBusinessContext = additionalBusinessContext != null ? additionalBusinessContext.trim() : "";
this.httpClient = HttpClient.newBuilder()
.connectTimeout(this.timeout)
.build();
}
public AdminTranslateShopProductResponse translateProduct(AdminTranslateShopProductRequest payload) {
ensureConfigured();
NormalizedTranslationRequest normalizedRequest = normalizeRequest(payload);
List<String> targetLanguages = resolveTargetLanguages(normalizedRequest);
if (targetLanguages.isEmpty()) {
return emptyResponse(normalizedRequest.sourceLanguage());
}
CategoryContext categoryContext = loadCategoryContext(normalizedRequest.categoryId());
String businessContext = buildBusinessContext(categoryContext, normalizedRequest.materialCodes());
TranslationBundle generated = callOpenAiFunction(
"generate_product_translations",
"Generate translated product copy for the requested target languages.",
buildInstructions("Generate the first-pass translations.", businessContext),
buildGenerationInput(normalizedRequest, targetLanguages, categoryContext),
buildTranslationToolSchema(targetLanguages),
"generate"
);
TranslationBundle normalizedGenerated = sanitizeBundle(generated, targetLanguages);
List<String> validationNotes = buildValidationNotes(normalizedGenerated, targetLanguages);
TranslationBundle reviewed = callOpenAiFunction(
"review_product_translations",
"Review and correct translated product copy while preserving meaning, SEO limits, and technical terminology.",
buildInstructions("Review and correct the generated translations.", businessContext),
buildReviewInput(normalizedRequest, normalizedGenerated, targetLanguages, categoryContext, validationNotes),
buildTranslationToolSchema(targetLanguages),
"review"
);
TranslationBundle finalBundle = sanitizeBundle(reviewed, targetLanguages);
ensureRequiredTranslations(finalBundle, targetLanguages);
return toResponse(normalizedRequest.sourceLanguage(), targetLanguages, finalBundle);
}
private void ensureConfigured() {
if (apiKey.isBlank() || model.isBlank()) {
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE,
"OpenAI translation is not configured on the backend"
);
}
}
private NormalizedTranslationRequest normalizeRequest(AdminTranslateShopProductRequest payload) {
if (payload == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Translation payload is required");
}
String sourceLanguage = normalizeLanguage(payload.getSourceLanguage());
if (!SUPPORTED_LANGUAGES.contains(sourceLanguage)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported source language");
}
Map<String, String> names = normalizeLocalizedMap(payload.getNames(), false);
Map<String, String> excerpts = normalizeLocalizedMap(payload.getExcerpts(), false);
Map<String, String> descriptions = normalizeLocalizedMap(payload.getDescriptions(), true);
Map<String, String> seoTitles = normalizeLocalizedMap(payload.getSeoTitles(), false);
Map<String, String> seoDescriptions = normalizeLocalizedMap(payload.getSeoDescriptions(), false);
if (names.get(sourceLanguage).isBlank()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"The active source language must have a product name before translation"
);
}
Set<String> materialCodes = new LinkedHashSet<>();
if (payload.getMaterialCodes() != null) {
for (String materialCode : payload.getMaterialCodes()) {
String normalizedCode = normalizeOptional(materialCode);
if (normalizedCode != null) {
materialCodes.add(normalizedCode.toUpperCase(Locale.ROOT));
}
}
}
return new NormalizedTranslationRequest(
payload.getCategoryId(),
sourceLanguage,
Boolean.TRUE.equals(payload.getOverwriteExisting()),
List.copyOf(materialCodes),
names,
excerpts,
descriptions,
seoTitles,
seoDescriptions
);
}
private List<String> resolveTargetLanguages(NormalizedTranslationRequest request) {
List<String> targetLanguages = new ArrayList<>();
for (String language : SUPPORTED_LANGUAGES) {
if (language.equals(request.sourceLanguage())) {
continue;
}
if (request.overwriteExisting() || needsTranslation(request, language)) {
targetLanguages.add(language);
}
}
return targetLanguages;
}
private boolean needsTranslation(NormalizedTranslationRequest request, String language) {
return request.names().get(language).isBlank()
|| request.excerpts().get(language).isBlank()
|| normalizeRichTextOptional(request.descriptions().get(language)) == null
|| request.seoTitles().get(language).isBlank()
|| request.seoDescriptions().get(language).isBlank();
}
private CategoryContext loadCategoryContext(UUID categoryId) {
if (categoryId == null) {
return null;
}
ShopCategory category = shopCategoryRepository.findById(categoryId).orElse(null);
if (category == null) {
return null;
}
return new CategoryContext(
category.getSlug(),
Map.of(
"it", safeValue(category.getNameIt()),
"en", safeValue(category.getNameEn()),
"de", safeValue(category.getNameDe()),
"fr", safeValue(category.getNameFr())
),
Map.of(
"it", safeValue(category.getDescriptionIt()),
"en", safeValue(category.getDescriptionEn()),
"de", safeValue(category.getDescriptionDe()),
"fr", safeValue(category.getDescriptionFr())
)
);
}
private String buildBusinessContext(CategoryContext categoryContext, List<String> materialCodes) {
StringBuilder context = new StringBuilder(DEFAULT_SHOP_CONTEXT);
if (!additionalBusinessContext.isBlank()) {
context.append('\n').append(additionalBusinessContext.trim());
}
if (categoryContext != null) {
context.append("\nCategory slug: ").append(categoryContext.slug());
context.append("\nCategory names: ").append(writeJson(categoryContext.names()));
if (categoryContext.descriptions().values().stream().anyMatch(value -> !value.isBlank())) {
context.append("\nCategory descriptions: ").append(writeJson(categoryContext.descriptions()));
}
}
if (materialCodes != null && !materialCodes.isEmpty()) {
context.append("\nMaterial codes present in the product: ").append(String.join(", ", materialCodes));
}
return context.toString();
}
private String buildInstructions(String task, String businessContext) {
return """
You are a senior ecommerce localization editor.
Task: %s
Return only the function call arguments that match the provided schema.
Always preserve meaning, HTML safety, and technical precision.
Never invent specifications or marketing claims not present in the source.
If a source field is empty, return an empty string for that field.
General context:
%s
""".formatted(task, businessContext);
}
private String buildGenerationInput(NormalizedTranslationRequest request,
List<String> targetLanguages,
CategoryContext categoryContext) {
ObjectNode input = objectMapper.createObjectNode();
input.put("sourceLanguage", request.sourceLanguage());
input.set("targetLanguages", objectMapper.valueToTree(targetLanguages));
input.put("overwriteExisting", request.overwriteExisting());
input.set("source", localizedFieldNode(request, request.sourceLanguage()));
input.set("existingTranslations", existingTranslationsNode(request, targetLanguages));
input.set("materialCodes", objectMapper.valueToTree(request.materialCodes()));
if (categoryContext != null) {
input.put("categorySlug", categoryContext.slug());
input.set("categoryNames", objectMapper.valueToTree(categoryContext.names()));
}
return writeJson(input);
}
private String buildReviewInput(NormalizedTranslationRequest request,
TranslationBundle generated,
List<String> targetLanguages,
CategoryContext categoryContext,
List<String> validationNotes) {
ObjectNode input = objectMapper.createObjectNode();
input.put("sourceLanguage", request.sourceLanguage());
input.set("targetLanguages", objectMapper.valueToTree(targetLanguages));
input.set("source", localizedFieldNode(request, request.sourceLanguage()));
input.set("generatedTranslations", generated.toJsonNode(objectMapper));
input.set("validationNotes", objectMapper.valueToTree(validationNotes));
input.set("materialCodes", objectMapper.valueToTree(request.materialCodes()));
if (categoryContext != null) {
input.put("categorySlug", categoryContext.slug());
input.set("categoryNames", objectMapper.valueToTree(categoryContext.names()));
}
return writeJson(input);
}
private ObjectNode localizedFieldNode(NormalizedTranslationRequest request, String language) {
ObjectNode node = objectMapper.createObjectNode();
node.put("name", request.names().get(language));
node.put("excerpt", request.excerpts().get(language));
node.put("description", request.descriptions().get(language));
node.put("seoTitle", request.seoTitles().get(language));
node.put("seoDescription", request.seoDescriptions().get(language));
return node;
}
private ObjectNode existingTranslationsNode(NormalizedTranslationRequest request, List<String> targetLanguages) {
ObjectNode node = objectMapper.createObjectNode();
for (String language : targetLanguages) {
node.set(language, localizedFieldNode(request, language));
}
return node;
}
private ObjectNode buildTranslationToolSchema(List<String> targetLanguages) {
ObjectNode root = objectMapper.createObjectNode();
root.put("type", "object");
root.put("additionalProperties", false);
ObjectNode properties = root.putObject("properties");
ObjectNode translations = properties.putObject("translations");
translations.put("type", "object");
translations.put("additionalProperties", false);
ObjectNode translationProperties = translations.putObject("properties");
ArrayNode requiredTranslations = translations.putArray("required");
for (String language : targetLanguages) {
translationProperties.set(language, buildTranslationSchemaForLanguage(language));
requiredTranslations.add(language);
}
ArrayNode required = root.putArray("required");
required.add("translations");
return root;
}
private ObjectNode buildTranslationSchemaForLanguage(String language) {
ObjectNode languageSchema = objectMapper.createObjectNode();
languageSchema.put("type", "object");
languageSchema.put("additionalProperties", false);
languageSchema.put("description", "Localized product copy for language " + language);
ObjectNode properties = languageSchema.putObject("properties");
addSchemaString(properties, "name", "Translated product name. Never empty.");
addSchemaString(properties, "excerpt", "Short excerpt. Empty string if source excerpt is empty.");
addSchemaString(properties, "description", "Product description as safe HTML or empty string if source description is empty.");
addSchemaString(properties, "seoTitle", "SEO title. Empty string if source SEO title is empty.");
addSchemaString(properties, "seoDescription", "SEO description, ideally under 160 characters. Empty string if source SEO description is empty.");
ArrayNode required = languageSchema.putArray("required");
required.add("name");
required.add("excerpt");
required.add("description");
required.add("seoTitle");
required.add("seoDescription");
return languageSchema;
}
private void addSchemaString(ObjectNode properties, String name, String description) {
ObjectNode property = properties.putObject(name);
property.put("type", "string");
property.put("description", description);
}
private TranslationBundle callOpenAiFunction(String functionName,
String functionDescription,
String instructions,
String input,
ObjectNode parametersSchema,
String cacheSuffix) {
ObjectNode requestPayload = objectMapper.createObjectNode();
requestPayload.put("model", model);
requestPayload.put("instructions", instructions);
requestPayload.put("input", input);
requestPayload.put("tool_choice", "required");
requestPayload.put("temperature", 0.2);
requestPayload.put("store", false);
requestPayload.put("prompt_cache_key", promptCacheKeyPrefix + ":" + cacheSuffix);
ArrayNode tools = requestPayload.putArray("tools");
ObjectNode tool = tools.addObject();
tool.put("type", "function");
tool.put("name", functionName);
tool.put("description", functionDescription);
tool.put("strict", true);
tool.set("parameters", parametersSchema);
JsonNode responseNode = postResponsesRequest(requestPayload);
JsonNode output = responseNode.path("output");
if (output.isArray()) {
for (JsonNode item : output) {
if ("function_call".equals(item.path("type").asText())) {
String arguments = item.path("arguments").asText("");
if (arguments.isBlank()) {
break;
}
try {
JsonNode argumentsNode = objectMapper.readTree(arguments);
JsonNode translationsNode = argumentsNode.path("translations");
if (!translationsNode.isObject()) {
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
"OpenAI returned a function call without translations"
);
}
return TranslationBundle.fromJson(translationsNode);
} catch (JsonProcessingException exception) {
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
"OpenAI returned invalid JSON arguments",
exception
);
}
}
}
}
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
"OpenAI did not return the expected function call"
);
}
private JsonNode postResponsesRequest(ObjectNode requestPayload) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/responses"))
.timeout(timeout)
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(writeJson(requestPayload)))
.build();
try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode body = readJson(response.body());
if (response.statusCode() >= 400) {
String message = body.path("error").path("message").asText("").trim();
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
message.isBlank() ? "OpenAI translation request failed" : message
);
}
return body;
} catch (IOException exception) {
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
"Unable to read the OpenAI translation response",
exception
);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
"The OpenAI translation request was interrupted",
exception
);
}
}
private List<String> buildValidationNotes(TranslationBundle bundle, List<String> targetLanguages) {
List<String> notes = new ArrayList<>();
for (String language : targetLanguages) {
if (bundle.names().getOrDefault(language, "").isBlank()) {
notes.add(language + ": translated name is empty and must be fixed");
}
String seoDescription = bundle.seoDescriptions().getOrDefault(language, "");
if (seoDescription.length() > 160) {
notes.add(language + ": seoDescription exceeds 160 characters and must be shortened");
}
String description = bundle.descriptions().getOrDefault(language, "");
if (!description.isBlank() && normalizeRichTextOptional(description) == null) {
notes.add(language + ": description lost meaningful text during sanitization");
}
}
if (notes.isEmpty()) {
notes.add("No structural validation issues were found. Review naturalness, terminology, SEO clarity, and consistency.");
}
return notes;
}
private TranslationBundle sanitizeBundle(TranslationBundle bundle, List<String> targetLanguages) {
Map<String, String> names = new LinkedHashMap<>();
Map<String, String> excerpts = new LinkedHashMap<>();
Map<String, String> descriptions = new LinkedHashMap<>();
Map<String, String> seoTitles = new LinkedHashMap<>();
Map<String, String> seoDescriptions = new LinkedHashMap<>();
for (String language : targetLanguages) {
names.put(language, safeValue(bundle.names().get(language)));
excerpts.put(language, safeValue(bundle.excerpts().get(language)));
descriptions.put(language, safeDescription(bundle.descriptions().get(language)));
seoTitles.put(language, safeValue(bundle.seoTitles().get(language)));
seoDescriptions.put(language, limitSeoDescription(safeValue(bundle.seoDescriptions().get(language))));
}
return new TranslationBundle(names, excerpts, descriptions, seoTitles, seoDescriptions);
}
private void ensureRequiredTranslations(TranslationBundle bundle, List<String> targetLanguages) {
for (String language : targetLanguages) {
if (bundle.names().getOrDefault(language, "").isBlank()) {
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
"OpenAI did not return a valid translated name for " + language.toUpperCase(Locale.ROOT)
);
}
}
}
private AdminTranslateShopProductResponse toResponse(String sourceLanguage,
List<String> targetLanguages,
TranslationBundle bundle) {
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
response.setSourceLanguage(sourceLanguage);
response.setTargetLanguages(targetLanguages);
response.setNames(bundle.names());
response.setExcerpts(bundle.excerpts());
response.setDescriptions(bundle.descriptions());
response.setSeoTitles(bundle.seoTitles());
response.setSeoDescriptions(bundle.seoDescriptions());
return response;
}
private AdminTranslateShopProductResponse emptyResponse(String sourceLanguage) {
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
response.setSourceLanguage(sourceLanguage);
response.setTargetLanguages(List.of());
response.setNames(Map.of());
response.setExcerpts(Map.of());
response.setDescriptions(Map.of());
response.setSeoTitles(Map.of());
response.setSeoDescriptions(Map.of());
return response;
}
private Map<String, String> normalizeLocalizedMap(Map<String, String> rawValues, boolean richText) {
Map<String, String> normalized = new LinkedHashMap<>();
for (String language : SUPPORTED_LANGUAGES) {
String value = rawValues != null ? rawValues.get(language) : null;
if (richText) {
normalized.put(language, normalizeRichTextOptional(value) != null ? normalizeRichTextOptional(value) : "");
} else {
normalized.put(language, safeValue(value));
}
}
return normalized;
}
private String safeValue(String value) {
return value == null ? "" : value.trim();
}
private String safeDescription(String value) {
String normalized = normalizeRichTextOptional(value);
return normalized != null ? normalized : "";
}
private String limitSeoDescription(String value) {
String normalized = safeValue(value);
if (normalized.length() <= 160) {
return normalized;
}
int lastSpace = normalized.lastIndexOf(' ', 157);
if (lastSpace >= 120) {
return normalized.substring(0, lastSpace).trim();
}
return normalized.substring(0, 160).trim();
}
private String normalizeOptional(String value) {
if (value == null) {
return null;
}
String normalized = value.trim();
return normalized.isBlank() ? null : normalized;
}
private String normalizeLanguage(String language) {
if (language == null) {
return "";
}
String normalized = language.trim().toLowerCase(Locale.ROOT);
int separatorIndex = normalized.indexOf('-');
if (separatorIndex > 0) {
normalized = normalized.substring(0, separatorIndex);
}
return normalized;
}
private String normalizeRichTextOptional(String value) {
String normalized = normalizeOptional(value);
if (normalized == null) {
return null;
}
String sanitized = Jsoup.clean(
normalized,
"",
PRODUCT_DESCRIPTION_SAFELIST,
new Document.OutputSettings().prettyPrint(false)
).trim();
if (sanitized.isBlank()) {
return null;
}
String plainText = Jsoup.parse(sanitized).text();
return plainText != null && !plainText.trim().isEmpty() ? sanitized : null;
}
private String normalizeBaseUrl(String rawBaseUrl) {
String normalized = rawBaseUrl != null && !rawBaseUrl.isBlank()
? rawBaseUrl.trim()
: "https://api.openai.com/v1";
while (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
private String writeJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR,
"Unable to serialize translation payload",
exception
);
}
}
private JsonNode readJson(String rawJson) throws IOException {
return objectMapper.readTree(rawJson);
}
private record NormalizedTranslationRequest(UUID categoryId,
String sourceLanguage,
boolean overwriteExisting,
List<String> materialCodes,
Map<String, String> names,
Map<String, String> excerpts,
Map<String, String> descriptions,
Map<String, String> seoTitles,
Map<String, String> seoDescriptions) {
}
private record CategoryContext(String slug,
Map<String, String> names,
Map<String, String> descriptions) {
}
private record TranslationBundle(Map<String, String> names,
Map<String, String> excerpts,
Map<String, String> descriptions,
Map<String, String> seoTitles,
Map<String, String> seoDescriptions) {
static TranslationBundle fromJson(JsonNode translationsNode) {
Map<String, String> names = new LinkedHashMap<>();
Map<String, String> excerpts = new LinkedHashMap<>();
Map<String, String> descriptions = new LinkedHashMap<>();
Map<String, String> seoTitles = new LinkedHashMap<>();
Map<String, String> seoDescriptions = new LinkedHashMap<>();
translationsNode.fieldNames().forEachRemaining(language -> {
JsonNode localizedNode = translationsNode.path(language);
names.put(language, localizedNode.path("name").asText(""));
excerpts.put(language, localizedNode.path("excerpt").asText(""));
descriptions.put(language, localizedNode.path("description").asText(""));
seoTitles.put(language, localizedNode.path("seoTitle").asText(""));
seoDescriptions.put(language, localizedNode.path("seoDescription").asText(""));
});
return new TranslationBundle(names, excerpts, descriptions, seoTitles, seoDescriptions);
}
ObjectNode toJsonNode(ObjectMapper objectMapper) {
ObjectNode root = objectMapper.createObjectNode();
ObjectNode translations = root.putObject("translations");
for (String language : names.keySet()) {
ObjectNode languageNode = translations.putObject(language);
languageNode.put("name", names.getOrDefault(language, ""));
languageNode.put("excerpt", excerpts.getOrDefault(language, ""));
languageNode.put("description", descriptions.getOrDefault(language, ""));
languageNode.put("seoTitle", seoTitles.getOrDefault(language, ""));
languageNode.put("seoDescription", seoDescriptions.getOrDefault(language, ""));
}
return root;
}
}
}

View File

@@ -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);

View File

@@ -54,7 +54,6 @@ public class QuoteStorageService {
return switch (ext) {
case "stl" -> "stl";
case "3mf" -> "3mf";
case "step", "stp" -> "step";
default -> fallback;
};
}

View File

@@ -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";
}
}

View File

@@ -399,6 +399,7 @@ public class PublicShopCatalogService {
Map<String, String> variantColorHexByMaterialAndColor,
String language) {
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
Map<String, String> 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<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language);
String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language);
Map<String, String> 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;

View File

@@ -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<String, String> buildLocalizedProductPaths(ShopProduct product) {
Map<String, String> 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;
}
}

View File

@@ -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<String> 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<String, String> 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<String, String> 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(" <xhtml:link rel=\"alternate\" hreflang=\"")
.append(language)
.append(HREFLANG_BY_LANGUAGE.getOrDefault(language, language))
.append("\" href=\"")
.append(xmlEscape(href))
.append("\" />\n");
@@ -186,48 +190,6 @@ public class ShopSitemapService {
xml.append(" </url>\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");

View File

@@ -57,6 +57,12 @@ app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:
app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
app.sitemap.shop.cache-seconds=${APP_SITEMAP_SHOP_CACHE_SECONDS:3600}
openai.translation.api-key=${OPENAI_API_KEY:}
openai.translation.base-url=${OPENAI_BASE_URL:https://api.openai.com/v1}
openai.translation.model=${OPENAI_TRANSLATION_MODEL:gpt-5.4}
openai.translation.timeout-seconds=${OPENAI_TRANSLATION_TIMEOUT_SECONDS:45}
openai.translation.prompt-cache-key-prefix=${OPENAI_TRANSLATION_PROMPT_CACHE_KEY_PREFIX:printcalc-shop-product-translation-v1}
openai.translation.business-context=${OPENAI_TRANSLATION_BUSINESS_CONTEXT:}
# Admin back-office authentication
admin.password=${ADMIN_PASSWORD}

View File

@@ -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 @@
</head>
<body>
<div class="container">
<h1>Nuova richiesta di contatto</h1>
<div class="header">
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
<h1>Nuova richiesta di contatto</h1>
</div>
<p>E' stata ricevuta una nuova richiesta dal form contatti/su misura.</p>
<table>

View File

@@ -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 @@
</head>
<body>
<div class="container">
<h1 th:text="${headlineText}">We received your contact request</h1>
<div class="header">
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
<h1 th:text="${headlineText}">We received your contact request</h1>
</div>
<p th:text="${greetingText}">Hi customer,</p>
<p th:text="${introText}">Thank you for contacting us. Our team will reply as soon as possible.</p>
<p>

View File

@@ -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 @@
<body>
<div class="container">
<div class="header">
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
<h1 th:text="${headlineText}">Thank you for your order #00000000</h1>
</div>

View File

@@ -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 @@
<body>
<div class="container">
<div class="header">
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
<h1 th:text="${headlineText}">Your order #00000000 has been shipped</h1>
</div>

View File

@@ -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 @@
<body>
<div class="container">
<div class="header">
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
<h1 th:text="${headlineText}">Payment confirmed for order #00000000</h1>
</div>

View File

@@ -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 @@
<body>
<div class="container">
<div class="header">
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
<h1 th:text="${headlineText}">Payment reported for order #00000000</h1>
</div>

View File

@@ -0,0 +1,149 @@
package com.printcalculator.controller.admin;
import com.printcalculator.config.SecurityConfig;
import com.printcalculator.dto.AdminTranslateShopProductResponse;
import com.printcalculator.service.admin.AdminShopProductControllerService;
import com.printcalculator.service.admin.AdminShopProductTranslationService;
import com.printcalculator.security.AdminLoginThrottleService;
import com.printcalculator.security.AdminSessionAuthenticationFilter;
import com.printcalculator.security.AdminSessionService;
import jakarta.servlet.http.Cookie;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
import org.springframework.transaction.support.DefaultTransactionStatus;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(controllers = {AdminAuthController.class, AdminShopProductController.class})
@Import({
SecurityConfig.class,
AdminSessionAuthenticationFilter.class,
AdminSessionService.class,
AdminLoginThrottleService.class,
AdminShopProductControllerSecurityTest.TransactionTestConfig.class
})
@TestPropertySource(properties = {
"admin.password=test-admin-password",
"admin.session.secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"admin.session.ttl-minutes=60"
})
class AdminShopProductControllerSecurityTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private AdminShopProductControllerService adminShopProductControllerService;
@MockitoBean
private AdminShopProductTranslationService adminShopProductTranslationService;
@Test
void translateProduct_withoutAdminCookie_shouldReturn401() throws Exception {
mockMvc.perform(post("/api/admin/shop/products/translate")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"sourceLanguage\":\"it\",\"names\":{\"it\":\"Supporto cavo\"}}"))
.andExpect(status().isUnauthorized());
}
@Test
void translateProduct_withAdminCookie_shouldReturnTranslations() throws Exception {
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
response.setSourceLanguage("it");
response.setTargetLanguages(List.of("en", "de", "fr"));
response.setNames(Map.of("en", "Desk cable clip"));
response.setExcerpts(Map.of());
response.setDescriptions(Map.of());
response.setSeoTitles(Map.of());
response.setSeoDescriptions(Map.of());
when(adminShopProductTranslationService.translateProduct(org.mockito.ArgumentMatchers.any()))
.thenReturn(response);
mockMvc.perform(post("/api/admin/shop/products/translate")
.cookie(loginAndExtractCookie())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"sourceLanguage":"it",
"overwriteExisting":false,
"materialCodes":["PLA"],
"names":{"it":"Supporto cavo"},
"excerpts":{"it":"Accessorio tecnico"},
"descriptions":{"it":"<p>Descrizione</p>"},
"seoTitles":{"it":"SEO IT"},
"seoDescriptions":{"it":"SEO description IT"}
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.sourceLanguage").value("it"))
.andExpect(jsonPath("$.targetLanguages[0]").value("en"))
.andExpect(jsonPath("$.names.en").value("Desk cable clip"));
}
private Cookie loginAndExtractCookie() throws Exception {
MvcResult login = mockMvc.perform(post("/api/admin/auth/login")
.with(req -> {
req.setRemoteAddr("10.0.0.44");
return req;
})
.contentType(MediaType.APPLICATION_JSON)
.content("{\"password\":\"test-admin-password\"}"))
.andExpect(status().isOk())
.andReturn();
String setCookie = login.getResponse().getHeader(HttpHeaders.SET_COOKIE);
assertNotNull(setCookie);
String[] parts = setCookie.split(";", 2);
String[] keyValue = parts[0].split("=", 2);
return new Cookie(keyValue[0], keyValue.length > 1 ? keyValue[1] : "");
}
@TestConfiguration
static class TransactionTestConfig {
@Bean
PlatformTransactionManager transactionManager() {
return new AbstractPlatformTransactionManager() {
@Override
protected Object doGetTransaction() {
return new Object();
}
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
// No-op transaction manager for WebMvc security tests.
}
@Override
protected void doCommit(DefaultTransactionStatus status) {
// No-op transaction manager for WebMvc security tests.
}
@Override
protected void doRollback(DefaultTransactionStatus status) {
// No-op transaction manager for WebMvc security tests.
}
};
}
}
}

View File

@@ -0,0 +1,226 @@
package com.printcalculator.service.admin;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.printcalculator.dto.AdminTranslateShopProductRequest;
import com.printcalculator.dto.AdminTranslateShopProductResponse;
import com.printcalculator.repository.ShopCategoryRepository;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AdminShopProductTranslationServiceTest {
@Mock
private ShopCategoryRepository shopCategoryRepository;
private HttpServer server;
@AfterEach
void tearDown() {
if (server != null) {
server.stop(0);
}
}
@Test
void translateProduct_shouldCallOpenAiTwiceAndReturnReviewedTranslations() throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
List<JsonNode> capturedRequests = new CopyOnWriteArrayList<>();
AtomicInteger requestCounter = new AtomicInteger();
server = HttpServer.create(new InetSocketAddress(0), 0);
server.createContext("/v1/responses", exchange -> {
capturedRequests.add(readBody(objectMapper, exchange));
int currentRequest = requestCounter.incrementAndGet();
String functionName = currentRequest == 1
? "generate_product_translations"
: "review_product_translations";
String body = functionResponse(
objectMapper,
functionName,
Map.of(
"en", localized("Desk cable clip", "Technical desk accessory", "<p>Desk cable clip for clean cable routing.</p>", "Desk cable clip | 3D fab", "Technical 3D printed desk cable clip for clean cable routing."),
"de", localized("Schreibtisch-Kabelhalter", "Technisches Schreibtisch-Zubehor", "<p>Kabelhalter fur einen aufgeraumten Schreibtisch.</p>", "Schreibtisch-Kabelhalter | 3D fab", "Technischer 3D-gedruckter Kabelhalter fur einen aufgeraumten Schreibtisch."),
"fr", localized("Support de cable de bureau", "Accessoire technique de bureau", "<p>Support de cable pour un bureau ordonne.</p>", "Support de cable de bureau | 3D fab", "Support de cable de bureau imprime en 3D pour garder un espace ordonne.")
)
);
writeJsonResponse(exchange, body);
});
server.start();
when(shopCategoryRepository.findById(UUID.fromString("00000000-0000-0000-0000-000000000001")))
.thenReturn(Optional.empty());
AdminShopProductTranslationService service = new AdminShopProductTranslationService(
shopCategoryRepository,
objectMapper,
"test-key",
"http://127.0.0.1:" + server.getAddress().getPort() + "/v1",
"gpt-5.4",
20,
"test-cache-key",
"Use concise ecommerce wording."
);
AdminTranslateShopProductRequest payload = new AdminTranslateShopProductRequest();
payload.setCategoryId(UUID.fromString("00000000-0000-0000-0000-000000000001"));
payload.setSourceLanguage("it");
payload.setOverwriteExisting(false);
payload.setMaterialCodes(List.of("pla", "petg"));
payload.setNames(Map.of(
"it", "Supporto cavo scrivania",
"en", "",
"de", "",
"fr", ""
));
payload.setExcerpts(Map.of(
"it", "Accessorio tecnico",
"en", "",
"de", "",
"fr", ""
));
payload.setDescriptions(Map.of(
"it", "<p>Supporto per tenere i cavi ordinati sulla scrivania.</p>",
"en", "",
"de", "",
"fr", ""
));
payload.setSeoTitles(Map.of(
"it", "Supporto cavo scrivania | 3D fab",
"en", "",
"de", "",
"fr", ""
));
payload.setSeoDescriptions(Map.of(
"it", "Supporto tecnico stampato in 3D per tenere i cavi in ordine sulla scrivania.",
"en", "",
"de", "",
"fr", ""
));
AdminTranslateShopProductResponse response = service.translateProduct(payload);
assertEquals(List.of("en", "de", "fr"), response.getTargetLanguages());
assertEquals("Desk cable clip", response.getNames().get("en"));
assertTrue(response.getDescriptions().get("en").contains("<p>"));
assertEquals(2, capturedRequests.size());
assertEquals("required", capturedRequests.get(0).path("tool_choice").asText());
assertEquals("test-cache-key:generate", capturedRequests.get(0).path("prompt_cache_key").asText());
assertEquals("test-cache-key:review", capturedRequests.get(1).path("prompt_cache_key").asText());
}
@Test
void translateProduct_shouldSkipOpenAiWhenNoTargetLanguageNeedsUpdates() {
ObjectMapper objectMapper = new ObjectMapper();
AdminShopProductTranslationService service = new AdminShopProductTranslationService(
shopCategoryRepository,
objectMapper,
"test-key",
"http://127.0.0.1:65535/v1",
"gpt-5.4",
20,
"test-cache-key",
""
);
AdminTranslateShopProductRequest payload = new AdminTranslateShopProductRequest();
payload.setSourceLanguage("it");
payload.setOverwriteExisting(false);
payload.setNames(Map.of(
"it", "Supporto cavo scrivania",
"en", "Desk cable clip",
"de", "Schreibtisch-Kabelhalter",
"fr", "Support de cable de bureau"
));
payload.setExcerpts(Map.of(
"it", "Accessorio tecnico",
"en", "Technical desk accessory",
"de", "Technisches Schreibtisch-Zubehor",
"fr", "Accessoire technique de bureau"
));
payload.setDescriptions(Map.of(
"it", "<p>Descrizione</p>",
"en", "<p>Description</p>",
"de", "<p>Beschreibung</p>",
"fr", "<p>Description</p>"
));
payload.setSeoTitles(Map.of(
"it", "SEO IT",
"en", "SEO EN",
"de", "SEO DE",
"fr", "SEO FR"
));
payload.setSeoDescriptions(Map.of(
"it", "SEO description IT",
"en", "SEO description EN",
"de", "SEO description DE",
"fr", "SEO description FR"
));
AdminTranslateShopProductResponse response = service.translateProduct(payload);
assertTrue(response.getTargetLanguages().isEmpty());
}
private JsonNode readBody(ObjectMapper objectMapper, HttpExchange exchange) throws IOException {
return objectMapper.readTree(exchange.getRequestBody());
}
private void writeJsonResponse(HttpExchange exchange, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(200, bytes.length);
try (OutputStream outputStream = exchange.getResponseBody()) {
outputStream.write(bytes);
}
}
private String functionResponse(ObjectMapper objectMapper,
String functionName,
Map<String, Map<String, String>> translations) throws IOException {
Map<String, Object> arguments = Map.of("translations", translations);
Map<String, Object> item = Map.of(
"type", "function_call",
"name", functionName,
"arguments", objectMapper.writeValueAsString(arguments)
);
Map<String, Object> response = Map.of(
"id", "resp_test",
"output", List.of(item)
);
return objectMapper.writeValueAsString(response);
}
private Map<String, String> localized(String name,
String excerpt,
String description,
String seoTitle,
String seoDescription) {
return Map.of(
"name", name,
"excerpt", excerpt,
"description", description,
"seoTitle", seoTitle,
"seoDescription", seoDescription
);
}
}

View File

@@ -92,15 +92,15 @@ class ShopSitemapServiceTest {
assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/accessori</loc>"));
assertTrue(xml.contains("<loc>https://3d-fab.ch/de/shop/accessori</loc>"));
assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/accessori</loc>"));
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("<loc>https://3d-fab.ch/it/shop/p/123e4567-supporto-bici</loc>"));
assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/p/123e4567-bike-holder</loc>"));
assertTrue(xml.contains("<loc>https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter</loc>"));
assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/p/123e4567-support-velo</loc>"));
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("<lastmod>2026-03-11T07:30:00Z</lastmod>"));
assertFalse(xml.contains("33333333-draft"));

View File

@@ -29,6 +29,12 @@ services:
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
- ADMIN_SESSION_TTL_MINUTES=${ADMIN_SESSION_TTL_MINUTES:-480}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-}
- OPENAI_TRANSLATION_MODEL=${OPENAI_TRANSLATION_MODEL:-}
- OPENAI_TRANSLATION_TIMEOUT_SECONDS=${OPENAI_TRANSLATION_TIMEOUT_SECONDS:-}
- OPENAI_TRANSLATION_PROMPT_CACHE_KEY_PREFIX=${OPENAI_TRANSLATION_PROMPT_CACHE_KEY_PREFIX:-}
- OPENAI_TRANSLATION_BUSINESS_CONTEXT=${OPENAI_TRANSLATION_BUSINESS_CONTEXT:-}
- TEMP_DIR=/app/temp
- PROFILES_DIR=/app/profiles
- MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -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"
}
]
}

View File

@@ -2,40 +2,40 @@
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://3d-fab.ch/it</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://3d-fab.ch/en</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://3d-fab.ch/de</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
<changefreq>weekly</changefreq>
<priority>1.0</priority>
@@ -43,40 +43,40 @@
<url>
<loc>https://3d-fab.ch/it/calculator/basic</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/calculator/basic</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/calculator/basic</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/calculator/basic</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
<changefreq>weekly</changefreq>
<priority>0.9</priority>
@@ -84,40 +84,40 @@
<url>
<loc>https://3d-fab.ch/it/calculator/advanced</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/calculator/advanced</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/calculator/advanced</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/calculator/advanced</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
@@ -125,40 +125,40 @@
<url>
<loc>https://3d-fab.ch/it/shop</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/shop</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/shop</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/shop</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
@@ -166,40 +166,40 @@
<url>
<loc>https://3d-fab.ch/it/about</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/about</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/about</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/about</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
@@ -207,40 +207,40 @@
<url>
<loc>https://3d-fab.ch/it/contact</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/contact</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/contact</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/contact</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
@@ -248,40 +248,40 @@
<url>
<loc>https://3d-fab.ch/it/privacy</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/privacy</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/privacy</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/privacy</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
@@ -289,40 +289,40 @@
<url>
<loc>https://3d-fab.ch/it/terms</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/terms</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/terms</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/terms</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>

View File

@@ -1 +1,14 @@
<router-outlet></router-outlet>
@if (siteIntroState() !== "hidden") {
<div
class="site-intro"
[class.site-intro--closing]="siteIntroState() === 'closing'"
aria-hidden="true"
>
<app-brand-animation-logo
class="site-intro__logo"
variant="site-intro"
></app-brand-animation-logo>
</div>
}

View File

@@ -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);
}

View File

@@ -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);
});
});
}
}

View File

@@ -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<string, string | string[] | undefined>;
} | 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<string, string | string[] | undefined>;
} | 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;
}

View File

@@ -15,18 +15,8 @@ const appChildRoutes: Routes = [
loadComponent: () =>
import('./features/home/home.component').then((m) => m.HomeComponent),
data: {
seoTitleByLang: {
it: 'Stampa 3D su misura in Ticino | Prototipi, ricambi e piccole serie - 3D Fab',
en: 'Custom 3D Printing in Switzerland | Prototypes, Spare Parts & Short Runs - 3D Fab',
de: '3D-Druck in Zürich | Prototypen, Ersatzteile und Kleinserien - 3D Fab',
fr: 'Impression 3D à Bienne | Prototypes, pièces et petites séries - 3D Fab',
},
seoDescriptionByLang: {
it: 'Servizio di stampa 3D in Ticino per prototipi, pezzi di ricambio e piccole serie. Shop tecnico e supporto CAD, con preventivo rapido da file STL.',
en: 'Swiss-based 3D printing service for prototypes, spare parts and short production runs. Technical shop and CAD support, with fast quotes from STL files.',
de: '3D-Druckservice in Zürich für Prototypen, Ersatzteile und Kleinserien. Technischer Shop und CAD-Service, mit schneller Angebotsanfrage aus STL-Dateien.',
fr: "Service d'impression 3D à Bienne pour prototypes, pièces de rechange et petites séries. Boutique technique et support CAD, avec devis rapide depuis un fichier STL.",
},
seoTitleKey: 'SEO.ROUTES.HOME.TITLE',
seoDescriptionKey: 'SEO.ROUTES.HOME.DESCRIPTION',
},
},
{
@@ -36,9 +26,8 @@ const appChildRoutes: Routes = [
(m) => m.CALCULATOR_ROUTES,
),
data: {
seoTitle: 'Calcolatore preventivo stampa 3D | 3D fab',
seoDescription:
'Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.',
seoTitleKey: 'SEO.ROUTES.CALCULATOR.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.DESCRIPTION',
},
},
{
@@ -46,9 +35,8 @@ const appChildRoutes: Routes = [
loadChildren: () =>
import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES),
data: {
seoTitle: 'Shop 3D fab',
seoDescription:
'Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.',
seoTitleKey: 'SEO.ROUTES.SHOP.TITLE',
seoDescriptionKey: 'SEO.ROUTES.SHOP.DESCRIPTION',
},
},
{
@@ -56,21 +44,19 @@ const appChildRoutes: Routes = [
loadChildren: () =>
import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES),
data: {
seoTitle: 'Chi siamo | 3D fab',
seoDescription:
'Scopri il team 3D fab e il laboratorio di stampa 3D con sedi in Ticino e Bienne.',
seoTitleKey: 'SEO.ROUTES.ABOUT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ABOUT.DESCRIPTION',
},
},
/* {
/* {
path: 'materials',
loadComponent: () =>
import('./features/materials/materials-page.component').then(
(m) => m.MaterialsPageComponent,
),
data: {
seoTitle: 'Qualita e Materiali | 3D fab',
seoDescription:
'Confronta materiali di stampa 3D con radar chart interattivo, proprieta tecniche e fonti citate.',
seoTitleKey: 'SEO.ROUTES.MATERIALS.TITLE',
seoDescriptionKey: 'SEO.ROUTES.MATERIALS.DESCRIPTION',
},
},*/
{
@@ -78,9 +64,8 @@ const appChildRoutes: Routes = [
loadChildren: () =>
import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES),
data: {
seoTitle: 'Contatti | 3D fab',
seoDescription:
'Contatta 3D fab per preventivi, supporto tecnico e richieste personalizzate di stampa 3D.',
seoTitleKey: 'SEO.ROUTES.CONTACT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CONTACT.DESCRIPTION',
},
},
{
@@ -90,7 +75,8 @@ const appChildRoutes: Routes = [
(m) => m.CheckoutComponent,
),
data: {
seoTitle: 'Checkout | 3D fab',
seoTitleKey: 'SEO.ROUTES.CHECKOUT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CHECKOUT.DESCRIPTION',
seoRobots: 'noindex, nofollow',
},
},
@@ -101,7 +87,8 @@ const appChildRoutes: Routes = [
(m) => m.CheckoutComponent,
),
data: {
seoTitle: 'Checkout | 3D fab',
seoTitleKey: 'SEO.ROUTES.CHECKOUT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CHECKOUT.DESCRIPTION',
seoRobots: 'noindex, nofollow',
},
},
@@ -110,7 +97,8 @@ const appChildRoutes: Routes = [
loadComponent: () =>
import('./features/order/order.component').then((m) => m.OrderComponent),
data: {
seoTitle: 'Ordine | 3D fab',
seoTitleKey: 'SEO.ROUTES.ORDER.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ORDER.DESCRIPTION',
seoRobots: 'noindex, nofollow',
},
},
@@ -119,7 +107,8 @@ const appChildRoutes: Routes = [
loadComponent: () =>
import('./features/order/order.component').then((m) => m.OrderComponent),
data: {
seoTitle: 'Ordine | 3D fab',
seoTitleKey: 'SEO.ROUTES.ORDER.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ORDER.DESCRIPTION',
seoRobots: 'noindex, nofollow',
},
},
@@ -133,7 +122,8 @@ const appChildRoutes: Routes = [
loadChildren: () =>
import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES),
data: {
seoTitle: 'Admin | 3D fab',
seoTitleKey: 'SEO.ROUTES.ADMIN.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ADMIN.DESCRIPTION',
seoRobots: 'noindex, nofollow',
},
},
@@ -144,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],

View File

@@ -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;
}

View File

@@ -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<string, TranslationObject> = {
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<SupportedLang, Promise<TranslationObject>>();
const translationLoaders: Record<
SupportedLang,
() => Promise<TranslationObject>
> = {
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<TranslationObject> {
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<TranslationObject> {
const transferStateKey = makeStateKey<TranslationObject>(
`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;
}
}

View File

@@ -1,47 +1,14 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject, REQUEST } from '@angular/core';
type RequestLike = {
protocol?: string;
get?: (name: string) => string | undefined;
headers?: Record<string, string | string[] | undefined>;
};
import {
RequestLike,
resolveRequestOrigin,
} from '../../../core/request-origin';
function isAbsoluteUrl(url: string): boolean {
return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//');
}
function firstHeaderValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null;
}
return typeof value === 'string' ? value : null;
}
function resolveOrigin(request: RequestLike | null): string | null {
if (!request) {
return null;
}
const host =
request.get?.('host') ??
firstHeaderValue(request.headers?.['host']) ??
firstHeaderValue(request.headers?.['x-forwarded-host']);
if (!host) {
return null;
}
const forwardedProtoRaw = firstHeaderValue(
request.headers?.['x-forwarded-proto'],
);
const forwardedProto = forwardedProtoRaw
?.split(',')
.map((part) => part.trim().toLowerCase())
.find(Boolean);
const protocol = forwardedProto || request.protocol || 'http';
return `${protocol}://${host}`;
}
function normalizeRelativePath(url: string): string {
const withoutDot = url.replace(/^\.\//, '');
return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`;
@@ -53,7 +20,7 @@ export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => {
}
const request = inject(REQUEST, { optional: true }) as RequestLike | null;
const origin = resolveOrigin(request);
const origin = resolveRequestOrigin(request);
if (!origin) {
return next(req);
}

View File

@@ -1,14 +1,24 @@
<footer class="footer">
<div class="container footer-inner">
<div class="col">
<span class="brand">3D fab</span>
<img
class="brand"
src="/assets/images/brand-logo-white.svg"
alt="3D Fab"
/>
<p class="copyright">&copy; 2026 3D fab.</p>
</div>
<div class="col links">
<a routerLink="/privacy">{{ "FOOTER.PRIVACY" | translate }}</a>
<a routerLink="/terms">{{ "FOOTER.TERMS" | translate }}</a>
<a routerLink="/contact">{{ "FOOTER.CONTACT" | translate }}</a>
<a [routerLink]="languageService.localizedPath('/privacy')">{{
"FOOTER.PRIVACY" | translate
}}</a>
<a [routerLink]="languageService.localizedPath('/terms')">{{
"FOOTER.TERMS" | translate
}}</a>
<a [routerLink]="languageService.localizedPath('/contact')">{{
"FOOTER.CONTACT" | translate
}}</a>
</div>
<div class="col social">

View File

@@ -38,9 +38,10 @@
}
.brand {
font-weight: 700;
color: white;
display: block;
width: auto;
height: 1.85rem;
max-width: min(9.25rem, 46vw);
margin-bottom: var(--space-2);
}
.copyright {

View File

@@ -1,6 +1,7 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { RouterLink } from '@angular/router';
import { LanguageService } from '../services/language.service';
@Component({
selector: 'app-footer',
@@ -9,4 +10,6 @@ import { RouterLink } from '@angular/router';
templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss'],
})
export class FooterComponent {}
export class FooterComponent {
readonly languageService = inject(LanguageService);
}

View File

@@ -1,6 +1,15 @@
<header class="navbar">
<div class="container navbar-inner">
<a routerLink="/" class="brand">3D <span class="highlight">fab</span></a>
<a [routerLink]="langService.localizedPath('/')" class="brand">
<img
class="brand-logo"
ngSrc="/assets/images/Asset%202.svg"
alt="3D Fab"
width="380"
height="86"
priority
/>
</a>
<div
class="mobile-toggle"
@@ -14,27 +23,33 @@
<nav class="nav-links" [class.open]="isMenuOpen">
<a
routerLink="/"
[routerLink]="langService.localizedPath('/')"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"
(click)="closeMenu()"
>{{ "NAV.HOME" | translate }}</a
>
<a
routerLink="/calculator/basic"
[routerLink]="langService.localizedPath('/calculator/basic')"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: false }"
(click)="closeMenu()"
>{{ "NAV.CALCULATOR" | translate }}</a
>
<a routerLink="/shop" routerLinkActive="active" (click)="closeMenu()">{{
"NAV.SHOP" | translate
}}</a>
<a routerLink="/about" routerLinkActive="active" (click)="closeMenu()">{{
"NAV.ABOUT" | translate
}}</a>
<a
routerLink="/contact"
[routerLink]="langService.localizedPath('/shop')"
routerLinkActive="active"
(click)="closeMenu()"
>{{ "NAV.SHOP" | translate }}</a
>
<a
[routerLink]="langService.localizedPath('/about')"
routerLinkActive="active"
(click)="closeMenu()"
>{{ "NAV.ABOUT" | translate }}</a
>
<a
[routerLink]="langService.localizedPath('/contact')"
routerLinkActive="active"
(click)="closeMenu()"
>{{ "NAV.CONTACT" | translate }}</a
@@ -82,7 +97,10 @@
}
</select>
<div class="icon-placeholder" routerLink="/admin">
<div
class="icon-placeholder"
[routerLink]="langService.localizedPath('/admin')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
@@ -130,7 +148,9 @@
<div class="cart-line-copy">
<strong>{{ cartItemName(item) }}</strong>
@if (cartItemVariant(item); as variant) {
<span class="cart-line-meta">{{ variant | translate }}</span>
<span class="cart-line-meta">{{
variant | translate
}}</span>
}
@if (cartItemColor(item); as color) {
<span class="cart-line-color">

View File

@@ -14,13 +14,16 @@
justify-content: space-between;
}
.brand {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
display: inline-flex;
align-items: center;
text-decoration: none;
}
.highlight {
color: var(--color-brand);
.brand-logo {
display: block;
width: auto;
height: 2.1rem;
max-width: min(11rem, 40vw);
}
.nav-links {

View File

@@ -1,5 +1,12 @@
import { CommonModule } from '@angular/common';
import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
import { CommonModule, NgOptimizedImage } from '@angular/common';
import {
afterNextRender,
Component,
DestroyRef,
computed,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
NavigationStart,
@@ -23,7 +30,13 @@ import {
@Component({
selector: 'app-navbar',
standalone: true,
imports: [CommonModule, RouterLink, RouterLinkActive, TranslateModule],
imports: [
CommonModule,
RouterLink,
RouterLinkActive,
TranslateModule,
NgOptimizedImage,
],
templateUrl: './navbar.component.html',
styleUrls: ['./navbar.component.scss'],
})
@@ -58,16 +71,9 @@ export class NavbarComponent {
];
constructor(public langService: LanguageService) {
if (!this.shopService.cartLoaded()) {
this.shopService
.loadCart()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
});
}
afterNextRender(() => {
this.scheduleCartWarmup();
});
this.router.events
.pipe(takeUntilDestroyed(this.destroyRef))
@@ -96,6 +102,9 @@ export class NavbarComponent {
toggleCart(): void {
this.closeMenu();
this.isCartOpen.update((open) => !open);
if (this.isCartOpen()) {
this.loadCartIfNeeded();
}
}
closeCart(): void {
@@ -133,7 +142,7 @@ export class NavbarComponent {
}
this.closeCart();
this.router.navigate(['/checkout'], {
this.router.navigate(['/', this.langService.selectedLang(), 'checkout'], {
queryParams: {
session: sessionId,
},
@@ -192,5 +201,44 @@ export class NavbarComponent {
.subscribe();
}
private scheduleCartWarmup(): void {
if (typeof window === 'undefined') {
this.loadCartIfNeeded();
return;
}
const warmup = () => this.loadCartIfNeeded();
const idleCallback = (
window as Window & {
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions,
) => number;
}
).requestIdleCallback;
if (typeof idleCallback === 'function') {
idleCallback(() => warmup(), { timeout: 1500 });
return;
}
window.setTimeout(warmup, 300);
}
private loadCartIfNeeded(): void {
if (this.shopService.cartLoaded() || this.shopService.cartLoading()) {
return;
}
this.shopService
.loadCart()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
});
}
protected readonly routes = routes;
}

View File

@@ -1,7 +1,13 @@
import { Subject } from 'rxjs';
import { DefaultUrlSerializer, Router, UrlTree } from '@angular/router';
import {
DefaultUrlSerializer,
NavigationEnd,
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() {
@@ -9,7 +15,7 @@ describe('LanguageService', () => {
const translate = {
currentLang: '',
addLangs: jasmine.createSpy('addLangs'),
setDefaultLang: jasmine.createSpy('setDefaultLang'),
setFallbackLang: jasmine.createSpy('setFallbackLang'),
use: jasmine.createSpy('use').and.callFake((lang: string) => {
translate.currentLang = lang;
onLangChange.next({ lang });
@@ -60,7 +66,14 @@ describe('LanguageService', () => {
parseUrl: (url: string) => serializer.parse(url),
createUrlTree,
serializeUrl: (tree: UrlTree) => serializer.serialize(tree),
navigateByUrl: jasmine.createSpy('navigateByUrl'),
navigateByUrl: jasmine
.createSpy('navigateByUrl')
.and.callFake((tree: UrlTree) => {
const nextUrl = serializer.serialize(tree);
router.url = nextUrl;
events$.next(new NavigationEnd(1, nextUrl, nextUrl));
return Promise.resolve(true);
}),
};
return router as unknown as Router;
@@ -70,11 +83,17 @@ 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');
expect(navigateSpy).toHaveBeenCalledTimes(1);
const firstCall = navigateSpy.calls.mostRecent();
@@ -84,6 +103,48 @@ describe('LanguageService', () => {
expect(navOptions.replaceUrl).toBeTrue();
});
it('uses the preferred browser language on the root URL', () => {
const translate = createTranslateMock();
const router = createRouterMock('/');
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');
});
it('uses the default language for non-root URLs without a 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('/it/calculator?session=abc');
});
it('switches language while preserving path and query params', () => {
const translate = createTranslateMock();
const router = createRouterMock('/it/calculator?session=abc&mode=advanced');
@@ -103,4 +164,34 @@ describe('LanguageService', () => {
'/de/calculator?session=abc&mode=advanced',
);
});
it('builds localized paths for internal links while preserving query and hash', () => {
const translate = createTranslateMock();
const router = createRouterMock('/de/shop');
const service = new LanguageService(translate, router);
expect(service.localizedPath('/privacy')).toBe('/de/privacy');
expect(service.localizedPath('/it/contact?topic=seo#form')).toBe(
'/de/contact?topic=seo#form',
);
});
it('switches product pages using the resolved localized route overrides', () => {
const translate = createTranslateMock();
const router = createRouterMock('/it/shop/p/12345678-supporto-cavo');
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
const service = new LanguageService(translate, router);
service.setLocalizedRouteOverrides({
it: '/it/shop/p/12345678-supporto-cavo',
de: '/de/shop/p/12345678-kabelhalter',
});
navigateSpy.calls.reset();
service.switchLang('de');
const call = navigateSpy.calls.mostRecent();
const tree = call.args[0] as UrlTree;
expect(router.serializeUrl(tree)).toBe('/de/shop/p/12345678-kabelhalter');
});
});

View File

@@ -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,25 +6,32 @@ import {
Router,
UrlTree,
} from '@angular/router';
import {
getNavigatorLanguagePreferences,
parseAcceptLanguage,
resolveInitialLanguage,
} from '../i18n/language-resolution';
import { RequestLike } from '../../../core/request-origin';
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
type LocalizedRouteOverrides = Partial<Record<SupportedLang, string>>;
@Injectable({
providedIn: 'root',
})
export class LanguageService {
currentLang = signal<'it' | 'en' | 'de' | 'fr'>('it');
private readonly supportedLangs: Array<'it' | 'en' | 'de' | 'fr'> = [
'it',
'en',
'de',
'fr',
];
currentLang = signal<SupportedLang>('it');
private readonly defaultLang: SupportedLang = 'it';
private readonly supportedLangs: SupportedLang[] = ['it', 'en', 'de', 'fr'];
private localizedRouteOverrides: LocalizedRouteOverrides | null = null;
constructor(
private translate: TranslateService,
private router: Router,
@Optional() @Inject(REQUEST) private request: RequestLike | null = null,
) {
this.translate.addLangs(this.supportedLangs);
this.translate.setDefaultLang('it');
this.translate.setFallbackLang('it');
this.translate.onLangChange.subscribe((event) => {
const lang =
typeof event.lang === 'string' ? event.lang.toLowerCase() : null;
@@ -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);
@@ -53,13 +61,21 @@ export class LanguageService {
});
}
switchLang(lang: 'it' | 'en' | 'de' | 'fr') {
switchLang(lang: SupportedLang) {
if (!this.isSupportedLang(lang)) {
return;
}
this.applyLanguage(lang);
const currentTree = this.router.parseUrl(this.router.url);
const localizedRoute = this.resolveLocalizedRouteOverride(
currentTree,
lang,
);
if (localizedRoute) {
this.navigateToLocalizedRoute(currentTree, localizedRoute);
return;
}
const segments = this.getPrimarySegments(currentTree);
let targetSegments: string[];
@@ -77,7 +93,7 @@ export class LanguageService {
this.navigateIfChanged(currentTree, targetSegments);
}
selectedLang(): 'it' | 'en' | 'de' | 'fr' {
selectedLang(): SupportedLang {
const activeLang =
typeof this.translate.currentLang === 'string'
? this.translate.currentLang.toLowerCase()
@@ -85,6 +101,41 @@ export class LanguageService {
return this.isSupportedLang(activeLang) ? activeLang : this.currentLang();
}
localizedPath(path: string): string {
const lang = this.selectedLang();
const rawValue = String(path ?? '').trim();
const normalized = rawValue || '/';
const match = normalized.match(/^([^?#]*)([?#].*)?$/);
const rawPath = match?.[1] || '/';
const suffix = match?.[2] || '';
const segments = rawPath.split('/').filter(Boolean);
if (segments.length === 0) {
return `/${lang}${suffix}`;
}
if (this.isSupportedLang(segments[0])) {
segments[0] = lang;
return `/${segments.join('/')}${suffix}`;
}
if (this.looksLikeLangToken(segments[0])) {
return `/${[lang, ...segments.slice(1)].join('/')}${suffix}`;
}
return `/${[lang, ...segments].join('/')}${suffix}`;
}
setLocalizedRouteOverrides(
paths: LocalizedRouteOverrides | null | undefined,
): void {
this.localizedRouteOverrides = this.normalizeLocalizedRouteOverrides(paths);
}
clearLocalizedRouteOverrides(): void {
this.localizedRouteOverrides = null;
}
private ensureLanguageInPath(urlTree: UrlTree): void {
const segments = this.getPrimarySegments(urlTree);
@@ -93,23 +144,26 @@ export class LanguageService {
return;
}
const queryLang = this.getQueryLang(urlTree);
const activeLang = this.isSupportedLang(queryLang)
? queryLang
: this.currentLang();
if (activeLang !== this.currentLang()) {
this.applyLanguage(activeLang);
}
let targetSegments: string[];
if (segments.length === 0) {
targetSegments = [activeLang];
} else if (this.looksLikeLangToken(segments[0])) {
targetSegments = [activeLang, ...segments.slice(1)];
} else {
targetSegments = [activeLang, ...segments];
const queryLang = this.getQueryLang(urlTree);
const rootLang = this.isSupportedLang(queryLang)
? queryLang
: this.currentLang();
if (rootLang !== this.currentLang()) {
this.applyLanguage(rootLang);
}
this.navigateIfChanged(urlTree, [rootLang]);
return;
}
if (this.currentLang() !== this.defaultLang) {
this.applyLanguage(this.defaultLang);
}
const targetSegments = this.looksLikeLangToken(segments[0])
? [this.defaultLang, ...segments.slice(1)]
: [this.defaultLang, ...segments];
this.navigateIfChanged(urlTree, targetSegments);
}
@@ -126,12 +180,23 @@ 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' {
): lang is SupportedLang {
return (
typeof lang === 'string' &&
this.supportedLangs.includes(lang as 'it' | 'en' | 'de' | 'fr')
this.supportedLangs.includes(lang as SupportedLang)
);
}
@@ -141,7 +206,7 @@ export class LanguageService {
);
}
private applyLanguage(lang: 'it' | 'en' | 'de' | 'fr'): void {
private applyLanguage(lang: SupportedLang): void {
if (this.currentLang() === lang && this.translate.currentLang === lang) {
return;
}
@@ -149,6 +214,88 @@ export class LanguageService {
this.currentLang.set(lang);
}
private resolveLocalizedRouteOverride(
currentTree: UrlTree,
lang: SupportedLang,
): string | null {
const overrides = this.localizedRouteOverrides;
if (!overrides) {
return null;
}
const currentPath = this.getCleanPath(
this.router.serializeUrl(currentTree),
);
const paths = Object.values(overrides)
.map((path) => this.normalizeLocalizedRoutePath(path))
.filter((path): path is string => !!path);
if (!paths.includes(currentPath)) {
return null;
}
return this.normalizeLocalizedRoutePath(overrides[lang]);
}
private normalizeLocalizedRouteOverrides(
paths: LocalizedRouteOverrides | null | undefined,
): LocalizedRouteOverrides | null {
if (!paths) {
return null;
}
const normalized = this.supportedLangs.reduce<LocalizedRouteOverrides>(
(accumulator, lang) => {
const path = this.normalizeLocalizedRoutePath(paths[lang]);
if (path) {
accumulator[lang] = path;
}
return accumulator;
},
{},
);
return Object.keys(normalized).length > 0 ? normalized : null;
}
private normalizeLocalizedRoutePath(
path: string | null | undefined,
): string | null {
const rawPath = String(path ?? '').trim();
if (!rawPath) {
return null;
}
const cleanPath = this.getCleanPath(rawPath);
return cleanPath.startsWith('/') ? cleanPath : null;
}
private navigateToLocalizedRoute(
currentTree: UrlTree,
localizedPath: string,
): void {
const { lang: _unusedLang, ...queryParams } = currentTree.queryParams;
const targetTree = this.router.createUrlTree(
['/', ...localizedPath.split('/').filter(Boolean)],
{
queryParams,
fragment: currentTree.fragment ?? undefined,
},
);
if (
this.router.serializeUrl(targetTree) ===
this.router.serializeUrl(currentTree)
) {
return;
}
this.router.navigateByUrl(targetTree, { replaceUrl: true });
}
private getCleanPath(url: string): string {
const path = (url || '/').split('?')[0].split('#')[0];
return path || '/';
}
private navigateIfChanged(
currentTree: UrlTree,
targetSegments: string[],

View File

@@ -0,0 +1,189 @@
import { ActivatedRouteSnapshot, Router } from '@angular/router';
import { Meta, Title } from '@angular/platform-browser';
import { Subject } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { SeoService } from './seo.service';
describe('SeoService', () => {
function createSnapshot(
data: Record<string, unknown>,
firstChild: ActivatedRouteSnapshot | null = null,
): ActivatedRouteSnapshot {
return {
data,
firstChild,
} as unknown as ActivatedRouteSnapshot;
}
function cleanupSeoDom(): void {
document.head
.querySelectorAll(
'link[rel="canonical"], link[rel="alternate"][data-seo-managed="true"], meta[property="og:locale:alternate"][data-seo-managed="true"]',
)
.forEach((node) => node.remove());
document.documentElement.removeAttribute('lang');
}
function createService(options: {
url: string;
data: Record<string, unknown>;
translations: Record<string, string>;
}): {
service: SeoService;
meta: jasmine.SpyObj<Meta>;
title: jasmine.SpyObj<Title>;
} {
const events$ = new Subject<unknown>();
const title = jasmine.createSpyObj<Title>('Title', ['setTitle']);
const meta = jasmine.createSpyObj<Meta>('Meta', ['updateTag']);
const translate = {
instant: (key: string) => options.translations[key] ?? key,
} as TranslateService;
const router = {
url: options.url,
events: events$.asObservable(),
routerState: {
snapshot: {
root: createSnapshot(options.data),
},
},
} as unknown as Router;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const service = new SeoService(router, title, meta, translate, document);
return { service, meta, title };
}
beforeEach(() => {
cleanupSeoDom();
});
afterEach(() => {
cleanupSeoDom();
});
it('adds the language prefix to canonical and hreflang URLs', () => {
const { meta, title } = createService({
url: '/privacy?utm=test',
data: {
seoTitleKey: 'SEO.ROUTES.LEGAL.PRIVACY.TITLE',
seoDescriptionKey: 'SEO.ROUTES.LEGAL.PRIVACY.DESCRIPTION',
},
translations: {
'SEO.ROUTES.LEGAL.PRIVACY.TITLE': 'Privacy Policy | 3D fab',
'SEO.ROUTES.LEGAL.PRIVACY.DESCRIPTION': 'Privacy description',
},
});
expect(title.setTitle).toHaveBeenCalledWith('Privacy Policy | 3D fab');
const canonical = document.head.querySelector(
'link[rel="canonical"]',
) as HTMLLinkElement | null;
expect(canonical?.getAttribute('href')).toBe(
`${document.location.origin}/it/privacy`,
);
const alternates = Array.from(
document.head.querySelectorAll(
'link[rel="alternate"][data-seo-managed="true"]',
),
).map((node) => ({
hreflang: node.getAttribute('hreflang'),
href: node.getAttribute('href'),
}));
expect(alternates).toContain({
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-CH');
const ogUrlCall = meta.updateTag.calls
.allArgs()
.find(([tag]) => tag.property === 'og:url');
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', () => {
const { meta, title } = createService({
url: '/en/about',
data: {
seoTitleKey: 'SEO.ROUTES.ABOUT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ABOUT.DESCRIPTION',
},
translations: {
'SEO.ROUTES.ABOUT.TITLE': 'About Us | 3D fab',
'SEO.ROUTES.ABOUT.DESCRIPTION': 'About description',
},
});
expect(title.setTitle).toHaveBeenCalledWith('About Us | 3D fab');
const descriptionCall = meta.updateTag.calls
.allArgs()
.find(([tag]) => tag.name === 'description');
expect(descriptionCall?.[0].content).toBe('About description');
expect(document.documentElement.lang).toBe('en-CH');
});
it('applies canonical and hreflang values resolved from localized paths', () => {
const { service } = createService({
url: '/it/shop/p/12345678-supporto-cavo-scrivania',
data: {},
translations: {},
});
service.applyResolvedSeo({
title: 'Supporto cavo scrivania | 3D fab',
description: 'Accessorio tecnico',
robots: 'index, follow',
ogTitle: 'Supporto cavo scrivania | 3D fab',
ogDescription: 'Accessorio tecnico',
canonicalPath: '/it/shop/p/12345678-supporto-cavo-scrivania',
alternates: {
it: '/it/shop/p/12345678-supporto-cavo-scrivania',
en: '/en/shop/p/12345678-desk-cable-clip',
de: '/de/shop/p/12345678-schreibtisch-kabelhalter',
},
xDefault: '/it/shop/p/12345678-supporto-cavo-scrivania',
});
const canonical = document.head.querySelector(
'link[rel="canonical"]',
) as HTMLLinkElement | null;
expect(canonical?.getAttribute('href')).toBe(
`${document.location.origin}/it/shop/p/12345678-supporto-cavo-scrivania`,
);
const alternates = Array.from(
document.head.querySelectorAll(
'link[rel="alternate"][data-seo-managed="true"]',
),
).map((node) => ({
hreflang: node.getAttribute('hreflang'),
href: node.getAttribute('href'),
}));
expect(alternates).toContain({
hreflang: 'de-CH',
href: `${document.location.origin}/de/shop/p/12345678-schreibtisch-kabelhalter`,
});
expect(alternates).toContain({
hreflang: 'x-default',
href: `${document.location.origin}/it/shop/p/12345678-supporto-cavo-scrivania`,
});
});
});

View File

@@ -2,18 +2,34 @@ import { DOCUMENT } from '@angular/common';
import { Inject, Injectable } from '@angular/core';
import { Title, Meta } from '@angular/platform-browser';
import { ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { filter } from 'rxjs/operators';
export interface PageSeoOverride {
title?: string | null;
titleKey?: string | null;
description?: string | null;
descriptionKey?: string | null;
robots?: string | null;
ogTitle?: string | null;
ogTitleKey?: string | null;
ogDescription?: string | null;
ogDescriptionKey?: string | null;
}
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
export interface ResolvedPageSeo extends PageSeoOverride {
canonicalPath: string | null;
alternates?: SeoMap | null;
xDefault?: string | null;
}
export type SupportedLang = 'it' | 'en' | 'de' | 'fr';
type SeoMap = Partial<Record<SupportedLang, string>>;
type SeoTextDataKey =
| 'seoTitle'
| 'seoDescription'
| 'ogTitle'
| 'ogDescription';
@Injectable({
providedIn: 'root',
@@ -31,17 +47,33 @@ export class SeoService {
de: '3D-Druckservice nach Maß, technischer Shop und CAD-Support für Prototypen, Ersatzteile und Kleinserien.',
fr: "Service d'impression 3D sur mesure, boutique technique et support CAD pour prototypes, pièces et petites séries.",
};
private readonly supportedLangs = new Set<SupportedLang>([
private readonly supportedLangs: readonly SupportedLang[] = [
'it',
'en',
'de',
'fr',
]);
];
private readonly supportedLangSet = new Set<SupportedLang>(
this.supportedLangs,
);
private readonly ogLocaleByLang: Record<SupportedLang, string> = {
it: 'it_CH',
en: 'en_CH',
de: 'de_CH',
fr: 'fr_CH',
};
private readonly seoLocaleByLang: Record<SupportedLang, string> = {
it: 'it-CH',
en: 'en-CH',
de: 'de-CH',
fr: 'fr-CH',
};
constructor(
private router: Router,
private titleService: Title,
private metaService: Meta,
private translate: TranslateService,
@Inject(DOCUMENT) private document: Document,
) {
this.applyRouteSeo(this.router.routerState.snapshot.root);
@@ -59,14 +91,49 @@ export class SeoService {
applyPageSeo(override: PageSeoOverride): void {
const cleanPath = this.getCleanPath(this.router.url);
const lang = this.resolveLangFromPath(cleanPath);
const title = this.asString(override.title) ?? this.defaultTitleByLang[lang];
const description =
this.asString(override.description) ?? this.defaultDescriptionByLang[lang];
const robots = this.asString(override.robots) ?? 'index, follow';
const ogTitle = this.asString(override.ogTitle) ?? title;
const ogDescription = this.asString(override.ogDescription) ?? description;
const { title, description, robots, ogTitle, ogDescription } =
this.resolvePageSeoOverride(override, lang);
const canonicalPath = this.buildLocalizedPath(cleanPath, lang);
const alternates = this.buildAlternatePaths(canonicalPath);
this.applySeoValues(title, description, robots, ogTitle, ogDescription);
this.applySeoValues(
title,
description,
robots,
ogTitle,
ogDescription,
cleanPath,
canonicalPath,
alternates,
alternates.it ?? canonicalPath,
lang,
);
}
applyResolvedSeo(override: ResolvedPageSeo): void {
const cleanPath = this.getCleanPath(this.router.url);
const lang = this.resolveLangFromPath(cleanPath);
const { title, description, robots, ogTitle, ogDescription } =
this.resolvePageSeoOverride(override, lang);
const canonicalPath = this.normalizeSeoPath(override.canonicalPath);
const alternates = this.normalizeAlternatePaths(override.alternates);
const xDefault =
this.normalizeSeoPath(override.xDefault) ??
alternates?.it ??
canonicalPath;
this.applySeoValues(
title,
description,
robots,
ogTitle,
ogDescription,
cleanPath,
canonicalPath,
alternates,
xDefault,
lang,
);
}
private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void {
@@ -75,16 +142,29 @@ export class SeoService {
const lang = this.resolveLangFromPath(cleanPath);
const title =
this.resolveSeoText(mergedData, 'seoTitle', lang) ??
this.defaultTitleByLang[lang];
this.defaultTitle(lang);
const description =
this.resolveSeoText(mergedData, 'seoDescription', lang) ??
this.defaultDescriptionByLang[lang];
this.defaultDescription(lang);
const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow';
const ogTitle = this.resolveSeoText(mergedData, 'ogTitle', lang) ?? title;
const ogDescription =
this.resolveSeoText(mergedData, 'ogDescription', lang) ?? description;
const canonicalPath = this.buildLocalizedPath(cleanPath, lang);
const alternates = this.buildAlternatePaths(canonicalPath);
this.applySeoValues(title, description, robots, ogTitle, ogDescription);
this.applySeoValues(
title,
description,
robots,
ogTitle,
ogDescription,
cleanPath,
canonicalPath,
alternates,
alternates.it ?? canonicalPath,
lang,
);
}
private applySeoValues(
@@ -93,6 +173,11 @@ export class SeoService {
robots: string,
ogTitle: string,
ogDescription: string,
cleanPath: string,
canonicalPath: string | null,
alternates: SeoMap | null,
xDefaultPath: string | null,
lang: SupportedLang,
): void {
this.titleService.setTitle(title);
this.metaService.updateTag({ name: 'description', content: description });
@@ -103,13 +188,21 @@ export class SeoService {
content: ogDescription,
});
this.metaService.updateTag({ property: 'og:type', content: 'website' });
this.metaService.updateTag({ property: 'og:site_name', content: '3D fab' });
this.metaService.updateTag({ name: 'twitter:card', content: 'summary' });
this.metaService.updateTag({ name: 'twitter:title', content: ogTitle });
this.metaService.updateTag({
name: 'twitter:description',
content: ogDescription,
});
const cleanPath = this.getCleanPath(this.router.url);
const canonical = `${this.document.location.origin}${cleanPath}`;
this.metaService.updateTag({ property: 'og:url', content: canonical });
this.updateCanonicalTag(canonical);
this.updateLangAndAlternates(cleanPath);
const ogUrl = this.toAbsoluteUrl(canonicalPath ?? cleanPath);
this.metaService.updateTag({ property: 'og:url', content: ogUrl });
this.updateCanonicalTag(
canonicalPath ? this.toAbsoluteUrl(canonicalPath) : null,
);
this.updateOpenGraphLocales(lang);
this.updateLangAndAlternates(alternates, xDefaultPath, lang);
}
private getMergedRouteData(
@@ -128,23 +221,100 @@ export class SeoService {
return typeof value === 'string' ? value : undefined;
}
private resolveOverrideSeoText(
value: string | null | undefined,
key: string | null | undefined,
): string | undefined {
return this.asString(value) ?? this.resolveTranslation(key);
}
private resolvePageSeoOverride(
override: PageSeoOverride,
lang: SupportedLang,
): {
title: string;
description: string;
robots: string;
ogTitle: string;
ogDescription: string;
} {
const title =
this.resolveOverrideSeoText(override.title, override.titleKey) ??
this.defaultTitle(lang);
const description =
this.resolveOverrideSeoText(
override.description,
override.descriptionKey,
) ?? this.defaultDescription(lang);
const robots = this.asString(override.robots) ?? 'index, follow';
const ogTitle =
this.resolveOverrideSeoText(override.ogTitle, override.ogTitleKey) ??
title;
const ogDescription =
this.resolveOverrideSeoText(
override.ogDescription,
override.ogDescriptionKey,
) ?? description;
return {
title,
description,
robots,
ogTitle,
ogDescription,
};
}
private resolveSeoText(
routeData: Record<string, unknown>,
key: 'seoTitle' | 'seoDescription' | 'ogTitle' | 'ogDescription',
key: SeoTextDataKey,
lang: SupportedLang,
): string | undefined {
const mapKey = `${key}ByLang`;
const localized = routeData[mapKey];
if (localized && typeof localized === 'object' && !Array.isArray(localized)) {
if (
localized &&
typeof localized === 'object' &&
!Array.isArray(localized)
) {
const mapped = localized as SeoMap;
const byLang = this.asString(mapped[lang]);
if (byLang) {
return byLang;
}
}
const translated = this.resolveTranslation(routeData[`${key}Key`]);
if (translated) {
return translated;
}
return this.asString(routeData[key]);
}
private resolveTranslation(value: unknown): string | undefined {
const key = this.asString(value)?.trim();
if (!key) {
return undefined;
}
const translated = this.translate.instant(key);
return typeof translated === 'string' && translated !== key
? translated
: undefined;
}
private defaultTitle(lang: SupportedLang): string {
return (
this.resolveTranslation('SEO.DEFAULT.TITLE') ??
this.defaultTitleByLang[lang]
);
}
private defaultDescription(lang: SupportedLang): string {
return (
this.resolveTranslation('SEO.DEFAULT.DESCRIPTION') ??
this.defaultDescriptionByLang[lang]
);
}
private getCleanPath(url: string): string {
const path = (url || '/').split('?')[0].split('#')[0];
return path || '/';
@@ -152,16 +322,86 @@ export class SeoService {
private resolveLangFromPath(path: string): SupportedLang {
const firstSegment = path.split('/').filter(Boolean)[0]?.toLowerCase();
if (firstSegment && this.supportedLangs.has(firstSegment as SupportedLang)) {
if (
firstSegment &&
this.supportedLangSet.has(firstSegment as SupportedLang)
) {
return firstSegment as SupportedLang;
}
return 'it';
}
private updateCanonicalTag(url: string): void {
private buildLocalizedPath(path: string, lang: SupportedLang): string {
const segments = path.split('/').filter(Boolean);
if (segments.length === 0) {
return `/${lang}`;
}
const firstSegment = segments[0]?.toLowerCase();
if (
firstSegment &&
this.supportedLangSet.has(firstSegment as SupportedLang)
) {
segments[0] = lang;
return `/${segments.join('/')}`;
}
return `/${[lang, ...segments].join('/')}`;
}
private buildAlternatePaths(canonicalPath: string): SeoMap {
const suffixSegments = canonicalPath.split('/').filter(Boolean).slice(1);
const suffix =
suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : '';
return this.supportedLangs.reduce<SeoMap>((accumulator, alt) => {
accumulator[alt] = `/${alt}${suffix}`;
return accumulator;
}, {});
}
private normalizeAlternatePaths(
paths: SeoMap | null | undefined,
): SeoMap | null {
if (!paths) {
return null;
}
const normalized = this.supportedLangs.reduce<SeoMap>(
(accumulator, lang) => {
const path = this.normalizeSeoPath(paths[lang]);
if (path) {
accumulator[lang] = path;
}
return accumulator;
},
{},
);
return Object.keys(normalized).length > 0 ? normalized : null;
}
private normalizeSeoPath(path: string | null | undefined): string | null {
const rawPath = String(path ?? '').trim();
if (!rawPath) {
return null;
}
const normalized = this.getCleanPath(rawPath);
return normalized.startsWith('/') ? normalized : null;
}
private toAbsoluteUrl(path: string): string {
return `${this.document.location.origin}${path}`;
}
private updateCanonicalTag(url: string | null): void {
let link = this.document.head.querySelector(
'link[rel="canonical"]',
) as HTMLLinkElement | null;
if (!url) {
link?.remove();
return;
}
if (!link) {
link = this.document.createElement('link');
link.setAttribute('rel', 'canonical');
@@ -170,32 +410,54 @@ export class SeoService {
link.setAttribute('href', url);
}
private updateLangAndAlternates(path: string): void {
const segments = path.split('/').filter(Boolean);
const firstSegment = segments[0]?.toLowerCase();
const maybeLang = firstSegment as SupportedLang | undefined;
const hasLang = Boolean(maybeLang && this.supportedLangs.has(maybeLang));
const lang: SupportedLang = hasLang && maybeLang ? maybeLang : 'it';
const suffixSegments = hasLang ? segments.slice(1) : segments;
const suffix =
suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : '';
private updateOpenGraphLocales(lang: SupportedLang): void {
this.metaService.updateTag({
property: 'og:locale',
content: this.ogLocaleByLang[lang],
});
this.document.documentElement.lang = lang;
this.document.head
.querySelectorAll(
'meta[property="og:locale:alternate"][data-seo-managed="true"]',
)
.forEach((node) => node.remove());
for (const alternateLang of this.supportedLangs) {
if (alternateLang === lang) {
continue;
}
this.appendOgLocaleAlternate(this.ogLocaleByLang[alternateLang]);
}
}
private updateLangAndAlternates(
alternates: SeoMap | null,
xDefaultPath: string | null,
lang: SupportedLang,
): void {
this.document.documentElement.lang = this.seoLocaleByLang[lang];
this.document.head
.querySelectorAll('link[rel="alternate"][data-seo-managed="true"]')
.forEach((node) => node.remove());
for (const alt of ['it', 'en', 'de', 'fr']) {
if (!alternates) {
return;
}
for (const alt of this.supportedLangs) {
const path = alternates[alt];
if (!path) {
continue;
}
this.appendAlternateLink(
alt,
`${this.document.location.origin}/${alt}${suffix}`,
this.seoLocaleByLang[alt],
this.toAbsoluteUrl(path),
);
}
this.appendAlternateLink(
'x-default',
`${this.document.location.origin}/it${suffix}`,
);
if (xDefaultPath) {
this.appendAlternateLink('x-default', this.toAbsoluteUrl(xDefaultPath));
}
}
private appendAlternateLink(hreflang: string, href: string): void {
@@ -206,4 +468,12 @@ export class SeoService {
link.setAttribute('data-seo-managed', 'true');
this.document.head.appendChild(link);
}
private appendOgLocaleAlternate(locale: string): void {
const meta = this.document.createElement('meta');
meta.setAttribute('property', 'og:locale:alternate');
meta.setAttribute('content', locale);
meta.setAttribute('data-seo-managed', 'true');
this.document.head.appendChild(meta);
}
}

View File

@@ -6,9 +6,8 @@ export const ABOUT_ROUTES: Routes = [
path: '',
component: AboutPageComponent,
data: {
seoTitle: 'Chi siamo | 3D fab',
seoDescription:
'Siamo un laboratorio di stampa 3D orientato a prototipi, ricambi e produzioni su misura.',
seoTitleKey: 'SEO.ROUTES.ABOUT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ABOUT.DESCRIPTION',
},
},
];

View File

@@ -212,10 +212,10 @@
<input
class="ui-form-control"
type="text"
[(ngModel)]="categoryForm.slug"
name="categorySlug"
placeholder="desk-accessories"
/>
[(ngModel)]="categoryForm.slug"
name="categorySlug"
placeholder="desk-accessories"
/>
<button
type="button"
class="ui-button ui-button--ghost"
@@ -668,9 +668,31 @@
<div>
<h3>Contenuti localizzati</h3>
<p>
Nome obbligatorio in tutte le lingue. Descrizioni opzionali.
Nome obbligatorio in tutte le lingue. Descrizioni opzionali. La
traduzione usa la lingua editor come sorgente e compila il form
senza salvare.
</p>
</div>
<button
type="button"
class="ui-button ui-button--ghost"
(click)="translateProductFromCurrentLanguage()"
[disabled]="!canTranslateProductFromCurrentLanguage()"
>
{{ translatingProduct ? "Traduco..." : "Traduci" }}
</button>
</div>
<div class="toggle-row toggle-row--compact">
<label class="ui-checkbox">
<input
type="checkbox"
[(ngModel)]="overwriteExistingTranslations"
name="productOverwriteExistingTranslations"
/>
<span class="ui-checkbox__mark" aria-hidden="true"></span>
<span>Sovrascrivi traduzioni esistenti</span>
</label>
</div>
<div class="ui-language-toolbar">

View File

@@ -18,6 +18,8 @@ import {
AdminShopProductModel,
AdminShopProductVariant,
AdminShopService,
AdminTranslateShopProductPayload,
AdminTranslateShopProductResponse,
AdminUpsertShopCategoryPayload,
AdminUpsertShopProductPayload,
AdminUpsertShopProductVariantPayload,
@@ -174,6 +176,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
loading = false;
detailLoading = false;
savingProduct = false;
translatingProduct = false;
deletingProduct = false;
savingCategory = false;
deletingCategory = false;
@@ -188,6 +191,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
productStatusFilter: ProductStatusFilter = 'ALL';
showCategoryManager = false;
activeContentLanguage: ShopLanguage = 'it';
overwriteExistingTranslations = false;
errorMessage: string | null = null;
successMessage: string | null = null;
@@ -560,6 +564,52 @@ export class AdminShopComponent implements OnInit, OnDestroy {
this.categoryForm.slug = this.slugify(source);
}
translateProductFromCurrentLanguage(): void {
if (this.translatingProduct) {
return;
}
this.syncDescriptionFromEditor(this.descriptionEditorElement, true);
const sourceLanguage = this.activeContentLanguage;
if (!this.productForm.names[sourceLanguage].trim()) {
this.errorMessage = `Il nome prodotto ${this.languageLabels[sourceLanguage]} e obbligatorio per avviare la traduzione.`;
this.successMessage = null;
return;
}
const payload = this.buildProductTranslationPayload(sourceLanguage);
this.translatingProduct = true;
this.errorMessage = null;
this.successMessage = null;
this.adminShopService.translateProduct(payload).subscribe({
next: (response) => {
this.translatingProduct = false;
this.applyProductTranslation(response, payload.overwriteExisting);
this.successMessage = response.targetLanguages.length
? `Traduzioni ${response.targetLanguages
.map((language) => this.languageLabels[language])
.join(' / ')} aggiornate nel form.`
: 'Nessun campo da tradurre.';
},
error: (error) => {
this.translatingProduct = false;
this.errorMessage = this.extractErrorMessage(
error,
'Traduzione prodotto non riuscita.',
);
},
});
}
canTranslateProductFromCurrentLanguage(): boolean {
return (
!this.translatingProduct &&
!!this.productForm.names[this.activeContentLanguage].trim()
);
}
setActiveContentLanguage(language: ShopLanguage): void {
this.syncDescriptionFromEditor(this.descriptionEditorElement, true);
this.activeContentLanguage = language;
@@ -1343,7 +1393,9 @@ export class AdminShopComponent implements OnInit, OnDestroy {
seoTitleEn: this.optionalValue(this.categoryForm.seoTitles['en']),
seoTitleDe: this.optionalValue(this.categoryForm.seoTitles['de']),
seoTitleFr: this.optionalValue(this.categoryForm.seoTitles['fr']),
seoDescription: this.optionalValue(this.categoryForm.seoDescriptions['it']),
seoDescription: this.optionalValue(
this.categoryForm.seoDescriptions['it'],
),
seoDescriptionIt: this.optionalValue(
this.categoryForm.seoDescriptions['it'],
),
@@ -1667,6 +1719,98 @@ export class AdminShopComponent implements OnInit, OnDestroy {
};
}
private buildProductTranslationPayload(
sourceLanguage: ShopLanguage,
): AdminTranslateShopProductPayload {
const materialCodes = Array.from(
new Set(
this.productForm.materials
.map((material) => material.materialCode.trim().toUpperCase())
.filter((materialCode) => !!materialCode),
),
);
return {
categoryId: this.productForm.categoryId || undefined,
sourceLanguage,
overwriteExisting: this.overwriteExistingTranslations,
materialCodes,
names: { ...this.productForm.names },
excerpts: { ...this.productForm.excerpts },
descriptions: { ...this.productForm.descriptions },
seoTitles: { ...this.productForm.seoTitles },
seoDescriptions: { ...this.productForm.seoDescriptions },
};
}
private applyProductTranslation(
response: AdminTranslateShopProductResponse,
overwriteExisting: boolean,
): void {
for (const language of response.targetLanguages) {
this.mergeLocalizedText(
this.productForm.names,
response.names,
language,
overwriteExisting,
);
this.mergeLocalizedText(
this.productForm.excerpts,
response.excerpts,
language,
overwriteExisting,
);
this.mergeLocalizedText(
this.productForm.descriptions,
response.descriptions,
language,
overwriteExisting,
true,
);
this.mergeLocalizedText(
this.productForm.seoTitles,
response.seoTitles,
language,
overwriteExisting,
);
this.mergeLocalizedText(
this.productForm.seoDescriptions,
response.seoDescriptions,
language,
overwriteExisting,
);
}
this.renderActiveDescriptionInEditor();
}
private mergeLocalizedText(
target: Record<ShopLanguage, string>,
translated:
| Partial<Record<ShopLanguage, string>>
| Record<ShopLanguage, string>
| undefined,
language: ShopLanguage,
overwriteExisting: boolean,
richText = false,
): void {
const incoming = translated?.[language];
if (incoming === undefined) {
return;
}
const hasCurrentValue = richText
? this.hasMeaningfulRichText(target[language] ?? '')
: !!target[language]?.trim();
if (hasCurrentValue && !overwriteExisting) {
return;
}
target[language] = richText
? this.normalizeDescriptionForEditor(incoming)
: incoming.trim();
}
private buildVariantsFromMaterials(): AdminUpsertShopProductVariantPayload[] {
const existingVariantsByKey = new Map(
(this.selectedProduct?.variants ?? []).map((variant) => [

View File

@@ -0,0 +1,93 @@
import { TestBed } from '@angular/core/testing';
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing';
import {
AdminShopService,
AdminTranslateShopProductPayload,
} from './admin-shop.service';
describe('AdminShopService', () => {
let service: AdminShopService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [AdminShopService],
});
service = TestBed.inject(AdminShopService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('posts product translation requests with credentials', () => {
const payload: AdminTranslateShopProductPayload = {
categoryId: 'category-1',
sourceLanguage: 'it',
overwriteExisting: false,
materialCodes: ['PLA', 'PETG'],
names: {
it: 'Supporto cavo scrivania',
en: '',
de: '',
fr: '',
},
excerpts: {
it: 'Accessorio tecnico',
en: '',
de: '',
fr: '',
},
descriptions: {
it: '<p>Descrizione prodotto</p>',
en: '',
de: '',
fr: '',
},
seoTitles: {
it: 'Supporto cavo scrivania | 3D fab',
en: '',
de: '',
fr: '',
},
seoDescriptions: {
it: 'Supporto tecnico stampato in 3D per scrivania.',
en: '',
de: '',
fr: '',
},
};
service.translateProduct(payload).subscribe((response) => {
expect(response.targetLanguages).toEqual(['en', 'de', 'fr']);
expect(response.names.en).toBe('Desk cable clip');
});
const request = httpMock.expectOne(
'http://localhost:8000/api/admin/shop/products/translate',
);
expect(request.request.method).toBe('POST');
expect(request.request.withCredentials).toBeTrue();
expect(request.request.body).toEqual(payload);
request.flush({
sourceLanguage: 'it',
targetLanguages: ['en', 'de', 'fr'],
names: {
en: 'Desk cable clip',
de: 'Schreibtisch-Kabelhalter',
fr: 'Support de cable de bureau',
},
excerpts: {},
descriptions: {},
seoTitles: {},
seoDescriptions: {},
});
});
});

View File

@@ -18,6 +18,8 @@ export interface AdminMediaTextTranslation {
altText: string;
}
export type AdminShopLanguage = 'it' | 'en' | 'de' | 'fr';
export interface AdminShopCategoryRef {
id: string;
slug: string;
@@ -255,6 +257,28 @@ export interface AdminUpsertShopProductPayload {
variants: AdminUpsertShopProductVariantPayload[];
}
export interface AdminTranslateShopProductPayload {
categoryId?: string;
sourceLanguage: AdminShopLanguage;
overwriteExisting: boolean;
materialCodes: string[];
names: Record<AdminShopLanguage, string>;
excerpts: Record<AdminShopLanguage, string>;
descriptions: Record<AdminShopLanguage, string>;
seoTitles: Record<AdminShopLanguage, string>;
seoDescriptions: Record<AdminShopLanguage, string>;
}
export interface AdminTranslateShopProductResponse {
sourceLanguage: AdminShopLanguage;
targetLanguages: AdminShopLanguage[];
names: Partial<Record<AdminShopLanguage, string>>;
excerpts: Partial<Record<AdminShopLanguage, string>>;
descriptions: Partial<Record<AdminShopLanguage, string>>;
seoTitles: Partial<Record<AdminShopLanguage, string>>;
seoDescriptions: Partial<Record<AdminShopLanguage, string>>;
}
@Injectable({
providedIn: 'root',
})
@@ -351,6 +375,18 @@ export class AdminShopService {
});
}
translateProduct(
payload: AdminTranslateShopProductPayload,
): Observable<AdminTranslateShopProductResponse> {
return this.http.post<AdminTranslateShopProductResponse>(
`${this.productsBaseUrl}/translate`,
payload,
{
withCredentials: true,
},
);
}
uploadProductModel(
productId: string,
file: File,

View File

@@ -0,0 +1,28 @@
<section class="animation-test-page">
<div class="animation-toolbar" role="group" aria-label="Animation variants">
<button
type="button"
class="variant-toggle"
[class.active]="variant() === 'site-intro'"
(click)="setVariant('site-intro')"
>
Site intro
</button>
<button
type="button"
class="variant-toggle"
[class.active]="variant() === 'calculator-loader'"
(click)="setVariant('calculator-loader')"
>
Calculator loader
</button>
</div>
<div class="animation-stage" [attr.data-variant]="variant()">
<app-brand-animation-logo
[variant]="variant()"
[decorative]="false"
ariaLabel="3D fab animation test"
></app-brand-animation-logo>
</div>
</section>

View File

@@ -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);
}
}

View File

@@ -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<BrandAnimationVariant>('site-intro');
setVariant(variant: BrandAnimationVariant): void {
this.variant.set(variant);
}
}

View File

@@ -57,7 +57,10 @@
@if (loading()) {
<app-card class="loading-state">
<div class="loader-content">
<div class="spinner"></div>
<app-brand-animation-logo
class="loader-logo"
variant="calculator-loader"
></app-brand-animation-logo>
<h3 class="loading-title">
{{ "CALC.ANALYZING_TITLE" | translate }}
</h3>

View File

@@ -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.65s;
}

View File

@@ -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,

View File

@@ -3,14 +3,25 @@ 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,
data: {
mode: 'easy',
seoTitle: 'Calcolatore stampa 3D base | 3D fab',
seoDescription:
'Calcola rapidamente il prezzo della tua stampa 3D in modalita base.',
seoTitleKey: 'SEO.ROUTES.CALCULATOR.BASIC.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.BASIC.DESCRIPTION',
},
},
{
@@ -18,9 +29,8 @@ export const CALCULATOR_ROUTES: Routes = [
component: CalculatorPageComponent,
data: {
mode: 'advanced',
seoTitle: 'Calcolatore stampa 3D avanzato | 3D fab',
seoDescription:
'Configura parametri avanzati e ottieni un preventivo preciso con slicing reale.',
seoTitleKey: 'SEO.ROUTES.CALCULATOR.ADVANCED.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.ADVANCED.DESCRIPTION',
},
},
];

View File

@@ -101,9 +101,11 @@
<p class="upload-privacy-note">
{{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate
}}</a
<a
[href]="languageService.localizedPath('/privacy')"
target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate }}</a
>.
</p>

View File

@@ -30,6 +30,7 @@ import {
VariantOption,
} from '../../services/quote-estimator.service';
import { getColorHex } from '../../../../core/constants/colors.const';
import { LanguageService } from '../../../../core/services/language.service';
interface FormItem {
file: File;
@@ -106,6 +107,7 @@ export class UploadFormComponent implements OnInit {
private estimator = inject(QuoteEstimatorService);
private fb = inject(FormBuilder);
private translate = inject(TranslateService);
readonly languageService = inject(LanguageService);
form: FormGroup;
@@ -125,7 +127,8 @@ export class UploadFormComponent implements OnInit {
private layerHeightsByNozzle: Record<string, SimpleOption[]> = {};
private isPatchingSettings = false;
acceptedFormats = '.stl,.3mf,.step,.stp';
acceptedFormats = '.stl,.3mf';
private readonly allowedExtensions = ['stl', '3mf'] as const;
constructor() {
this.form = this.fb.group({
@@ -284,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());
}
@@ -338,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;
}
@@ -365,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'));
}

View File

@@ -120,13 +120,18 @@
<input type="checkbox" formControlName="acceptLegal" />
<span>
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
<a href="/terms" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.TERMS_LINK" | translate
}}</a>
<a
[href]="languageService.localizedPath('/terms')"
target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.TERMS_LINK" | translate }}</a
>
{{ "LEGAL.CONSENT.AND" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.PRIVACY_LINK" | translate
}}</a
<a
[href]="languageService.localizedPath('/privacy')"
target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.PRIVACY_LINK" | translate }}</a
>.
</span>
</label>

View File

@@ -1,4 +1,4 @@
import { Component, input, output, signal } from '@angular/core';
import { Component, inject, input, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
ReactiveFormsModule,
@@ -11,6 +11,7 @@ import { AppCardComponent } from '../../../../shared/components/app-card/app-car
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { QuoteResult } from '../../services/quote-estimator.service';
import { LanguageService } from '../../../../core/services/language.service';
@Component({
selector: 'app-user-details',
@@ -30,6 +31,7 @@ export class UserDetailsComponent {
quote = input<QuoteResult>();
submitOrder = output<any>();
cancel = output<void>();
readonly languageService = inject(LanguageService);
form: FormGroup;
submitting = signal(false);

View File

@@ -2,9 +2,9 @@
<div class="container ui-page-hero ui-page-hero--spacious checkout-hero">
<h1 class="ui-page-title">{{ "CHECKOUT.TITLE" | translate }}</h1>
<p class="ui-page-subtitle cad-subtitle" *ngIf="isCadSession()">
Servizio CAD
{{ "CHECKOUT.CAD_SERVICE" | translate }}
<ng-container *ngIf="cadRequestId()">
riferito alla richiesta contatto #{{ cadRequestId() }}
{{ "CHECKOUT.CAD_REQUEST_REF" | translate: { id: cadRequestId() } }}
</ng-container>
</p>
</div>
@@ -204,13 +204,18 @@
<span class="ui-checkbox__mark"></span>
<span>
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
<a href="/terms" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.TERMS_LINK" | translate
}}</a>
<a
[href]="languageService.localizedPath('/terms')"
target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.TERMS_LINK" | translate }}</a
>
{{ "LEGAL.CONSENT.AND" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.PRIVACY_LINK" | translate
}}</a
<a
[href]="languageService.localizedPath('/privacy')"
target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.PRIVACY_LINK" | translate }}</a
>.
</span>
</label>
@@ -329,7 +334,9 @@
</div>
<div class="summary-item cad-summary-item" *ngIf="cadTotal() > 0">
<div class="item-details">
<span class="item-name">Servizio CAD</span>
<span class="item-name">{{
"CHECKOUT.CAD_SERVICE" | translate
}}</span>
<div class="item-specs-sub">{{ cadHours() }}h</div>
</div>
<div class="item-price">

View File

@@ -51,7 +51,7 @@ export class CheckoutComponent implements OnInit {
private quoteService = inject(QuoteEstimatorService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private languageService = inject(LanguageService);
readonly languageService = inject(LanguageService);
checkoutForm: FormGroup;
sessionId: string | null = null;
@@ -147,7 +147,7 @@ export class CheckoutComponent implements OnInit {
this.sessionId = params['session'];
if (!this.sessionId) {
this.error = 'CHECKOUT.ERR_NO_SESSION_START';
this.router.navigate(['/']); // Redirect if no session
this.router.navigate(['/', this.languageService.selectedLang()]);
return;
}

View File

@@ -85,9 +85,11 @@
<p class="ui-form-hint">{{ "CONTACT.UPLOAD_HINT" | translate }}</p>
<p class="ui-form-hint upload-privacy-note">
{{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate
}}</a
<a
[href]="languageService.localizedPath('/privacy')"
target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate }}</a
>.
</p>
@@ -161,13 +163,18 @@
<span class="ui-checkbox__mark"></span>
<span>
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
<a href="/terms" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.TERMS_LINK" | translate
}}</a>
<a
[href]="languageService.localizedPath('/terms')"
target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.TERMS_LINK" | translate }}</a
>
{{ "LEGAL.CONSENT.AND" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.PRIVACY_LINK" | translate
}}</a
<a
[href]="languageService.localizedPath('/privacy')"
target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.PRIVACY_LINK" | translate }}</a
>.
</span>
</label>

View File

@@ -70,7 +70,7 @@ export class ContactFormComponent implements OnDestroy {
];
private quoteRequestService = inject(QuoteRequestService);
private languageService = inject(LanguageService);
readonly languageService = inject(LanguageService);
constructor(
private fb: FormBuilder,

View File

@@ -6,9 +6,8 @@ export const CONTACT_ROUTES: Routes = [
loadComponent: () =>
import('./contact-page.component').then((m) => m.ContactPageComponent),
data: {
seoTitle: 'Contatti | 3D fab',
seoDescription:
'Richiedi informazioni, preventivi personalizzati o supporto per progetti di stampa 3D.',
seoTitleKey: 'SEO.ROUTES.CONTACT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CONTACT.DESCRIPTION',
},
},
];

View File

@@ -16,15 +16,21 @@
{{ "HOME.HERO_SUBTITLE" | translate }}
</p>
<div class="hero-actions ui-inline-actions ui-inline-actions--wide">
<app-button variant="primary" routerLink="/calculator/basic">{{
"HOME.BTN_CALCULATE" | translate
}}</app-button>
<app-button variant="outline" routerLink="/shop">{{
"HOME.BTN_SHOP" | translate
}}</app-button>
<app-button variant="text" routerLink="/contact">{{
"HOME.BTN_CONTACT" | translate
}}</app-button>
<app-button
variant="primary"
[routerLink]="languageService.localizedPath('/calculator/basic')"
>{{ "HOME.BTN_CALCULATE" | translate }}</app-button
>
<app-button
variant="outline"
[routerLink]="languageService.localizedPath('/shop')"
>{{ "HOME.BTN_SHOP" | translate }}</app-button
>
<app-button
variant="text"
[routerLink]="languageService.localizedPath('/contact')"
>{{ "HOME.BTN_CONTACT" | translate }}</app-button
>
</div>
</div>
<aside class="hero-swiss-card">
@@ -136,13 +142,13 @@
<app-button
variant="primary"
[fullWidth]="true"
routerLink="/calculator/basic"
[routerLink]="languageService.localizedPath('/calculator/basic')"
>{{ "HOME.BTN_OPEN_CALC" | translate }}</app-button
>
<app-button
variant="outline"
[fullWidth]="true"
routerLink="/contact"
[routerLink]="languageService.localizedPath('/contact')"
>{{ "HOME.BTN_CONTACT" | translate }}</app-button
>
</div>
@@ -167,12 +173,16 @@
<li>{{ "HOME.SEC_SHOP_LIST_3" | translate }}</li>
</ul>
<div class="shop-actions ui-inline-actions">
<app-button variant="primary" routerLink="/shop">{{
"HOME.BTN_DISCOVER" | translate
}}</app-button>
<app-button variant="outline" routerLink="/contact">{{
"HOME.BTN_REQ_SOLUTION" | translate
}}</app-button>
<app-button
variant="primary"
[routerLink]="languageService.localizedPath('/shop')"
>{{ "HOME.BTN_DISCOVER" | translate }}</app-button
>
<app-button
variant="outline"
[routerLink]="languageService.localizedPath('/contact')"
>{{ "HOME.BTN_REQ_SOLUTION" | translate }}</app-button
>
</div>
</div>
<div
@@ -237,12 +247,16 @@
{{ "HOME.SEC_ABOUT_TEXT" | translate }}
</p>
<div class="about-actions ui-inline-actions">
<app-button variant="primary" routerLink="/about">{{
"HOME.SEC_ABOUT_TITLE" | translate
}}</app-button>
<app-button variant="outline" routerLink="/contact">{{
"HOME.BTN_CONTACT" | translate
}}</app-button>
<app-button
variant="primary"
[routerLink]="languageService.localizedPath('/about')"
>{{ "HOME.SEC_ABOUT_TITLE" | translate }}</app-button
>
<app-button
variant="outline"
[routerLink]="languageService.localizedPath('/contact')"
>{{ "HOME.BTN_CONTACT" | translate }}</app-button
>
</div>
</div>
<div class="about-media">

View File

@@ -56,7 +56,7 @@
width: min(100%, 340px);
padding: 1rem 1.1rem;
border: 1px solid var(--color-border);
border-left: 4px solid var(--swiss-red);
border-left: 4px solid var(--color-brand);
border-radius: 12px;
background: #fff;
animation: fadeUp 0.85s ease both;

View File

@@ -5,6 +5,7 @@ import { RouterLink } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { LanguageService } from '../../core/services/language.service';
import {
buildPublicMediaUsageScopeKey,
PublicMediaDisplayImage,
@@ -69,6 +70,7 @@ const HOME_CAPABILITY_CONFIGS: readonly HomeCapabilityConfig[] = [
})
export class HomeComponent {
private readonly publicMediaService = inject(PublicMediaService);
readonly languageService = inject(LanguageService);
private readonly mediaByUsage = toSignal(
this.publicMediaService.getUsageCollections([

View File

@@ -6,9 +6,8 @@ export const LEGAL_ROUTES: Routes = [
loadComponent: () =>
import('./privacy/privacy.component').then((m) => m.PrivacyComponent),
data: {
seoTitle: 'Privacy Policy | 3D fab',
seoDescription:
'Informativa privacy di 3D fab: trattamento dati, finalita e contatti.',
seoTitleKey: 'SEO.ROUTES.LEGAL.PRIVACY.TITLE',
seoDescriptionKey: 'SEO.ROUTES.LEGAL.PRIVACY.DESCRIPTION',
},
},
{
@@ -16,9 +15,8 @@ export const LEGAL_ROUTES: Routes = [
loadComponent: () =>
import('./terms/terms.component').then((m) => m.TermsComponent),
data: {
seoTitle: 'Termini e condizioni | 3D fab',
seoDescription:
'Termini e condizioni del servizio di stampa 3D e del calcolatore preventivi.',
seoTitleKey: 'SEO.ROUTES.LEGAL.TERMS.TITLE',
seoDescriptionKey: 'SEO.ROUTES.LEGAL.TERMS.DESCRIPTION',
},
},
];

View File

@@ -239,7 +239,8 @@
<div class="order-item-meta">
<span
>{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span
>{{ "CHECKOUT.QTY" | translate }}:
{{ item.quantity }}</span
>
<span *ngIf="showItemMaterial(item)">
{{ "CHECKOUT.MATERIAL" | translate }}:

View File

@@ -245,7 +245,9 @@ export class OrderComponent implements OnInit {
amount: order?.subtotalChf ?? 0,
},
{
label: `Servizio CAD (${order?.cadHours || 0}h)`,
label: this.translate.instant('ORDER.CAD_SERVICE', {
hours: order?.cadHours || 0,
}),
amount: order?.cadTotalChf ?? 0,
visible: (order?.cadTotalChf ?? 0) > 0,
},

View File

@@ -1,5 +1,10 @@
<article class="product-card">
<a class="media" [routerLink]="productLink()" [state]="navigationState()">
<a
class="media"
[routerLink]="productLink()"
[state]="navigationState()"
(click)="rememberCatalogScroll()"
>
@if (imageUrl(); as imageUrl) {
<img
[src]="imageUrl"
@@ -32,9 +37,12 @@
</div>
<h3 class="name">
<a [routerLink]="productLink()" [state]="navigationState()">{{
product().name
}}</a>
<a
[routerLink]="productLink()"
[state]="navigationState()"
(click)="rememberCatalogScroll()"
>{{ product().name }}</a
>
</h3>
<p class="excerpt">
@@ -62,6 +70,7 @@
<a
[routerLink]="productLink()"
[state]="navigationState()"
(click)="rememberCatalogScroll()"
class="view-btn"
>{{ "SHOP.DETAILS" | translate }}</a
>

View File

@@ -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, '');
}
}

View File

@@ -20,7 +20,9 @@
}}</a>
@for (crumb of p.breadcrumbs; track crumb.id) {
<span class="breadcrumbs__separator">/</span>
<a class="breadcrumbs__item" [routerLink]="categoryLink(crumb.slug)"
<a
class="breadcrumbs__item"
[routerLink]="categoryLink(crumb.slug)"
>{{ crumb.name }}</a
>
}
@@ -143,12 +145,15 @@
<span>{{ selectedMaterial()?.label }}</span>
}
@if (
colorLabel(activeVariant) !== selectedMaterial()?.label
colorLabel(activeVariant) !==
selectedMaterial()?.label
) {
@if (selectedMaterial()?.label) {
<span aria-hidden="true">·</span>
}
<span>{{ colorLabel(activeVariant) | translate }}</span>
<span>{{
colorLabel(activeVariant) | translate
}}</span>
}
</p>
}
@@ -174,7 +179,10 @@
</div>
<div class="material-grid">
@for (material of materialOptions(); track material.key) {
@for (
material of materialOptions();
track material.key
) {
<button
type="button"
class="material-option"

View File

@@ -1,5 +1,7 @@
import { CommonModule, isPlatformBrowser } from '@angular/common';
import { CommonModule, Location, isPlatformBrowser } from '@angular/common';
import {
RESPONSE_INIT,
afterNextRender,
Component,
DestroyRef,
Injector,
@@ -15,10 +17,7 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { catchError, combineLatest, finalize, of, switchMap, tap } from 'rxjs';
import { SeoService } from '../../core/services/seo.service';
import { LanguageService } from '../../core/services/language.service';
import {
findColorHex,
getColorHex,
} from '../../core/constants/colors.const';
import { findColorHex, getColorHex } from '../../core/constants/colors.const';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
@@ -61,12 +60,14 @@ 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);
private readonly languageService = inject(LanguageService);
private readonly shopRouteService = inject(ShopRouteService);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
readonly shopService = inject(ShopService);
readonly categorySlug = input<string | undefined>();
@@ -196,16 +197,12 @@ export class ProductDetailComponent {
);
constructor() {
if (!this.shopService.cartLoaded()) {
this.shopService
.loadCart()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
});
}
afterNextRender(() => {
this.scheduleCartWarmup();
});
this.destroyRef.onDestroy(() => {
this.languageService.clearLocalizedRouteOverrides();
});
combineLatest([
toObservable(this.productSlug, { injector: this.injector }),
@@ -224,13 +221,17 @@ export class ProductDetailComponent {
}),
switchMap(([productSlug]) => {
if (!productSlug) {
this.languageService.clearLocalizedRouteOverrides();
this.error.set('SHOP.NOT_FOUND');
this.setResponseStatus(404);
this.applyFallbackSeo();
this.loading.set(false);
return of(null);
}
return this.shopService.getProductByPublicPath(productSlug).pipe(
catchError((error) => {
this.languageService.clearLocalizedRouteOverrides();
this.product.set(null);
this.selectedVariantId.set(null);
this.setSelectedImageAssetId(null);
@@ -238,6 +239,9 @@ export class ProductDetailComponent {
this.error.set(
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
);
if (error?.status === 404) {
this.setResponseStatus(404);
}
this.applyFallbackSeo();
return of(null);
}),
@@ -266,6 +270,7 @@ export class ProductDetailComponent {
null,
);
this.quantity.set(1);
this.languageService.setLocalizedRouteOverrides(product.localizedPaths);
this.syncPublicUrl(product);
this.applySeo(product);
this.modelFile.set(null);
@@ -285,6 +290,45 @@ export class ProductDetailComponent {
);
}
private scheduleCartWarmup(): void {
if (typeof window === 'undefined') {
this.loadCartIfNeeded();
return;
}
const warmup = () => this.loadCartIfNeeded();
const idleCallback = (
window as Window & {
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions,
) => number;
}
).requestIdleCallback;
if (typeof idleCallback === 'function') {
idleCallback(() => warmup(), { timeout: 1500 });
return;
}
window.setTimeout(warmup, 300);
}
private loadCartIfNeeded(): void {
if (this.shopService.cartLoaded() || this.shopService.cartLoading()) {
return;
}
this.shopService
.loadCart()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
});
}
selectImage(mediaAssetId: string): void {
this.setSelectedImageAssetId(mediaAssetId);
}
@@ -369,9 +413,12 @@ export class ProductDetailComponent {
if (!sessionId) {
return;
}
this.router.navigate(['/checkout'], {
queryParams: { session: sessionId },
});
this.router.navigate(
['/', this.languageService.selectedLang(), 'checkout'],
{
queryParams: { session: sessionId },
},
);
}
priceLabel(): number {
@@ -381,7 +428,9 @@ export class ProductDetailComponent {
}
colorLabel(variant: ShopProductVariantOption): string {
return variant.colorLabel || variant.colorName || variant.variantLabel || '-';
return (
variant.colorLabel || variant.colorName || variant.variantLabel || '-'
);
}
colorHex(variant: ShopProductVariantOption | null | undefined): string {
@@ -454,6 +503,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;
}
@@ -513,25 +567,34 @@ export class ProductDetailComponent {
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
const robots =
product.indexable === false ? 'noindex, nofollow' : 'index, follow';
const lang = this.languageService.selectedLang();
const canonicalPath =
product.localizedPaths?.[lang] ?? product.localizedPaths?.it ?? null;
this.seoService.applyPageSeo({
this.seoService.applyResolvedSeo({
title,
description,
robots,
ogTitle: product.ogTitle || title,
ogDescription: product.ogDescription || description,
canonicalPath,
alternates: product.localizedPaths,
xDefault: product.localizedPaths?.it ?? canonicalPath,
});
}
private applyFallbackSeo(): void {
const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`;
const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
this.seoService.applyPageSeo({
this.seoService.applyResolvedSeo({
title,
description,
robots: 'index, follow',
robots: 'noindex, nofollow',
ogTitle: title,
ogDescription: description,
canonicalPath: null,
alternates: null,
xDefault: null,
});
}
@@ -706,21 +769,23 @@ export class ProductDetailComponent {
return;
}
const currentProductSlug = this.productSlug()?.trim().toLowerCase() ?? '';
const targetProductSlug = this.shopRouteService.productPathSegment(product);
if (currentProductSlug === targetProductSlug) {
const currentTree = this.router.parseUrl(this.router.url);
const lang = this.languageService.selectedLang();
const targetPath =
product.localizedPaths?.[lang] ??
`/${lang}/shop/p/${this.shopRouteService.productPathSegment(product)}`;
const normalizedTargetPath = targetPath.startsWith('/')
? targetPath
: `/${targetPath}`;
const currentPath = this.router
.serializeUrl(currentTree)
.split(/[?#]/, 1)[0];
if (currentPath === normalizedTargetPath) {
return;
}
const currentTree = this.router.parseUrl(this.router.url);
const targetTree = this.router.createUrlTree(
[
'/',
this.languageService.selectedLang(),
'shop',
'p',
targetProductSlug,
],
['/', ...normalizedTargetPath.split('/').filter(Boolean)],
{
queryParams: currentTree.queryParams,
fragment: currentTree.fragment ?? undefined,
@@ -739,4 +804,10 @@ export class ProductDetailComponent {
state: history.state,
});
}
private setResponseStatus(status: number): void {
if (this.responseInit) {
this.responseInit.status = status;
}
}
}

View File

@@ -1,15 +1,14 @@
import { Injectable } from '@angular/core';
import { LanguageService } from '../../../core/services/language.service';
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
export interface ShopProductRouteRef {
id: string | null | undefined;
name: string | null | undefined;
slug?: string | null | undefined;
}
export interface ShopProductLookup {
idPrefix: string | null;
slugHint: string | null;
publicPath?: string | null | undefined;
localizedPaths?: Partial<Record<SupportedLang, string>> | null | undefined;
}
@Injectable({
@@ -26,11 +25,21 @@ export class ShopRouteService {
}
productCommands(product: ShopProductRouteRef): string[] {
const localizedPath = this.localizedProductPath(product);
if (localizedPath) {
return ['/', ...localizedPath.split('/').filter(Boolean)];
}
const lang = this.languageService.currentLang();
return ['/', lang, 'shop', 'p', this.productPathSegment(product)];
}
productPathSegment(product: ShopProductRouteRef): string {
const publicPath = String(product.publicPath ?? '').trim();
if (publicPath) {
return publicPath;
}
const idPrefix = this.productIdPrefix(product.id);
const tail =
this.slugify(product.name) || this.slugify(product.slug) || 'product';
@@ -38,41 +47,6 @@ export class ShopRouteService {
return idPrefix ? `${idPrefix}-${tail}` : tail;
}
resolveProductLookup(
productPathSegment: string | null | undefined,
): ShopProductLookup {
const normalized = String(productPathSegment ?? '')
.trim()
.toLowerCase();
if (!normalized) {
return {
idPrefix: null,
slugHint: null,
};
}
const bareUuidMatch = normalized.match(/^([0-9a-f]{8})$/);
if (bareUuidMatch) {
return {
idPrefix: bareUuidMatch[1],
slugHint: null,
};
}
const publicSlugMatch = normalized.match(/^([0-9a-f]{8})-(.+)$/);
if (publicSlugMatch) {
return {
idPrefix: publicSlugMatch[1],
slugHint: this.slugify(publicSlugMatch[2]) || null,
};
}
return {
idPrefix: null,
slugHint: normalized,
};
}
isCatalogUrl(url: string | null | undefined): boolean {
if (!url) {
return false;
@@ -92,6 +66,12 @@ export class ShopRouteService {
.replace(/-{2,}/g, '-');
}
private localizedProductPath(product: ShopProductRouteRef): string | null {
const lang = this.languageService.currentLang();
const localizedPath = String(product.localizedPaths?.[lang] ?? '').trim();
return localizedPath.startsWith('/') ? localizedPath : null;
}
private productIdPrefix(productId: string | null | undefined): string {
const normalized = String(productId ?? '')
.trim()

View File

@@ -112,6 +112,13 @@ describe('ShopService', () => {
defaultVariant: null,
primaryImage: null,
model3d: null,
publicPath: '12345678-supporto-cavo-scrivania',
localizedPaths: {
it: '/it/shop/p/12345678-supporto-cavo-scrivania',
en: '/en/shop/p/12345678-desk-cable-clip',
de: '/de/shop/p/12345678-schreibtisch-kabelhalter',
fr: '/fr/shop/p/12345678-support-cable-bureau',
},
},
],
});
@@ -142,6 +149,13 @@ describe('ShopService', () => {
primaryImage: null,
images: [],
model3d: null,
publicPath: '12345678-supporto-cavo-scrivania',
localizedPaths: {
it: '/it/shop/p/12345678-supporto-cavo-scrivania',
en: '/en/shop/p/12345678-desk-cable-clip',
de: '/de/shop/p/12345678-schreibtisch-kabelhalter',
fr: '/fr/shop/p/12345678-support-cable-bureau',
},
});
beforeEach(() => {
@@ -235,14 +249,15 @@ describe('ShopService', () => {
expect(response?.name).toBe('Supporto cavo scrivania');
});
it('resolves product detail from uuid prefix even when slug tail does not match', () => {
let response: ShopProductDetail | undefined;
it('rejects product paths whose slug tail does not match the canonical path', () => {
let errorResponse: { status?: number } | undefined;
service
.getProductByPublicPath('12345678-qualunque-nome')
.subscribe((product) => {
response = product;
});
service.getProductByPublicPath('12345678-qualunque-nome').subscribe({
next: () => fail('Expected canonical path mismatch to return 404'),
error: (error) => {
errorResponse = error;
},
});
const catalogRequest = httpMock.expectOne((request) => {
return (
@@ -253,24 +268,20 @@ describe('ShopService', () => {
});
catalogRequest.flush(buildCatalog());
const detailRequest = httpMock.expectOne((request) => {
return (
request.method === 'GET' &&
request.url ===
'http://localhost:8000/api/shop/products/desk-cable-clip' &&
request.params.get('lang') === 'it'
);
});
detailRequest.flush(buildProduct());
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
httpMock.expectNone(
'http://localhost:8000/api/shop/products/desk-cable-clip',
);
expect(errorResponse?.status).toBe(404);
});
it('resolves product detail from bare uuid prefix without slug tail', () => {
let response: ShopProductDetail | undefined;
it('rejects bare uuid product paths without the localized slug tail', () => {
let errorResponse: { status?: number } | undefined;
service.getProductByPublicPath('12345678').subscribe((product) => {
response = product;
service.getProductByPublicPath('12345678').subscribe({
next: () => fail('Expected bare uuid path to return 404'),
error: (error) => {
errorResponse = error;
},
});
const catalogRequest = httpMock.expectOne((request) => {
@@ -282,16 +293,9 @@ describe('ShopService', () => {
});
catalogRequest.flush(buildCatalog());
const detailRequest = httpMock.expectOne((request) => {
return (
request.method === 'GET' &&
request.url ===
'http://localhost:8000/api/shop/products/desk-cable-clip' &&
request.params.get('lang') === 'it'
);
});
detailRequest.flush(buildProduct());
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
httpMock.expectNone(
'http://localhost:8000/api/shop/products/desk-cable-clip',
);
expect(errorResponse?.status).toBe(404);
});
});

View File

@@ -7,7 +7,9 @@ import {
PublicMediaVariantDto,
} from '../../../core/services/public-media.service';
import { LanguageService } from '../../../core/services/language.service';
import { ShopRouteService } from './shop-route.service';
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
type LocalizedPathMap = Partial<Record<SupportedLang, string>>;
export interface ShopCategoryRef {
id: string;
@@ -84,6 +86,8 @@ export interface ShopProductSummary {
defaultVariant: ShopProductVariantOption | null;
primaryImage: PublicMediaUsageDto | null;
model3d: ShopProductModel | null;
publicPath: string;
localizedPaths: LocalizedPathMap;
}
export interface ShopProductDetail {
@@ -108,6 +112,8 @@ export interface ShopProductDetail {
primaryImage: PublicMediaUsageDto | null;
images: PublicMediaUsageDto[];
model3d: ShopProductModel | null;
publicPath: string;
localizedPaths: LocalizedPathMap;
}
export interface ShopProductCatalogResponse {
@@ -185,7 +191,6 @@ export interface ShopCategoryNavNode {
export class ShopService {
private readonly http = inject(HttpClient);
private readonly languageService = inject(LanguageService);
private readonly shopRouteService = inject(ShopRouteService);
private readonly apiUrl = `${environment.apiUrl}/api/shop`;
readonly cart = signal<ShopCartResponse | null>(null);
@@ -278,16 +283,18 @@ export class ShopService {
getProductByPublicPath(
productPathSegment: string,
): Observable<ShopProductDetail> {
const lookup =
this.shopRouteService.resolveProductLookup(productPathSegment);
if (!lookup.idPrefix && lookup.slugHint) {
return this.getProduct(lookup.slugHint);
const normalizedPath = this.normalizePublicPath(productPathSegment);
if (!normalizedPath) {
return throwError(() => ({
status: 404,
}));
}
return this.getProductCatalog().pipe(
map((catalog) =>
catalog.products.find((product) =>
product.id.toLowerCase().startsWith(lookup.idPrefix ?? ''),
catalog.products.find(
(product) =>
this.normalizePublicPath(product.publicPath) === normalizedPath,
),
),
switchMap((product) => {
@@ -301,6 +308,12 @@ export class ShopService {
);
}
private normalizePublicPath(value: string | null | undefined): string {
return String(value ?? '')
.trim()
.toLowerCase();
}
loadCart(): Observable<ShopCartResponse> {
this.cartLoading.set(true);
return this.http

View File

@@ -84,7 +84,9 @@
<div class="cart-line-copy">
<strong>{{ cartItemName(item) }}</strong>
@if (cartItemVariant(item); as variant) {
<span class="cart-line-meta">{{ variant | translate }}</span>
<span class="cart-line-meta">{{
variant | translate
}}</span>
}
@if (cartItemColor(item); as color) {
<span class="cart-line-color">
@@ -237,7 +239,10 @@
</h2>
</div>
<app-button variant="primary" routerLink="/contact">
<app-button
variant="primary"
[routerLink]="languageService.localizedPath('/contact')"
>
{{ "NAV.CONTACT" | translate }}
</app-button>
</div>

View File

@@ -332,6 +332,10 @@
}
@media (max-width: 760px) {
.cart-card {
display: none;
}
.product-grid {
grid-template-columns: 1fr;
}

View File

@@ -1,5 +1,7 @@
import { CommonModule } from '@angular/common';
import {
RESPONSE_INIT,
afterNextRender,
Component,
DestroyRef,
Injector,
@@ -59,7 +61,8 @@ export class ShopPageComponent {
private readonly router = inject(Router);
private readonly translate = inject(TranslateService);
private readonly seoService = inject(SeoService);
private readonly languageService = inject(LanguageService);
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
readonly languageService = inject(LanguageService);
private readonly shopRouteService = inject(ShopRouteService);
readonly shopService = inject(ShopService);
@@ -89,16 +92,9 @@ export class ShopPageComponent {
readonly cartHasItems = computed(() => this.cartItems().length > 0);
constructor() {
if (!this.shopService.cartLoaded()) {
this.shopService
.loadCart()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
});
}
afterNextRender(() => {
this.scheduleCartWarmup();
});
combineLatest([
toObservable(this.categorySlug, { injector: this.injector }),
@@ -124,7 +120,10 @@ export class ShopPageComponent {
this.error.set(
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
);
this.applyDefaultSeo();
if (error?.status === 404) {
this.setResponseStatus(404);
}
this.applyErrorSeo();
return of(null);
}),
finalize(() => this.loading.set(false)),
@@ -147,6 +146,46 @@ export class ShopPageComponent {
this.selectedCategory.set(result.catalog.category ?? null);
this.products.set(result.catalog.products);
this.applySeo(result.catalog.category ?? null);
this.restoreCatalogScrollIfNeeded();
});
}
private scheduleCartWarmup(): void {
if (typeof window === 'undefined') {
this.loadCartIfNeeded();
return;
}
const warmup = () => this.loadCartIfNeeded();
const idleCallback = (
window as Window & {
requestIdleCallback?: (
callback: IdleRequestCallback,
options?: IdleRequestOptions,
) => number;
}
).requestIdleCallback;
if (typeof idleCallback === 'function') {
idleCallback(() => warmup(), { timeout: 1500 });
return;
}
window.setTimeout(warmup, 300);
}
private loadCartIfNeeded(): void {
if (this.shopService.cartLoaded() || this.shopService.cartLoading()) {
return;
}
this.shopService
.loadCart()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
});
}
@@ -242,11 +281,14 @@ export class ShopPageComponent {
if (!sessionId) {
return;
}
this.router.navigate(['/checkout'], {
queryParams: {
session: sessionId,
this.router.navigate(
['/', this.languageService.selectedLang(), 'checkout'],
{
queryParams: {
session: sessionId,
},
},
});
);
}
trackByCategory(_index: number, item: ShopCategoryNavNode): string {
@@ -317,4 +359,46 @@ export class ShopPageComponent {
ogDescription: description,
});
}
private applyErrorSeo(): void {
const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`;
const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
this.seoService.applyResolvedSeo({
title,
description,
robots: 'noindex, nofollow',
ogTitle: title,
ogDescription: description,
canonicalPath: null,
alternates: null,
xDefault: null,
});
}
private setResponseStatus(status: number): void {
if (this.responseInit) {
this.responseInit.status = status;
}
}
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);
});
}
}

View File

@@ -7,30 +7,32 @@ export const SHOP_ROUTES: Routes = [
path: '',
component: ShopPageComponent,
data: {
seoTitle: 'Shop 3D fab',
seoDescription:
'Catalogo prodotti stampati in 3D, accessori tecnici e soluzioni pratiche pronte all uso.',
seoTitleKey: 'SEO.ROUTES.SHOP.TITLE',
seoDescriptionKey: 'SEO.ROUTES.SHOP.DESCRIPTION',
},
},
{
path: 'p/:productSlug',
component: ProductDetailComponent,
data: {
seoTitle: 'Prodotto | 3D fab',
seoTitleKey: 'SEO.ROUTES.SHOP.PRODUCT_TITLE',
seoDescriptionKey: 'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION',
},
},
{
path: ':categorySlug/:productSlug',
component: ProductDetailComponent,
data: {
seoTitle: 'Prodotto | 3D fab',
seoTitleKey: 'SEO.ROUTES.SHOP.PRODUCT_TITLE',
seoDescriptionKey: 'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION',
},
},
{
path: ':categorySlug',
component: ShopPageComponent,
data: {
seoTitle: 'Categoria Shop | 3D fab',
seoTitleKey: 'SEO.ROUTES.SHOP.CATEGORY_TITLE',
seoDescriptionKey: 'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION',
},
},
];

View File

@@ -12,7 +12,7 @@ import { TranslateModule } from '@ngx-translate/core';
export class AppDropzoneComponent {
label = input<string>('DROPZONE.DEFAULT_LABEL');
subtext = input<string>('DROPZONE.DEFAULT_SUBTEXT');
accept = input<string>('.stl,.3mf,.step,.stp');
accept = input<string>('.stl,.3mf');
multiple = input<boolean>(true);
filesDropped = output<File[]>();

View File

@@ -27,7 +27,10 @@
</div>
<div class="actions">
<a routerLink="/contact" class="contact-btn">
<a
[routerLink]="languageService.localizedPath('/contact')"
class="contact-btn"
>
{{ "LOCATIONS.CONTACT_US" | translate }}
</a>
</div>

View File

@@ -1,7 +1,8 @@
import { Component } from '@angular/core';
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { RouterLink } from '@angular/router';
import { LanguageService } from '../../../core/services/language.service';
import {
AppToggleSelectorComponent,
ToggleOption,
@@ -20,6 +21,7 @@ import {
styleUrl: './app-locations.component.scss',
})
export class AppLocationsComponent {
readonly languageService = inject(LanguageService);
selectedLocation: 'ticino' | 'bienne' = 'ticino';
locationOptions: ToggleOption[] = [

View File

@@ -0,0 +1,17 @@
<div
class="brand-animation"
[attr.data-variant]="variant()"
role="img"
[attr.aria-hidden]="decorative() ? 'true' : null"
[attr.aria-label]="decorative() ? null : ariaLabel()"
>
@for (letter of resolvedLetters(); track letter.key) {
<img
class="brand-animation__letter"
[src]="letter.src"
alt=""
[attr.data-letter]="letter.key"
[style.--word-x]="letter.wordX"
/>
}
</div>

View File

@@ -0,0 +1,172 @@
: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-group-scale-x: 0.94;
--loader-group-scale-y: 1.05;
--loader-exit-shift: 1.6rem;
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.65s) infinite;
}
@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%,
5% {
opacity: 0;
transform: translate(-50%, -50%)
translateX(
calc(
var(--three-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
)
)
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
}
12% {
opacity: 1;
transform: translate(-50%, -50%)
translateX(
calc(
var(--three-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
)
)
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
}
12% {
animation-timing-function: cubic-bezier(0.22, 0.82, 0.28, 1);
}
38%,
56% {
opacity: 1;
transform: translate(-50%, -50%)
translateX(
calc(var(--word-x) * var(--word-scale) * var(--word-spacing-factor))
)
scale(1);
}
56% {
animation-timing-function: cubic-bezier(0.38, 0, 0.72, 1);
}
82%,
88% {
opacity: 1;
transform: translate(-50%, -50%)
translateX(
calc(
var(--bee-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
)
)
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
}
88% {
animation-timing-function: cubic-bezier(0.4, 0, 1, 1);
}
94% {
opacity: 0;
transform: translate(-50%, -50%)
translateX(
calc(
(var(--bee-anchor-x) + var(--loader-exit-shift)) * var(--word-scale) *
var(--word-spacing-factor)
)
)
scaleX(0.98) scaleY(1.02);
}
94.01%,
100% {
opacity: 0;
transform: translate(-50%, -50%)
translateX(
calc(
var(--three-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
)
)
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
}
}
@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);
}
}

View File

@@ -0,0 +1,69 @@
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<BrandAnimationVariant>('site-intro');
readonly decorative = input(true);
readonly ariaLabel = input('3D fab animated logo');
readonly resolvedLetters = computed<ResolvedAnimationLetter[]>(() =>
LETTERS.map((letter) => ({
key: letter.key,
src: this.variant() === 'site-intro' ? letter.yellowSrc : letter.darkSrc,
wordX: letter.wordX,
})),
);
}

View File

@@ -1,4 +1,11 @@
import { Component, input, output, signal, computed, inject } from '@angular/core';
import {
Component,
input,
output,
signal,
computed,
inject,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import {

View File

@@ -39,16 +39,82 @@
"TERMS": "AGB",
"CONTACT": "Kontakt"
},
"SEO": {
"DEFAULT": {
"TITLE": "3D fab | 3D-Druck nach Maß",
"DESCRIPTION": "3D-Druckservice nach Maß, technischer Shop und CAD-Support für Prototypen, Ersatzteile und Kleinserien."
},
"ROUTES": {
"HOME": {
"TITLE": "3D-Druck in Zürich | Prototypen, Ersatzteile und Kleinserien - 3D fab",
"DESCRIPTION": "3D-Druckservice in Zürich für Prototypen, Ersatzteile und Kleinserien. Technischer Shop und CAD-Service, mit schneller Angebotsanfrage aus STL-Dateien."
},
"CALCULATOR": {
"TITLE": "3D-Druck-Angebotsrechner | 3D fab",
"DESCRIPTION": "Laden Sie Ihre 3D-Datei hoch und erhalten Sie Preis und Lieferzeit in Sekunden mit echtem Slicing.",
"BASIC": {
"TITLE": "Einfacher 3D-Druck-Rechner | 3D fab",
"DESCRIPTION": "Berechnen Sie den Preis Ihres 3D-Drucks schnell mit dem Basis-Workflow."
},
"ADVANCED": {
"TITLE": "Erweiterter 3D-Druck-Rechner | 3D fab",
"DESCRIPTION": "Konfigurieren Sie erweiterte Druckparameter und erhalten Sie ein präzises Angebot mit echtem Slicing."
}
},
"SHOP": {
"TITLE": "3D fab Shop",
"DESCRIPTION": "Katalog mit 3D-gedruckten Produkten, technischem Zubehör und sofort einsatzbereiten Lösungen.",
"CATEGORY_TITLE": "Shop-Kategorie | 3D fab",
"CATEGORY_DESCRIPTION": "Entdecken Sie Produkte dieser Kategorie, verfügbare Varianten und technische 3D-Druck-Lösungen.",
"PRODUCT_TITLE": "Produkt | 3D fab",
"PRODUCT_DESCRIPTION": "Entdecken Sie Details, Materialien, Varianten und Verfügbarkeit des ausgewählten Produkts im 3D fab Shop."
},
"MATERIALS": {
"TITLE": "Qualität und Materialien | 3D fab",
"DESCRIPTION": "Vergleichen Sie 3D-Druckmaterialien mit interaktiven Radar-Charts, technischen Eigenschaften und referenzierten Quellen."
},
"ABOUT": {
"TITLE": "Über uns | 3D fab",
"DESCRIPTION": "Lernen Sie das Team von 3D fab und unser 3D-Druck-Labor für Prototypen, Ersatzteile und maßgeschneiderte Produktionen kennen."
},
"CONTACT": {
"TITLE": "Kontakt | 3D fab",
"DESCRIPTION": "Fordern Sie Informationen, individuelle Angebote oder technischen Support für Ihr 3D-Druck-Projekt an."
},
"LEGAL": {
"PRIVACY": {
"TITLE": "Datenschutz | 3D fab",
"DESCRIPTION": "Datenschutzerklärung von 3D fab: Datenverarbeitung, Zwecke und Kontaktangaben."
},
"TERMS": {
"TITLE": "AGB | 3D fab",
"DESCRIPTION": "Allgemeine Geschäftsbedingungen für den 3D-Druck-Service und den Angebotsrechner."
}
},
"CHECKOUT": {
"TITLE": "Checkout | 3D fab",
"DESCRIPTION": "Schließen Sie Ihre Anfrage ab und bestätigen Sie die Daten Ihrer 3D-Druck-Bestellung."
},
"ORDER": {
"TITLE": "Bestellung | 3D fab",
"DESCRIPTION": "Prüfen Sie die Zusammenfassung Ihrer Bestellung und den Status Ihrer 3D-Druck-Anfrage."
},
"ADMIN": {
"TITLE": "Admin | 3D fab",
"DESCRIPTION": "Geschützter Administrationsbereich von 3D fab."
}
}
},
"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",
@@ -75,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...",
@@ -99,6 +166,7 @@
"SHOP": {
"TITLE": "Technische Lösungen",
"SUBTITLE": "Fertige Produkte, die praktische Probleme lösen",
"HERO_EYEBROW": "Technischer Shop",
"WIP_EYEBROW": "Work in progress",
"WIP_TITLE": "Shop im Aufbau",
"WIP_SUBTITLE": "Wir bereiten einen Shop mit ausgewählten Produkten und Funktionen zur automatischen Erstellung vor!",
@@ -109,6 +177,7 @@
"CUSTOM_PART_FOOTER_TEXT": "Kontaktieren Sie uns für individuelle Teile.",
"ADD_CART": "In den Warenkorb",
"ADDING": "Wird hinzugefügt",
"ADD_SUCCESS": "Produkt zum Warenkorb hinzugefügt.",
"BACK": "Zurück zum Shop",
"NOT_FOUND": "Produkt nicht gefunden.",
"DETAILS": "Details",
@@ -116,14 +185,39 @@
"SUCCESS_TITLE": "Zum Warenkorb hinzugefügt",
"SUCCESS_DESC": "Das Produkt wurde erfolgreich zum Warenkorb hinzugefügt.",
"CONTINUE": "Weiter",
"VIEW_ALL": "Gesamten Shop ansehen",
"CATALOG_LABEL": "Katalog",
"CATALOG_TITLE": "Alle Produkte",
"CATALOG_META_DESCRIPTION": "Entdecken Sie 3D-gedruckte Produkte, technisches Zubehör und einsatzbereite Lösungen mit demselben Checkout wie im Rechner.",
"CUSTOM_PART_CTA": "Nicht gefunden, was Sie suchen? Fordern Sie ein individuelles Teil an.",
"CATEGORY_META": "{{count}} Produkte in dieser Kategorie verfügbar",
"CATEGORY_PANEL_KICKER": "Navigation",
"CATEGORY_PANEL_TITLE": "Kategorien",
"SELECTED_CATEGORY": "Ausgewählte Kategorie",
"ITEMS_FOUND": "Produkte",
"EMPTY_CATEGORY": "Derzeit sind in dieser Kategorie keine Produkte verfügbar.",
"FEATURED_KICKER": "Empfohlen",
"FEATURED_TITLE": "Produkte, die sich lohnen",
"FEATURED_BADGE": "Empfohlen",
"HIGHLIGHT_PRODUCTS": "Produkte",
"HIGHLIGHT_CART": "Im Warenkorb",
"HIGHLIGHT_READY": "Vorschau",
"PRICE_FROM": "Preis ab",
"MODEL_OPEN": "3D-Ansicht öffnen",
"MODEL_CLOSE": "3D-Ansicht schließen",
"MODEL_3D": "3D-Vorschau",
"MODEL_TITLE": "Modellvorschau",
"MODEL_LOADING": "Das 3D-Modell wird geladen.",
"MODEL_UNAVAILABLE": "3D-Vorschau nicht verfügbar.",
"PREVIOUS_IMAGE": "Vorheriges Bild",
"NEXT_IMAGE": "Nächstes Bild",
"BREADCRUMB_ROOT": "Shop",
"PRICE_LABEL": "Preis",
"EXCERPT_FALLBACK": "Produktseite in Vorbereitung.",
"SELECT_MATERIAL": "Material",
"SELECT_COLOR": "Farbe",
"MATERIAL_COLOR_COUNT": "{{count}} Farben verfügbar",
"VARIANT": "Variante",
"PROPERTY_UV": "UV-Beständigkeit",
"PROPERTY_WEATHER": "Außeneinsatz",
"PROPERTY_RIGIDITY": "Steifigkeit",
@@ -132,6 +226,22 @@
"PROPERTY_LOW": "Niedrig",
"PROPERTY_RIGID": "Steif",
"PROPERTY_FLEXIBLE": "Flexibel",
"QUANTITY": "Menge",
"GO_TO_CHECKOUT": "Zum Checkout",
"IN_CART_SHORT": "Im Warenkorb x{{count}}",
"IN_CART_LONG": "Bereits im Warenkorb x{{count}}",
"DESCRIPTION_TITLE": "Beschreibung",
"CART_TITLE": "Warenkorb",
"CART_SUMMARY_TITLE": "Aktuelle Übersicht",
"CART_LOADING": "Warenkorb wird geladen.",
"CART_EMPTY": "Der Warenkorb ist leer. Fügen Sie ein Produkt hinzu.",
"CART_SUBTOTAL": "Zwischensumme Produkte",
"CART_SHIPPING": "Versand",
"CART_TOTAL": "Geschätzte Gesamtsumme",
"CLEAR_CART": "Leeren",
"REMOVE": "Entfernen",
"CART_UPDATE_ERROR": "Der Warenkorb konnte nicht aktualisiert werden. Bitte erneut versuchen.",
"ALL_CATEGORIES": "Alle Kategorien",
"CATEGORIES": {
"FILAMENTS": "Filamente",
"ACCESSORIES": "Zubehör"
@@ -399,6 +509,8 @@
"CHECKOUT": {
"TITLE": "Checkout",
"SUBTITLE": "Schließen Sie Ihre Bestellung ab, indem Sie Versand- und Zahlungsdetails eingeben.",
"CAD_SERVICE": "CAD-Service",
"CAD_REQUEST_REF": "bezogen auf Kontaktanfrage #{{id}}",
"CONTACT_INFO": "Kontaktinformationen",
"BILLING_ADDR": "Rechnungsadresse",
"SHIPPING_ADDR": "Lieferadresse",
@@ -513,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",
@@ -552,11 +664,18 @@
"ERR_ID_NOT_FOUND": "Bestell-ID nicht gefunden.",
"ERR_LOAD_ORDER": "Bestelldetails konnten nicht geladen werden.",
"ERR_REPORT_PAYMENT": "Zahlung konnte nicht gemeldet werden. Bitte erneut versuchen.",
"CAD_SERVICE": "CAD-Service ({{hours}}h)",
"ITEMS_TITLE": "Bestellartikel",
"ORDER_TYPE_LABEL": "Bestelltyp",
"ITEM_COUNT": "Positionen",
"TYPE_SHOP": "Shop",
"TYPE_CALCULATOR": "Rechner",
"TYPE_MIXED": "Gemischt",
"NOT_AVAILABLE": "N/V"
},
"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",

View File

@@ -39,16 +39,82 @@
"TERMS": "Terms & Conditions",
"CONTACT": "Contact Us"
},
"SEO": {
"DEFAULT": {
"TITLE": "3D fab | Custom 3D Printing",
"DESCRIPTION": "Custom 3D printing service, technical shop and CAD support for prototypes, spare parts and short runs."
},
"ROUTES": {
"HOME": {
"TITLE": "Custom 3D Printing in Switzerland | Prototypes, Spare Parts & Short Runs - 3D fab",
"DESCRIPTION": "Swiss-based 3D printing service for prototypes, spare parts and short production runs. Technical shop and CAD support, with fast quotes from STL files."
},
"CALCULATOR": {
"TITLE": "3D Printing Quote Calculator | 3D fab",
"DESCRIPTION": "Upload your 3D file and get price and lead time in seconds with real slicing.",
"BASIC": {
"TITLE": "Basic 3D Printing Calculator | 3D fab",
"DESCRIPTION": "Quickly estimate the price of your 3D print with the basic workflow."
},
"ADVANCED": {
"TITLE": "Advanced 3D Printing Calculator | 3D fab",
"DESCRIPTION": "Configure advanced print settings and get a precise quote based on real slicing."
}
},
"SHOP": {
"TITLE": "3D fab Shop",
"DESCRIPTION": "Catalog of 3D printed products, technical accessories and practical ready-to-use solutions.",
"CATEGORY_TITLE": "Shop Category | 3D fab",
"CATEGORY_DESCRIPTION": "Browse products in this category, available variants and technical 3D printed solutions.",
"PRODUCT_TITLE": "Product | 3D fab",
"PRODUCT_DESCRIPTION": "Discover details, materials, variants and availability for the selected product in the 3D fab shop."
},
"MATERIALS": {
"TITLE": "Quality and Materials | 3D fab",
"DESCRIPTION": "Compare 3D printing materials with interactive radar charts, technical properties and cited sources."
},
"ABOUT": {
"TITLE": "About Us | 3D fab",
"DESCRIPTION": "Learn more about the 3D fab team and our 3D printing lab for prototypes, spare parts and custom production."
},
"CONTACT": {
"TITLE": "Contact | 3D fab",
"DESCRIPTION": "Request information, custom quotes or technical support for your 3D printing project."
},
"LEGAL": {
"PRIVACY": {
"TITLE": "Privacy Policy | 3D fab",
"DESCRIPTION": "3D fab privacy policy: data processing, purposes and contact details."
},
"TERMS": {
"TITLE": "Terms and Conditions | 3D fab",
"DESCRIPTION": "Terms and conditions for the 3D printing service and instant quote calculator."
}
},
"CHECKOUT": {
"TITLE": "Checkout | 3D fab",
"DESCRIPTION": "Complete your request and confirm the details for your 3D printing order."
},
"ORDER": {
"TITLE": "Order | 3D fab",
"DESCRIPTION": "Review your order summary and the status of your 3D printing request."
},
"ADMIN": {
"TITLE": "Admin | 3D fab",
"DESCRIPTION": "Restricted 3D fab administration area."
}
}
},
"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",
@@ -75,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...",
@@ -99,6 +166,7 @@
"SHOP": {
"TITLE": "Technical solutions",
"SUBTITLE": "Ready-made products solving practical problems",
"HERO_EYEBROW": "Technical shop",
"WIP_EYEBROW": "Work in progress",
"WIP_TITLE": "Shop under construction",
"WIP_SUBTITLE": "We are building a curated technical shop with products that are genuinely useful and field-tested.",
@@ -109,6 +177,7 @@
"CUSTOM_PART_FOOTER_TEXT": "Contact us for custom parts.",
"ADD_CART": "Add to Cart",
"ADDING": "Adding to cart",
"ADD_SUCCESS": "Product added to cart.",
"BACK": "Back to Shop",
"NOT_FOUND": "Product not found.",
"DETAILS": "Details",
@@ -116,14 +185,39 @@
"SUCCESS_TITLE": "Added to cart",
"SUCCESS_DESC": "The product has been added to the cart.",
"CONTINUE": "Continue",
"VIEW_ALL": "View the full shop",
"CATALOG_LABEL": "Catalog",
"CATALOG_TITLE": "All products",
"CATALOG_META_DESCRIPTION": "Discover 3D printed products, technical accessories, and ready-to-use solutions with the same checkout as the calculator.",
"CUSTOM_PART_CTA": "Can't find what you're looking for? Request a custom part.",
"CATEGORY_META": "{{count}} products available in this category",
"CATEGORY_PANEL_KICKER": "Navigation",
"CATEGORY_PANEL_TITLE": "Categories",
"SELECTED_CATEGORY": "Selected category",
"ITEMS_FOUND": "products",
"EMPTY_CATEGORY": "No products are currently available in this category.",
"FEATURED_KICKER": "Featured",
"FEATURED_TITLE": "Products worth watching",
"FEATURED_BADGE": "Featured",
"HIGHLIGHT_PRODUCTS": "Products",
"HIGHLIGHT_CART": "In cart",
"HIGHLIGHT_READY": "Preview",
"PRICE_FROM": "Price from",
"MODEL_OPEN": "Open 3D view",
"MODEL_CLOSE": "Close 3D view",
"MODEL_3D": "3D preview",
"MODEL_TITLE": "Model preview",
"MODEL_LOADING": "Loading the 3D model.",
"MODEL_UNAVAILABLE": "3D preview unavailable.",
"PREVIOUS_IMAGE": "Previous image",
"NEXT_IMAGE": "Next image",
"BREADCRUMB_ROOT": "Shop",
"PRICE_LABEL": "Price",
"EXCERPT_FALLBACK": "Product page coming soon.",
"SELECT_MATERIAL": "Material",
"SELECT_COLOR": "Color",
"MATERIAL_COLOR_COUNT": "{{count}} colors available",
"VARIANT": "Variant",
"PROPERTY_UV": "UV resistance",
"PROPERTY_WEATHER": "Outdoor use",
"PROPERTY_RIGIDITY": "Rigidity",
@@ -132,6 +226,22 @@
"PROPERTY_LOW": "Low",
"PROPERTY_RIGID": "Rigid",
"PROPERTY_FLEXIBLE": "Flexible",
"QUANTITY": "Quantity",
"GO_TO_CHECKOUT": "Go to checkout",
"IN_CART_SHORT": "In cart x{{count}}",
"IN_CART_LONG": "Already in cart x{{count}}",
"DESCRIPTION_TITLE": "Description",
"CART_TITLE": "Cart",
"CART_SUMMARY_TITLE": "Current summary",
"CART_LOADING": "Loading cart.",
"CART_EMPTY": "Your cart is empty. Add a product.",
"CART_SUBTOTAL": "Products subtotal",
"CART_SHIPPING": "Shipping",
"CART_TOTAL": "Estimated total",
"CLEAR_CART": "Clear",
"REMOVE": "Remove",
"CART_UPDATE_ERROR": "We couldn't update the cart. Please try again.",
"ALL_CATEGORIES": "All categories",
"CATEGORIES": {
"FILAMENTS": "Filaments",
"ACCESSORIES": "Accessories"
@@ -399,6 +509,8 @@
"CHECKOUT": {
"TITLE": "Checkout",
"SUBTITLE": "Complete your order by entering the shipping and payment details.",
"CAD_SERVICE": "CAD service",
"CAD_REQUEST_REF": "related to contact request #{{id}}",
"CONTACT_INFO": "Contact Information",
"BILLING_ADDR": "Billing Address",
"SHIPPING_ADDR": "Shipping Address",
@@ -513,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",
@@ -552,11 +664,18 @@
"ERR_ID_NOT_FOUND": "Order ID not found.",
"ERR_LOAD_ORDER": "Failed to load order details.",
"ERR_REPORT_PAYMENT": "Failed to report payment. Please try again.",
"CAD_SERVICE": "CAD service ({{hours}}h)",
"ITEMS_TITLE": "Order items",
"ORDER_TYPE_LABEL": "Order type",
"ITEM_COUNT": "Lines",
"TYPE_SHOP": "Shop",
"TYPE_CALCULATOR": "Calculator",
"TYPE_MIXED": "Mixed",
"NOT_AVAILABLE": "N/A"
},
"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",

View File

@@ -13,6 +13,72 @@
"TERMS": "Conditions générales",
"CONTACT": "Contactez-nous"
},
"SEO": {
"DEFAULT": {
"TITLE": "3D fab | Impression 3D sur mesure",
"DESCRIPTION": "Service d'impression 3D sur mesure, boutique technique et support CAD pour prototypes, pièces et petites séries."
},
"ROUTES": {
"HOME": {
"TITLE": "Impression 3D à Bienne | Prototypes, pièces et petites séries - 3D fab",
"DESCRIPTION": "Service d'impression 3D à Bienne pour prototypes, pièces de rechange et petites séries. Boutique technique et support CAD, avec devis rapide depuis un fichier STL."
},
"CALCULATOR": {
"TITLE": "Calculateur de devis impression 3D | 3D fab",
"DESCRIPTION": "Chargez votre fichier 3D et obtenez prix et délais en quelques secondes avec un vrai slicing.",
"BASIC": {
"TITLE": "Calculateur impression 3D de base | 3D fab",
"DESCRIPTION": "Calculez rapidement le prix de votre impression 3D avec le parcours de base."
},
"ADVANCED": {
"TITLE": "Calculateur impression 3D avancé | 3D fab",
"DESCRIPTION": "Configurez des paramètres avancés et obtenez un devis précis basé sur un vrai slicing."
}
},
"SHOP": {
"TITLE": "Boutique 3D fab",
"DESCRIPTION": "Catalogue de produits imprimés en 3D, accessoires techniques et solutions pratiques prêtes à l'emploi.",
"CATEGORY_TITLE": "Catégorie boutique | 3D fab",
"CATEGORY_DESCRIPTION": "Parcourez les produits de cette catégorie, les variantes disponibles et les solutions techniques imprimées en 3D.",
"PRODUCT_TITLE": "Produit | 3D fab",
"PRODUCT_DESCRIPTION": "Découvrez les détails, matériaux, variantes et disponibilités du produit sélectionné dans la boutique 3D fab."
},
"MATERIALS": {
"TITLE": "Qualité et matériaux | 3D fab",
"DESCRIPTION": "Comparez les matériaux d'impression 3D avec des radar charts interactifs, des propriétés techniques et des sources citées."
},
"ABOUT": {
"TITLE": "Qui sommes-nous | 3D fab",
"DESCRIPTION": "Découvrez l'équipe de 3D fab et notre atelier d'impression 3D pour prototypes, pièces et productions sur mesure."
},
"CONTACT": {
"TITLE": "Contact | 3D fab",
"DESCRIPTION": "Demandez des informations, des devis personnalisés ou un support technique pour votre projet d'impression 3D."
},
"LEGAL": {
"PRIVACY": {
"TITLE": "Politique de confidentialité | 3D fab",
"DESCRIPTION": "Politique de confidentialité de 3D fab : traitement des données, finalités et contacts."
},
"TERMS": {
"TITLE": "Conditions générales | 3D fab",
"DESCRIPTION": "Conditions générales du service d'impression 3D et du calculateur de devis."
}
},
"CHECKOUT": {
"TITLE": "Checkout | 3D fab",
"DESCRIPTION": "Finalisez votre demande et confirmez les détails de votre commande d'impression 3D."
},
"ORDER": {
"TITLE": "Commande | 3D fab",
"DESCRIPTION": "Consultez le récapitulatif de votre commande et l'état de votre demande d'impression 3D."
},
"ADMIN": {
"TITLE": "Admin | 3D fab",
"DESCRIPTION": "Espace d'administration restreint de 3D fab."
}
}
},
"HOME": {
"HERO_EYEBROW": "Impression 3D technique pour entreprises, freelances et makers",
"HERO_TITLE": "Service d'impression 3D.<br>Du fichier à la pièce finie.",
@@ -30,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",
@@ -73,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",
@@ -119,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."
@@ -163,6 +230,7 @@
"SHOP": {
"TITLE": "Solutions techniques",
"SUBTITLE": "Produits prêts à l'emploi qui résolvent des problèmes pratiques",
"HERO_EYEBROW": "Boutique technique",
"WIP_EYEBROW": "Work in progress",
"WIP_TITLE": "Boutique en préparation",
"WIP_SUBTITLE": "Nous préparons une boutique avec des produits sélectionnés et des fonctionnalités de création automatique !",
@@ -173,6 +241,7 @@
"CUSTOM_PART_FOOTER_TEXT": "Contactez-nous pour des pièces personnalisées.",
"ADD_CART": "Ajouter au panier",
"ADDING": "Ajout en cours",
"ADD_SUCCESS": "Produit ajouté au panier.",
"BACK": "Retour à la boutique",
"NOT_FOUND": "Produit introuvable.",
"DETAILS": "Détails",
@@ -180,14 +249,39 @@
"SUCCESS_TITLE": "Ajouté au panier",
"SUCCESS_DESC": "Le produit a été ajouté au panier avec succès.",
"CONTINUE": "Continuer",
"VIEW_ALL": "Voir toute la boutique",
"CATALOG_LABEL": "Catalogue",
"CATALOG_TITLE": "Tous les produits",
"CATALOG_META_DESCRIPTION": "Découvrez des produits imprimés en 3D, des accessoires techniques et des solutions prêtes à l'emploi avec le même checkout que le calculateur.",
"CUSTOM_PART_CTA": "Vous ne trouvez pas ce que vous cherchez ? Demandez une pièce personnalisée.",
"CATEGORY_META": "{{count}} produits disponibles dans cette catégorie",
"CATEGORY_PANEL_KICKER": "Navigation",
"CATEGORY_PANEL_TITLE": "Catégories",
"SELECTED_CATEGORY": "Catégorie sélectionnée",
"ITEMS_FOUND": "produits",
"EMPTY_CATEGORY": "Aucun produit n'est disponible dans cette catégorie pour le moment.",
"FEATURED_KICKER": "À la une",
"FEATURED_TITLE": "Produits à surveiller",
"FEATURED_BADGE": "À la une",
"HIGHLIGHT_PRODUCTS": "Produits",
"HIGHLIGHT_CART": "Dans le panier",
"HIGHLIGHT_READY": "Aperçu",
"PRICE_FROM": "Prix à partir de",
"MODEL_OPEN": "Ouvrir la vue 3D",
"MODEL_CLOSE": "Fermer la vue 3D",
"MODEL_3D": "Aperçu 3D",
"MODEL_TITLE": "Aperçu du modèle",
"MODEL_LOADING": "Chargement du modèle 3D.",
"MODEL_UNAVAILABLE": "Aperçu 3D indisponible.",
"PREVIOUS_IMAGE": "Image précédente",
"NEXT_IMAGE": "Image suivante",
"BREADCRUMB_ROOT": "Boutique",
"PRICE_LABEL": "Prix",
"EXCERPT_FALLBACK": "Fiche produit en préparation.",
"SELECT_MATERIAL": "Matériau",
"SELECT_COLOR": "Couleur",
"MATERIAL_COLOR_COUNT": "{{count}} couleurs disponibles",
"VARIANT": "Variante",
"PROPERTY_UV": "Résistance UV",
"PROPERTY_WEATHER": "Usage extérieur",
"PROPERTY_RIGIDITY": "Rigidité",
@@ -196,6 +290,22 @@
"PROPERTY_LOW": "Faible",
"PROPERTY_RIGID": "Rigide",
"PROPERTY_FLEXIBLE": "Flexible",
"QUANTITY": "Quantité",
"GO_TO_CHECKOUT": "Aller au checkout",
"IN_CART_SHORT": "Dans le panier x{{count}}",
"IN_CART_LONG": "Déjà dans le panier x{{count}}",
"DESCRIPTION_TITLE": "Description",
"CART_TITLE": "Panier",
"CART_SUMMARY_TITLE": "Récapitulatif actuel",
"CART_LOADING": "Chargement du panier.",
"CART_EMPTY": "Le panier est vide. Ajoutez un produit.",
"CART_SUBTOTAL": "Sous-total produits",
"CART_SHIPPING": "Expédition",
"CART_TOTAL": "Total estimé",
"CLEAR_CART": "Vider",
"REMOVE": "Supprimer",
"CART_UPDATE_ERROR": "Nous n'avons pas réussi à mettre à jour le panier. Réessayez.",
"ALL_CATEGORIES": "Toutes les catégories",
"CATEGORIES": {
"FILAMENTS": "Filaments",
"ACCESSORIES": "Accessoires"
@@ -463,6 +573,8 @@
"CHECKOUT": {
"TITLE": "Checkout",
"SUBTITLE": "Complétez votre commande en saisissant les détails de livraison et de paiement.",
"CAD_SERVICE": "Service CAD",
"CAD_REQUEST_REF": "lié à la demande de contact #{{id}}",
"CONTACT_INFO": "Informations de contact",
"BILLING_ADDR": "Adresse de facturation",
"SHIPPING_ADDR": "Adresse de livraison",
@@ -558,11 +670,18 @@
"ERR_ID_NOT_FOUND": "ID de commande introuvable.",
"ERR_LOAD_ORDER": "Impossible de charger les détails de la commande.",
"ERR_REPORT_PAYMENT": "Impossible de signaler le paiement. Réessayez.",
"CAD_SERVICE": "Service CAD ({{hours}}h)",
"ITEMS_TITLE": "Articles de la commande",
"ORDER_TYPE_LABEL": "Type de commande",
"ITEM_COUNT": "Lignes",
"TYPE_SHOP": "Boutique",
"TYPE_CALCULATOR": "Calculateur",
"TYPE_MIXED": "Mixte",
"NOT_AVAILABLE": "N/D"
},
"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",

View File

@@ -13,6 +13,72 @@
"TERMS": "Termini & Condizioni",
"CONTACT": "Contattaci"
},
"SEO": {
"DEFAULT": {
"TITLE": "3D fab | Stampa 3D su misura",
"DESCRIPTION": "Servizio di stampa 3D su misura, shop tecnico e supporto CAD per prototipi, ricambi e piccole serie."
},
"ROUTES": {
"HOME": {
"TITLE": "Stampa 3D su misura in Ticino | Prototipi, ricambi e piccole serie - 3D fab",
"DESCRIPTION": "Servizio di stampa 3D in Ticino per prototipi, pezzi di ricambio e piccole serie. Shop tecnico e supporto CAD, con preventivo rapido da file STL."
},
"CALCULATOR": {
"TITLE": "Calcolatore preventivo stampa 3D | 3D fab",
"DESCRIPTION": "Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.",
"BASIC": {
"TITLE": "Calcolatore stampa 3D base | 3D fab",
"DESCRIPTION": "Calcola rapidamente il prezzo della tua stampa 3D in modalita base."
},
"ADVANCED": {
"TITLE": "Calcolatore stampa 3D avanzato | 3D fab",
"DESCRIPTION": "Configura parametri avanzati e ottieni un preventivo preciso con slicing reale."
}
},
"SHOP": {
"TITLE": "Shop 3D fab",
"DESCRIPTION": "Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.",
"CATEGORY_TITLE": "Categoria Shop | 3D fab",
"CATEGORY_DESCRIPTION": "Esplora i prodotti di questa categoria, le varianti disponibili e le soluzioni tecniche stampate in 3D.",
"PRODUCT_TITLE": "Prodotto | 3D fab",
"PRODUCT_DESCRIPTION": "Scopri dettagli, materiali, varianti e disponibilita del prodotto selezionato nello shop 3D fab."
},
"MATERIALS": {
"TITLE": "Qualita e Materiali | 3D fab",
"DESCRIPTION": "Confronta materiali di stampa 3D con radar chart interattivo, proprieta tecniche e fonti citate."
},
"ABOUT": {
"TITLE": "Chi siamo | 3D fab",
"DESCRIPTION": "Scopri il team 3D fab e il laboratorio di stampa 3D per prototipi, ricambi e produzioni su misura."
},
"CONTACT": {
"TITLE": "Contatti | 3D fab",
"DESCRIPTION": "Richiedi informazioni, preventivi personalizzati o supporto tecnico per il tuo progetto di stampa 3D."
},
"LEGAL": {
"PRIVACY": {
"TITLE": "Privacy Policy | 3D fab",
"DESCRIPTION": "Informativa privacy di 3D fab: trattamento dati, finalita e contatti."
},
"TERMS": {
"TITLE": "Termini e condizioni | 3D fab",
"DESCRIPTION": "Termini e condizioni del servizio di stampa 3D e del calcolatore preventivi."
}
},
"CHECKOUT": {
"TITLE": "Checkout | 3D fab",
"DESCRIPTION": "Completa la richiesta e conferma i dati del tuo ordine di stampa 3D."
},
"ORDER": {
"TITLE": "Ordine | 3D fab",
"DESCRIPTION": "Consulta il riepilogo del tuo ordine e lo stato della richiesta di stampa 3D."
},
"ADMIN": {
"TITLE": "Admin | 3D fab",
"DESCRIPTION": "Area amministrativa riservata di 3D fab."
}
}
},
"HOME": {
"HERO_EYEBROW": "Stampa 3D tecnica per aziende, freelance e maker",
"HERO_TITLE": "Servizio di stampa 3D.<br>Dal file al pezzo finito.",
@@ -30,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",
@@ -73,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",
@@ -119,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."
@@ -506,6 +573,8 @@
"CHECKOUT": {
"TITLE": "Checkout",
"SUBTITLE": "Completa il tuo ordine inserendo i dettagli per la spedizione e il pagamento.",
"CAD_SERVICE": "Servizio CAD",
"CAD_REQUEST_REF": "riferito alla richiesta contatto #{{id}}",
"CONTACT_INFO": "Informazioni di Contatto",
"BILLING_ADDR": "Indirizzo di Fatturazione",
"SHIPPING_ADDR": "Indirizzo di Spedizione",
@@ -601,6 +670,7 @@
"ERR_ID_NOT_FOUND": "ID ordine non trovato.",
"ERR_LOAD_ORDER": "Impossibile caricare i dettagli dell'ordine.",
"ERR_REPORT_PAYMENT": "Impossibile segnalare il pagamento. Riprova.",
"CAD_SERVICE": "Servizio CAD ({{hours}}h)",
"ITEMS_TITLE": "Articoli dell'ordine",
"ORDER_TYPE_LABEL": "Tipo ordine",
"ITEM_COUNT": "Righe",
@@ -611,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",

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 74 KiB

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