diff --git a/backend/src/main/java/com/printcalculator/controller/SitemapController.java b/backend/src/main/java/com/printcalculator/controller/SitemapController.java
new file mode 100644
index 0000000..27a7d8e
--- /dev/null
+++ b/backend/src/main/java/com/printcalculator/controller/SitemapController.java
@@ -0,0 +1,37 @@
+package com.printcalculator.controller;
+
+import com.printcalculator.service.shop.ShopSitemapService;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.CacheControl;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.Duration;
+
+@RestController
+public class SitemapController {
+ private final ShopSitemapService shopSitemapService;
+ private final long cacheSeconds;
+
+ public SitemapController(
+ ShopSitemapService shopSitemapService,
+ @Value("${app.sitemap.shop.cache-seconds:3600}") long cacheSeconds
+ ) {
+ this.shopSitemapService = shopSitemapService;
+ this.cacheSeconds = Math.max(cacheSeconds, 0L);
+ }
+
+ @GetMapping(value = "/api/sitemap-shop.xml", produces = MediaType.APPLICATION_XML_VALUE)
+ public ResponseEntity getShopSitemap() {
+ CacheControl cacheControl = cacheSeconds > 0
+ ? CacheControl.maxAge(Duration.ofSeconds(cacheSeconds)).cachePublic()
+ : CacheControl.noCache();
+
+ return ResponseEntity.ok()
+ .contentType(MediaType.parseMediaType("application/xml;charset=UTF-8"))
+ .cacheControl(cacheControl)
+ .body(shopSitemapService.getShopSitemapXml());
+ }
+}
diff --git a/backend/src/main/java/com/printcalculator/service/shop/ShopSitemapService.java b/backend/src/main/java/com/printcalculator/service/shop/ShopSitemapService.java
new file mode 100644
index 0000000..26aa397
--- /dev/null
+++ b/backend/src/main/java/com/printcalculator/service/shop/ShopSitemapService.java
@@ -0,0 +1,244 @@
+package com.printcalculator.service.shop;
+
+import com.printcalculator.entity.ShopCategory;
+import com.printcalculator.entity.ShopProduct;
+import com.printcalculator.repository.ShopCategoryRepository;
+import com.printcalculator.repository.ShopProductRepository;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.text.Normalizer;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+@Service
+@Transactional(readOnly = true)
+public class ShopSitemapService {
+ private static final List SUPPORTED_LANGUAGES = ShopProduct.SUPPORTED_LANGUAGES;
+ private static final String DEFAULT_LANGUAGE = "it";
+ private static final DateTimeFormatter LASTMOD_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+
+ private final ShopCategoryRepository shopCategoryRepository;
+ private final ShopProductRepository shopProductRepository;
+ private final String frontendBaseUrl;
+ private final Duration cacheTtl;
+ private final Clock clock;
+
+ private volatile CachedSitemap cachedSitemap;
+
+ @Autowired
+ public ShopSitemapService(ShopCategoryRepository shopCategoryRepository,
+ ShopProductRepository shopProductRepository,
+ @Value("${app.frontend.base-url:http://localhost:4200}") String frontendBaseUrl,
+ @Value("${app.sitemap.shop.cache-seconds:3600}") long cacheSeconds) {
+ this(shopCategoryRepository, shopProductRepository, frontendBaseUrl, cacheSeconds, Clock.systemUTC());
+ }
+
+ ShopSitemapService(ShopCategoryRepository shopCategoryRepository,
+ ShopProductRepository shopProductRepository,
+ String frontendBaseUrl,
+ long cacheSeconds,
+ Clock clock) {
+ this.shopCategoryRepository = shopCategoryRepository;
+ this.shopProductRepository = shopProductRepository;
+ this.frontendBaseUrl = normalizeBaseUrl(frontendBaseUrl);
+ this.cacheTtl = cacheSeconds > 0 ? Duration.ofSeconds(cacheSeconds) : Duration.ZERO;
+ this.clock = clock;
+ }
+
+ public String getShopSitemapXml() {
+ Instant now = Instant.now(clock);
+ CachedSitemap current = cachedSitemap;
+ if (current != null && now.isBefore(current.expiresAt())) {
+ return current.xml();
+ }
+
+ synchronized (this) {
+ current = cachedSitemap;
+ now = Instant.now(clock);
+ if (current != null && now.isBefore(current.expiresAt())) {
+ return current.xml();
+ }
+
+ String xml = buildSitemapXml();
+ Instant expiresAt = cacheTtl.isZero() ? now : now.plus(cacheTtl);
+ cachedSitemap = new CachedSitemap(xml, expiresAt);
+ return xml;
+ }
+ }
+
+ private String buildSitemapXml() {
+ List activeCategories = shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc();
+ Set activeCategoryIds = activeCategories.stream()
+ .map(ShopCategory::getId)
+ .collect(Collectors.toSet());
+
+ List activeProducts = shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc();
+
+ StringBuilder xml = new StringBuilder(16_384);
+ xml.append("\n");
+ xml.append("\n");
+
+ appendCategoryUrls(xml, activeCategories);
+ appendProductUrls(xml, activeProducts, activeCategoryIds);
+
+ xml.append("\n");
+ return xml.toString();
+ }
+
+ private void appendCategoryUrls(StringBuilder xml, List categories) {
+ for (ShopCategory category : categories) {
+ if (!Boolean.TRUE.equals(category.getIndexable())) {
+ continue;
+ }
+
+ String encodedSlug = pathEncodeSegment(category.getSlug());
+ Map hrefByLanguage = new LinkedHashMap<>();
+ for (String language : SUPPORTED_LANGUAGES) {
+ hrefByLanguage.put(language, frontendBaseUrl + "/" + language + "/shop/" + encodedSlug);
+ }
+
+ appendUrlEntry(xml, hrefByLanguage, category.getUpdatedAt());
+ }
+ }
+
+ private void appendProductUrls(StringBuilder xml,
+ List products,
+ Set activeCategoryIds) {
+ for (ShopProduct product : products) {
+ if (!Boolean.TRUE.equals(product.getIndexable())) {
+ continue;
+ }
+ if (product.getCategory() == null || !activeCategoryIds.contains(product.getCategory().getId())) {
+ continue;
+ }
+
+ Map hrefByLanguage = new LinkedHashMap<>();
+ for (String language : SUPPORTED_LANGUAGES) {
+ String publicSegment = localizedProductPathSegment(product, language);
+ hrefByLanguage.put(language, frontendBaseUrl + "/" + language + "/shop/p/" + pathEncodeSegment(publicSegment));
+ }
+
+ appendUrlEntry(xml, hrefByLanguage, product.getUpdatedAt());
+ }
+ }
+
+ private void appendUrlEntry(StringBuilder xml,
+ Map hrefByLanguage,
+ OffsetDateTime lastmod) {
+ String defaultHref = hrefByLanguage.get(DEFAULT_LANGUAGE);
+ if (defaultHref == null || defaultHref.isBlank()) {
+ return;
+ }
+
+ xml.append(" \n");
+ xml.append(" ").append(xmlEscape(defaultHref)).append("\n");
+
+ for (String language : SUPPORTED_LANGUAGES) {
+ String href = hrefByLanguage.get(language);
+ if (href == null || href.isBlank()) {
+ continue;
+ }
+ xml.append(" \n");
+ }
+
+ xml.append(" \n");
+
+ if (lastmod != null) {
+ xml.append(" ").append(LASTMOD_FORMATTER.format(lastmod)).append("\n");
+ }
+
+ xml.append(" \n");
+ }
+
+ private String localizedProductPathSegment(ShopProduct product, String language) {
+ String localizedName = product.getNameForLanguage(language);
+ String idPrefix = productIdPrefix(product.getId());
+ String tail = firstNonBlank(slugify(localizedName), slugify(product.getSlug()), "product");
+ return idPrefix.isBlank() ? tail : idPrefix + "-" + tail;
+ }
+
+ private String productIdPrefix(UUID productId) {
+ if (productId == null) {
+ return "";
+ }
+ String raw = productId.toString().trim().toLowerCase(Locale.ROOT);
+ int dashIndex = raw.indexOf('-');
+ if (dashIndex > 0) {
+ return raw.substring(0, dashIndex);
+ }
+ return raw.length() >= 8 ? raw.substring(0, 8) : raw;
+ }
+
+ static String slugify(String rawValue) {
+ String safeValue = rawValue == null ? "" : rawValue;
+ String normalized = Normalizer.normalize(safeValue, Normalizer.Form.NFD)
+ .replaceAll("\\p{M}+", "")
+ .toLowerCase(Locale.ROOT)
+ .replaceAll("[^a-z0-9]+", "-")
+ .replaceAll("^-+|-+$", "")
+ .replaceAll("-{2,}", "-");
+ return normalized;
+ }
+
+ private String firstNonBlank(String... values) {
+ if (values == null) {
+ return null;
+ }
+ for (String value : values) {
+ if (value != null && !value.isBlank()) {
+ return value;
+ }
+ }
+ return null;
+ }
+
+ private String pathEncodeSegment(String rawSegment) {
+ String safeSegment = rawSegment == null ? "" : rawSegment;
+ return URLEncoder.encode(safeSegment, StandardCharsets.UTF_8).replace("+", "%20");
+ }
+
+ private String xmlEscape(String value) {
+ return String.valueOf(value)
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """)
+ .replace("'", "'");
+ }
+
+ private String normalizeBaseUrl(String baseUrl) {
+ String normalized = (baseUrl == null ? "" : baseUrl).trim();
+ if (normalized.isBlank()) {
+ return "http://localhost:4200";
+ }
+ while (normalized.endsWith("/")) {
+ normalized = normalized.substring(0, normalized.length() - 1);
+ }
+ return normalized;
+ }
+
+ private record CachedSitemap(String xml, Instant expiresAt) {
+ }
+}
diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties
index 9985c08..8fbd17c 100644
--- a/backend/src/main/resources/application.properties
+++ b/backend/src/main/resources/application.properties
@@ -56,6 +56,7 @@ app.mail.contact-request.admin.enabled=${APP_MAIL_CONTACT_REQUEST_ADMIN_ENABLED:
app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:info@3d-fab.ch}
app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
+app.sitemap.shop.cache-seconds=${APP_SITEMAP_SHOP_CACHE_SECONDS:3600}
# Admin back-office authentication
admin.password=${ADMIN_PASSWORD}
diff --git a/backend/src/test/java/com/printcalculator/service/shop/ShopSitemapServiceTest.java b/backend/src/test/java/com/printcalculator/service/shop/ShopSitemapServiceTest.java
new file mode 100644
index 0000000..d2eace0
--- /dev/null
+++ b/backend/src/test/java/com/printcalculator/service/shop/ShopSitemapServiceTest.java
@@ -0,0 +1,116 @@
+package com.printcalculator.service.shop;
+
+import com.printcalculator.entity.ShopCategory;
+import com.printcalculator.entity.ShopProduct;
+import com.printcalculator.repository.ShopCategoryRepository;
+import com.printcalculator.repository.ShopProductRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class ShopSitemapServiceTest {
+
+ @Mock
+ private ShopCategoryRepository shopCategoryRepository;
+ @Mock
+ private ShopProductRepository shopProductRepository;
+
+ private ShopSitemapService service;
+
+ @BeforeEach
+ void setUp() {
+ Clock fixedClock = Clock.fixed(Instant.parse("2026-03-11T10:00:00Z"), ZoneOffset.UTC);
+ service = new ShopSitemapService(
+ shopCategoryRepository,
+ shopProductRepository,
+ "https://3d-fab.ch/",
+ 900,
+ fixedClock
+ );
+ }
+
+ @Test
+ void getShopSitemapXml_shouldGenerateLocalizedCategoryAndProductEntries() {
+ ShopCategory visibleCategory = new ShopCategory();
+ visibleCategory.setId(UUID.fromString("21111111-1111-1111-1111-111111111111"));
+ visibleCategory.setSlug("accessori");
+ visibleCategory.setIndexable(true);
+ visibleCategory.setIsActive(true);
+ visibleCategory.setUpdatedAt(OffsetDateTime.parse("2026-03-10T08:00:00Z"));
+
+ ShopCategory hiddenCategory = new ShopCategory();
+ hiddenCategory.setId(UUID.fromString("22222222-2222-2222-2222-222222222222"));
+ hiddenCategory.setSlug("bozza");
+ hiddenCategory.setIndexable(false);
+ hiddenCategory.setIsActive(true);
+ hiddenCategory.setUpdatedAt(OffsetDateTime.parse("2026-03-10T09:00:00Z"));
+
+ ShopProduct indexedProduct = new ShopProduct();
+ indexedProduct.setId(UUID.fromString("123e4567-e89b-12d3-a456-426614174000"));
+ indexedProduct.setCategory(visibleCategory);
+ indexedProduct.setSlug("supporto-bici");
+ indexedProduct.setNameIt("Supporto bici");
+ indexedProduct.setNameEn("Bike Holder");
+ indexedProduct.setNameDe("Fahrrad Halter");
+ indexedProduct.setNameFr("Support velo");
+ indexedProduct.setIndexable(true);
+ indexedProduct.setIsActive(true);
+ indexedProduct.setUpdatedAt(OffsetDateTime.parse("2026-03-11T07:30:00Z"));
+
+ ShopProduct hiddenProduct = new ShopProduct();
+ hiddenProduct.setId(UUID.fromString("33333333-3333-3333-3333-333333333333"));
+ hiddenProduct.setCategory(visibleCategory);
+ hiddenProduct.setSlug("draft");
+ hiddenProduct.setIndexable(false);
+ hiddenProduct.setIsActive(true);
+ hiddenProduct.setUpdatedAt(OffsetDateTime.parse("2026-03-11T08:00:00Z"));
+
+ when(shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc())
+ .thenReturn(List.of(visibleCategory, hiddenCategory));
+ when(shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc())
+ .thenReturn(List.of(indexedProduct, hiddenProduct));
+
+ String xml = service.getShopSitemapXml();
+
+ assertTrue(xml.contains("https://3d-fab.ch/it/shop/accessori"));
+ assertTrue(xml.contains("hreflang=\"en\" href=\"https://3d-fab.ch/en/shop/accessori\""));
+ assertFalse(xml.contains("https://3d-fab.ch/it/shop/bozza"));
+
+ assertTrue(xml.contains("https://3d-fab.ch/it/shop/p/123e4567-supporto-bici"));
+ 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\""));
+ assertTrue(xml.contains("2026-03-11T07:30:00Z"));
+ assertFalse(xml.contains("33333333-draft"));
+ }
+
+ @Test
+ void getShopSitemapXml_shouldServeCachedPayloadWithinTtl() {
+ when(shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc()).thenReturn(List.of());
+ when(shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc()).thenReturn(List.of());
+
+ String firstXml = service.getShopSitemapXml();
+ String secondXml = service.getShopSitemapXml();
+
+ assertTrue(firstXml.contains("
+
+
+ https://3d-fab.ch/it
+
+
+
+
+
+ weekly
+ 1.0
+
+
+ https://3d-fab.ch/it/calculator/basic
+
+
+
+
+
+ weekly
+ 0.9
+
+
+ https://3d-fab.ch/it/calculator/advanced
+
+
+
+
+
+ weekly
+ 0.8
+
+
+ https://3d-fab.ch/it/shop
+
+
+
+
+
+ weekly
+ 0.8
+
+
+ https://3d-fab.ch/it/about
+
+
+
+
+
+ monthly
+ 0.7
+
+
+ https://3d-fab.ch/it/contact
+
+
+
+
+
+ monthly
+ 0.7
+
+
+ https://3d-fab.ch/it/privacy
+
+
+
+
+
+ yearly
+ 0.4
+
+
+ https://3d-fab.ch/it/terms
+
+
+
+
+
+ yearly
+ 0.4
+
+
diff --git a/frontend/public/sitemap.xml b/frontend/public/sitemap.xml
index a1195d2..3a45048 100644
--- a/frontend/public/sitemap.xml
+++ b/frontend/public/sitemap.xml
@@ -1,144 +1,9 @@
-
-
- https://3d-fab.ch/it
-
-
-
-
-
- weekly
- 1.0
-
-
- https://3d-fab.ch/it/calculator/basic
-
-
-
-
-
- weekly
- 0.9
-
-
- https://3d-fab.ch/it/calculator/advanced
-
-
-
-
-
- weekly
- 0.8
-
-
- https://3d-fab.ch/it/about
-
-
-
-
-
- monthly
- 0.7
-
-
- https://3d-fab.ch/it/contact
-
-
-
-
-
- monthly
- 0.7
-
-
- https://3d-fab.ch/it/privacy
-
-
-
-
-
- yearly
- 0.4
-
-
- https://3d-fab.ch/it/terms
-
-
-
-
-
- yearly
- 0.4
-
-
+
+
+ https://3d-fab.ch/sitemap-static.xml
+
+
+ https://3d-fab.ch/api/sitemap-shop.xml
+
+
diff --git a/frontend/src/app/features/admin/pages/admin-home-media.component.html b/frontend/src/app/features/admin/pages/admin-home-media.component.html
index 50474eb..1d63da0 100644
--- a/frontend/src/app/features/admin/pages/admin-home-media.component.html
+++ b/frontend/src/app/features/admin/pages/admin-home-media.component.html
@@ -130,7 +130,7 @@
diff --git a/frontend/src/app/features/admin/pages/admin-home-media.component.scss b/frontend/src/app/features/admin/pages/admin-home-media.component.scss
index 7f70af2..c5b246c 100644
--- a/frontend/src/app/features/admin/pages/admin-home-media.component.scss
+++ b/frontend/src/app/features/admin/pages/admin-home-media.component.scss
@@ -27,6 +27,62 @@
gap: var(--space-1);
}
+.image-language-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ min-width: 3.15rem;
+ background: #ffffff;
+ color: var(--color-text-muted);
+}
+
+.image-language-button.empty {
+ opacity: 0.76;
+}
+
+.image-language-button.complete {
+ border-color: #b8ddc2;
+}
+
+.image-language-button.incomplete {
+ border-color: #e8c8c2;
+}
+
+.image-language-button.active {
+ background: #fff5b8;
+ border-color: var(--color-brand);
+ color: var(--color-text);
+ opacity: 1;
+}
+
+.image-language-button__label {
+ line-height: 1;
+}
+
+.image-language-button__state {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 1rem;
+ height: 1rem;
+ padding: 0 0.2rem;
+ border-radius: 999px;
+ background: rgba(0, 0, 0, 0.08);
+ font-size: 0.62rem;
+ font-weight: 800;
+ line-height: 1;
+}
+
+.image-language-button.complete .image-language-button__state {
+ background: #dcefdc;
+ color: #25603b;
+}
+
+.image-language-button.incomplete .image-language-button__state {
+ background: #f7ddd7;
+ color: #944329;
+}
+
.form-field--wide {
grid-column: 1 / -1;
}
diff --git a/frontend/src/app/features/admin/pages/admin-home-media.component.ts b/frontend/src/app/features/admin/pages/admin-home-media.component.ts
index 5358d24..4ad4866 100644
--- a/frontend/src/app/features/admin/pages/admin-home-media.component.ts
+++ b/frontend/src/app/features/admin/pages/admin-home-media.component.ts
@@ -17,12 +17,16 @@ type HomeSectionKey =
| 'capability-prototyping'
| 'capability-custom-parts'
| 'capability-small-series'
- | 'capability-cad';
+ | 'capability-cad'
+ | 'joe'
+ | 'matteo';
+
+type HomeMediaUsageType = 'HOME_SECTION' | 'ABOUT_MEMBER';
interface HomeMediaSectionConfig {
- usageType: 'HOME_SECTION';
+ usageType: HomeMediaUsageType;
usageKey: HomeSectionKey;
- groupId: 'galleries' | 'capabilities';
+ groupId: 'galleries' | 'capabilities' | 'about-members';
title: string;
preferredVariantName: 'card' | 'hero';
}
@@ -94,6 +98,10 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
id: 'capabilities',
title: 'Cosa puoi ottenere',
},
+ {
+ id: 'about-members',
+ title: 'Chi siamo',
+ },
];
readonly sectionConfigs: readonly HomeMediaSectionConfig[] = [
@@ -139,6 +147,20 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
title: 'Home: consulenza e CAD',
preferredVariantName: 'card',
},
+ {
+ usageType: 'ABOUT_MEMBER',
+ usageKey: 'joe',
+ groupId: 'about-members',
+ title: 'Chi siamo: Joe',
+ preferredVariantName: 'card',
+ },
+ {
+ usageType: 'ABOUT_MEMBER',
+ usageKey: 'matteo',
+ groupId: 'about-members',
+ title: 'Chi siamo: Matteo',
+ preferredVariantName: 'card',
+ },
];
sections: HomeMediaSectionView[] = [];
@@ -155,6 +177,8 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
'capability-custom-parts': this.createEmptyFormState(),
'capability-small-series': this.createEmptyFormState(),
'capability-cad': this.createEmptyFormState(),
+ joe: this.createEmptyFormState(),
+ matteo: this.createEmptyFormState(),
};
get configuredSectionCount(): number {
@@ -432,6 +456,25 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
);
}
+ isLanguageStarted(
+ sectionKey: HomeSectionKey,
+ language: AdminMediaLanguage,
+ ): boolean {
+ return this.isTranslationStarted(
+ this.getFormState(sectionKey).translations[language],
+ );
+ }
+
+ isLanguageIncomplete(
+ sectionKey: HomeSectionKey,
+ language: AdminMediaLanguage,
+ ): boolean {
+ return (
+ this.isLanguageStarted(sectionKey, language) &&
+ !this.isLanguageComplete(sectionKey, language)
+ );
+ }
+
getItemTranslation(
item: HomeMediaItem,
language: AdminMediaLanguage,
@@ -619,6 +662,10 @@ export class AdminHomeMediaComponent implements OnInit, OnDestroy {
return !!translation.title.trim() && !!translation.altText.trim();
}
+ private isTranslationStarted(translation: AdminMediaTranslation): boolean {
+ return !!translation.title.trim() || !!translation.altText.trim();
+ }
+
private validateTranslations(
translations: Record,
): string | null {
diff --git a/frontend/src/app/features/admin/pages/admin-shop.component.html b/frontend/src/app/features/admin/pages/admin-shop.component.html
index 039c9d7..70292de 100644
--- a/frontend/src/app/features/admin/pages/admin-shop.component.html
+++ b/frontend/src/app/features/admin/pages/admin-shop.component.html
@@ -8,25 +8,29 @@
-