From a212a1d8ccbd29c4c5f7d946cc0f7cd305f07b12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Tue, 10 Mar 2026 08:31:29 +0100 Subject: [PATCH] feat(back-end): shop ui implementation --- .../printcalculator/service/OrderService.java | 4 +- .../payment/InvoicePdfRenderingService.java | 52 +- .../AdminOrderControllerSecurityTest.java | 138 ++++++ .../service/OrderServiceTest.java | 248 ++++++++++ .../media/PublicMediaQueryServiceTest.java | 8 +- .../InvoicePdfRenderingServiceTest.java | 85 ++++ .../service/shop/ShopCartServiceTest.java | 220 +++++++++ frontend/src/app/app.routes.ts | 1 - frontend/src/app/core/services/seo.service.ts | 35 +- .../pages/admin-dashboard.component.html | 112 +++-- .../pages/admin-dashboard.component.scss | 85 +++- .../admin/pages/admin-dashboard.component.ts | 108 ++++- .../admin/services/admin-orders.service.ts | 9 + .../features/checkout/checkout.component.html | 15 +- .../checkout/checkout.component.spec.ts | 64 +++ .../features/checkout/checkout.component.ts | 47 +- .../app/features/order/order.component.html | 330 ++++++++----- .../app/features/order/order.component.scss | 113 +++++ .../src/app/features/order/order.component.ts | 150 +++++- .../product-card/product-card.component.html | 61 ++- .../product-card/product-card.component.scss | 195 ++++++-- .../product-card/product-card.component.ts | 32 +- .../shop/product-detail.component.html | 235 ++++++++- .../shop/product-detail.component.scss | 370 +++++++++++++- .../features/shop/product-detail.component.ts | 307 +++++++++++- .../shop/services/shop.service.spec.ts | 144 ++++++ .../features/shop/services/shop.service.ts | 450 ++++++++++++++++-- .../features/shop/shop-page.component.html | 305 +++++++++++- .../features/shop/shop-page.component.scss | 355 ++++++++++++-- .../app/features/shop/shop-page.component.ts | 288 ++++++++++- frontend/src/app/features/shop/shop.routes.ts | 13 +- frontend/src/assets/i18n/it.json | 50 ++ 32 files changed, 4233 insertions(+), 396 deletions(-) create mode 100644 backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerSecurityTest.java create mode 100644 backend/src/test/java/com/printcalculator/service/OrderServiceTest.java create mode 100644 backend/src/test/java/com/printcalculator/service/payment/InvoicePdfRenderingServiceTest.java create mode 100644 backend/src/test/java/com/printcalculator/service/shop/ShopCartServiceTest.java create mode 100644 frontend/src/app/features/checkout/checkout.component.spec.ts create mode 100644 frontend/src/app/features/shop/services/shop.service.spec.ts diff --git a/backend/src/main/java/com/printcalculator/service/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java index 6426046..9179f7d 100644 --- a/backend/src/main/java/com/printcalculator/service/OrderService.java +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -191,7 +191,9 @@ public class OrderService { oItem.setShopVariantLabel(qItem.getShopVariantLabel()); oItem.setShopVariantColorName(qItem.getShopVariantColorName()); oItem.setShopVariantColorHex(qItem.getShopVariantColorHex()); - if (qItem.getFilamentVariant() != null + if (qItem.getMaterialCode() != null && !qItem.getMaterialCode().isBlank()) { + oItem.setMaterialCode(qItem.getMaterialCode()); + } else if (qItem.getFilamentVariant() != null && qItem.getFilamentVariant().getFilamentMaterialType() != null && qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode() != null) { oItem.setMaterialCode(qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode()); diff --git a/backend/src/main/java/com/printcalculator/service/payment/InvoicePdfRenderingService.java b/backend/src/main/java/com/printcalculator/service/payment/InvoicePdfRenderingService.java index 96ec578..0709c9c 100644 --- a/backend/src/main/java/com/printcalculator/service/payment/InvoicePdfRenderingService.java +++ b/backend/src/main/java/com/printcalculator/service/payment/InvoicePdfRenderingService.java @@ -88,14 +88,9 @@ public class InvoicePdfRenderingService { vars.put("shippingAddressLine2", order.getShippingZip() + " " + order.getShippingCity() + ", " + order.getShippingCountryCode()); } - List> invoiceLineItems = items.stream().map(i -> { - Map line = new HashMap<>(); - line.put("description", "Stampa 3D: " + i.getOriginalFilename()); - line.put("quantity", i.getQuantity()); - line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf())); - line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf())); - return line; - }).collect(Collectors.toList()); + List> invoiceLineItems = items.stream() + .map(this::toInvoiceLineItem) + .collect(Collectors.toList()); if (order.getCadTotalChf() != null && order.getCadTotalChf().compareTo(BigDecimal.ZERO) > 0) { BigDecimal cadHours = order.getCadHours() != null ? order.getCadHours() : BigDecimal.ZERO; @@ -157,4 +152,45 @@ public class InvoicePdfRenderingService { private String formatCadHours(BigDecimal hours) { return hours.setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString(); } + + private Map toInvoiceLineItem(OrderItem item) { + Map line = new HashMap<>(); + line.put("description", buildLineDescription(item)); + line.put("quantity", item.getQuantity()); + line.put("unitPriceFormatted", String.format("CHF %.2f", item.getUnitPriceChf())); + line.put("lineTotalFormatted", String.format("CHF %.2f", item.getLineTotalChf())); + return line; + } + + private String buildLineDescription(OrderItem item) { + if (item == null) { + return "Articolo"; + } + + if ("SHOP_PRODUCT".equalsIgnoreCase(item.getItemType())) { + String productName = firstNonBlank( + item.getDisplayName(), + item.getShopProductName(), + item.getOriginalFilename(), + "Prodotto shop" + ); + String variantLabel = firstNonBlank(item.getShopVariantLabel(), item.getShopVariantColorName(), null); + return variantLabel != null ? productName + " - " + variantLabel : productName; + } + + String fileName = firstNonBlank(item.getDisplayName(), item.getOriginalFilename(), "File 3D"); + return "Stampa 3D: " + fileName; + } + + private String firstNonBlank(String... values) { + if (values == null || values.length == 0) { + return null; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } } diff --git a/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerSecurityTest.java b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerSecurityTest.java new file mode 100644 index 0000000..799526d --- /dev/null +++ b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerSecurityTest.java @@ -0,0 +1,138 @@ +package com.printcalculator.controller.admin; + +import com.printcalculator.config.SecurityConfig; +import com.printcalculator.service.order.AdminOrderControllerService; +import com.printcalculator.security.AdminLoginThrottleService; +import com.printcalculator.security.AdminSessionAuthenticationFilter; +import com.printcalculator.security.AdminSessionService; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.AbstractPlatformTransactionManager; +import org.springframework.transaction.support.DefaultTransactionStatus; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = {AdminAuthController.class, AdminOrderController.class}) +@Import({ + SecurityConfig.class, + AdminSessionAuthenticationFilter.class, + AdminSessionService.class, + AdminLoginThrottleService.class, + AdminOrderControllerSecurityTest.TransactionTestConfig.class +}) +@TestPropertySource(properties = { + "admin.password=test-admin-password", + "admin.session.secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "admin.session.ttl-minutes=60" +}) +class AdminOrderControllerSecurityTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AdminOrderControllerService adminOrderControllerService; + + @Test + void confirmationDocument_withoutAdminCookie_shouldReturn401() throws Exception { + UUID orderId = UUID.randomUUID(); + + mockMvc.perform(get("/api/admin/orders/{orderId}/documents/confirmation", orderId)) + .andExpect(status().isUnauthorized()); + } + + @Test + void confirmationDocument_withAdminCookie_shouldReturnPdf() throws Exception { + UUID orderId = UUID.randomUUID(); + when(adminOrderControllerService.downloadOrderConfirmation(orderId)) + .thenReturn(ResponseEntity.ok() + .contentType(MediaType.APPLICATION_PDF) + .body("confirmation".getBytes())); + + mockMvc.perform(get("/api/admin/orders/{orderId}/documents/confirmation", orderId) + .cookie(loginAndExtractCookie())) + .andExpect(status().isOk()) + .andExpect(content().bytes("confirmation".getBytes())); + } + + @Test + void invoiceDocument_withAdminCookie_shouldReturnPdf() throws Exception { + UUID orderId = UUID.randomUUID(); + when(adminOrderControllerService.downloadOrderInvoice(orderId)) + .thenReturn(ResponseEntity.ok() + .contentType(MediaType.APPLICATION_PDF) + .body("invoice".getBytes())); + + mockMvc.perform(get("/api/admin/orders/{orderId}/documents/invoice", orderId) + .cookie(loginAndExtractCookie())) + .andExpect(status().isOk()) + .andExpect(content().bytes("invoice".getBytes())); + } + + private Cookie loginAndExtractCookie() throws Exception { + MvcResult login = mockMvc.perform(post("/api/admin/auth/login") + .with(req -> { + req.setRemoteAddr("10.0.0.44"); + return req; + }) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"password\":\"test-admin-password\"}")) + .andExpect(status().isOk()) + .andReturn(); + + String setCookie = login.getResponse().getHeader(HttpHeaders.SET_COOKIE); + assertNotNull(setCookie); + String[] parts = setCookie.split(";", 2); + String[] keyValue = parts[0].split("=", 2); + return new Cookie(keyValue[0], keyValue.length > 1 ? keyValue[1] : ""); + } + + @TestConfiguration + static class TransactionTestConfig { + @Bean + PlatformTransactionManager transactionManager() { + return new AbstractPlatformTransactionManager() { + @Override + protected Object doGetTransaction() { + return new Object(); + } + + @Override + protected void doBegin(Object transaction, TransactionDefinition definition) { + // No-op transaction manager for WebMvc security tests. + } + + @Override + protected void doCommit(DefaultTransactionStatus status) { + // No-op transaction manager for WebMvc security tests. + } + + @Override + protected void doRollback(DefaultTransactionStatus status) { + // No-op transaction manager for WebMvc security tests. + } + }; + } + } +} diff --git a/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java b/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java new file mode 100644 index 0000000..0a671b8 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java @@ -0,0 +1,248 @@ +package com.printcalculator.service; + +import com.printcalculator.dto.AddressDto; +import com.printcalculator.dto.CreateOrderRequest; +import com.printcalculator.dto.CustomerDto; +import com.printcalculator.entity.Customer; +import com.printcalculator.entity.Order; +import com.printcalculator.entity.OrderItem; +import com.printcalculator.entity.Payment; +import com.printcalculator.entity.QuoteLineItem; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.entity.ShopCategory; +import com.printcalculator.entity.ShopProduct; +import com.printcalculator.entity.ShopProductVariant; +import com.printcalculator.event.OrderCreatedEvent; +import com.printcalculator.repository.CustomerRepository; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.repository.QuoteSessionRepository; +import com.printcalculator.service.payment.InvoicePdfRenderingService; +import com.printcalculator.service.payment.PaymentService; +import com.printcalculator.service.payment.QrBillService; +import com.printcalculator.service.storage.StorageService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OrderServiceTest { + + @Mock + private OrderRepository orderRepo; + @Mock + private OrderItemRepository orderItemRepo; + @Mock + private QuoteSessionRepository quoteSessionRepo; + @Mock + private QuoteLineItemRepository quoteLineItemRepo; + @Mock + private CustomerRepository customerRepo; + @Mock + private StorageService storageService; + @Mock + private InvoicePdfRenderingService invoiceService; + @Mock + private QrBillService qrBillService; + @Mock + private ApplicationEventPublisher eventPublisher; + @Mock + private PaymentService paymentService; + @Mock + private QuoteSessionTotalsService quoteSessionTotalsService; + + @InjectMocks + private OrderService service; + + @Test + void createOrderFromQuote_withShopCart_shouldPreserveShopSnapshotAndMaterialCode() 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("cable-management"); + category.setName("Cable Management"); + + ShopProduct product = new ShopProduct(); + product.setId(UUID.randomUUID()); + product.setCategory(category); + product.setSlug("desk-cable-clip"); + product.setName("Desk Cable Clip"); + + ShopProductVariant variant = new ShopProductVariant(); + variant.setId(UUID.randomUUID()); + variant.setProduct(product); + variant.setVariantLabel("Coral Red"); + variant.setColorName("Coral Red"); + variant.setColorHex("#ff6b6b"); + variant.setInternalMaterialCode("PLA-MATTE"); + variant.setPriceChf(new BigDecimal("14.90")); + + Path sourceDir = Path.of("storage_quotes").toAbsolutePath().normalize().resolve(sessionId.toString()); + Files.createDirectories(sourceDir); + Path sourceFile = sourceDir.resolve("shop-demo.stl"); + Files.writeString(sourceFile, "solid demo\nendsolid demo\n", StandardCharsets.UTF_8); + + QuoteLineItem qItem = new QuoteLineItem(); + qItem.setId(UUID.randomUUID()); + qItem.setQuoteSession(session); + qItem.setStatus("READY"); + qItem.setLineItemType("SHOP_PRODUCT"); + qItem.setOriginalFilename("shop-demo.stl"); + qItem.setDisplayName("Desk Cable Clip"); + qItem.setQuantity(2); + qItem.setColorCode("Coral Red"); + qItem.setMaterialCode("PLA-MATTE"); + qItem.setShopProduct(product); + qItem.setShopProductVariant(variant); + qItem.setShopProductSlug(product.getSlug()); + qItem.setShopProductName(product.getName()); + qItem.setShopVariantLabel("Coral Red"); + qItem.setShopVariantColorName("Coral Red"); + qItem.setShopVariantColorHex("#ff6b6b"); + qItem.setBoundingBoxXMm(new BigDecimal("60.000")); + qItem.setBoundingBoxYMm(new BigDecimal("40.000")); + qItem.setBoundingBoxZMm(new BigDecimal("20.000")); + qItem.setUnitPriceChf(new BigDecimal("14.90")); + qItem.setStoredPath(sourceFile.toString()); + + Customer customer = new Customer(); + customer.setId(UUID.randomUUID()); + customer.setEmail("buyer@example.com"); + + 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(customer.getId()); + } + 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("29.80"), + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("29.80"), + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("2.00"), + new BigDecimal("31.80"), + 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("SHOP", order.getSourceType()); + assertEquals("CONVERTED", session.getStatus()); + assertEquals(orderId, session.getConvertedOrderId()); + assertAmountEquals("29.80", order.getSubtotalChf()); + assertAmountEquals("31.80", order.getTotalChf()); + + ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(OrderItem.class); + verify(orderItemRepo, times(2)).save(itemCaptor.capture()); + OrderItem savedItem = itemCaptor.getAllValues().getLast(); + assertEquals("SHOP_PRODUCT", savedItem.getItemType()); + assertEquals("Desk Cable Clip", savedItem.getDisplayName()); + assertEquals("PLA-MATTE", savedItem.getMaterialCode()); + assertEquals("desk-cable-clip", savedItem.getShopProductSlug()); + assertEquals("Desk Cable Clip", savedItem.getShopProductName()); + assertEquals("Coral Red", savedItem.getShopVariantLabel()); + assertEquals("Coral Red", savedItem.getShopVariantColorName()); + assertEquals("#ff6b6b", savedItem.getShopVariantColorHex()); + assertAmountEquals("14.90", savedItem.getUnitPriceChf()); + assertAmountEquals("29.80", savedItem.getLineTotalChf()); + + verify(storageService).store(eq(sourceFile), eq(Path.of( + "orders", orderId.toString(), "3d-files", orderItemId.toString(), savedItem.getStoredFilename() + ))); + verify(paymentService).getOrCreatePaymentForOrder(order, "OTHER"); + verify(eventPublisher).publishEvent(any(OrderCreatedEvent.class)); + } + + private CreateOrderRequest buildRequest() { + CustomerDto customer = new CustomerDto(); + customer.setEmail("buyer@example.com"); + customer.setPhone("+41790000000"); + customer.setCustomerType("PRIVATE"); + + AddressDto billing = new AddressDto(); + billing.setFirstName("Joe"); + billing.setLastName("Buyer"); + billing.setAddressLine1("Via Test 1"); + billing.setZip("6900"); + billing.setCity("Lugano"); + billing.setCountryCode("CH"); + + CreateOrderRequest request = new CreateOrderRequest(); + request.setCustomer(customer); + request.setBillingAddress(billing); + request.setShippingSameAsBilling(true); + request.setLanguage("it"); + request.setAcceptTerms(true); + request.setAcceptPrivacy(true); + return request; + } + + private void assertAmountEquals(String expected, BigDecimal actual) { + assertTrue(new BigDecimal(expected).compareTo(actual) == 0, + "Expected " + expected + " but got " + actual); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java b/backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java index 85288c7..b3be679 100644 --- a/backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/media/PublicMediaQueryServiceTest.java @@ -65,8 +65,8 @@ class PublicMediaQueryServiceTest { MediaUsage usageDraft = buildUsage(draftAsset, "HOME_SECTION", "shop-gallery", 0, false, true); MediaUsage usagePrivate = buildUsage(privateAsset, "HOME_SECTION", "shop-gallery", 3, false, true); - when(mediaUsageRepository.findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc( - "HOME_SECTION", "shop-gallery" + when(mediaUsageRepository.findActiveByUsageTypeAndUsageKeys( + "HOME_SECTION", List.of("shop-gallery") )).thenReturn(List.of(usageSecond, usageFirst, usageDraft, usagePrivate)); when(mediaVariantRepository.findByMediaAsset_IdIn(List.of(readyPublicAsset.getId()))) .thenReturn(List.of( @@ -93,8 +93,8 @@ class PublicMediaQueryServiceTest { MediaAsset asset = buildAsset("READY", "PUBLIC", "Joe portrait", "Joe portrait fallback"); MediaUsage usage = buildUsage(asset, "ABOUT_MEMBER", "joe", 0, true, true); - when(mediaUsageRepository.findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc( - "ABOUT_MEMBER", "joe" + when(mediaUsageRepository.findActiveByUsageTypeAndUsageKeys( + "ABOUT_MEMBER", List.of("joe") )).thenReturn(List.of(usage)); when(mediaVariantRepository.findByMediaAsset_IdIn(List.of(asset.getId()))) .thenReturn(List.of(buildVariant(asset, "card", "JPEG", "joe/card.jpg"))); diff --git a/backend/src/test/java/com/printcalculator/service/payment/InvoicePdfRenderingServiceTest.java b/backend/src/test/java/com/printcalculator/service/payment/InvoicePdfRenderingServiceTest.java new file mode 100644 index 0000000..9bfd928 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/payment/InvoicePdfRenderingServiceTest.java @@ -0,0 +1,85 @@ +package com.printcalculator.service.payment; + +import com.printcalculator.entity.Order; +import com.printcalculator.entity.OrderItem; +import org.junit.jupiter.api.Test; +import org.thymeleaf.TemplateEngine; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class InvoicePdfRenderingServiceTest { + + @Test + void generateDocumentPdf_shouldDescribeShopItemsWithProductAndVariant() { + CapturingInvoicePdfRenderingService service = new CapturingInvoicePdfRenderingService(); + QrBillService qrBillService = mock(QrBillService.class); + when(qrBillService.generateQrBillSvg(org.mockito.ArgumentMatchers.any(Order.class))) + .thenReturn("".getBytes(StandardCharsets.UTF_8)); + + Order order = new Order(); + order.setId(UUID.randomUUID()); + order.setCreatedAt(OffsetDateTime.parse("2026-03-10T10:15:30+01:00")); + order.setBillingCustomerType("PRIVATE"); + order.setBillingFirstName("Joe"); + order.setBillingLastName("Buyer"); + order.setBillingAddressLine1("Via Test 1"); + order.setBillingZip("6900"); + order.setBillingCity("Lugano"); + order.setBillingCountryCode("CH"); + order.setSetupCostChf(BigDecimal.ZERO); + order.setShippingCostChf(new BigDecimal("2.00")); + order.setSubtotalChf(new BigDecimal("36.80")); + order.setTotalChf(new BigDecimal("38.80")); + order.setCadTotalChf(BigDecimal.ZERO); + + OrderItem shopItem = new OrderItem(); + shopItem.setItemType("SHOP_PRODUCT"); + shopItem.setDisplayName("Desk Cable Clip"); + shopItem.setOriginalFilename("desk-cable-clip-demo.stl"); + shopItem.setShopProductName("Desk Cable Clip"); + shopItem.setShopVariantLabel("Coral Red"); + shopItem.setQuantity(2); + shopItem.setUnitPriceChf(new BigDecimal("14.90")); + shopItem.setLineTotalChf(new BigDecimal("29.80")); + + OrderItem printItem = new OrderItem(); + printItem.setItemType("PRINT_FILE"); + printItem.setDisplayName("gear-cover.stl"); + printItem.setOriginalFilename("gear-cover.stl"); + printItem.setQuantity(1); + printItem.setUnitPriceChf(new BigDecimal("7.00")); + printItem.setLineTotalChf(new BigDecimal("7.00")); + + byte[] pdf = service.generateDocumentPdf(order, List.of(shopItem, printItem), true, qrBillService, null); + + assertNotNull(pdf); + @SuppressWarnings("unchecked") + List> invoiceLineItems = (List>) service.capturedVariables.get("invoiceLineItems"); + assertEquals("Desk Cable Clip - Coral Red", invoiceLineItems.getFirst().get("description")); + assertEquals("Stampa 3D: gear-cover.stl", invoiceLineItems.get(1).get("description")); + } + + private static class CapturingInvoicePdfRenderingService extends InvoicePdfRenderingService { + private Map capturedVariables; + + private CapturingInvoicePdfRenderingService() { + super(mock(TemplateEngine.class)); + } + + @Override + public byte[] generateInvoicePdfBytesFromTemplate(Map invoiceTemplateVariables, String qrBillSvg) { + this.capturedVariables = invoiceTemplateVariables; + return new byte[]{1, 2, 3}; + } + } +} diff --git a/backend/src/test/java/com/printcalculator/service/shop/ShopCartServiceTest.java b/backend/src/test/java/com/printcalculator/service/shop/ShopCartServiceTest.java new file mode 100644 index 0000000..41e3154 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/shop/ShopCartServiceTest.java @@ -0,0 +1,220 @@ +package com.printcalculator.service.shop; + +import com.printcalculator.dto.ShopCartAddItemRequest; +import com.printcalculator.entity.QuoteLineItem; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.entity.ShopCategory; +import com.printcalculator.entity.ShopProduct; +import com.printcalculator.entity.ShopProductVariant; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.repository.QuoteSessionRepository; +import com.printcalculator.repository.ShopProductModelAssetRepository; +import com.printcalculator.repository.ShopProductVariantRepository; +import com.printcalculator.service.QuoteSessionTotalsService; +import com.printcalculator.service.quote.QuoteSessionResponseAssembler; +import com.printcalculator.service.quote.QuoteStorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ShopCartServiceTest { + + @Mock + private QuoteSessionRepository quoteSessionRepository; + @Mock + private QuoteLineItemRepository quoteLineItemRepository; + @Mock + private ShopProductVariantRepository shopProductVariantRepository; + @Mock + private ShopProductModelAssetRepository shopProductModelAssetRepository; + @Mock + private QuoteSessionTotalsService quoteSessionTotalsService; + @Mock + private QuoteSessionResponseAssembler quoteSessionResponseAssembler; + @Mock + private ShopStorageService shopStorageService; + @Mock + private ShopCartCookieService shopCartCookieService; + + private ShopCartService service; + + @BeforeEach + void setUp() { + service = new ShopCartService( + quoteSessionRepository, + quoteLineItemRepository, + shopProductVariantRepository, + shopProductModelAssetRepository, + quoteSessionTotalsService, + quoteSessionResponseAssembler, + new QuoteStorageService(), + shopStorageService, + shopCartCookieService + ); + } + + @Test + void addItem_shouldCreateServerCartAndPersistVariantPricingSnapshot() { + UUID sessionId = UUID.randomUUID(); + UUID lineItemId = UUID.randomUUID(); + UUID variantId = UUID.randomUUID(); + List savedItems = new ArrayList<>(); + + ShopProductVariant variant = buildVariant(variantId); + + when(shopCartCookieService.extractSessionId(any())).thenReturn(Optional.empty()); + when(shopCartCookieService.getCookieTtlDays()).thenReturn(30L); + when(shopProductVariantRepository.findById(variantId)).thenReturn(Optional.of(variant)); + when(shopProductModelAssetRepository.findByProduct_Id(variant.getProduct().getId())).thenReturn(Optional.empty()); + when(quoteSessionRepository.save(any(QuoteSession.class))).thenAnswer(invocation -> { + QuoteSession session = invocation.getArgument(0); + if (session.getId() == null) { + session.setId(sessionId); + } + return session; + }); + when(quoteLineItemRepository.findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id( + eq(sessionId), + eq("SHOP_PRODUCT"), + eq(variantId) + )).thenReturn(Optional.empty()); + when(quoteLineItemRepository.save(any(QuoteLineItem.class))).thenAnswer(invocation -> { + QuoteLineItem item = invocation.getArgument(0); + if (item.getId() == null) { + item.setId(lineItemId); + } + savedItems.clear(); + savedItems.add(item); + return item; + }); + when(quoteLineItemRepository.findByQuoteSessionIdOrderByCreatedAtAsc(sessionId)).thenAnswer(invocation -> List.copyOf(savedItems)); + when(quoteSessionTotalsService.compute(any(), any())).thenReturn( + new QuoteSessionTotalsService.QuoteSessionTotals( + new BigDecimal("22.80"), + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("22.80"), + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("2.00"), + new BigDecimal("24.80"), + BigDecimal.ZERO + ) + ); + when(quoteSessionResponseAssembler.assemble(any(), any(), any())).thenAnswer(invocation -> { + QuoteSession session = invocation.getArgument(0); + Map response = new HashMap<>(); + response.put("session", session); + response.put("items", List.of()); + response.put("grandTotalChf", new BigDecimal("24.80")); + return response; + }); + + ShopCartAddItemRequest payload = new ShopCartAddItemRequest(); + payload.setShopProductVariantId(variantId); + payload.setQuantity(2); + + ShopCartService.CartResult result = service.addItem(new MockHttpServletRequest(), payload); + + assertEquals(sessionId, result.sessionId()); + assertFalse(result.clearCookie()); + assertEquals(new BigDecimal("24.80"), result.response().get("grandTotalChf")); + + QuoteLineItem savedItem = savedItems.getFirst(); + assertEquals("SHOP_PRODUCT", savedItem.getLineItemType()); + assertEquals("Desk Cable Clip", savedItem.getDisplayName()); + assertEquals("desk-cable-clip", savedItem.getOriginalFilename()); + assertEquals(2, savedItem.getQuantity()); + assertEquals("PLA", savedItem.getMaterialCode()); + assertEquals("Coral Red", savedItem.getColorCode()); + assertEquals("Desk Cable Clip", savedItem.getShopProductName()); + assertEquals("Coral Red", savedItem.getShopVariantLabel()); + assertEquals("Coral Red", savedItem.getShopVariantColorName()); + assertAmountEquals("11.40", savedItem.getUnitPriceChf()); + assertNull(savedItem.getStoredPath()); + } + + @Test + void loadCart_withExpiredCookieSession_shouldExpireSessionAndAskCookieClear() { + UUID sessionId = UUID.randomUUID(); + QuoteSession session = new QuoteSession(); + session.setId(sessionId); + session.setSessionType("SHOP_CART"); + session.setStatus("ACTIVE"); + session.setExpiresAt(OffsetDateTime.now().minusHours(1)); + + Map emptyResponse = new HashMap<>(); + emptyResponse.put("session", null); + emptyResponse.put("items", List.of()); + + when(shopCartCookieService.hasCartCookie(any())).thenReturn(true); + when(shopCartCookieService.extractSessionId(any())).thenReturn(Optional.of(sessionId)); + when(quoteSessionRepository.findByIdAndSessionType(sessionId, "SHOP_CART")).thenReturn(Optional.of(session)); + when(quoteSessionRepository.save(any(QuoteSession.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(quoteSessionResponseAssembler.emptyCart()).thenReturn(emptyResponse); + + ShopCartService.CartResult result = service.loadCart(new MockHttpServletRequest()); + + assertTrue(result.clearCookie()); + assertNull(result.sessionId()); + assertEquals(emptyResponse, result.response()); + assertEquals("EXPIRED", session.getStatus()); + verify(quoteSessionRepository).save(session); + } + + private ShopProductVariant buildVariant(UUID variantId) { + ShopCategory category = new ShopCategory(); + category.setId(UUID.randomUUID()); + category.setSlug("cable-management"); + category.setName("Cable Management"); + category.setIsActive(true); + + ShopProduct product = new ShopProduct(); + product.setId(UUID.randomUUID()); + product.setCategory(category); + product.setSlug("desk-cable-clip"); + product.setName("Desk Cable Clip"); + product.setIsActive(true); + + ShopProductVariant variant = new ShopProductVariant(); + variant.setId(variantId); + variant.setProduct(product); + variant.setSku("DEMO-CLIP-CORAL"); + variant.setVariantLabel("Coral Red"); + variant.setColorName("Coral Red"); + variant.setColorHex("#ff6b6b"); + variant.setInternalMaterialCode("PLA"); + variant.setPriceChf(new BigDecimal("11.40")); + variant.setIsActive(true); + variant.setIsDefault(false); + return variant; + } + + private void assertAmountEquals(String expected, BigDecimal actual) { + assertTrue(new BigDecimal(expected).compareTo(actual) == 0, + "Expected " + expected + " but got " + actual); + } +} diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index c423e3b..c12362d 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -31,7 +31,6 @@ const appChildRoutes: Routes = [ seoTitle: 'Shop 3D fab', seoDescription: 'Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.', - seoRobots: 'noindex, nofollow', }, }, { diff --git a/frontend/src/app/core/services/seo.service.ts b/frontend/src/app/core/services/seo.service.ts index ef26bde..b12a3ad 100644 --- a/frontend/src/app/core/services/seo.service.ts +++ b/frontend/src/app/core/services/seo.service.ts @@ -4,6 +4,14 @@ import { Title, Meta } from '@angular/platform-browser'; import { ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router'; import { filter } from 'rxjs/operators'; +export interface PageSeoOverride { + title?: string | null; + description?: string | null; + robots?: string | null; + ogTitle?: string | null; + ogDescription?: string | null; +} + @Injectable({ providedIn: 'root', }) @@ -31,20 +39,43 @@ export class SeoService { }); } + applyPageSeo(override: PageSeoOverride): void { + const title = this.asString(override.title) ?? this.defaultTitle; + const description = + this.asString(override.description) ?? this.defaultDescription; + const robots = this.asString(override.robots) ?? 'index, follow'; + const ogTitle = this.asString(override.ogTitle) ?? title; + const ogDescription = this.asString(override.ogDescription) ?? description; + + this.applySeoValues(title, description, robots, ogTitle, ogDescription); + } + private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void { const mergedData = this.getMergedRouteData(rootSnapshot); const title = this.asString(mergedData['seoTitle']) ?? this.defaultTitle; const description = this.asString(mergedData['seoDescription']) ?? this.defaultDescription; const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow'; + const ogTitle = this.asString(mergedData['ogTitle']) ?? title; + const ogDescription = this.asString(mergedData['ogDescription']) ?? description; + this.applySeoValues(title, description, robots, ogTitle, ogDescription); + } + + private applySeoValues( + title: string, + description: string, + robots: string, + ogTitle: string, + ogDescription: string, + ): void { this.titleService.setTitle(title); this.metaService.updateTag({ name: 'description', content: description }); this.metaService.updateTag({ name: 'robots', content: robots }); - this.metaService.updateTag({ property: 'og:title', content: title }); + this.metaService.updateTag({ property: 'og:title', content: ogTitle }); this.metaService.updateTag({ property: 'og:description', - content: description, + content: ogDescription, }); this.metaService.updateTag({ property: 'og:type', content: 'website' }); this.metaService.updateTag({ name: 'twitter:card', content: 'summary' }); diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.html b/frontend/src/app/features/admin/pages/admin-dashboard.component.html index 55eb4c7..758e27d 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.html +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.html @@ -67,12 +67,26 @@ +
+ @@ -86,6 +100,15 @@ (click)="openDetails(order.id)" > + @@ -94,7 +117,7 @@ - @@ -105,7 +128,16 @@
-

Dettaglio ordine {{ selectedOrder.orderNumber }}

+
+

Dettaglio ordine {{ selectedOrder.orderNumber }}

+ + {{ orderKindLabel(selectedOrder) }} + +

UUID: Stato ordine{{ selectedOrder.status }}

+
+ Tipo ordine{{ orderKindLabel(selectedOrder) }} +
Totale{{ @@ -207,6 +242,7 @@ type="button" class="ui-button ui-button--ghost" (click)="openPrintDetails()" + [disabled]="!hasPrintItems(selectedOrder)" > Dettagli stampa @@ -215,38 +251,60 @@
-

- {{ item.originalFilename }} -

-

- Qta: {{ item.quantity }} | Materiale: - {{ getItemMaterialLabel(item) }} | Colore: +

+

+ {{ itemDisplayName(item) }} +

- - {{ getItemColorLabel(item) }} - - ({{ colorCode }}) - + class="item-kind-badge" + [class.item-kind-badge--shop]="isShopItem(item)" + > + {{ isShopItem(item) ? "Shop" : "Calcolatore" }} - | Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer: +
+

+ Qta: {{ item.quantity }} + + Materiale: {{ getItemMaterialLabel(item) }} + + + Variante: {{ variantLabel }} + + + Colore: + + + {{ getItemColorLabel(item) }} + + ({{ colorCode }}) + + + +

+

+ Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer: {{ item.layerHeightMm ?? "-" }} mm | Infill: {{ item.infillPercent ?? "-" }}% | Supporti: {{ formatSupports(item.supportsEnabled) }} - | Riga: +

+

+ Riga: {{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }}

- +
+ +
@@ -315,7 +373,7 @@

Parametri per file

-
+
{{ item.originalFilename }} {{ getItemMaterialLabel(item) }} | Colore: diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.scss b/frontend/src/app/features/admin/pages/admin-dashboard.component.scss index 622215f..cc20a1d 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.scss +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.scss @@ -21,10 +21,11 @@ .list-toolbar { display: grid; - grid-template-columns: minmax(230px, 1.6fr) minmax(170px, 1fr) minmax( - 190px, - 1fr - ); + grid-template-columns: + minmax(230px, 1.6fr) + minmax(170px, 1fr) + minmax(190px, 1fr) + minmax(170px, 1fr); gap: var(--space-2); margin-bottom: var(--space-3); } @@ -69,6 +70,13 @@ tbody tr.no-results:hover { margin: 0 0 var(--space-2); } +.detail-title-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); +} + .actions-block { display: flex; flex-wrap: wrap; @@ -113,6 +121,15 @@ tbody tr.no-results:hover { .item-main { min-width: 0; + display: grid; + gap: var(--space-2); +} + +.item-heading { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); } .file-name { @@ -124,7 +141,7 @@ tbody tr.no-results:hover { } .item-meta { - margin: var(--space-1) 0 0; + margin: 0; font-size: 0.84rem; color: var(--color-text-muted); display: flex; @@ -133,7 +150,33 @@ tbody tr.no-results:hover { flex-wrap: wrap; } -.item button { +.item-meta__color { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.item-tech, +.item-total { + margin: 0; + font-size: 0.84rem; +} + +.item-tech { + color: var(--color-text-muted); +} + +.item-total { + font-weight: 600; + color: var(--color-text); +} + +.item-actions { + display: flex; + align-items: flex-start; +} + +.item-actions button { justify-self: start; } @@ -150,6 +193,32 @@ tbody tr.no-results:hover { margin: 0; } +.order-type-badge, +.item-kind-badge { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 0.2rem 0.65rem; + background: var(--color-neutral-100); + color: var(--color-text-muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + white-space: nowrap; +} + +.order-type-badge--shop, +.item-kind-badge--shop { + background: color-mix(in srgb, var(--color-brand) 12%, white); + color: var(--color-brand); +} + +.order-type-badge--mixed { + background: color-mix(in srgb, #f59e0b 16%, white); + color: #9a5b00; +} + .modal-backdrop { position: fixed; inset: 0; @@ -247,6 +316,10 @@ h4 { align-items: flex-start; } + .item-actions { + width: 100%; + } + .actions-block { flex-direction: column; align-items: stretch; diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.ts b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts index 836425e..0d15464 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.ts +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts @@ -26,6 +26,7 @@ export class AdminDashboardComponent implements OnInit { orderSearchTerm = ''; paymentStatusFilter = 'ALL'; orderStatusFilter = 'ALL'; + orderTypeFilter = 'ALL'; showPrintDetails = false; loading = false; detailLoading = false; @@ -62,6 +63,7 @@ export class AdminDashboardComponent implements OnInit { 'COMPLETED', 'CANCELLED', ]; + readonly orderTypeFilterOptions = ['ALL', 'SHOP', 'CALCULATOR', 'MIXED']; ngOnInit(): void { this.loadOrders(); @@ -117,6 +119,11 @@ export class AdminDashboardComponent implements OnInit { this.applyListFiltersAndSelection(); } + onOrderTypeFilterChange(value: string): void { + this.orderTypeFilter = value || 'ALL'; + this.applyListFiltersAndSelection(); + } + openDetails(orderId: string): void { this.detailLoading = true; this.adminOrdersService.getOrder(orderId).subscribe({ @@ -124,6 +131,7 @@ export class AdminDashboardComponent implements OnInit { this.selectedOrder = order; this.selectedStatus = order.status; this.selectedPaymentMethod = order.paymentMethod || 'OTHER'; + this.showPrintDetails = this.showPrintDetails && this.hasPrintItems(order); this.detailLoading = false; }, error: () => { @@ -247,6 +255,9 @@ export class AdminDashboardComponent implements OnInit { } openPrintDetails(): void { + if (!this.selectedOrder || !this.hasPrintItems(this.selectedOrder)) { + return; + } this.showPrintDetails = true; } @@ -267,6 +278,34 @@ export class AdminDashboardComponent implements OnInit { return 'Bozza'; } + isShopItem(item: AdminOrderItem): boolean { + return String(item?.itemType ?? '').toUpperCase() === 'SHOP_PRODUCT'; + } + + itemDisplayName(item: AdminOrderItem): string { + const displayName = (item.displayName || '').trim(); + if (displayName) { + return displayName; + } + + const shopName = (item.shopProductName || '').trim(); + if (shopName) { + return shopName; + } + + return (item.originalFilename || '').trim() || '-'; + } + + itemVariantLabel(item: AdminOrderItem): string | null { + const variantLabel = (item.shopVariantLabel || '').trim(); + if (variantLabel) { + return variantLabel; + } + + const colorName = (item.shopVariantColorName || '').trim(); + return colorName || null; + } + isHexColor(value?: string): boolean { return ( typeof value === 'string' && @@ -291,12 +330,22 @@ export class AdminDashboardComponent implements OnInit { } getItemColorLabel(item: AdminOrderItem): string { + const shopColorName = (item.shopVariantColorName || '').trim(); + if (shopColorName) { + return shopColorName; + } + const colorName = (item.filamentColorName || '').trim(); const colorCode = (item.colorCode || '').trim(); return colorName || colorCode || '-'; } getItemColorHex(item: AdminOrderItem): string | null { + const shopColorHex = (item.shopVariantColorHex || '').trim(); + if (this.isHexColor(shopColorHex)) { + return shopColorHex; + } + const variantHex = (item.filamentColorHex || '').trim(); if (this.isHexColor(variantHex)) { return variantHex; @@ -336,6 +385,54 @@ export class AdminDashboardComponent implements OnInit { return 'Supporti -'; } + showItemMaterial(item: AdminOrderItem): boolean { + return !this.isShopItem(item); + } + + showItemPrintDetails(item: AdminOrderItem): boolean { + return !this.isShopItem(item); + } + + hasShopItems(order: AdminOrder | null): boolean { + return (order?.items || []).some((item) => this.isShopItem(item)); + } + + hasPrintItems(order: AdminOrder | null): boolean { + return (order?.items || []).some((item) => !this.isShopItem(item)); + } + + printItems(order: AdminOrder | null): AdminOrderItem[] { + return (order?.items || []).filter((item) => !this.isShopItem(item)); + } + + orderKind(order: AdminOrder | null): 'SHOP' | 'CALCULATOR' | 'MIXED' { + const hasShop = this.hasShopItems(order); + const hasPrint = this.hasPrintItems(order); + + if (hasShop && hasPrint) { + return 'MIXED'; + } + if (hasShop) { + return 'SHOP'; + } + return 'CALCULATOR'; + } + + orderKindLabel(order: AdminOrder | null): string { + switch (this.orderKind(order)) { + case 'SHOP': + return 'Shop'; + case 'MIXED': + return 'Misto'; + default: + return 'Calcolatore'; + } + } + + downloadItemLabel(item: AdminOrderItem): string { + return this.isShopItem(item) ? 'Scarica modello' : 'Scarica file'; + } + isSelected(orderId: string): boolean { return this.selectedOrder?.id === orderId; } @@ -349,6 +446,7 @@ export class AdminDashboardComponent implements OnInit { this.selectedStatus = updatedOrder.status; this.selectedPaymentMethod = updatedOrder.paymentMethod || this.selectedPaymentMethod; + this.showPrintDetails = this.showPrintDetails && this.hasPrintItems(updatedOrder); } private applyListFiltersAndSelection(): void { @@ -384,8 +482,16 @@ export class AdminDashboardComponent implements OnInit { const matchesOrderStatus = this.orderStatusFilter === 'ALL' || orderStatus === this.orderStatusFilter; + const matchesOrderType = + this.orderTypeFilter === 'ALL' || + this.orderKind(order) === this.orderTypeFilter; - return matchesSearch && matchesPayment && matchesOrderStatus; + return ( + matchesSearch && + matchesPayment && + matchesOrderStatus && + matchesOrderType + ); }); } diff --git a/frontend/src/app/features/admin/services/admin-orders.service.ts b/frontend/src/app/features/admin/services/admin-orders.service.ts index 0ac32c4..fb0622a 100644 --- a/frontend/src/app/features/admin/services/admin-orders.service.ts +++ b/frontend/src/app/features/admin/services/admin-orders.service.ts @@ -5,10 +5,19 @@ import { environment } from '../../../../environments/environment'; export interface AdminOrderItem { id: string; + itemType: string; originalFilename: string; + displayName?: string; materialCode: string; colorCode: string; filamentVariantId?: number; + shopProductId?: string; + shopProductVariantId?: string; + shopProductSlug?: string; + shopProductName?: string; + shopVariantLabel?: string; + shopVariantColorName?: string; + shopVariantColorHex?: string; filamentVariantDisplayName?: string; filamentColorName?: string; filamentColorHex?: string; diff --git a/frontend/src/app/features/checkout/checkout.component.html b/frontend/src/app/features/checkout/checkout.component.html index bce23e6..584eac3 100644 --- a/frontend/src/app/features/checkout/checkout.component.html +++ b/frontend/src/app/features/checkout/checkout.component.html @@ -254,15 +254,19 @@
- {{ item.originalFilename }} + {{ itemDisplayName(item) }}
{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }} - + {{ "CHECKOUT.MATERIAL" | translate }}: {{ itemMaterial(item) }} + + {{ "SHOP.VARIANT" | translate }}: + {{ variantLabel }} + {{ itemColorLabel(item) }}
-
+
{{ item.printTimeSeconds / 3600 | number: "1.1-1" }}h | {{ item.materialGrams | number: "1.0-0" }}g
-
+
diff --git a/frontend/src/app/features/checkout/checkout.component.spec.ts b/frontend/src/app/features/checkout/checkout.component.spec.ts new file mode 100644 index 0000000..c85f46e --- /dev/null +++ b/frontend/src/app/features/checkout/checkout.component.spec.ts @@ -0,0 +1,64 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { of } from 'rxjs'; +import { FormBuilder } from '@angular/forms'; +import { CheckoutComponent } from './checkout.component'; +import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service'; +import { LanguageService } from '../../core/services/language.service'; + +describe('CheckoutComponent', () => { + let component: CheckoutComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + FormBuilder, + { + provide: QuoteEstimatorService, + useValue: jasmine.createSpyObj( + 'QuoteEstimatorService', + ['getQuoteSession'], + ), + }, + { + provide: Router, + useValue: jasmine.createSpyObj('Router', ['navigate']), + }, + { + provide: ActivatedRoute, + useValue: { + queryParams: of({}), + }, + }, + { + provide: LanguageService, + useValue: { + selectedLang: () => 'it', + }, + }, + ], + }); + + component = TestBed.runInInjectionContext(() => new CheckoutComponent()); + }); + + it('prefers shop variant metadata for labels and swatches', () => { + const item = { + lineItemType: 'SHOP_PRODUCT', + displayName: 'Desk Cable Clip', + shopProductName: 'Desk Cable Clip', + shopVariantLabel: 'Coral Red', + shopVariantColorName: 'Coral Red', + shopVariantColorHex: '#ff6b6b', + colorCode: 'Rosso', + }; + + expect(component.isShopItem(item)).toBeTrue(); + expect(component.itemDisplayName(item)).toBe('Desk Cable Clip'); + expect(component.itemVariantLabel(item)).toBe('Coral Red'); + expect(component.itemColorLabel(item)).toBe('Coral Red'); + expect(component.itemColorSwatch(item)).toBe('#ff6b6b'); + expect(component.showItemMaterial(item)).toBeFalse(); + expect(component.showItemPrintMetrics(item)).toBeFalse(); + }); +}); diff --git a/frontend/src/app/features/checkout/checkout.component.ts b/frontend/src/app/features/checkout/checkout.component.ts index 039a8e2..bb34b60 100644 --- a/frontend/src/app/features/checkout/checkout.component.ts +++ b/frontend/src/app/features/checkout/checkout.component.ts @@ -172,7 +172,7 @@ export class CheckoutComponent implements OnInit { this.quoteService.getQuoteSession(this.sessionId).subscribe({ next: (session) => { this.quoteSession.set(session); - if (this.isCadSessionData(session)) { + if (Array.isArray(session?.items) && session.items.length > 0) { this.loadStlPreviews(session); } else { this.resetPreviewState(); @@ -231,6 +231,39 @@ export class CheckoutComponent implements OnInit { ); } + isShopItem(item: any): boolean { + return String(item?.lineItemType ?? '').toUpperCase() === 'SHOP_PRODUCT'; + } + + itemDisplayName(item: any): string { + const displayName = String(item?.displayName ?? '').trim(); + if (displayName) { + return displayName; + } + const shopName = String(item?.shopProductName ?? '').trim(); + if (shopName) { + return shopName; + } + return String(item?.originalFilename ?? '-'); + } + + itemVariantLabel(item: any): string | null { + const variantLabel = String(item?.shopVariantLabel ?? '').trim(); + if (variantLabel) { + return variantLabel; + } + const colorName = String(item?.shopVariantColorName ?? '').trim(); + return colorName || null; + } + + showItemMaterial(item: any): boolean { + return !this.isShopItem(item); + } + + showItemPrintMetrics(item: any): boolean { + return !this.isShopItem(item); + } + isStlItem(item: any): boolean { const name = String(item?.originalFilename ?? '').toLowerCase(); return name.endsWith('.stl'); @@ -249,11 +282,20 @@ export class CheckoutComponent implements OnInit { } itemColorLabel(item: any): string { + const shopColor = String(item?.shopVariantColorName ?? '').trim(); + if (shopColor) { + return shopColor; + } const raw = String(item?.colorCode ?? '').trim(); return raw || '-'; } itemColorSwatch(item: any): string { + const shopHex = String(item?.shopVariantColorHex ?? '').trim(); + if (this.isHexColor(shopHex)) { + return shopHex; + } + const variantId = Number(item?.filamentVariantId); if (Number.isFinite(variantId) && this.variantHexById.has(variantId)) { return this.variantHexById.get(variantId)!; @@ -303,7 +345,7 @@ export class CheckoutComponent implements OnInit { return; } this.selectedPreviewFile.set(file); - this.selectedPreviewName.set(String(item?.originalFilename ?? file.name)); + this.selectedPreviewName.set(this.itemDisplayName(item)); this.selectedPreviewColor.set(this.previewColor(item)); this.previewModalOpen.set(true); } @@ -353,7 +395,6 @@ export class CheckoutComponent implements OnInit { private loadStlPreviews(session: any): void { if ( !this.sessionId || - !this.isCadSessionData(session) || !Array.isArray(session?.items) ) { return; diff --git a/frontend/src/app/features/order/order.component.html b/frontend/src/app/features/order/order.component.html index 48842c3..2b73d7a 100644 --- a/frontend/src/app/features/order/order.component.html +++ b/frontend/src/app/features/order/order.component.html @@ -58,146 +58,214 @@
- - -
-

{{ "PAYMENT.STATUS_REPORTED_TITLE" | translate }}

-

{{ "PAYMENT.STATUS_REPORTED_DESC" | translate }}

-
-
+ +
+

{{ "PAYMENT.STATUS_REPORTED_TITLE" | translate }}

+

{{ "PAYMENT.STATUS_REPORTED_DESC" | translate }}

+
+
-
-
- -
-

{{ "PAYMENT.METHOD" | translate }}

-
+
+
+ +
+

{{ "PAYMENT.METHOD" | translate }}

+
-
-
-
- {{ - "PAYMENT.METHOD_TWINT" | translate - }} -
-
- {{ - "PAYMENT.METHOD_BANK" | translate - }} -
-
-
- -
-
-

{{ "PAYMENT.TWINT_TITLE" | translate }}

-
-
- -

{{ "PAYMENT.TWINT_DESC" | translate }}

-

- {{ "PAYMENT.BILLING_INFO_HINT" | translate }} -

-
- -
-

- {{ "PAYMENT.TOTAL" | translate }}: - {{ o.totalChf | currency: "CHF" }} -

-
-
- -
-
-

{{ "PAYMENT.BANK_TITLE" | translate }}

-
-
-

- {{ "PAYMENT.BILLING_INFO_HINT" | translate }} -

-
-
- - {{ "PAYMENT.DOWNLOAD_QR" | translate }} - -
-
-
- -
- +
+
- {{ - o.paymentStatus === "REPORTED" - ? ("PAYMENT.IN_VERIFICATION" | translate) - : ("PAYMENT.CONFIRM" | translate) - }} - + {{ + "PAYMENT.METHOD_TWINT" | translate + }} +
+
+ {{ + "PAYMENT.METHOD_BANK" | translate + }} +
- -
+
-
- -
-

- {{ "PAYMENT.SUMMARY_TITLE" | translate }} -

-

- #{{ getDisplayOrderNumber(o) }} +

+
+

{{ "PAYMENT.TWINT_TITLE" | translate }}

+
+
+ +

{{ "PAYMENT.TWINT_DESC" | translate }}

+

+ {{ "PAYMENT.BILLING_INFO_HINT" | translate }} +

+
+ +
+

+ {{ "PAYMENT.TOTAL" | translate }}: + {{ o.totalChf | currency: "CHF" }}

+
- - -
+
+
+

{{ "PAYMENT.BANK_TITLE" | translate }}

+
+
+

+ {{ "PAYMENT.BILLING_INFO_HINT" | translate }} +

+
+
+ + {{ "PAYMENT.DOWNLOAD_QR" | translate }} + +
+
+
+ +
+ + {{ + o.paymentStatus === "REPORTED" + ? ("PAYMENT.IN_VERIFICATION" | translate) + : ("PAYMENT.CONFIRM" | translate) + }} + +
+
+ + +
+

{{ "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" }} + +
+
+
- + +
+ +
+

+ {{ "PAYMENT.SUMMARY_TITLE" | translate }} +

+

+ #{{ getDisplayOrderNumber(o) }} +

+
+ +
+
+ {{ + "ORDER.ORDER_TYPE_LABEL" | translate + }} + {{ orderKindLabel(o) }} +
+
+ {{ "ORDER.ITEM_COUNT" | translate }} + {{ (o.items || []).length }} +
+
+ + +
+
+
diff --git a/frontend/src/app/features/order/order.component.scss b/frontend/src/app/features/order/order.component.scss index 1646bec..14737d1 100644 --- a/frontend/src/app/features/order/order.component.scss +++ b/frontend/src/app/features/order/order.component.scss @@ -115,6 +115,107 @@ top: var(--space-6); } +.order-items { + display: grid; + gap: var(--space-3); +} + +.order-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); +} + +.order-item-copy { + min-width: 0; + display: grid; + gap: var(--space-2); +} + +.order-item-name-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); +} + +.order-item-name { + font-size: 1rem; + line-height: 1.35; +} + +.order-item-kind { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 0.2rem 0.65rem; + background: var(--color-neutral-100); + color: var(--color-text-muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.order-item-kind-shop { + background: color-mix(in srgb, var(--color-brand) 12%, white); + color: var(--color-brand); +} + +.order-item-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.9rem; + color: var(--color-text-muted); + font-size: 0.92rem; +} + +.item-color-chip { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.color-swatch { + width: 12px; + height: 12px; + border-radius: 999px; + border: 1px solid var(--color-border); + flex: 0 0 auto; +} + +.order-item-tech { + font-size: 0.86rem; + color: var(--color-text-muted); +} + +.order-item-total { + white-space: nowrap; + font-size: 1rem; +} + +.order-summary-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); + margin-bottom: var(--space-4); +} + +.summary-label { + display: block; + margin-bottom: 0.2rem; + font-size: 0.76rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.02em; + color: var(--color-text-muted); +} + .fade-in { animation: fadeIn 0.4s ease-out; } @@ -236,4 +337,16 @@ } } } + + .order-item { + flex-direction: column; + } + + .order-item-total { + width: 100%; + } + + .order-summary-meta { + grid-template-columns: 1fr; + } } diff --git a/frontend/src/app/features/order/order.component.ts b/frontend/src/app/features/order/order.component.ts index 18d8b86..52c2d8d 100644 --- a/frontend/src/app/features/order/order.component.ts +++ b/frontend/src/app/features/order/order.component.ts @@ -11,6 +11,52 @@ import { PriceBreakdownRow, } from '../../shared/components/price-breakdown/price-breakdown.component'; +interface PublicOrderItem { + id: string; + itemType?: string; + originalFilename?: string; + displayName?: string; + materialCode?: string; + colorCode?: string; + filamentVariantId?: number; + shopProductId?: string; + shopProductVariantId?: string; + shopProductSlug?: string; + shopProductName?: string; + shopVariantLabel?: string; + shopVariantColorName?: string; + shopVariantColorHex?: string; + filamentVariantDisplayName?: string; + filamentColorName?: string; + filamentColorHex?: string; + quality?: string; + nozzleDiameterMm?: number; + layerHeightMm?: number; + infillPercent?: number; + infillPattern?: string; + supportsEnabled?: boolean; + quantity: number; + printTimeSeconds?: number; + materialGrams?: number; + unitPriceChf?: number; + lineTotalChf?: number; +} + +interface PublicOrder { + id: string; + orderNumber?: string; + status?: string; + paymentStatus?: string; + paymentMethod?: string; + subtotalChf?: number; + shippingCostChf?: number; + setupCostChf?: number; + totalChf?: number; + cadHours?: number; + cadTotalChf?: number; + items?: PublicOrderItem[]; +} + @Component({ selector: 'app-order', standalone: true, @@ -32,7 +78,7 @@ export class OrderComponent implements OnInit { orderId: string | null = null; selectedPaymentMethod: 'twint' | 'bill' | null = 'twint'; - order = signal(null); + order = signal(null); loading = signal(true); error = signal(null); twintOpenUrl = signal(null); @@ -201,4 +247,106 @@ export class OrderComponent implements OnInit { private extractOrderNumber(orderId: string): string { return orderId.split('-')[0]; } + + isShopItem(item: PublicOrderItem): boolean { + return String(item?.itemType ?? '').toUpperCase() === 'SHOP_PRODUCT'; + } + + itemDisplayName(item: PublicOrderItem): string { + const displayName = String(item?.displayName ?? '').trim(); + if (displayName) { + return displayName; + } + + const shopName = String(item?.shopProductName ?? '').trim(); + if (shopName) { + return shopName; + } + + return String(item?.originalFilename ?? this.translate.instant('ORDER.NOT_AVAILABLE')); + } + + itemVariantLabel(item: PublicOrderItem): string | null { + const variantLabel = String(item?.shopVariantLabel ?? '').trim(); + if (variantLabel) { + return variantLabel; + } + + const colorName = String(item?.shopVariantColorName ?? '').trim(); + return colorName || null; + } + + itemColorLabel(item: PublicOrderItem): string { + const shopColor = String(item?.shopVariantColorName ?? '').trim(); + if (shopColor) { + return shopColor; + } + + const filamentColor = String(item?.filamentColorName ?? '').trim(); + if (filamentColor) { + return filamentColor; + } + + const rawColor = String(item?.colorCode ?? '').trim(); + return rawColor || this.translate.instant('ORDER.NOT_AVAILABLE'); + } + + itemColorHex(item: PublicOrderItem): string | null { + const shopHex = String(item?.shopVariantColorHex ?? '').trim(); + if (this.isHexColor(shopHex)) { + return shopHex; + } + + const filamentHex = String(item?.filamentColorHex ?? '').trim(); + if (this.isHexColor(filamentHex)) { + return filamentHex; + } + + const rawColor = String(item?.colorCode ?? '').trim(); + if (this.isHexColor(rawColor)) { + return rawColor; + } + + return null; + } + + showItemMaterial(item: PublicOrderItem): boolean { + return !this.isShopItem(item); + } + + showItemPrintMetrics(item: PublicOrderItem): boolean { + return !this.isShopItem(item); + } + + orderKind(order: PublicOrder | null): 'SHOP' | 'CALCULATOR' | 'MIXED' { + const items = order?.items ?? []; + const hasShop = items.some((item) => this.isShopItem(item)); + const hasPrint = items.some((item) => !this.isShopItem(item)); + + if (hasShop && hasPrint) { + return 'MIXED'; + } + if (hasShop) { + return 'SHOP'; + } + return 'CALCULATOR'; + } + + orderKindLabel(order: PublicOrder | null): string { + switch (this.orderKind(order)) { + case 'SHOP': + return this.translate.instant('ORDER.TYPE_SHOP'); + case 'MIXED': + return this.translate.instant('ORDER.TYPE_MIXED'); + default: + return this.translate.instant('ORDER.TYPE_CALCULATOR'); + } + } + + private isHexColor(value?: string): boolean { + return ( + typeof value === 'string' && + /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value) + ); + } } diff --git a/frontend/src/app/features/shop/components/product-card/product-card.component.html b/frontend/src/app/features/shop/components/product-card/product-card.component.html index 293d1d6..96d3994 100644 --- a/frontend/src/app/features/shop/components/product-card/product-card.component.html +++ b/frontend/src/app/features/shop/components/product-card/product-card.component.html @@ -1,17 +1,60 @@ - + 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 5819227..8825c7d 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 @@ -1,48 +1,187 @@ .product-card { - background: var(--color-bg-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); + display: grid; + height: 100%; + border: 1px solid rgba(16, 24, 32, 0.08); + border-radius: 1.1rem; overflow: hidden; - transition: box-shadow 0.2s; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 246, 241, 1)); + box-shadow: 0 18px 40px rgba(16, 24, 32, 0.08); + transition: + transform 0.2s ease, + box-shadow 0.2s ease, + border-color 0.2s ease; + &:hover { - box-shadow: var(--shadow-md); + transform: translateY(-4px); + box-shadow: 0 22px 48px rgba(16, 24, 32, 0.14); + border-color: rgba(250, 207, 10, 0.42); } } -.image-placeholder { - height: 200px; - background-color: var(--color-neutral-200); + +.media { + position: relative; + display: block; + min-height: 244px; + background: + radial-gradient(circle at top right, rgba(250, 207, 10, 0.28), transparent 42%), + linear-gradient(160deg, #f7f4ed 0%, #ece7db 100%); } -.content { - padding: var(--space-4); + +.media img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; } -.category { - font-size: 0.75rem; - color: var(--color-text-muted); + +.image-fallback { + width: 100%; + height: 100%; + min-height: 244px; + display: flex; + align-items: flex-end; + padding: var(--space-5); +} + +.image-fallback span { + display: inline-flex; + align-items: center; + min-height: 2rem; + padding: 0.4rem 0.8rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(16, 24, 32, 0.08); + color: var(--color-neutral-900); + font-size: 0.78rem; + font-weight: 700; +} + +.card-badges { + position: absolute; + inset: var(--space-4) var(--space-4) auto auto; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.45rem; +} + +.badge { + display: inline-flex; + align-items: center; + min-height: 1.9rem; + padding: 0.3rem 0.7rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; text-transform: uppercase; - letter-spacing: 0.05em; } + +.badge-featured { + background: rgba(250, 207, 10, 0.92); + color: var(--color-neutral-900); +} + +.badge-cart { + background: rgba(16, 24, 32, 0.82); + color: #fff; +} + +.content { + display: grid; + gap: var(--space-4); + padding: var(--space-5); +} + +.meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.55rem; +} + +.category, +.model-pill { + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.category { + color: var(--color-secondary-600); + font-weight: 700; +} + +.model-pill { + display: inline-flex; + padding: 0.18rem 0.55rem; + border-radius: 999px; + border: 1px solid rgba(16, 24, 32, 0.08); + color: var(--color-text-muted); + background: rgba(255, 255, 255, 0.72); +} + .name { - font-size: 1.125rem; - margin: var(--space-2) 0; - a { - color: var(--color-text); - text-decoration: none; - &:hover { - color: var(--color-brand); - } - } + margin: 0; + font-size: 1.2rem; + line-height: 1.16; } + +.name a { + color: var(--color-text); + text-decoration: none; +} + +.excerpt { + margin: 0; + color: var(--color-text-muted); + line-height: 1.55; +} + .footer { display: flex; + align-items: flex-end; justify-content: space-between; - align-items: center; - margin-top: var(--space-4); + gap: var(--space-4); } + +.pricing { + display: grid; + gap: 0.1rem; +} + .price { + font-size: 1.35rem; font-weight: 700; - color: var(--color-brand); + color: var(--color-neutral-900); } + +.price-note { + color: var(--color-text-muted); +} + .view-btn { - font-size: 0.875rem; - font-weight: 500; + display: inline-flex; + align-items: center; + min-height: 2.35rem; + padding: 0 0.9rem; + border-radius: 999px; + background: rgba(16, 24, 32, 0.06); + color: var(--color-neutral-900); + font-size: 0.9rem; + font-weight: 600; + text-decoration: none; +} + +@media (max-width: 640px) { + .media, + .image-fallback { + min-height: 220px; + } + + .footer { + align-items: start; + flex-direction: column; + } } diff --git a/frontend/src/app/features/shop/components/product-card/product-card.component.ts b/frontend/src/app/features/shop/components/product-card/product-card.component.ts index 79e7db8..c38d99c 100644 --- a/frontend/src/app/features/shop/components/product-card/product-card.component.ts +++ b/frontend/src/app/features/shop/components/product-card/product-card.component.ts @@ -1,8 +1,8 @@ -import { Component, input } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { Component, computed, inject, input } from '@angular/core'; import { RouterLink } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; -import { Product } from '../../services/shop.service'; +import { ShopProductSummary, ShopService } from '../../services/shop.service'; @Component({ selector: 'app-product-card', @@ -12,5 +12,31 @@ import { Product } from '../../services/shop.service'; styleUrl: './product-card.component.scss', }) export class ProductCardComponent { - product = input.required(); + private readonly shopService = inject(ShopService); + + readonly product = input.required(); + readonly cartQuantity = input(0); + + readonly productLink = computed(() => [ + '/shop', + this.product().category.slug, + this.product().slug, + ]); + + readonly imageUrl = computed(() => { + const image = this.product().primaryImage; + return ( + this.shopService.resolveMediaUrl(image?.card) ?? + this.shopService.resolveMediaUrl(image?.hero) ?? + this.shopService.resolveMediaUrl(image?.thumb) + ); + }); + + priceLabel(): number { + return this.product().priceFromChf; + } + + hasPriceRange(): boolean { + return this.product().priceFromChf !== this.product().priceToChf; + } } diff --git a/frontend/src/app/features/shop/product-detail.component.html b/frontend/src/app/features/shop/product-detail.component.html index 604c453..eec9d42 100644 --- a/frontend/src/app/features/shop/product-detail.component.html +++ b/frontend/src/app/features/shop/product-detail.component.html @@ -1,25 +1,214 @@ -
- ← {{ "SHOP.BACK" | translate }} +
+
+ + ← {{ "SHOP.BACK" | translate }} + - @if (product(); as p) { -
-
- -
- {{ p.category | translate }} -

{{ p.name | translate }}

-

{{ p.price | currency: "EUR" }}

- -

{{ p.description | translate }}

- -
- - {{ "SHOP.ADD_CART" | translate }} - -
+ @if (loading()) { +
+
+
-
- } @else { -

{{ "SHOP.NOT_FOUND" | translate }}

- } -
+ } @else { + @if (error()) { +
{{ error() | translate }}
+ } @else { + @if (product(); as p) { + + +
+
+
+ @if (imageUrl(selectedImage()); as imageUrl) { + + } @else { +
+ {{ p.category.name }} +
+ } +
+ + @if (galleryImages().length > 1) { +
+ @for (image of galleryImages(); track image.mediaAssetId) { + + } +
+ } + + @if (p.model3d) { + +
+
+

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

+

{{ "SHOP.MODEL_TITLE" | translate }}

+
+
+ + X {{ p.model3d.boundingBoxXMm || 0 | number: "1.0-1" }} mm + + + Y {{ p.model3d.boundingBoxYMm || 0 | number: "1.0-1" }} mm + + + Z {{ p.model3d.boundingBoxZMm || 0 | number: "1.0-1" }} mm + +
+
+ + @if (modelLoading()) { +
+ {{ "SHOP.MODEL_LOADING" | translate }} +
+ } @else if (modelError()) { +
+ {{ "SHOP.MODEL_UNAVAILABLE" | translate }} +
+ } @else { + @if (modelFile(); as modelPreviewFile) { + + } + } +
+ } +
+ +
+
+
+ {{ p.category.name }} + @if (p.isFeatured) { + {{ + "SHOP.FEATURED_BADGE" | translate + }} + } +
+

{{ p.name }}

+

+ {{ + p.excerpt || + p.description || + ("SHOP.EXCERPT_FALLBACK" | translate) + }} +

+
+ + +
+
+
+

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

+

{{ priceLabel() | currency: "CHF" }}

+
+ @if (selectedVariantCartQuantity() > 0) { + + {{ + "SHOP.IN_CART_LONG" + | translate: { count: selectedVariantCartQuantity() } + }} + + } +
+ +
+ @for (variant of p.variants; track variant.id) { + + } +
+ +
+ {{ "SHOP.QUANTITY" | translate }} +
+ + {{ quantity() }} + +
+
+ +
+ + {{ + (isAddingToCart() + ? "SHOP.ADDING" + : "SHOP.ADD_CART") | translate + }} + + + @if (shopService.cartItemCount() > 0) { + + {{ "SHOP.GO_TO_CHECKOUT" | translate }} + + } +
+ + @if (addSuccess()) { +

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

+ } +
+
+ + @if (p.description) { +
+

{{ "SHOP.DESCRIPTION_TITLE" | translate }}

+

{{ p.description }}

+
+ } +
+
+ } + } + } +
+
diff --git a/frontend/src/app/features/shop/product-detail.component.scss b/frontend/src/app/features/shop/product-detail.component.scss index 5fc4e68..a9e229d 100644 --- a/frontend/src/app/features/shop/product-detail.component.scss +++ b/frontend/src/app/features/shop/product-detail.component.scss @@ -1,40 +1,364 @@ -.wrapper { - padding-top: var(--space-8); +.product-page { + padding: var(--space-8) 0 var(--space-12); + background: + radial-gradient(circle at top left, rgba(250, 207, 10, 0.18), transparent 20%), + linear-gradient(180deg, #faf7ee 0%, var(--color-bg) 25%); } -.back-link { - display: inline-block; - margin-bottom: var(--space-6); + +.wrapper { + display: grid; + gap: var(--space-6); +} + +.back-link, +.breadcrumbs { color: var(--color-text-muted); } +.breadcrumbs { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + font-size: 0.9rem; +} + .detail-grid { display: grid; gap: var(--space-8); - @media (min-width: 768px) { - grid-template-columns: 1fr 1fr; - } + grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr); } -.image-box { - background-color: var(--color-neutral-200); +.visual-column, +.info-column { + display: grid; + gap: var(--space-5); +} + +.hero-media { + min-height: 480px; + overflow: hidden; + border-radius: 1.25rem; + border: 1px solid rgba(16, 24, 32, 0.08); + background: + radial-gradient(circle at top right, rgba(250, 207, 10, 0.3), transparent 30%), + linear-gradient(160deg, #f8f4ea 0%, #eee8db 100%); + box-shadow: 0 18px 42px rgba(16, 24, 32, 0.08); +} + +.hero-image { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.image-fallback { + width: 100%; + height: 100%; + min-height: 480px; + display: flex; + align-items: flex-end; + padding: var(--space-6); +} + +.image-fallback span { + display: inline-flex; + min-height: 2rem; + align-items: center; + padding: 0.4rem 0.8rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(16, 24, 32, 0.08); + font-weight: 700; +} + +.thumb-grid { + display: grid; + gap: var(--space-3); + grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); +} + +.thumb { + min-height: 92px; + overflow: hidden; + border-radius: 0.85rem; + border: 1px solid var(--color-border); + background: rgba(255, 255, 255, 0.78); + padding: 0; + cursor: pointer; +} + +.thumb.active { + border-color: rgba(250, 207, 10, 0.65); + box-shadow: 0 0 0 2px rgba(250, 207, 10, 0.22); +} + +.thumb img, +.thumb span { + width: 100%; + height: 100%; + display: grid; + place-items: center; + object-fit: cover; +} + +.viewer-card { + display: block; +} + +.viewer-head { + display: flex; + justify-content: space-between; + gap: var(--space-4); + margin-bottom: var(--space-4); +} + +.viewer-kicker, +.panel-kicker { + margin: 0 0 0.2rem; + color: var(--color-secondary-600); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.dimensions { + display: grid; + gap: 0.25rem; + color: var(--color-text-muted); + font-size: 0.82rem; + text-align: right; +} + +.viewer-state { + display: grid; + place-items: center; + min-height: 220px; border-radius: var(--radius-lg); - aspect-ratio: 1; + background: rgba(16, 24, 32, 0.04); + color: var(--color-text-muted); +} + +.viewer-state-error { + color: var(--color-danger-600); +} + +.title-block { + display: grid; + gap: var(--space-3); +} + +.title-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.7rem; +} + +.category, +.featured-pill { + text-transform: uppercase; + font-size: 0.76rem; + letter-spacing: 0.08em; } .category { - color: var(--color-brand); - font-weight: 600; - text-transform: uppercase; - font-size: 0.875rem; -} -.price { - font-size: 1.5rem; + color: var(--color-secondary-600); font-weight: 700; - color: var(--color-text); - margin: var(--space-4) 0; } -.desc { + +.featured-pill { + display: inline-flex; + padding: 0.25rem 0.65rem; + border-radius: 999px; + background: rgba(250, 207, 10, 0.92); + color: var(--color-neutral-900); + font-weight: 700; +} + +h1 { + font-size: clamp(2rem, 2vw + 1.2rem, 3.2rem); +} + +.excerpt, +.description-block p { + margin: 0; color: var(--color-text-muted); - line-height: 1.6; - margin-bottom: var(--space-8); + line-height: 1.7; +} + +.purchase-card { + display: grid; + gap: var(--space-5); +} + +.price-row, +.quantity-row { + display: flex; + justify-content: space-between; + gap: var(--space-4); + align-items: center; +} + +.cart-pill { + display: inline-flex; + align-items: center; + min-height: 1.9rem; + padding: 0.3rem 0.7rem; + border-radius: 999px; + background: rgba(16, 24, 32, 0.08); + color: var(--color-text); + font-size: 0.8rem; + font-weight: 600; +} + +.variant-grid { + display: grid; + gap: 0.7rem; +} + +.variant-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: 0.9rem 1rem; + border-radius: 1rem; + border: 1px solid var(--color-border); + background: rgba(255, 255, 255, 0.86); + cursor: pointer; + text-align: left; + transition: + border-color 0.18s ease, + transform 0.18s ease, + box-shadow 0.18s ease; +} + +.variant-option.active { + border-color: rgba(250, 207, 10, 0.6); + box-shadow: 0 10px 24px rgba(16, 24, 32, 0.08); + transform: translateY(-1px); +} + +.variant-swatch { + width: 1.2rem; + height: 1.2rem; + border-radius: 50%; + border: 1px solid rgba(16, 24, 32, 0.12); + flex: 0 0 auto; +} + +.variant-copy { + display: grid; + gap: 0.12rem; + flex: 1; +} + +.variant-copy small { + color: var(--color-text-muted); +} + +.qty-control { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.2rem; + border-radius: 999px; + border: 1px solid var(--color-border); + background: rgba(255, 255, 255, 0.82); +} + +.qty-control button { + width: 2rem; + height: 2rem; + border: 0; + border-radius: 50%; + background: rgba(16, 24, 32, 0.08); + color: var(--color-text); + cursor: pointer; +} + +.qty-control span { + min-width: 1.5rem; + text-align: center; + font-weight: 700; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); +} + +.success-note { + margin: 0; + color: #047857; + font-weight: 600; +} + +.description-block { + display: grid; + gap: var(--space-3); +} + +.description-block h2 { + font-size: 1.2rem; +} + +.state-card, +.skeleton-block { + min-height: 320px; + border-radius: 1.1rem; + border: 1px solid var(--color-border); + background: rgba(255, 255, 255, 0.8); +} + +.state-card { + display: grid; + place-items: center; + color: var(--color-text-muted); +} + +.skeleton-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.skeleton-block { + background: + linear-gradient( + 110deg, + rgba(255, 255, 255, 0.7) 8%, + rgba(238, 235, 226, 0.95) 18%, + rgba(255, 255, 255, 0.7) 33% + ); + background-size: 220% 100%; + animation: skeleton 1.35s linear infinite; +} + +@keyframes skeleton { + to { + background-position-x: -220%; + } +} + +@media (max-width: 960px) { + .detail-grid, + .skeleton-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .viewer-head, + .price-row, + .quantity-row { + flex-direction: column; + align-items: start; + } + + .hero-media, + .image-fallback { + min-height: 320px; + } } diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts index 45124b8..7d4e8b4 100644 --- a/frontend/src/app/features/shop/product-detail.component.ts +++ b/frontend/src/app/features/shop/product-detail.component.ts @@ -1,38 +1,307 @@ -import { Component, input, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { RouterLink } from '@angular/router'; +import { + Component, + DestroyRef, + Injector, + computed, + inject, + input, + signal, +} from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { Router, RouterLink } from '@angular/router'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { ShopService, Product } from './services/shop.service'; +import { catchError, combineLatest, finalize, of, switchMap, tap } from 'rxjs'; +import { SeoService } from '../../core/services/seo.service'; +import { LanguageService } from '../../core/services/language.service'; 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'; +import { + ShopProductDetail, + ShopProductVariantOption, + ShopService, +} from './services/shop.service'; @Component({ selector: 'app-product-detail', standalone: true, - imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent], + imports: [ + CommonModule, + RouterLink, + TranslateModule, + AppButtonComponent, + AppCardComponent, + StlViewerComponent, + ], templateUrl: './product-detail.component.html', styleUrl: './product-detail.component.scss', }) export class ProductDetailComponent { - // Input binding from router - id = input(); + private readonly destroyRef = inject(DestroyRef); + private readonly injector = inject(Injector); + private readonly router = inject(Router); + private readonly translate = inject(TranslateService); + private readonly seoService = inject(SeoService); + private readonly languageService = inject(LanguageService); + readonly shopService = inject(ShopService); - product = signal(undefined); + readonly categorySlug = input(); + readonly productSlug = input(); - constructor( - private shopService: ShopService, - private translate: TranslateService, - ) {} + readonly loading = signal(true); + readonly error = signal(null); + readonly product = signal(null); + readonly selectedVariantId = signal(null); + readonly selectedImageAssetId = signal(null); + readonly quantity = signal(1); + readonly isAddingToCart = signal(false); + readonly addSuccess = signal(false); - ngOnInit() { - const productId = this.id(); - if (productId) { - this.shopService - .getProductById(productId) - .subscribe((p) => this.product.set(p)); + readonly modelLoading = signal(false); + readonly modelError = signal(false); + readonly modelFile = signal(null); + + readonly selectedVariant = computed(() => { + const product = this.product(); + const variantId = this.selectedVariantId(); + if (!product) { + return null; } + return ( + product.variants.find((variant) => variant.id === variantId) ?? + product.defaultVariant ?? + product.variants[0] ?? + null + ); + }); + + readonly galleryImages = computed(() => { + const product = this.product(); + if (!product) { + return []; + } + + const images = [...(product.images ?? [])]; + const primary = product.primaryImage; + if ( + primary && + !images.some((image) => image.mediaAssetId === primary.mediaAssetId) + ) { + images.unshift(primary); + } + return images; + }); + + readonly selectedImage = computed(() => { + const images = this.galleryImages(); + const selectedAssetId = this.selectedImageAssetId(); + return ( + images.find((image) => image.mediaAssetId === selectedAssetId) ?? + images[0] ?? + null + ); + }); + + readonly selectedVariantCartQuantity = computed(() => + this.shopService.quantityForVariant(this.selectedVariant()?.id), + ); + + constructor() { + if (!this.shopService.cartLoaded()) { + this.shopService + .loadCart() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + error: () => { + this.shopService.cart.set(null); + }, + }); + } + + combineLatest([ + toObservable(this.productSlug, { injector: this.injector }), + toObservable(this.languageService.currentLang, { injector: this.injector }), + ]) + .pipe( + tap(() => { + this.loading.set(true); + this.error.set(null); + this.addSuccess.set(false); + this.modelError.set(false); + }), + switchMap(([productSlug]) => { + if (!productSlug) { + this.error.set('SHOP.NOT_FOUND'); + this.loading.set(false); + return of(null); + } + + return this.shopService.getProduct(productSlug).pipe( + catchError((error) => { + this.product.set(null); + this.selectedVariantId.set(null); + this.selectedImageAssetId.set(null); + this.modelFile.set(null); + this.error.set( + error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR', + ); + this.applyFallbackSeo(); + return of(null); + }), + finalize(() => this.loading.set(false)), + ); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((product) => { + if (!product) { + return; + } + + this.product.set(product); + this.selectedVariantId.set(product.defaultVariant?.id ?? product.variants[0]?.id ?? null); + this.selectedImageAssetId.set(product.primaryImage?.mediaAssetId ?? product.images[0]?.mediaAssetId ?? null); + this.quantity.set(1); + this.applySeo(product); + + if (product.model3d?.url && product.model3d.originalFilename) { + this.loadModelPreview(product.model3d.url, product.model3d.originalFilename); + } else { + this.modelFile.set(null); + this.modelLoading.set(false); + this.modelError.set(false); + } + }); } - addToCart() { - alert(this.translate.instant('SHOP.MOCK_ADD_CART')); + imageUrl(image: ShopProductDetail['images'][number] | null): string | null { + if (!image) { + return null; + } + return ( + this.shopService.resolveMediaUrl(image.hero) ?? + this.shopService.resolveMediaUrl(image.card) ?? + this.shopService.resolveMediaUrl(image.thumb) + ); + } + + selectImage(mediaAssetId: string): void { + this.selectedImageAssetId.set(mediaAssetId); + } + + selectVariant(variant: ShopProductVariantOption): void { + this.selectedVariantId.set(variant.id); + this.addSuccess.set(false); + } + + decreaseQuantity(): void { + this.quantity.update((value) => Math.max(1, value - 1)); + this.addSuccess.set(false); + } + + increaseQuantity(): void { + this.quantity.update((value) => value + 1); + this.addSuccess.set(false); + } + + addToCart(): void { + const variant = this.selectedVariant(); + if (!variant) { + return; + } + + this.isAddingToCart.set(true); + this.shopService + .addToCart(variant.id, this.quantity()) + .pipe( + finalize(() => this.isAddingToCart.set(false)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: () => { + this.addSuccess.set(true); + }, + error: () => { + this.error.set('SHOP.CART_UPDATE_ERROR'); + }, + }); + } + + goToCheckout(): void { + const sessionId = this.shopService.cartSessionId(); + if (!sessionId) { + return; + } + this.router.navigate(['/checkout'], { + queryParams: { session: sessionId }, + }); + } + + priceLabel(): number { + return this.selectedVariant()?.priceChf ?? this.product()?.priceFromChf ?? 0; + } + + colorLabel(variant: ShopProductVariantOption): string { + return variant.colorName || variant.variantLabel || '-'; + } + + colorHex(variant: ShopProductVariantOption): string { + return variant.colorHex || '#d5d8de'; + } + + productLinkRoot(): string[] { + const categorySlug = this.product()?.category.slug || this.categorySlug(); + return categorySlug ? ['/shop', categorySlug] : ['/shop']; + } + + private loadModelPreview(urlOrPath: string, filename: string): void { + this.modelLoading.set(true); + this.modelError.set(false); + + this.shopService + .getProductModelFile(urlOrPath, filename) + .pipe( + finalize(() => this.modelLoading.set(false)), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + next: (file) => { + this.modelFile.set(file); + }, + error: () => { + this.modelFile.set(null); + this.modelError.set(true); + }, + }); + } + + private applySeo(product: ShopProductDetail): void { + const title = product.seoTitle || `${product.name} | 3D fab`; + const description = + product.seoDescription || + product.excerpt || + this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); + const robots = product.indexable === false ? 'noindex, nofollow' : 'index, follow'; + + this.seoService.applyPageSeo({ + title, + description, + robots, + ogTitle: product.ogTitle || title, + ogDescription: product.ogDescription || description, + }); + } + + private applyFallbackSeo(): void { + const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`; + const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); + this.seoService.applyPageSeo({ + title, + description, + robots: 'index, follow', + ogTitle: title, + ogDescription: description, + }); } } diff --git a/frontend/src/app/features/shop/services/shop.service.spec.ts b/frontend/src/app/features/shop/services/shop.service.spec.ts new file mode 100644 index 0000000..69f5c8f --- /dev/null +++ b/frontend/src/app/features/shop/services/shop.service.spec.ts @@ -0,0 +1,144 @@ +import { TestBed } from '@angular/core/testing'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { ShopCartResponse, ShopService } from './shop.service'; +import { LanguageService } from '../../../core/services/language.service'; + +describe('ShopService', () => { + let service: ShopService; + let httpMock: HttpTestingController; + + const buildCart = (): ShopCartResponse => ({ + session: { + id: 'session-1', + status: 'ACTIVE', + sessionType: 'SHOP_CART', + }, + items: [ + { + id: 'line-1', + lineItemType: 'SHOP_PRODUCT', + originalFilename: 'desk-cable-clip-demo.stl', + displayName: 'Desk Cable Clip', + quantity: 2, + printTimeSeconds: null, + materialGrams: null, + colorCode: 'Coral Red', + filamentVariantId: null, + shopProductId: 'product-1', + shopProductVariantId: 'variant-red', + shopProductSlug: 'desk-cable-clip', + shopProductName: 'Desk Cable Clip', + shopVariantLabel: 'Coral Red', + shopVariantColorName: 'Coral Red', + shopVariantColorHex: '#ff6b6b', + materialCode: 'PLA', + quality: null, + nozzleDiameterMm: null, + layerHeightMm: null, + infillPercent: null, + infillPattern: null, + supportsEnabled: false, + status: 'READY', + convertedStoredPath: '/storage/items/desk-cable-clip-demo.stl', + unitPriceChf: 11.4, + }, + { + id: 'line-2', + lineItemType: 'SHOP_PRODUCT', + originalFilename: 'desk-cable-clip-demo.stl', + displayName: 'Desk Cable Clip', + quantity: 1, + printTimeSeconds: null, + materialGrams: null, + colorCode: 'Sand Beige', + filamentVariantId: null, + shopProductId: 'product-1', + shopProductVariantId: 'variant-sand', + shopProductSlug: 'desk-cable-clip', + shopProductName: 'Desk Cable Clip', + shopVariantLabel: 'Sand Beige', + shopVariantColorName: 'Sand Beige', + shopVariantColorHex: '#d8c3a5', + materialCode: 'PLA', + quality: null, + nozzleDiameterMm: null, + layerHeightMm: null, + infillPercent: null, + infillPattern: null, + supportsEnabled: false, + status: 'READY', + convertedStoredPath: '/storage/items/desk-cable-clip-demo.stl', + unitPriceChf: 12.0, + }, + ], + printItemsTotalChf: 34.8, + cadTotalChf: 0, + itemsTotalChf: 34.8, + baseSetupCostChf: 0, + nozzleChangeCostChf: 0, + setupCostChf: 0, + shippingCostChf: 2, + globalMachineCostChf: 0, + grandTotalChf: 36.8, + }); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + ShopService, + { + provide: LanguageService, + useValue: { + selectedLang: () => 'it', + }, + }, + ], + }); + + service = TestBed.inject(ShopService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('loads the server-side cart and updates quantity indexes', () => { + let response: ShopCartResponse | undefined; + service.loadCart().subscribe((cart) => { + response = cart; + }); + + const request = httpMock.expectOne('http://localhost:8000/api/shop/cart'); + expect(request.request.method).toBe('GET'); + expect(request.request.withCredentials).toBeTrue(); + request.flush(buildCart()); + + expect(response?.grandTotalChf).toBe(36.8); + expect(service.cartLoaded()).toBeTrue(); + expect(service.cartItemCount()).toBe(3); + expect(service.quantityForProduct('product-1')).toBe(3); + expect(service.quantityForVariant('variant-red')).toBe(2); + expect(service.quantityForVariant('variant-sand')).toBe(1); + }); + + it('posts add-to-cart with credentials and replaces local cart state', () => { + service.addToCart('variant-red', 2).subscribe(); + + const request = httpMock.expectOne('http://localhost:8000/api/shop/cart/items'); + expect(request.request.method).toBe('POST'); + expect(request.request.withCredentials).toBeTrue(); + expect(request.request.body).toEqual({ + shopProductVariantId: 'variant-red', + quantity: 2, + }); + request.flush(buildCart()); + + expect(service.cart()?.session?.id).toBe('session-1'); + expect(service.cartItemCount()).toBe(3); + }); +}); diff --git a/frontend/src/app/features/shop/services/shop.service.ts b/frontend/src/app/features/shop/services/shop.service.ts index e50a4ec..c3bce2c 100644 --- a/frontend/src/app/features/shop/services/shop.service.ts +++ b/frontend/src/app/features/shop/services/shop.service.ts @@ -1,48 +1,430 @@ -import { Injectable } from '@angular/core'; -import { Observable, of } from 'rxjs'; +import { computed, inject, Injectable, signal } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { map, Observable, tap } from 'rxjs'; +import { environment } from '../../../../environments/environment'; +import { + PublicMediaUsageDto, + PublicMediaVariantDto, +} from '../../../core/services/public-media.service'; +import { LanguageService } from '../../../core/services/language.service'; -export interface Product { +export interface ShopCategoryRef { id: string; + slug: string; name: string; - description: string; - price: number; - category: string; +} + +export interface ShopCategoryTree { + id: string; + parentCategoryId: string | null; + slug: string; + name: string; + description: string | null; + seoTitle: string | null; + seoDescription: string | null; + ogTitle: string | null; + ogDescription: string | null; + indexable: boolean | null; + sortOrder: number | null; + productCount: number; + primaryImage: PublicMediaUsageDto | null; + children: ShopCategoryTree[]; +} + +export interface ShopCategoryDetail { + id: string; + slug: string; + name: string; + description: string | null; + seoTitle: string | null; + seoDescription: string | null; + ogTitle: string | null; + ogDescription: string | null; + indexable: boolean | null; + sortOrder: number | null; + productCount: number; + breadcrumbs: ShopCategoryRef[]; + primaryImage: PublicMediaUsageDto | null; + images: PublicMediaUsageDto[]; + children: ShopCategoryTree[]; +} + +export interface ShopProductVariantOption { + id: string; + sku: string | null; + variantLabel: string | null; + colorName: string | null; + colorHex: string | null; + priceChf: number; + isDefault: boolean; +} + +export interface ShopProductModel { + url: string; + originalFilename: string; + mimeType: string | null; + fileSizeBytes: number | null; + boundingBoxXMm: number | null; + boundingBoxYMm: number | null; + boundingBoxZMm: number | null; +} + +export interface ShopProductSummary { + id: string; + slug: string; + name: string; + excerpt: string | null; + isFeatured: boolean | null; + sortOrder: number | null; + category: ShopCategoryRef; + priceFromChf: number; + priceToChf: number; + defaultVariant: ShopProductVariantOption | null; + primaryImage: PublicMediaUsageDto | null; + model3d: ShopProductModel | null; +} + +export interface ShopProductDetail { + id: string; + slug: string; + name: string; + excerpt: string | null; + description: string | null; + seoTitle: string | null; + seoDescription: string | null; + ogTitle: string | null; + ogDescription: string | null; + indexable: boolean | null; + isFeatured: boolean | null; + sortOrder: number | null; + category: ShopCategoryRef; + breadcrumbs: ShopCategoryRef[]; + priceFromChf: number; + priceToChf: number; + defaultVariant: ShopProductVariantOption | null; + variants: ShopProductVariantOption[]; + primaryImage: PublicMediaUsageDto | null; + images: PublicMediaUsageDto[]; + model3d: ShopProductModel | null; +} + +export interface ShopProductCatalogResponse { + categorySlug: string | null; + featuredOnly: boolean | null; + category: ShopCategoryDetail | null; + products: ShopProductSummary[]; +} + +export interface ShopCartSession { + id: string | null; + status: string | null; + sessionType: string | null; +} + +export interface ShopCartItem { + id: string; + lineItemType: string; + originalFilename: string | null; + displayName: string | null; + quantity: number; + printTimeSeconds: number | null; + materialGrams: number | null; + colorCode: string | null; + filamentVariantId: number | null; + shopProductId: string | null; + shopProductVariantId: string | null; + shopProductSlug: string | null; + shopProductName: string | null; + shopVariantLabel: string | null; + shopVariantColorName: string | null; + shopVariantColorHex: string | null; + materialCode: string | null; + quality: string | null; + nozzleDiameterMm: number | null; + layerHeightMm: number | null; + infillPercent: number | null; + infillPattern: string | null; + supportsEnabled: boolean | null; + status: string | null; + convertedStoredPath: string | null; + unitPriceChf: number; +} + +export interface ShopCartResponse { + session: ShopCartSession | null; + items: ShopCartItem[]; + printItemsTotalChf: number; + cadTotalChf: number; + itemsTotalChf: number; + baseSetupCostChf: number; + nozzleChangeCostChf: number; + setupCostChf: number; + shippingCostChf: number; + globalMachineCostChf: number; + grandTotalChf: number; +} + +export interface ShopCategoryNavNode { + id: string; + slug: string; + name: string; + depth: number; + productCount: number; + current: boolean; } @Injectable({ providedIn: 'root', }) export class ShopService { - // Dati statici per ora - private staticProducts: Product[] = [ - { - id: '1', - name: 'SHOP.PRODUCTS.P1.NAME', - description: 'SHOP.PRODUCTS.P1.DESC', - price: 24.9, - category: 'SHOP.CATEGORIES.FILAMENTS', - }, - { - id: '2', - name: 'SHOP.PRODUCTS.P2.NAME', - description: 'SHOP.PRODUCTS.P2.DESC', - price: 29.9, - category: 'SHOP.CATEGORIES.FILAMENTS', - }, - { - id: '3', - name: 'SHOP.PRODUCTS.P3.NAME', - description: 'SHOP.PRODUCTS.P3.DESC', - price: 15.0, - category: 'SHOP.CATEGORIES.ACCESSORIES', - }, - ]; + private readonly http = inject(HttpClient); + private readonly languageService = inject(LanguageService); + private readonly apiUrl = `${environment.apiUrl}/api/shop`; - getProducts(): Observable { - return of(this.staticProducts); + readonly cart = signal(null); + readonly cartLoading = signal(false); + readonly cartLoaded = signal(false); + + readonly cartItemCount = computed(() => + (this.cart()?.items ?? []).reduce( + (total, item) => total + (Number(item.quantity) || 0), + 0, + ), + ); + + readonly cartSessionId = computed(() => this.cart()?.session?.id ?? null); + + readonly cartQuantityByProductId = computed(() => { + const quantities = new Map(); + for (const item of this.cart()?.items ?? []) { + const productId = item.shopProductId; + if (!productId) { + continue; + } + quantities.set( + productId, + (quantities.get(productId) ?? 0) + (Number(item.quantity) || 0), + ); + } + return quantities; + }); + + readonly cartQuantityByVariantId = computed(() => { + const quantities = new Map(); + for (const item of this.cart()?.items ?? []) { + const variantId = item.shopProductVariantId; + if (!variantId) { + continue; + } + quantities.set( + variantId, + (quantities.get(variantId) ?? 0) + (Number(item.quantity) || 0), + ); + } + return quantities; + }); + + getCategories(): Observable { + return this.http.get(`${this.apiUrl}/categories`, { + params: this.buildLangParams(), + }); } - getProductById(id: string): Observable { - return of(this.staticProducts.find((p) => p.id === id)); + getCategory(slug: string): Observable { + return this.http.get( + `${this.apiUrl}/categories/${encodeURIComponent(slug)}`, + { + params: this.buildLangParams(), + }, + ); + } + + getProductCatalog( + categorySlug?: string | null, + featured?: boolean | null, + ): Observable { + let params = this.buildLangParams(); + if (categorySlug) { + params = params.set('categorySlug', categorySlug); + } + if (featured !== null && featured !== undefined) { + params = params.set('featured', String(featured)); + } + + return this.http.get(`${this.apiUrl}/products`, { + params, + }); + } + + getProduct(slug: string): Observable { + return this.http.get( + `${this.apiUrl}/products/${encodeURIComponent(slug)}`, + { + params: this.buildLangParams(), + }, + ); + } + + loadCart(): Observable { + this.cartLoading.set(true); + return this.http + .get(`${this.apiUrl}/cart`, { + withCredentials: true, + }) + .pipe( + tap({ + next: (cart) => { + this.cart.set(cart); + this.cartLoaded.set(true); + this.cartLoading.set(false); + }, + error: () => { + this.cartLoading.set(false); + }, + }), + ); + } + + addToCart( + shopProductVariantId: string, + quantity = 1, + ): Observable { + return this.http + .post( + `${this.apiUrl}/cart/items`, + { + shopProductVariantId, + quantity, + }, + { + withCredentials: true, + }, + ) + .pipe(tap((cart) => this.setCart(cart))); + } + + updateCartItem( + lineItemId: string, + quantity: number, + ): Observable { + return this.http + .patch( + `${this.apiUrl}/cart/items/${encodeURIComponent(lineItemId)}`, + { quantity }, + { + withCredentials: true, + }, + ) + .pipe(tap((cart) => this.setCart(cart))); + } + + removeCartItem(lineItemId: string): Observable { + return this.http + .delete( + `${this.apiUrl}/cart/items/${encodeURIComponent(lineItemId)}`, + { + withCredentials: true, + }, + ) + .pipe(tap((cart) => this.setCart(cart))); + } + + clearCart(): Observable { + return this.http + .delete(`${this.apiUrl}/cart`, { + withCredentials: true, + }) + .pipe(tap((cart) => this.setCart(cart))); + } + + getProductModelFile( + urlOrPath: string, + filename: string, + ): Observable { + return this.http + .get(this.resolveApiUrl(urlOrPath), { + responseType: 'blob', + }) + .pipe( + map( + (blob) => + new File([blob], filename, { + type: blob.type || 'model/stl', + }), + ), + ); + } + + quantityForProduct(productId: string | null | undefined): number { + if (!productId) { + return 0; + } + return this.cartQuantityByProductId().get(productId) ?? 0; + } + + quantityForVariant(variantId: string | null | undefined): number { + if (!variantId) { + return 0; + } + return this.cartQuantityByVariantId().get(variantId) ?? 0; + } + + flattenCategoryTree( + categories: ShopCategoryTree[], + activeSlug: string | null, + ): ShopCategoryNavNode[] { + const nodes: ShopCategoryNavNode[] = []; + + const walk = (items: ShopCategoryTree[], depth: number) => { + for (const item of items) { + nodes.push({ + id: item.id, + slug: item.slug, + name: item.name, + depth, + productCount: item.productCount, + current: item.slug === activeSlug, + }); + walk(item.children ?? [], depth + 1); + } + }; + + walk(categories, 0); + return nodes; + } + + resolveMediaUrl( + variant: PublicMediaVariantDto | null | undefined, + ): string | null { + if (!variant) { + return null; + } + return variant.jpegUrl ?? variant.webpUrl ?? variant.avifUrl ?? null; + } + + resolveApiUrl(urlOrPath: string | null | undefined): string { + if (!urlOrPath) { + return ''; + } + if ( + urlOrPath.startsWith('http://') || + urlOrPath.startsWith('https://') || + urlOrPath.startsWith('blob:') + ) { + return urlOrPath; + } + const base = (environment.apiUrl || '').replace(/\/$/, ''); + const path = urlOrPath.startsWith('/') ? urlOrPath : `/${urlOrPath}`; + return `${base}${path}`; + } + + private buildLangParams(): HttpParams { + return new HttpParams().set('lang', this.languageService.selectedLang()); + } + + private setCart(cart: ShopCartResponse): void { + this.cart.set(cart); + this.cartLoaded.set(true); + this.cartLoading.set(false); } } diff --git a/frontend/src/app/features/shop/shop-page.component.html b/frontend/src/app/features/shop/shop-page.component.html index f6998b6..d7617c3 100644 --- a/frontend/src/app/features/shop/shop-page.component.html +++ b/frontend/src/app/features/shop/shop-page.component.html @@ -1,18 +1,299 @@ -
-
-
-

{{ "SHOP.WIP_EYEBROW" | translate }}

-

{{ "SHOP.WIP_TITLE" | translate }}

-

{{ "SHOP.WIP_SUBTITLE" | translate }}

+
+
+
+
+

{{ "SHOP.HERO_EYEBROW" | translate }}

+

+ {{ + selectedCategory()?.name || ("SHOP.TITLE" | translate) + }} +

+

+ {{ + selectedCategory()?.description || + ("SHOP.SUBTITLE" | translate) + }} +

+

+ {{ + selectedCategory() + ? ("SHOP.CATEGORY_META" | translate: { count: selectedCategory()?.productCount || 0 }) + : ("SHOP.CATALOG_META_DESCRIPTION" | translate) + }} +

-
- - {{ "SHOP.WIP_CTA_CALC" | translate }} - +
+ @if (cartHasItems()) { + + {{ "SHOP.GO_TO_CHECKOUT" | translate }} + + } @else { + + {{ "SHOP.WIP_CTA_CALC" | translate }} + + } + + @if (selectedCategory()) { + + {{ "SHOP.VIEW_ALL" | translate }} + + } +
-

{{ "SHOP.WIP_RETURN_LATER" | translate }}

-

{{ "SHOP.WIP_NOTE" | translate }}

+
+
+ {{ + "SHOP.HIGHLIGHT_PRODUCTS" | translate + }} + {{ + selectedCategory()?.productCount ?? products().length + }} +
+
+ {{ + "SHOP.HIGHLIGHT_CART" | translate + }} + {{ cartItemCount() }} +
+
+ {{ + "SHOP.HIGHLIGHT_READY" | translate + }} + {{ "SHOP.MODEL_3D" | translate }} +
+
+
+ +
+ + +
+ @if (error()) { +
+ {{ error() | translate }} +
+ } @else { + @if (featuredProducts().length > 0 && !selectedCategory()) { + + } + +
+
+
+

+ {{ + selectedCategory() + ? ("SHOP.SELECTED_CATEGORY" | translate) + : ("SHOP.CATALOG_LABEL" | translate) + }} +

+

+ {{ + selectedCategory()?.name || ("SHOP.CATALOG_TITLE" | translate) + }} +

+
+ + {{ products().length }} + {{ "SHOP.ITEMS_FOUND" | translate }} + +
+ + @if (loading()) { +
+ @for (ghost of [1, 2, 3, 4]; track ghost) { +
+ } +
+ } @else if (products().length === 0) { +
+ {{ "SHOP.EMPTY_CATEGORY" | translate }} +
+ } @else { +
+ @for (product of products(); track trackByProduct($index, product)) { + + } +
+ } +
+ } +
diff --git a/frontend/src/app/features/shop/shop-page.component.scss b/frontend/src/app/features/shop/shop-page.component.scss index 3288aec..2cc97e3 100644 --- a/frontend/src/app/features/shop/shop-page.component.scss +++ b/frontend/src/app/features/shop/shop-page.component.scss @@ -1,72 +1,339 @@ -.wip-section { +.shop-page { + --shop-hero-bg: #f8f3e5; + background: + radial-gradient(circle at top right, rgba(250, 207, 10, 0.24), transparent 22%), + linear-gradient(180deg, #faf7ef 0%, #f6f2e8 24%, var(--color-bg) 24%); +} + +.shop-hero { position: relative; - padding: var(--space-12) 0; - background-color: var(--color-bg); + overflow: hidden; + padding: 4.75rem 0 3.5rem; + background: transparent; } -.wip-card { - max-width: 760px; - margin: 0 auto; - padding: clamp(1.4rem, 3vw, 2.4rem); - border: 1px solid var(--color-border); - border-radius: var(--radius-xl); - background: rgba(255, 255, 255, 0.95); - box-shadow: var(--shadow-lg); - text-align: center; +.shop-hero-grid { + display: grid; + gap: var(--space-8); + align-items: end; + grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.8fr); } -.wip-eyebrow { - display: inline-block; - margin-bottom: var(--space-3); - padding: 0.3rem 0.7rem; - border-radius: 999px; - border: 1px solid rgba(16, 24, 32, 0.14); - font-size: 0.78rem; - letter-spacing: 0.12em; +.hero-copy { + display: grid; + gap: var(--space-4); +} + +.hero-highlights { + display: grid; + gap: var(--space-4); +} + +.highlight-card { + display: grid; + gap: 0.2rem; + padding: 1.15rem 1.2rem; + border-radius: 1rem; + border: 1px solid rgba(16, 24, 32, 0.08); + background: rgba(255, 255, 255, 0.78); + box-shadow: 0 12px 28px rgba(16, 24, 32, 0.08); + backdrop-filter: blur(8px); +} + +.highlight-card strong { + font-size: 1.35rem; + line-height: 1.1; +} + +.highlight-label { + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 0.72rem; + font-weight: 700; +} + +.shop-layout { + display: grid; + gap: var(--space-8); + align-items: start; + grid-template-columns: minmax(270px, 320px) minmax(0, 1fr); + padding-bottom: var(--space-12); +} + +.shop-sidebar { + position: sticky; + top: var(--space-6); + display: grid; + gap: var(--space-5); +} + +.panel-head { + display: flex; + justify-content: space-between; + gap: var(--space-4); + margin-bottom: var(--space-4); +} + +.panel-kicker { + margin: 0 0 var(--space-1); + font-size: 0.72rem; + letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-secondary-600); - background: rgba(250, 207, 10, 0.28); + font-weight: 700; } -h1 { - font-size: clamp(1.7rem, 4vw, 2.5rem); - margin-bottom: var(--space-4); +.panel-title { + margin: 0; + font-size: 1.1rem; +} + +.category-list { + display: grid; + gap: 0.4rem; +} + +.category-link { + --depth: 0; + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: 0.8rem 0.95rem 0.8rem calc(0.95rem + (var(--depth) * 0.95rem)); + border: 1px solid transparent; + border-radius: 0.9rem; + background: transparent; color: var(--color-text); + text-align: left; + cursor: pointer; + transition: + background-color 0.18s ease, + border-color 0.18s ease, + transform 0.18s ease; } -.wip-subtitle { - max-width: 60ch; - margin: 0 auto var(--space-8); +.category-link:hover, +.category-link.active { + background: rgba(250, 207, 10, 0.14); + border-color: rgba(250, 207, 10, 0.34); + transform: translateX(1px); +} + +.category-link small { color: var(--color-text-muted); } -.wip-actions { - display: flex; +.cart-card { + display: block; +} + +.panel-empty, +.catalog-state { + margin: 0; + padding: 1rem; + border-radius: 0.9rem; + background: rgba(16, 24, 32, 0.04); + color: var(--color-text-muted); +} + +.catalog-state-error { + background: rgba(239, 68, 68, 0.08); + color: var(--color-danger-600); +} + +.text-action, +.line-remove { + padding: 0; + border: 0; + background: transparent; + color: var(--color-text-muted); + font: inherit; + cursor: pointer; +} + +.cart-lines { + display: grid; gap: var(--space-4); - justify-content: center; - flex-wrap: wrap; + margin-bottom: var(--space-5); } -.wip-note { - margin: var(--space-4) auto 0; - max-width: 62ch; - font-size: 0.95rem; - color: var(--color-secondary-600); +.cart-line { + display: grid; + gap: var(--space-3); + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--color-border); } -.wip-return-later { - margin: var(--space-6) 0 0; +.cart-line:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.cart-line-copy { + display: grid; + gap: 0.25rem; +} + +.cart-line-copy strong { + font-size: 0.96rem; +} + +.cart-line-meta, +.cart-line-color { + color: var(--color-text-muted); + font-size: 0.86rem; +} + +.cart-line-color { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.color-dot { + width: 0.8rem; + height: 0.8rem; + border-radius: 50%; + border: 1px solid rgba(16, 24, 32, 0.12); +} + +.cart-line-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); +} + +.qty-control { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.2rem; + border-radius: 999px; + border: 1px solid var(--color-border); + background: rgba(255, 255, 255, 0.68); +} + +.qty-control button { + width: 1.9rem; + height: 1.9rem; + border: 0; + border-radius: 50%; + background: rgba(16, 24, 32, 0.06); + color: var(--color-text); + cursor: pointer; +} + +.qty-control span { + min-width: 1.4rem; + text-align: center; font-weight: 600; - color: var(--color-secondary-600); } -@media (max-width: 640px) { - .wip-section { - padding: var(--space-10) 0; +.line-total { + white-space: nowrap; +} + +.cart-totals { + display: grid; + gap: 0.55rem; + margin-bottom: var(--space-5); + padding-top: var(--space-4); + border-top: 1px solid var(--color-border); +} + +.cart-total-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + color: var(--color-text-muted); +} + +.cart-total-row-final { + color: var(--color-text); + font-size: 1.02rem; +} + +.catalog-content { + display: grid; + gap: var(--space-6); +} + +.featured-strip, +.catalog-panel { + display: grid; + gap: var(--space-5); +} + +.section-title { + margin: 0; + font-size: clamp(1.5rem, 1vw + 1.2rem, 2rem); +} + +.catalog-head { + display: flex; + justify-content: space-between; + align-items: end; + gap: var(--space-4); +} + +.catalog-counter { + color: var(--color-text-muted); + font-size: 0.9rem; + white-space: nowrap; +} + +.featured-grid, +.product-grid { + display: grid; + gap: var(--space-5); + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.skeleton-card { + min-height: 400px; + border-radius: 1.1rem; + background: + linear-gradient( + 110deg, + rgba(255, 255, 255, 0.7) 8%, + rgba(238, 235, 226, 0.95) 18%, + rgba(255, 255, 255, 0.7) 33% + ); + background-size: 220% 100%; + animation: skeleton 1.35s linear infinite; +} + +@keyframes skeleton { + to { + background-position-x: -220%; + } +} + +@media (max-width: 1080px) { + .shop-hero-grid, + .shop-layout { + grid-template-columns: 1fr; } - .wip-actions { + .shop-sidebar { + position: static; + } +} + +@media (max-width: 760px) { + .featured-grid, + .product-grid { + grid-template-columns: 1fr; + } + + .catalog-head, + .cart-line-controls, + .panel-head { + align-items: start; flex-direction: column; - align-items: stretch; } } diff --git a/frontend/src/app/features/shop/shop-page.component.ts b/frontend/src/app/features/shop/shop-page.component.ts index 589e743..ed1ef37 100644 --- a/frontend/src/app/features/shop/shop-page.component.ts +++ b/frontend/src/app/features/shop/shop-page.component.ts @@ -1,14 +1,292 @@ -import { Component } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { RouterLink } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; +import { + Component, + DestroyRef, + Injector, + computed, + inject, + input, + signal, +} from '@angular/core'; +import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; +import { Router, RouterLink } from '@angular/router'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { catchError, combineLatest, finalize, forkJoin, map, of, switchMap, tap } from 'rxjs'; +import { SeoService } from '../../core/services/seo.service'; +import { LanguageService } from '../../core/services/language.service'; 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'; +import { + ShopCategoryDetail, + ShopCategoryNavNode, + ShopCategoryTree, + ShopCartItem, + ShopProductSummary, + ShopService, +} from './services/shop.service'; @Component({ selector: 'app-shop-page', standalone: true, - imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent], + imports: [ + CommonModule, + TranslateModule, + RouterLink, + AppButtonComponent, + AppCardComponent, + ProductCardComponent, + ], templateUrl: './shop-page.component.html', styleUrl: './shop-page.component.scss', }) -export class ShopPageComponent {} +export class ShopPageComponent { + private readonly destroyRef = inject(DestroyRef); + private readonly injector = inject(Injector); + private readonly router = inject(Router); + private readonly translate = inject(TranslateService); + private readonly seoService = inject(SeoService); + private readonly languageService = inject(LanguageService); + readonly shopService = inject(ShopService); + + readonly categorySlug = input(); + + readonly loading = signal(true); + readonly error = signal(null); + readonly categories = signal([]); + readonly categoryNodes = signal([]); + readonly selectedCategory = signal(null); + readonly products = signal([]); + readonly featuredProducts = signal([]); + + readonly cartMutating = signal(false); + readonly busyLineItemId = signal(null); + + readonly cart = this.shopService.cart; + readonly cartLoading = this.shopService.cartLoading; + readonly cartItemCount = this.shopService.cartItemCount; + readonly currentCategorySlug = computed( + () => this.selectedCategory()?.slug ?? this.categorySlug() ?? null, + ); + readonly cartItems = computed(() => + (this.cart()?.items ?? []).filter((item) => item.lineItemType === 'SHOP_PRODUCT'), + ); + readonly cartHasItems = computed(() => this.cartItems().length > 0); + + constructor() { + if (!this.shopService.cartLoaded()) { + this.shopService + .loadCart() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + error: () => { + this.shopService.cart.set(null); + }, + }); + } + + combineLatest([ + toObservable(this.categorySlug, { injector: this.injector }), + toObservable(this.languageService.currentLang, { injector: this.injector }), + ]) + .pipe( + tap(() => { + this.loading.set(true); + this.error.set(null); + }), + switchMap(([categorySlug]) => + forkJoin({ + categories: this.shopService.getCategories(), + catalog: this.shopService.getProductCatalog(categorySlug ?? null), + featuredProducts: categorySlug + ? of([]) + : this.shopService + .getProductCatalog(null, true) + .pipe(map((response) => response.products)), + }).pipe( + catchError((error) => { + this.categories.set([]); + this.categoryNodes.set([]); + this.selectedCategory.set(null); + this.products.set([]); + this.featuredProducts.set([]); + this.error.set( + error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR', + ); + this.applyDefaultSeo(); + return of(null); + }), + finalize(() => this.loading.set(false)), + ), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((result) => { + if (!result) { + return; + } + + this.categories.set(result.categories); + this.categoryNodes.set( + this.shopService.flattenCategoryTree( + result.categories, + result.catalog.category?.slug ?? this.categorySlug() ?? null, + ), + ); + this.selectedCategory.set(result.catalog.category ?? null); + this.products.set(result.catalog.products); + this.featuredProducts.set(result.featuredProducts); + this.applySeo(result.catalog.category ?? null); + }); + } + + productCartQuantity(productId: string): number { + return this.shopService.quantityForProduct(productId); + } + + cartItemName(item: ShopCartItem): string { + return item.displayName || item.shopProductName || item.originalFilename || '-'; + } + + cartItemVariant(item: ShopCartItem): string | null { + return item.shopVariantLabel || item.shopVariantColorName || null; + } + + cartItemColor(item: ShopCartItem): string | null { + return item.shopVariantColorName || item.colorCode || null; + } + + cartItemColorHex(item: ShopCartItem): string { + return item.shopVariantColorHex || '#c9ced6'; + } + + navigateToCategory(slug?: string | null): void { + const commands = slug ? ['/shop', slug] : ['/shop']; + this.router.navigate(commands); + } + + increaseQuantity(item: ShopCartItem): void { + this.updateItemQuantity(item, (item.quantity ?? 0) + 1); + } + + decreaseQuantity(item: ShopCartItem): void { + const nextQuantity = Math.max(1, (item.quantity ?? 1) - 1); + this.updateItemQuantity(item, nextQuantity); + } + + removeItem(item: ShopCartItem): void { + this.cartMutating.set(true); + this.busyLineItemId.set(item.id); + this.shopService + .removeCartItem(item.id) + .pipe( + finalize(() => { + this.cartMutating.set(false); + this.busyLineItemId.set(null); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + error: () => { + this.error.set('SHOP.CART_UPDATE_ERROR'); + }, + }); + } + + clearCart(): void { + this.cartMutating.set(true); + this.busyLineItemId.set(null); + this.shopService + .clearCart() + .pipe( + finalize(() => { + this.cartMutating.set(false); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + error: () => { + this.error.set('SHOP.CART_UPDATE_ERROR'); + }, + }); + } + + goToCheckout(): void { + const sessionId = this.shopService.cartSessionId(); + if (!sessionId) { + return; + } + this.router.navigate(['/checkout'], { + queryParams: { + session: sessionId, + }, + }); + } + + trackByCategory(_index: number, item: ShopCategoryNavNode): string { + return item.id; + } + + trackByProduct(_index: number, product: ShopProductSummary): string { + return product.id; + } + + trackByCartItem(_index: number, item: ShopCartItem): string { + return item.id; + } + + private updateItemQuantity(item: ShopCartItem, quantity: number): void { + this.cartMutating.set(true); + this.busyLineItemId.set(item.id); + this.shopService + .updateCartItem(item.id, quantity) + .pipe( + finalize(() => { + this.cartMutating.set(false); + this.busyLineItemId.set(null); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe({ + error: () => { + this.error.set('SHOP.CART_UPDATE_ERROR'); + }, + }); + } + + private applySeo(category: ShopCategoryDetail | null): void { + if (!category) { + this.applyDefaultSeo(); + return; + } + + const title = + category.seoTitle || `${category.name} | ${this.translate.instant('SHOP.TITLE')} | 3D fab`; + const description = + category.seoDescription || + category.description || + this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); + const robots = category.indexable === false ? 'noindex, nofollow' : 'index, follow'; + + this.seoService.applyPageSeo({ + title, + description, + robots, + ogTitle: category.ogTitle || title, + ogDescription: category.ogDescription || description, + }); + } + + private applyDefaultSeo(): void { + const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`; + const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); + + this.seoService.applyPageSeo({ + title, + description, + robots: 'index, follow', + ogTitle: title, + ogDescription: description, + }); + } +} diff --git a/frontend/src/app/features/shop/shop.routes.ts b/frontend/src/app/features/shop/shop.routes.ts index b94949b..9654817 100644 --- a/frontend/src/app/features/shop/shop.routes.ts +++ b/frontend/src/app/features/shop/shop.routes.ts @@ -9,16 +9,21 @@ export const SHOP_ROUTES: Routes = [ data: { seoTitle: 'Shop 3D fab', seoDescription: - 'Lo shop 3D fab e in allestimento. Intanto puoi usare il calcolatore per ottenere un preventivo.', - seoRobots: 'noindex, nofollow', + 'Catalogo prodotti stampati in 3D, accessori tecnici e soluzioni pratiche pronte all uso.', }, }, { - path: ':id', + path: ':categorySlug/:productSlug', component: ProductDetailComponent, data: { seoTitle: 'Prodotto | 3D fab', - seoRobots: 'noindex, nofollow', + }, + }, + { + path: ':categorySlug', + component: ShopPageComponent, + data: { + seoTitle: 'Categoria Shop | 3D fab', }, }, ]; diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index 435a606..5842a47 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -155,6 +155,7 @@ "SHOP": { "TITLE": "Soluzioni tecniche", "SUBTITLE": "Prodotti pronti che risolvono problemi pratici", + "HERO_EYEBROW": "Shop tecnico", "WIP_EYEBROW": "Work in progress", "WIP_TITLE": "Shop in allestimento", "WIP_SUBTITLE": "Stiamo preparando uno shop con prodotti selezionati e funzionalità di creazione automatica!", @@ -162,6 +163,8 @@ "WIP_RETURN_LATER": "Torna tra un po'", "WIP_NOTE": "Ci teniamo a fare le cose fatte bene: nel frattempo puoi calcolare subito prezzo e tempi di un file 3D con il nostro calcolatore.", "ADD_CART": "Aggiungi al Carrello", + "ADDING": "Aggiunta in corso", + "ADD_SUCCESS": "Prodotto aggiunto al carrello.", "BACK": "Torna allo Shop", "NOT_FOUND": "Prodotto non trovato.", "DETAILS": "Dettagli", @@ -169,6 +172,47 @@ "SUCCESS_TITLE": "Aggiunto al carrello", "SUCCESS_DESC": "Il prodotto è stato aggiunto correttamente al carrello.", "CONTINUE": "Continua", + "VIEW_ALL": "Vedi tutto lo shop", + "ALL_CATEGORIES": "Tutte le categorie", + "CATALOG_LABEL": "Catalogo", + "CATALOG_TITLE": "Tutti i prodotti", + "CATALOG_META_DESCRIPTION": "Scopri prodotti stampati in 3D, accessori tecnici e soluzioni pronte all uso con lo stesso checkout del calcolatore.", + "CATEGORY_META": "{{count}} prodotti disponibili in questa categoria", + "CATEGORY_PANEL_KICKER": "Navigazione", + "CATEGORY_PANEL_TITLE": "Categorie", + "SELECTED_CATEGORY": "Categoria selezionata", + "ITEMS_FOUND": "prodotti", + "EMPTY_CATEGORY": "Nessun prodotto disponibile in questa categoria al momento.", + "FEATURED_KICKER": "In evidenza", + "FEATURED_TITLE": "Prodotti da tenere d occhio", + "FEATURED_BADGE": "Featured", + "HIGHLIGHT_PRODUCTS": "Prodotti", + "HIGHLIGHT_CART": "Nel carrello", + "HIGHLIGHT_READY": "Preview", + "PRICE_FROM": "Prezzo da", + "EXCERPT_FALLBACK": "Scheda prodotto in preparazione.", + "MODEL_3D": "3D preview", + "MODEL_TITLE": "Anteprima del modello", + "MODEL_LOADING": "Stiamo caricando il modello 3D.", + "MODEL_UNAVAILABLE": "Preview 3D non disponibile.", + "BREADCRUMB_ROOT": "Shop", + "SELECT_COLOR": "Colore", + "VARIANT": "Variante", + "QUANTITY": "Quantità", + "GO_TO_CHECKOUT": "Vai al checkout", + "IN_CART_SHORT": "Nel carrello x{{count}}", + "IN_CART_LONG": "Già nel carrello x{{count}}", + "DESCRIPTION_TITLE": "Descrizione", + "CART_TITLE": "Carrello", + "CART_SUMMARY_TITLE": "Riepilogo attuale", + "CART_LOADING": "Caricamento carrello in corso.", + "CART_EMPTY": "Il carrello è vuoto. Aggiungi un prodotto per ritrovarlo subito anche al prossimo accesso.", + "CART_SUBTOTAL": "Subtotale prodotti", + "CART_SHIPPING": "Spedizione", + "CART_TOTAL": "Totale stimato", + "CLEAR_CART": "Svuota", + "REMOVE": "Rimuovi", + "CART_UPDATE_ERROR": "Non siamo riusciti ad aggiornare il carrello. Riprova.", "CATEGORIES": { "FILAMENTS": "Filamenti", "ACCESSORIES": "Accessori" @@ -531,6 +575,12 @@ "ERR_ID_NOT_FOUND": "ID ordine non trovato.", "ERR_LOAD_ORDER": "Impossibile caricare i dettagli dell'ordine.", "ERR_REPORT_PAYMENT": "Impossibile segnalare il pagamento. Riprova.", + "ITEMS_TITLE": "Articoli dell'ordine", + "ORDER_TYPE_LABEL": "Tipo ordine", + "ITEM_COUNT": "Righe", + "TYPE_SHOP": "Shop", + "TYPE_CALCULATOR": "Calcolatore", + "TYPE_MIXED": "Misto", "NOT_AVAILABLE": "N/D" }, "DROPZONE": {
OrdineTipo Email Pagamento Stato ordine{{ order.orderNumber }} + + {{ orderKindLabel(order) }} + + {{ order.customerEmail }} {{ order.paymentStatus || "PENDING" }} {{ order.status }}
+ Nessun ordine trovato per i filtri selezionati.