feat(back-end): shop ui implementation
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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">
|
||||
<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,12 +251,27 @@
|
||||
<div class="items">
|
||||
<div class="item" *ngFor="let item of selectedOrder.items">
|
||||
<div class="item-main">
|
||||
<div class="item-heading">
|
||||
<p class="file-name">
|
||||
<strong>{{ item.originalFilename }}</strong>
|
||||
<strong>{{ itemDisplayName(item) }}</strong>
|
||||
</p>
|
||||
<span
|
||||
class="item-kind-badge"
|
||||
[class.item-kind-badge--shop]="isShopItem(item)"
|
||||
>
|
||||
{{ isShopItem(item) ? "Shop" : "Calcolatore" }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="item-meta">
|
||||
Qta: {{ item.quantity }} | Materiale:
|
||||
{{ getItemMaterialLabel(item) }} | Colore:
|
||||
<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"
|
||||
@@ -232,23 +283,30 @@
|
||||
({{ colorCode }})
|
||||
</ng-container>
|
||||
</span>
|
||||
| Nozzle: {{ item.nozzleDiameterMm ?? "-" }} mm | Layer:
|
||||
</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>
|
||||
<div class="item-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="ui-button ui-button--ghost"
|
||||
(click)="downloadItemFile(item.id, item.originalFilename)"
|
||||
(click)="downloadItemFile(item.id, item.originalFilename || itemDisplayName(item))"
|
||||
>
|
||||
Scarica file
|
||||
{{ downloadItemLabel(item) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -58,10 +58,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="o.status === 'PENDING_PAYMENT'">
|
||||
<app-card
|
||||
class="mb-6 status-reported-card"
|
||||
*ngIf="o.paymentStatus === 'REPORTED'"
|
||||
*ngIf="o.status === 'PENDING_PAYMENT' && o.paymentStatus === 'REPORTED'"
|
||||
>
|
||||
<div class="status-content text-center">
|
||||
<h3>{{ "PAYMENT.STATUS_REPORTED_TITLE" | translate }}</h3>
|
||||
@@ -71,7 +70,7 @@
|
||||
|
||||
<div class="payment-layout ui-two-column-layout">
|
||||
<div class="payment-main">
|
||||
<app-card class="mb-6">
|
||||
<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>
|
||||
@@ -162,9 +161,7 @@
|
||||
<app-button
|
||||
variant="outline"
|
||||
(click)="completeOrder()"
|
||||
[disabled]="
|
||||
!selectedPaymentMethod || o.paymentStatus === 'REPORTED'
|
||||
"
|
||||
[disabled]="!selectedPaymentMethod || o.paymentStatus === 'REPORTED'"
|
||||
[fullWidth]="true"
|
||||
>
|
||||
{{
|
||||
@@ -175,6 +172,65 @@
|
||||
</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>
|
||||
|
||||
<div class="payment-summary">
|
||||
@@ -188,6 +244,19 @@
|
||||
</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"
|
||||
@@ -198,7 +267,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<div *ngIf="loading()" class="loading-state">
|
||||
<app-card>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.16;
|
||||
}
|
||||
|
||||
.name a {
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
@if (loading()) {
|
||||
<div class="detail-grid skeleton-grid">
|
||||
<div class="skeleton-block"></div>
|
||||
<div class="skeleton-block"></div>
|
||||
</div>
|
||||
} @else {
|
||||
<p>{{ "SHOP.NOT_FOUND" | translate }}</p>
|
||||
@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>
|
||||
}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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
|
||||
.getProductById(productId)
|
||||
.subscribe((p) => this.product.set(p));
|
||||
}
|
||||
.loadCart()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
error: () => {
|
||||
this.shopService.cart.set(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
addToCart() {
|
||||
alert(this.translate.instant('SHOP.MOCK_ADD_CART'));
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
144
frontend/src/app/features/shop/services/shop.service.spec.ts
Normal file
144
frontend/src/app/features/shop/services/shop.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user