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

-
-
- {{ products.length }} - prodotti -
-
- {{ categories.length }} - categorie -
- - +
+
+
+ {{ products.length }} + prodotti +
+
+ {{ categories.length }} + categorie +
+
+
+ + +
diff --git a/frontend/src/app/features/admin/pages/admin-shop.component.scss b/frontend/src/app/features/admin/pages/admin-shop.component.scss index 7851e8a..a8b5d19 100644 --- a/frontend/src/app/features/admin/pages/admin-shop.component.scss +++ b/frontend/src/app/features/admin/pages/admin-shop.component.scss @@ -80,13 +80,6 @@ color: var(--color-text-muted); } -.header-actions { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: var(--space-2); -} - .workspace { align-items: start; }