feat(back-end front-end): upgrade to the order componen instead of payment and order-confirmed
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 39s
Build, Test and Deploy / build-and-push (push) Successful in 1m3s
Build, Test and Deploy / deploy (push) Successful in 9s

This commit is contained in:
2026-02-24 08:44:42 +01:00
parent c1652798b4
commit 699a968875
21 changed files with 717 additions and 761 deletions

View File

@@ -26,6 +26,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'xyz.capybara:clamav-client:2.1.2' implementation 'xyz.capybara:clamav-client:2.1.2'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
runtimeOnly 'org.postgresql:postgresql' runtimeOnly 'org.postgresql:postgresql'
developmentOnly 'org.springframework.boot:spring-boot-devtools' developmentOnly 'org.springframework.boot:spring-boot-devtools'
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'

View File

@@ -122,6 +122,6 @@ public class OrderEmailListener {
private String buildOrderDetailsUrl(Order order) { private String buildOrderDetailsUrl(Order order) {
String baseUrl = frontendBaseUrl == null ? "" : frontendBaseUrl.replaceAll("/+$", ""); String baseUrl = frontendBaseUrl == null ? "" : frontendBaseUrl.replaceAll("/+$", "");
return baseUrl + "/ordine/" + order.getId(); return baseUrl + "/co/" + order.getId();
} }
} }

View File

@@ -38,7 +38,8 @@ public class PaymentService {
Payment payment = new Payment(); Payment payment = new Payment();
payment.setOrder(order); payment.setOrder(order);
payment.setMethod(defaultMethod != null ? defaultMethod : "OTHER"); // Default to "OTHER" always, as payment method should only be set by the admin explicitly
payment.setMethod("OTHER");
payment.setStatus("PENDING"); payment.setStatus("PENDING");
payment.setCurrency(order.getCurrency() != null ? order.getCurrency() : "CHF"); payment.setCurrency(order.getCurrency() != null ? order.getCurrency() : "CHF");
payment.setAmountChf(order.getTotalChf() != null ? order.getTotalChf() : BigDecimal.ZERO); payment.setAmountChf(order.getTotalChf() != null ? order.getTotalChf() : BigDecimal.ZERO);
@@ -53,7 +54,7 @@ public class PaymentService {
.orElseThrow(() -> new RuntimeException("Order not found with id " + orderId)); .orElseThrow(() -> new RuntimeException("Order not found with id " + orderId));
Payment payment = paymentRepo.findByOrder_Id(orderId) Payment payment = paymentRepo.findByOrder_Id(orderId)
.orElseThrow(() -> new RuntimeException("No active payment found for order " + orderId)); .orElseGet(() -> getOrCreatePaymentForOrder(order, "OTHER"));
if (!"PENDING".equals(payment.getStatus())) { if (!"PENDING".equals(payment.getStatus())) {
throw new IllegalStateException("Payment is not in PENDING state. Current state: " + payment.getStatus()); throw new IllegalStateException("Payment is not in PENDING state. Current state: " + payment.getStatus());
@@ -61,9 +62,10 @@ public class PaymentService {
payment.setStatus("REPORTED"); payment.setStatus("REPORTED");
payment.setReportedAt(OffsetDateTime.now()); payment.setReportedAt(OffsetDateTime.now());
if (method != null && !method.isBlank()) {
payment.setMethod(method); // We intentionally do not update the payment method here based on user input,
} // because the user cannot reliably determine the actual method without an integration.
// It will be updated by the backoffice admin manually.
payment = paymentRepo.save(payment); payment = paymentRepo.save(payment);

View File

@@ -3,17 +3,28 @@
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<style> <style>
@page invoice { size: A4; margin: 14mm 14mm 12mm 14mm; } @page invoice {
@page qrpage { size: A4; margin: 0; } size: A4;
margin: 12mm 12mm 12mm 12mm;
}
@page qrpage {
size: A4;
margin: 0;
}
*, *:before, *:after {
box-sizing: border-box;
}
body { body {
page: invoice; page: invoice;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 9.5pt; font-size: 8.5pt;
margin: 0; margin: 0;
padding: 0; padding: 0;
background: #fff; background: #fff;
color: #191919; color: #000;
line-height: 1.35; line-height: 1.35;
} }
@@ -22,213 +33,207 @@
width: 100%; width: 100%;
} }
.top-layout { /* Top Header Layout */
.header-layout {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin-bottom: 8mm; table-layout: fixed;
margin-bottom: 25mm;
} }
.top-layout td { .header-layout td {
vertical-align: top; vertical-align: top;
padding: 0; padding: 0;
} }
.doc-title { .logo-block {
font-size: 18pt; width: 33%;
font-weight: 700; font-size: 20pt;
margin: 0 0 1.5mm 0; font-weight: bold;
letter-spacing: 0.2px; color: #005eb8; /* Brand blue similar to reference */
letter-spacing: -0.5px;
} }
.doc-subtitle { .logo-d {
color: #4b4b4b; font-style: italic;
font-size: 10pt; color: #d22630;
font-size: 22pt;
margin-right: 2px;
} }
.seller-block { .seller-block {
width: 33%;
font-size: 9pt;
line-height: 1.4;
}
.website-block {
width: 33%;
text-align: right; text-align: right;
line-height: 1.45; font-size: 9pt;
width: 42%;
} }
.seller-name { /* Document Title */
font-size: 11pt; .doc-title {
font-weight: 700; font-size: 20pt;
font-weight: normal;
margin: 0 0 10mm 0;
letter-spacing: -0.5px;
} }
.meta-layout { /* Meta and Customer Details Layout */
.details-layout {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin-bottom: 8mm; table-layout: fixed;
margin-bottom: 15mm;
} }
.meta-layout td { .details-layout td {
vertical-align: top; vertical-align: top;
padding: 0; padding: 0;
} }
.order-details { .meta-container {
width: 60%; width: 50%;
padding-right: 5mm;
} }
.customer-box { .customer-container {
width: 40%; width: 50%;
background: #f7f7f7; font-size: 10pt;
border: 1px solid #e2e2e2; line-height: 1.5;
padding: 3mm 3.2mm;
} }
.box-title { .meta-table {
font-size: 8.8pt;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #5a5a5a;
margin-bottom: 2mm;
font-weight: 700;
}
.details-table {
width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 8.5pt;
} }
.details-table td { .meta-table td {
padding: 1.1mm 0; padding: 1.5mm 0;
border-bottom: 1px solid #ececec;
vertical-align: top; vertical-align: top;
} }
.details-label { .meta-label {
color: #636363; width: 45mm;
width: 56%; padding-right: 2mm;
white-space: nowrap;
padding-right: 3mm;
} }
.details-value { .meta-value {
text-align: left; /* allow wrapping just in case */
font-weight: 600;
} }
/* Line Items Table */
.line-items { .line-items {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
table-layout: fixed; table-layout: fixed;
margin-top: 3mm; margin-top: 5mm;
border-top: 1px solid #cfcfcf; font-size: 8.5pt;
} }
.line-items th, .line-items th,
.line-items td { .line-items td {
border-bottom: 1px solid #dedede; padding: 1.5mm 0;
padding: 2.4mm 2mm;
vertical-align: top; vertical-align: top;
word-wrap: break-word;
} }
.line-items th { .line-items th {
text-align: left; text-align: left;
font-weight: 700; font-weight: normal;
background: #f2f2f2; border-bottom: 1pt solid #000;
color: #2c2c2c;
font-size: 9pt;
text-transform: uppercase;
letter-spacing: 0.25px;
} }
.line-items th:nth-child(1), .line-items tbody td {
.line-items td:nth-child(1) { border-bottom: 0.5pt solid #e0e0e0;
width: 50%;
} }
.line-items th:nth-child(2), .line-items tbody tr:last-child td {
.line-items td:nth-child(2) { border-bottom: 1pt solid #000;
width: 10%; }
.line-items th.center,
.line-items td.center {
text-align: center;
}
.line-items th.right,
.line-items td.right {
text-align: right; text-align: right;
white-space: nowrap;
} }
.line-items th:nth-child(3), .col-desc { width: 45%; }
.line-items td:nth-child(3) { .col-qty { width: 10%; }
width: 20%; .col-price { width: 22%; }
text-align: right; .col-total { width: 23%; }
white-space: nowrap;
.item-desc {
padding-right: 4mm;
} }
.line-items th:nth-child(4), /* Totals Block */
.line-items td:nth-child(4) { .totals-table {
width: 20%;
text-align: right;
white-space: nowrap;
font-weight: 600;
}
.summary-layout {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin-top: 6mm; table-layout: fixed;
margin-top: 0;
font-size: 8.5pt;
} }
.summary-layout td { .totals-table td {
vertical-align: top; padding: 1.5mm 0;
padding: 0; border-bottom: 0.5pt solid #000;
}
.notes {
width: 58%;
padding-right: 5mm;
color: #383838;
line-height: 1.45;
}
.notes .section-caption {
font-weight: 700;
margin: 0 0 1.2mm 0;
color: #2a2a2a;
}
.totals {
width: 42%;
margin-left: auto;
border-collapse: collapse;
}
.totals td {
border: none;
padding: 1.3mm 0;
} }
.totals-label { .totals-label {
text-align: left; text-align: left;
color: #4a4a4a;
} }
.totals-value { .totals-value {
text-align: right; text-align: right;
white-space: nowrap; width: 30%;
font-weight: 600;
} }
.total-strong td { .totals-table tr.no-border td {
font-size: 10.5pt; border-bottom: none;
font-weight: 700;
padding-top: 2mm;
border-top: 1px solid #cfcfcf;
} }
.due-row td { .summary-notes {
font-size: 10pt; margin-top: 4mm;
font-weight: 700; padding-bottom: 4mm;
border-top: 1px solid #cfcfcf; border-bottom: 1pt solid #000;
padding-top: 2.2mm;
} }
/* Footer Notes Layout */
.footer-layout {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-top: 15mm;
font-size: 8.5pt;
}
.footer-layout td {
vertical-align: top;
padding: 0 0 3mm 0;
}
.footer-label {
width: 25%;
font-weight: normal;
}
.footer-text {
width: 75%;
line-height: 1.4;
}
/* QR Page */
.qr-only-page { .qr-only-page {
page: qrpage; page: qrpage;
position: relative; position: relative;
width: 210mm; width: 100%;
height: 297mm; height: 100%;
background: #fff; background: #fff;
page-break-before: always; page-break-before: always;
} }
@@ -237,14 +242,14 @@
position: absolute; position: absolute;
left: 0; left: 0;
bottom: 0; bottom: 0;
width: 210mm; width: 100%;
height: 105mm; height: 105mm;
overflow: hidden; overflow: hidden;
background: #fff; background: #fff;
} }
.qr-bill-bottom svg { .qr-bill-bottom svg {
width: 210mm !important; width: 100% !important;
height: 105mm !important; height: 105mm !important;
display: block; display: block;
} }
@@ -253,104 +258,120 @@
<body> <body>
<div class="invoice-page"> <div class="invoice-page">
<table class="top-layout"> <!-- Header -->
<table class="header-layout">
<tr> <tr>
<td> <td class="logo-block">
<div class="doc-title">Conferma ordine</div> 3D-fab.ch
<div class="doc-subtitle">Ricevuta semplificata</div>
</td> </td>
<td class="seller-block"> <td class="seller-block">
<div class="seller-name" th:text="${sellerDisplayName}">3D Fab Switzerland</div> <div th:text="${sellerDisplayName}">3D Fab Switzerland</div>
<div th:text="${sellerAddressLine1}">Sede Ticino, Svizzera</div> <div th:text="${sellerAddressLine1}">Sede Ticino, Svizzera</div>
<div th:text="${sellerAddressLine2}">Sede Bienne, Svizzera</div> <div th:text="${sellerAddressLine2}">Sede Bienne, Svizzera</div>
<div th:text="${sellerEmail}">info@3dfab.ch</div> </td>
<td class="website-block">
www.3d-fab.ch
</td> </td>
</tr> </tr>
</table> </table>
<table class="meta-layout"> <!-- Document Title -->
<div class="doc-title">
Conferma dell'ordine <span th:text="${invoiceNumber}">141052743</span>
</div>
<!-- Details block (Meta and Customer) -->
<table class="details-layout">
<tr> <tr>
<td class="order-details"> <td class="meta-container">
<table class="details-table"> <table class="meta-table">
<tr> <tr>
<td class="details-label">Data ordine / fattura</td> <td class="meta-label">Data dell'ordine / fattura</td>
<td class="details-value" th:text="${invoiceDate}">2026-02-13</td> <td class="meta-value" th:text="${invoiceDate}">07.03.2025</td>
</tr> </tr>
<tr> <tr>
<td class="details-label">Numero documento</td> <td class="meta-label">Numero documento</td>
<td class="details-value" th:text="${invoiceNumber}">INV-2026-000123</td> <td class="meta-value" th:text="${invoiceNumber}">INV-2026-000123</td>
</tr> </tr>
<tr> <tr>
<td class="details-label">Data di scadenza</td> <td class="meta-label">Data di scadenza</td>
<td class="details-value" th:text="${dueDate}">2026-02-20</td> <td class="meta-value" th:text="${dueDate}">07.03.2025</td>
</tr> </tr>
<tr> <tr>
<td class="details-label">Valuta</td> <td class="meta-label">Metodo di pagamento</td>
<td class="details-value">CHF</td> <td class="meta-value">QR / Bonifico oppure TWINT</td>
</tr>
<tr>
<td class="meta-label">Valuta</td>
<td class="meta-value">CHF</td>
</tr> </tr>
</table> </table>
</td> </td>
<td class="customer-box"> <td class="customer-container">
<div class="box-title">Cliente</div> <div th:text="${buyerDisplayName}">Joe Küng</div>
<div th:text="${buyerDisplayName}">Cliente SA</div> <div th:text="${buyerAddressLine1}">Via G.Pioda, 29a</div>
<div th:text="${buyerAddressLine1}">Via Cliente 7</div> <div th:text="${buyerAddressLine2}">6710 biasca</div>
<div th:text="${buyerAddressLine2}">8000 Zürich, CH</div> <div>Svizzera</div>
</td> </td>
</tr> </tr>
</table> </table>
<!-- Items Table -->
<table class="line-items"> <table class="line-items">
<thead> <thead>
<tr> <tr>
<th>Descrizione</th> <th class="col-desc">Descrizione</th>
<th>Qtà</th> <th class="col-qty center">Quanti</th>
<th>Prezzo unit.</th> <th class="col-price right">Prezzo unitario</th>
<th>Totale</th> <th class="col-total right">Prezzo incl.</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr th:each="lineItem : ${invoiceLineItems}"> <tr th:each="lineItem : ${invoiceLineItems}">
<td th:text="${lineItem.description}">Stampa 3D pezzo X</td> <td class="item-desc" th:text="${lineItem.description}">Apple iPhone 16 Pro</td>
<td th:text="${lineItem.quantity}">1</td> <td class="center" th:text="${lineItem.quantity}">1</td>
<td th:text="${lineItem.unitPriceFormatted}">CHF 10.00</td> <td class="right" th:text="${lineItem.unitPriceFormatted}">968.55</td>
<td th:text="${lineItem.lineTotalFormatted}">CHF 10.00</td> <td class="right" th:text="${lineItem.lineTotalFormatted}">1'047.00</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<table class="summary-layout"> <!-- Totals -->
<table class="totals-table">
<tr> <tr>
<td class="notes"> <td class="totals-label">Importo totale</td>
<div class="section-caption">Informazioni</div> <td class="totals-value" th:text="${subtotalFormatted}">1'012.86</td>
<div th:text="${paymentTermsText}"> </tr>
Appena riceviamo il pagamento l'ordine entra nella coda di stampa. Grazie per la fiducia.
</div>
<div style="margin-top: 2.5mm;">
Verifica i dettagli dell'ordine al ricevimento. Per assistenza, rispondi alla nostra email di conferma.
</div>
</td>
<td>
<table class="totals">
<tr> <tr>
<td class="totals-label">Subtotale</td> <td class="totals-label">Totale di tutte le consegne e di tutti i servizi CHF</td>
<td class="totals-value" th:text="${subtotalFormatted}">CHF 10.00</td> <td class="totals-value" th:text="${grandTotalFormatted}">1'094.90</td>
</tr> </tr>
<tr class="total-strong"> <tr class="no-border">
<td class="totals-label">Totale ordine</td>
<td class="totals-value" th:text="${grandTotalFormatted}">CHF 10.00</td>
</tr>
<tr class="due-row">
<td class="totals-label">Importo dovuto</td> <td class="totals-label">Importo dovuto</td>
<td class="totals-value" th:text="${grandTotalFormatted}">CHF 10.00</td> <td class="totals-value" th:text="${grandTotalFormatted}">1'094.90</td>
</tr> </tr>
</table> </table>
<!-- Footer Notes -->
<table class="footer-layout">
<tr>
<td class="footer-label">Informazioni</td>
<td class="footer-text" th:text="${paymentTermsText}">
Appena riceviamo il pagamento l'ordine entra nella coda di stampa. Grazie per la fiducia.
</td>
</tr>
<tr>
<td class="footer-label">Generale</td>
<td class="footer-text">
Si applicano le nostre condizioni generali di contratto. Verifica i dettagli dell'ordine al ricevimento. Per assistenza, rispondi alla nostra email di conferma.
</td> </td>
</tr> </tr>
</table> </table>
</div> </div>
<div class="qr-only-page"> <!-- QR Bill Page (only renders if QR data is passed) -->
<div class="qr-only-page" th:if="${qrBillSvg != null}">
<div class="qr-bill-bottom" th:utext="${qrBillSvg}"> <div class="qr-bill-bottom" th:utext="${qrBillSvg}">
</div> </div>
</div> </div>

View File

@@ -56,7 +56,7 @@ class OrderEmailListenerTest {
ReflectionTestUtils.setField(orderEmailListener, "adminMailEnabled", true); ReflectionTestUtils.setField(orderEmailListener, "adminMailEnabled", true);
ReflectionTestUtils.setField(orderEmailListener, "adminMailAddress", "admin@printcalculator.local"); ReflectionTestUtils.setField(orderEmailListener, "adminMailAddress", "admin@printcalculator.local");
ReflectionTestUtils.setField(orderEmailListener, "frontendBaseUrl", "https://tuosito.it"); ReflectionTestUtils.setField(orderEmailListener, "frontendBaseUrl", "https://3d-fab.ch");
} }
@Test @Test
@@ -76,7 +76,7 @@ class OrderEmailListenerTest {
assertEquals("John", customerData.get("customerName")); assertEquals("John", customerData.get("customerName"));
assertEquals(order.getId(), customerData.get("orderId")); assertEquals(order.getId(), customerData.get("orderId"));
assertEquals(order.getOrderNumber(), customerData.get("orderNumber")); assertEquals(order.getOrderNumber(), customerData.get("orderNumber"));
assertEquals("https://tuosito.it/ordine/" + order.getId(), customerData.get("orderDetailsUrl")); assertEquals("https://3d-fab.ch/co/" + order.getId(), customerData.get("orderDetailsUrl"));
assertEquals(order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")), customerData.get("orderDate")); assertEquals(order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")), customerData.get("orderDate"));
assertEquals("150.50", customerData.get("totalCost")); assertEquals("150.50", customerData.get("totalCost"));

2
db.sql
View File

@@ -552,7 +552,7 @@ CREATE TABLE IF NOT EXISTS payments
payment_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), payment_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid NOT NULL REFERENCES orders (order_id) ON DELETE CASCADE, order_id uuid NOT NULL REFERENCES orders (order_id) ON DELETE CASCADE,
method text NOT NULL CHECK (method IN ('QR_BILL', 'BANK_TRANSFER', 'TWINT', 'CARD', 'OTHER')), method text NOT NULL CHECK (method IN ('QR_BILL', 'TWINT', 'OTHER')),
status text NOT NULL CHECK (status IN ('PENDING', 'REPORTED', 'RECEIVED', 'FAILED', 'CANCELLED', 'REFUNDED')), status text NOT NULL CHECK (status IN ('PENDING', 'REPORTED', 'RECEIVED', 'FAILED', 'CANCELLED', 'REFUNDED')),
currency char(3) NOT NULL DEFAULT 'CHF', currency char(3) NOT NULL DEFAULT 'CHF',

View File

@@ -30,16 +30,12 @@ export const routes: Routes = [
loadComponent: () => import('./features/checkout/checkout.component').then(m => m.CheckoutComponent) loadComponent: () => import('./features/checkout/checkout.component').then(m => m.CheckoutComponent)
}, },
{ {
path: 'payment/:orderId', path: 'order/:orderId',
loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent) loadComponent: () => import('./features/order/order.component').then(m => m.OrderComponent)
}, },
{ {
path: 'ordine/:orderId', path: 'co/:orderId',
loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent) loadComponent: () => import('./features/order/order.component').then(m => m.OrderComponent)
},
{
path: 'order-confirmed/:orderId',
loadComponent: () => import('./features/order-confirmed/order-confirmed.component').then(m => m.OrderConfirmedComponent)
}, },
{ {
path: '', path: '',

View File

@@ -193,8 +193,7 @@ export class CheckoutComponent implements OnInit {
this.quoteService.createOrder(this.sessionId, orderRequest).subscribe({ this.quoteService.createOrder(this.sessionId, orderRequest).subscribe({
next: (order) => { next: (order) => {
console.log('Order created', order); this.router.navigate(['/order', order.id]);
this.router.navigate(['/payment', order.id]);
}, },
error: (err) => { error: (err) => {
console.error('Order creation failed', err); console.error('Order creation failed', err);

View File

@@ -28,7 +28,6 @@
</p> </p>
<ul class="calculator-list"> <ul class="calculator-list">
<li>{{ 'HOME.SEC_CALC_LIST_1' | translate }}</li> <li>{{ 'HOME.SEC_CALC_LIST_1' | translate }}</li>
<li>{{ 'HOME.SEC_CALC_LIST_2' | translate }}</li>
</ul> </ul>
</div> </div>
<app-card class="quote-card"> <app-card class="quote-card">

View File

@@ -1,52 +0,0 @@
<div class="container hero">
<h1>{{ 'ORDER_CONFIRMED.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'ORDER_CONFIRMED.SUBTITLE' | translate }}</p>
</div>
<div class="container">
<div class="confirmation-layout" *ngIf="order() as o">
<app-card class="status-card">
<div class="status-badge">{{ o.status === 'SHIPPED' ? ('TRACKING.STEP_SHIPPED' | translate) : ('ORDER_CONFIRMED.STATUS' | translate) }}</div>
<h2>{{ 'ORDER_CONFIRMED.HEADING' | translate }}</h2>
<p class="order-ref" *ngIf="orderNumber">
{{ 'ORDER_CONFIRMED.ORDER_REF' | translate }}: <strong>#{{ orderNumber }}</strong>
</p>
<div class="status-timeline">
<div class="timeline-step"
[class.active]="o.status === 'PENDING_PAYMENT' && o.paymentStatus !== 'REPORTED'"
[class.completed]="o.paymentStatus === 'REPORTED' || o.status !== 'PENDING_PAYMENT'">
<div class="circle">1</div>
<div class="label">{{ 'TRACKING.STEP_PENDING' | translate }}</div>
</div>
<div class="timeline-step"
[class.active]="o.paymentStatus === 'REPORTED' && o.status === 'PENDING_PAYMENT'"
[class.completed]="o.status === 'PAID' || o.status === 'IN_PRODUCTION' || o.status === 'SHIPPED' || o.status === 'COMPLETED'">
<div class="circle">2</div>
<div class="label">{{ 'TRACKING.STEP_REPORTED' | translate }}</div>
</div>
<div class="timeline-step"
[class.active]="o.status === 'PAID' || o.status === 'IN_PRODUCTION'"
[class.completed]="o.status === 'SHIPPED' || o.status === 'COMPLETED'">
<div class="circle">3</div>
<div class="label">{{ 'TRACKING.STEP_PRODUCTION' | translate }}</div>
</div>
<div class="timeline-step"
[class.active]="o.status === 'SHIPPED'"
[class.completed]="o.status === 'COMPLETED'">
<div class="circle">4</div>
<div class="label">{{ 'TRACKING.STEP_SHIPPED' | translate }}</div>
</div>
</div>
<div class="message-block">
<p>{{ 'ORDER_CONFIRMED.PROCESSING_TEXT' | translate }}</p>
<p>{{ 'ORDER_CONFIRMED.EMAIL_TEXT' | translate }}</p>
</div>
<div class="actions">
<app-button (click)="goHome()">{{ 'ORDER_CONFIRMED.BACK_HOME' | translate }}</app-button>
</div>
</app-card>
</div>
</div>

View File

@@ -1,159 +0,0 @@
.hero {
padding: var(--space-12) 0 var(--space-8);
text-align: center;
h1 {
font-size: 2.4rem;
margin-bottom: var(--space-2);
}
}
.subtitle {
font-size: 1.1rem;
color: var(--color-text-muted);
max-width: 720px;
margin: 0 auto;
}
.confirmation-layout {
max-width: 760px;
margin: 0 auto var(--space-12);
}
.status-badge {
display: inline-block;
padding: 0.35rem 0.65rem;
border-radius: 999px;
background: #eef8f0;
color: #136f2d;
font-weight: 700;
font-size: 0.85rem;
margin-bottom: var(--space-4);
}
h2 {
margin: 0 0 var(--space-3);
}
.order-ref {
margin: 0 0 var(--space-4);
color: var(--color-text-muted);
}
.message-block {
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-5);
margin-bottom: var(--space-6);
p {
margin: 0;
line-height: 1.45;
}
p + p {
margin-top: var(--space-3);
}
}
.actions {
max-width: 320px;
}
.status-timeline {
display: flex;
justify-content: space-between;
margin-bottom: var(--space-6);
position: relative;
&::before {
content: '';
position: absolute;
top: 15px;
left: 20px;
right: 20px;
height: 2px;
background: var(--color-border);
z-index: 1;
}
}
.timeline-step {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 2;
flex: 1;
text-align: center;
.circle {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--color-neutral-100);
border: 2px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
margin-bottom: var(--space-2);
color: var(--color-text-muted);
transition: all 0.3s ease;
}
.label {
font-size: 0.85rem;
color: var(--color-text-muted);
font-weight: 500;
}
&.active {
.circle {
border-color: var(--color-primary);
background: var(--color-primary-light);
color: var(--color-primary);
}
.label {
color: var(--color-text);
font-weight: 600;
}
}
&.completed {
.circle {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
.label {
color: var(--color-text);
}
}
}
@media (max-width: 600px) {
.status-timeline {
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
&::before {
top: 10px;
bottom: 10px;
left: 15px;
width: 2px;
height: auto;
}
.timeline-step {
flex-direction: row;
gap: var(--space-3);
.circle {
margin-bottom: 0;
}
}
}
}

View File

@@ -1,50 +0,0 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
@Component({
selector: 'app-order-confirmed',
standalone: true,
imports: [CommonModule, TranslateModule, AppButtonComponent, AppCardComponent],
templateUrl: './order-confirmed.component.html',
styleUrl: './order-confirmed.component.scss'
})
export class OrderConfirmedComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private quoteService = inject(QuoteEstimatorService);
orderId: string | null = null;
orderNumber: string | null = null;
order = signal<any>(null);
ngOnInit(): void {
this.orderId = this.route.snapshot.paramMap.get('orderId');
if (!this.orderId) {
return;
}
this.orderNumber = this.extractOrderNumber(this.orderId);
this.quoteService.getOrder(this.orderId).subscribe({
next: (order) => {
this.order.set(order);
this.orderNumber = order?.orderNumber ?? this.orderNumber;
},
error: () => {
// Keep fallback derived from UUID when API is unavailable.
}
});
}
goHome(): void {
this.router.navigate(['/']);
}
private extractOrderNumber(orderId: string): string {
return orderId.split('-')[0];
}
}

View File

@@ -0,0 +1,151 @@
<div class="container hero">
<h1>
{{ 'TRACKING.TITLE' | translate }}
<ng-container *ngIf="order()">
<br/><span class="order-id-title">#{{ getDisplayOrderNumber(order()) }}</span>
</ng-container>
</h1>
<p class="subtitle">{{ 'TRACKING.SUBTITLE' | translate }}</p>
</div>
<div class="container">
<ng-container *ngIf="order() as o">
<div class="status-timeline mb-6">
<div class="timeline-step"
[class.active]="o.status === 'PENDING_PAYMENT' && o.paymentStatus !== 'REPORTED'"
[class.completed]="o.paymentStatus === 'REPORTED' || o.status !== 'PENDING_PAYMENT'">
<div class="circle">1</div>
<div class="label">{{ 'TRACKING.STEP_PENDING' | translate }}</div>
</div>
<div class="timeline-step"
[class.active]="o.paymentStatus === 'REPORTED' && o.status === 'PENDING_PAYMENT'"
[class.completed]="o.status === 'PAID' || o.status === 'IN_PRODUCTION' || o.status === 'SHIPPED' || o.status === 'COMPLETED'">
<div class="circle">2</div>
<div class="label">{{ 'TRACKING.STEP_REPORTED' | translate }}</div>
</div>
<div class="timeline-step"
[class.active]="o.status === 'PAID' || o.status === 'IN_PRODUCTION'"
[class.completed]="o.status === 'SHIPPED' || o.status === 'COMPLETED'">
<div class="circle">3</div>
<div class="label">{{ 'TRACKING.STEP_PRODUCTION' | translate }}</div>
</div>
<div class="timeline-step"
[class.active]="o.status === 'SHIPPED'"
[class.completed]="o.status === 'COMPLETED'">
<div class="circle">4</div>
<div class="label">{{ 'TRACKING.STEP_SHIPPED' | translate }}</div>
</div>
</div>
<ng-container *ngIf="o.status === 'PENDING_PAYMENT'">
<app-card class="mb-6 status-reported-card" *ngIf="o.paymentStatus === 'REPORTED'">
<div class="status-content text-center">
<h3>{{ 'PAYMENT.STATUS_REPORTED_TITLE' | translate }}</h3>
<p>{{ 'PAYMENT.STATUS_REPORTED_DESC' | translate }}</p>
</div>
</app-card>
<div class="payment-layout">
<div class="payment-main">
<app-card class="mb-6">
<div class="card-header-simple">
<h3>{{ 'PAYMENT.METHOD' | translate }}</h3>
</div>
<div class="payment-selection">
<div class="methods-grid">
<div class="type-option" [class.selected]="selectedPaymentMethod === 'twint'" (click)="selectPayment('twint')">
<span class="method-name">{{ 'PAYMENT.METHOD_TWINT' | translate }}</span>
</div>
<div class="type-option" [class.selected]="selectedPaymentMethod === 'bill'" (click)="selectPayment('bill')">
<span class="method-name">{{ 'PAYMENT.METHOD_BANK' | translate }}</span>
</div>
</div>
</div>
<div class="payment-details fade-in text-center" *ngIf="selectedPaymentMethod === 'twint'">
<div class="details-header">
<h4>{{ 'PAYMENT.TWINT_TITLE' | translate }}</h4>
</div>
<div class="qr-placeholder">
<img *ngIf="twintQrUrl()" class="twint-qr" [src]="getTwintQrUrl()" (error)="onTwintQrError()" alt="TWINT payment QR" />
<p>{{ 'PAYMENT.TWINT_DESC' | translate }}</p>
<p class="billing-hint">{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}</p>
<div class="twint-mobile-action">
<app-button (click)="openTwintPayment()" [fullWidth]="true">
{{ 'PAYMENT.TWINT_OPEN' | translate }}
</app-button>
</div>
<p class="amount">{{ 'PAYMENT.TOTAL' | translate }}: {{ o.totalChf | currency:'CHF' }}</p>
</div>
</div>
<div class="payment-details fade-in" *ngIf="selectedPaymentMethod === 'bill'">
<div class="details-header">
<h4>{{ 'PAYMENT.BANK_TITLE' | translate }}</h4>
</div>
<div class="bank-details">
<p><strong>{{ 'PAYMENT.BANK_OWNER' | translate }}:</strong> Küng, Joe</p>
<p><strong>{{ 'PAYMENT.BANK_IBAN' | translate }}:</strong> CH74 0900 0000 1548 2158 1</p>
<p><strong>{{ 'PAYMENT.BANK_REF' | translate }}:</strong> {{ getDisplayOrderNumber(o) }}</p>
<p class="billing-hint">{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}</p>
<div class="qr-bill-actions">
<app-button (click)="downloadInvoice()">
{{ 'PAYMENT.DOWNLOAD_QR' | translate }}
</app-button>
</div>
</div>
</div>
<div class="actions">
<app-button variant="outline" (click)="completeOrder()" [disabled]="!selectedPaymentMethod || o.paymentStatus === 'REPORTED'" [fullWidth]="true">
{{ o.paymentStatus === 'REPORTED' ? ('PAYMENT.IN_VERIFICATION' | translate) : ('PAYMENT.CONFIRM' | translate) }}
</app-button>
</div>
</app-card>
</div>
<div class="payment-summary">
<app-card class="sticky-card">
<div class="card-header-simple">
<h3>{{ 'PAYMENT.SUMMARY_TITLE' | translate }}</h3>
<p class="order-id">#{{ getDisplayOrderNumber(o) }}</p>
</div>
<div class="summary-totals">
<div class="total-row">
<span>{{ 'PAYMENT.SUBTOTAL' | translate }}</span>
<span>{{ o.subtotalChf | currency:'CHF' }}</span>
</div>
<div class="total-row">
<span>{{ 'PAYMENT.SHIPPING' | translate }}</span>
<span>{{ o.shippingCostChf | currency:'CHF' }}</span>
</div>
<div class="total-row">
<span>{{ 'PAYMENT.SETUP_FEE' | translate }}</span>
<span>{{ o.setupCostChf | currency:'CHF' }}</span>
</div>
<div class="grand-total-row">
<span>{{ 'PAYMENT.TOTAL' | translate }}</span>
<span>{{ o.totalChf | currency:'CHF' }}</span>
</div>
</div>
</app-card>
</div>
</div>
</ng-container>
</ng-container>
<div *ngIf="loading()" class="loading-state">
<app-card>
<p>{{ 'PAYMENT.LOADING' | translate }}</p>
</app-card>
</div>
<div *ngIf="error()" class="error-message">
<app-card>
<p>{{ error() }}</p>
</app-card>
</div>
</div>

View File

@@ -234,3 +234,101 @@
margin-top: var(--space-12); margin-top: var(--space-12);
text-align: center; text-align: center;
} }
.status-timeline {
display: flex;
justify-content: space-between;
margin-bottom: var(--space-8);
position: relative;
/* padding: var(--space-6); */ /* Removed if it was here to match non-card layout */
&::before {
content: '';
position: absolute;
top: 15px;
left: 12.5%;
right: 12.5%;
height: 2px;
background: var(--color-border);
z-index: 1;
}
}
.timeline-step {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 2;
flex: 1;
text-align: center;
.circle {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--color-neutral-100);
border: 2px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
margin-bottom: var(--space-2);
color: var(--color-text-muted);
transition: all 0.3s ease;
}
.label {
font-size: 0.85rem;
color: var(--color-text-muted);
font-weight: 500;
}
&.active {
.circle {
border-color: var(--color-brand);
background: var(--color-bg);
color: var(--color-brand);
}
.label {
color: var(--color-text);
font-weight: 600;
}
}
&.completed {
.circle {
background: var(--color-brand);
border-color: var(--color-brand);
color: white;
}
.label {
color: var(--color-text);
}
}
}
@media (max-width: 600px) {
.status-timeline {
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
&::before {
top: 10px;
bottom: 10px;
left: 15px;
width: 2px;
height: auto;
}
.timeline-step {
flex-direction: row;
gap: var(--space-3);
.circle {
margin-bottom: 0;
}
}
}
}

View File

@@ -8,19 +8,19 @@ import { TranslateModule } from '@ngx-translate/core';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
@Component({ @Component({
selector: 'app-payment', selector: 'app-order',
standalone: true, standalone: true,
imports: [CommonModule, AppButtonComponent, AppCardComponent, TranslateModule], imports: [CommonModule, AppButtonComponent, AppCardComponent, TranslateModule],
templateUrl: './payment.component.html', templateUrl: './order.component.html',
styleUrl: './payment.component.scss' styleUrl: './order.component.scss'
}) })
export class PaymentComponent implements OnInit { export class OrderComponent implements OnInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
private router = inject(Router); private router = inject(Router);
private quoteService = inject(QuoteEstimatorService); private quoteService = inject(QuoteEstimatorService);
orderId: string | null = null; orderId: string | null = null;
selectedPaymentMethod: 'twint' | 'bill' | null = null; selectedPaymentMethod: 'twint' | 'bill' | null = 'twint';
order = signal<any>(null); order = signal<any>(null);
loading = signal(true); loading = signal(true);
error = signal<string | null>(null); error = signal<string | null>(null);

View File

@@ -1,129 +0,0 @@
<div class="container hero">
<h1>{{ 'PAYMENT.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'CHECKOUT.SUBTITLE' | translate }}</p>
</div>
<div class="container">
<div class="payment-layout" *ngIf="order() as o">
<div class="payment-main">
<app-card class="mb-6 status-reported-card" *ngIf="o.paymentStatus === 'REPORTED'">
<div class="status-content text-center">
<h3>{{ 'PAYMENT.STATUS_REPORTED_TITLE' | translate }}</h3>
<p>{{ 'PAYMENT.STATUS_REPORTED_DESC' | translate }}</p>
</div>
</app-card>
<app-card class="mb-6">
<div class="card-header-simple">
<h3>{{ 'PAYMENT.METHOD' | translate }}</h3>
</div>
<div class="payment-selection">
<div class="methods-grid">
<div
class="type-option"
[class.selected]="selectedPaymentMethod === 'twint'"
(click)="selectPayment('twint')">
<span class="method-name">{{ 'PAYMENT.METHOD_TWINT' | translate }}</span>
</div>
<div
class="type-option"
[class.selected]="selectedPaymentMethod === 'bill'"
(click)="selectPayment('bill')">
<span class="method-name">{{ 'PAYMENT.METHOD_BANK' | translate }}</span>
</div>
</div>
</div>
<div class="payment-details fade-in text-center" *ngIf="selectedPaymentMethod === 'twint'">
<div class="details-header">
<h4>{{ 'PAYMENT.TWINT_TITLE' | translate }}</h4>
</div>
<div class="qr-placeholder">
<img
*ngIf="twintQrUrl()"
class="twint-qr"
[src]="getTwintQrUrl()"
(error)="onTwintQrError()"
alt="TWINT payment QR" />
<p>{{ 'PAYMENT.TWINT_DESC' | translate }}</p>
<p class="billing-hint">{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}</p>
<div class="twint-mobile-action">
<app-button variant="outline" (click)="openTwintPayment()" [fullWidth]="true">
{{ 'PAYMENT.TWINT_OPEN' | translate }}
</app-button>
</div>
<p class="amount">{{ 'PAYMENT.TOTAL' | translate }}: {{ o.totalChf | currency:'CHF' }}</p>
</div>
</div>
<div class="payment-details fade-in" *ngIf="selectedPaymentMethod === 'bill'">
<div class="details-header">
<h4>{{ 'PAYMENT.BANK_TITLE' | translate }}</h4>
</div>
<div class="bank-details">
<p><strong>{{ 'PAYMENT.BANK_OWNER' | translate }}:</strong> 3D Fab Switzerland</p>
<p><strong>{{ 'PAYMENT.BANK_IBAN' | translate }}:</strong> CH98 0000 0000 0000 0000 0</p>
<p><strong>{{ 'PAYMENT.BANK_REF' | translate }}:</strong> {{ getDisplayOrderNumber(o) }}</p>
<p class="billing-hint">{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}</p>
<div class="qr-bill-actions">
<app-button variant="outline" (click)="downloadInvoice()">
{{ 'PAYMENT.DOWNLOAD_QR' | translate }}
</app-button>
</div>
</div>
</div>
<div class="actions">
<app-button
(click)="completeOrder()"
[disabled]="!selectedPaymentMethod || o.paymentStatus === 'REPORTED'"
[fullWidth]="true">
{{ o.paymentStatus === 'REPORTED' ? ('PAYMENT.IN_VERIFICATION' | translate) : ('PAYMENT.CONFIRM' | translate) }}
</app-button>
</div>
</app-card>
</div>
<div class="payment-summary">
<app-card class="sticky-card">
<div class="card-header-simple">
<h3>{{ 'PAYMENT.SUMMARY_TITLE' | translate }}</h3>
<p class="order-id">#{{ getDisplayOrderNumber(o) }}</p>
</div>
<div class="summary-totals">
<div class="total-row">
<span>{{ 'PAYMENT.SUBTOTAL' | translate }}</span>
<span>{{ o.subtotalChf | currency:'CHF' }}</span>
</div>
<div class="total-row">
<span>{{ 'PAYMENT.SHIPPING' | translate }}</span>
<span>{{ o.shippingCostChf | currency:'CHF' }}</span>
</div>
<div class="total-row">
<span>{{ 'PAYMENT.SETUP_FEE' | translate }}</span>
<span>{{ o.setupCostChf | currency:'CHF' }}</span>
</div>
<div class="grand-total-row">
<span>{{ 'PAYMENT.TOTAL' | translate }}</span>
<span>{{ o.totalChf | currency:'CHF' }}</span>
</div>
</div>
</app-card>
</div>
</div>
<div *ngIf="loading()" class="loading-state">
<app-card>
<p>{{ 'PAYMENT.LOADING' | translate }}</p>
</app-card>
</div>
<div *ngIf="error()" class="error-message">
<app-card>
<p>{{ error() }}</p>
</app-card>
</div>
</div>

View File

@@ -1,12 +1,18 @@
<div class="container hero"> <section class="wip-section">
<h1>{{ 'SHOP.TITLE' | translate }}</h1> <div class="container">
<p class="subtitle">{{ 'SHOP.SUBTITLE' | translate }}</p> <div class="wip-card">
<p class="wip-eyebrow">{{ 'SHOP.WIP_EYEBROW' | translate }}</p>
<h1>{{ 'SHOP.WIP_TITLE' | translate }}</h1>
<p class="wip-subtitle">{{ 'SHOP.WIP_SUBTITLE' | translate }}</p>
<div class="wip-actions">
<app-button variant="primary" routerLink="/calculator/basic">
{{ 'SHOP.WIP_CTA_CALC' | translate }}
</app-button>
</div> </div>
<div class="container"> <p class="wip-return-later">{{ 'SHOP.WIP_RETURN_LATER' | translate }}</p>
<div class="grid"> <p class="wip-note">{{ 'SHOP.WIP_NOTE' | translate }}</p>
@for (product of products(); track product.id) {
<app-product-card [product]="product"></app-product-card>
}
</div> </div>
</div> </div>
</section>

View File

@@ -1,7 +1,72 @@
.hero { padding: var(--space-8) 0; text-align: center; } .wip-section {
.subtitle { color: var(--color-text-muted); margin-bottom: var(--space-8); } position: relative;
.grid { padding: var(--space-12) 0;
display: grid; background-color: var(--color-bg);
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
gap: var(--space-6);
.wip-card {
max-width: 760px;
margin: 0 auto;
padding: clamp(1.4rem, 3vw, 2.4rem);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
background: rgba(255, 255, 255, 0.95);
box-shadow: var(--shadow-lg);
text-align: center;
}
.wip-eyebrow {
display: inline-block;
margin-bottom: var(--space-3);
padding: 0.3rem 0.7rem;
border-radius: 999px;
border: 1px solid rgba(16, 24, 32, 0.14);
font-size: 0.78rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-secondary-600);
background: rgba(250, 207, 10, 0.28);
}
h1 {
font-size: clamp(1.7rem, 4vw, 2.5rem);
margin-bottom: var(--space-4);
color: var(--color-text);
}
.wip-subtitle {
max-width: 60ch;
margin: 0 auto var(--space-8);
color: var(--color-text-muted);
}
.wip-actions {
display: flex;
gap: var(--space-4);
justify-content: center;
flex-wrap: wrap;
}
.wip-note {
margin: var(--space-4) auto 0;
max-width: 62ch;
font-size: 0.95rem;
color: var(--color-secondary-600);
}
.wip-return-later {
margin: var(--space-6) 0 0;
font-weight: 600;
color: var(--color-secondary-600);
}
@media (max-width: 640px) {
.wip-section {
padding: var(--space-10) 0;
}
.wip-actions {
flex-direction: column;
align-items: stretch;
}
} }

View File

@@ -1,22 +1,14 @@
import { Component, signal } from '@angular/core'; import { Component } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { ShopService, Product } from './services/shop.service'; import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { ProductCardComponent } from './components/product-card/product-card.component';
@Component({ @Component({
selector: 'app-shop-page', selector: 'app-shop-page',
standalone: true, standalone: true,
imports: [CommonModule, TranslateModule, ProductCardComponent], imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent],
templateUrl: './shop-page.component.html', templateUrl: './shop-page.component.html',
styleUrl: './shop-page.component.scss' styleUrl: './shop-page.component.scss'
}) })
export class ShopPageComponent { export class ShopPageComponent {}
products = signal<Product[]>([]);
constructor(private shopService: ShopService) {
this.shopService.getProducts().subscribe(data => {
this.products.set(data);
});
}
}

View File

@@ -73,8 +73,15 @@
"SHOP": { "SHOP": {
"TITLE": "Technical solutions", "TITLE": "Technical solutions",
"SUBTITLE": "Ready-made products solving practical problems", "SUBTITLE": "Ready-made products solving practical problems",
"WIP_EYEBROW": "Work in progress",
"WIP_TITLE": "Shop under construction",
"WIP_SUBTITLE": "We are building a curated technical shop with products that are genuinely useful and field-tested.",
"WIP_CTA_CALC": "Check our calculator",
"WIP_RETURN_LATER": "Come back soon",
"WIP_NOTE": "We care about doing this right. In the meantime, you can get instant pricing and lead time from our calculator.",
"ADD_CART": "Add to Cart", "ADD_CART": "Add to Cart",
"BACK": "Back to Shop" "BACK": "Back to Shop",
"NOT_FOUND": "Product not found."
}, },
"ABOUT": { "ABOUT": {
"TITLE": "About Us", "TITLE": "About Us",
@@ -188,7 +195,7 @@
"BANK_REF": "Reference", "BANK_REF": "Reference",
"BILLING_INFO_HINT": "Add the same information used in billing.", "BILLING_INFO_HINT": "Add the same information used in billing.",
"DOWNLOAD_QR": "Download QR-Invoice (PDF)", "DOWNLOAD_QR": "Download QR-Invoice (PDF)",
"CONFIRM": "Confirm Order", "CONFIRM": "I have completed the payment",
"SUMMARY_TITLE": "Order Summary", "SUMMARY_TITLE": "Order Summary",
"SUBTOTAL": "Subtotal", "SUBTOTAL": "Subtotal",
"SHIPPING": "Shipping", "SHIPPING": "Shipping",
@@ -197,13 +204,15 @@
"LOADING": "Loading order details...", "LOADING": "Loading order details...",
"METHOD_TWINT": "TWINT", "METHOD_TWINT": "TWINT",
"METHOD_BANK": "Bank Transfer / QR", "METHOD_BANK": "Bank Transfer / QR",
"STATUS_REPORTED_TITLE": "Payment Reported", "STATUS_REPORTED_TITLE": "Order in progress",
"STATUS_REPORTED_DESC": "We are verifying your transaction. Your order will move to production as soon as the payment is confirmed.", "STATUS_REPORTED_DESC": "We have registered your operation. Your order will soon move to production.",
"IN_VERIFICATION": "Verifying Payment" "IN_VERIFICATION": "Payment Reported"
}, },
"TRACKING": { "TRACKING": {
"TITLE": "Order Status",
"SUBTITLE": "Check the status of your order and manage the payment if necessary.",
"STEP_PENDING": "Pending", "STEP_PENDING": "Pending",
"STEP_REPORTED": "Verifying", "STEP_REPORTED": "Received",
"STEP_PRODUCTION": "Production", "STEP_PRODUCTION": "Production",
"STEP_SHIPPED": "Shipped" "STEP_SHIPPED": "Shipped"
}, },

View File

@@ -15,14 +15,13 @@
"HERO_EYEBROW": "Stampa 3D tecnica per aziende, freelance e maker", "HERO_EYEBROW": "Stampa 3D tecnica per aziende, freelance e maker",
"HERO_TITLE": "Prezzo e tempi in pochi secondi.<br>Dal file 3D al pezzo finito.", "HERO_TITLE": "Prezzo e tempi in pochi secondi.<br>Dal file 3D al pezzo finito.",
"HERO_LEAD": "Il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.", "HERO_LEAD": "Il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.",
"HERO_SUBTITLE": "Realizziamo pezzi unici e produzioni in serie. Se hai il file, il preventivo è istantaneo. Se devi ancora crearlo, il nostro team di design lo progetterà per te.", "HERO_SUBTITLE": "Offriamo anche servizi di cad, per pezzi personalizzati!",
"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",
"SEC_CALC_TITLE": "Preventivo immediato in pochi secondi", "SEC_CALC_TITLE": "Prezzo corretto in pochi secondi",
"SEC_CALC_SUBTITLE": "Nessuna registrazione necessaria. Non salviamo il tuo file fino al momento dell'ordine e la stima è effettuata tramite vero slicing.", "SEC_CALC_SUBTITLE": "Nessuna registrazione necessaria. La stima è effettuata tramite vero slicing.",
"SEC_CALC_LIST_1": "Formati supportati: STL, 3MF, STEP, OBJ", "SEC_CALC_LIST_1": "Formati supportati: STL, 3MF, STEP, OBJ",
"SEC_CALC_LIST_2": "Qualità: bozza, standard, alta definizione",
"CARD_CALC_EYEBROW": "Calcolo automatico", "CARD_CALC_EYEBROW": "Calcolo automatico",
"CARD_CALC_TITLE": "Prezzo e tempi in un click", "CARD_CALC_TITLE": "Prezzo e tempi in un click",
"CARD_CALC_TAG": "Senza registrazione", "CARD_CALC_TAG": "Senza registrazione",
@@ -137,6 +136,12 @@
"SHOP": { "SHOP": {
"TITLE": "Soluzioni tecniche", "TITLE": "Soluzioni tecniche",
"SUBTITLE": "Prodotti pronti che risolvono problemi pratici", "SUBTITLE": "Prodotti pronti che risolvono problemi pratici",
"WIP_EYEBROW": "Work in progress",
"WIP_TITLE": "Shop in allestimento",
"WIP_SUBTITLE": "Stiamo preparando uno shop con prodotti selezionati e funzionalità di creazione automatica!",
"WIP_CTA_CALC": "Vai al calcolatore",
"WIP_RETURN_LATER": "Torna tra un po'",
"WIP_NOTE": "Ci teniamo a fare le cose fatte bene: nel frattempo puoi calcolare subito prezzo e tempi di un file 3d con il nostro calcolatore.",
"ADD_CART": "Aggiungi al Carrello", "ADD_CART": "Aggiungi al Carrello",
"BACK": "Torna allo Shop", "BACK": "Torna allo Shop",
"NOT_FOUND": "Prodotto non trovato." "NOT_FOUND": "Prodotto non trovato."
@@ -259,7 +264,7 @@
"BANK_REF": "Riferimento", "BANK_REF": "Riferimento",
"BILLING_INFO_HINT": "Aggiungi le informazioni uguali a quelle della fatturazione.", "BILLING_INFO_HINT": "Aggiungi le informazioni uguali a quelle della fatturazione.",
"DOWNLOAD_QR": "Scarica QR-Fattura (PDF)", "DOWNLOAD_QR": "Scarica QR-Fattura (PDF)",
"CONFIRM": "Conferma Ordine", "CONFIRM": "Ho completato il pagamento",
"SUMMARY_TITLE": "Riepilogo Ordine", "SUMMARY_TITLE": "Riepilogo Ordine",
"SUBTOTAL": "Subtotale", "SUBTOTAL": "Subtotale",
"SHIPPING": "Spedizione", "SHIPPING": "Spedizione",
@@ -268,11 +273,13 @@
"LOADING": "Caricamento dettagli ordine...", "LOADING": "Caricamento dettagli ordine...",
"METHOD_TWINT": "TWINT", "METHOD_TWINT": "TWINT",
"METHOD_BANK": "Fattura QR / Bonifico", "METHOD_BANK": "Fattura QR / Bonifico",
"STATUS_REPORTED_TITLE": "Abbiamo ricevuto la tua segnalazione", "STATUS_REPORTED_TITLE": "Ordine in lavorazione",
"STATUS_REPORTED_DESC": "Stiamo verificando la transazione. Il tuo ordine passerà in produzione non appena l'accredito sarà confermato.", "STATUS_REPORTED_DESC": "Abbiamo registrato la tua operazione. L'ordine entrerà a breve in produzione.",
"IN_VERIFICATION": "Pagamento in verifica" "IN_VERIFICATION": "Pagamento Segnalato"
}, },
"TRACKING": { "TRACKING": {
"TITLE": "Stato dell'Ordine",
"SUBTITLE": "Consulta lo stato del tuo ordine e gestisci il pagamento se necessario.",
"STEP_PENDING": "In attesa", "STEP_PENDING": "In attesa",
"STEP_REPORTED": "In verifica", "STEP_REPORTED": "In verifica",
"STEP_PRODUCTION": "In Produzione", "STEP_PRODUCTION": "In Produzione",