feat(back-end): shop ui implementation
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 19s
PR Checks / security-sast (pull_request) Successful in 34s
PR Checks / test-backend (pull_request) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 59s

This commit is contained in:
2026-03-10 08:31:29 +01:00
parent cd0c13203f
commit a212a1d8cc
32 changed files with 4233 additions and 396 deletions

View File

@@ -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());

View File

@@ -88,14 +88,9 @@ public class InvoicePdfRenderingService {
vars.put("shippingAddressLine2", order.getShippingZip() + " " + order.getShippingCity() + ", " + order.getShippingCountryCode());
}
List<Map<String, Object>> invoiceLineItems = items.stream().map(i -> {
Map<String, Object> 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<Map<String, Object>> 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<String, Object> toInvoiceLineItem(OrderItem item) {
Map<String, Object> 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;
}
}

View File

@@ -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.
}
};
}
}
}

View File

@@ -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("<svg/>".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<OrderItem> 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);
}
}

View File

@@ -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")));

View File

@@ -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("<svg/>".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<Map<String, Object>> invoiceLineItems = (List<Map<String, Object>>) 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<String, Object> capturedVariables;
private CapturingInvoicePdfRenderingService() {
super(mock(TemplateEngine.class));
}
@Override
public byte[] generateInvoicePdfBytesFromTemplate(Map<String, Object> invoiceTemplateVariables, String qrBillSvg) {
this.capturedVariables = invoiceTemplateVariables;
return new byte[]{1, 2, 3};
}
}
}

View File

@@ -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<QuoteLineItem> 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<String, Object> 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<String, Object> 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);
}
}

View File

@@ -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',
},
},
{

View File

@@ -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' });

View File

@@ -67,12 +67,26 @@
</option>
</select>
</label>
<label class="toolbar-field" for="order-type-filter">
<span>Tipo ordine</span>
<select
class="ui-form-control"
id="order-type-filter"
[ngModel]="orderTypeFilter"
(ngModelChange)="onOrderTypeFilterChange($event)"
>
<option *ngFor="let option of orderTypeFilterOptions" [ngValue]="option">
{{ option }}
</option>
</select>
</label>
</div>
<div class="table-wrap ui-table-wrap">
<table class="ui-data-table">
<thead>
<tr>
<th>Ordine</th>
<th>Tipo</th>
<th>Email</th>
<th>Pagamento</th>
<th>Stato ordine</th>
@@ -86,6 +100,15 @@
(click)="openDetails(order.id)"
>
<td>{{ order.orderNumber }}</td>
<td>
<span
class="order-type-badge"
[class.order-type-badge--shop]="orderKind(order) === 'SHOP'"
[class.order-type-badge--mixed]="orderKind(order) === 'MIXED'"
>
{{ orderKindLabel(order) }}
</span>
</td>
<td>{{ order.customerEmail }}</td>
<td>{{ order.paymentStatus || "PENDING" }}</td>
<td>{{ order.status }}</td>
@@ -94,7 +117,7 @@
</td>
</tr>
<tr class="no-results" *ngIf="filteredOrders.length === 0">
<td colspan="5">
<td colspan="6">
Nessun ordine trovato per i filtri selezionati.
</td>
</tr>
@@ -105,7 +128,16 @@
<section class="detail-panel ui-detail-panel" *ngIf="selectedOrder">
<div class="detail-header">
<h2>Dettaglio ordine {{ selectedOrder.orderNumber }}</h2>
<div class="detail-title-row">
<h2>Dettaglio ordine {{ selectedOrder.orderNumber }}</h2>
<span
class="order-type-badge"
[class.order-type-badge--shop]="orderKind(selectedOrder) === 'SHOP'"
[class.order-type-badge--mixed]="orderKind(selectedOrder) === 'MIXED'"
>
{{ orderKindLabel(selectedOrder) }}
</span>
</div>
<p class="order-uuid">
UUID:
<code
@@ -129,6 +161,9 @@
<div class="ui-meta-item">
<strong>Stato ordine</strong><span>{{ selectedOrder.status }}</span>
</div>
<div class="ui-meta-item">
<strong>Tipo ordine</strong><span>{{ orderKindLabel(selectedOrder) }}</span>
</div>
<div class="ui-meta-item">
<strong>Totale</strong
><span>{{
@@ -207,6 +242,7 @@
type="button"
class="ui-button ui-button--ghost"
(click)="openPrintDetails()"
[disabled]="!hasPrintItems(selectedOrder)"
>
Dettagli stampa
</button>
@@ -215,38 +251,60 @@
<div class="items">
<div class="item" *ngFor="let item of selectedOrder.items">
<div class="item-main">
<p class="file-name">
<strong>{{ item.originalFilename }}</strong>
</p>
<p class="item-meta">
Qta: {{ item.quantity }} | Materiale:
{{ getItemMaterialLabel(item) }} | Colore:
<div class="item-heading">
<p class="file-name">
<strong>{{ itemDisplayName(item) }}</strong>
</p>
<span
class="color-swatch"
*ngIf="getItemColorHex(item) as colorHex"
[style.background-color]="colorHex"
></span>
<span>
{{ getItemColorLabel(item) }}
<ng-container *ngIf="getItemColorCodeSuffix(item) as colorCode">
({{ colorCode }})
</ng-container>
class="item-kind-badge"
[class.item-kind-badge--shop]="isShopItem(item)"
>
{{ isShopItem(item) ? "Shop" : "Calcolatore" }}
</span>
| Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer:
</div>
<p class="item-meta">
<span>Qta: {{ item.quantity }}</span>
<span *ngIf="showItemMaterial(item)">
Materiale: {{ getItemMaterialLabel(item) }}
</span>
<span *ngIf="itemVariantLabel(item) as variantLabel">
Variante: {{ variantLabel }}
</span>
<span class="item-meta__color">
Colore:
<span
class="color-swatch"
*ngIf="getItemColorHex(item) as colorHex"
[style.background-color]="colorHex"
></span>
<span>
{{ getItemColorLabel(item) }}
<ng-container *ngIf="getItemColorCodeSuffix(item) as colorCode">
({{ colorCode }})
</ng-container>
</span>
</span>
</p>
<p class="item-tech" *ngIf="showItemPrintDetails(item)">
Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer:
{{ item.layerHeightMm ?? "-" }} mm | Infill:
{{ item.infillPercent ?? "-" }}% | Supporti:
{{ formatSupports(item.supportsEnabled) }}
| Riga:
</p>
<p class="item-total">
Riga:
{{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }}
</p>
</div>
<button
type="button"
class="ui-button ui-button--ghost"
(click)="downloadItemFile(item.id, item.originalFilename)"
>
Scarica file
</button>
<div class="item-actions">
<button
type="button"
class="ui-button ui-button--ghost"
(click)="downloadItemFile(item.id, item.originalFilename || itemDisplayName(item))"
>
{{ downloadItemLabel(item) }}
</button>
</div>
</div>
</div>
</section>
@@ -315,7 +373,7 @@
<h4>Parametri per file</h4>
<div class="file-color-list">
<div class="file-color-row" *ngFor="let item of selectedOrder.items">
<div class="file-color-row" *ngFor="let item of printItems(selectedOrder)">
<span class="filename">{{ item.originalFilename }}</span>
<span class="file-color">
{{ getItemMaterialLabel(item) }} | Colore:

View File

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

View File

@@ -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
);
});
}

View File

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

View File

@@ -254,15 +254,19 @@
<div class="summary-items" *ngIf="quoteSession() as session">
<div class="summary-item" *ngFor="let item of session.items">
<div class="item-details">
<span class="item-name">{{ item.originalFilename }}</span>
<span class="item-name">{{ itemDisplayName(item) }}</span>
<div class="item-specs">
<span
>{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span
>
<span>
<span *ngIf="showItemMaterial(item)">
{{ "CHECKOUT.MATERIAL" | translate }}:
{{ itemMaterial(item) }}
</span>
<span *ngIf="itemVariantLabel(item) as variantLabel">
{{ "SHOP.VARIANT" | translate }}:
{{ variantLabel }}
</span>
<span class="item-color" *ngIf="itemColorLabel(item) !== '-'">
<span
class="color-dot"
@@ -271,14 +275,11 @@
<span class="color-name">{{ itemColorLabel(item) }}</span>
</span>
</div>
<div class="item-specs-sub">
<div class="item-specs-sub" *ngIf="showItemPrintMetrics(item)">
{{ item.printTimeSeconds / 3600 | number: "1.1-1" }}h |
{{ item.materialGrams | number: "1.0-0" }}g
</div>
<div
class="item-preview"
*ngIf="isCadSession() && isStlItem(item)"
>
<div class="item-preview" *ngIf="isStlItem(item)">
<ng-container
*ngIf="previewFile(item) as itemPreview; else previewState"
>

View File

@@ -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>(
'QuoteEstimatorService',
['getQuoteSession'],
),
},
{
provide: Router,
useValue: jasmine.createSpyObj<Router>('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();
});
});

View File

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

View File

@@ -58,146 +58,214 @@
</div>
</div>
<ng-container *ngIf="o.status === 'PENDING_PAYMENT'">
<app-card
class="mb-6 status-reported-card"
*ngIf="o.paymentStatus === 'REPORTED'"
>
<div class="status-content text-center">
<h3>{{ "PAYMENT.STATUS_REPORTED_TITLE" | translate }}</h3>
<p>{{ "PAYMENT.STATUS_REPORTED_DESC" | translate }}</p>
</div>
</app-card>
<app-card
class="mb-6 status-reported-card"
*ngIf="o.status === 'PENDING_PAYMENT' && o.paymentStatus === 'REPORTED'"
>
<div class="status-content text-center">
<h3>{{ "PAYMENT.STATUS_REPORTED_TITLE" | translate }}</h3>
<p>{{ "PAYMENT.STATUS_REPORTED_DESC" | translate }}</p>
</div>
</app-card>
<div class="payment-layout ui-two-column-layout">
<div class="payment-main">
<app-card class="mb-6">
<div class="ui-card-header">
<h3 class="ui-card-title">{{ "PAYMENT.METHOD" | translate }}</h3>
</div>
<div class="payment-layout ui-two-column-layout">
<div class="payment-main">
<app-card class="mb-6" *ngIf="o.status === 'PENDING_PAYMENT'">
<div class="ui-card-header">
<h3 class="ui-card-title">{{ "PAYMENT.METHOD" | translate }}</h3>
</div>
<div class="payment-selection">
<div class="ui-choice-grid">
<div
class="ui-choice-card"
[class.is-selected]="selectedPaymentMethod === 'twint'"
(click)="selectPayment('twint')"
>
<span class="method-name">{{
"PAYMENT.METHOD_TWINT" | translate
}}</span>
</div>
<div
class="ui-choice-card"
[class.is-selected]="selectedPaymentMethod === 'bill'"
(click)="selectPayment('bill')"
>
<span class="method-name">{{
"PAYMENT.METHOD_BANK" | translate
}}</span>
</div>
</div>
</div>
<div
class="payment-details ui-soft-panel fade-in text-center"
*ngIf="selectedPaymentMethod === 'twint'"
>
<div class="details-header">
<h4>{{ "PAYMENT.TWINT_TITLE" | translate }}</h4>
</div>
<div class="qr-placeholder">
<img
*ngIf="twintQrUrl()"
class="twint-qr"
[src]="getTwintQrUrl()"
(error)="onTwintQrError()"
[attr.alt]="'PAYMENT.TWINT_QR_ALT' | translate"
/>
<p>{{ "PAYMENT.TWINT_DESC" | translate }}</p>
<p class="billing-hint">
{{ "PAYMENT.BILLING_INFO_HINT" | translate }}
</p>
<div class="twint-mobile-action twint-button-container">
<button
type="button"
class="twint-launch-button"
(click)="openTwintPayment()"
>
<img
class="twint-launch-button__image"
[attr.alt]="'PAYMENT.TWINT_BUTTON_ALT' | translate"
[src]="getTwintButtonImageUrl()"
/>
</button>
</div>
<p class="amount">
{{ "PAYMENT.TOTAL" | translate }}:
{{ o.totalChf | currency: "CHF" }}
</p>
</div>
</div>
<div
class="payment-details ui-soft-panel fade-in text-center"
*ngIf="selectedPaymentMethod === 'bill'"
>
<div class="details-header">
<h4>{{ "PAYMENT.BANK_TITLE" | translate }}</h4>
</div>
<div class="bank-details">
<p class="billing-hint">
{{ "PAYMENT.BILLING_INFO_HINT" | translate }}
</p>
<br />
<div class="qr-bill-actions">
<app-button (click)="downloadQrInvoice()">
{{ "PAYMENT.DOWNLOAD_QR" | translate }}
</app-button>
</div>
</div>
</div>
<div class="ui-actions">
<app-button
variant="outline"
(click)="completeOrder()"
[disabled]="
!selectedPaymentMethod || o.paymentStatus === 'REPORTED'
"
[fullWidth]="true"
<div class="payment-selection">
<div class="ui-choice-grid">
<div
class="ui-choice-card"
[class.is-selected]="selectedPaymentMethod === 'twint'"
(click)="selectPayment('twint')"
>
{{
o.paymentStatus === "REPORTED"
? ("PAYMENT.IN_VERIFICATION" | translate)
: ("PAYMENT.CONFIRM" | translate)
}}
</app-button>
<span class="method-name">{{
"PAYMENT.METHOD_TWINT" | translate
}}</span>
</div>
<div
class="ui-choice-card"
[class.is-selected]="selectedPaymentMethod === 'bill'"
(click)="selectPayment('bill')"
>
<span class="method-name">{{
"PAYMENT.METHOD_BANK" | translate
}}</span>
</div>
</div>
</app-card>
</div>
</div>
<div class="payment-summary">
<app-card class="sticky-card">
<div class="ui-card-header">
<h3 class="ui-card-title">
{{ "PAYMENT.SUMMARY_TITLE" | translate }}
</h3>
<p class="ui-card-subtitle order-id">
#{{ getDisplayOrderNumber(o) }}
<div
class="payment-details ui-soft-panel fade-in text-center"
*ngIf="selectedPaymentMethod === 'twint'"
>
<div class="details-header">
<h4>{{ "PAYMENT.TWINT_TITLE" | translate }}</h4>
</div>
<div class="qr-placeholder">
<img
*ngIf="twintQrUrl()"
class="twint-qr"
[src]="getTwintQrUrl()"
(error)="onTwintQrError()"
[attr.alt]="'PAYMENT.TWINT_QR_ALT' | translate"
/>
<p>{{ "PAYMENT.TWINT_DESC" | translate }}</p>
<p class="billing-hint">
{{ "PAYMENT.BILLING_INFO_HINT" | translate }}
</p>
<div class="twint-mobile-action twint-button-container">
<button
type="button"
class="twint-launch-button"
(click)="openTwintPayment()"
>
<img
class="twint-launch-button__image"
[attr.alt]="'PAYMENT.TWINT_BUTTON_ALT' | translate"
[src]="getTwintButtonImageUrl()"
/>
</button>
</div>
<p class="amount">
{{ "PAYMENT.TOTAL" | translate }}:
{{ o.totalChf | currency: "CHF" }}
</p>
</div>
</div>
<app-price-breakdown
[rows]="orderPriceBreakdownRows(o)"
[total]="o.totalChf || 0"
[currency]="'CHF'"
[totalLabelKey]="'PAYMENT.TOTAL'"
></app-price-breakdown>
</app-card>
</div>
<div
class="payment-details ui-soft-panel fade-in text-center"
*ngIf="selectedPaymentMethod === 'bill'"
>
<div class="details-header">
<h4>{{ "PAYMENT.BANK_TITLE" | translate }}</h4>
</div>
<div class="bank-details">
<p class="billing-hint">
{{ "PAYMENT.BILLING_INFO_HINT" | translate }}
</p>
<br />
<div class="qr-bill-actions">
<app-button (click)="downloadQrInvoice()">
{{ "PAYMENT.DOWNLOAD_QR" | translate }}
</app-button>
</div>
</div>
</div>
<div class="ui-actions">
<app-button
variant="outline"
(click)="completeOrder()"
[disabled]="!selectedPaymentMethod || o.paymentStatus === 'REPORTED'"
[fullWidth]="true"
>
{{
o.paymentStatus === "REPORTED"
? ("PAYMENT.IN_VERIFICATION" | translate)
: ("PAYMENT.CONFIRM" | translate)
}}
</app-button>
</div>
</app-card>
<app-card class="mb-6">
<div class="ui-card-header">
<h3 class="ui-card-title">{{ "ORDER.ITEMS_TITLE" | translate }}</h3>
<p class="ui-card-subtitle">
{{ orderKindLabel(o) }}
</p>
</div>
<div class="order-items">
<div class="order-item" *ngFor="let item of o.items || []">
<div class="order-item-copy">
<div class="order-item-name-row">
<strong class="order-item-name">{{
itemDisplayName(item)
}}</strong>
<span
class="order-item-kind"
[class.order-item-kind-shop]="isShopItem(item)"
>
{{
isShopItem(item)
? ("ORDER.TYPE_SHOP" | translate)
: ("ORDER.TYPE_CALCULATOR" | translate)
}}
</span>
</div>
<div class="order-item-meta">
<span>{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span>
<span *ngIf="showItemMaterial(item)">
{{ "CHECKOUT.MATERIAL" | translate }}:
{{ item.materialCode || ("ORDER.NOT_AVAILABLE" | translate) }}
</span>
<span *ngIf="itemVariantLabel(item) as variantLabel">
{{ "SHOP.VARIANT" | translate }}: {{ variantLabel }}
</span>
<span class="item-color-chip">
<span
class="color-swatch"
*ngIf="itemColorHex(item) as colorHex"
[style.background-color]="colorHex"
></span>
<span>{{ itemColorLabel(item) }}</span>
</span>
</div>
<div class="order-item-tech" *ngIf="showItemPrintMetrics(item)">
{{ item.printTimeSeconds || 0 | number: "1.0-0" }}s |
{{ item.materialGrams || 0 | number: "1.0-0" }}g
</div>
</div>
<strong class="order-item-total">
{{ item.lineTotalChf || 0 | currency: "CHF" }}
</strong>
</div>
</div>
</app-card>
</div>
</ng-container>
<div class="payment-summary">
<app-card class="sticky-card">
<div class="ui-card-header">
<h3 class="ui-card-title">
{{ "PAYMENT.SUMMARY_TITLE" | translate }}
</h3>
<p class="ui-card-subtitle order-id">
#{{ getDisplayOrderNumber(o) }}
</p>
</div>
<div class="order-summary-meta">
<div>
<span class="summary-label">{{
"ORDER.ORDER_TYPE_LABEL" | translate
}}</span>
<strong>{{ orderKindLabel(o) }}</strong>
</div>
<div>
<span class="summary-label">{{ "ORDER.ITEM_COUNT" | translate }}</span>
<strong>{{ (o.items || []).length }}</strong>
</div>
</div>
<app-price-breakdown
[rows]="orderPriceBreakdownRows(o)"
[total]="o.totalChf || 0"
[currency]="'CHF'"
[totalLabelKey]="'PAYMENT.TOTAL'"
></app-price-breakdown>
</app-card>
</div>
</div>
</ng-container>
<div *ngIf="loading()" class="loading-state">

View File

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

View File

@@ -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<any>(null);
order = signal<PublicOrder | null>(null);
loading = signal(true);
error = signal<string | null>(null);
twintOpenUrl = signal<string | null>(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)
);
}
}

View File

@@ -1,17 +1,60 @@
<div class="product-card">
<div class="image-placeholder"></div>
<article class="product-card">
<a class="media" [routerLink]="productLink()">
@if (imageUrl(); as imageUrl) {
<img
[src]="imageUrl"
[alt]="product().primaryImage?.altText || product().name"
loading="lazy"
/>
} @else {
<div class="image-fallback">
<span>{{ product().category.name }}</span>
</div>
}
<div class="card-badges">
@if (product().isFeatured) {
<span class="badge badge-featured">{{
"SHOP.FEATURED_BADGE" | translate
}}</span>
}
@if (cartQuantity() > 0) {
<span class="badge badge-cart">
{{ "SHOP.IN_CART_SHORT" | translate: { count: cartQuantity() } }}
</span>
}
</div>
</a>
<div class="content">
<span class="category">{{ product().category | translate }}</span>
<div class="meta">
<span class="category">{{ product().category.name }}</span>
@if (product().model3d) {
<span class="model-pill">{{ "SHOP.MODEL_3D" | translate }}</span>
}
</div>
<h3 class="name">
<a [routerLink]="['/shop', product().id]">{{
product().name | translate
}}</a>
<a [routerLink]="productLink()">{{ product().name }}</a>
</h3>
<p class="excerpt">
{{ product().excerpt || ("SHOP.EXCERPT_FALLBACK" | translate) }}
</p>
<div class="footer">
<span class="price">{{ product().price | currency: "EUR" }}</span>
<a [routerLink]="['/shop', product().id]" class="view-btn">{{
<div class="pricing">
<span class="price">{{ priceLabel() | currency: "CHF" }}</span>
@if (hasPriceRange()) {
<small class="price-note">{{
"SHOP.PRICE_FROM" | translate
}}</small>
}
</div>
<a [routerLink]="productLink()" class="view-btn">{{
"SHOP.DETAILS" | translate
}}</a>
</div>
</div>
</div>
</article>

View File

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

View File

@@ -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<Product>();
private readonly shopService = inject(ShopService);
readonly product = input.required<ShopProductSummary>();
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;
}
}

View File

@@ -1,25 +1,214 @@
<div class="container wrapper">
<a routerLink="/shop" class="back-link">← {{ "SHOP.BACK" | translate }}</a>
<section class="product-page">
<div class="container wrapper">
<a [routerLink]="productLinkRoot()" class="back-link">
← {{ "SHOP.BACK" | translate }}
</a>
@if (product(); as p) {
<div class="detail-grid">
<div class="image-box"></div>
<div class="info">
<span class="category">{{ p.category | translate }}</span>
<h1>{{ p.name | translate }}</h1>
<p class="price">{{ p.price | currency: "EUR" }}</p>
<p class="desc">{{ p.description | translate }}</p>
<div class="actions">
<app-button variant="primary" (click)="addToCart()">
{{ "SHOP.ADD_CART" | translate }}
</app-button>
</div>
@if (loading()) {
<div class="detail-grid skeleton-grid">
<div class="skeleton-block"></div>
<div class="skeleton-block"></div>
</div>
</div>
} @else {
<p>{{ "SHOP.NOT_FOUND" | translate }}</p>
}
</div>
} @else {
@if (error()) {
<div class="state-card">{{ error() | translate }}</div>
} @else {
@if (product(); as p) {
<nav class="breadcrumbs">
<a routerLink="/shop">{{ "SHOP.BREADCRUMB_ROOT" | translate }}</a>
@for (crumb of p.breadcrumbs; track crumb.id) {
<span>/</span>
<a [routerLink]="['/shop', crumb.slug]">{{ crumb.name }}</a>
}
</nav>
<div class="detail-grid">
<section class="visual-column">
<div class="hero-media">
@if (imageUrl(selectedImage()); as imageUrl) {
<img
[src]="imageUrl"
[alt]="selectedImage().altText || p.name"
class="hero-image"
/>
} @else {
<div class="image-fallback">
<span>{{ p.category.name }}</span>
</div>
}
</div>
@if (galleryImages().length > 1) {
<div class="thumb-grid">
@for (image of galleryImages(); track image.mediaAssetId) {
<button
type="button"
class="thumb"
[class.active]="selectedImage().mediaAssetId === image.mediaAssetId"
(click)="selectImage(image.mediaAssetId)"
>
@if (imageUrl(image); as imageUrl) {
<img [src]="imageUrl" [alt]="image.altText || p.name" />
} @else {
<span>{{ p.name }}</span>
}
</button>
}
</div>
}
@if (p.model3d) {
<app-card class="viewer-card">
<div class="viewer-head">
<div>
<p class="viewer-kicker">
{{ "SHOP.MODEL_3D" | translate }}
</p>
<h3>{{ "SHOP.MODEL_TITLE" | translate }}</h3>
</div>
<div class="dimensions">
<span>
X {{ p.model3d.boundingBoxXMm || 0 | number: "1.0-1" }} mm
</span>
<span>
Y {{ p.model3d.boundingBoxYMm || 0 | number: "1.0-1" }} mm
</span>
<span>
Z {{ p.model3d.boundingBoxZMm || 0 | number: "1.0-1" }} mm
</span>
</div>
</div>
@if (modelLoading()) {
<div class="viewer-state">
{{ "SHOP.MODEL_LOADING" | translate }}
</div>
} @else if (modelError()) {
<div class="viewer-state viewer-state-error">
{{ "SHOP.MODEL_UNAVAILABLE" | translate }}
</div>
} @else {
@if (modelFile(); as modelPreviewFile) {
<app-stl-viewer
[file]="modelPreviewFile"
[height]="360"
[color]="selectedVariant()?.colorHex || '#facf0a'"
></app-stl-viewer>
}
}
</app-card>
}
</section>
<section class="info-column">
<div class="title-block">
<div class="title-meta">
<span class="category">{{ p.category.name }}</span>
@if (p.isFeatured) {
<span class="featured-pill">{{
"SHOP.FEATURED_BADGE" | translate
}}</span>
}
</div>
<h1>{{ p.name }}</h1>
<p class="excerpt">
{{
p.excerpt ||
p.description ||
("SHOP.EXCERPT_FALLBACK" | translate)
}}
</p>
</div>
<app-card>
<div class="purchase-card">
<div class="price-row">
<div>
<p class="panel-kicker">
{{ "SHOP.SELECT_COLOR" | translate }}
</p>
<h3>{{ priceLabel() | currency: "CHF" }}</h3>
</div>
@if (selectedVariantCartQuantity() > 0) {
<span class="cart-pill">
{{
"SHOP.IN_CART_LONG"
| translate: { count: selectedVariantCartQuantity() }
}}
</span>
}
</div>
<div class="variant-grid">
@for (variant of p.variants; track variant.id) {
<button
type="button"
class="variant-option"
[class.active]="selectedVariant()?.id === variant.id"
(click)="selectVariant(variant)"
>
<span
class="variant-swatch"
[style.background-color]="colorHex(variant)"
></span>
<span class="variant-copy">
<strong>{{ colorLabel(variant) }}</strong>
@if (variant.variantLabel) {
<small>{{ variant.variantLabel }}</small>
}
</span>
<strong>{{ variant.priceChf | currency: "CHF" }}</strong>
</button>
}
</div>
<div class="quantity-row">
<span>{{ "SHOP.QUANTITY" | translate }}</span>
<div class="qty-control">
<button type="button" (click)="decreaseQuantity()">-</button>
<span>{{ quantity() }}</span>
<button type="button" (click)="increaseQuantity()">+</button>
</div>
</div>
<div class="actions">
<app-button
variant="primary"
[disabled]="isAddingToCart()"
(click)="addToCart()"
>
{{
(isAddingToCart()
? "SHOP.ADDING"
: "SHOP.ADD_CART") | translate
}}
</app-button>
@if (shopService.cartItemCount() > 0) {
<app-button variant="outline" (click)="goToCheckout()">
{{ "SHOP.GO_TO_CHECKOUT" | translate }}
</app-button>
}
</div>
@if (addSuccess()) {
<p class="success-note">
{{ "SHOP.ADD_SUCCESS" | translate }}
</p>
}
</div>
</app-card>
@if (p.description) {
<div class="description-block">
<h2>{{ "SHOP.DESCRIPTION_TITLE" | translate }}</h2>
<p>{{ p.description }}</p>
</div>
}
</section>
</div>
}
}
}
</div>
</section>

View File

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

View File

@@ -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<string>();
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<Product | undefined>(undefined);
readonly categorySlug = input<string | undefined>();
readonly productSlug = input<string | undefined>();
constructor(
private shopService: ShopService,
private translate: TranslateService,
) {}
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly product = signal<ShopProductDetail | null>(null);
readonly selectedVariantId = signal<string | null>(null);
readonly selectedImageAssetId = signal<string | null>(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<File | null>(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,
});
}
}

View File

@@ -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);
});
});

View File

@@ -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<Product[]> {
return of(this.staticProducts);
readonly cart = signal<ShopCartResponse | null>(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<string, number>();
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<string, number>();
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<ShopCategoryTree[]> {
return this.http.get<ShopCategoryTree[]>(`${this.apiUrl}/categories`, {
params: this.buildLangParams(),
});
}
getProductById(id: string): Observable<Product | undefined> {
return of(this.staticProducts.find((p) => p.id === id));
getCategory(slug: string): Observable<ShopCategoryDetail> {
return this.http.get<ShopCategoryDetail>(
`${this.apiUrl}/categories/${encodeURIComponent(slug)}`,
{
params: this.buildLangParams(),
},
);
}
getProductCatalog(
categorySlug?: string | null,
featured?: boolean | null,
): Observable<ShopProductCatalogResponse> {
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<ShopProductCatalogResponse>(`${this.apiUrl}/products`, {
params,
});
}
getProduct(slug: string): Observable<ShopProductDetail> {
return this.http.get<ShopProductDetail>(
`${this.apiUrl}/products/${encodeURIComponent(slug)}`,
{
params: this.buildLangParams(),
},
);
}
loadCart(): Observable<ShopCartResponse> {
this.cartLoading.set(true);
return this.http
.get<ShopCartResponse>(`${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<ShopCartResponse> {
return this.http
.post<ShopCartResponse>(
`${this.apiUrl}/cart/items`,
{
shopProductVariantId,
quantity,
},
{
withCredentials: true,
},
)
.pipe(tap((cart) => this.setCart(cart)));
}
updateCartItem(
lineItemId: string,
quantity: number,
): Observable<ShopCartResponse> {
return this.http
.patch<ShopCartResponse>(
`${this.apiUrl}/cart/items/${encodeURIComponent(lineItemId)}`,
{ quantity },
{
withCredentials: true,
},
)
.pipe(tap((cart) => this.setCart(cart)));
}
removeCartItem(lineItemId: string): Observable<ShopCartResponse> {
return this.http
.delete<ShopCartResponse>(
`${this.apiUrl}/cart/items/${encodeURIComponent(lineItemId)}`,
{
withCredentials: true,
},
)
.pipe(tap((cart) => this.setCart(cart)));
}
clearCart(): Observable<ShopCartResponse> {
return this.http
.delete<ShopCartResponse>(`${this.apiUrl}/cart`, {
withCredentials: true,
})
.pipe(tap((cart) => this.setCart(cart)));
}
getProductModelFile(
urlOrPath: string,
filename: string,
): Observable<File> {
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);
}
}

View File

@@ -1,18 +1,299 @@
<section class="wip-section">
<div class="container">
<div class="wip-card">
<p class="wip-eyebrow">{{ "SHOP.WIP_EYEBROW" | translate }}</p>
<h1>{{ "SHOP.WIP_TITLE" | translate }}</h1>
<p class="wip-subtitle">{{ "SHOP.WIP_SUBTITLE" | translate }}</p>
<section class="shop-page">
<section class="shop-hero">
<div class="container shop-hero-grid">
<div class="hero-copy">
<p class="ui-eyebrow">{{ "SHOP.HERO_EYEBROW" | translate }}</p>
<h1 class="ui-hero-display">
{{
selectedCategory()?.name || ("SHOP.TITLE" | translate)
}}
</h1>
<p class="ui-copy-lead">
{{
selectedCategory()?.description ||
("SHOP.SUBTITLE" | translate)
}}
</p>
<p class="ui-copy-subtitle">
{{
selectedCategory()
? ("SHOP.CATEGORY_META" | translate: { count: selectedCategory()?.productCount || 0 })
: ("SHOP.CATALOG_META_DESCRIPTION" | translate)
}}
</p>
<div class="wip-actions">
<app-button variant="primary" routerLink="/calculator/basic">
{{ "SHOP.WIP_CTA_CALC" | translate }}
</app-button>
<div class="hero-actions ui-inline-actions">
@if (cartHasItems()) {
<app-button
variant="primary"
[disabled]="cartMutating()"
(click)="goToCheckout()"
>
{{ "SHOP.GO_TO_CHECKOUT" | translate }}
</app-button>
} @else {
<app-button variant="outline" routerLink="/calculator/basic">
{{ "SHOP.WIP_CTA_CALC" | translate }}
</app-button>
}
@if (selectedCategory()) {
<app-button variant="text" (click)="navigateToCategory()">
{{ "SHOP.VIEW_ALL" | translate }}
</app-button>
}
</div>
</div>
<p class="wip-return-later">{{ "SHOP.WIP_RETURN_LATER" | translate }}</p>
<p class="wip-note">{{ "SHOP.WIP_NOTE" | translate }}</p>
<div class="hero-highlights">
<div class="highlight-card">
<span class="highlight-label">{{
"SHOP.HIGHLIGHT_PRODUCTS" | translate
}}</span>
<strong>{{
selectedCategory()?.productCount ?? products().length
}}</strong>
</div>
<div class="highlight-card">
<span class="highlight-label">{{
"SHOP.HIGHLIGHT_CART" | translate
}}</span>
<strong>{{ cartItemCount() }}</strong>
</div>
<div class="highlight-card">
<span class="highlight-label">{{
"SHOP.HIGHLIGHT_READY" | translate
}}</span>
<strong>{{ "SHOP.MODEL_3D" | translate }}</strong>
</div>
</div>
</div>
</section>
<div class="container shop-layout">
<aside class="shop-sidebar">
<app-card>
<div class="panel-head">
<div>
<p class="panel-kicker">{{ "SHOP.CATEGORY_PANEL_KICKER" | translate }}</p>
<h2 class="panel-title">
{{ "SHOP.CATEGORY_PANEL_TITLE" | translate }}
</h2>
</div>
</div>
<button
type="button"
class="category-link"
[class.active]="!currentCategorySlug()"
(click)="navigateToCategory()"
>
<span>{{ "SHOP.ALL_CATEGORIES" | translate }}</span>
</button>
<div class="category-list">
@for (node of categoryNodes(); track trackByCategory($index, node)) {
<button
type="button"
class="category-link"
[class.active]="node.current"
[style.--depth]="node.depth"
(click)="navigateToCategory(node.slug)"
>
<span>{{ node.name }}</span>
<small>{{ node.productCount }}</small>
</button>
}
</div>
</app-card>
<app-card class="cart-card">
<div class="panel-head">
<div>
<p class="panel-kicker">{{ "SHOP.CART_TITLE" | translate }}</p>
<h2 class="panel-title">
{{ "SHOP.CART_SUMMARY_TITLE" | translate }}
</h2>
</div>
@if (cartHasItems()) {
<button
type="button"
class="text-action"
[disabled]="cartMutating()"
(click)="clearCart()"
>
{{ "SHOP.CLEAR_CART" | translate }}
</button>
}
</div>
@if (cartLoading() && !cart()) {
<p class="panel-empty">{{ "SHOP.CART_LOADING" | translate }}</p>
} @else if (!cartHasItems()) {
<p class="panel-empty">{{ "SHOP.CART_EMPTY" | translate }}</p>
} @else {
<div class="cart-lines">
@for (item of cartItems(); track trackByCartItem($index, item)) {
<article class="cart-line">
<div class="cart-line-copy">
<strong>{{ cartItemName(item) }}</strong>
@if (cartItemVariant(item); as variant) {
<span class="cart-line-meta">{{ variant }}</span>
}
@if (cartItemColor(item); as color) {
<span class="cart-line-color">
<span
class="color-dot"
[style.background-color]="cartItemColorHex(item)"
></span>
<span>{{ color }}</span>
</span>
}
</div>
<div class="cart-line-controls">
<div class="qty-control">
<button
type="button"
[disabled]="cartMutating() && busyLineItemId() === item.id"
(click)="decreaseQuantity(item)"
>
-
</button>
<span>{{ item.quantity }}</span>
<button
type="button"
[disabled]="cartMutating() && busyLineItemId() === item.id"
(click)="increaseQuantity(item)"
>
+
</button>
</div>
<strong class="line-total">{{
item.unitPriceChf * item.quantity | currency: "CHF"
}}</strong>
</div>
<button
type="button"
class="line-remove"
[disabled]="cartMutating() && busyLineItemId() === item.id"
(click)="removeItem(item)"
>
{{ "SHOP.REMOVE" | translate }}
</button>
</article>
}
</div>
<div class="cart-totals">
<div class="cart-total-row">
<span>{{ "SHOP.CART_SUBTOTAL" | translate }}</span>
<strong>{{ cart()?.itemsTotalChf || 0 | currency: "CHF" }}</strong>
</div>
<div class="cart-total-row">
<span>{{ "SHOP.CART_SHIPPING" | translate }}</span>
<strong>{{
cart()?.shippingCostChf || 0 | currency: "CHF"
}}</strong>
</div>
<div class="cart-total-row cart-total-row-final">
<span>{{ "SHOP.CART_TOTAL" | translate }}</span>
<strong>{{
cart()?.grandTotalChf || 0 | currency: "CHF"
}}</strong>
</div>
</div>
<app-button
variant="primary"
[fullWidth]="true"
[disabled]="cartMutating()"
(click)="goToCheckout()"
>
{{ "SHOP.GO_TO_CHECKOUT" | translate }}
</app-button>
}
</app-card>
</aside>
<section class="catalog-content">
@if (error()) {
<div class="catalog-state catalog-state-error">
{{ error() | translate }}
</div>
} @else {
@if (featuredProducts().length > 0 && !selectedCategory()) {
<section class="featured-strip">
<div class="section-head">
<div>
<p class="ui-eyebrow ui-eyebrow--compact">
{{ "SHOP.FEATURED_KICKER" | translate }}
</p>
<h2 class="section-title">
{{ "SHOP.FEATURED_TITLE" | translate }}
</h2>
</div>
</div>
<div class="featured-grid">
@for (
product of featuredProducts();
track trackByProduct($index, product)
) {
<app-product-card
[product]="product"
[cartQuantity]="productCartQuantity(product.id)"
></app-product-card>
}
</div>
</section>
}
<section class="catalog-panel">
<div class="section-head catalog-head">
<div>
<p class="ui-eyebrow ui-eyebrow--compact">
{{
selectedCategory()
? ("SHOP.SELECTED_CATEGORY" | translate)
: ("SHOP.CATALOG_LABEL" | translate)
}}
</p>
<h2 class="section-title">
{{
selectedCategory()?.name || ("SHOP.CATALOG_TITLE" | translate)
}}
</h2>
</div>
<span class="catalog-counter">
{{ products().length }}
{{ "SHOP.ITEMS_FOUND" | translate }}
</span>
</div>
@if (loading()) {
<div class="product-grid skeleton-grid">
@for (ghost of [1, 2, 3, 4]; track ghost) {
<div class="skeleton-card"></div>
}
</div>
} @else if (products().length === 0) {
<div class="catalog-state">
{{ "SHOP.EMPTY_CATEGORY" | translate }}
</div>
} @else {
<div class="product-grid">
@for (product of products(); track trackByProduct($index, product)) {
<app-product-card
[product]="product"
[cartQuantity]="productCartQuantity(product.id)"
></app-product-card>
}
</div>
}
</section>
}
</section>
</div>
</section>

View File

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

View File

@@ -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<string | undefined>();
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly categories = signal<ShopCategoryTree[]>([]);
readonly categoryNodes = signal<ShopCategoryNavNode[]>([]);
readonly selectedCategory = signal<ShopCategoryDetail | null>(null);
readonly products = signal<ShopProductSummary[]>([]);
readonly featuredProducts = signal<ShopProductSummary[]>([]);
readonly cartMutating = signal(false);
readonly busyLineItemId = signal<string | null>(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<ShopProductSummary[]>([])
: 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,
});
}
}

View File

@@ -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',
},
},
];

View File

@@ -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": {