From 5d17b23c3a24199563acabe57c4afc23339d611b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 12 Mar 2026 16:26:36 +0100 Subject: [PATCH 1/6] chore(front-end): new seo, and improvements in shop component --- .../printcalculator/service/OrderService.java | 29 +- .../service/OrderServiceTest.java | 207 ++++++++++++++ frontend/src/app/app.routes.ts | 27 +- frontend/src/app/core/services/seo.service.ts | 76 ++++- .../features/checkout/checkout.component.html | 2 +- .../features/checkout/checkout.component.scss | 10 + .../src/app/features/home/home.component.html | 28 +- .../src/app/features/home/home.component.scss | 100 +++++++ .../app/features/order/order.component.html | 136 ++++----- .../app/features/order/order.component.scss | 70 ++++- .../product-card/product-card.component.scss | 2 +- .../shop/product-detail.component.html | 261 +++++++++++------- .../shop/product-detail.component.scss | 167 +++++++++-- .../features/shop/product-detail.component.ts | 40 ++- frontend/src/assets/i18n/de.json | 9 + frontend/src/assets/i18n/en.json | 9 + frontend/src/assets/i18n/fr.json | 9 + frontend/src/assets/i18n/it.json | 9 + .../_shop-product-detail-overrides.scss | 7 +- 19 files changed, 962 insertions(+), 236 deletions(-) diff --git a/backend/src/main/java/com/printcalculator/service/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java index 9179f7d..db3875a 100644 --- a/backend/src/main/java/com/printcalculator/service/OrderService.java +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -29,6 +29,7 @@ import java.util.*; @Service public class OrderService { private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize(); + private static final String SHOP_LINE_ITEM_TYPE = "SHOP_PRODUCT"; private final OrderRepository orderRepo; private final OrderItemRepository orderItemRepo; @@ -235,18 +236,20 @@ public class OrderService { oItem = orderItemRepo.save(oItem); - String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename; - oItem.setStoredRelativePath(relativePath); - Path sourcePath = resolveStoredQuotePath(qItem.getStoredPath(), session.getId()); if (sourcePath == null || !Files.exists(sourcePath)) { - throw new IllegalStateException("Source file not available for quote line item " + qItem.getId()); - } - try { - storageService.store(sourcePath, Paths.get(relativePath)); - oItem.setFileSizeBytes(Files.size(sourcePath)); - } catch (IOException e) { - throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e); + if (requiresStoredSourceFile(qItem)) { + throw new IllegalStateException("Source file not available for quote line item " + qItem.getId()); + } + } else { + String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename; + oItem.setStoredRelativePath(relativePath); + try { + storageService.store(sourcePath, Paths.get(relativePath)); + oItem.setFileSizeBytes(Files.size(sourcePath)); + } catch (IOException e) { + throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e); + } } oItem = orderItemRepo.save(oItem); @@ -318,6 +321,12 @@ public class OrderService { return "stl"; } + private boolean requiresStoredSourceFile(QuoteLineItem qItem) { + return !SHOP_LINE_ITEM_TYPE.equalsIgnoreCase( + qItem.getLineItemType() != null ? qItem.getLineItemType() : "" + ); + } + private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) { if (storedPath == null || storedPath.isBlank()) { return null; diff --git a/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java b/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java index aa90829..63f23e4 100644 --- a/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java @@ -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("".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 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"); diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 5fb79d4..8e75f90 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -15,9 +15,18 @@ const appChildRoutes: Routes = [ loadComponent: () => import('./features/home/home.component').then((m) => m.HomeComponent), data: { - seoTitle: '3D fab | Stampa 3D su misura', - seoDescription: - 'Servizio di stampa 3D con preventivo online immediato per prototipi, piccole serie e pezzi personalizzati.', + seoTitleByLang: { + it: 'Stampa 3D su misura in Ticino | Prototipi, ricambi e piccole serie - 3D Fab', + en: 'Custom 3D Printing in Switzerland | Prototypes, Spare Parts & Short Runs - 3D Fab', + de: '3D-Druck in Zürich | Prototypen, Ersatzteile und Kleinserien - 3D Fab', + fr: 'Impression 3D à Bienne | Prototypes, pièces et petites séries - 3D Fab', + }, + seoDescriptionByLang: { + it: 'Servizio di stampa 3D in Ticino per prototipi, pezzi di ricambio e piccole serie. Shop tecnico e supporto CAD, con preventivo rapido da file STL.', + en: 'Swiss-based 3D printing service for prototypes, spare parts and short production runs. Technical shop and CAD support, with fast quotes from STL files.', + de: '3D-Druckservice in Zürich für Prototypen, Ersatzteile und Kleinserien. Technischer Shop und CAD-Service, mit schneller Angebotsanfrage aus STL-Dateien.', + fr: "Service d'impression 3D à Bienne pour prototypes, pièces de rechange et petites séries. Boutique technique et support CAD, avec devis rapide depuis un fichier STL.", + }, }, }, { @@ -52,6 +61,18 @@ const appChildRoutes: Routes = [ 'Scopri il team 3D fab e il laboratorio di stampa 3D con sedi in Ticino e Bienne.', }, }, + /* { + path: 'materials', + loadComponent: () => + import('./features/materials/materials-page.component').then( + (m) => m.MaterialsPageComponent, + ), + data: { + seoTitle: 'Qualita e Materiali | 3D fab', + seoDescription: + 'Confronta materiali di stampa 3D con radar chart interattivo, proprieta tecniche e fonti citate.', + }, + },*/ { path: 'contact', loadChildren: () => diff --git a/frontend/src/app/core/services/seo.service.ts b/frontend/src/app/core/services/seo.service.ts index 2637ecc..b918f71 100644 --- a/frontend/src/app/core/services/seo.service.ts +++ b/frontend/src/app/core/services/seo.service.ts @@ -12,14 +12,31 @@ export interface PageSeoOverride { ogDescription?: string | null; } +type SupportedLang = 'it' | 'en' | 'de' | 'fr'; +type SeoMap = Partial>; + @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 = { + 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 = { + it: 'Servizio di stampa 3D su misura, shop tecnico e supporto CAD per prototipi, ricambi e piccole serie.', + en: 'Custom 3D printing service, technical shop and CAD support for prototypes, spare parts and short runs.', + de: '3D-Druckservice nach Maß, technischer Shop und CAD-Support für Prototypen, Ersatzteile und Kleinserien.', + fr: "Service d'impression 3D sur mesure, boutique technique et support CAD pour prototypes, pièces et petites séries.", + }; + private readonly supportedLangs = new Set([ + 'it', + 'en', + 'de', + 'fr', + ]); constructor( private router: Router, @@ -40,9 +57,11 @@ export class SeoService { } applyPageSeo(override: PageSeoOverride): void { - const title = this.asString(override.title) ?? this.defaultTitle; + const cleanPath = this.getCleanPath(this.router.url); + const lang = this.resolveLangFromPath(cleanPath); + const title = this.asString(override.title) ?? this.defaultTitleByLang[lang]; const description = - this.asString(override.description) ?? this.defaultDescription; + this.asString(override.description) ?? this.defaultDescriptionByLang[lang]; const robots = this.asString(override.robots) ?? 'index, follow'; const ogTitle = this.asString(override.ogTitle) ?? title; const ogDescription = this.asString(override.ogDescription) ?? description; @@ -52,13 +71,18 @@ export class SeoService { private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void { const mergedData = this.getMergedRouteData(rootSnapshot); - const title = this.asString(mergedData['seoTitle']) ?? this.defaultTitle; + const cleanPath = this.getCleanPath(this.router.url); + const lang = this.resolveLangFromPath(cleanPath); + const title = + this.resolveSeoText(mergedData, 'seoTitle', lang) ?? + this.defaultTitleByLang[lang]; const description = - this.asString(mergedData['seoDescription']) ?? this.defaultDescription; + this.resolveSeoText(mergedData, 'seoDescription', lang) ?? + this.defaultDescriptionByLang[lang]; const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow'; - const ogTitle = this.asString(mergedData['ogTitle']) ?? title; + const ogTitle = this.resolveSeoText(mergedData, 'ogTitle', lang) ?? title; const ogDescription = - this.asString(mergedData['ogDescription']) ?? description; + this.resolveSeoText(mergedData, 'ogDescription', lang) ?? description; this.applySeoValues(title, description, robots, ogTitle, ogDescription); } @@ -104,11 +128,36 @@ export class SeoService { return typeof value === 'string' ? value : undefined; } + private resolveSeoText( + routeData: Record, + key: 'seoTitle' | 'seoDescription' | 'ogTitle' | 'ogDescription', + lang: SupportedLang, + ): string | undefined { + const mapKey = `${key}ByLang`; + const localized = routeData[mapKey]; + if (localized && typeof localized === 'object' && !Array.isArray(localized)) { + const mapped = localized as SeoMap; + const byLang = this.asString(mapped[lang]); + if (byLang) { + return byLang; + } + } + return this.asString(routeData[key]); + } + private getCleanPath(url: string): string { const path = (url || '/').split('?')[0].split('#')[0]; return path || '/'; } + private resolveLangFromPath(path: string): SupportedLang { + const firstSegment = path.split('/').filter(Boolean)[0]?.toLowerCase(); + if (firstSegment && this.supportedLangs.has(firstSegment as SupportedLang)) { + return firstSegment as SupportedLang; + } + return 'it'; + } + private updateCanonicalTag(url: string): void { let link = this.document.head.querySelector( 'link[rel="canonical"]', @@ -124,10 +173,9 @@ export class SeoService { private updateLangAndAlternates(path: string): void { const segments = path.split('/').filter(Boolean); const firstSegment = segments[0]?.toLowerCase(); - const hasLang = Boolean( - firstSegment && this.supportedLangs.has(firstSegment), - ); - const lang = hasLang ? firstSegment : 'it'; + const maybeLang = firstSegment as SupportedLang | undefined; + const hasLang = Boolean(maybeLang && this.supportedLangs.has(maybeLang)); + const lang: SupportedLang = hasLang && maybeLang ? maybeLang : 'it'; const suffixSegments = hasLang ? segments.slice(1) : segments; const suffix = suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : ''; diff --git a/frontend/src/app/features/checkout/checkout.component.html b/frontend/src/app/features/checkout/checkout.component.html index 584eac3..0b5d5dd 100644 --- a/frontend/src/app/features/checkout/checkout.component.html +++ b/frontend/src/app/features/checkout/checkout.component.html @@ -1,5 +1,5 @@
-
+

{{ "CHECKOUT.TITLE" | translate }}

Servizio CAD diff --git a/frontend/src/app/features/checkout/checkout.component.scss b/frontend/src/app/features/checkout/checkout.component.scss index ac3fdb7..58f6b9c 100644 --- a/frontend/src/app/features/checkout/checkout.component.scss +++ b/frontend/src/app/features/checkout/checkout.component.scss @@ -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)); + } +} diff --git a/frontend/src/app/features/home/home.component.html b/frontend/src/app/features/home/home.component.html index 1775879..be5868a 100644 --- a/frontend/src/app/features/home/home.component.html +++ b/frontend/src/app/features/home/home.component.html @@ -1,6 +1,8 @@

-
+

{{ "HOME.HERO_EYEBROW" | translate }}

+
diff --git a/frontend/src/app/features/home/home.component.scss b/frontend/src/app/features/home/home.component.scss index 58c57d5..e557664 100644 --- a/frontend/src/app/features/home/home.component.scss +++ b/frontend/src/app/features/home/home.component.scss @@ -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; diff --git a/frontend/src/app/features/order/order.component.html b/frontend/src/app/features/order/order.component.html index 4c31008..59cbdeb 100644 --- a/frontend/src/app/features/order/order.component.html +++ b/frontend/src/app/features/order/order.component.html @@ -68,9 +68,12 @@
-
-
- +
+
+

{{ "PAYMENT.METHOD" | translate }}

@@ -174,69 +177,6 @@
- - -
-

{{ "ORDER.ITEMS_TITLE" | translate }}

-

- {{ orderKindLabel(o) }} -

-
- -
-
-
-
- {{ - itemDisplayName(item) - }} - - {{ - isShopItem(item) - ? ("ORDER.TYPE_SHOP" | translate) - : ("ORDER.TYPE_CALCULATOR" | translate) - }} - -
- -
- {{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }} - - {{ "CHECKOUT.MATERIAL" | translate }}: - {{ - item.materialCode || ("ORDER.NOT_AVAILABLE" | translate) - }} - - - {{ "SHOP.VARIANT" | translate }}: {{ variantLabel }} - - - - {{ itemColorLabel(item) }} - -
- -
- {{ item.printTimeSeconds || 0 | number: "1.0-0" }}s | - {{ item.materialGrams || 0 | number: "1.0-0" }}g -
-
- - - {{ item.lineTotalChf || 0 | currency: "CHF" }} - -
-
-
@@ -271,6 +211,70 @@ [currency]="'CHF'" [totalLabelKey]="'PAYMENT.TOTAL'" > + +
+
+

{{ "ORDER.ITEMS_TITLE" | translate }}

+ {{ (o.items || []).length }} +
+ +
+
+
+
+ {{ + itemDisplayName(item) + }} + + {{ + isShopItem(item) + ? ("ORDER.TYPE_SHOP" | translate) + : ("ORDER.TYPE_CALCULATOR" | translate) + }} + +
+ +
+ {{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }} + + {{ "CHECKOUT.MATERIAL" | translate }}: + {{ + item.materialCode || ("ORDER.NOT_AVAILABLE" | translate) + }} + + + {{ "SHOP.VARIANT" | translate }}: {{ variantLabel }} + + + + {{ itemColorLabel(item) }} + +
+ +
+ {{ item.printTimeSeconds || 0 | number: "1.0-0" }}s | + {{ item.materialGrams || 0 | number: "1.0-0" }}g +
+
+ + + {{ item.lineTotalChf || 0 | currency: "CHF" }} + +
+
+
diff --git a/frontend/src/app/features/order/order.component.scss b/frontend/src/app/features/order/order.component.scss index ecb216c..fb176e9 100644 --- a/frontend/src/app/features/order/order.component.scss +++ b/frontend/src/app/features/order/order.component.scss @@ -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; + } } diff --git a/frontend/src/app/features/shop/components/product-card/product-card.component.scss b/frontend/src/app/features/shop/components/product-card/product-card.component.scss index 65885ad..d9845d4 100644 --- a/frontend/src/app/features/shop/components/product-card/product-card.component.scss +++ b/frontend/src/app/features/shop/components/product-card/product-card.component.scss @@ -21,7 +21,7 @@ .media { position: relative; display: block; - aspect-ratio: 1 / 1; + aspect-ratio: 4 / 3; background: #f2eee5; overflow: hidden; } diff --git a/frontend/src/app/features/shop/product-detail.component.html b/frontend/src/app/features/shop/product-detail.component.html index e4fbc8d..65aaf4e 100644 --- a/frontend/src/app/features/shop/product-detail.component.html +++ b/frontend/src/app/features/shop/product-detail.component.html @@ -15,18 +15,23 @@ } @else { @if (product(); as p) {
-
+
@if (galleryImages().length > 1) { - } +
+
+

+ {{ "SHOP.SELECT_MATERIAL" | translate }} +

+
+ +
+ @for (material of materialOptions(); track material.key) { + + } +
+ } @else { + @if (selectedMaterial(); as material) { +
+
+

+ {{ "SHOP.SELECT_MATERIAL" | translate }} +

+ {{ material.label }} + + {{ + "SHOP.MATERIAL_COLOR_COUNT" + | translate + : { count: materialColorCount(material) } + }} + +
+
+ } } @if ( @@ -196,90 +244,95 @@
} -
-
-

- {{ "SHOP.SELECT_COLOR" | translate }} -

-
+
+
+
+

+ {{ "SHOP.SELECT_COLOR" | translate }} +

+
- @if (selectedVariant(); as activeVariant) { - - } + + {{ colorLabel(activeVariant) }} + {{ selectedMaterial()?.label }} + + + } - @if (colorPopupOpen()) { - -
-
- {{ selectedMaterial()?.label || "" | uppercase }} -
+ @if (colorPopupOpen()) { + +
+
+ {{ selectedMaterial()?.label || "" | uppercase }} +
-
- @for (variant of colorOptions(); track variant.id) { - - } + {{ + colorLabel(variant) + }} + + } +
-
- } -
+ } +
-
- {{ "SHOP.QUANTITY" | translate }} -
- - {{ quantity() }} - +
+

+ {{ "SHOP.QUANTITY" | translate }} +

+
+ + {{ quantity() }} + +
@@ -290,7 +343,11 @@ @if (shopService.cartItemCount() > 0) { - + {{ "SHOP.GO_TO_CHECKOUT" | translate }} } diff --git a/frontend/src/app/features/shop/product-detail.component.scss b/frontend/src/app/features/shop/product-detail.component.scss index e6e50c8..4369be5 100644 --- a/frontend/src/app/features/shop/product-detail.component.scss +++ b/frontend/src/app/features/shop/product-detail.component.scss @@ -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,18 @@ width: 100%; height: 100%; display: block; - object-fit: contain; + object-fit: cover; + object-position: center; background: #f2eee5; } +.hero-media--portrait .hero-image { + object-fit: contain; +} + .image-fallback { width: 100%; height: 100%; - min-height: 420px; display: flex; align-items: flex-end; padding: var(--space-6); @@ -111,8 +150,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 +265,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 +307,11 @@ h1 { font-weight: 600; } +.material-section { + display: grid; + gap: 0.65rem; +} + .material-grid { display: grid; gap: 0.55rem; @@ -301,6 +364,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 +428,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 +473,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 +576,10 @@ h1 { grid-template-columns: 1fr; } + .selector-layout { + grid-template-columns: 1fr; + } + .property-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -469,15 +590,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 +605,7 @@ h1 { } .thumb { - flex-basis: 78px; - height: 78px; + flex-basis: 84px; } .model-launch-row { @@ -514,6 +633,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; } diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts index 71bb621..c74a32b 100644 --- a/frontend/src/app/features/shop/product-detail.component.ts +++ b/frontend/src/app/features/shop/product-detail.component.ts @@ -74,6 +74,9 @@ export class ProductDetailComponent { readonly product = signal(null); readonly selectedVariantId = signal(null); readonly selectedImageAssetId = signal(null); + readonly selectedImageOrientation = signal< + 'portrait' | 'landscape' | 'square' | null + >(null); readonly quantity = signal(1); readonly isAddingToCart = signal(false); readonly addSuccess = signal(false); @@ -191,6 +194,9 @@ export class ProductDetailComponent { readonly selectedVariantCartQuantity = computed(() => this.shopService.quantityForVariant(this.selectedVariant()?.id), ); + readonly selectedImageIsPortrait = computed( + () => this.selectedImageOrientation() === 'portrait', + ); constructor() { if (!this.shopService.cartLoaded()) { @@ -230,7 +236,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 +263,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 +289,7 @@ export class ProductDetailComponent { } selectImage(mediaAssetId: string): void { - this.selectedImageAssetId.set(mediaAssetId); + this.setSelectedImageAssetId(mediaAssetId); } showPreviousImage(): void { @@ -293,7 +299,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 +308,26 @@ export class ProductDetailComponent { return; } const nextIndex = (this.selectedImageIndex() + 1) % images.length; - this.selectedImageAssetId.set(images[nextIndex].mediaAssetId); + this.setSelectedImageAssetId(images[nextIndex].mediaAssetId); + } + + onHeroImageLoad(event: Event): void { + const target = event.target; + if (!(target instanceof HTMLImageElement)) { + return; + } + + if (target.naturalHeight > target.naturalWidth) { + this.selectedImageOrientation.set('portrait'); + return; + } + + if (target.naturalWidth > target.naturalHeight) { + this.selectedImageOrientation.set('landscape'); + return; + } + + this.selectedImageOrientation.set('square'); } selectVariant(variant: ShopProductVariantOption): void { @@ -479,6 +504,11 @@ export class ProductDetailComponent { }); } + private setSelectedImageAssetId(mediaAssetId: string | null): void { + this.selectedImageAssetId.set(mediaAssetId); + this.selectedImageOrientation.set(null); + } + private normalizeHexColor(value: string | null | undefined): string | null { const raw = String(value ?? '').trim(); if (!raw) { diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index d92fcbd..223fd36 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -4,6 +4,7 @@ "CALCULATOR": "Rechner", "SHOP": "Shop", "ABOUT": "Über uns", + "MATERIALS": "Qualität & Materialien", "CONTACT": "Kontakt", "LANGUAGE_SELECTOR": "Sprachauswahl" }, @@ -119,6 +120,7 @@ "MODEL_CLOSE": "3D-Ansicht schließen", "PREVIOUS_IMAGE": "Vorheriges Bild", "NEXT_IMAGE": "Nächstes Bild", + "PRICE_LABEL": "Preis", "SELECT_MATERIAL": "Material", "SELECT_COLOR": "Farbe", "MATERIAL_COLOR_COUNT": "{{count}} Farben verfügbar", @@ -499,6 +501,13 @@ "HERO_TITLE": "3D-Druckservice.
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", diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index 3403fa2..8f8ffbe 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -4,6 +4,7 @@ "CALCULATOR": "Calculator", "SHOP": "Shop", "ABOUT": "About Us", + "MATERIALS": "Quality & Materials", "CONTACT": "Contact Us", "LANGUAGE_SELECTOR": "Language selector" }, @@ -119,6 +120,7 @@ "MODEL_CLOSE": "Close 3D view", "PREVIOUS_IMAGE": "Previous image", "NEXT_IMAGE": "Next image", + "PRICE_LABEL": "Price", "SELECT_MATERIAL": "Material", "SELECT_COLOR": "Color", "MATERIAL_COLOR_COUNT": "{{count}} colors available", @@ -499,6 +501,13 @@ "HERO_TITLE": "3D printing service.
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", diff --git a/frontend/src/assets/i18n/fr.json b/frontend/src/assets/i18n/fr.json index dd537db..e3e80ab 100644 --- a/frontend/src/assets/i18n/fr.json +++ b/frontend/src/assets/i18n/fr.json @@ -4,6 +4,7 @@ "CALCULATOR": "Calculateur", "SHOP": "Boutique", "ABOUT": "Qui sommes-nous", + "MATERIALS": "Qualité & matériaux", "CONTACT": "Contactez-nous", "LANGUAGE_SELECTOR": "Sélecteur de langue" }, @@ -17,6 +18,13 @@ "HERO_TITLE": "Service d'impression 3D.
Du fichier à la pièce finie.", "HERO_LEAD": "Avec le calculateur le plus avancé pour vos impressions 3D : précision absolue et zéro surprise.", "HERO_SUBTITLE": "Nous proposons aussi des services CAD pour des pièces personnalisées !", + "HERO_SWISS_TITLE": "Based in Switzerland", + "HERO_SWISS_COPY": "Production et support en Suisse.", + "HERO_SWISS_LOCATIONS_LABEL": "Sites", + "HERO_SWISS_LOCATION_1": "Ticino", + "HERO_SWISS_LOCATION_2": "Zurich", + "HERO_SWISS_LOCATION_3": "Biel/Bienne", + "HERO_SWISS_NOTE": "Actifs dans toute la Suisse.", "BTN_CALCULATE": "Calculer un devis", "BTN_SHOP": "Aller à la boutique", "BTN_CONTACT": "Parlez avec nous", @@ -176,6 +184,7 @@ "MODEL_CLOSE": "Fermer la vue 3D", "PREVIOUS_IMAGE": "Image précédente", "NEXT_IMAGE": "Image suivante", + "PRICE_LABEL": "Prix", "SELECT_MATERIAL": "Matériau", "SELECT_COLOR": "Couleur", "MATERIAL_COLOR_COUNT": "{{count}} couleurs disponibles", diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index 9e2ad80..006e9ff 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -4,6 +4,7 @@ "CALCULATOR": "Calcolatore", "SHOP": "Shop", "ABOUT": "Chi Siamo", + "MATERIALS": "Qualita e Materiali", "CONTACT": "Contattaci", "LANGUAGE_SELECTOR": "Selettore lingua" }, @@ -17,6 +18,13 @@ "HERO_TITLE": "Servizio di stampa 3D.
Dal file al pezzo finito.", "HERO_LEAD": "Con il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.", "HERO_SUBTITLE": "Offriamo anche servizi di CAD per pezzi personalizzati!", + "HERO_SWISS_TITLE": "Based in Switzerland", + "HERO_SWISS_COPY": "Produzione e supporto in Svizzera", + "HERO_SWISS_LOCATIONS_LABEL": "Sedi", + "HERO_SWISS_LOCATION_1": "Ticino", + "HERO_SWISS_LOCATION_2": "Zurich", + "HERO_SWISS_LOCATION_3": "Biel/Bienne", + "HERO_SWISS_NOTE": "Operativi in tutta la Svizzera.", "BTN_CALCULATE": "Calcola Preventivo", "BTN_SHOP": "Vai allo shop", "BTN_CONTACT": "Parla con noi", @@ -193,6 +201,7 @@ "HIGHLIGHT_CART": "Nel carrello", "HIGHLIGHT_READY": "Preview", "PRICE_FROM": "Prezzo da", + "PRICE_LABEL": "Prezzo", "EXCERPT_FALLBACK": "Scheda prodotto in preparazione.", "MODEL_3D": "3D preview", "MODEL_TITLE": "Anteprima del modello", diff --git a/frontend/src/styles/_shop-product-detail-overrides.scss b/frontend/src/styles/_shop-product-detail-overrides.scss index b9f1bcd..228999e 100644 --- a/frontend/src/styles/_shop-product-detail-overrides.scss +++ b/frontend/src/styles/_shop-product-detail-overrides.scss @@ -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; -- 2.49.1 From fcdede2dd6259afe3a8873463de8895cca1dd759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Thu, 12 Mar 2026 16:43:00 +0100 Subject: [PATCH 2/6] chore(front-end):map color --- .../src/app/core/constants/colors.const.ts | 149 +++++++++++++++++- .../src/app/core/layout/navbar.component.html | 4 +- .../src/app/core/layout/navbar.component.ts | 20 ++- .../features/checkout/checkout.component.html | 6 +- .../features/checkout/checkout.component.ts | 23 ++- .../app/features/order/order.component.html | 5 +- .../src/app/features/order/order.component.ts | 21 ++- .../shop/product-detail.component.html | 8 +- .../features/shop/product-detail.component.ts | 22 ++- .../features/shop/shop-page.component.html | 4 +- .../app/features/shop/shop-page.component.ts | 20 ++- .../color-selector.component.ts | 3 +- frontend/src/assets/i18n/de.json | 7 + frontend/src/assets/i18n/en.json | 7 + frontend/src/assets/i18n/fr.json | 7 + frontend/src/assets/i18n/it.json | 7 + 16 files changed, 262 insertions(+), 51 deletions(-) diff --git a/frontend/src/app/core/constants/colors.const.ts b/frontend/src/app/core/constants/colors.const.ts index 9a744c4..1223848 100644 --- a/frontend/src/app/core/constants/colors.const.ts +++ b/frontend/src/app/core/constants/colors.const.ts @@ -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,145 @@ export const PRODUCT_COLORS: ColorCategory[] = [ }, ]; -export function getColorHex(value: string): string { - for (const cat of PRODUCT_COLORS) { - const found = cat.colors.find((c) => c.value === value); - if (found) return found.hex; - } - return '#facf0a'; // Default Brand Color if not found +const COLOR_HEX_BY_TRANSLATION_KEY: Record = { + ...Object.fromEntries( + PRODUCT_COLORS.flatMap((category) => + category.colors.map((color) => [color.label, color.hex] as const), + ), + ), + 'COLOR.NAME.ORANGE': '#f5a623', + 'COLOR.NAME.GRAY': '#b7b7b7', + 'COLOR.NAME.LIGHT_GRAY': '#d8dadd', + 'COLOR.NAME.DARK_GRAY': '#4f4f4f', + 'COLOR.NAME.PURPLE': '#7b1fa2', + 'COLOR.NAME.BEIGE': '#d4c09a', + 'COLOR.NAME.SAND_BEIGE': '#d7c2a0', +}; + +const COLOR_TRANSLATION_KEY_BY_VALUE: Record = { + black: 'COLOR.NAME.BLACK', + nero: 'COLOR.NAME.BLACK', + noir: 'COLOR.NAME.BLACK', + schwarz: 'COLOR.NAME.BLACK', + white: 'COLOR.NAME.WHITE', + bianco: 'COLOR.NAME.WHITE', + blanc: 'COLOR.NAME.WHITE', + weiss: 'COLOR.NAME.WHITE', + red: 'COLOR.NAME.RED', + rosso: 'COLOR.NAME.RED', + rouge: 'COLOR.NAME.RED', + rot: 'COLOR.NAME.RED', + blue: 'COLOR.NAME.BLUE', + blu: 'COLOR.NAME.BLUE', + bleu: 'COLOR.NAME.BLUE', + blau: 'COLOR.NAME.BLUE', + green: 'COLOR.NAME.GREEN', + verde: 'COLOR.NAME.GREEN', + vert: 'COLOR.NAME.GREEN', + grun: 'COLOR.NAME.GREEN', + yellow: 'COLOR.NAME.YELLOW', + giallo: 'COLOR.NAME.YELLOW', + jaune: 'COLOR.NAME.YELLOW', + gelb: 'COLOR.NAME.YELLOW', + orange: 'COLOR.NAME.ORANGE', + arancione: 'COLOR.NAME.ORANGE', + naranja: 'COLOR.NAME.ORANGE', + gris: 'COLOR.NAME.GRAY', + gray: 'COLOR.NAME.GRAY', + grey: 'COLOR.NAME.GRAY', + grigio: 'COLOR.NAME.GRAY', + grau: 'COLOR.NAME.GRAY', + 'light gray': 'COLOR.NAME.LIGHT_GRAY', + 'light grey': 'COLOR.NAME.LIGHT_GRAY', + 'grigio chiaro': 'COLOR.NAME.LIGHT_GRAY', + 'gris clair': 'COLOR.NAME.LIGHT_GRAY', + hellgrau: 'COLOR.NAME.LIGHT_GRAY', + 'dark gray': 'COLOR.NAME.DARK_GRAY', + 'dark grey': 'COLOR.NAME.DARK_GRAY', + 'grigio scuro': 'COLOR.NAME.DARK_GRAY', + 'gris fonce': 'COLOR.NAME.DARK_GRAY', + dunkelgrau: 'COLOR.NAME.DARK_GRAY', + purple: 'COLOR.NAME.PURPLE', + violet: 'COLOR.NAME.PURPLE', + viola: 'COLOR.NAME.PURPLE', + lila: 'COLOR.NAME.PURPLE', + beige: 'COLOR.NAME.BEIGE', + 'sand beige': 'COLOR.NAME.SAND_BEIGE', + 'beige sabbia': 'COLOR.NAME.SAND_BEIGE', + 'beige sable': 'COLOR.NAME.SAND_BEIGE', + sandbeige: 'COLOR.NAME.SAND_BEIGE', + 'matte black': 'COLOR.NAME.MATTE_BLACK', + 'black matte': 'COLOR.NAME.MATTE_BLACK', + 'nero opaco': 'COLOR.NAME.MATTE_BLACK', + 'noir mat': 'COLOR.NAME.MATTE_BLACK', + 'matt schwarz': 'COLOR.NAME.MATTE_BLACK', + 'schwarz matt': 'COLOR.NAME.MATTE_BLACK', + 'matte white': 'COLOR.NAME.MATTE_WHITE', + 'white matte': 'COLOR.NAME.MATTE_WHITE', + 'bianco opaco': 'COLOR.NAME.MATTE_WHITE', + 'blanc mat': 'COLOR.NAME.MATTE_WHITE', + 'matt weiss': 'COLOR.NAME.MATTE_WHITE', + 'weiss matt': 'COLOR.NAME.MATTE_WHITE', + 'matte gray': 'COLOR.NAME.MATTE_GRAY', + 'matte grey': 'COLOR.NAME.MATTE_GRAY', + 'grigio opaco': 'COLOR.NAME.MATTE_GRAY', + 'gris mat': 'COLOR.NAME.MATTE_GRAY', + 'matt grau': 'COLOR.NAME.MATTE_GRAY', + 'grau matt': 'COLOR.NAME.MATTE_GRAY', +}; + +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 getColorTranslationKey( + value: string | null | undefined, +): string | null { + const normalized = normalizeColorValue(value); + return normalized ? COLOR_TRANSLATION_KEY_BY_VALUE[normalized] ?? null : null; +} + +export function getColorLabelToken( + value: string | null | undefined, +): string | null { + const raw = String(value ?? '').trim(); + if (!raw) { + return null; + } + + return getColorTranslationKey(raw) ?? raw; +} + +export function findColorHex(value: string | null | undefined): string | null { + const translationKey = getColorTranslationKey(value); + if (translationKey) { + return COLOR_HEX_BY_TRANSLATION_KEY[translationKey] ?? 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 function getColorHex(value: string): string { + return findColorHex(value) ?? DEFAULT_BRAND_COLOR; } diff --git a/frontend/src/app/core/layout/navbar.component.html b/frontend/src/app/core/layout/navbar.component.html index 46d2bd8..f402152 100644 --- a/frontend/src/app/core/layout/navbar.component.html +++ b/frontend/src/app/core/layout/navbar.component.html @@ -130,7 +130,7 @@
{{ cartItemName(item) }} @if (cartItemVariant(item); as variant) { - {{ variant }} + {{ variant | translate }} } @if (cartItemColor(item); as color) { @@ -138,7 +138,7 @@ class="color-dot" [style.background-color]="cartItemColorHex(item)" > - {{ color }} + {{ color | translate }} }
diff --git a/frontend/src/app/core/layout/navbar.component.ts b/frontend/src/app/core/layout/navbar.component.ts index 5a7c91a..1e2f870 100644 --- a/frontend/src/app/core/layout/navbar.component.ts +++ b/frontend/src/app/core/layout/navbar.component.ts @@ -15,6 +15,10 @@ import { ShopService, } from '../../features/shop/services/shop.service'; import { finalize } from 'rxjs'; +import { + findColorHex, + getColorLabelToken, +} from '../constants/colors.const'; @Component({ selector: 'app-navbar', @@ -143,15 +147,25 @@ export class NavbarComponent { } cartItemVariant(item: ShopCartItem): string | null { - return item.shopVariantLabel || item.shopVariantColorName || null; + return ( + item.shopVariantLabel || getColorLabelToken(item.shopVariantColorName) + ); } cartItemColor(item: ShopCartItem): string | null { - return item.shopVariantColorName || item.colorCode || null; + return ( + getColorLabelToken(item.shopVariantColorName) ?? + getColorLabelToken(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 { diff --git a/frontend/src/app/features/checkout/checkout.component.html b/frontend/src/app/features/checkout/checkout.component.html index 0b5d5dd..e76ef86 100644 --- a/frontend/src/app/features/checkout/checkout.component.html +++ b/frontend/src/app/features/checkout/checkout.component.html @@ -265,14 +265,16 @@ {{ "SHOP.VARIANT" | translate }}: - {{ variantLabel }} + {{ variantLabel | translate }} - {{ itemColorLabel(item) }} + {{ + itemColorLabel(item) | translate + }}
diff --git a/frontend/src/app/features/checkout/checkout.component.ts b/frontend/src/app/features/checkout/checkout.component.ts index c00abc7..77f7060 100644 --- a/frontend/src/app/features/checkout/checkout.component.ts +++ b/frontend/src/app/features/checkout/checkout.component.ts @@ -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, + getColorLabelToken, + normalizeColorValue, +} from '../../core/constants/colors.const'; @Component({ selector: 'app-checkout', @@ -252,8 +257,7 @@ export class CheckoutComponent implements OnInit { if (variantLabel) { return variantLabel; } - const colorName = String(item?.shopVariantColorName ?? '').trim(); - return colorName || null; + return getColorLabelToken(item?.shopVariantColorName); } showItemMaterial(item: any): boolean { @@ -284,10 +288,10 @@ export class CheckoutComponent implements OnInit { itemColorLabel(item: any): string { const shopColor = String(item?.shopVariantColorName ?? '').trim(); if (shopColor) { - return shopColor; + return getColorLabelToken(shopColor) ?? '-'; } const raw = String(item?.colorCode ?? '').trim(); - return raw || '-'; + return getColorLabelToken(raw) ?? '-'; } itemColorSwatch(item: any): string { @@ -310,12 +314,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; } @@ -373,7 +377,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, + ); } } } diff --git a/frontend/src/app/features/order/order.component.html b/frontend/src/app/features/order/order.component.html index 59cbdeb..ac7d314 100644 --- a/frontend/src/app/features/order/order.component.html +++ b/frontend/src/app/features/order/order.component.html @@ -248,7 +248,8 @@ }} - {{ "SHOP.VARIANT" | translate }}: {{ variantLabel }} + {{ "SHOP.VARIANT" | translate }}: + {{ variantLabel | translate }} - {{ itemColorLabel(item) }} + {{ itemColorLabel(item) | translate }}
diff --git a/frontend/src/app/features/order/order.component.ts b/frontend/src/app/features/order/order.component.ts index 09d49ff..cee461e 100644 --- a/frontend/src/app/features/order/order.component.ts +++ b/frontend/src/app/features/order/order.component.ts @@ -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, + getColorLabelToken, +} from '../../core/constants/colors.const'; import { PriceBreakdownComponent, PriceBreakdownRow, @@ -278,23 +282,28 @@ export class OrderComponent implements OnInit { return variantLabel; } - const colorName = String(item?.shopVariantColorName ?? '').trim(); - return colorName || null; + return getColorLabelToken(item?.shopVariantColorName); } itemColorLabel(item: PublicOrderItem): string { const shopColor = String(item?.shopVariantColorName ?? '').trim(); if (shopColor) { - return shopColor; + return getColorLabelToken(shopColor) ?? this.translate.instant('ORDER.NOT_AVAILABLE'); } const filamentColor = String(item?.filamentColorName ?? '').trim(); if (filamentColor) { - return filamentColor; + return ( + getColorLabelToken(filamentColor) ?? + this.translate.instant('ORDER.NOT_AVAILABLE') + ); } const rawColor = String(item?.colorCode ?? '').trim(); - return rawColor || this.translate.instant('ORDER.NOT_AVAILABLE'); + return ( + getColorLabelToken(rawColor) ?? + this.translate.instant('ORDER.NOT_AVAILABLE') + ); } itemColorHex(item: PublicOrderItem): string | null { @@ -313,7 +322,7 @@ export class OrderComponent implements OnInit { return rawColor; } - return null; + return findColorHex(rawColor); } showItemMaterial(item: PublicOrderItem): boolean { diff --git a/frontend/src/app/features/shop/product-detail.component.html b/frontend/src/app/features/shop/product-detail.component.html index 65aaf4e..96439d6 100644 --- a/frontend/src/app/features/shop/product-detail.component.html +++ b/frontend/src/app/features/shop/product-detail.component.html @@ -152,7 +152,7 @@ @if (selectedMaterial()?.label) { } - {{ colorLabel(activeVariant) }} + {{ colorLabel(activeVariant) | translate }} }

} @@ -267,7 +267,9 @@ - {{ colorLabel(activeVariant) }} + {{ + colorLabel(activeVariant) | translate + }} {{ selectedMaterial()?.label }} @@ -304,7 +306,7 @@ {{ - colorLabel(variant) + colorLabel(variant) | translate }} } diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts index c74a32b..0ef11cc 100644 --- a/frontend/src/app/features/shop/product-detail.component.ts +++ b/frontend/src/app/features/shop/product-detail.component.ts @@ -15,7 +15,11 @@ 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, + getColorLabelToken, +} 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'; @@ -403,7 +407,9 @@ export class ProductDetailComponent { } colorLabel(variant: ShopProductVariantOption): string { - return variant.colorName || variant.variantLabel || '-'; + return ( + getColorLabelToken(variant.colorName || variant.variantLabel) ?? '-' + ); } colorHex(variant: ShopProductVariantOption | null | undefined): string { @@ -524,17 +530,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 { diff --git a/frontend/src/app/features/shop/shop-page.component.html b/frontend/src/app/features/shop/shop-page.component.html index 2471b43..e2950fe 100644 --- a/frontend/src/app/features/shop/shop-page.component.html +++ b/frontend/src/app/features/shop/shop-page.component.html @@ -84,7 +84,7 @@
{{ cartItemName(item) }} @if (cartItemVariant(item); as variant) { - {{ variant }} + {{ variant | translate }} } @if (cartItemColor(item); as color) { @@ -92,7 +92,7 @@ class="color-dot" [style.background-color]="cartItemColorHex(item)" > - {{ color }} + {{ color | translate }} }
diff --git a/frontend/src/app/features/shop/shop-page.component.ts b/frontend/src/app/features/shop/shop-page.component.ts index 3dbf4e9..90c1a19 100644 --- a/frontend/src/app/features/shop/shop-page.component.ts +++ b/frontend/src/app/features/shop/shop-page.component.ts @@ -22,6 +22,10 @@ import { } from 'rxjs'; import { SeoService } from '../../core/services/seo.service'; import { LanguageService } from '../../core/services/language.service'; +import { + findColorHex, + getColorLabelToken, +} from '../../core/constants/colors.const'; import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; import { ProductCardComponent } from './components/product-card/product-card.component'; @@ -157,15 +161,25 @@ export class ShopPageComponent { } cartItemVariant(item: ShopCartItem): string | null { - return item.shopVariantLabel || item.shopVariantColorName || null; + return ( + item.shopVariantLabel || getColorLabelToken(item.shopVariantColorName) + ); } cartItemColor(item: ShopCartItem): string | null { - return item.shopVariantColorName || item.colorCode || null; + return ( + getColorLabelToken(item.shopVariantColorName) ?? + getColorLabelToken(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 { diff --git a/frontend/src/app/shared/components/color-selector/color-selector.component.ts b/frontend/src/app/shared/components/color-selector/color-selector.component.ts index a45231b..1777686 100644 --- a/frontend/src/app/shared/components/color-selector/color-selector.component.ts +++ b/frontend/src/app/shared/components/color-selector/color-selector.component.ts @@ -4,6 +4,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { PRODUCT_COLORS, getColorHex, + getColorLabelToken, ColorCategory, ColorOption, } from '../../../core/constants/colors.const'; @@ -32,7 +33,7 @@ export class ColorSelectorComponent { const finish = v.finishType || 'AVAILABLE_COLORS'; const bucket = byFinish.get(finish) || []; bucket.push({ - label: v.colorName, + label: getColorLabelToken(v.colorName) ?? v.colorName, value: v.colorName, hex: v.hexColor, variantId: v.id, diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index 223fd36..cb0bbca 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -569,6 +569,13 @@ "BLUE": "Blau", "GREEN": "Grün", "YELLOW": "Gelb", + "ORANGE": "Orange", + "GRAY": "Grau", + "LIGHT_GRAY": "Hellgrau", + "DARK_GRAY": "Dunkelgrau", + "PURPLE": "Lila", + "BEIGE": "Beige", + "SAND_BEIGE": "Sandbeige", "MATTE_BLACK": "Matt Schwarz", "MATTE_WHITE": "Matt Weiß", "MATTE_GRAY": "Matt Grau" diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index 8f8ffbe..600c48d 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -569,6 +569,13 @@ "BLUE": "Blue", "GREEN": "Green", "YELLOW": "Yellow", + "ORANGE": "Orange", + "GRAY": "Gray", + "LIGHT_GRAY": "Light Gray", + "DARK_GRAY": "Dark Gray", + "PURPLE": "Purple", + "BEIGE": "Beige", + "SAND_BEIGE": "Sand Beige", "MATTE_BLACK": "Matte Black", "MATTE_WHITE": "Matte White", "MATTE_GRAY": "Matte Gray" diff --git a/frontend/src/assets/i18n/fr.json b/frontend/src/assets/i18n/fr.json index e3e80ab..6d27ad9 100644 --- a/frontend/src/assets/i18n/fr.json +++ b/frontend/src/assets/i18n/fr.json @@ -575,6 +575,13 @@ "BLUE": "Bleu", "GREEN": "Vert", "YELLOW": "Jaune", + "ORANGE": "Orange", + "GRAY": "Gris", + "LIGHT_GRAY": "Gris clair", + "DARK_GRAY": "Gris foncé", + "PURPLE": "Violet", + "BEIGE": "Beige", + "SAND_BEIGE": "Beige sable", "MATTE_BLACK": "Noir mat", "MATTE_WHITE": "Blanc mat", "MATTE_GRAY": "Gris mat" diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index 006e9ff..13ccb71 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -624,6 +624,13 @@ "BLUE": "Blu", "GREEN": "Verde", "YELLOW": "Giallo", + "ORANGE": "Arancione", + "GRAY": "Grigio", + "LIGHT_GRAY": "Grigio chiaro", + "DARK_GRAY": "Grigio scuro", + "PURPLE": "Viola", + "BEIGE": "Beige", + "SAND_BEIGE": "Beige sabbia", "MATTE_BLACK": "Nero opaco", "MATTE_WHITE": "Bianco opaco", "MATTE_GRAY": "Grigio opaco" -- 2.49.1 From 00af9a9701907681843de55334da108fceb83e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Fri, 13 Mar 2026 16:16:49 +0100 Subject: [PATCH 3/6] feat(back-end front-end): shop improvements --- .../controller/OptionsController.java | 4 + .../dto/AdminFilamentVariantDto.java | 36 +++ .../dto/AdminShopCategoryDto.java | 144 +++++++++ .../dto/AdminShopProductVariantDto.java | 36 +++ .../AdminUpsertFilamentVariantRequest.java | 36 +++ .../dto/AdminUpsertShopCategoryRequest.java | 144 +++++++++ .../AdminUpsertShopProductVariantRequest.java | 36 +++ .../printcalculator/dto/OptionsResponse.java | 4 + .../com/printcalculator/dto/OrderItemDto.java | 32 ++ .../dto/ShopProductVariantOptionDto.java | 1 + .../entity/FilamentVariant.java | 100 ++++++ .../printcalculator/entity/ShopCategory.java | 284 ++++++++++++++++++ .../entity/ShopProductVariant.java | 100 ++++++ .../admin/AdminFilamentControllerService.java | 27 ++ .../AdminShopCategoryControllerService.java | 171 +++++++++-- .../AdminShopProductControllerService.java | 15 + .../order/AdminOrderControllerService.java | 8 + .../service/order/OrderControllerService.java | 8 + .../quote/QuoteSessionResponseAssembler.java | 8 + .../shop/PublicShopCatalogService.java | 62 ++-- .../entity/ShopCategoryTest.java | 55 ++++ db.sql | 116 +++++++ .../src/app/core/constants/colors.const.ts | 156 +++------- .../src/app/core/layout/navbar.component.ts | 17 +- .../pages/admin-filament-stock.component.html | 34 ++- .../pages/admin-filament-stock.component.ts | 12 + .../admin/pages/admin-shop.component.html | 184 +++++++++--- .../admin/pages/admin-shop.component.ts | 151 ++++++++-- .../services/admin-operations.service.ts | 8 + .../admin/services/admin-shop.service.ts | 40 +++ .../services/quote-estimator.service.ts | 4 + .../features/checkout/checkout.component.ts | 21 +- .../src/app/features/order/order.component.ts | 53 ++-- .../shop/product-detail.component.html | 6 +- .../shop/product-detail.component.scss | 4 - .../features/shop/product-detail.component.ts | 31 +- .../features/shop/services/shop.service.ts | 5 + .../app/features/shop/shop-page.component.ts | 17 +- .../color-selector.component.ts | 15 +- 39 files changed, 1886 insertions(+), 299 deletions(-) create mode 100644 backend/src/test/java/com/printcalculator/entity/ShopCategoryTest.java diff --git a/backend/src/main/java/com/printcalculator/controller/OptionsController.java b/backend/src/main/java/com/printcalculator/controller/OptionsController.java index 28a1abb..d4f65e1 100644 --- a/backend/src/main/java/com/printcalculator/controller/OptionsController.java +++ b/backend/src/main/java/com/printcalculator/controller/OptionsController.java @@ -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, diff --git a/backend/src/main/java/com/printcalculator/dto/AdminFilamentVariantDto.java b/backend/src/main/java/com/printcalculator/dto/AdminFilamentVariantDto.java index 88b32ac..f5cb9a3 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminFilamentVariantDto.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminFilamentVariantDto.java @@ -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; } diff --git a/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryDto.java b/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryDto.java index 3e43c0d..a61c326 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryDto.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryDto.java @@ -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; } diff --git a/backend/src/main/java/com/printcalculator/dto/AdminShopProductVariantDto.java b/backend/src/main/java/com/printcalculator/dto/AdminShopProductVariantDto.java index e03c629..9a32330 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminShopProductVariantDto.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminShopProductVariantDto.java @@ -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; } diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentVariantRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentVariantRequest.java index 89cd51c..820141a 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentVariantRequest.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentVariantRequest.java @@ -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; } diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopCategoryRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopCategoryRequest.java index 28096f2..a8ed10f 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopCategoryRequest.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopCategoryRequest.java @@ -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; } diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductVariantRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductVariantRequest.java index 14ef9af..2b84871 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductVariantRequest.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductVariantRequest.java @@ -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; } diff --git a/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java b/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java index 9b85460..566c366 100644 --- a/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java +++ b/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java @@ -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, diff --git a/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java b/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java index efbcc87..8c21d5e 100644 --- a/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java +++ b/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java @@ -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; } diff --git a/backend/src/main/java/com/printcalculator/dto/ShopProductVariantOptionDto.java b/backend/src/main/java/com/printcalculator/dto/ShopProductVariantOptionDto.java index 318a87c..c959bb4 100644 --- a/backend/src/main/java/com/printcalculator/dto/ShopProductVariantOptionDto.java +++ b/backend/src/main/java/com/printcalculator/dto/ShopProductVariantOptionDto.java @@ -8,6 +8,7 @@ public record ShopProductVariantOptionDto( String sku, String variantLabel, String colorName, + String colorLabel, String colorHex, BigDecimal priceChf, Boolean isDefault diff --git a/backend/src/main/java/com/printcalculator/entity/FilamentVariant.java b/backend/src/main/java/com/printcalculator/entity/FilamentVariant.java index e2f8bf5..465635e 100644 --- a/backend/src/main/java/com/printcalculator/entity/FilamentVariant.java +++ b/backend/src/main/java/com/printcalculator/entity/FilamentVariant.java @@ -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; + } + } diff --git a/backend/src/main/java/com/printcalculator/entity/ShopCategory.java b/backend/src/main/java/com/printcalculator/entity/ShopCategory.java index a018a97..87b4dd0 100644 --- a/backend/src/main/java/com/printcalculator/entity/ShopCategory.java +++ b/backend/src/main/java/com/printcalculator/entity/ShopCategory.java @@ -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 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; + } } diff --git a/backend/src/main/java/com/printcalculator/entity/ShopProductVariant.java b/backend/src/main/java/com/printcalculator/entity/ShopProductVariant.java index d1d6d03..24932a3 100644 --- a/backend/src/main/java/com/printcalculator/entity/ShopProductVariant.java +++ b/backend/src/main/java/com/printcalculator/entity/ShopProductVariant.java @@ -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; + } } diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminFilamentControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminFilamentControllerService.java index 1fc2de4..b540984 100644 --- a/backend/src/main/java/com/printcalculator/service/admin/AdminFilamentControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminFilamentControllerService.java @@ -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()); diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminShopCategoryControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminShopCategoryControllerService.java index e327ac6..e7665c9 100644 --- a/backend/src/main/java/com/printcalculator/service/admin/AdminShopCategoryControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminShopCategoryControllerService.java @@ -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 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 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 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 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 categories = shopCategoryRepository.findAllByOrderBySortOrderAscNameAsc(); List 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 descendantProductCounts ) { } + + private record LocalizedCategoryContent( + String defaultName, + String defaultDescription, + String defaultSeoTitle, + String defaultSeoDescription, + Map names, + Map descriptions, + Map seoTitles, + Map seoDescriptions + ) { + } } diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java index f562a92..225d952 100644 --- a/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java @@ -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()); diff --git a/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java b/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java index 164ac74..2e15aa8 100644 --- a/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java @@ -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()); diff --git a/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java b/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java index 9b1ae40..69c36cb 100644 --- a/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java @@ -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()); diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java index 555ecc5..375d7fa 100644 --- a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java @@ -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()); diff --git a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java index e37c450..62636a1 100644 --- a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java +++ b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java @@ -71,7 +71,7 @@ public class PublicShopCatalogService { public List 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 buildCategoryTree(UUID parentId, CategoryContext categoryContext) { + private List 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 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 buildCategoryBreadcrumbs(ShopCategory category) { + private List buildCategoryBreadcrumbs(ShopCategory category, String language) { List 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 variantColorHexByMaterialAndColor) { + Map 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()) diff --git a/backend/src/test/java/com/printcalculator/entity/ShopCategoryTest.java b/backend/src/test/java/com/printcalculator/entity/ShopCategoryTest.java new file mode 100644 index 0000000..ac987e8 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/entity/ShopCategoryTest.java @@ -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")); + } +} diff --git a/db.sql b/db.sql index c3975e6..3ad02dd 100644 --- a/db.sql +++ b/db.sql @@ -44,6 +44,10 @@ create table filament_variant variant_display_name text not null, -- es: "PLA Nero Opaco BrandX" color_name text not null, -- Nero, Bianco, ecc. + color_label_it text, + color_label_en text, + color_label_de text, + color_label_fr text, color_hex text, finish_type text not null default 'GLOSSY' check (finish_type in ('GLOSSY', 'MATTE', 'MARBLE', 'SILK', 'TRANSLUCENT', 'SPECIAL')), @@ -70,6 +74,22 @@ select filament_variant_id, (stock_spools * spool_net_kg) as stock_kg from filament_variant; +alter table filament_variant + add column if not exists color_label_it text, + add column if not exists color_label_en text, + add column if not exists color_label_de text, + add column if not exists color_label_fr text; + +update filament_variant +set color_label_it = coalesce(nullif(btrim(color_label_it), ''), color_name), + color_label_en = coalesce(nullif(btrim(color_label_en), ''), color_name), + color_label_de = coalesce(nullif(btrim(color_label_de), ''), color_name), + color_label_fr = coalesce(nullif(btrim(color_label_fr), ''), color_name) +where nullif(btrim(color_label_it), '') is null + or nullif(btrim(color_label_en), '') is null + or nullif(btrim(color_label_de), '') is null + or nullif(btrim(color_label_fr), '') is null; + create table printer_machine_profile ( printer_machine_profile_id bigserial primary key, @@ -1013,9 +1033,25 @@ CREATE TABLE IF NOT EXISTS shop_category parent_category_id uuid REFERENCES shop_category (shop_category_id) ON DELETE SET NULL, slug text NOT NULL UNIQUE, name text NOT NULL, + name_it text, + name_en text, + name_de text, + name_fr text, description text, + description_it text, + description_en text, + description_de text, + description_fr text, seo_title text, + seo_title_it text, + seo_title_en text, + seo_title_de text, + seo_title_fr text, seo_description text, + seo_description_it text, + seo_description_en text, + seo_description_de text, + seo_description_fr text, og_title text, og_description text, indexable boolean NOT NULL DEFAULT true, @@ -1034,6 +1070,66 @@ CREATE INDEX IF NOT EXISTS ix_shop_category_parent_sort CREATE INDEX IF NOT EXISTS ix_shop_category_active_sort ON shop_category (is_active, sort_order, created_at DESC); +ALTER TABLE shop_category + ADD COLUMN IF NOT EXISTS name_it text, + ADD COLUMN IF NOT EXISTS name_en text, + ADD COLUMN IF NOT EXISTS name_de text, + ADD COLUMN IF NOT EXISTS name_fr text, + ADD COLUMN IF NOT EXISTS description_it text, + ADD COLUMN IF NOT EXISTS description_en text, + ADD COLUMN IF NOT EXISTS description_de text, + ADD COLUMN IF NOT EXISTS description_fr text, + ADD COLUMN IF NOT EXISTS seo_title_it text, + ADD COLUMN IF NOT EXISTS seo_title_en text, + ADD COLUMN IF NOT EXISTS seo_title_de text, + ADD COLUMN IF NOT EXISTS seo_title_fr text, + ADD COLUMN IF NOT EXISTS seo_description_it text, + ADD COLUMN IF NOT EXISTS seo_description_en text, + ADD COLUMN IF NOT EXISTS seo_description_de text, + ADD COLUMN IF NOT EXISTS seo_description_fr text; + +UPDATE shop_category +SET + name_it = COALESCE(NULLIF(btrim(name_it), ''), name), + name_en = COALESCE(NULLIF(btrim(name_en), ''), name), + name_de = COALESCE(NULLIF(btrim(name_de), ''), name), + name_fr = COALESCE(NULLIF(btrim(name_fr), ''), name), + description_it = COALESCE(NULLIF(btrim(description_it), ''), description), + description_en = COALESCE(NULLIF(btrim(description_en), ''), description), + description_de = COALESCE(NULLIF(btrim(description_de), ''), description), + description_fr = COALESCE(NULLIF(btrim(description_fr), ''), description), + seo_title_it = COALESCE(NULLIF(btrim(seo_title_it), ''), seo_title), + seo_title_en = COALESCE(NULLIF(btrim(seo_title_en), ''), seo_title), + seo_title_de = COALESCE(NULLIF(btrim(seo_title_de), ''), seo_title), + seo_title_fr = COALESCE(NULLIF(btrim(seo_title_fr), ''), seo_title), + seo_description_it = COALESCE(NULLIF(btrim(seo_description_it), ''), seo_description), + seo_description_en = COALESCE(NULLIF(btrim(seo_description_en), ''), seo_description), + seo_description_de = COALESCE(NULLIF(btrim(seo_description_de), ''), seo_description), + seo_description_fr = COALESCE(NULLIF(btrim(seo_description_fr), ''), seo_description) +WHERE + NULLIF(btrim(name_it), '') IS NULL + OR NULLIF(btrim(name_en), '') IS NULL + OR NULLIF(btrim(name_de), '') IS NULL + OR NULLIF(btrim(name_fr), '') IS NULL + OR (description IS NOT NULL AND ( + NULLIF(btrim(description_it), '') IS NULL + OR NULLIF(btrim(description_en), '') IS NULL + OR NULLIF(btrim(description_de), '') IS NULL + OR NULLIF(btrim(description_fr), '') IS NULL + )) + OR (seo_title IS NOT NULL AND ( + NULLIF(btrim(seo_title_it), '') IS NULL + OR NULLIF(btrim(seo_title_en), '') IS NULL + OR NULLIF(btrim(seo_title_de), '') IS NULL + OR NULLIF(btrim(seo_title_fr), '') IS NULL + )) + OR (seo_description IS NOT NULL AND ( + NULLIF(btrim(seo_description_it), '') IS NULL + OR NULLIF(btrim(seo_description_en), '') IS NULL + OR NULLIF(btrim(seo_description_de), '') IS NULL + OR NULLIF(btrim(seo_description_fr), '') IS NULL + )); + CREATE TABLE IF NOT EXISTS shop_product ( shop_product_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), @@ -1165,6 +1261,10 @@ CREATE TABLE IF NOT EXISTS shop_product_variant sku text UNIQUE, variant_label text NOT NULL, color_name text NOT NULL, + color_label_it text, + color_label_en text, + color_label_de text, + color_label_fr text, color_hex text, internal_material_code text NOT NULL, price_chf numeric(12, 2) NOT NULL DEFAULT 0.00 CHECK (price_chf >= 0), @@ -1181,6 +1281,22 @@ CREATE INDEX IF NOT EXISTS ix_shop_product_variant_product_active_sort CREATE INDEX IF NOT EXISTS ix_shop_product_variant_sku ON shop_product_variant (sku); +ALTER TABLE shop_product_variant + ADD COLUMN IF NOT EXISTS color_label_it text, + ADD COLUMN IF NOT EXISTS color_label_en text, + ADD COLUMN IF NOT EXISTS color_label_de text, + ADD COLUMN IF NOT EXISTS color_label_fr text; + +UPDATE shop_product_variant +SET color_label_it = COALESCE(NULLIF(btrim(color_label_it), ''), color_name), + color_label_en = COALESCE(NULLIF(btrim(color_label_en), ''), color_name), + color_label_de = COALESCE(NULLIF(btrim(color_label_de), ''), color_name), + color_label_fr = COALESCE(NULLIF(btrim(color_label_fr), ''), color_name) +WHERE NULLIF(btrim(color_label_it), '') IS NULL + OR NULLIF(btrim(color_label_en), '') IS NULL + OR NULLIF(btrim(color_label_de), '') IS NULL + OR NULLIF(btrim(color_label_fr), '') IS NULL; + CREATE TABLE IF NOT EXISTS shop_product_model_asset ( shop_product_model_asset_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/frontend/src/app/core/constants/colors.const.ts b/frontend/src/app/core/constants/colors.const.ts index 1223848..4a0911b 100644 --- a/frontend/src/app/core/constants/colors.const.ts +++ b/frontend/src/app/core/constants/colors.const.ts @@ -40,93 +40,6 @@ export const PRODUCT_COLORS: ColorCategory[] = [ }, ]; -const COLOR_HEX_BY_TRANSLATION_KEY: Record = { - ...Object.fromEntries( - PRODUCT_COLORS.flatMap((category) => - category.colors.map((color) => [color.label, color.hex] as const), - ), - ), - 'COLOR.NAME.ORANGE': '#f5a623', - 'COLOR.NAME.GRAY': '#b7b7b7', - 'COLOR.NAME.LIGHT_GRAY': '#d8dadd', - 'COLOR.NAME.DARK_GRAY': '#4f4f4f', - 'COLOR.NAME.PURPLE': '#7b1fa2', - 'COLOR.NAME.BEIGE': '#d4c09a', - 'COLOR.NAME.SAND_BEIGE': '#d7c2a0', -}; - -const COLOR_TRANSLATION_KEY_BY_VALUE: Record = { - black: 'COLOR.NAME.BLACK', - nero: 'COLOR.NAME.BLACK', - noir: 'COLOR.NAME.BLACK', - schwarz: 'COLOR.NAME.BLACK', - white: 'COLOR.NAME.WHITE', - bianco: 'COLOR.NAME.WHITE', - blanc: 'COLOR.NAME.WHITE', - weiss: 'COLOR.NAME.WHITE', - red: 'COLOR.NAME.RED', - rosso: 'COLOR.NAME.RED', - rouge: 'COLOR.NAME.RED', - rot: 'COLOR.NAME.RED', - blue: 'COLOR.NAME.BLUE', - blu: 'COLOR.NAME.BLUE', - bleu: 'COLOR.NAME.BLUE', - blau: 'COLOR.NAME.BLUE', - green: 'COLOR.NAME.GREEN', - verde: 'COLOR.NAME.GREEN', - vert: 'COLOR.NAME.GREEN', - grun: 'COLOR.NAME.GREEN', - yellow: 'COLOR.NAME.YELLOW', - giallo: 'COLOR.NAME.YELLOW', - jaune: 'COLOR.NAME.YELLOW', - gelb: 'COLOR.NAME.YELLOW', - orange: 'COLOR.NAME.ORANGE', - arancione: 'COLOR.NAME.ORANGE', - naranja: 'COLOR.NAME.ORANGE', - gris: 'COLOR.NAME.GRAY', - gray: 'COLOR.NAME.GRAY', - grey: 'COLOR.NAME.GRAY', - grigio: 'COLOR.NAME.GRAY', - grau: 'COLOR.NAME.GRAY', - 'light gray': 'COLOR.NAME.LIGHT_GRAY', - 'light grey': 'COLOR.NAME.LIGHT_GRAY', - 'grigio chiaro': 'COLOR.NAME.LIGHT_GRAY', - 'gris clair': 'COLOR.NAME.LIGHT_GRAY', - hellgrau: 'COLOR.NAME.LIGHT_GRAY', - 'dark gray': 'COLOR.NAME.DARK_GRAY', - 'dark grey': 'COLOR.NAME.DARK_GRAY', - 'grigio scuro': 'COLOR.NAME.DARK_GRAY', - 'gris fonce': 'COLOR.NAME.DARK_GRAY', - dunkelgrau: 'COLOR.NAME.DARK_GRAY', - purple: 'COLOR.NAME.PURPLE', - violet: 'COLOR.NAME.PURPLE', - viola: 'COLOR.NAME.PURPLE', - lila: 'COLOR.NAME.PURPLE', - beige: 'COLOR.NAME.BEIGE', - 'sand beige': 'COLOR.NAME.SAND_BEIGE', - 'beige sabbia': 'COLOR.NAME.SAND_BEIGE', - 'beige sable': 'COLOR.NAME.SAND_BEIGE', - sandbeige: 'COLOR.NAME.SAND_BEIGE', - 'matte black': 'COLOR.NAME.MATTE_BLACK', - 'black matte': 'COLOR.NAME.MATTE_BLACK', - 'nero opaco': 'COLOR.NAME.MATTE_BLACK', - 'noir mat': 'COLOR.NAME.MATTE_BLACK', - 'matt schwarz': 'COLOR.NAME.MATTE_BLACK', - 'schwarz matt': 'COLOR.NAME.MATTE_BLACK', - 'matte white': 'COLOR.NAME.MATTE_WHITE', - 'white matte': 'COLOR.NAME.MATTE_WHITE', - 'bianco opaco': 'COLOR.NAME.MATTE_WHITE', - 'blanc mat': 'COLOR.NAME.MATTE_WHITE', - 'matt weiss': 'COLOR.NAME.MATTE_WHITE', - 'weiss matt': 'COLOR.NAME.MATTE_WHITE', - 'matte gray': 'COLOR.NAME.MATTE_GRAY', - 'matte grey': 'COLOR.NAME.MATTE_GRAY', - 'grigio opaco': 'COLOR.NAME.MATTE_GRAY', - 'gris mat': 'COLOR.NAME.MATTE_GRAY', - 'matt grau': 'COLOR.NAME.MATTE_GRAY', - 'grau matt': 'COLOR.NAME.MATTE_GRAY', -}; - export function normalizeColorValue(value: string | null | undefined): string { return String(value ?? '') .trim() @@ -138,30 +51,7 @@ export function normalizeColorValue(value: string | null | undefined): string { .replace(/\s+/g, ' '); } -export function getColorTranslationKey( - value: string | null | undefined, -): string | null { - const normalized = normalizeColorValue(value); - return normalized ? COLOR_TRANSLATION_KEY_BY_VALUE[normalized] ?? null : null; -} - -export function getColorLabelToken( - value: string | null | undefined, -): string | null { - const raw = String(value ?? '').trim(); - if (!raw) { - return null; - } - - return getColorTranslationKey(raw) ?? raw; -} - export function findColorHex(value: string | null | undefined): string | null { - const translationKey = getColorTranslationKey(value); - if (translationKey) { - return COLOR_HEX_BY_TRANSLATION_KEY[translationKey] ?? null; - } - const normalized = normalizeColorValue(value); if (!normalized) { return null; @@ -179,6 +69,52 @@ export function findColorHex(value: string | null | undefined): string | null { 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 { + for (const value of values) { + const normalized = String(value ?? '').trim(); + if (normalized) { + return normalized; + } + } + return null; +} + export function getColorHex(value: string): string { return findColorHex(value) ?? DEFAULT_BRAND_COLOR; } diff --git a/frontend/src/app/core/layout/navbar.component.ts b/frontend/src/app/core/layout/navbar.component.ts index 1e2f870..cbd17e1 100644 --- a/frontend/src/app/core/layout/navbar.component.ts +++ b/frontend/src/app/core/layout/navbar.component.ts @@ -17,7 +17,7 @@ import { import { finalize } from 'rxjs'; import { findColorHex, - getColorLabelToken, + resolveLocalizedColorLabel, } from '../constants/colors.const'; @Component({ @@ -147,15 +147,20 @@ export class NavbarComponent { } cartItemVariant(item: ShopCartItem): string | null { - return ( - item.shopVariantLabel || getColorLabelToken(item.shopVariantColorName) - ); + return item.shopVariantLabel || this.cartItemColor(item); } cartItemColor(item: ShopCartItem): string | null { return ( - getColorLabelToken(item.shopVariantColorName) ?? - getColorLabelToken(item.colorCode) + 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 ); } diff --git a/frontend/src/app/features/admin/pages/admin-filament-stock.component.html b/frontend/src/app/features/admin/pages/admin-filament-stock.component.html index 98ed4dd..63a6d7e 100644 --- a/frontend/src/app/features/admin/pages/admin-filament-stock.component.html +++ b/frontend/src/app/features/admin/pages/admin-filament-stock.component.html @@ -101,6 +101,22 @@ placeholder="Nero, Bianco..." /> + + + + + + + + - -