Compare commits
26 Commits
e190359041
...
feat/brand
| Author | SHA1 | Date | |
|---|---|---|---|
| 997e770256 | |||
| fb1a6456e6 | |||
| 43cd80600e | |||
|
|
23e1abdbbb | ||
| e575021f53 | |||
| 7e8c89ce45 | |||
| a40a8df894 | |||
|
|
41f36ed18a | ||
| e04189bbfe | |||
| 20988e425a | |||
| df63937406 | |||
| 4ba408859d | |||
| 996e95f93c | |||
|
|
c4bd0b5a67 | ||
| 5c43873ede | |||
| 249645619e | |||
| be9f303b37 | |||
| 6da8b3b6e4 | |||
| a3cd451575 | |||
|
|
6f700c923a | ||
| 46fd59ed71 | |||
| ba49463ee7 | |||
| 576380e9a0 | |||
| cac534ccbb | |||
| 2e68105da4 | |||
| ed7ed6636d |
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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" -> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ public class QuoteStorageService {
|
||||
return switch (ext) {
|
||||
case "stl" -> "stl";
|
||||
case "3mf" -> "3mf";
|
||||
case "step", "stp" -> "step";
|
||||
default -> fallback;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 |
23
frontend/public/site.webmanifest
Normal file
23
frontend/public/site.webmanifest
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,9 +44,8 @@ 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',
|
||||
},
|
||||
},
|
||||
/* {
|
||||
@@ -68,9 +55,8 @@ const appChildRoutes: Routes = [
|
||||
(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],
|
||||
|
||||
135
frontend/src/app/core/i18n/language-resolution.ts
Normal file
135
frontend/src/app/core/i18n/language-resolution.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">© 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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
if (segments.length === 0) {
|
||||
const queryLang = this.getQueryLang(urlTree);
|
||||
const activeLang = this.isSupportedLang(queryLang)
|
||||
const rootLang = this.isSupportedLang(queryLang)
|
||||
? queryLang
|
||||
: this.currentLang();
|
||||
if (activeLang !== this.currentLang()) {
|
||||
this.applyLanguage(activeLang);
|
||||
if (rootLang !== this.currentLang()) {
|
||||
this.applyLanguage(rootLang);
|
||||
}
|
||||
this.navigateIfChanged(urlTree, [rootLang]);
|
||||
return;
|
||||
}
|
||||
let targetSegments: string[];
|
||||
|
||||
if (segments.length === 0) {
|
||||
targetSegments = [activeLang];
|
||||
} else if (this.looksLikeLangToken(segments[0])) {
|
||||
targetSegments = [activeLang, ...segments.slice(1)];
|
||||
} else {
|
||||
targetSegments = [activeLang, ...segments];
|
||||
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[],
|
||||
|
||||
189
frontend/src/app/core/services/seo.service.spec.ts
Normal file
189
frontend/src/app/core/services/seo.service.spec.ts
Normal 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`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,16 +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 {
|
||||
@@ -77,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(
|
||||
@@ -95,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 });
|
||||
@@ -105,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(
|
||||
@@ -130,9 +221,53 @@ 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`;
|
||||
@@ -148,9 +283,38 @@ export class SeoService {
|
||||
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 || '/';
|
||||
@@ -160,17 +324,84 @@ export class SeoService {
|
||||
const firstSegment = path.split('/').filter(Boolean)[0]?.toLowerCase();
|
||||
if (
|
||||
firstSegment &&
|
||||
this.supportedLangs.has(firstSegment as SupportedLang)
|
||||
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');
|
||||
@@ -179,33 +410,55 @@ 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']) {
|
||||
this.appendAlternateLink(
|
||||
alt,
|
||||
`${this.document.location.origin}/${alt}${suffix}`,
|
||||
);
|
||||
if (!alternates) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const alt of this.supportedLangs) {
|
||||
const path = alternates[alt];
|
||||
if (!path) {
|
||||
continue;
|
||||
}
|
||||
this.appendAlternateLink(
|
||||
'x-default',
|
||||
`${this.document.location.origin}/it${suffix}`,
|
||||
this.seoLocaleByLang[alt],
|
||||
this.toAbsoluteUrl(path),
|
||||
);
|
||||
}
|
||||
if (xDefaultPath) {
|
||||
this.appendAlternateLink('x-default', this.toAbsoluteUrl(xDefaultPath));
|
||||
}
|
||||
}
|
||||
|
||||
private appendAlternateLink(hreflang: string, href: string): void {
|
||||
const link = this.document.createElement('link');
|
||||
@@ -215,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
@@ -1669,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) => [
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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, '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
||||
import { CommonModule, Location, isPlatformBrowser } from '@angular/common';
|
||||
import {
|
||||
RESPONSE_INIT,
|
||||
afterNextRender,
|
||||
Component,
|
||||
DestroyRef,
|
||||
Injector,
|
||||
@@ -58,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>();
|
||||
@@ -193,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 }),
|
||||
@@ -221,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);
|
||||
@@ -235,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);
|
||||
}),
|
||||
@@ -263,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);
|
||||
@@ -282,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);
|
||||
}
|
||||
@@ -366,9 +413,12 @@ export class ProductDetailComponent {
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
this.router.navigate(['/checkout'], {
|
||||
this.router.navigate(
|
||||
['/', this.languageService.selectedLang(), 'checkout'],
|
||||
{
|
||||
queryParams: { session: sessionId },
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
priceLabel(): number {
|
||||
@@ -453,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;
|
||||
}
|
||||
@@ -512,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -705,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,
|
||||
@@ -738,4 +804,10 @@ export class ProductDetailComponent {
|
||||
state: history.state,
|
||||
});
|
||||
}
|
||||
|
||||
private setResponseStatus(status: number): void {
|
||||
if (this.responseInit) {
|
||||
this.responseInit.status = status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,13 +249,14 @@ 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) => {
|
||||
@@ -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'
|
||||
httpMock.expectNone(
|
||||
'http://localhost:8000/api/shop/products/desk-cable-clip',
|
||||
);
|
||||
});
|
||||
detailRequest.flush(buildProduct());
|
||||
|
||||
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
||||
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'
|
||||
httpMock.expectNone(
|
||||
'http://localhost:8000/api/shop/products/desk-cable-clip',
|
||||
);
|
||||
});
|
||||
detailRequest.flush(buildProduct());
|
||||
|
||||
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
||||
expect(errorResponse?.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -239,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>
|
||||
|
||||
@@ -332,6 +332,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.cart-card {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.product-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -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'], {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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[]>();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
1080
frontend/src/assets/images/Asset 2.svg
Normal file
1080
frontend/src/assets/images/Asset 2.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 74 KiB |
1080
frontend/src/assets/images/Asset 223.svg
Normal file
1080
frontend/src/assets/images/Asset 223.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 76 KiB |
BIN
frontend/src/assets/images/Fav-icon-192x192.png
Normal file
BIN
frontend/src/assets/images/Fav-icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
BIN
frontend/src/assets/images/Fav-icon-512x512.png
Normal file
BIN
frontend/src/assets/images/Fav-icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 325 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user