feat(back-end front-end): shop feature

This commit is contained in:
2026-03-10 15:04:49 +01:00
parent 8733184dc5
commit 3f228ef6e2
30 changed files with 2584 additions and 435 deletions

View File

@@ -27,7 +27,15 @@ public class AdminShopProductDto {
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;
@@ -215,6 +223,38 @@ public class AdminShopProductDto {
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;
}
@@ -223,6 +263,38 @@ public class AdminShopProductDto {
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

@@ -22,7 +22,15 @@ public class AdminUpsertShopProductRequest {
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;
@@ -175,6 +183,38 @@ public class AdminUpsertShopProductRequest {
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;
}
@@ -183,6 +223,38 @@ public class AdminUpsertShopProductRequest {
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

@@ -86,9 +86,33 @@ public class ShopProduct {
@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;
@@ -319,6 +343,70 @@ public class ShopProduct {
this.seoDescription = seoDescription;
}
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 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;
}
@@ -428,6 +516,36 @@ public class ShopProduct {
}
}
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,

View File

@@ -163,7 +163,13 @@ public class AdminShopProductControllerService {
}
}
shopProductModelAssetRepository.findByProduct_Id(productId).ifPresent(asset -> deleteExistingModelFile(asset, productId));
shopProductModelAssetRepository.findByProduct_Id(productId).ifPresent(asset -> {
deleteExistingModelFile(asset, productId);
shopProductModelAssetRepository.delete(asset);
});
if (!variants.isEmpty()) {
shopProductVariantRepository.deleteAll(variants);
}
shopProductRepository.delete(product);
}
@@ -315,10 +321,18 @@ public class AdminShopProductControllerService {
product.setDescriptionEn(localizedContent.descriptions().get("en"));
product.setDescriptionDe(localizedContent.descriptions().get("de"));
product.setDescriptionFr(localizedContent.descriptions().get("fr"));
product.setSeoTitle(normalizeOptional(payload.getSeoTitle()));
product.setSeoDescription(normalizeOptional(payload.getSeoDescription()));
product.setOgTitle(normalizeOptional(payload.getOgTitle()));
product.setOgDescription(normalizeOptional(payload.getOgDescription()));
product.setSeoTitle(localizedContent.defaultSeoTitle());
product.setSeoTitleIt(localizedContent.seoTitles().get("it"));
product.setSeoTitleEn(localizedContent.seoTitles().get("en"));
product.setSeoTitleDe(localizedContent.seoTitles().get("de"));
product.setSeoTitleFr(localizedContent.seoTitles().get("fr"));
product.setSeoDescription(localizedContent.defaultSeoDescription());
product.setSeoDescriptionIt(localizedContent.seoDescriptions().get("it"));
product.setSeoDescriptionEn(localizedContent.seoDescriptions().get("en"));
product.setSeoDescriptionDe(localizedContent.seoDescriptions().get("de"));
product.setSeoDescriptionFr(localizedContent.seoDescriptions().get("fr"));
product.setOgTitle(localizedContent.defaultSeoTitle());
product.setOgDescription(localizedContent.defaultSeoDescription());
product.setIndexable(payload.getIndexable() == null || payload.getIndexable());
product.setIsFeatured(Boolean.TRUE.equals(payload.getIsFeatured()));
product.setIsActive(payload.getIsActive() == null || payload.getIsActive());
@@ -374,16 +388,23 @@ public class AdminShopProductControllerService {
}
List<AdminUpsertShopProductVariantRequest> normalized = new ArrayList<>(payloads);
Set<String> colorKeys = new LinkedHashSet<>();
Set<String> variantKeys = new LinkedHashSet<>();
int defaultCount = 0;
for (AdminUpsertShopProductVariantRequest payload : normalized) {
if (payload == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant payload is required");
}
String colorName = normalizeRequired(payload.getColorName(), "Variant colorName is required");
String colorKey = colorName.toLowerCase(Locale.ROOT);
if (!colorKeys.add(colorKey)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Duplicate variant colorName: " + colorName);
String materialCode = normalizeRequired(
payload.getInternalMaterialCode(),
"Variant internalMaterialCode is required"
).toUpperCase(Locale.ROOT);
String variantKey = materialCode + "|" + colorName.toLowerCase(Locale.ROOT);
if (!variantKeys.add(variantKey)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Duplicate variant combination: " + materialCode + " / " + colorName
);
}
if (Boolean.TRUE.equals(payload.getIsDefault())) {
defaultCount++;
@@ -467,7 +488,15 @@ public class AdminShopProductControllerService {
dto.setDescriptionDe(product.getDescriptionDe());
dto.setDescriptionFr(product.getDescriptionFr());
dto.setSeoTitle(product.getSeoTitle());
dto.setSeoTitleIt(product.getSeoTitleIt());
dto.setSeoTitleEn(product.getSeoTitleEn());
dto.setSeoTitleDe(product.getSeoTitleDe());
dto.setSeoTitleFr(product.getSeoTitleFr());
dto.setSeoDescription(product.getSeoDescription());
dto.setSeoDescriptionIt(product.getSeoDescriptionIt());
dto.setSeoDescriptionEn(product.getSeoDescriptionEn());
dto.setSeoDescriptionDe(product.getSeoDescriptionDe());
dto.setSeoDescriptionFr(product.getSeoDescriptionFr());
dto.setOgTitle(product.getOgTitle());
dto.setOgDescription(product.getOgDescription());
dto.setIndexable(product.getIndexable());
@@ -596,13 +625,43 @@ public class AdminShopProductControllerService {
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 LocalizedProductContent(
names.get("it"),
firstNonBlank(excerpts.get("it"), fallbackExcerpt),
firstNonBlank(descriptions.get("it"), fallbackDescription),
firstNonBlank(seoTitles.get("it"), fallbackSeoTitle),
firstNonBlank(seoDescriptions.get("it"), fallbackSeoDescription),
names,
excerpts,
descriptions
descriptions,
seoTitles,
seoDescriptions
);
}
@@ -670,6 +729,13 @@ public class AdminShopProductControllerService {
return normalized.toUpperCase(Locale.ROOT);
}
private String validateSeoDescriptionLength(String value, String languageLabel) {
if (value != null && value.length() > 160) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, languageLabel + " SEO description must be at most 160 characters");
}
return value;
}
private void validateModelUpload(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "3D model file is required");
@@ -786,9 +852,13 @@ public class AdminShopProductControllerService {
String defaultName,
String defaultExcerpt,
String defaultDescription,
String defaultSeoTitle,
String defaultSeoDescription,
Map<String, String> names,
Map<String, String> excerpts,
Map<String, String> descriptions
Map<String, String> descriptions,
Map<String, String> seoTitles,
Map<String, String> seoDescriptions
) {
}
}

View File

@@ -21,7 +21,7 @@ public class MediaStorageService {
private final String frontendBaseUrl;
public MediaStorageService(@Value("${media.storage.root:storage_media}") String storageRoot,
@Value("${app.frontend.base-url:${APP_FRONTEND_BASE_URL:http://localhost:8080}}") String frontendBaseUrl) {
@Value("${app.frontend.base-url:${APP_FRONTEND_BASE_URL:http://localhost:8081}}") String frontendBaseUrl) {
this.normalizedRootLocation = Paths.get(storageRoot).toAbsolutePath().normalize();
this.originalRootLocation = normalizedRootLocation.resolve("original").normalize();
this.publicRootLocation = normalizedRootLocation.resolve("public").normalize();
@@ -131,8 +131,11 @@ public class MediaStorageService {
private String buildMediaBaseUrl() {
String normalized = frontendBaseUrl != null ? frontendBaseUrl.trim() : "";
if (normalized.contains("localhost")){
return "http://localhost:8081";
}
if (normalized.isBlank()) {
normalized = "http://localhost:4200";
normalized = "http://localhost:8081";
}
if (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);

View File

@@ -375,16 +375,18 @@ public class PublicShopCatalogService {
Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
String language) {
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language);
String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language);
return new ShopProductDetailDto(
entry.product().getId(),
entry.product().getSlug(),
entry.product().getNameForLanguage(language),
entry.product().getExcerptForLanguage(language),
entry.product().getDescriptionForLanguage(language),
entry.product().getSeoTitle(),
entry.product().getSeoDescription(),
entry.product().getOgTitle(),
entry.product().getOgDescription(),
localizedSeoTitle,
localizedSeoDescription,
localizedSeoTitle,
localizedSeoDescription,
entry.product().getIndexable(),
entry.product().getIsFeatured(),
entry.product().getSortOrder(),