Compare commits
20 Commits
feat/ssr
...
d8ad61ec54
| Author | SHA1 | Date | |
|---|---|---|---|
| d8ad61ec54 | |||
| 7e9a1482d6 | |||
| aa0adbf993 | |||
| 00af9a9701 | |||
| fcdede2dd6 | |||
| 5d17b23c3a | |||
| 1ec8a43a50 | |||
| 96cfa91c67 | |||
| 669ace82c0 | |||
| 93163ae6e8 | |||
| af2d506da1 | |||
| 637541994a | |||
|
|
63cd4c4f5e | ||
| fd4104da39 | |||
| 5bb23fbcfa | |||
| 6a22c54e9f | |||
| 3ac3262e77 | |||
| 18ecc07240 | |||
| cb468492b3 | |||
| feee2b0bff |
@@ -94,6 +94,10 @@ public class OptionsController {
|
||||
v.getId(),
|
||||
v.getVariantDisplayName(),
|
||||
v.getColorName(),
|
||||
v.getColorLabelIt(),
|
||||
v.getColorLabelEn(),
|
||||
v.getColorLabelDe(),
|
||||
v.getColorLabelFr(),
|
||||
resolveHexColor(v),
|
||||
v.getFinishType() != null ? v.getFinishType() : "GLOSSY",
|
||||
v.getStockSpools() != null ? v.getStockSpools().doubleValue() : 0d,
|
||||
|
||||
@@ -12,6 +12,10 @@ public class AdminFilamentVariantDto {
|
||||
private String materialTechnicalTypeLabel;
|
||||
private String variantDisplayName;
|
||||
private String colorName;
|
||||
private String colorLabelIt;
|
||||
private String colorLabelEn;
|
||||
private String colorLabelDe;
|
||||
private String colorLabelFr;
|
||||
private String colorHex;
|
||||
private String finishType;
|
||||
private String brand;
|
||||
@@ -89,6 +93,38 @@ public class AdminFilamentVariantDto {
|
||||
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() {
|
||||
return colorHex;
|
||||
}
|
||||
|
||||
@@ -10,9 +10,25 @@ public class AdminShopCategoryDto {
|
||||
private String parentCategoryName;
|
||||
private String slug;
|
||||
private String name;
|
||||
private String nameIt;
|
||||
private String nameEn;
|
||||
private String nameDe;
|
||||
private String nameFr;
|
||||
private String description;
|
||||
private String descriptionIt;
|
||||
private String descriptionEn;
|
||||
private String descriptionDe;
|
||||
private String descriptionFr;
|
||||
private String seoTitle;
|
||||
private String seoTitleIt;
|
||||
private String seoTitleEn;
|
||||
private String seoTitleDe;
|
||||
private String seoTitleFr;
|
||||
private String seoDescription;
|
||||
private String seoDescriptionIt;
|
||||
private String seoDescriptionEn;
|
||||
private String seoDescriptionDe;
|
||||
private String seoDescriptionFr;
|
||||
private String ogTitle;
|
||||
private String ogDescription;
|
||||
private Boolean indexable;
|
||||
@@ -69,6 +85,38 @@ public class AdminShopCategoryDto {
|
||||
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() {
|
||||
return description;
|
||||
}
|
||||
@@ -77,6 +125,38 @@ public class AdminShopCategoryDto {
|
||||
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() {
|
||||
return seoTitle;
|
||||
}
|
||||
@@ -85,6 +165,38 @@ public class AdminShopCategoryDto {
|
||||
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() {
|
||||
return seoDescription;
|
||||
}
|
||||
@@ -93,6 +205,38 @@ public class AdminShopCategoryDto {
|
||||
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() {
|
||||
return ogTitle;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ public class AdminShopProductVariantDto {
|
||||
private String sku;
|
||||
private String variantLabel;
|
||||
private String colorName;
|
||||
private String colorLabelIt;
|
||||
private String colorLabelEn;
|
||||
private String colorLabelDe;
|
||||
private String colorLabelFr;
|
||||
private String colorHex;
|
||||
private String internalMaterialCode;
|
||||
private BigDecimal priceChf;
|
||||
@@ -50,6 +54,38 @@ public class AdminShopProductVariantDto {
|
||||
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() {
|
||||
return colorHex;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ public class AdminUpsertFilamentVariantRequest {
|
||||
private Long materialTypeId;
|
||||
private String variantDisplayName;
|
||||
private String colorName;
|
||||
private String colorLabelIt;
|
||||
private String colorLabelEn;
|
||||
private String colorLabelDe;
|
||||
private String colorLabelFr;
|
||||
private String colorHex;
|
||||
private String finishType;
|
||||
private String brand;
|
||||
@@ -40,6 +44,38 @@ public class AdminUpsertFilamentVariantRequest {
|
||||
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() {
|
||||
return colorHex;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,25 @@ public class AdminUpsertShopCategoryRequest {
|
||||
private UUID parentCategoryId;
|
||||
private String slug;
|
||||
private String name;
|
||||
private String nameIt;
|
||||
private String nameEn;
|
||||
private String nameDe;
|
||||
private String nameFr;
|
||||
private String description;
|
||||
private String descriptionIt;
|
||||
private String descriptionEn;
|
||||
private String descriptionDe;
|
||||
private String descriptionFr;
|
||||
private String seoTitle;
|
||||
private String seoTitleIt;
|
||||
private String seoTitleEn;
|
||||
private String seoTitleDe;
|
||||
private String seoTitleFr;
|
||||
private String seoDescription;
|
||||
private String seoDescriptionIt;
|
||||
private String seoDescriptionEn;
|
||||
private String seoDescriptionDe;
|
||||
private String seoDescriptionFr;
|
||||
private String ogTitle;
|
||||
private String ogDescription;
|
||||
private Boolean indexable;
|
||||
@@ -39,6 +55,38 @@ public class AdminUpsertShopCategoryRequest {
|
||||
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() {
|
||||
return description;
|
||||
}
|
||||
@@ -47,6 +95,38 @@ public class AdminUpsertShopCategoryRequest {
|
||||
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() {
|
||||
return seoTitle;
|
||||
}
|
||||
@@ -55,6 +135,38 @@ public class AdminUpsertShopCategoryRequest {
|
||||
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() {
|
||||
return seoDescription;
|
||||
}
|
||||
@@ -63,6 +175,38 @@ public class AdminUpsertShopCategoryRequest {
|
||||
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() {
|
||||
return ogTitle;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ public class AdminUpsertShopProductVariantRequest {
|
||||
private String sku;
|
||||
private String variantLabel;
|
||||
private String colorName;
|
||||
private String colorLabelIt;
|
||||
private String colorLabelEn;
|
||||
private String colorLabelDe;
|
||||
private String colorLabelFr;
|
||||
private String colorHex;
|
||||
private String internalMaterialCode;
|
||||
private BigDecimal priceChf;
|
||||
@@ -47,6 +51,38 @@ public class AdminUpsertShopProductVariantRequest {
|
||||
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() {
|
||||
return colorHex;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ public record OptionsResponse(
|
||||
Long id,
|
||||
String name,
|
||||
String colorName,
|
||||
String colorLabelIt,
|
||||
String colorLabelEn,
|
||||
String colorLabelDe,
|
||||
String colorLabelFr,
|
||||
String hexColor,
|
||||
String finishType,
|
||||
Double stockSpools,
|
||||
|
||||
@@ -17,9 +17,17 @@ public class OrderItemDto {
|
||||
private String shopProductName;
|
||||
private String shopVariantLabel;
|
||||
private String shopVariantColorName;
|
||||
private String shopVariantColorLabelIt;
|
||||
private String shopVariantColorLabelEn;
|
||||
private String shopVariantColorLabelDe;
|
||||
private String shopVariantColorLabelFr;
|
||||
private String shopVariantColorHex;
|
||||
private String filamentVariantDisplayName;
|
||||
private String filamentColorName;
|
||||
private String filamentColorLabelIt;
|
||||
private String filamentColorLabelEn;
|
||||
private String filamentColorLabelDe;
|
||||
private String filamentColorLabelFr;
|
||||
private String filamentColorHex;
|
||||
private String quality;
|
||||
private BigDecimal nozzleDiameterMm;
|
||||
@@ -73,6 +81,18 @@ public class OrderItemDto {
|
||||
public String getShopVariantColorName() { return 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 void setShopVariantColorHex(String shopVariantColorHex) { this.shopVariantColorHex = shopVariantColorHex; }
|
||||
|
||||
@@ -82,6 +102,18 @@ public class OrderItemDto {
|
||||
public String getFilamentColorName() { return 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 void setFilamentColorHex(String filamentColorHex) { this.filamentColorHex = filamentColorHex; }
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ public record ShopProductVariantOptionDto(
|
||||
String sku,
|
||||
String variantLabel,
|
||||
String colorName,
|
||||
String colorLabel,
|
||||
String colorHex,
|
||||
BigDecimal priceChf,
|
||||
Boolean isDefault
|
||||
|
||||
@@ -24,6 +24,18 @@ public class FilamentVariant {
|
||||
@Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE)
|
||||
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)
|
||||
private String colorHex;
|
||||
|
||||
@@ -93,6 +105,38 @@ public class FilamentVariant {
|
||||
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() {
|
||||
return colorHex;
|
||||
}
|
||||
@@ -173,4 +217,60 @@ public class FilamentVariant {
|
||||
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 java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@@ -23,6 +24,8 @@ import java.util.UUID;
|
||||
@Index(name = "ix_shop_category_active_sort", columnList = "is_active, sort_order")
|
||||
})
|
||||
public class ShopCategory {
|
||||
public static final List<String> SUPPORTED_LANGUAGES = List.of("it", "en", "de", "fr");
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.AUTO)
|
||||
@Column(name = "shop_category_id", nullable = false)
|
||||
@@ -38,15 +41,63 @@ public class ShopCategory {
|
||||
@Column(name = "name", nullable = false, length = Integer.MAX_VALUE)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
private String ogTitle;
|
||||
|
||||
@@ -139,6 +190,38 @@ public class ShopCategory {
|
||||
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() {
|
||||
return description;
|
||||
}
|
||||
@@ -147,6 +230,38 @@ public class ShopCategory {
|
||||
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() {
|
||||
return seoTitle;
|
||||
}
|
||||
@@ -155,6 +270,38 @@ public class ShopCategory {
|
||||
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() {
|
||||
return seoDescription;
|
||||
}
|
||||
@@ -163,6 +310,38 @@ public class ShopCategory {
|
||||
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() {
|
||||
return ogTitle;
|
||||
}
|
||||
@@ -218,4 +397,109 @@ public class ShopCategory {
|
||||
public void setUpdatedAt(OffsetDateTime 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)
|
||||
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)
|
||||
private String colorHex;
|
||||
|
||||
@@ -152,6 +164,38 @@ public class ShopProductVariant {
|
||||
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() {
|
||||
return colorHex;
|
||||
}
|
||||
@@ -215,4 +259,60 @@ public class ShopProductVariant {
|
||||
public void setUpdatedAt(OffsetDateTime 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import java.util.*;
|
||||
@Service
|
||||
public class OrderService {
|
||||
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 OrderItemRepository orderItemRepo;
|
||||
@@ -235,18 +236,20 @@ public class OrderService {
|
||||
|
||||
oItem = orderItemRepo.save(oItem);
|
||||
|
||||
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
|
||||
oItem.setStoredRelativePath(relativePath);
|
||||
|
||||
Path sourcePath = resolveStoredQuotePath(qItem.getStoredPath(), session.getId());
|
||||
if (sourcePath == null || !Files.exists(sourcePath)) {
|
||||
throw new IllegalStateException("Source file not available for quote line item " + qItem.getId());
|
||||
}
|
||||
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);
|
||||
if (requiresStoredSourceFile(qItem)) {
|
||||
throw new IllegalStateException("Source file not available for quote line item " + qItem.getId());
|
||||
}
|
||||
} else {
|
||||
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
|
||||
oItem.setStoredRelativePath(relativePath);
|
||||
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);
|
||||
@@ -318,6 +321,12 @@ public class OrderService {
|
||||
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) {
|
||||
if (storedPath == null || storedPath.isBlank()) {
|
||||
return null;
|
||||
|
||||
@@ -161,10 +161,21 @@ public class AdminFilamentControllerService {
|
||||
String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex());
|
||||
String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte());
|
||||
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.setVariantDisplayName(normalizedDisplayName);
|
||||
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.setFinishType(normalizedFinishType);
|
||||
variant.setBrand(normalizedBrand);
|
||||
@@ -226,6 +237,18 @@ public class AdminFilamentControllerService {
|
||||
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) {
|
||||
if (payload == null || payload.getMaterialTypeId() == null) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Material type id is required");
|
||||
@@ -306,6 +329,10 @@ public class AdminFilamentControllerService {
|
||||
|
||||
dto.setVariantDisplayName(variant.getVariantDisplayName());
|
||||
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.setFinishType(variant.getFinishType());
|
||||
dto.setBrand(variant.getBrand());
|
||||
|
||||
@@ -67,13 +67,13 @@ public class AdminShopCategoryControllerService {
|
||||
@Transactional
|
||||
public AdminShopCategoryDto createCategory(AdminUpsertShopCategoryRequest payload) {
|
||||
ensurePayload(payload);
|
||||
String normalizedName = normalizeRequiredName(payload.getName());
|
||||
String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), normalizedName);
|
||||
LocalizedCategoryContent localizedContent = normalizeLocalizedCategoryContent(payload);
|
||||
String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName());
|
||||
ensureSlugAvailable(normalizedSlug, null);
|
||||
|
||||
ShopCategory category = new ShopCategory();
|
||||
category.setCreatedAt(OffsetDateTime.now());
|
||||
applyPayload(category, payload, normalizedName, normalizedSlug, null);
|
||||
applyPayload(category, payload, localizedContent, normalizedSlug, null);
|
||||
|
||||
ShopCategory saved = shopCategoryRepository.save(category);
|
||||
return getCategory(saved.getId());
|
||||
@@ -86,11 +86,11 @@ public class AdminShopCategoryControllerService {
|
||||
ShopCategory category = shopCategoryRepository.findById(categoryId)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Shop category not found"));
|
||||
|
||||
String normalizedName = normalizeRequiredName(payload.getName());
|
||||
String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), normalizedName);
|
||||
LocalizedCategoryContent localizedContent = normalizeLocalizedCategoryContent(payload);
|
||||
String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName());
|
||||
ensureSlugAvailable(normalizedSlug, category.getId());
|
||||
|
||||
applyPayload(category, payload, normalizedName, normalizedSlug, category.getId());
|
||||
applyPayload(category, payload, localizedContent, normalizedSlug, category.getId());
|
||||
ShopCategory saved = shopCategoryRepository.save(category);
|
||||
return getCategory(saved.getId());
|
||||
}
|
||||
@@ -112,17 +112,33 @@ public class AdminShopCategoryControllerService {
|
||||
|
||||
private void applyPayload(ShopCategory category,
|
||||
AdminUpsertShopCategoryRequest payload,
|
||||
String normalizedName,
|
||||
LocalizedCategoryContent localizedContent,
|
||||
String normalizedSlug,
|
||||
UUID currentCategoryId) {
|
||||
ShopCategory parentCategory = resolveParentCategory(payload.getParentCategoryId(), currentCategoryId);
|
||||
|
||||
category.setParentCategory(parentCategory);
|
||||
category.setSlug(normalizedSlug);
|
||||
category.setName(normalizedName);
|
||||
category.setDescription(normalizeOptional(payload.getDescription()));
|
||||
category.setSeoTitle(normalizeOptional(payload.getSeoTitle()));
|
||||
category.setSeoDescription(normalizeOptional(payload.getSeoDescription()));
|
||||
category.setName(localizedContent.defaultName());
|
||||
category.setNameIt(localizedContent.names().get("it"));
|
||||
category.setNameEn(localizedContent.names().get("en"));
|
||||
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.setOgDescription(normalizeOptional(payload.getOgDescription()));
|
||||
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) {
|
||||
String source = normalizeOptional(slug);
|
||||
if (source == null) {
|
||||
@@ -203,6 +211,103 @@ public class AdminShopCategoryControllerService {
|
||||
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() {
|
||||
List<ShopCategory> categories = shopCategoryRepository.findAllByOrderBySortOrderAscNameAsc();
|
||||
List<ShopProduct> products = shopProductRepository.findAll();
|
||||
@@ -278,9 +383,25 @@ public class AdminShopCategoryControllerService {
|
||||
dto.setParentCategoryName(category.getParentCategory() != null ? category.getParentCategory().getName() : null);
|
||||
dto.setSlug(category.getSlug());
|
||||
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.setDescriptionIt(category.getDescriptionIt());
|
||||
dto.setDescriptionEn(category.getDescriptionEn());
|
||||
dto.setDescriptionDe(category.getDescriptionDe());
|
||||
dto.setDescriptionFr(category.getDescriptionFr());
|
||||
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.setSeoDescriptionIt(category.getSeoDescriptionIt());
|
||||
dto.setSeoDescriptionEn(category.getSeoDescriptionEn());
|
||||
dto.setSeoDescriptionDe(category.getSeoDescriptionDe());
|
||||
dto.setSeoDescriptionFr(category.getSeoDescriptionFr());
|
||||
dto.setOgTitle(category.getOgTitle());
|
||||
dto.setOgDescription(category.getOgDescription());
|
||||
dto.setIndexable(category.getIndexable());
|
||||
@@ -331,4 +452,16 @@ public class AdminShopCategoryControllerService {
|
||||
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 normalizedVariantLabel = normalizeOptional(payload.getVariantLabel());
|
||||
String normalizedSku = normalizeOptional(payload.getSku());
|
||||
String fallbackColorLabel = firstNonBlank(
|
||||
normalizeOptional(payload.getColorLabelIt()),
|
||||
normalizeOptional(payload.getColorLabelEn()),
|
||||
normalizeOptional(payload.getColorLabelDe()),
|
||||
normalizeOptional(payload.getColorLabelFr()),
|
||||
normalizedColorName
|
||||
);
|
||||
String normalizedMaterialCode = normalizeRequired(
|
||||
payload.getInternalMaterialCode(),
|
||||
"Variant internalMaterialCode is required"
|
||||
@@ -380,6 +387,10 @@ public class AdminShopProductControllerService {
|
||||
variant.setSku(normalizedSku);
|
||||
variant.setVariantLabel(normalizedVariantLabel != null ? normalizedVariantLabel : 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.setInternalMaterialCode(normalizedMaterialCode);
|
||||
variant.setPriceChf(price);
|
||||
@@ -531,6 +542,10 @@ public class AdminShopProductControllerService {
|
||||
dto.setSku(variant.getSku());
|
||||
dto.setVariantLabel(variant.getVariantLabel());
|
||||
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.setInternalMaterialCode(variant.getInternalMaterialCode());
|
||||
dto.setPriceChf(variant.getPriceChf());
|
||||
|
||||
@@ -280,11 +280,19 @@ public class AdminOrderControllerService {
|
||||
itemDto.setShopProductName(item.getShopProductName());
|
||||
itemDto.setShopVariantLabel(item.getShopVariantLabel());
|
||||
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());
|
||||
if (item.getFilamentVariant() != null) {
|
||||
itemDto.setFilamentVariantId(item.getFilamentVariant().getId());
|
||||
itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName());
|
||||
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.setQuality(item.getQuality());
|
||||
|
||||
@@ -334,11 +334,19 @@ public class OrderControllerService {
|
||||
itemDto.setShopProductName(item.getShopProductName());
|
||||
itemDto.setShopVariantLabel(item.getShopVariantLabel());
|
||||
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());
|
||||
if (item.getFilamentVariant() != null) {
|
||||
itemDto.setFilamentVariantId(item.getFilamentVariant().getId());
|
||||
itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName());
|
||||
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.setQuality(item.getQuality());
|
||||
|
||||
@@ -81,7 +81,15 @@ public class QuoteSessionResponseAssembler {
|
||||
dto.put("shopProductName", item.getShopProductName());
|
||||
dto.put("shopVariantLabel", item.getShopVariantLabel());
|
||||
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("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("quality", item.getQuality());
|
||||
dto.put("nozzleDiameterMm", item.getNozzleDiameterMm());
|
||||
|
||||
@@ -71,7 +71,7 @@ public class PublicShopCatalogService {
|
||||
|
||||
public List<ShopCategoryTreeDto> getCategories(String language) {
|
||||
CategoryContext categoryContext = loadCategoryContext(language);
|
||||
return buildCategoryTree(null, categoryContext);
|
||||
return buildCategoryTree(null, categoryContext, language);
|
||||
}
|
||||
|
||||
public ShopCategoryDetailDto getCategory(String slug, String language) {
|
||||
@@ -83,7 +83,7 @@ public class PublicShopCatalogService {
|
||||
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) {
|
||||
@@ -114,7 +114,7 @@ public class PublicShopCatalogService {
|
||||
.toList();
|
||||
|
||||
ShopCategoryDetailDto selectedCategoryDetail = selectedCategory != null
|
||||
? buildCategoryDetail(selectedCategory, categoryContext)
|
||||
? buildCategoryDetail(selectedCategory, categoryContext, language)
|
||||
: null;
|
||||
|
||||
return new ShopProductCatalogResponseDto(
|
||||
@@ -316,53 +316,63 @@ public class PublicShopCatalogService {
|
||||
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()
|
||||
.map(category -> new ShopCategoryTreeDto(
|
||||
category.getId(),
|
||||
category.getParentCategory() != null ? category.getParentCategory().getId() : null,
|
||||
category.getSlug(),
|
||||
category.getName(),
|
||||
category.getDescription(),
|
||||
category.getSeoTitle(),
|
||||
category.getSeoDescription(),
|
||||
category.getNameForLanguage(language),
|
||||
category.getDescriptionForLanguage(language),
|
||||
category.getSeoTitleForLanguage(language),
|
||||
category.getSeoDescriptionForLanguage(language),
|
||||
category.getOgTitle(),
|
||||
category.getOgDescription(),
|
||||
category.getIndexable(),
|
||||
category.getSortOrder(),
|
||||
categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0),
|
||||
selectPrimaryMedia(categoryContext.categoryMediaBySlug().get(categoryMediaUsageKey(category))),
|
||||
buildCategoryTree(category.getId(), categoryContext)
|
||||
buildCategoryTree(category.getId(), categoryContext, language)
|
||||
))
|
||||
.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());
|
||||
String localizedSeoTitle = category.getSeoTitleForLanguage(language);
|
||||
String localizedSeoDescription = category.getSeoDescriptionForLanguage(language);
|
||||
return new ShopCategoryDetailDto(
|
||||
category.getId(),
|
||||
category.getSlug(),
|
||||
category.getName(),
|
||||
category.getDescription(),
|
||||
category.getSeoTitle(),
|
||||
category.getSeoDescription(),
|
||||
category.getNameForLanguage(language),
|
||||
category.getDescriptionForLanguage(language),
|
||||
localizedSeoTitle,
|
||||
localizedSeoDescription,
|
||||
category.getOgTitle(),
|
||||
category.getOgDescription(),
|
||||
category.getIndexable(),
|
||||
category.getSortOrder(),
|
||||
categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0),
|
||||
buildCategoryBreadcrumbs(category),
|
||||
buildCategoryBreadcrumbs(category, language),
|
||||
selectPrimaryMedia(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<>();
|
||||
ShopCategory current = category;
|
||||
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();
|
||||
}
|
||||
java.util.Collections.reverse(breadcrumbs);
|
||||
@@ -399,11 +409,11 @@ public class PublicShopCatalogService {
|
||||
new ShopCategoryRefDto(
|
||||
entry.product().getCategory().getId(),
|
||||
entry.product().getCategory().getSlug(),
|
||||
entry.product().getCategory().getName()
|
||||
entry.product().getCategory().getNameForLanguage(language)
|
||||
),
|
||||
resolvePriceFrom(entry.variants()),
|
||||
resolvePriceTo(entry.variants()),
|
||||
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor),
|
||||
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
|
||||
selectPrimaryMedia(images),
|
||||
toProductModelDto(entry)
|
||||
);
|
||||
@@ -432,14 +442,14 @@ public class PublicShopCatalogService {
|
||||
new ShopCategoryRefDto(
|
||||
entry.product().getCategory().getId(),
|
||||
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()),
|
||||
resolvePriceTo(entry.variants()),
|
||||
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor),
|
||||
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
|
||||
entry.variants().stream()
|
||||
.map(variant -> toVariantDto(variant, entry.defaultVariant(), variantColorHexByMaterialAndColor))
|
||||
.map(variant -> toVariantDto(variant, entry.defaultVariant(), variantColorHexByMaterialAndColor, language))
|
||||
.toList(),
|
||||
selectPrimaryMedia(images),
|
||||
images,
|
||||
@@ -449,7 +459,8 @@ public class PublicShopCatalogService {
|
||||
|
||||
private ShopProductVariantOptionDto toVariantDto(ShopProductVariant variant,
|
||||
ShopProductVariant defaultVariant,
|
||||
Map<String, String> variantColorHexByMaterialAndColor) {
|
||||
Map<String, String> variantColorHexByMaterialAndColor,
|
||||
String language) {
|
||||
if (variant == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -463,6 +474,7 @@ public class PublicShopCatalogService {
|
||||
variant.getSku(),
|
||||
variant.getVariantLabel(),
|
||||
variant.getColorName(),
|
||||
variant.getColorLabelForLanguage(language),
|
||||
colorHex,
|
||||
variant.getPriceChf(),
|
||||
defaultVariant != null && Objects.equals(defaultVariant.getId(), variant.getId())
|
||||
|
||||
@@ -146,8 +146,22 @@ public class ShopSitemapService {
|
||||
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(" <loc>").append(xmlEscape(defaultHref)).append("</loc>\n");
|
||||
xml.append(" <loc>").append(xmlEscape(locHref)).append("</loc>\n");
|
||||
|
||||
for (String language : SUPPORTED_LANGUAGES) {
|
||||
String href = hrefByLanguage.get(language);
|
||||
|
||||
@@ -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 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.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -217,6 +220,210 @@ class OrderServiceTest {
|
||||
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() {
|
||||
CustomerDto customer = new CustomerDto();
|
||||
customer.setEmail("buyer@example.com");
|
||||
|
||||
@@ -89,10 +89,16 @@ class ShopSitemapServiceTest {
|
||||
String xml = service.getShopSitemapXml();
|
||||
|
||||
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\""));
|
||||
assertFalse(xml.contains("https://3d-fab.ch/it/shop/bozza"));
|
||||
|
||||
assertTrue(xml.contains("<loc>https://3d-fab.ch/it/shop/p/123e4567-supporto-bici</loc>"));
|
||||
assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/p/123e4567-bike-holder</loc>"));
|
||||
assertTrue(xml.contains("<loc>https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter</loc>"));
|
||||
assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/p/123e4567-support-velo</loc>"));
|
||||
assertTrue(xml.contains("hreflang=\"en\" href=\"https://3d-fab.ch/en/shop/p/123e4567-bike-holder\""));
|
||||
assertTrue(xml.contains("hreflang=\"de\" href=\"https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter\""));
|
||||
assertTrue(xml.contains("hreflang=\"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"
|
||||
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,
|
||||
finish_type text not null default 'GLOSSY'
|
||||
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
|
||||
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
|
||||
(
|
||||
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,
|
||||
slug text NOT NULL UNIQUE,
|
||||
name text NOT NULL,
|
||||
name_it text,
|
||||
name_en text,
|
||||
name_de text,
|
||||
name_fr text,
|
||||
description text,
|
||||
description_it text,
|
||||
description_en text,
|
||||
description_de text,
|
||||
description_fr 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_it text,
|
||||
seo_description_en text,
|
||||
seo_description_de text,
|
||||
seo_description_fr text,
|
||||
og_title text,
|
||||
og_description text,
|
||||
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
|
||||
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
|
||||
(
|
||||
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,
|
||||
variant_label 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,
|
||||
internal_material_code text NOT NULL,
|
||||
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
|
||||
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
|
||||
(
|
||||
shop_product_model_asset_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
@@ -61,13 +61,13 @@
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
"maximumWarning": "600kB",
|
||||
"maximumError": "1.2MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
"maximumWarning": "10kB",
|
||||
"maximumError": "14kB"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -9,9 +9,9 @@ Disallow: /order
|
||||
Disallow: /order/
|
||||
Disallow: /*/order
|
||||
Disallow: /*/order/
|
||||
Disallow: /co
|
||||
Disallow: /co$
|
||||
Disallow: /co/
|
||||
Disallow: /*/co
|
||||
Disallow: /*/co$
|
||||
Disallow: /*/co/
|
||||
Disallow: /checkout
|
||||
Disallow: /checkout/
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset
|
||||
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xhtml="http://www.w3.org/1999/xhtml"
|
||||
>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||
<url>
|
||||
<loc>https://3d-fab.ch/it</loc>
|
||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
|
||||
@@ -13,66 +10,119 @@
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://3d-fab.ch/en</loc>
|
||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
|
||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
|
||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
|
||||
<xhtml:link rel="alternate" hreflang="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>
|
||||
<loc>https://3d-fab.ch/it/calculator/basic</loc>
|
||||
<xhtml:link
|
||||
rel="alternate"
|
||||
hreflang="it"
|
||||
href="https://3d-fab.ch/it/calculator/basic"
|
||||
/>
|
||||
<xhtml:link
|
||||
rel="alternate"
|
||||
hreflang="en"
|
||||
href="https://3d-fab.ch/en/calculator/basic"
|
||||
/>
|
||||
<xhtml:link
|
||||
rel="alternate"
|
||||
hreflang="de"
|
||||
href="https://3d-fab.ch/de/calculator/basic"
|
||||
/>
|
||||
<xhtml:link
|
||||
rel="alternate"
|
||||
hreflang="fr"
|
||||
href="https://3d-fab.ch/fr/calculator/basic"
|
||||
/>
|
||||
<xhtml:link
|
||||
rel="alternate"
|
||||
hreflang="x-default"
|
||||
href="https://3d-fab.ch/it/calculator/basic"
|
||||
/>
|
||||
<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/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>
|
||||
<loc>https://3d-fab.ch/it/calculator/advanced</loc>
|
||||
<xhtml:link
|
||||
rel="alternate"
|
||||
hreflang="it"
|
||||
href="https://3d-fab.ch/it/calculator/advanced"
|
||||
/>
|
||||
<xhtml:link
|
||||
rel="alternate"
|
||||
hreflang="en"
|
||||
href="https://3d-fab.ch/en/calculator/advanced"
|
||||
/>
|
||||
<xhtml:link
|
||||
rel="alternate"
|
||||
hreflang="de"
|
||||
href="https://3d-fab.ch/de/calculator/advanced"
|
||||
/>
|
||||
<xhtml:link
|
||||
rel="alternate"
|
||||
hreflang="fr"
|
||||
href="https://3d-fab.ch/fr/calculator/advanced"
|
||||
/>
|
||||
<xhtml:link
|
||||
rel="alternate"
|
||||
hreflang="x-default"
|
||||
href="https://3d-fab.ch/it/calculator/advanced"
|
||||
/>
|
||||
<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/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>
|
||||
<loc>https://3d-fab.ch/it/shop</loc>
|
||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
|
||||
@@ -83,64 +133,160 @@
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://3d-fab.ch/en/shop</loc>
|
||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
|
||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
|
||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
|
||||
<xhtml:link rel="alternate" hreflang="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>
|
||||
<loc>https://3d-fab.ch/it/about</loc>
|
||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
|
||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
|
||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
|
||||
<xhtml:link
|
||||
rel="alternate"
|
||||
hreflang="x-default"
|
||||
href="https://3d-fab.ch/it/about"
|
||||
/>
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
||||
<changefreq>monthly</changefreq>
|
||||
<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"
|
||||
/>
|
||||
<loc>https://3d-fab.ch/en/about</loc>
|
||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
|
||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
|
||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://3d-fab.ch/de/about</loc>
|
||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
|
||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
|
||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
|
||||
<xhtml:link rel="alternate" hreflang="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>
|
||||
<loc>https://3d-fab.ch/it/privacy</loc>
|
||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
|
||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
|
||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
|
||||
<xhtml:link
|
||||
rel="alternate"
|
||||
hreflang="x-default"
|
||||
href="https://3d-fab.ch/it/privacy"
|
||||
/>
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.4</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://3d-fab.ch/en/privacy</loc>
|
||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
|
||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
|
||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
|
||||
<xhtml:link rel="alternate" hreflang="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>
|
||||
<loc>https://3d-fab.ch/it/terms</loc>
|
||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
|
||||
@@ -151,4 +297,34 @@
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.4</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://3d-fab.ch/en/terms</loc>
|
||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
|
||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
|
||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
|
||||
<xhtml:link rel="alternate" hreflang="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>
|
||||
|
||||
@@ -1,26 +1,48 @@
|
||||
import {
|
||||
ApplicationConfig,
|
||||
provideAppInitializer,
|
||||
provideZoneChangeDetection,
|
||||
importProvidersFrom,
|
||||
inject,
|
||||
REQUEST,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
provideRouter,
|
||||
withComponentInputBinding,
|
||||
withInMemoryScrolling,
|
||||
withViewTransitions,
|
||||
Router,
|
||||
} from '@angular/router';
|
||||
import { routes } from './app.routes';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
|
||||
import {
|
||||
provideTranslateHttpLoader,
|
||||
TranslateHttpLoader,
|
||||
} from '@ngx-translate/http-loader';
|
||||
TranslateLoader,
|
||||
TranslateModule,
|
||||
TranslateService,
|
||||
} from '@ngx-translate/core';
|
||||
import { adminAuthInterceptor } from './core/interceptors/admin-auth.interceptor';
|
||||
import {
|
||||
provideClientHydration,
|
||||
withEventReplay,
|
||||
} 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 = {
|
||||
providers: [
|
||||
@@ -33,20 +55,44 @@ export const appConfig: ApplicationConfig = {
|
||||
scrollPositionRestoration: 'top',
|
||||
}),
|
||||
),
|
||||
provideHttpClient(withInterceptors([adminAuthInterceptor])),
|
||||
provideTranslateHttpLoader({
|
||||
prefix: './assets/i18n/',
|
||||
suffix: '.json',
|
||||
}),
|
||||
provideHttpClient(
|
||||
withInterceptors([serverOriginInterceptor, adminAuthInterceptor]),
|
||||
),
|
||||
importProvidersFrom(
|
||||
TranslateModule.forRoot({
|
||||
defaultLanguage: 'it',
|
||||
loader: {
|
||||
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.setDefaultLang('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()),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -15,9 +15,18 @@ const appChildRoutes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./features/home/home.component').then((m) => m.HomeComponent),
|
||||
data: {
|
||||
seoTitle: '3D fab | Stampa 3D su misura',
|
||||
seoDescription:
|
||||
'Servizio di stampa 3D con preventivo online immediato per prototipi, piccole serie e pezzi personalizzati.',
|
||||
seoTitleByLang: {
|
||||
it: 'Stampa 3D su misura in Ticino | Prototipi, ricambi e piccole serie - 3D Fab',
|
||||
en: 'Custom 3D Printing in Switzerland | Prototypes, Spare Parts & Short Runs - 3D Fab',
|
||||
de: '3D-Druck in Zürich | Prototypen, Ersatzteile und Kleinserien - 3D Fab',
|
||||
fr: 'Impression 3D à Bienne | Prototypes, pièces et petites séries - 3D Fab',
|
||||
},
|
||||
seoDescriptionByLang: {
|
||||
it: 'Servizio di stampa 3D in Ticino per prototipi, pezzi di ricambio e piccole serie. Shop tecnico e supporto CAD, con preventivo rapido da file STL.',
|
||||
en: 'Swiss-based 3D printing service for prototypes, spare parts and short production runs. Technical shop and CAD support, with fast quotes from STL files.',
|
||||
de: '3D-Druckservice in Zürich für Prototypen, Ersatzteile und Kleinserien. Technischer Shop und CAD-Service, mit schneller Angebotsanfrage aus STL-Dateien.',
|
||||
fr: "Service d'impression 3D à Bienne pour prototypes, pièces de rechange et petites séries. Boutique technique et support CAD, avec devis rapide depuis un fichier STL.",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -52,6 +61,18 @@ const appChildRoutes: Routes = [
|
||||
'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: {
|
||||
seoTitle: 'Qualita e Materiali | 3D fab',
|
||||
seoDescription:
|
||||
'Confronta materiali di stampa 3D con radar chart interattivo, proprieta tecniche e fonti citate.',
|
||||
},
|
||||
},*/
|
||||
{
|
||||
path: 'contact',
|
||||
loadChildren: () =>
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface ColorCategory {
|
||||
colors: ColorOption[];
|
||||
}
|
||||
|
||||
const DEFAULT_BRAND_COLOR = '#facf0a';
|
||||
|
||||
export const PRODUCT_COLORS: ColorCategory[] = [
|
||||
{
|
||||
name: 'COLOR.CATEGORY_GLOSSY',
|
||||
@@ -38,10 +40,81 @@ export const PRODUCT_COLORS: ColorCategory[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function getColorHex(value: string): string {
|
||||
for (const cat of PRODUCT_COLORS) {
|
||||
const found = cat.colors.find((c) => c.value === value);
|
||||
if (found) return found.hex;
|
||||
}
|
||||
return '#facf0a'; // Default Brand Color if not found
|
||||
export function normalizeColorValue(value: string | null | undefined): string {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/ß/g, 'ss')
|
||||
.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;
|
||||
}
|
||||
|
||||
22
frontend/src/app/core/i18n/static-translate.loader.ts
Normal file
22
frontend/src/app/core/i18n/static-translate.loader.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { TranslateLoader, TranslationObject } from '@ngx-translate/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import de from '../../../assets/i18n/de.json';
|
||||
import en from '../../../assets/i18n/en.json';
|
||||
import fr from '../../../assets/i18n/fr.json';
|
||||
import it from '../../../assets/i18n/it.json';
|
||||
|
||||
const TRANSLATIONS: Record<string, TranslationObject> = {
|
||||
it: it as TranslationObject,
|
||||
en: en as TranslationObject,
|
||||
de: de as TranslationObject,
|
||||
fr: fr as TranslationObject,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class StaticTranslateLoader implements TranslateLoader {
|
||||
getTranslation(lang: string): Observable<TranslationObject> {
|
||||
const normalized = String(lang || 'it').toLowerCase();
|
||||
return of(TRANSLATIONS[normalized] ?? TRANSLATIONS['it']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject, REQUEST } from '@angular/core';
|
||||
|
||||
type RequestLike = {
|
||||
protocol?: string;
|
||||
get?: (name: string) => string | undefined;
|
||||
headers?: Record<string, string | string[] | undefined>;
|
||||
};
|
||||
|
||||
function isAbsoluteUrl(url: string): boolean {
|
||||
return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//');
|
||||
}
|
||||
|
||||
function firstHeaderValue(value: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null;
|
||||
}
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function resolveOrigin(request: RequestLike | null): string | null {
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host =
|
||||
request.get?.('host') ??
|
||||
firstHeaderValue(request.headers?.['host']) ??
|
||||
firstHeaderValue(request.headers?.['x-forwarded-host']);
|
||||
if (!host) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const forwardedProtoRaw = firstHeaderValue(
|
||||
request.headers?.['x-forwarded-proto'],
|
||||
);
|
||||
const forwardedProto = forwardedProtoRaw
|
||||
?.split(',')
|
||||
.map((part) => part.trim().toLowerCase())
|
||||
.find(Boolean);
|
||||
const protocol = forwardedProto || request.protocol || 'http';
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
function normalizeRelativePath(url: string): string {
|
||||
const withoutDot = url.replace(/^\.\//, '');
|
||||
return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`;
|
||||
}
|
||||
|
||||
export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (isAbsoluteUrl(req.url)) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
const request = inject(REQUEST, { optional: true }) as RequestLike | null;
|
||||
const origin = resolveOrigin(request);
|
||||
if (!origin) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
const absoluteUrl = `${origin}${normalizeRelativePath(req.url)}`;
|
||||
return next(req.clone({ url: absoluteUrl }));
|
||||
};
|
||||
@@ -130,7 +130,7 @@
|
||||
<div class="cart-line-copy">
|
||||
<strong>{{ cartItemName(item) }}</strong>
|
||||
@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) {
|
||||
<span class="cart-line-color">
|
||||
@@ -138,7 +138,7 @@
|
||||
class="color-dot"
|
||||
[style.background-color]="cartItemColorHex(item)"
|
||||
></span>
|
||||
<span>{{ color }}</span>
|
||||
<span>{{ color | translate }}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
ShopService,
|
||||
} from '../../features/shop/services/shop.service';
|
||||
import { finalize } from 'rxjs';
|
||||
import {
|
||||
findColorHex,
|
||||
resolveLocalizedColorLabel,
|
||||
} from '../constants/colors.const';
|
||||
|
||||
@Component({
|
||||
selector: 'app-navbar',
|
||||
@@ -143,15 +147,30 @@ export class NavbarComponent {
|
||||
}
|
||||
|
||||
cartItemVariant(item: ShopCartItem): string | null {
|
||||
return item.shopVariantLabel || item.shopVariantColorName || null;
|
||||
return item.shopVariantLabel || this.cartItemColor(item);
|
||||
}
|
||||
|
||||
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 {
|
||||
return item.shopVariantColorHex || '#c9ced6';
|
||||
return (
|
||||
item.shopVariantColorHex ||
|
||||
findColorHex(item.shopVariantColorName) ||
|
||||
findColorHex(item.colorCode) ||
|
||||
'#c9ced6'
|
||||
);
|
||||
}
|
||||
|
||||
trackByCartItem(_index: number, item: ShopCartItem): string {
|
||||
|
||||
@@ -12,14 +12,31 @@ export interface PageSeoOverride {
|
||||
ogDescription?: string | null;
|
||||
}
|
||||
|
||||
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
||||
type SeoMap = Partial<Record<SupportedLang, string>>;
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SeoService {
|
||||
private readonly defaultTitle = '3D fab | Stampa 3D su misura';
|
||||
private readonly defaultDescription =
|
||||
'Stampa 3D su misura con preventivo online immediato. Carica il file, scegli materiale e qualità, ricevi prezzo e tempi in pochi secondi.';
|
||||
private readonly supportedLangs = new Set(['it', 'en', 'de', 'fr']);
|
||||
private readonly defaultTitleByLang: Record<SupportedLang, string> = {
|
||||
it: '3D fab | Stampa 3D su misura',
|
||||
en: '3D fab | Custom 3D Printing',
|
||||
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 = new Set<SupportedLang>([
|
||||
'it',
|
||||
'en',
|
||||
'de',
|
||||
'fr',
|
||||
]);
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
@@ -40,9 +57,11 @@ export class SeoService {
|
||||
}
|
||||
|
||||
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.asString(override.title) ?? this.defaultTitleByLang[lang];
|
||||
const description =
|
||||
this.asString(override.description) ?? this.defaultDescription;
|
||||
this.asString(override.description) ?? this.defaultDescriptionByLang[lang];
|
||||
const robots = this.asString(override.robots) ?? 'index, follow';
|
||||
const ogTitle = this.asString(override.ogTitle) ?? title;
|
||||
const ogDescription = this.asString(override.ogDescription) ?? description;
|
||||
@@ -52,13 +71,18 @@ export class SeoService {
|
||||
|
||||
private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void {
|
||||
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.defaultTitleByLang[lang];
|
||||
const description =
|
||||
this.asString(mergedData['seoDescription']) ?? this.defaultDescription;
|
||||
this.resolveSeoText(mergedData, 'seoDescription', lang) ??
|
||||
this.defaultDescriptionByLang[lang];
|
||||
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 =
|
||||
this.asString(mergedData['ogDescription']) ?? description;
|
||||
this.resolveSeoText(mergedData, 'ogDescription', lang) ?? description;
|
||||
|
||||
this.applySeoValues(title, description, robots, ogTitle, ogDescription);
|
||||
}
|
||||
@@ -104,11 +128,36 @@ export class SeoService {
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
private resolveSeoText(
|
||||
routeData: Record<string, unknown>,
|
||||
key: 'seoTitle' | 'seoDescription' | 'ogTitle' | 'ogDescription',
|
||||
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;
|
||||
}
|
||||
}
|
||||
return this.asString(routeData[key]);
|
||||
}
|
||||
|
||||
private getCleanPath(url: string): string {
|
||||
const path = (url || '/').split('?')[0].split('#')[0];
|
||||
return path || '/';
|
||||
}
|
||||
|
||||
private resolveLangFromPath(path: string): SupportedLang {
|
||||
const firstSegment = path.split('/').filter(Boolean)[0]?.toLowerCase();
|
||||
if (firstSegment && this.supportedLangs.has(firstSegment as SupportedLang)) {
|
||||
return firstSegment as SupportedLang;
|
||||
}
|
||||
return 'it';
|
||||
}
|
||||
|
||||
private updateCanonicalTag(url: string): void {
|
||||
let link = this.document.head.querySelector(
|
||||
'link[rel="canonical"]',
|
||||
@@ -124,10 +173,9 @@ export class SeoService {
|
||||
private updateLangAndAlternates(path: string): void {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const firstSegment = segments[0]?.toLowerCase();
|
||||
const hasLang = Boolean(
|
||||
firstSegment && this.supportedLangs.has(firstSegment),
|
||||
);
|
||||
const lang = hasLang ? firstSegment : 'it';
|
||||
const maybeLang = firstSegment as SupportedLang | undefined;
|
||||
const hasLang = Boolean(maybeLang && this.supportedLangs.has(maybeLang));
|
||||
const lang: SupportedLang = hasLang && maybeLang ? maybeLang : 'it';
|
||||
const suffixSegments = hasLang ? segments.slice(1) : segments;
|
||||
const suffix =
|
||||
suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : '';
|
||||
|
||||
@@ -101,6 +101,22 @@
|
||||
placeholder="Nero, Bianco..."
|
||||
/>
|
||||
</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">
|
||||
<span>Hex colore</span>
|
||||
<input
|
||||
@@ -229,7 +245,7 @@
|
||||
class="color-dot"
|
||||
[style.background-color]="getVariantColorHex(variant)"
|
||||
></span>
|
||||
{{ variant.colorName || "N/D" }}
|
||||
{{ variant.colorLabelIt || variant.colorName || "N/D" }}
|
||||
</span>
|
||||
<span
|
||||
>Stock spools:
|
||||
@@ -290,6 +306,22 @@
|
||||
<span>Colore</span>
|
||||
<input type="text" [(ngModel)]="variant.colorName" />
|
||||
</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">
|
||||
<span>Hex colore</span>
|
||||
<input type="text" [(ngModel)]="variant.colorHex" />
|
||||
|
||||
@@ -47,6 +47,10 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
materialTypeId: 0,
|
||||
variantDisplayName: '',
|
||||
colorName: '',
|
||||
colorLabelIt: '',
|
||||
colorLabelEn: '',
|
||||
colorLabelDe: '',
|
||||
colorLabelFr: '',
|
||||
colorHex: '',
|
||||
finishType: 'GLOSSY',
|
||||
brand: '',
|
||||
@@ -206,6 +210,10 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
this.newVariant.materialTypeId || this.materials[0]?.id || 0,
|
||||
variantDisplayName: '',
|
||||
colorName: '',
|
||||
colorLabelIt: '',
|
||||
colorLabelEn: '',
|
||||
colorLabelDe: '',
|
||||
colorLabelFr: '',
|
||||
colorHex: '',
|
||||
finishType: 'GLOSSY',
|
||||
brand: '',
|
||||
@@ -359,6 +367,10 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
materialTypeId: Number(source.materialTypeId),
|
||||
variantDisplayName: (source.variantDisplayName || '').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,
|
||||
finishType: (source.finishType || 'GLOSSY').trim().toUpperCase(),
|
||||
brand: (source.brand || '').trim() || undefined,
|
||||
|
||||
@@ -206,27 +206,16 @@
|
||||
/>
|
||||
</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">
|
||||
<span class="ui-form-caption">Slug</span>
|
||||
<div class="input-with-action">
|
||||
<input
|
||||
class="ui-form-control"
|
||||
type="text"
|
||||
[(ngModel)]="categoryForm.slug"
|
||||
name="categorySlug"
|
||||
placeholder="desk-accessories"
|
||||
/>
|
||||
[(ngModel)]="categoryForm.slug"
|
||||
name="categorySlug"
|
||||
placeholder="desk-accessories"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="ui-button ui-button--ghost"
|
||||
@@ -237,36 +226,6 @@
|
||||
</div>
|
||||
</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">
|
||||
<span class="ui-form-caption">OG title</span>
|
||||
<input
|
||||
@@ -288,6 +247,141 @@
|
||||
</label>
|
||||
</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">
|
||||
<label class="ui-checkbox">
|
||||
<input
|
||||
|
||||
@@ -41,10 +41,10 @@ interface CategoryFormState {
|
||||
id: string | null;
|
||||
parentCategoryId: string | null;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
seoTitle: string;
|
||||
seoDescription: string;
|
||||
names: Record<ShopLanguage, string>;
|
||||
descriptions: Record<ShopLanguage, string>;
|
||||
seoTitles: Record<ShopLanguage, string>;
|
||||
seoDescriptions: Record<ShopLanguage, string>;
|
||||
ogTitle: string;
|
||||
ogDescription: string;
|
||||
indexable: boolean;
|
||||
@@ -554,7 +554,10 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
setActiveContentLanguage(language: ShopLanguage): void {
|
||||
@@ -603,6 +606,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 {
|
||||
event.preventDefault();
|
||||
}
|
||||
@@ -1228,10 +1270,10 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
||||
id: null,
|
||||
parentCategoryId: null,
|
||||
slug: '',
|
||||
name: '',
|
||||
description: '',
|
||||
seoTitle: '',
|
||||
seoDescription: '',
|
||||
names: this.createEmptyLocalizedTextRecord(),
|
||||
descriptions: this.createEmptyLocalizedTextRecord(),
|
||||
seoTitles: this.createEmptyLocalizedTextRecord(),
|
||||
seoDescriptions: this.createEmptyLocalizedTextRecord(),
|
||||
ogTitle: '',
|
||||
ogDescription: '',
|
||||
indexable: true,
|
||||
@@ -1241,6 +1283,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private resetCategoryForm(): void {
|
||||
this.activeContentLanguage = 'it';
|
||||
Object.assign(this.categoryForm, this.createEmptyCategoryForm());
|
||||
}
|
||||
|
||||
@@ -1249,10 +1292,30 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
||||
id: category.id,
|
||||
parentCategoryId: category.parentCategoryId,
|
||||
slug: category.slug ?? '',
|
||||
name: category.name ?? '',
|
||||
description: category.description ?? '',
|
||||
seoTitle: category.seoTitle ?? '',
|
||||
seoDescription: category.seoDescription ?? '',
|
||||
names: {
|
||||
it: category.nameIt ?? category.name ?? '',
|
||||
en: category.nameEn ?? category.name ?? '',
|
||||
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 ?? '',
|
||||
ogDescription: category.ogDescription ?? '',
|
||||
indexable: category.indexable,
|
||||
@@ -1265,10 +1328,34 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
||||
return {
|
||||
parentCategoryId: this.categoryForm.parentCategoryId || null,
|
||||
slug: this.categoryForm.slug.trim(),
|
||||
name: this.categoryForm.name.trim(),
|
||||
description: this.categoryForm.description.trim(),
|
||||
seoTitle: this.categoryForm.seoTitle.trim(),
|
||||
seoDescription: this.categoryForm.seoDescription.trim(),
|
||||
name: this.categoryForm.names['it'].trim(),
|
||||
nameIt: this.categoryForm.names['it'].trim(),
|
||||
nameEn: this.categoryForm.names['en'].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(),
|
||||
ogDescription: this.categoryForm.ogDescription.trim(),
|
||||
indexable: this.categoryForm.indexable,
|
||||
@@ -1278,12 +1365,19 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private validateCategoryForm(): string | null {
|
||||
if (!this.categoryForm.name.trim()) {
|
||||
return 'Il nome categoria è obbligatorio.';
|
||||
for (const language of this.shopLanguages) {
|
||||
if (!this.categoryForm.names[language].trim()) {
|
||||
return `Il nome categoria ${this.languageLabels[language]} è obbligatorio.`;
|
||||
}
|
||||
}
|
||||
if (!this.categoryForm.slug.trim()) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1616,6 +1710,10 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
||||
sku: this.optionalValue(existingVariant?.sku ?? ''),
|
||||
variantLabel: materialCode,
|
||||
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(
|
||||
stockVariant.colorHex ?? '',
|
||||
)?.toUpperCase(),
|
||||
@@ -1714,7 +1812,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private stockVariantLabel(variant: AdminFilamentVariant): string {
|
||||
const colorName = variant.colorName.trim();
|
||||
const colorName = (variant.colorLabelIt || variant.colorName).trim();
|
||||
const variantDisplayName = variant.variantDisplayName.trim();
|
||||
if (
|
||||
variantDisplayName &&
|
||||
@@ -2193,6 +2291,19 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
||||
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 {
|
||||
return source
|
||||
.normalize('NFD')
|
||||
|
||||
@@ -32,6 +32,10 @@ export interface AdminFilamentVariant {
|
||||
materialTechnicalTypeLabel?: string;
|
||||
variantDisplayName: string;
|
||||
colorName: string;
|
||||
colorLabelIt: string;
|
||||
colorLabelEn: string;
|
||||
colorLabelDe: string;
|
||||
colorLabelFr: string;
|
||||
colorHex?: string;
|
||||
finishType?: string;
|
||||
brand?: string;
|
||||
@@ -57,6 +61,10 @@ export interface AdminUpsertFilamentVariantPayload {
|
||||
materialTypeId: number;
|
||||
variantDisplayName: string;
|
||||
colorName: string;
|
||||
colorLabelIt?: string;
|
||||
colorLabelEn?: string;
|
||||
colorLabelDe?: string;
|
||||
colorLabelFr?: string;
|
||||
colorHex?: string;
|
||||
finishType?: string;
|
||||
brand?: string;
|
||||
|
||||
@@ -30,9 +30,25 @@ export interface AdminShopCategory {
|
||||
parentCategoryName: string | null;
|
||||
slug: string;
|
||||
name: string;
|
||||
nameIt: string;
|
||||
nameEn: string;
|
||||
nameDe: string;
|
||||
nameFr: string;
|
||||
description: string | null;
|
||||
descriptionIt: string | null;
|
||||
descriptionEn: string | null;
|
||||
descriptionDe: string | null;
|
||||
descriptionFr: string | null;
|
||||
seoTitle: string | null;
|
||||
seoTitleIt: string | null;
|
||||
seoTitleEn: string | null;
|
||||
seoTitleDe: string | null;
|
||||
seoTitleFr: string | null;
|
||||
seoDescription: string | null;
|
||||
seoDescriptionIt: string | null;
|
||||
seoDescriptionEn: string | null;
|
||||
seoDescriptionDe: string | null;
|
||||
seoDescriptionFr: string | null;
|
||||
ogTitle: string | null;
|
||||
ogDescription: string | null;
|
||||
indexable: boolean;
|
||||
@@ -54,9 +70,25 @@ export interface AdminUpsertShopCategoryPayload {
|
||||
parentCategoryId?: string | null;
|
||||
slug: string;
|
||||
name: string;
|
||||
nameIt: string;
|
||||
nameEn: string;
|
||||
nameDe: string;
|
||||
nameFr: string;
|
||||
description?: string;
|
||||
descriptionIt?: string;
|
||||
descriptionEn?: string;
|
||||
descriptionDe?: string;
|
||||
descriptionFr?: string;
|
||||
seoTitle?: string;
|
||||
seoTitleIt?: string;
|
||||
seoTitleEn?: string;
|
||||
seoTitleDe?: string;
|
||||
seoTitleFr?: string;
|
||||
seoDescription?: string;
|
||||
seoDescriptionIt?: string;
|
||||
seoDescriptionEn?: string;
|
||||
seoDescriptionDe?: string;
|
||||
seoDescriptionFr?: string;
|
||||
ogTitle?: string;
|
||||
ogDescription?: string;
|
||||
indexable: boolean;
|
||||
@@ -69,6 +101,10 @@ export interface AdminShopProductVariant {
|
||||
sku: string | null;
|
||||
variantLabel: string;
|
||||
colorName: string;
|
||||
colorLabelIt: string;
|
||||
colorLabelEn: string;
|
||||
colorLabelDe: string;
|
||||
colorLabelFr: string;
|
||||
colorHex: string | null;
|
||||
internalMaterialCode: string;
|
||||
priceChf: number;
|
||||
@@ -170,6 +206,10 @@ export interface AdminUpsertShopProductVariantPayload {
|
||||
sku?: string;
|
||||
variantLabel?: string;
|
||||
colorName: string;
|
||||
colorLabelIt?: string;
|
||||
colorLabelEn?: string;
|
||||
colorLabelDe?: string;
|
||||
colorLabelFr?: string;
|
||||
colorHex?: string;
|
||||
internalMaterialCode: string;
|
||||
priceChf: number;
|
||||
|
||||
@@ -75,6 +75,10 @@ export interface VariantOption {
|
||||
id: number;
|
||||
name: string;
|
||||
colorName: string;
|
||||
colorLabelIt?: string;
|
||||
colorLabelEn?: string;
|
||||
colorLabelDe?: string;
|
||||
colorLabelFr?: string;
|
||||
hexColor: string;
|
||||
finishType: string;
|
||||
stockSpools: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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>
|
||||
<p class="ui-page-subtitle cad-subtitle" *ngIf="isCadSession()">
|
||||
Servizio CAD
|
||||
@@ -265,14 +265,16 @@
|
||||
</span>
|
||||
<span *ngIf="itemVariantLabel(item) as variantLabel">
|
||||
{{ "SHOP.VARIANT" | translate }}:
|
||||
{{ variantLabel }}
|
||||
{{ variantLabel | translate }}
|
||||
</span>
|
||||
<span class="item-color" *ngIf="itemColorLabel(item) !== '-'">
|
||||
<span
|
||||
class="color-dot"
|
||||
[style.background-color]="itemColorSwatch(item)"
|
||||
></span>
|
||||
<span class="color-name">{{ itemColorLabel(item) }}</span>
|
||||
<span class="color-name">{{
|
||||
itemColorLabel(item) | translate
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="item-specs-sub" *ngIf="showItemPrintMetrics(item)">
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.checkout-hero {
|
||||
padding-top: calc(var(--space-12) + var(--space-4));
|
||||
}
|
||||
|
||||
.cad-subtitle {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -273,3 +277,9 @@ app-toggle-selector.user-type-selector-compact {
|
||||
.mb-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';
|
||||
import { LanguageService } from '../../core/services/language.service';
|
||||
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({
|
||||
selector: 'app-checkout',
|
||||
@@ -252,8 +257,7 @@ export class CheckoutComponent implements OnInit {
|
||||
if (variantLabel) {
|
||||
return variantLabel;
|
||||
}
|
||||
const colorName = String(item?.shopVariantColorName ?? '').trim();
|
||||
return colorName || null;
|
||||
return this.localizedShopColorLabel(item);
|
||||
}
|
||||
|
||||
showItemMaterial(item: any): boolean {
|
||||
@@ -282,12 +286,7 @@ export class CheckoutComponent implements OnInit {
|
||||
}
|
||||
|
||||
itemColorLabel(item: any): string {
|
||||
const shopColor = String(item?.shopVariantColorName ?? '').trim();
|
||||
if (shopColor) {
|
||||
return shopColor;
|
||||
}
|
||||
const raw = String(item?.colorCode ?? '').trim();
|
||||
return raw || '-';
|
||||
return this.localizedShopColorLabel(item) || String(item?.colorCode ?? '-');
|
||||
}
|
||||
|
||||
itemColorSwatch(item: any): string {
|
||||
@@ -310,12 +309,12 @@ export class CheckoutComponent implements OnInit {
|
||||
return raw;
|
||||
}
|
||||
|
||||
const byName = this.variantHexByColorName.get(raw.toLowerCase());
|
||||
const byName = this.variantHexByColorName.get(normalizeColorValue(raw));
|
||||
if (byName) {
|
||||
return byName;
|
||||
}
|
||||
|
||||
const fallback = getColorHex(raw);
|
||||
const fallback = findColorHex(raw) ?? getColorHex(raw);
|
||||
if (fallback && fallback !== '#facf0a') {
|
||||
return fallback;
|
||||
}
|
||||
@@ -331,6 +330,16 @@ export class CheckoutComponent implements OnInit {
|
||||
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 {
|
||||
const id = String(item?.id ?? '');
|
||||
if (!id) {
|
||||
@@ -373,7 +382,10 @@ export class CheckoutComponent implements OnInit {
|
||||
this.variantHexById.set(variantId, colorHex);
|
||||
}
|
||||
if (colorName && colorHex) {
|
||||
this.variantHexByColorName.set(colorName.toLowerCase(), colorHex);
|
||||
this.variantHexByColorName.set(
|
||||
normalizeColorValue(colorName),
|
||||
colorHex,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<main class="home-page">
|
||||
<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">
|
||||
<p class="eyebrow ui-eyebrow">{{ "HOME.HERO_EYEBROW" | translate }}</p>
|
||||
<h1
|
||||
@@ -25,6 +27,30 @@
|
||||
}}</app-button>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -45,6 +45,99 @@
|
||||
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(--swiss-red);
|
||||
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 {
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
@@ -165,6 +258,13 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero-swiss-card {
|
||||
align-self: start;
|
||||
justify-self: center;
|
||||
width: min(100%, 340px);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.shop-gallery {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
|
||||
@@ -68,9 +68,12 @@
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<div class="payment-layout ui-two-column-layout">
|
||||
<div class="payment-main">
|
||||
<app-card class="mb-6" *ngIf="o.status === 'PENDING_PAYMENT'">
|
||||
<div
|
||||
class="payment-layout ui-two-column-layout"
|
||||
[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">
|
||||
<h3 class="ui-card-title">{{ "PAYMENT.METHOD" | translate }}</h3>
|
||||
</div>
|
||||
@@ -174,69 +177,6 @@
|
||||
</app-button>
|
||||
</div>
|
||||
</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 class="payment-summary">
|
||||
@@ -271,6 +211,71 @@
|
||||
[currency]="'CHF'"
|
||||
[totalLabelKey]="'PAYMENT.TOTAL'"
|
||||
></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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.payment-layout--summary-only {
|
||||
grid-template-columns: minmax(0, 440px);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.payment-details {
|
||||
margin-bottom: var(--space-6);
|
||||
|
||||
@@ -119,9 +124,52 @@
|
||||
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 {
|
||||
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 {
|
||||
@@ -129,7 +177,7 @@
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
padding: 0.85rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-card);
|
||||
@@ -149,7 +197,7 @@
|
||||
}
|
||||
|
||||
.order-item-name {
|
||||
font-size: 1rem;
|
||||
font-size: 0.96rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@@ -176,7 +224,7 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.92rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.item-color-chip {
|
||||
@@ -194,13 +242,13 @@
|
||||
}
|
||||
|
||||
.order-item-tech {
|
||||
font-size: 0.86rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.order-item-total {
|
||||
white-space: nowrap;
|
||||
font-size: 1rem;
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.order-summary-meta {
|
||||
@@ -325,6 +373,10 @@
|
||||
padding-top: calc(var(--space-8) + var(--space-4));
|
||||
}
|
||||
|
||||
.payment-layout--summary-only {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-timeline {
|
||||
margin-top: var(--space-4);
|
||||
margin-bottom: var(--space-8);
|
||||
@@ -362,4 +414,10 @@
|
||||
.order-summary-meta {
|
||||
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 { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import {
|
||||
findColorHex,
|
||||
resolveLocalizedColorLabel,
|
||||
} from '../../core/constants/colors.const';
|
||||
import {
|
||||
PriceBreakdownComponent,
|
||||
PriceBreakdownRow,
|
||||
@@ -25,9 +29,17 @@ interface PublicOrderItem {
|
||||
shopProductName?: string;
|
||||
shopVariantLabel?: string;
|
||||
shopVariantColorName?: string;
|
||||
shopVariantColorLabelIt?: string;
|
||||
shopVariantColorLabelEn?: string;
|
||||
shopVariantColorLabelDe?: string;
|
||||
shopVariantColorLabelFr?: string;
|
||||
shopVariantColorHex?: string;
|
||||
filamentVariantDisplayName?: string;
|
||||
filamentColorName?: string;
|
||||
filamentColorLabelIt?: string;
|
||||
filamentColorLabelEn?: string;
|
||||
filamentColorLabelDe?: string;
|
||||
filamentColorLabelFr?: string;
|
||||
filamentColorHex?: string;
|
||||
quality?: string;
|
||||
nozzleDiameterMm?: number;
|
||||
@@ -278,23 +290,16 @@ export class OrderComponent implements OnInit {
|
||||
return variantLabel;
|
||||
}
|
||||
|
||||
const colorName = String(item?.shopVariantColorName ?? '').trim();
|
||||
return colorName || null;
|
||||
return this.localizedColorLabel(item, 'shop');
|
||||
}
|
||||
|
||||
itemColorLabel(item: PublicOrderItem): string {
|
||||
const shopColor = String(item?.shopVariantColorName ?? '').trim();
|
||||
if (shopColor) {
|
||||
return shopColor;
|
||||
}
|
||||
|
||||
const filamentColor = String(item?.filamentColorName ?? '').trim();
|
||||
if (filamentColor) {
|
||||
return filamentColor;
|
||||
}
|
||||
|
||||
const rawColor = String(item?.colorCode ?? '').trim();
|
||||
return rawColor || this.translate.instant('ORDER.NOT_AVAILABLE');
|
||||
return (
|
||||
this.localizedColorLabel(item, 'shop') ||
|
||||
this.localizedColorLabel(item, 'filament') ||
|
||||
String(item?.colorCode ?? '').trim() ||
|
||||
this.translate.instant('ORDER.NOT_AVAILABLE')
|
||||
);
|
||||
}
|
||||
|
||||
itemColorHex(item: PublicOrderItem): string | null {
|
||||
@@ -313,7 +318,7 @@ export class OrderComponent implements OnInit {
|
||||
return rawColor;
|
||||
}
|
||||
|
||||
return null;
|
||||
return findColorHex(rawColor);
|
||||
}
|
||||
|
||||
showItemMaterial(item: PublicOrderItem): boolean {
|
||||
@@ -324,6 +329,29 @@ export class OrderComponent implements OnInit {
|
||||
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' {
|
||||
const items = order?.items ?? [];
|
||||
const hasShop = items.some((item) => this.isShopItem(item));
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
.media {
|
||||
position: relative;
|
||||
display: block;
|
||||
aspect-ratio: 1 / 1;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: #f2eee5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -15,12 +15,14 @@
|
||||
} @else {
|
||||
@if (product(); as p) {
|
||||
<nav class="breadcrumbs">
|
||||
<a [routerLink]="shopRootLink()">{{
|
||||
<a class="breadcrumbs__item" [routerLink]="shopRootLink()">{{
|
||||
"SHOP.BREADCRUMB_ROOT" | translate
|
||||
}}</a>
|
||||
@for (crumb of p.breadcrumbs; track crumb.id) {
|
||||
<span>/</span>
|
||||
<a [routerLink]="categoryLink(crumb.slug)">{{ crumb.name }}</a>
|
||||
<span class="breadcrumbs__separator">/</span>
|
||||
<a class="breadcrumbs__item" [routerLink]="categoryLink(crumb.slug)"
|
||||
>{{ crumb.name }}</a
|
||||
>
|
||||
}
|
||||
</nav>
|
||||
|
||||
@@ -129,13 +131,29 @@
|
||||
|
||||
<app-card class="purchase-shell">
|
||||
<div class="purchase-card">
|
||||
<div class="price-row">
|
||||
<div>
|
||||
<div class="offer-header">
|
||||
<div class="offer-price">
|
||||
<p class="panel-kicker">
|
||||
{{ "SHOP.SELECT_MATERIAL" | translate }}
|
||||
{{ "SHOP.PRICE_LABEL" | translate }}
|
||||
</p>
|
||||
<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>
|
||||
|
||||
@if (selectedVariantCartQuantity() > 0) {
|
||||
<span class="cart-pill">
|
||||
{{
|
||||
@@ -148,32 +166,58 @@
|
||||
</div>
|
||||
|
||||
@if (materialOptions().length > 1) {
|
||||
<div class="material-grid">
|
||||
@for (material of materialOptions(); track material.key) {
|
||||
<button
|
||||
type="button"
|
||||
class="material-option"
|
||||
[class.active]="
|
||||
selectedMaterial()?.key === material.key
|
||||
"
|
||||
(click)="selectMaterial(material.key)"
|
||||
>
|
||||
<span class="material-copy">
|
||||
<strong>{{ material.label }}</strong>
|
||||
<small>
|
||||
{{
|
||||
"SHOP.MATERIAL_COLOR_COUNT"
|
||||
| translate
|
||||
: { count: materialColorCount(material) }
|
||||
}}
|
||||
</small>
|
||||
</span>
|
||||
<strong>{{
|
||||
materialPriceLabel(material) | currency: "CHF"
|
||||
}}</strong>
|
||||
</button>
|
||||
}
|
||||
<div class="material-section">
|
||||
<div class="selector-head">
|
||||
<p class="panel-kicker">
|
||||
{{ "SHOP.SELECT_MATERIAL" | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="material-grid">
|
||||
@for (material of materialOptions(); track material.key) {
|
||||
<button
|
||||
type="button"
|
||||
class="material-option"
|
||||
[class.active]="
|
||||
selectedMaterial()?.key === material.key
|
||||
"
|
||||
(click)="selectMaterial(material.key)"
|
||||
>
|
||||
<span class="material-copy">
|
||||
<strong>{{ material.label }}</strong>
|
||||
<small>
|
||||
{{
|
||||
"SHOP.MATERIAL_COLOR_COUNT"
|
||||
| translate
|
||||
: { count: materialColorCount(material) }
|
||||
}}
|
||||
</small>
|
||||
</span>
|
||||
<strong>{{
|
||||
materialPriceLabel(material) | currency: "CHF"
|
||||
}}</strong>
|
||||
</button>
|
||||
}
|
||||
</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 (
|
||||
@@ -196,90 +240,97 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="color-selector-block">
|
||||
<div class="selector-head">
|
||||
<p class="panel-kicker">
|
||||
{{ "SHOP.SELECT_COLOR" | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="selector-layout">
|
||||
<div class="selector-card color-selector-block">
|
||||
<div class="selector-head">
|
||||
<p class="panel-kicker">
|
||||
{{ "SHOP.SELECT_COLOR" | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (selectedVariant(); as activeVariant) {
|
||||
<button
|
||||
type="button"
|
||||
class="color-trigger"
|
||||
[class.open]="colorPopupOpen()"
|
||||
(click)="toggleColorPopup()"
|
||||
>
|
||||
<span class="color-trigger__ring">
|
||||
<span
|
||||
class="color-trigger__swatch"
|
||||
[style.background-color]="colorHex(activeVariant)"
|
||||
></span>
|
||||
</span>
|
||||
@if (selectedVariant(); as activeVariant) {
|
||||
<button
|
||||
type="button"
|
||||
class="color-trigger"
|
||||
[class.open]="colorPopupOpen()"
|
||||
(click)="toggleColorPopup()"
|
||||
>
|
||||
<span class="color-trigger__ring">
|
||||
<span
|
||||
class="color-trigger__swatch"
|
||||
[style.background-color]="colorHex(activeVariant)"
|
||||
></span>
|
||||
</span>
|
||||
|
||||
<span class="color-trigger__copy">
|
||||
<strong>{{ colorLabel(activeVariant) }}</strong>
|
||||
<small>{{ selectedMaterial()?.label }}</small>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
<span class="color-trigger__copy">
|
||||
<strong>{{
|
||||
colorLabel(activeVariant) | translate
|
||||
}}</strong>
|
||||
<small>{{ selectedMaterial()?.label }}</small>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (colorPopupOpen()) {
|
||||
<button
|
||||
type="button"
|
||||
class="color-popup-backdrop"
|
||||
(click)="closeColorPopup()"
|
||||
></button>
|
||||
<div class="color-popup">
|
||||
<div class="color-popup__category">
|
||||
{{ selectedMaterial()?.label || "" | uppercase }}
|
||||
</div>
|
||||
@if (colorPopupOpen()) {
|
||||
<button
|
||||
type="button"
|
||||
class="color-popup-backdrop"
|
||||
(click)="closeColorPopup()"
|
||||
></button>
|
||||
<div class="color-popup">
|
||||
<div class="color-popup__category">
|
||||
{{ selectedMaterial()?.label || "" | uppercase }}
|
||||
</div>
|
||||
|
||||
<div class="color-popup__grid">
|
||||
@for (variant of colorOptions(); track variant.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="color-popup__item"
|
||||
(click)="selectVariant(variant)"
|
||||
>
|
||||
<span
|
||||
class="color-popup__ring"
|
||||
[class.active]="
|
||||
selectedVariant()?.id === variant.id
|
||||
"
|
||||
<div class="color-popup__grid">
|
||||
@for (variant of colorOptions(); track variant.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="color-popup__item"
|
||||
(click)="selectVariant(variant)"
|
||||
>
|
||||
<span
|
||||
class="color-popup__swatch"
|
||||
[style.background-color]="colorHex(variant)"
|
||||
></span>
|
||||
</span>
|
||||
class="color-popup__ring"
|
||||
[class.active]="
|
||||
selectedVariant()?.id === variant.id
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="color-popup__swatch"
|
||||
[style.background-color]="colorHex(variant)"
|
||||
></span>
|
||||
</span>
|
||||
|
||||
<span class="color-popup__name">{{
|
||||
colorLabel(variant)
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
<span class="color-popup__name">{{
|
||||
colorLabel(variant) | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="quantity-row">
|
||||
<span>{{ "SHOP.QUANTITY" | translate }}</span>
|
||||
<div class="qty-control">
|
||||
<button type="button" (click)="decreaseQuantity()">
|
||||
-
|
||||
</button>
|
||||
<span>{{ quantity() }}</span>
|
||||
<button type="button" (click)="increaseQuantity()">
|
||||
+
|
||||
</button>
|
||||
<div class="selector-card quantity-card">
|
||||
<p class="panel-kicker">
|
||||
{{ "SHOP.QUANTITY" | translate }}
|
||||
</p>
|
||||
<div class="qty-control">
|
||||
<button type="button" (click)="decreaseQuantity()">
|
||||
-
|
||||
</button>
|
||||
<span>{{ quantity() }}</span>
|
||||
<button type="button" (click)="increaseQuantity()">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<app-button
|
||||
variant="primary"
|
||||
[fullWidth]="true"
|
||||
[disabled]="isAddingToCart()"
|
||||
(click)="addToCart()"
|
||||
>
|
||||
@@ -290,7 +341,11 @@
|
||||
</app-button>
|
||||
|
||||
@if (shopService.cartItemCount() > 0) {
|
||||
<app-button variant="outline" (click)="goToCheckout()">
|
||||
<app-button
|
||||
variant="outline"
|
||||
[fullWidth]="true"
|
||||
(click)="goToCheckout()"
|
||||
>
|
||||
{{ "SHOP.GO_TO_CHECKOUT" | translate }}
|
||||
</app-button>
|
||||
}
|
||||
|
||||
@@ -18,15 +18,51 @@
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.9rem;
|
||||
align-items: center;
|
||||
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 {
|
||||
@@ -53,9 +89,8 @@
|
||||
|
||||
.hero-media {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
min-height: 420px;
|
||||
max-height: 620px;
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid rgba(16, 24, 32, 0.12);
|
||||
@@ -67,14 +102,14 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
background: #f2eee5;
|
||||
}
|
||||
|
||||
.image-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 420px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: var(--space-6);
|
||||
@@ -111,8 +146,8 @@
|
||||
}
|
||||
|
||||
.thumb {
|
||||
flex: 0 0 92px;
|
||||
height: 92px;
|
||||
flex: 0 0 96px;
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: 0.85rem;
|
||||
border: 1px solid rgba(16, 24, 32, 0.12);
|
||||
@@ -226,15 +261,34 @@ h1 {
|
||||
|
||||
.purchase-card {
|
||||
display: grid;
|
||||
gap: 0.78rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.price-row,
|
||||
.quantity-row {
|
||||
.offer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
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;
|
||||
gap: 0.38rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.cart-pill {
|
||||
@@ -249,6 +303,11 @@ h1 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.material-section {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.material-grid {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
@@ -301,6 +360,31 @@ h1 {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -340,11 +424,30 @@ h1 {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.2rem;
|
||||
min-height: 3.2rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
@@ -366,10 +469,20 @@ h1 {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.quantity-card {
|
||||
justify-items: start;
|
||||
align-content: start;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.quantity-card .qty-control {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.success-note {
|
||||
@@ -459,6 +572,10 @@ h1 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.selector-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.property-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -469,15 +586,14 @@ h1 {
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.price-row,
|
||||
.quantity-row {
|
||||
.offer-header,
|
||||
.material-summary {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.hero-media,
|
||||
.image-fallback {
|
||||
min-height: 300px;
|
||||
.hero-media--portrait .hero-image {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumb-strip {
|
||||
@@ -485,8 +601,7 @@ h1 {
|
||||
}
|
||||
|
||||
.thumb {
|
||||
flex-basis: 78px;
|
||||
height: 78px;
|
||||
flex-basis: 84px;
|
||||
}
|
||||
|
||||
.model-launch-row {
|
||||
@@ -514,6 +629,10 @@ h1 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.selector-card {
|
||||
padding: 0.74rem 0.78rem;
|
||||
}
|
||||
|
||||
:host ::ng-deep app-card.purchase-shell .card-body {
|
||||
padding: 0.82rem 0.82rem;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { catchError, combineLatest, finalize, of, switchMap, tap } from 'rxjs';
|
||||
import { SeoService } from '../../core/services/seo.service';
|
||||
import { LanguageService } from '../../core/services/language.service';
|
||||
import { getColorHex } from '../../core/constants/colors.const';
|
||||
import {
|
||||
findColorHex,
|
||||
getColorHex,
|
||||
} from '../../core/constants/colors.const';
|
||||
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
||||
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
|
||||
@@ -230,7 +233,7 @@ export class ProductDetailComponent {
|
||||
catchError((error) => {
|
||||
this.product.set(null);
|
||||
this.selectedVariantId.set(null);
|
||||
this.selectedImageAssetId.set(null);
|
||||
this.setSelectedImageAssetId(null);
|
||||
this.modelFile.set(null);
|
||||
this.error.set(
|
||||
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
|
||||
@@ -257,7 +260,7 @@ export class ProductDetailComponent {
|
||||
product.defaultVariant ?? product.variants[0] ?? null,
|
||||
),
|
||||
);
|
||||
this.selectedImageAssetId.set(
|
||||
this.setSelectedImageAssetId(
|
||||
product.primaryImage?.mediaAssetId ??
|
||||
product.images[0]?.mediaAssetId ??
|
||||
null,
|
||||
@@ -283,7 +286,7 @@ export class ProductDetailComponent {
|
||||
}
|
||||
|
||||
selectImage(mediaAssetId: string): void {
|
||||
this.selectedImageAssetId.set(mediaAssetId);
|
||||
this.setSelectedImageAssetId(mediaAssetId);
|
||||
}
|
||||
|
||||
showPreviousImage(): void {
|
||||
@@ -293,7 +296,7 @@ export class ProductDetailComponent {
|
||||
}
|
||||
const nextIndex =
|
||||
(this.selectedImageIndex() - 1 + images.length) % images.length;
|
||||
this.selectedImageAssetId.set(images[nextIndex].mediaAssetId);
|
||||
this.setSelectedImageAssetId(images[nextIndex].mediaAssetId);
|
||||
}
|
||||
|
||||
showNextImage(): void {
|
||||
@@ -302,7 +305,7 @@ export class ProductDetailComponent {
|
||||
return;
|
||||
}
|
||||
const nextIndex = (this.selectedImageIndex() + 1) % images.length;
|
||||
this.selectedImageAssetId.set(images[nextIndex].mediaAssetId);
|
||||
this.setSelectedImageAssetId(images[nextIndex].mediaAssetId);
|
||||
}
|
||||
|
||||
selectVariant(variant: ShopProductVariantOption): void {
|
||||
@@ -378,7 +381,7 @@ export class ProductDetailComponent {
|
||||
}
|
||||
|
||||
colorLabel(variant: ShopProductVariantOption): string {
|
||||
return variant.colorName || variant.variantLabel || '-';
|
||||
return variant.colorLabel || variant.colorName || variant.variantLabel || '-';
|
||||
}
|
||||
|
||||
colorHex(variant: ShopProductVariantOption | null | undefined): string {
|
||||
@@ -479,6 +482,10 @@ export class ProductDetailComponent {
|
||||
});
|
||||
}
|
||||
|
||||
private setSelectedImageAssetId(mediaAssetId: string | null): void {
|
||||
this.selectedImageAssetId.set(mediaAssetId);
|
||||
}
|
||||
|
||||
private normalizeHexColor(value: string | null | undefined): string | null {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) {
|
||||
@@ -494,17 +501,7 @@ export class ProductDetailComponent {
|
||||
}
|
||||
|
||||
private colorHexFromName(value: string | null | undefined): string | null {
|
||||
const colorName = String(value ?? '').trim();
|
||||
if (!colorName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallback = getColorHex(colorName);
|
||||
if (!fallback || fallback === '#facf0a') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
return findColorHex(value);
|
||||
}
|
||||
|
||||
private applySeo(product: ShopProductDetail): void {
|
||||
|
||||
@@ -55,6 +55,7 @@ export interface ShopProductVariantOption {
|
||||
sku: string | null;
|
||||
variantLabel: string | null;
|
||||
colorName: string | null;
|
||||
colorLabel: string | null;
|
||||
colorHex: string | null;
|
||||
priceChf: number;
|
||||
isDefault: boolean;
|
||||
@@ -138,6 +139,10 @@ export interface ShopCartItem {
|
||||
shopProductName: string | null;
|
||||
shopVariantLabel: string | null;
|
||||
shopVariantColorName: string | null;
|
||||
shopVariantColorLabelIt?: string | null;
|
||||
shopVariantColorLabelEn?: string | null;
|
||||
shopVariantColorLabelDe?: string | null;
|
||||
shopVariantColorLabelFr?: string | null;
|
||||
shopVariantColorHex: string | null;
|
||||
materialCode: string | null;
|
||||
quality: string | null;
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
<div class="cart-line-copy">
|
||||
<strong>{{ cartItemName(item) }}</strong>
|
||||
@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) {
|
||||
<span class="cart-line-color">
|
||||
@@ -92,7 +92,7 @@
|
||||
class="color-dot"
|
||||
[style.background-color]="cartItemColorHex(item)"
|
||||
></span>
|
||||
<span>{{ color }}</span>
|
||||
<span>{{ color | translate }}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
} from 'rxjs';
|
||||
import { SeoService } from '../../core/services/seo.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 { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||
import { ProductCardComponent } from './components/product-card/product-card.component';
|
||||
@@ -157,15 +161,30 @@ export class ShopPageComponent {
|
||||
}
|
||||
|
||||
cartItemVariant(item: ShopCartItem): string | null {
|
||||
return item.shopVariantLabel || item.shopVariantColorName || null;
|
||||
return item.shopVariantLabel || this.cartItemColor(item);
|
||||
}
|
||||
|
||||
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 {
|
||||
return item.shopVariantColorHex || '#c9ced6';
|
||||
return (
|
||||
item.shopVariantColorHex ||
|
||||
findColorHex(item.shopVariantColorName) ||
|
||||
findColorHex(item.colorCode) ||
|
||||
'#c9ced6'
|
||||
);
|
||||
}
|
||||
|
||||
navigateToCategory(slug?: string | null): void {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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 { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
getColorHex,
|
||||
ColorCategory,
|
||||
ColorOption,
|
||||
resolveLocalizedColorLabel,
|
||||
} from '../../../core/constants/colors.const';
|
||||
import { VariantOption } from '../../../features/calculator/services/quote-estimator.service';
|
||||
import { LanguageService } from '../../../core/services/language.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-color-selector',
|
||||
@@ -17,6 +19,7 @@ import { VariantOption } from '../../../features/calculator/services/quote-estim
|
||||
styleUrl: './color-selector.component.scss',
|
||||
})
|
||||
export class ColorSelectorComponent {
|
||||
private readonly languageService = inject(LanguageService);
|
||||
selectedColor = input<string>('Black');
|
||||
selectedVariantId = input<number | null>(null);
|
||||
variants = input<VariantOption[]>([]);
|
||||
@@ -32,7 +35,14 @@ export class ColorSelectorComponent {
|
||||
const finish = v.finishType || 'AVAILABLE_COLORS';
|
||||
const bucket = byFinish.get(finish) || [];
|
||||
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,
|
||||
hex: v.hexColor,
|
||||
variantId: v.id,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"CALCULATOR": "Rechner",
|
||||
"SHOP": "Shop",
|
||||
"ABOUT": "Über uns",
|
||||
"MATERIALS": "Qualität & Materialien",
|
||||
"CONTACT": "Kontakt",
|
||||
"LANGUAGE_SELECTOR": "Sprachauswahl"
|
||||
},
|
||||
@@ -119,6 +120,7 @@
|
||||
"MODEL_CLOSE": "3D-Ansicht schließen",
|
||||
"PREVIOUS_IMAGE": "Vorheriges Bild",
|
||||
"NEXT_IMAGE": "Nächstes Bild",
|
||||
"PRICE_LABEL": "Preis",
|
||||
"SELECT_MATERIAL": "Material",
|
||||
"SELECT_COLOR": "Farbe",
|
||||
"MATERIAL_COLOR_COUNT": "{{count}} Farben verfügbar",
|
||||
@@ -499,6 +501,13 @@
|
||||
"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_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_SHOP": "Zum Shop",
|
||||
"BTN_CONTACT": "Mit uns sprechen",
|
||||
@@ -560,6 +569,13 @@
|
||||
"BLUE": "Blau",
|
||||
"GREEN": "Grün",
|
||||
"YELLOW": "Gelb",
|
||||
"ORANGE": "Orange",
|
||||
"GRAY": "Grau",
|
||||
"LIGHT_GRAY": "Hellgrau",
|
||||
"DARK_GRAY": "Dunkelgrau",
|
||||
"PURPLE": "Lila",
|
||||
"BEIGE": "Beige",
|
||||
"SAND_BEIGE": "Sandbeige",
|
||||
"MATTE_BLACK": "Matt Schwarz",
|
||||
"MATTE_WHITE": "Matt Weiß",
|
||||
"MATTE_GRAY": "Matt Grau"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"CALCULATOR": "Calculator",
|
||||
"SHOP": "Shop",
|
||||
"ABOUT": "About Us",
|
||||
"MATERIALS": "Quality & Materials",
|
||||
"CONTACT": "Contact Us",
|
||||
"LANGUAGE_SELECTOR": "Language selector"
|
||||
},
|
||||
@@ -119,6 +120,7 @@
|
||||
"MODEL_CLOSE": "Close 3D view",
|
||||
"PREVIOUS_IMAGE": "Previous image",
|
||||
"NEXT_IMAGE": "Next image",
|
||||
"PRICE_LABEL": "Price",
|
||||
"SELECT_MATERIAL": "Material",
|
||||
"SELECT_COLOR": "Color",
|
||||
"MATERIAL_COLOR_COUNT": "{{count}} colors available",
|
||||
@@ -499,6 +501,13 @@
|
||||
"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_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_SHOP": "Go to shop",
|
||||
"BTN_CONTACT": "Talk to us",
|
||||
@@ -560,6 +569,13 @@
|
||||
"BLUE": "Blue",
|
||||
"GREEN": "Green",
|
||||
"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_WHITE": "Matte White",
|
||||
"MATTE_GRAY": "Matte Gray"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"CALCULATOR": "Calculateur",
|
||||
"SHOP": "Boutique",
|
||||
"ABOUT": "Qui sommes-nous",
|
||||
"MATERIALS": "Qualité & matériaux",
|
||||
"CONTACT": "Contactez-nous",
|
||||
"LANGUAGE_SELECTOR": "Sélecteur de langue"
|
||||
},
|
||||
@@ -17,6 +18,13 @@
|
||||
"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_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_SHOP": "Aller à la boutique",
|
||||
"BTN_CONTACT": "Parlez avec nous",
|
||||
@@ -176,6 +184,7 @@
|
||||
"MODEL_CLOSE": "Fermer la vue 3D",
|
||||
"PREVIOUS_IMAGE": "Image précédente",
|
||||
"NEXT_IMAGE": "Image suivante",
|
||||
"PRICE_LABEL": "Prix",
|
||||
"SELECT_MATERIAL": "Matériau",
|
||||
"SELECT_COLOR": "Couleur",
|
||||
"MATERIAL_COLOR_COUNT": "{{count}} couleurs disponibles",
|
||||
@@ -566,6 +575,13 @@
|
||||
"BLUE": "Bleu",
|
||||
"GREEN": "Vert",
|
||||
"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_WHITE": "Blanc mat",
|
||||
"MATTE_GRAY": "Gris mat"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"CALCULATOR": "Calcolatore",
|
||||
"SHOP": "Shop",
|
||||
"ABOUT": "Chi Siamo",
|
||||
"MATERIALS": "Qualita e Materiali",
|
||||
"CONTACT": "Contattaci",
|
||||
"LANGUAGE_SELECTOR": "Selettore lingua"
|
||||
},
|
||||
@@ -17,6 +18,13 @@
|
||||
"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_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_SHOP": "Vai allo shop",
|
||||
"BTN_CONTACT": "Parla con noi",
|
||||
@@ -193,6 +201,7 @@
|
||||
"HIGHLIGHT_CART": "Nel carrello",
|
||||
"HIGHLIGHT_READY": "Preview",
|
||||
"PRICE_FROM": "Prezzo da",
|
||||
"PRICE_LABEL": "Prezzo",
|
||||
"EXCERPT_FALLBACK": "Scheda prodotto in preparazione.",
|
||||
"MODEL_3D": "3D preview",
|
||||
"MODEL_TITLE": "Anteprima del modello",
|
||||
@@ -615,6 +624,13 @@
|
||||
"BLUE": "Blu",
|
||||
"GREEN": "Verde",
|
||||
"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_WHITE": "Bianco 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 |
@@ -19,6 +19,7 @@
|
||||
href="https://fonts.googleapis.com/icon?family=Material+Icons"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<meta name="msvalidate.01" content="5AF60C1471E1800B39DF4DBC3C709035" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
|
||||
@@ -107,10 +107,6 @@ app-product-detail {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
padding: 0;
|
||||
border-radius: 1rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.selector-head {
|
||||
@@ -119,7 +115,8 @@ app-product-detail {
|
||||
|
||||
.color-trigger {
|
||||
width: 100%;
|
||||
max-width: 230px;
|
||||
max-width: none;
|
||||
min-height: 3.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022"
|
||||
|
||||
Reference in New Issue
Block a user