diff --git a/backend/src/main/java/com/printcalculator/service/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java index 9179f7d..db3875a 100644 --- a/backend/src/main/java/com/printcalculator/service/OrderService.java +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -29,6 +29,7 @@ import java.util.*; @Service public class OrderService { 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 OrderItemRepository orderItemRepo; @@ -235,18 +236,20 @@ public class OrderService { oItem = orderItemRepo.save(oItem); - String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename; - oItem.setStoredRelativePath(relativePath); - Path sourcePath = resolveStoredQuotePath(qItem.getStoredPath(), session.getId()); if (sourcePath == null || !Files.exists(sourcePath)) { - throw new IllegalStateException("Source file not available for quote line item " + qItem.getId()); - } - 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); + if (requiresStoredSourceFile(qItem)) { + 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 { + 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); @@ -318,6 +321,12 @@ public class OrderService { 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) { if (storedPath == null || storedPath.isBlank()) { return null; diff --git a/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java b/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java index aa90829..63f23e4 100644 --- a/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java @@ -40,10 +40,13 @@ import java.util.Optional; import java.util.UUID; 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.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -217,6 +220,210 @@ class OrderServiceTest { 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("".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 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() { CustomerDto customer = new CustomerDto(); customer.setEmail("buyer@example.com"); diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 5fb79d4..8e75f90 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -15,9 +15,18 @@ const appChildRoutes: Routes = [ loadComponent: () => import('./features/home/home.component').then((m) => m.HomeComponent), data: { - seoTitle: '3D fab | Stampa 3D su misura', - seoDescription: - 'Servizio di stampa 3D con preventivo online immediato per prototipi, piccole serie e pezzi personalizzati.', + seoTitleByLang: { + it: 'Stampa 3D su misura in Ticino | Prototipi, ricambi e piccole serie - 3D Fab', + 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.', }, }, + /* { + 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', loadChildren: () => diff --git a/frontend/src/app/core/services/seo.service.ts b/frontend/src/app/core/services/seo.service.ts index 2637ecc..b918f71 100644 --- a/frontend/src/app/core/services/seo.service.ts +++ b/frontend/src/app/core/services/seo.service.ts @@ -12,14 +12,31 @@ export interface PageSeoOverride { ogDescription?: string | null; } +type SupportedLang = 'it' | 'en' | 'de' | 'fr'; +type SeoMap = Partial>; + @Injectable({ providedIn: 'root', }) export class SeoService { - private readonly defaultTitle = '3D fab | Stampa 3D su misura'; - private readonly defaultDescription = - 'Stampa 3D su misura con preventivo online immediato. Carica il file, scegli materiale e qualità, ricevi prezzo e tempi in pochi secondi.'; - private readonly supportedLangs = new Set(['it', 'en', 'de', 'fr']); + private readonly defaultTitleByLang: Record = { + it: '3D fab | Stampa 3D su misura', + en: '3D fab | Custom 3D Printing', + de: '3D fab | 3D-Druck nach Maß', + fr: '3D fab | Impression 3D sur mesure', + }; + private readonly defaultDescriptionByLang: Record = { + 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([ + 'it', + 'en', + 'de', + 'fr', + ]); constructor( private router: Router, @@ -40,9 +57,11 @@ export class SeoService { } 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 = - this.asString(override.description) ?? this.defaultDescription; + this.asString(override.description) ?? this.defaultDescriptionByLang[lang]; const robots = this.asString(override.robots) ?? 'index, follow'; const ogTitle = this.asString(override.ogTitle) ?? title; const ogDescription = this.asString(override.ogDescription) ?? description; @@ -52,13 +71,18 @@ export class SeoService { private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void { 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 = - this.asString(mergedData['seoDescription']) ?? this.defaultDescription; + this.resolveSeoText(mergedData, 'seoDescription', lang) ?? + this.defaultDescriptionByLang[lang]; 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 = - this.asString(mergedData['ogDescription']) ?? description; + this.resolveSeoText(mergedData, 'ogDescription', lang) ?? description; this.applySeoValues(title, description, robots, ogTitle, ogDescription); } @@ -104,11 +128,36 @@ export class SeoService { return typeof value === 'string' ? value : undefined; } + private resolveSeoText( + routeData: Record, + 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 { const path = (url || '/').split('?')[0].split('#')[0]; 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 { let link = this.document.head.querySelector( 'link[rel="canonical"]', @@ -124,10 +173,9 @@ export class SeoService { private updateLangAndAlternates(path: string): void { const segments = path.split('/').filter(Boolean); const firstSegment = segments[0]?.toLowerCase(); - const hasLang = Boolean( - firstSegment && this.supportedLangs.has(firstSegment), - ); - const lang = hasLang ? firstSegment : 'it'; + const maybeLang = firstSegment as SupportedLang | undefined; + const hasLang = Boolean(maybeLang && this.supportedLangs.has(maybeLang)); + const lang: SupportedLang = hasLang && maybeLang ? maybeLang : 'it'; const suffixSegments = hasLang ? segments.slice(1) : segments; const suffix = suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : ''; diff --git a/frontend/src/app/features/checkout/checkout.component.html b/frontend/src/app/features/checkout/checkout.component.html index 584eac3..0b5d5dd 100644 --- a/frontend/src/app/features/checkout/checkout.component.html +++ b/frontend/src/app/features/checkout/checkout.component.html @@ -1,5 +1,5 @@
-
+

{{ "CHECKOUT.TITLE" | translate }}

Servizio CAD diff --git a/frontend/src/app/features/checkout/checkout.component.scss b/frontend/src/app/features/checkout/checkout.component.scss index ac3fdb7..58f6b9c 100644 --- a/frontend/src/app/features/checkout/checkout.component.scss +++ b/frontend/src/app/features/checkout/checkout.component.scss @@ -1,3 +1,7 @@ +.checkout-hero { + padding-top: calc(var(--space-12) + var(--space-4)); +} + .cad-subtitle { margin: 0; } @@ -273,3 +277,9 @@ app-toggle-selector.user-type-selector-compact { .mb-6 { margin-bottom: var(--space-6); } + +@media (max-width: 640px) { + .checkout-hero { + padding-top: calc(var(--space-8) + var(--space-4)); + } +} diff --git a/frontend/src/app/features/home/home.component.html b/frontend/src/app/features/home/home.component.html index 1775879..be5868a 100644 --- a/frontend/src/app/features/home/home.component.html +++ b/frontend/src/app/features/home/home.component.html @@ -1,6 +1,8 @@

-
+

{{ "HOME.HERO_EYEBROW" | translate }}

+
diff --git a/frontend/src/app/features/home/home.component.scss b/frontend/src/app/features/home/home.component.scss index 58c57d5..e557664 100644 --- a/frontend/src/app/features/home/home.component.scss +++ b/frontend/src/app/features/home/home.component.scss @@ -45,6 +45,99 @@ 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 { position: relative; border-bottom: 1px solid var(--color-border); @@ -165,6 +258,13 @@ } @media (max-width: 640px) { + .hero-swiss-card { + align-self: start; + justify-self: center; + width: min(100%, 340px); + margin-top: 1rem; + } + .shop-gallery { width: 100%; max-width: none; diff --git a/frontend/src/app/features/order/order.component.html b/frontend/src/app/features/order/order.component.html index 4c31008..59cbdeb 100644 --- a/frontend/src/app/features/order/order.component.html +++ b/frontend/src/app/features/order/order.component.html @@ -68,9 +68,12 @@
-
-
- +
+
+

{{ "PAYMENT.METHOD" | translate }}

@@ -174,69 +177,6 @@
- - -
-

{{ "ORDER.ITEMS_TITLE" | translate }}

-

- {{ orderKindLabel(o) }} -

-
- -
-
-
-
- {{ - itemDisplayName(item) - }} - - {{ - isShopItem(item) - ? ("ORDER.TYPE_SHOP" | translate) - : ("ORDER.TYPE_CALCULATOR" | translate) - }} - -
- -
- {{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }} - - {{ "CHECKOUT.MATERIAL" | translate }}: - {{ - item.materialCode || ("ORDER.NOT_AVAILABLE" | translate) - }} - - - {{ "SHOP.VARIANT" | translate }}: {{ variantLabel }} - - - - {{ itemColorLabel(item) }} - -
- -
- {{ item.printTimeSeconds || 0 | number: "1.0-0" }}s | - {{ item.materialGrams || 0 | number: "1.0-0" }}g -
-
- - - {{ item.lineTotalChf || 0 | currency: "CHF" }} - -
-
-
@@ -271,6 +211,70 @@ [currency]="'CHF'" [totalLabelKey]="'PAYMENT.TOTAL'" > + +
+
+

{{ "ORDER.ITEMS_TITLE" | translate }}

+ {{ (o.items || []).length }} +
+ +
+
+
+
+ {{ + itemDisplayName(item) + }} + + {{ + isShopItem(item) + ? ("ORDER.TYPE_SHOP" | translate) + : ("ORDER.TYPE_CALCULATOR" | translate) + }} + +
+ +
+ {{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }} + + {{ "CHECKOUT.MATERIAL" | translate }}: + {{ + item.materialCode || ("ORDER.NOT_AVAILABLE" | translate) + }} + + + {{ "SHOP.VARIANT" | translate }}: {{ variantLabel }} + + + + {{ itemColorLabel(item) }} + +
+ +
+ {{ item.printTimeSeconds || 0 | number: "1.0-0" }}s | + {{ item.materialGrams || 0 | number: "1.0-0" }}g +
+
+ + + {{ item.lineTotalChf || 0 | currency: "CHF" }} + +
+
+
diff --git a/frontend/src/app/features/order/order.component.scss b/frontend/src/app/features/order/order.component.scss index ecb216c..fb176e9 100644 --- a/frontend/src/app/features/order/order.component.scss +++ b/frontend/src/app/features/order/order.component.scss @@ -10,6 +10,11 @@ margin-bottom: var(--space-6); } +.payment-layout--summary-only { + grid-template-columns: minmax(0, 440px); + justify-content: center; +} + .payment-details { margin-bottom: var(--space-6); @@ -119,9 +124,52 @@ 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 { 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 { @@ -129,7 +177,7 @@ justify-content: space-between; align-items: flex-start; gap: var(--space-3); - padding: var(--space-3); + padding: 0.85rem 0.9rem; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-bg-card); @@ -149,7 +197,7 @@ } .order-item-name { - font-size: 1rem; + font-size: 0.96rem; line-height: 1.35; } @@ -176,7 +224,7 @@ flex-wrap: wrap; gap: 0.5rem 0.9rem; color: var(--color-text-muted); - font-size: 0.92rem; + font-size: 0.88rem; } .item-color-chip { @@ -194,13 +242,13 @@ } .order-item-tech { - font-size: 0.86rem; + font-size: 0.82rem; color: var(--color-text-muted); } .order-item-total { white-space: nowrap; - font-size: 1rem; + font-size: 0.96rem; } .order-summary-meta { @@ -325,6 +373,10 @@ padding-top: calc(var(--space-8) + var(--space-4)); } + .payment-layout--summary-only { + grid-template-columns: 1fr; + } + .status-timeline { margin-top: var(--space-4); margin-bottom: var(--space-8); @@ -362,4 +414,10 @@ .order-summary-meta { grid-template-columns: 1fr; } + + .order-items { + max-height: none; + overflow: visible; + padding-right: 0; + } } diff --git a/frontend/src/app/features/shop/components/product-card/product-card.component.scss b/frontend/src/app/features/shop/components/product-card/product-card.component.scss index 65885ad..d9845d4 100644 --- a/frontend/src/app/features/shop/components/product-card/product-card.component.scss +++ b/frontend/src/app/features/shop/components/product-card/product-card.component.scss @@ -21,7 +21,7 @@ .media { position: relative; display: block; - aspect-ratio: 1 / 1; + aspect-ratio: 4 / 3; background: #f2eee5; overflow: hidden; } diff --git a/frontend/src/app/features/shop/product-detail.component.html b/frontend/src/app/features/shop/product-detail.component.html index e4fbc8d..65aaf4e 100644 --- a/frontend/src/app/features/shop/product-detail.component.html +++ b/frontend/src/app/features/shop/product-detail.component.html @@ -15,18 +15,23 @@ } @else { @if (product(); as p) {
-
+
@if (galleryImages().length > 1) { - } +
+
+

+ {{ "SHOP.SELECT_MATERIAL" | translate }} +

+
+ +
+ @for (material of materialOptions(); track material.key) { + + } +
+ } @else { + @if (selectedMaterial(); as material) { +
+
+

+ {{ "SHOP.SELECT_MATERIAL" | translate }} +

+ {{ material.label }} + + {{ + "SHOP.MATERIAL_COLOR_COUNT" + | translate + : { count: materialColorCount(material) } + }} + +
+
+ } } @if ( @@ -196,90 +244,95 @@
} -
-
-

- {{ "SHOP.SELECT_COLOR" | translate }} -

-
+
+
+
+

+ {{ "SHOP.SELECT_COLOR" | translate }} +

+
- @if (selectedVariant(); as activeVariant) { - - } + + {{ colorLabel(activeVariant) }} + {{ selectedMaterial()?.label }} + + + } - @if (colorPopupOpen()) { - -
-
- {{ selectedMaterial()?.label || "" | uppercase }} -
+ @if (colorPopupOpen()) { + +
+
+ {{ selectedMaterial()?.label || "" | uppercase }} +
-
- @for (variant of colorOptions(); track variant.id) { - - } + {{ + colorLabel(variant) + }} + + } +
-
- } -
+ } +
-
- {{ "SHOP.QUANTITY" | translate }} -
- - {{ quantity() }} - +
+

+ {{ "SHOP.QUANTITY" | translate }} +

+
+ + {{ quantity() }} + +
@@ -290,7 +343,11 @@ @if (shopService.cartItemCount() > 0) { - + {{ "SHOP.GO_TO_CHECKOUT" | translate }} } diff --git a/frontend/src/app/features/shop/product-detail.component.scss b/frontend/src/app/features/shop/product-detail.component.scss index e6e50c8..4369be5 100644 --- a/frontend/src/app/features/shop/product-detail.component.scss +++ b/frontend/src/app/features/shop/product-detail.component.scss @@ -18,15 +18,51 @@ border: 0; background: none; font: inherit; + font-size: 1rem; + font-weight: 600; + line-height: 1.3; cursor: pointer; text-align: left; } +.back-link:hover { + color: var(--color-text); +} + .breadcrumbs { display: flex; flex-wrap: wrap; - gap: 0.45rem; - font-size: 0.9rem; + align-items: center; + 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 { @@ -53,9 +89,8 @@ .hero-media { position: relative; - aspect-ratio: 1 / 1; - min-height: 420px; - max-height: 620px; + width: 100%; + aspect-ratio: 4 / 3; overflow: hidden; border-radius: 1.25rem; border: 1px solid rgba(16, 24, 32, 0.12); @@ -67,14 +102,18 @@ width: 100%; height: 100%; display: block; - object-fit: contain; + object-fit: cover; + object-position: center; background: #f2eee5; } +.hero-media--portrait .hero-image { + object-fit: contain; +} + .image-fallback { width: 100%; height: 100%; - min-height: 420px; display: flex; align-items: flex-end; padding: var(--space-6); @@ -111,8 +150,8 @@ } .thumb { - flex: 0 0 92px; - height: 92px; + flex: 0 0 96px; + aspect-ratio: 4 / 3; overflow: hidden; border-radius: 0.85rem; border: 1px solid rgba(16, 24, 32, 0.12); @@ -226,15 +265,34 @@ h1 { .purchase-card { display: grid; - gap: 0.78rem; + gap: 1rem; } -.price-row, -.quantity-row { +.offer-header { display: flex; justify-content: space-between; 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; + gap: 0.38rem; + color: var(--color-text-muted); + font-size: 0.9rem; } .cart-pill { @@ -249,6 +307,11 @@ h1 { font-weight: 600; } +.material-section { + display: grid; + gap: 0.65rem; +} + .material-grid { display: grid; gap: 0.55rem; @@ -301,6 +364,31 @@ h1 { 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 { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -340,11 +428,30 @@ h1 { 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 { display: inline-flex; align-items: center; gap: 0.45rem; padding: 0.2rem; + min-height: 3.2rem; border-radius: 999px; border: 1px solid var(--color-border); background: rgba(255, 255, 255, 0.82); @@ -366,10 +473,20 @@ h1 { font-weight: 700; } +.quantity-card { + justify-items: start; + align-content: start; + grid-template-rows: auto 1fr; +} + +.quantity-card .qty-control { + align-self: center; +} + .actions { - display: flex; - flex-wrap: wrap; - gap: var(--space-3); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.75rem; } .success-note { @@ -459,6 +576,10 @@ h1 { grid-template-columns: 1fr; } + .selector-layout { + grid-template-columns: 1fr; + } + .property-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -469,15 +590,14 @@ h1 { } @media (max-width: 640px) { - .price-row, - .quantity-row { + .offer-header, + .material-summary { flex-direction: column; align-items: start; } - .hero-media, - .image-fallback { - min-height: 300px; + .hero-media--portrait .hero-image { + object-fit: cover; } .thumb-strip { @@ -485,8 +605,7 @@ h1 { } .thumb { - flex-basis: 78px; - height: 78px; + flex-basis: 84px; } .model-launch-row { @@ -514,6 +633,10 @@ h1 { grid-template-columns: 1fr; } + .selector-card { + padding: 0.74rem 0.78rem; + } + :host ::ng-deep app-card.purchase-shell .card-body { padding: 0.82rem 0.82rem; } diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts index 71bb621..c74a32b 100644 --- a/frontend/src/app/features/shop/product-detail.component.ts +++ b/frontend/src/app/features/shop/product-detail.component.ts @@ -74,6 +74,9 @@ export class ProductDetailComponent { readonly product = signal(null); readonly selectedVariantId = signal(null); readonly selectedImageAssetId = signal(null); + readonly selectedImageOrientation = signal< + 'portrait' | 'landscape' | 'square' | null + >(null); readonly quantity = signal(1); readonly isAddingToCart = signal(false); readonly addSuccess = signal(false); @@ -191,6 +194,9 @@ export class ProductDetailComponent { readonly selectedVariantCartQuantity = computed(() => this.shopService.quantityForVariant(this.selectedVariant()?.id), ); + readonly selectedImageIsPortrait = computed( + () => this.selectedImageOrientation() === 'portrait', + ); constructor() { if (!this.shopService.cartLoaded()) { @@ -230,7 +236,7 @@ export class ProductDetailComponent { catchError((error) => { this.product.set(null); this.selectedVariantId.set(null); - this.selectedImageAssetId.set(null); + this.setSelectedImageAssetId(null); this.modelFile.set(null); this.error.set( error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR', @@ -257,7 +263,7 @@ export class ProductDetailComponent { product.defaultVariant ?? product.variants[0] ?? null, ), ); - this.selectedImageAssetId.set( + this.setSelectedImageAssetId( product.primaryImage?.mediaAssetId ?? product.images[0]?.mediaAssetId ?? null, @@ -283,7 +289,7 @@ export class ProductDetailComponent { } selectImage(mediaAssetId: string): void { - this.selectedImageAssetId.set(mediaAssetId); + this.setSelectedImageAssetId(mediaAssetId); } showPreviousImage(): void { @@ -293,7 +299,7 @@ export class ProductDetailComponent { } const nextIndex = (this.selectedImageIndex() - 1 + images.length) % images.length; - this.selectedImageAssetId.set(images[nextIndex].mediaAssetId); + this.setSelectedImageAssetId(images[nextIndex].mediaAssetId); } showNextImage(): void { @@ -302,7 +308,26 @@ export class ProductDetailComponent { return; } 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 { @@ -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 { const raw = String(value ?? '').trim(); if (!raw) { diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index d92fcbd..223fd36 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -4,6 +4,7 @@ "CALCULATOR": "Rechner", "SHOP": "Shop", "ABOUT": "Über uns", + "MATERIALS": "Qualität & Materialien", "CONTACT": "Kontakt", "LANGUAGE_SELECTOR": "Sprachauswahl" }, @@ -119,6 +120,7 @@ "MODEL_CLOSE": "3D-Ansicht schließen", "PREVIOUS_IMAGE": "Vorheriges Bild", "NEXT_IMAGE": "Nächstes Bild", + "PRICE_LABEL": "Preis", "SELECT_MATERIAL": "Material", "SELECT_COLOR": "Farbe", "MATERIAL_COLOR_COUNT": "{{count}} Farben verfügbar", @@ -499,6 +501,13 @@ "HERO_TITLE": "3D-Druckservice.
Von der Datei zum fertigen Teil.", "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_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_SHOP": "Zum Shop", "BTN_CONTACT": "Mit uns sprechen", diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index 3403fa2..8f8ffbe 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -4,6 +4,7 @@ "CALCULATOR": "Calculator", "SHOP": "Shop", "ABOUT": "About Us", + "MATERIALS": "Quality & Materials", "CONTACT": "Contact Us", "LANGUAGE_SELECTOR": "Language selector" }, @@ -119,6 +120,7 @@ "MODEL_CLOSE": "Close 3D view", "PREVIOUS_IMAGE": "Previous image", "NEXT_IMAGE": "Next image", + "PRICE_LABEL": "Price", "SELECT_MATERIAL": "Material", "SELECT_COLOR": "Color", "MATERIAL_COLOR_COUNT": "{{count}} colors available", @@ -499,6 +501,13 @@ "HERO_TITLE": "3D printing service.
From file to finished part.", "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_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_SHOP": "Go to shop", "BTN_CONTACT": "Talk to us", diff --git a/frontend/src/assets/i18n/fr.json b/frontend/src/assets/i18n/fr.json index dd537db..e3e80ab 100644 --- a/frontend/src/assets/i18n/fr.json +++ b/frontend/src/assets/i18n/fr.json @@ -4,6 +4,7 @@ "CALCULATOR": "Calculateur", "SHOP": "Boutique", "ABOUT": "Qui sommes-nous", + "MATERIALS": "Qualité & matériaux", "CONTACT": "Contactez-nous", "LANGUAGE_SELECTOR": "Sélecteur de langue" }, @@ -17,6 +18,13 @@ "HERO_TITLE": "Service d'impression 3D.
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_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_SHOP": "Aller à la boutique", "BTN_CONTACT": "Parlez avec nous", @@ -176,6 +184,7 @@ "MODEL_CLOSE": "Fermer la vue 3D", "PREVIOUS_IMAGE": "Image précédente", "NEXT_IMAGE": "Image suivante", + "PRICE_LABEL": "Prix", "SELECT_MATERIAL": "Matériau", "SELECT_COLOR": "Couleur", "MATERIAL_COLOR_COUNT": "{{count}} couleurs disponibles", diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index 9e2ad80..006e9ff 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -4,6 +4,7 @@ "CALCULATOR": "Calcolatore", "SHOP": "Shop", "ABOUT": "Chi Siamo", + "MATERIALS": "Qualita e Materiali", "CONTACT": "Contattaci", "LANGUAGE_SELECTOR": "Selettore lingua" }, @@ -17,6 +18,13 @@ "HERO_TITLE": "Servizio di stampa 3D.
Dal file al pezzo finito.", "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_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_SHOP": "Vai allo shop", "BTN_CONTACT": "Parla con noi", @@ -193,6 +201,7 @@ "HIGHLIGHT_CART": "Nel carrello", "HIGHLIGHT_READY": "Preview", "PRICE_FROM": "Prezzo da", + "PRICE_LABEL": "Prezzo", "EXCERPT_FALLBACK": "Scheda prodotto in preparazione.", "MODEL_3D": "3D preview", "MODEL_TITLE": "Anteprima del modello", diff --git a/frontend/src/styles/_shop-product-detail-overrides.scss b/frontend/src/styles/_shop-product-detail-overrides.scss index b9f1bcd..228999e 100644 --- a/frontend/src/styles/_shop-product-detail-overrides.scss +++ b/frontend/src/styles/_shop-product-detail-overrides.scss @@ -107,10 +107,6 @@ app-product-detail { position: relative; display: grid; gap: 0.4rem; - padding: 0; - border-radius: 1rem; - border: 0; - background: transparent; } .selector-head { @@ -119,7 +115,8 @@ app-product-detail { .color-trigger { width: 100%; - max-width: 230px; + max-width: none; + min-height: 3.2rem; display: flex; align-items: center; gap: 0.75rem;