Compare commits
38 Commits
feat/ssr
...
20988e425a
| Author | SHA1 | Date | |
|---|---|---|---|
| 20988e425a | |||
| df63937406 | |||
| 996e95f93c | |||
|
|
c4bd0b5a67 | ||
| 5c43873ede | |||
| 249645619e | |||
| be9f303b37 | |||
| 6da8b3b6e4 | |||
| a3cd451575 | |||
|
|
6f700c923a | ||
| 46fd59ed71 | |||
| ba49463ee7 | |||
| 576380e9a0 | |||
| cac534ccbb | |||
| 2e68105da4 | |||
| ed7ed6636d | |||
| e190359041 | |||
|
|
bed94790d4 | ||
| d8ad61ec54 | |||
| 7e9a1482d6 | |||
| aa0adbf993 | |||
| 00af9a9701 | |||
| fcdede2dd6 | |||
| 5d17b23c3a | |||
| 1ec8a43a50 | |||
| 96cfa91c67 | |||
| 669ace82c0 | |||
| 93163ae6e8 | |||
| af2d506da1 | |||
| 637541994a | |||
|
|
63cd4c4f5e | ||
| fd4104da39 | |||
| 5bb23fbcfa | |||
| 6a22c54e9f | |||
| 3ac3262e77 | |||
| 18ecc07240 | |||
| cb468492b3 | |||
| feee2b0bff |
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ public class OptionsController {
|
|||||||
v.getId(),
|
v.getId(),
|
||||||
v.getVariantDisplayName(),
|
v.getVariantDisplayName(),
|
||||||
v.getColorName(),
|
v.getColorName(),
|
||||||
|
v.getColorLabelIt(),
|
||||||
|
v.getColorLabelEn(),
|
||||||
|
v.getColorLabelDe(),
|
||||||
|
v.getColorLabelFr(),
|
||||||
resolveHexColor(v),
|
resolveHexColor(v),
|
||||||
v.getFinishType() != null ? v.getFinishType() : "GLOSSY",
|
v.getFinishType() != null ? v.getFinishType() : "GLOSSY",
|
||||||
v.getStockSpools() != null ? v.getStockSpools().doubleValue() : 0d,
|
v.getStockSpools() != null ? v.getStockSpools().doubleValue() : 0d,
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ public class AdminFilamentVariantDto {
|
|||||||
private String materialTechnicalTypeLabel;
|
private String materialTechnicalTypeLabel;
|
||||||
private String variantDisplayName;
|
private String variantDisplayName;
|
||||||
private String colorName;
|
private String colorName;
|
||||||
|
private String colorLabelIt;
|
||||||
|
private String colorLabelEn;
|
||||||
|
private String colorLabelDe;
|
||||||
|
private String colorLabelFr;
|
||||||
private String colorHex;
|
private String colorHex;
|
||||||
private String finishType;
|
private String finishType;
|
||||||
private String brand;
|
private String brand;
|
||||||
@@ -89,6 +93,38 @@ public class AdminFilamentVariantDto {
|
|||||||
this.colorName = colorName;
|
this.colorName = colorName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getColorLabelIt() {
|
||||||
|
return colorLabelIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelIt(String colorLabelIt) {
|
||||||
|
this.colorLabelIt = colorLabelIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getColorLabelEn() {
|
||||||
|
return colorLabelEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelEn(String colorLabelEn) {
|
||||||
|
this.colorLabelEn = colorLabelEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getColorLabelDe() {
|
||||||
|
return colorLabelDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelDe(String colorLabelDe) {
|
||||||
|
this.colorLabelDe = colorLabelDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getColorLabelFr() {
|
||||||
|
return colorLabelFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelFr(String colorLabelFr) {
|
||||||
|
this.colorLabelFr = colorLabelFr;
|
||||||
|
}
|
||||||
|
|
||||||
public String getColorHex() {
|
public String getColorHex() {
|
||||||
return colorHex;
|
return colorHex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,25 @@ public class AdminShopCategoryDto {
|
|||||||
private String parentCategoryName;
|
private String parentCategoryName;
|
||||||
private String slug;
|
private String slug;
|
||||||
private String name;
|
private String name;
|
||||||
|
private String nameIt;
|
||||||
|
private String nameEn;
|
||||||
|
private String nameDe;
|
||||||
|
private String nameFr;
|
||||||
private String description;
|
private String description;
|
||||||
|
private String descriptionIt;
|
||||||
|
private String descriptionEn;
|
||||||
|
private String descriptionDe;
|
||||||
|
private String descriptionFr;
|
||||||
private String seoTitle;
|
private String seoTitle;
|
||||||
|
private String seoTitleIt;
|
||||||
|
private String seoTitleEn;
|
||||||
|
private String seoTitleDe;
|
||||||
|
private String seoTitleFr;
|
||||||
private String seoDescription;
|
private String seoDescription;
|
||||||
|
private String seoDescriptionIt;
|
||||||
|
private String seoDescriptionEn;
|
||||||
|
private String seoDescriptionDe;
|
||||||
|
private String seoDescriptionFr;
|
||||||
private String ogTitle;
|
private String ogTitle;
|
||||||
private String ogDescription;
|
private String ogDescription;
|
||||||
private Boolean indexable;
|
private Boolean indexable;
|
||||||
@@ -69,6 +85,38 @@ public class AdminShopCategoryDto {
|
|||||||
this.name = name;
|
this.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getNameIt() {
|
||||||
|
return nameIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNameIt(String nameIt) {
|
||||||
|
this.nameIt = nameIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNameEn() {
|
||||||
|
return nameEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNameEn(String nameEn) {
|
||||||
|
this.nameEn = nameEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNameDe() {
|
||||||
|
return nameDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNameDe(String nameDe) {
|
||||||
|
this.nameDe = nameDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNameFr() {
|
||||||
|
return nameFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNameFr(String nameFr) {
|
||||||
|
this.nameFr = nameFr;
|
||||||
|
}
|
||||||
|
|
||||||
public String getDescription() {
|
public String getDescription() {
|
||||||
return description;
|
return description;
|
||||||
}
|
}
|
||||||
@@ -77,6 +125,38 @@ public class AdminShopCategoryDto {
|
|||||||
this.description = description;
|
this.description = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getDescriptionIt() {
|
||||||
|
return descriptionIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptionIt(String descriptionIt) {
|
||||||
|
this.descriptionIt = descriptionIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescriptionEn() {
|
||||||
|
return descriptionEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptionEn(String descriptionEn) {
|
||||||
|
this.descriptionEn = descriptionEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescriptionDe() {
|
||||||
|
return descriptionDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptionDe(String descriptionDe) {
|
||||||
|
this.descriptionDe = descriptionDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescriptionFr() {
|
||||||
|
return descriptionFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptionFr(String descriptionFr) {
|
||||||
|
this.descriptionFr = descriptionFr;
|
||||||
|
}
|
||||||
|
|
||||||
public String getSeoTitle() {
|
public String getSeoTitle() {
|
||||||
return seoTitle;
|
return seoTitle;
|
||||||
}
|
}
|
||||||
@@ -85,6 +165,38 @@ public class AdminShopCategoryDto {
|
|||||||
this.seoTitle = seoTitle;
|
this.seoTitle = seoTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getSeoTitleIt() {
|
||||||
|
return seoTitleIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitleIt(String seoTitleIt) {
|
||||||
|
this.seoTitleIt = seoTitleIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoTitleEn() {
|
||||||
|
return seoTitleEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitleEn(String seoTitleEn) {
|
||||||
|
this.seoTitleEn = seoTitleEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoTitleDe() {
|
||||||
|
return seoTitleDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitleDe(String seoTitleDe) {
|
||||||
|
this.seoTitleDe = seoTitleDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoTitleFr() {
|
||||||
|
return seoTitleFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitleFr(String seoTitleFr) {
|
||||||
|
this.seoTitleFr = seoTitleFr;
|
||||||
|
}
|
||||||
|
|
||||||
public String getSeoDescription() {
|
public String getSeoDescription() {
|
||||||
return seoDescription;
|
return seoDescription;
|
||||||
}
|
}
|
||||||
@@ -93,6 +205,38 @@ public class AdminShopCategoryDto {
|
|||||||
this.seoDescription = seoDescription;
|
this.seoDescription = seoDescription;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getSeoDescriptionIt() {
|
||||||
|
return seoDescriptionIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptionIt(String seoDescriptionIt) {
|
||||||
|
this.seoDescriptionIt = seoDescriptionIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoDescriptionEn() {
|
||||||
|
return seoDescriptionEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptionEn(String seoDescriptionEn) {
|
||||||
|
this.seoDescriptionEn = seoDescriptionEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoDescriptionDe() {
|
||||||
|
return seoDescriptionDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptionDe(String seoDescriptionDe) {
|
||||||
|
this.seoDescriptionDe = seoDescriptionDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoDescriptionFr() {
|
||||||
|
return seoDescriptionFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptionFr(String seoDescriptionFr) {
|
||||||
|
this.seoDescriptionFr = seoDescriptionFr;
|
||||||
|
}
|
||||||
|
|
||||||
public String getOgTitle() {
|
public String getOgTitle() {
|
||||||
return ogTitle;
|
return ogTitle;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ public class AdminShopProductVariantDto {
|
|||||||
private String sku;
|
private String sku;
|
||||||
private String variantLabel;
|
private String variantLabel;
|
||||||
private String colorName;
|
private String colorName;
|
||||||
|
private String colorLabelIt;
|
||||||
|
private String colorLabelEn;
|
||||||
|
private String colorLabelDe;
|
||||||
|
private String colorLabelFr;
|
||||||
private String colorHex;
|
private String colorHex;
|
||||||
private String internalMaterialCode;
|
private String internalMaterialCode;
|
||||||
private BigDecimal priceChf;
|
private BigDecimal priceChf;
|
||||||
@@ -50,6 +54,38 @@ public class AdminShopProductVariantDto {
|
|||||||
this.colorName = colorName;
|
this.colorName = colorName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getColorLabelIt() {
|
||||||
|
return colorLabelIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelIt(String colorLabelIt) {
|
||||||
|
this.colorLabelIt = colorLabelIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getColorLabelEn() {
|
||||||
|
return colorLabelEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelEn(String colorLabelEn) {
|
||||||
|
this.colorLabelEn = colorLabelEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getColorLabelDe() {
|
||||||
|
return colorLabelDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelDe(String colorLabelDe) {
|
||||||
|
this.colorLabelDe = colorLabelDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getColorLabelFr() {
|
||||||
|
return colorLabelFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelFr(String colorLabelFr) {
|
||||||
|
this.colorLabelFr = colorLabelFr;
|
||||||
|
}
|
||||||
|
|
||||||
public String getColorHex() {
|
public String getColorHex() {
|
||||||
return colorHex;
|
return colorHex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class AdminTranslateShopProductRequest {
|
||||||
|
private UUID categoryId;
|
||||||
|
private String sourceLanguage;
|
||||||
|
private Boolean overwriteExisting;
|
||||||
|
private List<String> materialCodes;
|
||||||
|
private Map<String, String> names;
|
||||||
|
private Map<String, String> excerpts;
|
||||||
|
private Map<String, String> descriptions;
|
||||||
|
private Map<String, String> seoTitles;
|
||||||
|
private Map<String, String> seoDescriptions;
|
||||||
|
|
||||||
|
public UUID getCategoryId() {
|
||||||
|
return categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCategoryId(UUID categoryId) {
|
||||||
|
this.categoryId = categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSourceLanguage() {
|
||||||
|
return sourceLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSourceLanguage(String sourceLanguage) {
|
||||||
|
this.sourceLanguage = sourceLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getOverwriteExisting() {
|
||||||
|
return overwriteExisting;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOverwriteExisting(Boolean overwriteExisting) {
|
||||||
|
this.overwriteExisting = overwriteExisting;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getMaterialCodes() {
|
||||||
|
return materialCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaterialCodes(List<String> materialCodes) {
|
||||||
|
this.materialCodes = materialCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getNames() {
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNames(Map<String, String> names) {
|
||||||
|
this.names = names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getExcerpts() {
|
||||||
|
return excerpts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExcerpts(Map<String, String> excerpts) {
|
||||||
|
this.excerpts = excerpts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getDescriptions() {
|
||||||
|
return descriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptions(Map<String, String> descriptions) {
|
||||||
|
this.descriptions = descriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getSeoTitles() {
|
||||||
|
return seoTitles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitles(Map<String, String> seoTitles) {
|
||||||
|
this.seoTitles = seoTitles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getSeoDescriptions() {
|
||||||
|
return seoDescriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptions(Map<String, String> seoDescriptions) {
|
||||||
|
this.seoDescriptions = seoDescriptions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class AdminTranslateShopProductResponse {
|
||||||
|
private String sourceLanguage;
|
||||||
|
private List<String> targetLanguages;
|
||||||
|
private Map<String, String> names;
|
||||||
|
private Map<String, String> excerpts;
|
||||||
|
private Map<String, String> descriptions;
|
||||||
|
private Map<String, String> seoTitles;
|
||||||
|
private Map<String, String> seoDescriptions;
|
||||||
|
|
||||||
|
public String getSourceLanguage() {
|
||||||
|
return sourceLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSourceLanguage(String sourceLanguage) {
|
||||||
|
this.sourceLanguage = sourceLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getTargetLanguages() {
|
||||||
|
return targetLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTargetLanguages(List<String> targetLanguages) {
|
||||||
|
this.targetLanguages = targetLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getNames() {
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNames(Map<String, String> names) {
|
||||||
|
this.names = names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getExcerpts() {
|
||||||
|
return excerpts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExcerpts(Map<String, String> excerpts) {
|
||||||
|
this.excerpts = excerpts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getDescriptions() {
|
||||||
|
return descriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptions(Map<String, String> descriptions) {
|
||||||
|
this.descriptions = descriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getSeoTitles() {
|
||||||
|
return seoTitles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitles(Map<String, String> seoTitles) {
|
||||||
|
this.seoTitles = seoTitles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getSeoDescriptions() {
|
||||||
|
return seoDescriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptions(Map<String, String> seoDescriptions) {
|
||||||
|
this.seoDescriptions = seoDescriptions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ public class AdminUpsertFilamentVariantRequest {
|
|||||||
private Long materialTypeId;
|
private Long materialTypeId;
|
||||||
private String variantDisplayName;
|
private String variantDisplayName;
|
||||||
private String colorName;
|
private String colorName;
|
||||||
|
private String colorLabelIt;
|
||||||
|
private String colorLabelEn;
|
||||||
|
private String colorLabelDe;
|
||||||
|
private String colorLabelFr;
|
||||||
private String colorHex;
|
private String colorHex;
|
||||||
private String finishType;
|
private String finishType;
|
||||||
private String brand;
|
private String brand;
|
||||||
@@ -40,6 +44,38 @@ public class AdminUpsertFilamentVariantRequest {
|
|||||||
this.colorName = colorName;
|
this.colorName = colorName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getColorLabelIt() {
|
||||||
|
return colorLabelIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelIt(String colorLabelIt) {
|
||||||
|
this.colorLabelIt = colorLabelIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getColorLabelEn() {
|
||||||
|
return colorLabelEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelEn(String colorLabelEn) {
|
||||||
|
this.colorLabelEn = colorLabelEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getColorLabelDe() {
|
||||||
|
return colorLabelDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelDe(String colorLabelDe) {
|
||||||
|
this.colorLabelDe = colorLabelDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getColorLabelFr() {
|
||||||
|
return colorLabelFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelFr(String colorLabelFr) {
|
||||||
|
this.colorLabelFr = colorLabelFr;
|
||||||
|
}
|
||||||
|
|
||||||
public String getColorHex() {
|
public String getColorHex() {
|
||||||
return colorHex;
|
return colorHex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,25 @@ public class AdminUpsertShopCategoryRequest {
|
|||||||
private UUID parentCategoryId;
|
private UUID parentCategoryId;
|
||||||
private String slug;
|
private String slug;
|
||||||
private String name;
|
private String name;
|
||||||
|
private String nameIt;
|
||||||
|
private String nameEn;
|
||||||
|
private String nameDe;
|
||||||
|
private String nameFr;
|
||||||
private String description;
|
private String description;
|
||||||
|
private String descriptionIt;
|
||||||
|
private String descriptionEn;
|
||||||
|
private String descriptionDe;
|
||||||
|
private String descriptionFr;
|
||||||
private String seoTitle;
|
private String seoTitle;
|
||||||
|
private String seoTitleIt;
|
||||||
|
private String seoTitleEn;
|
||||||
|
private String seoTitleDe;
|
||||||
|
private String seoTitleFr;
|
||||||
private String seoDescription;
|
private String seoDescription;
|
||||||
|
private String seoDescriptionIt;
|
||||||
|
private String seoDescriptionEn;
|
||||||
|
private String seoDescriptionDe;
|
||||||
|
private String seoDescriptionFr;
|
||||||
private String ogTitle;
|
private String ogTitle;
|
||||||
private String ogDescription;
|
private String ogDescription;
|
||||||
private Boolean indexable;
|
private Boolean indexable;
|
||||||
@@ -39,6 +55,38 @@ public class AdminUpsertShopCategoryRequest {
|
|||||||
this.name = name;
|
this.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getNameIt() {
|
||||||
|
return nameIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNameIt(String nameIt) {
|
||||||
|
this.nameIt = nameIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNameEn() {
|
||||||
|
return nameEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNameEn(String nameEn) {
|
||||||
|
this.nameEn = nameEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNameDe() {
|
||||||
|
return nameDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNameDe(String nameDe) {
|
||||||
|
this.nameDe = nameDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNameFr() {
|
||||||
|
return nameFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNameFr(String nameFr) {
|
||||||
|
this.nameFr = nameFr;
|
||||||
|
}
|
||||||
|
|
||||||
public String getDescription() {
|
public String getDescription() {
|
||||||
return description;
|
return description;
|
||||||
}
|
}
|
||||||
@@ -47,6 +95,38 @@ public class AdminUpsertShopCategoryRequest {
|
|||||||
this.description = description;
|
this.description = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getDescriptionIt() {
|
||||||
|
return descriptionIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptionIt(String descriptionIt) {
|
||||||
|
this.descriptionIt = descriptionIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescriptionEn() {
|
||||||
|
return descriptionEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptionEn(String descriptionEn) {
|
||||||
|
this.descriptionEn = descriptionEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescriptionDe() {
|
||||||
|
return descriptionDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptionDe(String descriptionDe) {
|
||||||
|
this.descriptionDe = descriptionDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescriptionFr() {
|
||||||
|
return descriptionFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptionFr(String descriptionFr) {
|
||||||
|
this.descriptionFr = descriptionFr;
|
||||||
|
}
|
||||||
|
|
||||||
public String getSeoTitle() {
|
public String getSeoTitle() {
|
||||||
return seoTitle;
|
return seoTitle;
|
||||||
}
|
}
|
||||||
@@ -55,6 +135,38 @@ public class AdminUpsertShopCategoryRequest {
|
|||||||
this.seoTitle = seoTitle;
|
this.seoTitle = seoTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getSeoTitleIt() {
|
||||||
|
return seoTitleIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitleIt(String seoTitleIt) {
|
||||||
|
this.seoTitleIt = seoTitleIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoTitleEn() {
|
||||||
|
return seoTitleEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitleEn(String seoTitleEn) {
|
||||||
|
this.seoTitleEn = seoTitleEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoTitleDe() {
|
||||||
|
return seoTitleDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitleDe(String seoTitleDe) {
|
||||||
|
this.seoTitleDe = seoTitleDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoTitleFr() {
|
||||||
|
return seoTitleFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitleFr(String seoTitleFr) {
|
||||||
|
this.seoTitleFr = seoTitleFr;
|
||||||
|
}
|
||||||
|
|
||||||
public String getSeoDescription() {
|
public String getSeoDescription() {
|
||||||
return seoDescription;
|
return seoDescription;
|
||||||
}
|
}
|
||||||
@@ -63,6 +175,38 @@ public class AdminUpsertShopCategoryRequest {
|
|||||||
this.seoDescription = seoDescription;
|
this.seoDescription = seoDescription;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getSeoDescriptionIt() {
|
||||||
|
return seoDescriptionIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptionIt(String seoDescriptionIt) {
|
||||||
|
this.seoDescriptionIt = seoDescriptionIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoDescriptionEn() {
|
||||||
|
return seoDescriptionEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptionEn(String seoDescriptionEn) {
|
||||||
|
this.seoDescriptionEn = seoDescriptionEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoDescriptionDe() {
|
||||||
|
return seoDescriptionDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptionDe(String seoDescriptionDe) {
|
||||||
|
this.seoDescriptionDe = seoDescriptionDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoDescriptionFr() {
|
||||||
|
return seoDescriptionFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptionFr(String seoDescriptionFr) {
|
||||||
|
this.seoDescriptionFr = seoDescriptionFr;
|
||||||
|
}
|
||||||
|
|
||||||
public String getOgTitle() {
|
public String getOgTitle() {
|
||||||
return ogTitle;
|
return ogTitle;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ public class AdminUpsertShopProductVariantRequest {
|
|||||||
private String sku;
|
private String sku;
|
||||||
private String variantLabel;
|
private String variantLabel;
|
||||||
private String colorName;
|
private String colorName;
|
||||||
|
private String colorLabelIt;
|
||||||
|
private String colorLabelEn;
|
||||||
|
private String colorLabelDe;
|
||||||
|
private String colorLabelFr;
|
||||||
private String colorHex;
|
private String colorHex;
|
||||||
private String internalMaterialCode;
|
private String internalMaterialCode;
|
||||||
private BigDecimal priceChf;
|
private BigDecimal priceChf;
|
||||||
@@ -47,6 +51,38 @@ public class AdminUpsertShopProductVariantRequest {
|
|||||||
this.colorName = colorName;
|
this.colorName = colorName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getColorLabelIt() {
|
||||||
|
return colorLabelIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelIt(String colorLabelIt) {
|
||||||
|
this.colorLabelIt = colorLabelIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getColorLabelEn() {
|
||||||
|
return colorLabelEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelEn(String colorLabelEn) {
|
||||||
|
this.colorLabelEn = colorLabelEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getColorLabelDe() {
|
||||||
|
return colorLabelDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelDe(String colorLabelDe) {
|
||||||
|
this.colorLabelDe = colorLabelDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getColorLabelFr() {
|
||||||
|
return colorLabelFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelFr(String colorLabelFr) {
|
||||||
|
this.colorLabelFr = colorLabelFr;
|
||||||
|
}
|
||||||
|
|
||||||
public String getColorHex() {
|
public String getColorHex() {
|
||||||
return colorHex;
|
return colorHex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public record OptionsResponse(
|
|||||||
Long id,
|
Long id,
|
||||||
String name,
|
String name,
|
||||||
String colorName,
|
String colorName,
|
||||||
|
String colorLabelIt,
|
||||||
|
String colorLabelEn,
|
||||||
|
String colorLabelDe,
|
||||||
|
String colorLabelFr,
|
||||||
String hexColor,
|
String hexColor,
|
||||||
String finishType,
|
String finishType,
|
||||||
Double stockSpools,
|
Double stockSpools,
|
||||||
|
|||||||
@@ -17,9 +17,17 @@ public class OrderItemDto {
|
|||||||
private String shopProductName;
|
private String shopProductName;
|
||||||
private String shopVariantLabel;
|
private String shopVariantLabel;
|
||||||
private String shopVariantColorName;
|
private String shopVariantColorName;
|
||||||
|
private String shopVariantColorLabelIt;
|
||||||
|
private String shopVariantColorLabelEn;
|
||||||
|
private String shopVariantColorLabelDe;
|
||||||
|
private String shopVariantColorLabelFr;
|
||||||
private String shopVariantColorHex;
|
private String shopVariantColorHex;
|
||||||
private String filamentVariantDisplayName;
|
private String filamentVariantDisplayName;
|
||||||
private String filamentColorName;
|
private String filamentColorName;
|
||||||
|
private String filamentColorLabelIt;
|
||||||
|
private String filamentColorLabelEn;
|
||||||
|
private String filamentColorLabelDe;
|
||||||
|
private String filamentColorLabelFr;
|
||||||
private String filamentColorHex;
|
private String filamentColorHex;
|
||||||
private String quality;
|
private String quality;
|
||||||
private BigDecimal nozzleDiameterMm;
|
private BigDecimal nozzleDiameterMm;
|
||||||
@@ -73,6 +81,18 @@ public class OrderItemDto {
|
|||||||
public String getShopVariantColorName() { return shopVariantColorName; }
|
public String getShopVariantColorName() { return shopVariantColorName; }
|
||||||
public void setShopVariantColorName(String shopVariantColorName) { this.shopVariantColorName = shopVariantColorName; }
|
public void setShopVariantColorName(String shopVariantColorName) { this.shopVariantColorName = shopVariantColorName; }
|
||||||
|
|
||||||
|
public String getShopVariantColorLabelIt() { return shopVariantColorLabelIt; }
|
||||||
|
public void setShopVariantColorLabelIt(String shopVariantColorLabelIt) { this.shopVariantColorLabelIt = shopVariantColorLabelIt; }
|
||||||
|
|
||||||
|
public String getShopVariantColorLabelEn() { return shopVariantColorLabelEn; }
|
||||||
|
public void setShopVariantColorLabelEn(String shopVariantColorLabelEn) { this.shopVariantColorLabelEn = shopVariantColorLabelEn; }
|
||||||
|
|
||||||
|
public String getShopVariantColorLabelDe() { return shopVariantColorLabelDe; }
|
||||||
|
public void setShopVariantColorLabelDe(String shopVariantColorLabelDe) { this.shopVariantColorLabelDe = shopVariantColorLabelDe; }
|
||||||
|
|
||||||
|
public String getShopVariantColorLabelFr() { return shopVariantColorLabelFr; }
|
||||||
|
public void setShopVariantColorLabelFr(String shopVariantColorLabelFr) { this.shopVariantColorLabelFr = shopVariantColorLabelFr; }
|
||||||
|
|
||||||
public String getShopVariantColorHex() { return shopVariantColorHex; }
|
public String getShopVariantColorHex() { return shopVariantColorHex; }
|
||||||
public void setShopVariantColorHex(String shopVariantColorHex) { this.shopVariantColorHex = shopVariantColorHex; }
|
public void setShopVariantColorHex(String shopVariantColorHex) { this.shopVariantColorHex = shopVariantColorHex; }
|
||||||
|
|
||||||
@@ -82,6 +102,18 @@ public class OrderItemDto {
|
|||||||
public String getFilamentColorName() { return filamentColorName; }
|
public String getFilamentColorName() { return filamentColorName; }
|
||||||
public void setFilamentColorName(String filamentColorName) { this.filamentColorName = filamentColorName; }
|
public void setFilamentColorName(String filamentColorName) { this.filamentColorName = filamentColorName; }
|
||||||
|
|
||||||
|
public String getFilamentColorLabelIt() { return filamentColorLabelIt; }
|
||||||
|
public void setFilamentColorLabelIt(String filamentColorLabelIt) { this.filamentColorLabelIt = filamentColorLabelIt; }
|
||||||
|
|
||||||
|
public String getFilamentColorLabelEn() { return filamentColorLabelEn; }
|
||||||
|
public void setFilamentColorLabelEn(String filamentColorLabelEn) { this.filamentColorLabelEn = filamentColorLabelEn; }
|
||||||
|
|
||||||
|
public String getFilamentColorLabelDe() { return filamentColorLabelDe; }
|
||||||
|
public void setFilamentColorLabelDe(String filamentColorLabelDe) { this.filamentColorLabelDe = filamentColorLabelDe; }
|
||||||
|
|
||||||
|
public String getFilamentColorLabelFr() { return filamentColorLabelFr; }
|
||||||
|
public void setFilamentColorLabelFr(String filamentColorLabelFr) { this.filamentColorLabelFr = filamentColorLabelFr; }
|
||||||
|
|
||||||
public String getFilamentColorHex() { return filamentColorHex; }
|
public String getFilamentColorHex() { return filamentColorHex; }
|
||||||
public void setFilamentColorHex(String filamentColorHex) { this.filamentColorHex = filamentColorHex; }
|
public void setFilamentColorHex(String filamentColorHex) { this.filamentColorHex = filamentColorHex; }
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public record ShopProductVariantOptionDto(
|
|||||||
String sku,
|
String sku,
|
||||||
String variantLabel,
|
String variantLabel,
|
||||||
String colorName,
|
String colorName,
|
||||||
|
String colorLabel,
|
||||||
String colorHex,
|
String colorHex,
|
||||||
BigDecimal priceChf,
|
BigDecimal priceChf,
|
||||||
Boolean isDefault
|
Boolean isDefault
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ public class FilamentVariant {
|
|||||||
@Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE)
|
@Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE)
|
||||||
private String colorName;
|
private String colorName;
|
||||||
|
|
||||||
|
@Column(name = "color_label_it", length = Integer.MAX_VALUE)
|
||||||
|
private String colorLabelIt;
|
||||||
|
|
||||||
|
@Column(name = "color_label_en", length = Integer.MAX_VALUE)
|
||||||
|
private String colorLabelEn;
|
||||||
|
|
||||||
|
@Column(name = "color_label_de", length = Integer.MAX_VALUE)
|
||||||
|
private String colorLabelDe;
|
||||||
|
|
||||||
|
@Column(name = "color_label_fr", length = Integer.MAX_VALUE)
|
||||||
|
private String colorLabelFr;
|
||||||
|
|
||||||
@Column(name = "color_hex", length = Integer.MAX_VALUE)
|
@Column(name = "color_hex", length = Integer.MAX_VALUE)
|
||||||
private String colorHex;
|
private String colorHex;
|
||||||
|
|
||||||
@@ -93,6 +105,38 @@ public class FilamentVariant {
|
|||||||
this.colorName = colorName;
|
this.colorName = colorName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getColorLabelIt() {
|
||||||
|
return colorLabelIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelIt(String colorLabelIt) {
|
||||||
|
this.colorLabelIt = colorLabelIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getColorLabelEn() {
|
||||||
|
return colorLabelEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelEn(String colorLabelEn) {
|
||||||
|
this.colorLabelEn = colorLabelEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getColorLabelDe() {
|
||||||
|
return colorLabelDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelDe(String colorLabelDe) {
|
||||||
|
this.colorLabelDe = colorLabelDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getColorLabelFr() {
|
||||||
|
return colorLabelFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelFr(String colorLabelFr) {
|
||||||
|
this.colorLabelFr = colorLabelFr;
|
||||||
|
}
|
||||||
|
|
||||||
public String getColorHex() {
|
public String getColorHex() {
|
||||||
return colorHex;
|
return colorHex;
|
||||||
}
|
}
|
||||||
@@ -173,4 +217,60 @@ public class FilamentVariant {
|
|||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getColorLabelForLanguage(String language) {
|
||||||
|
return resolveLocalizedValue(
|
||||||
|
language,
|
||||||
|
colorName,
|
||||||
|
colorLabelIt,
|
||||||
|
colorLabelEn,
|
||||||
|
colorLabelDe,
|
||||||
|
colorLabelFr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveLocalizedValue(String language,
|
||||||
|
String fallback,
|
||||||
|
String valueIt,
|
||||||
|
String valueEn,
|
||||||
|
String valueDe,
|
||||||
|
String valueFr) {
|
||||||
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
|
String preferred = switch (normalizedLanguage) {
|
||||||
|
case "it" -> valueIt;
|
||||||
|
case "en" -> valueEn;
|
||||||
|
case "de" -> valueDe;
|
||||||
|
case "fr" -> valueFr;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
String resolved = firstNonBlank(preferred, fallback);
|
||||||
|
if (resolved != null) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
return firstNonBlank(valueIt, valueEn, valueDe, valueFr);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeLanguage(String language) {
|
||||||
|
if (language == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String normalized = language.trim().toLowerCase();
|
||||||
|
int separatorIndex = normalized.indexOf('-');
|
||||||
|
if (separatorIndex > 0) {
|
||||||
|
normalized = normalized.substring(0, separatorIndex);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import jakarta.persistence.Table;
|
|||||||
import org.hibernate.annotations.ColumnDefault;
|
import org.hibernate.annotations.ColumnDefault;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -23,6 +24,8 @@ import java.util.UUID;
|
|||||||
@Index(name = "ix_shop_category_active_sort", columnList = "is_active, sort_order")
|
@Index(name = "ix_shop_category_active_sort", columnList = "is_active, sort_order")
|
||||||
})
|
})
|
||||||
public class ShopCategory {
|
public class ShopCategory {
|
||||||
|
public static final List<String> SUPPORTED_LANGUAGES = List.of("it", "en", "de", "fr");
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||||
@Column(name = "shop_category_id", nullable = false)
|
@Column(name = "shop_category_id", nullable = false)
|
||||||
@@ -38,15 +41,63 @@ public class ShopCategory {
|
|||||||
@Column(name = "name", nullable = false, length = Integer.MAX_VALUE)
|
@Column(name = "name", nullable = false, length = Integer.MAX_VALUE)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "name_it", length = Integer.MAX_VALUE)
|
||||||
|
private String nameIt;
|
||||||
|
|
||||||
|
@Column(name = "name_en", length = Integer.MAX_VALUE)
|
||||||
|
private String nameEn;
|
||||||
|
|
||||||
|
@Column(name = "name_de", length = Integer.MAX_VALUE)
|
||||||
|
private String nameDe;
|
||||||
|
|
||||||
|
@Column(name = "name_fr", length = Integer.MAX_VALUE)
|
||||||
|
private String nameFr;
|
||||||
|
|
||||||
@Column(name = "description", length = Integer.MAX_VALUE)
|
@Column(name = "description", length = Integer.MAX_VALUE)
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
|
@Column(name = "description_it", length = Integer.MAX_VALUE)
|
||||||
|
private String descriptionIt;
|
||||||
|
|
||||||
|
@Column(name = "description_en", length = Integer.MAX_VALUE)
|
||||||
|
private String descriptionEn;
|
||||||
|
|
||||||
|
@Column(name = "description_de", length = Integer.MAX_VALUE)
|
||||||
|
private String descriptionDe;
|
||||||
|
|
||||||
|
@Column(name = "description_fr", length = Integer.MAX_VALUE)
|
||||||
|
private String descriptionFr;
|
||||||
|
|
||||||
@Column(name = "seo_title", length = Integer.MAX_VALUE)
|
@Column(name = "seo_title", length = Integer.MAX_VALUE)
|
||||||
private String seoTitle;
|
private String seoTitle;
|
||||||
|
|
||||||
|
@Column(name = "seo_title_it", length = Integer.MAX_VALUE)
|
||||||
|
private String seoTitleIt;
|
||||||
|
|
||||||
|
@Column(name = "seo_title_en", length = Integer.MAX_VALUE)
|
||||||
|
private String seoTitleEn;
|
||||||
|
|
||||||
|
@Column(name = "seo_title_de", length = Integer.MAX_VALUE)
|
||||||
|
private String seoTitleDe;
|
||||||
|
|
||||||
|
@Column(name = "seo_title_fr", length = Integer.MAX_VALUE)
|
||||||
|
private String seoTitleFr;
|
||||||
|
|
||||||
@Column(name = "seo_description", length = Integer.MAX_VALUE)
|
@Column(name = "seo_description", length = Integer.MAX_VALUE)
|
||||||
private String seoDescription;
|
private String seoDescription;
|
||||||
|
|
||||||
|
@Column(name = "seo_description_it", length = Integer.MAX_VALUE)
|
||||||
|
private String seoDescriptionIt;
|
||||||
|
|
||||||
|
@Column(name = "seo_description_en", length = Integer.MAX_VALUE)
|
||||||
|
private String seoDescriptionEn;
|
||||||
|
|
||||||
|
@Column(name = "seo_description_de", length = Integer.MAX_VALUE)
|
||||||
|
private String seoDescriptionDe;
|
||||||
|
|
||||||
|
@Column(name = "seo_description_fr", length = Integer.MAX_VALUE)
|
||||||
|
private String seoDescriptionFr;
|
||||||
|
|
||||||
@Column(name = "og_title", length = Integer.MAX_VALUE)
|
@Column(name = "og_title", length = Integer.MAX_VALUE)
|
||||||
private String ogTitle;
|
private String ogTitle;
|
||||||
|
|
||||||
@@ -139,6 +190,38 @@ public class ShopCategory {
|
|||||||
this.name = name;
|
this.name = name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getNameIt() {
|
||||||
|
return nameIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNameIt(String nameIt) {
|
||||||
|
this.nameIt = nameIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNameEn() {
|
||||||
|
return nameEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNameEn(String nameEn) {
|
||||||
|
this.nameEn = nameEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNameDe() {
|
||||||
|
return nameDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNameDe(String nameDe) {
|
||||||
|
this.nameDe = nameDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNameFr() {
|
||||||
|
return nameFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNameFr(String nameFr) {
|
||||||
|
this.nameFr = nameFr;
|
||||||
|
}
|
||||||
|
|
||||||
public String getDescription() {
|
public String getDescription() {
|
||||||
return description;
|
return description;
|
||||||
}
|
}
|
||||||
@@ -147,6 +230,38 @@ public class ShopCategory {
|
|||||||
this.description = description;
|
this.description = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getDescriptionIt() {
|
||||||
|
return descriptionIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptionIt(String descriptionIt) {
|
||||||
|
this.descriptionIt = descriptionIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescriptionEn() {
|
||||||
|
return descriptionEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptionEn(String descriptionEn) {
|
||||||
|
this.descriptionEn = descriptionEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescriptionDe() {
|
||||||
|
return descriptionDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptionDe(String descriptionDe) {
|
||||||
|
this.descriptionDe = descriptionDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescriptionFr() {
|
||||||
|
return descriptionFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptionFr(String descriptionFr) {
|
||||||
|
this.descriptionFr = descriptionFr;
|
||||||
|
}
|
||||||
|
|
||||||
public String getSeoTitle() {
|
public String getSeoTitle() {
|
||||||
return seoTitle;
|
return seoTitle;
|
||||||
}
|
}
|
||||||
@@ -155,6 +270,38 @@ public class ShopCategory {
|
|||||||
this.seoTitle = seoTitle;
|
this.seoTitle = seoTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getSeoTitleIt() {
|
||||||
|
return seoTitleIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitleIt(String seoTitleIt) {
|
||||||
|
this.seoTitleIt = seoTitleIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoTitleEn() {
|
||||||
|
return seoTitleEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitleEn(String seoTitleEn) {
|
||||||
|
this.seoTitleEn = seoTitleEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoTitleDe() {
|
||||||
|
return seoTitleDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitleDe(String seoTitleDe) {
|
||||||
|
this.seoTitleDe = seoTitleDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoTitleFr() {
|
||||||
|
return seoTitleFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitleFr(String seoTitleFr) {
|
||||||
|
this.seoTitleFr = seoTitleFr;
|
||||||
|
}
|
||||||
|
|
||||||
public String getSeoDescription() {
|
public String getSeoDescription() {
|
||||||
return seoDescription;
|
return seoDescription;
|
||||||
}
|
}
|
||||||
@@ -163,6 +310,38 @@ public class ShopCategory {
|
|||||||
this.seoDescription = seoDescription;
|
this.seoDescription = seoDescription;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getSeoDescriptionIt() {
|
||||||
|
return seoDescriptionIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptionIt(String seoDescriptionIt) {
|
||||||
|
this.seoDescriptionIt = seoDescriptionIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoDescriptionEn() {
|
||||||
|
return seoDescriptionEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptionEn(String seoDescriptionEn) {
|
||||||
|
this.seoDescriptionEn = seoDescriptionEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoDescriptionDe() {
|
||||||
|
return seoDescriptionDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptionDe(String seoDescriptionDe) {
|
||||||
|
this.seoDescriptionDe = seoDescriptionDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoDescriptionFr() {
|
||||||
|
return seoDescriptionFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptionFr(String seoDescriptionFr) {
|
||||||
|
this.seoDescriptionFr = seoDescriptionFr;
|
||||||
|
}
|
||||||
|
|
||||||
public String getOgTitle() {
|
public String getOgTitle() {
|
||||||
return ogTitle;
|
return ogTitle;
|
||||||
}
|
}
|
||||||
@@ -218,4 +397,109 @@ public class ShopCategory {
|
|||||||
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
||||||
this.updatedAt = updatedAt;
|
this.updatedAt = updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getNameForLanguage(String language) {
|
||||||
|
return resolveLocalizedValue(language, name, nameIt, nameEn, nameDe, nameFr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNameForLanguage(String language, String value) {
|
||||||
|
switch (normalizeLanguage(language)) {
|
||||||
|
case "it" -> nameIt = value;
|
||||||
|
case "en" -> nameEn = value;
|
||||||
|
case "de" -> nameDe = value;
|
||||||
|
case "fr" -> nameFr = value;
|
||||||
|
default -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescriptionForLanguage(String language) {
|
||||||
|
return resolveLocalizedValue(language, description, descriptionIt, descriptionEn, descriptionDe, descriptionFr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptionForLanguage(String language, String value) {
|
||||||
|
switch (normalizeLanguage(language)) {
|
||||||
|
case "it" -> descriptionIt = value;
|
||||||
|
case "en" -> descriptionEn = value;
|
||||||
|
case "de" -> descriptionDe = value;
|
||||||
|
case "fr" -> descriptionFr = value;
|
||||||
|
default -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoTitleForLanguage(String language) {
|
||||||
|
return resolveLocalizedValue(language, seoTitle, seoTitleIt, seoTitleEn, seoTitleDe, seoTitleFr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitleForLanguage(String language, String value) {
|
||||||
|
switch (normalizeLanguage(language)) {
|
||||||
|
case "it" -> seoTitleIt = value;
|
||||||
|
case "en" -> seoTitleEn = value;
|
||||||
|
case "de" -> seoTitleDe = value;
|
||||||
|
case "fr" -> seoTitleFr = value;
|
||||||
|
default -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSeoDescriptionForLanguage(String language) {
|
||||||
|
return resolveLocalizedValue(language, seoDescription, seoDescriptionIt, seoDescriptionEn, seoDescriptionDe, seoDescriptionFr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptionForLanguage(String language, String value) {
|
||||||
|
switch (normalizeLanguage(language)) {
|
||||||
|
case "it" -> seoDescriptionIt = value;
|
||||||
|
case "en" -> seoDescriptionEn = value;
|
||||||
|
case "de" -> seoDescriptionDe = value;
|
||||||
|
case "fr" -> seoDescriptionFr = value;
|
||||||
|
default -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveLocalizedValue(String language,
|
||||||
|
String fallback,
|
||||||
|
String valueIt,
|
||||||
|
String valueEn,
|
||||||
|
String valueDe,
|
||||||
|
String valueFr) {
|
||||||
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
|
String preferred = switch (normalizedLanguage) {
|
||||||
|
case "it" -> valueIt;
|
||||||
|
case "en" -> valueEn;
|
||||||
|
case "de" -> valueDe;
|
||||||
|
case "fr" -> valueFr;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
String resolved = firstNonBlank(preferred, fallback);
|
||||||
|
if (resolved != null) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
return firstNonBlank(valueIt, valueEn, valueDe, valueFr);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeLanguage(String language) {
|
||||||
|
if (language == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String normalized = language.trim().toLowerCase();
|
||||||
|
int separatorIndex = normalized.indexOf('-');
|
||||||
|
if (separatorIndex > 0) {
|
||||||
|
normalized = normalized.substring(0, separatorIndex);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ public class ShopProductVariant {
|
|||||||
@Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE)
|
@Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE)
|
||||||
private String colorName;
|
private String colorName;
|
||||||
|
|
||||||
|
@Column(name = "color_label_it", length = Integer.MAX_VALUE)
|
||||||
|
private String colorLabelIt;
|
||||||
|
|
||||||
|
@Column(name = "color_label_en", length = Integer.MAX_VALUE)
|
||||||
|
private String colorLabelEn;
|
||||||
|
|
||||||
|
@Column(name = "color_label_de", length = Integer.MAX_VALUE)
|
||||||
|
private String colorLabelDe;
|
||||||
|
|
||||||
|
@Column(name = "color_label_fr", length = Integer.MAX_VALUE)
|
||||||
|
private String colorLabelFr;
|
||||||
|
|
||||||
@Column(name = "color_hex", length = Integer.MAX_VALUE)
|
@Column(name = "color_hex", length = Integer.MAX_VALUE)
|
||||||
private String colorHex;
|
private String colorHex;
|
||||||
|
|
||||||
@@ -152,6 +164,38 @@ public class ShopProductVariant {
|
|||||||
this.colorName = colorName;
|
this.colorName = colorName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getColorLabelIt() {
|
||||||
|
return colorLabelIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelIt(String colorLabelIt) {
|
||||||
|
this.colorLabelIt = colorLabelIt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getColorLabelEn() {
|
||||||
|
return colorLabelEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelEn(String colorLabelEn) {
|
||||||
|
this.colorLabelEn = colorLabelEn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getColorLabelDe() {
|
||||||
|
return colorLabelDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelDe(String colorLabelDe) {
|
||||||
|
this.colorLabelDe = colorLabelDe;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getColorLabelFr() {
|
||||||
|
return colorLabelFr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setColorLabelFr(String colorLabelFr) {
|
||||||
|
this.colorLabelFr = colorLabelFr;
|
||||||
|
}
|
||||||
|
|
||||||
public String getColorHex() {
|
public String getColorHex() {
|
||||||
return colorHex;
|
return colorHex;
|
||||||
}
|
}
|
||||||
@@ -215,4 +259,60 @@ public class ShopProductVariant {
|
|||||||
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
||||||
this.updatedAt = updatedAt;
|
this.updatedAt = updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getColorLabelForLanguage(String language) {
|
||||||
|
return resolveLocalizedValue(
|
||||||
|
language,
|
||||||
|
colorName,
|
||||||
|
colorLabelIt,
|
||||||
|
colorLabelEn,
|
||||||
|
colorLabelDe,
|
||||||
|
colorLabelFr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveLocalizedValue(String language,
|
||||||
|
String fallback,
|
||||||
|
String valueIt,
|
||||||
|
String valueEn,
|
||||||
|
String valueDe,
|
||||||
|
String valueFr) {
|
||||||
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
|
String preferred = switch (normalizedLanguage) {
|
||||||
|
case "it" -> valueIt;
|
||||||
|
case "en" -> valueEn;
|
||||||
|
case "de" -> valueDe;
|
||||||
|
case "fr" -> valueFr;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
String resolved = firstNonBlank(preferred, fallback);
|
||||||
|
if (resolved != null) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
return firstNonBlank(valueIt, valueEn, valueDe, valueFr);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeLanguage(String language) {
|
||||||
|
if (language == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String normalized = language.trim().toLowerCase();
|
||||||
|
int separatorIndex = normalized.indexOf('-');
|
||||||
|
if (separatorIndex > 0) {
|
||||||
|
normalized = normalized.substring(0, separatorIndex);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import java.util.*;
|
|||||||
@Service
|
@Service
|
||||||
public class OrderService {
|
public class OrderService {
|
||||||
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
|
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
|
||||||
|
private static final String SHOP_LINE_ITEM_TYPE = "SHOP_PRODUCT";
|
||||||
|
|
||||||
private final OrderRepository orderRepo;
|
private final OrderRepository orderRepo;
|
||||||
private final OrderItemRepository orderItemRepo;
|
private final OrderItemRepository orderItemRepo;
|
||||||
@@ -235,18 +236,20 @@ public class OrderService {
|
|||||||
|
|
||||||
oItem = orderItemRepo.save(oItem);
|
oItem = orderItemRepo.save(oItem);
|
||||||
|
|
||||||
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
|
|
||||||
oItem.setStoredRelativePath(relativePath);
|
|
||||||
|
|
||||||
Path sourcePath = resolveStoredQuotePath(qItem.getStoredPath(), session.getId());
|
Path sourcePath = resolveStoredQuotePath(qItem.getStoredPath(), session.getId());
|
||||||
if (sourcePath == null || !Files.exists(sourcePath)) {
|
if (sourcePath == null || !Files.exists(sourcePath)) {
|
||||||
throw new IllegalStateException("Source file not available for quote line item " + qItem.getId());
|
if (requiresStoredSourceFile(qItem)) {
|
||||||
}
|
throw new IllegalStateException("Source file not available for quote line item " + qItem.getId());
|
||||||
try {
|
}
|
||||||
storageService.store(sourcePath, Paths.get(relativePath));
|
} else {
|
||||||
oItem.setFileSizeBytes(Files.size(sourcePath));
|
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
|
||||||
} catch (IOException e) {
|
oItem.setStoredRelativePath(relativePath);
|
||||||
throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e);
|
try {
|
||||||
|
storageService.store(sourcePath, Paths.get(relativePath));
|
||||||
|
oItem.setFileSizeBytes(Files.size(sourcePath));
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
oItem = orderItemRepo.save(oItem);
|
oItem = orderItemRepo.save(oItem);
|
||||||
@@ -318,6 +321,12 @@ public class OrderService {
|
|||||||
return "stl";
|
return "stl";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean requiresStoredSourceFile(QuoteLineItem qItem) {
|
||||||
|
return !SHOP_LINE_ITEM_TYPE.equalsIgnoreCase(
|
||||||
|
qItem.getLineItemType() != null ? qItem.getLineItemType() : ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
|
private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
|
||||||
if (storedPath == null || storedPath.isBlank()) {
|
if (storedPath == null || storedPath.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -161,10 +161,21 @@ public class AdminFilamentControllerService {
|
|||||||
String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex());
|
String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex());
|
||||||
String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte());
|
String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte());
|
||||||
String normalizedBrand = normalizeOptional(payload.getBrand());
|
String normalizedBrand = normalizeOptional(payload.getBrand());
|
||||||
|
String fallbackColorLabel = firstNonBlank(
|
||||||
|
normalizeOptional(payload.getColorLabelIt()),
|
||||||
|
normalizeOptional(payload.getColorLabelEn()),
|
||||||
|
normalizeOptional(payload.getColorLabelDe()),
|
||||||
|
normalizeOptional(payload.getColorLabelFr()),
|
||||||
|
normalizedColorName
|
||||||
|
);
|
||||||
|
|
||||||
variant.setFilamentMaterialType(material);
|
variant.setFilamentMaterialType(material);
|
||||||
variant.setVariantDisplayName(normalizedDisplayName);
|
variant.setVariantDisplayName(normalizedDisplayName);
|
||||||
variant.setColorName(normalizedColorName);
|
variant.setColorName(normalizedColorName);
|
||||||
|
variant.setColorLabelIt(firstNonBlank(normalizeOptional(payload.getColorLabelIt()), fallbackColorLabel));
|
||||||
|
variant.setColorLabelEn(firstNonBlank(normalizeOptional(payload.getColorLabelEn()), fallbackColorLabel));
|
||||||
|
variant.setColorLabelDe(firstNonBlank(normalizeOptional(payload.getColorLabelDe()), fallbackColorLabel));
|
||||||
|
variant.setColorLabelFr(firstNonBlank(normalizeOptional(payload.getColorLabelFr()), fallbackColorLabel));
|
||||||
variant.setColorHex(normalizedColorHex);
|
variant.setColorHex(normalizedColorHex);
|
||||||
variant.setFinishType(normalizedFinishType);
|
variant.setFinishType(normalizedFinishType);
|
||||||
variant.setBrand(normalizedBrand);
|
variant.setBrand(normalizedBrand);
|
||||||
@@ -226,6 +237,18 @@ public class AdminFilamentControllerService {
|
|||||||
return normalized.isBlank() ? null : normalized;
|
return normalized.isBlank() ? null : 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 FilamentMaterialType validateAndResolveMaterial(AdminUpsertFilamentVariantRequest payload) {
|
private FilamentMaterialType validateAndResolveMaterial(AdminUpsertFilamentVariantRequest payload) {
|
||||||
if (payload == null || payload.getMaterialTypeId() == null) {
|
if (payload == null || payload.getMaterialTypeId() == null) {
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "Material type id is required");
|
throw new ResponseStatusException(BAD_REQUEST, "Material type id is required");
|
||||||
@@ -306,6 +329,10 @@ public class AdminFilamentControllerService {
|
|||||||
|
|
||||||
dto.setVariantDisplayName(variant.getVariantDisplayName());
|
dto.setVariantDisplayName(variant.getVariantDisplayName());
|
||||||
dto.setColorName(variant.getColorName());
|
dto.setColorName(variant.getColorName());
|
||||||
|
dto.setColorLabelIt(variant.getColorLabelIt());
|
||||||
|
dto.setColorLabelEn(variant.getColorLabelEn());
|
||||||
|
dto.setColorLabelDe(variant.getColorLabelDe());
|
||||||
|
dto.setColorLabelFr(variant.getColorLabelFr());
|
||||||
dto.setColorHex(variant.getColorHex());
|
dto.setColorHex(variant.getColorHex());
|
||||||
dto.setFinishType(variant.getFinishType());
|
dto.setFinishType(variant.getFinishType());
|
||||||
dto.setBrand(variant.getBrand());
|
dto.setBrand(variant.getBrand());
|
||||||
|
|||||||
@@ -67,13 +67,13 @@ public class AdminShopCategoryControllerService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public AdminShopCategoryDto createCategory(AdminUpsertShopCategoryRequest payload) {
|
public AdminShopCategoryDto createCategory(AdminUpsertShopCategoryRequest payload) {
|
||||||
ensurePayload(payload);
|
ensurePayload(payload);
|
||||||
String normalizedName = normalizeRequiredName(payload.getName());
|
LocalizedCategoryContent localizedContent = normalizeLocalizedCategoryContent(payload);
|
||||||
String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), normalizedName);
|
String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName());
|
||||||
ensureSlugAvailable(normalizedSlug, null);
|
ensureSlugAvailable(normalizedSlug, null);
|
||||||
|
|
||||||
ShopCategory category = new ShopCategory();
|
ShopCategory category = new ShopCategory();
|
||||||
category.setCreatedAt(OffsetDateTime.now());
|
category.setCreatedAt(OffsetDateTime.now());
|
||||||
applyPayload(category, payload, normalizedName, normalizedSlug, null);
|
applyPayload(category, payload, localizedContent, normalizedSlug, null);
|
||||||
|
|
||||||
ShopCategory saved = shopCategoryRepository.save(category);
|
ShopCategory saved = shopCategoryRepository.save(category);
|
||||||
return getCategory(saved.getId());
|
return getCategory(saved.getId());
|
||||||
@@ -86,11 +86,11 @@ public class AdminShopCategoryControllerService {
|
|||||||
ShopCategory category = shopCategoryRepository.findById(categoryId)
|
ShopCategory category = shopCategoryRepository.findById(categoryId)
|
||||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Shop category not found"));
|
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Shop category not found"));
|
||||||
|
|
||||||
String normalizedName = normalizeRequiredName(payload.getName());
|
LocalizedCategoryContent localizedContent = normalizeLocalizedCategoryContent(payload);
|
||||||
String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), normalizedName);
|
String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName());
|
||||||
ensureSlugAvailable(normalizedSlug, category.getId());
|
ensureSlugAvailable(normalizedSlug, category.getId());
|
||||||
|
|
||||||
applyPayload(category, payload, normalizedName, normalizedSlug, category.getId());
|
applyPayload(category, payload, localizedContent, normalizedSlug, category.getId());
|
||||||
ShopCategory saved = shopCategoryRepository.save(category);
|
ShopCategory saved = shopCategoryRepository.save(category);
|
||||||
return getCategory(saved.getId());
|
return getCategory(saved.getId());
|
||||||
}
|
}
|
||||||
@@ -112,17 +112,33 @@ public class AdminShopCategoryControllerService {
|
|||||||
|
|
||||||
private void applyPayload(ShopCategory category,
|
private void applyPayload(ShopCategory category,
|
||||||
AdminUpsertShopCategoryRequest payload,
|
AdminUpsertShopCategoryRequest payload,
|
||||||
String normalizedName,
|
LocalizedCategoryContent localizedContent,
|
||||||
String normalizedSlug,
|
String normalizedSlug,
|
||||||
UUID currentCategoryId) {
|
UUID currentCategoryId) {
|
||||||
ShopCategory parentCategory = resolveParentCategory(payload.getParentCategoryId(), currentCategoryId);
|
ShopCategory parentCategory = resolveParentCategory(payload.getParentCategoryId(), currentCategoryId);
|
||||||
|
|
||||||
category.setParentCategory(parentCategory);
|
category.setParentCategory(parentCategory);
|
||||||
category.setSlug(normalizedSlug);
|
category.setSlug(normalizedSlug);
|
||||||
category.setName(normalizedName);
|
category.setName(localizedContent.defaultName());
|
||||||
category.setDescription(normalizeOptional(payload.getDescription()));
|
category.setNameIt(localizedContent.names().get("it"));
|
||||||
category.setSeoTitle(normalizeOptional(payload.getSeoTitle()));
|
category.setNameEn(localizedContent.names().get("en"));
|
||||||
category.setSeoDescription(normalizeOptional(payload.getSeoDescription()));
|
category.setNameDe(localizedContent.names().get("de"));
|
||||||
|
category.setNameFr(localizedContent.names().get("fr"));
|
||||||
|
category.setDescription(localizedContent.defaultDescription());
|
||||||
|
category.setDescriptionIt(localizedContent.descriptions().get("it"));
|
||||||
|
category.setDescriptionEn(localizedContent.descriptions().get("en"));
|
||||||
|
category.setDescriptionDe(localizedContent.descriptions().get("de"));
|
||||||
|
category.setDescriptionFr(localizedContent.descriptions().get("fr"));
|
||||||
|
category.setSeoTitle(localizedContent.defaultSeoTitle());
|
||||||
|
category.setSeoTitleIt(localizedContent.seoTitles().get("it"));
|
||||||
|
category.setSeoTitleEn(localizedContent.seoTitles().get("en"));
|
||||||
|
category.setSeoTitleDe(localizedContent.seoTitles().get("de"));
|
||||||
|
category.setSeoTitleFr(localizedContent.seoTitles().get("fr"));
|
||||||
|
category.setSeoDescription(localizedContent.defaultSeoDescription());
|
||||||
|
category.setSeoDescriptionIt(localizedContent.seoDescriptions().get("it"));
|
||||||
|
category.setSeoDescriptionEn(localizedContent.seoDescriptions().get("en"));
|
||||||
|
category.setSeoDescriptionDe(localizedContent.seoDescriptions().get("de"));
|
||||||
|
category.setSeoDescriptionFr(localizedContent.seoDescriptions().get("fr"));
|
||||||
category.setOgTitle(normalizeOptional(payload.getOgTitle()));
|
category.setOgTitle(normalizeOptional(payload.getOgTitle()));
|
||||||
category.setOgDescription(normalizeOptional(payload.getOgDescription()));
|
category.setOgDescription(normalizeOptional(payload.getOgDescription()));
|
||||||
category.setIndexable(payload.getIndexable() == null || payload.getIndexable());
|
category.setIndexable(payload.getIndexable() == null || payload.getIndexable());
|
||||||
@@ -161,14 +177,6 @@ public class AdminShopCategoryControllerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String normalizeRequiredName(String name) {
|
|
||||||
String normalized = normalizeOptional(name);
|
|
||||||
if (normalized == null) {
|
|
||||||
throw new ResponseStatusException(BAD_REQUEST, "Category name is required");
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String normalizeAndValidateSlug(String slug, String fallbackName) {
|
private String normalizeAndValidateSlug(String slug, String fallbackName) {
|
||||||
String source = normalizeOptional(slug);
|
String source = normalizeOptional(slug);
|
||||||
if (source == null) {
|
if (source == null) {
|
||||||
@@ -203,6 +211,103 @@ public class AdminShopCategoryControllerService {
|
|||||||
return normalized.isBlank() ? null : normalized;
|
return normalized.isBlank() ? null : normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String normalizeRequired(String value, String message) {
|
||||||
|
String normalized = normalizeOptional(value);
|
||||||
|
if (normalized == null) {
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, message);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalizedCategoryContent normalizeLocalizedCategoryContent(AdminUpsertShopCategoryRequest payload) {
|
||||||
|
String legacyName = normalizeOptional(payload.getName());
|
||||||
|
String fallbackName = firstNonBlank(
|
||||||
|
legacyName,
|
||||||
|
normalizeOptional(payload.getNameIt()),
|
||||||
|
normalizeOptional(payload.getNameEn()),
|
||||||
|
normalizeOptional(payload.getNameDe()),
|
||||||
|
normalizeOptional(payload.getNameFr())
|
||||||
|
);
|
||||||
|
if (fallbackName == null) {
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, "Category name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> names = new LinkedHashMap<>();
|
||||||
|
names.put("it", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameIt()), fallbackName), "Italian category name is required"));
|
||||||
|
names.put("en", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameEn()), fallbackName), "English category name is required"));
|
||||||
|
names.put("de", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameDe()), fallbackName), "German category name is required"));
|
||||||
|
names.put("fr", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameFr()), fallbackName), "French category name is required"));
|
||||||
|
|
||||||
|
String fallbackDescription = firstNonBlank(
|
||||||
|
normalizeOptional(payload.getDescription()),
|
||||||
|
normalizeOptional(payload.getDescriptionIt()),
|
||||||
|
normalizeOptional(payload.getDescriptionEn()),
|
||||||
|
normalizeOptional(payload.getDescriptionDe()),
|
||||||
|
normalizeOptional(payload.getDescriptionFr())
|
||||||
|
);
|
||||||
|
Map<String, String> descriptions = new LinkedHashMap<>();
|
||||||
|
descriptions.put("it", firstNonBlank(normalizeOptional(payload.getDescriptionIt()), fallbackDescription));
|
||||||
|
descriptions.put("en", firstNonBlank(normalizeOptional(payload.getDescriptionEn()), fallbackDescription));
|
||||||
|
descriptions.put("de", firstNonBlank(normalizeOptional(payload.getDescriptionDe()), fallbackDescription));
|
||||||
|
descriptions.put("fr", firstNonBlank(normalizeOptional(payload.getDescriptionFr()), fallbackDescription));
|
||||||
|
|
||||||
|
String fallbackSeoTitle = firstNonBlank(
|
||||||
|
normalizeOptional(payload.getSeoTitle()),
|
||||||
|
normalizeOptional(payload.getSeoTitleIt()),
|
||||||
|
normalizeOptional(payload.getSeoTitleEn()),
|
||||||
|
normalizeOptional(payload.getSeoTitleDe()),
|
||||||
|
normalizeOptional(payload.getSeoTitleFr())
|
||||||
|
);
|
||||||
|
Map<String, String> seoTitles = new LinkedHashMap<>();
|
||||||
|
seoTitles.put("it", firstNonBlank(normalizeOptional(payload.getSeoTitleIt()), fallbackSeoTitle));
|
||||||
|
seoTitles.put("en", firstNonBlank(normalizeOptional(payload.getSeoTitleEn()), fallbackSeoTitle));
|
||||||
|
seoTitles.put("de", firstNonBlank(normalizeOptional(payload.getSeoTitleDe()), fallbackSeoTitle));
|
||||||
|
seoTitles.put("fr", firstNonBlank(normalizeOptional(payload.getSeoTitleFr()), fallbackSeoTitle));
|
||||||
|
|
||||||
|
String fallbackSeoDescription = firstNonBlank(
|
||||||
|
normalizeOptional(payload.getSeoDescription()),
|
||||||
|
normalizeOptional(payload.getSeoDescriptionIt()),
|
||||||
|
normalizeOptional(payload.getSeoDescriptionEn()),
|
||||||
|
normalizeOptional(payload.getSeoDescriptionDe()),
|
||||||
|
normalizeOptional(payload.getSeoDescriptionFr())
|
||||||
|
);
|
||||||
|
Map<String, String> seoDescriptions = new LinkedHashMap<>();
|
||||||
|
seoDescriptions.put("it", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionIt()), fallbackSeoDescription), "Italian"));
|
||||||
|
seoDescriptions.put("en", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionEn()), fallbackSeoDescription), "English"));
|
||||||
|
seoDescriptions.put("de", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionDe()), fallbackSeoDescription), "German"));
|
||||||
|
seoDescriptions.put("fr", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionFr()), fallbackSeoDescription), "French"));
|
||||||
|
|
||||||
|
return new LocalizedCategoryContent(
|
||||||
|
names.get("it"),
|
||||||
|
firstNonBlank(descriptions.get("it"), fallbackDescription),
|
||||||
|
firstNonBlank(seoTitles.get("it"), fallbackSeoTitle),
|
||||||
|
firstNonBlank(seoDescriptions.get("it"), fallbackSeoDescription),
|
||||||
|
names,
|
||||||
|
descriptions,
|
||||||
|
seoTitles,
|
||||||
|
seoDescriptions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validateSeoDescriptionLength(String value, String languageLabel) {
|
||||||
|
if (value != null && value.length() > 160) {
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, languageLabel + " SEO description must be at most 160 characters");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String firstNonBlank(String... values) {
|
||||||
|
if (values == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (String value : values) {
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private CategoryContext buildContext() {
|
private CategoryContext buildContext() {
|
||||||
List<ShopCategory> categories = shopCategoryRepository.findAllByOrderBySortOrderAscNameAsc();
|
List<ShopCategory> categories = shopCategoryRepository.findAllByOrderBySortOrderAscNameAsc();
|
||||||
List<ShopProduct> products = shopProductRepository.findAll();
|
List<ShopProduct> products = shopProductRepository.findAll();
|
||||||
@@ -278,9 +383,25 @@ public class AdminShopCategoryControllerService {
|
|||||||
dto.setParentCategoryName(category.getParentCategory() != null ? category.getParentCategory().getName() : null);
|
dto.setParentCategoryName(category.getParentCategory() != null ? category.getParentCategory().getName() : null);
|
||||||
dto.setSlug(category.getSlug());
|
dto.setSlug(category.getSlug());
|
||||||
dto.setName(category.getName());
|
dto.setName(category.getName());
|
||||||
|
dto.setNameIt(category.getNameIt());
|
||||||
|
dto.setNameEn(category.getNameEn());
|
||||||
|
dto.setNameDe(category.getNameDe());
|
||||||
|
dto.setNameFr(category.getNameFr());
|
||||||
dto.setDescription(category.getDescription());
|
dto.setDescription(category.getDescription());
|
||||||
|
dto.setDescriptionIt(category.getDescriptionIt());
|
||||||
|
dto.setDescriptionEn(category.getDescriptionEn());
|
||||||
|
dto.setDescriptionDe(category.getDescriptionDe());
|
||||||
|
dto.setDescriptionFr(category.getDescriptionFr());
|
||||||
dto.setSeoTitle(category.getSeoTitle());
|
dto.setSeoTitle(category.getSeoTitle());
|
||||||
|
dto.setSeoTitleIt(category.getSeoTitleIt());
|
||||||
|
dto.setSeoTitleEn(category.getSeoTitleEn());
|
||||||
|
dto.setSeoTitleDe(category.getSeoTitleDe());
|
||||||
|
dto.setSeoTitleFr(category.getSeoTitleFr());
|
||||||
dto.setSeoDescription(category.getSeoDescription());
|
dto.setSeoDescription(category.getSeoDescription());
|
||||||
|
dto.setSeoDescriptionIt(category.getSeoDescriptionIt());
|
||||||
|
dto.setSeoDescriptionEn(category.getSeoDescriptionEn());
|
||||||
|
dto.setSeoDescriptionDe(category.getSeoDescriptionDe());
|
||||||
|
dto.setSeoDescriptionFr(category.getSeoDescriptionFr());
|
||||||
dto.setOgTitle(category.getOgTitle());
|
dto.setOgTitle(category.getOgTitle());
|
||||||
dto.setOgDescription(category.getOgDescription());
|
dto.setOgDescription(category.getOgDescription());
|
||||||
dto.setIndexable(category.getIndexable());
|
dto.setIndexable(category.getIndexable());
|
||||||
@@ -331,4 +452,16 @@ public class AdminShopCategoryControllerService {
|
|||||||
Map<UUID, Integer> descendantProductCounts
|
Map<UUID, Integer> descendantProductCounts
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record LocalizedCategoryContent(
|
||||||
|
String defaultName,
|
||||||
|
String defaultDescription,
|
||||||
|
String defaultSeoTitle,
|
||||||
|
String defaultSeoDescription,
|
||||||
|
Map<String, String> names,
|
||||||
|
Map<String, String> descriptions,
|
||||||
|
Map<String, String> seoTitles,
|
||||||
|
Map<String, String> seoDescriptions
|
||||||
|
) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -353,6 +353,13 @@ public class AdminShopProductControllerService {
|
|||||||
String normalizedColorName = normalizeRequired(payload.getColorName(), "Variant colorName is required");
|
String normalizedColorName = normalizeRequired(payload.getColorName(), "Variant colorName is required");
|
||||||
String normalizedVariantLabel = normalizeOptional(payload.getVariantLabel());
|
String normalizedVariantLabel = normalizeOptional(payload.getVariantLabel());
|
||||||
String normalizedSku = normalizeOptional(payload.getSku());
|
String normalizedSku = normalizeOptional(payload.getSku());
|
||||||
|
String fallbackColorLabel = firstNonBlank(
|
||||||
|
normalizeOptional(payload.getColorLabelIt()),
|
||||||
|
normalizeOptional(payload.getColorLabelEn()),
|
||||||
|
normalizeOptional(payload.getColorLabelDe()),
|
||||||
|
normalizeOptional(payload.getColorLabelFr()),
|
||||||
|
normalizedColorName
|
||||||
|
);
|
||||||
String normalizedMaterialCode = normalizeRequired(
|
String normalizedMaterialCode = normalizeRequired(
|
||||||
payload.getInternalMaterialCode(),
|
payload.getInternalMaterialCode(),
|
||||||
"Variant internalMaterialCode is required"
|
"Variant internalMaterialCode is required"
|
||||||
@@ -380,6 +387,10 @@ public class AdminShopProductControllerService {
|
|||||||
variant.setSku(normalizedSku);
|
variant.setSku(normalizedSku);
|
||||||
variant.setVariantLabel(normalizedVariantLabel != null ? normalizedVariantLabel : normalizedColorName);
|
variant.setVariantLabel(normalizedVariantLabel != null ? normalizedVariantLabel : normalizedColorName);
|
||||||
variant.setColorName(normalizedColorName);
|
variant.setColorName(normalizedColorName);
|
||||||
|
variant.setColorLabelIt(firstNonBlank(normalizeOptional(payload.getColorLabelIt()), fallbackColorLabel));
|
||||||
|
variant.setColorLabelEn(firstNonBlank(normalizeOptional(payload.getColorLabelEn()), fallbackColorLabel));
|
||||||
|
variant.setColorLabelDe(firstNonBlank(normalizeOptional(payload.getColorLabelDe()), fallbackColorLabel));
|
||||||
|
variant.setColorLabelFr(firstNonBlank(normalizeOptional(payload.getColorLabelFr()), fallbackColorLabel));
|
||||||
variant.setColorHex(normalizeColorHex(payload.getColorHex()));
|
variant.setColorHex(normalizeColorHex(payload.getColorHex()));
|
||||||
variant.setInternalMaterialCode(normalizedMaterialCode);
|
variant.setInternalMaterialCode(normalizedMaterialCode);
|
||||||
variant.setPriceChf(price);
|
variant.setPriceChf(price);
|
||||||
@@ -531,6 +542,10 @@ public class AdminShopProductControllerService {
|
|||||||
dto.setSku(variant.getSku());
|
dto.setSku(variant.getSku());
|
||||||
dto.setVariantLabel(variant.getVariantLabel());
|
dto.setVariantLabel(variant.getVariantLabel());
|
||||||
dto.setColorName(variant.getColorName());
|
dto.setColorName(variant.getColorName());
|
||||||
|
dto.setColorLabelIt(variant.getColorLabelIt());
|
||||||
|
dto.setColorLabelEn(variant.getColorLabelEn());
|
||||||
|
dto.setColorLabelDe(variant.getColorLabelDe());
|
||||||
|
dto.setColorLabelFr(variant.getColorLabelFr());
|
||||||
dto.setColorHex(variant.getColorHex());
|
dto.setColorHex(variant.getColorHex());
|
||||||
dto.setInternalMaterialCode(variant.getInternalMaterialCode());
|
dto.setInternalMaterialCode(variant.getInternalMaterialCode());
|
||||||
dto.setPriceChf(variant.getPriceChf());
|
dto.setPriceChf(variant.getPriceChf());
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -280,11 +280,19 @@ public class AdminOrderControllerService {
|
|||||||
itemDto.setShopProductName(item.getShopProductName());
|
itemDto.setShopProductName(item.getShopProductName());
|
||||||
itemDto.setShopVariantLabel(item.getShopVariantLabel());
|
itemDto.setShopVariantLabel(item.getShopVariantLabel());
|
||||||
itemDto.setShopVariantColorName(item.getShopVariantColorName());
|
itemDto.setShopVariantColorName(item.getShopVariantColorName());
|
||||||
|
itemDto.setShopVariantColorLabelIt(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelIt() : null);
|
||||||
|
itemDto.setShopVariantColorLabelEn(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelEn() : null);
|
||||||
|
itemDto.setShopVariantColorLabelDe(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelDe() : null);
|
||||||
|
itemDto.setShopVariantColorLabelFr(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelFr() : null);
|
||||||
itemDto.setShopVariantColorHex(item.getShopVariantColorHex());
|
itemDto.setShopVariantColorHex(item.getShopVariantColorHex());
|
||||||
if (item.getFilamentVariant() != null) {
|
if (item.getFilamentVariant() != null) {
|
||||||
itemDto.setFilamentVariantId(item.getFilamentVariant().getId());
|
itemDto.setFilamentVariantId(item.getFilamentVariant().getId());
|
||||||
itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName());
|
itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName());
|
||||||
itemDto.setFilamentColorName(item.getFilamentVariant().getColorName());
|
itemDto.setFilamentColorName(item.getFilamentVariant().getColorName());
|
||||||
|
itemDto.setFilamentColorLabelIt(item.getFilamentVariant().getColorLabelIt());
|
||||||
|
itemDto.setFilamentColorLabelEn(item.getFilamentVariant().getColorLabelEn());
|
||||||
|
itemDto.setFilamentColorLabelDe(item.getFilamentVariant().getColorLabelDe());
|
||||||
|
itemDto.setFilamentColorLabelFr(item.getFilamentVariant().getColorLabelFr());
|
||||||
itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex());
|
itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex());
|
||||||
}
|
}
|
||||||
itemDto.setQuality(item.getQuality());
|
itemDto.setQuality(item.getQuality());
|
||||||
|
|||||||
@@ -334,11 +334,19 @@ public class OrderControllerService {
|
|||||||
itemDto.setShopProductName(item.getShopProductName());
|
itemDto.setShopProductName(item.getShopProductName());
|
||||||
itemDto.setShopVariantLabel(item.getShopVariantLabel());
|
itemDto.setShopVariantLabel(item.getShopVariantLabel());
|
||||||
itemDto.setShopVariantColorName(item.getShopVariantColorName());
|
itemDto.setShopVariantColorName(item.getShopVariantColorName());
|
||||||
|
itemDto.setShopVariantColorLabelIt(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelIt() : null);
|
||||||
|
itemDto.setShopVariantColorLabelEn(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelEn() : null);
|
||||||
|
itemDto.setShopVariantColorLabelDe(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelDe() : null);
|
||||||
|
itemDto.setShopVariantColorLabelFr(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelFr() : null);
|
||||||
itemDto.setShopVariantColorHex(item.getShopVariantColorHex());
|
itemDto.setShopVariantColorHex(item.getShopVariantColorHex());
|
||||||
if (item.getFilamentVariant() != null) {
|
if (item.getFilamentVariant() != null) {
|
||||||
itemDto.setFilamentVariantId(item.getFilamentVariant().getId());
|
itemDto.setFilamentVariantId(item.getFilamentVariant().getId());
|
||||||
itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName());
|
itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName());
|
||||||
itemDto.setFilamentColorName(item.getFilamentVariant().getColorName());
|
itemDto.setFilamentColorName(item.getFilamentVariant().getColorName());
|
||||||
|
itemDto.setFilamentColorLabelIt(item.getFilamentVariant().getColorLabelIt());
|
||||||
|
itemDto.setFilamentColorLabelEn(item.getFilamentVariant().getColorLabelEn());
|
||||||
|
itemDto.setFilamentColorLabelDe(item.getFilamentVariant().getColorLabelDe());
|
||||||
|
itemDto.setFilamentColorLabelFr(item.getFilamentVariant().getColorLabelFr());
|
||||||
itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex());
|
itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex());
|
||||||
}
|
}
|
||||||
itemDto.setQuality(item.getQuality());
|
itemDto.setQuality(item.getQuality());
|
||||||
|
|||||||
@@ -81,7 +81,15 @@ public class QuoteSessionResponseAssembler {
|
|||||||
dto.put("shopProductName", item.getShopProductName());
|
dto.put("shopProductName", item.getShopProductName());
|
||||||
dto.put("shopVariantLabel", item.getShopVariantLabel());
|
dto.put("shopVariantLabel", item.getShopVariantLabel());
|
||||||
dto.put("shopVariantColorName", item.getShopVariantColorName());
|
dto.put("shopVariantColorName", item.getShopVariantColorName());
|
||||||
|
dto.put("shopVariantColorLabelIt", item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelIt() : null);
|
||||||
|
dto.put("shopVariantColorLabelEn", item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelEn() : null);
|
||||||
|
dto.put("shopVariantColorLabelDe", item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelDe() : null);
|
||||||
|
dto.put("shopVariantColorLabelFr", item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelFr() : null);
|
||||||
dto.put("shopVariantColorHex", item.getShopVariantColorHex());
|
dto.put("shopVariantColorHex", item.getShopVariantColorHex());
|
||||||
|
dto.put("filamentColorLabelIt", item.getFilamentVariant() != null ? item.getFilamentVariant().getColorLabelIt() : null);
|
||||||
|
dto.put("filamentColorLabelEn", item.getFilamentVariant() != null ? item.getFilamentVariant().getColorLabelEn() : null);
|
||||||
|
dto.put("filamentColorLabelDe", item.getFilamentVariant() != null ? item.getFilamentVariant().getColorLabelDe() : null);
|
||||||
|
dto.put("filamentColorLabelFr", item.getFilamentVariant() != null ? item.getFilamentVariant().getColorLabelFr() : null);
|
||||||
dto.put("materialCode", item.getMaterialCode());
|
dto.put("materialCode", item.getMaterialCode());
|
||||||
dto.put("quality", item.getQuality());
|
dto.put("quality", item.getQuality());
|
||||||
dto.put("nozzleDiameterMm", item.getNozzleDiameterMm());
|
dto.put("nozzleDiameterMm", item.getNozzleDiameterMm());
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ public class PublicShopCatalogService {
|
|||||||
|
|
||||||
public List<ShopCategoryTreeDto> getCategories(String language) {
|
public List<ShopCategoryTreeDto> getCategories(String language) {
|
||||||
CategoryContext categoryContext = loadCategoryContext(language);
|
CategoryContext categoryContext = loadCategoryContext(language);
|
||||||
return buildCategoryTree(null, categoryContext);
|
return buildCategoryTree(null, categoryContext, language);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ShopCategoryDetailDto getCategory(String slug, String language) {
|
public ShopCategoryDetailDto getCategory(String slug, String language) {
|
||||||
@@ -83,7 +83,7 @@ public class PublicShopCatalogService {
|
|||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Category not found");
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Category not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildCategoryDetail(category, categoryContext);
|
return buildCategoryDetail(category, categoryContext, language);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ShopProductCatalogResponseDto getProductCatalog(String categorySlug, Boolean featuredOnly, String language) {
|
public ShopProductCatalogResponseDto getProductCatalog(String categorySlug, Boolean featuredOnly, String language) {
|
||||||
@@ -114,7 +114,7 @@ public class PublicShopCatalogService {
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
ShopCategoryDetailDto selectedCategoryDetail = selectedCategory != null
|
ShopCategoryDetailDto selectedCategoryDetail = selectedCategory != null
|
||||||
? buildCategoryDetail(selectedCategory, categoryContext)
|
? buildCategoryDetail(selectedCategory, categoryContext, language)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return new ShopProductCatalogResponseDto(
|
return new ShopProductCatalogResponseDto(
|
||||||
@@ -316,53 +316,63 @@ public class PublicShopCatalogService {
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<ShopCategoryTreeDto> buildCategoryTree(UUID parentId, CategoryContext categoryContext) {
|
private List<ShopCategoryTreeDto> buildCategoryTree(UUID parentId,
|
||||||
|
CategoryContext categoryContext,
|
||||||
|
String language) {
|
||||||
return categoryContext.childrenByParentId().getOrDefault(parentId, List.of()).stream()
|
return categoryContext.childrenByParentId().getOrDefault(parentId, List.of()).stream()
|
||||||
.map(category -> new ShopCategoryTreeDto(
|
.map(category -> new ShopCategoryTreeDto(
|
||||||
category.getId(),
|
category.getId(),
|
||||||
category.getParentCategory() != null ? category.getParentCategory().getId() : null,
|
category.getParentCategory() != null ? category.getParentCategory().getId() : null,
|
||||||
category.getSlug(),
|
category.getSlug(),
|
||||||
category.getName(),
|
category.getNameForLanguage(language),
|
||||||
category.getDescription(),
|
category.getDescriptionForLanguage(language),
|
||||||
category.getSeoTitle(),
|
category.getSeoTitleForLanguage(language),
|
||||||
category.getSeoDescription(),
|
category.getSeoDescriptionForLanguage(language),
|
||||||
category.getOgTitle(),
|
category.getOgTitle(),
|
||||||
category.getOgDescription(),
|
category.getOgDescription(),
|
||||||
category.getIndexable(),
|
category.getIndexable(),
|
||||||
category.getSortOrder(),
|
category.getSortOrder(),
|
||||||
categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0),
|
categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0),
|
||||||
selectPrimaryMedia(categoryContext.categoryMediaBySlug().get(categoryMediaUsageKey(category))),
|
selectPrimaryMedia(categoryContext.categoryMediaBySlug().get(categoryMediaUsageKey(category))),
|
||||||
buildCategoryTree(category.getId(), categoryContext)
|
buildCategoryTree(category.getId(), categoryContext, language)
|
||||||
))
|
))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ShopCategoryDetailDto buildCategoryDetail(ShopCategory category, CategoryContext categoryContext) {
|
private ShopCategoryDetailDto buildCategoryDetail(ShopCategory category,
|
||||||
|
CategoryContext categoryContext,
|
||||||
|
String language) {
|
||||||
List<PublicMediaUsageDto> images = categoryContext.categoryMediaBySlug().getOrDefault(categoryMediaUsageKey(category), List.of());
|
List<PublicMediaUsageDto> images = categoryContext.categoryMediaBySlug().getOrDefault(categoryMediaUsageKey(category), List.of());
|
||||||
|
String localizedSeoTitle = category.getSeoTitleForLanguage(language);
|
||||||
|
String localizedSeoDescription = category.getSeoDescriptionForLanguage(language);
|
||||||
return new ShopCategoryDetailDto(
|
return new ShopCategoryDetailDto(
|
||||||
category.getId(),
|
category.getId(),
|
||||||
category.getSlug(),
|
category.getSlug(),
|
||||||
category.getName(),
|
category.getNameForLanguage(language),
|
||||||
category.getDescription(),
|
category.getDescriptionForLanguage(language),
|
||||||
category.getSeoTitle(),
|
localizedSeoTitle,
|
||||||
category.getSeoDescription(),
|
localizedSeoDescription,
|
||||||
category.getOgTitle(),
|
category.getOgTitle(),
|
||||||
category.getOgDescription(),
|
category.getOgDescription(),
|
||||||
category.getIndexable(),
|
category.getIndexable(),
|
||||||
category.getSortOrder(),
|
category.getSortOrder(),
|
||||||
categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0),
|
categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0),
|
||||||
buildCategoryBreadcrumbs(category),
|
buildCategoryBreadcrumbs(category, language),
|
||||||
selectPrimaryMedia(images),
|
selectPrimaryMedia(images),
|
||||||
images,
|
images,
|
||||||
buildCategoryTree(category.getId(), categoryContext)
|
buildCategoryTree(category.getId(), categoryContext, language)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<ShopCategoryRefDto> buildCategoryBreadcrumbs(ShopCategory category) {
|
private List<ShopCategoryRefDto> buildCategoryBreadcrumbs(ShopCategory category, String language) {
|
||||||
List<ShopCategoryRefDto> breadcrumbs = new ArrayList<>();
|
List<ShopCategoryRefDto> breadcrumbs = new ArrayList<>();
|
||||||
ShopCategory current = category;
|
ShopCategory current = category;
|
||||||
while (current != null) {
|
while (current != null) {
|
||||||
breadcrumbs.add(new ShopCategoryRefDto(current.getId(), current.getSlug(), current.getName()));
|
breadcrumbs.add(new ShopCategoryRefDto(
|
||||||
|
current.getId(),
|
||||||
|
current.getSlug(),
|
||||||
|
current.getNameForLanguage(language)
|
||||||
|
));
|
||||||
current = current.getParentCategory();
|
current = current.getParentCategory();
|
||||||
}
|
}
|
||||||
java.util.Collections.reverse(breadcrumbs);
|
java.util.Collections.reverse(breadcrumbs);
|
||||||
@@ -399,11 +409,11 @@ public class PublicShopCatalogService {
|
|||||||
new ShopCategoryRefDto(
|
new ShopCategoryRefDto(
|
||||||
entry.product().getCategory().getId(),
|
entry.product().getCategory().getId(),
|
||||||
entry.product().getCategory().getSlug(),
|
entry.product().getCategory().getSlug(),
|
||||||
entry.product().getCategory().getName()
|
entry.product().getCategory().getNameForLanguage(language)
|
||||||
),
|
),
|
||||||
resolvePriceFrom(entry.variants()),
|
resolvePriceFrom(entry.variants()),
|
||||||
resolvePriceTo(entry.variants()),
|
resolvePriceTo(entry.variants()),
|
||||||
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor),
|
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
|
||||||
selectPrimaryMedia(images),
|
selectPrimaryMedia(images),
|
||||||
toProductModelDto(entry)
|
toProductModelDto(entry)
|
||||||
);
|
);
|
||||||
@@ -432,14 +442,14 @@ public class PublicShopCatalogService {
|
|||||||
new ShopCategoryRefDto(
|
new ShopCategoryRefDto(
|
||||||
entry.product().getCategory().getId(),
|
entry.product().getCategory().getId(),
|
||||||
entry.product().getCategory().getSlug(),
|
entry.product().getCategory().getSlug(),
|
||||||
entry.product().getCategory().getName()
|
entry.product().getCategory().getNameForLanguage(language)
|
||||||
),
|
),
|
||||||
buildCategoryBreadcrumbs(entry.product().getCategory()),
|
buildCategoryBreadcrumbs(entry.product().getCategory(), language),
|
||||||
resolvePriceFrom(entry.variants()),
|
resolvePriceFrom(entry.variants()),
|
||||||
resolvePriceTo(entry.variants()),
|
resolvePriceTo(entry.variants()),
|
||||||
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor),
|
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
|
||||||
entry.variants().stream()
|
entry.variants().stream()
|
||||||
.map(variant -> toVariantDto(variant, entry.defaultVariant(), variantColorHexByMaterialAndColor))
|
.map(variant -> toVariantDto(variant, entry.defaultVariant(), variantColorHexByMaterialAndColor, language))
|
||||||
.toList(),
|
.toList(),
|
||||||
selectPrimaryMedia(images),
|
selectPrimaryMedia(images),
|
||||||
images,
|
images,
|
||||||
@@ -449,7 +459,8 @@ public class PublicShopCatalogService {
|
|||||||
|
|
||||||
private ShopProductVariantOptionDto toVariantDto(ShopProductVariant variant,
|
private ShopProductVariantOptionDto toVariantDto(ShopProductVariant variant,
|
||||||
ShopProductVariant defaultVariant,
|
ShopProductVariant defaultVariant,
|
||||||
Map<String, String> variantColorHexByMaterialAndColor) {
|
Map<String, String> variantColorHexByMaterialAndColor,
|
||||||
|
String language) {
|
||||||
if (variant == null) {
|
if (variant == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -463,6 +474,7 @@ public class PublicShopCatalogService {
|
|||||||
variant.getSku(),
|
variant.getSku(),
|
||||||
variant.getVariantLabel(),
|
variant.getVariantLabel(),
|
||||||
variant.getColorName(),
|
variant.getColorName(),
|
||||||
|
variant.getColorLabelForLanguage(language),
|
||||||
colorHex,
|
colorHex,
|
||||||
variant.getPriceChf(),
|
variant.getPriceChf(),
|
||||||
defaultVariant != null && Objects.equals(defaultVariant.getId(), variant.getId())
|
defaultVariant != null && Objects.equals(defaultVariant.getId(), variant.getId())
|
||||||
|
|||||||
@@ -146,8 +146,22 @@ public class ShopSitemapService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (String locLanguage : SUPPORTED_LANGUAGES) {
|
||||||
|
String locHref = hrefByLanguage.get(locLanguage);
|
||||||
|
if (locHref == null || locHref.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
appendLocalizedUrlEntry(xml, locHref, hrefByLanguage, defaultHref, lastmod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendLocalizedUrlEntry(StringBuilder xml,
|
||||||
|
String locHref,
|
||||||
|
Map<String, String> hrefByLanguage,
|
||||||
|
String defaultHref,
|
||||||
|
OffsetDateTime lastmod) {
|
||||||
xml.append(" <url>\n");
|
xml.append(" <url>\n");
|
||||||
xml.append(" <loc>").append(xmlEscape(defaultHref)).append("</loc>\n");
|
xml.append(" <loc>").append(xmlEscape(locHref)).append("</loc>\n");
|
||||||
|
|
||||||
for (String language : SUPPORTED_LANGUAGES) {
|
for (String language : SUPPORTED_LANGUAGES) {
|
||||||
String href = hrefByLanguage.get(language);
|
String href = hrefByLanguage.get(language);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.printcalculator.controller.admin;
|
||||||
|
|
||||||
|
import com.printcalculator.config.SecurityConfig;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductResponse;
|
||||||
|
import com.printcalculator.service.admin.AdminShopProductControllerService;
|
||||||
|
import com.printcalculator.service.admin.AdminShopProductTranslationService;
|
||||||
|
import com.printcalculator.security.AdminLoginThrottleService;
|
||||||
|
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||||
|
import com.printcalculator.security.AdminSessionService;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.TransactionDefinition;
|
||||||
|
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.support.DefaultTransactionStatus;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@WebMvcTest(controllers = {AdminAuthController.class, AdminShopProductController.class})
|
||||||
|
@Import({
|
||||||
|
SecurityConfig.class,
|
||||||
|
AdminSessionAuthenticationFilter.class,
|
||||||
|
AdminSessionService.class,
|
||||||
|
AdminLoginThrottleService.class,
|
||||||
|
AdminShopProductControllerSecurityTest.TransactionTestConfig.class
|
||||||
|
})
|
||||||
|
@TestPropertySource(properties = {
|
||||||
|
"admin.password=test-admin-password",
|
||||||
|
"admin.session.secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"admin.session.ttl-minutes=60"
|
||||||
|
})
|
||||||
|
class AdminShopProductControllerSecurityTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private AdminShopProductControllerService adminShopProductControllerService;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private AdminShopProductTranslationService adminShopProductTranslationService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void translateProduct_withoutAdminCookie_shouldReturn401() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/admin/shop/products/translate")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"sourceLanguage\":\"it\",\"names\":{\"it\":\"Supporto cavo\"}}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void translateProduct_withAdminCookie_shouldReturnTranslations() throws Exception {
|
||||||
|
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
|
||||||
|
response.setSourceLanguage("it");
|
||||||
|
response.setTargetLanguages(List.of("en", "de", "fr"));
|
||||||
|
response.setNames(Map.of("en", "Desk cable clip"));
|
||||||
|
response.setExcerpts(Map.of());
|
||||||
|
response.setDescriptions(Map.of());
|
||||||
|
response.setSeoTitles(Map.of());
|
||||||
|
response.setSeoDescriptions(Map.of());
|
||||||
|
|
||||||
|
when(adminShopProductTranslationService.translateProduct(org.mockito.ArgumentMatchers.any()))
|
||||||
|
.thenReturn(response);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/admin/shop/products/translate")
|
||||||
|
.cookie(loginAndExtractCookie())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{
|
||||||
|
"sourceLanguage":"it",
|
||||||
|
"overwriteExisting":false,
|
||||||
|
"materialCodes":["PLA"],
|
||||||
|
"names":{"it":"Supporto cavo"},
|
||||||
|
"excerpts":{"it":"Accessorio tecnico"},
|
||||||
|
"descriptions":{"it":"<p>Descrizione</p>"},
|
||||||
|
"seoTitles":{"it":"SEO IT"},
|
||||||
|
"seoDescriptions":{"it":"SEO description IT"}
|
||||||
|
}
|
||||||
|
"""))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.sourceLanguage").value("it"))
|
||||||
|
.andExpect(jsonPath("$.targetLanguages[0]").value("en"))
|
||||||
|
.andExpect(jsonPath("$.names.en").value("Desk cable clip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Cookie loginAndExtractCookie() throws Exception {
|
||||||
|
MvcResult login = mockMvc.perform(post("/api/admin/auth/login")
|
||||||
|
.with(req -> {
|
||||||
|
req.setRemoteAddr("10.0.0.44");
|
||||||
|
return req;
|
||||||
|
})
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn();
|
||||||
|
|
||||||
|
String setCookie = login.getResponse().getHeader(HttpHeaders.SET_COOKIE);
|
||||||
|
assertNotNull(setCookie);
|
||||||
|
String[] parts = setCookie.split(";", 2);
|
||||||
|
String[] keyValue = parts[0].split("=", 2);
|
||||||
|
return new Cookie(keyValue[0], keyValue.length > 1 ? keyValue[1] : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
static class TransactionTestConfig {
|
||||||
|
@Bean
|
||||||
|
PlatformTransactionManager transactionManager() {
|
||||||
|
return new AbstractPlatformTransactionManager() {
|
||||||
|
@Override
|
||||||
|
protected Object doGetTransaction() {
|
||||||
|
return new Object();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doBegin(Object transaction, TransactionDefinition definition) {
|
||||||
|
// No-op transaction manager for WebMvc security tests.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doCommit(DefaultTransactionStatus status) {
|
||||||
|
// No-op transaction manager for WebMvc security tests.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doRollback(DefaultTransactionStatus status) {
|
||||||
|
// No-op transaction manager for WebMvc security tests.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.printcalculator.entity;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
class FilamentVariantTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getColorLabelForLanguageShouldReturnLocalizedValue() {
|
||||||
|
FilamentVariant variant = new FilamentVariant();
|
||||||
|
variant.setColorName("Orange");
|
||||||
|
variant.setColorLabelIt("Arancione");
|
||||||
|
variant.setColorLabelEn("Orange");
|
||||||
|
variant.setColorLabelDe("Orange");
|
||||||
|
variant.setColorLabelFr("Orange");
|
||||||
|
|
||||||
|
assertEquals("Arancione", variant.getColorLabelForLanguage("it"));
|
||||||
|
assertEquals("Orange", variant.getColorLabelForLanguage("en"));
|
||||||
|
assertEquals("Orange", variant.getColorLabelForLanguage("de-CH"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getColorLabelForLanguageShouldFallbackToColorName() {
|
||||||
|
FilamentVariant variant = new FilamentVariant();
|
||||||
|
variant.setColorName("Orange");
|
||||||
|
|
||||||
|
assertEquals("Orange", variant.getColorLabelForLanguage("it"));
|
||||||
|
assertEquals("Orange", variant.getColorLabelForLanguage("fr"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.printcalculator.entity;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
class ShopCategoryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void localizedAccessorsShouldReturnLanguageSpecificValues() {
|
||||||
|
ShopCategory category = new ShopCategory();
|
||||||
|
category.setName("Desk accessories");
|
||||||
|
category.setNameIt("Accessori da scrivania");
|
||||||
|
category.setNameEn("Desk accessories");
|
||||||
|
category.setNameDe("Schreibtischzubehor");
|
||||||
|
category.setNameFr("Accessoires de bureau");
|
||||||
|
category.setDescription("Legacy description");
|
||||||
|
category.setDescriptionIt("Organizer e accessori stampati per la scrivania.");
|
||||||
|
category.setDescriptionEn("Printed desk organizers and accessories.");
|
||||||
|
category.setDescriptionDe("Gedruckte Organizer und Zubehor fur den Schreibtisch.");
|
||||||
|
category.setDescriptionFr("Accessoires et organiseurs imprimes pour le bureau.");
|
||||||
|
category.setSeoTitle("Legacy SEO title");
|
||||||
|
category.setSeoTitleIt("Accessori da scrivania stampati in 3D");
|
||||||
|
category.setSeoTitleEn("3D printed desk accessories");
|
||||||
|
category.setSeoTitleDe("3D-gedruckte Schreibtischaccessoires");
|
||||||
|
category.setSeoTitleFr("Accessoires de bureau imprimes en 3D");
|
||||||
|
category.setSeoDescription("Legacy SEO description");
|
||||||
|
category.setSeoDescriptionIt("Accessori da scrivania personalizzati e funzionali.");
|
||||||
|
category.setSeoDescriptionEn("Functional custom desk accessories.");
|
||||||
|
category.setSeoDescriptionDe("Funktionale personalisierte Schreibtischaccessoires.");
|
||||||
|
category.setSeoDescriptionFr("Accessoires de bureau fonctionnels et personnalises.");
|
||||||
|
|
||||||
|
assertEquals("Accessori da scrivania", category.getNameForLanguage("it"));
|
||||||
|
assertEquals("Desk accessories", category.getNameForLanguage("en"));
|
||||||
|
assertEquals("Schreibtischzubehor", category.getNameForLanguage("de"));
|
||||||
|
assertEquals("Accessoires de bureau", category.getNameForLanguage("fr"));
|
||||||
|
assertEquals("Gedruckte Organizer und Zubehor fur den Schreibtisch.", category.getDescriptionForLanguage("de"));
|
||||||
|
assertEquals("3D printed desk accessories", category.getSeoTitleForLanguage("en"));
|
||||||
|
assertEquals("Accessoires de bureau fonctionnels et personnalises.", category.getSeoDescriptionForLanguage("fr"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void localizedAccessorsShouldFallbackToLegacyValues() {
|
||||||
|
ShopCategory category = new ShopCategory();
|
||||||
|
category.setName("Desk accessories");
|
||||||
|
category.setDescription("Printed desk organizers and accessories.");
|
||||||
|
category.setSeoTitle("3D printed desk accessories");
|
||||||
|
category.setSeoDescription("Functional custom desk accessories.");
|
||||||
|
|
||||||
|
assertEquals("Desk accessories", category.getNameForLanguage("it"));
|
||||||
|
assertEquals("Printed desk organizers and accessories.", category.getDescriptionForLanguage("de"));
|
||||||
|
assertEquals("3D printed desk accessories", category.getSeoTitleForLanguage("fr-CH"));
|
||||||
|
assertEquals("Functional custom desk accessories.", category.getSeoDescriptionForLanguage("en-US"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.printcalculator.entity;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
class ShopProductVariantTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getColorLabelForLanguageShouldReturnLocalizedValue() {
|
||||||
|
ShopProductVariant variant = new ShopProductVariant();
|
||||||
|
variant.setColorName("Gray");
|
||||||
|
variant.setColorLabelIt("Grigio");
|
||||||
|
variant.setColorLabelEn("Gray");
|
||||||
|
variant.setColorLabelDe("Grau");
|
||||||
|
variant.setColorLabelFr("Gris");
|
||||||
|
|
||||||
|
assertEquals("Grigio", variant.getColorLabelForLanguage("it"));
|
||||||
|
assertEquals("Gray", variant.getColorLabelForLanguage("en"));
|
||||||
|
assertEquals("Grau", variant.getColorLabelForLanguage("de"));
|
||||||
|
assertEquals("Gris", variant.getColorLabelForLanguage("fr-CH"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getColorLabelForLanguageShouldFallbackToColorName() {
|
||||||
|
ShopProductVariant variant = new ShopProductVariant();
|
||||||
|
variant.setColorName("Gray");
|
||||||
|
|
||||||
|
assertEquals("Gray", variant.getColorLabelForLanguage("it"));
|
||||||
|
assertEquals("Gray", variant.getColorLabelForLanguage("de"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,10 +40,13 @@ import java.util.Optional;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.ArgumentMatchers.isNull;
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@@ -217,6 +220,210 @@ class OrderServiceTest {
|
|||||||
verify(eventPublisher).publishEvent(any(OrderCreatedEvent.class));
|
verify(eventPublisher).publishEvent(any(OrderCreatedEvent.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createOrderFromQuote_withShopProductMissingSourceFile_shouldNotFail() throws Exception {
|
||||||
|
UUID sessionId = UUID.randomUUID();
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
UUID orderItemId = UUID.randomUUID();
|
||||||
|
|
||||||
|
QuoteSession session = new QuoteSession();
|
||||||
|
session.setId(sessionId);
|
||||||
|
session.setStatus("ACTIVE");
|
||||||
|
session.setSessionType("SHOP_CART");
|
||||||
|
session.setMaterialCode("SHOP");
|
||||||
|
session.setPricingVersion("v1");
|
||||||
|
session.setSetupCostChf(BigDecimal.ZERO);
|
||||||
|
session.setExpiresAt(OffsetDateTime.now().plusDays(30));
|
||||||
|
|
||||||
|
ShopCategory category = new ShopCategory();
|
||||||
|
category.setId(UUID.randomUUID());
|
||||||
|
category.setSlug("desk");
|
||||||
|
category.setName("Desk");
|
||||||
|
|
||||||
|
ShopProduct product = new ShopProduct();
|
||||||
|
product.setId(UUID.randomUUID());
|
||||||
|
product.setCategory(category);
|
||||||
|
product.setSlug("organizer");
|
||||||
|
product.setName("Organizer");
|
||||||
|
|
||||||
|
ShopProductVariant variant = new ShopProductVariant();
|
||||||
|
variant.setId(UUID.randomUUID());
|
||||||
|
variant.setProduct(product);
|
||||||
|
variant.setVariantLabel("PLA");
|
||||||
|
variant.setColorName("Orange");
|
||||||
|
variant.setColorHex("#ff8a00");
|
||||||
|
variant.setInternalMaterialCode("PLA");
|
||||||
|
variant.setPriceChf(new BigDecimal("18.00"));
|
||||||
|
|
||||||
|
Path missingSource = Path.of("storage_quotes")
|
||||||
|
.toAbsolutePath()
|
||||||
|
.normalize()
|
||||||
|
.resolve(sessionId.toString())
|
||||||
|
.resolve("missing-shop-item.stl");
|
||||||
|
|
||||||
|
QuoteLineItem qItem = new QuoteLineItem();
|
||||||
|
qItem.setId(UUID.randomUUID());
|
||||||
|
qItem.setQuoteSession(session);
|
||||||
|
qItem.setStatus("READY");
|
||||||
|
qItem.setLineItemType("SHOP_PRODUCT");
|
||||||
|
qItem.setOriginalFilename("organizer.stl");
|
||||||
|
qItem.setDisplayName("Organizer");
|
||||||
|
qItem.setQuantity(1);
|
||||||
|
qItem.setColorCode("Orange");
|
||||||
|
qItem.setMaterialCode("PLA");
|
||||||
|
qItem.setShopProduct(product);
|
||||||
|
qItem.setShopProductVariant(variant);
|
||||||
|
qItem.setShopProductSlug(product.getSlug());
|
||||||
|
qItem.setShopProductName(product.getName());
|
||||||
|
qItem.setShopVariantLabel("PLA");
|
||||||
|
qItem.setShopVariantColorName("Orange");
|
||||||
|
qItem.setShopVariantColorHex("#ff8a00");
|
||||||
|
qItem.setUnitPriceChf(new BigDecimal("18.00"));
|
||||||
|
qItem.setStoredPath(missingSource.toString());
|
||||||
|
|
||||||
|
when(quoteSessionRepo.findById(sessionId)).thenReturn(Optional.of(session));
|
||||||
|
when(customerRepo.findByEmail("buyer@example.com")).thenReturn(Optional.empty());
|
||||||
|
when(customerRepo.save(any(Customer.class))).thenAnswer(invocation -> {
|
||||||
|
Customer saved = invocation.getArgument(0);
|
||||||
|
if (saved.getId() == null) {
|
||||||
|
saved.setId(UUID.randomUUID());
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
});
|
||||||
|
when(quoteLineItemRepo.findByQuoteSessionId(sessionId)).thenReturn(List.of(qItem));
|
||||||
|
when(quoteSessionTotalsService.compute(eq(session), eq(List.of(qItem)))).thenReturn(
|
||||||
|
new QuoteSessionTotalsService.QuoteSessionTotals(
|
||||||
|
new BigDecimal("18.00"),
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
new BigDecimal("18.00"),
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
new BigDecimal("18.00"),
|
||||||
|
BigDecimal.ZERO
|
||||||
|
)
|
||||||
|
);
|
||||||
|
when(orderRepo.save(any(Order.class))).thenAnswer(invocation -> {
|
||||||
|
Order saved = invocation.getArgument(0);
|
||||||
|
if (saved.getId() == null) {
|
||||||
|
saved.setId(orderId);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
});
|
||||||
|
when(orderItemRepo.save(any(OrderItem.class))).thenAnswer(invocation -> {
|
||||||
|
OrderItem saved = invocation.getArgument(0);
|
||||||
|
if (saved.getId() == null) {
|
||||||
|
saved.setId(orderItemId);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
});
|
||||||
|
when(qrBillService.generateQrBillSvg(any(Order.class))).thenReturn("<svg/>".getBytes(StandardCharsets.UTF_8));
|
||||||
|
when(invoiceService.generateDocumentPdf(any(Order.class), any(List.class), eq(true), eq(qrBillService), isNull()))
|
||||||
|
.thenReturn("pdf".getBytes(StandardCharsets.UTF_8));
|
||||||
|
when(paymentService.getOrCreatePaymentForOrder(any(Order.class), eq("OTHER"))).thenReturn(new Payment());
|
||||||
|
|
||||||
|
Order order = service.createOrderFromQuote(sessionId, buildRequest());
|
||||||
|
|
||||||
|
assertEquals(orderId, order.getId());
|
||||||
|
assertEquals("CONVERTED", session.getStatus());
|
||||||
|
|
||||||
|
ArgumentCaptor<OrderItem> itemCaptor = ArgumentCaptor.forClass(OrderItem.class);
|
||||||
|
verify(orderItemRepo, times(2)).save(itemCaptor.capture());
|
||||||
|
OrderItem savedItem = itemCaptor.getAllValues().getLast();
|
||||||
|
assertEquals("PENDING", savedItem.getStoredRelativePath());
|
||||||
|
assertNull(savedItem.getFileSizeBytes());
|
||||||
|
|
||||||
|
verify(storageService, never()).store(eq(missingSource), any(Path.class));
|
||||||
|
verify(paymentService).getOrCreatePaymentForOrder(order, "OTHER");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createOrderFromQuote_withCalculatorItemMissingSourceFile_shouldFail() {
|
||||||
|
UUID sessionId = UUID.randomUUID();
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
UUID orderItemId = UUID.randomUUID();
|
||||||
|
|
||||||
|
QuoteSession session = new QuoteSession();
|
||||||
|
session.setId(sessionId);
|
||||||
|
session.setStatus("ACTIVE");
|
||||||
|
session.setSessionType("QUOTE");
|
||||||
|
session.setMaterialCode("PLA");
|
||||||
|
session.setPricingVersion("v1");
|
||||||
|
session.setSetupCostChf(BigDecimal.ZERO);
|
||||||
|
session.setExpiresAt(OffsetDateTime.now().plusDays(30));
|
||||||
|
|
||||||
|
Path missingSource = Path.of("storage_quotes")
|
||||||
|
.toAbsolutePath()
|
||||||
|
.normalize()
|
||||||
|
.resolve(sessionId.toString())
|
||||||
|
.resolve("missing-calculator-item.stl");
|
||||||
|
|
||||||
|
QuoteLineItem qItem = new QuoteLineItem();
|
||||||
|
qItem.setId(UUID.randomUUID());
|
||||||
|
qItem.setQuoteSession(session);
|
||||||
|
qItem.setStatus("READY");
|
||||||
|
qItem.setLineItemType("PRINT_FILE");
|
||||||
|
qItem.setOriginalFilename("part.stl");
|
||||||
|
qItem.setDisplayName("part.stl");
|
||||||
|
qItem.setQuantity(1);
|
||||||
|
qItem.setMaterialCode("PLA");
|
||||||
|
qItem.setUnitPriceChf(new BigDecimal("9.50"));
|
||||||
|
qItem.setStoredPath(missingSource.toString());
|
||||||
|
|
||||||
|
when(quoteSessionRepo.findById(sessionId)).thenReturn(Optional.of(session));
|
||||||
|
when(customerRepo.findByEmail("buyer@example.com")).thenReturn(Optional.empty());
|
||||||
|
when(customerRepo.save(any(Customer.class))).thenAnswer(invocation -> {
|
||||||
|
Customer saved = invocation.getArgument(0);
|
||||||
|
if (saved.getId() == null) {
|
||||||
|
saved.setId(UUID.randomUUID());
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
});
|
||||||
|
when(quoteLineItemRepo.findByQuoteSessionId(sessionId)).thenReturn(List.of(qItem));
|
||||||
|
when(quoteSessionTotalsService.compute(eq(session), eq(List.of(qItem)))).thenReturn(
|
||||||
|
new QuoteSessionTotalsService.QuoteSessionTotals(
|
||||||
|
new BigDecimal("9.50"),
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
new BigDecimal("9.50"),
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
new BigDecimal("9.50"),
|
||||||
|
BigDecimal.ZERO
|
||||||
|
)
|
||||||
|
);
|
||||||
|
when(orderRepo.save(any(Order.class))).thenAnswer(invocation -> {
|
||||||
|
Order saved = invocation.getArgument(0);
|
||||||
|
if (saved.getId() == null) {
|
||||||
|
saved.setId(orderId);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
});
|
||||||
|
when(orderItemRepo.save(any(OrderItem.class))).thenAnswer(invocation -> {
|
||||||
|
OrderItem saved = invocation.getArgument(0);
|
||||||
|
if (saved.getId() == null) {
|
||||||
|
saved.setId(orderItemId);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
});
|
||||||
|
|
||||||
|
IllegalStateException exception = assertThrows(
|
||||||
|
IllegalStateException.class,
|
||||||
|
() -> service.createOrderFromQuote(sessionId, buildRequest())
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"Source file not available for quote line item " + qItem.getId(),
|
||||||
|
exception.getMessage()
|
||||||
|
);
|
||||||
|
verify(paymentService, never()).getOrCreatePaymentForOrder(any(Order.class), eq("OTHER"));
|
||||||
|
verify(eventPublisher, never()).publishEvent(any(OrderCreatedEvent.class));
|
||||||
|
}
|
||||||
|
|
||||||
private CreateOrderRequest buildRequest() {
|
private CreateOrderRequest buildRequest() {
|
||||||
CustomerDto customer = new CustomerDto();
|
CustomerDto customer = new CustomerDto();
|
||||||
customer.setEmail("buyer@example.com");
|
customer.setEmail("buyer@example.com");
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,10 +89,16 @@ class ShopSitemapServiceTest {
|
|||||||
String xml = service.getShopSitemapXml();
|
String xml = service.getShopSitemapXml();
|
||||||
|
|
||||||
assertTrue(xml.contains("<loc>https://3d-fab.ch/it/shop/accessori</loc>"));
|
assertTrue(xml.contains("<loc>https://3d-fab.ch/it/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/fr/shop/accessori</loc>"));
|
||||||
assertTrue(xml.contains("hreflang=\"en\" href=\"https://3d-fab.ch/en/shop/accessori\""));
|
assertTrue(xml.contains("hreflang=\"en\" 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/de/shop/p/123e4567-fahrrad-halter</loc>"));
|
||||||
|
assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/p/123e4567-support-velo</loc>"));
|
||||||
assertTrue(xml.contains("hreflang=\"en\" href=\"https://3d-fab.ch/en/shop/p/123e4567-bike-holder\""));
|
assertTrue(xml.contains("hreflang=\"en\" href=\"https://3d-fab.ch/en/shop/p/123e4567-bike-holder\""));
|
||||||
assertTrue(xml.contains("hreflang=\"de\" href=\"https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter\""));
|
assertTrue(xml.contains("hreflang=\"de\" 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\""));
|
||||||
|
|||||||
116
db.sql
116
db.sql
@@ -44,6 +44,10 @@ create table filament_variant
|
|||||||
|
|
||||||
variant_display_name text not null, -- es: "PLA Nero Opaco BrandX"
|
variant_display_name text not null, -- es: "PLA Nero Opaco BrandX"
|
||||||
color_name text not null, -- Nero, Bianco, ecc.
|
color_name text not null, -- Nero, Bianco, ecc.
|
||||||
|
color_label_it text,
|
||||||
|
color_label_en text,
|
||||||
|
color_label_de text,
|
||||||
|
color_label_fr text,
|
||||||
color_hex text,
|
color_hex text,
|
||||||
finish_type text not null default 'GLOSSY'
|
finish_type text not null default 'GLOSSY'
|
||||||
check (finish_type in ('GLOSSY', 'MATTE', 'MARBLE', 'SILK', 'TRANSLUCENT', 'SPECIAL')),
|
check (finish_type in ('GLOSSY', 'MATTE', 'MARBLE', 'SILK', 'TRANSLUCENT', 'SPECIAL')),
|
||||||
@@ -70,6 +74,22 @@ select filament_variant_id,
|
|||||||
(stock_spools * spool_net_kg) as stock_kg
|
(stock_spools * spool_net_kg) as stock_kg
|
||||||
from filament_variant;
|
from filament_variant;
|
||||||
|
|
||||||
|
alter table filament_variant
|
||||||
|
add column if not exists color_label_it text,
|
||||||
|
add column if not exists color_label_en text,
|
||||||
|
add column if not exists color_label_de text,
|
||||||
|
add column if not exists color_label_fr text;
|
||||||
|
|
||||||
|
update filament_variant
|
||||||
|
set color_label_it = coalesce(nullif(btrim(color_label_it), ''), color_name),
|
||||||
|
color_label_en = coalesce(nullif(btrim(color_label_en), ''), color_name),
|
||||||
|
color_label_de = coalesce(nullif(btrim(color_label_de), ''), color_name),
|
||||||
|
color_label_fr = coalesce(nullif(btrim(color_label_fr), ''), color_name)
|
||||||
|
where nullif(btrim(color_label_it), '') is null
|
||||||
|
or nullif(btrim(color_label_en), '') is null
|
||||||
|
or nullif(btrim(color_label_de), '') is null
|
||||||
|
or nullif(btrim(color_label_fr), '') is null;
|
||||||
|
|
||||||
create table printer_machine_profile
|
create table printer_machine_profile
|
||||||
(
|
(
|
||||||
printer_machine_profile_id bigserial primary key,
|
printer_machine_profile_id bigserial primary key,
|
||||||
@@ -1013,9 +1033,25 @@ CREATE TABLE IF NOT EXISTS shop_category
|
|||||||
parent_category_id uuid REFERENCES shop_category (shop_category_id) ON DELETE SET NULL,
|
parent_category_id uuid REFERENCES shop_category (shop_category_id) ON DELETE SET NULL,
|
||||||
slug text NOT NULL UNIQUE,
|
slug text NOT NULL UNIQUE,
|
||||||
name text NOT NULL,
|
name text NOT NULL,
|
||||||
|
name_it text,
|
||||||
|
name_en text,
|
||||||
|
name_de text,
|
||||||
|
name_fr text,
|
||||||
description text,
|
description text,
|
||||||
|
description_it text,
|
||||||
|
description_en text,
|
||||||
|
description_de text,
|
||||||
|
description_fr text,
|
||||||
seo_title text,
|
seo_title text,
|
||||||
|
seo_title_it text,
|
||||||
|
seo_title_en text,
|
||||||
|
seo_title_de text,
|
||||||
|
seo_title_fr text,
|
||||||
seo_description text,
|
seo_description text,
|
||||||
|
seo_description_it text,
|
||||||
|
seo_description_en text,
|
||||||
|
seo_description_de text,
|
||||||
|
seo_description_fr text,
|
||||||
og_title text,
|
og_title text,
|
||||||
og_description text,
|
og_description text,
|
||||||
indexable boolean NOT NULL DEFAULT true,
|
indexable boolean NOT NULL DEFAULT true,
|
||||||
@@ -1034,6 +1070,66 @@ CREATE INDEX IF NOT EXISTS ix_shop_category_parent_sort
|
|||||||
CREATE INDEX IF NOT EXISTS ix_shop_category_active_sort
|
CREATE INDEX IF NOT EXISTS ix_shop_category_active_sort
|
||||||
ON shop_category (is_active, sort_order, created_at DESC);
|
ON shop_category (is_active, sort_order, created_at DESC);
|
||||||
|
|
||||||
|
ALTER TABLE shop_category
|
||||||
|
ADD COLUMN IF NOT EXISTS name_it text,
|
||||||
|
ADD COLUMN IF NOT EXISTS name_en text,
|
||||||
|
ADD COLUMN IF NOT EXISTS name_de text,
|
||||||
|
ADD COLUMN IF NOT EXISTS name_fr text,
|
||||||
|
ADD COLUMN IF NOT EXISTS description_it text,
|
||||||
|
ADD COLUMN IF NOT EXISTS description_en text,
|
||||||
|
ADD COLUMN IF NOT EXISTS description_de text,
|
||||||
|
ADD COLUMN IF NOT EXISTS description_fr text,
|
||||||
|
ADD COLUMN IF NOT EXISTS seo_title_it text,
|
||||||
|
ADD COLUMN IF NOT EXISTS seo_title_en text,
|
||||||
|
ADD COLUMN IF NOT EXISTS seo_title_de text,
|
||||||
|
ADD COLUMN IF NOT EXISTS seo_title_fr text,
|
||||||
|
ADD COLUMN IF NOT EXISTS seo_description_it text,
|
||||||
|
ADD COLUMN IF NOT EXISTS seo_description_en text,
|
||||||
|
ADD COLUMN IF NOT EXISTS seo_description_de text,
|
||||||
|
ADD COLUMN IF NOT EXISTS seo_description_fr text;
|
||||||
|
|
||||||
|
UPDATE shop_category
|
||||||
|
SET
|
||||||
|
name_it = COALESCE(NULLIF(btrim(name_it), ''), name),
|
||||||
|
name_en = COALESCE(NULLIF(btrim(name_en), ''), name),
|
||||||
|
name_de = COALESCE(NULLIF(btrim(name_de), ''), name),
|
||||||
|
name_fr = COALESCE(NULLIF(btrim(name_fr), ''), name),
|
||||||
|
description_it = COALESCE(NULLIF(btrim(description_it), ''), description),
|
||||||
|
description_en = COALESCE(NULLIF(btrim(description_en), ''), description),
|
||||||
|
description_de = COALESCE(NULLIF(btrim(description_de), ''), description),
|
||||||
|
description_fr = COALESCE(NULLIF(btrim(description_fr), ''), description),
|
||||||
|
seo_title_it = COALESCE(NULLIF(btrim(seo_title_it), ''), seo_title),
|
||||||
|
seo_title_en = COALESCE(NULLIF(btrim(seo_title_en), ''), seo_title),
|
||||||
|
seo_title_de = COALESCE(NULLIF(btrim(seo_title_de), ''), seo_title),
|
||||||
|
seo_title_fr = COALESCE(NULLIF(btrim(seo_title_fr), ''), seo_title),
|
||||||
|
seo_description_it = COALESCE(NULLIF(btrim(seo_description_it), ''), seo_description),
|
||||||
|
seo_description_en = COALESCE(NULLIF(btrim(seo_description_en), ''), seo_description),
|
||||||
|
seo_description_de = COALESCE(NULLIF(btrim(seo_description_de), ''), seo_description),
|
||||||
|
seo_description_fr = COALESCE(NULLIF(btrim(seo_description_fr), ''), seo_description)
|
||||||
|
WHERE
|
||||||
|
NULLIF(btrim(name_it), '') IS NULL
|
||||||
|
OR NULLIF(btrim(name_en), '') IS NULL
|
||||||
|
OR NULLIF(btrim(name_de), '') IS NULL
|
||||||
|
OR NULLIF(btrim(name_fr), '') IS NULL
|
||||||
|
OR (description IS NOT NULL AND (
|
||||||
|
NULLIF(btrim(description_it), '') IS NULL
|
||||||
|
OR NULLIF(btrim(description_en), '') IS NULL
|
||||||
|
OR NULLIF(btrim(description_de), '') IS NULL
|
||||||
|
OR NULLIF(btrim(description_fr), '') IS NULL
|
||||||
|
))
|
||||||
|
OR (seo_title IS NOT NULL AND (
|
||||||
|
NULLIF(btrim(seo_title_it), '') IS NULL
|
||||||
|
OR NULLIF(btrim(seo_title_en), '') IS NULL
|
||||||
|
OR NULLIF(btrim(seo_title_de), '') IS NULL
|
||||||
|
OR NULLIF(btrim(seo_title_fr), '') IS NULL
|
||||||
|
))
|
||||||
|
OR (seo_description IS NOT NULL AND (
|
||||||
|
NULLIF(btrim(seo_description_it), '') IS NULL
|
||||||
|
OR NULLIF(btrim(seo_description_en), '') IS NULL
|
||||||
|
OR NULLIF(btrim(seo_description_de), '') IS NULL
|
||||||
|
OR NULLIF(btrim(seo_description_fr), '') IS NULL
|
||||||
|
));
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS shop_product
|
CREATE TABLE IF NOT EXISTS shop_product
|
||||||
(
|
(
|
||||||
shop_product_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
shop_product_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -1165,6 +1261,10 @@ CREATE TABLE IF NOT EXISTS shop_product_variant
|
|||||||
sku text UNIQUE,
|
sku text UNIQUE,
|
||||||
variant_label text NOT NULL,
|
variant_label text NOT NULL,
|
||||||
color_name text NOT NULL,
|
color_name text NOT NULL,
|
||||||
|
color_label_it text,
|
||||||
|
color_label_en text,
|
||||||
|
color_label_de text,
|
||||||
|
color_label_fr text,
|
||||||
color_hex text,
|
color_hex text,
|
||||||
internal_material_code text NOT NULL,
|
internal_material_code text NOT NULL,
|
||||||
price_chf numeric(12, 2) NOT NULL DEFAULT 0.00 CHECK (price_chf >= 0),
|
price_chf numeric(12, 2) NOT NULL DEFAULT 0.00 CHECK (price_chf >= 0),
|
||||||
@@ -1181,6 +1281,22 @@ CREATE INDEX IF NOT EXISTS ix_shop_product_variant_product_active_sort
|
|||||||
CREATE INDEX IF NOT EXISTS ix_shop_product_variant_sku
|
CREATE INDEX IF NOT EXISTS ix_shop_product_variant_sku
|
||||||
ON shop_product_variant (sku);
|
ON shop_product_variant (sku);
|
||||||
|
|
||||||
|
ALTER TABLE shop_product_variant
|
||||||
|
ADD COLUMN IF NOT EXISTS color_label_it text,
|
||||||
|
ADD COLUMN IF NOT EXISTS color_label_en text,
|
||||||
|
ADD COLUMN IF NOT EXISTS color_label_de text,
|
||||||
|
ADD COLUMN IF NOT EXISTS color_label_fr text;
|
||||||
|
|
||||||
|
UPDATE shop_product_variant
|
||||||
|
SET color_label_it = COALESCE(NULLIF(btrim(color_label_it), ''), color_name),
|
||||||
|
color_label_en = COALESCE(NULLIF(btrim(color_label_en), ''), color_name),
|
||||||
|
color_label_de = COALESCE(NULLIF(btrim(color_label_de), ''), color_name),
|
||||||
|
color_label_fr = COALESCE(NULLIF(btrim(color_label_fr), ''), color_name)
|
||||||
|
WHERE NULLIF(btrim(color_label_it), '') IS NULL
|
||||||
|
OR NULLIF(btrim(color_label_en), '') IS NULL
|
||||||
|
OR NULLIF(btrim(color_label_de), '') IS NULL
|
||||||
|
OR NULLIF(btrim(color_label_fr), '') IS NULL;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS shop_product_model_asset
|
CREATE TABLE IF NOT EXISTS shop_product_model_asset
|
||||||
(
|
(
|
||||||
shop_product_model_asset_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
shop_product_model_asset_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -61,13 +61,13 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "500kB",
|
"maximumWarning": "600kB",
|
||||||
"maximumError": "1MB"
|
"maximumError": "1.2MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "4kB",
|
"maximumWarning": "10kB",
|
||||||
"maximumError": "8kB"
|
"maximumError": "14kB"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ Disallow: /order
|
|||||||
Disallow: /order/
|
Disallow: /order/
|
||||||
Disallow: /*/order
|
Disallow: /*/order
|
||||||
Disallow: /*/order/
|
Disallow: /*/order/
|
||||||
Disallow: /co
|
Disallow: /co$
|
||||||
Disallow: /co/
|
Disallow: /co/
|
||||||
Disallow: /*/co
|
Disallow: /*/co$
|
||||||
Disallow: /*/co/
|
Disallow: /*/co/
|
||||||
Disallow: /checkout
|
Disallow: /checkout
|
||||||
Disallow: /checkout/
|
Disallow: /checkout/
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||||
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" href="https://3d-fab.ch/it" />
|
||||||
@@ -13,66 +10,119 @@
|
|||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/en</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/de</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/fr</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/it/calculator/basic</loc>
|
<loc>https://3d-fab.ch/it/calculator/basic</loc>
|
||||||
<xhtml:link
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
rel="alternate"
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
|
||||||
hreflang="it"
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
|
||||||
href="https://3d-fab.ch/it/calculator/basic"
|
<xhtml:link rel="alternate" hreflang="fr" 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="en"
|
|
||||||
href="https://3d-fab.ch/en/calculator/basic"
|
|
||||||
/>
|
|
||||||
<xhtml:link
|
|
||||||
rel="alternate"
|
|
||||||
hreflang="de"
|
|
||||||
href="https://3d-fab.ch/de/calculator/basic"
|
|
||||||
/>
|
|
||||||
<xhtml:link
|
|
||||||
rel="alternate"
|
|
||||||
hreflang="fr"
|
|
||||||
href="https://3d-fab.ch/fr/calculator/basic"
|
|
||||||
/>
|
|
||||||
<xhtml:link
|
|
||||||
rel="alternate"
|
|
||||||
hreflang="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>
|
||||||
|
<loc>https://3d-fab.ch/en/calculator/basic</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/de/calculator/basic</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/fr/calculator/basic</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/it/calculator/advanced</loc>
|
<loc>https://3d-fab.ch/it/calculator/advanced</loc>
|
||||||
<xhtml:link
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
rel="alternate"
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
|
||||||
hreflang="it"
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
|
||||||
href="https://3d-fab.ch/it/calculator/advanced"
|
<xhtml:link rel="alternate" hreflang="fr" 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="en"
|
|
||||||
href="https://3d-fab.ch/en/calculator/advanced"
|
|
||||||
/>
|
|
||||||
<xhtml:link
|
|
||||||
rel="alternate"
|
|
||||||
hreflang="de"
|
|
||||||
href="https://3d-fab.ch/de/calculator/advanced"
|
|
||||||
/>
|
|
||||||
<xhtml:link
|
|
||||||
rel="alternate"
|
|
||||||
hreflang="fr"
|
|
||||||
href="https://3d-fab.ch/fr/calculator/advanced"
|
|
||||||
/>
|
|
||||||
<xhtml:link
|
|
||||||
rel="alternate"
|
|
||||||
hreflang="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>
|
||||||
|
<loc>https://3d-fab.ch/en/calculator/advanced</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/de/calculator/advanced</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/fr/calculator/advanced</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
<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" href="https://3d-fab.ch/it/shop" />
|
||||||
@@ -83,64 +133,160 @@
|
|||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/en/shop</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/de/shop</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/fr/shop</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
<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" 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" 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" 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" href="https://3d-fab.ch/fr/about" />
|
||||||
<xhtml:link
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
||||||
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/it/contact</loc>
|
<loc>https://3d-fab.ch/en/about</loc>
|
||||||
<xhtml:link
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
|
||||||
rel="alternate"
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
|
||||||
hreflang="it"
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
|
||||||
href="https://3d-fab.ch/it/contact"
|
<xhtml:link rel="alternate" hreflang="fr" 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="en"
|
|
||||||
href="https://3d-fab.ch/en/contact"
|
|
||||||
/>
|
|
||||||
<xhtml:link
|
|
||||||
rel="alternate"
|
|
||||||
hreflang="de"
|
|
||||||
href="https://3d-fab.ch/de/contact"
|
|
||||||
/>
|
|
||||||
<xhtml:link
|
|
||||||
rel="alternate"
|
|
||||||
hreflang="fr"
|
|
||||||
href="https://3d-fab.ch/fr/contact"
|
|
||||||
/>
|
|
||||||
<xhtml:link
|
|
||||||
rel="alternate"
|
|
||||||
hreflang="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>
|
||||||
|
<loc>https://3d-fab.ch/de/about</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/fr/about</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/it/contact</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/en/contact</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/de/contact</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/fr/contact</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
<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" 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" 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" 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" href="https://3d-fab.ch/fr/privacy" />
|
||||||
<xhtml:link
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
||||||
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>
|
||||||
|
<loc>https://3d-fab.ch/en/privacy</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/de/privacy</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/fr/privacy</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
|
||||||
<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" href="https://3d-fab.ch/it/terms" />
|
||||||
@@ -151,4 +297,34 @@
|
|||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.4</priority>
|
<priority>0.4</priority>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/en/terms</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/de/terms</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://3d-fab.ch/fr/terms</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
||||||
|
<changefreq>yearly</changefreq>
|
||||||
|
<priority>0.4</priority>
|
||||||
|
</url>
|
||||||
</urlset>
|
</urlset>
|
||||||
|
|||||||
@@ -1,26 +1,48 @@
|
|||||||
import {
|
import {
|
||||||
ApplicationConfig,
|
ApplicationConfig,
|
||||||
|
provideAppInitializer,
|
||||||
provideZoneChangeDetection,
|
provideZoneChangeDetection,
|
||||||
importProvidersFrom,
|
importProvidersFrom,
|
||||||
|
inject,
|
||||||
|
REQUEST,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
provideRouter,
|
provideRouter,
|
||||||
withComponentInputBinding,
|
withComponentInputBinding,
|
||||||
withInMemoryScrolling,
|
withInMemoryScrolling,
|
||||||
withViewTransitions,
|
withViewTransitions,
|
||||||
|
Router,
|
||||||
} from '@angular/router';
|
} from '@angular/router';
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
|
|
||||||
import {
|
import {
|
||||||
provideTranslateHttpLoader,
|
TranslateLoader,
|
||||||
TranslateHttpLoader,
|
TranslateModule,
|
||||||
} from '@ngx-translate/http-loader';
|
TranslateService,
|
||||||
|
} from '@ngx-translate/core';
|
||||||
import { adminAuthInterceptor } from './core/interceptors/admin-auth.interceptor';
|
import { adminAuthInterceptor } from './core/interceptors/admin-auth.interceptor';
|
||||||
import {
|
import {
|
||||||
provideClientHydration,
|
provideClientHydration,
|
||||||
withEventReplay,
|
withEventReplay,
|
||||||
} from '@angular/platform-browser';
|
} from '@angular/platform-browser';
|
||||||
|
import { serverOriginInterceptor } from './core/interceptors/server-origin.interceptor';
|
||||||
|
import { catchError, firstValueFrom, of } from 'rxjs';
|
||||||
|
import { StaticTranslateLoader } from './core/i18n/static-translate.loader';
|
||||||
|
|
||||||
|
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
||||||
|
const SUPPORTED_LANGS: readonly SupportedLang[] = ['it', 'en', 'de', 'fr'];
|
||||||
|
|
||||||
|
function resolveLangFromUrl(url: string): SupportedLang {
|
||||||
|
const firstSegment = (url || '/')
|
||||||
|
.split('?')[0]
|
||||||
|
.split('#')[0]
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)[0]
|
||||||
|
?.toLowerCase();
|
||||||
|
return SUPPORTED_LANGS.includes(firstSegment as SupportedLang)
|
||||||
|
? (firstSegment as SupportedLang)
|
||||||
|
: 'it';
|
||||||
|
}
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
@@ -33,20 +55,44 @@ export const appConfig: ApplicationConfig = {
|
|||||||
scrollPositionRestoration: 'top',
|
scrollPositionRestoration: 'top',
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
provideHttpClient(withInterceptors([adminAuthInterceptor])),
|
provideHttpClient(
|
||||||
provideTranslateHttpLoader({
|
withInterceptors([serverOriginInterceptor, adminAuthInterceptor]),
|
||||||
prefix: './assets/i18n/',
|
),
|
||||||
suffix: '.json',
|
|
||||||
}),
|
|
||||||
importProvidersFrom(
|
importProvidersFrom(
|
||||||
TranslateModule.forRoot({
|
TranslateModule.forRoot({
|
||||||
defaultLanguage: 'it',
|
fallbackLang: 'it',
|
||||||
loader: {
|
loader: {
|
||||||
provide: TranslateLoader,
|
provide: TranslateLoader,
|
||||||
useClass: TranslateHttpLoader,
|
useClass: StaticTranslateLoader,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
provideAppInitializer(() => {
|
||||||
|
const translate = inject(TranslateService);
|
||||||
|
const router = inject(Router);
|
||||||
|
const request = inject(REQUEST, { optional: true }) as {
|
||||||
|
url?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
translate.addLangs([...SUPPORTED_LANGS]);
|
||||||
|
translate.setFallbackLang('it');
|
||||||
|
const requestedUrl =
|
||||||
|
(typeof request?.url === 'string' && request.url) || router.url || '/';
|
||||||
|
const lang = resolveLangFromUrl(requestedUrl);
|
||||||
|
|
||||||
|
return firstValueFrom(
|
||||||
|
translate.use(lang).pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('[i18n] Failed to preload language for SSR', {
|
||||||
|
lang,
|
||||||
|
requestedUrl,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
return of({});
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).then(() => undefined);
|
||||||
|
}),
|
||||||
provideClientHydration(withEventReplay()),
|
provideClientHydration(withEventReplay()),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,9 +15,8 @@ const appChildRoutes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/home/home.component').then((m) => m.HomeComponent),
|
import('./features/home/home.component').then((m) => m.HomeComponent),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: '3D fab | Stampa 3D su misura',
|
seoTitleKey: 'SEO.ROUTES.HOME.TITLE',
|
||||||
seoDescription:
|
seoDescriptionKey: 'SEO.ROUTES.HOME.DESCRIPTION',
|
||||||
'Servizio di stampa 3D con preventivo online immediato per prototipi, piccole serie e pezzi personalizzati.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -27,9 +26,8 @@ const appChildRoutes: Routes = [
|
|||||||
(m) => m.CALCULATOR_ROUTES,
|
(m) => m.CALCULATOR_ROUTES,
|
||||||
),
|
),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Calcolatore preventivo stampa 3D | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.CALCULATOR.TITLE',
|
||||||
seoDescription:
|
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.DESCRIPTION',
|
||||||
'Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -37,9 +35,8 @@ const appChildRoutes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES),
|
import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Shop 3D fab',
|
seoTitleKey: 'SEO.ROUTES.SHOP.TITLE',
|
||||||
seoDescription:
|
seoDescriptionKey: 'SEO.ROUTES.SHOP.DESCRIPTION',
|
||||||
'Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -47,19 +44,28 @@ const appChildRoutes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES),
|
import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Chi siamo | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.ABOUT.TITLE',
|
||||||
seoDescription:
|
seoDescriptionKey: 'SEO.ROUTES.ABOUT.DESCRIPTION',
|
||||||
'Scopri il team 3D fab e il laboratorio di stampa 3D con sedi in Ticino e Bienne.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
/* {
|
||||||
|
path: 'materials',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/materials/materials-page.component').then(
|
||||||
|
(m) => m.MaterialsPageComponent,
|
||||||
|
),
|
||||||
|
data: {
|
||||||
|
seoTitleKey: 'SEO.ROUTES.MATERIALS.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.MATERIALS.DESCRIPTION',
|
||||||
|
},
|
||||||
|
},*/
|
||||||
{
|
{
|
||||||
path: 'contact',
|
path: 'contact',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES),
|
import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Contatti | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.CONTACT.TITLE',
|
||||||
seoDescription:
|
seoDescriptionKey: 'SEO.ROUTES.CONTACT.DESCRIPTION',
|
||||||
'Contatta 3D fab per preventivi, supporto tecnico e richieste personalizzate di stampa 3D.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -69,7 +75,8 @@ const appChildRoutes: Routes = [
|
|||||||
(m) => m.CheckoutComponent,
|
(m) => m.CheckoutComponent,
|
||||||
),
|
),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Checkout | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.CHECKOUT.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.CHECKOUT.DESCRIPTION',
|
||||||
seoRobots: 'noindex, nofollow',
|
seoRobots: 'noindex, nofollow',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -80,7 +87,8 @@ const appChildRoutes: Routes = [
|
|||||||
(m) => m.CheckoutComponent,
|
(m) => m.CheckoutComponent,
|
||||||
),
|
),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Checkout | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.CHECKOUT.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.CHECKOUT.DESCRIPTION',
|
||||||
seoRobots: 'noindex, nofollow',
|
seoRobots: 'noindex, nofollow',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -89,7 +97,8 @@ const appChildRoutes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/order/order.component').then((m) => m.OrderComponent),
|
import('./features/order/order.component').then((m) => m.OrderComponent),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Ordine | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.ORDER.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.ORDER.DESCRIPTION',
|
||||||
seoRobots: 'noindex, nofollow',
|
seoRobots: 'noindex, nofollow',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -98,7 +107,8 @@ const appChildRoutes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/order/order.component').then((m) => m.OrderComponent),
|
import('./features/order/order.component').then((m) => m.OrderComponent),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Ordine | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.ORDER.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.ORDER.DESCRIPTION',
|
||||||
seoRobots: 'noindex, nofollow',
|
seoRobots: 'noindex, nofollow',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -112,7 +122,8 @@ const appChildRoutes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES),
|
import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Admin | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.ADMIN.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.ADMIN.DESCRIPTION',
|
||||||
seoRobots: 'noindex, nofollow',
|
seoRobots: 'noindex, nofollow',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface ColorCategory {
|
|||||||
colors: ColorOption[];
|
colors: ColorOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BRAND_COLOR = '#facf0a';
|
||||||
|
|
||||||
export const PRODUCT_COLORS: ColorCategory[] = [
|
export const PRODUCT_COLORS: ColorCategory[] = [
|
||||||
{
|
{
|
||||||
name: 'COLOR.CATEGORY_GLOSSY',
|
name: 'COLOR.CATEGORY_GLOSSY',
|
||||||
@@ -38,10 +40,81 @@ export const PRODUCT_COLORS: ColorCategory[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getColorHex(value: string): string {
|
export function normalizeColorValue(value: string | null | undefined): string {
|
||||||
for (const cat of PRODUCT_COLORS) {
|
return String(value ?? '')
|
||||||
const found = cat.colors.find((c) => c.value === value);
|
.trim()
|
||||||
if (found) return found.hex;
|
.toLowerCase()
|
||||||
}
|
.replace(/ß/g, 'ss')
|
||||||
return '#facf0a'; // Default Brand Color if not found
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[_-]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findColorHex(value: string | null | undefined): string | null {
|
||||||
|
const normalized = normalizeColorValue(value);
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const category of PRODUCT_COLORS) {
|
||||||
|
const match = category.colors.find(
|
||||||
|
(color) => normalizeColorValue(color.value) === normalized,
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
return match.hex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalizedColorLabelSet {
|
||||||
|
fallback?: string | null;
|
||||||
|
it?: string | null;
|
||||||
|
en?: string | null;
|
||||||
|
de?: string | null;
|
||||||
|
fr?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLocalizedColorLabel(
|
||||||
|
language: string | null | undefined,
|
||||||
|
labels: LocalizedColorLabelSet,
|
||||||
|
): string | null {
|
||||||
|
const normalizedLanguage = String(language ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.split('-')[0];
|
||||||
|
|
||||||
|
const preferred =
|
||||||
|
normalizedLanguage === 'it'
|
||||||
|
? labels.it
|
||||||
|
: normalizedLanguage === 'en'
|
||||||
|
? labels.en
|
||||||
|
: normalizedLanguage === 'de'
|
||||||
|
? labels.de
|
||||||
|
: normalizedLanguage === 'fr'
|
||||||
|
? labels.fr
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
firstNonBlank(preferred, labels.fallback) ??
|
||||||
|
firstNonBlank(labels.it, labels.en, labels.de, labels.fr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstNonBlank(
|
||||||
|
...values: Array<string | null | undefined>
|
||||||
|
): string | null {
|
||||||
|
for (const value of values) {
|
||||||
|
const normalized = String(value ?? '').trim();
|
||||||
|
if (normalized) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColorHex(value: string): string {
|
||||||
|
return findColorHex(value) ?? DEFAULT_BRAND_COLOR;
|
||||||
}
|
}
|
||||||
|
|||||||
89
frontend/src/app/core/i18n/static-translate.loader.ts
Normal file
89
frontend/src/app/core/i18n/static-translate.loader.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
PLATFORM_ID,
|
||||||
|
TransferState,
|
||||||
|
inject,
|
||||||
|
makeStateKey,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { TranslateLoader, TranslationObject } from '@ngx-translate/core';
|
||||||
|
import { from, Observable } from 'rxjs';
|
||||||
|
|
||||||
|
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
||||||
|
|
||||||
|
const FALLBACK_LANG: SupportedLang = 'it';
|
||||||
|
const translationCache = new Map<SupportedLang, Promise<TranslationObject>>();
|
||||||
|
|
||||||
|
const translationLoaders: Record<SupportedLang, () => Promise<TranslationObject>> = {
|
||||||
|
it: () =>
|
||||||
|
import('../../../assets/i18n/it.json').then(
|
||||||
|
(module) => module.default as TranslationObject,
|
||||||
|
),
|
||||||
|
en: () =>
|
||||||
|
import('../../../assets/i18n/en.json').then(
|
||||||
|
(module) => module.default as TranslationObject,
|
||||||
|
),
|
||||||
|
de: () =>
|
||||||
|
import('../../../assets/i18n/de.json').then(
|
||||||
|
(module) => module.default as TranslationObject,
|
||||||
|
),
|
||||||
|
fr: () =>
|
||||||
|
import('../../../assets/i18n/fr.json').then(
|
||||||
|
(module) => module.default as TranslationObject,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StaticTranslateLoader implements TranslateLoader {
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
private readonly transferState = inject(TransferState);
|
||||||
|
|
||||||
|
getTranslation(lang: string): Observable<TranslationObject> {
|
||||||
|
const normalized = this.normalizeLanguage(lang);
|
||||||
|
return from(this.loadTranslation(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeLanguage(lang: string): SupportedLang {
|
||||||
|
const normalized = String(lang || FALLBACK_LANG).toLowerCase();
|
||||||
|
return normalized in translationLoaders
|
||||||
|
? (normalized as SupportedLang)
|
||||||
|
: FALLBACK_LANG;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadTranslation(lang: SupportedLang): Promise<TranslationObject> {
|
||||||
|
const transferStateKey =
|
||||||
|
makeStateKey<TranslationObject>(`i18n:${lang.toLowerCase()}`);
|
||||||
|
if (
|
||||||
|
isPlatformBrowser(this.platformId) &&
|
||||||
|
this.transferState.hasKey(transferStateKey)
|
||||||
|
) {
|
||||||
|
const transferred = this.transferState.get(transferStateKey, {});
|
||||||
|
this.transferState.remove(transferStateKey);
|
||||||
|
return Promise.resolve(transferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = translationCache.get(lang);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = translationLoaders[lang]()
|
||||||
|
.then((translation) => {
|
||||||
|
if (
|
||||||
|
isPlatformServer(this.platformId) &&
|
||||||
|
!this.transferState.hasKey(transferStateKey)
|
||||||
|
) {
|
||||||
|
this.transferState.set(transferStateKey, translation);
|
||||||
|
}
|
||||||
|
return translation;
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
lang === FALLBACK_LANG
|
||||||
|
? Promise.resolve({})
|
||||||
|
: this.loadTranslation(FALLBACK_LANG),
|
||||||
|
);
|
||||||
|
|
||||||
|
translationCache.set(lang, pending);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
import { inject, REQUEST } from '@angular/core';
|
||||||
|
import {
|
||||||
|
RequestLike,
|
||||||
|
resolveRequestOrigin,
|
||||||
|
} from '../../../core/request-origin';
|
||||||
|
|
||||||
|
function isAbsoluteUrl(url: string): boolean {
|
||||||
|
return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRelativePath(url: string): string {
|
||||||
|
const withoutDot = url.replace(/^\.\//, '');
|
||||||
|
return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
if (isAbsoluteUrl(req.url)) {
|
||||||
|
return next(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = inject(REQUEST, { optional: true }) as RequestLike | null;
|
||||||
|
const origin = resolveRequestOrigin(request);
|
||||||
|
if (!origin) {
|
||||||
|
return next(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
const absoluteUrl = `${origin}${normalizeRelativePath(req.url)}`;
|
||||||
|
return next(req.clone({ url: absoluteUrl }));
|
||||||
|
};
|
||||||
@@ -6,9 +6,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col links">
|
<div class="col links">
|
||||||
<a routerLink="/privacy">{{ "FOOTER.PRIVACY" | translate }}</a>
|
<a [routerLink]="languageService.localizedPath('/privacy')">{{
|
||||||
<a routerLink="/terms">{{ "FOOTER.TERMS" | translate }}</a>
|
"FOOTER.PRIVACY" | translate
|
||||||
<a routerLink="/contact">{{ "FOOTER.CONTACT" | translate }}</a>
|
}}</a>
|
||||||
|
<a [routerLink]="languageService.localizedPath('/terms')">{{
|
||||||
|
"FOOTER.TERMS" | translate
|
||||||
|
}}</a>
|
||||||
|
<a [routerLink]="languageService.localizedPath('/contact')">{{
|
||||||
|
"FOOTER.CONTACT" | translate
|
||||||
|
}}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col social">
|
<div class="col social">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { LanguageService } from '../services/language.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-footer',
|
selector: 'app-footer',
|
||||||
@@ -9,4 +10,6 @@ import { RouterLink } from '@angular/router';
|
|||||||
templateUrl: './footer.component.html',
|
templateUrl: './footer.component.html',
|
||||||
styleUrls: ['./footer.component.scss'],
|
styleUrls: ['./footer.component.scss'],
|
||||||
})
|
})
|
||||||
export class FooterComponent {}
|
export class FooterComponent {
|
||||||
|
readonly languageService = inject(LanguageService);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<header class="navbar">
|
<header class="navbar">
|
||||||
<div class="container navbar-inner">
|
<div class="container navbar-inner">
|
||||||
<a routerLink="/" class="brand">3D <span class="highlight">fab</span></a>
|
<a [routerLink]="langService.localizedPath('/')" class="brand"
|
||||||
|
>3D <span class="highlight">fab</span></a
|
||||||
|
>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="mobile-toggle"
|
class="mobile-toggle"
|
||||||
@@ -14,27 +16,33 @@
|
|||||||
|
|
||||||
<nav class="nav-links" [class.open]="isMenuOpen">
|
<nav class="nav-links" [class.open]="isMenuOpen">
|
||||||
<a
|
<a
|
||||||
routerLink="/"
|
[routerLink]="langService.localizedPath('/')"
|
||||||
routerLinkActive="active"
|
routerLinkActive="active"
|
||||||
[routerLinkActiveOptions]="{ exact: true }"
|
[routerLinkActiveOptions]="{ exact: true }"
|
||||||
(click)="closeMenu()"
|
(click)="closeMenu()"
|
||||||
>{{ "NAV.HOME" | translate }}</a
|
>{{ "NAV.HOME" | translate }}</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
routerLink="/calculator/basic"
|
[routerLink]="langService.localizedPath('/calculator/basic')"
|
||||||
routerLinkActive="active"
|
routerLinkActive="active"
|
||||||
[routerLinkActiveOptions]="{ exact: false }"
|
[routerLinkActiveOptions]="{ exact: false }"
|
||||||
(click)="closeMenu()"
|
(click)="closeMenu()"
|
||||||
>{{ "NAV.CALCULATOR" | translate }}</a
|
>{{ "NAV.CALCULATOR" | translate }}</a
|
||||||
>
|
>
|
||||||
<a routerLink="/shop" routerLinkActive="active" (click)="closeMenu()">{{
|
|
||||||
"NAV.SHOP" | translate
|
|
||||||
}}</a>
|
|
||||||
<a routerLink="/about" routerLinkActive="active" (click)="closeMenu()">{{
|
|
||||||
"NAV.ABOUT" | translate
|
|
||||||
}}</a>
|
|
||||||
<a
|
<a
|
||||||
routerLink="/contact"
|
[routerLink]="langService.localizedPath('/shop')"
|
||||||
|
routerLinkActive="active"
|
||||||
|
(click)="closeMenu()"
|
||||||
|
>{{ "NAV.SHOP" | translate }}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
[routerLink]="langService.localizedPath('/about')"
|
||||||
|
routerLinkActive="active"
|
||||||
|
(click)="closeMenu()"
|
||||||
|
>{{ "NAV.ABOUT" | translate }}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
[routerLink]="langService.localizedPath('/contact')"
|
||||||
routerLinkActive="active"
|
routerLinkActive="active"
|
||||||
(click)="closeMenu()"
|
(click)="closeMenu()"
|
||||||
>{{ "NAV.CONTACT" | translate }}</a
|
>{{ "NAV.CONTACT" | translate }}</a
|
||||||
@@ -82,7 +90,10 @@
|
|||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<div class="icon-placeholder" routerLink="/admin">
|
<div
|
||||||
|
class="icon-placeholder"
|
||||||
|
[routerLink]="langService.localizedPath('/admin')"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="20"
|
width="20"
|
||||||
@@ -130,7 +141,9 @@
|
|||||||
<div class="cart-line-copy">
|
<div class="cart-line-copy">
|
||||||
<strong>{{ cartItemName(item) }}</strong>
|
<strong>{{ cartItemName(item) }}</strong>
|
||||||
@if (cartItemVariant(item); as variant) {
|
@if (cartItemVariant(item); as variant) {
|
||||||
<span class="cart-line-meta">{{ variant }}</span>
|
<span class="cart-line-meta">{{
|
||||||
|
variant | translate
|
||||||
|
}}</span>
|
||||||
}
|
}
|
||||||
@if (cartItemColor(item); as color) {
|
@if (cartItemColor(item); as color) {
|
||||||
<span class="cart-line-color">
|
<span class="cart-line-color">
|
||||||
@@ -138,7 +151,7 @@
|
|||||||
class="color-dot"
|
class="color-dot"
|
||||||
[style.background-color]="cartItemColorHex(item)"
|
[style.background-color]="cartItemColorHex(item)"
|
||||||
></span>
|
></span>
|
||||||
<span>{{ color }}</span>
|
<span>{{ color | translate }}</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } 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,
|
||||||
@@ -15,6 +22,10 @@ import {
|
|||||||
ShopService,
|
ShopService,
|
||||||
} from '../../features/shop/services/shop.service';
|
} from '../../features/shop/services/shop.service';
|
||||||
import { finalize } from 'rxjs';
|
import { finalize } from 'rxjs';
|
||||||
|
import {
|
||||||
|
findColorHex,
|
||||||
|
resolveLocalizedColorLabel,
|
||||||
|
} from '../constants/colors.const';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-navbar',
|
selector: 'app-navbar',
|
||||||
@@ -54,16 +65,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))
|
||||||
@@ -92,6 +96,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 {
|
||||||
@@ -129,7 +136,7 @@ export class NavbarComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.closeCart();
|
this.closeCart();
|
||||||
this.router.navigate(['/checkout'], {
|
this.router.navigate(['/', this.langService.selectedLang(), 'checkout'], {
|
||||||
queryParams: {
|
queryParams: {
|
||||||
session: sessionId,
|
session: sessionId,
|
||||||
},
|
},
|
||||||
@@ -143,15 +150,30 @@ export class NavbarComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cartItemVariant(item: ShopCartItem): string | null {
|
cartItemVariant(item: ShopCartItem): string | null {
|
||||||
return item.shopVariantLabel || item.shopVariantColorName || null;
|
return item.shopVariantLabel || this.cartItemColor(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
cartItemColor(item: ShopCartItem): string | null {
|
cartItemColor(item: ShopCartItem): string | null {
|
||||||
return item.shopVariantColorName || item.colorCode || null;
|
return (
|
||||||
|
resolveLocalizedColorLabel(this.langService.selectedLang(), {
|
||||||
|
fallback: item.shopVariantColorName ?? item.colorCode,
|
||||||
|
it: item.shopVariantColorLabelIt,
|
||||||
|
en: item.shopVariantColorLabelEn,
|
||||||
|
de: item.shopVariantColorLabelDe,
|
||||||
|
fr: item.shopVariantColorLabelFr,
|
||||||
|
}) ??
|
||||||
|
item.shopVariantColorName ??
|
||||||
|
item.colorCode
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
cartItemColorHex(item: ShopCartItem): string {
|
cartItemColorHex(item: ShopCartItem): string {
|
||||||
return item.shopVariantColorHex || '#c9ced6';
|
return (
|
||||||
|
item.shopVariantColorHex ||
|
||||||
|
findColorHex(item.shopVariantColorName) ||
|
||||||
|
findColorHex(item.colorCode) ||
|
||||||
|
'#c9ced6'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
trackByCartItem(_index: number, item: ShopCartItem): string {
|
trackByCartItem(_index: number, item: ShopCartItem): string {
|
||||||
@@ -173,5 +195,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,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 });
|
||||||
@@ -75,6 +75,7 @@ describe('LanguageService', () => {
|
|||||||
const service = new LanguageService(translate, router);
|
const service = new LanguageService(translate, router);
|
||||||
|
|
||||||
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();
|
||||||
@@ -103,4 +104,15 @@ describe('LanguageService', () => {
|
|||||||
'/de/calculator?session=abc&mode=advanced',
|
'/de/calculator?session=abc&mode=advanced',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds localized paths for internal links while preserving query and hash', () => {
|
||||||
|
const translate = createTranslateMock();
|
||||||
|
const router = createRouterMock('/de/shop');
|
||||||
|
const service = new LanguageService(translate, router);
|
||||||
|
|
||||||
|
expect(service.localizedPath('/privacy')).toBe('/de/privacy');
|
||||||
|
expect(service.localizedPath('/it/contact?topic=seo#form')).toBe(
|
||||||
|
'/de/contact?topic=seo#form',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export class LanguageService {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
) {
|
) {
|
||||||
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;
|
||||||
@@ -85,6 +85,31 @@ export class LanguageService {
|
|||||||
return this.isSupportedLang(activeLang) ? activeLang : this.currentLang();
|
return this.isSupportedLang(activeLang) ? activeLang : this.currentLang();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
localizedPath(path: string): string {
|
||||||
|
const lang = this.selectedLang();
|
||||||
|
const rawValue = String(path ?? '').trim();
|
||||||
|
const normalized = rawValue || '/';
|
||||||
|
const match = normalized.match(/^([^?#]*)([?#].*)?$/);
|
||||||
|
const rawPath = match?.[1] || '/';
|
||||||
|
const suffix = match?.[2] || '';
|
||||||
|
const segments = rawPath.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return `/${lang}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isSupportedLang(segments[0])) {
|
||||||
|
segments[0] = lang;
|
||||||
|
return `/${segments.join('/')}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.looksLikeLangToken(segments[0])) {
|
||||||
|
return `/${[lang, ...segments.slice(1)].join('/')}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/${[lang, ...segments].join('/')}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
private ensureLanguageInPath(urlTree: UrlTree): void {
|
private ensureLanguageInPath(urlTree: UrlTree): void {
|
||||||
const segments = this.getPrimarySegments(urlTree);
|
const segments = this.getPrimarySegments(urlTree);
|
||||||
|
|
||||||
|
|||||||
135
frontend/src/app/core/services/seo.service.spec.ts
Normal file
135
frontend/src/app/core/services/seo.service.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { ActivatedRouteSnapshot, Router } from '@angular/router';
|
||||||
|
import { Meta, Title } from '@angular/platform-browser';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { SeoService } from './seo.service';
|
||||||
|
|
||||||
|
describe('SeoService', () => {
|
||||||
|
function createSnapshot(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
firstChild: ActivatedRouteSnapshot | null = null,
|
||||||
|
): ActivatedRouteSnapshot {
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
firstChild,
|
||||||
|
} as unknown as ActivatedRouteSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupSeoDom(): void {
|
||||||
|
document.head
|
||||||
|
.querySelectorAll(
|
||||||
|
'link[rel="canonical"], link[rel="alternate"][data-seo-managed="true"], meta[property="og:locale:alternate"][data-seo-managed="true"]',
|
||||||
|
)
|
||||||
|
.forEach((node) => node.remove());
|
||||||
|
document.documentElement.removeAttribute('lang');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService(options: {
|
||||||
|
url: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
translations: Record<string, string>;
|
||||||
|
}): {
|
||||||
|
meta: jasmine.SpyObj<Meta>;
|
||||||
|
title: jasmine.SpyObj<Title>;
|
||||||
|
} {
|
||||||
|
const events$ = new Subject<unknown>();
|
||||||
|
const title = jasmine.createSpyObj<Title>('Title', ['setTitle']);
|
||||||
|
const meta = jasmine.createSpyObj<Meta>('Meta', ['updateTag']);
|
||||||
|
const translate = {
|
||||||
|
instant: (key: string) => options.translations[key] ?? key,
|
||||||
|
} as TranslateService;
|
||||||
|
const router = {
|
||||||
|
url: options.url,
|
||||||
|
events: events$.asObservable(),
|
||||||
|
routerState: {
|
||||||
|
snapshot: {
|
||||||
|
root: createSnapshot(options.data),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Router;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const service = new SeoService(router, title, meta, translate, document);
|
||||||
|
|
||||||
|
return { meta, title };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanupSeoDom();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupSeoDom();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds the language prefix to canonical and hreflang URLs', () => {
|
||||||
|
const { meta, title } = createService({
|
||||||
|
url: '/privacy?utm=test',
|
||||||
|
data: {
|
||||||
|
seoTitleKey: 'SEO.ROUTES.LEGAL.PRIVACY.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.LEGAL.PRIVACY.DESCRIPTION',
|
||||||
|
},
|
||||||
|
translations: {
|
||||||
|
'SEO.ROUTES.LEGAL.PRIVACY.TITLE': 'Privacy Policy | 3D fab',
|
||||||
|
'SEO.ROUTES.LEGAL.PRIVACY.DESCRIPTION': 'Privacy description',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(title.setTitle).toHaveBeenCalledWith('Privacy Policy | 3D fab');
|
||||||
|
|
||||||
|
const canonical = document.head.querySelector(
|
||||||
|
'link[rel="canonical"]',
|
||||||
|
) as HTMLLinkElement | null;
|
||||||
|
expect(canonical?.getAttribute('href')).toBe(
|
||||||
|
`${document.location.origin}/it/privacy`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const alternates = Array.from(
|
||||||
|
document.head.querySelectorAll(
|
||||||
|
'link[rel="alternate"][data-seo-managed="true"]',
|
||||||
|
),
|
||||||
|
).map((node) => ({
|
||||||
|
hreflang: node.getAttribute('hreflang'),
|
||||||
|
href: node.getAttribute('href'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(alternates).toContain({
|
||||||
|
hreflang: 'en',
|
||||||
|
href: `${document.location.origin}/en/privacy`,
|
||||||
|
});
|
||||||
|
expect(alternates).toContain({
|
||||||
|
hreflang: 'x-default',
|
||||||
|
href: `${document.location.origin}/it/privacy`,
|
||||||
|
});
|
||||||
|
expect(document.documentElement.lang).toBe('it');
|
||||||
|
|
||||||
|
const ogUrlCall = meta.updateTag.calls
|
||||||
|
.allArgs()
|
||||||
|
.find(([tag]) => tag.property === 'og:url');
|
||||||
|
expect(ogUrlCall?.[0].content).toBe(
|
||||||
|
`${document.location.origin}/it/privacy`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves translated route metadata for the active language', () => {
|
||||||
|
const { meta, title } = createService({
|
||||||
|
url: '/en/about',
|
||||||
|
data: {
|
||||||
|
seoTitleKey: 'SEO.ROUTES.ABOUT.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.ABOUT.DESCRIPTION',
|
||||||
|
},
|
||||||
|
translations: {
|
||||||
|
'SEO.ROUTES.ABOUT.TITLE': 'About Us | 3D fab',
|
||||||
|
'SEO.ROUTES.ABOUT.DESCRIPTION': 'About description',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(title.setTitle).toHaveBeenCalledWith('About Us | 3D fab');
|
||||||
|
|
||||||
|
const descriptionCall = meta.updateTag.calls
|
||||||
|
.allArgs()
|
||||||
|
.find(([tag]) => tag.name === 'description');
|
||||||
|
expect(descriptionCall?.[0].content).toBe('About description');
|
||||||
|
expect(document.documentElement.lang).toBe('en');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,29 +2,66 @@ import { DOCUMENT } from '@angular/common';
|
|||||||
import { Inject, Injectable } from '@angular/core';
|
import { Inject, Injectable } from '@angular/core';
|
||||||
import { Title, Meta } from '@angular/platform-browser';
|
import { Title, Meta } from '@angular/platform-browser';
|
||||||
import { ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router';
|
import { ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { filter } from 'rxjs/operators';
|
import { filter } from 'rxjs/operators';
|
||||||
|
|
||||||
export interface PageSeoOverride {
|
export interface PageSeoOverride {
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
|
titleKey?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
descriptionKey?: string | null;
|
||||||
robots?: string | null;
|
robots?: string | null;
|
||||||
ogTitle?: string | null;
|
ogTitle?: string | null;
|
||||||
|
ogTitleKey?: string | null;
|
||||||
ogDescription?: string | null;
|
ogDescription?: string | null;
|
||||||
|
ogDescriptionKey?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
||||||
|
type SeoMap = Partial<Record<SupportedLang, string>>;
|
||||||
|
type SeoTextDataKey =
|
||||||
|
| 'seoTitle'
|
||||||
|
| 'seoDescription'
|
||||||
|
| 'ogTitle'
|
||||||
|
| 'ogDescription';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class SeoService {
|
export class SeoService {
|
||||||
private readonly defaultTitle = '3D fab | Stampa 3D su misura';
|
private readonly defaultTitleByLang: Record<SupportedLang, string> = {
|
||||||
private readonly defaultDescription =
|
it: '3D fab | Stampa 3D su misura',
|
||||||
'Stampa 3D su misura con preventivo online immediato. Carica il file, scegli materiale e qualità, ricevi prezzo e tempi in pochi secondi.';
|
en: '3D fab | Custom 3D Printing',
|
||||||
private readonly supportedLangs = new Set(['it', 'en', 'de', 'fr']);
|
de: '3D fab | 3D-Druck nach Maß',
|
||||||
|
fr: '3D fab | Impression 3D sur mesure',
|
||||||
|
};
|
||||||
|
private readonly defaultDescriptionByLang: Record<SupportedLang, string> = {
|
||||||
|
it: 'Servizio di stampa 3D su misura, shop tecnico e supporto CAD per prototipi, ricambi e piccole serie.',
|
||||||
|
en: 'Custom 3D printing service, technical shop and CAD support for prototypes, spare parts and short runs.',
|
||||||
|
de: '3D-Druckservice nach Maß, technischer Shop und CAD-Support für Prototypen, Ersatzteile und Kleinserien.',
|
||||||
|
fr: "Service d'impression 3D sur mesure, boutique technique et support CAD pour prototypes, pièces et petites séries.",
|
||||||
|
};
|
||||||
|
private readonly supportedLangs: readonly SupportedLang[] = [
|
||||||
|
'it',
|
||||||
|
'en',
|
||||||
|
'de',
|
||||||
|
'fr',
|
||||||
|
];
|
||||||
|
private readonly supportedLangSet = new Set<SupportedLang>(
|
||||||
|
this.supportedLangs,
|
||||||
|
);
|
||||||
|
private readonly ogLocaleByLang: Record<SupportedLang, string> = {
|
||||||
|
it: 'it_IT',
|
||||||
|
en: 'en_US',
|
||||||
|
de: 'de_DE',
|
||||||
|
fr: 'fr_FR',
|
||||||
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private titleService: Title,
|
private titleService: Title,
|
||||||
private metaService: Meta,
|
private metaService: Meta,
|
||||||
|
private translate: TranslateService,
|
||||||
@Inject(DOCUMENT) private document: Document,
|
@Inject(DOCUMENT) private document: Document,
|
||||||
) {
|
) {
|
||||||
this.applyRouteSeo(this.router.routerState.snapshot.root);
|
this.applyRouteSeo(this.router.routerState.snapshot.root);
|
||||||
@@ -40,27 +77,61 @@ export class SeoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyPageSeo(override: PageSeoOverride): void {
|
applyPageSeo(override: PageSeoOverride): void {
|
||||||
const title = this.asString(override.title) ?? this.defaultTitle;
|
const cleanPath = this.getCleanPath(this.router.url);
|
||||||
|
const lang = this.resolveLangFromPath(cleanPath);
|
||||||
|
const title =
|
||||||
|
this.resolveOverrideSeoText(override.title, override.titleKey) ??
|
||||||
|
this.defaultTitle(lang);
|
||||||
const description =
|
const description =
|
||||||
this.asString(override.description) ?? this.defaultDescription;
|
this.resolveOverrideSeoText(
|
||||||
|
override.description,
|
||||||
|
override.descriptionKey,
|
||||||
|
) ?? this.defaultDescription(lang);
|
||||||
const robots = this.asString(override.robots) ?? 'index, follow';
|
const robots = this.asString(override.robots) ?? 'index, follow';
|
||||||
const ogTitle = this.asString(override.ogTitle) ?? title;
|
const ogTitle =
|
||||||
const ogDescription = this.asString(override.ogDescription) ?? description;
|
this.resolveOverrideSeoText(override.ogTitle, override.ogTitleKey) ??
|
||||||
|
title;
|
||||||
|
const ogDescription =
|
||||||
|
this.resolveOverrideSeoText(
|
||||||
|
override.ogDescription,
|
||||||
|
override.ogDescriptionKey,
|
||||||
|
) ?? description;
|
||||||
|
|
||||||
this.applySeoValues(title, description, robots, ogTitle, ogDescription);
|
this.applySeoValues(
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
robots,
|
||||||
|
ogTitle,
|
||||||
|
ogDescription,
|
||||||
|
cleanPath,
|
||||||
|
lang,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void {
|
private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void {
|
||||||
const mergedData = this.getMergedRouteData(rootSnapshot);
|
const mergedData = this.getMergedRouteData(rootSnapshot);
|
||||||
const title = this.asString(mergedData['seoTitle']) ?? this.defaultTitle;
|
const cleanPath = this.getCleanPath(this.router.url);
|
||||||
|
const lang = this.resolveLangFromPath(cleanPath);
|
||||||
|
const title =
|
||||||
|
this.resolveSeoText(mergedData, 'seoTitle', lang) ??
|
||||||
|
this.defaultTitle(lang);
|
||||||
const description =
|
const description =
|
||||||
this.asString(mergedData['seoDescription']) ?? this.defaultDescription;
|
this.resolveSeoText(mergedData, 'seoDescription', lang) ??
|
||||||
|
this.defaultDescription(lang);
|
||||||
const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow';
|
const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow';
|
||||||
const ogTitle = this.asString(mergedData['ogTitle']) ?? title;
|
const ogTitle = this.resolveSeoText(mergedData, 'ogTitle', lang) ?? title;
|
||||||
const ogDescription =
|
const ogDescription =
|
||||||
this.asString(mergedData['ogDescription']) ?? description;
|
this.resolveSeoText(mergedData, 'ogDescription', lang) ?? description;
|
||||||
|
|
||||||
this.applySeoValues(title, description, robots, ogTitle, ogDescription);
|
this.applySeoValues(
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
robots,
|
||||||
|
ogTitle,
|
||||||
|
ogDescription,
|
||||||
|
cleanPath,
|
||||||
|
lang,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private applySeoValues(
|
private applySeoValues(
|
||||||
@@ -69,6 +140,8 @@ export class SeoService {
|
|||||||
robots: string,
|
robots: string,
|
||||||
ogTitle: string,
|
ogTitle: string,
|
||||||
ogDescription: string,
|
ogDescription: string,
|
||||||
|
cleanPath: string,
|
||||||
|
lang: SupportedLang,
|
||||||
): void {
|
): void {
|
||||||
this.titleService.setTitle(title);
|
this.titleService.setTitle(title);
|
||||||
this.metaService.updateTag({ name: 'description', content: description });
|
this.metaService.updateTag({ name: 'description', content: description });
|
||||||
@@ -79,13 +152,20 @@ export class SeoService {
|
|||||||
content: ogDescription,
|
content: ogDescription,
|
||||||
});
|
});
|
||||||
this.metaService.updateTag({ property: 'og:type', content: 'website' });
|
this.metaService.updateTag({ property: 'og:type', content: 'website' });
|
||||||
|
this.metaService.updateTag({ property: 'og:site_name', content: '3D fab' });
|
||||||
this.metaService.updateTag({ name: 'twitter:card', content: 'summary' });
|
this.metaService.updateTag({ name: 'twitter:card', content: 'summary' });
|
||||||
|
this.metaService.updateTag({ name: 'twitter:title', content: ogTitle });
|
||||||
|
this.metaService.updateTag({
|
||||||
|
name: 'twitter:description',
|
||||||
|
content: ogDescription,
|
||||||
|
});
|
||||||
|
|
||||||
const cleanPath = this.getCleanPath(this.router.url);
|
const canonicalPath = this.buildLocalizedPath(cleanPath, lang);
|
||||||
const canonical = `${this.document.location.origin}${cleanPath}`;
|
const canonical = `${this.document.location.origin}${canonicalPath}`;
|
||||||
this.metaService.updateTag({ property: 'og:url', content: canonical });
|
this.metaService.updateTag({ property: 'og:url', content: canonical });
|
||||||
this.updateCanonicalTag(canonical);
|
this.updateCanonicalTag(canonical);
|
||||||
this.updateLangAndAlternates(cleanPath);
|
this.updateOpenGraphLocales(lang);
|
||||||
|
this.updateLangAndAlternates(canonicalPath, lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMergedRouteData(
|
private getMergedRouteData(
|
||||||
@@ -104,11 +184,97 @@ export class SeoService {
|
|||||||
return typeof value === 'string' ? value : undefined;
|
return typeof value === 'string' ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveOverrideSeoText(
|
||||||
|
value: string | null | undefined,
|
||||||
|
key: string | null | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
return this.asString(value) ?? this.resolveTranslation(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveSeoText(
|
||||||
|
routeData: Record<string, unknown>,
|
||||||
|
key: SeoTextDataKey,
|
||||||
|
lang: SupportedLang,
|
||||||
|
): string | undefined {
|
||||||
|
const mapKey = `${key}ByLang`;
|
||||||
|
const localized = routeData[mapKey];
|
||||||
|
if (
|
||||||
|
localized &&
|
||||||
|
typeof localized === 'object' &&
|
||||||
|
!Array.isArray(localized)
|
||||||
|
) {
|
||||||
|
const mapped = localized as SeoMap;
|
||||||
|
const byLang = this.asString(mapped[lang]);
|
||||||
|
if (byLang) {
|
||||||
|
return byLang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const translated = this.resolveTranslation(routeData[`${key}Key`]);
|
||||||
|
if (translated) {
|
||||||
|
return translated;
|
||||||
|
}
|
||||||
|
return this.asString(routeData[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveTranslation(value: unknown): string | undefined {
|
||||||
|
const key = this.asString(value)?.trim();
|
||||||
|
if (!key) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const translated = this.translate.instant(key);
|
||||||
|
return typeof translated === 'string' && translated !== key
|
||||||
|
? translated
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private defaultTitle(lang: SupportedLang): string {
|
||||||
|
return (
|
||||||
|
this.resolveTranslation('SEO.DEFAULT.TITLE') ??
|
||||||
|
this.defaultTitleByLang[lang]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private defaultDescription(lang: SupportedLang): string {
|
||||||
|
return (
|
||||||
|
this.resolveTranslation('SEO.DEFAULT.DESCRIPTION') ??
|
||||||
|
this.defaultDescriptionByLang[lang]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private getCleanPath(url: string): string {
|
private getCleanPath(url: string): string {
|
||||||
const path = (url || '/').split('?')[0].split('#')[0];
|
const path = (url || '/').split('?')[0].split('#')[0];
|
||||||
return path || '/';
|
return path || '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveLangFromPath(path: string): SupportedLang {
|
||||||
|
const firstSegment = path.split('/').filter(Boolean)[0]?.toLowerCase();
|
||||||
|
if (
|
||||||
|
firstSegment &&
|
||||||
|
this.supportedLangSet.has(firstSegment as SupportedLang)
|
||||||
|
) {
|
||||||
|
return firstSegment as SupportedLang;
|
||||||
|
}
|
||||||
|
return 'it';
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildLocalizedPath(path: string, lang: SupportedLang): string {
|
||||||
|
const segments = path.split('/').filter(Boolean);
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return `/${lang}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstSegment = segments[0]?.toLowerCase();
|
||||||
|
if (
|
||||||
|
firstSegment &&
|
||||||
|
this.supportedLangSet.has(firstSegment as SupportedLang)
|
||||||
|
) {
|
||||||
|
segments[0] = lang;
|
||||||
|
return `/${segments.join('/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/${[lang, ...segments].join('/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
private updateCanonicalTag(url: string): void {
|
private updateCanonicalTag(url: string): void {
|
||||||
let link = this.document.head.querySelector(
|
let link = this.document.head.querySelector(
|
||||||
'link[rel="canonical"]',
|
'link[rel="canonical"]',
|
||||||
@@ -121,14 +287,31 @@ export class SeoService {
|
|||||||
link.setAttribute('href', url);
|
link.setAttribute('href', url);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateLangAndAlternates(path: string): void {
|
private updateOpenGraphLocales(lang: SupportedLang): void {
|
||||||
const segments = path.split('/').filter(Boolean);
|
this.metaService.updateTag({
|
||||||
const firstSegment = segments[0]?.toLowerCase();
|
property: 'og:locale',
|
||||||
const hasLang = Boolean(
|
content: this.ogLocaleByLang[lang],
|
||||||
firstSegment && this.supportedLangs.has(firstSegment),
|
});
|
||||||
);
|
|
||||||
const lang = hasLang ? firstSegment : 'it';
|
this.document.head
|
||||||
const suffixSegments = hasLang ? segments.slice(1) : segments;
|
.querySelectorAll(
|
||||||
|
'meta[property="og:locale:alternate"][data-seo-managed="true"]',
|
||||||
|
)
|
||||||
|
.forEach((node) => node.remove());
|
||||||
|
|
||||||
|
for (const alternateLang of this.supportedLangs) {
|
||||||
|
if (alternateLang === lang) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.appendOgLocaleAlternate(this.ogLocaleByLang[alternateLang]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateLangAndAlternates(
|
||||||
|
localizedPath: string,
|
||||||
|
lang: SupportedLang,
|
||||||
|
): void {
|
||||||
|
const suffixSegments = localizedPath.split('/').filter(Boolean).slice(1);
|
||||||
const suffix =
|
const suffix =
|
||||||
suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : '';
|
suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : '';
|
||||||
|
|
||||||
@@ -138,7 +321,7 @@ export class SeoService {
|
|||||||
.querySelectorAll('link[rel="alternate"][data-seo-managed="true"]')
|
.querySelectorAll('link[rel="alternate"][data-seo-managed="true"]')
|
||||||
.forEach((node) => node.remove());
|
.forEach((node) => node.remove());
|
||||||
|
|
||||||
for (const alt of ['it', 'en', 'de', 'fr']) {
|
for (const alt of this.supportedLangs) {
|
||||||
this.appendAlternateLink(
|
this.appendAlternateLink(
|
||||||
alt,
|
alt,
|
||||||
`${this.document.location.origin}/${alt}${suffix}`,
|
`${this.document.location.origin}/${alt}${suffix}`,
|
||||||
@@ -158,4 +341,12 @@ export class SeoService {
|
|||||||
link.setAttribute('data-seo-managed', 'true');
|
link.setAttribute('data-seo-managed', 'true');
|
||||||
this.document.head.appendChild(link);
|
this.document.head.appendChild(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private appendOgLocaleAlternate(locale: string): void {
|
||||||
|
const meta = this.document.createElement('meta');
|
||||||
|
meta.setAttribute('property', 'og:locale:alternate');
|
||||||
|
meta.setAttribute('content', locale);
|
||||||
|
meta.setAttribute('data-seo-managed', 'true');
|
||||||
|
this.document.head.appendChild(meta);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ export const ABOUT_ROUTES: Routes = [
|
|||||||
path: '',
|
path: '',
|
||||||
component: AboutPageComponent,
|
component: AboutPageComponent,
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Chi siamo | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.ABOUT.TITLE',
|
||||||
seoDescription:
|
seoDescriptionKey: 'SEO.ROUTES.ABOUT.DESCRIPTION',
|
||||||
'Siamo un laboratorio di stampa 3D orientato a prototipi, ricambi e produzioni su misura.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -101,6 +101,22 @@
|
|||||||
placeholder="Nero, Bianco..."
|
placeholder="Nero, Bianco..."
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Label IT</span>
|
||||||
|
<input type="text" [(ngModel)]="newVariant.colorLabelIt" />
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Label EN</span>
|
||||||
|
<input type="text" [(ngModel)]="newVariant.colorLabelEn" />
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Label DE</span>
|
||||||
|
<input type="text" [(ngModel)]="newVariant.colorLabelDe" />
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Label FR</span>
|
||||||
|
<input type="text" [(ngModel)]="newVariant.colorLabelFr" />
|
||||||
|
</label>
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
<span>Hex colore</span>
|
<span>Hex colore</span>
|
||||||
<input
|
<input
|
||||||
@@ -229,7 +245,7 @@
|
|||||||
class="color-dot"
|
class="color-dot"
|
||||||
[style.background-color]="getVariantColorHex(variant)"
|
[style.background-color]="getVariantColorHex(variant)"
|
||||||
></span>
|
></span>
|
||||||
{{ variant.colorName || "N/D" }}
|
{{ variant.colorLabelIt || variant.colorName || "N/D" }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
>Stock spools:
|
>Stock spools:
|
||||||
@@ -290,6 +306,22 @@
|
|||||||
<span>Colore</span>
|
<span>Colore</span>
|
||||||
<input type="text" [(ngModel)]="variant.colorName" />
|
<input type="text" [(ngModel)]="variant.colorName" />
|
||||||
</label>
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Label IT</span>
|
||||||
|
<input type="text" [(ngModel)]="variant.colorLabelIt" />
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Label EN</span>
|
||||||
|
<input type="text" [(ngModel)]="variant.colorLabelEn" />
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Label DE</span>
|
||||||
|
<input type="text" [(ngModel)]="variant.colorLabelDe" />
|
||||||
|
</label>
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Label FR</span>
|
||||||
|
<input type="text" [(ngModel)]="variant.colorLabelFr" />
|
||||||
|
</label>
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
<span>Hex colore</span>
|
<span>Hex colore</span>
|
||||||
<input type="text" [(ngModel)]="variant.colorHex" />
|
<input type="text" [(ngModel)]="variant.colorHex" />
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ export class AdminFilamentStockComponent implements OnInit {
|
|||||||
materialTypeId: 0,
|
materialTypeId: 0,
|
||||||
variantDisplayName: '',
|
variantDisplayName: '',
|
||||||
colorName: '',
|
colorName: '',
|
||||||
|
colorLabelIt: '',
|
||||||
|
colorLabelEn: '',
|
||||||
|
colorLabelDe: '',
|
||||||
|
colorLabelFr: '',
|
||||||
colorHex: '',
|
colorHex: '',
|
||||||
finishType: 'GLOSSY',
|
finishType: 'GLOSSY',
|
||||||
brand: '',
|
brand: '',
|
||||||
@@ -206,6 +210,10 @@ export class AdminFilamentStockComponent implements OnInit {
|
|||||||
this.newVariant.materialTypeId || this.materials[0]?.id || 0,
|
this.newVariant.materialTypeId || this.materials[0]?.id || 0,
|
||||||
variantDisplayName: '',
|
variantDisplayName: '',
|
||||||
colorName: '',
|
colorName: '',
|
||||||
|
colorLabelIt: '',
|
||||||
|
colorLabelEn: '',
|
||||||
|
colorLabelDe: '',
|
||||||
|
colorLabelFr: '',
|
||||||
colorHex: '',
|
colorHex: '',
|
||||||
finishType: 'GLOSSY',
|
finishType: 'GLOSSY',
|
||||||
brand: '',
|
brand: '',
|
||||||
@@ -359,6 +367,10 @@ export class AdminFilamentStockComponent implements OnInit {
|
|||||||
materialTypeId: Number(source.materialTypeId),
|
materialTypeId: Number(source.materialTypeId),
|
||||||
variantDisplayName: (source.variantDisplayName || '').trim(),
|
variantDisplayName: (source.variantDisplayName || '').trim(),
|
||||||
colorName: (source.colorName || '').trim(),
|
colorName: (source.colorName || '').trim(),
|
||||||
|
colorLabelIt: (source.colorLabelIt || '').trim() || undefined,
|
||||||
|
colorLabelEn: (source.colorLabelEn || '').trim() || undefined,
|
||||||
|
colorLabelDe: (source.colorLabelDe || '').trim() || undefined,
|
||||||
|
colorLabelFr: (source.colorLabelFr || '').trim() || undefined,
|
||||||
colorHex: (source.colorHex || '').trim() || undefined,
|
colorHex: (source.colorHex || '').trim() || undefined,
|
||||||
finishType: (source.finishType || 'GLOSSY').trim().toUpperCase(),
|
finishType: (source.finishType || 'GLOSSY').trim().toUpperCase(),
|
||||||
brand: (source.brand || '').trim() || undefined,
|
brand: (source.brand || '').trim() || undefined,
|
||||||
|
|||||||
@@ -206,17 +206,6 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="ui-form-field">
|
|
||||||
<span class="ui-form-caption">Nome categoria</span>
|
|
||||||
<input
|
|
||||||
class="ui-form-control"
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="categoryForm.name"
|
|
||||||
name="categoryName"
|
|
||||||
placeholder="Desk accessories"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="ui-form-field">
|
<label class="ui-form-field">
|
||||||
<span class="ui-form-caption">Slug</span>
|
<span class="ui-form-caption">Slug</span>
|
||||||
<div class="input-with-action">
|
<div class="input-with-action">
|
||||||
@@ -237,36 +226,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="ui-form-field form-field--wide">
|
|
||||||
<span class="ui-form-caption">Descrizione</span>
|
|
||||||
<textarea
|
|
||||||
class="ui-form-control textarea-control"
|
|
||||||
[(ngModel)]="categoryForm.description"
|
|
||||||
name="categoryDescription"
|
|
||||||
rows="3"
|
|
||||||
></textarea>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="ui-form-field">
|
|
||||||
<span class="ui-form-caption">SEO title</span>
|
|
||||||
<input
|
|
||||||
class="ui-form-control"
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="categoryForm.seoTitle"
|
|
||||||
name="categorySeoTitle"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="ui-form-field">
|
|
||||||
<span class="ui-form-caption">SEO description</span>
|
|
||||||
<input
|
|
||||||
class="ui-form-control"
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="categoryForm.seoDescription"
|
|
||||||
name="categorySeoDescription"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="ui-form-field">
|
<label class="ui-form-field">
|
||||||
<span class="ui-form-caption">OG title</span>
|
<span class="ui-form-caption">OG title</span>
|
||||||
<input
|
<input
|
||||||
@@ -288,6 +247,141 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="ui-language-toolbar">
|
||||||
|
<div class="ui-language-toolbar__copy">
|
||||||
|
<span>Lingua contenuti categoria</span>
|
||||||
|
<p>IT / EN / DE / FR</p>
|
||||||
|
</div>
|
||||||
|
<div class="ui-language-toolbar__toggle">
|
||||||
|
<button
|
||||||
|
*ngFor="let language of shopLanguages"
|
||||||
|
type="button"
|
||||||
|
class="ui-language-toolbar__button image-language-button"
|
||||||
|
[class.active]="activeContentLanguage === language"
|
||||||
|
[class.complete]="isCategoryContentLanguageComplete(language)"
|
||||||
|
[class.incomplete]="
|
||||||
|
isCategoryContentLanguageIncomplete(language)
|
||||||
|
"
|
||||||
|
[class.empty]="!isCategoryContentLanguageStarted(language)"
|
||||||
|
(click)="setActiveContentLanguage(language)"
|
||||||
|
>
|
||||||
|
<span class="image-language-button__label">
|
||||||
|
{{ languageLabels[language] }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="image-language-button__state"
|
||||||
|
*ngIf="isCategoryContentLanguageComplete(language)"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="image-language-button__state"
|
||||||
|
*ngIf="isCategoryContentLanguageIncomplete(language)"
|
||||||
|
>
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ui-form-grid ui-form-grid--two">
|
||||||
|
<label class="ui-form-field">
|
||||||
|
<span class="ui-form-caption">
|
||||||
|
Nome categoria {{ languageLabels[activeContentLanguage] }}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
class="ui-form-control"
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="categoryForm.names[activeContentLanguage]"
|
||||||
|
[name]="'category-name-' + activeContentLanguage"
|
||||||
|
placeholder="Desk accessories"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="ui-form-field form-field--wide">
|
||||||
|
<span class="ui-form-caption">
|
||||||
|
Descrizione {{ languageLabels[activeContentLanguage] }}
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
class="ui-form-control textarea-control"
|
||||||
|
[(ngModel)]="categoryForm.descriptions[activeContentLanguage]"
|
||||||
|
[name]="'category-description-' + activeContentLanguage"
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ui-language-toolbar">
|
||||||
|
<div class="ui-language-toolbar__copy">
|
||||||
|
<span>Lingua SEO categoria</span>
|
||||||
|
<p>Stessa lingua attiva dell'editor</p>
|
||||||
|
</div>
|
||||||
|
<div class="ui-language-toolbar__toggle">
|
||||||
|
<button
|
||||||
|
*ngFor="let language of shopLanguages"
|
||||||
|
type="button"
|
||||||
|
class="ui-language-toolbar__button image-language-button"
|
||||||
|
[class.active]="activeContentLanguage === language"
|
||||||
|
[class.complete]="isCategorySeoLanguageComplete(language)"
|
||||||
|
[class.incomplete]="isCategorySeoLanguageIncomplete(language)"
|
||||||
|
[class.empty]="!isCategorySeoLanguageStarted(language)"
|
||||||
|
(click)="setActiveContentLanguage(language)"
|
||||||
|
>
|
||||||
|
<span class="image-language-button__label">
|
||||||
|
{{ languageLabels[language] }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="image-language-button__state"
|
||||||
|
*ngIf="isCategorySeoLanguageComplete(language)"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="image-language-button__state"
|
||||||
|
*ngIf="isCategorySeoLanguageIncomplete(language)"
|
||||||
|
>
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ui-form-grid ui-form-grid--two">
|
||||||
|
<label class="ui-form-field">
|
||||||
|
<span class="ui-form-caption">
|
||||||
|
SEO title {{ languageLabels[activeContentLanguage] }}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
class="ui-form-control"
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="categoryForm.seoTitles[activeContentLanguage]"
|
||||||
|
[name]="'category-seo-title-' + activeContentLanguage"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="ui-form-field form-field--wide">
|
||||||
|
<span class="ui-form-caption">
|
||||||
|
SEO description {{ languageLabels[activeContentLanguage] }}
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
class="ui-form-control"
|
||||||
|
[(ngModel)]="
|
||||||
|
categoryForm.seoDescriptions[activeContentLanguage]
|
||||||
|
"
|
||||||
|
[name]="'category-seo-description-' + activeContentLanguage"
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
<span
|
||||||
|
class="seo-counter"
|
||||||
|
[class.seo-counter--danger]="
|
||||||
|
categorySeoDescriptionLength(activeContentLanguage) > 160
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ categorySeoDescriptionLength(activeContentLanguage) }}/160
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toggle-row">
|
<div class="toggle-row">
|
||||||
<label class="ui-checkbox">
|
<label class="ui-checkbox">
|
||||||
<input
|
<input
|
||||||
@@ -574,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">
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
AdminShopProductModel,
|
AdminShopProductModel,
|
||||||
AdminShopProductVariant,
|
AdminShopProductVariant,
|
||||||
AdminShopService,
|
AdminShopService,
|
||||||
|
AdminTranslateShopProductPayload,
|
||||||
|
AdminTranslateShopProductResponse,
|
||||||
AdminUpsertShopCategoryPayload,
|
AdminUpsertShopCategoryPayload,
|
||||||
AdminUpsertShopProductPayload,
|
AdminUpsertShopProductPayload,
|
||||||
AdminUpsertShopProductVariantPayload,
|
AdminUpsertShopProductVariantPayload,
|
||||||
@@ -41,10 +43,10 @@ interface CategoryFormState {
|
|||||||
id: string | null;
|
id: string | null;
|
||||||
parentCategoryId: string | null;
|
parentCategoryId: string | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
names: Record<ShopLanguage, string>;
|
||||||
description: string;
|
descriptions: Record<ShopLanguage, string>;
|
||||||
seoTitle: string;
|
seoTitles: Record<ShopLanguage, string>;
|
||||||
seoDescription: string;
|
seoDescriptions: Record<ShopLanguage, string>;
|
||||||
ogTitle: string;
|
ogTitle: string;
|
||||||
ogDescription: string;
|
ogDescription: string;
|
||||||
indexable: boolean;
|
indexable: boolean;
|
||||||
@@ -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;
|
||||||
@@ -554,7 +558,56 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
slugifyCategoryFromName(): void {
|
slugifyCategoryFromName(): void {
|
||||||
this.categoryForm.slug = this.slugify(this.categoryForm.name);
|
const source =
|
||||||
|
this.categoryForm.names[this.activeContentLanguage] ||
|
||||||
|
this.categoryForm.names['it'];
|
||||||
|
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 {
|
||||||
@@ -603,6 +656,45 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isCategoryContentLanguageComplete(language: ShopLanguage): boolean {
|
||||||
|
return !!this.categoryForm.names[language].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
isCategoryContentLanguageStarted(language: ShopLanguage): boolean {
|
||||||
|
return (
|
||||||
|
!!this.categoryForm.names[language].trim() ||
|
||||||
|
!!this.categoryForm.descriptions[language].trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
isCategoryContentLanguageIncomplete(language: ShopLanguage): boolean {
|
||||||
|
return (
|
||||||
|
this.isCategoryContentLanguageStarted(language) &&
|
||||||
|
!this.isCategoryContentLanguageComplete(language)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
isCategorySeoLanguageComplete(language: ShopLanguage): boolean {
|
||||||
|
return (
|
||||||
|
!!this.categoryForm.seoTitles[language].trim() &&
|
||||||
|
!!this.categoryForm.seoDescriptions[language].trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
isCategorySeoLanguageStarted(language: ShopLanguage): boolean {
|
||||||
|
return (
|
||||||
|
!!this.categoryForm.seoTitles[language].trim() ||
|
||||||
|
!!this.categoryForm.seoDescriptions[language].trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
isCategorySeoLanguageIncomplete(language: ShopLanguage): boolean {
|
||||||
|
return (
|
||||||
|
this.isCategorySeoLanguageStarted(language) &&
|
||||||
|
!this.isCategorySeoLanguageComplete(language)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
preventRichTextToolbarMouseDown(event: MouseEvent): void {
|
preventRichTextToolbarMouseDown(event: MouseEvent): void {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
@@ -1228,10 +1320,10 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
id: null,
|
id: null,
|
||||||
parentCategoryId: null,
|
parentCategoryId: null,
|
||||||
slug: '',
|
slug: '',
|
||||||
name: '',
|
names: this.createEmptyLocalizedTextRecord(),
|
||||||
description: '',
|
descriptions: this.createEmptyLocalizedTextRecord(),
|
||||||
seoTitle: '',
|
seoTitles: this.createEmptyLocalizedTextRecord(),
|
||||||
seoDescription: '',
|
seoDescriptions: this.createEmptyLocalizedTextRecord(),
|
||||||
ogTitle: '',
|
ogTitle: '',
|
||||||
ogDescription: '',
|
ogDescription: '',
|
||||||
indexable: true,
|
indexable: true,
|
||||||
@@ -1241,6 +1333,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resetCategoryForm(): void {
|
private resetCategoryForm(): void {
|
||||||
|
this.activeContentLanguage = 'it';
|
||||||
Object.assign(this.categoryForm, this.createEmptyCategoryForm());
|
Object.assign(this.categoryForm, this.createEmptyCategoryForm());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1249,10 +1342,30 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
id: category.id,
|
id: category.id,
|
||||||
parentCategoryId: category.parentCategoryId,
|
parentCategoryId: category.parentCategoryId,
|
||||||
slug: category.slug ?? '',
|
slug: category.slug ?? '',
|
||||||
name: category.name ?? '',
|
names: {
|
||||||
description: category.description ?? '',
|
it: category.nameIt ?? category.name ?? '',
|
||||||
seoTitle: category.seoTitle ?? '',
|
en: category.nameEn ?? category.name ?? '',
|
||||||
seoDescription: category.seoDescription ?? '',
|
de: category.nameDe ?? category.name ?? '',
|
||||||
|
fr: category.nameFr ?? category.name ?? '',
|
||||||
|
},
|
||||||
|
descriptions: {
|
||||||
|
it: category.descriptionIt ?? category.description ?? '',
|
||||||
|
en: category.descriptionEn ?? category.description ?? '',
|
||||||
|
de: category.descriptionDe ?? category.description ?? '',
|
||||||
|
fr: category.descriptionFr ?? category.description ?? '',
|
||||||
|
},
|
||||||
|
seoTitles: {
|
||||||
|
it: category.seoTitleIt ?? category.seoTitle ?? '',
|
||||||
|
en: category.seoTitleEn ?? category.seoTitle ?? '',
|
||||||
|
de: category.seoTitleDe ?? category.seoTitle ?? '',
|
||||||
|
fr: category.seoTitleFr ?? category.seoTitle ?? '',
|
||||||
|
},
|
||||||
|
seoDescriptions: {
|
||||||
|
it: category.seoDescriptionIt ?? category.seoDescription ?? '',
|
||||||
|
en: category.seoDescriptionEn ?? category.seoDescription ?? '',
|
||||||
|
de: category.seoDescriptionDe ?? category.seoDescription ?? '',
|
||||||
|
fr: category.seoDescriptionFr ?? category.seoDescription ?? '',
|
||||||
|
},
|
||||||
ogTitle: category.ogTitle ?? '',
|
ogTitle: category.ogTitle ?? '',
|
||||||
ogDescription: category.ogDescription ?? '',
|
ogDescription: category.ogDescription ?? '',
|
||||||
indexable: category.indexable,
|
indexable: category.indexable,
|
||||||
@@ -1265,10 +1378,36 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
return {
|
return {
|
||||||
parentCategoryId: this.categoryForm.parentCategoryId || null,
|
parentCategoryId: this.categoryForm.parentCategoryId || null,
|
||||||
slug: this.categoryForm.slug.trim(),
|
slug: this.categoryForm.slug.trim(),
|
||||||
name: this.categoryForm.name.trim(),
|
name: this.categoryForm.names['it'].trim(),
|
||||||
description: this.categoryForm.description.trim(),
|
nameIt: this.categoryForm.names['it'].trim(),
|
||||||
seoTitle: this.categoryForm.seoTitle.trim(),
|
nameEn: this.categoryForm.names['en'].trim(),
|
||||||
seoDescription: this.categoryForm.seoDescription.trim(),
|
nameDe: this.categoryForm.names['de'].trim(),
|
||||||
|
nameFr: this.categoryForm.names['fr'].trim(),
|
||||||
|
description: this.optionalValue(this.categoryForm.descriptions['it']),
|
||||||
|
descriptionIt: this.optionalValue(this.categoryForm.descriptions['it']),
|
||||||
|
descriptionEn: this.optionalValue(this.categoryForm.descriptions['en']),
|
||||||
|
descriptionDe: this.optionalValue(this.categoryForm.descriptions['de']),
|
||||||
|
descriptionFr: this.optionalValue(this.categoryForm.descriptions['fr']),
|
||||||
|
seoTitle: this.optionalValue(this.categoryForm.seoTitles['it']),
|
||||||
|
seoTitleIt: this.optionalValue(this.categoryForm.seoTitles['it']),
|
||||||
|
seoTitleEn: this.optionalValue(this.categoryForm.seoTitles['en']),
|
||||||
|
seoTitleDe: this.optionalValue(this.categoryForm.seoTitles['de']),
|
||||||
|
seoTitleFr: this.optionalValue(this.categoryForm.seoTitles['fr']),
|
||||||
|
seoDescription: this.optionalValue(
|
||||||
|
this.categoryForm.seoDescriptions['it'],
|
||||||
|
),
|
||||||
|
seoDescriptionIt: this.optionalValue(
|
||||||
|
this.categoryForm.seoDescriptions['it'],
|
||||||
|
),
|
||||||
|
seoDescriptionEn: this.optionalValue(
|
||||||
|
this.categoryForm.seoDescriptions['en'],
|
||||||
|
),
|
||||||
|
seoDescriptionDe: this.optionalValue(
|
||||||
|
this.categoryForm.seoDescriptions['de'],
|
||||||
|
),
|
||||||
|
seoDescriptionFr: this.optionalValue(
|
||||||
|
this.categoryForm.seoDescriptions['fr'],
|
||||||
|
),
|
||||||
ogTitle: this.categoryForm.ogTitle.trim(),
|
ogTitle: this.categoryForm.ogTitle.trim(),
|
||||||
ogDescription: this.categoryForm.ogDescription.trim(),
|
ogDescription: this.categoryForm.ogDescription.trim(),
|
||||||
indexable: this.categoryForm.indexable,
|
indexable: this.categoryForm.indexable,
|
||||||
@@ -1278,12 +1417,19 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private validateCategoryForm(): string | null {
|
private validateCategoryForm(): string | null {
|
||||||
if (!this.categoryForm.name.trim()) {
|
for (const language of this.shopLanguages) {
|
||||||
return 'Il nome categoria è obbligatorio.';
|
if (!this.categoryForm.names[language].trim()) {
|
||||||
|
return `Il nome categoria ${this.languageLabels[language]} è obbligatorio.`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!this.categoryForm.slug.trim()) {
|
if (!this.categoryForm.slug.trim()) {
|
||||||
return 'Lo slug categoria è obbligatorio.';
|
return 'Lo slug categoria è obbligatorio.';
|
||||||
}
|
}
|
||||||
|
for (const language of this.shopLanguages) {
|
||||||
|
if (this.categoryForm.seoDescriptions[language].trim().length > 160) {
|
||||||
|
return `La SEO description categoria ${this.languageLabels[language]} deve avere massimo 160 caratteri.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1573,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) => [
|
||||||
@@ -1616,6 +1854,10 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
sku: this.optionalValue(existingVariant?.sku ?? ''),
|
sku: this.optionalValue(existingVariant?.sku ?? ''),
|
||||||
variantLabel: materialCode,
|
variantLabel: materialCode,
|
||||||
colorName: stockVariant.colorName.trim(),
|
colorName: stockVariant.colorName.trim(),
|
||||||
|
colorLabelIt: this.optionalValue(stockVariant.colorLabelIt ?? ''),
|
||||||
|
colorLabelEn: this.optionalValue(stockVariant.colorLabelEn ?? ''),
|
||||||
|
colorLabelDe: this.optionalValue(stockVariant.colorLabelDe ?? ''),
|
||||||
|
colorLabelFr: this.optionalValue(stockVariant.colorLabelFr ?? ''),
|
||||||
colorHex: this.optionalValue(
|
colorHex: this.optionalValue(
|
||||||
stockVariant.colorHex ?? '',
|
stockVariant.colorHex ?? '',
|
||||||
)?.toUpperCase(),
|
)?.toUpperCase(),
|
||||||
@@ -1714,7 +1956,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private stockVariantLabel(variant: AdminFilamentVariant): string {
|
private stockVariantLabel(variant: AdminFilamentVariant): string {
|
||||||
const colorName = variant.colorName.trim();
|
const colorName = (variant.colorLabelIt || variant.colorName).trim();
|
||||||
const variantDisplayName = variant.variantDisplayName.trim();
|
const variantDisplayName = variant.variantDisplayName.trim();
|
||||||
if (
|
if (
|
||||||
variantDisplayName &&
|
variantDisplayName &&
|
||||||
@@ -2193,6 +2435,19 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
return this.productForm.seoDescriptions[language].trim().length;
|
return this.productForm.seoDescriptions[language].trim().length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
categorySeoDescriptionLength(language: ShopLanguage): number {
|
||||||
|
return this.categoryForm.seoDescriptions[language].trim().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createEmptyLocalizedTextRecord(): Record<ShopLanguage, string> {
|
||||||
|
return {
|
||||||
|
it: '',
|
||||||
|
en: '',
|
||||||
|
de: '',
|
||||||
|
fr: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private slugify(source: string): string {
|
private slugify(source: string): string {
|
||||||
return source
|
return source
|
||||||
.normalize('NFD')
|
.normalize('NFD')
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export interface AdminFilamentVariant {
|
|||||||
materialTechnicalTypeLabel?: string;
|
materialTechnicalTypeLabel?: string;
|
||||||
variantDisplayName: string;
|
variantDisplayName: string;
|
||||||
colorName: string;
|
colorName: string;
|
||||||
|
colorLabelIt: string;
|
||||||
|
colorLabelEn: string;
|
||||||
|
colorLabelDe: string;
|
||||||
|
colorLabelFr: string;
|
||||||
colorHex?: string;
|
colorHex?: string;
|
||||||
finishType?: string;
|
finishType?: string;
|
||||||
brand?: string;
|
brand?: string;
|
||||||
@@ -57,6 +61,10 @@ export interface AdminUpsertFilamentVariantPayload {
|
|||||||
materialTypeId: number;
|
materialTypeId: number;
|
||||||
variantDisplayName: string;
|
variantDisplayName: string;
|
||||||
colorName: string;
|
colorName: string;
|
||||||
|
colorLabelIt?: string;
|
||||||
|
colorLabelEn?: string;
|
||||||
|
colorLabelDe?: string;
|
||||||
|
colorLabelFr?: string;
|
||||||
colorHex?: string;
|
colorHex?: string;
|
||||||
finishType?: string;
|
finishType?: string;
|
||||||
brand?: string;
|
brand?: string;
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import {
|
||||||
|
HttpClientTestingModule,
|
||||||
|
HttpTestingController,
|
||||||
|
} from '@angular/common/http/testing';
|
||||||
|
import {
|
||||||
|
AdminShopService,
|
||||||
|
AdminTranslateShopProductPayload,
|
||||||
|
} from './admin-shop.service';
|
||||||
|
|
||||||
|
describe('AdminShopService', () => {
|
||||||
|
let service: AdminShopService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [HttpClientTestingModule],
|
||||||
|
providers: [AdminShopService],
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(AdminShopService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpMock.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts product translation requests with credentials', () => {
|
||||||
|
const payload: AdminTranslateShopProductPayload = {
|
||||||
|
categoryId: 'category-1',
|
||||||
|
sourceLanguage: 'it',
|
||||||
|
overwriteExisting: false,
|
||||||
|
materialCodes: ['PLA', 'PETG'],
|
||||||
|
names: {
|
||||||
|
it: 'Supporto cavo scrivania',
|
||||||
|
en: '',
|
||||||
|
de: '',
|
||||||
|
fr: '',
|
||||||
|
},
|
||||||
|
excerpts: {
|
||||||
|
it: 'Accessorio tecnico',
|
||||||
|
en: '',
|
||||||
|
de: '',
|
||||||
|
fr: '',
|
||||||
|
},
|
||||||
|
descriptions: {
|
||||||
|
it: '<p>Descrizione prodotto</p>',
|
||||||
|
en: '',
|
||||||
|
de: '',
|
||||||
|
fr: '',
|
||||||
|
},
|
||||||
|
seoTitles: {
|
||||||
|
it: 'Supporto cavo scrivania | 3D fab',
|
||||||
|
en: '',
|
||||||
|
de: '',
|
||||||
|
fr: '',
|
||||||
|
},
|
||||||
|
seoDescriptions: {
|
||||||
|
it: 'Supporto tecnico stampato in 3D per scrivania.',
|
||||||
|
en: '',
|
||||||
|
de: '',
|
||||||
|
fr: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
service.translateProduct(payload).subscribe((response) => {
|
||||||
|
expect(response.targetLanguages).toEqual(['en', 'de', 'fr']);
|
||||||
|
expect(response.names.en).toBe('Desk cable clip');
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = httpMock.expectOne(
|
||||||
|
'http://localhost:8000/api/admin/shop/products/translate',
|
||||||
|
);
|
||||||
|
expect(request.request.method).toBe('POST');
|
||||||
|
expect(request.request.withCredentials).toBeTrue();
|
||||||
|
expect(request.request.body).toEqual(payload);
|
||||||
|
|
||||||
|
request.flush({
|
||||||
|
sourceLanguage: 'it',
|
||||||
|
targetLanguages: ['en', 'de', 'fr'],
|
||||||
|
names: {
|
||||||
|
en: 'Desk cable clip',
|
||||||
|
de: 'Schreibtisch-Kabelhalter',
|
||||||
|
fr: 'Support de cable de bureau',
|
||||||
|
},
|
||||||
|
excerpts: {},
|
||||||
|
descriptions: {},
|
||||||
|
seoTitles: {},
|
||||||
|
seoDescriptions: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,8 @@ export interface AdminMediaTextTranslation {
|
|||||||
altText: string;
|
altText: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdminShopLanguage = 'it' | 'en' | 'de' | 'fr';
|
||||||
|
|
||||||
export interface AdminShopCategoryRef {
|
export interface AdminShopCategoryRef {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -30,9 +32,25 @@ export interface AdminShopCategory {
|
|||||||
parentCategoryName: string | null;
|
parentCategoryName: string | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
nameIt: string;
|
||||||
|
nameEn: string;
|
||||||
|
nameDe: string;
|
||||||
|
nameFr: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
descriptionIt: string | null;
|
||||||
|
descriptionEn: string | null;
|
||||||
|
descriptionDe: string | null;
|
||||||
|
descriptionFr: string | null;
|
||||||
seoTitle: string | null;
|
seoTitle: string | null;
|
||||||
|
seoTitleIt: string | null;
|
||||||
|
seoTitleEn: string | null;
|
||||||
|
seoTitleDe: string | null;
|
||||||
|
seoTitleFr: string | null;
|
||||||
seoDescription: string | null;
|
seoDescription: string | null;
|
||||||
|
seoDescriptionIt: string | null;
|
||||||
|
seoDescriptionEn: string | null;
|
||||||
|
seoDescriptionDe: string | null;
|
||||||
|
seoDescriptionFr: string | null;
|
||||||
ogTitle: string | null;
|
ogTitle: string | null;
|
||||||
ogDescription: string | null;
|
ogDescription: string | null;
|
||||||
indexable: boolean;
|
indexable: boolean;
|
||||||
@@ -54,9 +72,25 @@ export interface AdminUpsertShopCategoryPayload {
|
|||||||
parentCategoryId?: string | null;
|
parentCategoryId?: string | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
nameIt: string;
|
||||||
|
nameEn: string;
|
||||||
|
nameDe: string;
|
||||||
|
nameFr: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
descriptionIt?: string;
|
||||||
|
descriptionEn?: string;
|
||||||
|
descriptionDe?: string;
|
||||||
|
descriptionFr?: string;
|
||||||
seoTitle?: string;
|
seoTitle?: string;
|
||||||
|
seoTitleIt?: string;
|
||||||
|
seoTitleEn?: string;
|
||||||
|
seoTitleDe?: string;
|
||||||
|
seoTitleFr?: string;
|
||||||
seoDescription?: string;
|
seoDescription?: string;
|
||||||
|
seoDescriptionIt?: string;
|
||||||
|
seoDescriptionEn?: string;
|
||||||
|
seoDescriptionDe?: string;
|
||||||
|
seoDescriptionFr?: string;
|
||||||
ogTitle?: string;
|
ogTitle?: string;
|
||||||
ogDescription?: string;
|
ogDescription?: string;
|
||||||
indexable: boolean;
|
indexable: boolean;
|
||||||
@@ -69,6 +103,10 @@ export interface AdminShopProductVariant {
|
|||||||
sku: string | null;
|
sku: string | null;
|
||||||
variantLabel: string;
|
variantLabel: string;
|
||||||
colorName: string;
|
colorName: string;
|
||||||
|
colorLabelIt: string;
|
||||||
|
colorLabelEn: string;
|
||||||
|
colorLabelDe: string;
|
||||||
|
colorLabelFr: string;
|
||||||
colorHex: string | null;
|
colorHex: string | null;
|
||||||
internalMaterialCode: string;
|
internalMaterialCode: string;
|
||||||
priceChf: number;
|
priceChf: number;
|
||||||
@@ -170,6 +208,10 @@ export interface AdminUpsertShopProductVariantPayload {
|
|||||||
sku?: string;
|
sku?: string;
|
||||||
variantLabel?: string;
|
variantLabel?: string;
|
||||||
colorName: string;
|
colorName: string;
|
||||||
|
colorLabelIt?: string;
|
||||||
|
colorLabelEn?: string;
|
||||||
|
colorLabelDe?: string;
|
||||||
|
colorLabelFr?: string;
|
||||||
colorHex?: string;
|
colorHex?: string;
|
||||||
internalMaterialCode: string;
|
internalMaterialCode: string;
|
||||||
priceChf: number;
|
priceChf: number;
|
||||||
@@ -215,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',
|
||||||
})
|
})
|
||||||
@@ -311,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,
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ export const CALCULATOR_ROUTES: Routes = [
|
|||||||
component: CalculatorPageComponent,
|
component: CalculatorPageComponent,
|
||||||
data: {
|
data: {
|
||||||
mode: 'easy',
|
mode: 'easy',
|
||||||
seoTitle: 'Calcolatore stampa 3D base | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.CALCULATOR.BASIC.TITLE',
|
||||||
seoDescription:
|
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.BASIC.DESCRIPTION',
|
||||||
'Calcola rapidamente il prezzo della tua stampa 3D in modalita base.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -18,9 +17,8 @@ export const CALCULATOR_ROUTES: Routes = [
|
|||||||
component: CalculatorPageComponent,
|
component: CalculatorPageComponent,
|
||||||
data: {
|
data: {
|
||||||
mode: 'advanced',
|
mode: 'advanced',
|
||||||
seoTitle: 'Calcolatore stampa 3D avanzato | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.CALCULATOR.ADVANCED.TITLE',
|
||||||
seoDescription:
|
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.ADVANCED.DESCRIPTION',
|
||||||
'Configura parametri avanzati e ottieni un preventivo preciso con slicing reale.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -101,9 +101,11 @@
|
|||||||
|
|
||||||
<p class="upload-privacy-note">
|
<p class="upload-privacy-note">
|
||||||
{{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
|
{{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
|
||||||
<a href="/privacy" target="_blank" rel="noopener">{{
|
<a
|
||||||
"LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate
|
[href]="languageService.localizedPath('/privacy')"
|
||||||
}}</a
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>{{ "LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate }}</a
|
||||||
>.
|
>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
VariantOption,
|
VariantOption,
|
||||||
} from '../../services/quote-estimator.service';
|
} from '../../services/quote-estimator.service';
|
||||||
import { getColorHex } from '../../../../core/constants/colors.const';
|
import { getColorHex } from '../../../../core/constants/colors.const';
|
||||||
|
import { LanguageService } from '../../../../core/services/language.service';
|
||||||
|
|
||||||
interface FormItem {
|
interface FormItem {
|
||||||
file: File;
|
file: File;
|
||||||
@@ -106,6 +107,7 @@ export class UploadFormComponent implements OnInit {
|
|||||||
private estimator = inject(QuoteEstimatorService);
|
private estimator = inject(QuoteEstimatorService);
|
||||||
private fb = inject(FormBuilder);
|
private fb = inject(FormBuilder);
|
||||||
private translate = inject(TranslateService);
|
private translate = inject(TranslateService);
|
||||||
|
readonly languageService = inject(LanguageService);
|
||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
|
|
||||||
|
|||||||
@@ -120,13 +120,18 @@
|
|||||||
<input type="checkbox" formControlName="acceptLegal" />
|
<input type="checkbox" formControlName="acceptLegal" />
|
||||||
<span>
|
<span>
|
||||||
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
|
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
|
||||||
<a href="/terms" target="_blank" rel="noopener">{{
|
<a
|
||||||
"LEGAL.CONSENT.TERMS_LINK" | translate
|
[href]="languageService.localizedPath('/terms')"
|
||||||
}}</a>
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>{{ "LEGAL.CONSENT.TERMS_LINK" | translate }}</a
|
||||||
|
>
|
||||||
{{ "LEGAL.CONSENT.AND" | translate }}
|
{{ "LEGAL.CONSENT.AND" | translate }}
|
||||||
<a href="/privacy" target="_blank" rel="noopener">{{
|
<a
|
||||||
"LEGAL.CONSENT.PRIVACY_LINK" | translate
|
[href]="languageService.localizedPath('/privacy')"
|
||||||
}}</a
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>{{ "LEGAL.CONSENT.PRIVACY_LINK" | translate }}</a
|
||||||
>.
|
>.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, input, output, signal } from '@angular/core';
|
import { Component, inject, input, output, signal } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
@@ -11,6 +11,7 @@ import { AppCardComponent } from '../../../../shared/components/app-card/app-car
|
|||||||
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
|
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
|
||||||
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
||||||
import { QuoteResult } from '../../services/quote-estimator.service';
|
import { QuoteResult } from '../../services/quote-estimator.service';
|
||||||
|
import { LanguageService } from '../../../../core/services/language.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-details',
|
selector: 'app-user-details',
|
||||||
@@ -30,6 +31,7 @@ export class UserDetailsComponent {
|
|||||||
quote = input<QuoteResult>();
|
quote = input<QuoteResult>();
|
||||||
submitOrder = output<any>();
|
submitOrder = output<any>();
|
||||||
cancel = output<void>();
|
cancel = output<void>();
|
||||||
|
readonly languageService = inject(LanguageService);
|
||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
submitting = signal(false);
|
submitting = signal(false);
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ export interface VariantOption {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
colorName: string;
|
colorName: string;
|
||||||
|
colorLabelIt?: string;
|
||||||
|
colorLabelEn?: string;
|
||||||
|
colorLabelDe?: string;
|
||||||
|
colorLabelFr?: string;
|
||||||
hexColor: string;
|
hexColor: string;
|
||||||
finishType: string;
|
finishType: string;
|
||||||
stockSpools: number;
|
stockSpools: number;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<div class="checkout-page">
|
<div class="checkout-page">
|
||||||
<div class="container ui-page-hero">
|
<div class="container ui-page-hero ui-page-hero--spacious checkout-hero">
|
||||||
<h1 class="ui-page-title">{{ "CHECKOUT.TITLE" | translate }}</h1>
|
<h1 class="ui-page-title">{{ "CHECKOUT.TITLE" | translate }}</h1>
|
||||||
<p class="ui-page-subtitle cad-subtitle" *ngIf="isCadSession()">
|
<p class="ui-page-subtitle cad-subtitle" *ngIf="isCadSession()">
|
||||||
Servizio CAD
|
{{ "CHECKOUT.CAD_SERVICE" | translate }}
|
||||||
<ng-container *ngIf="cadRequestId()">
|
<ng-container *ngIf="cadRequestId()">
|
||||||
riferito alla richiesta contatto #{{ cadRequestId() }}
|
{{ "CHECKOUT.CAD_REQUEST_REF" | translate: { id: cadRequestId() } }}
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,13 +204,18 @@
|
|||||||
<span class="ui-checkbox__mark"></span>
|
<span class="ui-checkbox__mark"></span>
|
||||||
<span>
|
<span>
|
||||||
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
|
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
|
||||||
<a href="/terms" target="_blank" rel="noopener">{{
|
<a
|
||||||
"LEGAL.CONSENT.TERMS_LINK" | translate
|
[href]="languageService.localizedPath('/terms')"
|
||||||
}}</a>
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>{{ "LEGAL.CONSENT.TERMS_LINK" | translate }}</a
|
||||||
|
>
|
||||||
{{ "LEGAL.CONSENT.AND" | translate }}
|
{{ "LEGAL.CONSENT.AND" | translate }}
|
||||||
<a href="/privacy" target="_blank" rel="noopener">{{
|
<a
|
||||||
"LEGAL.CONSENT.PRIVACY_LINK" | translate
|
[href]="languageService.localizedPath('/privacy')"
|
||||||
}}</a
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>{{ "LEGAL.CONSENT.PRIVACY_LINK" | translate }}</a
|
||||||
>.
|
>.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -265,14 +270,16 @@
|
|||||||
</span>
|
</span>
|
||||||
<span *ngIf="itemVariantLabel(item) as variantLabel">
|
<span *ngIf="itemVariantLabel(item) as variantLabel">
|
||||||
{{ "SHOP.VARIANT" | translate }}:
|
{{ "SHOP.VARIANT" | translate }}:
|
||||||
{{ variantLabel }}
|
{{ variantLabel | translate }}
|
||||||
</span>
|
</span>
|
||||||
<span class="item-color" *ngIf="itemColorLabel(item) !== '-'">
|
<span class="item-color" *ngIf="itemColorLabel(item) !== '-'">
|
||||||
<span
|
<span
|
||||||
class="color-dot"
|
class="color-dot"
|
||||||
[style.background-color]="itemColorSwatch(item)"
|
[style.background-color]="itemColorSwatch(item)"
|
||||||
></span>
|
></span>
|
||||||
<span class="color-name">{{ itemColorLabel(item) }}</span>
|
<span class="color-name">{{
|
||||||
|
itemColorLabel(item) | translate
|
||||||
|
}}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-specs-sub" *ngIf="showItemPrintMetrics(item)">
|
<div class="item-specs-sub" *ngIf="showItemPrintMetrics(item)">
|
||||||
@@ -327,7 +334,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="summary-item cad-summary-item" *ngIf="cadTotal() > 0">
|
<div class="summary-item cad-summary-item" *ngIf="cadTotal() > 0">
|
||||||
<div class="item-details">
|
<div class="item-details">
|
||||||
<span class="item-name">Servizio CAD</span>
|
<span class="item-name">{{
|
||||||
|
"CHECKOUT.CAD_SERVICE" | translate
|
||||||
|
}}</span>
|
||||||
<div class="item-specs-sub">{{ cadHours() }}h</div>
|
<div class="item-specs-sub">{{ cadHours() }}h</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-price">
|
<div class="item-price">
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
.checkout-hero {
|
||||||
|
padding-top: calc(var(--space-12) + var(--space-4));
|
||||||
|
}
|
||||||
|
|
||||||
.cad-subtitle {
|
.cad-subtitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -273,3 +277,9 @@ app-toggle-selector.user-type-selector-compact {
|
|||||||
.mb-6 {
|
.mb-6 {
|
||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.checkout-hero {
|
||||||
|
padding-top: calc(var(--space-8) + var(--space-4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ import {
|
|||||||
} from '../../shared/components/price-breakdown/price-breakdown.component';
|
} from '../../shared/components/price-breakdown/price-breakdown.component';
|
||||||
import { LanguageService } from '../../core/services/language.service';
|
import { LanguageService } from '../../core/services/language.service';
|
||||||
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
|
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
|
||||||
import { getColorHex } from '../../core/constants/colors.const';
|
import {
|
||||||
|
findColorHex,
|
||||||
|
getColorHex,
|
||||||
|
normalizeColorValue,
|
||||||
|
resolveLocalizedColorLabel,
|
||||||
|
} from '../../core/constants/colors.const';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-checkout',
|
selector: 'app-checkout',
|
||||||
@@ -46,7 +51,7 @@ export class CheckoutComponent implements OnInit {
|
|||||||
private quoteService = inject(QuoteEstimatorService);
|
private quoteService = inject(QuoteEstimatorService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private languageService = inject(LanguageService);
|
readonly languageService = inject(LanguageService);
|
||||||
|
|
||||||
checkoutForm: FormGroup;
|
checkoutForm: FormGroup;
|
||||||
sessionId: string | null = null;
|
sessionId: string | null = null;
|
||||||
@@ -142,7 +147,7 @@ export class CheckoutComponent implements OnInit {
|
|||||||
this.sessionId = params['session'];
|
this.sessionId = params['session'];
|
||||||
if (!this.sessionId) {
|
if (!this.sessionId) {
|
||||||
this.error = 'CHECKOUT.ERR_NO_SESSION_START';
|
this.error = 'CHECKOUT.ERR_NO_SESSION_START';
|
||||||
this.router.navigate(['/']); // Redirect if no session
|
this.router.navigate(['/', this.languageService.selectedLang()]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,8 +257,7 @@ export class CheckoutComponent implements OnInit {
|
|||||||
if (variantLabel) {
|
if (variantLabel) {
|
||||||
return variantLabel;
|
return variantLabel;
|
||||||
}
|
}
|
||||||
const colorName = String(item?.shopVariantColorName ?? '').trim();
|
return this.localizedShopColorLabel(item);
|
||||||
return colorName || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showItemMaterial(item: any): boolean {
|
showItemMaterial(item: any): boolean {
|
||||||
@@ -282,12 +286,7 @@ export class CheckoutComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
itemColorLabel(item: any): string {
|
itemColorLabel(item: any): string {
|
||||||
const shopColor = String(item?.shopVariantColorName ?? '').trim();
|
return this.localizedShopColorLabel(item) || String(item?.colorCode ?? '-');
|
||||||
if (shopColor) {
|
|
||||||
return shopColor;
|
|
||||||
}
|
|
||||||
const raw = String(item?.colorCode ?? '').trim();
|
|
||||||
return raw || '-';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
itemColorSwatch(item: any): string {
|
itemColorSwatch(item: any): string {
|
||||||
@@ -310,12 +309,12 @@ export class CheckoutComponent implements OnInit {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
const byName = this.variantHexByColorName.get(raw.toLowerCase());
|
const byName = this.variantHexByColorName.get(normalizeColorValue(raw));
|
||||||
if (byName) {
|
if (byName) {
|
||||||
return byName;
|
return byName;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallback = getColorHex(raw);
|
const fallback = findColorHex(raw) ?? getColorHex(raw);
|
||||||
if (fallback && fallback !== '#facf0a') {
|
if (fallback && fallback !== '#facf0a') {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
@@ -331,6 +330,16 @@ export class CheckoutComponent implements OnInit {
|
|||||||
return !!this.previewLoading()[id];
|
return !!this.previewLoading()[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private localizedShopColorLabel(item: any): string | null {
|
||||||
|
return resolveLocalizedColorLabel(this.languageService.selectedLang(), {
|
||||||
|
fallback: item?.shopVariantColorName ?? item?.colorCode,
|
||||||
|
it: item?.shopVariantColorLabelIt,
|
||||||
|
en: item?.shopVariantColorLabelEn,
|
||||||
|
de: item?.shopVariantColorLabelDe,
|
||||||
|
fr: item?.shopVariantColorLabelFr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
hasPreviewError(item: any): boolean {
|
hasPreviewError(item: any): boolean {
|
||||||
const id = String(item?.id ?? '');
|
const id = String(item?.id ?? '');
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -373,7 +382,10 @@ export class CheckoutComponent implements OnInit {
|
|||||||
this.variantHexById.set(variantId, colorHex);
|
this.variantHexById.set(variantId, colorHex);
|
||||||
}
|
}
|
||||||
if (colorName && colorHex) {
|
if (colorName && colorHex) {
|
||||||
this.variantHexByColorName.set(colorName.toLowerCase(), colorHex);
|
this.variantHexByColorName.set(
|
||||||
|
normalizeColorValue(colorName),
|
||||||
|
colorHex,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,9 +85,11 @@
|
|||||||
<p class="ui-form-hint">{{ "CONTACT.UPLOAD_HINT" | translate }}</p>
|
<p class="ui-form-hint">{{ "CONTACT.UPLOAD_HINT" | translate }}</p>
|
||||||
<p class="ui-form-hint upload-privacy-note">
|
<p class="ui-form-hint upload-privacy-note">
|
||||||
{{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
|
{{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
|
||||||
<a href="/privacy" target="_blank" rel="noopener">{{
|
<a
|
||||||
"LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate
|
[href]="languageService.localizedPath('/privacy')"
|
||||||
}}</a
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>{{ "LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate }}</a
|
||||||
>.
|
>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -161,13 +163,18 @@
|
|||||||
<span class="ui-checkbox__mark"></span>
|
<span class="ui-checkbox__mark"></span>
|
||||||
<span>
|
<span>
|
||||||
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
|
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
|
||||||
<a href="/terms" target="_blank" rel="noopener">{{
|
<a
|
||||||
"LEGAL.CONSENT.TERMS_LINK" | translate
|
[href]="languageService.localizedPath('/terms')"
|
||||||
}}</a>
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>{{ "LEGAL.CONSENT.TERMS_LINK" | translate }}</a
|
||||||
|
>
|
||||||
{{ "LEGAL.CONSENT.AND" | translate }}
|
{{ "LEGAL.CONSENT.AND" | translate }}
|
||||||
<a href="/privacy" target="_blank" rel="noopener">{{
|
<a
|
||||||
"LEGAL.CONSENT.PRIVACY_LINK" | translate
|
[href]="languageService.localizedPath('/privacy')"
|
||||||
}}</a
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>{{ "LEGAL.CONSENT.PRIVACY_LINK" | translate }}</a
|
||||||
>.
|
>.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export class ContactFormComponent implements OnDestroy {
|
|||||||
];
|
];
|
||||||
|
|
||||||
private quoteRequestService = inject(QuoteRequestService);
|
private quoteRequestService = inject(QuoteRequestService);
|
||||||
private languageService = inject(LanguageService);
|
readonly languageService = inject(LanguageService);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private fb: FormBuilder,
|
private fb: FormBuilder,
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ export const CONTACT_ROUTES: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./contact-page.component').then((m) => m.ContactPageComponent),
|
import('./contact-page.component').then((m) => m.ContactPageComponent),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Contatti | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.CONTACT.TITLE',
|
||||||
seoDescription:
|
seoDescriptionKey: 'SEO.ROUTES.CONTACT.DESCRIPTION',
|
||||||
'Richiedi informazioni, preventivi personalizzati o supporto per progetti di stampa 3D.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<main class="home-page">
|
<main class="home-page">
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="container hero-grid ui-content-grid ui-content-grid--spacious">
|
<div
|
||||||
|
class="container hero-grid ui-content-grid ui-content-grid--spacious ui-content-grid--split"
|
||||||
|
>
|
||||||
<div class="hero-copy">
|
<div class="hero-copy">
|
||||||
<p class="eyebrow ui-eyebrow">{{ "HOME.HERO_EYEBROW" | translate }}</p>
|
<p class="eyebrow ui-eyebrow">{{ "HOME.HERO_EYEBROW" | translate }}</p>
|
||||||
<h1
|
<h1
|
||||||
@@ -14,17 +16,47 @@
|
|||||||
{{ "HOME.HERO_SUBTITLE" | translate }}
|
{{ "HOME.HERO_SUBTITLE" | translate }}
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-actions ui-inline-actions ui-inline-actions--wide">
|
<div class="hero-actions ui-inline-actions ui-inline-actions--wide">
|
||||||
<app-button variant="primary" routerLink="/calculator/basic">{{
|
<app-button
|
||||||
"HOME.BTN_CALCULATE" | translate
|
variant="primary"
|
||||||
}}</app-button>
|
[routerLink]="languageService.localizedPath('/calculator/basic')"
|
||||||
<app-button variant="outline" routerLink="/shop">{{
|
>{{ "HOME.BTN_CALCULATE" | translate }}</app-button
|
||||||
"HOME.BTN_SHOP" | translate
|
>
|
||||||
}}</app-button>
|
<app-button
|
||||||
<app-button variant="text" routerLink="/contact">{{
|
variant="outline"
|
||||||
"HOME.BTN_CONTACT" | translate
|
[routerLink]="languageService.localizedPath('/shop')"
|
||||||
}}</app-button>
|
>{{ "HOME.BTN_SHOP" | translate }}</app-button
|
||||||
|
>
|
||||||
|
<app-button
|
||||||
|
variant="text"
|
||||||
|
[routerLink]="languageService.localizedPath('/contact')"
|
||||||
|
>{{ "HOME.BTN_CONTACT" | translate }}</app-button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<aside class="hero-swiss-card">
|
||||||
|
<div class="hero-swiss-head">
|
||||||
|
<span class="hero-swiss-emblem" aria-hidden="true">
|
||||||
|
<span class="hero-swiss-cross"></span>
|
||||||
|
</span>
|
||||||
|
<p class="hero-swiss-kicker ui-eyebrow ui-eyebrow--compact">
|
||||||
|
{{ "HOME.HERO_SWISS_TITLE" | translate }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="hero-swiss-copy">
|
||||||
|
{{ "HOME.HERO_SWISS_COPY" | translate }}
|
||||||
|
</p>
|
||||||
|
<div class="hero-swiss-locations">
|
||||||
|
<span class="hero-swiss-chip">{{
|
||||||
|
"HOME.HERO_SWISS_LOCATION_1" | translate
|
||||||
|
}}</span>
|
||||||
|
<span class="hero-swiss-chip">{{
|
||||||
|
"HOME.HERO_SWISS_LOCATION_2" | translate
|
||||||
|
}}</span>
|
||||||
|
<span class="hero-swiss-chip">{{
|
||||||
|
"HOME.HERO_SWISS_LOCATION_3" | translate
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -110,13 +142,13 @@
|
|||||||
<app-button
|
<app-button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
[fullWidth]="true"
|
[fullWidth]="true"
|
||||||
routerLink="/calculator/basic"
|
[routerLink]="languageService.localizedPath('/calculator/basic')"
|
||||||
>{{ "HOME.BTN_OPEN_CALC" | translate }}</app-button
|
>{{ "HOME.BTN_OPEN_CALC" | translate }}</app-button
|
||||||
>
|
>
|
||||||
<app-button
|
<app-button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
[fullWidth]="true"
|
[fullWidth]="true"
|
||||||
routerLink="/contact"
|
[routerLink]="languageService.localizedPath('/contact')"
|
||||||
>{{ "HOME.BTN_CONTACT" | translate }}</app-button
|
>{{ "HOME.BTN_CONTACT" | translate }}</app-button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,12 +173,16 @@
|
|||||||
<li>{{ "HOME.SEC_SHOP_LIST_3" | translate }}</li>
|
<li>{{ "HOME.SEC_SHOP_LIST_3" | translate }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="shop-actions ui-inline-actions">
|
<div class="shop-actions ui-inline-actions">
|
||||||
<app-button variant="primary" routerLink="/shop">{{
|
<app-button
|
||||||
"HOME.BTN_DISCOVER" | translate
|
variant="primary"
|
||||||
}}</app-button>
|
[routerLink]="languageService.localizedPath('/shop')"
|
||||||
<app-button variant="outline" routerLink="/contact">{{
|
>{{ "HOME.BTN_DISCOVER" | translate }}</app-button
|
||||||
"HOME.BTN_REQ_SOLUTION" | translate
|
>
|
||||||
}}</app-button>
|
<app-button
|
||||||
|
variant="outline"
|
||||||
|
[routerLink]="languageService.localizedPath('/contact')"
|
||||||
|
>{{ "HOME.BTN_REQ_SOLUTION" | translate }}</app-button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -211,12 +247,16 @@
|
|||||||
{{ "HOME.SEC_ABOUT_TEXT" | translate }}
|
{{ "HOME.SEC_ABOUT_TEXT" | translate }}
|
||||||
</p>
|
</p>
|
||||||
<div class="about-actions ui-inline-actions">
|
<div class="about-actions ui-inline-actions">
|
||||||
<app-button variant="primary" routerLink="/about">{{
|
<app-button
|
||||||
"HOME.SEC_ABOUT_TITLE" | translate
|
variant="primary"
|
||||||
}}</app-button>
|
[routerLink]="languageService.localizedPath('/about')"
|
||||||
<app-button variant="outline" routerLink="/contact">{{
|
>{{ "HOME.SEC_ABOUT_TITLE" | translate }}</app-button
|
||||||
"HOME.BTN_CONTACT" | translate
|
>
|
||||||
}}</app-button>
|
<app-button
|
||||||
|
variant="outline"
|
||||||
|
[routerLink]="languageService.localizedPath('/contact')"
|
||||||
|
>{{ "HOME.BTN_CONTACT" | translate }}</app-button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="about-media">
|
<div class="about-media">
|
||||||
|
|||||||
@@ -45,6 +45,99 @@
|
|||||||
animation: fadeUp 0.8s ease both;
|
animation: fadeUp 0.8s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-grid {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-card {
|
||||||
|
--swiss-red: #d52b1e;
|
||||||
|
align-self: center;
|
||||||
|
justify-self: center;
|
||||||
|
width: min(100%, 340px);
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-left: 4px solid var(--color-brand);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
animation: fadeUp 0.85s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-emblem {
|
||||||
|
width: 1.3rem;
|
||||||
|
height: 1.3rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--swiss-red);
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-cross {
|
||||||
|
position: relative;
|
||||||
|
width: 0.86rem;
|
||||||
|
height: 0.86rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-cross::before,
|
||||||
|
.hero-swiss-cross::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-cross::before {
|
||||||
|
width: 0.28rem;
|
||||||
|
height: 100%;
|
||||||
|
left: calc(50% - 0.14rem);
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-cross::after {
|
||||||
|
width: 100%;
|
||||||
|
height: 0.28rem;
|
||||||
|
left: 0;
|
||||||
|
top: calc(50% - 0.14rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-kicker {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-copy {
|
||||||
|
margin: 0 0 0.7rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-locations {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 1.75rem;
|
||||||
|
padding: 0.2rem 0.58rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(14, 24, 38, 0.14);
|
||||||
|
background: #fff;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2a2f36;
|
||||||
|
}
|
||||||
|
|
||||||
.capabilities {
|
.capabilities {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
@@ -165,6 +258,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.hero-swiss-card {
|
||||||
|
align-self: start;
|
||||||
|
justify-self: center;
|
||||||
|
width: min(100%, 340px);
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.shop-gallery {
|
.shop-gallery {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { RouterLink } from '@angular/router';
|
|||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
||||||
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||||
|
import { LanguageService } from '../../core/services/language.service';
|
||||||
import {
|
import {
|
||||||
buildPublicMediaUsageScopeKey,
|
buildPublicMediaUsageScopeKey,
|
||||||
PublicMediaDisplayImage,
|
PublicMediaDisplayImage,
|
||||||
@@ -69,6 +70,7 @@ const HOME_CAPABILITY_CONFIGS: readonly HomeCapabilityConfig[] = [
|
|||||||
})
|
})
|
||||||
export class HomeComponent {
|
export class HomeComponent {
|
||||||
private readonly publicMediaService = inject(PublicMediaService);
|
private readonly publicMediaService = inject(PublicMediaService);
|
||||||
|
readonly languageService = inject(LanguageService);
|
||||||
|
|
||||||
private readonly mediaByUsage = toSignal(
|
private readonly mediaByUsage = toSignal(
|
||||||
this.publicMediaService.getUsageCollections([
|
this.publicMediaService.getUsageCollections([
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ export const LEGAL_ROUTES: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./privacy/privacy.component').then((m) => m.PrivacyComponent),
|
import('./privacy/privacy.component').then((m) => m.PrivacyComponent),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Privacy Policy | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.LEGAL.PRIVACY.TITLE',
|
||||||
seoDescription:
|
seoDescriptionKey: 'SEO.ROUTES.LEGAL.PRIVACY.DESCRIPTION',
|
||||||
'Informativa privacy di 3D fab: trattamento dati, finalita e contatti.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -16,9 +15,8 @@ export const LEGAL_ROUTES: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./terms/terms.component').then((m) => m.TermsComponent),
|
import('./terms/terms.component').then((m) => m.TermsComponent),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Termini e condizioni | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.LEGAL.TERMS.TITLE',
|
||||||
seoDescription:
|
seoDescriptionKey: 'SEO.ROUTES.LEGAL.TERMS.DESCRIPTION',
|
||||||
'Termini e condizioni del servizio di stampa 3D e del calcolatore preventivi.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -68,9 +68,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</app-card>
|
</app-card>
|
||||||
|
|
||||||
<div class="payment-layout ui-two-column-layout">
|
<div
|
||||||
<div class="payment-main">
|
class="payment-layout ui-two-column-layout"
|
||||||
<app-card class="mb-6" *ngIf="o.status === 'PENDING_PAYMENT'">
|
[class.payment-layout--summary-only]="o.status !== 'PENDING_PAYMENT'"
|
||||||
|
>
|
||||||
|
<div class="payment-main" *ngIf="o.status === 'PENDING_PAYMENT'">
|
||||||
|
<app-card class="mb-6">
|
||||||
<div class="ui-card-header">
|
<div class="ui-card-header">
|
||||||
<h3 class="ui-card-title">{{ "PAYMENT.METHOD" | translate }}</h3>
|
<h3 class="ui-card-title">{{ "PAYMENT.METHOD" | translate }}</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,69 +177,6 @@
|
|||||||
</app-button>
|
</app-button>
|
||||||
</div>
|
</div>
|
||||||
</app-card>
|
</app-card>
|
||||||
|
|
||||||
<app-card class="mb-6">
|
|
||||||
<div class="ui-card-header">
|
|
||||||
<h3 class="ui-card-title">{{ "ORDER.ITEMS_TITLE" | translate }}</h3>
|
|
||||||
<p class="ui-card-subtitle">
|
|
||||||
{{ orderKindLabel(o) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="order-items">
|
|
||||||
<div class="order-item" *ngFor="let item of o.items || []">
|
|
||||||
<div class="order-item-copy">
|
|
||||||
<div class="order-item-name-row">
|
|
||||||
<strong class="order-item-name">{{
|
|
||||||
itemDisplayName(item)
|
|
||||||
}}</strong>
|
|
||||||
<span
|
|
||||||
class="order-item-kind"
|
|
||||||
[class.order-item-kind-shop]="isShopItem(item)"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
isShopItem(item)
|
|
||||||
? ("ORDER.TYPE_SHOP" | translate)
|
|
||||||
: ("ORDER.TYPE_CALCULATOR" | translate)
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="order-item-meta">
|
|
||||||
<span
|
|
||||||
>{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span
|
|
||||||
>
|
|
||||||
<span *ngIf="showItemMaterial(item)">
|
|
||||||
{{ "CHECKOUT.MATERIAL" | translate }}:
|
|
||||||
{{
|
|
||||||
item.materialCode || ("ORDER.NOT_AVAILABLE" | translate)
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
<span *ngIf="itemVariantLabel(item) as variantLabel">
|
|
||||||
{{ "SHOP.VARIANT" | translate }}: {{ variantLabel }}
|
|
||||||
</span>
|
|
||||||
<span class="item-color-chip">
|
|
||||||
<span
|
|
||||||
class="color-swatch"
|
|
||||||
*ngIf="itemColorHex(item) as colorHex"
|
|
||||||
[style.background-color]="colorHex"
|
|
||||||
></span>
|
|
||||||
<span>{{ itemColorLabel(item) }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="order-item-tech" *ngIf="showItemPrintMetrics(item)">
|
|
||||||
{{ item.printTimeSeconds || 0 | number: "1.0-0" }}s |
|
|
||||||
{{ item.materialGrams || 0 | number: "1.0-0" }}g
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<strong class="order-item-total">
|
|
||||||
{{ item.lineTotalChf || 0 | currency: "CHF" }}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</app-card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="payment-summary">
|
<div class="payment-summary">
|
||||||
@@ -271,6 +211,72 @@
|
|||||||
[currency]="'CHF'"
|
[currency]="'CHF'"
|
||||||
[totalLabelKey]="'PAYMENT.TOTAL'"
|
[totalLabelKey]="'PAYMENT.TOTAL'"
|
||||||
></app-price-breakdown>
|
></app-price-breakdown>
|
||||||
|
|
||||||
|
<div class="summary-items-section" *ngIf="(o.items || []).length > 0">
|
||||||
|
<div class="summary-items-head">
|
||||||
|
<h4>{{ "ORDER.ITEMS_TITLE" | translate }}</h4>
|
||||||
|
<span>{{ (o.items || []).length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="order-items">
|
||||||
|
<div class="order-item" *ngFor="let item of o.items || []">
|
||||||
|
<div class="order-item-copy">
|
||||||
|
<div class="order-item-name-row">
|
||||||
|
<strong class="order-item-name">{{
|
||||||
|
itemDisplayName(item)
|
||||||
|
}}</strong>
|
||||||
|
<span
|
||||||
|
class="order-item-kind"
|
||||||
|
[class.order-item-kind-shop]="isShopItem(item)"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
isShopItem(item)
|
||||||
|
? ("ORDER.TYPE_SHOP" | translate)
|
||||||
|
: ("ORDER.TYPE_CALCULATOR" | translate)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="order-item-meta">
|
||||||
|
<span
|
||||||
|
>{{ "CHECKOUT.QTY" | translate }}:
|
||||||
|
{{ item.quantity }}</span
|
||||||
|
>
|
||||||
|
<span *ngIf="showItemMaterial(item)">
|
||||||
|
{{ "CHECKOUT.MATERIAL" | translate }}:
|
||||||
|
{{
|
||||||
|
item.materialCode || ("ORDER.NOT_AVAILABLE" | translate)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span *ngIf="itemVariantLabel(item) as variantLabel">
|
||||||
|
{{ "SHOP.VARIANT" | translate }}:
|
||||||
|
{{ variantLabel | translate }}
|
||||||
|
</span>
|
||||||
|
<span class="item-color-chip">
|
||||||
|
<span
|
||||||
|
class="color-swatch"
|
||||||
|
*ngIf="itemColorHex(item) as colorHex"
|
||||||
|
[style.background-color]="colorHex"
|
||||||
|
></span>
|
||||||
|
<span>{{ itemColorLabel(item) | translate }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="order-item-tech"
|
||||||
|
*ngIf="showItemPrintMetrics(item)"
|
||||||
|
>
|
||||||
|
{{ item.printTimeSeconds || 0 | number: "1.0-0" }}s |
|
||||||
|
{{ item.materialGrams || 0 | number: "1.0-0" }}g
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<strong class="order-item-total">
|
||||||
|
{{ item.lineTotalChf || 0 | currency: "CHF" }}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</app-card>
|
</app-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.payment-layout--summary-only {
|
||||||
|
grid-template-columns: minmax(0, 440px);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.payment-details {
|
.payment-details {
|
||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
|
|
||||||
@@ -119,9 +124,52 @@
|
|||||||
top: var(--space-6);
|
top: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.payment-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-items-section {
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
padding-top: var(--space-5);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-items-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.8rem;
|
||||||
|
min-height: 1.8rem;
|
||||||
|
padding: 0 0.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(16, 24, 32, 0.06);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.order-items {
|
.order-items {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-3);
|
gap: var(--space-2);
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: var(--space-1);
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-item {
|
.order-item {
|
||||||
@@ -129,7 +177,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: var(--space-3);
|
padding: 0.85rem 0.9rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
@@ -149,7 +197,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.order-item-name {
|
.order-item-name {
|
||||||
font-size: 1rem;
|
font-size: 0.96rem;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +224,7 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem 0.9rem;
|
gap: 0.5rem 0.9rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.92rem;
|
font-size: 0.88rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-color-chip {
|
.item-color-chip {
|
||||||
@@ -194,13 +242,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.order-item-tech {
|
.order-item-tech {
|
||||||
font-size: 0.86rem;
|
font-size: 0.82rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-item-total {
|
.order-item-total {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 1rem;
|
font-size: 0.96rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-summary-meta {
|
.order-summary-meta {
|
||||||
@@ -325,6 +373,10 @@
|
|||||||
padding-top: calc(var(--space-8) + var(--space-4));
|
padding-top: calc(var(--space-8) + var(--space-4));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.payment-layout--summary-only {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.status-timeline {
|
.status-timeline {
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
margin-bottom: var(--space-8);
|
margin-bottom: var(--space-8);
|
||||||
@@ -362,4 +414,10 @@
|
|||||||
.order-summary-meta {
|
.order-summary-meta {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.order-items {
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
|
|||||||
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
|
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
|
||||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
|
import {
|
||||||
|
findColorHex,
|
||||||
|
resolveLocalizedColorLabel,
|
||||||
|
} from '../../core/constants/colors.const';
|
||||||
import {
|
import {
|
||||||
PriceBreakdownComponent,
|
PriceBreakdownComponent,
|
||||||
PriceBreakdownRow,
|
PriceBreakdownRow,
|
||||||
@@ -25,9 +29,17 @@ interface PublicOrderItem {
|
|||||||
shopProductName?: string;
|
shopProductName?: string;
|
||||||
shopVariantLabel?: string;
|
shopVariantLabel?: string;
|
||||||
shopVariantColorName?: string;
|
shopVariantColorName?: string;
|
||||||
|
shopVariantColorLabelIt?: string;
|
||||||
|
shopVariantColorLabelEn?: string;
|
||||||
|
shopVariantColorLabelDe?: string;
|
||||||
|
shopVariantColorLabelFr?: string;
|
||||||
shopVariantColorHex?: string;
|
shopVariantColorHex?: string;
|
||||||
filamentVariantDisplayName?: string;
|
filamentVariantDisplayName?: string;
|
||||||
filamentColorName?: string;
|
filamentColorName?: string;
|
||||||
|
filamentColorLabelIt?: string;
|
||||||
|
filamentColorLabelEn?: string;
|
||||||
|
filamentColorLabelDe?: string;
|
||||||
|
filamentColorLabelFr?: string;
|
||||||
filamentColorHex?: string;
|
filamentColorHex?: string;
|
||||||
quality?: string;
|
quality?: string;
|
||||||
nozzleDiameterMm?: number;
|
nozzleDiameterMm?: number;
|
||||||
@@ -233,7 +245,9 @@ export class OrderComponent implements OnInit {
|
|||||||
amount: order?.subtotalChf ?? 0,
|
amount: order?.subtotalChf ?? 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: `Servizio CAD (${order?.cadHours || 0}h)`,
|
label: this.translate.instant('ORDER.CAD_SERVICE', {
|
||||||
|
hours: order?.cadHours || 0,
|
||||||
|
}),
|
||||||
amount: order?.cadTotalChf ?? 0,
|
amount: order?.cadTotalChf ?? 0,
|
||||||
visible: (order?.cadTotalChf ?? 0) > 0,
|
visible: (order?.cadTotalChf ?? 0) > 0,
|
||||||
},
|
},
|
||||||
@@ -278,23 +292,16 @@ export class OrderComponent implements OnInit {
|
|||||||
return variantLabel;
|
return variantLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
const colorName = String(item?.shopVariantColorName ?? '').trim();
|
return this.localizedColorLabel(item, 'shop');
|
||||||
return colorName || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
itemColorLabel(item: PublicOrderItem): string {
|
itemColorLabel(item: PublicOrderItem): string {
|
||||||
const shopColor = String(item?.shopVariantColorName ?? '').trim();
|
return (
|
||||||
if (shopColor) {
|
this.localizedColorLabel(item, 'shop') ||
|
||||||
return shopColor;
|
this.localizedColorLabel(item, 'filament') ||
|
||||||
}
|
String(item?.colorCode ?? '').trim() ||
|
||||||
|
this.translate.instant('ORDER.NOT_AVAILABLE')
|
||||||
const filamentColor = String(item?.filamentColorName ?? '').trim();
|
);
|
||||||
if (filamentColor) {
|
|
||||||
return filamentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawColor = String(item?.colorCode ?? '').trim();
|
|
||||||
return rawColor || this.translate.instant('ORDER.NOT_AVAILABLE');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
itemColorHex(item: PublicOrderItem): string | null {
|
itemColorHex(item: PublicOrderItem): string | null {
|
||||||
@@ -313,7 +320,7 @@ export class OrderComponent implements OnInit {
|
|||||||
return rawColor;
|
return rawColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return findColorHex(rawColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
showItemMaterial(item: PublicOrderItem): boolean {
|
showItemMaterial(item: PublicOrderItem): boolean {
|
||||||
@@ -324,6 +331,29 @@ export class OrderComponent implements OnInit {
|
|||||||
return !this.isShopItem(item);
|
return !this.isShopItem(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private localizedColorLabel(
|
||||||
|
item: PublicOrderItem,
|
||||||
|
source: 'shop' | 'filament',
|
||||||
|
): string | null {
|
||||||
|
if (source === 'shop') {
|
||||||
|
return resolveLocalizedColorLabel(this.translate.currentLang, {
|
||||||
|
fallback: item.shopVariantColorName,
|
||||||
|
it: item.shopVariantColorLabelIt,
|
||||||
|
en: item.shopVariantColorLabelEn,
|
||||||
|
de: item.shopVariantColorLabelDe,
|
||||||
|
fr: item.shopVariantColorLabelFr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveLocalizedColorLabel(this.translate.currentLang, {
|
||||||
|
fallback: item.filamentColorName ?? item.colorCode,
|
||||||
|
it: item.filamentColorLabelIt,
|
||||||
|
en: item.filamentColorLabelEn,
|
||||||
|
de: item.filamentColorLabelDe,
|
||||||
|
fr: item.filamentColorLabelFr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
orderKind(order: PublicOrder | null): 'SHOP' | 'CALCULATOR' | 'MIXED' {
|
orderKind(order: PublicOrder | null): 'SHOP' | 'CALCULATOR' | 'MIXED' {
|
||||||
const items = order?.items ?? [];
|
const items = order?.items ?? [];
|
||||||
const hasShop = items.some((item) => this.isShopItem(item));
|
const hasShop = items.some((item) => this.isShopItem(item));
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
.media {
|
.media {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 4 / 3;
|
||||||
background: #f2eee5;
|
background: #f2eee5;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,16 @@
|
|||||||
} @else {
|
} @else {
|
||||||
@if (product(); as p) {
|
@if (product(); as p) {
|
||||||
<nav class="breadcrumbs">
|
<nav class="breadcrumbs">
|
||||||
<a [routerLink]="shopRootLink()">{{
|
<a class="breadcrumbs__item" [routerLink]="shopRootLink()">{{
|
||||||
"SHOP.BREADCRUMB_ROOT" | translate
|
"SHOP.BREADCRUMB_ROOT" | translate
|
||||||
}}</a>
|
}}</a>
|
||||||
@for (crumb of p.breadcrumbs; track crumb.id) {
|
@for (crumb of p.breadcrumbs; track crumb.id) {
|
||||||
<span>/</span>
|
<span class="breadcrumbs__separator">/</span>
|
||||||
<a [routerLink]="categoryLink(crumb.slug)">{{ crumb.name }}</a>
|
<a
|
||||||
|
class="breadcrumbs__item"
|
||||||
|
[routerLink]="categoryLink(crumb.slug)"
|
||||||
|
>{{ crumb.name }}</a
|
||||||
|
>
|
||||||
}
|
}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -129,13 +133,32 @@
|
|||||||
|
|
||||||
<app-card class="purchase-shell">
|
<app-card class="purchase-shell">
|
||||||
<div class="purchase-card">
|
<div class="purchase-card">
|
||||||
<div class="price-row">
|
<div class="offer-header">
|
||||||
<div>
|
<div class="offer-price">
|
||||||
<p class="panel-kicker">
|
<p class="panel-kicker">
|
||||||
{{ "SHOP.SELECT_MATERIAL" | translate }}
|
{{ "SHOP.PRICE_LABEL" | translate }}
|
||||||
</p>
|
</p>
|
||||||
<h3>{{ priceLabel() | currency: "CHF" }}</h3>
|
<h3>{{ priceLabel() | currency: "CHF" }}</h3>
|
||||||
|
@if (selectedVariant(); as activeVariant) {
|
||||||
|
<p class="offer-caption">
|
||||||
|
@if (selectedMaterial()?.label) {
|
||||||
|
<span>{{ selectedMaterial()?.label }}</span>
|
||||||
|
}
|
||||||
|
@if (
|
||||||
|
colorLabel(activeVariant) !==
|
||||||
|
selectedMaterial()?.label
|
||||||
|
) {
|
||||||
|
@if (selectedMaterial()?.label) {
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
}
|
||||||
|
<span>{{
|
||||||
|
colorLabel(activeVariant) | translate
|
||||||
|
}}</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (selectedVariantCartQuantity() > 0) {
|
@if (selectedVariantCartQuantity() > 0) {
|
||||||
<span class="cart-pill">
|
<span class="cart-pill">
|
||||||
{{
|
{{
|
||||||
@@ -148,32 +171,61 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (materialOptions().length > 1) {
|
@if (materialOptions().length > 1) {
|
||||||
<div class="material-grid">
|
<div class="material-section">
|
||||||
@for (material of materialOptions(); track material.key) {
|
<div class="selector-head">
|
||||||
<button
|
<p class="panel-kicker">
|
||||||
type="button"
|
{{ "SHOP.SELECT_MATERIAL" | translate }}
|
||||||
class="material-option"
|
</p>
|
||||||
[class.active]="
|
</div>
|
||||||
selectedMaterial()?.key === material.key
|
|
||||||
"
|
<div class="material-grid">
|
||||||
(click)="selectMaterial(material.key)"
|
@for (
|
||||||
>
|
material of materialOptions();
|
||||||
<span class="material-copy">
|
track material.key
|
||||||
<strong>{{ material.label }}</strong>
|
) {
|
||||||
<small>
|
<button
|
||||||
{{
|
type="button"
|
||||||
"SHOP.MATERIAL_COLOR_COUNT"
|
class="material-option"
|
||||||
| translate
|
[class.active]="
|
||||||
: { count: materialColorCount(material) }
|
selectedMaterial()?.key === material.key
|
||||||
}}
|
"
|
||||||
</small>
|
(click)="selectMaterial(material.key)"
|
||||||
</span>
|
>
|
||||||
<strong>{{
|
<span class="material-copy">
|
||||||
materialPriceLabel(material) | currency: "CHF"
|
<strong>{{ material.label }}</strong>
|
||||||
}}</strong>
|
<small>
|
||||||
</button>
|
{{
|
||||||
}
|
"SHOP.MATERIAL_COLOR_COUNT"
|
||||||
|
| translate
|
||||||
|
: { count: materialColorCount(material) }
|
||||||
|
}}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<strong>{{
|
||||||
|
materialPriceLabel(material) | currency: "CHF"
|
||||||
|
}}</strong>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
} @else {
|
||||||
|
@if (selectedMaterial(); as material) {
|
||||||
|
<div class="material-summary">
|
||||||
|
<div class="material-summary__copy">
|
||||||
|
<p class="panel-kicker">
|
||||||
|
{{ "SHOP.SELECT_MATERIAL" | translate }}
|
||||||
|
</p>
|
||||||
|
<strong>{{ material.label }}</strong>
|
||||||
|
<small>
|
||||||
|
{{
|
||||||
|
"SHOP.MATERIAL_COLOR_COUNT"
|
||||||
|
| translate
|
||||||
|
: { count: materialColorCount(material) }
|
||||||
|
}}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (
|
@if (
|
||||||
@@ -196,90 +248,97 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="color-selector-block">
|
<div class="selector-layout">
|
||||||
<div class="selector-head">
|
<div class="selector-card color-selector-block">
|
||||||
<p class="panel-kicker">
|
<div class="selector-head">
|
||||||
{{ "SHOP.SELECT_COLOR" | translate }}
|
<p class="panel-kicker">
|
||||||
</p>
|
{{ "SHOP.SELECT_COLOR" | translate }}
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (selectedVariant(); as activeVariant) {
|
@if (selectedVariant(); as activeVariant) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="color-trigger"
|
class="color-trigger"
|
||||||
[class.open]="colorPopupOpen()"
|
[class.open]="colorPopupOpen()"
|
||||||
(click)="toggleColorPopup()"
|
(click)="toggleColorPopup()"
|
||||||
>
|
>
|
||||||
<span class="color-trigger__ring">
|
<span class="color-trigger__ring">
|
||||||
<span
|
<span
|
||||||
class="color-trigger__swatch"
|
class="color-trigger__swatch"
|
||||||
[style.background-color]="colorHex(activeVariant)"
|
[style.background-color]="colorHex(activeVariant)"
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="color-trigger__copy">
|
<span class="color-trigger__copy">
|
||||||
<strong>{{ colorLabel(activeVariant) }}</strong>
|
<strong>{{
|
||||||
<small>{{ selectedMaterial()?.label }}</small>
|
colorLabel(activeVariant) | translate
|
||||||
</span>
|
}}</strong>
|
||||||
</button>
|
<small>{{ selectedMaterial()?.label }}</small>
|
||||||
}
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
@if (colorPopupOpen()) {
|
@if (colorPopupOpen()) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="color-popup-backdrop"
|
class="color-popup-backdrop"
|
||||||
(click)="closeColorPopup()"
|
(click)="closeColorPopup()"
|
||||||
></button>
|
></button>
|
||||||
<div class="color-popup">
|
<div class="color-popup">
|
||||||
<div class="color-popup__category">
|
<div class="color-popup__category">
|
||||||
{{ selectedMaterial()?.label || "" | uppercase }}
|
{{ selectedMaterial()?.label || "" | uppercase }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="color-popup__grid">
|
<div class="color-popup__grid">
|
||||||
@for (variant of colorOptions(); track variant.id) {
|
@for (variant of colorOptions(); track variant.id) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="color-popup__item"
|
class="color-popup__item"
|
||||||
(click)="selectVariant(variant)"
|
(click)="selectVariant(variant)"
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="color-popup__ring"
|
|
||||||
[class.active]="
|
|
||||||
selectedVariant()?.id === variant.id
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="color-popup__swatch"
|
class="color-popup__ring"
|
||||||
[style.background-color]="colorHex(variant)"
|
[class.active]="
|
||||||
></span>
|
selectedVariant()?.id === variant.id
|
||||||
</span>
|
"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="color-popup__swatch"
|
||||||
|
[style.background-color]="colorHex(variant)"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
|
||||||
<span class="color-popup__name">{{
|
<span class="color-popup__name">{{
|
||||||
colorLabel(variant)
|
colorLabel(variant) | translate
|
||||||
}}</span>
|
}}</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="quantity-row">
|
<div class="selector-card quantity-card">
|
||||||
<span>{{ "SHOP.QUANTITY" | translate }}</span>
|
<p class="panel-kicker">
|
||||||
<div class="qty-control">
|
{{ "SHOP.QUANTITY" | translate }}
|
||||||
<button type="button" (click)="decreaseQuantity()">
|
</p>
|
||||||
-
|
<div class="qty-control">
|
||||||
</button>
|
<button type="button" (click)="decreaseQuantity()">
|
||||||
<span>{{ quantity() }}</span>
|
-
|
||||||
<button type="button" (click)="increaseQuantity()">
|
</button>
|
||||||
+
|
<span>{{ quantity() }}</span>
|
||||||
</button>
|
<button type="button" (click)="increaseQuantity()">
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<app-button
|
<app-button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
[fullWidth]="true"
|
||||||
[disabled]="isAddingToCart()"
|
[disabled]="isAddingToCart()"
|
||||||
(click)="addToCart()"
|
(click)="addToCart()"
|
||||||
>
|
>
|
||||||
@@ -290,7 +349,11 @@
|
|||||||
</app-button>
|
</app-button>
|
||||||
|
|
||||||
@if (shopService.cartItemCount() > 0) {
|
@if (shopService.cartItemCount() > 0) {
|
||||||
<app-button variant="outline" (click)="goToCheckout()">
|
<app-button
|
||||||
|
variant="outline"
|
||||||
|
[fullWidth]="true"
|
||||||
|
(click)="goToCheckout()"
|
||||||
|
>
|
||||||
{{ "SHOP.GO_TO_CHECKOUT" | translate }}
|
{{ "SHOP.GO_TO_CHECKOUT" | translate }}
|
||||||
</app-button>
|
</app-button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,15 +18,51 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
background: none;
|
background: none;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
.breadcrumbs {
|
.breadcrumbs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.45rem;
|
align-items: center;
|
||||||
font-size: 0.9rem;
|
gap: 0.35rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs__item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 1.9rem;
|
||||||
|
padding: 0.26rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(16, 24, 32, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: var(--color-secondary-600);
|
||||||
|
font-weight: 600;
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background 0.18s ease,
|
||||||
|
color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs__item:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: rgba(16, 24, 32, 0.18);
|
||||||
|
background: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs__separator {
|
||||||
|
color: rgba(81, 77, 67, 0.64);
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-grid {
|
.detail-grid {
|
||||||
@@ -53,9 +89,8 @@
|
|||||||
|
|
||||||
.hero-media {
|
.hero-media {
|
||||||
position: relative;
|
position: relative;
|
||||||
aspect-ratio: 1 / 1;
|
width: 100%;
|
||||||
min-height: 420px;
|
aspect-ratio: 4 / 3;
|
||||||
max-height: 620px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 1.25rem;
|
border-radius: 1.25rem;
|
||||||
border: 1px solid rgba(16, 24, 32, 0.12);
|
border: 1px solid rgba(16, 24, 32, 0.12);
|
||||||
@@ -67,14 +102,14 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
object-fit: contain;
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
background: #f2eee5;
|
background: #f2eee5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-fallback {
|
.image-fallback {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 420px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
@@ -111,8 +146,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.thumb {
|
.thumb {
|
||||||
flex: 0 0 92px;
|
flex: 0 0 96px;
|
||||||
height: 92px;
|
aspect-ratio: 4 / 3;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 0.85rem;
|
border-radius: 0.85rem;
|
||||||
border: 1px solid rgba(16, 24, 32, 0.12);
|
border: 1px solid rgba(16, 24, 32, 0.12);
|
||||||
@@ -226,15 +261,34 @@ h1 {
|
|||||||
|
|
||||||
.purchase-card {
|
.purchase-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.78rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.price-row,
|
.offer-header {
|
||||||
.quantity-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offer-price {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offer-price h3 {
|
||||||
|
font-size: clamp(1.9rem, 1.5vw + 1.15rem, 2.5rem);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offer-caption {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.38rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cart-pill {
|
.cart-pill {
|
||||||
@@ -249,6 +303,11 @@ h1 {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.material-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
.material-grid {
|
.material-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
@@ -301,6 +360,31 @@ h1 {
|
|||||||
font-size: 1.04rem;
|
font-size: 1.04rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.material-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid rgba(16, 24, 32, 0.12);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-summary__copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-summary__copy strong {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-summary__copy small {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
.property-grid {
|
.property-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -340,11 +424,30 @@ h1 {
|
|||||||
border-left: 3px solid rgba(245, 158, 11, 0.7);
|
border-left: 3px solid rgba(245, 158, 11, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selector-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(210px, 0.8fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-card {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.82rem 0.9rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid rgba(16, 24, 32, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.qty-control {
|
.qty-control {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
padding: 0.2rem;
|
padding: 0.2rem;
|
||||||
|
min-height: 3.2rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
background: rgba(255, 255, 255, 0.82);
|
background: rgba(255, 255, 255, 0.82);
|
||||||
@@ -366,10 +469,20 @@ h1 {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quantity-card {
|
||||||
|
justify-items: start;
|
||||||
|
align-content: start;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-card .qty-control {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
gap: var(--space-3);
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-note {
|
.success-note {
|
||||||
@@ -459,6 +572,10 @@ h1 {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selector-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.property-grid {
|
.property-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -469,15 +586,14 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.price-row,
|
.offer-header,
|
||||||
.quantity-row {
|
.material-summary {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-media,
|
.hero-media--portrait .hero-image {
|
||||||
.image-fallback {
|
object-fit: cover;
|
||||||
min-height: 300px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb-strip {
|
.thumb-strip {
|
||||||
@@ -485,8 +601,7 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.thumb {
|
.thumb {
|
||||||
flex-basis: 78px;
|
flex-basis: 84px;
|
||||||
height: 78px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-launch-row {
|
.model-launch-row {
|
||||||
@@ -514,6 +629,10 @@ h1 {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selector-card {
|
||||||
|
padding: 0.74rem 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
:host ::ng-deep app-card.purchase-shell .card-body {
|
:host ::ng-deep app-card.purchase-shell .card-body {
|
||||||
padding: 0.82rem 0.82rem;
|
padding: 0.82rem 0.82rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
|
afterNextRender,
|
||||||
Component,
|
Component,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
Injector,
|
Injector,
|
||||||
@@ -15,7 +16,7 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
|||||||
import { catchError, combineLatest, finalize, of, switchMap, tap } from 'rxjs';
|
import { catchError, combineLatest, finalize, of, switchMap, tap } from 'rxjs';
|
||||||
import { SeoService } from '../../core/services/seo.service';
|
import { SeoService } from '../../core/services/seo.service';
|
||||||
import { LanguageService } from '../../core/services/language.service';
|
import { LanguageService } from '../../core/services/language.service';
|
||||||
import { getColorHex } from '../../core/constants/colors.const';
|
import { findColorHex, getColorHex } from '../../core/constants/colors.const';
|
||||||
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
||||||
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||||
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
|
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
|
||||||
@@ -193,16 +194,9 @@ export class ProductDetailComponent {
|
|||||||
);
|
);
|
||||||
|
|
||||||
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.productSlug, { injector: this.injector }),
|
toObservable(this.productSlug, { injector: this.injector }),
|
||||||
@@ -230,7 +224,7 @@ export class ProductDetailComponent {
|
|||||||
catchError((error) => {
|
catchError((error) => {
|
||||||
this.product.set(null);
|
this.product.set(null);
|
||||||
this.selectedVariantId.set(null);
|
this.selectedVariantId.set(null);
|
||||||
this.selectedImageAssetId.set(null);
|
this.setSelectedImageAssetId(null);
|
||||||
this.modelFile.set(null);
|
this.modelFile.set(null);
|
||||||
this.error.set(
|
this.error.set(
|
||||||
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
|
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
|
||||||
@@ -257,7 +251,7 @@ export class ProductDetailComponent {
|
|||||||
product.defaultVariant ?? product.variants[0] ?? null,
|
product.defaultVariant ?? product.variants[0] ?? null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
this.selectedImageAssetId.set(
|
this.setSelectedImageAssetId(
|
||||||
product.primaryImage?.mediaAssetId ??
|
product.primaryImage?.mediaAssetId ??
|
||||||
product.images[0]?.mediaAssetId ??
|
product.images[0]?.mediaAssetId ??
|
||||||
null,
|
null,
|
||||||
@@ -279,11 +273,50 @@ export class ProductDetailComponent {
|
|||||||
this.shopService.resolveMediaUrl(image.hero) ??
|
this.shopService.resolveMediaUrl(image.hero) ??
|
||||||
this.shopService.resolveMediaUrl(image.card) ??
|
this.shopService.resolveMediaUrl(image.card) ??
|
||||||
this.shopService.resolveMediaUrl(image.thumb)
|
this.shopService.resolveMediaUrl(image.thumb)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.selectedImageAssetId.set(mediaAssetId);
|
this.setSelectedImageAssetId(mediaAssetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
showPreviousImage(): void {
|
showPreviousImage(): void {
|
||||||
@@ -293,7 +326,7 @@ export class ProductDetailComponent {
|
|||||||
}
|
}
|
||||||
const nextIndex =
|
const nextIndex =
|
||||||
(this.selectedImageIndex() - 1 + images.length) % images.length;
|
(this.selectedImageIndex() - 1 + images.length) % images.length;
|
||||||
this.selectedImageAssetId.set(images[nextIndex].mediaAssetId);
|
this.setSelectedImageAssetId(images[nextIndex].mediaAssetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
showNextImage(): void {
|
showNextImage(): void {
|
||||||
@@ -302,7 +335,7 @@ export class ProductDetailComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextIndex = (this.selectedImageIndex() + 1) % images.length;
|
const nextIndex = (this.selectedImageIndex() + 1) % images.length;
|
||||||
this.selectedImageAssetId.set(images[nextIndex].mediaAssetId);
|
this.setSelectedImageAssetId(images[nextIndex].mediaAssetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
selectVariant(variant: ShopProductVariantOption): void {
|
selectVariant(variant: ShopProductVariantOption): void {
|
||||||
@@ -366,9 +399,12 @@ export class ProductDetailComponent {
|
|||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.router.navigate(['/checkout'], {
|
this.router.navigate(
|
||||||
queryParams: { session: sessionId },
|
['/', this.languageService.selectedLang(), 'checkout'],
|
||||||
});
|
{
|
||||||
|
queryParams: { session: sessionId },
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
priceLabel(): number {
|
priceLabel(): number {
|
||||||
@@ -378,7 +414,9 @@ export class ProductDetailComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
colorLabel(variant: ShopProductVariantOption): string {
|
colorLabel(variant: ShopProductVariantOption): string {
|
||||||
return variant.colorName || variant.variantLabel || '-';
|
return (
|
||||||
|
variant.colorLabel || variant.colorName || variant.variantLabel || '-'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
colorHex(variant: ShopProductVariantOption | null | undefined): string {
|
colorHex(variant: ShopProductVariantOption | null | undefined): string {
|
||||||
@@ -479,6 +517,10 @@ export class ProductDetailComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setSelectedImageAssetId(mediaAssetId: string | null): void {
|
||||||
|
this.selectedImageAssetId.set(mediaAssetId);
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeHexColor(value: string | null | undefined): string | null {
|
private normalizeHexColor(value: string | null | undefined): string | null {
|
||||||
const raw = String(value ?? '').trim();
|
const raw = String(value ?? '').trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
@@ -494,17 +536,7 @@ export class ProductDetailComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private colorHexFromName(value: string | null | undefined): string | null {
|
private colorHexFromName(value: string | null | undefined): string | null {
|
||||||
const colorName = String(value ?? '').trim();
|
return findColorHex(value);
|
||||||
if (!colorName) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallback = getColorHex(colorName);
|
|
||||||
if (!fallback || fallback === '#facf0a') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private applySeo(product: ShopProductDetail): void {
|
private applySeo(product: ShopProductDetail): void {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface ShopProductVariantOption {
|
|||||||
sku: string | null;
|
sku: string | null;
|
||||||
variantLabel: string | null;
|
variantLabel: string | null;
|
||||||
colorName: string | null;
|
colorName: string | null;
|
||||||
|
colorLabel: string | null;
|
||||||
colorHex: string | null;
|
colorHex: string | null;
|
||||||
priceChf: number;
|
priceChf: number;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
@@ -138,6 +139,10 @@ export interface ShopCartItem {
|
|||||||
shopProductName: string | null;
|
shopProductName: string | null;
|
||||||
shopVariantLabel: string | null;
|
shopVariantLabel: string | null;
|
||||||
shopVariantColorName: string | null;
|
shopVariantColorName: string | null;
|
||||||
|
shopVariantColorLabelIt?: string | null;
|
||||||
|
shopVariantColorLabelEn?: string | null;
|
||||||
|
shopVariantColorLabelDe?: string | null;
|
||||||
|
shopVariantColorLabelFr?: string | null;
|
||||||
shopVariantColorHex: string | null;
|
shopVariantColorHex: string | null;
|
||||||
materialCode: string | null;
|
materialCode: string | null;
|
||||||
quality: string | null;
|
quality: string | null;
|
||||||
|
|||||||
@@ -84,7 +84,9 @@
|
|||||||
<div class="cart-line-copy">
|
<div class="cart-line-copy">
|
||||||
<strong>{{ cartItemName(item) }}</strong>
|
<strong>{{ cartItemName(item) }}</strong>
|
||||||
@if (cartItemVariant(item); as variant) {
|
@if (cartItemVariant(item); as variant) {
|
||||||
<span class="cart-line-meta">{{ variant }}</span>
|
<span class="cart-line-meta">{{
|
||||||
|
variant | translate
|
||||||
|
}}</span>
|
||||||
}
|
}
|
||||||
@if (cartItemColor(item); as color) {
|
@if (cartItemColor(item); as color) {
|
||||||
<span class="cart-line-color">
|
<span class="cart-line-color">
|
||||||
@@ -92,7 +94,7 @@
|
|||||||
class="color-dot"
|
class="color-dot"
|
||||||
[style.background-color]="cartItemColorHex(item)"
|
[style.background-color]="cartItemColorHex(item)"
|
||||||
></span>
|
></span>
|
||||||
<span>{{ color }}</span>
|
<span>{{ color | translate }}</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -237,7 +239,10 @@
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<app-button variant="primary" routerLink="/contact">
|
<app-button
|
||||||
|
variant="primary"
|
||||||
|
[routerLink]="languageService.localizedPath('/contact')"
|
||||||
|
>
|
||||||
{{ "NAV.CONTACT" | translate }}
|
{{ "NAV.CONTACT" | translate }}
|
||||||
</app-button>
|
</app-button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
|
afterNextRender,
|
||||||
Component,
|
Component,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
Injector,
|
Injector,
|
||||||
@@ -22,6 +23,10 @@ import {
|
|||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import { SeoService } from '../../core/services/seo.service';
|
import { SeoService } from '../../core/services/seo.service';
|
||||||
import { LanguageService } from '../../core/services/language.service';
|
import { LanguageService } from '../../core/services/language.service';
|
||||||
|
import {
|
||||||
|
findColorHex,
|
||||||
|
resolveLocalizedColorLabel,
|
||||||
|
} from '../../core/constants/colors.const';
|
||||||
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
||||||
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||||
import { ProductCardComponent } from './components/product-card/product-card.component';
|
import { ProductCardComponent } from './components/product-card/product-card.component';
|
||||||
@@ -55,7 +60,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 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);
|
||||||
|
|
||||||
@@ -85,16 +90,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 }),
|
||||||
@@ -146,6 +144,45 @@ export class ShopPageComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
productCartQuantity(productId: string): number {
|
productCartQuantity(productId: string): number {
|
||||||
return this.shopService.quantityForProduct(productId);
|
return this.shopService.quantityForProduct(productId);
|
||||||
}
|
}
|
||||||
@@ -157,15 +194,30 @@ export class ShopPageComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cartItemVariant(item: ShopCartItem): string | null {
|
cartItemVariant(item: ShopCartItem): string | null {
|
||||||
return item.shopVariantLabel || item.shopVariantColorName || null;
|
return item.shopVariantLabel || this.cartItemColor(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
cartItemColor(item: ShopCartItem): string | null {
|
cartItemColor(item: ShopCartItem): string | null {
|
||||||
return item.shopVariantColorName || item.colorCode || null;
|
return (
|
||||||
|
resolveLocalizedColorLabel(this.languageService.selectedLang(), {
|
||||||
|
fallback: item.shopVariantColorName ?? item.colorCode,
|
||||||
|
it: item.shopVariantColorLabelIt,
|
||||||
|
en: item.shopVariantColorLabelEn,
|
||||||
|
de: item.shopVariantColorLabelDe,
|
||||||
|
fr: item.shopVariantColorLabelFr,
|
||||||
|
}) ??
|
||||||
|
item.shopVariantColorName ??
|
||||||
|
item.colorCode
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
cartItemColorHex(item: ShopCartItem): string {
|
cartItemColorHex(item: ShopCartItem): string {
|
||||||
return item.shopVariantColorHex || '#c9ced6';
|
return (
|
||||||
|
item.shopVariantColorHex ||
|
||||||
|
findColorHex(item.shopVariantColorName) ||
|
||||||
|
findColorHex(item.colorCode) ||
|
||||||
|
'#c9ced6'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
navigateToCategory(slug?: string | null): void {
|
navigateToCategory(slug?: string | null): void {
|
||||||
@@ -223,11 +275,14 @@ export class ShopPageComponent {
|
|||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.router.navigate(['/checkout'], {
|
this.router.navigate(
|
||||||
queryParams: {
|
['/', this.languageService.selectedLang(), 'checkout'],
|
||||||
session: sessionId,
|
{
|
||||||
|
queryParams: {
|
||||||
|
session: sessionId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
trackByCategory(_index: number, item: ShopCategoryNavNode): string {
|
trackByCategory(_index: number, item: ShopCategoryNavNode): string {
|
||||||
|
|||||||
@@ -7,30 +7,32 @@ export const SHOP_ROUTES: Routes = [
|
|||||||
path: '',
|
path: '',
|
||||||
component: ShopPageComponent,
|
component: ShopPageComponent,
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Shop 3D fab',
|
seoTitleKey: 'SEO.ROUTES.SHOP.TITLE',
|
||||||
seoDescription:
|
seoDescriptionKey: 'SEO.ROUTES.SHOP.DESCRIPTION',
|
||||||
'Catalogo prodotti stampati in 3D, accessori tecnici e soluzioni pratiche pronte all uso.',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'p/:productSlug',
|
path: 'p/:productSlug',
|
||||||
component: ProductDetailComponent,
|
component: ProductDetailComponent,
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Prodotto | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.SHOP.PRODUCT_TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':categorySlug/:productSlug',
|
path: ':categorySlug/:productSlug',
|
||||||
component: ProductDetailComponent,
|
component: ProductDetailComponent,
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Prodotto | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.SHOP.PRODUCT_TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':categorySlug',
|
path: ':categorySlug',
|
||||||
component: ShopPageComponent,
|
component: ShopPageComponent,
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Categoria Shop | 3D fab',
|
seoTitleKey: 'SEO.ROUTES.SHOP.CATEGORY_TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -27,7 +27,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a routerLink="/contact" class="contact-btn">
|
<a
|
||||||
|
[routerLink]="languageService.localizedPath('/contact')"
|
||||||
|
class="contact-btn"
|
||||||
|
>
|
||||||
{{ "LOCATIONS.CONTACT_US" | translate }}
|
{{ "LOCATIONS.CONTACT_US" | translate }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { LanguageService } from '../../../core/services/language.service';
|
||||||
import {
|
import {
|
||||||
AppToggleSelectorComponent,
|
AppToggleSelectorComponent,
|
||||||
ToggleOption,
|
ToggleOption,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
styleUrl: './app-locations.component.scss',
|
styleUrl: './app-locations.component.scss',
|
||||||
})
|
})
|
||||||
export class AppLocationsComponent {
|
export class AppLocationsComponent {
|
||||||
|
readonly languageService = inject(LanguageService);
|
||||||
selectedLocation: 'ticino' | 'bienne' = 'ticino';
|
selectedLocation: 'ticino' | 'bienne' = 'ticino';
|
||||||
|
|
||||||
locationOptions: ToggleOption[] = [
|
locationOptions: ToggleOption[] = [
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Component, input, output, signal, computed } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
signal,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
} from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import {
|
import {
|
||||||
@@ -6,8 +13,10 @@ import {
|
|||||||
getColorHex,
|
getColorHex,
|
||||||
ColorCategory,
|
ColorCategory,
|
||||||
ColorOption,
|
ColorOption,
|
||||||
|
resolveLocalizedColorLabel,
|
||||||
} from '../../../core/constants/colors.const';
|
} from '../../../core/constants/colors.const';
|
||||||
import { VariantOption } from '../../../features/calculator/services/quote-estimator.service';
|
import { VariantOption } from '../../../features/calculator/services/quote-estimator.service';
|
||||||
|
import { LanguageService } from '../../../core/services/language.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-color-selector',
|
selector: 'app-color-selector',
|
||||||
@@ -17,6 +26,7 @@ import { VariantOption } from '../../../features/calculator/services/quote-estim
|
|||||||
styleUrl: './color-selector.component.scss',
|
styleUrl: './color-selector.component.scss',
|
||||||
})
|
})
|
||||||
export class ColorSelectorComponent {
|
export class ColorSelectorComponent {
|
||||||
|
private readonly languageService = inject(LanguageService);
|
||||||
selectedColor = input<string>('Black');
|
selectedColor = input<string>('Black');
|
||||||
selectedVariantId = input<number | null>(null);
|
selectedVariantId = input<number | null>(null);
|
||||||
variants = input<VariantOption[]>([]);
|
variants = input<VariantOption[]>([]);
|
||||||
@@ -32,7 +42,14 @@ export class ColorSelectorComponent {
|
|||||||
const finish = v.finishType || 'AVAILABLE_COLORS';
|
const finish = v.finishType || 'AVAILABLE_COLORS';
|
||||||
const bucket = byFinish.get(finish) || [];
|
const bucket = byFinish.get(finish) || [];
|
||||||
bucket.push({
|
bucket.push({
|
||||||
label: v.colorName,
|
label:
|
||||||
|
resolveLocalizedColorLabel(this.languageService.selectedLang(), {
|
||||||
|
fallback: v.colorName,
|
||||||
|
it: v.colorLabelIt,
|
||||||
|
en: v.colorLabelEn,
|
||||||
|
de: v.colorLabelDe,
|
||||||
|
fr: v.colorLabelFr,
|
||||||
|
}) ?? v.colorName,
|
||||||
value: v.colorName,
|
value: v.colorName,
|
||||||
hex: v.hexColor,
|
hex: v.hexColor,
|
||||||
variantId: v.id,
|
variantId: v.id,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"CALCULATOR": "Rechner",
|
"CALCULATOR": "Rechner",
|
||||||
"SHOP": "Shop",
|
"SHOP": "Shop",
|
||||||
"ABOUT": "Über uns",
|
"ABOUT": "Über uns",
|
||||||
|
"MATERIALS": "Qualität & Materialien",
|
||||||
"CONTACT": "Kontakt",
|
"CONTACT": "Kontakt",
|
||||||
"LANGUAGE_SELECTOR": "Sprachauswahl"
|
"LANGUAGE_SELECTOR": "Sprachauswahl"
|
||||||
},
|
},
|
||||||
@@ -38,6 +39,72 @@
|
|||||||
"TERMS": "AGB",
|
"TERMS": "AGB",
|
||||||
"CONTACT": "Kontakt"
|
"CONTACT": "Kontakt"
|
||||||
},
|
},
|
||||||
|
"SEO": {
|
||||||
|
"DEFAULT": {
|
||||||
|
"TITLE": "3D fab | 3D-Druck nach Maß",
|
||||||
|
"DESCRIPTION": "3D-Druckservice nach Maß, technischer Shop und CAD-Support für Prototypen, Ersatzteile und Kleinserien."
|
||||||
|
},
|
||||||
|
"ROUTES": {
|
||||||
|
"HOME": {
|
||||||
|
"TITLE": "3D-Druck in Zürich | Prototypen, Ersatzteile und Kleinserien - 3D fab",
|
||||||
|
"DESCRIPTION": "3D-Druckservice in Zürich für Prototypen, Ersatzteile und Kleinserien. Technischer Shop und CAD-Service, mit schneller Angebotsanfrage aus STL-Dateien."
|
||||||
|
},
|
||||||
|
"CALCULATOR": {
|
||||||
|
"TITLE": "3D-Druck-Angebotsrechner | 3D fab",
|
||||||
|
"DESCRIPTION": "Laden Sie Ihre 3D-Datei hoch und erhalten Sie Preis und Lieferzeit in Sekunden mit echtem Slicing.",
|
||||||
|
"BASIC": {
|
||||||
|
"TITLE": "Einfacher 3D-Druck-Rechner | 3D fab",
|
||||||
|
"DESCRIPTION": "Berechnen Sie den Preis Ihres 3D-Drucks schnell mit dem Basis-Workflow."
|
||||||
|
},
|
||||||
|
"ADVANCED": {
|
||||||
|
"TITLE": "Erweiterter 3D-Druck-Rechner | 3D fab",
|
||||||
|
"DESCRIPTION": "Konfigurieren Sie erweiterte Druckparameter und erhalten Sie ein präzises Angebot mit echtem Slicing."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SHOP": {
|
||||||
|
"TITLE": "3D fab Shop",
|
||||||
|
"DESCRIPTION": "Katalog mit 3D-gedruckten Produkten, technischem Zubehör und sofort einsatzbereiten Lösungen.",
|
||||||
|
"CATEGORY_TITLE": "Shop-Kategorie | 3D fab",
|
||||||
|
"CATEGORY_DESCRIPTION": "Entdecken Sie Produkte dieser Kategorie, verfügbare Varianten und technische 3D-Druck-Lösungen.",
|
||||||
|
"PRODUCT_TITLE": "Produkt | 3D fab",
|
||||||
|
"PRODUCT_DESCRIPTION": "Entdecken Sie Details, Materialien, Varianten und Verfügbarkeit des ausgewählten Produkts im 3D fab Shop."
|
||||||
|
},
|
||||||
|
"MATERIALS": {
|
||||||
|
"TITLE": "Qualität und Materialien | 3D fab",
|
||||||
|
"DESCRIPTION": "Vergleichen Sie 3D-Druckmaterialien mit interaktiven Radar-Charts, technischen Eigenschaften und referenzierten Quellen."
|
||||||
|
},
|
||||||
|
"ABOUT": {
|
||||||
|
"TITLE": "Über uns | 3D fab",
|
||||||
|
"DESCRIPTION": "Lernen Sie das Team von 3D fab und unser 3D-Druck-Labor für Prototypen, Ersatzteile und maßgeschneiderte Produktionen kennen."
|
||||||
|
},
|
||||||
|
"CONTACT": {
|
||||||
|
"TITLE": "Kontakt | 3D fab",
|
||||||
|
"DESCRIPTION": "Fordern Sie Informationen, individuelle Angebote oder technischen Support für Ihr 3D-Druck-Projekt an."
|
||||||
|
},
|
||||||
|
"LEGAL": {
|
||||||
|
"PRIVACY": {
|
||||||
|
"TITLE": "Datenschutz | 3D fab",
|
||||||
|
"DESCRIPTION": "Datenschutzerklärung von 3D fab: Datenverarbeitung, Zwecke und Kontaktangaben."
|
||||||
|
},
|
||||||
|
"TERMS": {
|
||||||
|
"TITLE": "AGB | 3D fab",
|
||||||
|
"DESCRIPTION": "Allgemeine Geschäftsbedingungen für den 3D-Druck-Service und den Angebotsrechner."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CHECKOUT": {
|
||||||
|
"TITLE": "Checkout | 3D fab",
|
||||||
|
"DESCRIPTION": "Schließen Sie Ihre Anfrage ab und bestätigen Sie die Daten Ihrer 3D-Druck-Bestellung."
|
||||||
|
},
|
||||||
|
"ORDER": {
|
||||||
|
"TITLE": "Bestellung | 3D fab",
|
||||||
|
"DESCRIPTION": "Prüfen Sie die Zusammenfassung Ihrer Bestellung und den Status Ihrer 3D-Druck-Anfrage."
|
||||||
|
},
|
||||||
|
"ADMIN": {
|
||||||
|
"TITLE": "Admin | 3D fab",
|
||||||
|
"DESCRIPTION": "Geschützter Administrationsbereich von 3D fab."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"CALC": {
|
"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, STEP) hoch, stellen Sie Qualität und Farbe ein und berechnen Sie sofort Preis und Lieferzeit.",
|
||||||
@@ -98,6 +165,7 @@
|
|||||||
"SHOP": {
|
"SHOP": {
|
||||||
"TITLE": "Technische Lösungen",
|
"TITLE": "Technische Lösungen",
|
||||||
"SUBTITLE": "Fertige Produkte, die praktische Probleme lösen",
|
"SUBTITLE": "Fertige Produkte, die praktische Probleme lösen",
|
||||||
|
"HERO_EYEBROW": "Technischer Shop",
|
||||||
"WIP_EYEBROW": "Work in progress",
|
"WIP_EYEBROW": "Work in progress",
|
||||||
"WIP_TITLE": "Shop im Aufbau",
|
"WIP_TITLE": "Shop im Aufbau",
|
||||||
"WIP_SUBTITLE": "Wir bereiten einen Shop mit ausgewählten Produkten und Funktionen zur automatischen Erstellung vor!",
|
"WIP_SUBTITLE": "Wir bereiten einen Shop mit ausgewählten Produkten und Funktionen zur automatischen Erstellung vor!",
|
||||||
@@ -108,6 +176,7 @@
|
|||||||
"CUSTOM_PART_FOOTER_TEXT": "Kontaktieren Sie uns für individuelle Teile.",
|
"CUSTOM_PART_FOOTER_TEXT": "Kontaktieren Sie uns für individuelle Teile.",
|
||||||
"ADD_CART": "In den Warenkorb",
|
"ADD_CART": "In den Warenkorb",
|
||||||
"ADDING": "Wird hinzugefügt",
|
"ADDING": "Wird hinzugefügt",
|
||||||
|
"ADD_SUCCESS": "Produkt zum Warenkorb hinzugefügt.",
|
||||||
"BACK": "Zurück zum Shop",
|
"BACK": "Zurück zum Shop",
|
||||||
"NOT_FOUND": "Produkt nicht gefunden.",
|
"NOT_FOUND": "Produkt nicht gefunden.",
|
||||||
"DETAILS": "Details",
|
"DETAILS": "Details",
|
||||||
@@ -115,13 +184,39 @@
|
|||||||
"SUCCESS_TITLE": "Zum Warenkorb hinzugefügt",
|
"SUCCESS_TITLE": "Zum Warenkorb hinzugefügt",
|
||||||
"SUCCESS_DESC": "Das Produkt wurde erfolgreich zum Warenkorb hinzugefügt.",
|
"SUCCESS_DESC": "Das Produkt wurde erfolgreich zum Warenkorb hinzugefügt.",
|
||||||
"CONTINUE": "Weiter",
|
"CONTINUE": "Weiter",
|
||||||
|
"VIEW_ALL": "Gesamten Shop ansehen",
|
||||||
|
"CATALOG_LABEL": "Katalog",
|
||||||
|
"CATALOG_TITLE": "Alle Produkte",
|
||||||
|
"CATALOG_META_DESCRIPTION": "Entdecken Sie 3D-gedruckte Produkte, technisches Zubehör und einsatzbereite Lösungen mit demselben Checkout wie im Rechner.",
|
||||||
|
"CUSTOM_PART_CTA": "Nicht gefunden, was Sie suchen? Fordern Sie ein individuelles Teil an.",
|
||||||
|
"CATEGORY_META": "{{count}} Produkte in dieser Kategorie verfügbar",
|
||||||
|
"CATEGORY_PANEL_KICKER": "Navigation",
|
||||||
|
"CATEGORY_PANEL_TITLE": "Kategorien",
|
||||||
|
"SELECTED_CATEGORY": "Ausgewählte Kategorie",
|
||||||
|
"ITEMS_FOUND": "Produkte",
|
||||||
|
"EMPTY_CATEGORY": "Derzeit sind in dieser Kategorie keine Produkte verfügbar.",
|
||||||
|
"FEATURED_KICKER": "Empfohlen",
|
||||||
|
"FEATURED_TITLE": "Produkte, die sich lohnen",
|
||||||
|
"FEATURED_BADGE": "Empfohlen",
|
||||||
|
"HIGHLIGHT_PRODUCTS": "Produkte",
|
||||||
|
"HIGHLIGHT_CART": "Im Warenkorb",
|
||||||
|
"HIGHLIGHT_READY": "Vorschau",
|
||||||
|
"PRICE_FROM": "Preis ab",
|
||||||
"MODEL_OPEN": "3D-Ansicht öffnen",
|
"MODEL_OPEN": "3D-Ansicht öffnen",
|
||||||
"MODEL_CLOSE": "3D-Ansicht schließen",
|
"MODEL_CLOSE": "3D-Ansicht schließen",
|
||||||
|
"MODEL_3D": "3D-Vorschau",
|
||||||
|
"MODEL_TITLE": "Modellvorschau",
|
||||||
|
"MODEL_LOADING": "Das 3D-Modell wird geladen.",
|
||||||
|
"MODEL_UNAVAILABLE": "3D-Vorschau nicht verfügbar.",
|
||||||
"PREVIOUS_IMAGE": "Vorheriges Bild",
|
"PREVIOUS_IMAGE": "Vorheriges Bild",
|
||||||
"NEXT_IMAGE": "Nächstes Bild",
|
"NEXT_IMAGE": "Nächstes Bild",
|
||||||
|
"BREADCRUMB_ROOT": "Shop",
|
||||||
|
"PRICE_LABEL": "Preis",
|
||||||
|
"EXCERPT_FALLBACK": "Produktseite in Vorbereitung.",
|
||||||
"SELECT_MATERIAL": "Material",
|
"SELECT_MATERIAL": "Material",
|
||||||
"SELECT_COLOR": "Farbe",
|
"SELECT_COLOR": "Farbe",
|
||||||
"MATERIAL_COLOR_COUNT": "{{count}} Farben verfügbar",
|
"MATERIAL_COLOR_COUNT": "{{count}} Farben verfügbar",
|
||||||
|
"VARIANT": "Variante",
|
||||||
"PROPERTY_UV": "UV-Beständigkeit",
|
"PROPERTY_UV": "UV-Beständigkeit",
|
||||||
"PROPERTY_WEATHER": "Außeneinsatz",
|
"PROPERTY_WEATHER": "Außeneinsatz",
|
||||||
"PROPERTY_RIGIDITY": "Steifigkeit",
|
"PROPERTY_RIGIDITY": "Steifigkeit",
|
||||||
@@ -130,6 +225,22 @@
|
|||||||
"PROPERTY_LOW": "Niedrig",
|
"PROPERTY_LOW": "Niedrig",
|
||||||
"PROPERTY_RIGID": "Steif",
|
"PROPERTY_RIGID": "Steif",
|
||||||
"PROPERTY_FLEXIBLE": "Flexibel",
|
"PROPERTY_FLEXIBLE": "Flexibel",
|
||||||
|
"QUANTITY": "Menge",
|
||||||
|
"GO_TO_CHECKOUT": "Zum Checkout",
|
||||||
|
"IN_CART_SHORT": "Im Warenkorb x{{count}}",
|
||||||
|
"IN_CART_LONG": "Bereits im Warenkorb x{{count}}",
|
||||||
|
"DESCRIPTION_TITLE": "Beschreibung",
|
||||||
|
"CART_TITLE": "Warenkorb",
|
||||||
|
"CART_SUMMARY_TITLE": "Aktuelle Übersicht",
|
||||||
|
"CART_LOADING": "Warenkorb wird geladen.",
|
||||||
|
"CART_EMPTY": "Der Warenkorb ist leer. Fügen Sie ein Produkt hinzu.",
|
||||||
|
"CART_SUBTOTAL": "Zwischensumme Produkte",
|
||||||
|
"CART_SHIPPING": "Versand",
|
||||||
|
"CART_TOTAL": "Geschätzte Gesamtsumme",
|
||||||
|
"CLEAR_CART": "Leeren",
|
||||||
|
"REMOVE": "Entfernen",
|
||||||
|
"CART_UPDATE_ERROR": "Der Warenkorb konnte nicht aktualisiert werden. Bitte erneut versuchen.",
|
||||||
|
"ALL_CATEGORIES": "Alle Kategorien",
|
||||||
"CATEGORIES": {
|
"CATEGORIES": {
|
||||||
"FILAMENTS": "Filamente",
|
"FILAMENTS": "Filamente",
|
||||||
"ACCESSORIES": "Zubehör"
|
"ACCESSORIES": "Zubehör"
|
||||||
@@ -397,6 +508,8 @@
|
|||||||
"CHECKOUT": {
|
"CHECKOUT": {
|
||||||
"TITLE": "Checkout",
|
"TITLE": "Checkout",
|
||||||
"SUBTITLE": "Schließen Sie Ihre Bestellung ab, indem Sie Versand- und Zahlungsdetails eingeben.",
|
"SUBTITLE": "Schließen Sie Ihre Bestellung ab, indem Sie Versand- und Zahlungsdetails eingeben.",
|
||||||
|
"CAD_SERVICE": "CAD-Service",
|
||||||
|
"CAD_REQUEST_REF": "bezogen auf Kontaktanfrage #{{id}}",
|
||||||
"CONTACT_INFO": "Kontaktinformationen",
|
"CONTACT_INFO": "Kontaktinformationen",
|
||||||
"BILLING_ADDR": "Rechnungsadresse",
|
"BILLING_ADDR": "Rechnungsadresse",
|
||||||
"SHIPPING_ADDR": "Lieferadresse",
|
"SHIPPING_ADDR": "Lieferadresse",
|
||||||
@@ -499,6 +612,13 @@
|
|||||||
"HERO_TITLE": "3D-Druckservice.<br>Von der Datei zum fertigen Teil.",
|
"HERO_TITLE": "3D-Druckservice.<br>Von der Datei zum fertigen Teil.",
|
||||||
"HERO_LEAD": "Mit dem fortschrittlichsten Rechner für Ihre 3D-Drucke: absolute Präzision und keine Überraschungen.",
|
"HERO_LEAD": "Mit dem fortschrittlichsten Rechner für Ihre 3D-Drucke: absolute Präzision und keine Überraschungen.",
|
||||||
"HERO_SUBTITLE": "Wir bieten auch CAD-Services für individuelle Teile an!",
|
"HERO_SUBTITLE": "Wir bieten auch CAD-Services für individuelle Teile an!",
|
||||||
|
"HERO_SWISS_TITLE": "Based in Switzerland",
|
||||||
|
"HERO_SWISS_COPY": "Produktion und Support in der Schweiz.",
|
||||||
|
"HERO_SWISS_LOCATIONS_LABEL": "Standorte",
|
||||||
|
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||||
|
"HERO_SWISS_LOCATION_2": "Zurich",
|
||||||
|
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
||||||
|
"HERO_SWISS_NOTE": "In der ganzen Schweiz aktiv.",
|
||||||
"BTN_CALCULATE": "Angebot berechnen",
|
"BTN_CALCULATE": "Angebot berechnen",
|
||||||
"BTN_SHOP": "Zum Shop",
|
"BTN_SHOP": "Zum Shop",
|
||||||
"BTN_CONTACT": "Mit uns sprechen",
|
"BTN_CONTACT": "Mit uns sprechen",
|
||||||
@@ -543,6 +663,13 @@
|
|||||||
"ERR_ID_NOT_FOUND": "Bestell-ID nicht gefunden.",
|
"ERR_ID_NOT_FOUND": "Bestell-ID nicht gefunden.",
|
||||||
"ERR_LOAD_ORDER": "Bestelldetails konnten nicht geladen werden.",
|
"ERR_LOAD_ORDER": "Bestelldetails konnten nicht geladen werden.",
|
||||||
"ERR_REPORT_PAYMENT": "Zahlung konnte nicht gemeldet werden. Bitte erneut versuchen.",
|
"ERR_REPORT_PAYMENT": "Zahlung konnte nicht gemeldet werden. Bitte erneut versuchen.",
|
||||||
|
"CAD_SERVICE": "CAD-Service ({{hours}}h)",
|
||||||
|
"ITEMS_TITLE": "Bestellartikel",
|
||||||
|
"ORDER_TYPE_LABEL": "Bestelltyp",
|
||||||
|
"ITEM_COUNT": "Positionen",
|
||||||
|
"TYPE_SHOP": "Shop",
|
||||||
|
"TYPE_CALCULATOR": "Rechner",
|
||||||
|
"TYPE_MIXED": "Gemischt",
|
||||||
"NOT_AVAILABLE": "N/V"
|
"NOT_AVAILABLE": "N/V"
|
||||||
},
|
},
|
||||||
"DROPZONE": {
|
"DROPZONE": {
|
||||||
@@ -560,6 +687,13 @@
|
|||||||
"BLUE": "Blau",
|
"BLUE": "Blau",
|
||||||
"GREEN": "Grün",
|
"GREEN": "Grün",
|
||||||
"YELLOW": "Gelb",
|
"YELLOW": "Gelb",
|
||||||
|
"ORANGE": "Orange",
|
||||||
|
"GRAY": "Grau",
|
||||||
|
"LIGHT_GRAY": "Hellgrau",
|
||||||
|
"DARK_GRAY": "Dunkelgrau",
|
||||||
|
"PURPLE": "Lila",
|
||||||
|
"BEIGE": "Beige",
|
||||||
|
"SAND_BEIGE": "Sandbeige",
|
||||||
"MATTE_BLACK": "Matt Schwarz",
|
"MATTE_BLACK": "Matt Schwarz",
|
||||||
"MATTE_WHITE": "Matt Weiß",
|
"MATTE_WHITE": "Matt Weiß",
|
||||||
"MATTE_GRAY": "Matt Grau"
|
"MATTE_GRAY": "Matt Grau"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"CALCULATOR": "Calculator",
|
"CALCULATOR": "Calculator",
|
||||||
"SHOP": "Shop",
|
"SHOP": "Shop",
|
||||||
"ABOUT": "About Us",
|
"ABOUT": "About Us",
|
||||||
|
"MATERIALS": "Quality & Materials",
|
||||||
"CONTACT": "Contact Us",
|
"CONTACT": "Contact Us",
|
||||||
"LANGUAGE_SELECTOR": "Language selector"
|
"LANGUAGE_SELECTOR": "Language selector"
|
||||||
},
|
},
|
||||||
@@ -38,6 +39,72 @@
|
|||||||
"TERMS": "Terms & Conditions",
|
"TERMS": "Terms & Conditions",
|
||||||
"CONTACT": "Contact Us"
|
"CONTACT": "Contact Us"
|
||||||
},
|
},
|
||||||
|
"SEO": {
|
||||||
|
"DEFAULT": {
|
||||||
|
"TITLE": "3D fab | Custom 3D Printing",
|
||||||
|
"DESCRIPTION": "Custom 3D printing service, technical shop and CAD support for prototypes, spare parts and short runs."
|
||||||
|
},
|
||||||
|
"ROUTES": {
|
||||||
|
"HOME": {
|
||||||
|
"TITLE": "Custom 3D Printing in Switzerland | Prototypes, Spare Parts & Short Runs - 3D fab",
|
||||||
|
"DESCRIPTION": "Swiss-based 3D printing service for prototypes, spare parts and short production runs. Technical shop and CAD support, with fast quotes from STL files."
|
||||||
|
},
|
||||||
|
"CALCULATOR": {
|
||||||
|
"TITLE": "3D Printing Quote Calculator | 3D fab",
|
||||||
|
"DESCRIPTION": "Upload your 3D file and get price and lead time in seconds with real slicing.",
|
||||||
|
"BASIC": {
|
||||||
|
"TITLE": "Basic 3D Printing Calculator | 3D fab",
|
||||||
|
"DESCRIPTION": "Quickly estimate the price of your 3D print with the basic workflow."
|
||||||
|
},
|
||||||
|
"ADVANCED": {
|
||||||
|
"TITLE": "Advanced 3D Printing Calculator | 3D fab",
|
||||||
|
"DESCRIPTION": "Configure advanced print settings and get a precise quote based on real slicing."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SHOP": {
|
||||||
|
"TITLE": "3D fab Shop",
|
||||||
|
"DESCRIPTION": "Catalog of 3D printed products, technical accessories and practical ready-to-use solutions.",
|
||||||
|
"CATEGORY_TITLE": "Shop Category | 3D fab",
|
||||||
|
"CATEGORY_DESCRIPTION": "Browse products in this category, available variants and technical 3D printed solutions.",
|
||||||
|
"PRODUCT_TITLE": "Product | 3D fab",
|
||||||
|
"PRODUCT_DESCRIPTION": "Discover details, materials, variants and availability for the selected product in the 3D fab shop."
|
||||||
|
},
|
||||||
|
"MATERIALS": {
|
||||||
|
"TITLE": "Quality and Materials | 3D fab",
|
||||||
|
"DESCRIPTION": "Compare 3D printing materials with interactive radar charts, technical properties and cited sources."
|
||||||
|
},
|
||||||
|
"ABOUT": {
|
||||||
|
"TITLE": "About Us | 3D fab",
|
||||||
|
"DESCRIPTION": "Learn more about the 3D fab team and our 3D printing lab for prototypes, spare parts and custom production."
|
||||||
|
},
|
||||||
|
"CONTACT": {
|
||||||
|
"TITLE": "Contact | 3D fab",
|
||||||
|
"DESCRIPTION": "Request information, custom quotes or technical support for your 3D printing project."
|
||||||
|
},
|
||||||
|
"LEGAL": {
|
||||||
|
"PRIVACY": {
|
||||||
|
"TITLE": "Privacy Policy | 3D fab",
|
||||||
|
"DESCRIPTION": "3D fab privacy policy: data processing, purposes and contact details."
|
||||||
|
},
|
||||||
|
"TERMS": {
|
||||||
|
"TITLE": "Terms and Conditions | 3D fab",
|
||||||
|
"DESCRIPTION": "Terms and conditions for the 3D printing service and instant quote calculator."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CHECKOUT": {
|
||||||
|
"TITLE": "Checkout | 3D fab",
|
||||||
|
"DESCRIPTION": "Complete your request and confirm the details for your 3D printing order."
|
||||||
|
},
|
||||||
|
"ORDER": {
|
||||||
|
"TITLE": "Order | 3D fab",
|
||||||
|
"DESCRIPTION": "Review your order summary and the status of your 3D printing request."
|
||||||
|
},
|
||||||
|
"ADMIN": {
|
||||||
|
"TITLE": "Admin | 3D fab",
|
||||||
|
"DESCRIPTION": "Restricted 3D fab administration area."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"CALC": {
|
"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, STEP...) and get an instant estimate of costs and print time.",
|
||||||
@@ -98,6 +165,7 @@
|
|||||||
"SHOP": {
|
"SHOP": {
|
||||||
"TITLE": "Technical solutions",
|
"TITLE": "Technical solutions",
|
||||||
"SUBTITLE": "Ready-made products solving practical problems",
|
"SUBTITLE": "Ready-made products solving practical problems",
|
||||||
|
"HERO_EYEBROW": "Technical shop",
|
||||||
"WIP_EYEBROW": "Work in progress",
|
"WIP_EYEBROW": "Work in progress",
|
||||||
"WIP_TITLE": "Shop under construction",
|
"WIP_TITLE": "Shop under construction",
|
||||||
"WIP_SUBTITLE": "We are building a curated technical shop with products that are genuinely useful and field-tested.",
|
"WIP_SUBTITLE": "We are building a curated technical shop with products that are genuinely useful and field-tested.",
|
||||||
@@ -108,6 +176,7 @@
|
|||||||
"CUSTOM_PART_FOOTER_TEXT": "Contact us for custom parts.",
|
"CUSTOM_PART_FOOTER_TEXT": "Contact us for custom parts.",
|
||||||
"ADD_CART": "Add to Cart",
|
"ADD_CART": "Add to Cart",
|
||||||
"ADDING": "Adding to cart",
|
"ADDING": "Adding to cart",
|
||||||
|
"ADD_SUCCESS": "Product added to cart.",
|
||||||
"BACK": "Back to Shop",
|
"BACK": "Back to Shop",
|
||||||
"NOT_FOUND": "Product not found.",
|
"NOT_FOUND": "Product not found.",
|
||||||
"DETAILS": "Details",
|
"DETAILS": "Details",
|
||||||
@@ -115,13 +184,39 @@
|
|||||||
"SUCCESS_TITLE": "Added to cart",
|
"SUCCESS_TITLE": "Added to cart",
|
||||||
"SUCCESS_DESC": "The product has been added to the cart.",
|
"SUCCESS_DESC": "The product has been added to the cart.",
|
||||||
"CONTINUE": "Continue",
|
"CONTINUE": "Continue",
|
||||||
|
"VIEW_ALL": "View the full shop",
|
||||||
|
"CATALOG_LABEL": "Catalog",
|
||||||
|
"CATALOG_TITLE": "All products",
|
||||||
|
"CATALOG_META_DESCRIPTION": "Discover 3D printed products, technical accessories, and ready-to-use solutions with the same checkout as the calculator.",
|
||||||
|
"CUSTOM_PART_CTA": "Can't find what you're looking for? Request a custom part.",
|
||||||
|
"CATEGORY_META": "{{count}} products available in this category",
|
||||||
|
"CATEGORY_PANEL_KICKER": "Navigation",
|
||||||
|
"CATEGORY_PANEL_TITLE": "Categories",
|
||||||
|
"SELECTED_CATEGORY": "Selected category",
|
||||||
|
"ITEMS_FOUND": "products",
|
||||||
|
"EMPTY_CATEGORY": "No products are currently available in this category.",
|
||||||
|
"FEATURED_KICKER": "Featured",
|
||||||
|
"FEATURED_TITLE": "Products worth watching",
|
||||||
|
"FEATURED_BADGE": "Featured",
|
||||||
|
"HIGHLIGHT_PRODUCTS": "Products",
|
||||||
|
"HIGHLIGHT_CART": "In cart",
|
||||||
|
"HIGHLIGHT_READY": "Preview",
|
||||||
|
"PRICE_FROM": "Price from",
|
||||||
"MODEL_OPEN": "Open 3D view",
|
"MODEL_OPEN": "Open 3D view",
|
||||||
"MODEL_CLOSE": "Close 3D view",
|
"MODEL_CLOSE": "Close 3D view",
|
||||||
|
"MODEL_3D": "3D preview",
|
||||||
|
"MODEL_TITLE": "Model preview",
|
||||||
|
"MODEL_LOADING": "Loading the 3D model.",
|
||||||
|
"MODEL_UNAVAILABLE": "3D preview unavailable.",
|
||||||
"PREVIOUS_IMAGE": "Previous image",
|
"PREVIOUS_IMAGE": "Previous image",
|
||||||
"NEXT_IMAGE": "Next image",
|
"NEXT_IMAGE": "Next image",
|
||||||
|
"BREADCRUMB_ROOT": "Shop",
|
||||||
|
"PRICE_LABEL": "Price",
|
||||||
|
"EXCERPT_FALLBACK": "Product page coming soon.",
|
||||||
"SELECT_MATERIAL": "Material",
|
"SELECT_MATERIAL": "Material",
|
||||||
"SELECT_COLOR": "Color",
|
"SELECT_COLOR": "Color",
|
||||||
"MATERIAL_COLOR_COUNT": "{{count}} colors available",
|
"MATERIAL_COLOR_COUNT": "{{count}} colors available",
|
||||||
|
"VARIANT": "Variant",
|
||||||
"PROPERTY_UV": "UV resistance",
|
"PROPERTY_UV": "UV resistance",
|
||||||
"PROPERTY_WEATHER": "Outdoor use",
|
"PROPERTY_WEATHER": "Outdoor use",
|
||||||
"PROPERTY_RIGIDITY": "Rigidity",
|
"PROPERTY_RIGIDITY": "Rigidity",
|
||||||
@@ -130,6 +225,22 @@
|
|||||||
"PROPERTY_LOW": "Low",
|
"PROPERTY_LOW": "Low",
|
||||||
"PROPERTY_RIGID": "Rigid",
|
"PROPERTY_RIGID": "Rigid",
|
||||||
"PROPERTY_FLEXIBLE": "Flexible",
|
"PROPERTY_FLEXIBLE": "Flexible",
|
||||||
|
"QUANTITY": "Quantity",
|
||||||
|
"GO_TO_CHECKOUT": "Go to checkout",
|
||||||
|
"IN_CART_SHORT": "In cart x{{count}}",
|
||||||
|
"IN_CART_LONG": "Already in cart x{{count}}",
|
||||||
|
"DESCRIPTION_TITLE": "Description",
|
||||||
|
"CART_TITLE": "Cart",
|
||||||
|
"CART_SUMMARY_TITLE": "Current summary",
|
||||||
|
"CART_LOADING": "Loading cart.",
|
||||||
|
"CART_EMPTY": "Your cart is empty. Add a product.",
|
||||||
|
"CART_SUBTOTAL": "Products subtotal",
|
||||||
|
"CART_SHIPPING": "Shipping",
|
||||||
|
"CART_TOTAL": "Estimated total",
|
||||||
|
"CLEAR_CART": "Clear",
|
||||||
|
"REMOVE": "Remove",
|
||||||
|
"CART_UPDATE_ERROR": "We couldn't update the cart. Please try again.",
|
||||||
|
"ALL_CATEGORIES": "All categories",
|
||||||
"CATEGORIES": {
|
"CATEGORIES": {
|
||||||
"FILAMENTS": "Filaments",
|
"FILAMENTS": "Filaments",
|
||||||
"ACCESSORIES": "Accessories"
|
"ACCESSORIES": "Accessories"
|
||||||
@@ -397,6 +508,8 @@
|
|||||||
"CHECKOUT": {
|
"CHECKOUT": {
|
||||||
"TITLE": "Checkout",
|
"TITLE": "Checkout",
|
||||||
"SUBTITLE": "Complete your order by entering the shipping and payment details.",
|
"SUBTITLE": "Complete your order by entering the shipping and payment details.",
|
||||||
|
"CAD_SERVICE": "CAD service",
|
||||||
|
"CAD_REQUEST_REF": "related to contact request #{{id}}",
|
||||||
"CONTACT_INFO": "Contact Information",
|
"CONTACT_INFO": "Contact Information",
|
||||||
"BILLING_ADDR": "Billing Address",
|
"BILLING_ADDR": "Billing Address",
|
||||||
"SHIPPING_ADDR": "Shipping Address",
|
"SHIPPING_ADDR": "Shipping Address",
|
||||||
@@ -499,6 +612,13 @@
|
|||||||
"HERO_TITLE": "3D printing service.<br>From file to finished part.",
|
"HERO_TITLE": "3D printing service.<br>From file to finished part.",
|
||||||
"HERO_LEAD": "With the most advanced calculator for your 3D prints: absolute precision and zero surprises.",
|
"HERO_LEAD": "With the most advanced calculator for your 3D prints: absolute precision and zero surprises.",
|
||||||
"HERO_SUBTITLE": "We also offer CAD services for custom parts!",
|
"HERO_SUBTITLE": "We also offer CAD services for custom parts!",
|
||||||
|
"HERO_SWISS_TITLE": "Based in Switzerland",
|
||||||
|
"HERO_SWISS_COPY": "Swiss production and support.",
|
||||||
|
"HERO_SWISS_LOCATIONS_LABEL": "Locations",
|
||||||
|
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||||
|
"HERO_SWISS_LOCATION_2": "Zurich",
|
||||||
|
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
||||||
|
"HERO_SWISS_NOTE": "Serving customers across Switzerland.",
|
||||||
"BTN_CALCULATE": "Calculate Quote",
|
"BTN_CALCULATE": "Calculate Quote",
|
||||||
"BTN_SHOP": "Go to shop",
|
"BTN_SHOP": "Go to shop",
|
||||||
"BTN_CONTACT": "Talk to us",
|
"BTN_CONTACT": "Talk to us",
|
||||||
@@ -543,6 +663,13 @@
|
|||||||
"ERR_ID_NOT_FOUND": "Order ID not found.",
|
"ERR_ID_NOT_FOUND": "Order ID not found.",
|
||||||
"ERR_LOAD_ORDER": "Failed to load order details.",
|
"ERR_LOAD_ORDER": "Failed to load order details.",
|
||||||
"ERR_REPORT_PAYMENT": "Failed to report payment. Please try again.",
|
"ERR_REPORT_PAYMENT": "Failed to report payment. Please try again.",
|
||||||
|
"CAD_SERVICE": "CAD service ({{hours}}h)",
|
||||||
|
"ITEMS_TITLE": "Order items",
|
||||||
|
"ORDER_TYPE_LABEL": "Order type",
|
||||||
|
"ITEM_COUNT": "Lines",
|
||||||
|
"TYPE_SHOP": "Shop",
|
||||||
|
"TYPE_CALCULATOR": "Calculator",
|
||||||
|
"TYPE_MIXED": "Mixed",
|
||||||
"NOT_AVAILABLE": "N/A"
|
"NOT_AVAILABLE": "N/A"
|
||||||
},
|
},
|
||||||
"DROPZONE": {
|
"DROPZONE": {
|
||||||
@@ -560,6 +687,13 @@
|
|||||||
"BLUE": "Blue",
|
"BLUE": "Blue",
|
||||||
"GREEN": "Green",
|
"GREEN": "Green",
|
||||||
"YELLOW": "Yellow",
|
"YELLOW": "Yellow",
|
||||||
|
"ORANGE": "Orange",
|
||||||
|
"GRAY": "Gray",
|
||||||
|
"LIGHT_GRAY": "Light Gray",
|
||||||
|
"DARK_GRAY": "Dark Gray",
|
||||||
|
"PURPLE": "Purple",
|
||||||
|
"BEIGE": "Beige",
|
||||||
|
"SAND_BEIGE": "Sand Beige",
|
||||||
"MATTE_BLACK": "Matte Black",
|
"MATTE_BLACK": "Matte Black",
|
||||||
"MATTE_WHITE": "Matte White",
|
"MATTE_WHITE": "Matte White",
|
||||||
"MATTE_GRAY": "Matte Gray"
|
"MATTE_GRAY": "Matte Gray"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"CALCULATOR": "Calculateur",
|
"CALCULATOR": "Calculateur",
|
||||||
"SHOP": "Boutique",
|
"SHOP": "Boutique",
|
||||||
"ABOUT": "Qui sommes-nous",
|
"ABOUT": "Qui sommes-nous",
|
||||||
|
"MATERIALS": "Qualité & matériaux",
|
||||||
"CONTACT": "Contactez-nous",
|
"CONTACT": "Contactez-nous",
|
||||||
"LANGUAGE_SELECTOR": "Sélecteur de langue"
|
"LANGUAGE_SELECTOR": "Sélecteur de langue"
|
||||||
},
|
},
|
||||||
@@ -12,11 +13,84 @@
|
|||||||
"TERMS": "Conditions générales",
|
"TERMS": "Conditions générales",
|
||||||
"CONTACT": "Contactez-nous"
|
"CONTACT": "Contactez-nous"
|
||||||
},
|
},
|
||||||
|
"SEO": {
|
||||||
|
"DEFAULT": {
|
||||||
|
"TITLE": "3D fab | Impression 3D sur mesure",
|
||||||
|
"DESCRIPTION": "Service d'impression 3D sur mesure, boutique technique et support CAD pour prototypes, pièces et petites séries."
|
||||||
|
},
|
||||||
|
"ROUTES": {
|
||||||
|
"HOME": {
|
||||||
|
"TITLE": "Impression 3D à Bienne | Prototypes, pièces et petites séries - 3D fab",
|
||||||
|
"DESCRIPTION": "Service d'impression 3D à Bienne pour prototypes, pièces de rechange et petites séries. Boutique technique et support CAD, avec devis rapide depuis un fichier STL."
|
||||||
|
},
|
||||||
|
"CALCULATOR": {
|
||||||
|
"TITLE": "Calculateur de devis impression 3D | 3D fab",
|
||||||
|
"DESCRIPTION": "Chargez votre fichier 3D et obtenez prix et délais en quelques secondes avec un vrai slicing.",
|
||||||
|
"BASIC": {
|
||||||
|
"TITLE": "Calculateur impression 3D de base | 3D fab",
|
||||||
|
"DESCRIPTION": "Calculez rapidement le prix de votre impression 3D avec le parcours de base."
|
||||||
|
},
|
||||||
|
"ADVANCED": {
|
||||||
|
"TITLE": "Calculateur impression 3D avancé | 3D fab",
|
||||||
|
"DESCRIPTION": "Configurez des paramètres avancés et obtenez un devis précis basé sur un vrai slicing."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SHOP": {
|
||||||
|
"TITLE": "Boutique 3D fab",
|
||||||
|
"DESCRIPTION": "Catalogue de produits imprimés en 3D, accessoires techniques et solutions pratiques prêtes à l'emploi.",
|
||||||
|
"CATEGORY_TITLE": "Catégorie boutique | 3D fab",
|
||||||
|
"CATEGORY_DESCRIPTION": "Parcourez les produits de cette catégorie, les variantes disponibles et les solutions techniques imprimées en 3D.",
|
||||||
|
"PRODUCT_TITLE": "Produit | 3D fab",
|
||||||
|
"PRODUCT_DESCRIPTION": "Découvrez les détails, matériaux, variantes et disponibilités du produit sélectionné dans la boutique 3D fab."
|
||||||
|
},
|
||||||
|
"MATERIALS": {
|
||||||
|
"TITLE": "Qualité et matériaux | 3D fab",
|
||||||
|
"DESCRIPTION": "Comparez les matériaux d'impression 3D avec des radar charts interactifs, des propriétés techniques et des sources citées."
|
||||||
|
},
|
||||||
|
"ABOUT": {
|
||||||
|
"TITLE": "Qui sommes-nous | 3D fab",
|
||||||
|
"DESCRIPTION": "Découvrez l'équipe de 3D fab et notre atelier d'impression 3D pour prototypes, pièces et productions sur mesure."
|
||||||
|
},
|
||||||
|
"CONTACT": {
|
||||||
|
"TITLE": "Contact | 3D fab",
|
||||||
|
"DESCRIPTION": "Demandez des informations, des devis personnalisés ou un support technique pour votre projet d'impression 3D."
|
||||||
|
},
|
||||||
|
"LEGAL": {
|
||||||
|
"PRIVACY": {
|
||||||
|
"TITLE": "Politique de confidentialité | 3D fab",
|
||||||
|
"DESCRIPTION": "Politique de confidentialité de 3D fab : traitement des données, finalités et contacts."
|
||||||
|
},
|
||||||
|
"TERMS": {
|
||||||
|
"TITLE": "Conditions générales | 3D fab",
|
||||||
|
"DESCRIPTION": "Conditions générales du service d'impression 3D et du calculateur de devis."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CHECKOUT": {
|
||||||
|
"TITLE": "Checkout | 3D fab",
|
||||||
|
"DESCRIPTION": "Finalisez votre demande et confirmez les détails de votre commande d'impression 3D."
|
||||||
|
},
|
||||||
|
"ORDER": {
|
||||||
|
"TITLE": "Commande | 3D fab",
|
||||||
|
"DESCRIPTION": "Consultez le récapitulatif de votre commande et l'état de votre demande d'impression 3D."
|
||||||
|
},
|
||||||
|
"ADMIN": {
|
||||||
|
"TITLE": "Admin | 3D fab",
|
||||||
|
"DESCRIPTION": "Espace d'administration restreint de 3D fab."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"HOME": {
|
"HOME": {
|
||||||
"HERO_EYEBROW": "Impression 3D technique pour entreprises, freelances et makers",
|
"HERO_EYEBROW": "Impression 3D technique pour entreprises, freelances et makers",
|
||||||
"HERO_TITLE": "Service d'impression 3D.<br>Du fichier à la pièce finie.",
|
"HERO_TITLE": "Service d'impression 3D.<br>Du fichier à la pièce finie.",
|
||||||
"HERO_LEAD": "Avec le calculateur le plus avancé pour vos impressions 3D : précision absolue et zéro surprise.",
|
"HERO_LEAD": "Avec le calculateur le plus avancé pour vos impressions 3D : précision absolue et zéro surprise.",
|
||||||
"HERO_SUBTITLE": "Nous proposons aussi des services CAD pour des pièces personnalisées !",
|
"HERO_SUBTITLE": "Nous proposons aussi des services CAD pour des pièces personnalisées !",
|
||||||
|
"HERO_SWISS_TITLE": "Based in Switzerland",
|
||||||
|
"HERO_SWISS_COPY": "Production et support en Suisse.",
|
||||||
|
"HERO_SWISS_LOCATIONS_LABEL": "Sites",
|
||||||
|
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||||
|
"HERO_SWISS_LOCATION_2": "Zurich",
|
||||||
|
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
||||||
|
"HERO_SWISS_NOTE": "Actifs dans toute la Suisse.",
|
||||||
"BTN_CALCULATE": "Calculer un devis",
|
"BTN_CALCULATE": "Calculer un devis",
|
||||||
"BTN_SHOP": "Aller à la boutique",
|
"BTN_SHOP": "Aller à la boutique",
|
||||||
"BTN_CONTACT": "Parlez avec nous",
|
"BTN_CONTACT": "Parlez avec nous",
|
||||||
@@ -155,6 +229,7 @@
|
|||||||
"SHOP": {
|
"SHOP": {
|
||||||
"TITLE": "Solutions techniques",
|
"TITLE": "Solutions techniques",
|
||||||
"SUBTITLE": "Produits prêts à l'emploi qui résolvent des problèmes pratiques",
|
"SUBTITLE": "Produits prêts à l'emploi qui résolvent des problèmes pratiques",
|
||||||
|
"HERO_EYEBROW": "Boutique technique",
|
||||||
"WIP_EYEBROW": "Work in progress",
|
"WIP_EYEBROW": "Work in progress",
|
||||||
"WIP_TITLE": "Boutique en préparation",
|
"WIP_TITLE": "Boutique en préparation",
|
||||||
"WIP_SUBTITLE": "Nous préparons une boutique avec des produits sélectionnés et des fonctionnalités de création automatique !",
|
"WIP_SUBTITLE": "Nous préparons une boutique avec des produits sélectionnés et des fonctionnalités de création automatique !",
|
||||||
@@ -165,6 +240,7 @@
|
|||||||
"CUSTOM_PART_FOOTER_TEXT": "Contactez-nous pour des pièces personnalisées.",
|
"CUSTOM_PART_FOOTER_TEXT": "Contactez-nous pour des pièces personnalisées.",
|
||||||
"ADD_CART": "Ajouter au panier",
|
"ADD_CART": "Ajouter au panier",
|
||||||
"ADDING": "Ajout en cours",
|
"ADDING": "Ajout en cours",
|
||||||
|
"ADD_SUCCESS": "Produit ajouté au panier.",
|
||||||
"BACK": "Retour à la boutique",
|
"BACK": "Retour à la boutique",
|
||||||
"NOT_FOUND": "Produit introuvable.",
|
"NOT_FOUND": "Produit introuvable.",
|
||||||
"DETAILS": "Détails",
|
"DETAILS": "Détails",
|
||||||
@@ -172,13 +248,39 @@
|
|||||||
"SUCCESS_TITLE": "Ajouté au panier",
|
"SUCCESS_TITLE": "Ajouté au panier",
|
||||||
"SUCCESS_DESC": "Le produit a été ajouté au panier avec succès.",
|
"SUCCESS_DESC": "Le produit a été ajouté au panier avec succès.",
|
||||||
"CONTINUE": "Continuer",
|
"CONTINUE": "Continuer",
|
||||||
|
"VIEW_ALL": "Voir toute la boutique",
|
||||||
|
"CATALOG_LABEL": "Catalogue",
|
||||||
|
"CATALOG_TITLE": "Tous les produits",
|
||||||
|
"CATALOG_META_DESCRIPTION": "Découvrez des produits imprimés en 3D, des accessoires techniques et des solutions prêtes à l'emploi avec le même checkout que le calculateur.",
|
||||||
|
"CUSTOM_PART_CTA": "Vous ne trouvez pas ce que vous cherchez ? Demandez une pièce personnalisée.",
|
||||||
|
"CATEGORY_META": "{{count}} produits disponibles dans cette catégorie",
|
||||||
|
"CATEGORY_PANEL_KICKER": "Navigation",
|
||||||
|
"CATEGORY_PANEL_TITLE": "Catégories",
|
||||||
|
"SELECTED_CATEGORY": "Catégorie sélectionnée",
|
||||||
|
"ITEMS_FOUND": "produits",
|
||||||
|
"EMPTY_CATEGORY": "Aucun produit n'est disponible dans cette catégorie pour le moment.",
|
||||||
|
"FEATURED_KICKER": "À la une",
|
||||||
|
"FEATURED_TITLE": "Produits à surveiller",
|
||||||
|
"FEATURED_BADGE": "À la une",
|
||||||
|
"HIGHLIGHT_PRODUCTS": "Produits",
|
||||||
|
"HIGHLIGHT_CART": "Dans le panier",
|
||||||
|
"HIGHLIGHT_READY": "Aperçu",
|
||||||
|
"PRICE_FROM": "Prix à partir de",
|
||||||
"MODEL_OPEN": "Ouvrir la vue 3D",
|
"MODEL_OPEN": "Ouvrir la vue 3D",
|
||||||
"MODEL_CLOSE": "Fermer la vue 3D",
|
"MODEL_CLOSE": "Fermer la vue 3D",
|
||||||
|
"MODEL_3D": "Aperçu 3D",
|
||||||
|
"MODEL_TITLE": "Aperçu du modèle",
|
||||||
|
"MODEL_LOADING": "Chargement du modèle 3D.",
|
||||||
|
"MODEL_UNAVAILABLE": "Aperçu 3D indisponible.",
|
||||||
"PREVIOUS_IMAGE": "Image précédente",
|
"PREVIOUS_IMAGE": "Image précédente",
|
||||||
"NEXT_IMAGE": "Image suivante",
|
"NEXT_IMAGE": "Image suivante",
|
||||||
|
"BREADCRUMB_ROOT": "Boutique",
|
||||||
|
"PRICE_LABEL": "Prix",
|
||||||
|
"EXCERPT_FALLBACK": "Fiche produit en préparation.",
|
||||||
"SELECT_MATERIAL": "Matériau",
|
"SELECT_MATERIAL": "Matériau",
|
||||||
"SELECT_COLOR": "Couleur",
|
"SELECT_COLOR": "Couleur",
|
||||||
"MATERIAL_COLOR_COUNT": "{{count}} couleurs disponibles",
|
"MATERIAL_COLOR_COUNT": "{{count}} couleurs disponibles",
|
||||||
|
"VARIANT": "Variante",
|
||||||
"PROPERTY_UV": "Résistance UV",
|
"PROPERTY_UV": "Résistance UV",
|
||||||
"PROPERTY_WEATHER": "Usage extérieur",
|
"PROPERTY_WEATHER": "Usage extérieur",
|
||||||
"PROPERTY_RIGIDITY": "Rigidité",
|
"PROPERTY_RIGIDITY": "Rigidité",
|
||||||
@@ -187,6 +289,22 @@
|
|||||||
"PROPERTY_LOW": "Faible",
|
"PROPERTY_LOW": "Faible",
|
||||||
"PROPERTY_RIGID": "Rigide",
|
"PROPERTY_RIGID": "Rigide",
|
||||||
"PROPERTY_FLEXIBLE": "Flexible",
|
"PROPERTY_FLEXIBLE": "Flexible",
|
||||||
|
"QUANTITY": "Quantité",
|
||||||
|
"GO_TO_CHECKOUT": "Aller au checkout",
|
||||||
|
"IN_CART_SHORT": "Dans le panier x{{count}}",
|
||||||
|
"IN_CART_LONG": "Déjà dans le panier x{{count}}",
|
||||||
|
"DESCRIPTION_TITLE": "Description",
|
||||||
|
"CART_TITLE": "Panier",
|
||||||
|
"CART_SUMMARY_TITLE": "Récapitulatif actuel",
|
||||||
|
"CART_LOADING": "Chargement du panier.",
|
||||||
|
"CART_EMPTY": "Le panier est vide. Ajoutez un produit.",
|
||||||
|
"CART_SUBTOTAL": "Sous-total produits",
|
||||||
|
"CART_SHIPPING": "Expédition",
|
||||||
|
"CART_TOTAL": "Total estimé",
|
||||||
|
"CLEAR_CART": "Vider",
|
||||||
|
"REMOVE": "Supprimer",
|
||||||
|
"CART_UPDATE_ERROR": "Nous n'avons pas réussi à mettre à jour le panier. Réessayez.",
|
||||||
|
"ALL_CATEGORIES": "Toutes les catégories",
|
||||||
"CATEGORIES": {
|
"CATEGORIES": {
|
||||||
"FILAMENTS": "Filaments",
|
"FILAMENTS": "Filaments",
|
||||||
"ACCESSORIES": "Accessoires"
|
"ACCESSORIES": "Accessoires"
|
||||||
@@ -454,6 +572,8 @@
|
|||||||
"CHECKOUT": {
|
"CHECKOUT": {
|
||||||
"TITLE": "Checkout",
|
"TITLE": "Checkout",
|
||||||
"SUBTITLE": "Complétez votre commande en saisissant les détails de livraison et de paiement.",
|
"SUBTITLE": "Complétez votre commande en saisissant les détails de livraison et de paiement.",
|
||||||
|
"CAD_SERVICE": "Service CAD",
|
||||||
|
"CAD_REQUEST_REF": "lié à la demande de contact #{{id}}",
|
||||||
"CONTACT_INFO": "Informations de contact",
|
"CONTACT_INFO": "Informations de contact",
|
||||||
"BILLING_ADDR": "Adresse de facturation",
|
"BILLING_ADDR": "Adresse de facturation",
|
||||||
"SHIPPING_ADDR": "Adresse de livraison",
|
"SHIPPING_ADDR": "Adresse de livraison",
|
||||||
@@ -549,6 +669,13 @@
|
|||||||
"ERR_ID_NOT_FOUND": "ID de commande introuvable.",
|
"ERR_ID_NOT_FOUND": "ID de commande introuvable.",
|
||||||
"ERR_LOAD_ORDER": "Impossible de charger les détails de la commande.",
|
"ERR_LOAD_ORDER": "Impossible de charger les détails de la commande.",
|
||||||
"ERR_REPORT_PAYMENT": "Impossible de signaler le paiement. Réessayez.",
|
"ERR_REPORT_PAYMENT": "Impossible de signaler le paiement. Réessayez.",
|
||||||
|
"CAD_SERVICE": "Service CAD ({{hours}}h)",
|
||||||
|
"ITEMS_TITLE": "Articles de la commande",
|
||||||
|
"ORDER_TYPE_LABEL": "Type de commande",
|
||||||
|
"ITEM_COUNT": "Lignes",
|
||||||
|
"TYPE_SHOP": "Boutique",
|
||||||
|
"TYPE_CALCULATOR": "Calculateur",
|
||||||
|
"TYPE_MIXED": "Mixte",
|
||||||
"NOT_AVAILABLE": "N/D"
|
"NOT_AVAILABLE": "N/D"
|
||||||
},
|
},
|
||||||
"DROPZONE": {
|
"DROPZONE": {
|
||||||
@@ -566,6 +693,13 @@
|
|||||||
"BLUE": "Bleu",
|
"BLUE": "Bleu",
|
||||||
"GREEN": "Vert",
|
"GREEN": "Vert",
|
||||||
"YELLOW": "Jaune",
|
"YELLOW": "Jaune",
|
||||||
|
"ORANGE": "Orange",
|
||||||
|
"GRAY": "Gris",
|
||||||
|
"LIGHT_GRAY": "Gris clair",
|
||||||
|
"DARK_GRAY": "Gris foncé",
|
||||||
|
"PURPLE": "Violet",
|
||||||
|
"BEIGE": "Beige",
|
||||||
|
"SAND_BEIGE": "Beige sable",
|
||||||
"MATTE_BLACK": "Noir mat",
|
"MATTE_BLACK": "Noir mat",
|
||||||
"MATTE_WHITE": "Blanc mat",
|
"MATTE_WHITE": "Blanc mat",
|
||||||
"MATTE_GRAY": "Gris mat"
|
"MATTE_GRAY": "Gris mat"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"CALCULATOR": "Calcolatore",
|
"CALCULATOR": "Calcolatore",
|
||||||
"SHOP": "Shop",
|
"SHOP": "Shop",
|
||||||
"ABOUT": "Chi Siamo",
|
"ABOUT": "Chi Siamo",
|
||||||
|
"MATERIALS": "Qualita e Materiali",
|
||||||
"CONTACT": "Contattaci",
|
"CONTACT": "Contattaci",
|
||||||
"LANGUAGE_SELECTOR": "Selettore lingua"
|
"LANGUAGE_SELECTOR": "Selettore lingua"
|
||||||
},
|
},
|
||||||
@@ -12,11 +13,84 @@
|
|||||||
"TERMS": "Termini & Condizioni",
|
"TERMS": "Termini & Condizioni",
|
||||||
"CONTACT": "Contattaci"
|
"CONTACT": "Contattaci"
|
||||||
},
|
},
|
||||||
|
"SEO": {
|
||||||
|
"DEFAULT": {
|
||||||
|
"TITLE": "3D fab | Stampa 3D su misura",
|
||||||
|
"DESCRIPTION": "Servizio di stampa 3D su misura, shop tecnico e supporto CAD per prototipi, ricambi e piccole serie."
|
||||||
|
},
|
||||||
|
"ROUTES": {
|
||||||
|
"HOME": {
|
||||||
|
"TITLE": "Stampa 3D su misura in Ticino | Prototipi, ricambi e piccole serie - 3D fab",
|
||||||
|
"DESCRIPTION": "Servizio di stampa 3D in Ticino per prototipi, pezzi di ricambio e piccole serie. Shop tecnico e supporto CAD, con preventivo rapido da file STL."
|
||||||
|
},
|
||||||
|
"CALCULATOR": {
|
||||||
|
"TITLE": "Calcolatore preventivo stampa 3D | 3D fab",
|
||||||
|
"DESCRIPTION": "Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.",
|
||||||
|
"BASIC": {
|
||||||
|
"TITLE": "Calcolatore stampa 3D base | 3D fab",
|
||||||
|
"DESCRIPTION": "Calcola rapidamente il prezzo della tua stampa 3D in modalita base."
|
||||||
|
},
|
||||||
|
"ADVANCED": {
|
||||||
|
"TITLE": "Calcolatore stampa 3D avanzato | 3D fab",
|
||||||
|
"DESCRIPTION": "Configura parametri avanzati e ottieni un preventivo preciso con slicing reale."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"SHOP": {
|
||||||
|
"TITLE": "Shop 3D fab",
|
||||||
|
"DESCRIPTION": "Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.",
|
||||||
|
"CATEGORY_TITLE": "Categoria Shop | 3D fab",
|
||||||
|
"CATEGORY_DESCRIPTION": "Esplora i prodotti di questa categoria, le varianti disponibili e le soluzioni tecniche stampate in 3D.",
|
||||||
|
"PRODUCT_TITLE": "Prodotto | 3D fab",
|
||||||
|
"PRODUCT_DESCRIPTION": "Scopri dettagli, materiali, varianti e disponibilita del prodotto selezionato nello shop 3D fab."
|
||||||
|
},
|
||||||
|
"MATERIALS": {
|
||||||
|
"TITLE": "Qualita e Materiali | 3D fab",
|
||||||
|
"DESCRIPTION": "Confronta materiali di stampa 3D con radar chart interattivo, proprieta tecniche e fonti citate."
|
||||||
|
},
|
||||||
|
"ABOUT": {
|
||||||
|
"TITLE": "Chi siamo | 3D fab",
|
||||||
|
"DESCRIPTION": "Scopri il team 3D fab e il laboratorio di stampa 3D per prototipi, ricambi e produzioni su misura."
|
||||||
|
},
|
||||||
|
"CONTACT": {
|
||||||
|
"TITLE": "Contatti | 3D fab",
|
||||||
|
"DESCRIPTION": "Richiedi informazioni, preventivi personalizzati o supporto tecnico per il tuo progetto di stampa 3D."
|
||||||
|
},
|
||||||
|
"LEGAL": {
|
||||||
|
"PRIVACY": {
|
||||||
|
"TITLE": "Privacy Policy | 3D fab",
|
||||||
|
"DESCRIPTION": "Informativa privacy di 3D fab: trattamento dati, finalita e contatti."
|
||||||
|
},
|
||||||
|
"TERMS": {
|
||||||
|
"TITLE": "Termini e condizioni | 3D fab",
|
||||||
|
"DESCRIPTION": "Termini e condizioni del servizio di stampa 3D e del calcolatore preventivi."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CHECKOUT": {
|
||||||
|
"TITLE": "Checkout | 3D fab",
|
||||||
|
"DESCRIPTION": "Completa la richiesta e conferma i dati del tuo ordine di stampa 3D."
|
||||||
|
},
|
||||||
|
"ORDER": {
|
||||||
|
"TITLE": "Ordine | 3D fab",
|
||||||
|
"DESCRIPTION": "Consulta il riepilogo del tuo ordine e lo stato della richiesta di stampa 3D."
|
||||||
|
},
|
||||||
|
"ADMIN": {
|
||||||
|
"TITLE": "Admin | 3D fab",
|
||||||
|
"DESCRIPTION": "Area amministrativa riservata di 3D fab."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"HOME": {
|
"HOME": {
|
||||||
"HERO_EYEBROW": "Stampa 3D tecnica per aziende, freelance e maker",
|
"HERO_EYEBROW": "Stampa 3D tecnica per aziende, freelance e maker",
|
||||||
"HERO_TITLE": "Servizio di stampa 3D.<br>Dal file al pezzo finito.",
|
"HERO_TITLE": "Servizio di stampa 3D.<br>Dal file al pezzo finito.",
|
||||||
"HERO_LEAD": "Con il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.",
|
"HERO_LEAD": "Con il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.",
|
||||||
"HERO_SUBTITLE": "Offriamo anche servizi di CAD per pezzi personalizzati!",
|
"HERO_SUBTITLE": "Offriamo anche servizi di CAD per pezzi personalizzati!",
|
||||||
|
"HERO_SWISS_TITLE": "Based in Switzerland",
|
||||||
|
"HERO_SWISS_COPY": "Produzione e supporto in Svizzera",
|
||||||
|
"HERO_SWISS_LOCATIONS_LABEL": "Sedi",
|
||||||
|
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||||
|
"HERO_SWISS_LOCATION_2": "Zurich",
|
||||||
|
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
||||||
|
"HERO_SWISS_NOTE": "Operativi in tutta la Svizzera.",
|
||||||
"BTN_CALCULATE": "Calcola Preventivo",
|
"BTN_CALCULATE": "Calcola Preventivo",
|
||||||
"BTN_SHOP": "Vai allo shop",
|
"BTN_SHOP": "Vai allo shop",
|
||||||
"BTN_CONTACT": "Parla con noi",
|
"BTN_CONTACT": "Parla con noi",
|
||||||
@@ -193,6 +267,7 @@
|
|||||||
"HIGHLIGHT_CART": "Nel carrello",
|
"HIGHLIGHT_CART": "Nel carrello",
|
||||||
"HIGHLIGHT_READY": "Preview",
|
"HIGHLIGHT_READY": "Preview",
|
||||||
"PRICE_FROM": "Prezzo da",
|
"PRICE_FROM": "Prezzo da",
|
||||||
|
"PRICE_LABEL": "Prezzo",
|
||||||
"EXCERPT_FALLBACK": "Scheda prodotto in preparazione.",
|
"EXCERPT_FALLBACK": "Scheda prodotto in preparazione.",
|
||||||
"MODEL_3D": "3D preview",
|
"MODEL_3D": "3D preview",
|
||||||
"MODEL_TITLE": "Anteprima del modello",
|
"MODEL_TITLE": "Anteprima del modello",
|
||||||
@@ -497,6 +572,8 @@
|
|||||||
"CHECKOUT": {
|
"CHECKOUT": {
|
||||||
"TITLE": "Checkout",
|
"TITLE": "Checkout",
|
||||||
"SUBTITLE": "Completa il tuo ordine inserendo i dettagli per la spedizione e il pagamento.",
|
"SUBTITLE": "Completa il tuo ordine inserendo i dettagli per la spedizione e il pagamento.",
|
||||||
|
"CAD_SERVICE": "Servizio CAD",
|
||||||
|
"CAD_REQUEST_REF": "riferito alla richiesta contatto #{{id}}",
|
||||||
"CONTACT_INFO": "Informazioni di Contatto",
|
"CONTACT_INFO": "Informazioni di Contatto",
|
||||||
"BILLING_ADDR": "Indirizzo di Fatturazione",
|
"BILLING_ADDR": "Indirizzo di Fatturazione",
|
||||||
"SHIPPING_ADDR": "Indirizzo di Spedizione",
|
"SHIPPING_ADDR": "Indirizzo di Spedizione",
|
||||||
@@ -592,6 +669,7 @@
|
|||||||
"ERR_ID_NOT_FOUND": "ID ordine non trovato.",
|
"ERR_ID_NOT_FOUND": "ID ordine non trovato.",
|
||||||
"ERR_LOAD_ORDER": "Impossibile caricare i dettagli dell'ordine.",
|
"ERR_LOAD_ORDER": "Impossibile caricare i dettagli dell'ordine.",
|
||||||
"ERR_REPORT_PAYMENT": "Impossibile segnalare il pagamento. Riprova.",
|
"ERR_REPORT_PAYMENT": "Impossibile segnalare il pagamento. Riprova.",
|
||||||
|
"CAD_SERVICE": "Servizio CAD ({{hours}}h)",
|
||||||
"ITEMS_TITLE": "Articoli dell'ordine",
|
"ITEMS_TITLE": "Articoli dell'ordine",
|
||||||
"ORDER_TYPE_LABEL": "Tipo ordine",
|
"ORDER_TYPE_LABEL": "Tipo ordine",
|
||||||
"ITEM_COUNT": "Righe",
|
"ITEM_COUNT": "Righe",
|
||||||
@@ -615,6 +693,13 @@
|
|||||||
"BLUE": "Blu",
|
"BLUE": "Blu",
|
||||||
"GREEN": "Verde",
|
"GREEN": "Verde",
|
||||||
"YELLOW": "Giallo",
|
"YELLOW": "Giallo",
|
||||||
|
"ORANGE": "Arancione",
|
||||||
|
"GRAY": "Grigio",
|
||||||
|
"LIGHT_GRAY": "Grigio chiaro",
|
||||||
|
"DARK_GRAY": "Grigio scuro",
|
||||||
|
"PURPLE": "Viola",
|
||||||
|
"BEIGE": "Beige",
|
||||||
|
"SAND_BEIGE": "Beige sabbia",
|
||||||
"MATTE_BLACK": "Nero opaco",
|
"MATTE_BLACK": "Nero opaco",
|
||||||
"MATTE_WHITE": "Bianco opaco",
|
"MATTE_WHITE": "Bianco opaco",
|
||||||
"MATTE_GRAY": "Grigio opaco"
|
"MATTE_GRAY": "Grigio opaco"
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 433 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 345 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user