chore(front-end): new seo, and improvements in shop component
Some checks failed
Build and Deploy / test-backend (push) Successful in 29s
Build and Deploy / test-frontend (push) Successful in 1m2s
Build and Deploy / build-and-push (push) Failing after 1m16s
Build and Deploy / deploy (push) Has been skipped

This commit is contained in:
2026-03-12 16:26:36 +01:00
parent 96cfa91c67
commit 5d17b23c3a
19 changed files with 962 additions and 236 deletions

View File

@@ -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,18 +236,20 @@ 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)) {
throw new IllegalStateException("Source file not available for quote line item " + qItem.getId()); if (requiresStoredSourceFile(qItem)) {
} throw new IllegalStateException("Source file not available for quote line item " + qItem.getId());
try { }
storageService.store(sourcePath, Paths.get(relativePath)); } else {
oItem.setFileSizeBytes(Files.size(sourcePath)); String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
} catch (IOException e) { oItem.setStoredRelativePath(relativePath);
throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e); try {
storageService.store(sourcePath, Paths.get(relativePath));
oItem.setFileSizeBytes(Files.size(sourcePath));
} catch (IOException e) {
throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e);
}
} }
oItem = orderItemRepo.save(oItem); oItem = orderItemRepo.save(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;

View File

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

View File

@@ -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: () =>

View File

@@ -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('/')}` : '';

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,69 +177,6 @@
</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">
@@ -271,6 +211,70 @@
[currency]="'CHF'" [currency]="'CHF'"
[totalLabelKey]="'PAYMENT.TOTAL'" [totalLabelKey]="'PAYMENT.TOTAL'"
></app-price-breakdown> ></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-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>
</div>
</app-card> </app-card>
</div> </div>
</div> </div>

View File

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

View File

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

View File

@@ -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,32 +170,58 @@
</div> </div>
@if (materialOptions().length > 1) { @if (materialOptions().length > 1) {
<div class="material-grid"> <div class="material-section">
@for (material of materialOptions(); track material.key) { <div class="selector-head">
<button <p class="panel-kicker">
type="button" {{ "SHOP.SELECT_MATERIAL" | translate }}
class="material-option" </p>
[class.active]=" </div>
selectedMaterial()?.key === material.key
" <div class="material-grid">
(click)="selectMaterial(material.key)" @for (material of materialOptions(); track material.key) {
> <button
<span class="material-copy"> type="button"
<strong>{{ material.label }}</strong> class="material-option"
<small> [class.active]="
{{ selectedMaterial()?.key === material.key
"SHOP.MATERIAL_COLOR_COUNT" "
| translate (click)="selectMaterial(material.key)"
: { count: materialColorCount(material) } >
}} <span class="material-copy">
</small> <strong>{{ material.label }}</strong>
</span> <small>
<strong>{{ {{
materialPriceLabel(material) | currency: "CHF" "SHOP.MATERIAL_COLOR_COUNT"
}}</strong> | translate
</button> : { count: materialColorCount(material) }
} }}
</small>
</span>
<strong>{{
materialPriceLabel(material) | currency: "CHF"
}}</strong>
</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,90 +244,95 @@
</div> </div>
} }
<div class="color-selector-block"> <div class="selector-layout">
<div class="selector-head"> <div class="selector-card color-selector-block">
<p class="panel-kicker"> <div class="selector-head">
{{ "SHOP.SELECT_COLOR" | translate }} <p class="panel-kicker">
</p> {{ "SHOP.SELECT_COLOR" | translate }}
</div> </p>
</div>
@if (selectedVariant(); as activeVariant) { @if (selectedVariant(); as activeVariant) {
<button <button
type="button" type="button"
class="color-trigger" class="color-trigger"
[class.open]="colorPopupOpen()" [class.open]="colorPopupOpen()"
(click)="toggleColorPopup()" (click)="toggleColorPopup()"
> >
<span class="color-trigger__ring"> <span class="color-trigger__ring">
<span <span
class="color-trigger__swatch" class="color-trigger__swatch"
[style.background-color]="colorHex(activeVariant)" [style.background-color]="colorHex(activeVariant)"
></span> ></span>
</span> </span>
<span class="color-trigger__copy"> <span class="color-trigger__copy">
<strong>{{ colorLabel(activeVariant) }}</strong> <strong>{{ colorLabel(activeVariant) }}</strong>
<small>{{ selectedMaterial()?.label }}</small> <small>{{ selectedMaterial()?.label }}</small>
</span> </span>
</button> </button>
} }
@if (colorPopupOpen()) { @if (colorPopupOpen()) {
<button <button
type="button" type="button"
class="color-popup-backdrop" class="color-popup-backdrop"
(click)="closeColorPopup()" (click)="closeColorPopup()"
></button> ></button>
<div class="color-popup"> <div class="color-popup">
<div class="color-popup__category"> <div class="color-popup__category">
{{ selectedMaterial()?.label || "" | uppercase }} {{ selectedMaterial()?.label || "" | uppercase }}
</div> </div>
<div class="color-popup__grid"> <div class="color-popup__grid">
@for (variant of colorOptions(); track variant.id) { @for (variant of colorOptions(); track variant.id) {
<button <button
type="button" type="button"
class="color-popup__item" class="color-popup__item"
(click)="selectVariant(variant)" (click)="selectVariant(variant)"
>
<span
class="color-popup__ring"
[class.active]="
selectedVariant()?.id === variant.id
"
> >
<span <span
class="color-popup__swatch" class="color-popup__ring"
[style.background-color]="colorHex(variant)" [class.active]="
></span> selectedVariant()?.id === variant.id
</span> "
>
<span
class="color-popup__swatch"
[style.background-color]="colorHex(variant)"
></span>
</span>
<span class="color-popup__name">{{ <span class="color-popup__name">{{
colorLabel(variant) colorLabel(variant)
}}</span> }}</span>
</button> </button>
} }
</div>
</div> </div>
</div> }
} </div>
</div>
<div class="quantity-row"> <div class="selector-card quantity-card">
<span>{{ "SHOP.QUANTITY" | translate }}</span> <p class="panel-kicker">
<div class="qty-control"> {{ "SHOP.QUANTITY" | translate }}
<button type="button" (click)="decreaseQuantity()"> </p>
- <div class="qty-control">
</button> <button type="button" (click)="decreaseQuantity()">
<span>{{ quantity() }}</span> -
<button type="button" (click)="increaseQuantity()"> </button>
+ <span>{{ quantity() }}</span>
</button> <button type="button" (click)="increaseQuantity()">
+
</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>
} }

View File

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

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

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