feat(back-end): shop ui implementation
This commit is contained in:
@@ -191,7 +191,9 @@ public class OrderService {
|
|||||||
oItem.setShopVariantLabel(qItem.getShopVariantLabel());
|
oItem.setShopVariantLabel(qItem.getShopVariantLabel());
|
||||||
oItem.setShopVariantColorName(qItem.getShopVariantColorName());
|
oItem.setShopVariantColorName(qItem.getShopVariantColorName());
|
||||||
oItem.setShopVariantColorHex(qItem.getShopVariantColorHex());
|
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() != null
|
||||||
&& qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode() != null) {
|
&& qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode() != null) {
|
||||||
oItem.setMaterialCode(qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode());
|
oItem.setMaterialCode(qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode());
|
||||||
|
|||||||
@@ -88,14 +88,9 @@ public class InvoicePdfRenderingService {
|
|||||||
vars.put("shippingAddressLine2", order.getShippingZip() + " " + order.getShippingCity() + ", " + order.getShippingCountryCode());
|
vars.put("shippingAddressLine2", order.getShippingZip() + " " + order.getShippingCity() + ", " + order.getShippingCountryCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Map<String, Object>> invoiceLineItems = items.stream().map(i -> {
|
List<Map<String, Object>> invoiceLineItems = items.stream()
|
||||||
Map<String, Object> line = new HashMap<>();
|
.map(this::toInvoiceLineItem)
|
||||||
line.put("description", "Stampa 3D: " + i.getOriginalFilename());
|
.collect(Collectors.toList());
|
||||||
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());
|
|
||||||
|
|
||||||
if (order.getCadTotalChf() != null && order.getCadTotalChf().compareTo(BigDecimal.ZERO) > 0) {
|
if (order.getCadTotalChf() != null && order.getCadTotalChf().compareTo(BigDecimal.ZERO) > 0) {
|
||||||
BigDecimal cadHours = order.getCadHours() != null ? order.getCadHours() : BigDecimal.ZERO;
|
BigDecimal cadHours = order.getCadHours() != null ? order.getCadHours() : BigDecimal.ZERO;
|
||||||
@@ -157,4 +152,45 @@ public class InvoicePdfRenderingService {
|
|||||||
private String formatCadHours(BigDecimal hours) {
|
private String formatCadHours(BigDecimal hours) {
|
||||||
return hours.setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
|
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 usageDraft = buildUsage(draftAsset, "HOME_SECTION", "shop-gallery", 0, false, true);
|
||||||
MediaUsage usagePrivate = buildUsage(privateAsset, "HOME_SECTION", "shop-gallery", 3, false, true);
|
MediaUsage usagePrivate = buildUsage(privateAsset, "HOME_SECTION", "shop-gallery", 3, false, true);
|
||||||
|
|
||||||
when(mediaUsageRepository.findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc(
|
when(mediaUsageRepository.findActiveByUsageTypeAndUsageKeys(
|
||||||
"HOME_SECTION", "shop-gallery"
|
"HOME_SECTION", List.of("shop-gallery")
|
||||||
)).thenReturn(List.of(usageSecond, usageFirst, usageDraft, usagePrivate));
|
)).thenReturn(List.of(usageSecond, usageFirst, usageDraft, usagePrivate));
|
||||||
when(mediaVariantRepository.findByMediaAsset_IdIn(List.of(readyPublicAsset.getId())))
|
when(mediaVariantRepository.findByMediaAsset_IdIn(List.of(readyPublicAsset.getId())))
|
||||||
.thenReturn(List.of(
|
.thenReturn(List.of(
|
||||||
@@ -93,8 +93,8 @@ class PublicMediaQueryServiceTest {
|
|||||||
MediaAsset asset = buildAsset("READY", "PUBLIC", "Joe portrait", "Joe portrait fallback");
|
MediaAsset asset = buildAsset("READY", "PUBLIC", "Joe portrait", "Joe portrait fallback");
|
||||||
MediaUsage usage = buildUsage(asset, "ABOUT_MEMBER", "joe", 0, true, true);
|
MediaUsage usage = buildUsage(asset, "ABOUT_MEMBER", "joe", 0, true, true);
|
||||||
|
|
||||||
when(mediaUsageRepository.findByUsageTypeAndUsageKeyAndIsActiveTrueOrderBySortOrderAscCreatedAtAsc(
|
when(mediaUsageRepository.findActiveByUsageTypeAndUsageKeys(
|
||||||
"ABOUT_MEMBER", "joe"
|
"ABOUT_MEMBER", List.of("joe")
|
||||||
)).thenReturn(List.of(usage));
|
)).thenReturn(List.of(usage));
|
||||||
when(mediaVariantRepository.findByMediaAsset_IdIn(List.of(asset.getId())))
|
when(mediaVariantRepository.findByMediaAsset_IdIn(List.of(asset.getId())))
|
||||||
.thenReturn(List.of(buildVariant(asset, "card", "JPEG", "joe/card.jpg")));
|
.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',
|
seoTitle: 'Shop 3D fab',
|
||||||
seoDescription:
|
seoDescription:
|
||||||
'Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.',
|
'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 { ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router';
|
||||||
import { filter } from 'rxjs/operators';
|
import { filter } from 'rxjs/operators';
|
||||||
|
|
||||||
|
export interface PageSeoOverride {
|
||||||
|
title?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
robots?: string | null;
|
||||||
|
ogTitle?: string | null;
|
||||||
|
ogDescription?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
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 {
|
private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void {
|
||||||
const mergedData = this.getMergedRouteData(rootSnapshot);
|
const mergedData = this.getMergedRouteData(rootSnapshot);
|
||||||
const title = this.asString(mergedData['seoTitle']) ?? this.defaultTitle;
|
const title = this.asString(mergedData['seoTitle']) ?? this.defaultTitle;
|
||||||
const description =
|
const description =
|
||||||
this.asString(mergedData['seoDescription']) ?? this.defaultDescription;
|
this.asString(mergedData['seoDescription']) ?? this.defaultDescription;
|
||||||
const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow';
|
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.titleService.setTitle(title);
|
||||||
this.metaService.updateTag({ name: 'description', content: description });
|
this.metaService.updateTag({ name: 'description', content: description });
|
||||||
this.metaService.updateTag({ name: 'robots', content: robots });
|
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({
|
this.metaService.updateTag({
|
||||||
property: 'og:description',
|
property: 'og:description',
|
||||||
content: description,
|
content: ogDescription,
|
||||||
});
|
});
|
||||||
this.metaService.updateTag({ property: 'og:type', content: 'website' });
|
this.metaService.updateTag({ property: 'og:type', content: 'website' });
|
||||||
this.metaService.updateTag({ name: 'twitter:card', content: 'summary' });
|
this.metaService.updateTag({ name: 'twitter:card', content: 'summary' });
|
||||||
|
|||||||
@@ -67,12 +67,26 @@
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
||||||
<div class="table-wrap ui-table-wrap">
|
<div class="table-wrap ui-table-wrap">
|
||||||
<table class="ui-data-table">
|
<table class="ui-data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Ordine</th>
|
<th>Ordine</th>
|
||||||
|
<th>Tipo</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Pagamento</th>
|
<th>Pagamento</th>
|
||||||
<th>Stato ordine</th>
|
<th>Stato ordine</th>
|
||||||
@@ -86,6 +100,15 @@
|
|||||||
(click)="openDetails(order.id)"
|
(click)="openDetails(order.id)"
|
||||||
>
|
>
|
||||||
<td>{{ order.orderNumber }}</td>
|
<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.customerEmail }}</td>
|
||||||
<td>{{ order.paymentStatus || "PENDING" }}</td>
|
<td>{{ order.paymentStatus || "PENDING" }}</td>
|
||||||
<td>{{ order.status }}</td>
|
<td>{{ order.status }}</td>
|
||||||
@@ -94,7 +117,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="no-results" *ngIf="filteredOrders.length === 0">
|
<tr class="no-results" *ngIf="filteredOrders.length === 0">
|
||||||
<td colspan="5">
|
<td colspan="6">
|
||||||
Nessun ordine trovato per i filtri selezionati.
|
Nessun ordine trovato per i filtri selezionati.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -105,7 +128,16 @@
|
|||||||
|
|
||||||
<section class="detail-panel ui-detail-panel" *ngIf="selectedOrder">
|
<section class="detail-panel ui-detail-panel" *ngIf="selectedOrder">
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
|
<div class="detail-title-row">
|
||||||
<h2>Dettaglio ordine {{ selectedOrder.orderNumber }}</h2>
|
<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">
|
<p class="order-uuid">
|
||||||
UUID:
|
UUID:
|
||||||
<code
|
<code
|
||||||
@@ -129,6 +161,9 @@
|
|||||||
<div class="ui-meta-item">
|
<div class="ui-meta-item">
|
||||||
<strong>Stato ordine</strong><span>{{ selectedOrder.status }}</span>
|
<strong>Stato ordine</strong><span>{{ selectedOrder.status }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ui-meta-item">
|
||||||
|
<strong>Tipo ordine</strong><span>{{ orderKindLabel(selectedOrder) }}</span>
|
||||||
|
</div>
|
||||||
<div class="ui-meta-item">
|
<div class="ui-meta-item">
|
||||||
<strong>Totale</strong
|
<strong>Totale</strong
|
||||||
><span>{{
|
><span>{{
|
||||||
@@ -207,6 +242,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="ui-button ui-button--ghost"
|
class="ui-button ui-button--ghost"
|
||||||
(click)="openPrintDetails()"
|
(click)="openPrintDetails()"
|
||||||
|
[disabled]="!hasPrintItems(selectedOrder)"
|
||||||
>
|
>
|
||||||
Dettagli stampa
|
Dettagli stampa
|
||||||
</button>
|
</button>
|
||||||
@@ -215,12 +251,27 @@
|
|||||||
<div class="items">
|
<div class="items">
|
||||||
<div class="item" *ngFor="let item of selectedOrder.items">
|
<div class="item" *ngFor="let item of selectedOrder.items">
|
||||||
<div class="item-main">
|
<div class="item-main">
|
||||||
|
<div class="item-heading">
|
||||||
<p class="file-name">
|
<p class="file-name">
|
||||||
<strong>{{ item.originalFilename }}</strong>
|
<strong>{{ itemDisplayName(item) }}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
<span
|
||||||
|
class="item-kind-badge"
|
||||||
|
[class.item-kind-badge--shop]="isShopItem(item)"
|
||||||
|
>
|
||||||
|
{{ isShopItem(item) ? "Shop" : "Calcolatore" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<p class="item-meta">
|
<p class="item-meta">
|
||||||
Qta: {{ item.quantity }} | Materiale:
|
<span>Qta: {{ item.quantity }}</span>
|
||||||
{{ getItemMaterialLabel(item) }} | Colore:
|
<span *ngIf="showItemMaterial(item)">
|
||||||
|
Materiale: {{ getItemMaterialLabel(item) }}
|
||||||
|
</span>
|
||||||
|
<span *ngIf="itemVariantLabel(item) as variantLabel">
|
||||||
|
Variante: {{ variantLabel }}
|
||||||
|
</span>
|
||||||
|
<span class="item-meta__color">
|
||||||
|
Colore:
|
||||||
<span
|
<span
|
||||||
class="color-swatch"
|
class="color-swatch"
|
||||||
*ngIf="getItemColorHex(item) as colorHex"
|
*ngIf="getItemColorHex(item) as colorHex"
|
||||||
@@ -232,23 +283,30 @@
|
|||||||
({{ colorCode }})
|
({{ colorCode }})
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</span>
|
</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.layerHeightMm ?? "-" }} mm | Infill:
|
||||||
{{ item.infillPercent ?? "-" }}% | Supporti:
|
{{ item.infillPercent ?? "-" }}% | Supporti:
|
||||||
{{ formatSupports(item.supportsEnabled) }}
|
{{ formatSupports(item.supportsEnabled) }}
|
||||||
| Riga:
|
</p>
|
||||||
|
<p class="item-total">
|
||||||
|
Riga:
|
||||||
{{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }}
|
{{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="item-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="ui-button ui-button--ghost"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -315,7 +373,7 @@
|
|||||||
|
|
||||||
<h4>Parametri per file</h4>
|
<h4>Parametri per file</h4>
|
||||||
<div class="file-color-list">
|
<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="filename">{{ item.originalFilename }}</span>
|
||||||
<span class="file-color">
|
<span class="file-color">
|
||||||
{{ getItemMaterialLabel(item) }} | Colore:
|
{{ getItemMaterialLabel(item) }} | Colore:
|
||||||
|
|||||||
@@ -21,10 +21,11 @@
|
|||||||
|
|
||||||
.list-toolbar {
|
.list-toolbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(230px, 1.6fr) minmax(170px, 1fr) minmax(
|
grid-template-columns:
|
||||||
190px,
|
minmax(230px, 1.6fr)
|
||||||
1fr
|
minmax(170px, 1fr)
|
||||||
);
|
minmax(190px, 1fr)
|
||||||
|
minmax(170px, 1fr);
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
@@ -69,6 +70,13 @@ tbody tr.no-results:hover {
|
|||||||
margin: 0 0 var(--space-2);
|
margin: 0 0 var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.actions-block {
|
.actions-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -113,6 +121,15 @@ tbody tr.no-results:hover {
|
|||||||
|
|
||||||
.item-main {
|
.item-main {
|
||||||
min-width: 0;
|
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 {
|
.file-name {
|
||||||
@@ -124,7 +141,7 @@ tbody tr.no-results:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.item-meta {
|
.item-meta {
|
||||||
margin: var(--space-1) 0 0;
|
margin: 0;
|
||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -133,7 +150,33 @@ tbody tr.no-results:hover {
|
|||||||
flex-wrap: wrap;
|
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;
|
justify-self: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +193,32 @@ tbody tr.no-results:hover {
|
|||||||
margin: 0;
|
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 {
|
.modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -247,6 +316,10 @@ h4 {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.actions-block {
|
.actions-block {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export class AdminDashboardComponent implements OnInit {
|
|||||||
orderSearchTerm = '';
|
orderSearchTerm = '';
|
||||||
paymentStatusFilter = 'ALL';
|
paymentStatusFilter = 'ALL';
|
||||||
orderStatusFilter = 'ALL';
|
orderStatusFilter = 'ALL';
|
||||||
|
orderTypeFilter = 'ALL';
|
||||||
showPrintDetails = false;
|
showPrintDetails = false;
|
||||||
loading = false;
|
loading = false;
|
||||||
detailLoading = false;
|
detailLoading = false;
|
||||||
@@ -62,6 +63,7 @@ export class AdminDashboardComponent implements OnInit {
|
|||||||
'COMPLETED',
|
'COMPLETED',
|
||||||
'CANCELLED',
|
'CANCELLED',
|
||||||
];
|
];
|
||||||
|
readonly orderTypeFilterOptions = ['ALL', 'SHOP', 'CALCULATOR', 'MIXED'];
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadOrders();
|
this.loadOrders();
|
||||||
@@ -117,6 +119,11 @@ export class AdminDashboardComponent implements OnInit {
|
|||||||
this.applyListFiltersAndSelection();
|
this.applyListFiltersAndSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onOrderTypeFilterChange(value: string): void {
|
||||||
|
this.orderTypeFilter = value || 'ALL';
|
||||||
|
this.applyListFiltersAndSelection();
|
||||||
|
}
|
||||||
|
|
||||||
openDetails(orderId: string): void {
|
openDetails(orderId: string): void {
|
||||||
this.detailLoading = true;
|
this.detailLoading = true;
|
||||||
this.adminOrdersService.getOrder(orderId).subscribe({
|
this.adminOrdersService.getOrder(orderId).subscribe({
|
||||||
@@ -124,6 +131,7 @@ export class AdminDashboardComponent implements OnInit {
|
|||||||
this.selectedOrder = order;
|
this.selectedOrder = order;
|
||||||
this.selectedStatus = order.status;
|
this.selectedStatus = order.status;
|
||||||
this.selectedPaymentMethod = order.paymentMethod || 'OTHER';
|
this.selectedPaymentMethod = order.paymentMethod || 'OTHER';
|
||||||
|
this.showPrintDetails = this.showPrintDetails && this.hasPrintItems(order);
|
||||||
this.detailLoading = false;
|
this.detailLoading = false;
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
@@ -247,6 +255,9 @@ export class AdminDashboardComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openPrintDetails(): void {
|
openPrintDetails(): void {
|
||||||
|
if (!this.selectedOrder || !this.hasPrintItems(this.selectedOrder)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.showPrintDetails = true;
|
this.showPrintDetails = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +278,34 @@ export class AdminDashboardComponent implements OnInit {
|
|||||||
return 'Bozza';
|
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 {
|
isHexColor(value?: string): boolean {
|
||||||
return (
|
return (
|
||||||
typeof value === 'string' &&
|
typeof value === 'string' &&
|
||||||
@@ -291,12 +330,22 @@ export class AdminDashboardComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getItemColorLabel(item: AdminOrderItem): string {
|
getItemColorLabel(item: AdminOrderItem): string {
|
||||||
|
const shopColorName = (item.shopVariantColorName || '').trim();
|
||||||
|
if (shopColorName) {
|
||||||
|
return shopColorName;
|
||||||
|
}
|
||||||
|
|
||||||
const colorName = (item.filamentColorName || '').trim();
|
const colorName = (item.filamentColorName || '').trim();
|
||||||
const colorCode = (item.colorCode || '').trim();
|
const colorCode = (item.colorCode || '').trim();
|
||||||
return colorName || colorCode || '-';
|
return colorName || colorCode || '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
getItemColorHex(item: AdminOrderItem): string | null {
|
getItemColorHex(item: AdminOrderItem): string | null {
|
||||||
|
const shopColorHex = (item.shopVariantColorHex || '').trim();
|
||||||
|
if (this.isHexColor(shopColorHex)) {
|
||||||
|
return shopColorHex;
|
||||||
|
}
|
||||||
|
|
||||||
const variantHex = (item.filamentColorHex || '').trim();
|
const variantHex = (item.filamentColorHex || '').trim();
|
||||||
if (this.isHexColor(variantHex)) {
|
if (this.isHexColor(variantHex)) {
|
||||||
return variantHex;
|
return variantHex;
|
||||||
@@ -336,6 +385,54 @@ export class AdminDashboardComponent implements OnInit {
|
|||||||
return 'Supporti -';
|
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 {
|
isSelected(orderId: string): boolean {
|
||||||
return this.selectedOrder?.id === orderId;
|
return this.selectedOrder?.id === orderId;
|
||||||
}
|
}
|
||||||
@@ -349,6 +446,7 @@ export class AdminDashboardComponent implements OnInit {
|
|||||||
this.selectedStatus = updatedOrder.status;
|
this.selectedStatus = updatedOrder.status;
|
||||||
this.selectedPaymentMethod =
|
this.selectedPaymentMethod =
|
||||||
updatedOrder.paymentMethod || this.selectedPaymentMethod;
|
updatedOrder.paymentMethod || this.selectedPaymentMethod;
|
||||||
|
this.showPrintDetails = this.showPrintDetails && this.hasPrintItems(updatedOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyListFiltersAndSelection(): void {
|
private applyListFiltersAndSelection(): void {
|
||||||
@@ -384,8 +482,16 @@ export class AdminDashboardComponent implements OnInit {
|
|||||||
const matchesOrderStatus =
|
const matchesOrderStatus =
|
||||||
this.orderStatusFilter === 'ALL' ||
|
this.orderStatusFilter === 'ALL' ||
|
||||||
orderStatus === this.orderStatusFilter;
|
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 {
|
export interface AdminOrderItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
itemType: string;
|
||||||
originalFilename: string;
|
originalFilename: string;
|
||||||
|
displayName?: string;
|
||||||
materialCode: string;
|
materialCode: string;
|
||||||
colorCode: string;
|
colorCode: string;
|
||||||
filamentVariantId?: number;
|
filamentVariantId?: number;
|
||||||
|
shopProductId?: string;
|
||||||
|
shopProductVariantId?: string;
|
||||||
|
shopProductSlug?: string;
|
||||||
|
shopProductName?: string;
|
||||||
|
shopVariantLabel?: string;
|
||||||
|
shopVariantColorName?: string;
|
||||||
|
shopVariantColorHex?: string;
|
||||||
filamentVariantDisplayName?: string;
|
filamentVariantDisplayName?: string;
|
||||||
filamentColorName?: string;
|
filamentColorName?: string;
|
||||||
filamentColorHex?: string;
|
filamentColorHex?: string;
|
||||||
|
|||||||
@@ -254,15 +254,19 @@
|
|||||||
<div class="summary-items" *ngIf="quoteSession() as session">
|
<div class="summary-items" *ngIf="quoteSession() as session">
|
||||||
<div class="summary-item" *ngFor="let item of session.items">
|
<div class="summary-item" *ngFor="let item of session.items">
|
||||||
<div class="item-details">
|
<div class="item-details">
|
||||||
<span class="item-name">{{ item.originalFilename }}</span>
|
<span class="item-name">{{ itemDisplayName(item) }}</span>
|
||||||
<div class="item-specs">
|
<div class="item-specs">
|
||||||
<span
|
<span
|
||||||
>{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span
|
>{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span
|
||||||
>
|
>
|
||||||
<span>
|
<span *ngIf="showItemMaterial(item)">
|
||||||
{{ "CHECKOUT.MATERIAL" | translate }}:
|
{{ "CHECKOUT.MATERIAL" | translate }}:
|
||||||
{{ itemMaterial(item) }}
|
{{ itemMaterial(item) }}
|
||||||
</span>
|
</span>
|
||||||
|
<span *ngIf="itemVariantLabel(item) as variantLabel">
|
||||||
|
{{ "SHOP.VARIANT" | translate }}:
|
||||||
|
{{ variantLabel }}
|
||||||
|
</span>
|
||||||
<span class="item-color" *ngIf="itemColorLabel(item) !== '-'">
|
<span class="item-color" *ngIf="itemColorLabel(item) !== '-'">
|
||||||
<span
|
<span
|
||||||
class="color-dot"
|
class="color-dot"
|
||||||
@@ -271,14 +275,11 @@
|
|||||||
<span class="color-name">{{ itemColorLabel(item) }}</span>
|
<span class="color-name">{{ itemColorLabel(item) }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-specs-sub">
|
<div class="item-specs-sub" *ngIf="showItemPrintMetrics(item)">
|
||||||
{{ item.printTimeSeconds / 3600 | number: "1.1-1" }}h |
|
{{ item.printTimeSeconds / 3600 | number: "1.1-1" }}h |
|
||||||
{{ item.materialGrams | number: "1.0-0" }}g
|
{{ item.materialGrams | number: "1.0-0" }}g
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="item-preview" *ngIf="isStlItem(item)">
|
||||||
class="item-preview"
|
|
||||||
*ngIf="isCadSession() && isStlItem(item)"
|
|
||||||
>
|
|
||||||
<ng-container
|
<ng-container
|
||||||
*ngIf="previewFile(item) as itemPreview; else previewState"
|
*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({
|
this.quoteService.getQuoteSession(this.sessionId).subscribe({
|
||||||
next: (session) => {
|
next: (session) => {
|
||||||
this.quoteSession.set(session);
|
this.quoteSession.set(session);
|
||||||
if (this.isCadSessionData(session)) {
|
if (Array.isArray(session?.items) && session.items.length > 0) {
|
||||||
this.loadStlPreviews(session);
|
this.loadStlPreviews(session);
|
||||||
} else {
|
} else {
|
||||||
this.resetPreviewState();
|
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 {
|
isStlItem(item: any): boolean {
|
||||||
const name = String(item?.originalFilename ?? '').toLowerCase();
|
const name = String(item?.originalFilename ?? '').toLowerCase();
|
||||||
return name.endsWith('.stl');
|
return name.endsWith('.stl');
|
||||||
@@ -249,11 +282,20 @@ export class CheckoutComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
itemColorLabel(item: any): string {
|
itemColorLabel(item: any): string {
|
||||||
|
const shopColor = String(item?.shopVariantColorName ?? '').trim();
|
||||||
|
if (shopColor) {
|
||||||
|
return shopColor;
|
||||||
|
}
|
||||||
const raw = String(item?.colorCode ?? '').trim();
|
const raw = String(item?.colorCode ?? '').trim();
|
||||||
return raw || '-';
|
return raw || '-';
|
||||||
}
|
}
|
||||||
|
|
||||||
itemColorSwatch(item: any): string {
|
itemColorSwatch(item: any): string {
|
||||||
|
const shopHex = String(item?.shopVariantColorHex ?? '').trim();
|
||||||
|
if (this.isHexColor(shopHex)) {
|
||||||
|
return shopHex;
|
||||||
|
}
|
||||||
|
|
||||||
const variantId = Number(item?.filamentVariantId);
|
const variantId = Number(item?.filamentVariantId);
|
||||||
if (Number.isFinite(variantId) && this.variantHexById.has(variantId)) {
|
if (Number.isFinite(variantId) && this.variantHexById.has(variantId)) {
|
||||||
return this.variantHexById.get(variantId)!;
|
return this.variantHexById.get(variantId)!;
|
||||||
@@ -303,7 +345,7 @@ export class CheckoutComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.selectedPreviewFile.set(file);
|
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.selectedPreviewColor.set(this.previewColor(item));
|
||||||
this.previewModalOpen.set(true);
|
this.previewModalOpen.set(true);
|
||||||
}
|
}
|
||||||
@@ -353,7 +395,6 @@ export class CheckoutComponent implements OnInit {
|
|||||||
private loadStlPreviews(session: any): void {
|
private loadStlPreviews(session: any): void {
|
||||||
if (
|
if (
|
||||||
!this.sessionId ||
|
!this.sessionId ||
|
||||||
!this.isCadSessionData(session) ||
|
|
||||||
!Array.isArray(session?.items)
|
!Array.isArray(session?.items)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -58,10 +58,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-container *ngIf="o.status === 'PENDING_PAYMENT'">
|
|
||||||
<app-card
|
<app-card
|
||||||
class="mb-6 status-reported-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">
|
<div class="status-content text-center">
|
||||||
<h3>{{ "PAYMENT.STATUS_REPORTED_TITLE" | translate }}</h3>
|
<h3>{{ "PAYMENT.STATUS_REPORTED_TITLE" | translate }}</h3>
|
||||||
@@ -71,7 +70,7 @@
|
|||||||
|
|
||||||
<div class="payment-layout ui-two-column-layout">
|
<div class="payment-layout ui-two-column-layout">
|
||||||
<div class="payment-main">
|
<div class="payment-main">
|
||||||
<app-card class="mb-6">
|
<app-card class="mb-6" *ngIf="o.status === 'PENDING_PAYMENT'">
|
||||||
<div class="ui-card-header">
|
<div class="ui-card-header">
|
||||||
<h3 class="ui-card-title">{{ "PAYMENT.METHOD" | translate }}</h3>
|
<h3 class="ui-card-title">{{ "PAYMENT.METHOD" | translate }}</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,9 +161,7 @@
|
|||||||
<app-button
|
<app-button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
(click)="completeOrder()"
|
(click)="completeOrder()"
|
||||||
[disabled]="
|
[disabled]="!selectedPaymentMethod || o.paymentStatus === 'REPORTED'"
|
||||||
!selectedPaymentMethod || o.paymentStatus === 'REPORTED'
|
|
||||||
"
|
|
||||||
[fullWidth]="true"
|
[fullWidth]="true"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
@@ -175,6 +172,65 @@
|
|||||||
</app-button>
|
</app-button>
|
||||||
</div>
|
</div>
|
||||||
</app-card>
|
</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>
|
||||||
|
|
||||||
<div class="payment-summary">
|
<div class="payment-summary">
|
||||||
@@ -188,6 +244,19 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<app-price-breakdown
|
||||||
[rows]="orderPriceBreakdownRows(o)"
|
[rows]="orderPriceBreakdownRows(o)"
|
||||||
[total]="o.totalChf || 0"
|
[total]="o.totalChf || 0"
|
||||||
@@ -198,7 +267,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<div *ngIf="loading()" class="loading-state">
|
<div *ngIf="loading()" class="loading-state">
|
||||||
<app-card>
|
<app-card>
|
||||||
|
|||||||
@@ -115,6 +115,107 @@
|
|||||||
top: var(--space-6);
|
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 {
|
.fade-in {
|
||||||
animation: fadeIn 0.4s ease-out;
|
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,
|
PriceBreakdownRow,
|
||||||
} from '../../shared/components/price-breakdown/price-breakdown.component';
|
} 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({
|
@Component({
|
||||||
selector: 'app-order',
|
selector: 'app-order',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -32,7 +78,7 @@ export class OrderComponent implements OnInit {
|
|||||||
|
|
||||||
orderId: string | null = null;
|
orderId: string | null = null;
|
||||||
selectedPaymentMethod: 'twint' | 'bill' | null = 'twint';
|
selectedPaymentMethod: 'twint' | 'bill' | null = 'twint';
|
||||||
order = signal<any>(null);
|
order = signal<PublicOrder | null>(null);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
error = signal<string | null>(null);
|
error = signal<string | null>(null);
|
||||||
twintOpenUrl = signal<string | null>(null);
|
twintOpenUrl = signal<string | null>(null);
|
||||||
@@ -201,4 +247,106 @@ export class OrderComponent implements OnInit {
|
|||||||
private extractOrderNumber(orderId: string): string {
|
private extractOrderNumber(orderId: string): string {
|
||||||
return orderId.split('-')[0];
|
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">
|
<article class="product-card">
|
||||||
<div class="image-placeholder"></div>
|
<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">
|
<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">
|
<h3 class="name">
|
||||||
<a [routerLink]="['/shop', product().id]">{{
|
<a [routerLink]="productLink()">{{ product().name }}</a>
|
||||||
product().name | translate
|
|
||||||
}}</a>
|
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
<p class="excerpt">
|
||||||
|
{{ product().excerpt || ("SHOP.EXCERPT_FALLBACK" | translate) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<span class="price">{{ product().price | currency: "EUR" }}</span>
|
<div class="pricing">
|
||||||
<a [routerLink]="['/shop', product().id]" class="view-btn">{{
|
<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
|
"SHOP.DETAILS" | translate
|
||||||
}}</a>
|
}}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
|
|||||||
@@ -1,48 +1,187 @@
|
|||||||
.product-card {
|
.product-card {
|
||||||
background: var(--color-bg-card);
|
display: grid;
|
||||||
border: 1px solid var(--color-border);
|
height: 100%;
|
||||||
border-radius: var(--radius-lg);
|
border: 1px solid rgba(16, 24, 32, 0.08);
|
||||||
|
border-radius: 1.1rem;
|
||||||
overflow: hidden;
|
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 {
|
&: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;
|
.media {
|
||||||
background-color: var(--color-neutral-200);
|
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;
|
.image-fallback {
|
||||||
color: var(--color-text-muted);
|
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;
|
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 {
|
.name {
|
||||||
font-size: 1.125rem;
|
margin: 0;
|
||||||
margin: var(--space-2) 0;
|
font-size: 1.2rem;
|
||||||
a {
|
line-height: 1.16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name a {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
&:hover {
|
|
||||||
color: var(--color-brand);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.excerpt {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
gap: var(--space-4);
|
||||||
margin-top: var(--space-4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pricing {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.price {
|
.price {
|
||||||
|
font-size: 1.35rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-brand);
|
color: var(--color-neutral-900);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.price-note {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.view-btn {
|
.view-btn {
|
||||||
font-size: 0.875rem;
|
display: inline-flex;
|
||||||
font-weight: 500;
|
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 { CommonModule } from '@angular/common';
|
||||||
|
import { Component, computed, inject, input } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { Product } from '../../services/shop.service';
|
import { ShopProductSummary, ShopService } from '../../services/shop.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-product-card',
|
selector: 'app-product-card',
|
||||||
@@ -12,5 +12,31 @@ import { Product } from '../../services/shop.service';
|
|||||||
styleUrl: './product-card.component.scss',
|
styleUrl: './product-card.component.scss',
|
||||||
})
|
})
|
||||||
export class ProductCardComponent {
|
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">
|
<section class="product-page">
|
||||||
<a routerLink="/shop" class="back-link">← {{ "SHOP.BACK" | translate }}</a>
|
<div class="container wrapper">
|
||||||
|
<a [routerLink]="productLinkRoot()" class="back-link">
|
||||||
|
← {{ "SHOP.BACK" | translate }}
|
||||||
|
</a>
|
||||||
|
|
||||||
@if (product(); as p) {
|
@if (loading()) {
|
||||||
<div class="detail-grid">
|
<div class="detail-grid skeleton-grid">
|
||||||
<div class="image-box"></div>
|
<div class="skeleton-block"></div>
|
||||||
|
<div class="skeleton-block"></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>
|
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @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 {
|
.product-page {
|
||||||
padding-top: var(--space-8);
|
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;
|
.wrapper {
|
||||||
margin-bottom: var(--space-6);
|
display: grid;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link,
|
||||||
|
.breadcrumbs {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.breadcrumbs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-grid {
|
.detail-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-8);
|
gap: var(--space-8);
|
||||||
@media (min-width: 768px) {
|
grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr);
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-box {
|
.visual-column,
|
||||||
background-color: var(--color-neutral-200);
|
.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);
|
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 {
|
.category {
|
||||||
color: var(--color-brand);
|
color: var(--color-secondary-600);
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
.price {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
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);
|
color: var(--color-text-muted);
|
||||||
line-height: 1.6;
|
line-height: 1.7;
|
||||||
margin-bottom: var(--space-8);
|
}
|
||||||
|
|
||||||
|
.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 { 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 { 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 { 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({
|
@Component({
|
||||||
selector: 'app-product-detail',
|
selector: 'app-product-detail',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterLink,
|
||||||
|
TranslateModule,
|
||||||
|
AppButtonComponent,
|
||||||
|
AppCardComponent,
|
||||||
|
StlViewerComponent,
|
||||||
|
],
|
||||||
templateUrl: './product-detail.component.html',
|
templateUrl: './product-detail.component.html',
|
||||||
styleUrl: './product-detail.component.scss',
|
styleUrl: './product-detail.component.scss',
|
||||||
})
|
})
|
||||||
export class ProductDetailComponent {
|
export class ProductDetailComponent {
|
||||||
// Input binding from router
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
id = input<string>();
|
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(
|
readonly loading = signal(true);
|
||||||
private shopService: ShopService,
|
readonly error = signal<string | null>(null);
|
||||||
private translate: TranslateService,
|
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() {
|
readonly modelLoading = signal(false);
|
||||||
const productId = this.id();
|
readonly modelError = signal(false);
|
||||||
if (productId) {
|
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
|
this.shopService
|
||||||
.getProductById(productId)
|
.loadCart()
|
||||||
.subscribe((p) => this.product.set(p));
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
}
|
.subscribe({
|
||||||
|
error: () => {
|
||||||
|
this.shopService.cart.set(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
addToCart() {
|
combineLatest([
|
||||||
alert(this.translate.instant('SHOP.MOCK_ADD_CART'));
|
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 { computed, inject, Injectable, signal } from '@angular/core';
|
||||||
import { Observable, of } from 'rxjs';
|
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;
|
id: string;
|
||||||
|
slug: string;
|
||||||
name: 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({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class ShopService {
|
export class ShopService {
|
||||||
// Dati statici per ora
|
private readonly http = inject(HttpClient);
|
||||||
private staticProducts: Product[] = [
|
private readonly languageService = inject(LanguageService);
|
||||||
{
|
private readonly apiUrl = `${environment.apiUrl}/api/shop`;
|
||||||
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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
getProducts(): Observable<Product[]> {
|
readonly cart = signal<ShopCartResponse | null>(null);
|
||||||
return of(this.staticProducts);
|
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> {
|
getCategory(slug: string): Observable<ShopCategoryDetail> {
|
||||||
return of(this.staticProducts.find((p) => p.id === id));
|
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">
|
<section class="shop-page">
|
||||||
<div class="container">
|
<section class="shop-hero">
|
||||||
<div class="wip-card">
|
<div class="container shop-hero-grid">
|
||||||
<p class="wip-eyebrow">{{ "SHOP.WIP_EYEBROW" | translate }}</p>
|
<div class="hero-copy">
|
||||||
<h1>{{ "SHOP.WIP_TITLE" | translate }}</h1>
|
<p class="ui-eyebrow">{{ "SHOP.HERO_EYEBROW" | translate }}</p>
|
||||||
<p class="wip-subtitle">{{ "SHOP.WIP_SUBTITLE" | 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">
|
<div class="hero-actions ui-inline-actions">
|
||||||
<app-button variant="primary" routerLink="/calculator/basic">
|
@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 }}
|
{{ "SHOP.WIP_CTA_CALC" | translate }}
|
||||||
</app-button>
|
</app-button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (selectedCategory()) {
|
||||||
|
<app-button variant="text" (click)="navigateToCategory()">
|
||||||
|
{{ "SHOP.VIEW_ALL" | translate }}
|
||||||
|
</app-button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="wip-return-later">{{ "SHOP.WIP_RETURN_LATER" | translate }}</p>
|
<div class="hero-highlights">
|
||||||
<p class="wip-note">{{ "SHOP.WIP_NOTE" | translate }}</p>
|
<div class="highlight-card">
|
||||||
|
<span class="highlight-label">{{
|
||||||
|
"SHOP.HIGHLIGHT_PRODUCTS" | translate
|
||||||
|
}}</span>
|
||||||
|
<strong>{{
|
||||||
|
selectedCategory()?.productCount ?? products().length
|
||||||
|
}}</strong>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</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;
|
position: relative;
|
||||||
padding: var(--space-12) 0;
|
overflow: hidden;
|
||||||
background-color: var(--color-bg);
|
padding: 4.75rem 0 3.5rem;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wip-card {
|
.shop-hero-grid {
|
||||||
max-width: 760px;
|
display: grid;
|
||||||
margin: 0 auto;
|
gap: var(--space-8);
|
||||||
padding: clamp(1.4rem, 3vw, 2.4rem);
|
align-items: end;
|
||||||
border: 1px solid var(--color-border);
|
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.8fr);
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wip-eyebrow {
|
.hero-copy {
|
||||||
display: inline-block;
|
display: grid;
|
||||||
margin-bottom: var(--space-3);
|
gap: var(--space-4);
|
||||||
padding: 0.3rem 0.7rem;
|
}
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid rgba(16, 24, 32, 0.14);
|
.hero-highlights {
|
||||||
font-size: 0.78rem;
|
display: grid;
|
||||||
letter-spacing: 0.12em;
|
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;
|
text-transform: uppercase;
|
||||||
color: var(--color-secondary-600);
|
color: var(--color-secondary-600);
|
||||||
background: rgba(250, 207, 10, 0.28);
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
.panel-title {
|
||||||
font-size: clamp(1.7rem, 4vw, 2.5rem);
|
margin: 0;
|
||||||
margin-bottom: var(--space-4);
|
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);
|
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 {
|
.category-link:hover,
|
||||||
max-width: 60ch;
|
.category-link.active {
|
||||||
margin: 0 auto var(--space-8);
|
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);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wip-actions {
|
.cart-card {
|
||||||
display: flex;
|
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);
|
gap: var(--space-4);
|
||||||
justify-content: center;
|
margin-bottom: var(--space-5);
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wip-note {
|
.cart-line {
|
||||||
margin: var(--space-4) auto 0;
|
display: grid;
|
||||||
max-width: 62ch;
|
gap: var(--space-3);
|
||||||
font-size: 0.95rem;
|
padding-bottom: var(--space-4);
|
||||||
color: var(--color-secondary-600);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wip-return-later {
|
.cart-line:last-child {
|
||||||
margin: var(--space-6) 0 0;
|
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;
|
font-weight: 600;
|
||||||
color: var(--color-secondary-600);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
.line-total {
|
||||||
.wip-section {
|
white-space: nowrap;
|
||||||
padding: var(--space-10) 0;
|
}
|
||||||
|
|
||||||
|
.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;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,292 @@
|
|||||||
import { Component } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { RouterLink } from '@angular/router';
|
import {
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
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 { 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({
|
@Component({
|
||||||
selector: 'app-shop-page',
|
selector: 'app-shop-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
TranslateModule,
|
||||||
|
RouterLink,
|
||||||
|
AppButtonComponent,
|
||||||
|
AppCardComponent,
|
||||||
|
ProductCardComponent,
|
||||||
|
],
|
||||||
templateUrl: './shop-page.component.html',
|
templateUrl: './shop-page.component.html',
|
||||||
styleUrl: './shop-page.component.scss',
|
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: {
|
data: {
|
||||||
seoTitle: 'Shop 3D fab',
|
seoTitle: 'Shop 3D fab',
|
||||||
seoDescription:
|
seoDescription:
|
||||||
'Lo shop 3D fab e in allestimento. Intanto puoi usare il calcolatore per ottenere un preventivo.',
|
'Catalogo prodotti stampati in 3D, accessori tecnici e soluzioni pratiche pronte all uso.',
|
||||||
seoRobots: 'noindex, nofollow',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':id',
|
path: ':categorySlug/:productSlug',
|
||||||
component: ProductDetailComponent,
|
component: ProductDetailComponent,
|
||||||
data: {
|
data: {
|
||||||
seoTitle: 'Prodotto | 3D fab',
|
seoTitle: 'Prodotto | 3D fab',
|
||||||
seoRobots: 'noindex, nofollow',
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':categorySlug',
|
||||||
|
component: ShopPageComponent,
|
||||||
|
data: {
|
||||||
|
seoTitle: 'Categoria Shop | 3D fab',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -155,6 +155,7 @@
|
|||||||
"SHOP": {
|
"SHOP": {
|
||||||
"TITLE": "Soluzioni tecniche",
|
"TITLE": "Soluzioni tecniche",
|
||||||
"SUBTITLE": "Prodotti pronti che risolvono problemi pratici",
|
"SUBTITLE": "Prodotti pronti che risolvono problemi pratici",
|
||||||
|
"HERO_EYEBROW": "Shop tecnico",
|
||||||
"WIP_EYEBROW": "Work in progress",
|
"WIP_EYEBROW": "Work in progress",
|
||||||
"WIP_TITLE": "Shop in allestimento",
|
"WIP_TITLE": "Shop in allestimento",
|
||||||
"WIP_SUBTITLE": "Stiamo preparando uno shop con prodotti selezionati e funzionalità di creazione automatica!",
|
"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_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.",
|
"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",
|
"ADD_CART": "Aggiungi al Carrello",
|
||||||
|
"ADDING": "Aggiunta in corso",
|
||||||
|
"ADD_SUCCESS": "Prodotto aggiunto al carrello.",
|
||||||
"BACK": "Torna allo Shop",
|
"BACK": "Torna allo Shop",
|
||||||
"NOT_FOUND": "Prodotto non trovato.",
|
"NOT_FOUND": "Prodotto non trovato.",
|
||||||
"DETAILS": "Dettagli",
|
"DETAILS": "Dettagli",
|
||||||
@@ -169,6 +172,47 @@
|
|||||||
"SUCCESS_TITLE": "Aggiunto al carrello",
|
"SUCCESS_TITLE": "Aggiunto al carrello",
|
||||||
"SUCCESS_DESC": "Il prodotto è stato aggiunto correttamente al carrello.",
|
"SUCCESS_DESC": "Il prodotto è stato aggiunto correttamente al carrello.",
|
||||||
"CONTINUE": "Continua",
|
"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": {
|
"CATEGORIES": {
|
||||||
"FILAMENTS": "Filamenti",
|
"FILAMENTS": "Filamenti",
|
||||||
"ACCESSORIES": "Accessori"
|
"ACCESSORIES": "Accessori"
|
||||||
@@ -531,6 +575,12 @@
|
|||||||
"ERR_ID_NOT_FOUND": "ID ordine non trovato.",
|
"ERR_ID_NOT_FOUND": "ID ordine non trovato.",
|
||||||
"ERR_LOAD_ORDER": "Impossibile caricare i dettagli dell'ordine.",
|
"ERR_LOAD_ORDER": "Impossibile caricare i dettagli dell'ordine.",
|
||||||
"ERR_REPORT_PAYMENT": "Impossibile segnalare il pagamento. Riprova.",
|
"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"
|
"NOT_AVAILABLE": "N/D"
|
||||||
},
|
},
|
||||||
"DROPZONE": {
|
"DROPZONE": {
|
||||||
|
|||||||
Reference in New Issue
Block a user