23 Commits

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

View File

@@ -217,9 +217,12 @@ jobs:
ADMIN_TTL="${ADMIN_TTL:-480}" ADMIN_TTL="${ADMIN_TTL:-480}"
printf 'ADMIN_PASSWORD="%s"\nADMIN_SESSION_SECRET="%s"\nADMIN_SESSION_TTL_MINUTES="%s"\n' \ 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 "${{ 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:" 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 }}" \ ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
"setenv ${{ env.ENV }}" < /tmp/full_env.env "setenv ${{ env.ENV }}" < /tmp/full_env.env

View File

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

View File

@@ -18,6 +18,7 @@ import java.math.BigDecimal;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.HashMap; import java.util.HashMap;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.BAD_REQUEST;
@@ -124,6 +125,9 @@ public class QuoteController {
if (file.isEmpty()) { if (file.isEmpty()) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
if (!isSupportedInputFile(file)) {
throw new ResponseStatusException(BAD_REQUEST, "Unsupported file type. Allowed: stl, 3mf");
}
// Scan for virus // Scan for virus
clamAVService.scan(file.getInputStream()); clamAVService.scan(file.getInputStream());
@@ -153,4 +157,14 @@ public class QuoteController {
Files.deleteIfExists(tempInput); Files.deleteIfExists(tempInput);
} }
} }
private boolean isSupportedInputFile(MultipartFile file) {
String originalFilename = file.getOriginalFilename();
if (originalFilename == null || originalFilename.isBlank()) {
return false;
}
String normalized = originalFilename.toLowerCase(Locale.ROOT);
return normalized.endsWith(".stl") || normalized.endsWith(".3mf");
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ package com.printcalculator.dto;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
public record ShopProductDetailDto( public record ShopProductDetailDto(
@@ -25,6 +26,8 @@ public record ShopProductDetailDto(
List<ShopProductVariantOptionDto> variants, List<ShopProductVariantOptionDto> variants,
PublicMediaUsageDto primaryImage, PublicMediaUsageDto primaryImage,
List<PublicMediaUsageDto> images, List<PublicMediaUsageDto> images,
ShopProductModelDto model3d ShopProductModelDto model3d,
String publicPath,
Map<String, String> localizedPaths
) { ) {
} }

View File

@@ -1,6 +1,7 @@
package com.printcalculator.dto; package com.printcalculator.dto;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
public record ShopProductSummaryDto( public record ShopProductSummaryDto(
@@ -15,6 +16,8 @@ public record ShopProductSummaryDto(
BigDecimal priceToChf, BigDecimal priceToChf,
ShopProductVariantOptionDto defaultVariant, ShopProductVariantOptionDto defaultVariant,
PublicMediaUsageDto primaryImage, PublicMediaUsageDto primaryImage,
ShopProductModelDto model3d ShopProductModelDto model3d,
String publicPath,
Map<String, String> localizedPaths
) { ) {
} }

View File

@@ -223,10 +223,15 @@ public class OrderEmailListener {
order.getCreatedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(locale)) order.getCreatedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(locale))
); );
templateData.put("totalCost", currencyFormatter.format(order.getTotalChf())); templateData.put("totalCost", currencyFormatter.format(order.getTotalChf()));
templateData.put("logoUrl", buildLogoUrl());
templateData.put("currentYear", Year.now().getValue()); templateData.put("currentYear", Year.now().getValue());
return templateData; 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) { private String applyOrderConfirmationTexts(Map<String, Object> templateData, String language, String orderNumber) {
return switch (language) { return switch (language) {
case "en" -> { case "en" -> {

View File

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

View File

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

View File

@@ -72,10 +72,14 @@ public class QuoteSessionItemService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot modify a converted session"); 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()); clamAVService.scan(file.getInputStream());
Path sessionStorageDir = quoteStorageService.sessionStorageDir(session.getId()); Path sessionStorageDir = quoteStorageService.sessionStorageDir(session.getId());
String ext = quoteStorageService.getSafeExtension(file.getOriginalFilename(), "stl");
String storedFilename = UUID.randomUUID() + "." + ext; String storedFilename = UUID.randomUUID() + "." + ext;
Path persistentPath = quoteStorageService.resolveSessionPath(sessionStorageDir, storedFilename); Path persistentPath = quoteStorageService.resolveSessionPath(sessionStorageDir, storedFilename);

View File

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

View File

@@ -30,6 +30,9 @@ public class CustomQuoteRequestNotificationService {
@Value("${app.mail.contact-request.customer.enabled:true}") @Value("${app.mail.contact-request.customer.enabled:true}")
private boolean contactRequestCustomerMailEnabled; private boolean contactRequestCustomerMailEnabled;
@Value("${app.frontend.base-url:http://localhost:4200}")
private String frontendBaseUrl;
public CustomQuoteRequestNotificationService(EmailNotificationService emailNotificationService, public CustomQuoteRequestNotificationService(EmailNotificationService emailNotificationService,
ContactRequestLocalizationService localizationService) { ContactRequestLocalizationService localizationService) {
this.emailNotificationService = emailNotificationService; this.emailNotificationService = emailNotificationService;
@@ -63,6 +66,7 @@ public class CustomQuoteRequestNotificationService {
templateData.put("phone", safeValue(request.getPhone())); templateData.put("phone", safeValue(request.getPhone()));
templateData.put("message", safeValue(request.getMessage())); templateData.put("message", safeValue(request.getMessage()));
templateData.put("attachmentsCount", attachmentsCount); templateData.put("attachmentsCount", attachmentsCount);
templateData.put("logoUrl", buildLogoUrl());
templateData.put("currentYear", Year.now().getValue()); templateData.put("currentYear", Year.now().getValue());
emailNotificationService.sendEmail( emailNotificationService.sendEmail(
@@ -101,6 +105,7 @@ public class CustomQuoteRequestNotificationService {
templateData.put("phone", safeValue(request.getPhone())); templateData.put("phone", safeValue(request.getPhone()));
templateData.put("message", safeValue(request.getMessage())); templateData.put("message", safeValue(request.getMessage()));
templateData.put("attachmentsCount", attachmentsCount); templateData.put("attachmentsCount", attachmentsCount);
templateData.put("logoUrl", buildLogoUrl());
templateData.put("currentYear", Year.now().getValue()); templateData.put("currentYear", Year.now().getValue());
String subject = localizationService.applyCustomerContactRequestTexts(templateData, language, request.getId()); String subject = localizationService.applyCustomerContactRequestTexts(templateData, language, request.getId());
@@ -119,4 +124,11 @@ public class CustomQuoteRequestNotificationService {
} }
return value; return value;
} }
private String buildLogoUrl() {
String baseUrl = frontendBaseUrl == null || frontendBaseUrl.isBlank()
? "http://localhost:4200"
: frontendBaseUrl;
return baseUrl.replaceAll("/+$", "") + "/assets/images/brand-logo-yellow.svg";
}
} }

View File

@@ -399,6 +399,7 @@ public class PublicShopCatalogService {
Map<String, String> variantColorHexByMaterialAndColor, Map<String, String> variantColorHexByMaterialAndColor,
String language) { String language) {
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
return new ShopProductSummaryDto( return new ShopProductSummaryDto(
entry.product().getId(), entry.product().getId(),
entry.product().getSlug(), entry.product().getSlug(),
@@ -415,7 +416,9 @@ public class PublicShopCatalogService {
resolvePriceTo(entry.variants()), resolvePriceTo(entry.variants()),
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language), toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
selectPrimaryMedia(images), 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()); List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language); String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language);
String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language); String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language);
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
return new ShopProductDetailDto( return new ShopProductDetailDto(
entry.product().getId(), entry.product().getId(),
entry.product().getSlug(), entry.product().getSlug(),
@@ -453,7 +457,9 @@ public class PublicShopCatalogService {
.toList(), .toList(),
selectPrimaryMedia(images), selectPrimaryMedia(images),
images, images,
toProductModelDto(entry) toProductModelDto(entry),
localizedPaths.getOrDefault(normalizeLanguage(language), localizedPaths.get("it")),
localizedPaths
); );
} }
@@ -514,6 +520,22 @@ public class PublicShopCatalogService {
return raw; 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) { private ShopProductModelDto toProductModelDto(ProductEntry entry) {
if (entry.modelAsset() == null) { if (entry.modelAsset() == null) {
return null; return null;

View File

@@ -0,0 +1,66 @@
package com.printcalculator.service.shop;
import com.printcalculator.entity.ShopProduct;
import java.text.Normalizer;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
final class ShopPublicPathSupport {
private static final String PRODUCT_ROUTE_PREFIX = "/shop/p/";
private ShopPublicPathSupport() {
}
static String buildProductPathSegment(ShopProduct product, String language) {
String localizedName = product.getNameForLanguage(language);
String idPrefix = productIdPrefix(product.getId());
String tail = firstNonBlank(slugify(localizedName), slugify(product.getSlug()), "product");
return idPrefix.isBlank() ? tail : idPrefix + "-" + tail;
}
static Map<String, String> buildLocalizedProductPaths(ShopProduct product) {
Map<String, String> localizedPaths = new LinkedHashMap<>();
for (String language : ShopProduct.SUPPORTED_LANGUAGES) {
localizedPaths.put(language, "/" + language + PRODUCT_ROUTE_PREFIX + buildProductPathSegment(product, language));
}
return localizedPaths;
}
static String productIdPrefix(UUID productId) {
if (productId == null) {
return "";
}
String raw = productId.toString().trim().toLowerCase(Locale.ROOT);
int dashIndex = raw.indexOf('-');
if (dashIndex > 0) {
return raw.substring(0, dashIndex);
}
return raw.length() >= 8 ? raw.substring(0, 8) : raw;
}
static String slugify(String rawValue) {
String safeValue = rawValue == null ? "" : rawValue;
String normalized = Normalizer.normalize(safeValue, Normalizer.Form.NFD)
.replaceAll("\\p{M}+", "")
.toLowerCase(Locale.ROOT)
.replaceAll("[^a-z0-9]+", "-")
.replaceAll("^-+|-+$", "")
.replaceAll("-{2,}", "-");
return normalized;
}
private static String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value;
}
}
return null;
}
}

View File

@@ -11,7 +11,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.text.Normalizer;
import java.time.Clock; import java.time.Clock;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
@@ -19,7 +18,6 @@ import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@@ -31,6 +29,12 @@ public class ShopSitemapService {
private static final List<String> SUPPORTED_LANGUAGES = ShopProduct.SUPPORTED_LANGUAGES; private static final List<String> SUPPORTED_LANGUAGES = ShopProduct.SUPPORTED_LANGUAGES;
private static final String DEFAULT_LANGUAGE = "it"; private static final String DEFAULT_LANGUAGE = "it";
private static final DateTimeFormatter LASTMOD_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; 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 ShopCategoryRepository shopCategoryRepository;
private final ShopProductRepository shopProductRepository; private final ShopProductRepository shopProductRepository;
@@ -130,7 +134,7 @@ public class ShopSitemapService {
Map<String, String> hrefByLanguage = new LinkedHashMap<>(); Map<String, String> hrefByLanguage = new LinkedHashMap<>();
for (String language : SUPPORTED_LANGUAGES) { 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)); hrefByLanguage.put(language, frontendBaseUrl + "/" + language + "/shop/p/" + pathEncodeSegment(publicSegment));
} }
@@ -169,7 +173,7 @@ public class ShopSitemapService {
continue; continue;
} }
xml.append(" <xhtml:link rel=\"alternate\" hreflang=\"") xml.append(" <xhtml:link rel=\"alternate\" hreflang=\"")
.append(language) .append(HREFLANG_BY_LANGUAGE.getOrDefault(language, language))
.append("\" href=\"") .append("\" href=\"")
.append(xmlEscape(href)) .append(xmlEscape(href))
.append("\" />\n"); .append("\" />\n");
@@ -186,48 +190,6 @@ public class ShopSitemapService {
xml.append(" </url>\n"); 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) { private String pathEncodeSegment(String rawSegment) {
String safeSegment = rawSegment == null ? "" : rawSegment; String safeSegment = rawSegment == null ? "" : rawSegment;
return URLEncoder.encode(safeSegment, StandardCharsets.UTF_8).replace("+", "%20"); return URLEncoder.encode(safeSegment, StandardCharsets.UTF_8).replace("+", "%20");

View File

@@ -57,6 +57,12 @@ app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:
app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true} app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200} app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
app.sitemap.shop.cache-seconds=${APP_SITEMAP_SHOP_CACHE_SECONDS:3600} 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 back-office authentication
admin.password=${ADMIN_PASSWORD} admin.password=${ADMIN_PASSWORD}

View File

@@ -25,6 +25,21 @@
color: #222222; 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 { p {
color: #444444; color: #444444;
line-height: 1.5; line-height: 1.5;
@@ -63,7 +78,10 @@
</head> </head>
<body> <body>
<div class="container"> <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> <h1>Nuova richiesta di contatto</h1>
</div>
<p>E' stata ricevuta una nuova richiesta dal form contatti/su misura.</p> <p>E' stata ricevuta una nuova richiesta dal form contatti/su misura.</p>
<table> <table>

View File

@@ -25,6 +25,21 @@
color: #222222; 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 { h2 {
margin-top: 18px; margin-top: 18px;
color: #222222; color: #222222;
@@ -69,7 +84,10 @@
</head> </head>
<body> <body>
<div class="container"> <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> <h1 th:text="${headlineText}">We received your contact request</h1>
</div>
<p th:text="${greetingText}">Hi customer,</p> <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 th:text="${introText}">Thank you for contacting us. Our team will reply as soon as possible.</p>
<p> <p>

View File

@@ -27,8 +27,17 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
.brand-logo {
display: block;
width: 220px;
max-width: 220px;
height: auto;
margin: 0 auto 16px;
}
.header h1 { .header h1 {
color: #333333; color: #333333;
margin: 0;
} }
.content { .content {
@@ -67,6 +76,7 @@
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <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> <h1 th:text="${headlineText}">Thank you for your order #00000000</h1>
</div> </div>

View File

@@ -27,8 +27,17 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
.brand-logo {
display: block;
width: 220px;
max-width: 220px;
height: auto;
margin: 0 auto 16px;
}
.header h1 { .header h1 {
color: #333333; color: #333333;
margin: 0;
} }
.content { .content {
@@ -70,6 +79,7 @@
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <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> <h1 th:text="${headlineText}">Your order #00000000 has been shipped</h1>
</div> </div>

View File

@@ -27,8 +27,17 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
.brand-logo {
display: block;
width: 220px;
max-width: 220px;
height: auto;
margin: 0 auto 16px;
}
.header h1 { .header h1 {
color: #333333; color: #333333;
margin: 0;
} }
.content { .content {
@@ -70,6 +79,7 @@
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <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> <h1 th:text="${headlineText}">Payment confirmed for order #00000000</h1>
</div> </div>

View File

@@ -27,8 +27,17 @@
margin-bottom: 20px; margin-bottom: 20px;
} }
.brand-logo {
display: block;
width: 220px;
max-width: 220px;
height: auto;
margin: 0 auto 16px;
}
.header h1 { .header h1 {
color: #333333; color: #333333;
margin: 0;
} }
.content { .content {
@@ -70,6 +79,7 @@
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <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> <h1 th:text="${headlineText}">Payment reported for order #00000000</h1>
</div> </div>

View File

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

View File

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

View File

@@ -92,15 +92,15 @@ class ShopSitemapServiceTest {
assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/accessori</loc>")); assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/accessori</loc>"));
assertTrue(xml.contains("<loc>https://3d-fab.ch/de/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("<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")); 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/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/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/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("<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=\"en-CH\" 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=\"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("hreflang=\"x-default\" href=\"https://3d-fab.ch/it/shop/p/123e4567-supporto-bici\""));
assertTrue(xml.contains("<lastmod>2026-03-11T07:30:00Z</lastmod>")); assertTrue(xml.contains("<lastmod>2026-03-11T07:30:00Z</lastmod>"));
assertFalse(xml.contains("33333333-draft")); assertFalse(xml.contains("33333333-draft"));

View File

@@ -29,6 +29,12 @@ services:
- ADMIN_PASSWORD=${ADMIN_PASSWORD} - ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET} - ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
- ADMIN_SESSION_TTL_MINUTES=${ADMIN_SESSION_TTL_MINUTES:-480} - 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 - TEMP_DIR=/app/temp
- PROFILES_DIR=/app/profiles - PROFILES_DIR=/app/profiles
- MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media} - MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -0,0 +1,23 @@
{
"name": "3D fab",
"short_name": "3D fab",
"description": "Stampa 3D su misura con preventivo online immediato.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{
"src": "/assets/images/Fav-icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/assets/images/Fav-icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
.site-intro {
position: fixed;
inset: 0;
z-index: 2000;
display: grid;
place-items: center;
background: var(--color-bg);
pointer-events: none;
opacity: 1;
transition: opacity 0.24s ease-out;
}
.site-intro--closing {
opacity: 0;
}
.site-intro__logo {
width: min(calc(100vw - 2rem), 23rem);
--brand-animation-width: 23rem;
--brand-animation-height: 7.1rem;
--brand-animation-letter-width: 3.75rem;
--brand-animation-scale: 0.88;
--brand-animation-width-mobile: 16.8rem;
--brand-animation-height-mobile: 5.3rem;
--brand-animation-letter-width-mobile: 2.8rem;
--brand-animation-scale-mobile: 0.68;
--brand-animation-site-intro-duration: 1.05s;
justify-self: center;
align-self: center;
opacity: 1;
transform: scale(1);
transition:
opacity 0.24s ease-out,
transform 0.24s ease-out;
}
.site-intro--closing .site-intro__logo {
opacity: 0;
transform: scale(0.985);
}

View File

@@ -1,14 +1,50 @@
import { Component, inject } from '@angular/core'; import {
afterNextRender,
Component,
DestroyRef,
Inject,
Optional,
PLATFORM_ID,
inject,
signal,
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { SeoService } from './core/services/seo.service'; import { SeoService } from './core/services/seo.service';
import { BrandAnimationLogoComponent } from './shared/components/brand-animation-logo/brand-animation-logo.component';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
standalone: true, standalone: true,
imports: [RouterOutlet], imports: [RouterOutlet, BrandAnimationLogoComponent],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss', styleUrl: './app.component.scss',
}) })
export class AppComponent { export class AppComponent {
private readonly seoService = inject(SeoService); private readonly seoService = inject(SeoService);
private readonly destroyRef = inject(DestroyRef);
readonly siteIntroState = signal<'hidden' | 'active' | 'closing'>('hidden');
constructor(@Optional() @Inject(PLATFORM_ID) platformId?: Object) {
if (!isPlatformBrowser(platformId ?? 'browser')) {
return;
}
afterNextRender(() => {
this.siteIntroState.set('active');
const closeTimeoutId = window.setTimeout(() => {
this.siteIntroState.set('closing');
}, 1020);
const hideTimeoutId = window.setTimeout(() => {
this.siteIntroState.set('hidden');
}, 1280);
this.destroyRef.onDestroy(() => {
window.clearTimeout(closeTimeoutId);
window.clearTimeout(hideTimeoutId);
});
});
}
} }

View File

@@ -28,21 +28,12 @@ import {
import { serverOriginInterceptor } from './core/interceptors/server-origin.interceptor'; import { serverOriginInterceptor } from './core/interceptors/server-origin.interceptor';
import { catchError, firstValueFrom, of } from 'rxjs'; import { catchError, firstValueFrom, of } from 'rxjs';
import { StaticTranslateLoader } from './core/i18n/static-translate.loader'; import { StaticTranslateLoader } from './core/i18n/static-translate.loader';
import {
type SupportedLang = 'it' | 'en' | 'de' | 'fr'; getNavigatorLanguagePreferences,
const SUPPORTED_LANGS: readonly SupportedLang[] = ['it', 'en', 'de', 'fr']; parseAcceptLanguage,
resolveInitialLanguage,
function resolveLangFromUrl(url: string): SupportedLang { SUPPORTED_LANGS,
const firstSegment = (url || '/') } from './core/i18n/language-resolution';
.split('?')[0]
.split('#')[0]
.split('/')
.filter(Boolean)[0]
?.toLowerCase();
return SUPPORTED_LANGS.includes(firstSegment as SupportedLang)
? (firstSegment as SupportedLang)
: 'it';
}
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
@@ -52,7 +43,7 @@ export const appConfig: ApplicationConfig = {
withComponentInputBinding(), withComponentInputBinding(),
withViewTransitions(), withViewTransitions(),
withInMemoryScrolling({ withInMemoryScrolling({
scrollPositionRestoration: 'top', scrollPositionRestoration: 'enabled',
}), }),
), ),
provideHttpClient( provideHttpClient(
@@ -60,7 +51,7 @@ export const appConfig: ApplicationConfig = {
), ),
importProvidersFrom( importProvidersFrom(
TranslateModule.forRoot({ TranslateModule.forRoot({
defaultLanguage: 'it', fallbackLang: 'it',
loader: { loader: {
provide: TranslateLoader, provide: TranslateLoader,
useClass: StaticTranslateLoader, useClass: StaticTranslateLoader,
@@ -72,13 +63,21 @@ export const appConfig: ApplicationConfig = {
const router = inject(Router); const router = inject(Router);
const request = inject(REQUEST, { optional: true }) as { const request = inject(REQUEST, { optional: true }) as {
url?: string; url?: string;
headers?: Record<string, string | string[] | undefined>;
} | null; } | null;
translate.addLangs([...SUPPORTED_LANGS]); translate.addLangs([...SUPPORTED_LANGS]);
translate.setDefaultLang('it'); translate.setFallbackLang('it');
const requestedUrl = const requestedUrl =
(typeof request?.url === 'string' && request.url) || router.url || '/'; (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( return firstValueFrom(
translate.use(lang).pipe( translate.use(lang).pipe(
@@ -96,3 +95,21 @@ export const appConfig: ApplicationConfig = {
provideClientHydration(withEventReplay()), provideClientHydration(withEventReplay()),
], ],
}; };
function readRequestHeader(
request: {
headers?: Record<string, string | string[] | undefined>;
} | null,
headerName: string,
): string | null {
if (!request?.headers) {
return null;
}
const headerValue = request.headers[headerName.toLowerCase()];
if (Array.isArray(headerValue)) {
return headerValue[0] ?? null;
}
return typeof headerValue === 'string' ? headerValue : null;
}

View File

@@ -134,6 +134,31 @@ const appChildRoutes: Routes = [
]; ];
export const routes: 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', path: ':lang',
canMatch: [langPrefixCanMatch], canMatch: [langPrefixCanMatch],

View File

@@ -0,0 +1,135 @@
export type SupportedLang = 'it' | 'en' | 'de' | 'fr';
export const SUPPORTED_LANGS: readonly SupportedLang[] = [
'it',
'en',
'de',
'fr',
];
type InitialLanguageOptions = {
url?: string | null;
preferredLanguages?: readonly string[] | null;
fallbackLang?: SupportedLang;
};
type NavigatorLike = {
language?: string;
languages?: readonly string[];
};
export function resolveInitialLanguage({
url,
preferredLanguages,
fallbackLang = 'it',
}: InitialLanguageOptions): SupportedLang {
const explicitLang = resolveExplicitLanguageFromUrl(url);
if (explicitLang) {
return explicitLang;
}
for (const candidate of preferredLanguages ?? []) {
const normalized = normalizeSupportedLanguage(candidate);
if (normalized) {
return normalized;
}
}
return fallbackLang;
}
export function parseAcceptLanguage(
header: string | null | undefined,
): string[] {
if (!header) {
return [];
}
return header
.split(',')
.map((entry, index) => {
const [rawTag, ...params] = entry.split(';').map((part) => part.trim());
if (!rawTag) {
return null;
}
const qualityParam = params.find((param) => param.startsWith('q='));
const quality = qualityParam
? Number.parseFloat(qualityParam.slice(2))
: 1;
return {
tag: rawTag,
quality: Number.isFinite(quality) ? quality : 0,
index,
};
})
.filter(
(
entry,
): entry is {
tag: string;
quality: number;
index: number;
} => entry !== null && entry.quality > 0,
)
.sort(
(left, right) => right.quality - left.quality || left.index - right.index,
)
.map((entry) => entry.tag);
}
export function getNavigatorLanguagePreferences(
navigatorLike: NavigatorLike | null | undefined,
): string[] {
if (!navigatorLike) {
return [];
}
const orderedLanguages = [
...(Array.isArray(navigatorLike.languages) ? navigatorLike.languages : []),
];
if (
typeof navigatorLike.language === 'string' &&
navigatorLike.language &&
!orderedLanguages.includes(navigatorLike.language)
) {
orderedLanguages.push(navigatorLike.language);
}
return orderedLanguages;
}
function resolveExplicitLanguageFromUrl(
url: string | null | undefined,
): SupportedLang | null {
const normalizedUrl = String(url ?? '/');
const [pathAndQuery] = normalizedUrl.split('#', 1);
const [rawPath, rawQuery] = pathAndQuery.split('?', 2);
const firstSegment = rawPath.split('/').filter(Boolean)[0];
const pathLanguage = normalizeSupportedLanguage(firstSegment);
if (pathLanguage) {
return pathLanguage;
}
const queryLanguage = new URLSearchParams(rawQuery ?? '').get('lang');
return normalizeSupportedLanguage(queryLanguage);
}
function normalizeSupportedLanguage(
rawLanguage: string | null | undefined,
): SupportedLang | null {
if (typeof rawLanguage !== 'string') {
return null;
}
const normalized = rawLanguage.trim().toLowerCase();
if (!normalized || normalized === '*') {
return null;
}
const [baseLanguage] = normalized.split('-', 1);
return SUPPORTED_LANGS.includes(baseLanguage as SupportedLang)
? (baseLanguage as SupportedLang)
: null;
}

View File

@@ -1,22 +1,93 @@
import { Injectable } from '@angular/core'; import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import {
Injectable,
PLATFORM_ID,
TransferState,
inject,
makeStateKey,
} from '@angular/core';
import { TranslateLoader, TranslationObject } from '@ngx-translate/core'; import { TranslateLoader, TranslationObject } from '@ngx-translate/core';
import { Observable, of } from 'rxjs'; import { from, Observable } 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';
const TRANSLATIONS: Record<string, TranslationObject> = { type SupportedLang = 'it' | 'en' | 'de' | 'fr';
it: it as TranslationObject,
en: en as TranslationObject, const FALLBACK_LANG: SupportedLang = 'it';
de: de as TranslationObject, const translationCache = new Map<SupportedLang, Promise<TranslationObject>>();
fr: fr as 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() @Injectable()
export class StaticTranslateLoader implements TranslateLoader { export class StaticTranslateLoader implements TranslateLoader {
private readonly platformId = inject(PLATFORM_ID);
private readonly transferState = inject(TransferState);
getTranslation(lang: string): Observable<TranslationObject> { getTranslation(lang: string): Observable<TranslationObject> {
const normalized = String(lang || 'it').toLowerCase(); const normalized = this.normalizeLanguage(lang);
return of(TRANSLATIONS[normalized] ?? TRANSLATIONS['it']); return from(this.loadTranslation(normalized));
}
private normalizeLanguage(lang: string): SupportedLang {
const normalized = String(lang || FALLBACK_LANG).toLowerCase();
return normalized in translationLoaders
? (normalized as SupportedLang)
: FALLBACK_LANG;
}
private loadTranslation(lang: SupportedLang): Promise<TranslationObject> {
const transferStateKey = makeStateKey<TranslationObject>(
`i18n:${lang.toLowerCase()}`,
);
if (
isPlatformBrowser(this.platformId) &&
this.transferState.hasKey(transferStateKey)
) {
const transferred = this.transferState.get(transferStateKey, {});
this.transferState.remove(transferStateKey);
return Promise.resolve(transferred);
}
const cached = translationCache.get(lang);
if (cached) {
return cached;
}
const pending = translationLoaders[lang]()
.then((translation) => {
if (
isPlatformServer(this.platformId) &&
!this.transferState.hasKey(transferStateKey)
) {
this.transferState.set(transferStateKey, translation);
}
return translation;
})
.catch(() =>
lang === FALLBACK_LANG
? Promise.resolve({})
: this.loadTranslation(FALLBACK_LANG),
);
translationCache.set(lang, pending);
return pending;
} }
} }

View File

@@ -1,6 +1,9 @@
import { HttpInterceptorFn } from '@angular/common/http'; import { HttpInterceptorFn } from '@angular/common/http';
import { inject, REQUEST } from '@angular/core'; import { inject, REQUEST } from '@angular/core';
import { RequestLike, resolveRequestOrigin } from '../../../core/request-origin'; import {
RequestLike,
resolveRequestOrigin,
} from '../../../core/request-origin';
function isAbsoluteUrl(url: string): boolean { function isAbsoluteUrl(url: string): boolean {
return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//'); return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//');

View File

@@ -1,7 +1,11 @@
<footer class="footer"> <footer class="footer">
<div class="container footer-inner"> <div class="container footer-inner">
<div class="col"> <div class="col">
<span class="brand">3D fab</span> <img
class="brand"
src="/assets/images/brand-logo-white.svg"
alt="3D Fab"
/>
<p class="copyright">&copy; 2026 3D fab.</p> <p class="copyright">&copy; 2026 3D fab.</p>
</div> </div>

View File

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

View File

@@ -1,8 +1,15 @@
<header class="navbar"> <header class="navbar">
<div class="container navbar-inner"> <div class="container navbar-inner">
<a [routerLink]="langService.localizedPath('/')" class="brand" <a [routerLink]="langService.localizedPath('/')" class="brand">
>3D <span class="highlight">fab</span></a <img
> class="brand-logo"
ngSrc="/assets/images/Asset%202.svg"
alt="3D Fab"
width="380"
height="86"
priority
/>
</a>
<div <div
class="mobile-toggle" class="mobile-toggle"

View File

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

View File

@@ -1,5 +1,12 @@
import { CommonModule } from '@angular/common'; import { CommonModule, NgOptimizedImage } from '@angular/common';
import { Component, DestroyRef, computed, inject, signal } from '@angular/core'; import {
afterNextRender,
Component,
DestroyRef,
computed,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
NavigationStart, NavigationStart,
@@ -23,7 +30,13 @@ import {
@Component({ @Component({
selector: 'app-navbar', selector: 'app-navbar',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, RouterLinkActive, TranslateModule], imports: [
CommonModule,
RouterLink,
RouterLinkActive,
TranslateModule,
NgOptimizedImage,
],
templateUrl: './navbar.component.html', templateUrl: './navbar.component.html',
styleUrls: ['./navbar.component.scss'], styleUrls: ['./navbar.component.scss'],
}) })
@@ -58,16 +71,9 @@ export class NavbarComponent {
]; ];
constructor(public langService: LanguageService) { constructor(public langService: LanguageService) {
if (!this.shopService.cartLoaded()) { afterNextRender(() => {
this.shopService this.scheduleCartWarmup();
.loadCart()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.shopService.cart.set(null);
},
}); });
}
this.router.events this.router.events
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@@ -96,6 +102,9 @@ export class NavbarComponent {
toggleCart(): void { toggleCart(): void {
this.closeMenu(); this.closeMenu();
this.isCartOpen.update((open) => !open); this.isCartOpen.update((open) => !open);
if (this.isCartOpen()) {
this.loadCartIfNeeded();
}
} }
closeCart(): void { closeCart(): void {
@@ -192,5 +201,44 @@ export class NavbarComponent {
.subscribe(); .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; protected readonly routes = routes;
} }

View File

@@ -1,7 +1,13 @@
import { Subject } from 'rxjs'; 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 { TranslateService } from '@ngx-translate/core';
import { LanguageService } from './language.service'; import { LanguageService } from './language.service';
import { RequestLike } from '../../../core/request-origin';
describe('LanguageService', () => { describe('LanguageService', () => {
function createTranslateMock() { function createTranslateMock() {
@@ -9,7 +15,7 @@ describe('LanguageService', () => {
const translate = { const translate = {
currentLang: '', currentLang: '',
addLangs: jasmine.createSpy('addLangs'), addLangs: jasmine.createSpy('addLangs'),
setDefaultLang: jasmine.createSpy('setDefaultLang'), setFallbackLang: jasmine.createSpy('setFallbackLang'),
use: jasmine.createSpy('use').and.callFake((lang: string) => { use: jasmine.createSpy('use').and.callFake((lang: string) => {
translate.currentLang = lang; translate.currentLang = lang;
onLangChange.next({ lang }); onLangChange.next({ lang });
@@ -60,7 +66,14 @@ describe('LanguageService', () => {
parseUrl: (url: string) => serializer.parse(url), parseUrl: (url: string) => serializer.parse(url),
createUrlTree, createUrlTree,
serializeUrl: (tree: UrlTree) => serializer.serialize(tree), 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; return router as unknown as Router;
@@ -70,11 +83,17 @@ describe('LanguageService', () => {
const translate = createTranslateMock(); const translate = createTranslateMock();
const router = createRouterMock('/calculator?session=abc'); const router = createRouterMock('/calculator?session=abc');
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy; 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 // 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.use).toHaveBeenCalledWith('it');
expect((translate as any).setFallbackLang).toHaveBeenCalledWith('it');
expect(navigateSpy).toHaveBeenCalledTimes(1); expect(navigateSpy).toHaveBeenCalledTimes(1);
const firstCall = navigateSpy.calls.mostRecent(); const firstCall = navigateSpy.calls.mostRecent();
@@ -84,6 +103,48 @@ describe('LanguageService', () => {
expect(navOptions.replaceUrl).toBeTrue(); 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', () => { it('switches language while preserving path and query params', () => {
const translate = createTranslateMock(); const translate = createTranslateMock();
const router = createRouterMock('/it/calculator?session=abc&mode=advanced'); const router = createRouterMock('/it/calculator?session=abc&mode=advanced');
@@ -114,4 +175,23 @@ describe('LanguageService', () => {
'/de/contact?topic=seo#form', '/de/contact?topic=seo#form',
); );
}); });
it('switches product pages using the resolved localized route overrides', () => {
const translate = createTranslateMock();
const router = createRouterMock('/it/shop/p/12345678-supporto-cavo');
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
const service = new LanguageService(translate, router);
service.setLocalizedRouteOverrides({
it: '/it/shop/p/12345678-supporto-cavo',
de: '/de/shop/p/12345678-kabelhalter',
});
navigateSpy.calls.reset();
service.switchLang('de');
const call = navigateSpy.calls.mostRecent();
const tree = call.args[0] as UrlTree;
expect(router.serializeUrl(tree)).toBe('/de/shop/p/12345678-kabelhalter');
});
}); });

View File

@@ -1,4 +1,4 @@
import { Injectable, signal } from '@angular/core'; import { Inject, Injectable, Optional, REQUEST, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { import {
NavigationEnd, NavigationEnd,
@@ -6,25 +6,32 @@ import {
Router, Router,
UrlTree, UrlTree,
} from '@angular/router'; } 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({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class LanguageService { export class LanguageService {
currentLang = signal<'it' | 'en' | 'de' | 'fr'>('it'); currentLang = signal<SupportedLang>('it');
private readonly supportedLangs: Array<'it' | 'en' | 'de' | 'fr'> = [ private readonly defaultLang: SupportedLang = 'it';
'it', private readonly supportedLangs: SupportedLang[] = ['it', 'en', 'de', 'fr'];
'en', private localizedRouteOverrides: LocalizedRouteOverrides | null = null;
'de',
'fr',
];
constructor( constructor(
private translate: TranslateService, private translate: TranslateService,
private router: Router, private router: Router,
@Optional() @Inject(REQUEST) private request: RequestLike | null = null,
) { ) {
this.translate.addLangs(this.supportedLangs); this.translate.addLangs(this.supportedLangs);
this.translate.setDefaultLang('it'); this.translate.setFallbackLang('it');
this.translate.onLangChange.subscribe((event) => { this.translate.onLangChange.subscribe((event) => {
const lang = const lang =
typeof event.lang === 'string' ? event.lang.toLowerCase() : null; typeof event.lang === 'string' ? event.lang.toLowerCase() : null;
@@ -34,13 +41,14 @@ export class LanguageService {
}); });
const initialTree = this.router.parseUrl(this.router.url); const initialTree = this.router.parseUrl(this.router.url);
const initialSegments = this.getPrimarySegments(initialTree); const initialLang = resolveInitialLanguage({
const queryLang = this.getQueryLang(initialTree); url: this.router.url,
const initialLang = this.isSupportedLang(initialSegments[0]) preferredLanguages: this.request
? initialSegments[0] ? parseAcceptLanguage(this.readRequestHeader('accept-language'))
: this.isSupportedLang(queryLang) : getNavigatorLanguagePreferences(
? queryLang typeof navigator === 'undefined' ? null : navigator,
: 'it'; ),
});
this.applyLanguage(initialLang); this.applyLanguage(initialLang);
this.ensureLanguageInPath(initialTree); this.ensureLanguageInPath(initialTree);
@@ -53,13 +61,21 @@ export class LanguageService {
}); });
} }
switchLang(lang: 'it' | 'en' | 'de' | 'fr') { switchLang(lang: SupportedLang) {
if (!this.isSupportedLang(lang)) { if (!this.isSupportedLang(lang)) {
return; return;
} }
this.applyLanguage(lang);
const currentTree = this.router.parseUrl(this.router.url); 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); const segments = this.getPrimarySegments(currentTree);
let targetSegments: string[]; let targetSegments: string[];
@@ -77,7 +93,7 @@ export class LanguageService {
this.navigateIfChanged(currentTree, targetSegments); this.navigateIfChanged(currentTree, targetSegments);
} }
selectedLang(): 'it' | 'en' | 'de' | 'fr' { selectedLang(): SupportedLang {
const activeLang = const activeLang =
typeof this.translate.currentLang === 'string' typeof this.translate.currentLang === 'string'
? this.translate.currentLang.toLowerCase() ? this.translate.currentLang.toLowerCase()
@@ -110,6 +126,16 @@ export class LanguageService {
return `/${[lang, ...segments].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 { private ensureLanguageInPath(urlTree: UrlTree): void {
const segments = this.getPrimarySegments(urlTree); const segments = this.getPrimarySegments(urlTree);
@@ -118,23 +144,26 @@ export class LanguageService {
return; return;
} }
if (segments.length === 0) {
const queryLang = this.getQueryLang(urlTree); const queryLang = this.getQueryLang(urlTree);
const activeLang = this.isSupportedLang(queryLang) const rootLang = this.isSupportedLang(queryLang)
? queryLang ? queryLang
: this.currentLang(); : this.currentLang();
if (activeLang !== this.currentLang()) { if (rootLang !== this.currentLang()) {
this.applyLanguage(activeLang); this.applyLanguage(rootLang);
}
this.navigateIfChanged(urlTree, [rootLang]);
return;
} }
let targetSegments: string[];
if (segments.length === 0) { if (this.currentLang() !== this.defaultLang) {
targetSegments = [activeLang]; this.applyLanguage(this.defaultLang);
} else if (this.looksLikeLangToken(segments[0])) {
targetSegments = [activeLang, ...segments.slice(1)];
} else {
targetSegments = [activeLang, ...segments];
} }
const targetSegments = this.looksLikeLangToken(segments[0])
? [this.defaultLang, ...segments.slice(1)]
: [this.defaultLang, ...segments];
this.navigateIfChanged(urlTree, targetSegments); this.navigateIfChanged(urlTree, targetSegments);
} }
@@ -151,12 +180,23 @@ export class LanguageService {
return typeof lang === 'string' ? lang.toLowerCase() : null; 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( private isSupportedLang(
lang: string | null | undefined, lang: string | null | undefined,
): lang is 'it' | 'en' | 'de' | 'fr' { ): lang is SupportedLang {
return ( return (
typeof lang === 'string' && typeof lang === 'string' &&
this.supportedLangs.includes(lang as 'it' | 'en' | 'de' | 'fr') this.supportedLangs.includes(lang as SupportedLang)
); );
} }
@@ -166,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) { if (this.currentLang() === lang && this.translate.currentLang === lang) {
return; return;
} }
@@ -174,6 +214,88 @@ export class LanguageService {
this.currentLang.set(lang); 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( private navigateIfChanged(
currentTree: UrlTree, currentTree: UrlTree,
targetSegments: string[], targetSegments: string[],

View File

@@ -29,6 +29,7 @@ describe('SeoService', () => {
data: Record<string, unknown>; data: Record<string, unknown>;
translations: Record<string, string>; translations: Record<string, string>;
}): { }): {
service: SeoService;
meta: jasmine.SpyObj<Meta>; meta: jasmine.SpyObj<Meta>;
title: jasmine.SpyObj<Title>; title: jasmine.SpyObj<Title>;
} { } {
@@ -51,7 +52,7 @@ describe('SeoService', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const service = new SeoService(router, title, meta, translate, document); const service = new SeoService(router, title, meta, translate, document);
return { meta, title }; return { service, meta, title };
} }
beforeEach(() => { beforeEach(() => {
@@ -94,14 +95,14 @@ describe('SeoService', () => {
})); }));
expect(alternates).toContain({ expect(alternates).toContain({
hreflang: 'en', hreflang: 'en-CH',
href: `${document.location.origin}/en/privacy`, href: `${document.location.origin}/en/privacy`,
}); });
expect(alternates).toContain({ expect(alternates).toContain({
hreflang: 'x-default', hreflang: 'x-default',
href: `${document.location.origin}/it/privacy`, href: `${document.location.origin}/it/privacy`,
}); });
expect(document.documentElement.lang).toBe('it'); expect(document.documentElement.lang).toBe('it-CH');
const ogUrlCall = meta.updateTag.calls const ogUrlCall = meta.updateTag.calls
.allArgs() .allArgs()
@@ -109,6 +110,11 @@ describe('SeoService', () => {
expect(ogUrlCall?.[0].content).toBe( expect(ogUrlCall?.[0].content).toBe(
`${document.location.origin}/it/privacy`, `${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', () => { it('resolves translated route metadata for the active language', () => {
@@ -130,6 +136,54 @@ describe('SeoService', () => {
.allArgs() .allArgs()
.find(([tag]) => tag.name === 'description'); .find(([tag]) => tag.name === 'description');
expect(descriptionCall?.[0].content).toBe('About description'); expect(descriptionCall?.[0].content).toBe('About description');
expect(document.documentElement.lang).toBe('en'); expect(document.documentElement.lang).toBe('en-CH');
});
it('applies canonical and hreflang values resolved from localized paths', () => {
const { service } = createService({
url: '/it/shop/p/12345678-supporto-cavo-scrivania',
data: {},
translations: {},
});
service.applyResolvedSeo({
title: 'Supporto cavo scrivania | 3D fab',
description: 'Accessorio tecnico',
robots: 'index, follow',
ogTitle: 'Supporto cavo scrivania | 3D fab',
ogDescription: 'Accessorio tecnico',
canonicalPath: '/it/shop/p/12345678-supporto-cavo-scrivania',
alternates: {
it: '/it/shop/p/12345678-supporto-cavo-scrivania',
en: '/en/shop/p/12345678-desk-cable-clip',
de: '/de/shop/p/12345678-schreibtisch-kabelhalter',
},
xDefault: '/it/shop/p/12345678-supporto-cavo-scrivania',
});
const canonical = document.head.querySelector(
'link[rel="canonical"]',
) as HTMLLinkElement | null;
expect(canonical?.getAttribute('href')).toBe(
`${document.location.origin}/it/shop/p/12345678-supporto-cavo-scrivania`,
);
const alternates = Array.from(
document.head.querySelectorAll(
'link[rel="alternate"][data-seo-managed="true"]',
),
).map((node) => ({
hreflang: node.getAttribute('hreflang'),
href: node.getAttribute('href'),
}));
expect(alternates).toContain({
hreflang: 'de-CH',
href: `${document.location.origin}/de/shop/p/12345678-schreibtisch-kabelhalter`,
});
expect(alternates).toContain({
hreflang: 'x-default',
href: `${document.location.origin}/it/shop/p/12345678-supporto-cavo-scrivania`,
});
}); });
}); });

View File

@@ -17,7 +17,13 @@ export interface PageSeoOverride {
ogDescriptionKey?: 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 SeoMap = Partial<Record<SupportedLang, string>>;
type SeoTextDataKey = type SeoTextDataKey =
| 'seoTitle' | 'seoTitle'
@@ -51,10 +57,16 @@ export class SeoService {
this.supportedLangs, this.supportedLangs,
); );
private readonly ogLocaleByLang: Record<SupportedLang, string> = { private readonly ogLocaleByLang: Record<SupportedLang, string> = {
it: 'it_IT', it: 'it_CH',
en: 'en_US', en: 'en_CH',
de: 'de_DE', de: 'de_CH',
fr: 'fr_FR', fr: 'fr_CH',
};
private readonly seoLocaleByLang: Record<SupportedLang, string> = {
it: 'it-CH',
en: 'en-CH',
de: 'de-CH',
fr: 'fr-CH',
}; };
constructor( constructor(
@@ -79,23 +91,10 @@ export class SeoService {
applyPageSeo(override: PageSeoOverride): void { applyPageSeo(override: PageSeoOverride): void {
const cleanPath = this.getCleanPath(this.router.url); const cleanPath = this.getCleanPath(this.router.url);
const lang = this.resolveLangFromPath(cleanPath); const lang = this.resolveLangFromPath(cleanPath);
const title = const { title, description, robots, ogTitle, ogDescription } =
this.resolveOverrideSeoText(override.title, override.titleKey) ?? this.resolvePageSeoOverride(override, lang);
this.defaultTitle(lang); const canonicalPath = this.buildLocalizedPath(cleanPath, lang);
const description = const alternates = this.buildAlternatePaths(canonicalPath);
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;
this.applySeoValues( this.applySeoValues(
title, title,
@@ -104,6 +103,35 @@ export class SeoService {
ogTitle, ogTitle,
ogDescription, ogDescription,
cleanPath, 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, lang,
); );
} }
@@ -122,6 +150,8 @@ export class SeoService {
const ogTitle = this.resolveSeoText(mergedData, 'ogTitle', lang) ?? title; const ogTitle = this.resolveSeoText(mergedData, 'ogTitle', lang) ?? title;
const ogDescription = const ogDescription =
this.resolveSeoText(mergedData, 'ogDescription', lang) ?? description; this.resolveSeoText(mergedData, 'ogDescription', lang) ?? description;
const canonicalPath = this.buildLocalizedPath(cleanPath, lang);
const alternates = this.buildAlternatePaths(canonicalPath);
this.applySeoValues( this.applySeoValues(
title, title,
@@ -130,6 +160,9 @@ export class SeoService {
ogTitle, ogTitle,
ogDescription, ogDescription,
cleanPath, cleanPath,
canonicalPath,
alternates,
alternates.it ?? canonicalPath,
lang, lang,
); );
} }
@@ -141,6 +174,9 @@ export class SeoService {
ogTitle: string, ogTitle: string,
ogDescription: string, ogDescription: string,
cleanPath: string, cleanPath: string,
canonicalPath: string | null,
alternates: SeoMap | null,
xDefaultPath: string | null,
lang: SupportedLang, lang: SupportedLang,
): void { ): void {
this.titleService.setTitle(title); this.titleService.setTitle(title);
@@ -160,12 +196,13 @@ export class SeoService {
content: ogDescription, content: ogDescription,
}); });
const canonicalPath = this.buildLocalizedPath(cleanPath, lang); const ogUrl = this.toAbsoluteUrl(canonicalPath ?? cleanPath);
const canonical = `${this.document.location.origin}${canonicalPath}`; this.metaService.updateTag({ property: 'og:url', content: ogUrl });
this.metaService.updateTag({ property: 'og:url', content: canonical }); this.updateCanonicalTag(
this.updateCanonicalTag(canonical); canonicalPath ? this.toAbsoluteUrl(canonicalPath) : null,
);
this.updateOpenGraphLocales(lang); this.updateOpenGraphLocales(lang);
this.updateLangAndAlternates(canonicalPath, lang); this.updateLangAndAlternates(alternates, xDefaultPath, lang);
} }
private getMergedRouteData( private getMergedRouteData(
@@ -191,6 +228,43 @@ export class SeoService {
return this.asString(value) ?? this.resolveTranslation(key); 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( private resolveSeoText(
routeData: Record<string, unknown>, routeData: Record<string, unknown>,
key: SeoTextDataKey, key: SeoTextDataKey,
@@ -275,10 +349,59 @@ export class SeoService {
return `/${[lang, ...segments].join('/')}`; return `/${[lang, ...segments].join('/')}`;
} }
private updateCanonicalTag(url: string): void { 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( let link = this.document.head.querySelector(
'link[rel="canonical"]', 'link[rel="canonical"]',
) as HTMLLinkElement | null; ) as HTMLLinkElement | null;
if (!url) {
link?.remove();
return;
}
if (!link) { if (!link) {
link = this.document.createElement('link'); link = this.document.createElement('link');
link.setAttribute('rel', 'canonical'); link.setAttribute('rel', 'canonical');
@@ -308,30 +431,34 @@ export class SeoService {
} }
private updateLangAndAlternates( private updateLangAndAlternates(
localizedPath: string, alternates: SeoMap | null,
xDefaultPath: string | null,
lang: SupportedLang, lang: SupportedLang,
): void { ): void {
const suffixSegments = localizedPath.split('/').filter(Boolean).slice(1); this.document.documentElement.lang = this.seoLocaleByLang[lang];
const suffix =
suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : '';
this.document.documentElement.lang = lang;
this.document.head this.document.head
.querySelectorAll('link[rel="alternate"][data-seo-managed="true"]') .querySelectorAll('link[rel="alternate"][data-seo-managed="true"]')
.forEach((node) => node.remove()); .forEach((node) => node.remove());
if (!alternates) {
return;
}
for (const alt of this.supportedLangs) { for (const alt of this.supportedLangs) {
this.appendAlternateLink( const path = alternates[alt];
alt, if (!path) {
`${this.document.location.origin}/${alt}${suffix}`, continue;
);
} }
this.appendAlternateLink( this.appendAlternateLink(
'x-default', this.seoLocaleByLang[alt],
`${this.document.location.origin}/it${suffix}`, this.toAbsoluteUrl(path),
); );
} }
if (xDefaultPath) {
this.appendAlternateLink('x-default', this.toAbsoluteUrl(xDefaultPath));
}
}
private appendAlternateLink(hreflang: string, href: string): void { private appendAlternateLink(hreflang: string, href: string): void {
const link = this.document.createElement('link'); const link = this.document.createElement('link');

View File

@@ -668,9 +668,31 @@
<div> <div>
<h3>Contenuti localizzati</h3> <h3>Contenuti localizzati</h3>
<p> <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> </p>
</div> </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>
<div class="ui-language-toolbar"> <div class="ui-language-toolbar">

View File

@@ -18,6 +18,8 @@ import {
AdminShopProductModel, AdminShopProductModel,
AdminShopProductVariant, AdminShopProductVariant,
AdminShopService, AdminShopService,
AdminTranslateShopProductPayload,
AdminTranslateShopProductResponse,
AdminUpsertShopCategoryPayload, AdminUpsertShopCategoryPayload,
AdminUpsertShopProductPayload, AdminUpsertShopProductPayload,
AdminUpsertShopProductVariantPayload, AdminUpsertShopProductVariantPayload,
@@ -174,6 +176,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
loading = false; loading = false;
detailLoading = false; detailLoading = false;
savingProduct = false; savingProduct = false;
translatingProduct = false;
deletingProduct = false; deletingProduct = false;
savingCategory = false; savingCategory = false;
deletingCategory = false; deletingCategory = false;
@@ -188,6 +191,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
productStatusFilter: ProductStatusFilter = 'ALL'; productStatusFilter: ProductStatusFilter = 'ALL';
showCategoryManager = false; showCategoryManager = false;
activeContentLanguage: ShopLanguage = 'it'; activeContentLanguage: ShopLanguage = 'it';
overwriteExistingTranslations = false;
errorMessage: string | null = null; errorMessage: string | null = null;
successMessage: string | null = null; successMessage: string | null = null;
@@ -560,6 +564,52 @@ export class AdminShopComponent implements OnInit, OnDestroy {
this.categoryForm.slug = this.slugify(source); 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 { setActiveContentLanguage(language: ShopLanguage): void {
this.syncDescriptionFromEditor(this.descriptionEditorElement, true); this.syncDescriptionFromEditor(this.descriptionEditorElement, true);
this.activeContentLanguage = language; 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[] { private buildVariantsFromMaterials(): AdminUpsertShopProductVariantPayload[] {
const existingVariantsByKey = new Map( const existingVariantsByKey = new Map(
(this.selectedProduct?.variants ?? []).map((variant) => [ (this.selectedProduct?.variants ?? []).map((variant) => [

View File

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

View File

@@ -18,6 +18,8 @@ export interface AdminMediaTextTranslation {
altText: string; altText: string;
} }
export type AdminShopLanguage = 'it' | 'en' | 'de' | 'fr';
export interface AdminShopCategoryRef { export interface AdminShopCategoryRef {
id: string; id: string;
slug: string; slug: string;
@@ -255,6 +257,28 @@ export interface AdminUpsertShopProductPayload {
variants: AdminUpsertShopProductVariantPayload[]; 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({ @Injectable({
providedIn: 'root', 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( uploadProductModel(
productId: string, productId: string,
file: File, file: File,

View File

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

View File

@@ -0,0 +1,60 @@
:host {
display: block;
}
.animation-test-page {
min-height: 100vh;
display: grid;
align-content: center;
justify-items: center;
gap: 1.5rem;
padding: 2rem 1.5rem 3rem;
background: #fff;
}
.animation-toolbar {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem;
border: 1px solid rgba(16, 24, 32, 0.12);
border-radius: 999px;
background: #f7f5ef;
}
.variant-toggle {
min-height: 2.4rem;
padding: 0 1rem;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--color-text-muted);
font: inherit;
font-weight: 600;
cursor: pointer;
transition:
background-color 0.18s ease,
color 0.18s ease,
box-shadow 0.18s ease;
}
.variant-toggle.active {
background: #fff;
color: var(--color-text);
box-shadow: 0 6px 16px rgba(16, 24, 32, 0.08);
}
.animation-stage {
width: min(100%, 26rem);
}
@media (max-width: 640px) {
.animation-toolbar {
flex-wrap: wrap;
justify-content: center;
}
.animation-stage {
width: min(100%, 19rem);
}
}

View File

@@ -0,0 +1,21 @@
import { CommonModule } from '@angular/common';
import { Component, signal } from '@angular/core';
import {
BrandAnimationLogoComponent,
BrandAnimationVariant,
} from '../../shared/components/brand-animation-logo/brand-animation-logo.component';
@Component({
selector: 'app-calculator-animation-test',
standalone: true,
imports: [CommonModule, BrandAnimationLogoComponent],
templateUrl: './calculator-animation-test.component.html',
styleUrl: './calculator-animation-test.component.scss',
})
export class CalculatorAnimationTestComponent {
readonly variant = signal<BrandAnimationVariant>('site-intro');
setVariant(variant: BrandAnimationVariant): void {
this.variant.set(variant);
}
}

View File

@@ -57,7 +57,10 @@
@if (loading()) { @if (loading()) {
<app-card class="loading-state"> <app-card class="loading-state">
<div class="loader-content"> <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"> <h3 class="loading-title">
{{ "CALC.ANALYZING_TITLE" | translate }} {{ "CALC.ANALYZING_TITLE" | translate }}
</h3> </h3>

View File

@@ -93,7 +93,7 @@
.loader-content { .loader-content {
text-align: center; text-align: center;
max-width: 300px; max-width: 22rem;
margin: 0 auto; margin: 0 auto;
/* Center content vertically within the stretched card */ /* Center content vertically within the stretched card */
@@ -101,12 +101,14 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center;
gap: var(--space-3);
} }
.loading-title { .loading-title {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
margin: var(--space-4) 0 var(--space-2); margin: 0;
color: var(--color-text); color: var(--color-text);
} }
@@ -114,23 +116,21 @@
font-size: 0.9rem; font-size: 0.9rem;
color: var(--color-text-muted); color: var(--color-text-muted);
line-height: 1.5; line-height: 1.5;
margin: 0;
} }
.spinner { .loader-logo {
border: 3px solid var(--color-neutral-200); display: block;
border-left-color: var(--color-brand); width: min(100%, 16rem);
border-radius: 50%;
width: 48px;
height: 48px;
animation: spin 1s linear infinite;
margin: 0 auto; margin: 0 auto;
} --brand-animation-width: 16rem;
--brand-animation-height: 4.8rem;
@keyframes spin { --brand-animation-letter-width: 2.85rem;
0% { --brand-animation-scale: 0.84;
transform: rotate(0deg); --brand-animation-word-spacing: 0.97;
} --brand-animation-width-mobile: 14rem;
100% { --brand-animation-height-mobile: 4.1rem;
transform: rotate(360deg); --brand-animation-letter-width-mobile: 2.45rem;
} --brand-animation-scale-mobile: 0.84;
--brand-animation-loader-loop-duration: 2.65s;
} }

View File

@@ -17,6 +17,7 @@ import { catchError, map } from 'rxjs/operators';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component'; import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.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 { UploadFormComponent } from './components/upload-form/upload-form.component';
import { QuoteResultComponent } from './components/quote-result/quote-result.component'; import { QuoteResultComponent } from './components/quote-result/quote-result.component';
import { import {
@@ -48,6 +49,7 @@ type TrackedPrintSettings = {
AppCardComponent, AppCardComponent,
AppAlertComponent, AppAlertComponent,
AppButtonComponent, AppButtonComponent,
BrandAnimationLogoComponent,
UploadFormComponent, UploadFormComponent,
QuoteResultComponent, QuoteResultComponent,
SuccessStateComponent, SuccessStateComponent,

View File

@@ -3,6 +3,18 @@ import { CalculatorPageComponent } from './calculator-page.component';
export const CALCULATOR_ROUTES: Routes = [ export const CALCULATOR_ROUTES: Routes = [
{ path: '', redirectTo: 'basic', pathMatch: 'full' }, { 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', path: 'basic',
component: CalculatorPageComponent, component: CalculatorPageComponent,

View File

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

View File

@@ -127,7 +127,8 @@ export class UploadFormComponent implements OnInit {
private layerHeightsByNozzle: Record<string, SimpleOption[]> = {}; private layerHeightsByNozzle: Record<string, SimpleOption[]> = {};
private isPatchingSettings = false; private isPatchingSettings = false;
acceptedFormats = '.stl,.3mf,.step,.stp'; acceptedFormats = '.stl,.3mf';
private readonly allowedExtensions = ['stl', '3mf'] as const;
constructor() { constructor() {
this.form = this.fb.group({ this.form = this.fb.group({
@@ -286,6 +287,13 @@ export class UploadFormComponent implements OnInit {
return name.endsWith('.stl'); 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 { canPreviewSelectedFile(): boolean {
return this.isStlFile(this.getSelectedPreviewFile()); return this.isStlFile(this.getSelectedPreviewFile());
} }
@@ -340,13 +348,19 @@ export class UploadFormComponent implements OnInit {
onFilesDropped(newFiles: File[]) { onFilesDropped(newFiles: File[]) {
const MAX_SIZE = 200 * 1024 * 1024; const MAX_SIZE = 200 * 1024 * 1024;
const validItems: FormItem[] = []; const validItems: FormItem[] = [];
let hasError = false; let hasInvalidType = false;
let hasOversize = false;
const defaults = this.getCurrentGlobalItemDefaults(); const defaults = this.getCurrentGlobalItemDefaults();
for (const file of newFiles) { for (const file of newFiles) {
if (!this.isSupportedFile(file)) {
hasInvalidType = true;
continue;
}
if (file.size > MAX_SIZE) { if (file.size > MAX_SIZE) {
hasError = true; hasOversize = true;
continue; continue;
} }
@@ -367,7 +381,11 @@ export class UploadFormComponent implements OnInit {
}); });
} }
if (hasError) { if (hasInvalidType) {
alert(this.translate.instant('CALC.ERR_INVALID_FILE_TYPE'));
}
if (hasOversize) {
alert(this.translate.instant('CALC.ERR_FILE_TOO_LARGE')); alert(this.translate.instant('CALC.ERR_FILE_TOO_LARGE'));
} }

View File

@@ -124,17 +124,14 @@
[href]="languageService.localizedPath('/terms')" [href]="languageService.localizedPath('/terms')"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
>{{ >{{ "LEGAL.CONSENT.TERMS_LINK" | translate }}</a
"LEGAL.CONSENT.TERMS_LINK" | translate >
}}</a>
{{ "LEGAL.CONSENT.AND" | translate }} {{ "LEGAL.CONSENT.AND" | translate }}
<a <a
[href]="languageService.localizedPath('/privacy')" [href]="languageService.localizedPath('/privacy')"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
>{{ >{{ "LEGAL.CONSENT.PRIVACY_LINK" | translate }}</a
"LEGAL.CONSENT.PRIVACY_LINK" | translate
}}</a
>. >.
</span> </span>
</label> </label>

View File

@@ -208,17 +208,14 @@
[href]="languageService.localizedPath('/terms')" [href]="languageService.localizedPath('/terms')"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
>{{ >{{ "LEGAL.CONSENT.TERMS_LINK" | translate }}</a
"LEGAL.CONSENT.TERMS_LINK" | translate >
}}</a>
{{ "LEGAL.CONSENT.AND" | translate }} {{ "LEGAL.CONSENT.AND" | translate }}
<a <a
[href]="languageService.localizedPath('/privacy')" [href]="languageService.localizedPath('/privacy')"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
>{{ >{{ "LEGAL.CONSENT.PRIVACY_LINK" | translate }}</a
"LEGAL.CONSENT.PRIVACY_LINK" | translate
}}</a
>. >.
</span> </span>
</label> </label>

View File

@@ -89,9 +89,7 @@
[href]="languageService.localizedPath('/privacy')" [href]="languageService.localizedPath('/privacy')"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
>{{ >{{ "LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate }}</a
"LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate
}}</a
>. >.
</p> </p>
@@ -169,17 +167,14 @@
[href]="languageService.localizedPath('/terms')" [href]="languageService.localizedPath('/terms')"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
>{{ >{{ "LEGAL.CONSENT.TERMS_LINK" | translate }}</a
"LEGAL.CONSENT.TERMS_LINK" | translate >
}}</a>
{{ "LEGAL.CONSENT.AND" | translate }} {{ "LEGAL.CONSENT.AND" | translate }}
<a <a
[href]="languageService.localizedPath('/privacy')" [href]="languageService.localizedPath('/privacy')"
target="_blank" target="_blank"
rel="noopener" rel="noopener"
>{{ >{{ "LEGAL.CONSENT.PRIVACY_LINK" | translate }}</a
"LEGAL.CONSENT.PRIVACY_LINK" | translate
}}</a
>. >.
</span> </span>
</label> </label>

View File

@@ -19,23 +19,17 @@
<app-button <app-button
variant="primary" variant="primary"
[routerLink]="languageService.localizedPath('/calculator/basic')" [routerLink]="languageService.localizedPath('/calculator/basic')"
>{{ >{{ "HOME.BTN_CALCULATE" | translate }}</app-button
"HOME.BTN_CALCULATE" | translate
}}</app-button
> >
<app-button <app-button
variant="outline" variant="outline"
[routerLink]="languageService.localizedPath('/shop')" [routerLink]="languageService.localizedPath('/shop')"
>{{ >{{ "HOME.BTN_SHOP" | translate }}</app-button
"HOME.BTN_SHOP" | translate
}}</app-button
> >
<app-button <app-button
variant="text" variant="text"
[routerLink]="languageService.localizedPath('/contact')" [routerLink]="languageService.localizedPath('/contact')"
>{{ >{{ "HOME.BTN_CONTACT" | translate }}</app-button
"HOME.BTN_CONTACT" | translate
}}</app-button
> >
</div> </div>
</div> </div>
@@ -182,16 +176,12 @@
<app-button <app-button
variant="primary" variant="primary"
[routerLink]="languageService.localizedPath('/shop')" [routerLink]="languageService.localizedPath('/shop')"
>{{ >{{ "HOME.BTN_DISCOVER" | translate }}</app-button
"HOME.BTN_DISCOVER" | translate
}}</app-button
> >
<app-button <app-button
variant="outline" variant="outline"
[routerLink]="languageService.localizedPath('/contact')" [routerLink]="languageService.localizedPath('/contact')"
>{{ >{{ "HOME.BTN_REQ_SOLUTION" | translate }}</app-button
"HOME.BTN_REQ_SOLUTION" | translate
}}</app-button
> >
</div> </div>
</div> </div>
@@ -260,16 +250,12 @@
<app-button <app-button
variant="primary" variant="primary"
[routerLink]="languageService.localizedPath('/about')" [routerLink]="languageService.localizedPath('/about')"
>{{ >{{ "HOME.SEC_ABOUT_TITLE" | translate }}</app-button
"HOME.SEC_ABOUT_TITLE" | translate
}}</app-button
> >
<app-button <app-button
variant="outline" variant="outline"
[routerLink]="languageService.localizedPath('/contact')" [routerLink]="languageService.localizedPath('/contact')"
>{{ >{{ "HOME.BTN_CONTACT" | translate }}</app-button
"HOME.BTN_CONTACT" | translate
}}</app-button
> >
</div> </div>
</div> </div>

View File

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

View File

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

View File

@@ -74,4 +74,16 @@ export class ProductCardComponent {
shopReturnUrl: this.router.url, shopReturnUrl: this.router.url,
}; };
} }
rememberCatalogScroll(): void {
if (typeof window === 'undefined') {
return;
}
const nextState = {
...(history.state ?? {}),
shopRestoreScrollY: Math.max(0, Math.round(window.scrollY)),
};
history.replaceState(nextState, '');
}
} }

View File

@@ -1,5 +1,7 @@
import { CommonModule, isPlatformBrowser } from '@angular/common'; import { CommonModule, Location, isPlatformBrowser } from '@angular/common';
import { import {
RESPONSE_INIT,
afterNextRender,
Component, Component,
DestroyRef, DestroyRef,
Injector, Injector,
@@ -58,12 +60,14 @@ export class ProductDetailComponent {
/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly injector = inject(Injector); private readonly injector = inject(Injector);
private readonly location = inject(Location);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
private readonly seoService = inject(SeoService); private readonly seoService = inject(SeoService);
private readonly languageService = inject(LanguageService); private readonly languageService = inject(LanguageService);
private readonly shopRouteService = inject(ShopRouteService); private readonly shopRouteService = inject(ShopRouteService);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
readonly shopService = inject(ShopService); readonly shopService = inject(ShopService);
readonly categorySlug = input<string | undefined>(); readonly categorySlug = input<string | undefined>();
@@ -193,16 +197,12 @@ export class ProductDetailComponent {
); );
constructor() { constructor() {
if (!this.shopService.cartLoaded()) { afterNextRender(() => {
this.shopService this.scheduleCartWarmup();
.loadCart() });
.pipe(takeUntilDestroyed(this.destroyRef)) this.destroyRef.onDestroy(() => {
.subscribe({ this.languageService.clearLocalizedRouteOverrides();
error: () => {
this.shopService.cart.set(null);
},
}); });
}
combineLatest([ combineLatest([
toObservable(this.productSlug, { injector: this.injector }), toObservable(this.productSlug, { injector: this.injector }),
@@ -221,13 +221,17 @@ export class ProductDetailComponent {
}), }),
switchMap(([productSlug]) => { switchMap(([productSlug]) => {
if (!productSlug) { if (!productSlug) {
this.languageService.clearLocalizedRouteOverrides();
this.error.set('SHOP.NOT_FOUND'); this.error.set('SHOP.NOT_FOUND');
this.setResponseStatus(404);
this.applyFallbackSeo();
this.loading.set(false); this.loading.set(false);
return of(null); return of(null);
} }
return this.shopService.getProductByPublicPath(productSlug).pipe( return this.shopService.getProductByPublicPath(productSlug).pipe(
catchError((error) => { catchError((error) => {
this.languageService.clearLocalizedRouteOverrides();
this.product.set(null); this.product.set(null);
this.selectedVariantId.set(null); this.selectedVariantId.set(null);
this.setSelectedImageAssetId(null); this.setSelectedImageAssetId(null);
@@ -235,6 +239,9 @@ export class ProductDetailComponent {
this.error.set( this.error.set(
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR', error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
); );
if (error?.status === 404) {
this.setResponseStatus(404);
}
this.applyFallbackSeo(); this.applyFallbackSeo();
return of(null); return of(null);
}), }),
@@ -263,6 +270,7 @@ export class ProductDetailComponent {
null, null,
); );
this.quantity.set(1); this.quantity.set(1);
this.languageService.setLocalizedRouteOverrides(product.localizedPaths);
this.syncPublicUrl(product); this.syncPublicUrl(product);
this.applySeo(product); this.applySeo(product);
this.modelFile.set(null); 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 { selectImage(mediaAssetId: string): void {
this.setSelectedImageAssetId(mediaAssetId); this.setSelectedImageAssetId(mediaAssetId);
} }
@@ -366,9 +413,12 @@ export class ProductDetailComponent {
if (!sessionId) { if (!sessionId) {
return; return;
} }
this.router.navigate(['/', this.languageService.selectedLang(), 'checkout'], { this.router.navigate(
['/', this.languageService.selectedLang(), 'checkout'],
{
queryParams: { session: sessionId }, queryParams: { session: sessionId },
}); },
);
} }
priceLabel(): number { priceLabel(): number {
@@ -453,6 +503,11 @@ export class ProductDetailComponent {
: null; : null;
if (returnUrl && this.shopRouteService.isCatalogUrl(returnUrl)) { if (returnUrl && this.shopRouteService.isCatalogUrl(returnUrl)) {
if (this.isBrowser && window.history.length > 1) {
this.location.back();
return;
}
void this.router.navigateByUrl(returnUrl); void this.router.navigateByUrl(returnUrl);
return; return;
} }
@@ -512,25 +567,34 @@ export class ProductDetailComponent {
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
const robots = const robots =
product.indexable === false ? 'noindex, nofollow' : 'index, follow'; 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, title,
description, description,
robots, robots,
ogTitle: product.ogTitle || title, ogTitle: product.ogTitle || title,
ogDescription: product.ogDescription || description, ogDescription: product.ogDescription || description,
canonicalPath,
alternates: product.localizedPaths,
xDefault: product.localizedPaths?.it ?? canonicalPath,
}); });
} }
private applyFallbackSeo(): void { private applyFallbackSeo(): void {
const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`; const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`;
const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
this.seoService.applyPageSeo({ this.seoService.applyResolvedSeo({
title, title,
description, description,
robots: 'index, follow', robots: 'noindex, nofollow',
ogTitle: title, ogTitle: title,
ogDescription: description, ogDescription: description,
canonicalPath: null,
alternates: null,
xDefault: null,
}); });
} }
@@ -705,21 +769,23 @@ export class ProductDetailComponent {
return; return;
} }
const currentProductSlug = this.productSlug()?.trim().toLowerCase() ?? ''; const currentTree = this.router.parseUrl(this.router.url);
const targetProductSlug = this.shopRouteService.productPathSegment(product); const lang = this.languageService.selectedLang();
if (currentProductSlug === targetProductSlug) { 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; return;
} }
const currentTree = this.router.parseUrl(this.router.url);
const targetTree = this.router.createUrlTree( const targetTree = this.router.createUrlTree(
[ ['/', ...normalizedTargetPath.split('/').filter(Boolean)],
'/',
this.languageService.selectedLang(),
'shop',
'p',
targetProductSlug,
],
{ {
queryParams: currentTree.queryParams, queryParams: currentTree.queryParams,
fragment: currentTree.fragment ?? undefined, fragment: currentTree.fragment ?? undefined,
@@ -738,4 +804,10 @@ export class ProductDetailComponent {
state: history.state, state: history.state,
}); });
} }
private setResponseStatus(status: number): void {
if (this.responseInit) {
this.responseInit.status = status;
}
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,172 @@
:host {
display: block;
width: 100%;
}
.brand-animation {
--three-anchor-x: -9.4rem;
--bee-anchor-x: 10.2rem;
--word-scale: var(--brand-animation-scale, 1);
--word-spacing-factor: var(--brand-animation-word-spacing, 1);
--loader-group-scale-x: 0.94;
--loader-group-scale-y: 1.05;
--loader-exit-shift: 1.6rem;
position: relative;
width: min(100%, var(--brand-animation-width, 26rem));
height: var(--brand-animation-height, 8rem);
margin-inline: auto;
}
.brand-animation__letter {
--word-x: 0rem;
position: absolute;
top: 50%;
left: 50%;
width: var(--brand-animation-letter-width, clamp(2.7rem, 6vw, 4rem));
height: auto;
transform: translate(-50%, -50%);
transform-origin: center center;
will-change: transform;
}
.brand-animation[data-variant="site-intro"] .brand-animation__letter {
animation: site-intro-preview var(--brand-animation-site-intro-duration, 1s)
linear 1 forwards;
}
.brand-animation[data-variant="calculator-loader"] .brand-animation__letter {
animation: calculator-loader-loop
var(--brand-animation-loader-loop-duration, 2.65s) infinite;
}
@keyframes site-intro-preview {
0% {
transform: translate(-50%, -50%) translateX(0) scale(0.92);
}
20% {
transform: translate(-50%, -50%) translateX(0) scale(0.92);
}
80% {
transform: translate(-50%, -50%)
translateX(
calc(var(--word-x) * var(--word-scale) * var(--word-spacing-factor))
)
scale(1);
}
100% {
transform: translate(-50%, -50%)
translateX(
calc(var(--word-x) * var(--word-scale) * var(--word-spacing-factor))
)
scale(1);
}
}
@keyframes calculator-loader-loop {
0%,
5% {
opacity: 0;
transform: translate(-50%, -50%)
translateX(
calc(
var(--three-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
)
)
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
}
12% {
opacity: 1;
transform: translate(-50%, -50%)
translateX(
calc(
var(--three-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
)
)
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
}
12% {
animation-timing-function: cubic-bezier(0.22, 0.82, 0.28, 1);
}
38%,
56% {
opacity: 1;
transform: translate(-50%, -50%)
translateX(
calc(var(--word-x) * var(--word-scale) * var(--word-spacing-factor))
)
scale(1);
}
56% {
animation-timing-function: cubic-bezier(0.38, 0, 0.72, 1);
}
82%,
88% {
opacity: 1;
transform: translate(-50%, -50%)
translateX(
calc(
var(--bee-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
)
)
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
}
88% {
animation-timing-function: cubic-bezier(0.4, 0, 1, 1);
}
94% {
opacity: 0;
transform: translate(-50%, -50%)
translateX(
calc(
(var(--bee-anchor-x) + var(--loader-exit-shift)) * var(--word-scale) *
var(--word-spacing-factor)
)
)
scaleX(0.98) scaleY(1.02);
}
94.01%,
100% {
opacity: 0;
transform: translate(-50%, -50%)
translateX(
calc(
var(--three-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
)
)
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
}
}
@media (max-width: 640px) {
.brand-animation {
width: min(100%, var(--brand-animation-width-mobile, 19rem));
height: var(--brand-animation-height-mobile, 6rem);
--word-scale: var(--brand-animation-scale-mobile, 0.74);
}
.brand-animation__letter {
width: var(--brand-animation-letter-width-mobile, 2.8rem);
}
}
@media (prefers-reduced-motion: reduce) {
.brand-animation__letter {
animation: none !important;
transform: translate(-50%, -50%)
translateX(
calc(var(--word-x) * var(--word-scale) * var(--word-spacing-factor))
)
scale(1);
}
}

View File

@@ -0,0 +1,69 @@
import { Component, computed, input } from '@angular/core';
export type BrandAnimationVariant = 'site-intro' | 'calculator-loader';
interface AnimationLetter {
key: string;
darkSrc: string;
yellowSrc: string;
wordX: string;
}
interface ResolvedAnimationLetter {
key: string;
src: string;
wordX: string;
}
const LETTERS: readonly AnimationLetter[] = [
{
key: '3',
darkSrc: '/assets/images/animation/31200.svg',
yellowSrc: '/assets/images/animation/3g1200.svg',
wordX: '-9.4rem',
},
{
key: 'd',
darkSrc: '/assets/images/animation/d1200.svg',
yellowSrc: '/assets/images/animation/Dg1200.svg',
wordX: '-4.9rem',
},
{
key: 'F',
darkSrc: '/assets/images/animation/F1200.svg',
yellowSrc: '/assets/images/animation/Fg1200.svg',
wordX: '1rem',
},
{
key: 'A',
darkSrc: '/assets/images/animation/A1200.svg',
yellowSrc: '/assets/images/animation/Ag1200.svg',
wordX: '5.6rem',
},
{
key: 'B',
darkSrc: '/assets/images/animation/B1200.svg',
yellowSrc: '/assets/images/animation/Bg1200.svg',
wordX: '10.2rem',
},
] as const;
@Component({
selector: 'app-brand-animation-logo',
standalone: true,
templateUrl: './brand-animation-logo.component.html',
styleUrl: './brand-animation-logo.component.scss',
})
export class BrandAnimationLogoComponent {
readonly variant = input<BrandAnimationVariant>('site-intro');
readonly decorative = input(true);
readonly ariaLabel = input('3D fab animated logo');
readonly resolvedLetters = computed<ResolvedAnimationLetter[]>(() =>
LETTERS.map((letter) => ({
key: letter.key,
src: this.variant() === 'site-intro' ? letter.yellowSrc : letter.darkSrc,
wordX: letter.wordX,
})),
);
}

View File

@@ -107,14 +107,14 @@
}, },
"CALC": { "CALC": {
"TITLE": "3D-Angebot berechnen", "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", "CTA_START": "Jetzt starten",
"BUSINESS": "Unternehmen", "BUSINESS": "Unternehmen",
"PRIVATE": "Privat", "PRIVATE": "Privat",
"MODE_EASY": "Basis", "MODE_EASY": "Basis",
"MODE_ADVANCED": "Erweitert", "MODE_ADVANCED": "Erweitert",
"UPLOAD_LABEL": "Ziehen Sie Ihre 3D-Datei hierher", "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", "MATERIAL": "Material",
"QUALITY": "Qualität", "QUALITY": "Qualität",
"QUANTITY": "Menge", "QUANTITY": "Menge",
@@ -141,11 +141,12 @@
"BENEFITS_2": "Ausgewählte Materialien und Qualitätskontrolle", "BENEFITS_2": "Ausgewählte Materialien und Qualitätskontrolle",
"BENEFITS_3": "CAD-Beratung, falls die Datei Änderungen benötigt", "BENEFITS_3": "CAD-Beratung, falls die Datei Änderungen benötigt",
"ERR_FILE_REQUIRED": "Die Datei ist erforderlich.", "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", "REMOVE_FILE": "Datei entfernen",
"FALLBACK_MATERIAL": "PLA (Fallback)", "FALLBACK_MATERIAL": "PLA (Fallback)",
"FALLBACK_QUALITY_STANDARD": "Standard", "FALLBACK_QUALITY_STANDARD": "Standard",
"ERR_FILE_TOO_LARGE": "Einige Dateien überschreiten das 200MB-Limit und wurden nicht hinzugefügt.", "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", "PRINT_SPEED": "Druckgeschwindigkeit",
"COLOR": "Farbe", "COLOR": "Farbe",
"ANALYZING_TITLE": "Analyse läuft...", "ANALYZING_TITLE": "Analyse läuft...",
@@ -624,7 +625,7 @@
"BTN_CONTACT": "Mit uns sprechen", "BTN_CONTACT": "Mit uns sprechen",
"SEC_CALC_TITLE": "Korrekte Preisberechnung in wenigen Sekunden", "SEC_CALC_TITLE": "Korrekte Preisberechnung in wenigen Sekunden",
"SEC_CALC_SUBTITLE": "Keine Registrierung erforderlich. Die Schätzung basiert auf echtem Slicing.", "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_EYEBROW": "Automatische Berechnung",
"CARD_CALC_TITLE": "Preis und Lieferzeit mit einem Klick", "CARD_CALC_TITLE": "Preis und Lieferzeit mit einem Klick",
"CARD_CALC_TAG": "Ohne Registrierung", "CARD_CALC_TAG": "Ohne Registrierung",
@@ -674,7 +675,7 @@
}, },
"DROPZONE": { "DROPZONE": {
"DEFAULT_LABEL": "Dateien hierher ziehen oder klicken zum Hochladen", "DEFAULT_LABEL": "Dateien hierher ziehen oder klicken zum Hochladen",
"DEFAULT_SUBTEXT": "Unterstützt .STL, .3MF, .STEP" "DEFAULT_SUBTEXT": "Unterstützt .STL, .3MF"
}, },
"COLOR": { "COLOR": {
"AVAILABLE_COLORS": "Verfügbare Farben", "AVAILABLE_COLORS": "Verfügbare Farben",

View File

@@ -107,14 +107,14 @@
}, },
"CALC": { "CALC": {
"TITLE": "3D Print Calculator", "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", "CTA_START": "Start Now",
"BUSINESS": "Business", "BUSINESS": "Business",
"PRIVATE": "Private", "PRIVATE": "Private",
"MODE_EASY": "Quick", "MODE_EASY": "Quick",
"MODE_ADVANCED": "Advanced", "MODE_ADVANCED": "Advanced",
"UPLOAD_LABEL": "Drag your 3D file here", "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", "MATERIAL": "Material",
"QUALITY": "Quality", "QUALITY": "Quality",
"QUANTITY": "Quantity", "QUANTITY": "Quantity",
@@ -141,11 +141,12 @@
"BENEFITS_2": "Selected materials and quality control", "BENEFITS_2": "Selected materials and quality control",
"BENEFITS_3": "CAD consultation if file needs modifications", "BENEFITS_3": "CAD consultation if file needs modifications",
"ERR_FILE_REQUIRED": "File is required.", "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", "REMOVE_FILE": "Remove file",
"FALLBACK_MATERIAL": "PLA (fallback)", "FALLBACK_MATERIAL": "PLA (fallback)",
"FALLBACK_QUALITY_STANDARD": "Standard", "FALLBACK_QUALITY_STANDARD": "Standard",
"ERR_FILE_TOO_LARGE": "Some files exceed the 200MB limit and were not added.", "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", "PRINT_SPEED": "Print speed",
"COLOR": "Color", "COLOR": "Color",
"ANALYZING_TITLE": "Analysis in progress...", "ANALYZING_TITLE": "Analysis in progress...",
@@ -624,7 +625,7 @@
"BTN_CONTACT": "Talk to us", "BTN_CONTACT": "Talk to us",
"SEC_CALC_TITLE": "Accurate pricing in a few seconds", "SEC_CALC_TITLE": "Accurate pricing in a few seconds",
"SEC_CALC_SUBTITLE": "No registration required. The estimate is calculated through real slicing.", "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_EYEBROW": "Automatic calculation",
"CARD_CALC_TITLE": "Price and lead time in one click", "CARD_CALC_TITLE": "Price and lead time in one click",
"CARD_CALC_TAG": "No registration", "CARD_CALC_TAG": "No registration",
@@ -674,7 +675,7 @@
}, },
"DROPZONE": { "DROPZONE": {
"DEFAULT_LABEL": "Drop files here or click to upload", "DEFAULT_LABEL": "Drop files here or click to upload",
"DEFAULT_SUBTEXT": "Supports .stl, .3mf, .step" "DEFAULT_SUBTEXT": "Supports .stl, .3mf"
}, },
"COLOR": { "COLOR": {
"AVAILABLE_COLORS": "Available colors", "AVAILABLE_COLORS": "Available colors",

View File

@@ -96,7 +96,7 @@
"BTN_CONTACT": "Parlez avec nous", "BTN_CONTACT": "Parlez avec nous",
"SEC_CALC_TITLE": "Prix correct en quelques secondes", "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_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_EYEBROW": "Calcul automatique",
"CARD_CALC_TITLE": "Prix et délais en un clic", "CARD_CALC_TITLE": "Prix et délais en un clic",
"CARD_CALC_TAG": "Sans inscription", "CARD_CALC_TAG": "Sans inscription",
@@ -139,14 +139,14 @@
}, },
"CALC": { "CALC": {
"TITLE": "Calculer un devis 3D", "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", "CTA_START": "Commencer maintenant",
"BUSINESS": "Entreprises", "BUSINESS": "Entreprises",
"PRIVATE": "Particuliers", "PRIVATE": "Particuliers",
"MODE_EASY": "Base", "MODE_EASY": "Base",
"MODE_ADVANCED": "Avancée", "MODE_ADVANCED": "Avancée",
"UPLOAD_LABEL": "Glissez votre fichier 3D ici", "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", "MATERIAL": "Matériau",
"QUALITY": "Qualité", "QUALITY": "Qualité",
"PRINT_SPEED": "Vitesse d'impression", "PRINT_SPEED": "Vitesse d'impression",
@@ -185,11 +185,12 @@
"NOTES_PLACEHOLDER": "Instructions spécifiques...", "NOTES_PLACEHOLDER": "Instructions spécifiques...",
"SETUP_NOTE": "* Inclut {{cost}} comme coût de setup", "SETUP_NOTE": "* Inclut {{cost}} comme coût de setup",
"SHIPPING_NOTE": "* Frais d'expédition exclus, calculés à l'étape suivante", "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", "REMOVE_FILE": "Supprimer le fichier",
"FALLBACK_MATERIAL": "PLA (fallback)", "FALLBACK_MATERIAL": "PLA (fallback)",
"FALLBACK_QUALITY_STANDARD": "Standard", "FALLBACK_QUALITY_STANDARD": "Standard",
"ERR_FILE_TOO_LARGE": "Certains fichiers dépassent la limite de 200MB et n'ont pas été ajoutés.", "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.", "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_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." "ZERO_RESULT_HELP": "Le calcul a renvoyé des valeurs nulles invalides. Essayez un autre format de fichier ou contactez-nous directement via Demander une consultation."
@@ -680,7 +681,7 @@
}, },
"DROPZONE": { "DROPZONE": {
"DEFAULT_LABEL": "Glissez les fichiers ici ou cliquez pour téléverser", "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": { "COLOR": {
"AVAILABLE_COLORS": "Couleurs disponibles", "AVAILABLE_COLORS": "Couleurs disponibles",

View File

@@ -96,7 +96,7 @@
"BTN_CONTACT": "Parla con noi", "BTN_CONTACT": "Parla con noi",
"SEC_CALC_TITLE": "Prezzo corretto in pochi secondi", "SEC_CALC_TITLE": "Prezzo corretto in pochi secondi",
"SEC_CALC_SUBTITLE": "Nessuna registrazione necessaria. La stima è effettuata tramite vero slicing.", "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_EYEBROW": "Calcolo automatico",
"CARD_CALC_TITLE": "Prezzo e tempi in un click", "CARD_CALC_TITLE": "Prezzo e tempi in un click",
"CARD_CALC_TAG": "Senza registrazione", "CARD_CALC_TAG": "Senza registrazione",
@@ -139,14 +139,14 @@
}, },
"CALC": { "CALC": {
"TITLE": "Calcola Preventivo 3D", "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", "CTA_START": "Inizia Ora",
"BUSINESS": "Aziende", "BUSINESS": "Aziende",
"PRIVATE": "Privati", "PRIVATE": "Privati",
"MODE_EASY": "Base", "MODE_EASY": "Base",
"MODE_ADVANCED": "Avanzata", "MODE_ADVANCED": "Avanzata",
"UPLOAD_LABEL": "Trascina il tuo file 3D qui", "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", "MATERIAL": "Materiale",
"QUALITY": "Qualità", "QUALITY": "Qualità",
"PRINT_SPEED": "Velocità di Stampa", "PRINT_SPEED": "Velocità di Stampa",
@@ -185,11 +185,12 @@
"NOTES_PLACEHOLDER": "Istruzioni specifiche...", "NOTES_PLACEHOLDER": "Istruzioni specifiche...",
"SETUP_NOTE": "* Include {{cost}} come costo di setup", "SETUP_NOTE": "* Include {{cost}} come costo di setup",
"SHIPPING_NOTE": "* Costi di spedizione esclusi, calcolati al passaggio successivo", "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", "REMOVE_FILE": "Rimuovi file",
"FALLBACK_MATERIAL": "PLA (fallback)", "FALLBACK_MATERIAL": "PLA (fallback)",
"FALLBACK_QUALITY_STANDARD": "Standard", "FALLBACK_QUALITY_STANDARD": "Standard",
"ERR_FILE_TOO_LARGE": "Alcuni file superano il limite di 200MB e non sono stati aggiunti.", "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.", "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_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." "ZERO_RESULT_HELP": "Il calcolo ha restituito valori non validi (0). Prova con un altro formato file oppure contattaci direttamente con Richiedi Consulenza."
@@ -680,7 +681,7 @@
}, },
"DROPZONE": { "DROPZONE": {
"DEFAULT_LABEL": "Trascina i file qui o clicca per caricare", "DEFAULT_LABEL": "Trascina i file qui o clicca per caricare",
"DEFAULT_SUBTEXT": "Supporta .STL, .3MF, .STEP" "DEFAULT_SUBTEXT": "Supporta .STL, .3MF"
}, },
"COLOR": { "COLOR": {
"AVAILABLE_COLORS": "Colori disponibili", "AVAILABLE_COLORS": "Colori disponibili",

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 74 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -0,0 +1,207 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.16 163.48">
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect x=".25" y="157.08" width="125.66" height="1.05"/>
<rect x=".25" y="146.6" width="125.66" height="1.05"/>
<rect x=".25" y="136.13" width="125.66" height="1.05"/>
<rect x=".25" y="125.66" width="125.66" height="1.05"/>
<rect x=".25" y="115.19" width="125.66" height="1.05"/>
<rect x=".25" y="104.72" width="125.66" height="1.05"/>
<rect x=".25" y="94.25" width="125.66" height="1.05"/>
<rect x=".25" y="83.77" width="125.66" height="1.05"/>
<rect x=".25" y="73.3" width="125.66" height="1.05"/>
<rect x=".25" y="62.83" width="125.66" height="1.05"/>
<rect x=".25" y="52.36" width="125.66" height="1.05"/>
<rect x=".25" y="41.89" width="125.66" height="1.05"/>
<rect x=".25" y="31.42" width="125.66" height="1.05"/>
<rect x=".25" y="20.94" width="125.66" height="1.05"/>
<rect x=".25" y="10.47" width="125.66" height="1.05"/>
<rect x=".25" width="125.66" height="1.05"/>
</g>
<g>
<rect x="110.82" y="131.82" width="5.02" height="5.25"/>
<rect x="110.82" y="121.37" width="5.02" height="5.25"/>
<rect x="110.82" y="110.91" width="5.02" height="5.25"/>
<rect x="110.82" y="100.46" width="5.02" height="5.21"/>
<rect x="110.82" y="89.97" width="5.02" height="5.25"/>
<rect x="110.82" y="79.51" width="5.02" height="5.25"/>
<rect x="110.82" y="69.06" width="5.02" height="5.25"/>
<rect x="110.82" y="58.6" width="5.02" height="5.21"/>
<rect x="110.82" y="48.15" width="5.02" height="5.21"/>
<rect x="110.82" y="37.66" width="5.02" height="5.25"/>
<rect x="110.82" y="27.2" width="5.02" height="5.25"/>
<rect x="110.82" y="16.75" width="5.02" height="5.21"/>
<rect x="105.79" y="142.31" width="5.02" height="5.21"/>
<rect x="105.79" y="131.82" width="5.02" height="5.25"/>
<rect x="105.79" y="121.37" width="5.02" height="5.25"/>
<rect x="105.79" y="110.91" width="5.02" height="5.25"/>
<rect x="105.79" y="100.46" width="5.02" height="5.21"/>
<rect x="105.79" y="89.97" width="5.02" height="5.25"/>
<rect x="105.79" y="79.51" width="5.02" height="5.25"/>
<rect x="105.79" y="69.06" width="5.02" height="5.25"/>
<rect x="105.79" y="58.6" width="5.02" height="5.21"/>
<rect x="105.79" y="48.15" width="5.02" height="5.21"/>
<rect x="105.79" y="37.66" width="5.02" height="5.25"/>
<rect x="105.79" y="27.2" width="5.02" height="5.25"/>
<rect x="105.79" y="16.75" width="5.02" height="5.21"/>
<rect x="105.79" y="6.29" width="5.02" height="5.21"/>
<rect x="100.77" y="142.31" width="5.02" height="5.21"/>
<rect x="100.77" y="131.82" width="5.02" height="5.25"/>
<rect x="100.77" y="121.37" width="5.02" height="5.25"/>
<rect x="100.77" y="110.91" width="5.02" height="5.25"/>
<rect x="100.77" y="100.46" width="5.02" height="5.21"/>
<rect x="100.77" y="89.97" width="5.02" height="5.25"/>
<rect x="100.77" y="79.51" width="5.02" height="5.25"/>
<rect x="100.77" y="69.06" width="5.02" height="5.25"/>
<rect x="100.77" y="58.6" width="5.02" height="5.21"/>
<rect x="100.77" y="48.15" width="5.02" height="5.21"/>
<rect x="100.77" y="37.66" width="5.02" height="5.25"/>
<rect x="100.77" y="27.2" width="5.02" height="5.25"/>
<rect x="100.77" y="16.75" width="5.02" height="5.21"/>
<rect x="100.77" y="6.29" width="5.02" height="5.21"/>
<rect x="95.75" y="142.31" width="5.02" height="5.21"/>
<rect x="95.75" y="131.82" width="5.02" height="5.25"/>
<rect x="95.75" y="121.37" width="5.02" height="5.25"/>
<rect x="95.75" y="110.91" width="5.02" height="5.25"/>
<rect x="95.75" y="100.46" width="5.02" height="5.21"/>
<rect x="95.75" y="89.97" width="5.02" height="5.25"/>
<rect x="95.75" y="79.51" width="5.02" height="5.25"/>
<rect x="95.75" y="69.06" width="5.02" height="5.25"/>
<rect x="95.75" y="58.6" width="5.02" height="5.21"/>
<rect x="95.75" y="48.15" width="5.02" height="5.21"/>
<rect x="95.75" y="37.66" width="5.02" height="5.25"/>
<rect x="95.75" y="27.2" width="5.02" height="5.25"/>
<rect x="95.75" y="16.75" width="5.02" height="5.21"/>
<rect x="95.75" y="6.29" width="5.02" height="5.21"/>
<rect x="90.72" y="142.31" width="5.02" height="5.21"/>
<rect x="90.72" y="131.82" width="5.02" height="5.25"/>
<rect x="90.72" y="121.37" width="5.02" height="5.25"/>
<rect x="90.72" y="110.91" width="5.02" height="5.25"/>
<rect x="90.72" y="100.46" width="5.02" height="5.21"/>
<rect x="90.72" y="89.97" width="5.02" height="5.25"/>
<rect x="90.72" y="79.51" width="5.02" height="5.25"/>
<rect x="90.72" y="69.06" width="5.02" height="5.25"/>
<rect x="90.72" y="58.6" width="5.02" height="5.21"/>
<rect x="90.72" y="48.15" width="5.02" height="5.21"/>
<rect x="90.72" y="37.66" width="5.02" height="5.25"/>
<rect x="90.72" y="27.2" width="5.02" height="5.25"/>
<rect x="90.72" y="16.75" width="5.02" height="5.21"/>
<rect x="90.72" y="6.29" width="5.02" height="5.21"/>
<rect x="85.7" y="142.31" width="5.02" height="5.21"/>
<rect x="85.7" y="131.82" width="5.02" height="5.25"/>
<rect x="85.7" y="121.37" width="5.02" height="5.25"/>
<rect x="85.7" y="110.91" width="5.02" height="5.25"/>
<rect x="85.7" y="100.46" width="5.02" height="5.21"/>
<rect x="85.7" y="89.97" width="5.02" height="5.25"/>
<rect x="85.7" y="79.51" width="5.02" height="5.25"/>
<rect x="85.7" y="69.06" width="5.02" height="5.25"/>
<rect x="85.7" y="58.6" width="5.02" height="5.21"/>
<rect x="85.7" y="48.15" width="5.02" height="5.21"/>
<rect x="85.7" y="37.66" width="5.02" height="5.25"/>
<rect x="85.7" y="27.2" width="5.02" height="5.25"/>
<rect x="85.7" y="16.75" width="5.02" height="5.21"/>
<rect x="85.7" y="6.29" width="5.02" height="5.21"/>
<rect x="80.67" y="142.31" width="5.02" height="5.21"/>
<rect x="80.67" y="131.82" width="5.02" height="5.25"/>
<rect x="80.67" y="121.37" width="5.02" height="5.25"/>
<rect x="80.67" y="79.51" width="5.02" height="5.25"/>
<rect x="80.67" y="69.06" width="5.02" height="5.25"/>
<rect x="80.67" y="27.2" width="5.02" height="5.25"/>
<rect x="80.67" y="16.75" width="5.02" height="5.21"/>
<rect x="80.67" y="6.29" width="5.02" height="5.21"/>
<rect x="75.65" y="142.31" width="5.02" height="5.21"/>
<rect x="75.65" y="131.82" width="5.02" height="5.25"/>
<rect x="75.65" y="121.37" width="5.02" height="5.25"/>
<rect x="75.65" y="79.51" width="5.02" height="5.25"/>
<rect x="75.65" y="69.06" width="5.02" height="5.25"/>
<rect x="75.65" y="27.2" width="5.02" height="5.25"/>
<rect x="75.65" y="16.75" width="5.02" height="5.21"/>
<rect x="75.65" y="6.29" width="5.02" height="5.21"/>
<rect x="70.63" y="142.31" width="5.02" height="5.21"/>
<rect x="70.63" y="131.82" width="5.02" height="5.25"/>
<rect x="70.63" y="121.37" width="5.02" height="5.25"/>
<rect x="70.63" y="79.51" width="5.02" height="5.25"/>
<rect x="70.63" y="69.06" width="5.02" height="5.25"/>
<rect x="70.63" y="27.2" width="5.02" height="5.25"/>
<rect x="70.63" y="16.75" width="5.02" height="5.21"/>
<rect x="70.63" y="6.29" width="5.02" height="5.21"/>
<rect x="65.6" y="142.31" width="5.02" height="5.21"/>
<rect x="65.6" y="131.82" width="5.02" height="5.25"/>
<rect x="65.6" y="121.37" width="5.02" height="5.25"/>
<rect x="65.6" y="79.51" width="5.02" height="5.25"/>
<rect x="65.6" y="69.06" width="5.02" height="5.25"/>
<rect x="65.6" y="27.2" width="5.02" height="5.25"/>
<rect x="65.6" y="16.75" width="5.02" height="5.21"/>
<rect x="65.6" y="6.29" width="5.02" height="5.21"/>
<rect x="60.58" y="142.31" width="5.02" height="5.21"/>
<rect x="60.58" y="131.82" width="5.02" height="5.25"/>
<rect x="60.58" y="121.37" width="5.02" height="5.25"/>
<rect x="60.58" y="79.51" width="5.02" height="5.25"/>
<rect x="60.58" y="69.06" width="5.02" height="5.25"/>
<rect x="60.58" y="27.2" width="5.02" height="5.25"/>
<rect x="60.58" y="16.75" width="5.02" height="5.21"/>
<rect x="60.58" y="6.29" width="5.02" height="5.21"/>
<rect x="55.55" y="142.31" width="5.02" height="5.21"/>
<rect x="55.55" y="131.82" width="5.02" height="5.25"/>
<rect x="55.55" y="121.37" width="5.02" height="5.25"/>
<rect x="55.55" y="79.51" width="5.02" height="5.25"/>
<rect x="55.55" y="69.06" width="5.02" height="5.25"/>
<rect x="55.55" y="27.2" width="5.02" height="5.25"/>
<rect x="55.55" y="16.75" width="5.02" height="5.21"/>
<rect x="55.55" y="6.29" width="5.02" height="5.21"/>
<rect x="50.53" y="142.31" width="5.02" height="5.21"/>
<rect x="50.53" y="131.82" width="5.02" height="5.25"/>
<rect x="50.53" y="121.37" width="5.02" height="5.25"/>
<rect x="50.53" y="79.51" width="5.02" height="5.25"/>
<rect x="50.53" y="69.06" width="5.02" height="5.25"/>
<rect x="50.53" y="27.2" width="5.02" height="5.25"/>
<rect x="50.53" y="16.75" width="5.02" height="5.21"/>
<rect x="50.53" y="6.29" width="5.02" height="5.21"/>
<rect x="45.51" y="142.31" width="5.02" height="5.21"/>
<rect x="45.51" y="131.82" width="5.02" height="5.25"/>
<rect x="45.51" y="121.37" width="5.02" height="5.25"/>
<rect x="45.51" y="27.2" width="5.02" height="5.25"/>
<rect x="45.51" y="16.75" width="5.02" height="5.21"/>
<rect x="45.51" y="6.29" width="5.02" height="5.21"/>
<rect x="40.44" y="142.31" width="5.06" height="5.21"/>
<rect x="40.44" y="131.82" width="5.06" height="5.25"/>
<rect x="40.44" y="121.37" width="5.06" height="5.25"/>
<rect x="40.44" y="27.2" width="5.06" height="5.25"/>
<rect x="40.44" y="16.75" width="5.06" height="5.21"/>
<rect x="40.44" y="6.29" width="5.06" height="5.21"/>
<rect x="35.42" y="142.31" width="5.02" height="5.21"/>
<rect x="35.42" y="131.82" width="5.02" height="5.25"/>
<rect x="35.42" y="121.37" width="5.02" height="5.25"/>
<rect x="35.42" y="27.2" width="5.02" height="5.25"/>
<rect x="35.42" y="16.75" width="5.02" height="5.21"/>
<rect x="35.42" y="6.29" width="5.02" height="5.21"/>
<rect x="30.4" y="142.31" width="5.02" height="5.21"/>
<rect x="30.4" y="131.82" width="5.02" height="5.25"/>
<rect x="30.4" y="121.37" width="5.02" height="5.25"/>
<rect x="30.4" y="27.2" width="5.02" height="5.25"/>
<rect x="30.4" y="16.75" width="5.02" height="5.21"/>
<rect x="30.4" y="6.29" width="5.02" height="5.21"/>
<rect x="25.37" y="142.31" width="5.02" height="5.21"/>
<rect x="25.37" y="131.82" width="5.02" height="5.25"/>
<rect x="25.37" y="121.37" width="5.02" height="5.25"/>
<rect x="25.37" y="27.2" width="5.02" height="5.25"/>
<rect x="25.37" y="16.75" width="5.02" height="5.21"/>
<rect x="25.37" y="6.29" width="5.02" height="5.21"/>
<rect x="20.35" y="142.31" width="5.02" height="5.21"/>
<rect x="20.35" y="131.82" width="5.02" height="5.25"/>
<rect x="20.35" y="121.37" width="5.02" height="5.25"/>
<rect x="20.35" y="27.2" width="5.02" height="5.25"/>
<rect x="20.35" y="16.75" width="5.02" height="5.21"/>
<rect x="20.35" y="6.29" width="5.02" height="5.21"/>
<rect x="15.32" y="142.31" width="5.02" height="5.21"/>
<rect x="15.32" y="131.82" width="5.02" height="5.25"/>
<rect x="15.32" y="121.37" width="5.02" height="5.25"/>
<rect x="15.32" y="27.2" width="5.02" height="5.25"/>
<rect x="15.32" y="16.75" width="5.02" height="5.21"/>
<rect x="15.32" y="6.29" width="5.02" height="5.21"/>
<rect x="10.3" y="131.82" width="5.02" height="5.25"/>
<rect x="10.3" y="16.75" width="5.02" height="5.21"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,209 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.16 163.48">
<defs>
</defs>
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect class="cls-2" x=".25" y="157.08" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="146.6" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="136.13" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="125.66" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="115.19" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="104.72" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="94.25" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="83.77" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="73.3" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="62.83" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="52.36" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="41.89" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="31.42" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="20.94" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="10.47" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" width="125.66" height="1.05"/>
</g>
<g>
<rect class="cls-1" x="110.83" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.83" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.83" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.83" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="110.83" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="110.83" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.83" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.83" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="110.83" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="110.83" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.83" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.83" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.81" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.81" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.81" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.81" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.81" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.81" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.81" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.81" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.81" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.81" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.81" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.81" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.81" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.81" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.78" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.78" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.78" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.78" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.78" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.78" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.78" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.78" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.78" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.78" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.78" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.78" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.78" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.78" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.76" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.76" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.76" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.76" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.76" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.76" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.76" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.76" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.76" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.76" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.76" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.76" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.76" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.76" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.73" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.73" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.73" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.73" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.73" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.73" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.73" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.73" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.73" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.73" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.73" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.73" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.73" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.73" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="85.71" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="85.71" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.71" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.71" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.71" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="85.71" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="85.71" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.71" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.71" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="85.71" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="85.71" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.71" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.71" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.71" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="80.69" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="80.69" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.69" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.69" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.69" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.69" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.69" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.69" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="75.66" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="75.66" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.66" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.66" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.66" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.66" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.66" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.66" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="70.64" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="70.64" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.64" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.64" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.64" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.64" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.64" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.64" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="65.58" y="142.31" width="5.06" height="5.21"/>
<rect class="cls-1" x="65.58" y="131.82" width="5.06" height="5.25"/>
<rect class="cls-1" x="65.58" y="121.37" width="5.06" height="5.25"/>
<rect class="cls-1" x="65.58" y="79.51" width="5.06" height="5.25"/>
<rect class="cls-1" x="65.58" y="69.06" width="5.06" height="5.25"/>
<rect class="cls-1" x="65.58" y="27.2" width="5.06" height="5.25"/>
<rect class="cls-1" x="65.58" y="16.75" width="5.06" height="5.25"/>
<rect class="cls-1" x="65.58" y="6.29" width="5.06" height="5.21"/>
<rect class="cls-1" x="60.55" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="60.55" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.55" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.55" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.55" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.55" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.55" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.55" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="55.53" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="55.53" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.53" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.53" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.53" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.53" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.53" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.53" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="50.5" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="50.5" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.5" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.5" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.5" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.5" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.5" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.5" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="45.48" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="45.48" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="45.48" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="45.48" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="45.48" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="45.48" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="40.46" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="40.46" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.46" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.46" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.46" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.46" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.43" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.43" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.43" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.43" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.43" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.43" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.41" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.41" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.41" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.41" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.41" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.41" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.38" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.38" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.38" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.38" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.38" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.38" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.36" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.36" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.36" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.36" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.36" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.36" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.34" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.34" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.34" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.34" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.34" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.34" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.31" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.31" y="16.75" width="5.02" height="5.25"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,225 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.16 163.48">
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect x=".25" y="157.08" width="125.66" height="1.05"/>
<rect x=".25" y="146.6" width="125.66" height="1.05"/>
<rect x=".25" y="136.13" width="125.66" height="1.05"/>
<rect x=".25" y="125.66" width="125.66" height="1.05"/>
<rect x=".25" y="115.19" width="125.66" height="1.05"/>
<rect x=".25" y="104.72" width="125.66" height="1.05"/>
<rect x=".25" y="94.25" width="125.66" height="1.05"/>
<rect x=".25" y="83.77" width="125.66" height="1.05"/>
<rect x=".25" y="73.3" width="125.66" height="1.05"/>
<rect x=".25" y="62.83" width="125.66" height="1.05"/>
<rect x=".25" y="52.36" width="125.66" height="1.05"/>
<rect x=".25" y="41.89" width="125.66" height="1.05"/>
<rect x=".25" y="31.42" width="125.66" height="1.05"/>
<rect x=".25" y="20.94" width="125.66" height="1.05"/>
<rect x=".25" y="10.47" width="125.66" height="1.05"/>
<rect x=".25" width="125.66" height="1.05"/>
</g>
<g>
<rect x="115.73" y="142.22" width="3.4" height="5.28"/>
<rect x="112.33" y="142.22" width="3.4" height="5.28"/>
<rect x="112.33" y="131.72" width="3.4" height="5.25"/>
<rect x="108.93" y="142.22" width="3.4" height="5.28"/>
<rect x="108.93" y="131.72" width="3.4" height="5.25"/>
<rect x="108.93" y="121.23" width="3.4" height="5.25"/>
<rect x="105.53" y="142.22" width="3.4" height="5.28"/>
<rect x="105.53" y="131.72" width="3.4" height="5.25"/>
<rect x="105.53" y="121.23" width="3.4" height="5.25"/>
<rect x="105.53" y="110.74" width="3.4" height="5.25"/>
<rect x="102.14" y="142.22" width="3.4" height="5.28"/>
<rect x="102.14" y="131.72" width="3.4" height="5.25"/>
<rect x="102.14" y="121.23" width="3.4" height="5.25"/>
<rect x="102.14" y="110.74" width="3.4" height="5.25"/>
<rect x="102.14" y="100.25" width="3.4" height="5.25"/>
<rect x="98.74" y="142.22" width="3.4" height="5.28"/>
<rect x="98.74" y="131.72" width="3.4" height="5.25"/>
<rect x="98.74" y="121.23" width="3.4" height="5.25"/>
<rect x="98.74" y="110.74" width="3.4" height="5.25"/>
<rect x="98.74" y="100.25" width="3.4" height="5.25"/>
<rect x="98.74" y="89.76" width="3.4" height="5.25"/>
<rect x="95.34" y="142.22" width="3.4" height="5.28"/>
<rect x="95.34" y="131.72" width="3.4" height="5.25"/>
<rect x="95.34" y="121.23" width="3.4" height="5.25"/>
<rect x="95.34" y="110.74" width="3.4" height="5.25"/>
<rect x="95.34" y="100.25" width="3.4" height="5.25"/>
<rect x="95.34" y="89.76" width="3.4" height="5.25"/>
<rect x="95.34" y="79.27" width="3.4" height="5.25"/>
<rect x="91.94" y="131.72" width="3.4" height="5.25"/>
<rect x="91.94" y="121.23" width="3.4" height="5.25"/>
<rect x="91.94" y="110.74" width="3.4" height="5.25"/>
<rect x="91.94" y="100.25" width="3.4" height="5.25"/>
<rect x="91.94" y="89.76" width="3.4" height="5.25"/>
<rect x="91.94" y="79.27" width="3.4" height="5.25"/>
<rect x="91.94" y="68.74" width="3.4" height="5.28"/>
<rect x="88.54" y="121.23" width="3.4" height="5.25"/>
<rect x="88.54" y="110.74" width="3.4" height="5.25"/>
<rect x="88.54" y="100.25" width="3.4" height="5.25"/>
<rect x="88.54" y="89.76" width="3.4" height="5.25"/>
<rect x="88.54" y="79.27" width="3.4" height="5.25"/>
<rect x="88.54" y="68.74" width="3.4" height="5.28"/>
<rect x="88.54" y="58.25" width="3.4" height="5.25"/>
<rect x="85.14" y="110.74" width="3.4" height="5.25"/>
<rect x="85.14" y="100.25" width="3.4" height="5.25"/>
<rect x="85.14" y="89.76" width="3.4" height="5.25"/>
<rect x="85.14" y="79.27" width="3.4" height="5.25"/>
<rect x="85.14" y="68.74" width="3.4" height="5.28"/>
<rect x="85.14" y="58.25" width="3.4" height="5.25"/>
<rect x="85.14" y="47.75" width="3.4" height="5.25"/>
<rect x="81.74" y="100.25" width="3.4" height="5.25"/>
<rect x="81.74" y="89.76" width="3.4" height="5.25"/>
<rect x="81.74" y="79.27" width="3.4" height="5.25"/>
<rect x="81.74" y="68.74" width="3.4" height="5.28"/>
<rect x="81.74" y="58.25" width="3.4" height="5.25"/>
<rect x="81.74" y="47.75" width="3.4" height="5.25"/>
<rect x="81.74" y="37.26" width="3.4" height="5.25"/>
<rect x="78.38" y="100.25" width="3.36" height="5.25"/>
<rect x="78.38" y="89.76" width="3.36" height="5.25"/>
<rect x="78.38" y="79.27" width="3.36" height="5.25"/>
<rect x="78.38" y="68.74" width="3.36" height="5.28"/>
<rect x="78.38" y="58.25" width="3.36" height="5.25"/>
<rect x="78.38" y="47.75" width="3.36" height="5.25"/>
<rect x="78.38" y="37.26" width="3.36" height="5.25"/>
<rect x="78.38" y="26.77" width="3.36" height="5.25"/>
<rect x="74.98" y="100.25" width="3.4" height="5.25"/>
<rect x="74.98" y="89.76" width="3.4" height="5.25"/>
<rect x="74.98" y="79.27" width="3.4" height="5.25"/>
<rect x="74.98" y="68.74" width="3.4" height="5.28"/>
<rect x="74.98" y="58.25" width="3.4" height="5.25"/>
<rect x="74.98" y="47.75" width="3.4" height="5.25"/>
<rect x="74.98" y="37.26" width="3.4" height="5.25"/>
<rect x="74.98" y="26.77" width="3.4" height="5.25"/>
<rect x="74.98" y="16.28" width="3.4" height="5.25"/>
<rect x="71.58" y="100.25" width="3.4" height="5.25"/>
<rect x="71.58" y="89.76" width="3.4" height="5.25"/>
<rect x="71.58" y="68.74" width="3.4" height="5.28"/>
<rect x="71.58" y="58.25" width="3.4" height="5.25"/>
<rect x="71.58" y="47.75" width="3.4" height="5.25"/>
<rect x="71.58" y="37.26" width="3.4" height="5.25"/>
<rect x="71.58" y="26.77" width="3.4" height="5.25"/>
<rect x="71.58" y="16.28" width="3.4" height="5.25"/>
<rect x="71.58" y="5.79" width="3.4" height="5.25"/>
<rect x="68.19" y="100.25" width="3.4" height="5.25"/>
<rect x="68.19" y="89.76" width="3.4" height="5.25"/>
<rect x="68.19" y="58.25" width="3.4" height="5.25"/>
<rect x="68.19" y="47.75" width="3.4" height="5.25"/>
<rect x="68.19" y="37.26" width="3.4" height="5.25"/>
<rect x="68.19" y="26.77" width="3.4" height="5.25"/>
<rect x="68.19" y="16.28" width="3.4" height="5.25"/>
<rect x="68.19" y="5.79" width="3.4" height="5.25"/>
<rect x="64.79" y="100.25" width="3.4" height="5.25"/>
<rect x="64.79" y="89.76" width="3.4" height="5.25"/>
<rect x="64.79" y="47.75" width="3.4" height="5.25"/>
<rect x="64.79" y="37.26" width="3.4" height="5.25"/>
<rect x="64.79" y="26.77" width="3.4" height="5.25"/>
<rect x="64.79" y="16.28" width="3.4" height="5.25"/>
<rect x="64.79" y="5.79" width="3.4" height="5.25"/>
<rect x="61.39" y="100.25" width="3.4" height="5.25"/>
<rect x="61.39" y="89.76" width="3.4" height="5.25"/>
<rect x="61.39" y="37.26" width="3.4" height="5.25"/>
<rect x="61.39" y="26.77" width="3.4" height="5.25"/>
<rect x="61.39" y="16.28" width="3.4" height="5.25"/>
<rect x="61.39" y="5.79" width="3.4" height="5.25"/>
<rect x="57.99" y="100.25" width="3.4" height="5.25"/>
<rect x="57.99" y="89.76" width="3.4" height="5.25"/>
<rect x="57.99" y="47.75" width="3.4" height="5.25"/>
<rect x="57.99" y="37.26" width="3.4" height="5.25"/>
<rect x="57.99" y="26.77" width="3.4" height="5.25"/>
<rect x="57.99" y="16.28" width="3.4" height="5.25"/>
<rect x="57.99" y="5.79" width="3.4" height="5.25"/>
<rect x="54.59" y="100.25" width="3.4" height="5.25"/>
<rect x="54.59" y="89.76" width="3.4" height="5.25"/>
<rect x="54.59" y="58.25" width="3.4" height="5.25"/>
<rect x="54.59" y="47.75" width="3.4" height="5.25"/>
<rect x="54.59" y="37.26" width="3.4" height="5.25"/>
<rect x="54.59" y="26.77" width="3.4" height="5.25"/>
<rect x="54.59" y="16.28" width="3.4" height="5.25"/>
<rect x="54.59" y="5.79" width="3.4" height="5.25"/>
<rect x="51.19" y="100.25" width="3.4" height="5.25"/>
<rect x="51.19" y="89.76" width="3.4" height="5.25"/>
<rect x="51.19" y="68.74" width="3.4" height="5.28"/>
<rect x="51.19" y="58.25" width="3.4" height="5.25"/>
<rect x="51.19" y="47.75" width="3.4" height="5.25"/>
<rect x="51.19" y="37.26" width="3.4" height="5.25"/>
<rect x="51.19" y="26.77" width="3.4" height="5.25"/>
<rect x="51.19" y="16.28" width="3.4" height="5.25"/>
<rect x="51.19" y="5.79" width="3.4" height="5.25"/>
<rect x="47.79" y="100.25" width="3.4" height="5.25"/>
<rect x="47.79" y="89.76" width="3.4" height="5.25"/>
<rect x="47.79" y="79.27" width="3.4" height="5.25"/>
<rect x="47.79" y="68.74" width="3.4" height="5.28"/>
<rect x="47.79" y="58.25" width="3.4" height="5.25"/>
<rect x="47.79" y="47.75" width="3.4" height="5.25"/>
<rect x="47.79" y="37.26" width="3.4" height="5.25"/>
<rect x="47.79" y="26.77" width="3.4" height="5.25"/>
<rect x="47.79" y="16.28" width="3.4" height="5.25"/>
<rect x="44.39" y="100.25" width="3.4" height="5.25"/>
<rect x="44.39" y="89.76" width="3.4" height="5.25"/>
<rect x="44.39" y="79.27" width="3.4" height="5.25"/>
<rect x="44.39" y="68.74" width="3.4" height="5.28"/>
<rect x="44.39" y="58.25" width="3.4" height="5.25"/>
<rect x="44.39" y="47.75" width="3.4" height="5.25"/>
<rect x="44.39" y="37.26" width="3.4" height="5.25"/>
<rect x="44.39" y="26.77" width="3.4" height="5.25"/>
<rect x="41" y="100.25" width="3.4" height="5.25"/>
<rect x="41" y="89.76" width="3.4" height="5.25"/>
<rect x="41" y="79.27" width="3.4" height="5.25"/>
<rect x="41" y="68.74" width="3.4" height="5.28"/>
<rect x="41" y="58.25" width="3.4" height="5.25"/>
<rect x="41" y="47.75" width="3.4" height="5.25"/>
<rect x="41" y="37.26" width="3.4" height="5.25"/>
<rect x="37.6" y="110.74" width="3.4" height="5.25"/>
<rect x="37.6" y="100.25" width="3.4" height="5.25"/>
<rect x="37.6" y="89.76" width="3.4" height="5.25"/>
<rect x="37.6" y="79.27" width="3.4" height="5.25"/>
<rect x="37.6" y="68.74" width="3.4" height="5.28"/>
<rect x="37.6" y="58.25" width="3.4" height="5.25"/>
<rect x="37.6" y="47.75" width="3.4" height="5.25"/>
<rect x="34.2" y="121.23" width="3.4" height="5.25"/>
<rect x="34.2" y="110.74" width="3.4" height="5.25"/>
<rect x="34.2" y="100.25" width="3.4" height="5.25"/>
<rect x="34.2" y="89.76" width="3.4" height="5.25"/>
<rect x="34.2" y="79.27" width="3.4" height="5.25"/>
<rect x="34.2" y="68.74" width="3.4" height="5.28"/>
<rect x="34.2" y="58.25" width="3.4" height="5.25"/>
<rect x="30.8" y="131.72" width="3.4" height="5.25"/>
<rect x="30.8" y="121.23" width="3.4" height="5.25"/>
<rect x="30.8" y="110.74" width="3.4" height="5.25"/>
<rect x="30.8" y="100.25" width="3.4" height="5.25"/>
<rect x="30.8" y="89.76" width="3.4" height="5.25"/>
<rect x="30.8" y="79.27" width="3.4" height="5.25"/>
<rect x="30.8" y="68.74" width="3.4" height="5.28"/>
<rect x="27.4" y="142.22" width="3.4" height="5.28"/>
<rect x="27.4" y="131.72" width="3.4" height="5.25"/>
<rect x="27.4" y="121.23" width="3.4" height="5.25"/>
<rect x="27.4" y="110.74" width="3.4" height="5.25"/>
<rect x="27.4" y="100.25" width="3.4" height="5.25"/>
<rect x="27.4" y="89.76" width="3.4" height="5.25"/>
<rect x="27.4" y="79.27" width="3.4" height="5.25"/>
<rect x="24.04" y="142.22" width="3.36" height="5.28"/>
<rect x="24.04" y="131.72" width="3.36" height="5.25"/>
<rect x="24.04" y="121.23" width="3.36" height="5.25"/>
<rect x="24.04" y="110.74" width="3.36" height="5.25"/>
<rect x="24.04" y="100.25" width="3.36" height="5.25"/>
<rect x="24.04" y="89.76" width="3.36" height="5.25"/>
<rect x="20.64" y="142.22" width="3.4" height="5.28"/>
<rect x="20.64" y="131.72" width="3.4" height="5.25"/>
<rect x="20.64" y="121.23" width="3.4" height="5.25"/>
<rect x="20.64" y="110.74" width="3.4" height="5.25"/>
<rect x="20.64" y="100.25" width="3.4" height="5.25"/>
<rect x="17.24" y="142.22" width="3.4" height="5.28"/>
<rect x="17.24" y="131.72" width="3.4" height="5.25"/>
<rect x="17.24" y="121.23" width="3.4" height="5.25"/>
<rect x="17.24" y="110.74" width="3.4" height="5.25"/>
<rect x="13.84" y="142.22" width="3.4" height="5.28"/>
<rect x="13.84" y="131.72" width="3.4" height="5.25"/>
<rect x="13.84" y="121.23" width="3.4" height="5.25"/>
<rect x="10.45" y="142.22" width="3.4" height="5.28"/>
<rect x="10.45" y="131.72" width="3.4" height="5.25"/>
<rect x="7.05" y="142.22" width="3.4" height="5.28"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,236 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.16 164">
<defs>
<style>
.cls-1 {
fill: #ffdf07;
}
.cls-2 {
fill: #575756;
}
</style>
</defs>
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect class="cls-2" x=".25" y="157.08" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="146.6" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="136.13" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="125.66" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="115.19" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="104.72" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="94.25" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="83.77" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="73.3" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="62.83" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="52.36" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="41.89" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="31.42" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="20.94" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="10.47" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" width="125.66" height="1.05"/>
</g>
<g>
<rect class="cls-1" x="115.71" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="112.34" y="142.77" width="3.36" height="5.25"/>
<rect class="cls-1" x="112.34" y="132.24" width="3.36" height="5.25"/>
<rect class="cls-1" x="108.94" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="108.94" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="108.94" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="105.55" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="105.55" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="105.55" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="105.55" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="102.15" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="102.15" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="102.15" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="102.15" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="102.15" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="98.75" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="98.75" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="98.75" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="98.75" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="98.75" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="98.75" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="95.35" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="95.35" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="95.35" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="95.35" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="95.35" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="95.35" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="95.35" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="91.95" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="91.95" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="91.95" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="91.95" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="91.95" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="91.95" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="91.95" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="88.55" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="88.55" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="88.55" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="88.55" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="88.55" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="88.55" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="88.55" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="85.15" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="85.15" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="85.15" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="85.15" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="85.15" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="85.15" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="85.15" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="81.76" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="81.76" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="81.76" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="81.76" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="81.76" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="81.76" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="81.76" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="78.36" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="78.36" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="78.36" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="78.36" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="78.36" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="78.36" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="78.36" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="78.36" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="74.96" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="74.96" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="74.96" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="74.96" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="74.96" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="74.96" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="74.96" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="74.96" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="74.96" y="16.8" width="3.4" height="5.25"/>
<rect class="cls-1" x="71.56" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="71.56" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="71.56" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="71.56" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="71.56" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="71.56" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="71.56" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="71.56" y="16.8" width="3.4" height="5.25"/>
<rect class="cls-1" x="71.56" y="6.31" width="3.4" height="5.25"/>
<rect class="cls-1" x="68.16" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="68.16" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="68.16" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="68.16" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="68.16" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="68.16" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="68.16" y="16.8" width="3.4" height="5.25"/>
<rect class="cls-1" x="68.16" y="6.31" width="3.4" height="5.25"/>
<rect class="cls-1" x="64.76" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="64.76" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="64.76" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="64.76" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="64.76" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="64.76" y="16.8" width="3.4" height="5.25"/>
<rect class="cls-1" x="64.76" y="6.31" width="3.4" height="5.25"/>
<rect class="cls-1" x="61.4" y="100.77" width="3.36" height="5.25"/>
<rect class="cls-1" x="61.4" y="90.28" width="3.36" height="5.25"/>
<rect class="cls-1" x="61.4" y="37.78" width="3.36" height="5.25"/>
<rect class="cls-1" x="61.4" y="27.29" width="3.36" height="5.25"/>
<rect class="cls-1" x="61.4" y="16.8" width="3.36" height="5.25"/>
<rect class="cls-1" x="61.4" y="6.31" width="3.36" height="5.25"/>
<rect class="cls-1" x="58" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="58" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="58" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="58" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="58" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="58" y="16.8" width="3.4" height="5.25"/>
<rect class="cls-1" x="58" y="6.31" width="3.4" height="5.25"/>
<rect class="cls-1" x="54.6" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="54.6" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="54.6" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="54.6" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="54.6" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="54.6" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="54.6" y="16.8" width="3.4" height="5.25"/>
<rect class="cls-1" x="54.6" y="6.31" width="3.4" height="5.25"/>
<rect class="cls-1" x="51.2" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="51.2" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="51.2" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="51.2" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="51.2" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="51.2" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="51.2" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="51.2" y="16.8" width="3.4" height="5.25"/>
<rect class="cls-1" x="51.2" y="6.31" width="3.4" height="5.25"/>
<rect class="cls-1" x="47.81" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="47.81" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="47.81" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="47.81" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="47.81" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="47.81" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="47.81" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="47.81" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="47.81" y="16.8" width="3.4" height="5.25"/>
<rect class="cls-1" x="44.41" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="44.41" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="44.41" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="44.41" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="44.41" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="44.41" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="44.41" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="44.41" y="27.29" width="3.4" height="5.25"/>
<rect class="cls-1" x="41.01" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="41.01" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="41.01" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="41.01" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="41.01" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="41.01" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="41.01" y="37.78" width="3.4" height="5.25"/>
<rect class="cls-1" x="37.61" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="37.61" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="37.61" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="37.61" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="37.61" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="37.61" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="37.61" y="48.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="34.21" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="34.21" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="34.21" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="34.21" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="34.21" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="34.21" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="34.21" y="58.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="30.81" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="30.81" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="30.81" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="30.81" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="30.81" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="30.81" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="30.81" y="69.3" width="3.4" height="5.25"/>
<rect class="cls-1" x="27.41" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="27.41" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="27.41" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="27.41" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="27.41" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="27.41" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="27.41" y="79.79" width="3.4" height="5.25"/>
<rect class="cls-1" x="24.01" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="24.01" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="24.01" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="24.01" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="24.01" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="24.01" y="90.28" width="3.4" height="5.25"/>
<rect class="cls-1" x="20.62" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="20.62" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="20.62" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="20.62" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="20.62" y="100.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="17.22" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="17.22" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="17.22" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="17.22" y="111.26" width="3.4" height="5.25"/>
<rect class="cls-1" x="13.82" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="13.82" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="13.82" y="121.75" width="3.4" height="5.25"/>
<rect class="cls-1" x="10.42" y="142.77" width="3.4" height="5.25"/>
<rect class="cls-1" x="10.42" y="132.24" width="3.4" height="5.25"/>
<rect class="cls-1" x="7.06" y="142.77" width="3.36" height="5.25"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,232 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 129.61 163.48">
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect y="157.08" width="125.66" height="1.05"/>
<rect y="146.6" width="125.66" height="1.05"/>
<rect y="136.13" width="125.66" height="1.05"/>
<rect y="125.66" width="125.66" height="1.05"/>
<rect y="115.19" width="125.66" height="1.05"/>
<rect y="104.72" width="125.66" height="1.05"/>
<rect y="94.25" width="125.66" height="1.05"/>
<rect y="83.77" width="125.66" height="1.05"/>
<rect y="73.3" width="125.66" height="1.05"/>
<rect y="62.83" width="125.66" height="1.05"/>
<rect y="52.36" width="125.66" height="1.05"/>
<rect y="41.89" width="125.66" height="1.05"/>
<rect y="31.42" width="125.66" height="1.05"/>
<rect y="20.94" width="125.66" height="1.05"/>
<rect y="10.47" width="125.66" height="1.05"/>
<rect width="125.66" height="1.05"/>
</g>
<g>
<rect x="114.29" y="121.36" width="5.02" height="5.25"/>
<rect x="114.29" y="110.9" width="5.02" height="5.25"/>
<rect x="114.29" y="100.45" width="5.02" height="5.25"/>
<rect x="109.27" y="131.85" width="5.02" height="5.21"/>
<rect x="109.27" y="121.36" width="5.02" height="5.25"/>
<rect x="109.27" y="110.9" width="5.02" height="5.25"/>
<rect x="109.27" y="100.45" width="5.02" height="5.25"/>
<rect x="109.27" y="89.99" width="5.02" height="5.21"/>
<rect x="109.27" y="48.14" width="5.02" height="5.21"/>
<rect x="109.27" y="37.68" width="5.02" height="5.21"/>
<rect x="109.27" y="27.19" width="5.02" height="5.25"/>
<rect x="104.21" y="131.85" width="5.06" height="5.21"/>
<rect x="104.21" y="121.36" width="5.06" height="5.25"/>
<rect x="104.21" y="110.9" width="5.06" height="5.25"/>
<rect x="104.21" y="100.45" width="5.06" height="5.25"/>
<rect x="104.21" y="89.99" width="5.06" height="5.21"/>
<rect x="104.21" y="58.59" width="5.06" height="5.25"/>
<rect x="104.21" y="48.14" width="5.06" height="5.21"/>
<rect x="104.21" y="37.68" width="5.06" height="5.21"/>
<rect x="104.21" y="27.19" width="5.06" height="5.25"/>
<rect x="104.21" y="16.74" width="5.06" height="5.25"/>
<rect x="99.18" y="142.3" width="5.02" height="5.25"/>
<rect x="99.18" y="131.85" width="5.02" height="5.21"/>
<rect x="99.18" y="121.36" width="5.02" height="5.25"/>
<rect x="99.18" y="110.9" width="5.02" height="5.25"/>
<rect x="99.18" y="100.45" width="5.02" height="5.25"/>
<rect x="99.18" y="89.99" width="5.02" height="5.21"/>
<rect x="99.18" y="79.54" width="5.02" height="5.21"/>
<rect x="99.18" y="58.59" width="5.02" height="5.25"/>
<rect x="99.18" y="48.14" width="5.02" height="5.21"/>
<rect x="99.18" y="37.68" width="5.02" height="5.21"/>
<rect x="99.18" y="27.19" width="5.02" height="5.25"/>
<rect x="99.18" y="16.74" width="5.02" height="5.25"/>
<rect x="94.16" y="142.3" width="5.02" height="5.25"/>
<rect x="94.16" y="131.85" width="5.02" height="5.21"/>
<rect x="94.16" y="121.36" width="5.02" height="5.25"/>
<rect x="94.16" y="110.9" width="5.02" height="5.25"/>
<rect x="94.16" y="100.45" width="5.02" height="5.25"/>
<rect x="94.16" y="89.99" width="5.02" height="5.21"/>
<rect x="94.16" y="79.54" width="5.02" height="5.21"/>
<rect x="94.16" y="69.05" width="5.02" height="5.25"/>
<rect x="94.16" y="58.59" width="5.02" height="5.25"/>
<rect x="94.16" y="48.14" width="5.02" height="5.21"/>
<rect x="94.16" y="37.68" width="5.02" height="5.21"/>
<rect x="94.16" y="27.19" width="5.02" height="5.25"/>
<rect x="94.16" y="16.74" width="5.02" height="5.25"/>
<rect x="94.16" y="6.28" width="5.02" height="5.25"/>
<rect x="89.13" y="142.3" width="5.02" height="5.25"/>
<rect x="89.13" y="131.85" width="5.02" height="5.21"/>
<rect x="89.13" y="121.36" width="5.02" height="5.25"/>
<rect x="89.13" y="110.9" width="5.02" height="5.25"/>
<rect x="89.13" y="100.45" width="5.02" height="5.25"/>
<rect x="89.13" y="89.99" width="5.02" height="5.21"/>
<rect x="89.13" y="79.54" width="5.02" height="5.21"/>
<rect x="89.13" y="69.05" width="5.02" height="5.25"/>
<rect x="89.13" y="58.59" width="5.02" height="5.25"/>
<rect x="89.13" y="48.14" width="5.02" height="5.21"/>
<rect x="89.13" y="37.68" width="5.02" height="5.21"/>
<rect x="89.13" y="27.19" width="5.02" height="5.25"/>
<rect x="89.13" y="16.74" width="5.02" height="5.25"/>
<rect x="89.13" y="6.28" width="5.02" height="5.25"/>
<rect x="84.11" y="142.3" width="5.02" height="5.25"/>
<rect x="84.11" y="131.85" width="5.02" height="5.21"/>
<rect x="84.11" y="121.36" width="5.02" height="5.25"/>
<rect x="84.11" y="89.99" width="5.02" height="5.21"/>
<rect x="84.11" y="79.54" width="5.02" height="5.21"/>
<rect x="84.11" y="69.05" width="5.02" height="5.25"/>
<rect x="84.11" y="58.59" width="5.02" height="5.25"/>
<rect x="84.11" y="48.14" width="5.02" height="5.21"/>
<rect x="84.11" y="37.68" width="5.02" height="5.21"/>
<rect x="84.11" y="27.19" width="5.02" height="5.25"/>
<rect x="84.11" y="16.74" width="5.02" height="5.25"/>
<rect x="84.11" y="6.28" width="5.02" height="5.25"/>
<rect x="79.09" y="142.3" width="5.02" height="5.25"/>
<rect x="79.09" y="131.85" width="5.02" height="5.21"/>
<rect x="79.09" y="79.54" width="5.02" height="5.21"/>
<rect x="79.09" y="69.05" width="5.02" height="5.25"/>
<rect x="79.09" y="58.59" width="5.02" height="5.25"/>
<rect x="79.09" y="27.19" width="5.02" height="5.25"/>
<rect x="79.09" y="16.74" width="5.02" height="5.25"/>
<rect x="79.09" y="6.28" width="5.02" height="5.25"/>
<rect x="74.06" y="142.3" width="5.02" height="5.25"/>
<rect x="74.06" y="131.85" width="5.02" height="5.21"/>
<rect x="74.06" y="79.54" width="5.02" height="5.21"/>
<rect x="74.06" y="69.05" width="5.02" height="5.25"/>
<rect x="74.06" y="16.74" width="5.02" height="5.25"/>
<rect x="74.06" y="6.28" width="5.02" height="5.25"/>
<rect x="69.04" y="142.3" width="5.02" height="5.25"/>
<rect x="69.04" y="131.85" width="5.02" height="5.21"/>
<rect x="69.04" y="79.54" width="5.02" height="5.21"/>
<rect x="69.04" y="69.05" width="5.02" height="5.25"/>
<rect x="69.04" y="16.74" width="5.02" height="5.25"/>
<rect x="69.04" y="6.28" width="5.02" height="5.25"/>
<rect x="64.01" y="142.3" width="5.02" height="5.25"/>
<rect x="64.01" y="131.85" width="5.02" height="5.21"/>
<rect x="64.01" y="79.54" width="5.02" height="5.21"/>
<rect x="64.01" y="69.05" width="5.02" height="5.25"/>
<rect x="64.01" y="16.74" width="5.02" height="5.25"/>
<rect x="64.01" y="6.28" width="5.02" height="5.25"/>
<rect x="58.99" y="142.3" width="5.02" height="5.25"/>
<rect x="58.99" y="131.85" width="5.02" height="5.21"/>
<rect x="58.99" y="79.54" width="5.02" height="5.21"/>
<rect x="58.99" y="69.05" width="5.02" height="5.25"/>
<rect x="58.99" y="16.74" width="5.02" height="5.25"/>
<rect x="58.99" y="6.28" width="5.02" height="5.25"/>
<rect x="53.96" y="142.3" width="5.02" height="5.25"/>
<rect x="53.96" y="131.85" width="5.02" height="5.21"/>
<rect x="53.96" y="79.54" width="5.02" height="5.21"/>
<rect x="53.96" y="69.05" width="5.02" height="5.25"/>
<rect x="53.96" y="16.74" width="5.02" height="5.25"/>
<rect x="53.96" y="6.28" width="5.02" height="5.25"/>
<rect x="48.94" y="142.3" width="5.02" height="5.25"/>
<rect x="48.94" y="131.85" width="5.02" height="5.21"/>
<rect x="48.94" y="79.54" width="5.02" height="5.21"/>
<rect x="48.94" y="69.05" width="5.02" height="5.25"/>
<rect x="48.94" y="16.74" width="5.02" height="5.25"/>
<rect x="48.94" y="6.28" width="5.02" height="5.25"/>
<rect x="43.92" y="142.3" width="5.02" height="5.25"/>
<rect x="43.92" y="131.85" width="5.02" height="5.21"/>
<rect x="43.92" y="79.54" width="5.02" height="5.21"/>
<rect x="43.92" y="69.05" width="5.02" height="5.25"/>
<rect x="43.92" y="16.74" width="5.02" height="5.25"/>
<rect x="43.92" y="6.28" width="5.02" height="5.25"/>
<rect x="38.89" y="142.3" width="5.02" height="5.25"/>
<rect x="38.89" y="131.85" width="5.02" height="5.21"/>
<rect x="38.89" y="121.36" width="5.02" height="5.25"/>
<rect x="38.89" y="110.9" width="5.02" height="5.25"/>
<rect x="38.89" y="100.45" width="5.02" height="5.25"/>
<rect x="38.89" y="89.99" width="5.02" height="5.21"/>
<rect x="38.89" y="79.54" width="5.02" height="5.21"/>
<rect x="38.89" y="69.05" width="5.02" height="5.25"/>
<rect x="38.89" y="58.59" width="5.02" height="5.25"/>
<rect x="38.89" y="48.14" width="5.02" height="5.21"/>
<rect x="38.89" y="37.68" width="5.02" height="5.21"/>
<rect x="38.89" y="27.19" width="5.02" height="5.25"/>
<rect x="38.89" y="16.74" width="5.02" height="5.25"/>
<rect x="38.89" y="6.28" width="5.02" height="5.25"/>
<rect x="33.87" y="142.3" width="5.02" height="5.25"/>
<rect x="33.87" y="131.85" width="5.02" height="5.21"/>
<rect x="33.87" y="121.36" width="5.02" height="5.25"/>
<rect x="33.87" y="110.9" width="5.02" height="5.25"/>
<rect x="33.87" y="100.45" width="5.02" height="5.25"/>
<rect x="33.87" y="89.99" width="5.02" height="5.21"/>
<rect x="33.87" y="79.54" width="5.02" height="5.21"/>
<rect x="33.87" y="69.05" width="5.02" height="5.25"/>
<rect x="33.87" y="58.59" width="5.02" height="5.25"/>
<rect x="33.87" y="48.14" width="5.02" height="5.21"/>
<rect x="33.87" y="37.68" width="5.02" height="5.21"/>
<rect x="33.87" y="27.19" width="5.02" height="5.25"/>
<rect x="33.87" y="16.74" width="5.02" height="5.25"/>
<rect x="33.87" y="6.28" width="5.02" height="5.25"/>
<rect x="28.84" y="142.3" width="5.02" height="5.25"/>
<rect x="28.84" y="131.85" width="5.02" height="5.21"/>
<rect x="28.84" y="121.36" width="5.02" height="5.25"/>
<rect x="28.84" y="110.9" width="5.02" height="5.25"/>
<rect x="28.84" y="100.45" width="5.02" height="5.25"/>
<rect x="28.84" y="89.99" width="5.02" height="5.21"/>
<rect x="28.84" y="79.54" width="5.02" height="5.21"/>
<rect x="28.84" y="69.05" width="5.02" height="5.25"/>
<rect x="28.84" y="58.59" width="5.02" height="5.25"/>
<rect x="28.84" y="48.14" width="5.02" height="5.21"/>
<rect x="28.84" y="37.68" width="5.02" height="5.21"/>
<rect x="28.84" y="27.19" width="5.02" height="5.25"/>
<rect x="28.84" y="16.74" width="5.02" height="5.25"/>
<rect x="28.84" y="6.28" width="5.02" height="5.25"/>
<rect x="23.78" y="142.3" width="5.06" height="5.25"/>
<rect x="23.78" y="131.85" width="5.06" height="5.21"/>
<rect x="23.78" y="121.36" width="5.06" height="5.25"/>
<rect x="23.78" y="110.9" width="5.06" height="5.25"/>
<rect x="23.78" y="100.45" width="5.06" height="5.25"/>
<rect x="23.78" y="89.99" width="5.06" height="5.21"/>
<rect x="23.78" y="79.54" width="5.06" height="5.21"/>
<rect x="23.78" y="69.05" width="5.06" height="5.25"/>
<rect x="23.78" y="58.59" width="5.06" height="5.25"/>
<rect x="23.78" y="48.14" width="5.06" height="5.21"/>
<rect x="23.78" y="37.68" width="5.06" height="5.21"/>
<rect x="23.78" y="27.19" width="5.06" height="5.25"/>
<rect x="23.78" y="16.74" width="5.06" height="5.25"/>
<rect x="23.78" y="6.28" width="5.06" height="5.25"/>
<rect x="18.76" y="142.3" width="5.02" height="5.25"/>
<rect x="18.76" y="131.85" width="5.02" height="5.21"/>
<rect x="18.76" y="121.36" width="5.02" height="5.25"/>
<rect x="18.76" y="110.9" width="5.02" height="5.25"/>
<rect x="18.76" y="100.45" width="5.02" height="5.25"/>
<rect x="18.76" y="89.99" width="5.02" height="5.21"/>
<rect x="18.76" y="79.54" width="5.02" height="5.21"/>
<rect x="18.76" y="69.05" width="5.02" height="5.25"/>
<rect x="18.76" y="58.59" width="5.02" height="5.25"/>
<rect x="18.76" y="48.14" width="5.02" height="5.21"/>
<rect x="18.76" y="37.68" width="5.02" height="5.21"/>
<rect x="18.76" y="27.19" width="5.02" height="5.25"/>
<rect x="18.76" y="16.74" width="5.02" height="5.25"/>
<rect x="18.76" y="6.28" width="5.02" height="5.25"/>
<rect x="13.73" y="142.3" width="5.02" height="5.25"/>
<rect x="13.73" y="131.85" width="5.02" height="5.21"/>
<rect x="13.73" y="121.36" width="5.02" height="5.25"/>
<rect x="13.73" y="110.9" width="5.02" height="5.25"/>
<rect x="13.73" y="100.45" width="5.02" height="5.25"/>
<rect x="13.73" y="89.99" width="5.02" height="5.21"/>
<rect x="13.73" y="79.54" width="5.02" height="5.21"/>
<rect x="13.73" y="69.05" width="5.02" height="5.25"/>
<rect x="13.73" y="58.59" width="5.02" height="5.25"/>
<rect x="13.73" y="48.14" width="5.02" height="5.21"/>
<rect x="13.73" y="37.68" width="5.02" height="5.21"/>
<rect x="13.73" y="27.19" width="5.02" height="5.25"/>
<rect x="13.73" y="16.74" width="5.02" height="5.25"/>
<rect x="13.73" y="6.28" width="5.02" height="5.25"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,243 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 129.61 163.63">
<defs>
<style>
.cls-1 {
fill: #ffdf07;
}
.cls-2 {
fill: #575756;
}
</style>
</defs>
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect class="cls-2" y="157.08" width="125.66" height="1.05"/>
<rect class="cls-2" y="146.6" width="125.66" height="1.05"/>
<rect class="cls-2" y="136.13" width="125.66" height="1.05"/>
<rect class="cls-2" y="125.66" width="125.66" height="1.05"/>
<rect class="cls-2" y="115.19" width="125.66" height="1.05"/>
<rect class="cls-2" y="104.72" width="125.66" height="1.05"/>
<rect class="cls-2" y="94.25" width="125.66" height="1.05"/>
<rect class="cls-2" y="83.77" width="125.66" height="1.05"/>
<rect class="cls-2" y="73.3" width="125.66" height="1.05"/>
<rect class="cls-2" y="62.83" width="125.66" height="1.05"/>
<rect class="cls-2" y="52.36" width="125.66" height="1.05"/>
<rect class="cls-2" y="41.89" width="125.66" height="1.05"/>
<rect class="cls-2" y="31.42" width="125.66" height="1.05"/>
<rect class="cls-2" y="20.94" width="125.66" height="1.05"/>
<rect class="cls-2" y="10.47" width="125.66" height="1.05"/>
<rect class="cls-2" width="125.66" height="1.05"/>
</g>
<g>
<rect class="cls-1" x="114.27" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="114.27" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="114.27" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="109.24" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="109.24" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="109.24" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="109.24" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="109.24" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="109.24" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="109.24" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="109.24" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="104.22" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="104.22" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="104.22" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="104.22" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="104.22" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="104.22" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="104.22" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="104.22" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="104.22" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="104.22" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="99.19" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="99.19" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="99.19" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="99.19" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="99.19" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="99.19" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="99.19" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="99.19" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="99.19" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="99.19" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="99.19" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="99.19" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="94.17" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="94.17" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="94.17" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="94.17" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="94.17" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="94.17" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="94.17" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="94.17" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="94.17" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="94.17" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="94.17" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="94.17" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="94.17" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="94.17" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="89.15" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="89.15" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="89.15" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="89.15" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="89.15" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="89.15" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="89.15" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="89.15" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="89.15" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="89.15" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="89.15" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="89.15" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="89.15" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="89.15" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="84.12" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="84.12" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="84.12" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="84.12" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="84.12" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="84.12" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="84.12" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="84.12" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="84.12" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="84.12" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="84.12" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="84.12" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="79.1" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="79.1" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="79.1" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="79.1" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="79.1" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="79.1" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="79.1" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="79.1" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="74.07" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="74.07" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="74.07" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="74.07" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="74.07" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="74.07" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="69.05" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="69.05" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="69.05" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="69.05" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="69.05" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="69.05" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="64.03" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="64.03" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="64.03" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="64.03" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="64.03" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="64.03" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="59" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="59" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="59" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="59" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="59" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="59" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="53.98" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="53.98" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="53.98" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="53.98" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="53.98" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="53.98" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="48.92" y="142.45" width="5.06" height="5.25"/>
<rect class="cls-1" x="48.92" y="131.99" width="5.06" height="5.21"/>
<rect class="cls-1" x="48.92" y="79.68" width="5.06" height="5.21"/>
<rect class="cls-1" x="48.92" y="69.19" width="5.06" height="5.25"/>
<rect class="cls-1" x="48.92" y="16.88" width="5.06" height="5.25"/>
<rect class="cls-1" x="48.92" y="6.43" width="5.06" height="5.25"/>
<rect class="cls-1" x="43.89" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="43.89" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="43.89" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="43.89" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="43.89" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="43.89" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="38.87" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="38.87" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="38.87" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="38.87" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="38.87" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="38.87" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="38.87" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="38.87" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="38.87" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="38.87" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="38.87" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="38.87" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="38.87" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="38.87" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="33.84" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="33.84" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="33.84" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="33.84" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="33.84" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="33.84" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="33.84" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="33.84" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="33.84" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="33.84" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="33.84" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="33.84" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="33.84" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="33.84" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="28.82" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="28.82" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="28.82" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="28.82" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="28.82" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="28.82" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="28.82" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="28.82" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="28.82" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="28.82" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="28.82" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="28.82" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="28.82" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="28.82" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="23.8" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="23.8" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="23.8" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="23.8" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="23.8" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="23.8" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="23.8" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="23.8" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="23.8" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="23.8" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="23.8" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="23.8" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="23.8" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="23.8" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="18.77" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="18.77" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="18.77" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="18.77" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="18.77" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="18.77" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="18.77" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="18.77" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="18.77" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="18.77" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="18.77" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="18.77" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="18.77" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="18.77" y="6.43" width="5.02" height="5.25"/>
<rect class="cls-1" x="13.75" y="142.45" width="5.02" height="5.25"/>
<rect class="cls-1" x="13.75" y="131.99" width="5.02" height="5.21"/>
<rect class="cls-1" x="13.75" y="121.54" width="5.02" height="5.21"/>
<rect class="cls-1" x="13.75" y="111.05" width="5.02" height="5.25"/>
<rect class="cls-1" x="13.75" y="100.59" width="5.02" height="5.25"/>
<rect class="cls-1" x="13.75" y="90.14" width="5.02" height="5.21"/>
<rect class="cls-1" x="13.75" y="79.68" width="5.02" height="5.21"/>
<rect class="cls-1" x="13.75" y="69.19" width="5.02" height="5.25"/>
<rect class="cls-1" x="13.75" y="58.74" width="5.02" height="5.25"/>
<rect class="cls-1" x="13.75" y="48.28" width="5.02" height="5.25"/>
<rect class="cls-1" x="13.75" y="37.83" width="5.02" height="5.21"/>
<rect class="cls-1" x="13.75" y="27.34" width="5.02" height="5.25"/>
<rect class="cls-1" x="13.75" y="16.88" width="5.02" height="5.25"/>
<rect class="cls-1" x="13.75" y="6.43" width="5.02" height="5.25"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,252 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.16 163.48">
<defs>
<style>
.cls-1 {
fill: #1d1d1b;
}
.cls-2 {
fill: #575756;
}
</style>
</defs>
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect class="cls-2" x=".25" y="157.08" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="146.6" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="136.13" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="125.66" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="115.19" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="104.72" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="94.25" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="83.77" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="73.3" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="62.83" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="52.36" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="41.89" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="31.42" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="20.94" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" y="10.47" width="125.66" height="1.05"/>
<rect class="cls-2" x=".25" width="125.66" height="1.05"/>
</g>
<g>
<rect class="cls-1" x="110.84" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.84" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.84" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="110.84" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="110.84" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.84" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.84" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="110.84" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="110.84" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.84" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.82" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.82" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.82" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.82" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.82" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.82" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.82" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.82" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.82" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.82" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.82" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.82" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.79" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.79" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.79" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.79" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.79" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.79" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.79" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.79" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.79" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.79" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.79" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.79" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.77" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.77" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.77" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.77" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.77" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.77" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.77" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.77" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.77" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.77" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.77" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.77" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.77" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.77" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.74" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.74" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.74" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.74" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.74" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.74" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.74" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.74" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.74" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.74" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.74" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.74" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.74" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.74" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="85.68" y="142.31" width="5.06" height="5.21"/>
<rect class="cls-1" x="85.68" y="131.82" width="5.06" height="5.25"/>
<rect class="cls-1" x="85.68" y="121.37" width="5.06" height="5.25"/>
<rect class="cls-1" x="85.68" y="110.91" width="5.06" height="5.25"/>
<rect class="cls-1" x="85.68" y="100.46" width="5.06" height="5.21"/>
<rect class="cls-1" x="85.68" y="90" width="5.06" height="5.21"/>
<rect class="cls-1" x="85.68" y="79.51" width="5.06" height="5.25"/>
<rect class="cls-1" x="85.68" y="69.06" width="5.06" height="5.25"/>
<rect class="cls-1" x="85.68" y="58.6" width="5.06" height="5.21"/>
<rect class="cls-1" x="85.68" y="48.15" width="5.06" height="5.21"/>
<rect class="cls-1" x="85.68" y="37.65" width="5.06" height="5.25"/>
<rect class="cls-1" x="85.68" y="27.2" width="5.06" height="5.25"/>
<rect class="cls-1" x="85.68" y="16.75" width="5.06" height="5.25"/>
<rect class="cls-1" x="85.68" y="6.29" width="5.06" height="5.21"/>
<rect class="cls-1" x="80.66" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="80.66" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.66" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.66" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.66" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.66" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.66" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.66" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="75.63" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="75.63" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.63" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.63" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.63" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.63" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="70.61" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="70.61" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.61" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.61" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.61" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.61" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="65.59" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="65.59" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="65.59" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="65.59" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="65.59" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="65.59" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="60.56" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="60.56" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.56" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.56" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.56" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.56" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="55.54" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="55.54" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.54" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.54" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.54" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.54" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="50.51" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="50.51" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.51" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.51" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.51" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.51" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="45.49" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="45.49" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="45.49" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="45.49" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="45.49" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="45.49" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="40.47" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="40.47" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.47" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.47" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.47" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.47" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.44" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.44" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.44" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.44" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.44" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.44" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.44" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.44" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.44" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.44" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.44" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.44" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.44" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.44" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.42" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.42" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.42" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.42" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.42" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.42" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.42" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.42" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.42" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.42" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.42" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.42" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.42" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.42" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.39" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.39" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.39" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.39" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.39" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.39" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.39" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.39" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.39" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.39" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.39" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.39" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.39" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.39" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.37" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.37" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.37" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.37" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.37" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.37" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.37" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.37" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.37" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.37" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.37" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.37" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.37" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.37" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.35" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.35" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.35" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.35" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.35" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.35" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.35" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.35" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.35" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.35" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.35" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.35" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.35" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.35" y="6.29" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.32" y="142.31" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.32" y="131.82" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.32" y="121.37" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.32" y="110.91" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.32" y="100.46" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.32" y="90" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.32" y="79.51" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.32" y="69.06" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.32" y="58.6" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.32" y="48.15" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.32" y="37.65" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.32" y="27.2" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.32" y="16.75" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.32" y="6.29" width="5.02" height="5.21"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.16 163.48">
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect x=".25" y="157.08" width="125.66" height="1.05"/>
<rect x=".25" y="146.6" width="125.66" height="1.05"/>
<rect x=".25" y="136.13" width="125.66" height="1.05"/>
<rect x=".25" y="125.66" width="125.66" height="1.05"/>
<rect x=".25" y="115.19" width="125.66" height="1.05"/>
<rect x=".25" y="104.72" width="125.66" height="1.05"/>
<rect x=".25" y="94.25" width="125.66" height="1.05"/>
<rect x=".25" y="83.77" width="125.66" height="1.05"/>
<rect x=".25" y="73.3" width="125.66" height="1.05"/>
<rect x=".25" y="62.83" width="125.66" height="1.05"/>
<rect x=".25" y="52.36" width="125.66" height="1.05"/>
<rect x=".25" y="41.89" width="125.66" height="1.05"/>
<rect x=".25" y="31.42" width="125.66" height="1.05"/>
<rect x=".25" y="20.94" width="125.66" height="1.05"/>
<rect x=".25" y="10.47" width="125.66" height="1.05"/>
<rect x=".25" width="125.66" height="1.05"/>
</g>
<g>
<rect x="110.85" y="27.19" width="5.02" height="5.25"/>
<rect x="110.85" y="16.73" width="5.02" height="5.25"/>
<rect x="110.85" y="6.28" width="5.02" height="5.25"/>
<rect x="105.82" y="27.19" width="5.02" height="5.25"/>
<rect x="105.82" y="16.73" width="5.02" height="5.25"/>
<rect x="105.82" y="6.28" width="5.02" height="5.25"/>
<rect x="100.8" y="27.19" width="5.02" height="5.25"/>
<rect x="100.8" y="16.73" width="5.02" height="5.25"/>
<rect x="100.8" y="6.28" width="5.02" height="5.25"/>
<rect x="95.74" y="27.19" width="5.06" height="5.25"/>
<rect x="95.74" y="16.73" width="5.06" height="5.25"/>
<rect x="95.74" y="6.28" width="5.06" height="5.25"/>
<rect x="90.71" y="27.19" width="5.02" height="5.25"/>
<rect x="90.71" y="16.73" width="5.02" height="5.25"/>
<rect x="90.71" y="6.28" width="5.02" height="5.25"/>
<rect x="85.69" y="27.19" width="5.02" height="5.25"/>
<rect x="85.69" y="16.73" width="5.02" height="5.25"/>
<rect x="85.69" y="6.28" width="5.02" height="5.25"/>
<rect x="80.66" y="27.19" width="5.02" height="5.25"/>
<rect x="80.66" y="16.73" width="5.02" height="5.25"/>
<rect x="80.66" y="6.28" width="5.02" height="5.25"/>
<rect x="75.64" y="69.04" width="5.02" height="5.25"/>
<rect x="75.64" y="58.59" width="5.02" height="5.25"/>
<rect x="75.64" y="27.19" width="5.02" height="5.25"/>
<rect x="75.64" y="16.73" width="5.02" height="5.25"/>
<rect x="75.64" y="6.28" width="5.02" height="5.25"/>
<rect x="70.62" y="69.04" width="5.02" height="5.25"/>
<rect x="70.62" y="58.59" width="5.02" height="5.25"/>
<rect x="70.62" y="27.19" width="5.02" height="5.25"/>
<rect x="70.62" y="16.73" width="5.02" height="5.25"/>
<rect x="70.62" y="6.28" width="5.02" height="5.25"/>
<rect x="65.59" y="69.04" width="5.02" height="5.25"/>
<rect x="65.59" y="58.59" width="5.02" height="5.25"/>
<rect x="65.59" y="27.19" width="5.02" height="5.25"/>
<rect x="65.59" y="16.73" width="5.02" height="5.25"/>
<rect x="65.59" y="6.28" width="5.02" height="5.25"/>
<rect x="60.57" y="69.04" width="5.02" height="5.25"/>
<rect x="60.57" y="58.59" width="5.02" height="5.25"/>
<rect x="60.57" y="27.19" width="5.02" height="5.25"/>
<rect x="60.57" y="16.73" width="5.02" height="5.25"/>
<rect x="60.57" y="6.28" width="5.02" height="5.25"/>
<rect x="55.54" y="69.04" width="5.02" height="5.25"/>
<rect x="55.54" y="58.59" width="5.02" height="5.25"/>
<rect x="55.54" y="27.19" width="5.02" height="5.25"/>
<rect x="55.54" y="16.73" width="5.02" height="5.25"/>
<rect x="55.54" y="6.28" width="5.02" height="5.25"/>
<rect x="50.52" y="69.04" width="5.02" height="5.25"/>
<rect x="50.52" y="58.59" width="5.02" height="5.25"/>
<rect x="50.52" y="27.19" width="5.02" height="5.25"/>
<rect x="50.52" y="16.73" width="5.02" height="5.25"/>
<rect x="50.52" y="6.28" width="5.02" height="5.25"/>
<rect x="45.49" y="69.04" width="5.02" height="5.25"/>
<rect x="45.49" y="58.59" width="5.02" height="5.25"/>
<rect x="45.49" y="27.19" width="5.02" height="5.25"/>
<rect x="45.49" y="16.73" width="5.02" height="5.25"/>
<rect x="45.49" y="6.28" width="5.02" height="5.25"/>
<rect x="40.47" y="69.04" width="5.02" height="5.25"/>
<rect x="40.47" y="58.59" width="5.02" height="5.25"/>
<rect x="40.47" y="27.19" width="5.02" height="5.25"/>
<rect x="40.47" y="16.73" width="5.02" height="5.25"/>
<rect x="40.47" y="6.28" width="5.02" height="5.25"/>
<rect x="35.45" y="142.3" width="5.02" height="5.25"/>
<rect x="35.45" y="131.85" width="5.02" height="5.21"/>
<rect x="35.45" y="121.39" width="5.02" height="5.21"/>
<rect x="35.45" y="110.9" width="5.02" height="5.25"/>
<rect x="35.45" y="100.44" width="5.02" height="5.25"/>
<rect x="35.45" y="89.99" width="5.02" height="5.21"/>
<rect x="35.45" y="79.54" width="5.02" height="5.21"/>
<rect x="35.45" y="69.04" width="5.02" height="5.25"/>
<rect x="35.45" y="58.59" width="5.02" height="5.25"/>
<rect x="35.45" y="48.13" width="5.02" height="5.25"/>
<rect x="35.45" y="37.68" width="5.02" height="5.21"/>
<rect x="35.45" y="27.19" width="5.02" height="5.25"/>
<rect x="35.45" y="16.73" width="5.02" height="5.25"/>
<rect x="35.45" y="6.28" width="5.02" height="5.25"/>
<rect x="30.42" y="142.3" width="5.02" height="5.25"/>
<rect x="30.42" y="131.85" width="5.02" height="5.21"/>
<rect x="30.42" y="121.39" width="5.02" height="5.21"/>
<rect x="30.42" y="110.9" width="5.02" height="5.25"/>
<rect x="30.42" y="100.44" width="5.02" height="5.25"/>
<rect x="30.42" y="89.99" width="5.02" height="5.21"/>
<rect x="30.42" y="79.54" width="5.02" height="5.21"/>
<rect x="30.42" y="69.04" width="5.02" height="5.25"/>
<rect x="30.42" y="58.59" width="5.02" height="5.25"/>
<rect x="30.42" y="48.13" width="5.02" height="5.25"/>
<rect x="30.42" y="37.68" width="5.02" height="5.21"/>
<rect x="30.42" y="27.19" width="5.02" height="5.25"/>
<rect x="30.42" y="16.73" width="5.02" height="5.25"/>
<rect x="30.42" y="6.28" width="5.02" height="5.25"/>
<rect x="25.4" y="142.3" width="5.02" height="5.25"/>
<rect x="25.4" y="131.85" width="5.02" height="5.21"/>
<rect x="25.4" y="121.39" width="5.02" height="5.21"/>
<rect x="25.4" y="110.9" width="5.02" height="5.25"/>
<rect x="25.4" y="100.44" width="5.02" height="5.25"/>
<rect x="25.4" y="89.99" width="5.02" height="5.21"/>
<rect x="25.4" y="79.54" width="5.02" height="5.21"/>
<rect x="25.4" y="69.04" width="5.02" height="5.25"/>
<rect x="25.4" y="58.59" width="5.02" height="5.25"/>
<rect x="25.4" y="48.13" width="5.02" height="5.25"/>
<rect x="25.4" y="37.68" width="5.02" height="5.21"/>
<rect x="25.4" y="27.19" width="5.02" height="5.25"/>
<rect x="25.4" y="16.73" width="5.02" height="5.25"/>
<rect x="25.4" y="6.28" width="5.02" height="5.25"/>
<rect x="20.34" y="142.3" width="5.06" height="5.25"/>
<rect x="20.34" y="131.85" width="5.06" height="5.21"/>
<rect x="20.34" y="121.39" width="5.06" height="5.21"/>
<rect x="20.34" y="110.9" width="5.06" height="5.25"/>
<rect x="20.34" y="100.44" width="5.06" height="5.25"/>
<rect x="20.34" y="89.99" width="5.06" height="5.21"/>
<rect x="20.34" y="79.54" width="5.06" height="5.21"/>
<rect x="20.34" y="69.04" width="5.06" height="5.25"/>
<rect x="20.34" y="58.59" width="5.06" height="5.25"/>
<rect x="20.34" y="48.13" width="5.06" height="5.25"/>
<rect x="20.34" y="37.68" width="5.06" height="5.21"/>
<rect x="20.34" y="27.19" width="5.06" height="5.25"/>
<rect x="20.34" y="16.73" width="5.06" height="5.25"/>
<rect x="20.34" y="6.28" width="5.06" height="5.25"/>
<rect x="15.31" y="142.3" width="5.02" height="5.25"/>
<rect x="15.31" y="131.85" width="5.02" height="5.21"/>
<rect x="15.31" y="121.39" width="5.02" height="5.21"/>
<rect x="15.31" y="110.9" width="5.02" height="5.25"/>
<rect x="15.31" y="100.44" width="5.02" height="5.25"/>
<rect x="15.31" y="89.99" width="5.02" height="5.21"/>
<rect x="15.31" y="79.54" width="5.02" height="5.21"/>
<rect x="15.31" y="69.04" width="5.02" height="5.25"/>
<rect x="15.31" y="58.59" width="5.02" height="5.25"/>
<rect x="15.31" y="48.13" width="5.02" height="5.25"/>
<rect x="15.31" y="37.68" width="5.02" height="5.21"/>
<rect x="15.31" y="27.19" width="5.02" height="5.25"/>
<rect x="15.31" y="16.73" width="5.02" height="5.25"/>
<rect x="15.31" y="6.28" width="5.02" height="5.25"/>
<rect x="10.29" y="142.3" width="5.02" height="5.25"/>
<rect x="10.29" y="131.85" width="5.02" height="5.21"/>
<rect x="10.29" y="121.39" width="5.02" height="5.21"/>
<rect x="10.29" y="110.9" width="5.02" height="5.25"/>
<rect x="10.29" y="100.44" width="5.02" height="5.25"/>
<rect x="10.29" y="89.99" width="5.02" height="5.21"/>
<rect x="10.29" y="79.54" width="5.02" height="5.21"/>
<rect x="10.29" y="69.04" width="5.02" height="5.25"/>
<rect x="10.29" y="58.59" width="5.02" height="5.25"/>
<rect x="10.29" y="48.13" width="5.02" height="5.25"/>
<rect x="10.29" y="37.68" width="5.02" height="5.21"/>
<rect x="10.29" y="27.19" width="5.02" height="5.25"/>
<rect x="10.29" y="16.73" width="5.02" height="5.25"/>
<rect x="10.29" y="6.28" width="5.02" height="5.25"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -0,0 +1,181 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.16 163.62">
<defs>
<style>
.cls-1 {
fill: #ffdf07;
}
.cls-2 {
fill: #575756;
}
</style>
</defs>
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect class="cls-2" x=".27" y="157.08" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="146.6" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="136.13" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="125.66" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="115.19" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="104.72" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="94.25" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="83.77" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="73.3" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="62.83" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="52.36" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="41.89" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="31.42" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="20.94" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" y="10.47" width="125.66" height="1.05"/>
<rect class="cls-2" x=".27" width="125.66" height="1.05"/>
</g>
<g>
<rect class="cls-1" x="110.82" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="110.82" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="110.82" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.8" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="105.8" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="105.8" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.77" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="100.77" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="100.77" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.75" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="95.75" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="95.75" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.72" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="90.72" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="90.72" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.7" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="85.7" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="85.7" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.68" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="80.68" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="80.68" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.65" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.65" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.65" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="75.65" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="75.65" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.63" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.63" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.63" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="70.63" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="70.63" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="65.6" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="65.6" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="65.6" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="65.6" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="65.6" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.58" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.58" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.58" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="60.58" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="60.58" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.56" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.56" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.56" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="55.56" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="55.56" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.53" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.53" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.53" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="50.53" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="50.53" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="45.47" y="69.18" width="5.06" height="5.25"/>
<rect class="cls-1" x="45.47" y="58.73" width="5.06" height="5.25"/>
<rect class="cls-1" x="45.47" y="27.36" width="5.06" height="5.21"/>
<rect class="cls-1" x="45.47" y="16.87" width="5.06" height="5.25"/>
<rect class="cls-1" x="45.47" y="6.42" width="5.06" height="5.25"/>
<rect class="cls-1" x="40.45" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.45" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.45" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="40.45" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="40.45" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.42" y="142.44" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.42" y="131.98" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.42" y="121.53" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.42" y="111.04" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.42" y="100.58" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.42" y="90.13" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.42" y="79.67" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.42" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.42" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.42" y="48.27" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.42" y="37.82" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.42" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="35.42" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="35.42" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.4" y="142.44" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.4" y="131.98" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.4" y="121.53" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.4" y="111.04" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.4" y="100.58" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.4" y="90.13" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.4" y="79.67" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.4" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.4" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.4" y="48.27" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.4" y="37.82" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.4" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="30.4" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="30.4" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.37" y="142.44" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.37" y="131.98" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.37" y="121.53" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.37" y="111.04" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.37" y="100.58" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.37" y="90.13" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.37" y="79.67" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.37" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.37" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.37" y="48.27" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.37" y="37.82" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.37" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="25.37" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="25.37" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.35" y="142.44" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.35" y="131.98" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.35" y="121.53" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.35" y="111.04" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.35" y="100.58" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.35" y="90.13" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.35" y="79.67" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.35" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.35" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.35" y="48.27" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.35" y="37.82" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.35" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="20.35" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="20.35" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.33" y="142.44" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.33" y="131.98" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.33" y="121.53" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.33" y="111.04" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.33" y="100.58" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.33" y="90.13" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.33" y="79.67" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.33" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.33" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.33" y="48.27" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.33" y="37.82" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.33" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="15.33" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="15.33" y="6.42" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.3" y="142.44" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.3" y="131.98" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.3" y="121.53" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.3" y="111.04" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.3" y="100.58" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.3" y="90.13" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.3" y="79.67" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.3" y="69.18" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.3" y="58.73" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.3" y="48.27" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.3" y="37.82" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.3" y="27.36" width="5.02" height="5.21"/>
<rect class="cls-1" x="10.3" y="16.87" width="5.02" height="5.25"/>
<rect class="cls-1" x="10.3" y="6.42" width="5.02" height="5.25"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,241 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Livello_2" data-name="Livello 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 126.16 163.48">
<g id="Livello_1-2" data-name="Livello 1">
<g>
<rect x=".25" y="157.08" width="125.66" height="1.05"/>
<rect x=".25" y="146.6" width="125.66" height="1.05"/>
<rect x=".25" y="136.13" width="125.66" height="1.05"/>
<rect x=".25" y="125.66" width="125.66" height="1.05"/>
<rect x=".25" y="115.19" width="125.66" height="1.05"/>
<rect x=".25" y="104.72" width="125.66" height="1.05"/>
<rect x=".25" y="94.25" width="125.66" height="1.05"/>
<rect x=".25" y="83.77" width="125.66" height="1.05"/>
<rect x=".25" y="73.3" width="125.66" height="1.05"/>
<rect x=".25" y="62.83" width="125.66" height="1.05"/>
<rect x=".25" y="52.36" width="125.66" height="1.05"/>
<rect x=".25" y="41.89" width="125.66" height="1.05"/>
<rect x=".25" y="31.42" width="125.66" height="1.05"/>
<rect x=".25" y="20.94" width="125.66" height="1.05"/>
<rect x=".25" y="10.47" width="125.66" height="1.05"/>
<rect x=".25" width="125.66" height="1.05"/>
</g>
<g>
<rect x="110.83" y="121.37" width="5.02" height="5.25"/>
<rect x="110.83" y="110.91" width="5.02" height="5.25"/>
<rect x="110.83" y="100.46" width="5.02" height="5.21"/>
<rect x="110.83" y="89.97" width="5.02" height="5.25"/>
<rect x="110.83" y="79.51" width="5.02" height="5.25"/>
<rect x="110.83" y="69.06" width="5.02" height="5.25"/>
<rect x="110.83" y="58.6" width="5.02" height="5.21"/>
<rect x="110.83" y="48.15" width="5.02" height="5.21"/>
<rect x="110.83" y="37.66" width="5.02" height="5.25"/>
<rect x="110.83" y="27.2" width="5.02" height="5.25"/>
<rect x="105.8" y="131.82" width="5.02" height="5.25"/>
<rect x="105.8" y="121.37" width="5.02" height="5.25"/>
<rect x="105.8" y="110.91" width="5.02" height="5.25"/>
<rect x="105.8" y="100.46" width="5.02" height="5.21"/>
<rect x="105.8" y="89.97" width="5.02" height="5.25"/>
<rect x="105.8" y="79.51" width="5.02" height="5.25"/>
<rect x="105.8" y="69.06" width="5.02" height="5.25"/>
<rect x="105.8" y="58.6" width="5.02" height="5.21"/>
<rect x="105.8" y="48.15" width="5.02" height="5.21"/>
<rect x="105.8" y="37.66" width="5.02" height="5.25"/>
<rect x="105.8" y="27.2" width="5.02" height="5.25"/>
<rect x="105.8" y="16.75" width="5.02" height="5.21"/>
<rect x="100.78" y="131.82" width="5.02" height="5.25"/>
<rect x="100.78" y="121.37" width="5.02" height="5.25"/>
<rect x="100.78" y="110.91" width="5.02" height="5.25"/>
<rect x="100.78" y="100.46" width="5.02" height="5.21"/>
<rect x="100.78" y="89.97" width="5.02" height="5.25"/>
<rect x="100.78" y="79.51" width="5.02" height="5.25"/>
<rect x="100.78" y="69.06" width="5.02" height="5.25"/>
<rect x="100.78" y="58.6" width="5.02" height="5.21"/>
<rect x="100.78" y="48.15" width="5.02" height="5.21"/>
<rect x="100.78" y="37.66" width="5.02" height="5.25"/>
<rect x="100.78" y="27.2" width="5.02" height="5.25"/>
<rect x="100.78" y="16.75" width="5.02" height="5.21"/>
<rect x="95.76" y="142.31" width="5.02" height="5.21"/>
<rect x="95.76" y="131.82" width="5.02" height="5.25"/>
<rect x="95.76" y="121.37" width="5.02" height="5.25"/>
<rect x="95.76" y="110.91" width="5.02" height="5.25"/>
<rect x="95.76" y="100.46" width="5.02" height="5.21"/>
<rect x="95.76" y="89.97" width="5.02" height="5.25"/>
<rect x="95.76" y="79.51" width="5.02" height="5.25"/>
<rect x="95.76" y="69.06" width="5.02" height="5.25"/>
<rect x="95.76" y="58.6" width="5.02" height="5.21"/>
<rect x="95.76" y="48.15" width="5.02" height="5.21"/>
<rect x="95.76" y="37.66" width="5.02" height="5.25"/>
<rect x="95.76" y="27.2" width="5.02" height="5.25"/>
<rect x="95.76" y="16.75" width="5.02" height="5.21"/>
<rect x="95.76" y="6.29" width="5.02" height="5.21"/>
<rect x="90.73" y="142.31" width="5.02" height="5.21"/>
<rect x="90.73" y="131.82" width="5.02" height="5.25"/>
<rect x="90.73" y="121.37" width="5.02" height="5.25"/>
<rect x="90.73" y="110.91" width="5.02" height="5.25"/>
<rect x="90.73" y="100.46" width="5.02" height="5.21"/>
<rect x="90.73" y="89.97" width="5.02" height="5.25"/>
<rect x="90.73" y="79.51" width="5.02" height="5.25"/>
<rect x="90.73" y="69.06" width="5.02" height="5.25"/>
<rect x="90.73" y="58.6" width="5.02" height="5.21"/>
<rect x="90.73" y="48.15" width="5.02" height="5.21"/>
<rect x="90.73" y="37.66" width="5.02" height="5.25"/>
<rect x="90.73" y="27.2" width="5.02" height="5.25"/>
<rect x="90.73" y="16.75" width="5.02" height="5.21"/>
<rect x="90.73" y="6.29" width="5.02" height="5.21"/>
<rect x="85.71" y="142.31" width="5.02" height="5.21"/>
<rect x="85.71" y="131.82" width="5.02" height="5.25"/>
<rect x="85.71" y="121.37" width="5.02" height="5.25"/>
<rect x="85.71" y="110.91" width="5.02" height="5.25"/>
<rect x="85.71" y="100.46" width="5.02" height="5.21"/>
<rect x="85.71" y="89.97" width="5.02" height="5.25"/>
<rect x="85.71" y="79.51" width="5.02" height="5.25"/>
<rect x="85.71" y="69.06" width="5.02" height="5.25"/>
<rect x="85.71" y="58.6" width="5.02" height="5.21"/>
<rect x="85.71" y="48.15" width="5.02" height="5.21"/>
<rect x="85.71" y="37.66" width="5.02" height="5.25"/>
<rect x="85.71" y="27.2" width="5.02" height="5.25"/>
<rect x="85.71" y="16.75" width="5.02" height="5.21"/>
<rect x="85.71" y="6.29" width="5.02" height="5.21"/>
<rect x="80.68" y="142.31" width="5.02" height="5.21"/>
<rect x="80.68" y="131.82" width="5.02" height="5.25"/>
<rect x="80.68" y="121.37" width="5.02" height="5.25"/>
<rect x="80.68" y="110.91" width="5.02" height="5.25"/>
<rect x="80.68" y="37.66" width="5.02" height="5.25"/>
<rect x="80.68" y="27.2" width="5.02" height="5.25"/>
<rect x="80.68" y="16.75" width="5.02" height="5.21"/>
<rect x="80.68" y="6.29" width="5.02" height="5.21"/>
<rect x="75.66" y="142.31" width="5.02" height="5.21"/>
<rect x="75.66" y="131.82" width="5.02" height="5.25"/>
<rect x="75.66" y="121.37" width="5.02" height="5.25"/>
<rect x="75.66" y="27.2" width="5.02" height="5.25"/>
<rect x="75.66" y="16.75" width="5.02" height="5.21"/>
<rect x="75.66" y="6.29" width="5.02" height="5.21"/>
<rect x="70.64" y="142.31" width="5.02" height="5.21"/>
<rect x="70.64" y="131.82" width="5.02" height="5.25"/>
<rect x="70.64" y="121.37" width="5.02" height="5.25"/>
<rect x="70.64" y="27.2" width="5.02" height="5.25"/>
<rect x="70.64" y="16.75" width="5.02" height="5.21"/>
<rect x="70.64" y="6.29" width="5.02" height="5.21"/>
<rect x="65.61" y="142.31" width="5.02" height="5.21"/>
<rect x="65.61" y="131.82" width="5.02" height="5.25"/>
<rect x="65.61" y="121.37" width="5.02" height="5.25"/>
<rect x="65.61" y="27.2" width="5.02" height="5.25"/>
<rect x="65.61" y="16.75" width="5.02" height="5.21"/>
<rect x="65.61" y="6.29" width="5.02" height="5.21"/>
<rect x="60.55" y="142.31" width="5.06" height="5.21"/>
<rect x="60.55" y="131.82" width="5.06" height="5.25"/>
<rect x="60.55" y="121.37" width="5.06" height="5.25"/>
<rect x="60.55" y="27.2" width="5.06" height="5.25"/>
<rect x="60.55" y="16.75" width="5.06" height="5.21"/>
<rect x="60.55" y="6.29" width="5.06" height="5.21"/>
<rect x="55.53" y="142.31" width="5.02" height="5.21"/>
<rect x="55.53" y="131.82" width="5.02" height="5.25"/>
<rect x="55.53" y="121.37" width="5.02" height="5.25"/>
<rect x="55.53" y="27.2" width="5.02" height="5.25"/>
<rect x="55.53" y="16.75" width="5.02" height="5.21"/>
<rect x="55.53" y="6.29" width="5.02" height="5.21"/>
<rect x="50.5" y="142.31" width="5.02" height="5.21"/>
<rect x="50.5" y="131.82" width="5.02" height="5.25"/>
<rect x="50.5" y="121.37" width="5.02" height="5.25"/>
<rect x="50.5" y="27.2" width="5.02" height="5.25"/>
<rect x="50.5" y="16.75" width="5.02" height="5.21"/>
<rect x="50.5" y="6.29" width="5.02" height="5.21"/>
<rect x="45.48" y="142.31" width="5.02" height="5.21"/>
<rect x="45.48" y="131.82" width="5.02" height="5.25"/>
<rect x="45.48" y="121.37" width="5.02" height="5.25"/>
<rect x="45.48" y="27.2" width="5.02" height="5.25"/>
<rect x="45.48" y="16.75" width="5.02" height="5.21"/>
<rect x="45.48" y="6.29" width="5.02" height="5.21"/>
<rect x="40.45" y="142.31" width="5.02" height="5.21"/>
<rect x="40.45" y="131.82" width="5.02" height="5.25"/>
<rect x="40.45" y="121.37" width="5.02" height="5.25"/>
<rect x="40.45" y="27.2" width="5.02" height="5.25"/>
<rect x="40.45" y="16.75" width="5.02" height="5.21"/>
<rect x="40.45" y="6.29" width="5.02" height="5.21"/>
<rect x="35.43" y="142.31" width="5.02" height="5.21"/>
<rect x="35.43" y="131.82" width="5.02" height="5.25"/>
<rect x="35.43" y="121.37" width="5.02" height="5.25"/>
<rect x="35.43" y="110.91" width="5.02" height="5.25"/>
<rect x="35.43" y="100.46" width="5.02" height="5.21"/>
<rect x="35.43" y="89.97" width="5.02" height="5.25"/>
<rect x="35.43" y="79.51" width="5.02" height="5.25"/>
<rect x="35.43" y="69.06" width="5.02" height="5.25"/>
<rect x="35.43" y="58.6" width="5.02" height="5.21"/>
<rect x="35.43" y="48.15" width="5.02" height="5.21"/>
<rect x="35.43" y="37.66" width="5.02" height="5.25"/>
<rect x="35.43" y="27.2" width="5.02" height="5.25"/>
<rect x="35.43" y="16.75" width="5.02" height="5.21"/>
<rect x="35.43" y="6.29" width="5.02" height="5.21"/>
<rect x="30.41" y="142.31" width="5.02" height="5.21"/>
<rect x="30.41" y="131.82" width="5.02" height="5.25"/>
<rect x="30.41" y="121.37" width="5.02" height="5.25"/>
<rect x="30.41" y="110.91" width="5.02" height="5.25"/>
<rect x="30.41" y="100.46" width="5.02" height="5.21"/>
<rect x="30.41" y="89.97" width="5.02" height="5.25"/>
<rect x="30.41" y="79.51" width="5.02" height="5.25"/>
<rect x="30.41" y="69.06" width="5.02" height="5.25"/>
<rect x="30.41" y="58.6" width="5.02" height="5.21"/>
<rect x="30.41" y="48.15" width="5.02" height="5.21"/>
<rect x="30.41" y="37.66" width="5.02" height="5.25"/>
<rect x="30.41" y="27.2" width="5.02" height="5.25"/>
<rect x="30.41" y="16.75" width="5.02" height="5.21"/>
<rect x="30.41" y="6.29" width="5.02" height="5.21"/>
<rect x="25.38" y="142.31" width="5.02" height="5.21"/>
<rect x="25.38" y="131.82" width="5.02" height="5.25"/>
<rect x="25.38" y="121.37" width="5.02" height="5.25"/>
<rect x="25.38" y="110.91" width="5.02" height="5.25"/>
<rect x="25.38" y="100.46" width="5.02" height="5.21"/>
<rect x="25.38" y="89.97" width="5.02" height="5.25"/>
<rect x="25.38" y="79.51" width="5.02" height="5.25"/>
<rect x="25.38" y="69.06" width="5.02" height="5.25"/>
<rect x="25.38" y="58.6" width="5.02" height="5.21"/>
<rect x="25.38" y="48.15" width="5.02" height="5.21"/>
<rect x="25.38" y="37.66" width="5.02" height="5.25"/>
<rect x="25.38" y="27.2" width="5.02" height="5.25"/>
<rect x="25.38" y="16.75" width="5.02" height="5.21"/>
<rect x="25.38" y="6.29" width="5.02" height="5.21"/>
<rect x="20.36" y="142.31" width="5.02" height="5.21"/>
<rect x="20.36" y="131.82" width="5.02" height="5.25"/>
<rect x="20.36" y="121.37" width="5.02" height="5.25"/>
<rect x="20.36" y="110.91" width="5.02" height="5.25"/>
<rect x="20.36" y="100.46" width="5.02" height="5.21"/>
<rect x="20.36" y="89.97" width="5.02" height="5.25"/>
<rect x="20.36" y="79.51" width="5.02" height="5.25"/>
<rect x="20.36" y="69.06" width="5.02" height="5.25"/>
<rect x="20.36" y="58.6" width="5.02" height="5.21"/>
<rect x="20.36" y="48.15" width="5.02" height="5.21"/>
<rect x="20.36" y="37.66" width="5.02" height="5.25"/>
<rect x="20.36" y="27.2" width="5.02" height="5.25"/>
<rect x="20.36" y="16.75" width="5.02" height="5.21"/>
<rect x="20.36" y="6.29" width="5.02" height="5.21"/>
<rect x="15.33" y="142.31" width="5.02" height="5.21"/>
<rect x="15.33" y="131.82" width="5.02" height="5.25"/>
<rect x="15.33" y="121.37" width="5.02" height="5.25"/>
<rect x="15.33" y="110.91" width="5.02" height="5.25"/>
<rect x="15.33" y="100.46" width="5.02" height="5.21"/>
<rect x="15.33" y="89.97" width="5.02" height="5.25"/>
<rect x="15.33" y="79.51" width="5.02" height="5.25"/>
<rect x="15.33" y="69.06" width="5.02" height="5.25"/>
<rect x="15.33" y="58.6" width="5.02" height="5.21"/>
<rect x="15.33" y="48.15" width="5.02" height="5.21"/>
<rect x="15.33" y="37.66" width="5.02" height="5.25"/>
<rect x="15.33" y="27.2" width="5.02" height="5.25"/>
<rect x="15.33" y="16.75" width="5.02" height="5.21"/>
<rect x="15.33" y="6.29" width="5.02" height="5.21"/>
<rect x="10.31" y="142.31" width="5.02" height="5.21"/>
<rect x="10.31" y="131.82" width="5.02" height="5.25"/>
<rect x="10.31" y="121.37" width="5.02" height="5.25"/>
<rect x="10.31" y="110.91" width="5.02" height="5.25"/>
<rect x="10.31" y="100.46" width="5.02" height="5.21"/>
<rect x="10.31" y="89.97" width="5.02" height="5.25"/>
<rect x="10.31" y="79.51" width="5.02" height="5.25"/>
<rect x="10.31" y="69.06" width="5.02" height="5.25"/>
<rect x="10.31" y="58.6" width="5.02" height="5.21"/>
<rect x="10.31" y="48.15" width="5.02" height="5.21"/>
<rect x="10.31" y="37.66" width="5.02" height="5.25"/>
<rect x="10.31" y="27.2" width="5.02" height="5.25"/>
<rect x="10.31" y="16.75" width="5.02" height="5.21"/>
<rect x="10.31" y="6.29" width="5.02" height="5.21"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 62 KiB

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