produzione 1 #9
@@ -26,6 +26,7 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
implementation 'xyz.capybara:clamav-client:2.1.2'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
|
||||
@@ -122,6 +122,6 @@ public class OrderEmailListener {
|
||||
|
||||
private String buildOrderDetailsUrl(Order order) {
|
||||
String baseUrl = frontendBaseUrl == null ? "" : frontendBaseUrl.replaceAll("/+$", "");
|
||||
return baseUrl + "/ordine/" + order.getId();
|
||||
return baseUrl + "/co/" + order.getId();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@ public class PaymentService {
|
||||
|
||||
Payment payment = new Payment();
|
||||
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.setCurrency(order.getCurrency() != null ? order.getCurrency() : "CHF");
|
||||
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));
|
||||
|
||||
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())) {
|
||||
throw new IllegalStateException("Payment is not in PENDING state. Current state: " + payment.getStatus());
|
||||
@@ -61,9 +62,10 @@ public class PaymentService {
|
||||
|
||||
payment.setStatus("REPORTED");
|
||||
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);
|
||||
|
||||
|
||||
@@ -3,17 +3,28 @@
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<style>
|
||||
@page invoice { size: A4; margin: 14mm 14mm 12mm 14mm; }
|
||||
@page qrpage { size: A4; margin: 0; }
|
||||
@page invoice {
|
||||
size: A4;
|
||||
margin: 12mm 12mm 12mm 12mm;
|
||||
}
|
||||
|
||||
@page qrpage {
|
||||
size: A4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
*, *:before, *:after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
page: invoice;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 9.5pt;
|
||||
font-size: 8.5pt;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
color: #191919;
|
||||
color: #000;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@@ -22,213 +33,207 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.top-layout {
|
||||
/* Top Header Layout */
|
||||
.header-layout {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 8mm;
|
||||
table-layout: fixed;
|
||||
margin-bottom: 25mm;
|
||||
}
|
||||
|
||||
.top-layout td {
|
||||
.header-layout td {
|
||||
vertical-align: top;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.doc-title {
|
||||
font-size: 18pt;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1.5mm 0;
|
||||
letter-spacing: 0.2px;
|
||||
.logo-block {
|
||||
width: 33%;
|
||||
font-size: 20pt;
|
||||
font-weight: bold;
|
||||
color: #005eb8; /* Brand blue similar to reference */
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.doc-subtitle {
|
||||
color: #4b4b4b;
|
||||
font-size: 10pt;
|
||||
.logo-d {
|
||||
font-style: italic;
|
||||
color: #d22630;
|
||||
font-size: 22pt;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.seller-block {
|
||||
width: 33%;
|
||||
font-size: 9pt;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.website-block {
|
||||
width: 33%;
|
||||
text-align: right;
|
||||
line-height: 1.45;
|
||||
width: 42%;
|
||||
font-size: 9pt;
|
||||
}
|
||||
|
||||
.seller-name {
|
||||
font-size: 11pt;
|
||||
font-weight: 700;
|
||||
/* Document Title */
|
||||
.doc-title {
|
||||
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%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 8mm;
|
||||
table-layout: fixed;
|
||||
margin-bottom: 15mm;
|
||||
}
|
||||
|
||||
.meta-layout td {
|
||||
.details-layout td {
|
||||
vertical-align: top;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.order-details {
|
||||
width: 60%;
|
||||
padding-right: 5mm;
|
||||
.meta-container {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.customer-box {
|
||||
width: 40%;
|
||||
background: #f7f7f7;
|
||||
border: 1px solid #e2e2e2;
|
||||
padding: 3mm 3.2mm;
|
||||
.customer-container {
|
||||
width: 50%;
|
||||
font-size: 10pt;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.box-title {
|
||||
font-size: 8.8pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: #5a5a5a;
|
||||
margin-bottom: 2mm;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.details-table {
|
||||
width: 100%;
|
||||
.meta-table {
|
||||
border-collapse: collapse;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.details-table td {
|
||||
padding: 1.1mm 0;
|
||||
border-bottom: 1px solid #ececec;
|
||||
.meta-table td {
|
||||
padding: 1.5mm 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.details-label {
|
||||
color: #636363;
|
||||
width: 56%;
|
||||
white-space: nowrap;
|
||||
padding-right: 3mm;
|
||||
.meta-label {
|
||||
width: 45mm;
|
||||
padding-right: 2mm;
|
||||
}
|
||||
|
||||
.details-value {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
.meta-value {
|
||||
/* allow wrapping just in case */
|
||||
}
|
||||
|
||||
/* Line Items Table */
|
||||
.line-items {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
margin-top: 3mm;
|
||||
border-top: 1px solid #cfcfcf;
|
||||
margin-top: 5mm;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.line-items th,
|
||||
.line-items td {
|
||||
border-bottom: 1px solid #dedede;
|
||||
padding: 2.4mm 2mm;
|
||||
padding: 1.5mm 0;
|
||||
vertical-align: top;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.line-items th {
|
||||
text-align: left;
|
||||
font-weight: 700;
|
||||
background: #f2f2f2;
|
||||
color: #2c2c2c;
|
||||
font-size: 9pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.25px;
|
||||
font-weight: normal;
|
||||
border-bottom: 1pt solid #000;
|
||||
}
|
||||
|
||||
.line-items th:nth-child(1),
|
||||
.line-items td:nth-child(1) {
|
||||
width: 50%;
|
||||
.line-items tbody td {
|
||||
border-bottom: 0.5pt solid #e0e0e0;
|
||||
}
|
||||
|
||||
.line-items th:nth-child(2),
|
||||
.line-items td:nth-child(2) {
|
||||
width: 10%;
|
||||
.line-items tbody tr:last-child td {
|
||||
border-bottom: 1pt solid #000;
|
||||
}
|
||||
|
||||
.line-items th.center,
|
||||
.line-items td.center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.line-items th.right,
|
||||
.line-items td.right {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.line-items th:nth-child(3),
|
||||
.line-items td:nth-child(3) {
|
||||
width: 20%;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
.col-desc { width: 45%; }
|
||||
.col-qty { width: 10%; }
|
||||
.col-price { width: 22%; }
|
||||
.col-total { width: 23%; }
|
||||
|
||||
.item-desc {
|
||||
padding-right: 4mm;
|
||||
}
|
||||
|
||||
.line-items th:nth-child(4),
|
||||
.line-items td:nth-child(4) {
|
||||
width: 20%;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-layout {
|
||||
/* Totals Block */
|
||||
.totals-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 6mm;
|
||||
table-layout: fixed;
|
||||
margin-top: 0;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.summary-layout td {
|
||||
vertical-align: top;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.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-table td {
|
||||
padding: 1.5mm 0;
|
||||
border-bottom: 0.5pt solid #000;
|
||||
}
|
||||
|
||||
.totals-label {
|
||||
text-align: left;
|
||||
color: #4a4a4a;
|
||||
}
|
||||
|
||||
.totals-value {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.total-strong td {
|
||||
font-size: 10.5pt;
|
||||
font-weight: 700;
|
||||
padding-top: 2mm;
|
||||
border-top: 1px solid #cfcfcf;
|
||||
.totals-table tr.no-border td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.due-row td {
|
||||
font-size: 10pt;
|
||||
font-weight: 700;
|
||||
border-top: 1px solid #cfcfcf;
|
||||
padding-top: 2.2mm;
|
||||
.summary-notes {
|
||||
margin-top: 4mm;
|
||||
padding-bottom: 4mm;
|
||||
border-bottom: 1pt solid #000;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
page: qrpage;
|
||||
position: relative;
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
page-break-before: always;
|
||||
}
|
||||
@@ -237,14 +242,14 @@
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 210mm;
|
||||
width: 100%;
|
||||
height: 105mm;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.qr-bill-bottom svg {
|
||||
width: 210mm !important;
|
||||
width: 100% !important;
|
||||
height: 105mm !important;
|
||||
display: block;
|
||||
}
|
||||
@@ -253,104 +258,120 @@
|
||||
<body>
|
||||
<div class="invoice-page">
|
||||
|
||||
<table class="top-layout">
|
||||
<!-- Header -->
|
||||
<table class="header-layout">
|
||||
<tr>
|
||||
<td>
|
||||
<div class="doc-title">Conferma ordine</div>
|
||||
<div class="doc-subtitle">Ricevuta semplificata</div>
|
||||
<td class="logo-block">
|
||||
3D-fab.ch
|
||||
</td>
|
||||
<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="${sellerAddressLine2}">Sede Bienne, Svizzera</div>
|
||||
<div th:text="${sellerEmail}">info@3dfab.ch</div>
|
||||
</td>
|
||||
<td class="website-block">
|
||||
www.3d-fab.ch
|
||||
</td>
|
||||
</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>
|
||||
<td class="order-details">
|
||||
<table class="details-table">
|
||||
<td class="meta-container">
|
||||
<table class="meta-table">
|
||||
<tr>
|
||||
<td class="details-label">Data ordine / fattura</td>
|
||||
<td class="details-value" th:text="${invoiceDate}">2026-02-13</td>
|
||||
<td class="meta-label">Data dell'ordine / fattura</td>
|
||||
<td class="meta-value" th:text="${invoiceDate}">07.03.2025</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="details-label">Numero documento</td>
|
||||
<td class="details-value" th:text="${invoiceNumber}">INV-2026-000123</td>
|
||||
<td class="meta-label">Numero documento</td>
|
||||
<td class="meta-value" th:text="${invoiceNumber}">INV-2026-000123</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="details-label">Data di scadenza</td>
|
||||
<td class="details-value" th:text="${dueDate}">2026-02-20</td>
|
||||
<td class="meta-label">Data di scadenza</td>
|
||||
<td class="meta-value" th:text="${dueDate}">07.03.2025</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="details-label">Valuta</td>
|
||||
<td class="details-value">CHF</td>
|
||||
<td class="meta-label">Metodo di pagamento</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>
|
||||
</table>
|
||||
</td>
|
||||
<td class="customer-box">
|
||||
<div class="box-title">Cliente</div>
|
||||
<div th:text="${buyerDisplayName}">Cliente SA</div>
|
||||
<div th:text="${buyerAddressLine1}">Via Cliente 7</div>
|
||||
<div th:text="${buyerAddressLine2}">8000 Zürich, CH</div>
|
||||
<td class="customer-container">
|
||||
<div th:text="${buyerDisplayName}">Joe Küng</div>
|
||||
<div th:text="${buyerAddressLine1}">Via G.Pioda, 29a</div>
|
||||
<div th:text="${buyerAddressLine2}">6710 biasca</div>
|
||||
<div>Svizzera</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</table>
|
||||
|
||||
<table class="line-items">
|
||||
<!-- Items Table -->
|
||||
<table class="line-items">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Descrizione</th>
|
||||
<th>Qtà</th>
|
||||
<th>Prezzo unit.</th>
|
||||
<th>Totale</th>
|
||||
<th class="col-desc">Descrizione</th>
|
||||
<th class="col-qty center">Quantità</th>
|
||||
<th class="col-price right">Prezzo unitario</th>
|
||||
<th class="col-total right">Prezzo incl.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="lineItem : ${invoiceLineItems}">
|
||||
<td th:text="${lineItem.description}">Stampa 3D pezzo X</td>
|
||||
<td th:text="${lineItem.quantity}">1</td>
|
||||
<td th:text="${lineItem.unitPriceFormatted}">CHF 10.00</td>
|
||||
<td th:text="${lineItem.lineTotalFormatted}">CHF 10.00</td>
|
||||
<td class="item-desc" th:text="${lineItem.description}">Apple iPhone 16 Pro</td>
|
||||
<td class="center" th:text="${lineItem.quantity}">1</td>
|
||||
<td class="right" th:text="${lineItem.unitPriceFormatted}">968.55</td>
|
||||
<td class="right" th:text="${lineItem.lineTotalFormatted}">1'047.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
|
||||
<table class="summary-layout">
|
||||
<!-- Totals -->
|
||||
<table class="totals-table">
|
||||
<tr>
|
||||
<td class="notes">
|
||||
<div class="section-caption">Informazioni</div>
|
||||
<div th:text="${paymentTermsText}">
|
||||
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">
|
||||
<td class="totals-label">Importo totale</td>
|
||||
<td class="totals-value" th:text="${subtotalFormatted}">1'012.86</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="totals-label">Subtotale</td>
|
||||
<td class="totals-value" th:text="${subtotalFormatted}">CHF 10.00</td>
|
||||
<td class="totals-label">Totale di tutte le consegne e di tutti i servizi CHF</td>
|
||||
<td class="totals-value" th:text="${grandTotalFormatted}">1'094.90</td>
|
||||
</tr>
|
||||
<tr class="total-strong">
|
||||
<td class="totals-label">Totale ordine</td>
|
||||
<td class="totals-value" th:text="${grandTotalFormatted}">CHF 10.00</td>
|
||||
</tr>
|
||||
<tr class="due-row">
|
||||
<tr class="no-border">
|
||||
<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>
|
||||
</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>
|
||||
</table>
|
||||
<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>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,7 @@ class OrderEmailListenerTest {
|
||||
|
||||
ReflectionTestUtils.setField(orderEmailListener, "adminMailEnabled", true);
|
||||
ReflectionTestUtils.setField(orderEmailListener, "adminMailAddress", "admin@printcalculator.local");
|
||||
ReflectionTestUtils.setField(orderEmailListener, "frontendBaseUrl", "https://tuosito.it");
|
||||
ReflectionTestUtils.setField(orderEmailListener, "frontendBaseUrl", "https://3d-fab.ch");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -76,7 +76,7 @@ class OrderEmailListenerTest {
|
||||
assertEquals("John", customerData.get("customerName"));
|
||||
assertEquals(order.getId(), customerData.get("orderId"));
|
||||
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("150.50", customerData.get("totalCost"));
|
||||
|
||||
|
||||
2
db.sql
2
db.sql
@@ -552,7 +552,7 @@ CREATE TABLE IF NOT EXISTS payments
|
||||
payment_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
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')),
|
||||
|
||||
currency char(3) NOT NULL DEFAULT 'CHF',
|
||||
|
||||
@@ -30,16 +30,12 @@ export const routes: Routes = [
|
||||
loadComponent: () => import('./features/checkout/checkout.component').then(m => m.CheckoutComponent)
|
||||
},
|
||||
{
|
||||
path: 'payment/:orderId',
|
||||
loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent)
|
||||
path: 'order/:orderId',
|
||||
loadComponent: () => import('./features/order/order.component').then(m => m.OrderComponent)
|
||||
},
|
||||
{
|
||||
path: 'ordine/:orderId',
|
||||
loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent)
|
||||
},
|
||||
{
|
||||
path: 'order-confirmed/:orderId',
|
||||
loadComponent: () => import('./features/order-confirmed/order-confirmed.component').then(m => m.OrderConfirmedComponent)
|
||||
path: 'co/:orderId',
|
||||
loadComponent: () => import('./features/order/order.component').then(m => m.OrderComponent)
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
|
||||
@@ -193,8 +193,7 @@ export class CheckoutComponent implements OnInit {
|
||||
|
||||
this.quoteService.createOrder(this.sessionId, orderRequest).subscribe({
|
||||
next: (order) => {
|
||||
console.log('Order created', order);
|
||||
this.router.navigate(['/payment', order.id]);
|
||||
this.router.navigate(['/order', order.id]);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Order creation failed', err);
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
</p>
|
||||
<ul class="calculator-list">
|
||||
<li>{{ 'HOME.SEC_CALC_LIST_1' | translate }}</li>
|
||||
<li>{{ 'HOME.SEC_CALC_LIST_2' | translate }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<app-card class="quote-card">
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
151
frontend/src/app/features/order/order.component.html
Normal file
151
frontend/src/app/features/order/order.component.html
Normal 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>
|
||||
@@ -234,3 +234,101 @@
|
||||
margin-top: var(--space-12);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,19 +8,19 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-payment',
|
||||
selector: 'app-order',
|
||||
standalone: true,
|
||||
imports: [CommonModule, AppButtonComponent, AppCardComponent, TranslateModule],
|
||||
templateUrl: './payment.component.html',
|
||||
styleUrl: './payment.component.scss'
|
||||
templateUrl: './order.component.html',
|
||||
styleUrl: './order.component.scss'
|
||||
})
|
||||
export class PaymentComponent implements OnInit {
|
||||
export class OrderComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private quoteService = inject(QuoteEstimatorService);
|
||||
|
||||
orderId: string | null = null;
|
||||
selectedPaymentMethod: 'twint' | 'bill' | null = null;
|
||||
selectedPaymentMethod: 'twint' | 'bill' | null = 'twint';
|
||||
order = signal<any>(null);
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
@@ -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>
|
||||
@@ -1,12 +1,18 @@
|
||||
<div class="container hero">
|
||||
<h1>{{ 'SHOP.TITLE' | translate }}</h1>
|
||||
<p class="subtitle">{{ 'SHOP.SUBTITLE' | translate }}</p>
|
||||
</div>
|
||||
<section class="wip-section">
|
||||
<div class="container">
|
||||
<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="container">
|
||||
<div class="grid">
|
||||
@for (product of products(); track product.id) {
|
||||
<app-product-card [product]="product"></app-product-card>
|
||||
}
|
||||
<div class="wip-actions">
|
||||
<app-button variant="primary" routerLink="/calculator/basic">
|
||||
{{ 'SHOP.WIP_CTA_CALC' | translate }}
|
||||
</app-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="wip-return-later">{{ 'SHOP.WIP_RETURN_LATER' | translate }}</p>
|
||||
<p class="wip-note">{{ 'SHOP.WIP_NOTE' | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,72 @@
|
||||
.hero { padding: var(--space-8) 0; text-align: center; }
|
||||
.subtitle { color: var(--color-text-muted); margin-bottom: var(--space-8); }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--space-6);
|
||||
.wip-section {
|
||||
position: relative;
|
||||
padding: var(--space-12) 0;
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ShopService, Product } from './services/shop.service';
|
||||
import { ProductCardComponent } from './components/product-card/product-card.component';
|
||||
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shop-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, TranslateModule, ProductCardComponent],
|
||||
imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent],
|
||||
templateUrl: './shop-page.component.html',
|
||||
styleUrl: './shop-page.component.scss'
|
||||
})
|
||||
export class ShopPageComponent {
|
||||
products = signal<Product[]>([]);
|
||||
|
||||
constructor(private shopService: ShopService) {
|
||||
this.shopService.getProducts().subscribe(data => {
|
||||
this.products.set(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
export class ShopPageComponent {}
|
||||
|
||||
@@ -73,8 +73,15 @@
|
||||
"SHOP": {
|
||||
"TITLE": "Technical solutions",
|
||||
"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",
|
||||
"BACK": "Back to Shop"
|
||||
"BACK": "Back to Shop",
|
||||
"NOT_FOUND": "Product not found."
|
||||
},
|
||||
"ABOUT": {
|
||||
"TITLE": "About Us",
|
||||
@@ -188,7 +195,7 @@
|
||||
"BANK_REF": "Reference",
|
||||
"BILLING_INFO_HINT": "Add the same information used in billing.",
|
||||
"DOWNLOAD_QR": "Download QR-Invoice (PDF)",
|
||||
"CONFIRM": "Confirm Order",
|
||||
"CONFIRM": "I have completed the payment",
|
||||
"SUMMARY_TITLE": "Order Summary",
|
||||
"SUBTOTAL": "Subtotal",
|
||||
"SHIPPING": "Shipping",
|
||||
@@ -197,13 +204,15 @@
|
||||
"LOADING": "Loading order details...",
|
||||
"METHOD_TWINT": "TWINT",
|
||||
"METHOD_BANK": "Bank Transfer / QR",
|
||||
"STATUS_REPORTED_TITLE": "Payment Reported",
|
||||
"STATUS_REPORTED_DESC": "We are verifying your transaction. Your order will move to production as soon as the payment is confirmed.",
|
||||
"IN_VERIFICATION": "Verifying Payment"
|
||||
"STATUS_REPORTED_TITLE": "Order in progress",
|
||||
"STATUS_REPORTED_DESC": "We have registered your operation. Your order will soon move to production.",
|
||||
"IN_VERIFICATION": "Payment Reported"
|
||||
},
|
||||
"TRACKING": {
|
||||
"TITLE": "Order Status",
|
||||
"SUBTITLE": "Check the status of your order and manage the payment if necessary.",
|
||||
"STEP_PENDING": "Pending",
|
||||
"STEP_REPORTED": "Verifying",
|
||||
"STEP_REPORTED": "Received",
|
||||
"STEP_PRODUCTION": "Production",
|
||||
"STEP_SHIPPED": "Shipped"
|
||||
},
|
||||
|
||||
@@ -15,14 +15,13 @@
|
||||
"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_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_SHOP": "Vai allo shop",
|
||||
"BTN_CONTACT": "Parla con noi",
|
||||
"SEC_CALC_TITLE": "Preventivo immediato 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_TITLE": "Prezzo corretto in pochi secondi",
|
||||
"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_2": "Qualità: bozza, standard, alta definizione",
|
||||
"CARD_CALC_EYEBROW": "Calcolo automatico",
|
||||
"CARD_CALC_TITLE": "Prezzo e tempi in un click",
|
||||
"CARD_CALC_TAG": "Senza registrazione",
|
||||
@@ -137,6 +136,12 @@
|
||||
"SHOP": {
|
||||
"TITLE": "Soluzioni tecniche",
|
||||
"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",
|
||||
"BACK": "Torna allo Shop",
|
||||
"NOT_FOUND": "Prodotto non trovato."
|
||||
@@ -259,7 +264,7 @@
|
||||
"BANK_REF": "Riferimento",
|
||||
"BILLING_INFO_HINT": "Aggiungi le informazioni uguali a quelle della fatturazione.",
|
||||
"DOWNLOAD_QR": "Scarica QR-Fattura (PDF)",
|
||||
"CONFIRM": "Conferma Ordine",
|
||||
"CONFIRM": "Ho completato il pagamento",
|
||||
"SUMMARY_TITLE": "Riepilogo Ordine",
|
||||
"SUBTOTAL": "Subtotale",
|
||||
"SHIPPING": "Spedizione",
|
||||
@@ -268,11 +273,13 @@
|
||||
"LOADING": "Caricamento dettagli ordine...",
|
||||
"METHOD_TWINT": "TWINT",
|
||||
"METHOD_BANK": "Fattura QR / Bonifico",
|
||||
"STATUS_REPORTED_TITLE": "Abbiamo ricevuto la tua segnalazione",
|
||||
"STATUS_REPORTED_DESC": "Stiamo verificando la transazione. Il tuo ordine passerà in produzione non appena l'accredito sarà confermato.",
|
||||
"IN_VERIFICATION": "Pagamento in verifica"
|
||||
"STATUS_REPORTED_TITLE": "Ordine in lavorazione",
|
||||
"STATUS_REPORTED_DESC": "Abbiamo registrato la tua operazione. L'ordine entrerà a breve in produzione.",
|
||||
"IN_VERIFICATION": "Pagamento Segnalato"
|
||||
},
|
||||
"TRACKING": {
|
||||
"TITLE": "Stato dell'Ordine",
|
||||
"SUBTITLE": "Consulta lo stato del tuo ordine e gestisci il pagamento se necessario.",
|
||||
"STEP_PENDING": "In attesa",
|
||||
"STEP_REPORTED": "In verifica",
|
||||
"STEP_PRODUCTION": "In Produzione",
|
||||
|
||||
Reference in New Issue
Block a user