feat(back-end and front-end) email for request
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 18s
PR Checks / security-sast (pull_request) Successful in 33s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m8s

This commit is contained in:
2026-03-04 12:15:23 +01:00
parent 1b3f0b16ff
commit 767b65008b
20 changed files with 493 additions and 55 deletions

View File

@@ -29,6 +29,8 @@ import java.nio.file.Paths;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.Year; import java.time.Year;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -53,6 +55,9 @@ public class CustomQuoteRequestController {
@Value("${app.mail.contact-request.admin.address:infog@3d-fab.ch}") @Value("${app.mail.contact-request.admin.address:infog@3d-fab.ch}")
private String contactRequestAdminMailAddress; private String contactRequestAdminMailAddress;
@Value("${app.mail.contact-request.customer.enabled:true}")
private boolean contactRequestCustomerMailEnabled;
// TODO: Inject Storage Service // TODO: Inject Storage Service
private static final Path STORAGE_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize(); private static final Path STORAGE_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize();
private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$"); private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$");
@@ -97,6 +102,7 @@ public class CustomQuoteRequestController {
"Accettazione Termini e Privacy obbligatoria." "Accettazione Termini e Privacy obbligatoria."
); );
} }
String language = normalizeLanguage(requestDto.getLanguage());
// 1. Create Request // 1. Create Request
CustomQuoteRequest request = new CustomQuoteRequest(); CustomQuoteRequest request = new CustomQuoteRequest();
@@ -173,6 +179,7 @@ public class CustomQuoteRequestController {
} }
sendAdminContactRequestNotification(request, attachmentsCount); sendAdminContactRequestNotification(request, attachmentsCount);
sendCustomerContactRequestConfirmation(request, attachmentsCount, language);
return ResponseEntity.ok(request); return ResponseEntity.ok(request);
} }
@@ -258,6 +265,252 @@ public class CustomQuoteRequestController {
); );
} }
private void sendCustomerContactRequestConfirmation(CustomQuoteRequest request, int attachmentsCount, String language) {
if (!contactRequestCustomerMailEnabled) {
return;
}
if (request.getEmail() == null || request.getEmail().isBlank()) {
logger.warn("Contact request confirmation skipped: missing customer email for request {}", request.getId());
return;
}
Map<String, Object> templateData = new HashMap<>();
templateData.put("requestId", request.getId());
templateData.put(
"createdAt",
request.getCreatedAt().format(
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(localeForLanguage(language))
)
);
templateData.put("recipientName", resolveRecipientName(request, language));
templateData.put("requestType", localizeRequestType(request.getRequestType(), language));
templateData.put("customerType", localizeCustomerType(request.getCustomerType(), language));
templateData.put("name", safeValue(request.getName()));
templateData.put("companyName", safeValue(request.getCompanyName()));
templateData.put("contactPerson", safeValue(request.getContactPerson()));
templateData.put("email", safeValue(request.getEmail()));
templateData.put("phone", safeValue(request.getPhone()));
templateData.put("message", safeValue(request.getMessage()));
templateData.put("attachmentsCount", attachmentsCount);
templateData.put("currentYear", Year.now().getValue());
String subject = applyCustomerContactRequestTexts(templateData, language, request.getId());
emailNotificationService.sendEmail(
request.getEmail(),
subject,
"contact-request-customer",
templateData
);
}
private String applyCustomerContactRequestTexts(
Map<String, Object> templateData,
String language,
UUID requestId
) {
return switch (language) {
case "en" -> {
templateData.put("emailTitle", "Contact request received");
templateData.put("headlineText", "We received your contact request");
templateData.put("greetingText", "Hi " + templateData.get("recipientName") + ",");
templateData.put("introText", "Thank you for contacting us. Our team will reply as soon as possible.");
templateData.put("requestIdHintText", "Please keep this request ID for future order references:");
templateData.put("detailsTitleText", "Request details");
templateData.put("labelRequestId", "Request ID");
templateData.put("labelDate", "Date");
templateData.put("labelRequestType", "Request type");
templateData.put("labelCustomerType", "Customer type");
templateData.put("labelName", "Name");
templateData.put("labelCompany", "Company");
templateData.put("labelContactPerson", "Contact person");
templateData.put("labelEmail", "Email");
templateData.put("labelPhone", "Phone");
templateData.put("labelMessage", "Message");
templateData.put("labelAttachments", "Attachments");
templateData.put("supportText", "If you need help, reply to this email.");
templateData.put("footerText", "Automated request-receipt confirmation from 3D-Fab.");
yield "We received your contact request #" + requestId + " - 3D-Fab";
}
case "de" -> {
templateData.put("emailTitle", "Kontaktanfrage erhalten");
templateData.put("headlineText", "Wir haben Ihre Kontaktanfrage erhalten");
templateData.put("greetingText", "Hallo " + templateData.get("recipientName") + ",");
templateData.put("introText", "Vielen Dank fuer Ihre Anfrage. Unser Team antwortet Ihnen so schnell wie moeglich.");
templateData.put("requestIdHintText", "Bitte speichern Sie diese Anfrage-ID fuer zukuenftige Bestellreferenzen:");
templateData.put("detailsTitleText", "Anfragedetails");
templateData.put("labelRequestId", "Anfrage-ID");
templateData.put("labelDate", "Datum");
templateData.put("labelRequestType", "Anfragetyp");
templateData.put("labelCustomerType", "Kundentyp");
templateData.put("labelName", "Name");
templateData.put("labelCompany", "Firma");
templateData.put("labelContactPerson", "Kontaktperson");
templateData.put("labelEmail", "E-Mail");
templateData.put("labelPhone", "Telefon");
templateData.put("labelMessage", "Nachricht");
templateData.put("labelAttachments", "Anhaenge");
templateData.put("supportText", "Wenn Sie Hilfe brauchen, antworten Sie auf diese E-Mail.");
templateData.put("footerText", "Automatische Bestaetigung des Anfrageeingangs von 3D-Fab.");
yield "Wir haben Ihre Kontaktanfrage erhalten #" + requestId + " - 3D-Fab";
}
case "fr" -> {
templateData.put("emailTitle", "Demande de contact recue");
templateData.put("headlineText", "Nous avons recu votre demande de contact");
templateData.put("greetingText", "Bonjour " + templateData.get("recipientName") + ",");
templateData.put("introText", "Merci pour votre message. Notre equipe vous repondra des que possible.");
templateData.put("requestIdHintText", "Veuillez conserver cet ID de demande pour vos futures references de commande :");
templateData.put("detailsTitleText", "Details de la demande");
templateData.put("labelRequestId", "ID de demande");
templateData.put("labelDate", "Date");
templateData.put("labelRequestType", "Type de demande");
templateData.put("labelCustomerType", "Type de client");
templateData.put("labelName", "Nom");
templateData.put("labelCompany", "Entreprise");
templateData.put("labelContactPerson", "Contact");
templateData.put("labelEmail", "Email");
templateData.put("labelPhone", "Telephone");
templateData.put("labelMessage", "Message");
templateData.put("labelAttachments", "Pieces jointes");
templateData.put("supportText", "Si vous avez besoin d'aide, repondez a cet email.");
templateData.put("footerText", "Confirmation automatique de reception de demande par 3D-Fab.");
yield "Nous avons recu votre demande de contact #" + requestId + " - 3D-Fab";
}
default -> {
templateData.put("emailTitle", "Richiesta di contatto ricevuta");
templateData.put("headlineText", "Abbiamo ricevuto la tua richiesta di contatto");
templateData.put("greetingText", "Ciao " + templateData.get("recipientName") + ",");
templateData.put("introText", "Grazie per averci contattato. Il nostro team ti rispondera' il prima possibile.");
templateData.put("requestIdHintText", "Conserva questo ID richiesta per i futuri riferimenti d'ordine:");
templateData.put("detailsTitleText", "Dettagli richiesta");
templateData.put("labelRequestId", "ID richiesta");
templateData.put("labelDate", "Data");
templateData.put("labelRequestType", "Tipo richiesta");
templateData.put("labelCustomerType", "Tipo cliente");
templateData.put("labelName", "Nome");
templateData.put("labelCompany", "Azienda");
templateData.put("labelContactPerson", "Contatto");
templateData.put("labelEmail", "Email");
templateData.put("labelPhone", "Telefono");
templateData.put("labelMessage", "Messaggio");
templateData.put("labelAttachments", "Allegati");
templateData.put("supportText", "Se hai bisogno, rispondi direttamente a questa email.");
templateData.put("footerText", "Conferma automatica di ricezione richiesta da 3D-Fab.");
yield "Abbiamo ricevuto la tua richiesta di contatto #" + requestId + " - 3D-Fab";
}
};
}
private String localizeRequestType(String requestType, String language) {
if (requestType == null || requestType.isBlank()) {
return "-";
}
String normalized = requestType.trim().toLowerCase(Locale.ROOT);
return switch (language) {
case "en" -> switch (normalized) {
case "custom", "print_service" -> "Custom part request";
case "series" -> "Series production request";
case "consult", "design_service" -> "Consultation request";
case "question" -> "General question";
default -> requestType;
};
case "de" -> switch (normalized) {
case "custom", "print_service" -> "Anfrage fuer Einzelteil";
case "series" -> "Anfrage fuer Serienproduktion";
case "consult", "design_service" -> "Beratungsanfrage";
case "question" -> "Allgemeine Frage";
default -> requestType;
};
case "fr" -> switch (normalized) {
case "custom", "print_service" -> "Demande de piece personnalisee";
case "series" -> "Demande de production en serie";
case "consult", "design_service" -> "Demande de conseil";
case "question" -> "Question generale";
default -> requestType;
};
default -> switch (normalized) {
case "custom", "print_service" -> "Richiesta pezzo personalizzato";
case "series" -> "Richiesta produzione in serie";
case "consult", "design_service" -> "Richiesta consulenza";
case "question" -> "Domanda generale";
default -> requestType;
};
};
}
private String localizeCustomerType(String customerType, String language) {
if (customerType == null || customerType.isBlank()) {
return "-";
}
String normalized = customerType.trim().toLowerCase(Locale.ROOT);
return switch (language) {
case "en" -> switch (normalized) {
case "private" -> "Private";
case "business" -> "Business";
default -> customerType;
};
case "de" -> switch (normalized) {
case "private" -> "Privat";
case "business" -> "Unternehmen";
default -> customerType;
};
case "fr" -> switch (normalized) {
case "private" -> "Prive";
case "business" -> "Entreprise";
default -> customerType;
};
default -> switch (normalized) {
case "private" -> "Privato";
case "business" -> "Azienda";
default -> customerType;
};
};
}
private Locale localeForLanguage(String language) {
return switch (language) {
case "en" -> Locale.ENGLISH;
case "de" -> Locale.GERMAN;
case "fr" -> Locale.FRENCH;
default -> Locale.ITALIAN;
};
}
private String normalizeLanguage(String language) {
if (language == null || language.isBlank()) {
return "it";
}
String normalized = language.toLowerCase(Locale.ROOT).trim();
if (normalized.startsWith("en")) {
return "en";
}
if (normalized.startsWith("de")) {
return "de";
}
if (normalized.startsWith("fr")) {
return "fr";
}
return "it";
}
private String resolveRecipientName(CustomQuoteRequest request, String language) {
if (request.getName() != null && !request.getName().isBlank()) {
return request.getName().trim();
}
if (request.getContactPerson() != null && !request.getContactPerson().isBlank()) {
return request.getContactPerson().trim();
}
if (request.getCompanyName() != null && !request.getCompanyName().isBlank()) {
return request.getCompanyName().trim();
}
return switch (language) {
case "en" -> "customer";
case "de" -> "Kunde";
case "fr" -> "client";
default -> "cliente";
};
}
private String safeValue(String value) { private String safeValue(String value) {
if (value == null || value.isBlank()) { if (value == null || value.isBlank()) {
return "-"; return "-";

View File

@@ -7,6 +7,7 @@ import jakarta.validation.constraints.AssertTrue;
public class QuoteRequestDto { public class QuoteRequestDto {
private String requestType; // "PRINT_SERVICE" or "DESIGN_SERVICE" private String requestType; // "PRINT_SERVICE" or "DESIGN_SERVICE"
private String customerType; // "PRIVATE" or "BUSINESS" private String customerType; // "PRIVATE" or "BUSINESS"
private String language; // "it" | "en" | "de" | "fr"
private String email; private String email;
private String phone; private String phone;
private String name; private String name;

View File

@@ -44,6 +44,7 @@ app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true}
app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local} app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local}
app.mail.contact-request.admin.enabled=${APP_MAIL_CONTACT_REQUEST_ADMIN_ENABLED:true} app.mail.contact-request.admin.enabled=${APP_MAIL_CONTACT_REQUEST_ADMIN_ENABLED:true}
app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:infog@3d-fab.ch} app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:infog@3d-fab.ch}
app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200} app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
# Admin back-office authentication # Admin back-office authentication

View File

@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title th:text="${emailTitle}">Contact request received</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
max-width: 640px;
margin: 20px auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
h1 {
margin-top: 0;
color: #222222;
}
h2 {
margin-top: 18px;
color: #222222;
font-size: 18px;
}
p {
color: #444444;
line-height: 1.5;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 16px;
}
th,
td {
text-align: left;
vertical-align: top;
border-bottom: 1px solid #eeeeee;
padding: 10px 6px;
color: #333333;
word-break: break-word;
}
th {
width: 35%;
color: #222222;
background: #fafafa;
}
.footer {
margin-top: 24px;
font-size: 12px;
color: #888888;
border-top: 1px solid #eeeeee;
padding-top: 12px;
}
</style>
</head>
<body>
<div class="container">
<h1 th:text="${headlineText}">We received your contact request</h1>
<p th:text="${greetingText}">Hi customer,</p>
<p th:text="${introText}">Thank you for contacting us. Our team will reply as soon as possible.</p>
<p>
<strong th:text="${requestIdHintText}">Please keep this request ID for future order references:</strong>
<span th:text="${requestId}">00000000-0000-0000-0000-000000000000</span>
</p>
<h2 th:text="${detailsTitleText}">Request details</h2>
<table>
<tr>
<th th:text="${labelRequestId}">Request ID</th>
<td th:text="${requestId}">00000000-0000-0000-0000-000000000000</td>
</tr>
<tr>
<th th:text="${labelDate}">Date</th>
<td th:text="${createdAt}">2026-03-03T10:00:00Z</td>
</tr>
<tr>
<th th:text="${labelRequestType}">Request type</th>
<td th:text="${requestType}">custom</td>
</tr>
<tr>
<th th:text="${labelCustomerType}">Customer type</th>
<td th:text="${customerType}">PRIVATE</td>
</tr>
<tr>
<th th:text="${labelName}">Name</th>
<td th:text="${name}">Mario Rossi</td>
</tr>
<tr>
<th th:text="${labelCompany}">Company</th>
<td th:text="${companyName}">3D Fab SA</td>
</tr>
<tr>
<th th:text="${labelContactPerson}">Contact person</th>
<td th:text="${contactPerson}">Mario Rossi</td>
</tr>
<tr>
<th th:text="${labelEmail}">Email</th>
<td th:text="${email}">cliente@example.com</td>
</tr>
<tr>
<th th:text="${labelPhone}">Phone</th>
<td th:text="${phone}">+41 00 000 00 00</td>
</tr>
<tr>
<th th:text="${labelMessage}">Message</th>
<td th:text="${message}">Testo richiesta cliente...</td>
</tr>
<tr>
<th th:text="${labelAttachments}">Attachments</th>
<td th:text="${attachmentsCount}">0</td>
</tr>
</table>
<p th:text="${supportText}">If you need help, reply to this email.</p>
<div class="footer">
<p>&copy; <span th:text="${currentYear}">2026</span> <span th:text="${footerText}">Automated request-receipt confirmation from 3D-Fab.</span></p>
</div>
</div>
</body>
</html>

View File

@@ -6,6 +6,7 @@ import { environment } from '../../../environments/environment';
export interface QuoteRequestDto { export interface QuoteRequestDto {
requestType: string; requestType: string;
customerType: string; customerType: string;
language?: 'it' | 'en' | 'de' | 'fr';
email: string; email: string;
phone?: string; phone?: string;
name?: string; name?: string;

View File

@@ -16,6 +16,7 @@ type PassionId =
| 'woodworking' | 'woodworking'
| 'van-life' | 'van-life'
| 'coffee' | 'coffee'
| 'cooking'
| 'software-development'; | 'software-development';
interface PassionChip { interface PassionChip {
@@ -50,6 +51,7 @@ export class AboutPageComponent {
{ id: 'snowboard', labelKey: 'ABOUT.PASSION_SNOWBOARD' }, { id: 'snowboard', labelKey: 'ABOUT.PASSION_SNOWBOARD' },
{ id: 'van-life', labelKey: 'ABOUT.PASSION_VAN_LIFE' }, { id: 'van-life', labelKey: 'ABOUT.PASSION_VAN_LIFE' },
{ id: 'self-hosting', labelKey: 'ABOUT.PASSION_SELF_HOSTING' }, { id: 'self-hosting', labelKey: 'ABOUT.PASSION_SELF_HOSTING' },
{ id: 'cooking', labelKey: 'ABOUT.PASSION_COOKING' },
{ {
id: 'snowboard-instructor', id: 'snowboard-instructor',
labelKey: 'ABOUT.PASSION_SNOWBOARD_INSTRUCTOR', labelKey: 'ABOUT.PASSION_SNOWBOARD_INSTRUCTOR',
@@ -67,6 +69,7 @@ export class AboutPageComponent {
'print-3d', 'print-3d',
'travel', 'travel',
'coffee', 'coffee',
'cooking',
'software-development', 'software-development',
], ],
matteo: [ matteo: [

View File

@@ -82,8 +82,15 @@
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let row of invoices"> <tr *ngFor="let row of invoices">
<td [title]="row.sessionId">{{ row.sessionId | slice: 0 : 8 }}</td> <td [title]="row.sessionId" [appCopyOnClick]="row.sessionId">
<td>{{ row.sourceRequestId || "-" }}</td> {{ row.sessionId | slice: 0 : 8 }}
</td>
<td
[title]="row.sourceRequestId || ''"
[appCopyOnClick]="row.sourceRequestId"
>
{{ row.sourceRequestId || "-" }}
</td>
<td>{{ row.cadHours }}</td> <td>{{ row.cadHours }}</td>
<td>{{ row.cadHourlyRateChf | currency: "CHF" }}</td> <td>{{ row.cadHourlyRateChf | currency: "CHF" }}</td>
<td>{{ row.cadTotalChf | currency: "CHF" }}</td> <td>{{ row.cadTotalChf | currency: "CHF" }}</td>
@@ -91,7 +98,11 @@
<td>{{ row.sessionStatus }}</td> <td>{{ row.sessionStatus }}</td>
<td class="notes-cell" [title]="row.notes || ''">{{ row.notes || "-" }}</td> <td class="notes-cell" [title]="row.notes || ''">{{ row.notes || "-" }}</td>
<td> <td>
<span *ngIf="row.convertedOrderId; else noOrder"> <span
*ngIf="row.convertedOrderId; else noOrder"
[title]="row.convertedOrderId || ''"
[appCopyOnClick]="row.convertedOrderId"
>
{{ row.convertedOrderId | slice: 0 : 8 }} ({{ {{ row.convertedOrderId | slice: 0 : 8 }} ({{
row.convertedOrderStatus || "-" row.convertedOrderStatus || "-"
}}) }})

View File

@@ -6,11 +6,12 @@ import {
AdminOperationsService, AdminOperationsService,
} from '../services/admin-operations.service'; } from '../services/admin-operations.service';
import { AdminOrdersService } from '../services/admin-orders.service'; import { AdminOrdersService } from '../services/admin-orders.service';
import { CopyOnClickDirective } from '../../../shared/directives/copy-on-click.directive';
@Component({ @Component({
selector: 'app-admin-cad-invoices', selector: 'app-admin-cad-invoices',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule, CopyOnClickDirective],
templateUrl: './admin-cad-invoices.component.html', templateUrl: './admin-cad-invoices.component.html',
styleUrl: './admin-cad-invoices.component.scss', styleUrl: './admin-cad-invoices.component.scss',
}) })

View File

@@ -76,7 +76,10 @@
<div> <div>
<h3>Dettaglio richiesta</h3> <h3>Dettaglio richiesta</h3>
<p class="request-id"> <p class="request-id">
<span>ID</span><code>{{ selectedRequest.id }}</code> <span>ID</span>
<code [title]="selectedRequest.id" [appCopyOnClick]="selectedRequest.id">{{
selectedRequest.id
}}</code>
</p> </p>
</div> </div>
<div class="detail-chips"> <div class="detail-chips">

View File

@@ -7,11 +7,12 @@ import {
AdminContactRequestDetail, AdminContactRequestDetail,
AdminOperationsService, AdminOperationsService,
} from '../services/admin-operations.service'; } from '../services/admin-operations.service';
import { CopyOnClickDirective } from '../../../shared/directives/copy-on-click.directive';
@Component({ @Component({
selector: 'app-admin-contact-requests', selector: 'app-admin-contact-requests',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule, CopyOnClickDirective],
templateUrl: './admin-contact-requests.component.html', templateUrl: './admin-contact-requests.component.html',
styleUrl: './admin-contact-requests.component.scss', styleUrl: './admin-contact-requests.component.scss',
}) })

View File

@@ -97,7 +97,10 @@
<div class="detail-header"> <div class="detail-header">
<h2>Dettaglio ordine {{ selectedOrder.orderNumber }}</h2> <h2>Dettaglio ordine {{ selectedOrder.orderNumber }}</h2>
<p class="order-uuid"> <p class="order-uuid">
UUID: <code>{{ selectedOrder.id }}</code> UUID:
<code [title]="selectedOrder.id" [appCopyOnClick]="selectedOrder.id">{{
selectedOrder.id
}}</code>
</p> </p>
<p *ngIf="detailLoading">Caricamento dettaglio...</p> <p *ngIf="detailLoading">Caricamento dettaglio...</p>
</div> </div>

View File

@@ -5,11 +5,12 @@ import {
AdminOrder, AdminOrder,
AdminOrdersService, AdminOrdersService,
} from '../services/admin-orders.service'; } from '../services/admin-orders.service';
import { CopyOnClickDirective } from '../../../shared/directives/copy-on-click.directive';
@Component({ @Component({
selector: 'app-admin-dashboard', selector: 'app-admin-dashboard',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule, CopyOnClickDirective],
templateUrl: './admin-dashboard.component.html', templateUrl: './admin-dashboard.component.html',
styleUrl: './admin-dashboard.component.scss', styleUrl: './admin-dashboard.component.scss',
}) })

View File

@@ -33,12 +33,23 @@
<tbody> <tbody>
<ng-container *ngFor="let session of sessions"> <ng-container *ngFor="let session of sessions">
<tr> <tr>
<td [title]="session.id">{{ session.id | slice: 0 : 8 }}</td> <td [title]="session.id" [appCopyOnClick]="session.id">
{{ session.id | slice: 0 : 8 }}
</td>
<td>{{ session.createdAt | date: "short" }}</td> <td>{{ session.createdAt | date: "short" }}</td>
<td>{{ session.expiresAt | date: "short" }}</td> <td>{{ session.expiresAt | date: "short" }}</td>
<td>{{ session.materialCode }}</td> <td>{{ session.materialCode }}</td>
<td>{{ session.status }}</td> <td>{{ session.status }}</td>
<td>{{ session.convertedOrderId || "-" }}</td> <td
[title]="session.convertedOrderId || ''"
[appCopyOnClick]="session.convertedOrderId"
>
{{
session.convertedOrderId
? (session.convertedOrderId | slice: 0 : 8)
: "-"
}}
</td>
<td class="actions"> <td class="actions">
<button <button
type="button" type="button"
@@ -47,13 +58,6 @@
> >
{{ isDetailOpen(session.id) ? "Nascondi" : "Vedi" }} {{ isDetailOpen(session.id) ? "Nascondi" : "Vedi" }}
</button> </button>
<button
type="button"
class="btn-secondary"
(click)="copySessionUuid(session.id)"
>
Copia UUID
</button>
<button <button
type="button" type="button"
class="btn-danger" class="btn-danger"
@@ -87,7 +91,11 @@
> >
<div class="detail-session-id"> <div class="detail-session-id">
<strong>UUID sessione:</strong> <strong>UUID sessione:</strong>
<code>{{ detail.session.id }}</code> <code
[title]="detail.session.id"
[appCopyOnClick]="detail.session.id"
>{{ detail.session.id }}</code
>
</div> </div>
<div class="detail-summary"> <div class="detail-summary">

View File

@@ -5,11 +5,12 @@ import {
AdminQuoteSession, AdminQuoteSession,
AdminQuoteSessionDetail, AdminQuoteSessionDetail,
} from '../services/admin-operations.service'; } from '../services/admin-operations.service';
import { CopyOnClickDirective } from '../../../shared/directives/copy-on-click.directive';
@Component({ @Component({
selector: 'app-admin-sessions', selector: 'app-admin-sessions',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule, CopyOnClickDirective],
templateUrl: './admin-sessions.component.html', templateUrl: './admin-sessions.component.html',
styleUrl: './admin-sessions.component.scss', styleUrl: './admin-sessions.component.scss',
}) })
@@ -135,42 +136,6 @@ export class AdminSessionsComponent implements OnInit {
return `${hours}h ${minutes}m`; return `${hours}h ${minutes}m`;
} }
copySessionUuid(sessionId: string): void {
if (!sessionId) {
return;
}
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(sessionId).then(
() => {
this.errorMessage = null;
this.successMessage = 'UUID sessione copiato.';
},
() => {
this.errorMessage = 'Impossibile copiare UUID sessione.';
},
);
return;
}
// Fallback for older browsers.
const textarea = document.createElement('textarea');
textarea.value = sessionId;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
this.errorMessage = null;
this.successMessage = 'UUID sessione copiato.';
} catch {
this.errorMessage = 'Impossibile copiare UUID sessione.';
} finally {
document.body.removeChild(textarea);
}
}
private extractErrorMessage(error: unknown, fallback: string): string { private extractErrorMessage(error: unknown, fallback: string): string {
const err = error as { error?: { message?: string } }; const err = error as { error?: { message?: string } };
return err?.error?.message || fallback; return err?.error?.message || fallback;

View File

@@ -11,6 +11,7 @@ import { AppInputComponent } from '../../../../shared/components/app-input/app-i
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { QuoteEstimatorService } from '../../../calculator/services/quote-estimator.service'; import { QuoteEstimatorService } from '../../../calculator/services/quote-estimator.service';
import { QuoteRequestService } from '../../../../core/services/quote-request.service'; import { QuoteRequestService } from '../../../../core/services/quote-request.service';
import { LanguageService } from '../../../../core/services/language.service';
interface FilePreview { interface FilePreview {
file: File; file: File;
@@ -53,6 +54,7 @@ export class ContactFormComponent implements OnDestroy {
]; ];
private quoteRequestService = inject(QuoteRequestService); private quoteRequestService = inject(QuoteRequestService);
private languageService = inject(LanguageService);
constructor( constructor(
private fb: FormBuilder, private fb: FormBuilder,
@@ -257,6 +259,7 @@ export class ContactFormComponent implements OnDestroy {
const requestDto: any = { const requestDto: any = {
requestType: formVal.requestType, requestType: formVal.requestType,
customerType: isCompany ? 'BUSINESS' : 'PRIVATE', customerType: isCompany ? 'BUSINESS' : 'PRIVATE',
language: this.languageService.selectedLang(),
email: formVal.email, email: formVal.email,
phone: formVal.phone, phone: formVal.phone,
message: formVal.message, message: formVal.message,

View File

@@ -0,0 +1,45 @@
import { Directive, HostBinding, HostListener, Input } from '@angular/core';
@Directive({
selector: '[appCopyOnClick]',
standalone: true,
})
export class CopyOnClickDirective {
@Input('appCopyOnClick') value: string | null | undefined;
@HostBinding('style.cursor') readonly cursor = 'pointer';
@HostListener('click', ['$event'])
onClick(event: MouseEvent): void {
const text = (this.value ?? '').trim();
if (!text) {
return;
}
event.stopPropagation();
void this.copy(text);
}
private async copy(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// Fallback below for browsers/environments that block clipboard API.
}
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
} finally {
document.body.removeChild(textarea);
}
}
}

View File

@@ -149,6 +149,7 @@
"PASSION_WOODWORKING": "Holzbearbeitung", "PASSION_WOODWORKING": "Holzbearbeitung",
"PASSION_VAN_LIFE": "Van Life", "PASSION_VAN_LIFE": "Van Life",
"PASSION_COFFEE": "Kaffee", "PASSION_COFFEE": "Kaffee",
"PASSION_COOKING": "Kochen",
"PASSION_SOFTWARE_DEVELOPMENT": "Softwareentwicklung", "PASSION_SOFTWARE_DEVELOPMENT": "Softwareentwicklung",
"SERVICES_TITLE": "Hauptleistungen", "SERVICES_TITLE": "Hauptleistungen",
"TARGET_TITLE": "Für wen", "TARGET_TITLE": "Für wen",

View File

@@ -149,6 +149,7 @@
"PASSION_WOODWORKING": "Woodworking", "PASSION_WOODWORKING": "Woodworking",
"PASSION_VAN_LIFE": "Van life", "PASSION_VAN_LIFE": "Van life",
"PASSION_COFFEE": "Coffee", "PASSION_COFFEE": "Coffee",
"PASSION_COOKING": "Cooking",
"PASSION_SOFTWARE_DEVELOPMENT": "Software development", "PASSION_SOFTWARE_DEVELOPMENT": "Software development",
"SERVICES_TITLE": "Main Services", "SERVICES_TITLE": "Main Services",
"TARGET_TITLE": "Who is it for", "TARGET_TITLE": "Who is it for",

View File

@@ -206,6 +206,7 @@
"PASSION_WOODWORKING": "Travail du bois", "PASSION_WOODWORKING": "Travail du bois",
"PASSION_VAN_LIFE": "Van life", "PASSION_VAN_LIFE": "Van life",
"PASSION_COFFEE": "Café", "PASSION_COFFEE": "Café",
"PASSION_COOKING": "Cuisine",
"PASSION_SOFTWARE_DEVELOPMENT": "Développement logiciel", "PASSION_SOFTWARE_DEVELOPMENT": "Développement logiciel",
"SERVICES_TITLE": "Services principaux", "SERVICES_TITLE": "Services principaux",
"TARGET_TITLE": "Pour qui", "TARGET_TITLE": "Pour qui",

View File

@@ -206,6 +206,7 @@
"PASSION_WOODWORKING": "Lavorazione del legno", "PASSION_WOODWORKING": "Lavorazione del legno",
"PASSION_VAN_LIFE": "Van life", "PASSION_VAN_LIFE": "Van life",
"PASSION_COFFEE": "Caffè", "PASSION_COFFEE": "Caffè",
"PASSION_COOKING": "Cucina",
"PASSION_SOFTWARE_DEVELOPMENT": "Sviluppo software", "PASSION_SOFTWARE_DEVELOPMENT": "Sviluppo software",
"SERVICES_TITLE": "Servizi principali", "SERVICES_TITLE": "Servizi principali",
"TARGET_TITLE": "Per chi è", "TARGET_TITLE": "Per chi è",