dev #45
@@ -29,6 +29,7 @@ import java.util.*;
|
|||||||
@Service
|
@Service
|
||||||
public class OrderService {
|
public class OrderService {
|
||||||
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
|
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize();
|
||||||
|
private static final String SHOP_LINE_ITEM_TYPE = "SHOP_PRODUCT";
|
||||||
|
|
||||||
private final OrderRepository orderRepo;
|
private final OrderRepository orderRepo;
|
||||||
private final OrderItemRepository orderItemRepo;
|
private final OrderItemRepository orderItemRepo;
|
||||||
@@ -235,19 +236,21 @@ public class OrderService {
|
|||||||
|
|
||||||
oItem = orderItemRepo.save(oItem);
|
oItem = orderItemRepo.save(oItem);
|
||||||
|
|
||||||
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
|
|
||||||
oItem.setStoredRelativePath(relativePath);
|
|
||||||
|
|
||||||
Path sourcePath = resolveStoredQuotePath(qItem.getStoredPath(), session.getId());
|
Path sourcePath = resolveStoredQuotePath(qItem.getStoredPath(), session.getId());
|
||||||
if (sourcePath == null || !Files.exists(sourcePath)) {
|
if (sourcePath == null || !Files.exists(sourcePath)) {
|
||||||
|
if (requiresStoredSourceFile(qItem)) {
|
||||||
throw new IllegalStateException("Source file not available for quote line item " + qItem.getId());
|
throw new IllegalStateException("Source file not available for quote line item " + qItem.getId());
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
|
||||||
|
oItem.setStoredRelativePath(relativePath);
|
||||||
try {
|
try {
|
||||||
storageService.store(sourcePath, Paths.get(relativePath));
|
storageService.store(sourcePath, Paths.get(relativePath));
|
||||||
oItem.setFileSizeBytes(Files.size(sourcePath));
|
oItem.setFileSizeBytes(Files.size(sourcePath));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e);
|
throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
oItem = orderItemRepo.save(oItem);
|
oItem = orderItemRepo.save(oItem);
|
||||||
savedItems.add(oItem);
|
savedItems.add(oItem);
|
||||||
@@ -318,6 +321,12 @@ public class OrderService {
|
|||||||
return "stl";
|
return "stl";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean requiresStoredSourceFile(QuoteLineItem qItem) {
|
||||||
|
return !SHOP_LINE_ITEM_TYPE.equalsIgnoreCase(
|
||||||
|
qItem.getLineItemType() != null ? qItem.getLineItemType() : ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
|
private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
|
||||||
if (storedPath == null || storedPath.isBlank()) {
|
if (storedPath == null || storedPath.isBlank()) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -40,10 +40,13 @@ import java.util.Optional;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.ArgumentMatchers.isNull;
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@@ -217,6 +220,210 @@ class OrderServiceTest {
|
|||||||
verify(eventPublisher).publishEvent(any(OrderCreatedEvent.class));
|
verify(eventPublisher).publishEvent(any(OrderCreatedEvent.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createOrderFromQuote_withShopProductMissingSourceFile_shouldNotFail() throws Exception {
|
||||||
|
UUID sessionId = UUID.randomUUID();
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
UUID orderItemId = UUID.randomUUID();
|
||||||
|
|
||||||
|
QuoteSession session = new QuoteSession();
|
||||||
|
session.setId(sessionId);
|
||||||
|
session.setStatus("ACTIVE");
|
||||||
|
session.setSessionType("SHOP_CART");
|
||||||
|
session.setMaterialCode("SHOP");
|
||||||
|
session.setPricingVersion("v1");
|
||||||
|
session.setSetupCostChf(BigDecimal.ZERO);
|
||||||
|
session.setExpiresAt(OffsetDateTime.now().plusDays(30));
|
||||||
|
|
||||||
|
ShopCategory category = new ShopCategory();
|
||||||
|
category.setId(UUID.randomUUID());
|
||||||
|
category.setSlug("desk");
|
||||||
|
category.setName("Desk");
|
||||||
|
|
||||||
|
ShopProduct product = new ShopProduct();
|
||||||
|
product.setId(UUID.randomUUID());
|
||||||
|
product.setCategory(category);
|
||||||
|
product.setSlug("organizer");
|
||||||
|
product.setName("Organizer");
|
||||||
|
|
||||||
|
ShopProductVariant variant = new ShopProductVariant();
|
||||||
|
variant.setId(UUID.randomUUID());
|
||||||
|
variant.setProduct(product);
|
||||||
|
variant.setVariantLabel("PLA");
|
||||||
|
variant.setColorName("Orange");
|
||||||
|
variant.setColorHex("#ff8a00");
|
||||||
|
variant.setInternalMaterialCode("PLA");
|
||||||
|
variant.setPriceChf(new BigDecimal("18.00"));
|
||||||
|
|
||||||
|
Path missingSource = Path.of("storage_quotes")
|
||||||
|
.toAbsolutePath()
|
||||||
|
.normalize()
|
||||||
|
.resolve(sessionId.toString())
|
||||||
|
.resolve("missing-shop-item.stl");
|
||||||
|
|
||||||
|
QuoteLineItem qItem = new QuoteLineItem();
|
||||||
|
qItem.setId(UUID.randomUUID());
|
||||||
|
qItem.setQuoteSession(session);
|
||||||
|
qItem.setStatus("READY");
|
||||||
|
qItem.setLineItemType("SHOP_PRODUCT");
|
||||||
|
qItem.setOriginalFilename("organizer.stl");
|
||||||
|
qItem.setDisplayName("Organizer");
|
||||||
|
qItem.setQuantity(1);
|
||||||
|
qItem.setColorCode("Orange");
|
||||||
|
qItem.setMaterialCode("PLA");
|
||||||
|
qItem.setShopProduct(product);
|
||||||
|
qItem.setShopProductVariant(variant);
|
||||||
|
qItem.setShopProductSlug(product.getSlug());
|
||||||
|
qItem.setShopProductName(product.getName());
|
||||||
|
qItem.setShopVariantLabel("PLA");
|
||||||
|
qItem.setShopVariantColorName("Orange");
|
||||||
|
qItem.setShopVariantColorHex("#ff8a00");
|
||||||
|
qItem.setUnitPriceChf(new BigDecimal("18.00"));
|
||||||
|
qItem.setStoredPath(missingSource.toString());
|
||||||
|
|
||||||
|
when(quoteSessionRepo.findById(sessionId)).thenReturn(Optional.of(session));
|
||||||
|
when(customerRepo.findByEmail("buyer@example.com")).thenReturn(Optional.empty());
|
||||||
|
when(customerRepo.save(any(Customer.class))).thenAnswer(invocation -> {
|
||||||
|
Customer saved = invocation.getArgument(0);
|
||||||
|
if (saved.getId() == null) {
|
||||||
|
saved.setId(UUID.randomUUID());
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
});
|
||||||
|
when(quoteLineItemRepo.findByQuoteSessionId(sessionId)).thenReturn(List.of(qItem));
|
||||||
|
when(quoteSessionTotalsService.compute(eq(session), eq(List.of(qItem)))).thenReturn(
|
||||||
|
new QuoteSessionTotalsService.QuoteSessionTotals(
|
||||||
|
new BigDecimal("18.00"),
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
new BigDecimal("18.00"),
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
new BigDecimal("18.00"),
|
||||||
|
BigDecimal.ZERO
|
||||||
|
)
|
||||||
|
);
|
||||||
|
when(orderRepo.save(any(Order.class))).thenAnswer(invocation -> {
|
||||||
|
Order saved = invocation.getArgument(0);
|
||||||
|
if (saved.getId() == null) {
|
||||||
|
saved.setId(orderId);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
});
|
||||||
|
when(orderItemRepo.save(any(OrderItem.class))).thenAnswer(invocation -> {
|
||||||
|
OrderItem saved = invocation.getArgument(0);
|
||||||
|
if (saved.getId() == null) {
|
||||||
|
saved.setId(orderItemId);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
});
|
||||||
|
when(qrBillService.generateQrBillSvg(any(Order.class))).thenReturn("<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("CONVERTED", session.getStatus());
|
||||||
|
|
||||||
|
ArgumentCaptor<OrderItem> itemCaptor = ArgumentCaptor.forClass(OrderItem.class);
|
||||||
|
verify(orderItemRepo, times(2)).save(itemCaptor.capture());
|
||||||
|
OrderItem savedItem = itemCaptor.getAllValues().getLast();
|
||||||
|
assertEquals("PENDING", savedItem.getStoredRelativePath());
|
||||||
|
assertNull(savedItem.getFileSizeBytes());
|
||||||
|
|
||||||
|
verify(storageService, never()).store(eq(missingSource), any(Path.class));
|
||||||
|
verify(paymentService).getOrCreatePaymentForOrder(order, "OTHER");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createOrderFromQuote_withCalculatorItemMissingSourceFile_shouldFail() {
|
||||||
|
UUID sessionId = UUID.randomUUID();
|
||||||
|
UUID orderId = UUID.randomUUID();
|
||||||
|
UUID orderItemId = UUID.randomUUID();
|
||||||
|
|
||||||
|
QuoteSession session = new QuoteSession();
|
||||||
|
session.setId(sessionId);
|
||||||
|
session.setStatus("ACTIVE");
|
||||||
|
session.setSessionType("QUOTE");
|
||||||
|
session.setMaterialCode("PLA");
|
||||||
|
session.setPricingVersion("v1");
|
||||||
|
session.setSetupCostChf(BigDecimal.ZERO);
|
||||||
|
session.setExpiresAt(OffsetDateTime.now().plusDays(30));
|
||||||
|
|
||||||
|
Path missingSource = Path.of("storage_quotes")
|
||||||
|
.toAbsolutePath()
|
||||||
|
.normalize()
|
||||||
|
.resolve(sessionId.toString())
|
||||||
|
.resolve("missing-calculator-item.stl");
|
||||||
|
|
||||||
|
QuoteLineItem qItem = new QuoteLineItem();
|
||||||
|
qItem.setId(UUID.randomUUID());
|
||||||
|
qItem.setQuoteSession(session);
|
||||||
|
qItem.setStatus("READY");
|
||||||
|
qItem.setLineItemType("PRINT_FILE");
|
||||||
|
qItem.setOriginalFilename("part.stl");
|
||||||
|
qItem.setDisplayName("part.stl");
|
||||||
|
qItem.setQuantity(1);
|
||||||
|
qItem.setMaterialCode("PLA");
|
||||||
|
qItem.setUnitPriceChf(new BigDecimal("9.50"));
|
||||||
|
qItem.setStoredPath(missingSource.toString());
|
||||||
|
|
||||||
|
when(quoteSessionRepo.findById(sessionId)).thenReturn(Optional.of(session));
|
||||||
|
when(customerRepo.findByEmail("buyer@example.com")).thenReturn(Optional.empty());
|
||||||
|
when(customerRepo.save(any(Customer.class))).thenAnswer(invocation -> {
|
||||||
|
Customer saved = invocation.getArgument(0);
|
||||||
|
if (saved.getId() == null) {
|
||||||
|
saved.setId(UUID.randomUUID());
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
});
|
||||||
|
when(quoteLineItemRepo.findByQuoteSessionId(sessionId)).thenReturn(List.of(qItem));
|
||||||
|
when(quoteSessionTotalsService.compute(eq(session), eq(List.of(qItem)))).thenReturn(
|
||||||
|
new QuoteSessionTotalsService.QuoteSessionTotals(
|
||||||
|
new BigDecimal("9.50"),
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
new BigDecimal("9.50"),
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
BigDecimal.ZERO,
|
||||||
|
new BigDecimal("9.50"),
|
||||||
|
BigDecimal.ZERO
|
||||||
|
)
|
||||||
|
);
|
||||||
|
when(orderRepo.save(any(Order.class))).thenAnswer(invocation -> {
|
||||||
|
Order saved = invocation.getArgument(0);
|
||||||
|
if (saved.getId() == null) {
|
||||||
|
saved.setId(orderId);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
});
|
||||||
|
when(orderItemRepo.save(any(OrderItem.class))).thenAnswer(invocation -> {
|
||||||
|
OrderItem saved = invocation.getArgument(0);
|
||||||
|
if (saved.getId() == null) {
|
||||||
|
saved.setId(orderItemId);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
});
|
||||||
|
|
||||||
|
IllegalStateException exception = assertThrows(
|
||||||
|
IllegalStateException.class,
|
||||||
|
() -> service.createOrderFromQuote(sessionId, buildRequest())
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"Source file not available for quote line item " + qItem.getId(),
|
||||||
|
exception.getMessage()
|
||||||
|
);
|
||||||
|
verify(paymentService, never()).getOrCreatePaymentForOrder(any(Order.class), eq("OTHER"));
|
||||||
|
verify(eventPublisher, never()).publishEvent(any(OrderCreatedEvent.class));
|
||||||
|
}
|
||||||
|
|
||||||
private CreateOrderRequest buildRequest() {
|
private CreateOrderRequest buildRequest() {
|
||||||
CustomerDto customer = new CustomerDto();
|
CustomerDto customer = new CustomerDto();
|
||||||
customer.setEmail("buyer@example.com");
|
customer.setEmail("buyer@example.com");
|
||||||
|
|||||||
@@ -15,9 +15,18 @@ const appChildRoutes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/home/home.component').then((m) => m.HomeComponent),
|
import('./features/home/home.component').then((m) => m.HomeComponent),
|
||||||
data: {
|
data: {
|
||||||
seoTitle: '3D fab | Stampa 3D su misura',
|
seoTitleByLang: {
|
||||||
seoDescription:
|
it: 'Stampa 3D su misura in Ticino | Prototipi, ricambi e piccole serie - 3D Fab',
|
||||||
'Servizio di stampa 3D con preventivo online immediato per prototipi, piccole serie e pezzi personalizzati.',
|
en: 'Custom 3D Printing in Switzerland | Prototypes, Spare Parts & Short Runs - 3D Fab',
|
||||||
|
de: '3D-Druck in Zürich | Prototypen, Ersatzteile und Kleinserien - 3D Fab',
|
||||||
|
fr: 'Impression 3D à Bienne | Prototypes, pièces et petites séries - 3D Fab',
|
||||||
|
},
|
||||||
|
seoDescriptionByLang: {
|
||||||
|
it: 'Servizio di stampa 3D in Ticino per prototipi, pezzi di ricambio e piccole serie. Shop tecnico e supporto CAD, con preventivo rapido da file STL.',
|
||||||
|
en: 'Swiss-based 3D printing service for prototypes, spare parts and short production runs. Technical shop and CAD support, with fast quotes from STL files.',
|
||||||
|
de: '3D-Druckservice in Zürich für Prototypen, Ersatzteile und Kleinserien. Technischer Shop und CAD-Service, mit schneller Angebotsanfrage aus STL-Dateien.',
|
||||||
|
fr: "Service d'impression 3D à Bienne pour prototypes, pièces de rechange et petites séries. Boutique technique et support CAD, avec devis rapide depuis un fichier STL.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -52,6 +61,18 @@ const appChildRoutes: Routes = [
|
|||||||
'Scopri il team 3D fab e il laboratorio di stampa 3D con sedi in Ticino e Bienne.',
|
'Scopri il team 3D fab e il laboratorio di stampa 3D con sedi in Ticino e Bienne.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
/* {
|
||||||
|
path: 'materials',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/materials/materials-page.component').then(
|
||||||
|
(m) => m.MaterialsPageComponent,
|
||||||
|
),
|
||||||
|
data: {
|
||||||
|
seoTitle: 'Qualita e Materiali | 3D fab',
|
||||||
|
seoDescription:
|
||||||
|
'Confronta materiali di stampa 3D con radar chart interattivo, proprieta tecniche e fonti citate.',
|
||||||
|
},
|
||||||
|
},*/
|
||||||
{
|
{
|
||||||
path: 'contact',
|
path: 'contact',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
|
|||||||
@@ -12,14 +12,31 @@ export interface PageSeoOverride {
|
|||||||
ogDescription?: string | null;
|
ogDescription?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
||||||
|
type SeoMap = Partial<Record<SupportedLang, string>>;
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class SeoService {
|
export class SeoService {
|
||||||
private readonly defaultTitle = '3D fab | Stampa 3D su misura';
|
private readonly defaultTitleByLang: Record<SupportedLang, string> = {
|
||||||
private readonly defaultDescription =
|
it: '3D fab | Stampa 3D su misura',
|
||||||
'Stampa 3D su misura con preventivo online immediato. Carica il file, scegli materiale e qualità, ricevi prezzo e tempi in pochi secondi.';
|
en: '3D fab | Custom 3D Printing',
|
||||||
private readonly supportedLangs = new Set(['it', 'en', 'de', 'fr']);
|
de: '3D fab | 3D-Druck nach Maß',
|
||||||
|
fr: '3D fab | Impression 3D sur mesure',
|
||||||
|
};
|
||||||
|
private readonly defaultDescriptionByLang: Record<SupportedLang, string> = {
|
||||||
|
it: 'Servizio di stampa 3D su misura, shop tecnico e supporto CAD per prototipi, ricambi e piccole serie.',
|
||||||
|
en: 'Custom 3D printing service, technical shop and CAD support for prototypes, spare parts and short runs.',
|
||||||
|
de: '3D-Druckservice nach Maß, technischer Shop und CAD-Support für Prototypen, Ersatzteile und Kleinserien.',
|
||||||
|
fr: "Service d'impression 3D sur mesure, boutique technique et support CAD pour prototypes, pièces et petites séries.",
|
||||||
|
};
|
||||||
|
private readonly supportedLangs = new Set<SupportedLang>([
|
||||||
|
'it',
|
||||||
|
'en',
|
||||||
|
'de',
|
||||||
|
'fr',
|
||||||
|
]);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private router: Router,
|
private router: Router,
|
||||||
@@ -40,9 +57,11 @@ export class SeoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyPageSeo(override: PageSeoOverride): void {
|
applyPageSeo(override: PageSeoOverride): void {
|
||||||
const title = this.asString(override.title) ?? this.defaultTitle;
|
const cleanPath = this.getCleanPath(this.router.url);
|
||||||
|
const lang = this.resolveLangFromPath(cleanPath);
|
||||||
|
const title = this.asString(override.title) ?? this.defaultTitleByLang[lang];
|
||||||
const description =
|
const description =
|
||||||
this.asString(override.description) ?? this.defaultDescription;
|
this.asString(override.description) ?? this.defaultDescriptionByLang[lang];
|
||||||
const robots = this.asString(override.robots) ?? 'index, follow';
|
const robots = this.asString(override.robots) ?? 'index, follow';
|
||||||
const ogTitle = this.asString(override.ogTitle) ?? title;
|
const ogTitle = this.asString(override.ogTitle) ?? title;
|
||||||
const ogDescription = this.asString(override.ogDescription) ?? description;
|
const ogDescription = this.asString(override.ogDescription) ?? description;
|
||||||
@@ -52,13 +71,18 @@ export class SeoService {
|
|||||||
|
|
||||||
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 cleanPath = this.getCleanPath(this.router.url);
|
||||||
|
const lang = this.resolveLangFromPath(cleanPath);
|
||||||
|
const title =
|
||||||
|
this.resolveSeoText(mergedData, 'seoTitle', lang) ??
|
||||||
|
this.defaultTitleByLang[lang];
|
||||||
const description =
|
const description =
|
||||||
this.asString(mergedData['seoDescription']) ?? this.defaultDescription;
|
this.resolveSeoText(mergedData, 'seoDescription', lang) ??
|
||||||
|
this.defaultDescriptionByLang[lang];
|
||||||
const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow';
|
const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow';
|
||||||
const ogTitle = this.asString(mergedData['ogTitle']) ?? title;
|
const ogTitle = this.resolveSeoText(mergedData, 'ogTitle', lang) ?? title;
|
||||||
const ogDescription =
|
const ogDescription =
|
||||||
this.asString(mergedData['ogDescription']) ?? description;
|
this.resolveSeoText(mergedData, 'ogDescription', lang) ?? description;
|
||||||
|
|
||||||
this.applySeoValues(title, description, robots, ogTitle, ogDescription);
|
this.applySeoValues(title, description, robots, ogTitle, ogDescription);
|
||||||
}
|
}
|
||||||
@@ -104,11 +128,36 @@ export class SeoService {
|
|||||||
return typeof value === 'string' ? value : undefined;
|
return typeof value === 'string' ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveSeoText(
|
||||||
|
routeData: Record<string, unknown>,
|
||||||
|
key: 'seoTitle' | 'seoDescription' | 'ogTitle' | 'ogDescription',
|
||||||
|
lang: SupportedLang,
|
||||||
|
): string | undefined {
|
||||||
|
const mapKey = `${key}ByLang`;
|
||||||
|
const localized = routeData[mapKey];
|
||||||
|
if (localized && typeof localized === 'object' && !Array.isArray(localized)) {
|
||||||
|
const mapped = localized as SeoMap;
|
||||||
|
const byLang = this.asString(mapped[lang]);
|
||||||
|
if (byLang) {
|
||||||
|
return byLang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.asString(routeData[key]);
|
||||||
|
}
|
||||||
|
|
||||||
private getCleanPath(url: string): string {
|
private getCleanPath(url: string): string {
|
||||||
const path = (url || '/').split('?')[0].split('#')[0];
|
const path = (url || '/').split('?')[0].split('#')[0];
|
||||||
return path || '/';
|
return path || '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveLangFromPath(path: string): SupportedLang {
|
||||||
|
const firstSegment = path.split('/').filter(Boolean)[0]?.toLowerCase();
|
||||||
|
if (firstSegment && this.supportedLangs.has(firstSegment as SupportedLang)) {
|
||||||
|
return firstSegment as SupportedLang;
|
||||||
|
}
|
||||||
|
return 'it';
|
||||||
|
}
|
||||||
|
|
||||||
private updateCanonicalTag(url: string): void {
|
private updateCanonicalTag(url: string): void {
|
||||||
let link = this.document.head.querySelector(
|
let link = this.document.head.querySelector(
|
||||||
'link[rel="canonical"]',
|
'link[rel="canonical"]',
|
||||||
@@ -124,10 +173,9 @@ export class SeoService {
|
|||||||
private updateLangAndAlternates(path: string): void {
|
private updateLangAndAlternates(path: string): void {
|
||||||
const segments = path.split('/').filter(Boolean);
|
const segments = path.split('/').filter(Boolean);
|
||||||
const firstSegment = segments[0]?.toLowerCase();
|
const firstSegment = segments[0]?.toLowerCase();
|
||||||
const hasLang = Boolean(
|
const maybeLang = firstSegment as SupportedLang | undefined;
|
||||||
firstSegment && this.supportedLangs.has(firstSegment),
|
const hasLang = Boolean(maybeLang && this.supportedLangs.has(maybeLang));
|
||||||
);
|
const lang: SupportedLang = hasLang && maybeLang ? maybeLang : 'it';
|
||||||
const lang = hasLang ? firstSegment : 'it';
|
|
||||||
const suffixSegments = hasLang ? segments.slice(1) : segments;
|
const suffixSegments = hasLang ? segments.slice(1) : segments;
|
||||||
const suffix =
|
const suffix =
|
||||||
suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : '';
|
suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : '';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div class="checkout-page">
|
<div class="checkout-page">
|
||||||
<div class="container ui-page-hero">
|
<div class="container ui-page-hero ui-page-hero--spacious checkout-hero">
|
||||||
<h1 class="ui-page-title">{{ "CHECKOUT.TITLE" | translate }}</h1>
|
<h1 class="ui-page-title">{{ "CHECKOUT.TITLE" | translate }}</h1>
|
||||||
<p class="ui-page-subtitle cad-subtitle" *ngIf="isCadSession()">
|
<p class="ui-page-subtitle cad-subtitle" *ngIf="isCadSession()">
|
||||||
Servizio CAD
|
Servizio CAD
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
.checkout-hero {
|
||||||
|
padding-top: calc(var(--space-12) + var(--space-4));
|
||||||
|
}
|
||||||
|
|
||||||
.cad-subtitle {
|
.cad-subtitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -273,3 +277,9 @@ app-toggle-selector.user-type-selector-compact {
|
|||||||
.mb-6 {
|
.mb-6 {
|
||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.checkout-hero {
|
||||||
|
padding-top: calc(var(--space-8) + var(--space-4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<main class="home-page">
|
<main class="home-page">
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="container hero-grid ui-content-grid ui-content-grid--spacious">
|
<div
|
||||||
|
class="container hero-grid ui-content-grid ui-content-grid--spacious ui-content-grid--split"
|
||||||
|
>
|
||||||
<div class="hero-copy">
|
<div class="hero-copy">
|
||||||
<p class="eyebrow ui-eyebrow">{{ "HOME.HERO_EYEBROW" | translate }}</p>
|
<p class="eyebrow ui-eyebrow">{{ "HOME.HERO_EYEBROW" | translate }}</p>
|
||||||
<h1
|
<h1
|
||||||
@@ -25,6 +27,30 @@
|
|||||||
}}</app-button>
|
}}</app-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<aside class="hero-swiss-card">
|
||||||
|
<div class="hero-swiss-head">
|
||||||
|
<span class="hero-swiss-emblem" aria-hidden="true">
|
||||||
|
<span class="hero-swiss-cross"></span>
|
||||||
|
</span>
|
||||||
|
<p class="hero-swiss-kicker ui-eyebrow ui-eyebrow--compact">
|
||||||
|
{{ "HOME.HERO_SWISS_TITLE" | translate }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="hero-swiss-copy">
|
||||||
|
{{ "HOME.HERO_SWISS_COPY" | translate }}
|
||||||
|
</p>
|
||||||
|
<div class="hero-swiss-locations">
|
||||||
|
<span class="hero-swiss-chip">{{
|
||||||
|
"HOME.HERO_SWISS_LOCATION_1" | translate
|
||||||
|
}}</span>
|
||||||
|
<span class="hero-swiss-chip">{{
|
||||||
|
"HOME.HERO_SWISS_LOCATION_2" | translate
|
||||||
|
}}</span>
|
||||||
|
<span class="hero-swiss-chip">{{
|
||||||
|
"HOME.HERO_SWISS_LOCATION_3" | translate
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,99 @@
|
|||||||
animation: fadeUp 0.8s ease both;
|
animation: fadeUp 0.8s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-grid {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-card {
|
||||||
|
--swiss-red: #d52b1e;
|
||||||
|
align-self: center;
|
||||||
|
justify-self: center;
|
||||||
|
width: min(100%, 340px);
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-left: 4px solid var(--swiss-red);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
animation: fadeUp 0.85s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-emblem {
|
||||||
|
width: 1.3rem;
|
||||||
|
height: 1.3rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--swiss-red);
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-cross {
|
||||||
|
position: relative;
|
||||||
|
width: 0.86rem;
|
||||||
|
height: 0.86rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-cross::before,
|
||||||
|
.hero-swiss-cross::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-cross::before {
|
||||||
|
width: 0.28rem;
|
||||||
|
height: 100%;
|
||||||
|
left: calc(50% - 0.14rem);
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-cross::after {
|
||||||
|
width: 100%;
|
||||||
|
height: 0.28rem;
|
||||||
|
left: 0;
|
||||||
|
top: calc(50% - 0.14rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-kicker {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-copy {
|
||||||
|
margin: 0 0 0.7rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-locations {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiss-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 1.75rem;
|
||||||
|
padding: 0.2rem 0.58rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(14, 24, 38, 0.14);
|
||||||
|
background: #fff;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2a2f36;
|
||||||
|
}
|
||||||
|
|
||||||
.capabilities {
|
.capabilities {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
@@ -165,6 +258,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.hero-swiss-card {
|
||||||
|
align-self: start;
|
||||||
|
justify-self: center;
|
||||||
|
width: min(100%, 340px);
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.shop-gallery {
|
.shop-gallery {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
|||||||
@@ -68,9 +68,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</app-card>
|
</app-card>
|
||||||
|
|
||||||
<div class="payment-layout ui-two-column-layout">
|
<div
|
||||||
<div class="payment-main">
|
class="payment-layout ui-two-column-layout"
|
||||||
<app-card class="mb-6" *ngIf="o.status === 'PENDING_PAYMENT'">
|
[class.payment-layout--summary-only]="o.status !== 'PENDING_PAYMENT'"
|
||||||
|
>
|
||||||
|
<div class="payment-main" *ngIf="o.status === 'PENDING_PAYMENT'">
|
||||||
|
<app-card class="mb-6">
|
||||||
<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>
|
||||||
@@ -174,15 +177,47 @@
|
|||||||
</app-button>
|
</app-button>
|
||||||
</div>
|
</div>
|
||||||
</app-card>
|
</app-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<app-card class="mb-6">
|
<div class="payment-summary">
|
||||||
|
<app-card class="sticky-card">
|
||||||
<div class="ui-card-header">
|
<div class="ui-card-header">
|
||||||
<h3 class="ui-card-title">{{ "ORDER.ITEMS_TITLE" | translate }}</h3>
|
<h3 class="ui-card-title">
|
||||||
<p class="ui-card-subtitle">
|
{{ "PAYMENT.SUMMARY_TITLE" | translate }}
|
||||||
{{ orderKindLabel(o) }}
|
</h3>
|
||||||
|
<p class="ui-card-subtitle order-id">
|
||||||
|
#{{ getDisplayOrderNumber(o) }}
|
||||||
</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
|
||||||
|
[rows]="orderPriceBreakdownRows(o)"
|
||||||
|
[total]="o.totalChf || 0"
|
||||||
|
[currency]="'CHF'"
|
||||||
|
[totalLabelKey]="'PAYMENT.TOTAL'"
|
||||||
|
></app-price-breakdown>
|
||||||
|
|
||||||
|
<div class="summary-items-section" *ngIf="(o.items || []).length > 0">
|
||||||
|
<div class="summary-items-head">
|
||||||
|
<h4>{{ "ORDER.ITEMS_TITLE" | translate }}</h4>
|
||||||
|
<span>{{ (o.items || []).length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="order-items">
|
<div class="order-items">
|
||||||
<div class="order-item" *ngFor="let item of o.items || []">
|
<div class="order-item" *ngFor="let item of o.items || []">
|
||||||
<div class="order-item-copy">
|
<div class="order-item-copy">
|
||||||
@@ -225,7 +260,10 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="order-item-tech" *ngIf="showItemPrintMetrics(item)">
|
<div
|
||||||
|
class="order-item-tech"
|
||||||
|
*ngIf="showItemPrintMetrics(item)"
|
||||||
|
>
|
||||||
{{ item.printTimeSeconds || 0 | number: "1.0-0" }}s |
|
{{ item.printTimeSeconds || 0 | number: "1.0-0" }}s |
|
||||||
{{ item.materialGrams || 0 | number: "1.0-0" }}g
|
{{ item.materialGrams || 0 | number: "1.0-0" }}g
|
||||||
</div>
|
</div>
|
||||||
@@ -236,41 +274,7 @@
|
|||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</app-card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="payment-summary">
|
|
||||||
<app-card class="sticky-card">
|
|
||||||
<div class="ui-card-header">
|
|
||||||
<h3 class="ui-card-title">
|
|
||||||
{{ "PAYMENT.SUMMARY_TITLE" | translate }}
|
|
||||||
</h3>
|
|
||||||
<p class="ui-card-subtitle order-id">
|
|
||||||
#{{ getDisplayOrderNumber(o) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="order-summary-meta">
|
|
||||||
<div>
|
|
||||||
<span class="summary-label">{{
|
|
||||||
"ORDER.ORDER_TYPE_LABEL" | translate
|
|
||||||
}}</span>
|
|
||||||
<strong>{{ orderKindLabel(o) }}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="summary-label">{{
|
|
||||||
"ORDER.ITEM_COUNT" | translate
|
|
||||||
}}</span>
|
|
||||||
<strong>{{ (o.items || []).length }}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<app-price-breakdown
|
|
||||||
[rows]="orderPriceBreakdownRows(o)"
|
|
||||||
[total]="o.totalChf || 0"
|
|
||||||
[currency]="'CHF'"
|
|
||||||
[totalLabelKey]="'PAYMENT.TOTAL'"
|
|
||||||
></app-price-breakdown>
|
|
||||||
</app-card>
|
</app-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.payment-layout--summary-only {
|
||||||
|
grid-template-columns: minmax(0, 440px);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.payment-details {
|
.payment-details {
|
||||||
margin-bottom: var(--space-6);
|
margin-bottom: var(--space-6);
|
||||||
|
|
||||||
@@ -119,9 +124,52 @@
|
|||||||
top: var(--space-6);
|
top: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.payment-summary {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-items-section {
|
||||||
|
margin-top: var(--space-6);
|
||||||
|
padding-top: var(--space-5);
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-items-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 1.8rem;
|
||||||
|
min-height: 1.8rem;
|
||||||
|
padding: 0 0.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(16, 24, 32, 0.06);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.order-items {
|
.order-items {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-3);
|
gap: var(--space-2);
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: var(--space-1);
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-item {
|
.order-item {
|
||||||
@@ -129,7 +177,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: var(--space-3);
|
padding: 0.85rem 0.9rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
background: var(--color-bg-card);
|
background: var(--color-bg-card);
|
||||||
@@ -149,7 +197,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.order-item-name {
|
.order-item-name {
|
||||||
font-size: 1rem;
|
font-size: 0.96rem;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +224,7 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem 0.9rem;
|
gap: 0.5rem 0.9rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.92rem;
|
font-size: 0.88rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-color-chip {
|
.item-color-chip {
|
||||||
@@ -194,13 +242,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.order-item-tech {
|
.order-item-tech {
|
||||||
font-size: 0.86rem;
|
font-size: 0.82rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-item-total {
|
.order-item-total {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 1rem;
|
font-size: 0.96rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-summary-meta {
|
.order-summary-meta {
|
||||||
@@ -325,6 +373,10 @@
|
|||||||
padding-top: calc(var(--space-8) + var(--space-4));
|
padding-top: calc(var(--space-8) + var(--space-4));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.payment-layout--summary-only {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.status-timeline {
|
.status-timeline {
|
||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
margin-bottom: var(--space-8);
|
margin-bottom: var(--space-8);
|
||||||
@@ -362,4 +414,10 @@
|
|||||||
.order-summary-meta {
|
.order-summary-meta {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.order-items {
|
||||||
|
max-height: none;
|
||||||
|
overflow: visible;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
.media {
|
.media {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
aspect-ratio: 1 / 1;
|
aspect-ratio: 4 / 3;
|
||||||
background: #f2eee5;
|
background: #f2eee5;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,18 +15,23 @@
|
|||||||
} @else {
|
} @else {
|
||||||
@if (product(); as p) {
|
@if (product(); as p) {
|
||||||
<nav class="breadcrumbs">
|
<nav class="breadcrumbs">
|
||||||
<a [routerLink]="shopRootLink()">{{
|
<a class="breadcrumbs__item" [routerLink]="shopRootLink()">{{
|
||||||
"SHOP.BREADCRUMB_ROOT" | translate
|
"SHOP.BREADCRUMB_ROOT" | translate
|
||||||
}}</a>
|
}}</a>
|
||||||
@for (crumb of p.breadcrumbs; track crumb.id) {
|
@for (crumb of p.breadcrumbs; track crumb.id) {
|
||||||
<span>/</span>
|
<span class="breadcrumbs__separator">/</span>
|
||||||
<a [routerLink]="categoryLink(crumb.slug)">{{ crumb.name }}</a>
|
<a class="breadcrumbs__item" [routerLink]="categoryLink(crumb.slug)"
|
||||||
|
>{{ crumb.name }}</a
|
||||||
|
>
|
||||||
}
|
}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="detail-grid">
|
<div class="detail-grid">
|
||||||
<section class="visual-column">
|
<section class="visual-column">
|
||||||
<div class="hero-media">
|
<div
|
||||||
|
class="hero-media"
|
||||||
|
[class.hero-media--portrait]="selectedImageIsPortrait()"
|
||||||
|
>
|
||||||
@if (galleryImages().length > 1) {
|
@if (galleryImages().length > 1) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -51,6 +56,7 @@
|
|||||||
[src]="imageUrl"
|
[src]="imageUrl"
|
||||||
[alt]="selectedImage().altText || p.name"
|
[alt]="selectedImage().altText || p.name"
|
||||||
class="hero-image"
|
class="hero-image"
|
||||||
|
(load)="onHeroImageLoad($event)"
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="image-fallback">
|
<div class="image-fallback">
|
||||||
@@ -129,13 +135,29 @@
|
|||||||
|
|
||||||
<app-card class="purchase-shell">
|
<app-card class="purchase-shell">
|
||||||
<div class="purchase-card">
|
<div class="purchase-card">
|
||||||
<div class="price-row">
|
<div class="offer-header">
|
||||||
<div>
|
<div class="offer-price">
|
||||||
<p class="panel-kicker">
|
<p class="panel-kicker">
|
||||||
{{ "SHOP.SELECT_MATERIAL" | translate }}
|
{{ "SHOP.PRICE_LABEL" | translate }}
|
||||||
</p>
|
</p>
|
||||||
<h3>{{ priceLabel() | currency: "CHF" }}</h3>
|
<h3>{{ priceLabel() | currency: "CHF" }}</h3>
|
||||||
|
@if (selectedVariant(); as activeVariant) {
|
||||||
|
<p class="offer-caption">
|
||||||
|
@if (selectedMaterial()?.label) {
|
||||||
|
<span>{{ selectedMaterial()?.label }}</span>
|
||||||
|
}
|
||||||
|
@if (
|
||||||
|
colorLabel(activeVariant) !== selectedMaterial()?.label
|
||||||
|
) {
|
||||||
|
@if (selectedMaterial()?.label) {
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
}
|
||||||
|
<span>{{ colorLabel(activeVariant) }}</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (selectedVariantCartQuantity() > 0) {
|
@if (selectedVariantCartQuantity() > 0) {
|
||||||
<span class="cart-pill">
|
<span class="cart-pill">
|
||||||
{{
|
{{
|
||||||
@@ -148,6 +170,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (materialOptions().length > 1) {
|
@if (materialOptions().length > 1) {
|
||||||
|
<div class="material-section">
|
||||||
|
<div class="selector-head">
|
||||||
|
<p class="panel-kicker">
|
||||||
|
{{ "SHOP.SELECT_MATERIAL" | translate }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="material-grid">
|
<div class="material-grid">
|
||||||
@for (material of materialOptions(); track material.key) {
|
@for (material of materialOptions(); track material.key) {
|
||||||
<button
|
<button
|
||||||
@@ -174,6 +203,25 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
@if (selectedMaterial(); as material) {
|
||||||
|
<div class="material-summary">
|
||||||
|
<div class="material-summary__copy">
|
||||||
|
<p class="panel-kicker">
|
||||||
|
{{ "SHOP.SELECT_MATERIAL" | translate }}
|
||||||
|
</p>
|
||||||
|
<strong>{{ material.label }}</strong>
|
||||||
|
<small>
|
||||||
|
{{
|
||||||
|
"SHOP.MATERIAL_COLOR_COUNT"
|
||||||
|
| translate
|
||||||
|
: { count: materialColorCount(material) }
|
||||||
|
}}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (
|
@if (
|
||||||
@@ -196,7 +244,8 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="color-selector-block">
|
<div class="selector-layout">
|
||||||
|
<div class="selector-card color-selector-block">
|
||||||
<div class="selector-head">
|
<div class="selector-head">
|
||||||
<p class="panel-kicker">
|
<p class="panel-kicker">
|
||||||
{{ "SHOP.SELECT_COLOR" | translate }}
|
{{ "SHOP.SELECT_COLOR" | translate }}
|
||||||
@@ -264,8 +313,10 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="quantity-row">
|
<div class="selector-card quantity-card">
|
||||||
<span>{{ "SHOP.QUANTITY" | translate }}</span>
|
<p class="panel-kicker">
|
||||||
|
{{ "SHOP.QUANTITY" | translate }}
|
||||||
|
</p>
|
||||||
<div class="qty-control">
|
<div class="qty-control">
|
||||||
<button type="button" (click)="decreaseQuantity()">
|
<button type="button" (click)="decreaseQuantity()">
|
||||||
-
|
-
|
||||||
@@ -276,10 +327,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<app-button
|
<app-button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
[fullWidth]="true"
|
||||||
[disabled]="isAddingToCart()"
|
[disabled]="isAddingToCart()"
|
||||||
(click)="addToCart()"
|
(click)="addToCart()"
|
||||||
>
|
>
|
||||||
@@ -290,7 +343,11 @@
|
|||||||
</app-button>
|
</app-button>
|
||||||
|
|
||||||
@if (shopService.cartItemCount() > 0) {
|
@if (shopService.cartItemCount() > 0) {
|
||||||
<app-button variant="outline" (click)="goToCheckout()">
|
<app-button
|
||||||
|
variant="outline"
|
||||||
|
[fullWidth]="true"
|
||||||
|
(click)="goToCheckout()"
|
||||||
|
>
|
||||||
{{ "SHOP.GO_TO_CHECKOUT" | translate }}
|
{{ "SHOP.GO_TO_CHECKOUT" | translate }}
|
||||||
</app-button>
|
</app-button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,15 +18,51 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
background: none;
|
background: none;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
.breadcrumbs {
|
.breadcrumbs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.45rem;
|
align-items: center;
|
||||||
font-size: 0.9rem;
|
gap: 0.35rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs__item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 1.9rem;
|
||||||
|
padding: 0.26rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(16, 24, 32, 0.1);
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: var(--color-secondary-600);
|
||||||
|
font-weight: 600;
|
||||||
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
background 0.18s ease,
|
||||||
|
color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs__item:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: rgba(16, 24, 32, 0.18);
|
||||||
|
background: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumbs__separator {
|
||||||
|
color: rgba(81, 77, 67, 0.64);
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-grid {
|
.detail-grid {
|
||||||
@@ -53,9 +89,8 @@
|
|||||||
|
|
||||||
.hero-media {
|
.hero-media {
|
||||||
position: relative;
|
position: relative;
|
||||||
aspect-ratio: 1 / 1;
|
width: 100%;
|
||||||
min-height: 420px;
|
aspect-ratio: 4 / 3;
|
||||||
max-height: 620px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 1.25rem;
|
border-radius: 1.25rem;
|
||||||
border: 1px solid rgba(16, 24, 32, 0.12);
|
border: 1px solid rgba(16, 24, 32, 0.12);
|
||||||
@@ -67,14 +102,18 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
object-fit: contain;
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
background: #f2eee5;
|
background: #f2eee5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-media--portrait .hero-image {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.image-fallback {
|
.image-fallback {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 420px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
padding: var(--space-6);
|
padding: var(--space-6);
|
||||||
@@ -111,8 +150,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.thumb {
|
.thumb {
|
||||||
flex: 0 0 92px;
|
flex: 0 0 96px;
|
||||||
height: 92px;
|
aspect-ratio: 4 / 3;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 0.85rem;
|
border-radius: 0.85rem;
|
||||||
border: 1px solid rgba(16, 24, 32, 0.12);
|
border: 1px solid rgba(16, 24, 32, 0.12);
|
||||||
@@ -226,15 +265,34 @@ h1 {
|
|||||||
|
|
||||||
.purchase-card {
|
.purchase-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.78rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.price-row,
|
.offer-header {
|
||||||
.quantity-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offer-price {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offer-price h3 {
|
||||||
|
font-size: clamp(1.9rem, 1.5vw + 1.15rem, 2.5rem);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offer-caption {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 0.38rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cart-pill {
|
.cart-pill {
|
||||||
@@ -249,6 +307,11 @@ h1 {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.material-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
.material-grid {
|
.material-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
@@ -301,6 +364,31 @@ h1 {
|
|||||||
font-size: 1.04rem;
|
font-size: 1.04rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.material-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid rgba(16, 24, 32, 0.12);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-summary__copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-summary__copy strong {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-summary__copy small {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
.property-grid {
|
.property-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -340,11 +428,30 @@ h1 {
|
|||||||
border-left: 3px solid rgba(245, 158, 11, 0.7);
|
border-left: 3px solid rgba(245, 158, 11, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selector-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(210px, 0.8fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-card {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.82rem 0.9rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid rgba(16, 24, 32, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.qty-control {
|
.qty-control {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
padding: 0.2rem;
|
padding: 0.2rem;
|
||||||
|
min-height: 3.2rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
background: rgba(255, 255, 255, 0.82);
|
background: rgba(255, 255, 255, 0.82);
|
||||||
@@ -366,10 +473,20 @@ h1 {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quantity-card {
|
||||||
|
justify-items: start;
|
||||||
|
align-content: start;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quantity-card .qty-control {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
gap: var(--space-3);
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.success-note {
|
.success-note {
|
||||||
@@ -459,6 +576,10 @@ h1 {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selector-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.property-grid {
|
.property-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -469,15 +590,14 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.price-row,
|
.offer-header,
|
||||||
.quantity-row {
|
.material-summary {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-media,
|
.hero-media--portrait .hero-image {
|
||||||
.image-fallback {
|
object-fit: cover;
|
||||||
min-height: 300px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumb-strip {
|
.thumb-strip {
|
||||||
@@ -485,8 +605,7 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.thumb {
|
.thumb {
|
||||||
flex-basis: 78px;
|
flex-basis: 84px;
|
||||||
height: 78px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-launch-row {
|
.model-launch-row {
|
||||||
@@ -514,6 +633,10 @@ h1 {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selector-card {
|
||||||
|
padding: 0.74rem 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
:host ::ng-deep app-card.purchase-shell .card-body {
|
:host ::ng-deep app-card.purchase-shell .card-body {
|
||||||
padding: 0.82rem 0.82rem;
|
padding: 0.82rem 0.82rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ export class ProductDetailComponent {
|
|||||||
readonly product = signal<ShopProductDetail | null>(null);
|
readonly product = signal<ShopProductDetail | null>(null);
|
||||||
readonly selectedVariantId = signal<string | null>(null);
|
readonly selectedVariantId = signal<string | null>(null);
|
||||||
readonly selectedImageAssetId = signal<string | null>(null);
|
readonly selectedImageAssetId = signal<string | null>(null);
|
||||||
|
readonly selectedImageOrientation = signal<
|
||||||
|
'portrait' | 'landscape' | 'square' | null
|
||||||
|
>(null);
|
||||||
readonly quantity = signal(1);
|
readonly quantity = signal(1);
|
||||||
readonly isAddingToCart = signal(false);
|
readonly isAddingToCart = signal(false);
|
||||||
readonly addSuccess = signal(false);
|
readonly addSuccess = signal(false);
|
||||||
@@ -191,6 +194,9 @@ export class ProductDetailComponent {
|
|||||||
readonly selectedVariantCartQuantity = computed(() =>
|
readonly selectedVariantCartQuantity = computed(() =>
|
||||||
this.shopService.quantityForVariant(this.selectedVariant()?.id),
|
this.shopService.quantityForVariant(this.selectedVariant()?.id),
|
||||||
);
|
);
|
||||||
|
readonly selectedImageIsPortrait = computed(
|
||||||
|
() => this.selectedImageOrientation() === 'portrait',
|
||||||
|
);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!this.shopService.cartLoaded()) {
|
if (!this.shopService.cartLoaded()) {
|
||||||
@@ -230,7 +236,7 @@ export class ProductDetailComponent {
|
|||||||
catchError((error) => {
|
catchError((error) => {
|
||||||
this.product.set(null);
|
this.product.set(null);
|
||||||
this.selectedVariantId.set(null);
|
this.selectedVariantId.set(null);
|
||||||
this.selectedImageAssetId.set(null);
|
this.setSelectedImageAssetId(null);
|
||||||
this.modelFile.set(null);
|
this.modelFile.set(null);
|
||||||
this.error.set(
|
this.error.set(
|
||||||
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
|
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
|
||||||
@@ -257,7 +263,7 @@ export class ProductDetailComponent {
|
|||||||
product.defaultVariant ?? product.variants[0] ?? null,
|
product.defaultVariant ?? product.variants[0] ?? null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
this.selectedImageAssetId.set(
|
this.setSelectedImageAssetId(
|
||||||
product.primaryImage?.mediaAssetId ??
|
product.primaryImage?.mediaAssetId ??
|
||||||
product.images[0]?.mediaAssetId ??
|
product.images[0]?.mediaAssetId ??
|
||||||
null,
|
null,
|
||||||
@@ -283,7 +289,7 @@ export class ProductDetailComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
selectImage(mediaAssetId: string): void {
|
selectImage(mediaAssetId: string): void {
|
||||||
this.selectedImageAssetId.set(mediaAssetId);
|
this.setSelectedImageAssetId(mediaAssetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
showPreviousImage(): void {
|
showPreviousImage(): void {
|
||||||
@@ -293,7 +299,7 @@ export class ProductDetailComponent {
|
|||||||
}
|
}
|
||||||
const nextIndex =
|
const nextIndex =
|
||||||
(this.selectedImageIndex() - 1 + images.length) % images.length;
|
(this.selectedImageIndex() - 1 + images.length) % images.length;
|
||||||
this.selectedImageAssetId.set(images[nextIndex].mediaAssetId);
|
this.setSelectedImageAssetId(images[nextIndex].mediaAssetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
showNextImage(): void {
|
showNextImage(): void {
|
||||||
@@ -302,7 +308,26 @@ export class ProductDetailComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextIndex = (this.selectedImageIndex() + 1) % images.length;
|
const nextIndex = (this.selectedImageIndex() + 1) % images.length;
|
||||||
this.selectedImageAssetId.set(images[nextIndex].mediaAssetId);
|
this.setSelectedImageAssetId(images[nextIndex].mediaAssetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onHeroImageLoad(event: Event): void {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLImageElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.naturalHeight > target.naturalWidth) {
|
||||||
|
this.selectedImageOrientation.set('portrait');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.naturalWidth > target.naturalHeight) {
|
||||||
|
this.selectedImageOrientation.set('landscape');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedImageOrientation.set('square');
|
||||||
}
|
}
|
||||||
|
|
||||||
selectVariant(variant: ShopProductVariantOption): void {
|
selectVariant(variant: ShopProductVariantOption): void {
|
||||||
@@ -479,6 +504,11 @@ export class ProductDetailComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setSelectedImageAssetId(mediaAssetId: string | null): void {
|
||||||
|
this.selectedImageAssetId.set(mediaAssetId);
|
||||||
|
this.selectedImageOrientation.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeHexColor(value: string | null | undefined): string | null {
|
private normalizeHexColor(value: string | null | undefined): string | null {
|
||||||
const raw = String(value ?? '').trim();
|
const raw = String(value ?? '').trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"CALCULATOR": "Rechner",
|
"CALCULATOR": "Rechner",
|
||||||
"SHOP": "Shop",
|
"SHOP": "Shop",
|
||||||
"ABOUT": "Über uns",
|
"ABOUT": "Über uns",
|
||||||
|
"MATERIALS": "Qualität & Materialien",
|
||||||
"CONTACT": "Kontakt",
|
"CONTACT": "Kontakt",
|
||||||
"LANGUAGE_SELECTOR": "Sprachauswahl"
|
"LANGUAGE_SELECTOR": "Sprachauswahl"
|
||||||
},
|
},
|
||||||
@@ -119,6 +120,7 @@
|
|||||||
"MODEL_CLOSE": "3D-Ansicht schließen",
|
"MODEL_CLOSE": "3D-Ansicht schließen",
|
||||||
"PREVIOUS_IMAGE": "Vorheriges Bild",
|
"PREVIOUS_IMAGE": "Vorheriges Bild",
|
||||||
"NEXT_IMAGE": "Nächstes Bild",
|
"NEXT_IMAGE": "Nächstes Bild",
|
||||||
|
"PRICE_LABEL": "Preis",
|
||||||
"SELECT_MATERIAL": "Material",
|
"SELECT_MATERIAL": "Material",
|
||||||
"SELECT_COLOR": "Farbe",
|
"SELECT_COLOR": "Farbe",
|
||||||
"MATERIAL_COLOR_COUNT": "{{count}} Farben verfügbar",
|
"MATERIAL_COLOR_COUNT": "{{count}} Farben verfügbar",
|
||||||
@@ -499,6 +501,13 @@
|
|||||||
"HERO_TITLE": "3D-Druckservice.<br>Von der Datei zum fertigen Teil.",
|
"HERO_TITLE": "3D-Druckservice.<br>Von der Datei zum fertigen Teil.",
|
||||||
"HERO_LEAD": "Mit dem fortschrittlichsten Rechner für Ihre 3D-Drucke: absolute Präzision und keine Überraschungen.",
|
"HERO_LEAD": "Mit dem fortschrittlichsten Rechner für Ihre 3D-Drucke: absolute Präzision und keine Überraschungen.",
|
||||||
"HERO_SUBTITLE": "Wir bieten auch CAD-Services für individuelle Teile an!",
|
"HERO_SUBTITLE": "Wir bieten auch CAD-Services für individuelle Teile an!",
|
||||||
|
"HERO_SWISS_TITLE": "Based in Switzerland",
|
||||||
|
"HERO_SWISS_COPY": "Produktion und Support in der Schweiz.",
|
||||||
|
"HERO_SWISS_LOCATIONS_LABEL": "Standorte",
|
||||||
|
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||||
|
"HERO_SWISS_LOCATION_2": "Zurich",
|
||||||
|
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
||||||
|
"HERO_SWISS_NOTE": "In der ganzen Schweiz aktiv.",
|
||||||
"BTN_CALCULATE": "Angebot berechnen",
|
"BTN_CALCULATE": "Angebot berechnen",
|
||||||
"BTN_SHOP": "Zum Shop",
|
"BTN_SHOP": "Zum Shop",
|
||||||
"BTN_CONTACT": "Mit uns sprechen",
|
"BTN_CONTACT": "Mit uns sprechen",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"CALCULATOR": "Calculator",
|
"CALCULATOR": "Calculator",
|
||||||
"SHOP": "Shop",
|
"SHOP": "Shop",
|
||||||
"ABOUT": "About Us",
|
"ABOUT": "About Us",
|
||||||
|
"MATERIALS": "Quality & Materials",
|
||||||
"CONTACT": "Contact Us",
|
"CONTACT": "Contact Us",
|
||||||
"LANGUAGE_SELECTOR": "Language selector"
|
"LANGUAGE_SELECTOR": "Language selector"
|
||||||
},
|
},
|
||||||
@@ -119,6 +120,7 @@
|
|||||||
"MODEL_CLOSE": "Close 3D view",
|
"MODEL_CLOSE": "Close 3D view",
|
||||||
"PREVIOUS_IMAGE": "Previous image",
|
"PREVIOUS_IMAGE": "Previous image",
|
||||||
"NEXT_IMAGE": "Next image",
|
"NEXT_IMAGE": "Next image",
|
||||||
|
"PRICE_LABEL": "Price",
|
||||||
"SELECT_MATERIAL": "Material",
|
"SELECT_MATERIAL": "Material",
|
||||||
"SELECT_COLOR": "Color",
|
"SELECT_COLOR": "Color",
|
||||||
"MATERIAL_COLOR_COUNT": "{{count}} colors available",
|
"MATERIAL_COLOR_COUNT": "{{count}} colors available",
|
||||||
@@ -499,6 +501,13 @@
|
|||||||
"HERO_TITLE": "3D printing service.<br>From file to finished part.",
|
"HERO_TITLE": "3D printing service.<br>From file to finished part.",
|
||||||
"HERO_LEAD": "With the most advanced calculator for your 3D prints: absolute precision and zero surprises.",
|
"HERO_LEAD": "With the most advanced calculator for your 3D prints: absolute precision and zero surprises.",
|
||||||
"HERO_SUBTITLE": "We also offer CAD services for custom parts!",
|
"HERO_SUBTITLE": "We also offer CAD services for custom parts!",
|
||||||
|
"HERO_SWISS_TITLE": "Based in Switzerland",
|
||||||
|
"HERO_SWISS_COPY": "Swiss production and support.",
|
||||||
|
"HERO_SWISS_LOCATIONS_LABEL": "Locations",
|
||||||
|
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||||
|
"HERO_SWISS_LOCATION_2": "Zurich",
|
||||||
|
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
||||||
|
"HERO_SWISS_NOTE": "Serving customers across Switzerland.",
|
||||||
"BTN_CALCULATE": "Calculate Quote",
|
"BTN_CALCULATE": "Calculate Quote",
|
||||||
"BTN_SHOP": "Go to shop",
|
"BTN_SHOP": "Go to shop",
|
||||||
"BTN_CONTACT": "Talk to us",
|
"BTN_CONTACT": "Talk to us",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"CALCULATOR": "Calculateur",
|
"CALCULATOR": "Calculateur",
|
||||||
"SHOP": "Boutique",
|
"SHOP": "Boutique",
|
||||||
"ABOUT": "Qui sommes-nous",
|
"ABOUT": "Qui sommes-nous",
|
||||||
|
"MATERIALS": "Qualité & matériaux",
|
||||||
"CONTACT": "Contactez-nous",
|
"CONTACT": "Contactez-nous",
|
||||||
"LANGUAGE_SELECTOR": "Sélecteur de langue"
|
"LANGUAGE_SELECTOR": "Sélecteur de langue"
|
||||||
},
|
},
|
||||||
@@ -17,6 +18,13 @@
|
|||||||
"HERO_TITLE": "Service d'impression 3D.<br>Du fichier à la pièce finie.",
|
"HERO_TITLE": "Service d'impression 3D.<br>Du fichier à la pièce finie.",
|
||||||
"HERO_LEAD": "Avec le calculateur le plus avancé pour vos impressions 3D : précision absolue et zéro surprise.",
|
"HERO_LEAD": "Avec le calculateur le plus avancé pour vos impressions 3D : précision absolue et zéro surprise.",
|
||||||
"HERO_SUBTITLE": "Nous proposons aussi des services CAD pour des pièces personnalisées !",
|
"HERO_SUBTITLE": "Nous proposons aussi des services CAD pour des pièces personnalisées !",
|
||||||
|
"HERO_SWISS_TITLE": "Based in Switzerland",
|
||||||
|
"HERO_SWISS_COPY": "Production et support en Suisse.",
|
||||||
|
"HERO_SWISS_LOCATIONS_LABEL": "Sites",
|
||||||
|
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||||
|
"HERO_SWISS_LOCATION_2": "Zurich",
|
||||||
|
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
||||||
|
"HERO_SWISS_NOTE": "Actifs dans toute la Suisse.",
|
||||||
"BTN_CALCULATE": "Calculer un devis",
|
"BTN_CALCULATE": "Calculer un devis",
|
||||||
"BTN_SHOP": "Aller à la boutique",
|
"BTN_SHOP": "Aller à la boutique",
|
||||||
"BTN_CONTACT": "Parlez avec nous",
|
"BTN_CONTACT": "Parlez avec nous",
|
||||||
@@ -176,6 +184,7 @@
|
|||||||
"MODEL_CLOSE": "Fermer la vue 3D",
|
"MODEL_CLOSE": "Fermer la vue 3D",
|
||||||
"PREVIOUS_IMAGE": "Image précédente",
|
"PREVIOUS_IMAGE": "Image précédente",
|
||||||
"NEXT_IMAGE": "Image suivante",
|
"NEXT_IMAGE": "Image suivante",
|
||||||
|
"PRICE_LABEL": "Prix",
|
||||||
"SELECT_MATERIAL": "Matériau",
|
"SELECT_MATERIAL": "Matériau",
|
||||||
"SELECT_COLOR": "Couleur",
|
"SELECT_COLOR": "Couleur",
|
||||||
"MATERIAL_COLOR_COUNT": "{{count}} couleurs disponibles",
|
"MATERIAL_COLOR_COUNT": "{{count}} couleurs disponibles",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"CALCULATOR": "Calcolatore",
|
"CALCULATOR": "Calcolatore",
|
||||||
"SHOP": "Shop",
|
"SHOP": "Shop",
|
||||||
"ABOUT": "Chi Siamo",
|
"ABOUT": "Chi Siamo",
|
||||||
|
"MATERIALS": "Qualita e Materiali",
|
||||||
"CONTACT": "Contattaci",
|
"CONTACT": "Contattaci",
|
||||||
"LANGUAGE_SELECTOR": "Selettore lingua"
|
"LANGUAGE_SELECTOR": "Selettore lingua"
|
||||||
},
|
},
|
||||||
@@ -17,6 +18,13 @@
|
|||||||
"HERO_TITLE": "Servizio di stampa 3D.<br>Dal file al pezzo finito.",
|
"HERO_TITLE": "Servizio di stampa 3D.<br>Dal file al pezzo finito.",
|
||||||
"HERO_LEAD": "Con il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.",
|
"HERO_LEAD": "Con il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.",
|
||||||
"HERO_SUBTITLE": "Offriamo anche servizi di CAD per pezzi personalizzati!",
|
"HERO_SUBTITLE": "Offriamo anche servizi di CAD per pezzi personalizzati!",
|
||||||
|
"HERO_SWISS_TITLE": "Based in Switzerland",
|
||||||
|
"HERO_SWISS_COPY": "Produzione e supporto in Svizzera",
|
||||||
|
"HERO_SWISS_LOCATIONS_LABEL": "Sedi",
|
||||||
|
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||||
|
"HERO_SWISS_LOCATION_2": "Zurich",
|
||||||
|
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
||||||
|
"HERO_SWISS_NOTE": "Operativi in tutta la Svizzera.",
|
||||||
"BTN_CALCULATE": "Calcola Preventivo",
|
"BTN_CALCULATE": "Calcola Preventivo",
|
||||||
"BTN_SHOP": "Vai allo shop",
|
"BTN_SHOP": "Vai allo shop",
|
||||||
"BTN_CONTACT": "Parla con noi",
|
"BTN_CONTACT": "Parla con noi",
|
||||||
@@ -193,6 +201,7 @@
|
|||||||
"HIGHLIGHT_CART": "Nel carrello",
|
"HIGHLIGHT_CART": "Nel carrello",
|
||||||
"HIGHLIGHT_READY": "Preview",
|
"HIGHLIGHT_READY": "Preview",
|
||||||
"PRICE_FROM": "Prezzo da",
|
"PRICE_FROM": "Prezzo da",
|
||||||
|
"PRICE_LABEL": "Prezzo",
|
||||||
"EXCERPT_FALLBACK": "Scheda prodotto in preparazione.",
|
"EXCERPT_FALLBACK": "Scheda prodotto in preparazione.",
|
||||||
"MODEL_3D": "3D preview",
|
"MODEL_3D": "3D preview",
|
||||||
"MODEL_TITLE": "Anteprima del modello",
|
"MODEL_TITLE": "Anteprima del modello",
|
||||||
|
|||||||
@@ -107,10 +107,6 @@ app-product-detail {
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0;
|
|
||||||
border-radius: 1rem;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.selector-head {
|
.selector-head {
|
||||||
@@ -119,7 +115,8 @@ app-product-detail {
|
|||||||
|
|
||||||
.color-trigger {
|
.color-trigger {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 230px;
|
max-width: none;
|
||||||
|
min-height: 3.2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user