30 Commits

Author SHA1 Message Date
a3cd451575 Merge pull request 'dev' (#47) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Successful in 31s
Build and Deploy / deploy (push) Successful in 20s
Reviewed-on: #47
2026-03-14 16:13:37 +01:00
printcalc-ci
6f700c923a style: apply prettier formatting
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 9s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 58s
2026-03-14 14:15:10 +00:00
46fd59ed71 Merge branch 'main' into dev
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 12s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / test-backend (push) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m6s
Build and Deploy / build-and-push (push) Successful in 29s
Build and Deploy / deploy (push) Successful in 22s
2026-03-14 15:14:12 +01:00
ba49463ee7 fix(front-end): seo improvements with SSR
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / test-frontend (push) Has been cancelled
Build and Deploy / test-backend (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Failing after 13s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m6s
2026-03-14 15:13:54 +01:00
576380e9a0 fix(front-end): seo translated 2026-03-14 15:02:00 +01:00
cac534ccbb Merge pull request 'dev' (#46) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 18s
Reviewed-on: #46
2026-03-13 17:44:20 +01:00
2e68105da4 Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 31s
PR Checks / prettier-autofix (pull_request) Successful in 10s
Build and Deploy / test-frontend (push) Successful in 1m6s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / build-and-push (push) Successful in 30s
PR Checks / test-frontend (pull_request) Successful in 1m5s
Build and Deploy / deploy (push) Successful in 24s
2026-03-13 17:41:55 +01:00
ed7ed6636d fix(front-end): al categories translated 2026-03-13 17:41:25 +01:00
e190359041 Merge pull request 'dev' (#45) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 28s
Build and Deploy / deploy (push) Successful in 20s
Reviewed-on: #45
2026-03-13 16:36:42 +01:00
printcalc-ci
bed94790d4 style: apply prettier formatting
All checks were successful
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 59s
PR Checks / prettier-autofix (pull_request) Successful in 8s
2026-03-13 15:30:28 +00:00
d8ad61ec54 Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 30s
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / test-frontend (pull_request) Successful in 1m1s
Build and Deploy / test-frontend (push) Successful in 1m4s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / build-and-push (push) Successful in 17s
Build and Deploy / deploy (push) Successful in 21s
2026-03-13 16:28:40 +01:00
7e9a1482d6 fix(front-end): maximum budget
Some checks failed
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / build-and-push (push) Successful in 30s
Build and Deploy / deploy (push) Successful in 21s
PR Checks / prettier-autofix (pull_request) Failing after 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / test-frontend (push) Successful in 1m1s
2026-03-13 16:24:39 +01:00
aa0adbf993 feat(back-end front-end): shop improvements
Some checks failed
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 59s
Build and Deploy / build-and-push (push) Failing after 41s
Build and Deploy / deploy (push) Has been skipped
2026-03-13 16:21:57 +01:00
00af9a9701 feat(back-end front-end): shop improvements
Some checks failed
Build and Deploy / test-backend (push) Successful in 38s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Failing after 1m15s
Build and Deploy / deploy (push) Has been skipped
2026-03-13 16:16:49 +01:00
fcdede2dd6 chore(front-end):map color
Some checks failed
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Failing after 26s
Build and Deploy / deploy (push) Has been skipped
2026-03-12 16:43:00 +01:00
5d17b23c3a chore(front-end): new seo, and improvements in shop component
Some checks failed
Build and Deploy / test-backend (push) Successful in 29s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Failing after 1m16s
Build and Deploy / deploy (push) Has been skipped
2026-03-12 16:26:36 +01:00
1ec8a43a50 Merge pull request 'dev' (#43) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 30s
Build and Deploy / test-frontend (push) Successful in 1m8s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 20s
Reviewed-on: #43
2026-03-12 12:20:34 +01:00
96cfa91c67 Merge branch 'main' into dev
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / test-backend (push) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / test-frontend (push) Successful in 1m6s
Build and Deploy / build-and-push (push) Failing after 1m25s
Build and Deploy / deploy (push) Has been skipped
2026-03-12 12:17:42 +01:00
669ace82c0 Merge remote-tracking branch 'origin/dev' into dev
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / test-frontend (push) Has been cancelled
Build and Deploy / test-backend (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Successful in 13s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m7s
2026-03-12 12:17:28 +01:00
93163ae6e8 fix(front-end): sitemap static 2026-03-12 12:17:13 +01:00
af2d506da1 Merge pull request 'dev' (#42) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 59s
Build and Deploy / build-and-push (push) Successful in 15s
Build and Deploy / deploy (push) Successful in 8s
Reviewed-on: #42
2026-03-11 17:35:12 +01:00
637541994a Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
PR Checks / prettier-autofix (pull_request) Successful in 8s
Build and Deploy / test-frontend (push) Successful in 1m1s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 26s
Build and Deploy / build-and-push (push) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / deploy (push) Successful in 22s
2026-03-11 17:32:53 +01:00
printcalc-ci
63cd4c4f5e style: apply prettier formatting 2026-03-11 16:31:15 +00:00
fd4104da39 fix(front-end): ssr i18n fix
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / build-and-push (push) Successful in 29s
Build and Deploy / deploy (push) Successful in 21s
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 59s
2026-03-11 17:27:28 +01:00
5bb23fbcfa fix(front-end): ssr i18n fix
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 26s
Build and Deploy / deploy (push) Successful in 19s
2026-03-11 17:23:32 +01:00
6a22c54e9f feat(front-end): ssr i18n fix
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 30s
Build and Deploy / deploy (push) Successful in 19s
2026-03-11 17:19:26 +01:00
3ac3262e77 Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 27s
Build and Deploy / deploy (push) Successful in 20s
# Conflicts:
#	frontend/src/app/app.config.ts
2026-03-11 17:10:06 +01:00
18ecc07240 feat(front-end): ssr i18n fix 2026-03-11 17:09:51 +01:00
cb468492b3 Merge pull request 'feat(front-end): ssr implementation' (#41) from feat/ssr into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / build-and-push (push) Successful in 52s
Build and Deploy / deploy (push) Successful in 10s
Build and Deploy / test-frontend (push) Successful in 1m0s
Reviewed-on: #41
2026-03-11 16:59:21 +01:00
feee2b0bff Merge pull request 'dev' (#40) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 15s
Build and Deploy / deploy (push) Successful in 7s
Reviewed-on: #40
2026-03-11 15:32:14 +01:00
93 changed files with 4442 additions and 646 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ public record ShopProductVariantOptionDto(
String sku,
String variantLabel,
String colorName,
String colorLabel,
String colorHex,
BigDecimal priceChf,
Boolean isDefault

View File

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

View File

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

View File

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

View File

@@ -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,19 +236,21 @@ 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)) {
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);
savedItems.add(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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,9 +15,8 @@ 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.',
seoTitleKey: 'SEO.ROUTES.HOME.TITLE',
seoDescriptionKey: 'SEO.ROUTES.HOME.DESCRIPTION',
},
},
{
@@ -27,9 +26,8 @@ const appChildRoutes: Routes = [
(m) => m.CALCULATOR_ROUTES,
),
data: {
seoTitle: 'Calcolatore preventivo stampa 3D | 3D fab',
seoDescription:
'Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.',
seoTitleKey: 'SEO.ROUTES.CALCULATOR.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.DESCRIPTION',
},
},
{
@@ -37,9 +35,8 @@ const appChildRoutes: Routes = [
loadChildren: () =>
import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES),
data: {
seoTitle: 'Shop 3D fab',
seoDescription:
'Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.',
seoTitleKey: 'SEO.ROUTES.SHOP.TITLE',
seoDescriptionKey: 'SEO.ROUTES.SHOP.DESCRIPTION',
},
},
{
@@ -47,19 +44,28 @@ const appChildRoutes: Routes = [
loadChildren: () =>
import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES),
data: {
seoTitle: 'Chi siamo | 3D fab',
seoDescription:
'Scopri il team 3D fab e il laboratorio di stampa 3D con sedi in Ticino e Bienne.',
seoTitleKey: 'SEO.ROUTES.ABOUT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ABOUT.DESCRIPTION',
},
},
/* {
path: 'materials',
loadComponent: () =>
import('./features/materials/materials-page.component').then(
(m) => m.MaterialsPageComponent,
),
data: {
seoTitleKey: 'SEO.ROUTES.MATERIALS.TITLE',
seoDescriptionKey: 'SEO.ROUTES.MATERIALS.DESCRIPTION',
},
},*/
{
path: 'contact',
loadChildren: () =>
import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES),
data: {
seoTitle: 'Contatti | 3D fab',
seoDescription:
'Contatta 3D fab per preventivi, supporto tecnico e richieste personalizzate di stampa 3D.',
seoTitleKey: 'SEO.ROUTES.CONTACT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CONTACT.DESCRIPTION',
},
},
{
@@ -69,7 +75,8 @@ const appChildRoutes: Routes = [
(m) => m.CheckoutComponent,
),
data: {
seoTitle: 'Checkout | 3D fab',
seoTitleKey: 'SEO.ROUTES.CHECKOUT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CHECKOUT.DESCRIPTION',
seoRobots: 'noindex, nofollow',
},
},
@@ -80,7 +87,8 @@ const appChildRoutes: Routes = [
(m) => m.CheckoutComponent,
),
data: {
seoTitle: 'Checkout | 3D fab',
seoTitleKey: 'SEO.ROUTES.CHECKOUT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.CHECKOUT.DESCRIPTION',
seoRobots: 'noindex, nofollow',
},
},
@@ -89,7 +97,8 @@ const appChildRoutes: Routes = [
loadComponent: () =>
import('./features/order/order.component').then((m) => m.OrderComponent),
data: {
seoTitle: 'Ordine | 3D fab',
seoTitleKey: 'SEO.ROUTES.ORDER.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ORDER.DESCRIPTION',
seoRobots: 'noindex, nofollow',
},
},
@@ -98,7 +107,8 @@ const appChildRoutes: Routes = [
loadComponent: () =>
import('./features/order/order.component').then((m) => m.OrderComponent),
data: {
seoTitle: 'Ordine | 3D fab',
seoTitleKey: 'SEO.ROUTES.ORDER.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ORDER.DESCRIPTION',
seoRobots: 'noindex, nofollow',
},
},
@@ -112,7 +122,8 @@ const appChildRoutes: Routes = [
loadChildren: () =>
import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES),
data: {
seoTitle: 'Admin | 3D fab',
seoTitleKey: 'SEO.ROUTES.ADMIN.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ADMIN.DESCRIPTION',
seoRobots: 'noindex, nofollow',
},
},

View File

@@ -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 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 {
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
return findColorHex(value) ?? DEFAULT_BRAND_COLOR;
}

View 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']);
}
}

View File

@@ -0,0 +1,30 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject, REQUEST } from '@angular/core';
import {
RequestLike,
resolveRequestOrigin,
} from '../../../core/request-origin';
function isAbsoluteUrl(url: string): boolean {
return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//');
}
function normalizeRelativePath(url: string): string {
const withoutDot = url.replace(/^\.\//, '');
return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`;
}
export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => {
if (isAbsoluteUrl(req.url)) {
return next(req);
}
const request = inject(REQUEST, { optional: true }) as RequestLike | null;
const origin = resolveRequestOrigin(request);
if (!origin) {
return next(req);
}
const absoluteUrl = `${origin}${normalizeRelativePath(req.url)}`;
return next(req.clone({ url: absoluteUrl }));
};

View File

@@ -6,9 +6,15 @@
</div>
<div class="col links">
<a routerLink="/privacy">{{ "FOOTER.PRIVACY" | translate }}</a>
<a routerLink="/terms">{{ "FOOTER.TERMS" | translate }}</a>
<a routerLink="/contact">{{ "FOOTER.CONTACT" | translate }}</a>
<a [routerLink]="languageService.localizedPath('/privacy')">{{
"FOOTER.PRIVACY" | translate
}}</a>
<a [routerLink]="languageService.localizedPath('/terms')">{{
"FOOTER.TERMS" | translate
}}</a>
<a [routerLink]="languageService.localizedPath('/contact')">{{
"FOOTER.CONTACT" | translate
}}</a>
</div>
<div class="col social">

View File

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

View File

@@ -1,6 +1,8 @@
<header class="navbar">
<div class="container navbar-inner">
<a routerLink="/" class="brand">3D <span class="highlight">fab</span></a>
<a [routerLink]="langService.localizedPath('/')" class="brand"
>3D <span class="highlight">fab</span></a
>
<div
class="mobile-toggle"
@@ -14,27 +16,33 @@
<nav class="nav-links" [class.open]="isMenuOpen">
<a
routerLink="/"
[routerLink]="langService.localizedPath('/')"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: true }"
(click)="closeMenu()"
>{{ "NAV.HOME" | translate }}</a
>
<a
routerLink="/calculator/basic"
[routerLink]="langService.localizedPath('/calculator/basic')"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: false }"
(click)="closeMenu()"
>{{ "NAV.CALCULATOR" | translate }}</a
>
<a routerLink="/shop" routerLinkActive="active" (click)="closeMenu()">{{
"NAV.SHOP" | translate
}}</a>
<a routerLink="/about" routerLinkActive="active" (click)="closeMenu()">{{
"NAV.ABOUT" | translate
}}</a>
<a
routerLink="/contact"
[routerLink]="langService.localizedPath('/shop')"
routerLinkActive="active"
(click)="closeMenu()"
>{{ "NAV.SHOP" | translate }}</a
>
<a
[routerLink]="langService.localizedPath('/about')"
routerLinkActive="active"
(click)="closeMenu()"
>{{ "NAV.ABOUT" | translate }}</a
>
<a
[routerLink]="langService.localizedPath('/contact')"
routerLinkActive="active"
(click)="closeMenu()"
>{{ "NAV.CONTACT" | translate }}</a
@@ -82,7 +90,10 @@
}
</select>
<div class="icon-placeholder" routerLink="/admin">
<div
class="icon-placeholder"
[routerLink]="langService.localizedPath('/admin')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
@@ -130,7 +141,9 @@
<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 +151,7 @@
class="color-dot"
[style.background-color]="cartItemColorHex(item)"
></span>
<span>{{ color }}</span>
<span>{{ color | translate }}</span>
</span>
}
</div>

View File

@@ -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',
@@ -129,7 +133,7 @@ export class NavbarComponent {
}
this.closeCart();
this.router.navigate(['/checkout'], {
this.router.navigate(['/', this.langService.selectedLang(), 'checkout'], {
queryParams: {
session: sessionId,
},
@@ -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 {

View File

@@ -103,4 +103,15 @@ describe('LanguageService', () => {
'/de/calculator?session=abc&mode=advanced',
);
});
it('builds localized paths for internal links while preserving query and hash', () => {
const translate = createTranslateMock();
const router = createRouterMock('/de/shop');
const service = new LanguageService(translate, router);
expect(service.localizedPath('/privacy')).toBe('/de/privacy');
expect(service.localizedPath('/it/contact?topic=seo#form')).toBe(
'/de/contact?topic=seo#form',
);
});
});

View File

@@ -85,6 +85,31 @@ export class LanguageService {
return this.isSupportedLang(activeLang) ? activeLang : this.currentLang();
}
localizedPath(path: string): string {
const lang = this.selectedLang();
const rawValue = String(path ?? '').trim();
const normalized = rawValue || '/';
const match = normalized.match(/^([^?#]*)([?#].*)?$/);
const rawPath = match?.[1] || '/';
const suffix = match?.[2] || '';
const segments = rawPath.split('/').filter(Boolean);
if (segments.length === 0) {
return `/${lang}${suffix}`;
}
if (this.isSupportedLang(segments[0])) {
segments[0] = lang;
return `/${segments.join('/')}${suffix}`;
}
if (this.looksLikeLangToken(segments[0])) {
return `/${[lang, ...segments.slice(1)].join('/')}${suffix}`;
}
return `/${[lang, ...segments].join('/')}${suffix}`;
}
private ensureLanguageInPath(urlTree: UrlTree): void {
const segments = this.getPrimarySegments(urlTree);

View File

@@ -0,0 +1,135 @@
import { ActivatedRouteSnapshot, Router } from '@angular/router';
import { Meta, Title } from '@angular/platform-browser';
import { Subject } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { SeoService } from './seo.service';
describe('SeoService', () => {
function createSnapshot(
data: Record<string, unknown>,
firstChild: ActivatedRouteSnapshot | null = null,
): ActivatedRouteSnapshot {
return {
data,
firstChild,
} as unknown as ActivatedRouteSnapshot;
}
function cleanupSeoDom(): void {
document.head
.querySelectorAll(
'link[rel="canonical"], link[rel="alternate"][data-seo-managed="true"], meta[property="og:locale:alternate"][data-seo-managed="true"]',
)
.forEach((node) => node.remove());
document.documentElement.removeAttribute('lang');
}
function createService(options: {
url: string;
data: Record<string, unknown>;
translations: Record<string, string>;
}): {
meta: jasmine.SpyObj<Meta>;
title: jasmine.SpyObj<Title>;
} {
const events$ = new Subject<unknown>();
const title = jasmine.createSpyObj<Title>('Title', ['setTitle']);
const meta = jasmine.createSpyObj<Meta>('Meta', ['updateTag']);
const translate = {
instant: (key: string) => options.translations[key] ?? key,
} as TranslateService;
const router = {
url: options.url,
events: events$.asObservable(),
routerState: {
snapshot: {
root: createSnapshot(options.data),
},
},
} as unknown as Router;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const service = new SeoService(router, title, meta, translate, document);
return { meta, title };
}
beforeEach(() => {
cleanupSeoDom();
});
afterEach(() => {
cleanupSeoDom();
});
it('adds the language prefix to canonical and hreflang URLs', () => {
const { meta, title } = createService({
url: '/privacy?utm=test',
data: {
seoTitleKey: 'SEO.ROUTES.LEGAL.PRIVACY.TITLE',
seoDescriptionKey: 'SEO.ROUTES.LEGAL.PRIVACY.DESCRIPTION',
},
translations: {
'SEO.ROUTES.LEGAL.PRIVACY.TITLE': 'Privacy Policy | 3D fab',
'SEO.ROUTES.LEGAL.PRIVACY.DESCRIPTION': 'Privacy description',
},
});
expect(title.setTitle).toHaveBeenCalledWith('Privacy Policy | 3D fab');
const canonical = document.head.querySelector(
'link[rel="canonical"]',
) as HTMLLinkElement | null;
expect(canonical?.getAttribute('href')).toBe(
`${document.location.origin}/it/privacy`,
);
const alternates = Array.from(
document.head.querySelectorAll(
'link[rel="alternate"][data-seo-managed="true"]',
),
).map((node) => ({
hreflang: node.getAttribute('hreflang'),
href: node.getAttribute('href'),
}));
expect(alternates).toContain({
hreflang: 'en',
href: `${document.location.origin}/en/privacy`,
});
expect(alternates).toContain({
hreflang: 'x-default',
href: `${document.location.origin}/it/privacy`,
});
expect(document.documentElement.lang).toBe('it');
const ogUrlCall = meta.updateTag.calls
.allArgs()
.find(([tag]) => tag.property === 'og:url');
expect(ogUrlCall?.[0].content).toBe(
`${document.location.origin}/it/privacy`,
);
});
it('resolves translated route metadata for the active language', () => {
const { meta, title } = createService({
url: '/en/about',
data: {
seoTitleKey: 'SEO.ROUTES.ABOUT.TITLE',
seoDescriptionKey: 'SEO.ROUTES.ABOUT.DESCRIPTION',
},
translations: {
'SEO.ROUTES.ABOUT.TITLE': 'About Us | 3D fab',
'SEO.ROUTES.ABOUT.DESCRIPTION': 'About description',
},
});
expect(title.setTitle).toHaveBeenCalledWith('About Us | 3D fab');
const descriptionCall = meta.updateTag.calls
.allArgs()
.find(([tag]) => tag.name === 'description');
expect(descriptionCall?.[0].content).toBe('About description');
expect(document.documentElement.lang).toBe('en');
});
});

View File

@@ -2,29 +2,66 @@ import { DOCUMENT } from '@angular/common';
import { Inject, Injectable } from '@angular/core';
import { Title, Meta } from '@angular/platform-browser';
import { ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { filter } from 'rxjs/operators';
export interface PageSeoOverride {
title?: string | null;
titleKey?: string | null;
description?: string | null;
descriptionKey?: string | null;
robots?: string | null;
ogTitle?: string | null;
ogTitleKey?: string | null;
ogDescription?: string | null;
ogDescriptionKey?: string | null;
}
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
type SeoMap = Partial<Record<SupportedLang, string>>;
type SeoTextDataKey =
| 'seoTitle'
| 'seoDescription'
| 'ogTitle'
| 'ogDescription';
@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: readonly SupportedLang[] = [
'it',
'en',
'de',
'fr',
];
private readonly supportedLangSet = new Set<SupportedLang>(
this.supportedLangs,
);
private readonly ogLocaleByLang: Record<SupportedLang, string> = {
it: 'it_IT',
en: 'en_US',
de: 'de_DE',
fr: 'fr_FR',
};
constructor(
private router: Router,
private titleService: Title,
private metaService: Meta,
private translate: TranslateService,
@Inject(DOCUMENT) private document: Document,
) {
this.applyRouteSeo(this.router.routerState.snapshot.root);
@@ -40,27 +77,61 @@ 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.resolveOverrideSeoText(override.title, override.titleKey) ??
this.defaultTitle(lang);
const description =
this.asString(override.description) ?? this.defaultDescription;
this.resolveOverrideSeoText(
override.description,
override.descriptionKey,
) ?? this.defaultDescription(lang);
const robots = this.asString(override.robots) ?? 'index, follow';
const ogTitle = this.asString(override.ogTitle) ?? title;
const ogDescription = this.asString(override.ogDescription) ?? description;
const ogTitle =
this.resolveOverrideSeoText(override.ogTitle, override.ogTitleKey) ??
title;
const ogDescription =
this.resolveOverrideSeoText(
override.ogDescription,
override.ogDescriptionKey,
) ?? description;
this.applySeoValues(title, description, robots, ogTitle, ogDescription);
this.applySeoValues(
title,
description,
robots,
ogTitle,
ogDescription,
cleanPath,
lang,
);
}
private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void {
const mergedData = this.getMergedRouteData(rootSnapshot);
const title = this.asString(mergedData['seoTitle']) ?? this.defaultTitle;
const cleanPath = this.getCleanPath(this.router.url);
const lang = this.resolveLangFromPath(cleanPath);
const title =
this.resolveSeoText(mergedData, 'seoTitle', lang) ??
this.defaultTitle(lang);
const description =
this.asString(mergedData['seoDescription']) ?? this.defaultDescription;
this.resolveSeoText(mergedData, 'seoDescription', lang) ??
this.defaultDescription(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);
this.applySeoValues(
title,
description,
robots,
ogTitle,
ogDescription,
cleanPath,
lang,
);
}
private applySeoValues(
@@ -69,6 +140,8 @@ export class SeoService {
robots: string,
ogTitle: string,
ogDescription: string,
cleanPath: string,
lang: SupportedLang,
): void {
this.titleService.setTitle(title);
this.metaService.updateTag({ name: 'description', content: description });
@@ -79,13 +152,20 @@ export class SeoService {
content: ogDescription,
});
this.metaService.updateTag({ property: 'og:type', content: 'website' });
this.metaService.updateTag({ property: 'og:site_name', content: '3D fab' });
this.metaService.updateTag({ name: 'twitter:card', content: 'summary' });
this.metaService.updateTag({ name: 'twitter:title', content: ogTitle });
this.metaService.updateTag({
name: 'twitter:description',
content: ogDescription,
});
const cleanPath = this.getCleanPath(this.router.url);
const canonical = `${this.document.location.origin}${cleanPath}`;
const canonicalPath = this.buildLocalizedPath(cleanPath, lang);
const canonical = `${this.document.location.origin}${canonicalPath}`;
this.metaService.updateTag({ property: 'og:url', content: canonical });
this.updateCanonicalTag(canonical);
this.updateLangAndAlternates(cleanPath);
this.updateOpenGraphLocales(lang);
this.updateLangAndAlternates(canonicalPath, lang);
}
private getMergedRouteData(
@@ -104,11 +184,97 @@ export class SeoService {
return typeof value === 'string' ? value : undefined;
}
private resolveOverrideSeoText(
value: string | null | undefined,
key: string | null | undefined,
): string | undefined {
return this.asString(value) ?? this.resolveTranslation(key);
}
private resolveSeoText(
routeData: Record<string, unknown>,
key: SeoTextDataKey,
lang: SupportedLang,
): string | undefined {
const mapKey = `${key}ByLang`;
const localized = routeData[mapKey];
if (
localized &&
typeof localized === 'object' &&
!Array.isArray(localized)
) {
const mapped = localized as SeoMap;
const byLang = this.asString(mapped[lang]);
if (byLang) {
return byLang;
}
}
const translated = this.resolveTranslation(routeData[`${key}Key`]);
if (translated) {
return translated;
}
return this.asString(routeData[key]);
}
private resolveTranslation(value: unknown): string | undefined {
const key = this.asString(value)?.trim();
if (!key) {
return undefined;
}
const translated = this.translate.instant(key);
return typeof translated === 'string' && translated !== key
? translated
: undefined;
}
private defaultTitle(lang: SupportedLang): string {
return (
this.resolveTranslation('SEO.DEFAULT.TITLE') ??
this.defaultTitleByLang[lang]
);
}
private defaultDescription(lang: SupportedLang): string {
return (
this.resolveTranslation('SEO.DEFAULT.DESCRIPTION') ??
this.defaultDescriptionByLang[lang]
);
}
private getCleanPath(url: string): string {
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.supportedLangSet.has(firstSegment as SupportedLang)
) {
return firstSegment as SupportedLang;
}
return 'it';
}
private buildLocalizedPath(path: string, lang: SupportedLang): string {
const segments = path.split('/').filter(Boolean);
if (segments.length === 0) {
return `/${lang}`;
}
const firstSegment = segments[0]?.toLowerCase();
if (
firstSegment &&
this.supportedLangSet.has(firstSegment as SupportedLang)
) {
segments[0] = lang;
return `/${segments.join('/')}`;
}
return `/${[lang, ...segments].join('/')}`;
}
private updateCanonicalTag(url: string): void {
let link = this.document.head.querySelector(
'link[rel="canonical"]',
@@ -121,14 +287,31 @@ export class SeoService {
link.setAttribute('href', url);
}
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 suffixSegments = hasLang ? segments.slice(1) : segments;
private updateOpenGraphLocales(lang: SupportedLang): void {
this.metaService.updateTag({
property: 'og:locale',
content: this.ogLocaleByLang[lang],
});
this.document.head
.querySelectorAll(
'meta[property="og:locale:alternate"][data-seo-managed="true"]',
)
.forEach((node) => node.remove());
for (const alternateLang of this.supportedLangs) {
if (alternateLang === lang) {
continue;
}
this.appendOgLocaleAlternate(this.ogLocaleByLang[alternateLang]);
}
}
private updateLangAndAlternates(
localizedPath: string,
lang: SupportedLang,
): void {
const suffixSegments = localizedPath.split('/').filter(Boolean).slice(1);
const suffix =
suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : '';
@@ -138,7 +321,7 @@ export class SeoService {
.querySelectorAll('link[rel="alternate"][data-seo-managed="true"]')
.forEach((node) => node.remove());
for (const alt of ['it', 'en', 'de', 'fr']) {
for (const alt of this.supportedLangs) {
this.appendAlternateLink(
alt,
`${this.document.location.origin}/${alt}${suffix}`,
@@ -158,4 +341,12 @@ export class SeoService {
link.setAttribute('data-seo-managed', 'true');
this.document.head.appendChild(link);
}
private appendOgLocaleAlternate(locale: string): void {
const meta = this.document.createElement('meta');
meta.setAttribute('property', 'og:locale:alternate');
meta.setAttribute('content', locale);
meta.setAttribute('data-seo-managed', 'true');
this.document.head.appendChild(meta);
}
}

View File

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

View File

@@ -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" />

View File

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

View File

@@ -206,17 +206,6 @@
/>
</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">
@@ -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

View File

@@ -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,36 @@ 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 +1367,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 +1712,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 +1814,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 +2293,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')

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ import {
VariantOption,
} from '../../services/quote-estimator.service';
import { getColorHex } from '../../../../core/constants/colors.const';
import { LanguageService } from '../../../../core/services/language.service';
interface FormItem {
file: File;
@@ -106,6 +107,7 @@ export class UploadFormComponent implements OnInit {
private estimator = inject(QuoteEstimatorService);
private fb = inject(FormBuilder);
private translate = inject(TranslateService);
readonly languageService = inject(LanguageService);
form: FormGroup;

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
<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
{{ "CHECKOUT.CAD_SERVICE" | translate }}
<ng-container *ngIf="cadRequestId()">
riferito alla richiesta contatto #{{ cadRequestId() }}
{{ "CHECKOUT.CAD_REQUEST_REF" | translate: { id: cadRequestId() } }}
</ng-container>
</p>
</div>
@@ -204,13 +204,18 @@
<span class="ui-checkbox__mark"></span>
<span>
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
<a href="/terms" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.TERMS_LINK" | translate
}}</a>
<a
[href]="languageService.localizedPath('/terms')"
target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.TERMS_LINK" | translate }}</a
>
{{ "LEGAL.CONSENT.AND" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.PRIVACY_LINK" | translate
}}</a
<a
[href]="languageService.localizedPath('/privacy')"
target="_blank"
rel="noopener"
>{{ "LEGAL.CONSENT.PRIVACY_LINK" | translate }}</a
>.
</span>
</label>
@@ -265,14 +270,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)">
@@ -327,7 +334,9 @@
</div>
<div class="summary-item cad-summary-item" *ngIf="cadTotal() > 0">
<div class="item-details">
<span class="item-name">Servizio CAD</span>
<span class="item-name">{{
"CHECKOUT.CAD_SERVICE" | translate
}}</span>
<div class="item-specs-sub">{{ cadHours() }}h</div>
</div>
<div class="item-price">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
@@ -233,7 +245,9 @@ export class OrderComponent implements OnInit {
amount: order?.subtotalChf ?? 0,
},
{
label: `Servizio CAD (${order?.cadHours || 0}h)`,
label: this.translate.instant('ORDER.CAD_SERVICE', {
hours: order?.cadHours || 0,
}),
amount: order?.cadTotalChf ?? 0,
visible: (order?.cadTotalChf ?? 0) > 0,
},
@@ -278,23 +292,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 +320,7 @@ export class OrderComponent implements OnInit {
return rawColor;
}
return null;
return findColorHex(rawColor);
}
showItemMaterial(item: PublicOrderItem): boolean {
@@ -324,6 +331,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));

View File

@@ -21,7 +21,7 @@
.media {
position: relative;
display: block;
aspect-ratio: 1 / 1;
aspect-ratio: 4 / 3;
background: #f2eee5;
overflow: hidden;
}

View File

@@ -15,12 +15,16 @@
} @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 +133,32 @@
<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,8 +171,18 @@
</div>
@if (materialOptions().length > 1) {
<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) {
@for (
material of materialOptions();
track material.key
) {
<button
type="button"
class="material-option"
@@ -174,6 +207,25 @@
</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,7 +248,8 @@
</div>
}
<div class="color-selector-block">
<div class="selector-layout">
<div class="selector-card color-selector-block">
<div class="selector-head">
<p class="panel-kicker">
{{ "SHOP.SELECT_COLOR" | translate }}
@@ -218,7 +271,9 @@
</span>
<span class="color-trigger__copy">
<strong>{{ colorLabel(activeVariant) }}</strong>
<strong>{{
colorLabel(activeVariant) | translate
}}</strong>
<small>{{ selectedMaterial()?.label }}</small>
</span>
</button>
@@ -255,7 +310,7 @@
</span>
<span class="color-popup__name">{{
colorLabel(variant)
colorLabel(variant) | translate
}}</span>
</button>
}
@@ -264,8 +319,10 @@
}
</div>
<div class="quantity-row">
<span>{{ "SHOP.QUANTITY" | translate }}</span>
<div class="selector-card quantity-card">
<p class="panel-kicker">
{{ "SHOP.QUANTITY" | translate }}
</p>
<div class="qty-control">
<button type="button" (click)="decreaseQuantity()">
-
@@ -276,10 +333,12 @@
</button>
</div>
</div>
</div>
<div class="actions">
<app-button
variant="primary"
[fullWidth]="true"
[disabled]="isAddingToCart()"
(click)="addToCart()"
>
@@ -290,7 +349,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>
}

View File

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

View File

@@ -15,7 +15,7 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { catchError, combineLatest, finalize, of, switchMap, tap } from 'rxjs';
import { SeoService } from '../../core/services/seo.service';
import { LanguageService } from '../../core/services/language.service';
import { 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 +230,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 +257,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 +283,7 @@ export class ProductDetailComponent {
}
selectImage(mediaAssetId: string): void {
this.selectedImageAssetId.set(mediaAssetId);
this.setSelectedImageAssetId(mediaAssetId);
}
showPreviousImage(): void {
@@ -293,7 +293,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 +302,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 {
@@ -366,9 +366,12 @@ export class ProductDetailComponent {
if (!sessionId) {
return;
}
this.router.navigate(['/checkout'], {
this.router.navigate(
['/', this.languageService.selectedLang(), 'checkout'],
{
queryParams: { session: sessionId },
});
},
);
}
priceLabel(): number {
@@ -378,7 +381,9 @@ 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 +484,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 +503,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 {

View File

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

View File

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

View File

@@ -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';
@@ -55,7 +59,7 @@ export class ShopPageComponent {
private readonly router = inject(Router);
private readonly translate = inject(TranslateService);
private readonly seoService = inject(SeoService);
private readonly languageService = inject(LanguageService);
readonly languageService = inject(LanguageService);
private readonly shopRouteService = inject(ShopRouteService);
readonly shopService = inject(ShopService);
@@ -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 {
@@ -223,11 +242,14 @@ export class ShopPageComponent {
if (!sessionId) {
return;
}
this.router.navigate(['/checkout'], {
this.router.navigate(
['/', this.languageService.selectedLang(), 'checkout'],
{
queryParams: {
session: sessionId,
},
});
},
);
}
trackByCategory(_index: number, item: ShopCategoryNavNode): string {

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,11 @@
import { Component, input, output, signal, computed } from '@angular/core';
import {
Component,
input,
output,
signal,
computed,
inject,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import {
@@ -6,8 +13,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 +26,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 +42,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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
import { resolveRequestOrigin } from './request-origin';
describe('resolveRequestOrigin', () => {
it('prefers forwarded host and protocol when present', () => {
expect(
resolveRequestOrigin({
protocol: 'http',
headers: {
host: 'internal:4000',
'x-forwarded-host': '3d-fab.ch',
'x-forwarded-proto': 'https',
},
}),
).toBe('https://3d-fab.ch');
});
it('falls back to request protocol and host', () => {
expect(
resolveRequestOrigin({
protocol: 'http',
headers: {
host: 'localhost:4000',
},
}),
).toBe('http://localhost:4000');
});
it('uses the first forwarded value when proxies append multiple entries', () => {
expect(
resolveRequestOrigin({
protocol: 'http',
headers: {
host: 'internal:4000',
'x-forwarded-host': '3d-fab.ch, proxy.local',
'x-forwarded-proto': 'https, http',
},
}),
).toBe('https://3d-fab.ch');
});
});

View File

@@ -0,0 +1,50 @@
export type RequestLike = {
protocol?: string;
get?: (name: string) => string | undefined;
headers?: Record<string, string | string[] | undefined>;
};
function firstHeaderValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null;
}
return typeof value === 'string' ? value : null;
}
function firstForwardedValue(
value: string | string[] | undefined,
): string | null {
const raw = firstHeaderValue(value);
if (!raw) {
return null;
}
return (
raw
.split(',')
.map((part) => part.trim())
.find(Boolean) ?? null
);
}
export function resolveRequestOrigin(
request: RequestLike | null,
): string | null {
if (!request) {
return null;
}
const host =
firstForwardedValue(request.headers?.['x-forwarded-host']) ??
request.get?.('host') ??
firstHeaderValue(request.headers?.['host']);
if (!host) {
return null;
}
const forwardedProto = firstForwardedValue(
request.headers?.['x-forwarded-proto'],
)?.toLowerCase();
const protocol = forwardedProto || request.protocol || 'http';
return `${protocol}://${host}`;
}

View File

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

View File

@@ -4,6 +4,7 @@ import express from 'express';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import bootstrap from './main.server';
import { resolveRequestOrigin } from './core/request-origin';
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
@@ -39,13 +40,14 @@ app.get(
* Handle all other requests by rendering the Angular application.
*/
app.get('**', (req, res, next) => {
const { protocol, originalUrl, baseUrl, headers } = req;
const { originalUrl, baseUrl } = req;
const origin = resolveRequestOrigin(req);
commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
url: `${protocol}://${headers.host}${originalUrl}`,
url: `${origin ?? 'http://localhost:4000'}${originalUrl}`,
publicPath: browserDistFolder,
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
})

View File

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

View File

@@ -14,6 +14,7 @@
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"importHelpers": true,
"target": "ES2022",
"module": "ES2022"