dev #13

Merged
JoeKung merged 23 commits from dev into main 2026-03-03 18:28:30 +01:00
149 changed files with 7070 additions and 3587 deletions

View File

@@ -1,49 +1,15 @@
name: Build, Test, Deploy and Analysis name: Build and Deploy
on: on:
push: push:
branches: [main, int, dev] branches: [main, int, dev]
pull_request:
branches: [main, int, dev]
workflow_dispatch:
concurrency: concurrency:
group: print-calculator-${{ gitea.ref }} group: print-calculator-deploy-${{ gitea.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
# --- JOB DI ANALISI (In parallelo) ---
qodana:
if: ${{ gitea.event_name == 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fondamentale per Qodana per analizzare la storia
- name: Prepare Qodana directories
shell: bash
run: |
mkdir -p .qodana/caches .qodana/results
- name: 'Qodana Scan'
uses: JetBrains/qodana-action@v2025.3
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
with:
cache-dir: .qodana/caches
results-dir: .qodana/results
args: -i,backend
# In Gitea, pr-mode funziona se il runner ha accesso ai dati del clone
pr-mode: ${{ gitea.event_name == 'pull_request' }}
use-caches: false
# Nota: Gitea ha un supporto limitato per i commenti automatici
# rispetto a GitHub, ma l'analisi verrà eseguita correttamente.
post-pr-comment: false
use-annotations: true
test-backend: test-backend:
if: ${{ gitea.event_name == 'pull_request' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -52,8 +18,9 @@ jobs:
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
java-version: '21' java-version: "21"
distribution: 'temurin' distribution: "temurin"
cache: gradle
- name: Run Tests with Gradle - name: Run Tests with Gradle
run: | run: |
@@ -61,8 +28,42 @@ jobs:
chmod +x gradlew chmod +x gradlew
./gradlew test ./gradlew test
test-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: "frontend/package-lock.json"
- name: Install Chromium
shell: bash
run: |
apt-get update
apt-get install -y --no-install-recommends chromium
- name: Install frontend dependencies
shell: bash
run: |
cd frontend
npm ci --no-audit --no-fund
- name: Run frontend tests (headless)
shell: bash
env:
CHROME_BIN: /usr/bin/chromium
CI: "true"
run: |
cd frontend
npm run test -- --watch=false --browsers=ChromeHeadlessNoSandbox
build-and-push: build-and-push:
if: ${{ gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch' }} needs: [test-backend, test-frontend]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -113,7 +114,6 @@ jobs:
deploy: deploy:
needs: build-and-push needs: build-and-push
if: ${{ gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
@@ -141,21 +141,15 @@ jobs:
mkdir -p ~/.ssh mkdir -p ~/.ssh
chmod 700 ~/.ssh chmod 700 ~/.ssh
# 1) Prende il secret base64 e rimuove spazi/newline/CR
printf '%s' "${{ secrets.SSH_PRIVATE_KEY_B64 }}" | tr -d '\r\n\t ' > /tmp/key.b64 printf '%s' "${{ secrets.SSH_PRIVATE_KEY_B64 }}" | tr -d '\r\n\t ' > /tmp/key.b64
# 2) (debug sicuro) stampa solo la lunghezza della base64
echo "b64_len=$(wc -c < /tmp/key.b64)" echo "b64_len=$(wc -c < /tmp/key.b64)"
# 3) Decodifica in chiave privata
base64 -d /tmp/key.b64 > ~/.ssh/id_ed25519 base64 -d /tmp/key.b64 > ~/.ssh/id_ed25519
# 4) Rimuove eventuali CRLF dentro la chiave (se proviene da Windows)
tr -d '\r' < ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.clean tr -d '\r' < ~/.ssh/id_ed25519 > ~/.ssh/id_ed25519.clean
mv ~/.ssh/id_ed25519.clean ~/.ssh/id_ed25519 mv ~/.ssh/id_ed25519.clean ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519
# 5) Validazione: se fallisce qui, la chiave NON è valida/corrotta
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
@@ -163,7 +157,6 @@ jobs:
- name: Write env and compose to server - name: Write env and compose to server
shell: bash shell: bash
run: | run: |
# 1. Recalculate TAG and OWNER_LOWER (jobs don't share ENV)
if [[ "${{ gitea.ref }}" == "refs/heads/main" ]]; then if [[ "${{ gitea.ref }}" == "refs/heads/main" ]]; then
DEPLOY_TAG="prod" DEPLOY_TAG="prod"
elif [[ "${{ gitea.ref }}" == "refs/heads/int" ]]; then elif [[ "${{ gitea.ref }}" == "refs/heads/int" ]]; then
@@ -173,10 +166,8 @@ jobs:
fi fi
DEPLOY_OWNER=$(echo '${{ gitea.repository_owner }}' | tr '[:upper:]' '[:lower:]') DEPLOY_OWNER=$(echo '${{ gitea.repository_owner }}' | tr '[:upper:]' '[:lower:]')
# 2. Start with the static env file content
cat "deploy/envs/${{ env.ENV }}.env" > /tmp/full_env.env cat "deploy/envs/${{ env.ENV }}.env" > /tmp/full_env.env
# 3. Determine DB credentials
if [[ "${{ env.ENV }}" == "prod" ]]; then if [[ "${{ env.ENV }}" == "prod" ]]; then
DB_URL="${{ secrets.DB_URL_PROD }}" DB_URL="${{ secrets.DB_URL_PROD }}"
DB_USER="${{ secrets.DB_USERNAME_PROD }}" DB_USER="${{ secrets.DB_USERNAME_PROD }}"
@@ -191,7 +182,6 @@ jobs:
DB_PASS="${{ secrets.DB_PASSWORD_DEV }}" DB_PASS="${{ secrets.DB_PASSWORD_DEV }}"
fi fi
# 4. Append DB and Docker credentials (quoted)
printf '\nDB_URL="%s"\nDB_USERNAME="%s"\nDB_PASSWORD="%s"\n' \ printf '\nDB_URL="%s"\nDB_USERNAME="%s"\nDB_PASSWORD="%s"\n' \
"$DB_URL" "$DB_USER" "$DB_PASS" >> /tmp/full_env.env "$DB_URL" "$DB_USER" "$DB_PASS" >> /tmp/full_env.env
@@ -203,25 +193,17 @@ jobs:
printf 'ADMIN_PASSWORD="%s"\nADMIN_SESSION_SECRET="%s"\nADMIN_SESSION_TTL_MINUTES="%s"\n' \ printf 'ADMIN_PASSWORD="%s"\nADMIN_SESSION_SECRET="%s"\nADMIN_SESSION_TTL_MINUTES="%s"\n' \
"${{ secrets.ADMIN_PASSWORD }}" "${{ secrets.ADMIN_SESSION_SECRET }}" "$ADMIN_TTL" >> /tmp/full_env.env "${{ secrets.ADMIN_PASSWORD }}" "${{ secrets.ADMIN_SESSION_SECRET }}" "$ADMIN_TTL" >> /tmp/full_env.env
# 5. Debug: print content (for debug purposes)
echo "Preparing to send env file with variables:" echo "Preparing to send env file with variables:"
grep -Ev "PASSWORD|SECRET" /tmp/full_env.env || true grep -Ev "PASSWORD|SECRET" /tmp/full_env.env || true
# 5. Send env to server
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \ ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
"setenv ${{ env.ENV }}" < /tmp/full_env.env "setenv ${{ env.ENV }}" < /tmp/full_env.env
# 6. Send docker-compose.deploy.yml to server
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \ ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
"setcompose ${{ env.ENV }}" < docker-compose.deploy.yml "setcompose ${{ env.ENV }}" < docker-compose.deploy.yml
- name: Trigger deploy on Unraid (forced command key) - name: Trigger deploy on Unraid (forced command key)
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
# Aggiungiamo le opzioni di verbosità se dovesse fallire ancora,
# e assicuriamoci che l'input sia pulito
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "deploy ${{ env.ENV }}" ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "deploy ${{ env.ENV }}"

View File

@@ -0,0 +1,172 @@
name: PR Checks
on:
pull_request:
branches: [main, int, dev]
concurrency:
group: print-calculator-pr-${{ gitea.ref }}
cancel-in-progress: true
jobs:
prettier-autofix:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Apply formatting with Prettier
shell: bash
run: |
npx --yes prettier@3.6.2 --write \
"frontend/src/**/*.{ts,html,scss,css,json}" \
".gitea/workflows/*.{yml,yaml}"
- name: Commit and push formatting changes
shell: bash
run: |
if git diff --quiet; then
echo "No formatting changes to commit."
exit 0
fi
if ! command -v jq >/dev/null 2>&1; then
apt-get update
apt-get install -y --no-install-recommends jq
fi
EVENT_FILE="${GITHUB_EVENT_PATH:-}"
if [[ -z "$EVENT_FILE" || ! -f "$EVENT_FILE" ]]; then
echo "Event payload not found, skipping auto-push."
exit 0
fi
HEAD_REPO="$(jq -r '.pull_request.head.repo.full_name // empty' "$EVENT_FILE")"
BASE_REPO="$(jq -r '.repository.full_name // empty' "$EVENT_FILE")"
PR_BRANCH="$(jq -r '.pull_request.head.ref // empty' "$EVENT_FILE")"
if [[ -z "$PR_BRANCH" ]]; then
echo "PR branch not found in event payload, skipping auto-push."
exit 0
fi
if [[ -n "$HEAD_REPO" && -n "$BASE_REPO" && "$HEAD_REPO" != "$BASE_REPO" ]]; then
echo "PR from fork ($HEAD_REPO), skipping auto-push."
exit 0
fi
git config user.name "printcalc-ci"
git config user.email "ci@printcalculator.local"
git add frontend/src .gitea/workflows
git commit -m "style: apply prettier formatting"
git push origin "HEAD:${PR_BRANCH}"
security-sast:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Python and Semgrep
shell: bash
run: |
apt-get update
apt-get install -y --no-install-recommends python3 python3-pip
python3 -m pip install --upgrade pip
python3 -m pip install semgrep
- name: Run Semgrep (SAST)
shell: bash
run: |
semgrep --version
semgrep --config auto --error \
--exclude frontend/node_modules \
--exclude backend/build \
backend/src frontend/src
- name: Install Gitleaks
shell: bash
run: |
set -euo pipefail
VERSION="8.24.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" \
-o /tmp/gitleaks.tar.gz
tar -xzf /tmp/gitleaks.tar.gz -C /tmp
install -m 0755 /tmp/gitleaks /usr/local/bin/gitleaks
gitleaks version
- name: Run Gitleaks (secrets scan)
shell: bash
run: |
set +e
gitleaks detect --source . --no-git --redact --exit-code 1 \
--report-format json --report-path /tmp/gitleaks-report.json
rc=$?
if [[ $rc -ne 0 ]]; then
echo "Gitleaks findings:"
cat /tmp/gitleaks-report.json
fi
exit $rc
test-backend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: "21"
distribution: "temurin"
cache: gradle
- name: Run Tests with Gradle
run: |
cd backend
chmod +x gradlew
./gradlew test
test-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: "frontend/package-lock.json"
- name: Install Chromium
shell: bash
run: |
apt-get update
apt-get install -y --no-install-recommends chromium
- name: Install frontend dependencies
shell: bash
run: |
cd frontend
npm ci --no-audit --no-fund
- name: Run frontend tests (headless)
shell: bash
env:
CHROME_BIN: /usr/bin/chromium
CI: "true"
run: |
cd frontend
npm run test -- --watch=false --browsers=ChromeHeadlessNoSandbox

View File

@@ -15,6 +15,7 @@ FROM eclipse-temurin:21-jre-jammy
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
wget \ wget \
p7zip-full \ p7zip-full \
assimp-utils \
libgl1 \ libgl1 \
libglib2.0-0 \ libglib2.0-0 \
libgtk-3-0 \ libgtk-3-0 \
@@ -32,6 +33,7 @@ RUN wget -q https://github.com/SoftFever/OrcaSlicer/releases/download/v2.2.0/Orc
ENV PATH="/opt/orcaslicer/usr/bin:${PATH}" ENV PATH="/opt/orcaslicer/usr/bin:${PATH}"
# Set Slicer Path env variable for Java app # Set Slicer Path env variable for Java app
ENV SLICER_PATH="/opt/orcaslicer/AppRun" ENV SLICER_PATH="/opt/orcaslicer/AppRun"
ENV ASSIMP_PATH="assimp"
WORKDIR /app WORKDIR /app
# Copy JAR from build stage # Copy JAR from build stage

View File

@@ -1,18 +1,24 @@
package com.printcalculator.controller; package com.printcalculator.controller;
import com.printcalculator.dto.QuoteRequestDto;
import com.printcalculator.entity.CustomQuoteRequest; import com.printcalculator.entity.CustomQuoteRequest;
import com.printcalculator.entity.CustomQuoteRequestAttachment; import com.printcalculator.entity.CustomQuoteRequestAttachment;
import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
import com.printcalculator.repository.CustomQuoteRequestRepository; import com.printcalculator.repository.CustomQuoteRequestRepository;
import com.printcalculator.service.ClamAVService;
import com.printcalculator.service.email.EmailNotificationService;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.Valid;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
@@ -22,8 +28,11 @@ import java.nio.file.Path;
import java.nio.file.Paths; 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.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -32,9 +41,17 @@ import java.util.regex.Pattern;
@RequestMapping("/api/custom-quote-requests") @RequestMapping("/api/custom-quote-requests")
public class CustomQuoteRequestController { public class CustomQuoteRequestController {
private static final Logger logger = LoggerFactory.getLogger(CustomQuoteRequestController.class);
private final CustomQuoteRequestRepository requestRepo; private final CustomQuoteRequestRepository requestRepo;
private final CustomQuoteRequestAttachmentRepository attachmentRepo; private final CustomQuoteRequestAttachmentRepository attachmentRepo;
private final com.printcalculator.service.ClamAVService clamAVService; private final ClamAVService clamAVService;
private final EmailNotificationService emailNotificationService;
@Value("${app.mail.contact-request.admin.enabled:true}")
private boolean contactRequestAdminMailEnabled;
@Value("${app.mail.contact-request.admin.address:infog@3d-fab.ch}")
private String contactRequestAdminMailAddress;
// 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();
@@ -59,17 +76,19 @@ public class CustomQuoteRequestController {
public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo, public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
CustomQuoteRequestAttachmentRepository attachmentRepo, CustomQuoteRequestAttachmentRepository attachmentRepo,
com.printcalculator.service.ClamAVService clamAVService) { ClamAVService clamAVService,
EmailNotificationService emailNotificationService) {
this.requestRepo = requestRepo; this.requestRepo = requestRepo;
this.attachmentRepo = attachmentRepo; this.attachmentRepo = attachmentRepo;
this.clamAVService = clamAVService; this.clamAVService = clamAVService;
this.emailNotificationService = emailNotificationService;
} }
// 1. Create Custom Quote Request // 1. Create Custom Quote Request
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Transactional @Transactional
public ResponseEntity<CustomQuoteRequest> createCustomQuoteRequest( public ResponseEntity<CustomQuoteRequest> createCustomQuoteRequest(
@Valid @RequestPart("request") com.printcalculator.dto.QuoteRequestDto requestDto, @Valid @RequestPart("request") QuoteRequestDto requestDto,
@RequestPart(value = "files", required = false) List<MultipartFile> files @RequestPart(value = "files", required = false) List<MultipartFile> files
) throws IOException { ) throws IOException {
if (!requestDto.isAcceptTerms() || !requestDto.isAcceptPrivacy()) { if (!requestDto.isAcceptTerms() || !requestDto.isAcceptPrivacy()) {
@@ -96,6 +115,7 @@ public class CustomQuoteRequestController {
request = requestRepo.save(request); request = requestRepo.save(request);
// 2. Handle Attachments // 2. Handle Attachments
int attachmentsCount = 0;
if (files != null && !files.isEmpty()) { if (files != null && !files.isEmpty()) {
if (files.size() > 15) { if (files.size() > 15) {
throw new IOException("Too many files. Max 15 allowed."); throw new IOException("Too many files. Max 15 allowed.");
@@ -148,9 +168,12 @@ public class CustomQuoteRequestController {
try (InputStream inputStream = file.getInputStream()) { try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, absolutePath, StandardCopyOption.REPLACE_EXISTING); Files.copy(inputStream, absolutePath, StandardCopyOption.REPLACE_EXISTING);
} }
attachmentsCount++;
} }
} }
sendAdminContactRequestNotification(request, attachmentsCount);
return ResponseEntity.ok(request); return ResponseEntity.ok(request);
} }
@@ -203,4 +226,42 @@ public class CustomQuoteRequestController {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path");
} }
} }
private void sendAdminContactRequestNotification(CustomQuoteRequest request, int attachmentsCount) {
if (!contactRequestAdminMailEnabled) {
return;
}
if (contactRequestAdminMailAddress == null || contactRequestAdminMailAddress.isBlank()) {
logger.warn("Contact request admin notification enabled but no admin address configured.");
return;
}
Map<String, Object> templateData = new HashMap<>();
templateData.put("requestId", request.getId());
templateData.put("createdAt", request.getCreatedAt());
templateData.put("requestType", safeValue(request.getRequestType()));
templateData.put("customerType", safeValue(request.getCustomerType()));
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());
emailNotificationService.sendEmail(
contactRequestAdminMailAddress,
"Nuova richiesta di contatto #" + request.getId(),
"contact-request-admin",
templateData
);
}
private String safeValue(String value) {
if (value == null || value.isBlank()) {
return "-";
}
return value;
}
} }

View File

@@ -30,6 +30,8 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -144,9 +146,13 @@ public class AdminOrderController {
if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) { if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) {
throw new ResponseStatusException(NOT_FOUND, "File not available"); throw new ResponseStatusException(NOT_FOUND, "File not available");
} }
Path safeRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId);
if (safeRelativePath == null) {
throw new ResponseStatusException(NOT_FOUND, "File not available");
}
try { try {
Resource resource = storageService.loadAsResource(Paths.get(relativePath)); Resource resource = storageService.loadAsResource(safeRelativePath);
MediaType contentType = MediaType.APPLICATION_OCTET_STREAM; MediaType contentType = MediaType.APPLICATION_OCTET_STREAM;
if (item.getMimeType() != null && !item.getMimeType().isBlank()) { if (item.getMimeType() != null && !item.getMimeType().isBlank()) {
try { try {
@@ -276,9 +282,9 @@ public class AdminOrderController {
private ResponseEntity<byte[]> generateDocument(Order order, boolean isConfirmation) { private ResponseEntity<byte[]> generateDocument(Order order, boolean isConfirmation) {
String displayOrderNumber = getDisplayOrderNumber(order); String displayOrderNumber = getDisplayOrderNumber(order);
if (isConfirmation) { if (isConfirmation) {
String relativePath = "orders/" + order.getId() + "/documents/confirmation-" + displayOrderNumber + ".pdf"; Path relativePath = buildConfirmationPdfRelativePath(order.getId(), displayOrderNumber);
try { try {
byte[] existingPdf = storageService.loadAsResource(Paths.get(relativePath)).getInputStream().readAllBytes(); byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes();
return ResponseEntity.ok() return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"confirmation-" + displayOrderNumber + ".pdf\"") .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"confirmation-" + displayOrderNumber + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF) .contentType(MediaType.APPLICATION_PDF)
@@ -298,4 +304,24 @@ public class AdminOrderController {
.contentType(MediaType.APPLICATION_PDF) .contentType(MediaType.APPLICATION_PDF)
.body(pdf); .body(pdf);
} }
private Path resolveOrderItemRelativePath(String storedRelativePath, UUID orderId, UUID orderItemId) {
try {
Path candidate = Path.of(storedRelativePath).normalize();
if (candidate.isAbsolute()) {
return null;
}
Path expectedPrefix = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString());
if (!candidate.startsWith(expectedPrefix)) {
return null;
}
return candidate;
} catch (InvalidPathException e) {
return null;
}
}
private Path buildConfirmationPdfRelativePath(UUID orderId, String orderNumber) {
return Path.of("orders", orderId.toString(), "documents", "confirmation-" + orderNumber + ".pdf");
}
} }

View File

@@ -12,7 +12,7 @@ import java.util.concurrent.ConcurrentHashMap;
public class AdminLoginThrottleService { public class AdminLoginThrottleService {
private static final long BASE_DELAY_SECONDS = 2L; private static final long BASE_DELAY_SECONDS = 2L;
private static final long MAX_DELAY_SECONDS = 3600L; private static final long MAX_DELAY_SECONDS = 3601L;
private final ConcurrentHashMap<String, LoginAttemptState> attemptsByClient = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, LoginAttemptState> attemptsByClient = new ConcurrentHashMap<>();
private final boolean trustProxyHeaders; private final boolean trustProxyHeaders;

View File

@@ -21,10 +21,12 @@ import java.nio.file.StandardCopyOption;
public class FileSystemStorageService implements StorageService { public class FileSystemStorageService implements StorageService {
private final Path rootLocation; private final Path rootLocation;
private final Path normalizedRootLocation;
private final ClamAVService clamAVService; private final ClamAVService clamAVService;
public FileSystemStorageService(@Value("${storage.location:storage_orders}") String storageLocation, ClamAVService clamAVService) { public FileSystemStorageService(@Value("${storage.location:storage_orders}") String storageLocation, ClamAVService clamAVService) {
this.rootLocation = Paths.get(storageLocation); this.rootLocation = Paths.get(storageLocation);
this.normalizedRootLocation = this.rootLocation.toAbsolutePath().normalize();
this.clamAVService = clamAVService; this.clamAVService = clamAVService;
} }
@@ -39,10 +41,7 @@ public class FileSystemStorageService implements StorageService {
@Override @Override
public void store(MultipartFile file, Path destinationRelativePath) throws IOException { public void store(MultipartFile file, Path destinationRelativePath) throws IOException {
Path destinationFile = this.rootLocation.resolve(destinationRelativePath).normalize().toAbsolutePath(); Path destinationFile = resolveInsideStorage(destinationRelativePath);
if (!destinationFile.getParent().startsWith(this.rootLocation.toAbsolutePath())) {
throw new StorageException("Cannot store file outside current directory.");
}
// 1. Salva prima il file su disco per evitare problemi di stream con file grandi // 1. Salva prima il file su disco per evitare problemi di stream con file grandi
Files.createDirectories(destinationFile.getParent()); Files.createDirectories(destinationFile.getParent());
@@ -63,32 +62,46 @@ public class FileSystemStorageService implements StorageService {
@Override @Override
public void store(Path source, Path destinationRelativePath) throws IOException { public void store(Path source, Path destinationRelativePath) throws IOException {
Path destinationFile = this.rootLocation.resolve(destinationRelativePath).normalize().toAbsolutePath(); Path destinationFile = resolveInsideStorage(destinationRelativePath);
if (!destinationFile.getParent().startsWith(this.rootLocation.toAbsolutePath())) {
throw new StorageException("Cannot store file outside current directory.");
}
Files.createDirectories(destinationFile.getParent()); Files.createDirectories(destinationFile.getParent());
Files.copy(source, destinationFile, StandardCopyOption.REPLACE_EXISTING); Files.copy(source, destinationFile, StandardCopyOption.REPLACE_EXISTING);
} }
@Override @Override
public void delete(Path path) throws IOException { public void delete(Path path) throws IOException {
Path file = rootLocation.resolve(path); Path file = resolveInsideStorage(path);
Files.deleteIfExists(file); Files.deleteIfExists(file);
} }
@Override @Override
public Resource loadAsResource(Path path) throws IOException { public Resource loadAsResource(Path path) throws IOException {
try { try {
Path file = rootLocation.resolve(path); Path file = resolveInsideStorage(path);
Resource resource = new UrlResource(file.toUri()); Resource resource = new UrlResource(file.toUri());
if (resource.exists() || resource.isReadable()) { if (resource.exists() || resource.isReadable()) {
return resource; return resource;
} else { } else {
throw new RuntimeException("Could not read file: " + path); throw new StorageException("Could not read file: " + path);
} }
} catch (MalformedURLException e) { } catch (MalformedURLException e) {
throw new RuntimeException("Could not read file: " + path, e); throw new StorageException("Could not read file: " + path, e);
} }
} }
private Path resolveInsideStorage(Path relativePath) {
if (relativePath == null) {
throw new StorageException("Path is required.");
}
Path normalizedRelative = relativePath.normalize();
if (normalizedRelative.isAbsolute()) {
throw new StorageException("Cannot access absolute paths.");
}
Path resolved = normalizedRootLocation.resolve(normalizedRelative).normalize();
if (!resolved.startsWith(normalizedRootLocation)) {
throw new StorageException("Cannot access files outside storage root.");
}
return resolved;
}
} }

View File

@@ -6,21 +6,37 @@ import com.printcalculator.model.ModelDimensions;
import com.printcalculator.model.PrintStats; import com.printcalculator.model.PrintStats;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.BufferedWriter;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.logging.Logger; import java.util.logging.Logger;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
@Service @Service
public class SlicerService { public class SlicerService {
@@ -30,17 +46,20 @@ public class SlicerService {
private static final Pattern SIZE_Y_PATTERN = Pattern.compile("(?m)^\\s*size_y\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$"); private static final Pattern SIZE_Y_PATTERN = Pattern.compile("(?m)^\\s*size_y\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$");
private static final Pattern SIZE_Z_PATTERN = Pattern.compile("(?m)^\\s*size_z\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$"); private static final Pattern SIZE_Z_PATTERN = Pattern.compile("(?m)^\\s*size_z\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$");
private final String slicerPath; private final String trustedSlicerPath;
private final String trustedAssimpPath;
private final ProfileManager profileManager; private final ProfileManager profileManager;
private final GCodeParser gCodeParser; private final GCodeParser gCodeParser;
private final ObjectMapper mapper; private final ObjectMapper mapper;
public SlicerService( public SlicerService(
@Value("${slicer.path}") String slicerPath, @Value("${slicer.path}") String slicerPath,
@Value("${assimp.path:assimp}") String assimpPath,
ProfileManager profileManager, ProfileManager profileManager,
GCodeParser gCodeParser, GCodeParser gCodeParser,
ObjectMapper mapper) { ObjectMapper mapper) {
this.slicerPath = slicerPath; this.trustedSlicerPath = normalizeExecutablePath(slicerPath);
this.trustedAssimpPath = normalizeExecutablePath(assimpPath);
this.profileManager = profileManager; this.profileManager = profileManager;
this.gCodeParser = gCodeParser; this.gCodeParser = gCodeParser;
this.mapper = mapper; this.mapper = mapper;
@@ -83,17 +102,25 @@ public class SlicerService {
basename = basename.substring(0, basename.length() - 4); basename = basename.substring(0, basename.length() - 4);
} }
Path slicerLogPath = tempDir.resolve("orcaslicer.log"); Path slicerLogPath = tempDir.resolve("orcaslicer.log");
String machineProfilePath = requireSafeArgument(mFile.getAbsolutePath(), "machine profile path");
String processProfilePath = requireSafeArgument(pFile.getAbsolutePath(), "process profile path");
String filamentProfilePath = requireSafeArgument(fFile.getAbsolutePath(), "filament profile path");
String outputDirPath = requireSafeArgument(tempDir.toAbsolutePath().toString(), "output directory path");
String inputModelPath = requireSafeArgument(inputStl.getAbsolutePath(), "input model path");
List<String> slicerInputPaths = resolveSlicerInputPaths(inputStl, inputModelPath, tempDir);
// 3. Run slicer. Retry with arrange only for out-of-volume style failures. // 3. Run slicer. Retry with arrange only for out-of-volume style failures.
for (boolean useArrange : new boolean[]{false, true}) { for (boolean useArrange : new boolean[]{false, true}) {
List<String> command = new ArrayList<>(); // Build process arguments explicitly to avoid shell interpretation and command injection.
command.add(slicerPath); ProcessBuilder pb = new ProcessBuilder();
List<String> command = pb.command();
command.add(trustedSlicerPath);
command.add("--load-settings"); command.add("--load-settings");
command.add(mFile.getAbsolutePath()); command.add(machineProfilePath);
command.add("--load-settings"); command.add("--load-settings");
command.add(pFile.getAbsolutePath()); command.add(processProfilePath);
command.add("--load-filaments"); command.add("--load-filaments");
command.add(fFile.getAbsolutePath()); command.add(filamentProfilePath);
command.add("--ensure-on-bed"); command.add("--ensure-on-bed");
if (useArrange) { if (useArrange) {
command.add("--arrange"); command.add("--arrange");
@@ -102,13 +129,12 @@ public class SlicerService {
command.add("--slice"); command.add("--slice");
command.add("0"); command.add("0");
command.add("--outputdir"); command.add("--outputdir");
command.add(tempDir.toAbsolutePath().toString()); command.add(outputDirPath);
command.add(inputStl.getAbsolutePath()); command.addAll(slicerInputPaths);
logger.info("Executing Slicer" + (useArrange ? " (retry with arrange)" : "") + ": " + String.join(" ", command)); logger.info("Executing Slicer" + (useArrange ? " (retry with arrange)" : "") + ": " + String.join(" ", command));
Files.deleteIfExists(slicerLogPath); Files.deleteIfExists(slicerLogPath);
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(tempDir.toFile()); pb.directory(tempDir.toFile());
pb.redirectErrorStream(true); pb.redirectErrorStream(true);
pb.redirectOutput(slicerLogPath.toFile()); pb.redirectOutput(slicerLogPath.toFile());
@@ -161,13 +187,13 @@ public class SlicerService {
try { try {
tempDir = Files.createTempDirectory("slicer_info_"); tempDir = Files.createTempDirectory("slicer_info_");
Path infoLogPath = tempDir.resolve("orcaslicer-info.log"); Path infoLogPath = tempDir.resolve("orcaslicer-info.log");
String inputModelPath = requireSafeArgument(inputModel.getAbsolutePath(), "input model path");
List<String> command = new ArrayList<>(); ProcessBuilder pb = new ProcessBuilder();
command.add(slicerPath); List<String> infoCommand = pb.command();
command.add("--info"); infoCommand.add(trustedSlicerPath);
command.add(inputModel.getAbsolutePath()); infoCommand.add("--info");
infoCommand.add(inputModelPath);
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(tempDir.toFile()); pb.directory(tempDir.toFile());
pb.redirectErrorStream(true); pb.redirectErrorStream(true);
pb.redirectOutput(infoLogPath.toFile()); pb.redirectOutput(infoLogPath.toFile());
@@ -267,4 +293,547 @@ public class SlicerService {
|| normalized.contains("no object is fully inside the print volume") || normalized.contains("no object is fully inside the print volume")
|| normalized.contains("calc_exclude_triangles"); || normalized.contains("calc_exclude_triangles");
} }
private List<String> resolveSlicerInputPaths(File inputModel, String inputModelPath, Path tempDir)
throws IOException, InterruptedException {
if (!inputModel.getName().toLowerCase().endsWith(".3mf")) {
return List.of(inputModelPath);
}
List<String> convertedStlPaths = convert3mfToStlInputPaths(inputModel, tempDir);
logger.info("Converted 3MF to " + convertedStlPaths.size() + " STL file(s) for slicing.");
return convertedStlPaths;
}
private List<String> convert3mfToStlInputPaths(File input3mf, Path tempDir) throws IOException, InterruptedException {
Path conversionOutputDir = tempDir.resolve("converted-from-3mf");
Files.createDirectories(conversionOutputDir);
String conversionOutputStlPath = requireSafeArgument(
conversionOutputDir.resolve("converted.stl").toAbsolutePath().toString(),
"3MF conversion output STL path"
);
String conversionOutputObjPath = requireSafeArgument(
conversionOutputDir.resolve("converted.obj").toAbsolutePath().toString(),
"3MF conversion output OBJ path"
);
String input3mfPath = requireSafeArgument(input3mf.getAbsolutePath(), "input 3MF path");
Path convertedStl = Path.of(conversionOutputStlPath);
String stlLog = runAssimpExport(input3mfPath, conversionOutputStlPath, tempDir.resolve("assimp-convert-stl.log"));
if (hasRenderableGeometry(convertedStl)) {
return List.of(convertedStl.toString());
}
logger.warning("Assimp STL conversion produced empty geometry. Retrying conversion to OBJ.");
Path convertedObj = Path.of(conversionOutputObjPath);
String objLog = runAssimpExport(input3mfPath, conversionOutputObjPath, tempDir.resolve("assimp-convert-obj.log"));
if (hasRenderableGeometry(convertedObj)) {
return List.of(convertedObj.toString());
}
Path fallbackStl = conversionOutputDir.resolve("converted-fallback.stl");
long fallbackTriangles = convert3mfArchiveToAsciiStl(input3mf.toPath(), fallbackStl);
if (fallbackTriangles > 0 && hasRenderableGeometry(fallbackStl)) {
logger.warning("Assimp conversion produced empty geometry. Fallback 3MF XML extractor generated "
+ fallbackTriangles + " triangles.");
return List.of(fallbackStl.toString());
}
throw new IOException("3MF conversion produced no renderable geometry (STL+OBJ). STL log: "
+ stlLog + " OBJ log: " + objLog);
}
private String runAssimpExport(String input3mfPath, String outputModelPath, Path conversionLogPath)
throws IOException, InterruptedException {
ProcessBuilder conversionPb = new ProcessBuilder();
List<String> conversionCommand = conversionPb.command();
conversionCommand.add(trustedAssimpPath);
conversionCommand.add("export");
conversionCommand.add(input3mfPath);
conversionCommand.add(outputModelPath);
logger.info("Converting 3MF with Assimp: " + String.join(" ", conversionCommand));
Files.deleteIfExists(conversionLogPath);
conversionPb.redirectErrorStream(true);
conversionPb.redirectOutput(conversionLogPath.toFile());
Process conversionProcess = conversionPb.start();
boolean conversionFinished = conversionProcess.waitFor(3, TimeUnit.MINUTES);
if (!conversionFinished) {
conversionProcess.destroyForcibly();
throw new IOException("3MF conversion timed out");
}
String conversionLog = Files.exists(conversionLogPath)
? Files.readString(conversionLogPath, StandardCharsets.UTF_8)
: "";
if (conversionProcess.exitValue() != 0) {
throw new IOException("3MF conversion failed with exit code "
+ conversionProcess.exitValue() + ": " + conversionLog);
}
return conversionLog;
}
private boolean hasRenderableGeometry(Path modelPath) throws IOException {
if (!Files.isRegularFile(modelPath) || Files.size(modelPath) == 0) {
return false;
}
String fileName = modelPath.getFileName().toString().toLowerCase();
if (fileName.endsWith(".obj")) {
try (var lines = Files.lines(modelPath)) {
return lines.map(String::trim).anyMatch(line -> line.startsWith("f "));
}
}
if (fileName.endsWith(".stl")) {
long size = Files.size(modelPath);
if (size <= 84) {
return false;
}
byte[] header = new byte[84];
try (InputStream is = Files.newInputStream(modelPath)) {
int read = is.read(header);
if (read < 84) {
return false;
}
}
long triangleCount = ((long) (header[80] & 0xff))
| (((long) (header[81] & 0xff)) << 8)
| (((long) (header[82] & 0xff)) << 16)
| (((long) (header[83] & 0xff)) << 24);
if (triangleCount > 0) {
return true;
}
try (var lines = Files.lines(modelPath)) {
return lines.limit(2000).anyMatch(line -> line.contains("facet normal"));
}
}
return true;
}
private long convert3mfArchiveToAsciiStl(Path input3mf, Path outputStl) throws IOException {
Map<String, ThreeMfModelDocument> modelCache = new HashMap<>();
long[] triangleCount = new long[]{0L};
try (ZipFile zipFile = new ZipFile(input3mf.toFile());
BufferedWriter writer = Files.newBufferedWriter(outputStl, StandardCharsets.UTF_8)) {
writer.write("solid converted\n");
ThreeMfModelDocument rootModel = loadThreeMfModel(zipFile, modelCache, "3D/3dmodel.model");
Element build = findFirstChildByLocalName(rootModel.rootElement(), "build");
if (build == null) {
throw new IOException("3MF build section not found in root model");
}
for (Element item : findChildrenByLocalName(build, "item")) {
if ("0".equals(getAttributeByLocalName(item, "printable"))) {
continue;
}
String objectId = getAttributeByLocalName(item, "objectid");
if (objectId == null || objectId.isBlank()) {
continue;
}
Transform itemTransform = parseTransform(getAttributeByLocalName(item, "transform"));
writeObjectTriangles(
zipFile,
modelCache,
rootModel.modelPath(),
objectId,
itemTransform,
writer,
triangleCount,
new HashSet<>(),
0
);
}
writer.write("endsolid converted\n");
} catch (IOException e) {
throw e;
} catch (Exception e) {
throw new IOException("3MF fallback conversion failed: " + e.getMessage(), e);
}
return triangleCount[0];
}
private void writeObjectTriangles(
ZipFile zipFile,
Map<String, ThreeMfModelDocument> modelCache,
String modelPath,
String objectId,
Transform transform,
BufferedWriter writer,
long[] triangleCount,
Set<String> recursionGuard,
int depth
) throws Exception {
if (depth > 64) {
throw new IOException("3MF component nesting too deep");
}
String guardKey = modelPath + "#" + objectId;
if (!recursionGuard.add(guardKey)) {
return;
}
try {
ThreeMfModelDocument modelDocument = loadThreeMfModel(zipFile, modelCache, modelPath);
Element objectElement = modelDocument.objectsById().get(objectId);
if (objectElement == null) {
return;
}
Element mesh = findFirstChildByLocalName(objectElement, "mesh");
if (mesh != null) {
writeMeshTriangles(mesh, transform, writer, triangleCount);
}
Element components = findFirstChildByLocalName(objectElement, "components");
if (components != null) {
for (Element component : findChildrenByLocalName(components, "component")) {
String childObjectId = getAttributeByLocalName(component, "objectid");
if (childObjectId == null || childObjectId.isBlank()) {
continue;
}
String componentPath = getAttributeByLocalName(component, "path");
String resolvedModelPath = (componentPath == null || componentPath.isBlank())
? modelDocument.modelPath()
: normalizeZipPath(componentPath);
Transform componentTransform = parseTransform(getAttributeByLocalName(component, "transform"));
Transform combinedTransform = transform.multiply(componentTransform);
writeObjectTriangles(
zipFile,
modelCache,
resolvedModelPath,
childObjectId,
combinedTransform,
writer,
triangleCount,
recursionGuard,
depth + 1
);
}
}
} finally {
recursionGuard.remove(guardKey);
}
}
private void writeMeshTriangles(
Element meshElement,
Transform transform,
BufferedWriter writer,
long[] triangleCount
) throws IOException {
Element verticesElement = findFirstChildByLocalName(meshElement, "vertices");
Element trianglesElement = findFirstChildByLocalName(meshElement, "triangles");
if (verticesElement == null || trianglesElement == null) {
return;
}
List<Vec3> vertices = new java.util.ArrayList<>();
for (Element vertex : findChildrenByLocalName(verticesElement, "vertex")) {
Double x = parseDoubleAttribute(vertex, "x");
Double y = parseDoubleAttribute(vertex, "y");
Double z = parseDoubleAttribute(vertex, "z");
if (x == null || y == null || z == null) {
continue;
}
vertices.add(new Vec3(x, y, z));
}
if (vertices.isEmpty()) {
return;
}
for (Element triangle : findChildrenByLocalName(trianglesElement, "triangle")) {
Integer v1 = parseIntAttribute(triangle, "v1");
Integer v2 = parseIntAttribute(triangle, "v2");
Integer v3 = parseIntAttribute(triangle, "v3");
if (v1 == null || v2 == null || v3 == null) {
continue;
}
if (v1 < 0 || v2 < 0 || v3 < 0 || v1 >= vertices.size() || v2 >= vertices.size() || v3 >= vertices.size()) {
continue;
}
Vec3 p1 = transform.apply(vertices.get(v1));
Vec3 p2 = transform.apply(vertices.get(v2));
Vec3 p3 = transform.apply(vertices.get(v3));
writeAsciiFacet(writer, p1, p2, p3);
triangleCount[0]++;
}
}
private void writeAsciiFacet(BufferedWriter writer, Vec3 p1, Vec3 p2, Vec3 p3) throws IOException {
Vec3 normal = computeNormal(p1, p2, p3);
writer.write("facet normal " + normal.x() + " " + normal.y() + " " + normal.z() + "\n");
writer.write(" outer loop\n");
writer.write(" vertex " + p1.x() + " " + p1.y() + " " + p1.z() + "\n");
writer.write(" vertex " + p2.x() + " " + p2.y() + " " + p2.z() + "\n");
writer.write(" vertex " + p3.x() + " " + p3.y() + " " + p3.z() + "\n");
writer.write(" endloop\n");
writer.write("endfacet\n");
}
private Vec3 computeNormal(Vec3 a, Vec3 b, Vec3 c) {
double ux = b.x() - a.x();
double uy = b.y() - a.y();
double uz = b.z() - a.z();
double vx = c.x() - a.x();
double vy = c.y() - a.y();
double vz = c.z() - a.z();
double nx = uy * vz - uz * vy;
double ny = uz * vx - ux * vz;
double nz = ux * vy - uy * vx;
double length = Math.sqrt(nx * nx + ny * ny + nz * nz);
if (length <= 1e-12) {
return new Vec3(0.0, 0.0, 0.0);
}
return new Vec3(nx / length, ny / length, nz / length);
}
private ThreeMfModelDocument loadThreeMfModel(
ZipFile zipFile,
Map<String, ThreeMfModelDocument> modelCache,
String modelPath
) throws Exception {
String normalizedPath = normalizeZipPath(modelPath);
ThreeMfModelDocument cached = modelCache.get(normalizedPath);
if (cached != null) {
return cached;
}
ZipEntry entry = zipFile.getEntry(normalizedPath);
if (entry == null) {
throw new IOException("3MF model entry not found: " + normalizedPath);
}
Document document = parseXmlDocument(zipFile, entry);
Element root = document.getDocumentElement();
Map<String, Element> objectsById = new HashMap<>();
Element resources = findFirstChildByLocalName(root, "resources");
if (resources != null) {
for (Element objectElement : findChildrenByLocalName(resources, "object")) {
String id = getAttributeByLocalName(objectElement, "id");
if (id != null && !id.isBlank()) {
objectsById.put(id, objectElement);
}
}
}
ThreeMfModelDocument loaded = new ThreeMfModelDocument(normalizedPath, root, objectsById);
modelCache.put(normalizedPath, loaded);
return loaded;
}
private Document parseXmlDocument(ZipFile zipFile, ZipEntry entry) throws Exception {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
try {
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
} catch (Exception ignored) {
// Best-effort hardening.
}
try {
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
} catch (Exception ignored) {
// Best-effort hardening.
}
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
try (InputStream is = zipFile.getInputStream(entry)) {
return dbf.newDocumentBuilder().parse(is);
}
}
private String normalizeZipPath(String rawPath) throws IOException {
if (rawPath == null || rawPath.isBlank()) {
throw new IOException("Invalid empty 3MF model path");
}
String normalized = rawPath.trim().replace("\\", "/");
while (normalized.startsWith("/")) {
normalized = normalized.substring(1);
}
if (normalized.contains("..")) {
throw new IOException("Invalid 3MF model path: " + rawPath);
}
return normalized;
}
private List<Element> findChildrenByLocalName(Element parent, String localName) {
List<Element> result = new java.util.ArrayList<>();
NodeList children = parent.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node node = children.item(i);
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element element = (Element) node;
String nodeLocalName = element.getLocalName() != null ? element.getLocalName() : element.getTagName();
if (localName.equals(nodeLocalName)) {
result.add(element);
}
}
}
return result;
}
private Element findFirstChildByLocalName(Element parent, String localName) {
NodeList children = parent.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node node = children.item(i);
if (node.getNodeType() == Node.ELEMENT_NODE) {
Element element = (Element) node;
String nodeLocalName = element.getLocalName() != null ? element.getLocalName() : element.getTagName();
if (localName.equals(nodeLocalName)) {
return element;
}
}
}
return null;
}
private String getAttributeByLocalName(Element element, String localName) {
if (element.hasAttribute(localName)) {
return element.getAttribute(localName);
}
NamedNodeMap attrs = element.getAttributes();
for (int i = 0; i < attrs.getLength(); i++) {
Node attr = attrs.item(i);
String attrLocal = attr.getLocalName() != null ? attr.getLocalName() : attr.getNodeName();
if (localName.equals(attrLocal)) {
return attr.getNodeValue();
}
}
return null;
}
private Double parseDoubleAttribute(Element element, String attributeName) {
String value = getAttributeByLocalName(element, attributeName);
if (value == null || value.isBlank()) {
return null;
}
try {
return Double.parseDouble(value);
} catch (NumberFormatException ignored) {
return null;
}
}
private Integer parseIntAttribute(Element element, String attributeName) {
String value = getAttributeByLocalName(element, attributeName);
if (value == null || value.isBlank()) {
return null;
}
try {
return Integer.parseInt(value);
} catch (NumberFormatException ignored) {
return null;
}
}
private Transform parseTransform(String rawTransform) throws IOException {
if (rawTransform == null || rawTransform.isBlank()) {
return Transform.identity();
}
String[] tokens = rawTransform.trim().split("\\s+");
if (tokens.length != 12) {
throw new IOException("Invalid 3MF transform format: " + rawTransform);
}
double[] v = new double[12];
for (int i = 0; i < 12; i++) {
try {
v[i] = Double.parseDouble(tokens[i]);
} catch (NumberFormatException e) {
throw new IOException("Invalid number in 3MF transform: " + rawTransform, e);
}
}
return new Transform(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8], v[9], v[10], v[11]);
}
private record ThreeMfModelDocument(String modelPath, Element rootElement, Map<String, Element> objectsById) {
}
private record Vec3(double x, double y, double z) {
}
private record Transform(
double m00, double m01, double m02,
double m10, double m11, double m12,
double m20, double m21, double m22,
double tx, double ty, double tz
) {
static Transform identity() {
return new Transform(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0);
}
Transform multiply(Transform other) {
return new Transform(
m00 * other.m00 + m01 * other.m10 + m02 * other.m20,
m00 * other.m01 + m01 * other.m11 + m02 * other.m21,
m00 * other.m02 + m01 * other.m12 + m02 * other.m22,
m10 * other.m00 + m11 * other.m10 + m12 * other.m20,
m10 * other.m01 + m11 * other.m11 + m12 * other.m21,
m10 * other.m02 + m11 * other.m12 + m12 * other.m22,
m20 * other.m00 + m21 * other.m10 + m22 * other.m20,
m20 * other.m01 + m21 * other.m11 + m22 * other.m21,
m20 * other.m02 + m21 * other.m12 + m22 * other.m22,
m00 * other.tx + m01 * other.ty + m02 * other.tz + tx,
m10 * other.tx + m11 * other.ty + m12 * other.tz + ty,
m20 * other.tx + m21 * other.ty + m22 * other.tz + tz
);
}
Vec3 apply(Vec3 v) {
return new Vec3(
m00 * v.x() + m01 * v.y() + m02 * v.z() + tx,
m10 * v.x() + m11 * v.y() + m12 * v.z() + ty,
m20 * v.x() + m21 * v.y() + m22 * v.z() + tz
);
}
}
private String normalizeExecutablePath(String configuredPath) {
if (configuredPath == null || configuredPath.isBlank()) {
throw new IllegalArgumentException("slicer.path is required");
}
if (containsControlChars(configuredPath)) {
throw new IllegalArgumentException("slicer.path contains invalid control characters");
}
try {
return Path.of(configuredPath.trim()).normalize().toString();
} catch (InvalidPathException e) {
throw new IllegalArgumentException("Invalid slicer.path: " + configuredPath, e);
}
}
private String requireSafeArgument(String value, String argName) throws IOException {
if (value == null || value.isBlank()) {
throw new IOException("Missing required argument: " + argName);
}
if (containsControlChars(value)) {
throw new IOException("Invalid control characters in " + argName);
}
return value;
}
private boolean containsControlChars(String value) {
for (int i = 0; i < value.length(); i++) {
char ch = value.charAt(i);
if (ch == '\0' || ch == '\n' || ch == '\r') {
return true;
}
}
return false;
}
} }

View File

@@ -15,13 +15,17 @@ public class TwintPaymentService {
private final String twintPaymentUrl; private final String twintPaymentUrl;
public TwintPaymentService( public TwintPaymentService(
@Value("${payment.twint.url:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}") @Value("${payment.twint.url:}")
String twintPaymentUrl String twintPaymentUrl
) { ) {
this.twintPaymentUrl = twintPaymentUrl; this.twintPaymentUrl = twintPaymentUrl;
} }
public String getTwintPaymentUrl(com.printcalculator.entity.Order order) { public String getTwintPaymentUrl(com.printcalculator.entity.Order order) {
if (twintPaymentUrl == null || twintPaymentUrl.isBlank()) {
throw new IllegalStateException("TWINT_PAYMENT_URL is not configured");
}
StringBuilder urlBuilder = new StringBuilder(twintPaymentUrl); StringBuilder urlBuilder = new StringBuilder(twintPaymentUrl);
if (order != null) { if (order != null) {

View File

@@ -1,7 +1,8 @@
app.mail.enabled=false app.mail.enabled=false
app.mail.admin.enabled=false app.mail.admin.enabled=false
app.mail.contact-request.admin.enabled=false
# Admin back-office local test credentials # Admin back-office local test credentials
admin.password=ciaociao admin.password=local-admin-password
admin.session.secret=local-admin-session-secret-0123456789abcdef0123456789 admin.session.secret=local-session-secret-for-dev-only-000000000000000000000000
admin.session.ttl-minutes=480 admin.session.ttl-minutes=480

View File

@@ -4,7 +4,7 @@ server.port=8000
# Database Configuration # Database Configuration
spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/printcalc} spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/printcalc}
spring.datasource.username=${DB_USERNAME:printcalc} spring.datasource.username=${DB_USERNAME:printcalc}
spring.datasource.password=${DB_PASSWORD:printcalc_secret} spring.datasource.password=${DB_PASSWORD:}
spring.jpa.hibernate.ddl-auto=update spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.open-in-view=false spring.jpa.open-in-view=false
@@ -26,7 +26,7 @@ clamav.port=${CLAMAV_PORT:3310}
clamav.enabled=${CLAMAV_ENABLED:false} clamav.enabled=${CLAMAV_ENABLED:false}
# TWINT Configuration # TWINT Configuration
payment.twint.url=${TWINT_PAYMENT_URL:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.} payment.twint.url=${TWINT_PAYMENT_URL:}
# Mail Configuration # Mail Configuration
spring.mail.host=${MAIL_HOST:mail.infomaniak.com} spring.mail.host=${MAIL_HOST:mail.infomaniak.com}
@@ -41,6 +41,8 @@ app.mail.enabled=${APP_MAIL_ENABLED:true}
app.mail.from=${APP_MAIL_FROM:${MAIL_USERNAME:noreply@printcalculator.local}} app.mail.from=${APP_MAIL_FROM:${MAIL_USERNAME:noreply@printcalculator.local}}
app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true} 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.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:infog@3d-fab.ch}
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,121 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Nuova richiesta di contatto</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;
}
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>Nuova richiesta di contatto</h1>
<p>E' stata ricevuta una nuova richiesta dal form contatti/su misura.</p>
<table>
<tr>
<th>ID richiesta</th>
<td th:text="${requestId}">00000000-0000-0000-0000-000000000000</td>
</tr>
<tr>
<th>Data</th>
<td th:text="${createdAt}">2026-03-03T10:00:00Z</td>
</tr>
<tr>
<th>Tipo richiesta</th>
<td th:text="${requestType}">PRINT_SERVICE</td>
</tr>
<tr>
<th>Tipo cliente</th>
<td th:text="${customerType}">PRIVATE</td>
</tr>
<tr>
<th>Nome</th>
<td th:text="${name}">Mario Rossi</td>
</tr>
<tr>
<th>Azienda</th>
<td th:text="${companyName}">3D Fab SA</td>
</tr>
<tr>
<th>Contatto</th>
<td th:text="${contactPerson}">Mario Rossi</td>
</tr>
<tr>
<th>Email</th>
<td th:text="${email}">cliente@example.com</td>
</tr>
<tr>
<th>Telefono</th>
<td th:text="${phone}">+41 00 000 00 00</td>
</tr>
<tr>
<th>Messaggio</th>
<td th:text="${message}">Testo richiesta cliente...</td>
</tr>
<tr>
<th>Allegati</th>
<td th:text="${attachmentsCount}">0</td>
</tr>
</table>
<div class="footer">
<p>&copy; <span th:text="${currentYear}">2026</span> 3D-Fab - notifica automatica.</p>
</div>
</div>
</body>
</html>

View File

@@ -32,7 +32,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
}) })
@TestPropertySource(properties = { @TestPropertySource(properties = {
"admin.password=test-admin-password", "admin.password=test-admin-password",
"admin.session.secret=0123456789abcdef0123456789abcdef", "admin.session.secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"admin.session.ttl-minutes=60" "admin.session.ttl-minutes=60"
}) })
class AdminAuthSecurityTest { class AdminAuthSecurityTest {

View File

@@ -4,7 +4,7 @@ services:
container_name: print-calculator-db container_name: print-calculator-db
environment: environment:
- POSTGRES_USER=printcalc - POSTGRES_USER=printcalc
- POSTGRES_PASSWORD=printcalc_secret - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=printcalc - POSTGRES_DB=printcalc
ports: ports:
- "5432:5432" - "5432:5432"

View File

@@ -107,6 +107,7 @@
"test": { "test": {
"builder": "@angular-devkit/build-angular:karma", "builder": "@angular-devkit/build-angular:karma",
"options": { "options": {
"karmaConfig": "karma.conf.js",
"polyfills": [ "polyfills": [
"zone.js", "zone.js",
"zone.js/testing" "zone.js/testing"

40
frontend/karma.conf.js Normal file
View File

@@ -0,0 +1,40 @@
// Karma config dedicated to CI-safe Chrome execution.
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma'),
],
client: {
jasmine: {},
clearContext: false,
},
jasmineHtmlReporter: {
suppressAll: true,
},
coverageReporter: {
dir: require('path').join(__dirname, './coverage/frontend'),
subdir: '.',
reporters: [{ type: 'html' }, { type: 'text-summary' }],
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['ChromeHeadlessNoSandbox'],
customLaunchers: {
ChromeHeadlessNoSandbox: {
base: 'ChromeHeadless',
flags: ['--no-sandbox', '--disable-dev-shm-usage'],
},
},
singleRun: false,
restartOnFileChange: true,
});
};

View File

@@ -6,6 +6,6 @@ import { RouterOutlet } from '@angular/router';
standalone: true, standalone: true,
imports: [RouterOutlet], imports: [RouterOutlet],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss' styleUrl: './app.component.scss',
}) })
export class AppComponent {} export class AppComponent {}

View File

@@ -1,9 +1,21 @@
import { ApplicationConfig, provideZoneChangeDetection, importProvidersFrom } from '@angular/core'; import {
import { provideRouter, withComponentInputBinding, withInMemoryScrolling, withViewTransitions } from '@angular/router'; ApplicationConfig,
provideZoneChangeDetection,
importProvidersFrom,
} from '@angular/core';
import {
provideRouter,
withComponentInputBinding,
withInMemoryScrolling,
withViewTransitions,
} from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { provideTranslateHttpLoader, TranslateHttpLoader } from '@ngx-translate/http-loader'; import {
provideTranslateHttpLoader,
TranslateHttpLoader,
} from '@ngx-translate/http-loader';
import { adminAuthInterceptor } from './core/interceptors/admin-auth.interceptor'; import { adminAuthInterceptor } from './core/interceptors/admin-auth.interceptor';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
@@ -14,24 +26,22 @@ export const appConfig: ApplicationConfig = {
withComponentInputBinding(), withComponentInputBinding(),
withViewTransitions(), withViewTransitions(),
withInMemoryScrolling({ withInMemoryScrolling({
scrollPositionRestoration: 'top' scrollPositionRestoration: 'top',
}) }),
),
provideHttpClient(
withInterceptors([adminAuthInterceptor])
), ),
provideHttpClient(withInterceptors([adminAuthInterceptor])),
provideTranslateHttpLoader({ provideTranslateHttpLoader({
prefix: './assets/i18n/', prefix: './assets/i18n/',
suffix: '.json' suffix: '.json',
}), }),
importProvidersFrom( importProvidersFrom(
TranslateModule.forRoot({ TranslateModule.forRoot({
defaultLanguage: 'it', defaultLanguage: 'it',
loader: { loader: {
provide: TranslateLoader, provide: TranslateLoader,
useClass: TranslateHttpLoader useClass: TranslateHttpLoader,
} },
}) }),
) ),
] ],
}; };

View File

@@ -3,63 +3,79 @@ import { Routes } from '@angular/router';
const appChildRoutes: Routes = [ const appChildRoutes: Routes = [
{ {
path: '', path: '',
loadComponent: () => import('./features/home/home.component').then(m => m.HomeComponent) loadComponent: () =>
import('./features/home/home.component').then((m) => m.HomeComponent),
}, },
{ {
path: 'calculator', path: 'calculator',
loadChildren: () => import('./features/calculator/calculator.routes').then(m => m.CALCULATOR_ROUTES) loadChildren: () =>
import('./features/calculator/calculator.routes').then(
(m) => m.CALCULATOR_ROUTES,
),
}, },
{ {
path: 'shop', path: 'shop',
loadChildren: () => import('./features/shop/shop.routes').then(m => m.SHOP_ROUTES) loadChildren: () =>
import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES),
}, },
{ {
path: 'about', path: 'about',
loadChildren: () => import('./features/about/about.routes').then(m => m.ABOUT_ROUTES) loadChildren: () =>
import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES),
}, },
{ {
path: 'contact', path: 'contact',
loadChildren: () => import('./features/contact/contact.routes').then(m => m.CONTACT_ROUTES) loadChildren: () =>
import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES),
}, },
{ {
path: 'checkout', path: 'checkout',
loadComponent: () => import('./features/checkout/checkout.component').then(m => m.CheckoutComponent) loadComponent: () =>
import('./features/checkout/checkout.component').then(
(m) => m.CheckoutComponent,
),
}, },
{ {
path: 'order/:orderId', path: 'order/:orderId',
loadComponent: () => import('./features/order/order.component').then(m => m.OrderComponent) loadComponent: () =>
import('./features/order/order.component').then((m) => m.OrderComponent),
}, },
{ {
path: 'co/:orderId', path: 'co/:orderId',
loadComponent: () => import('./features/order/order.component').then(m => m.OrderComponent) loadComponent: () =>
import('./features/order/order.component').then((m) => m.OrderComponent),
}, },
{ {
path: '', path: '',
loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES) loadChildren: () =>
import('./features/legal/legal.routes').then((m) => m.LEGAL_ROUTES),
}, },
{ {
path: 'admin', path: 'admin',
loadChildren: () => import('./features/admin/admin.routes').then(m => m.ADMIN_ROUTES) loadChildren: () =>
import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES),
}, },
{ {
path: '**', path: '**',
redirectTo: '' redirectTo: '',
} },
]; ];
export const routes: Routes = [ export const routes: Routes = [
{ {
path: ':lang', path: ':lang',
loadComponent: () => import('./core/layout/layout.component').then(m => m.LayoutComponent), loadComponent: () =>
children: appChildRoutes import('./core/layout/layout.component').then((m) => m.LayoutComponent),
children: appChildRoutes,
}, },
{ {
path: '', path: '',
loadComponent: () => import('./core/layout/layout.component').then(m => m.LayoutComponent), loadComponent: () =>
children: appChildRoutes import('./core/layout/layout.component').then((m) => m.LayoutComponent),
children: appChildRoutes,
}, },
{ {
path: '**', path: '**',
redirectTo: '' redirectTo: '',
} },
]; ];

View File

@@ -17,25 +17,30 @@ export const PRODUCT_COLORS: ColorCategory[] = [
colors: [ colors: [
{ label: 'COLOR.NAME.BLACK', value: 'Black', hex: '#1a1a1a' }, // Not pure black for visibility { label: 'COLOR.NAME.BLACK', value: 'Black', hex: '#1a1a1a' }, // Not pure black for visibility
{ label: 'COLOR.NAME.WHITE', value: 'White', hex: '#f5f5f5' }, { label: 'COLOR.NAME.WHITE', value: 'White', hex: '#f5f5f5' },
{ label: 'COLOR.NAME.RED', value: 'Red', hex: '#d32f2f', outOfStock: true }, {
label: 'COLOR.NAME.RED',
value: 'Red',
hex: '#d32f2f',
outOfStock: true,
},
{ label: 'COLOR.NAME.BLUE', value: 'Blue', hex: '#1976d2' }, { label: 'COLOR.NAME.BLUE', value: 'Blue', hex: '#1976d2' },
{ label: 'COLOR.NAME.GREEN', value: 'Green', hex: '#388e3c' }, { label: 'COLOR.NAME.GREEN', value: 'Green', hex: '#388e3c' },
{ label: 'COLOR.NAME.YELLOW', value: 'Yellow', hex: '#fbc02d' } { label: 'COLOR.NAME.YELLOW', value: 'Yellow', hex: '#fbc02d' },
] ],
}, },
{ {
name: 'COLOR.CATEGORY_MATTE', name: 'COLOR.CATEGORY_MATTE',
colors: [ colors: [
{ label: 'COLOR.NAME.MATTE_BLACK', value: 'Matte Black', hex: '#2c2c2c' }, // Lighter charcoal for matte { label: 'COLOR.NAME.MATTE_BLACK', value: 'Matte Black', hex: '#2c2c2c' }, // Lighter charcoal for matte
{ label: 'COLOR.NAME.MATTE_WHITE', value: 'Matte White', hex: '#e0e0e0' }, { label: 'COLOR.NAME.MATTE_WHITE', value: 'Matte White', hex: '#e0e0e0' },
{ label: 'COLOR.NAME.MATTE_GRAY', value: 'Matte Gray', hex: '#757575' } { label: 'COLOR.NAME.MATTE_GRAY', value: 'Matte Gray', hex: '#757575' },
] ],
} },
]; ];
export function getColorHex(value: string): string { export function getColorHex(value: string): string {
for (const cat of PRODUCT_COLORS) { for (const cat of PRODUCT_COLORS) {
const found = cat.colors.find(c => c.value === value); const found = cat.colors.find((c) => c.value === value);
if (found) return found.hex; if (found) return found.hex;
} }
return '#facf0a'; // Default Brand Color if not found return '#facf0a'; // Default Brand Color if not found

View File

@@ -25,13 +25,17 @@ export const adminAuthInterceptor: HttpInterceptorFn = (req, next) => {
return next(request).pipe( return next(request).pipe(
catchError((error: unknown) => { catchError((error: unknown) => {
if (!isLoginRequest && error instanceof HttpErrorResponse && error.status === 401) { if (
!isLoginRequest &&
error instanceof HttpErrorResponse &&
error.status === 401
) {
const lang = resolveLangFromUrl(router.url); const lang = resolveLangFromUrl(router.url);
if (!router.url.includes('/admin/login')) { if (!router.url.includes('/admin/login')) {
void router.navigate(['/', lang, 'admin', 'login']); void router.navigate(['/', lang, 'admin', 'login']);
} }
} }
return throwError(() => error); return throwError(() => error);
}) }),
); );
}; };

View File

@@ -6,9 +6,9 @@
</div> </div>
<div class="col links"> <div class="col links">
<a routerLink="/privacy">{{ 'FOOTER.PRIVACY' | translate }}</a> <a routerLink="/privacy">{{ "FOOTER.PRIVACY" | translate }}</a>
<a routerLink="/terms">{{ 'FOOTER.TERMS' | translate }}</a> <a routerLink="/terms">{{ "FOOTER.TERMS" | translate }}</a>
<a routerLink="/contact">{{ 'FOOTER.CONTACT' | translate }}</a> <a routerLink="/contact">{{ "FOOTER.CONTACT" | translate }}</a>
</div> </div>
<div class="col social"> <div class="col social">

View File

@@ -1,4 +1,4 @@
@use '../../../styles/patterns'; @use "../../../styles/patterns";
.footer { .footer {
background: var(--color-neutral-900); background: var(--color-neutral-900);
@@ -9,7 +9,7 @@
margin-top: auto; /* Push to bottom if content is short */ margin-top: auto; /* Push to bottom if content is short */
// Cross Hatch Pattern // Cross Hatch Pattern
&::before { &::before {
content: ''; content: "";
position: absolute; position: absolute;
inset: 0; inset: 0;
@include patterns.pattern-cross-hatch(var(--color-neutral-50), 20px, 1px); @include patterns.pattern-cross-hatch(var(--color-neutral-50), 20px, 1px);
@@ -37,8 +37,17 @@
} }
} }
.brand { font-weight: 700; color: white; display: block; margin-bottom: var(--space-2); } .brand {
.copyright { font-size: 0.875rem; color: var(--color-secondary-500); margin: 0; } font-weight: 700;
color: white;
display: block;
margin-bottom: var(--space-2);
}
.copyright {
font-size: 0.875rem;
color: var(--color-secondary-500);
margin: 0;
}
.links { .links {
display: flex; display: flex;
@@ -47,13 +56,20 @@
color: var(--color-neutral-300); color: var(--color-neutral-300);
font-size: 0.875rem; font-size: 0.875rem;
transition: color 0.2s; transition: color 0.2s;
&:hover { color: white; text-decoration: underline; } &:hover {
color: white;
text-decoration: underline;
}
} }
} }
.social { display: flex; gap: var(--space-3); } .social {
display: flex;
gap: var(--space-3);
}
.social-icon { .social-icon {
width: 24px; height: 24px; width: 24px;
height: 24px;
background-color: var(--color-neutral-800); background-color: var(--color-neutral-800);
border-radius: 50%; border-radius: 50%;
} }

View File

@@ -7,6 +7,6 @@ import { RouterLink } from '@angular/router';
standalone: true, standalone: true,
imports: [TranslateModule, RouterLink], imports: [TranslateModule, RouterLink],
templateUrl: './footer.component.html', templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss'] styleUrls: ['./footer.component.scss'],
}) })
export class FooterComponent {} export class FooterComponent {}

View File

@@ -8,6 +8,6 @@ import { FooterComponent } from './footer.component';
standalone: true, standalone: true,
imports: [RouterOutlet, NavbarComponent, FooterComponent], imports: [RouterOutlet, NavbarComponent, FooterComponent],
templateUrl: './layout.component.html', templateUrl: './layout.component.html',
styleUrl: './layout.component.scss' styleUrl: './layout.component.scss',
}) })
export class LayoutComponent {} export class LayoutComponent {}

View File

@@ -2,18 +2,43 @@
<div class="container navbar-inner"> <div class="container navbar-inner">
<a routerLink="/" class="brand">3D <span class="highlight">fab</span></a> <a routerLink="/" class="brand">3D <span class="highlight">fab</span></a>
<div class="mobile-toggle" (click)="toggleMenu()" [class.active]="isMenuOpen"> <div
class="mobile-toggle"
(click)="toggleMenu()"
[class.active]="isMenuOpen"
>
<span></span> <span></span>
<span></span> <span></span>
<span></span> <span></span>
</div> </div>
<nav class="nav-links" [class.open]="isMenuOpen"> <nav class="nav-links" [class.open]="isMenuOpen">
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" (click)="closeMenu()">{{ 'NAV.HOME' | translate }}</a> <a
<a routerLink="/calculator/basic" routerLinkActive="active" [routerLinkActiveOptions]="{exact: false}" (click)="closeMenu()">{{ 'NAV.CALCULATOR' | translate }}</a> routerLink="/"
<a routerLink="/shop" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.SHOP' | translate }}</a> routerLinkActive="active"
<a routerLink="/about" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.ABOUT' | translate }}</a> [routerLinkActiveOptions]="{ exact: true }"
<a routerLink="/contact" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.CONTACT' | translate }}</a> (click)="closeMenu()"
>{{ "NAV.HOME" | translate }}</a
>
<a
routerLink="/calculator/basic"
routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: false }"
(click)="closeMenu()"
>{{ "NAV.CALCULATOR" | translate }}</a
>
<a routerLink="/shop" routerLinkActive="active" (click)="closeMenu()">{{
"NAV.SHOP" | translate
}}</a>
<a routerLink="/about" routerLinkActive="active" (click)="closeMenu()">{{
"NAV.ABOUT" | translate
}}</a>
<a
routerLink="/contact"
routerLinkActive="active"
(click)="closeMenu()"
>{{ "NAV.CONTACT" | translate }}</a
>
</nav> </nav>
<div class="actions"> <div class="actions">
@@ -21,14 +46,28 @@
class="lang-switch" class="lang-switch"
[value]="langService.selectedLang()" [value]="langService.selectedLang()"
(change)="onLanguageChange($event)" (change)="onLanguageChange($event)"
[attr.aria-label]="'NAV.LANGUAGE_SELECTOR' | translate"> [attr.aria-label]="'NAV.LANGUAGE_SELECTOR' | translate"
>
@for (option of languageOptions; track option.value) { @for (option of languageOptions; track option.value) {
<option [value]="option.value">{{ option.label }}</option> <option [value]="option.value">{{ option.label }}</option>
} }
</select> </select>
<div class="icon-placeholder"> <div class="icon-placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg> <svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -19,7 +19,9 @@
color: var(--color-text); color: var(--color-text);
text-decoration: none; text-decoration: none;
} }
.highlight { color: var(--color-brand); } .highlight {
color: var(--color-brand);
}
.nav-links { .nav-links {
display: flex; display: flex;
@@ -31,7 +33,8 @@
text-decoration: none; text-decoration: none;
transition: color 0.2s; transition: color 0.2s;
&:hover, &.active { &:hover,
&.active {
color: var(--color-brand); color: var(--color-brand);
} }
} }
@@ -59,10 +62,15 @@
background-position: background-position:
calc(100% - 10px) calc(50% - 2px), calc(100% - 10px) calc(50% - 2px),
calc(100% - 5px) calc(50% - 2px); calc(100% - 5px) calc(50% - 2px);
background-size: 5px 5px, 5px 5px; background-size:
5px 5px,
5px 5px;
background-repeat: no-repeat; background-repeat: no-repeat;
&:hover { color: var(--color-text); border-color: var(--color-text); } &:hover {
color: var(--color-text);
border-color: var(--color-text);
}
&:focus-visible { &:focus-visible {
outline: 2px solid var(--color-brand); outline: 2px solid var(--color-brand);
outline-offset: 1px; outline-offset: 1px;
@@ -100,9 +108,15 @@
} }
&.active { &.active {
span:nth-child(1) { transform: translateY(8px) rotate(45deg); } span:nth-child(1) {
span:nth-child(2) { opacity: 0; } transform: translateY(8px) rotate(45deg);
span:nth-child(3) { transform: translateY(-8px) rotate(-45deg); } }
span:nth-child(2) {
opacity: 0;
}
span:nth-child(3) {
transform: translateY(-8px) rotate(-45deg);
}
} }
} }
@@ -150,6 +164,12 @@
} }
@keyframes slideDown { @keyframes slideDown {
from { opacity: 0; transform: translateY(-10px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }

View File

@@ -8,15 +8,18 @@ import { LanguageService } from '../services/language.service';
standalone: true, standalone: true,
imports: [RouterLink, RouterLinkActive, TranslateModule], imports: [RouterLink, RouterLinkActive, TranslateModule],
templateUrl: './navbar.component.html', templateUrl: './navbar.component.html',
styleUrls: ['./navbar.component.scss'] styleUrls: ['./navbar.component.scss'],
}) })
export class NavbarComponent { export class NavbarComponent {
isMenuOpen = false; isMenuOpen = false;
readonly languageOptions: Array<{ value: 'it' | 'en' | 'de' | 'fr'; label: string }> = [ readonly languageOptions: Array<{
value: 'it' | 'en' | 'de' | 'fr';
label: string;
}> = [
{ value: 'it', label: 'IT' }, { value: 'it', label: 'IT' },
{ value: 'en', label: 'EN' }, { value: 'en', label: 'EN' },
{ value: 'de', label: 'DE' }, { value: 'de', label: 'DE' },
{ value: 'fr', label: 'FR' } { value: 'fr', label: 'FR' },
]; ];
constructor(public langService: LanguageService) {} constructor(public langService: LanguageService) {}

View File

@@ -0,0 +1,106 @@
import { Subject } from 'rxjs';
import { DefaultUrlSerializer, Router, UrlTree } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { LanguageService } from './language.service';
describe('LanguageService', () => {
function createTranslateMock() {
const onLangChange = new Subject<{ lang: string }>();
const translate = {
currentLang: '',
addLangs: jasmine.createSpy('addLangs'),
setDefaultLang: jasmine.createSpy('setDefaultLang'),
use: jasmine.createSpy('use').and.callFake((lang: string) => {
translate.currentLang = lang;
onLangChange.next({ lang });
}),
onLangChange,
};
return translate as unknown as TranslateService;
}
function createRouterMock(initialUrl: string) {
const serializer = new DefaultUrlSerializer();
const events$ = new Subject<unknown>();
const createUrlTree = (
commands: unknown[],
extras?: { queryParams?: Record<string, string>; fragment?: string },
): UrlTree => {
const segments = commands
.filter((entry) => typeof entry === 'string' && entry !== '/')
.map((entry) => String(entry));
let url = `/${segments.join('/')}`;
if (url === '') {
url = '/';
}
const queryParams = extras?.queryParams ?? {};
const query = new URLSearchParams();
Object.entries(queryParams).forEach(([key, value]) => {
query.set(key, value);
});
const queryString = query.toString();
if (queryString) {
url += `?${queryString}`;
}
if (extras?.fragment) {
url += `#${extras.fragment}`;
}
return serializer.parse(url);
};
const router = {
url: initialUrl,
events: events$.asObservable(),
parseUrl: (url: string) => serializer.parse(url),
createUrlTree,
serializeUrl: (tree: UrlTree) => serializer.serialize(tree),
navigateByUrl: jasmine.createSpy('navigateByUrl'),
};
return router as unknown as Router;
}
it('prefixes URL with default language when missing', () => {
const translate = createTranslateMock();
const router = createRouterMock('/calculator?session=abc');
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const service = new LanguageService(translate, router);
expect(translate.use).toHaveBeenCalledWith('it');
expect(navigateSpy).toHaveBeenCalledTimes(1);
const firstCall = navigateSpy.calls.mostRecent();
const tree = firstCall.args[0] as UrlTree;
const navOptions = firstCall.args[1] as { replaceUrl: boolean };
expect(router.serializeUrl(tree)).toBe('/it/calculator?session=abc');
expect(navOptions.replaceUrl).toBeTrue();
});
it('switches language while preserving path and query params', () => {
const translate = createTranslateMock();
const router = createRouterMock('/it/calculator?session=abc&mode=advanced');
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
const service = new LanguageService(translate, router);
expect(navigateSpy).not.toHaveBeenCalled();
service.switchLang('de');
expect(translate.use).toHaveBeenCalledWith('de');
expect(navigateSpy).toHaveBeenCalledTimes(1);
const call = navigateSpy.calls.mostRecent();
const tree = call.args[0] as UrlTree;
expect(router.serializeUrl(tree)).toBe(
'/de/calculator?session=abc&mode=advanced',
);
});
});

View File

@@ -1,22 +1,33 @@
import { Injectable, signal } from '@angular/core'; import { Injectable, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { NavigationEnd, PRIMARY_OUTLET, Router, UrlTree } from '@angular/router'; import {
NavigationEnd,
PRIMARY_OUTLET,
Router,
UrlTree,
} from '@angular/router';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class LanguageService { export class LanguageService {
currentLang = signal<'it' | 'en' | 'de' | 'fr'>('it'); currentLang = signal<'it' | 'en' | 'de' | 'fr'>('it');
private readonly supportedLangs: Array<'it' | 'en' | 'de' | 'fr'> = ['it', 'en', 'de', 'fr']; private readonly supportedLangs: Array<'it' | 'en' | 'de' | 'fr'> = [
'it',
'en',
'de',
'fr',
];
constructor( constructor(
private translate: TranslateService, private translate: TranslateService,
private router: Router private router: Router,
) { ) {
this.translate.addLangs(this.supportedLangs); this.translate.addLangs(this.supportedLangs);
this.translate.setDefaultLang('it'); this.translate.setDefaultLang('it');
this.translate.onLangChange.subscribe(event => { this.translate.onLangChange.subscribe((event) => {
const lang = typeof event.lang === 'string' ? event.lang.toLowerCase() : null; const lang =
typeof event.lang === 'string' ? event.lang.toLowerCase() : null;
if (this.isSupportedLang(lang) && lang !== this.currentLang()) { if (this.isSupportedLang(lang) && lang !== this.currentLang()) {
this.currentLang.set(lang); this.currentLang.set(lang);
} }
@@ -27,11 +38,13 @@ export class LanguageService {
const queryLang = this.getQueryLang(initialTree); const queryLang = this.getQueryLang(initialTree);
const initialLang = this.isSupportedLang(initialSegments[0]) const initialLang = this.isSupportedLang(initialSegments[0])
? initialSegments[0] ? initialSegments[0]
: (this.isSupportedLang(queryLang) ? queryLang : 'it'); : this.isSupportedLang(queryLang)
? queryLang
: 'it';
this.applyLanguage(initialLang); this.applyLanguage(initialLang);
this.ensureLanguageInPath(initialTree); this.ensureLanguageInPath(initialTree);
this.router.events.subscribe(event => { this.router.events.subscribe((event) => {
if (!(event instanceof NavigationEnd)) { if (!(event instanceof NavigationEnd)) {
return; return;
} }
@@ -52,7 +65,10 @@ export class LanguageService {
let targetSegments: string[]; let targetSegments: string[];
if (segments.length === 0) { if (segments.length === 0) {
targetSegments = [lang]; targetSegments = [lang];
} else if (this.isSupportedLang(segments[0]) || this.looksLikeLangToken(segments[0])) { } else if (
this.isSupportedLang(segments[0]) ||
this.looksLikeLangToken(segments[0])
) {
targetSegments = [lang, ...segments.slice(1)]; targetSegments = [lang, ...segments.slice(1)];
} else { } else {
targetSegments = [lang, ...segments]; targetSegments = [lang, ...segments];
@@ -62,7 +78,8 @@ export class LanguageService {
} }
selectedLang(): 'it' | 'en' | 'de' | 'fr' { selectedLang(): 'it' | 'en' | 'de' | 'fr' {
const activeLang = typeof this.translate.currentLang === 'string' const activeLang =
typeof this.translate.currentLang === 'string'
? this.translate.currentLang.toLowerCase() ? this.translate.currentLang.toLowerCase()
: null; : null;
return this.isSupportedLang(activeLang) ? activeLang : this.currentLang(); return this.isSupportedLang(activeLang) ? activeLang : this.currentLang();
@@ -77,7 +94,9 @@ export class LanguageService {
} }
const queryLang = this.getQueryLang(urlTree); const queryLang = this.getQueryLang(urlTree);
const activeLang = this.isSupportedLang(queryLang) ? queryLang : this.currentLang(); const activeLang = this.isSupportedLang(queryLang)
? queryLang
: this.currentLang();
if (activeLang !== this.currentLang()) { if (activeLang !== this.currentLang()) {
this.applyLanguage(activeLang); this.applyLanguage(activeLang);
} }
@@ -99,7 +118,7 @@ export class LanguageService {
if (!primaryGroup) { if (!primaryGroup) {
return []; return [];
} }
return primaryGroup.segments.map(segment => segment.path.toLowerCase()); return primaryGroup.segments.map((segment) => segment.path.toLowerCase());
} }
private getQueryLang(urlTree: UrlTree): string | null { private getQueryLang(urlTree: UrlTree): string | null {
@@ -107,12 +126,19 @@ export class LanguageService {
return typeof lang === 'string' ? lang.toLowerCase() : null; return typeof lang === 'string' ? lang.toLowerCase() : null;
} }
private isSupportedLang(lang: string | null | undefined): lang is 'it' | 'en' | 'de' | 'fr' { private isSupportedLang(
return typeof lang === 'string' && this.supportedLangs.includes(lang as 'it' | 'en' | 'de' | 'fr'); lang: string | null | undefined,
): lang is 'it' | 'en' | 'de' | 'fr' {
return (
typeof lang === 'string' &&
this.supportedLangs.includes(lang as 'it' | 'en' | 'de' | 'fr')
);
} }
private looksLikeLangToken(segment: string | null | undefined): boolean { private looksLikeLangToken(segment: string | null | undefined): boolean {
return typeof segment === 'string' && /^[a-z]{2}(?:-[a-z]{2})?$/i.test(segment); return (
typeof segment === 'string' && /^[a-z]{2}(?:-[a-z]{2})?$/i.test(segment)
);
} }
private applyLanguage(lang: 'it' | 'en' | 'de' | 'fr'): void { private applyLanguage(lang: 'it' | 'en' | 'de' | 'fr'): void {
@@ -123,14 +149,20 @@ export class LanguageService {
this.currentLang.set(lang); this.currentLang.set(lang);
} }
private navigateIfChanged(currentTree: UrlTree, targetSegments: string[]): void { private navigateIfChanged(
currentTree: UrlTree,
targetSegments: string[],
): void {
const { lang: _unusedLang, ...queryParams } = currentTree.queryParams; const { lang: _unusedLang, ...queryParams } = currentTree.queryParams;
const targetTree = this.router.createUrlTree(['/', ...targetSegments], { const targetTree = this.router.createUrlTree(['/', ...targetSegments], {
queryParams, queryParams,
fragment: currentTree.fragment ?? undefined fragment: currentTree.fragment ?? undefined,
}); });
if (this.router.serializeUrl(targetTree) === this.router.serializeUrl(currentTree)) { if (
this.router.serializeUrl(targetTree) ===
this.router.serializeUrl(currentTree)
) {
return; return;
} }

View File

@@ -17,7 +17,7 @@ export interface QuoteRequestDto {
} }
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class QuoteRequestService { export class QuoteRequestService {
private http = inject(HttpClient); private http = inject(HttpClient);
@@ -28,12 +28,12 @@ export class QuoteRequestService {
// Append Request DTO as JSON Blob // Append Request DTO as JSON Blob
const requestBlob = new Blob([JSON.stringify(request)], { const requestBlob = new Blob([JSON.stringify(request)], {
type: 'application/json' type: 'application/json',
}); });
formData.append('request', requestBlob); formData.append('request', requestBlob);
// Append Files // Append Files
files.forEach(file => { files.forEach((file) => {
formData.append('files', file); formData.append('files', file);
}); });

View File

@@ -1,17 +1,16 @@
<section class="about-section"> <section class="about-section">
<div class="container split-layout"> <div class="container split-layout">
<!-- Left Column: Content --> <!-- Left Column: Content -->
<div class="text-content"> <div class="text-content">
<p class="eyebrow">{{ 'ABOUT.EYEBROW' | translate }}</p> <p class="eyebrow">{{ "ABOUT.EYEBROW" | translate }}</p>
<h1>{{ 'ABOUT.TITLE' | translate }}</h1> <h1>{{ "ABOUT.TITLE" | translate }}</h1>
<p class="subtitle">{{ 'ABOUT.SUBTITLE' | translate }}</p> <p class="subtitle">{{ "ABOUT.SUBTITLE" | translate }}</p>
<div class="divider"></div> <div class="divider"></div>
<p class="description">{{ 'ABOUT.HOW_TEXT' | translate }}</p> <p class="description">{{ "ABOUT.HOW_TEXT" | translate }}</p>
<br> <br />
<h2 class="passions-title">{{ 'ABOUT.PASSIONS_TITLE' | translate }}</h2> <h2 class="passions-title">{{ "ABOUT.PASSIONS_TITLE" | translate }}</h2>
<div class="tags-container"> <div class="tags-container">
@for (passion of passions; track passion.id) { @for (passion of passions; track passion.id) {
@@ -40,11 +39,18 @@
(keydown.space)="toggleSelectedMember('joe'); $event.preventDefault()" (keydown.space)="toggleSelectedMember('joe'); $event.preventDefault()"
> >
<div class="placeholder-img"> <div class="placeholder-img">
<img src="assets/images/joe.jpg" [attr.alt]="'ABOUT.MEMBER_JOE_ALT' | translate"> <img
src="assets/images/joe.jpg"
[attr.alt]="'ABOUT.MEMBER_JOE_ALT' | translate"
/>
</div> </div>
<div class="member-info"> <div class="member-info">
<span class="member-name">{{ 'ABOUT.MEMBER_JOE_NAME' | translate }}</span> <span class="member-name">{{
<span class="member-role">{{ 'ABOUT.MEMBER_JOE_ROLE' | translate }}</span> "ABOUT.MEMBER_JOE_NAME" | translate
}}</span>
<span class="member-role">{{
"ABOUT.MEMBER_JOE_ROLE" | translate
}}</span>
</div> </div>
</div> </div>
<div <div
@@ -60,18 +66,26 @@
(blur)="setHoveredMember(null)" (blur)="setHoveredMember(null)"
(click)="toggleSelectedMember('matteo')" (click)="toggleSelectedMember('matteo')"
(keydown.enter)="toggleSelectedMember('matteo')" (keydown.enter)="toggleSelectedMember('matteo')"
(keydown.space)="toggleSelectedMember('matteo'); $event.preventDefault()" (keydown.space)="
toggleSelectedMember('matteo'); $event.preventDefault()
"
> >
<div class="placeholder-img"> <div class="placeholder-img">
<img src="assets/images/matteo.jpg" [attr.alt]="'ABOUT.MEMBER_MATTEO_ALT' | translate"> <img
src="assets/images/matteo.jpg"
[attr.alt]="'ABOUT.MEMBER_MATTEO_ALT' | translate"
/>
</div> </div>
<div class="member-info"> <div class="member-info">
<span class="member-name">{{ 'ABOUT.MEMBER_MATTEO_NAME' | translate }}</span> <span class="member-name">{{
<span class="member-role">{{ 'ABOUT.MEMBER_MATTEO_ROLE' | translate }}</span> "ABOUT.MEMBER_MATTEO_NAME" | translate
}}</span>
<span class="member-role">{{
"ABOUT.MEMBER_MATTEO_ROLE" | translate
}}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -101,7 +101,11 @@ h1 {
font-weight: 500; font-weight: 500;
font-size: 0.9rem; font-size: 0.9rem;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease; transition:
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease;
} }
.tag.is-active { .tag.is-active {
@@ -137,7 +141,11 @@ h1 {
width: 100%; width: 100%;
max-width: 260px; max-width: 260px;
position: relative; position: relative;
transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease, outline-color 0.2s ease; transition:
box-shadow 0.2s ease,
transform 0.2s ease,
border-color 0.2s ease,
outline-color 0.2s ease;
cursor: pointer; cursor: pointer;
outline: 2px solid transparent; outline: 2px solid transparent;
outline-offset: 2px; outline-offset: 2px;
@@ -155,7 +163,9 @@ h1 {
.photo-card.is-active { .photo-card.is-active {
border-color: var(--color-primary-600); border-color: var(--color-primary-600);
box-shadow: 0 0 0 3px rgb(250 207 10 / 30%), var(--shadow-md); box-shadow:
0 0 0 3px rgb(250 207 10 / 30%),
var(--shadow-md);
} }
.photo-card.is-selected { .photo-card.is-selected {
@@ -165,7 +175,11 @@ h1 {
.placeholder-img { .placeholder-img {
width: 100%; width: 100%;
aspect-ratio: 3/4; aspect-ratio: 3/4;
background: linear-gradient(45deg, var(--color-neutral-200), var(--color-neutral-100)); background: linear-gradient(
45deg,
var(--color-neutral-200),
var(--color-neutral-100)
);
border-radius: var(--radius-md); border-radius: var(--radius-md);
margin-bottom: 1rem; margin-bottom: 1rem;
border-bottom: 1px solid var(--color-neutral-300); border-bottom: 1px solid var(--color-neutral-300);

View File

@@ -28,7 +28,7 @@ interface PassionChip {
standalone: true, standalone: true,
imports: [TranslateModule, AppLocationsComponent], imports: [TranslateModule, AppLocationsComponent],
templateUrl: './about-page.component.html', templateUrl: './about-page.component.html',
styleUrl: './about-page.component.scss' styleUrl: './about-page.component.scss',
}) })
export class AboutPageComponent { export class AboutPageComponent {
selectedMember: MemberId | null = null; selectedMember: MemberId | null = null;
@@ -43,14 +43,22 @@ export class AboutPageComponent {
{ id: 'woodworking', labelKey: 'ABOUT.PASSION_WOODWORKING' }, { id: 'woodworking', labelKey: 'ABOUT.PASSION_WOODWORKING' },
{ id: 'print-3d', labelKey: 'ABOUT.PASSION_PRINT_3D' }, { id: 'print-3d', labelKey: 'ABOUT.PASSION_PRINT_3D' },
{ id: 'ski', labelKey: 'ABOUT.PASSION_SKI' }, { id: 'ski', labelKey: 'ABOUT.PASSION_SKI' },
{ id: 'software-development', labelKey: 'ABOUT.PASSION_SOFTWARE_DEVELOPMENT' }, {
id: 'software-development',
labelKey: 'ABOUT.PASSION_SOFTWARE_DEVELOPMENT',
},
{ 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: 'snowboard-instructor', labelKey: 'ABOUT.PASSION_SNOWBOARD_INSTRUCTOR' } {
id: 'snowboard-instructor',
labelKey: 'ABOUT.PASSION_SNOWBOARD_INSTRUCTOR',
},
]; ];
private readonly memberPassions: Readonly<Record<MemberId, ReadonlyArray<PassionId>>> = { private readonly memberPassions: Readonly<
Record<MemberId, ReadonlyArray<PassionId>>
> = {
joe: [ joe: [
'bike-trial', 'bike-trial',
'mountain', 'mountain',
@@ -59,7 +67,7 @@ export class AboutPageComponent {
'print-3d', 'print-3d',
'travel', 'travel',
'coffee', 'coffee',
'software-development' 'software-development',
], ],
matteo: [ matteo: [
'bike-trial', 'bike-trial',
@@ -69,8 +77,8 @@ export class AboutPageComponent {
'electronics', 'electronics',
'print-3d', 'print-3d',
'woodworking', 'woodworking',
'van-life' 'van-life',
] ],
}; };
get activeMember(): MemberId | null { get activeMember(): MemberId | null {

View File

@@ -2,5 +2,5 @@ import { Routes } from '@angular/router';
import { AboutPageComponent } from './about-page.component'; import { AboutPageComponent } from './about-page.component';
export const ABOUT_ROUTES: Routes = [ export const ABOUT_ROUTES: Routes = [
{ path: '', component: AboutPageComponent } { path: '', component: AboutPageComponent },
]; ];

View File

@@ -4,34 +4,52 @@ import { adminAuthGuard } from './guards/admin-auth.guard';
export const ADMIN_ROUTES: Routes = [ export const ADMIN_ROUTES: Routes = [
{ {
path: 'login', path: 'login',
loadComponent: () => import('./pages/admin-login.component').then(m => m.AdminLoginComponent) loadComponent: () =>
import('./pages/admin-login.component').then(
(m) => m.AdminLoginComponent,
),
}, },
{ {
path: '', path: '',
canActivate: [adminAuthGuard], canActivate: [adminAuthGuard],
loadComponent: () => import('./pages/admin-shell.component').then(m => m.AdminShellComponent), loadComponent: () =>
import('./pages/admin-shell.component').then(
(m) => m.AdminShellComponent,
),
children: [ children: [
{ {
path: '', path: '',
pathMatch: 'full', pathMatch: 'full',
redirectTo: 'orders' redirectTo: 'orders',
}, },
{ {
path: 'orders', path: 'orders',
loadComponent: () => import('./pages/admin-dashboard.component').then(m => m.AdminDashboardComponent) loadComponent: () =>
import('./pages/admin-dashboard.component').then(
(m) => m.AdminDashboardComponent,
),
}, },
{ {
path: 'filament-stock', path: 'filament-stock',
loadComponent: () => import('./pages/admin-filament-stock.component').then(m => m.AdminFilamentStockComponent) loadComponent: () =>
import('./pages/admin-filament-stock.component').then(
(m) => m.AdminFilamentStockComponent,
),
}, },
{ {
path: 'contact-requests', path: 'contact-requests',
loadComponent: () => import('./pages/admin-contact-requests.component').then(m => m.AdminContactRequestsComponent) loadComponent: () =>
import('./pages/admin-contact-requests.component').then(
(m) => m.AdminContactRequestsComponent,
),
}, },
{ {
path: 'sessions', path: 'sessions',
loadComponent: () => import('./pages/admin-sessions.component').then(m => m.AdminSessionsComponent) loadComponent: () =>
} import('./pages/admin-sessions.component').then(
] (m) => m.AdminSessionsComponent,
} ),
},
],
},
]; ];

View File

@@ -1,5 +1,11 @@
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; import {
ActivatedRouteSnapshot,
CanActivateFn,
Router,
RouterStateSnapshot,
UrlTree,
} from '@angular/router';
import { catchError, map, Observable, of } from 'rxjs'; import { catchError, map, Observable, of } from 'rxjs';
import { AdminAuthService } from '../services/admin-auth.service'; import { AdminAuthService } from '../services/admin-auth.service';
@@ -17,7 +23,7 @@ function resolveLang(route: ActivatedRouteSnapshot): string {
export const adminAuthGuard: CanActivateFn = ( export const adminAuthGuard: CanActivateFn = (
route: ActivatedRouteSnapshot, route: ActivatedRouteSnapshot,
state: RouterStateSnapshot state: RouterStateSnapshot,
): Observable<boolean | UrlTree> => { ): Observable<boolean | UrlTree> => {
const authService = inject(AdminAuthService); const authService = inject(AdminAuthService);
const router = inject(Router); const router = inject(Router);
@@ -29,13 +35,15 @@ export const adminAuthGuard: CanActivateFn = (
return true; return true;
} }
return router.createUrlTree(['/', lang, 'admin', 'login'], { return router.createUrlTree(['/', lang, 'admin', 'login'], {
queryParams: { redirect: state.url } queryParams: { redirect: state.url },
}); });
}), }),
catchError(() => of( catchError(() =>
of(
router.createUrlTree(['/', lang, 'admin', 'login'], { router.createUrlTree(['/', lang, 'admin', 'login'], {
queryParams: { redirect: state.url } queryParams: { redirect: state.url },
}) }),
)) ),
),
); );
}; };

View File

@@ -5,7 +5,9 @@
<p>Richieste preventivo personalizzato ricevute dal sito.</p> <p>Richieste preventivo personalizzato ricevute dal sito.</p>
<span class="total-pill">{{ requests.length }} richieste</span> <span class="total-pill">{{ requests.length }} richieste</span>
</div> </div>
<button type="button" (click)="loadRequests()" [disabled]="loading">Aggiorna</button> <button type="button" (click)="loadRequests()" [disabled]="loading">
Aggiorna
</button>
</header> </header>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p> <p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
@@ -32,10 +34,19 @@
[class.selected]="isSelected(request.id)" [class.selected]="isSelected(request.id)"
(click)="openDetails(request.id)" (click)="openDetails(request.id)"
> >
<td class="created-at">{{ request.createdAt | date:'short' }}</td> <td class="created-at">
{{ request.createdAt | date: "short" }}
</td>
<td class="name-cell"> <td class="name-cell">
<p class="primary">{{ request.name || request.companyName || '-' }}</p> <p class="primary">
<p class="secondary" *ngIf="request.name && request.companyName">{{ request.companyName }}</p> {{ request.name || request.companyName || "-" }}
</p>
<p
class="secondary"
*ngIf="request.name && request.companyName"
>
{{ request.companyName }}
</p>
</td> </td>
<td class="email-cell">{{ request.email }}</td> <td class="email-cell">{{ request.email }}</td>
<td> <td>
@@ -45,7 +56,11 @@
<span class="chip chip-light">{{ request.customerType }}</span> <span class="chip chip-light">{{ request.customerType }}</span>
</td> </td>
<td> <td>
<span class="chip" [ngClass]="getStatusChipClass(request.status)">{{ request.status }}</span> <span
class="chip"
[ngClass]="getStatusChipClass(request.status)"
>{{ request.status }}</span
>
</td> </td>
</tr> </tr>
<tr class="empty-row" *ngIf="requests.length === 0"> <tr class="empty-row" *ngIf="requests.length === 0">
@@ -60,25 +75,58 @@
<header class="detail-header"> <header class="detail-header">
<div> <div>
<h3>Dettaglio richiesta</h3> <h3>Dettaglio richiesta</h3>
<p class="request-id"><span>ID</span><code>{{ selectedRequest.id }}</code></p> <p class="request-id">
<span>ID</span><code>{{ selectedRequest.id }}</code>
</p>
</div> </div>
<div class="detail-chips"> <div class="detail-chips">
<span class="chip" [ngClass]="getStatusChipClass(selectedRequest.status)">{{ selectedRequest.status }}</span> <span
<span class="chip chip-neutral">{{ selectedRequest.requestType }}</span> class="chip"
<span class="chip chip-light">{{ selectedRequest.customerType }}</span> [ngClass]="getStatusChipClass(selectedRequest.status)"
>{{ selectedRequest.status }}</span
>
<span class="chip chip-neutral">{{
selectedRequest.requestType
}}</span>
<span class="chip chip-light">{{
selectedRequest.customerType
}}</span>
</div> </div>
</header> </header>
<p class="loading-detail" *ngIf="detailLoading">Caricamento dettaglio...</p> <p class="loading-detail" *ngIf="detailLoading">
Caricamento dettaglio...
</p>
<dl class="meta-grid"> <dl class="meta-grid">
<div class="meta-item"><dt>Creata</dt><dd>{{ selectedRequest.createdAt | date:'medium' }}</dd></div> <div class="meta-item">
<div class="meta-item"><dt>Aggiornata</dt><dd>{{ selectedRequest.updatedAt | date:'medium' }}</dd></div> <dt>Creata</dt>
<div class="meta-item"><dt>Email</dt><dd>{{ selectedRequest.email }}</dd></div> <dd>{{ selectedRequest.createdAt | date: "medium" }}</dd>
<div class="meta-item"><dt>Telefono</dt><dd>{{ selectedRequest.phone || '-' }}</dd></div> </div>
<div class="meta-item"><dt>Nome</dt><dd>{{ selectedRequest.name || '-' }}</dd></div> <div class="meta-item">
<div class="meta-item"><dt>Azienda</dt><dd>{{ selectedRequest.companyName || '-' }}</dd></div> <dt>Aggiornata</dt>
<div class="meta-item"><dt>Referente</dt><dd>{{ selectedRequest.contactPerson || '-' }}</dd></div> <dd>{{ selectedRequest.updatedAt | date: "medium" }}</dd>
</div>
<div class="meta-item">
<dt>Email</dt>
<dd>{{ selectedRequest.email }}</dd>
</div>
<div class="meta-item">
<dt>Telefono</dt>
<dd>{{ selectedRequest.phone || "-" }}</dd>
</div>
<div class="meta-item">
<dt>Nome</dt>
<dd>{{ selectedRequest.name || "-" }}</dd>
</div>
<div class="meta-item">
<dt>Azienda</dt>
<dd>{{ selectedRequest.companyName || "-" }}</dd>
</div>
<div class="meta-item">
<dt>Referente</dt>
<dd>{{ selectedRequest.contactPerson || "-" }}</dd>
</div>
</dl> </dl>
<div class="status-editor"> <div class="status-editor">
@@ -87,36 +135,61 @@
<select <select
id="contact-request-status" id="contact-request-status"
[ngModel]="selectedStatus" [ngModel]="selectedStatus"
(ngModelChange)="selectedStatus = $event"> (ngModelChange)="selectedStatus = $event"
<option *ngFor="let status of statusOptions" [ngValue]="status">{{ status }}</option> >
<option *ngFor="let status of statusOptions" [ngValue]="status">
{{ status }}
</option>
</select> </select>
</div> </div>
<button <button
type="button" type="button"
(click)="updateRequestStatus()" (click)="updateRequestStatus()"
[disabled]="!selectedRequest || updatingStatus || !selectedStatus || selectedStatus === selectedRequest.status"> [disabled]="
{{ updatingStatus ? 'Salvataggio...' : 'Aggiorna stato' }} !selectedRequest ||
updatingStatus ||
!selectedStatus ||
selectedStatus === selectedRequest.status
"
>
{{ updatingStatus ? "Salvataggio..." : "Aggiorna stato" }}
</button> </button>
</div> </div>
<div class="message-box"> <div class="message-box">
<h4>Messaggio</h4> <h4>Messaggio</h4>
<p>{{ selectedRequest.message || '-' }}</p> <p>{{ selectedRequest.message || "-" }}</p>
</div> </div>
<div class="attachments"> <div class="attachments">
<h4>Allegati</h4> <h4>Allegati</h4>
<div class="attachment-list" *ngIf="selectedRequest.attachments.length > 0; else noAttachmentsTpl"> <div
<article class="attachment-item" *ngFor="let attachment of selectedRequest.attachments"> class="attachment-list"
*ngIf="selectedRequest.attachments.length > 0; else noAttachmentsTpl"
>
<article
class="attachment-item"
*ngFor="let attachment of selectedRequest.attachments"
>
<div> <div>
<p class="filename">{{ attachment.originalFilename }}</p> <p class="filename">{{ attachment.originalFilename }}</p>
<p class="meta"> <p class="meta">
{{ formatFileSize(attachment.fileSizeBytes) }} {{ formatFileSize(attachment.fileSizeBytes) }}
<span *ngIf="attachment.mimeType"> | {{ attachment.mimeType }}</span> <span *ngIf="attachment.mimeType">
<span *ngIf="attachment.createdAt"> | {{ attachment.createdAt | date:'short' }}</span> | {{ attachment.mimeType }}</span
>
<span *ngIf="attachment.createdAt">
| {{ attachment.createdAt | date: "short" }}</span
>
</p> </p>
</div> </div>
<button type="button" class="ghost" (click)="downloadAttachment(attachment)">Scarica file</button> <button
type="button"
class="ghost"
(click)="downloadAttachment(attachment)"
>
Scarica file
</button>
</article> </article>
</div> </div>
</div> </div>

View File

@@ -62,7 +62,9 @@ button {
padding: var(--space-2) var(--space-4); padding: var(--space-2) var(--space-4);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease, opacity 0.2s ease; transition:
background-color 0.2s ease,
opacity 0.2s ease;
line-height: 1.2; line-height: 1.2;
} }

View File

@@ -5,7 +5,7 @@ import {
AdminContactRequest, AdminContactRequest,
AdminContactRequestAttachment, AdminContactRequestAttachment,
AdminContactRequestDetail, AdminContactRequestDetail,
AdminOperationsService AdminOperationsService,
} from '../services/admin-operations.service'; } from '../services/admin-operations.service';
@Component({ @Component({
@@ -13,7 +13,7 @@ import {
standalone: true, standalone: true,
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule],
templateUrl: './admin-contact-requests.component.html', templateUrl: './admin-contact-requests.component.html',
styleUrl: './admin-contact-requests.component.scss' styleUrl: './admin-contact-requests.component.scss',
}) })
export class AdminContactRequestsComponent implements OnInit { export class AdminContactRequestsComponent implements OnInit {
private readonly adminOperationsService = inject(AdminOperationsService); private readonly adminOperationsService = inject(AdminOperationsService);
@@ -43,7 +43,10 @@ export class AdminContactRequestsComponent implements OnInit {
if (requests.length === 0) { if (requests.length === 0) {
this.selectedRequest = null; this.selectedRequest = null;
this.selectedRequestId = null; this.selectedRequestId = null;
} else if (this.selectedRequestId && requests.some(r => r.id === this.selectedRequestId)) { } else if (
this.selectedRequestId &&
requests.some((r) => r.id === this.selectedRequestId)
) {
this.openDetails(this.selectedRequestId); this.openDetails(this.selectedRequestId);
} else { } else {
this.openDetails(requests[0].id); this.openDetails(requests[0].id);
@@ -53,7 +56,7 @@ export class AdminContactRequestsComponent implements OnInit {
error: () => { error: () => {
this.loading = false; this.loading = false;
this.errorMessage = 'Impossibile caricare le richieste di contatto.'; this.errorMessage = 'Impossibile caricare le richieste di contatto.';
} },
}); });
} }
@@ -70,7 +73,7 @@ export class AdminContactRequestsComponent implements OnInit {
error: () => { error: () => {
this.detailLoading = false; this.detailLoading = false;
this.errorMessage = 'Impossibile caricare il dettaglio richiesta.'; this.errorMessage = 'Impossibile caricare il dettaglio richiesta.';
} },
}); });
} }
@@ -83,11 +86,17 @@ export class AdminContactRequestsComponent implements OnInit {
return; return;
} }
this.adminOperationsService.downloadContactRequestAttachment(this.selectedRequest.id, attachment.id).subscribe({ this.adminOperationsService
next: (blob) => this.downloadBlob(blob, attachment.originalFilename || `attachment-${attachment.id}`), .downloadContactRequestAttachment(this.selectedRequest.id, attachment.id)
.subscribe({
next: (blob) =>
this.downloadBlob(
blob,
attachment.originalFilename || `attachment-${attachment.id}`,
),
error: () => { error: () => {
this.errorMessage = 'Download allegato non riuscito.'; this.errorMessage = 'Download allegato non riuscito.';
} },
}); });
} }
@@ -120,7 +129,12 @@ export class AdminContactRequestsComponent implements OnInit {
} }
updateRequestStatus(): void { updateRequestStatus(): void {
if (!this.selectedRequest || !this.selectedRequestId || !this.selectedStatus || this.updatingStatus) { if (
!this.selectedRequest ||
!this.selectedRequestId ||
!this.selectedStatus ||
this.updatingStatus
) {
return; return;
} }
@@ -128,25 +142,30 @@ export class AdminContactRequestsComponent implements OnInit {
this.successMessage = null; this.successMessage = null;
this.updatingStatus = true; this.updatingStatus = true;
this.adminOperationsService.updateContactRequestStatus(this.selectedRequestId, { status: this.selectedStatus }).subscribe({ this.adminOperationsService
.updateContactRequestStatus(this.selectedRequestId, {
status: this.selectedStatus,
})
.subscribe({
next: (updated) => { next: (updated) => {
this.selectedRequest = updated; this.selectedRequest = updated;
this.selectedStatus = updated.status || this.selectedStatus; this.selectedStatus = updated.status || this.selectedStatus;
this.requests = this.requests.map(request => this.requests = this.requests.map((request) =>
request.id === updated.id request.id === updated.id
? { ? {
...request, ...request,
status: updated.status status: updated.status,
} }
: request : request,
); );
this.updatingStatus = false; this.updatingStatus = false;
this.successMessage = 'Stato richiesta aggiornato.'; this.successMessage = 'Stato richiesta aggiornato.';
}, },
error: () => { error: () => {
this.updatingStatus = false; this.updatingStatus = false;
this.errorMessage = 'Impossibile aggiornare lo stato della richiesta.'; this.errorMessage =
} 'Impossibile aggiornare lo stato della richiesta.';
},
}); });
} }

View File

@@ -5,7 +5,9 @@
<p>Seleziona un ordine a sinistra e gestiscilo nel dettaglio a destra.</p> <p>Seleziona un ordine a sinistra e gestiscilo nel dettaglio a destra.</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button type="button" (click)="loadOrders()" [disabled]="loading">Aggiorna</button> <button type="button" (click)="loadOrders()" [disabled]="loading">
Aggiorna
</button>
</div> </div>
</header> </header>
@@ -32,7 +34,12 @@
[ngModel]="paymentStatusFilter" [ngModel]="paymentStatusFilter"
(ngModelChange)="onPaymentStatusFilterChange($event)" (ngModelChange)="onPaymentStatusFilterChange($event)"
> >
<option *ngFor="let option of paymentStatusFilterOptions" [ngValue]="option">{{ option }}</option> <option
*ngFor="let option of paymentStatusFilterOptions"
[ngValue]="option"
>
{{ option }}
</option>
</select> </select>
</label> </label>
<label class="toolbar-field" for="order-status-filter"> <label class="toolbar-field" for="order-status-filter">
@@ -42,7 +49,12 @@
[ngModel]="orderStatusFilter" [ngModel]="orderStatusFilter"
(ngModelChange)="onOrderStatusFilterChange($event)" (ngModelChange)="onOrderStatusFilterChange($event)"
> >
<option *ngFor="let option of orderStatusFilterOptions" [ngValue]="option">{{ option }}</option> <option
*ngFor="let option of orderStatusFilterOptions"
[ngValue]="option"
>
{{ option }}
</option>
</select> </select>
</label> </label>
</div> </div>
@@ -65,12 +77,16 @@
> >
<td>{{ order.orderNumber }}</td> <td>{{ order.orderNumber }}</td>
<td>{{ order.customerEmail }}</td> <td>{{ order.customerEmail }}</td>
<td>{{ order.paymentStatus || 'PENDING' }}</td> <td>{{ order.paymentStatus || "PENDING" }}</td>
<td>{{ order.status }}</td> <td>{{ order.status }}</td>
<td>{{ order.totalChf | currency:'CHF':'symbol':'1.2-2' }}</td> <td>
{{ order.totalChf | currency: "CHF" : "symbol" : "1.2-2" }}
</td>
</tr> </tr>
<tr class="no-results" *ngIf="filteredOrders.length === 0"> <tr class="no-results" *ngIf="filteredOrders.length === 0">
<td colspan="5">Nessun ordine trovato per i filtri selezionati.</td> <td colspan="5">
Nessun ordine trovato per i filtri selezionati.
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -80,39 +96,74 @@
<section class="detail-panel" *ngIf="selectedOrder"> <section class="detail-panel" *ngIf="selectedOrder">
<div class="detail-header"> <div class="detail-header">
<h2>Dettaglio ordine {{ selectedOrder.orderNumber }}</h2> <h2>Dettaglio ordine {{ selectedOrder.orderNumber }}</h2>
<p class="order-uuid">UUID: <code>{{ selectedOrder.id }}</code></p> <p class="order-uuid">
UUID: <code>{{ selectedOrder.id }}</code>
</p>
<p *ngIf="detailLoading">Caricamento dettaglio...</p> <p *ngIf="detailLoading">Caricamento dettaglio...</p>
</div> </div>
<div class="meta-grid"> <div class="meta-grid">
<div><strong>Cliente</strong><span>{{ selectedOrder.customerEmail }}</span></div> <div>
<div><strong>Stato pagamento</strong><span>{{ selectedOrder.paymentStatus || 'PENDING' }}</span></div> <strong>Cliente</strong><span>{{ selectedOrder.customerEmail }}</span>
<div><strong>Stato ordine</strong><span>{{ selectedOrder.status }}</span></div> </div>
<div><strong>Totale</strong><span>{{ selectedOrder.totalChf | currency:'CHF':'symbol':'1.2-2' }}</span></div> <div>
<strong>Stato pagamento</strong
><span>{{ selectedOrder.paymentStatus || "PENDING" }}</span>
</div>
<div>
<strong>Stato ordine</strong><span>{{ selectedOrder.status }}</span>
</div>
<div>
<strong>Totale</strong
><span>{{
selectedOrder.totalChf | currency: "CHF" : "symbol" : "1.2-2"
}}</span>
</div>
</div> </div>
<div class="actions-block"> <div class="actions-block">
<div class="status-editor"> <div class="status-editor">
<label for="order-status">Stato ordine</label> <label for="order-status">Stato ordine</label>
<select id="order-status" [value]="selectedStatus" (change)="onStatusChange($event)"> <select
<option *ngFor="let option of orderStatusOptions" [value]="option">{{ option }}</option> id="order-status"
[value]="selectedStatus"
(change)="onStatusChange($event)"
>
<option *ngFor="let option of orderStatusOptions" [value]="option">
{{ option }}
</option>
</select> </select>
<button type="button" (click)="updateStatus()" [disabled]="updatingStatus"> <button
{{ updatingStatus ? 'Salvataggio...' : 'Aggiorna stato' }} type="button"
(click)="updateStatus()"
[disabled]="updatingStatus"
>
{{ updatingStatus ? "Salvataggio..." : "Aggiorna stato" }}
</button> </button>
</div> </div>
<div class="status-editor"> <div class="status-editor">
<label for="payment-method">Metodo pagamento</label> <label for="payment-method">Metodo pagamento</label>
<select id="payment-method" [value]="selectedPaymentMethod" (change)="onPaymentMethodChange($event)"> <select
<option *ngFor="let option of paymentMethodOptions" [value]="option">{{ option }}</option> id="payment-method"
[value]="selectedPaymentMethod"
(change)="onPaymentMethodChange($event)"
>
<option
*ngFor="let option of paymentMethodOptions"
[value]="option"
>
{{ option }}
</option>
</select> </select>
<button <button
type="button" type="button"
(click)="confirmPayment()" (click)="confirmPayment()"
[disabled]="confirmingPayment || selectedOrder.paymentStatus === 'COMPLETED'" [disabled]="
confirmingPayment || selectedOrder.paymentStatus === 'COMPLETED'
"
> >
{{ confirmingPayment ? 'Invio...' : 'Conferma pagamento' }} {{ confirmingPayment ? "Invio..." : "Conferma pagamento" }}
</button> </button>
</div> </div>
</div> </div>
@@ -132,17 +183,26 @@
<div class="items"> <div class="items">
<div class="item" *ngFor="let item of selectedOrder.items"> <div class="item" *ngFor="let item of selectedOrder.items">
<div class="item-main"> <div class="item-main">
<p class="file-name"><strong>{{ item.originalFilename }}</strong></p> <p class="file-name">
<strong>{{ item.originalFilename }}</strong>
</p>
<p class="item-meta"> <p class="item-meta">
Qta: {{ item.quantity }} | Qta: {{ item.quantity }} | Colore:
Colore: <span
<span class="color-swatch" *ngIf="isHexColor(item.colorCode)" [style.background-color]="item.colorCode"></span> class="color-swatch"
<span>{{ item.colorCode || '-' }}</span> *ngIf="isHexColor(item.colorCode)"
| [style.background-color]="item.colorCode"
Riga: {{ item.lineTotalChf | currency:'CHF':'symbol':'1.2-2' }} ></span>
<span>{{ item.colorCode || "-" }}</span>
| Riga:
{{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }}
</p> </p>
</div> </div>
<button type="button" class="ghost" (click)="downloadItemFile(item.id, item.originalFilename)"> <button
type="button"
class="ghost"
(click)="downloadItemFile(item.id, item.originalFilename)"
>
Scarica file Scarica file
</button> </button>
</div> </div>
@@ -160,21 +220,52 @@
<p>Caricamento ordini...</p> <p>Caricamento ordini...</p>
</ng-template> </ng-template>
<div class="modal-backdrop" *ngIf="showPrintDetails && selectedOrder" (click)="closePrintDetails()"> <div
class="modal-backdrop"
*ngIf="showPrintDetails && selectedOrder"
(click)="closePrintDetails()"
>
<div class="modal-card" (click)="$event.stopPropagation()"> <div class="modal-card" (click)="$event.stopPropagation()">
<header class="modal-header"> <header class="modal-header">
<h3>Dettagli stampa ordine {{ selectedOrder.orderNumber }}</h3> <h3>Dettagli stampa ordine {{ selectedOrder.orderNumber }}</h3>
<button type="button" class="ghost close-btn" (click)="closePrintDetails()">Chiudi</button> <button
type="button"
class="ghost close-btn"
(click)="closePrintDetails()"
>
Chiudi
</button>
</header> </header>
<div class="modal-grid"> <div class="modal-grid">
<div><strong>Qualità</strong><span>{{ getQualityLabel(selectedOrder.printLayerHeightMm) }}</span></div> <div>
<div><strong>Materiale</strong><span>{{ selectedOrder.printMaterialCode || '-' }}</span></div> <strong>Qualità</strong
<div><strong>Layer height</strong><span>{{ selectedOrder.printLayerHeightMm || '-' }} mm</span></div> ><span>{{ getQualityLabel(selectedOrder.printLayerHeightMm) }}</span>
<div><strong>Nozzle</strong><span>{{ selectedOrder.printNozzleDiameterMm || '-' }} mm</span></div> </div>
<div><strong>Infill pattern</strong><span>{{ selectedOrder.printInfillPattern || '-' }}</span></div> <div>
<div><strong>Infill %</strong><span>{{ selectedOrder.printInfillPercent ?? '-' }}</span></div> <strong>Materiale</strong
<div><strong>Supporti</strong><span>{{ selectedOrder.printSupportsEnabled ? 'Sì' : 'No' }}</span></div> ><span>{{ selectedOrder.printMaterialCode || "-" }}</span>
</div>
<div>
<strong>Layer height</strong
><span>{{ selectedOrder.printLayerHeightMm || "-" }} mm</span>
</div>
<div>
<strong>Nozzle</strong
><span>{{ selectedOrder.printNozzleDiameterMm || "-" }} mm</span>
</div>
<div>
<strong>Infill pattern</strong
><span>{{ selectedOrder.printInfillPattern || "-" }}</span>
</div>
<div>
<strong>Infill %</strong
><span>{{ selectedOrder.printInfillPercent ?? "-" }}</span>
</div>
<div>
<strong>Supporti</strong
><span>{{ selectedOrder.printSupportsEnabled ? "Sì" : "No" }}</span>
</div>
</div> </div>
<h4>Colori file</h4> <h4>Colori file</h4>
@@ -182,8 +273,12 @@
<div class="file-color-row" *ngFor="let item of selectedOrder.items"> <div class="file-color-row" *ngFor="let item of selectedOrder.items">
<span class="filename">{{ item.originalFilename }}</span> <span class="filename">{{ item.originalFilename }}</span>
<span class="file-color"> <span class="file-color">
<span class="color-swatch" *ngIf="isHexColor(item.colorCode)" [style.background-color]="item.colorCode"></span> <span
{{ item.colorCode || '-' }} class="color-swatch"
*ngIf="isHexColor(item.colorCode)"
[style.background-color]="item.colorCode"
></span>
{{ item.colorCode || "-" }}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -75,7 +75,10 @@ button:disabled {
.list-toolbar { .list-toolbar {
display: grid; display: grid;
grid-template-columns: minmax(230px, 1.6fr) minmax(170px, 1fr) minmax(190px, 1fr); grid-template-columns: minmax(230px, 1.6fr) minmax(170px, 1fr) minmax(
190px,
1fr
);
gap: var(--space-2); gap: var(--space-2);
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
} }

View File

@@ -1,14 +1,17 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, inject, OnInit } from '@angular/core'; import { Component, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { AdminOrder, AdminOrdersService } from '../services/admin-orders.service'; import {
AdminOrder,
AdminOrdersService,
} from '../services/admin-orders.service';
@Component({ @Component({
selector: 'app-admin-dashboard', selector: 'app-admin-dashboard',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule],
templateUrl: './admin-dashboard.component.html', templateUrl: './admin-dashboard.component.html',
styleUrl: './admin-dashboard.component.scss' styleUrl: './admin-dashboard.component.scss',
}) })
export class AdminDashboardComponent implements OnInit { export class AdminDashboardComponent implements OnInit {
private readonly adminOrdersService = inject(AdminOrdersService); private readonly adminOrdersService = inject(AdminOrdersService);
@@ -33,10 +36,21 @@ export class AdminDashboardComponent implements OnInit {
'IN_PRODUCTION', 'IN_PRODUCTION',
'SHIPPED', 'SHIPPED',
'COMPLETED', 'COMPLETED',
'CANCELLED' 'CANCELLED',
];
readonly paymentMethodOptions = [
'TWINT',
'BANK_TRANSFER',
'CARD',
'CASH',
'OTHER',
];
readonly paymentStatusFilterOptions = [
'ALL',
'PENDING',
'REPORTED',
'COMPLETED',
]; ];
readonly paymentMethodOptions = ['TWINT', 'BANK_TRANSFER', 'CARD', 'CASH', 'OTHER'];
readonly paymentStatusFilterOptions = ['ALL', 'PENDING', 'REPORTED', 'COMPLETED'];
readonly orderStatusFilterOptions = [ readonly orderStatusFilterOptions = [
'ALL', 'ALL',
'PENDING_PAYMENT', 'PENDING_PAYMENT',
@@ -44,7 +58,7 @@ export class AdminDashboardComponent implements OnInit {
'IN_PRODUCTION', 'IN_PRODUCTION',
'SHIPPED', 'SHIPPED',
'COMPLETED', 'COMPLETED',
'CANCELLED' 'CANCELLED',
]; ];
ngOnInit(): void { ngOnInit(): void {
@@ -62,8 +76,12 @@ export class AdminDashboardComponent implements OnInit {
if (!this.selectedOrder && this.filteredOrders.length > 0) { if (!this.selectedOrder && this.filteredOrders.length > 0) {
this.openDetails(this.filteredOrders[0].id); this.openDetails(this.filteredOrders[0].id);
} else if (this.selectedOrder) { } else if (this.selectedOrder) {
const exists = orders.find(order => order.id === this.selectedOrder?.id); const exists = orders.find(
const selectedIsVisible = this.filteredOrders.some(order => order.id === this.selectedOrder?.id); (order) => order.id === this.selectedOrder?.id,
);
const selectedIsVisible = this.filteredOrders.some(
(order) => order.id === this.selectedOrder?.id,
);
if (exists && selectedIsVisible) { if (exists && selectedIsVisible) {
this.openDetails(exists.id); this.openDetails(exists.id);
} else if (this.filteredOrders.length > 0) { } else if (this.filteredOrders.length > 0) {
@@ -78,7 +96,7 @@ export class AdminDashboardComponent implements OnInit {
error: () => { error: () => {
this.loading = false; this.loading = false;
this.errorMessage = 'Impossibile caricare gli ordini.'; this.errorMessage = 'Impossibile caricare gli ordini.';
} },
}); });
} }
@@ -109,7 +127,7 @@ export class AdminDashboardComponent implements OnInit {
error: () => { error: () => {
this.detailLoading = false; this.detailLoading = false;
this.errorMessage = 'Impossibile caricare il dettaglio ordine.'; this.errorMessage = 'Impossibile caricare il dettaglio ordine.';
} },
}); });
} }
@@ -119,7 +137,9 @@ export class AdminDashboardComponent implements OnInit {
} }
this.confirmingPayment = true; this.confirmingPayment = true;
this.adminOrdersService.confirmPayment(this.selectedOrder.id, this.selectedPaymentMethod).subscribe({ this.adminOrdersService
.confirmPayment(this.selectedOrder.id, this.selectedPaymentMethod)
.subscribe({
next: (updatedOrder) => { next: (updatedOrder) => {
this.confirmingPayment = false; this.confirmingPayment = false;
this.applyOrderUpdate(updatedOrder); this.applyOrderUpdate(updatedOrder);
@@ -127,19 +147,25 @@ export class AdminDashboardComponent implements OnInit {
error: () => { error: () => {
this.confirmingPayment = false; this.confirmingPayment = false;
this.errorMessage = 'Conferma pagamento non riuscita.'; this.errorMessage = 'Conferma pagamento non riuscita.';
} },
}); });
} }
updateStatus(): void { updateStatus(): void {
if (!this.selectedOrder || this.updatingStatus || !this.selectedStatus.trim()) { if (
!this.selectedOrder ||
this.updatingStatus ||
!this.selectedStatus.trim()
) {
return; return;
} }
this.updatingStatus = true; this.updatingStatus = true;
this.adminOrdersService.updateOrderStatus(this.selectedOrder.id, { this.adminOrdersService
status: this.selectedStatus.trim() .updateOrderStatus(this.selectedOrder.id, {
}).subscribe({ status: this.selectedStatus.trim(),
})
.subscribe({
next: (updatedOrder) => { next: (updatedOrder) => {
this.updatingStatus = false; this.updatingStatus = false;
this.applyOrderUpdate(updatedOrder); this.applyOrderUpdate(updatedOrder);
@@ -147,7 +173,7 @@ export class AdminDashboardComponent implements OnInit {
error: () => { error: () => {
this.updatingStatus = false; this.updatingStatus = false;
this.errorMessage = 'Aggiornamento stato ordine non riuscito.'; this.errorMessage = 'Aggiornamento stato ordine non riuscito.';
} },
}); });
} }
@@ -156,13 +182,15 @@ export class AdminDashboardComponent implements OnInit {
return; return;
} }
this.adminOrdersService.downloadOrderItemFile(this.selectedOrder.id, itemId).subscribe({ this.adminOrdersService
.downloadOrderItemFile(this.selectedOrder.id, itemId)
.subscribe({
next: (blob) => { next: (blob) => {
this.downloadBlob(blob, filename || `order-item-${itemId}`); this.downloadBlob(blob, filename || `order-item-${itemId}`);
}, },
error: () => { error: () => {
this.errorMessage = 'Download file non riuscito.'; this.errorMessage = 'Download file non riuscito.';
} },
}); });
} }
@@ -171,13 +199,18 @@ export class AdminDashboardComponent implements OnInit {
return; return;
} }
this.adminOrdersService.downloadOrderConfirmation(this.selectedOrder.id).subscribe({ this.adminOrdersService
.downloadOrderConfirmation(this.selectedOrder.id)
.subscribe({
next: (blob) => { next: (blob) => {
this.downloadBlob(blob, `conferma-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`); this.downloadBlob(
blob,
`conferma-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`,
);
}, },
error: () => { error: () => {
this.errorMessage = 'Download conferma ordine non riuscito.'; this.errorMessage = 'Download conferma ordine non riuscito.';
} },
}); });
} }
@@ -186,13 +219,18 @@ export class AdminDashboardComponent implements OnInit {
return; return;
} }
this.adminOrdersService.downloadOrderInvoice(this.selectedOrder.id).subscribe({ this.adminOrdersService
.downloadOrderInvoice(this.selectedOrder.id)
.subscribe({
next: (blob) => { next: (blob) => {
this.downloadBlob(blob, `fattura-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`); this.downloadBlob(
blob,
`fattura-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`,
);
}, },
error: () => { error: () => {
this.errorMessage = 'Download fattura non riuscito.'; this.errorMessage = 'Download fattura non riuscito.';
} },
}); });
} }
@@ -228,7 +266,10 @@ export class AdminDashboardComponent implements OnInit {
} }
isHexColor(value?: string): boolean { isHexColor(value?: string): boolean {
return typeof value === 'string' && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value); return (
typeof value === 'string' &&
/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value)
);
} }
isSelected(orderId: string): boolean { isSelected(orderId: string): boolean {
@@ -236,11 +277,14 @@ export class AdminDashboardComponent implements OnInit {
} }
private applyOrderUpdate(updatedOrder: AdminOrder): void { private applyOrderUpdate(updatedOrder: AdminOrder): void {
this.orders = this.orders.map((order) => order.id === updatedOrder.id ? updatedOrder : order); this.orders = this.orders.map((order) =>
order.id === updatedOrder.id ? updatedOrder : order,
);
this.applyListFiltersAndSelection(); this.applyListFiltersAndSelection();
this.selectedOrder = updatedOrder; this.selectedOrder = updatedOrder;
this.selectedStatus = updatedOrder.status; this.selectedStatus = updatedOrder.status;
this.selectedPaymentMethod = updatedOrder.paymentMethod || this.selectedPaymentMethod; this.selectedPaymentMethod =
updatedOrder.paymentMethod || this.selectedPaymentMethod;
} }
private applyListFiltersAndSelection(): void { private applyListFiltersAndSelection(): void {
@@ -252,7 +296,10 @@ export class AdminDashboardComponent implements OnInit {
return; return;
} }
if (!this.selectedOrder || !this.filteredOrders.some(order => order.id === this.selectedOrder?.id)) { if (
!this.selectedOrder ||
!this.filteredOrders.some((order) => order.id === this.selectedOrder?.id)
) {
this.openDetails(this.filteredOrders[0].id); this.openDetails(this.filteredOrders[0].id);
} }
} }
@@ -265,9 +312,14 @@ export class AdminDashboardComponent implements OnInit {
const paymentStatus = (order.paymentStatus || 'PENDING').toUpperCase(); const paymentStatus = (order.paymentStatus || 'PENDING').toUpperCase();
const orderStatus = (order.status || '').toUpperCase(); const orderStatus = (order.status || '').toUpperCase();
const matchesSearch = !term || fullUuid.includes(term) || shortUuid.includes(term); const matchesSearch =
const matchesPayment = this.paymentStatusFilter === 'ALL' || paymentStatus === this.paymentStatusFilter; !term || fullUuid.includes(term) || shortUuid.includes(term);
const matchesOrderStatus = this.orderStatusFilter === 'ALL' || orderStatus === this.orderStatusFilter; const matchesPayment =
this.paymentStatusFilter === 'ALL' ||
paymentStatus === this.paymentStatusFilter;
const matchesOrderStatus =
this.orderStatusFilter === 'ALL' ||
orderStatus === this.orderStatusFilter;
return matchesSearch && matchesPayment && matchesOrderStatus; return matchesSearch && matchesPayment && matchesOrderStatus;
}); });

View File

@@ -4,7 +4,9 @@
<h2>Stock filamenti</h2> <h2>Stock filamenti</h2>
<p>Gestione materiali, varianti e stock per il calcolatore.</p> <p>Gestione materiali, varianti e stock per il calcolatore.</p>
</div> </div>
<button type="button" (click)="loadData()" [disabled]="loading">Aggiorna</button> <button type="button" (click)="loadData()" [disabled]="loading">
Aggiorna
</button>
</header> </header>
<div class="alerts"> <div class="alerts">
@@ -16,8 +18,12 @@
<section class="panel"> <section class="panel">
<div class="panel-header"> <div class="panel-header">
<h3>Inserimento rapido</h3> <h3>Inserimento rapido</h3>
<button type="button" class="panel-toggle" (click)="toggleQuickInsertCollapsed()"> <button
{{ quickInsertCollapsed ? 'Espandi' : 'Collassa' }} type="button"
class="panel-toggle"
(click)="toggleQuickInsertCollapsed()"
>
{{ quickInsertCollapsed ? "Espandi" : "Collassa" }}
</button> </button>
</div> </div>
@@ -28,7 +34,11 @@
<div class="form-grid"> <div class="form-grid">
<label class="form-field form-field--wide"> <label class="form-field form-field--wide">
<span>Codice materiale</span> <span>Codice materiale</span>
<input type="text" [(ngModel)]="newMaterial.materialCode" placeholder="PLA, PETG, TPU..." /> <input
type="text"
[(ngModel)]="newMaterial.materialCode"
placeholder="PLA, PETG, TPU..."
/>
</label> </label>
<label class="form-field form-field--wide"> <label class="form-field form-field--wide">
<span>Etichetta tecnico</span> <span>Etichetta tecnico</span>
@@ -52,8 +62,12 @@
</label> </label>
</div> </div>
<button type="button" (click)="createMaterial()" [disabled]="creatingMaterial"> <button
{{ creatingMaterial ? 'Salvataggio...' : 'Aggiungi materiale' }} type="button"
(click)="createMaterial()"
[disabled]="creatingMaterial"
>
{{ creatingMaterial ? "Salvataggio..." : "Aggiungi materiale" }}
</button> </button>
</section> </section>
@@ -63,22 +77,37 @@
<label class="form-field"> <label class="form-field">
<span>Materiale</span> <span>Materiale</span>
<select [(ngModel)]="newVariant.materialTypeId"> <select [(ngModel)]="newVariant.materialTypeId">
<option *ngFor="let material of materials; trackBy: trackById" [ngValue]="material.id"> <option
*ngFor="let material of materials; trackBy: trackById"
[ngValue]="material.id"
>
{{ material.materialCode }} {{ material.materialCode }}
</option> </option>
</select> </select>
</label> </label>
<label class="form-field"> <label class="form-field">
<span>Nome variante</span> <span>Nome variante</span>
<input type="text" [(ngModel)]="newVariant.variantDisplayName" placeholder="PLA Nero Opaco BrandX" /> <input
type="text"
[(ngModel)]="newVariant.variantDisplayName"
placeholder="PLA Nero Opaco BrandX"
/>
</label> </label>
<label class="form-field"> <label class="form-field">
<span>Colore</span> <span>Colore</span>
<input type="text" [(ngModel)]="newVariant.colorName" placeholder="Nero, Bianco..." /> <input
type="text"
[(ngModel)]="newVariant.colorName"
placeholder="Nero, Bianco..."
/>
</label> </label>
<label class="form-field"> <label class="form-field">
<span>Hex colore</span> <span>Hex colore</span>
<input type="text" [(ngModel)]="newVariant.colorHex" placeholder="#1A1A1A" /> <input
type="text"
[(ngModel)]="newVariant.colorHex"
placeholder="#1A1A1A"
/>
</label> </label>
<label class="form-field"> <label class="form-field">
<span>Finitura</span> <span>Finitura</span>
@@ -93,19 +122,40 @@
</label> </label>
<label class="form-field"> <label class="form-field">
<span>Brand</span> <span>Brand</span>
<input type="text" [(ngModel)]="newVariant.brand" placeholder="Bambu, SUNLU..." /> <input
type="text"
[(ngModel)]="newVariant.brand"
placeholder="Bambu, SUNLU..."
/>
</label> </label>
<label class="form-field"> <label class="form-field">
<span>Costo CHF/kg</span> <span>Costo CHF/kg</span>
<input type="number" step="0.01" min="0" [(ngModel)]="newVariant.costChfPerKg" /> <input
type="number"
step="0.01"
min="0"
[(ngModel)]="newVariant.costChfPerKg"
/>
</label> </label>
<label class="form-field"> <label class="form-field">
<span>Stock spool</span> <span>Stock spool</span>
<input type="number" step="0.001" min="0" max="999.999" [(ngModel)]="newVariant.stockSpools" /> <input
type="number"
step="0.001"
min="0"
max="999.999"
[(ngModel)]="newVariant.stockSpools"
/>
</label> </label>
<label class="form-field"> <label class="form-field">
<span>Spool netto kg</span> <span>Spool netto kg</span>
<input type="number" step="0.001" min="0.001" max="999.999" [(ngModel)]="newVariant.spoolNetKg" /> <input
type="number"
step="0.001"
min="0.001"
max="999.999"
[(ngModel)]="newVariant.spoolNetKg"
/>
</label> </label>
</div> </div>
@@ -125,12 +175,26 @@
</div> </div>
<p class="variant-meta"> <p class="variant-meta">
Stock spools: <strong>{{ newVariant.stockSpools | number:'1.0-3' }}</strong> | Stock spools:
Filamento totale: <strong>{{ computeStockFilamentGrams(newVariant.stockSpools, newVariant.spoolNetKg) | number:'1.0-0' }} g</strong> <strong>{{ newVariant.stockSpools | number: "1.0-3" }}</strong> |
Filamento totale:
<strong
>{{
computeStockFilamentGrams(
newVariant.stockSpools,
newVariant.spoolNetKg
) | number: "1.0-0"
}}
g</strong
>
</p> </p>
<button type="button" (click)="createVariant()" [disabled]="creatingVariant || !materials.length"> <button
{{ creatingVariant ? 'Salvataggio...' : 'Aggiungi variante' }} type="button"
(click)="createVariant()"
[disabled]="creatingVariant || !materials.length"
>
{{ creatingVariant ? "Salvataggio..." : "Aggiungi variante" }}
</button> </button>
</section> </section>
</div> </div>
@@ -140,37 +204,68 @@
<section class="panel"> <section class="panel">
<h3>Varianti filamento</h3> <h3>Varianti filamento</h3>
<div class="variant-list"> <div class="variant-list">
<article class="variant-row" *ngFor="let variant of variants; trackBy: trackById"> <article
class="variant-row"
*ngFor="let variant of variants; trackBy: trackById"
>
<div class="variant-header"> <div class="variant-header">
<button <button
type="button" type="button"
class="expand-toggle" class="expand-toggle"
(click)="toggleVariantExpanded(variant.id)" (click)="toggleVariantExpanded(variant.id)"
[attr.aria-expanded]="isVariantExpanded(variant.id)"> [attr.aria-expanded]="isVariantExpanded(variant.id)"
{{ isVariantExpanded(variant.id) ? '▾' : '▸' }} >
{{ isVariantExpanded(variant.id) ? "▾" : "▸" }}
</button> </button>
<div class="variant-head-main"> <div class="variant-head-main">
<strong>{{ variant.variantDisplayName }}</strong> <strong>{{ variant.variantDisplayName }}</strong>
<div class="variant-collapsed-summary" *ngIf="!isVariantExpanded(variant.id)"> <div
class="variant-collapsed-summary"
*ngIf="!isVariantExpanded(variant.id)"
>
<span class="color-summary"> <span class="color-summary">
<span class="color-dot" [style.background-color]="getVariantColorHex(variant)"></span> <span
{{ variant.colorName || 'N/D' }} class="color-dot"
[style.background-color]="getVariantColorHex(variant)"
></span>
{{ variant.colorName || "N/D" }}
</span> </span>
<span>Stock spools: {{ variant.stockSpools | number:'1.0-3' }}</span> <span
<span>Filamento: {{ computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) | number:'1.0-0' }} g</span> >Stock spools:
{{ variant.stockSpools | number: "1.0-3" }}</span
>
<span
>Filamento:
{{
computeStockFilamentGrams(
variant.stockSpools,
variant.spoolNetKg
) | number: "1.0-0"
}}
g</span
>
</div> </div>
</div> </div>
<div class="variant-head-actions"> <div class="variant-head-actions">
<span class="badge low" *ngIf="isLowStock(variant)">Stock basso</span> <span class="badge low" *ngIf="isLowStock(variant)"
<span class="badge ok" *ngIf="!isLowStock(variant)">Stock ok</span> >Stock basso</span
>
<span class="badge ok" *ngIf="!isLowStock(variant)"
>Stock ok</span
>
<button <button
type="button" type="button"
class="btn-delete" class="btn-delete"
(click)="openDeleteVariant(variant)" (click)="openDeleteVariant(variant)"
[disabled]="deletingVariantIds.has(variant.id)"> [disabled]="deletingVariantIds.has(variant.id)"
{{ deletingVariantIds.has(variant.id) ? 'Eliminazione...' : 'Elimina' }} >
{{
deletingVariantIds.has(variant.id)
? "Eliminazione..."
: "Elimina"
}}
</button> </button>
</div> </div>
</div> </div>
@@ -179,7 +274,10 @@
<label class="form-field"> <label class="form-field">
<span>Materiale</span> <span>Materiale</span>
<select [(ngModel)]="variant.materialTypeId"> <select [(ngModel)]="variant.materialTypeId">
<option *ngFor="let material of materials; trackBy: trackById" [ngValue]="material.id"> <option
*ngFor="let material of materials; trackBy: trackById"
[ngValue]="material.id"
>
{{ material.materialCode }} {{ material.materialCode }}
</option> </option>
</select> </select>
@@ -213,15 +311,32 @@
</label> </label>
<label class="form-field"> <label class="form-field">
<span>Costo CHF/kg</span> <span>Costo CHF/kg</span>
<input type="number" step="0.01" min="0" [(ngModel)]="variant.costChfPerKg" /> <input
type="number"
step="0.01"
min="0"
[(ngModel)]="variant.costChfPerKg"
/>
</label> </label>
<label class="form-field"> <label class="form-field">
<span>Stock spool</span> <span>Stock spool</span>
<input type="number" step="0.001" min="0" max="999.999" [(ngModel)]="variant.stockSpools" /> <input
type="number"
step="0.001"
min="0"
max="999.999"
[(ngModel)]="variant.stockSpools"
/>
</label> </label>
<label class="form-field"> <label class="form-field">
<span>Spool netto kg</span> <span>Spool netto kg</span>
<input type="number" step="0.001" min="0.001" max="999.999" [(ngModel)]="variant.spoolNetKg" /> <input
type="number"
step="0.001"
min="0.001"
max="999.999"
[(ngModel)]="variant.spoolNetKg"
/>
</label> </label>
</div> </div>
@@ -241,33 +356,57 @@
</div> </div>
<p class="variant-meta" *ngIf="isVariantExpanded(variant.id)"> <p class="variant-meta" *ngIf="isVariantExpanded(variant.id)">
Stock spools: <strong>{{ variant.stockSpools | number:'1.0-3' }}</strong> | Stock spools:
Filamento totale: <strong>{{ computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) | number:'1.0-0' }} g</strong> <strong>{{ variant.stockSpools | number: "1.0-3" }}</strong> |
Filamento totale:
<strong
>{{
computeStockFilamentGrams(
variant.stockSpools,
variant.spoolNetKg
) | number: "1.0-0"
}}
g</strong
>
</p> </p>
<button <button
type="button" type="button"
*ngIf="isVariantExpanded(variant.id)" *ngIf="isVariantExpanded(variant.id)"
(click)="saveVariant(variant)" (click)="saveVariant(variant)"
[disabled]="savingVariantIds.has(variant.id)"> [disabled]="savingVariantIds.has(variant.id)"
{{ savingVariantIds.has(variant.id) ? 'Salvataggio...' : 'Salva variante' }} >
{{
savingVariantIds.has(variant.id)
? "Salvataggio..."
: "Salva variante"
}}
</button> </button>
</article> </article>
</div> </div>
<p class="muted" *ngIf="variants.length === 0">Nessuna variante configurata.</p> <p class="muted" *ngIf="variants.length === 0">
Nessuna variante configurata.
</p>
</section> </section>
<section class="panel"> <section class="panel">
<div class="panel-header"> <div class="panel-header">
<h3>Materiali</h3> <h3>Materiali</h3>
<button type="button" class="panel-toggle" (click)="toggleMaterialsCollapsed()"> <button
{{ materialsCollapsed ? 'Espandi' : 'Collassa' }} type="button"
class="panel-toggle"
(click)="toggleMaterialsCollapsed()"
>
{{ materialsCollapsed ? "Espandi" : "Collassa" }}
</button> </button>
</div> </div>
<div *ngIf="!materialsCollapsed; else materialsCollapsedTpl"> <div *ngIf="!materialsCollapsed; else materialsCollapsedTpl">
<div class="material-grid"> <div class="material-grid">
<article class="material-card" *ngFor="let material of materials; trackBy: trackById"> <article
class="material-card"
*ngFor="let material of materials; trackBy: trackById"
>
<div class="form-grid"> <div class="form-grid">
<label class="form-field form-field--wide"> <label class="form-field form-field--wide">
<span>Codice</span> <span>Codice</span>
@@ -275,7 +414,11 @@
</label> </label>
<label class="form-field form-field--wide"> <label class="form-field form-field--wide">
<span>Etichetta tecnico</span> <span>Etichetta tecnico</span>
<input type="text" [(ngModel)]="material.technicalTypeLabel" [disabled]="!material.isTechnical" /> <input
type="text"
[(ngModel)]="material.technicalTypeLabel"
[disabled]="!material.isTechnical"
/>
</label> </label>
</div> </div>
@@ -290,12 +433,22 @@
</label> </label>
</div> </div>
<button type="button" (click)="saveMaterial(material)" [disabled]="savingMaterialIds.has(material.id)"> <button
{{ savingMaterialIds.has(material.id) ? 'Salvataggio...' : 'Salva materiale' }} type="button"
(click)="saveMaterial(material)"
[disabled]="savingMaterialIds.has(material.id)"
>
{{
savingMaterialIds.has(material.id)
? "Salvataggio..."
: "Salva materiale"
}}
</button> </button>
</article> </article>
</div> </div>
<p class="muted" *ngIf="materials.length === 0">Nessun materiale configurato.</p> <p class="muted" *ngIf="materials.length === 0">
Nessun materiale configurato.
</p>
</div> </div>
</section> </section>
</div> </div>
@@ -313,19 +466,38 @@
<p class="muted">Sezione collassata.</p> <p class="muted">Sezione collassata.</p>
</ng-template> </ng-template>
<div class="dialog-backdrop" *ngIf="variantToDelete" (click)="closeDeleteVariantDialog()"></div> <div
class="dialog-backdrop"
*ngIf="variantToDelete"
(click)="closeDeleteVariantDialog()"
></div>
<div class="confirm-dialog" *ngIf="variantToDelete"> <div class="confirm-dialog" *ngIf="variantToDelete">
<h4>Sei sicuro?</h4> <h4>Sei sicuro?</h4>
<p>Vuoi eliminare la variante <strong>{{ variantToDelete.variantDisplayName }}</strong>?</p> <p>
Vuoi eliminare la variante
<strong>{{ variantToDelete.variantDisplayName }}</strong
>?
</p>
<p class="muted">L'operazione non è reversibile.</p> <p class="muted">L'operazione non è reversibile.</p>
<div class="dialog-actions"> <div class="dialog-actions">
<button type="button" class="btn-secondary" (click)="closeDeleteVariantDialog()">Annulla</button> <button
type="button"
class="btn-secondary"
(click)="closeDeleteVariantDialog()"
>
Annulla
</button>
<button <button
type="button" type="button"
class="btn-delete" class="btn-delete"
(click)="confirmDeleteVariant()" (click)="confirmDeleteVariant()"
[disabled]="variantToDelete && deletingVariantIds.has(variantToDelete.id)"> [disabled]="variantToDelete && deletingVariantIds.has(variantToDelete.id)"
{{ variantToDelete && deletingVariantIds.has(variantToDelete.id) ? 'Eliminazione...' : 'Conferma elimina' }} >
{{
variantToDelete && deletingVariantIds.has(variantToDelete.id)
? "Eliminazione..."
: "Conferma elimina"
}}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -6,7 +6,7 @@ import {
AdminFilamentVariant, AdminFilamentVariant,
AdminOperationsService, AdminOperationsService,
AdminUpsertFilamentMaterialTypePayload, AdminUpsertFilamentMaterialTypePayload,
AdminUpsertFilamentVariantPayload AdminUpsertFilamentVariantPayload,
} from '../services/admin-operations.service'; } from '../services/admin-operations.service';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
import { getColorHex } from '../../../core/constants/colors.const'; import { getColorHex } from '../../../core/constants/colors.const';
@@ -16,7 +16,7 @@ import { getColorHex } from '../../../core/constants/colors.const';
standalone: true, standalone: true,
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule],
templateUrl: './admin-filament-stock.component.html', templateUrl: './admin-filament-stock.component.html',
styleUrl: './admin-filament-stock.component.scss' styleUrl: './admin-filament-stock.component.scss',
}) })
export class AdminFilamentStockComponent implements OnInit { export class AdminFilamentStockComponent implements OnInit {
private readonly adminOperationsService = inject(AdminOperationsService); private readonly adminOperationsService = inject(AdminOperationsService);
@@ -40,7 +40,7 @@ export class AdminFilamentStockComponent implements OnInit {
materialCode: '', materialCode: '',
isFlexible: false, isFlexible: false,
isTechnical: false, isTechnical: false,
technicalTypeLabel: '' technicalTypeLabel: '',
}; };
newVariant: AdminUpsertFilamentVariantPayload = { newVariant: AdminUpsertFilamentVariantPayload = {
@@ -55,7 +55,7 @@ export class AdminFilamentStockComponent implements OnInit {
costChfPerKg: 0, costChfPerKg: 0,
stockSpools: 0, stockSpools: 0,
spoolNetKg: 1, spoolNetKg: 1,
isActive: true isActive: true,
}; };
ngOnInit(): void { ngOnInit(): void {
@@ -69,13 +69,13 @@ export class AdminFilamentStockComponent implements OnInit {
forkJoin({ forkJoin({
materials: this.adminOperationsService.getFilamentMaterials(), materials: this.adminOperationsService.getFilamentMaterials(),
variants: this.adminOperationsService.getFilamentVariants() variants: this.adminOperationsService.getFilamentVariants(),
}).subscribe({ }).subscribe({
next: ({ materials, variants }) => { next: ({ materials, variants }) => {
this.materials = this.sortMaterials(materials); this.materials = this.sortMaterials(materials);
this.variants = this.sortVariants(variants); this.variants = this.sortVariants(variants);
const existingIds = new Set(this.variants.map(v => v.id)); const existingIds = new Set(this.variants.map((v) => v.id));
this.expandedVariantIds.forEach(id => { this.expandedVariantIds.forEach((id) => {
if (!existingIds.has(id)) { if (!existingIds.has(id)) {
this.expandedVariantIds.delete(id); this.expandedVariantIds.delete(id);
} }
@@ -87,8 +87,11 @@ export class AdminFilamentStockComponent implements OnInit {
}, },
error: (err) => { error: (err) => {
this.loading = false; this.loading = false;
this.errorMessage = this.extractErrorMessage(err, 'Impossibile caricare i filamenti.'); this.errorMessage = this.extractErrorMessage(
} err,
'Impossibile caricare i filamenti.',
);
},
}); });
} }
@@ -107,7 +110,7 @@ export class AdminFilamentStockComponent implements OnInit {
isTechnical: !!this.newMaterial.isTechnical, isTechnical: !!this.newMaterial.isTechnical,
technicalTypeLabel: this.newMaterial.isTechnical technicalTypeLabel: this.newMaterial.isTechnical
? (this.newMaterial.technicalTypeLabel || '').trim() ? (this.newMaterial.technicalTypeLabel || '').trim()
: '' : '',
}; };
this.adminOperationsService.createFilamentMaterial(payload).subscribe({ this.adminOperationsService.createFilamentMaterial(payload).subscribe({
@@ -120,15 +123,18 @@ export class AdminFilamentStockComponent implements OnInit {
materialCode: '', materialCode: '',
isFlexible: false, isFlexible: false,
isTechnical: false, isTechnical: false,
technicalTypeLabel: '' technicalTypeLabel: '',
}; };
this.creatingMaterial = false; this.creatingMaterial = false;
this.successMessage = 'Materiale aggiunto.'; this.successMessage = 'Materiale aggiunto.';
}, },
error: (err) => { error: (err) => {
this.creatingMaterial = false; this.creatingMaterial = false;
this.errorMessage = this.extractErrorMessage(err, 'Creazione materiale non riuscita.'); this.errorMessage = this.extractErrorMessage(
} err,
'Creazione materiale non riuscita.',
);
},
}); });
} }
@@ -145,13 +151,17 @@ export class AdminFilamentStockComponent implements OnInit {
materialCode: (material.materialCode || '').trim(), materialCode: (material.materialCode || '').trim(),
isFlexible: !!material.isFlexible, isFlexible: !!material.isFlexible,
isTechnical: !!material.isTechnical, isTechnical: !!material.isTechnical,
technicalTypeLabel: material.isTechnical ? (material.technicalTypeLabel || '').trim() : '' technicalTypeLabel: material.isTechnical
? (material.technicalTypeLabel || '').trim()
: '',
}; };
this.adminOperationsService.updateFilamentMaterial(material.id, payload).subscribe({ this.adminOperationsService
.updateFilamentMaterial(material.id, payload)
.subscribe({
next: (updated) => { next: (updated) => {
this.materials = this.sortMaterials( this.materials = this.sortMaterials(
this.materials.map((m) => (m.id === updated.id ? updated : m)) this.materials.map((m) => (m.id === updated.id ? updated : m)),
); );
this.variants = this.variants.map((variant) => { this.variants = this.variants.map((variant) => {
if (variant.materialTypeId !== updated.id) { if (variant.materialTypeId !== updated.id) {
@@ -162,7 +172,7 @@ export class AdminFilamentStockComponent implements OnInit {
materialCode: updated.materialCode, materialCode: updated.materialCode,
materialIsFlexible: updated.isFlexible, materialIsFlexible: updated.isFlexible,
materialIsTechnical: updated.isTechnical, materialIsTechnical: updated.isTechnical,
materialTechnicalTypeLabel: updated.technicalTypeLabel materialTechnicalTypeLabel: updated.technicalTypeLabel,
}; };
}); });
this.savingMaterialIds.delete(material.id); this.savingMaterialIds.delete(material.id);
@@ -170,8 +180,11 @@ export class AdminFilamentStockComponent implements OnInit {
}, },
error: (err) => { error: (err) => {
this.savingMaterialIds.delete(material.id); this.savingMaterialIds.delete(material.id);
this.errorMessage = this.extractErrorMessage(err, 'Aggiornamento materiale non riuscito.'); this.errorMessage = this.extractErrorMessage(
} err,
'Aggiornamento materiale non riuscito.',
);
},
}); });
} }
@@ -189,7 +202,8 @@ export class AdminFilamentStockComponent implements OnInit {
next: (created) => { next: (created) => {
this.variants = this.sortVariants([...this.variants, created]); this.variants = this.sortVariants([...this.variants, created]);
this.newVariant = { this.newVariant = {
materialTypeId: this.newVariant.materialTypeId || this.materials[0]?.id || 0, materialTypeId:
this.newVariant.materialTypeId || this.materials[0]?.id || 0,
variantDisplayName: '', variantDisplayName: '',
colorName: '', colorName: '',
colorHex: '', colorHex: '',
@@ -200,15 +214,18 @@ export class AdminFilamentStockComponent implements OnInit {
costChfPerKg: 0, costChfPerKg: 0,
stockSpools: 0, stockSpools: 0,
spoolNetKg: 1, spoolNetKg: 1,
isActive: true isActive: true,
}; };
this.creatingVariant = false; this.creatingVariant = false;
this.successMessage = 'Variante aggiunta.'; this.successMessage = 'Variante aggiunta.';
}, },
error: (err) => { error: (err) => {
this.creatingVariant = false; this.creatingVariant = false;
this.errorMessage = this.extractErrorMessage(err, 'Creazione variante non riuscita.'); this.errorMessage = this.extractErrorMessage(
} err,
'Creazione variante non riuscita.',
);
},
}); });
} }
@@ -222,30 +239,43 @@ export class AdminFilamentStockComponent implements OnInit {
this.savingVariantIds.add(variant.id); this.savingVariantIds.add(variant.id);
const payload = this.toVariantPayload(variant); const payload = this.toVariantPayload(variant);
this.adminOperationsService.updateFilamentVariant(variant.id, payload).subscribe({ this.adminOperationsService
.updateFilamentVariant(variant.id, payload)
.subscribe({
next: (updated) => { next: (updated) => {
this.variants = this.sortVariants( this.variants = this.sortVariants(
this.variants.map((v) => (v.id === updated.id ? updated : v)) this.variants.map((v) => (v.id === updated.id ? updated : v)),
); );
this.savingVariantIds.delete(variant.id); this.savingVariantIds.delete(variant.id);
this.successMessage = 'Variante aggiornata.'; this.successMessage = 'Variante aggiornata.';
}, },
error: (err) => { error: (err) => {
this.savingVariantIds.delete(variant.id); this.savingVariantIds.delete(variant.id);
this.errorMessage = this.extractErrorMessage(err, 'Aggiornamento variante non riuscito.'); this.errorMessage = this.extractErrorMessage(
} err,
'Aggiornamento variante non riuscito.',
);
},
}); });
} }
isLowStock(variant: AdminFilamentVariant): boolean { isLowStock(variant: AdminFilamentVariant): boolean {
return this.computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) < 1000; return (
this.computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) <
1000
);
} }
computeStockKg(stockSpools?: number, spoolNetKg?: number): number { computeStockKg(stockSpools?: number, spoolNetKg?: number): number {
const spools = Number(stockSpools ?? 0); const spools = Number(stockSpools ?? 0);
const netKg = Number(spoolNetKg ?? 0); const netKg = Number(spoolNetKg ?? 0);
if (!Number.isFinite(spools) || !Number.isFinite(netKg) || spools < 0 || netKg < 0) { if (
!Number.isFinite(spools) ||
!Number.isFinite(netKg) ||
spools < 0 ||
netKg < 0
) {
return 0; return 0;
} }
return spools * netKg; return spools * netKg;
@@ -298,7 +328,7 @@ export class AdminFilamentStockComponent implements OnInit {
this.adminOperationsService.deleteFilamentVariant(variant.id).subscribe({ this.adminOperationsService.deleteFilamentVariant(variant.id).subscribe({
next: () => { next: () => {
this.variants = this.variants.filter(v => v.id !== variant.id); this.variants = this.variants.filter((v) => v.id !== variant.id);
this.expandedVariantIds.delete(variant.id); this.expandedVariantIds.delete(variant.id);
this.deletingVariantIds.delete(variant.id); this.deletingVariantIds.delete(variant.id);
this.variantToDelete = null; this.variantToDelete = null;
@@ -306,8 +336,11 @@ export class AdminFilamentStockComponent implements OnInit {
}, },
error: (err) => { error: (err) => {
this.deletingVariantIds.delete(variant.id); this.deletingVariantIds.delete(variant.id);
this.errorMessage = this.extractErrorMessage(err, 'Eliminazione variante non riuscita.'); this.errorMessage = this.extractErrorMessage(
} err,
'Eliminazione variante non riuscita.',
);
},
}); });
} }
@@ -319,7 +352,9 @@ export class AdminFilamentStockComponent implements OnInit {
this.quickInsertCollapsed = !this.quickInsertCollapsed; this.quickInsertCollapsed = !this.quickInsertCollapsed;
} }
private toVariantPayload(source: AdminUpsertFilamentVariantPayload | AdminFilamentVariant): AdminUpsertFilamentVariantPayload { private toVariantPayload(
source: AdminUpsertFilamentVariantPayload | AdminFilamentVariant,
): AdminUpsertFilamentVariantPayload {
return { return {
materialTypeId: Number(source.materialTypeId), materialTypeId: Number(source.materialTypeId),
variantDisplayName: (source.variantDisplayName || '').trim(), variantDisplayName: (source.variantDisplayName || '').trim(),
@@ -332,21 +367,31 @@ export class AdminFilamentStockComponent implements OnInit {
costChfPerKg: Number(source.costChfPerKg ?? 0), costChfPerKg: Number(source.costChfPerKg ?? 0),
stockSpools: Number(source.stockSpools ?? 0), stockSpools: Number(source.stockSpools ?? 0),
spoolNetKg: Number(source.spoolNetKg ?? 0), spoolNetKg: Number(source.spoolNetKg ?? 0),
isActive: source.isActive !== false isActive: source.isActive !== false,
}; };
} }
private sortMaterials(materials: AdminFilamentMaterialType[]): AdminFilamentMaterialType[] { private sortMaterials(
return [...materials].sort((a, b) => a.materialCode.localeCompare(b.materialCode)); materials: AdminFilamentMaterialType[],
): AdminFilamentMaterialType[] {
return [...materials].sort((a, b) =>
a.materialCode.localeCompare(b.materialCode),
);
} }
private sortVariants(variants: AdminFilamentVariant[]): AdminFilamentVariant[] { private sortVariants(
variants: AdminFilamentVariant[],
): AdminFilamentVariant[] {
return [...variants].sort((a, b) => { return [...variants].sort((a, b) => {
const byMaterial = (a.materialCode || '').localeCompare(b.materialCode || ''); const byMaterial = (a.materialCode || '').localeCompare(
b.materialCode || '',
);
if (byMaterial !== 0) { if (byMaterial !== 0) {
return byMaterial; return byMaterial;
} }
return (a.variantDisplayName || '').localeCompare(b.variantDisplayName || ''); return (a.variantDisplayName || '').localeCompare(
b.variantDisplayName || '',
);
}); });
} }

View File

@@ -15,8 +15,11 @@
required required
/> />
<button type="submit" [disabled]="loading || !password.trim() || lockSecondsRemaining > 0"> <button
{{ loading ? 'Accesso...' : 'Accedi' }} type="submit"
[disabled]="loading || !password.trim() || lockSecondsRemaining > 0"
>
{{ loading ? "Accesso..." : "Accedi" }}
</button> </button>
</form> </form>

View File

@@ -3,7 +3,10 @@ import { Component, inject, OnDestroy } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { AdminAuthResponse, AdminAuthService } from '../services/admin-auth.service'; import {
AdminAuthResponse,
AdminAuthService,
} from '../services/admin-auth.service';
const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']); const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
@@ -12,7 +15,7 @@ const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
standalone: true, standalone: true,
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule],
templateUrl: './admin-login.component.html', templateUrl: './admin-login.component.html',
styleUrl: './admin-login.component.scss' styleUrl: './admin-login.component.scss',
}) })
export class AdminLoginComponent implements OnDestroy { export class AdminLoginComponent implements OnDestroy {
private readonly authService = inject(AdminAuthService); private readonly authService = inject(AdminAuthService);
@@ -26,7 +29,11 @@ export class AdminLoginComponent implements OnDestroy {
private lockTimer: ReturnType<typeof setInterval> | null = null; private lockTimer: ReturnType<typeof setInterval> | null = null;
submit(): void { submit(): void {
if (!this.password.trim() || this.loading || this.lockSecondsRemaining > 0) { if (
!this.password.trim() ||
this.loading ||
this.lockSecondsRemaining > 0
) {
return; return;
} }
@@ -53,7 +60,7 @@ export class AdminLoginComponent implements OnDestroy {
error: (error: HttpErrorResponse) => { error: (error: HttpErrorResponse) => {
this.loading = false; this.loading = false;
this.handleLoginFailure(this.extractRetryAfterSeconds(error)); this.handleLoginFailure(this.extractRetryAfterSeconds(error));
} },
}); });
} }

View File

@@ -4,7 +4,14 @@
<h2>Sessioni quote</h2> <h2>Sessioni quote</h2>
<p>Sessioni create dal configuratore con stato e conversione ordine.</p> <p>Sessioni create dal configuratore con stato e conversione ordine.</p>
</div> </div>
<button type="button" class="btn-primary" (click)="loadSessions()" [disabled]="loading">Aggiorna</button> <button
type="button"
class="btn-primary"
(click)="loadSessions()"
[disabled]="loading"
>
Aggiorna
</button>
</header> </header>
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p> <p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
@@ -27,40 +34,72 @@
<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">{{ 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>{{ session.convertedOrderId || "-" }}</td>
<td class="actions"> <td class="actions">
<button <button
type="button" type="button"
class="btn-secondary" class="btn-secondary"
(click)="toggleSessionDetail(session)"> (click)="toggleSessionDetail(session)"
{{ isDetailOpen(session.id) ? 'Nascondi' : 'Vedi' }} >
{{ isDetailOpen(session.id) ? "Nascondi" : "Vedi" }}
</button> </button>
<button <button
type="button" type="button"
class="btn-danger" class="btn-danger"
(click)="deleteSession(session)" (click)="deleteSession(session)"
[disabled]="isDeletingSession(session.id) || !!session.convertedOrderId" [disabled]="
[title]="session.convertedOrderId ? 'Sessione collegata a un ordine, non eliminabile.' : ''"> isDeletingSession(session.id) || !!session.convertedOrderId
{{ isDeletingSession(session.id) ? 'Eliminazione...' : 'Elimina' }} "
[title]="
session.convertedOrderId
? 'Sessione collegata a un ordine, non eliminabile.'
: ''
"
>
{{
isDeletingSession(session.id) ? "Eliminazione..." : "Elimina"
}}
</button> </button>
</td> </td>
</tr> </tr>
<tr *ngIf="isDetailOpen(session.id)"> <tr *ngIf="isDetailOpen(session.id)">
<td colspan="7" class="detail-cell"> <td colspan="7" class="detail-cell">
<div *ngIf="isLoadingDetail(session.id)">Caricamento dettaglio...</div> <div *ngIf="isLoadingDetail(session.id)">
<div *ngIf="!isLoadingDetail(session.id) && getSessionDetail(session.id) as detail" class="detail-box"> Caricamento dettaglio...
</div>
<div
*ngIf="
!isLoadingDetail(session.id) &&
getSessionDetail(session.id) as detail
"
class="detail-box"
>
<div class="detail-summary"> <div class="detail-summary">
<div><strong>Elementi:</strong> {{ detail.items.length }}</div> <div>
<div><strong>Totale articoli:</strong> {{ detail.itemsTotalChf | currency:'CHF' }}</div> <strong>Elementi:</strong> {{ detail.items.length }}
<div><strong>Spedizione:</strong> {{ detail.shippingCostChf | currency:'CHF' }}</div> </div>
<div><strong>Totale sessione:</strong> {{ detail.grandTotalChf | currency:'CHF' }}</div> <div>
<strong>Totale articoli:</strong>
{{ detail.itemsTotalChf | currency: "CHF" }}
</div>
<div>
<strong>Spedizione:</strong>
{{ detail.shippingCostChf | currency: "CHF" }}
</div>
<div>
<strong>Totale sessione:</strong>
{{ detail.grandTotalChf | currency: "CHF" }}
</div>
</div> </div>
<table class="detail-table" *ngIf="detail.items.length > 0; else noItemsTpl"> <table
class="detail-table"
*ngIf="detail.items.length > 0; else noItemsTpl"
>
<thead> <thead>
<tr> <tr>
<th>File</th> <th>File</th>
@@ -76,9 +115,15 @@
<td>{{ item.originalFilename }}</td> <td>{{ item.originalFilename }}</td>
<td>{{ item.quantity }}</td> <td>{{ item.quantity }}</td>
<td>{{ formatPrintTime(item.printTimeSeconds) }}</td> <td>{{ formatPrintTime(item.printTimeSeconds) }}</td>
<td>{{ item.materialGrams ? (item.materialGrams | number:'1.0-2') + ' g' : '-' }}</td> <td>
{{
item.materialGrams
? (item.materialGrams | number: "1.0-2") + " g"
: "-"
}}
</td>
<td>{{ item.status }}</td> <td>{{ item.status }}</td>
<td>{{ item.unitPriceChf | currency:'CHF' }}</td> <td>{{ item.unitPriceChf | currency: "CHF" }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -3,7 +3,7 @@ import { Component, inject, OnInit } from '@angular/core';
import { import {
AdminOperationsService, AdminOperationsService,
AdminQuoteSession, AdminQuoteSession,
AdminQuoteSessionDetail AdminQuoteSessionDetail,
} from '../services/admin-operations.service'; } from '../services/admin-operations.service';
@Component({ @Component({
@@ -11,7 +11,7 @@ import {
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule],
templateUrl: './admin-sessions.component.html', templateUrl: './admin-sessions.component.html',
styleUrl: './admin-sessions.component.scss' styleUrl: './admin-sessions.component.scss',
}) })
export class AdminSessionsComponent implements OnInit { export class AdminSessionsComponent implements OnInit {
private readonly adminOperationsService = inject(AdminOperationsService); private readonly adminOperationsService = inject(AdminOperationsService);
@@ -41,7 +41,7 @@ export class AdminSessionsComponent implements OnInit {
error: () => { error: () => {
this.loading = false; this.loading = false;
this.errorMessage = 'Impossibile caricare le sessioni.'; this.errorMessage = 'Impossibile caricare le sessioni.';
} },
}); });
} }
@@ -51,7 +51,7 @@ export class AdminSessionsComponent implements OnInit {
} }
const confirmed = window.confirm( const confirmed = window.confirm(
`Vuoi eliminare la sessione ${session.id}? Questa azione non si puo annullare.` `Vuoi eliminare la sessione ${session.id}? Questa azione non si puo annullare.`,
); );
if (!confirmed) { if (!confirmed) {
return; return;
@@ -69,8 +69,11 @@ export class AdminSessionsComponent implements OnInit {
}, },
error: (err) => { error: (err) => {
this.deletingSessionIds.delete(session.id); this.deletingSessionIds.delete(session.id);
this.errorMessage = this.extractErrorMessage(err, 'Impossibile eliminare la sessione.'); this.errorMessage = this.extractErrorMessage(
} err,
'Impossibile eliminare la sessione.',
);
},
}); });
} }
@@ -85,7 +88,10 @@ export class AdminSessionsComponent implements OnInit {
} }
this.expandedSessionId = session.id; this.expandedSessionId = session.id;
if (this.sessionDetailsById[session.id] || this.loadingDetailSessionIds.has(session.id)) { if (
this.sessionDetailsById[session.id] ||
this.loadingDetailSessionIds.has(session.id)
) {
return; return;
} }
@@ -94,14 +100,17 @@ export class AdminSessionsComponent implements OnInit {
next: (detail) => { next: (detail) => {
this.sessionDetailsById = { this.sessionDetailsById = {
...this.sessionDetailsById, ...this.sessionDetailsById,
[session.id]: detail [session.id]: detail,
}; };
this.loadingDetailSessionIds.delete(session.id); this.loadingDetailSessionIds.delete(session.id);
}, },
error: (err) => { error: (err) => {
this.loadingDetailSessionIds.delete(session.id); this.loadingDetailSessionIds.delete(session.id);
this.errorMessage = this.extractErrorMessage(err, 'Impossibile caricare il dettaglio sessione.'); this.errorMessage = this.extractErrorMessage(
} err,
'Impossibile caricare il dettaglio sessione.',
);
},
}); });
} }

View File

@@ -9,8 +9,12 @@
<div class="menu-scroll"> <div class="menu-scroll">
<nav class="menu"> <nav class="menu">
<a routerLink="orders" routerLinkActive="active">Ordini</a> <a routerLink="orders" routerLinkActive="active">Ordini</a>
<a routerLink="filament-stock" routerLinkActive="active">Stock filamenti</a> <a routerLink="filament-stock" routerLinkActive="active"
<a routerLink="contact-requests" routerLinkActive="active">Richieste contatto</a> >Stock filamenti</a
>
<a routerLink="contact-requests" routerLinkActive="active"
>Richieste contatto</a
>
<a routerLink="sessions" routerLinkActive="active">Sessioni</a> <a routerLink="sessions" routerLinkActive="active">Sessioni</a>
</nav> </nav>
</div> </div>

View File

@@ -54,7 +54,10 @@
font-weight: 600; font-weight: 600;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
background: var(--color-bg-card); background: var(--color-bg-card);
transition: border-color 0.2s ease, background-color 0.2s ease, color 0.2s ease; transition:
border-color 0.2s ease,
background-color 0.2s ease,
color 0.2s ease;
white-space: nowrap; white-space: nowrap;
} }
@@ -78,7 +81,9 @@
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease; transition:
border-color 0.2s ease,
background-color 0.2s ease;
} }
.logout:hover { .logout:hover {

View File

@@ -1,6 +1,12 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, inject } from '@angular/core'; import { Component, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import {
ActivatedRoute,
Router,
RouterLink,
RouterLinkActive,
RouterOutlet,
} from '@angular/router';
import { AdminAuthService } from '../services/admin-auth.service'; import { AdminAuthService } from '../services/admin-auth.service';
const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']); const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
@@ -10,7 +16,7 @@ const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
standalone: true, standalone: true,
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive], imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
templateUrl: './admin-shell.component.html', templateUrl: './admin-shell.component.html',
styleUrl: './admin-shell.component.scss' styleUrl: './admin-shell.component.scss',
}) })
export class AdminShellComponent { export class AdminShellComponent {
private readonly adminAuthService = inject(AdminAuthService); private readonly adminAuthService = inject(AdminAuthService);
@@ -24,7 +30,7 @@ export class AdminShellComponent {
}, },
error: () => { error: () => {
void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']); void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']);
} },
}); });
} }

View File

@@ -11,23 +11,31 @@ export interface AdminAuthResponse {
} }
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class AdminAuthService { export class AdminAuthService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/api/admin/auth`; private readonly baseUrl = `${environment.apiUrl}/api/admin/auth`;
login(password: string): Observable<AdminAuthResponse> { login(password: string): Observable<AdminAuthResponse> {
return this.http.post<AdminAuthResponse>(`${this.baseUrl}/login`, { password }, { withCredentials: true }); return this.http.post<AdminAuthResponse>(
`${this.baseUrl}/login`,
{ password },
{ withCredentials: true },
);
} }
logout(): Observable<void> { logout(): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/logout`, {}, { withCredentials: true }); return this.http.post<void>(
`${this.baseUrl}/logout`,
{},
{ withCredentials: true },
);
} }
me(): Observable<boolean> { me(): Observable<boolean> {
return this.http.get<AdminAuthResponse>(`${this.baseUrl}/me`, { withCredentials: true }).pipe( return this.http
map((response) => Boolean(response?.authenticated)) .get<AdminAuthResponse>(`${this.baseUrl}/me`, { withCredentials: true })
); .pipe(map((response) => Boolean(response?.authenticated)));
} }
} }

View File

@@ -145,82 +145,138 @@ export interface AdminQuoteSessionDetail {
} }
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class AdminOperationsService { export class AdminOperationsService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}/api/admin`; private readonly baseUrl = `${environment.apiUrl}/api/admin`;
getFilamentStock(): Observable<AdminFilamentStockRow[]> { getFilamentStock(): Observable<AdminFilamentStockRow[]> {
return this.http.get<AdminFilamentStockRow[]>(`${this.baseUrl}/filament-stock`, { withCredentials: true }); return this.http.get<AdminFilamentStockRow[]>(
`${this.baseUrl}/filament-stock`,
{ withCredentials: true },
);
} }
getFilamentMaterials(): Observable<AdminFilamentMaterialType[]> { getFilamentMaterials(): Observable<AdminFilamentMaterialType[]> {
return this.http.get<AdminFilamentMaterialType[]>(`${this.baseUrl}/filaments/materials`, { withCredentials: true }); return this.http.get<AdminFilamentMaterialType[]>(
`${this.baseUrl}/filaments/materials`,
{ withCredentials: true },
);
} }
getFilamentVariants(): Observable<AdminFilamentVariant[]> { getFilamentVariants(): Observable<AdminFilamentVariant[]> {
return this.http.get<AdminFilamentVariant[]>(`${this.baseUrl}/filaments/variants`, { withCredentials: true }); return this.http.get<AdminFilamentVariant[]>(
`${this.baseUrl}/filaments/variants`,
{ withCredentials: true },
);
} }
createFilamentMaterial(payload: AdminUpsertFilamentMaterialTypePayload): Observable<AdminFilamentMaterialType> { createFilamentMaterial(
return this.http.post<AdminFilamentMaterialType>(`${this.baseUrl}/filaments/materials`, payload, { withCredentials: true }); payload: AdminUpsertFilamentMaterialTypePayload,
): Observable<AdminFilamentMaterialType> {
return this.http.post<AdminFilamentMaterialType>(
`${this.baseUrl}/filaments/materials`,
payload,
{ withCredentials: true },
);
} }
updateFilamentMaterial(materialId: number, payload: AdminUpsertFilamentMaterialTypePayload): Observable<AdminFilamentMaterialType> { updateFilamentMaterial(
return this.http.put<AdminFilamentMaterialType>(`${this.baseUrl}/filaments/materials/${materialId}`, payload, { withCredentials: true }); materialId: number,
payload: AdminUpsertFilamentMaterialTypePayload,
): Observable<AdminFilamentMaterialType> {
return this.http.put<AdminFilamentMaterialType>(
`${this.baseUrl}/filaments/materials/${materialId}`,
payload,
{ withCredentials: true },
);
} }
createFilamentVariant(payload: AdminUpsertFilamentVariantPayload): Observable<AdminFilamentVariant> { createFilamentVariant(
return this.http.post<AdminFilamentVariant>(`${this.baseUrl}/filaments/variants`, payload, { withCredentials: true }); payload: AdminUpsertFilamentVariantPayload,
): Observable<AdminFilamentVariant> {
return this.http.post<AdminFilamentVariant>(
`${this.baseUrl}/filaments/variants`,
payload,
{ withCredentials: true },
);
} }
updateFilamentVariant(variantId: number, payload: AdminUpsertFilamentVariantPayload): Observable<AdminFilamentVariant> { updateFilamentVariant(
return this.http.put<AdminFilamentVariant>(`${this.baseUrl}/filaments/variants/${variantId}`, payload, { withCredentials: true }); variantId: number,
payload: AdminUpsertFilamentVariantPayload,
): Observable<AdminFilamentVariant> {
return this.http.put<AdminFilamentVariant>(
`${this.baseUrl}/filaments/variants/${variantId}`,
payload,
{ withCredentials: true },
);
} }
deleteFilamentVariant(variantId: number): Observable<void> { deleteFilamentVariant(variantId: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/filaments/variants/${variantId}`, { withCredentials: true }); return this.http.delete<void>(
`${this.baseUrl}/filaments/variants/${variantId}`,
{ withCredentials: true },
);
} }
getContactRequests(): Observable<AdminContactRequest[]> { getContactRequests(): Observable<AdminContactRequest[]> {
return this.http.get<AdminContactRequest[]>(`${this.baseUrl}/contact-requests`, { withCredentials: true }); return this.http.get<AdminContactRequest[]>(
`${this.baseUrl}/contact-requests`,
{ withCredentials: true },
);
} }
getContactRequestDetail(requestId: string): Observable<AdminContactRequestDetail> { getContactRequestDetail(
return this.http.get<AdminContactRequestDetail>(`${this.baseUrl}/contact-requests/${requestId}`, { withCredentials: true }); requestId: string,
): Observable<AdminContactRequestDetail> {
return this.http.get<AdminContactRequestDetail>(
`${this.baseUrl}/contact-requests/${requestId}`,
{ withCredentials: true },
);
} }
updateContactRequestStatus( updateContactRequestStatus(
requestId: string, requestId: string,
payload: AdminUpdateContactRequestStatusPayload payload: AdminUpdateContactRequestStatusPayload,
): Observable<AdminContactRequestDetail> { ): Observable<AdminContactRequestDetail> {
return this.http.patch<AdminContactRequestDetail>( return this.http.patch<AdminContactRequestDetail>(
`${this.baseUrl}/contact-requests/${requestId}/status`, `${this.baseUrl}/contact-requests/${requestId}/status`,
payload, payload,
{ withCredentials: true } { withCredentials: true },
); );
} }
downloadContactRequestAttachment(requestId: string, attachmentId: string): Observable<Blob> { downloadContactRequestAttachment(
return this.http.get(`${this.baseUrl}/contact-requests/${requestId}/attachments/${attachmentId}/file`, { requestId: string,
attachmentId: string,
): Observable<Blob> {
return this.http.get(
`${this.baseUrl}/contact-requests/${requestId}/attachments/${attachmentId}/file`,
{
withCredentials: true, withCredentials: true,
responseType: 'blob' responseType: 'blob',
}); },
);
} }
getSessions(): Observable<AdminQuoteSession[]> { getSessions(): Observable<AdminQuoteSession[]> {
return this.http.get<AdminQuoteSession[]>(`${this.baseUrl}/sessions`, { withCredentials: true }); return this.http.get<AdminQuoteSession[]>(`${this.baseUrl}/sessions`, {
withCredentials: true,
});
} }
deleteSession(sessionId: string): Observable<void> { deleteSession(sessionId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/sessions/${sessionId}`, { withCredentials: true }); return this.http.delete<void>(`${this.baseUrl}/sessions/${sessionId}`, {
withCredentials: true,
});
} }
getSessionDetail(sessionId: string): Observable<AdminQuoteSessionDetail> { getSessionDetail(sessionId: string): Observable<AdminQuoteSessionDetail> {
return this.http.get<AdminQuoteSessionDetail>( return this.http.get<AdminQuoteSessionDetail>(
`${environment.apiUrl}/api/quote-sessions/${sessionId}`, `${environment.apiUrl}/api/quote-sessions/${sessionId}`,
{ withCredentials: true } { withCredentials: true },
); );
} }
} }

View File

@@ -38,7 +38,7 @@ export interface AdminUpdateOrderStatusPayload {
} }
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class AdminOrdersService { export class AdminOrdersService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
@@ -49,35 +49,54 @@ export class AdminOrdersService {
} }
getOrder(orderId: string): Observable<AdminOrder> { getOrder(orderId: string): Observable<AdminOrder> {
return this.http.get<AdminOrder>(`${this.baseUrl}/${orderId}`, { withCredentials: true }); return this.http.get<AdminOrder>(`${this.baseUrl}/${orderId}`, {
withCredentials: true,
});
} }
confirmPayment(orderId: string, method: string): Observable<AdminOrder> { confirmPayment(orderId: string, method: string): Observable<AdminOrder> {
return this.http.post<AdminOrder>(`${this.baseUrl}/${orderId}/payments/confirm`, { method }, { withCredentials: true }); return this.http.post<AdminOrder>(
`${this.baseUrl}/${orderId}/payments/confirm`,
{ method },
{ withCredentials: true },
);
} }
updateOrderStatus(orderId: string, payload: AdminUpdateOrderStatusPayload): Observable<AdminOrder> { updateOrderStatus(
return this.http.post<AdminOrder>(`${this.baseUrl}/${orderId}/status`, payload, { withCredentials: true }); orderId: string,
payload: AdminUpdateOrderStatusPayload,
): Observable<AdminOrder> {
return this.http.post<AdminOrder>(
`${this.baseUrl}/${orderId}/status`,
payload,
{ withCredentials: true },
);
} }
downloadOrderItemFile(orderId: string, orderItemId: string): Observable<Blob> { downloadOrderItemFile(
return this.http.get(`${this.baseUrl}/${orderId}/items/${orderItemId}/file`, { orderId: string,
orderItemId: string,
): Observable<Blob> {
return this.http.get(
`${this.baseUrl}/${orderId}/items/${orderItemId}/file`,
{
withCredentials: true, withCredentials: true,
responseType: 'blob' responseType: 'blob',
}); },
);
} }
downloadOrderConfirmation(orderId: string): Observable<Blob> { downloadOrderConfirmation(orderId: string): Observable<Blob> {
return this.http.get(`${this.baseUrl}/${orderId}/documents/confirmation`, { return this.http.get(`${this.baseUrl}/${orderId}/documents/confirmation`, {
withCredentials: true, withCredentials: true,
responseType: 'blob' responseType: 'blob',
}); });
} }
downloadOrderInvoice(orderId: string): Observable<Blob> { downloadOrderInvoice(orderId: string): Observable<Blob> {
return this.http.get(`${this.baseUrl}/${orderId}/documents/invoice`, { return this.http.get(`${this.baseUrl}/${orderId}/documents/invoice`, {
withCredentials: true, withCredentials: true,
responseType: 'blob' responseType: 'blob',
}); });
} }
} }

View File

@@ -1,15 +1,18 @@
<div class="container hero"> <div class="container hero">
<h1>{{ 'CALC.TITLE' | translate }}</h1> <h1>{{ "CALC.TITLE" | translate }}</h1>
<p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p> <p class="subtitle">{{ "CALC.SUBTITLE" | translate }}</p>
@if (error()) { @if (error()) {
<app-alert type="error">{{ errorKey() | translate }}</app-alert> <app-alert type="error">{{ errorKey() | translate }}</app-alert>
} }
</div> </div>
@if (step() === 'success') { @if (step() === "success") {
<div class="container hero"> <div class="container hero">
<app-success-state context="calc" (action)="onNewQuote()"></app-success-state> <app-success-state
context="calc"
(action)="onNewQuote()"
></app-success-state>
</div> </div>
} @else { } @else {
<div class="container content-grid"> <div class="container content-grid">
@@ -17,15 +20,19 @@
<div class="col-input"> <div class="col-input">
<app-card> <app-card>
<div class="mode-selector"> <div class="mode-selector">
<div class="mode-option" <div
class="mode-option"
[class.active]="mode() === 'easy'" [class.active]="mode() === 'easy'"
(click)="mode.set('easy')"> (click)="mode.set('easy')"
{{ 'CALC.MODE_EASY' | translate }} >
{{ "CALC.MODE_EASY" | translate }}
</div> </div>
<div class="mode-option" <div
class="mode-option"
[class.active]="mode() === 'advanced'" [class.active]="mode() === 'advanced'"
(click)="mode.set('advanced')"> (click)="mode.set('advanced')"
{{ 'CALC.MODE_ADVANCED' | translate }} >
{{ "CALC.MODE_ADVANCED" | translate }}
</div> </div>
</div> </div>
@@ -41,13 +48,14 @@
<!-- Right Column: Result or Info --> <!-- Right Column: Result or Info -->
<div class="col-result" #resultCol> <div class="col-result" #resultCol>
@if (loading()) { @if (loading()) {
<app-card class="loading-state"> <app-card class="loading-state">
<div class="loader-content"> <div class="loader-content">
<div class="spinner"></div> <div class="spinner"></div>
<h3 class="loading-title">{{ 'CALC.ANALYZING_TITLE' | translate }}</h3> <h3 class="loading-title">
<p class="loading-text">{{ 'CALC.ANALYZING_TEXT' | translate }}</p> {{ "CALC.ANALYZING_TITLE" | translate }}
</h3>
<p class="loading-text">{{ "CALC.ANALYZING_TEXT" | translate }}</p>
</div> </div>
</app-card> </app-card>
} @else if (result()) { } @else if (result()) {
@@ -59,11 +67,11 @@
></app-quote-result> ></app-quote-result>
} @else { } @else {
<app-card> <app-card>
<h3>{{ 'CALC.BENEFITS_TITLE' | translate }}</h3> <h3>{{ "CALC.BENEFITS_TITLE" | translate }}</h3>
<ul class="benefits"> <ul class="benefits">
<li>{{ 'CALC.BENEFITS_1' | translate }}</li> <li>{{ "CALC.BENEFITS_1" | translate }}</li>
<li>{{ 'CALC.BENEFITS_2' | translate }}</li> <li>{{ "CALC.BENEFITS_2" | translate }}</li>
<li>{{ 'CALC.BENEFITS_3' | translate }}</li> <li>{{ "CALC.BENEFITS_3" | translate }}</li>
</ul> </ul>
</app-card> </app-card>
} }

View File

@@ -1,5 +1,13 @@
.hero { padding: var(--space-12) 0; text-align: center; } .hero {
.subtitle { font-size: 1.25rem; color: var(--color-text-muted); max-width: 600px; margin: 0 auto; } padding: var(--space-12) 0;
text-align: center;
}
.subtitle {
font-size: 1.25rem;
color: var(--color-text-muted);
max-width: 600px;
margin: 0 auto;
}
.content-grid { .content-grid {
display: grid; display: grid;
@@ -56,7 +64,9 @@
transition: all 0.2s ease; transition: all 0.2s ease;
user-select: none; user-select: none;
&:hover { color: var(--color-text); } &:hover {
color: var(--color-text);
}
&.active { &.active {
background-color: var(--color-brand); background-color: var(--color-brand);
@@ -66,8 +76,11 @@
} }
} }
.benefits { padding-left: var(--space-4); color: var(--color-text-muted); line-height: 2; } .benefits {
padding-left: var(--space-4);
color: var(--color-text-muted);
line-height: 2;
}
.loader-content { .loader-content {
text-align: center; text-align: center;
@@ -105,6 +118,10 @@
} }
@keyframes spin { @keyframes spin {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }

View File

@@ -0,0 +1,112 @@
import { of } from 'rxjs';
import { ActivatedRoute, Router } from '@angular/router';
import { CalculatorPageComponent } from './calculator-page.component';
import {
QuoteEstimatorService,
QuoteResult,
} from './services/quote-estimator.service';
import { LanguageService } from '../../core/services/language.service';
import { UploadFormComponent } from './components/upload-form/upload-form.component';
describe('CalculatorPageComponent', () => {
const createResult = (sessionId: string, notes?: string): QuoteResult => ({
sessionId,
items: [
{
id: 'line-1',
fileName: 'part-a.stl',
unitPrice: 4,
unitTime: 120,
unitWeight: 2,
quantity: 1,
},
],
setupCost: 2,
globalMachineCost: 0,
currency: 'CHF',
totalPrice: 6,
totalTimeHours: 0,
totalTimeMinutes: 2,
totalWeight: 2,
notes,
});
function createComponent() {
const estimator = jasmine.createSpyObj<QuoteEstimatorService>(
'QuoteEstimatorService',
['updateLineItem', 'getQuoteSession', 'mapSessionToQuoteResult'],
);
const router = jasmine.createSpyObj<Router>('Router', ['navigate']);
const route = {
data: of({}),
queryParams: of({}),
} as unknown as ActivatedRoute;
const languageService = jasmine.createSpyObj<LanguageService>(
'LanguageService',
['selectedLang'],
);
const component = new CalculatorPageComponent(
estimator,
router,
route,
languageService,
);
const uploadForm = jasmine.createSpyObj<UploadFormComponent>(
'UploadFormComponent',
['updateItemQuantityByIndex', 'updateItemQuantityByName'],
);
component.uploadForm = uploadForm;
return {
component,
estimator,
uploadForm,
};
}
it('updates left panel quantities even when item id is missing', () => {
const { component, estimator, uploadForm } = createComponent();
component.onItemChange({
index: 0,
fileName: 'part-a.stl',
quantity: 4,
});
expect(uploadForm.updateItemQuantityByIndex).toHaveBeenCalledWith(0, 4);
expect(uploadForm.updateItemQuantityByName).toHaveBeenCalledWith(
'part-a.stl',
4,
);
expect(estimator.updateLineItem).not.toHaveBeenCalled();
});
it('refreshes quote totals after successful line item update', () => {
const { component, estimator } = createComponent();
component.result.set(createResult('session-1', 'persisted notes'));
estimator.updateLineItem.and.returnValue(of({ ok: true }));
estimator.getQuoteSession.and.returnValue(
of({ session: { id: 'session-1' } }),
);
estimator.mapSessionToQuoteResult.and.returnValue(
createResult('session-1'),
);
component.onItemChange({
id: 'line-1',
index: 0,
fileName: 'part-a.stl',
quantity: 7,
});
expect(estimator.updateLineItem).toHaveBeenCalledWith('line-1', {
quantity: 7,
});
expect(estimator.getQuoteSession).toHaveBeenCalledWith('session-1');
expect(component.result()?.notes).toBe('persisted notes');
expect(component.result()?.items[0].quantity).toBe(1);
});
});

View File

@@ -1,4 +1,10 @@
import { Component, signal, ViewChild, ElementRef, OnInit } from '@angular/core'; import {
Component,
signal,
ViewChild,
ElementRef,
OnInit,
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { forkJoin } from 'rxjs'; import { forkJoin } from 'rxjs';
@@ -8,7 +14,11 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component'; import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
import { UploadFormComponent } from './components/upload-form/upload-form.component'; import { UploadFormComponent } from './components/upload-form/upload-form.component';
import { QuoteResultComponent } from './components/quote-result/quote-result.component'; import { QuoteResultComponent } from './components/quote-result/quote-result.component';
import { QuoteRequest, QuoteResult, QuoteEstimatorService } from './services/quote-estimator.service'; import {
QuoteRequest,
QuoteResult,
QuoteEstimatorService,
} from './services/quote-estimator.service';
import { SuccessStateComponent } from '../../shared/components/success-state/success-state.component'; import { SuccessStateComponent } from '../../shared/components/success-state/success-state.component';
import { Router, ActivatedRoute } from '@angular/router'; import { Router, ActivatedRoute } from '@angular/router';
import { LanguageService } from '../../core/services/language.service'; import { LanguageService } from '../../core/services/language.service';
@@ -16,9 +26,17 @@ import { LanguageService } from '../../core/services/language.service';
@Component({ @Component({
selector: 'app-calculator-page', selector: 'app-calculator-page',
standalone: true, standalone: true,
imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, SuccessStateComponent], imports: [
CommonModule,
TranslateModule,
AppCardComponent,
AppAlertComponent,
UploadFormComponent,
QuoteResultComponent,
SuccessStateComponent,
],
templateUrl: './calculator-page.component.html', templateUrl: './calculator-page.component.html',
styleUrl: './calculator-page.component.scss' styleUrl: './calculator-page.component.scss',
}) })
export class CalculatorPageComponent implements OnInit { export class CalculatorPageComponent implements OnInit {
mode = signal<any>('easy'); mode = signal<any>('easy');
@@ -39,17 +57,17 @@ export class CalculatorPageComponent implements OnInit {
private estimator: QuoteEstimatorService, private estimator: QuoteEstimatorService,
private router: Router, private router: Router,
private route: ActivatedRoute, private route: ActivatedRoute,
private languageService: LanguageService private languageService: LanguageService,
) {} ) {}
ngOnInit() { ngOnInit() {
this.route.data.subscribe(data => { this.route.data.subscribe((data) => {
if (data['mode']) { if (data['mode']) {
this.mode.set(data['mode']); this.mode.set(data['mode']);
} }
}); });
this.route.queryParams.subscribe(params => { this.route.queryParams.subscribe((params) => {
const sessionId = params['session']; const sessionId = params['session'];
if (sessionId) { if (sessionId) {
// Avoid reloading if we just calculated this session // Avoid reloading if we just calculated this session
@@ -92,7 +110,7 @@ export class CalculatorPageComponent implements OnInit {
console.error('Failed to load session', err); console.error('Failed to load session', err);
this.setQuoteError('CALC.ERROR_GENERIC'); this.setQuoteError('CALC.ERROR_GENERIC');
this.loading.set(false); this.loading.set(false);
} },
}); });
} }
@@ -103,7 +121,7 @@ export class CalculatorPageComponent implements OnInit {
} }
// Download all files // Download all files
const downloads = items.map(item => const downloads = items.map((item) =>
this.estimator.getLineItemContent(session.id, item.id).pipe( this.estimator.getLineItemContent(session.id, item.id).pipe(
map((blob: Blob) => { map((blob: Blob) => {
return { return {
@@ -114,13 +132,18 @@ export class CalculatorPageComponent implements OnInit {
// We might need to handle matching but UploadForm just pushes them. // We might need to handle matching but UploadForm just pushes them.
// If order is preserved, we are good. items from backend are list. // If order is preserved, we are good. items from backend are list.
}; };
}) }),
) ),
); );
forkJoin(downloads).subscribe({ forkJoin(downloads).subscribe({
next: (results: any[]) => { next: (results: any[]) => {
const files = results.map(res => new File([res.blob], res.fileName, { type: 'application/octet-stream' })); const files = results.map(
(res) =>
new File([res.blob], res.fileName, {
type: 'application/octet-stream',
}),
);
if (this.uploadForm) { if (this.uploadForm) {
this.uploadForm.setFiles(files); this.uploadForm.setFiles(files);
@@ -137,7 +160,7 @@ export class CalculatorPageComponent implements OnInit {
if (item.colorCode) { if (item.colorCode) {
this.uploadForm.updateItemColor(index, { this.uploadForm.updateItemColor(index, {
colorName: item.colorCode, colorName: item.colorCode,
filamentVariantId: item.filamentVariantId filamentVariantId: item.filamentVariantId,
}); });
} }
}); });
@@ -150,7 +173,7 @@ export class CalculatorPageComponent implements OnInit {
console.error('Failed to download files', err); console.error('Failed to download files', err);
this.loading.set(false); this.loading.set(false);
// Still show result? Yes. // Still show result? Yes.
} },
}); });
} }
@@ -167,7 +190,10 @@ export class CalculatorPageComponent implements OnInit {
// Auto-scroll on mobile to make analysis visible // Auto-scroll on mobile to make analysis visible
setTimeout(() => { setTimeout(() => {
if (this.resultCol && window.innerWidth < 768) { if (this.resultCol && window.innerWidth < 768) {
this.resultCol.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); this.resultCol.nativeElement.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
} }
}, 100); }, 100);
@@ -197,7 +223,7 @@ export class CalculatorPageComponent implements OnInit {
relativeTo: this.route, relativeTo: this.route,
queryParams: { session: res.sessionId }, queryParams: { session: res.sessionId },
queryParamsHandling: 'merge', // merge with existing params like 'mode' if any queryParamsHandling: 'merge', // merge with existing params like 'mode' if any
replaceUrl: true // prevent cluttering history, or false if we want back button to work. replaceUrl seems safer for "state update" replaceUrl: true, // prevent cluttering history, or false if we want back button to work. replaceUrl seems safer for "state update"
}); });
} }
} }
@@ -205,7 +231,7 @@ export class CalculatorPageComponent implements OnInit {
error: () => { error: () => {
this.setQuoteError('CALC.ERROR_GENERIC'); this.setQuoteError('CALC.ERROR_GENERIC');
this.loading.set(false); this.loading.set(false);
} },
}); });
} }
@@ -214,7 +240,7 @@ export class CalculatorPageComponent implements OnInit {
if (res && res.sessionId) { if (res && res.sessionId) {
this.router.navigate( this.router.navigate(
['/', this.languageService.selectedLang(), 'checkout'], ['/', this.languageService.selectedLang(), 'checkout'],
{ queryParams: { session: res.sessionId } } { queryParams: { session: res.sessionId } },
); );
} else { } else {
console.error('No session ID found in quote result'); console.error('No session ID found in quote result');
@@ -226,7 +252,12 @@ export class CalculatorPageComponent implements OnInit {
this.step.set('quote'); this.step.set('quote');
} }
onItemChange(event: {id?: string, index: number, fileName: string, quantity: number}) { onItemChange(event: {
id?: string;
index: number;
fileName: string;
quantity: number;
}) {
// 1. Update local form for consistency (UI feedback) // 1. Update local form for consistency (UI feedback)
if (this.uploadForm) { if (this.uploadForm) {
this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity); this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity);
@@ -238,12 +269,15 @@ export class CalculatorPageComponent implements OnInit {
const currentSessionId = this.result()?.sessionId; const currentSessionId = this.result()?.sessionId;
if (!currentSessionId) return; if (!currentSessionId) return;
this.estimator.updateLineItem(event.id, { quantity: event.quantity }).subscribe({ this.estimator
.updateLineItem(event.id, { quantity: event.quantity })
.subscribe({
next: () => { next: () => {
// 3. Fetch the updated session totals from the backend // 3. Fetch the updated session totals from the backend
this.estimator.getQuoteSession(currentSessionId).subscribe({ this.estimator.getQuoteSession(currentSessionId).subscribe({
next: (sessionData) => { next: (sessionData) => {
const newResult = this.estimator.mapSessionToQuoteResult(sessionData); const newResult =
this.estimator.mapSessionToQuoteResult(sessionData);
// Preserve notes // Preserve notes
newResult.notes = this.result()?.notes; newResult.notes = this.result()?.notes;
@@ -258,12 +292,12 @@ export class CalculatorPageComponent implements OnInit {
}, },
error: (err) => { error: (err) => {
console.error('Failed to refresh session totals', err); console.error('Failed to refresh session totals', err);
} },
}); });
}, },
error: (err) => { error: (err) => {
console.error('Failed to update line item', err); console.error('Failed to update line item', err);
} },
}); });
} }
} }
@@ -292,7 +326,7 @@ export class CalculatorPageComponent implements OnInit {
details += `- Qualità: ${req.quality}\n`; details += `- Qualità: ${req.quality}\n`;
details += `- File:\n`; details += `- File:\n`;
req.items.forEach(item => { req.items.forEach((item) => {
details += ` * ${item.file.name} (Qtà: ${item.quantity}`; details += ` * ${item.file.name} (Qtà: ${item.quantity}`;
if (item.color) { if (item.color) {
details += `, Colore: ${item.color}`; details += `, Colore: ${item.color}`;
@@ -307,8 +341,8 @@ export class CalculatorPageComponent implements OnInit {
if (req.notes) details += `\nNote: ${req.notes}`; if (req.notes) details += `\nNote: ${req.notes}`;
this.estimator.setPendingConsultation({ this.estimator.setPendingConsultation({
files: req.items.map(i => i.file), files: req.items.map((i) => i.file),
message: details message: details,
}); });
this.router.navigate(['/', this.languageService.selectedLang(), 'contact']); this.router.navigate(['/', this.languageService.selectedLang(), 'contact']);

View File

@@ -4,5 +4,9 @@ import { CalculatorPageComponent } from './calculator-page.component';
export const CALCULATOR_ROUTES: Routes = [ export const CALCULATOR_ROUTES: Routes = [
{ path: '', redirectTo: 'basic', pathMatch: 'full' }, { path: '', redirectTo: 'basic', pathMatch: 'full' },
{ path: 'basic', component: CalculatorPageComponent, data: { mode: 'easy' } }, { path: 'basic', component: CalculatorPageComponent, data: { mode: 'easy' } },
{ path: 'advanced', component: CalculatorPageComponent, data: { mode: 'advanced' } } {
path: 'advanced',
component: CalculatorPageComponent,
data: { mode: 'advanced' },
},
]; ];

View File

@@ -1,5 +1,5 @@
<app-card> <app-card>
<h3 class="title">{{ 'CALC.RESULT' | translate }}</h3> <h3 class="title">{{ "CALC.RESULT" | translate }}</h3>
<!-- Summary Grid (NOW ON TOP) --> <!-- Summary Grid (NOW ON TOP) -->
<div class="result-grid"> <div class="result-grid">
@@ -7,7 +7,8 @@
class="item full-width" class="item full-width"
[label]="'CALC.COST' | translate" [label]="'CALC.COST' | translate"
[large]="true" [large]="true"
[highlight]="true"> [highlight]="true"
>
{{ totals().price | currency: result().currency }} {{ totals().price | currency: result().currency }}
</app-summary-card> </app-summary-card>
@@ -21,13 +22,20 @@
</div> </div>
<div class="setup-note"> <div class="setup-note">
<small>{{ 'CALC.SETUP_NOTE' | translate:{cost: (result().setupCost | currency:result().currency)} }}</small><br> <small>{{
<small class="shipping-note" style="color: #666;">{{ 'CALC.SHIPPING_NOTE' | translate }}</small> "CALC.SETUP_NOTE"
| translate
: { cost: (result().setupCost | currency: result().currency) }
}}</small
><br />
<small class="shipping-note" style="color: #666">{{
"CALC.SHIPPING_NOTE" | translate
}}</small>
</div> </div>
@if (result().notes) { @if (result().notes) {
<div class="notes-section"> <div class="notes-section">
<label>{{ 'CALC.NOTES' | translate }}:</label> <label>{{ "CALC.NOTES" | translate }}:</label>
<p>{{ result().notes }}</p> <p>{{ result().notes }}</p>
</div> </div>
} }
@@ -41,13 +49,14 @@
<div class="item-info"> <div class="item-info">
<span class="file-name">{{ item.fileName }}</span> <span class="file-name">{{ item.fileName }}</span>
<span class="file-details"> <span class="file-details">
{{ (item.unitTime / 3600) | number:'1.1-1' }}h | {{ item.unitWeight | number:'1.0-0' }}g {{ item.unitTime / 3600 | number: "1.1-1" }}h |
{{ item.unitWeight | number: "1.0-0" }}g
</span> </span>
</div> </div>
<div class="item-controls"> <div class="item-controls">
<div class="qty-control"> <div class="qty-control">
<label>{{ 'CHECKOUT.QTY' | translate }}:</label> <label>{{ "CHECKOUT.QTY" | translate }}:</label>
<input <input
type="number" type="number"
min="1" min="1"
@@ -55,17 +64,24 @@
[ngModel]="item.quantity" [ngModel]="item.quantity"
(ngModelChange)="updateQuantity(i, $event)" (ngModelChange)="updateQuantity(i, $event)"
(blur)="flushQuantityUpdate(i)" (blur)="flushQuantityUpdate(i)"
class="qty-input"> class="qty-input"
/>
</div> </div>
<div class="item-price"> <div class="item-price">
<span class="item-total-price"> <span class="item-total-price">
{{ (item.unitPrice * item.quantity) | currency:result().currency }} {{ item.unitPrice * item.quantity | currency: result().currency }}
</span> </span>
<small class="item-unit-price" *ngIf="item.quantity > 1; else unitPricePlaceholder"> <small
{{ item.unitPrice | currency:result().currency }} {{ 'CHECKOUT.PER_PIECE' | translate }} class="item-unit-price"
*ngIf="item.quantity > 1; else unitPricePlaceholder"
>
{{ item.unitPrice | currency: result().currency }}
{{ "CHECKOUT.PER_PIECE" | translate }}
</small> </small>
<ng-template #unitPricePlaceholder> <ng-template #unitPricePlaceholder>
<small class="item-unit-price item-unit-price--placeholder">&nbsp;</small> <small class="item-unit-price item-unit-price--placeholder"
>&nbsp;</small
>
</ng-template> </ng-template>
</div> </div>
</div> </div>
@@ -75,15 +91,17 @@
<div class="actions"> <div class="actions">
<app-button variant="outline" (click)="consult.emit()"> <app-button variant="outline" (click)="consult.emit()">
{{ 'QUOTE.CONSULT' | translate }} {{ "QUOTE.CONSULT" | translate }}
</app-button> </app-button>
@if (!hasQuantityOverLimit()) { @if (!hasQuantityOverLimit()) {
<app-button (click)="proceed.emit()"> <app-button (click)="proceed.emit()">
{{ 'QUOTE.PROCEED_ORDER' | translate }} {{ "QUOTE.PROCEED_ORDER" | translate }}
</app-button> </app-button>
} @else { } @else {
<small class="limit-note">{{ 'QUOTE.MAX_QTY_NOTICE' | translate:{ max: directOrderLimit } }}</small> <small class="limit-note">{{
"QUOTE.MAX_QTY_NOTICE" | translate: { max: directOrderLimit }
}}</small>
} }
</div> </div>
</app-card> </app-card>

View File

@@ -1,4 +1,7 @@
.title { margin-bottom: var(--space-6); text-align: center; } .title {
margin-bottom: var(--space-6);
text-align: center;
}
.divider { .divider {
height: 1px; height: 1px;
@@ -30,8 +33,18 @@
flex: 1; /* Ensure it takes available space */ flex: 1; /* Ensure it takes available space */
} }
.file-name { font-weight: 500; font-size: 0.9rem; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .file-name {
.file-details { font-size: 0.8rem; color: var(--color-text-muted); } font-weight: 500;
font-size: 0.9rem;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-details {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.item-controls { .item-controls {
display: flex; display: flex;
@@ -44,7 +57,10 @@
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
label { font-size: 0.8rem; color: var(--color-text-muted); } label {
font-size: 0.8rem;
color: var(--color-text-muted);
}
} }
.qty-input { .qty-input {
@@ -53,7 +69,10 @@
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
text-align: center; text-align: center;
&:focus { outline: none; border-color: var(--color-brand); } &:focus {
outline: none;
border-color: var(--color-brand);
}
} }
.item-price { .item-price {
@@ -94,7 +113,9 @@
gap: var(--space-4); gap: var(--space-4);
} }
} }
.full-width { grid-column: span 2; } .full-width {
grid-column: span 2;
}
.setup-note { .setup-note {
text-align: center; text-align: center;
@@ -103,7 +124,11 @@
font-size: 0.8rem; font-size: 0.8rem;
} }
.actions { display: flex; flex-direction: column; gap: var(--space-3); } .actions {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.limit-note { .limit-note {
font-size: 0.8rem; font-size: 0.8rem;

View File

@@ -0,0 +1,79 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { QuoteResultComponent } from './quote-result.component';
import { QuoteResult } from '../../services/quote-estimator.service';
describe('QuoteResultComponent', () => {
let fixture: ComponentFixture<QuoteResultComponent>;
let component: QuoteResultComponent;
const createResult = (): QuoteResult => ({
sessionId: 'session-1',
items: [
{
id: 'line-1',
fileName: 'part-a.stl',
unitPrice: 2,
unitTime: 120,
unitWeight: 1.2,
quantity: 2,
},
{
id: 'line-2',
fileName: 'part-b.stl',
unitPrice: 1.5,
unitTime: 60,
unitWeight: 0.5,
quantity: 1,
},
],
setupCost: 5,
globalMachineCost: 0,
currency: 'CHF',
totalPrice: 0,
totalTimeHours: 0,
totalTimeMinutes: 0,
totalWeight: 0,
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [QuoteResultComponent, TranslateModule.forRoot()],
}).compileComponents();
fixture = TestBed.createComponent(QuoteResultComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('result', createResult());
fixture.detectChanges();
});
it('emits quantity changes with clamped max quantity', () => {
spyOn(component.itemChange, 'emit');
component.updateQuantity(0, 999);
component.flushQuantityUpdate(0);
expect(component.items()[0].quantity).toBe(component.maxInputQuantity);
expect(component.itemChange.emit).toHaveBeenCalledWith({
id: 'line-1',
index: 0,
fileName: 'part-a.stl',
quantity: component.maxInputQuantity,
});
});
it('computes totals from local item quantities', () => {
component.updateQuantity(1, 3);
const totals = component.totals();
expect(totals.price).toBe(13.5);
expect(totals.hours).toBe(0);
expect(totals.minutes).toBe(7);
expect(totals.weight).toBe(4);
});
it('flags over-limit quantities for direct order', () => {
component.updateQuantity(0, 101);
expect(component.hasQuantityOverLimit()).toBeTrue();
});
});

View File

@@ -1,4 +1,12 @@
import { Component, OnDestroy, input, output, signal, computed, effect } from '@angular/core'; import {
Component,
OnDestroy,
input,
output,
signal,
computed,
effect,
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
@@ -10,9 +18,16 @@ import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
@Component({ @Component({
selector: 'app-quote-result', selector: 'app-quote-result',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, TranslateModule, AppCardComponent, AppButtonComponent, SummaryCardComponent], imports: [
CommonModule,
FormsModule,
TranslateModule,
AppCardComponent,
AppButtonComponent,
SummaryCardComponent,
],
templateUrl: './quote-result.component.html', templateUrl: './quote-result.component.html',
styleUrl: './quote-result.component.scss' styleUrl: './quote-result.component.scss',
}) })
export class QuoteResultComponent implements OnDestroy { export class QuoteResultComponent implements OnDestroy {
readonly maxInputQuantity = 500; readonly maxInputQuantity = 500;
@@ -22,7 +37,12 @@ export class QuoteResultComponent implements OnDestroy {
result = input.required<QuoteResult>(); result = input.required<QuoteResult>();
consult = output<void>(); consult = output<void>();
proceed = output<void>(); proceed = output<void>();
itemChange = output<{id?: string, index: number, fileName: string, quantity: number}>(); itemChange = output<{
id?: string;
index: number;
fileName: string;
quantity: number;
}>();
// Local mutable state for items to handle quantity changes // Local mutable state for items to handle quantity changes
items = signal<QuoteItem[]>([]); items = signal<QuoteItem[]>([]);
@@ -30,20 +50,23 @@ export class QuoteResultComponent implements OnDestroy {
private quantityTimers = new Map<string, ReturnType<typeof setTimeout>>(); private quantityTimers = new Map<string, ReturnType<typeof setTimeout>>();
constructor() { constructor() {
effect(() => { effect(
() => {
this.clearAllQuantityTimers(); this.clearAllQuantityTimers();
// Initialize local items when result inputs change // Initialize local items when result inputs change
// We map to new objects to avoid mutating the input directly if it was a reference // We map to new objects to avoid mutating the input directly if it was a reference
const nextItems = this.result().items.map(i => ({...i})); const nextItems = this.result().items.map((i) => ({ ...i }));
this.items.set(nextItems); this.items.set(nextItems);
this.lastSentQuantities.clear(); this.lastSentQuantities.clear();
nextItems.forEach(item => { nextItems.forEach((item) => {
const key = item.id ?? item.fileName; const key = item.id ?? item.fileName;
this.lastSentQuantities.set(key, item.quantity); this.lastSentQuantities.set(key, item.quantity);
}); });
}, { allowSignalWrites: true }); },
{ allowSignalWrites: true },
);
} }
ngOnDestroy(): void { ngOnDestroy(): void {
@@ -58,7 +81,7 @@ export class QuoteResultComponent implements OnDestroy {
if (!item) return; if (!item) return;
const key = item.id ?? item.fileName; const key = item.id ?? item.fileName;
this.items.update(current => { this.items.update((current) => {
const updated = [...current]; const updated = [...current];
updated[index] = { ...updated[index], quantity: normalizedQty }; updated[index] = { ...updated[index], quantity: normalizedQty };
return updated; return updated;
@@ -85,12 +108,14 @@ export class QuoteResultComponent implements OnDestroy {
id: item.id, id: item.id,
index, index,
fileName: item.fileName, fileName: item.fileName,
quantity: normalizedQty quantity: normalizedQty,
}); });
this.lastSentQuantities.set(key, normalizedQty); this.lastSentQuantities.set(key, normalizedQty);
} }
hasQuantityOverLimit = computed(() => this.items().some(item => item.quantity > this.directOrderLimit)); hasQuantityOverLimit = computed(() =>
this.items().some((item) => item.quantity > this.directOrderLimit),
);
totals = computed(() => { totals = computed(() => {
const currentItems = this.items(); const currentItems = this.items();
@@ -100,7 +125,7 @@ export class QuoteResultComponent implements OnDestroy {
let time = 0; let time = 0;
let weight = 0; let weight = 0;
currentItems.forEach(i => { currentItems.forEach((i) => {
price += i.unitPrice * i.quantity; price += i.unitPrice * i.quantity;
time += i.unitTime * i.quantity; time += i.unitTime * i.quantity;
weight += i.unitWeight * i.quantity; weight += i.unitWeight * i.quantity;
@@ -113,7 +138,7 @@ export class QuoteResultComponent implements OnDestroy {
price: Math.round(price * 100) / 100, price: Math.round(price * 100) / 100,
hours, hours,
minutes, minutes,
weight: Math.ceil(weight) weight: Math.ceil(weight),
}; };
}); });
@@ -142,8 +167,7 @@ export class QuoteResultComponent implements OnDestroy {
} }
private clearAllQuantityTimers(): void { private clearAllQuantityTimers(): void {
this.quantityTimers.forEach(timer => clearTimeout(timer)); this.quantityTimers.forEach((timer) => clearTimeout(timer));
this.quantityTimers.clear(); this.quantityTimers.clear();
} }
} }

View File

@@ -1,16 +1,16 @@
<form [formGroup]="form" (ngSubmit)="onSubmit()"> <form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="section"> <div class="section">
@if (selectedFile()) { @if (selectedFile()) {
<div class="viewer-wrapper"> <div class="viewer-wrapper">
@if (!isStepFile(selectedFile())) { @if (!isStepFile(selectedFile())) {
<div class="step-warning"> <div class="step-warning">
<p>{{ 'CALC.STEP_WARNING' | translate }}</p> <p>{{ "CALC.STEP_WARNING" | translate }}</p>
</div> </div>
} @else { } @else {
<app-stl-viewer <app-stl-viewer
[file]="selectedFile()" [file]="selectedFile()"
[color]="getSelectedFileColor()"> [color]="getSelectedFileColor()"
>
</app-stl-viewer> </app-stl-viewer>
} }
<!-- Close button removed as requested --> <!-- Close button removed as requested -->
@@ -24,7 +24,8 @@
[subtext]="'CALC.UPLOAD_SUB'" [subtext]="'CALC.UPLOAD_SUB'"
[accept]="acceptedFormats" [accept]="acceptedFormats"
[multiple]="true" [multiple]="true"
(filesDropped)="onFilesDropped($event)"> (filesDropped)="onFilesDropped($event)"
>
</app-dropzone> </app-dropzone>
} }
@@ -32,31 +33,39 @@
@if (items().length > 0) { @if (items().length > 0) {
<div class="items-grid"> <div class="items-grid">
@for (item of items(); track item.file.name; let i = $index) { @for (item of items(); track item.file.name; let i = $index) {
<div class="file-card" [class.active]="item.file === selectedFile()" (click)="selectFile(item.file)"> <div
class="file-card"
[class.active]="item.file === selectedFile()"
(click)="selectFile(item.file)"
>
<div class="card-header"> <div class="card-header">
<span class="file-name" [title]="item.file.name">{{ item.file.name }}</span> <span class="file-name" [title]="item.file.name">{{
item.file.name
}}</span>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="card-controls"> <div class="card-controls">
<div class="qty-group"> <div class="qty-group">
<label>{{ 'CALC.QTY_SHORT' | translate }}</label> <label>{{ "CALC.QTY_SHORT" | translate }}</label>
<input <input
type="number" type="number"
min="1" min="1"
[value]="item.quantity" [value]="item.quantity"
(change)="updateItemQuantity(i, $event)" (change)="updateItemQuantity(i, $event)"
class="qty-input" class="qty-input"
(click)="$event.stopPropagation()"> (click)="$event.stopPropagation()"
/>
</div> </div>
<div class="color-group"> <div class="color-group">
<label>{{ 'CALC.COLOR_LABEL' | translate }}</label> <label>{{ "CALC.COLOR_LABEL" | translate }}</label>
<app-color-selector <app-color-selector
[selectedColor]="item.color" [selectedColor]="item.color"
[selectedVariantId]="item.filamentVariantId ?? null" [selectedVariantId]="item.filamentVariantId ?? null"
[variants]="currentMaterialVariants()" [variants]="currentMaterialVariants()"
(colorSelected)="updateItemColor(i, $event)"> (colorSelected)="updateItemColor(i, $event)"
>
</app-color-selector> </app-color-selector>
</div> </div>
</div> </div>
@@ -65,7 +74,8 @@
type="button" type="button"
class="btn-remove" class="btn-remove"
(click)="removeItem(i); $event.stopPropagation()" (click)="removeItem(i); $event.stopPropagation()"
[attr.title]="'CALC.REMOVE_FILE' | translate"> [attr.title]="'CALC.REMOVE_FILE' | translate"
>
X X
</button> </button>
</div> </div>
@@ -75,21 +85,35 @@
<!-- "Add Files" Button (Visible only when files exist) --> <!-- "Add Files" Button (Visible only when files exist) -->
<div class="add-more-container"> <div class="add-more-container">
<input #additionalInput type="file" [accept]="acceptedFormats" multiple hidden (change)="onAdditionalFilesSelected($event)"> <input
#additionalInput
type="file"
[accept]="acceptedFormats"
multiple
hidden
(change)="onAdditionalFilesSelected($event)"
/>
<button type="button" class="btn-add-more" (click)="additionalInput.click()"> <button
+ {{ 'CALC.ADD_FILES' | translate }} type="button"
class="btn-add-more"
(click)="additionalInput.click()"
>
+ {{ "CALC.ADD_FILES" | translate }}
</button> </button>
</div> </div>
} }
@if (items().length === 0 && form.get('itemsTouched')?.value) { @if (items().length === 0 && form.get("itemsTouched")?.value) {
<div class="error-msg">{{ 'CALC.ERR_FILE_REQUIRED' | translate }}</div> <div class="error-msg">{{ "CALC.ERR_FILE_REQUIRED" | translate }}</div>
} }
<p class="upload-privacy-note"> <p class="upload-privacy-note">
{{ 'LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX' | translate }} {{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.UPLOAD_NOTICE_LINK' | translate }}</a>. <a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate
}}</a
>.
</p> </p>
</div> </div>
@@ -100,7 +124,7 @@
[options]="materials()" [options]="materials()"
></app-select> ></app-select>
@if (mode() === 'easy') { @if (mode() === "easy") {
<app-select <app-select
formControlName="quality" formControlName="quality"
[label]="'CALC.QUALITY' | translate" [label]="'CALC.QUALITY' | translate"
@@ -117,7 +141,7 @@
<!-- Global quantity removed, now per item --> <!-- Global quantity removed, now per item -->
@if (mode() === 'advanced') { @if (mode() === "advanced") {
<div class="grid"> <div class="grid">
<app-select <app-select
formControlName="infillPattern" formControlName="infillPattern"
@@ -140,11 +164,10 @@
></app-input> ></app-input>
<div class="checkbox-row"> <div class="checkbox-row">
<input type="checkbox" formControlName="supportEnabled" id="support"> <input type="checkbox" formControlName="supportEnabled" id="support" />
<label for="support">{{ 'CALC.SUPPORT' | translate }}</label> <label for="support">{{ "CALC.SUPPORT" | translate }}</label>
</div> </div>
</div> </div>
} }
<app-input <app-input
@@ -166,8 +189,15 @@
<app-button <app-button
type="submit" type="submit"
[disabled]="items().length === 0 || loading()" [disabled]="items().length === 0 || loading()"
[fullWidth]="true"> [fullWidth]="true"
{{ loading() ? (uploadProgress() < 100 ? ('CALC.UPLOADING' | translate) : ('CALC.PROCESSING' | translate)) : ('CALC.CALCULATE' | translate) }} >
{{
loading()
? uploadProgress() < 100
? ("CALC.UPLOADING" | translate)
: ("CALC.PROCESSING" | translate)
: ("CALC.CALCULATE" | translate)
}}
</app-button> </app-button>
</div> </div>
</form> </form>

View File

@@ -1,4 +1,6 @@
.section { margin-bottom: var(--space-6); } .section {
margin-bottom: var(--space-6);
}
.upload-privacy-note { .upload-privacy-note {
margin-top: var(--space-3); margin-top: var(--space-3);
margin-bottom: 0; margin-bottom: 0;
@@ -15,10 +17,20 @@
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
} }
} }
.actions { margin-top: var(--space-6); } .actions {
.error-msg { color: var(--color-danger-500); font-size: 0.875rem; margin-top: var(--space-2); text-align: center; } margin-top: var(--space-6);
}
.error-msg {
color: var(--color-danger-500);
font-size: 0.875rem;
margin-top: var(--space-2);
text-align: center;
}
.viewer-wrapper { position: relative; margin-bottom: var(--space-4); } .viewer-wrapper {
position: relative;
margin-bottom: var(--space-4);
}
/* Grid Layout for Files */ /* Grid Layout for Files */
.items-grid { .items-grid {
@@ -46,7 +58,9 @@
position: relative; /* For absolute positioning of remove btn */ position: relative; /* For absolute positioning of remove btn */
min-width: 0; /* Allow flex item to shrink below content size if needed */ min-width: 0; /* Allow flex item to shrink below content size if needed */
&:hover { border-color: var(--color-neutral-300); } &:hover {
border-color: var(--color-neutral-300);
}
&.active { &.active {
border-color: var(--color-brand); border-color: var(--color-brand);
background: rgba(250, 207, 10, 0.05); background: rgba(250, 207, 10, 0.05);
@@ -83,7 +97,8 @@
width: 100%; width: 100%;
} }
.qty-group, .color-group { .qty-group,
.color-group {
display: flex; display: flex;
flex-direction: column; /* Stack label and input */ flex-direction: column; /* Stack label and input */
align-items: flex-start; align-items: flex-start;
@@ -118,7 +133,10 @@
font-size: 0.85rem; font-size: 0.85rem;
background: white; background: white;
height: 24px; /* Explicit height to match color circle somewhat */ height: 24px; /* Explicit height to match color circle somewhat */
&:focus { outline: none; border-color: var(--color-brand); } &:focus {
outline: none;
border-color: var(--color-brand);
}
} }
.btn-remove { .btn-remove {
@@ -170,7 +188,9 @@
background: var(--color-neutral-900); background: var(--color-neutral-900);
transform: translateY(-1px); transform: translateY(-1px);
} }
&:active { transform: translateY(0); } &:active {
transform: translateY(0);
}
} }
.checkbox-row { .checkbox-row {

View File

@@ -1,6 +1,18 @@
import { Component, input, output, signal, OnInit, inject } from '@angular/core'; import {
Component,
input,
output,
signal,
OnInit,
inject,
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import {
ReactiveFormsModule,
FormBuilder,
FormGroup,
Validators,
} from '@angular/forms';
import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component'; import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
import { AppSelectComponent } from '../../../../shared/components/app-select/app-select.component'; import { AppSelectComponent } from '../../../../shared/components/app-select/app-select.component';
@@ -8,7 +20,14 @@ import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component'; import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component';
import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component'; import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component';
import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, MaterialOption, VariantOption } from '../../services/quote-estimator.service'; import {
QuoteRequest,
QuoteEstimatorService,
OptionsResponse,
SimpleOption,
MaterialOption,
VariantOption,
} from '../../services/quote-estimator.service';
import { getColorHex } from '../../../../core/constants/colors.const'; import { getColorHex } from '../../../../core/constants/colors.const';
interface FormItem { interface FormItem {
@@ -21,9 +40,19 @@ interface FormItem {
@Component({ @Component({
selector: 'app-upload-form', selector: 'app-upload-form',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppSelectComponent, AppDropzoneComponent, AppButtonComponent, StlViewerComponent, ColorSelectorComponent], imports: [
CommonModule,
ReactiveFormsModule,
TranslateModule,
AppInputComponent,
AppSelectComponent,
AppDropzoneComponent,
AppButtonComponent,
StlViewerComponent,
ColorSelectorComponent,
],
templateUrl: './upload-form.component.html', templateUrl: './upload-form.component.html',
styleUrl: './upload-form.component.scss' styleUrl: './upload-form.component.scss',
}) })
export class UploadFormComponent implements OnInit { export class UploadFormComponent implements OnInit {
mode = input<'easy' | 'advanced'>('easy'); mode = input<'easy' | 'advanced'>('easy');
@@ -57,7 +86,7 @@ export class UploadFormComponent implements OnInit {
private updateVariants() { private updateVariants() {
const matCode = this.form.get('material')?.value; const matCode = this.form.get('material')?.value;
if (matCode && this.fullMaterialOptions.length > 0) { if (matCode && this.fullMaterialOptions.length > 0) {
const found = this.fullMaterialOptions.find(m => m.code === matCode); const found = this.fullMaterialOptions.find((m) => m.code === matCode);
this.currentMaterialVariants.set(found ? found.variants : []); this.currentMaterialVariants.set(found ? found.variants : []);
this.syncItemVariantSelections(); this.syncItemVariantSelections();
} else { } else {
@@ -85,7 +114,7 @@ export class UploadFormComponent implements OnInit {
layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]], layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]],
nozzleDiameter: [0.4, Validators.required], nozzleDiameter: [0.4, Validators.required],
infillPattern: ['grid'], infillPattern: ['grid'],
supportEnabled: [false] supportEnabled: [false],
}); });
// Listen to material changes to update variants // Listen to material changes to update variants
@@ -102,11 +131,39 @@ export class UploadFormComponent implements OnInit {
private applyAdvancedPresetFromQuality(quality: string | null | undefined) { private applyAdvancedPresetFromQuality(quality: string | null | undefined) {
const normalized = (quality || 'standard').toLowerCase(); const normalized = (quality || 'standard').toLowerCase();
const presets: Record<string, { nozzleDiameter: number; layerHeight: number; infillDensity: number; infillPattern: string }> = { const presets: Record<
standard: { nozzleDiameter: 0.4, layerHeight: 0.2, infillDensity: 15, infillPattern: 'grid' }, string,
extra_fine: { nozzleDiameter: 0.4, layerHeight: 0.12, infillDensity: 20, infillPattern: 'grid' }, {
high: { nozzleDiameter: 0.4, layerHeight: 0.12, infillDensity: 20, infillPattern: 'grid' }, // Legacy alias nozzleDiameter: number;
draft: { nozzleDiameter: 0.4, layerHeight: 0.24, infillDensity: 12, infillPattern: 'grid' } layerHeight: number;
infillDensity: number;
infillPattern: string;
}
> = {
standard: {
nozzleDiameter: 0.4,
layerHeight: 0.2,
infillDensity: 15,
infillPattern: 'grid',
},
extra_fine: {
nozzleDiameter: 0.4,
layerHeight: 0.12,
infillDensity: 20,
infillPattern: 'grid',
},
high: {
nozzleDiameter: 0.4,
layerHeight: 0.12,
infillDensity: 20,
infillPattern: 'grid',
}, // Legacy alias
draft: {
nozzleDiameter: 0.4,
layerHeight: 0.24,
infillDensity: 12,
infillPattern: 'grid',
},
}; };
const preset = presets[normalized] || presets['standard']; const preset = presets[normalized] || presets['standard'];
@@ -119,22 +176,45 @@ export class UploadFormComponent implements OnInit {
this.fullMaterialOptions = options.materials; this.fullMaterialOptions = options.materials;
this.updateVariants(); // Trigger initial update this.updateVariants(); // Trigger initial update
this.materials.set(options.materials.map(m => ({ label: m.label, value: m.code }))); this.materials.set(
this.qualities.set(options.qualities.map(q => ({ label: q.label, value: q.id }))); options.materials.map((m) => ({ label: m.label, value: m.code })),
this.infillPatterns.set(options.infillPatterns.map(p => ({ label: p.label, value: p.id }))); );
this.layerHeights.set(options.layerHeights.map(l => ({ label: l.label, value: l.value }))); this.qualities.set(
this.nozzleDiameters.set(options.nozzleDiameters.map(n => ({ label: n.label, value: n.value }))); options.qualities.map((q) => ({ label: q.label, value: q.id })),
);
this.infillPatterns.set(
options.infillPatterns.map((p) => ({ label: p.label, value: p.id })),
);
this.layerHeights.set(
options.layerHeights.map((l) => ({ label: l.label, value: l.value })),
);
this.nozzleDiameters.set(
options.nozzleDiameters.map((n) => ({
label: n.label,
value: n.value,
})),
);
this.setDefaults(); this.setDefaults();
}, },
error: (err) => { error: (err) => {
console.error('Failed to load options', err); console.error('Failed to load options', err);
// Fallback for debugging/offline dev // Fallback for debugging/offline dev
this.materials.set([{ label: this.translate.instant('CALC.FALLBACK_MATERIAL'), value: 'PLA' }]); this.materials.set([
this.qualities.set([{ label: this.translate.instant('CALC.FALLBACK_QUALITY_STANDARD'), value: 'standard' }]); {
label: this.translate.instant('CALC.FALLBACK_MATERIAL'),
value: 'PLA',
},
]);
this.qualities.set([
{
label: this.translate.instant('CALC.FALLBACK_QUALITY_STANDARD'),
value: 'standard',
},
]);
this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]); this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]);
this.setDefaults(); this.setDefaults();
} },
}); });
} }
@@ -145,16 +225,27 @@ export class UploadFormComponent implements OnInit {
} }
if (this.qualities().length > 0 && !this.form.get('quality')?.value) { if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
// Try to find 'standard' or use first // Try to find 'standard' or use first
const std = this.qualities().find(q => q.value === 'standard'); const std = this.qualities().find((q) => q.value === 'standard');
this.form.get('quality')?.setValue(std ? std.value : this.qualities()[0].value); this.form
.get('quality')
?.setValue(std ? std.value : this.qualities()[0].value);
} }
if (this.nozzleDiameters().length > 0 && !this.form.get('nozzleDiameter')?.value) { if (
this.nozzleDiameters().length > 0 &&
!this.form.get('nozzleDiameter')?.value
) {
this.form.get('nozzleDiameter')?.setValue(0.4); // Prefer 0.4 this.form.get('nozzleDiameter')?.setValue(0.4); // Prefer 0.4
} }
if (this.layerHeights().length > 0 && !this.form.get('layerHeight')?.value) { if (
this.layerHeights().length > 0 &&
!this.form.get('layerHeight')?.value
) {
this.form.get('layerHeight')?.setValue(0.2); // Prefer 0.2 this.form.get('layerHeight')?.setValue(0.2); // Prefer 0.2
} }
if (this.infillPatterns().length > 0 && !this.form.get('infillPattern')?.value) { if (
this.infillPatterns().length > 0 &&
!this.form.get('infillPattern')?.value
) {
this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value); this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value);
} }
} }
@@ -173,7 +264,7 @@ export class UploadFormComponent implements OnInit {
file, file,
quantity: 1, quantity: 1,
color: defaultSelection.colorName, color: defaultSelection.colorName,
filamentVariantId: defaultSelection.filamentVariantId filamentVariantId: defaultSelection.filamentVariantId,
}); });
} }
} }
@@ -183,7 +274,7 @@ export class UploadFormComponent implements OnInit {
} }
if (validItems.length > 0) { if (validItems.length > 0) {
this.items.update(current => [...current, ...validItems]); this.items.update((current) => [...current, ...validItems]);
this.form.get('itemsTouched')?.setValue(true); this.form.get('itemsTouched')?.setValue(true);
// Auto select last added // Auto select last added
this.selectedFile.set(validItems[validItems.length - 1].file); this.selectedFile.set(validItems[validItems.length - 1].file);
@@ -203,7 +294,7 @@ export class UploadFormComponent implements OnInit {
if (!Number.isInteger(index) || index < 0) return; if (!Number.isInteger(index) || index < 0) return;
const normalizedQty = this.normalizeQuantity(quantity); const normalizedQty = this.normalizeQuantity(quantity);
this.items.update(current => { this.items.update((current) => {
if (index >= current.length) return current; if (index >= current.length) return current;
const updated = [...current]; const updated = [...current];
updated[index] = { ...updated[index], quantity: normalizedQty }; updated[index] = { ...updated[index], quantity: normalizedQty };
@@ -215,9 +306,9 @@ export class UploadFormComponent implements OnInit {
const targetName = this.normalizeFileName(fileName); const targetName = this.normalizeFileName(fileName);
const normalizedQty = this.normalizeQuantity(quantity); const normalizedQty = this.normalizeQuantity(quantity);
this.items.update(current => { this.items.update((current) => {
let matched = false; let matched = false;
return current.map(item => { return current.map((item) => {
if (!matched && this.normalizeFileName(item.file.name) === targetName) { if (!matched && this.normalizeFileName(item.file.name) === targetName) {
matched = true; matched = true;
return { ...item, quantity: normalizedQty }; return { ...item, quantity: normalizedQty };
@@ -240,13 +331,13 @@ export class UploadFormComponent implements OnInit {
const file = this.selectedFile(); const file = this.selectedFile();
if (!file) return '#facf0a'; // Default if (!file) return '#facf0a'; // Default
const item = this.items().find(i => i.file === file); const item = this.items().find((i) => i.file === file);
if (item) { if (item) {
const vars = this.currentMaterialVariants(); const vars = this.currentMaterialVariants();
if (vars && vars.length > 0) { if (vars && vars.length > 0) {
const found = item.filamentVariantId const found = item.filamentVariantId
? vars.find(v => v.id === item.filamentVariantId) ? vars.find((v) => v.id === item.filamentVariantId)
: vars.find(v => v.colorName === item.color); : vars.find((v) => v.colorName === item.color);
if (found) return found.hexColor; if (found) return found.hexColor;
} }
return getColorHex(item.color); return getColorHex(item.color);
@@ -261,18 +352,29 @@ export class UploadFormComponent implements OnInit {
this.updateItemQuantityByIndex(index, quantity); this.updateItemQuantityByIndex(index, quantity);
} }
updateItemColor(index: number, newSelection: string | { colorName: string; filamentVariantId?: number }) { updateItemColor(
const colorName = typeof newSelection === 'string' ? newSelection : newSelection.colorName; index: number,
const filamentVariantId = typeof newSelection === 'string' ? undefined : newSelection.filamentVariantId; newSelection: string | { colorName: string; filamentVariantId?: number },
this.items.update(current => { ) {
const colorName =
typeof newSelection === 'string' ? newSelection : newSelection.colorName;
const filamentVariantId =
typeof newSelection === 'string'
? undefined
: newSelection.filamentVariantId;
this.items.update((current) => {
const updated = [...current]; const updated = [...current];
updated[index] = { ...updated[index], color: colorName, filamentVariantId }; updated[index] = {
...updated[index],
color: colorName,
filamentVariantId,
};
return updated; return updated;
}); });
} }
removeItem(index: number) { removeItem(index: number) {
this.items.update(current => { this.items.update((current) => {
const updated = [...current]; const updated = [...current];
const removed = updated.splice(index, 1)[0]; const removed = updated.splice(index, 1)[0];
if (this.selectedFile() === removed.file) { if (this.selectedFile() === removed.file) {
@@ -290,7 +392,7 @@ export class UploadFormComponent implements OnInit {
file, file,
quantity: 1, quantity: 1,
color: defaultSelection.colorName, color: defaultSelection.colorName,
filamentVariantId: defaultSelection.filamentVariantId filamentVariantId: defaultSelection.filamentVariantId,
}); });
} }
@@ -302,13 +404,16 @@ export class UploadFormComponent implements OnInit {
} }
} }
private getDefaultVariantSelection(): { colorName: string; filamentVariantId?: number } { private getDefaultVariantSelection(): {
colorName: string;
filamentVariantId?: number;
} {
const vars = this.currentMaterialVariants(); const vars = this.currentMaterialVariants();
if (vars && vars.length > 0) { if (vars && vars.length > 0) {
const preferred = vars.find(v => !v.isOutOfStock) || vars[0]; const preferred = vars.find((v) => !v.isOutOfStock) || vars[0];
return { return {
colorName: preferred.colorName, colorName: preferred.colorName,
filamentVariantId: preferred.id filamentVariantId: preferred.id,
}; };
} }
return { colorName: 'Black' }; return { colorName: 'Black' };
@@ -320,19 +425,22 @@ export class UploadFormComponent implements OnInit {
return; return;
} }
const fallback = vars.find(v => !v.isOutOfStock) || vars[0]; const fallback = vars.find((v) => !v.isOutOfStock) || vars[0];
this.items.update(current => current.map(item => { this.items.update((current) =>
const byId = item.filamentVariantId != null current.map((item) => {
? vars.find(v => v.id === item.filamentVariantId) const byId =
item.filamentVariantId != null
? vars.find((v) => v.id === item.filamentVariantId)
: null; : null;
const byColor = vars.find(v => v.colorName === item.color); const byColor = vars.find((v) => v.colorName === item.color);
const selected = byId || byColor || fallback; const selected = byId || byColor || fallback;
return { return {
...item, ...item,
color: selected.colorName, color: selected.colorName,
filamentVariantId: selected.id filamentVariantId: selected.id,
}; };
})); }),
);
} }
patchSettings(settings: any) { patchSettings(settings: any) {
@@ -364,10 +472,12 @@ export class UploadFormComponent implements OnInit {
patch.layerHeight = settings.layerHeightMm; patch.layerHeight = settings.layerHeightMm;
} }
if (settings.nozzleDiameterMm) patch.nozzleDiameter = settings.nozzleDiameterMm; if (settings.nozzleDiameterMm)
patch.nozzleDiameter = settings.nozzleDiameterMm;
if (settings.infillPercent) patch.infillDensity = settings.infillPercent; if (settings.infillPercent) patch.infillDensity = settings.infillPercent;
if (settings.infillPattern) patch.infillPattern = settings.infillPattern; if (settings.infillPattern) patch.infillPattern = settings.infillPattern;
if (settings.supportsEnabled !== undefined) patch.supportEnabled = settings.supportsEnabled; if (settings.supportsEnabled !== undefined)
patch.supportEnabled = settings.supportsEnabled;
if (settings.notes) patch.notes = settings.notes; if (settings.notes) patch.notes = settings.notes;
this.isPatchingSettings = true; this.isPatchingSettings = true;
@@ -380,19 +490,28 @@ export class UploadFormComponent implements OnInit {
console.log('Form Valid:', this.form.valid, 'Items:', this.items().length); console.log('Form Valid:', this.form.valid, 'Items:', this.items().length);
if (this.form.valid && this.items().length > 0) { if (this.form.valid && this.items().length > 0) {
console.log('UploadFormComponent: Emitting submitRequest', this.form.value); console.log(
'UploadFormComponent: Emitting submitRequest',
this.form.value,
);
this.submitRequest.emit({ this.submitRequest.emit({
...this.form.value, ...this.form.value,
items: this.items(), // Pass the items array explicitly AFTER form value to prevent overwrite items: this.items(), // Pass the items array explicitly AFTER form value to prevent overwrite
mode: this.mode() mode: this.mode(),
}); });
} else { } else {
console.warn('UploadFormComponent: Form Invalid or No Items'); console.warn('UploadFormComponent: Form Invalid or No Items');
console.log('Form Errors:', this.form.errors); console.log('Form Errors:', this.form.errors);
Object.keys(this.form.controls).forEach(key => { Object.keys(this.form.controls).forEach((key) => {
const control = this.form.get(key); const control = this.form.get(key);
if (control?.invalid) { if (control?.invalid) {
console.log('Invalid Control:', key, control.errors, 'Value:', control.value); console.log(
'Invalid Control:',
key,
control.errors,
'Value:',
control.value,
);
} }
}); });
this.form.markAllAsTouched(); this.form.markAllAsTouched();
@@ -408,10 +527,6 @@ export class UploadFormComponent implements OnInit {
} }
private normalizeFileName(fileName: string): string { private normalizeFileName(fileName: string): string {
return (fileName || '') return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? '';
.split(/[\\/]/)
.pop()
?.trim()
.toLowerCase() ?? '';
} }
} }

View File

@@ -3,7 +3,6 @@
<div class="col-md-6"> <div class="col-md-6">
<app-card [title]="'USER_DETAILS.TITLE' | translate"> <app-card [title]="'USER_DETAILS.TITLE' | translate">
<form [formGroup]="form" (ngSubmit)="onSubmit()"> <form [formGroup]="form" (ngSubmit)="onSubmit()">
<!-- Name & Surname --> <!-- Name & Surname -->
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
@@ -12,7 +11,12 @@
[label]="'USER_DETAILS.NAME' | translate" [label]="'USER_DETAILS.NAME' | translate"
[placeholder]="'USER_DETAILS.NAME_PLACEHOLDER' | translate" [placeholder]="'USER_DETAILS.NAME_PLACEHOLDER' | translate"
[required]="true" [required]="true"
[error]="form.get('name')?.invalid && form.get('name')?.touched ? ('COMMON.REQUIRED' | translate) : null"> [error]="
form.get('name')?.invalid && form.get('name')?.touched
? ('COMMON.REQUIRED' | translate)
: null
"
>
</app-input> </app-input>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
@@ -21,7 +25,12 @@
[label]="'USER_DETAILS.SURNAME' | translate" [label]="'USER_DETAILS.SURNAME' | translate"
[placeholder]="'USER_DETAILS.SURNAME_PLACEHOLDER' | translate" [placeholder]="'USER_DETAILS.SURNAME_PLACEHOLDER' | translate"
[required]="true" [required]="true"
[error]="form.get('surname')?.invalid && form.get('surname')?.touched ? ('COMMON.REQUIRED' | translate) : null"> [error]="
form.get('surname')?.invalid && form.get('surname')?.touched
? ('COMMON.REQUIRED' | translate)
: null
"
>
</app-input> </app-input>
</div> </div>
</div> </div>
@@ -35,7 +44,12 @@
type="email" type="email"
[placeholder]="'USER_DETAILS.EMAIL_PLACEHOLDER' | translate" [placeholder]="'USER_DETAILS.EMAIL_PLACEHOLDER' | translate"
[required]="true" [required]="true"
[error]="form.get('email')?.invalid && form.get('email')?.touched ? ('COMMON.INVALID_EMAIL' | translate) : null"> [error]="
form.get('email')?.invalid && form.get('email')?.touched
? ('COMMON.INVALID_EMAIL' | translate)
: null
"
>
</app-input> </app-input>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
@@ -45,7 +59,12 @@
type="tel" type="tel"
[placeholder]="'USER_DETAILS.PHONE_PLACEHOLDER' | translate" [placeholder]="'USER_DETAILS.PHONE_PLACEHOLDER' | translate"
[required]="true" [required]="true"
[error]="form.get('phone')?.invalid && form.get('phone')?.touched ? ('COMMON.REQUIRED' | translate) : null"> [error]="
form.get('phone')?.invalid && form.get('phone')?.touched
? ('COMMON.REQUIRED' | translate)
: null
"
>
</app-input> </app-input>
</div> </div>
</div> </div>
@@ -56,7 +75,12 @@
[label]="'USER_DETAILS.ADDRESS' | translate" [label]="'USER_DETAILS.ADDRESS' | translate"
[placeholder]="'USER_DETAILS.ADDRESS_PLACEHOLDER' | translate" [placeholder]="'USER_DETAILS.ADDRESS_PLACEHOLDER' | translate"
[required]="true" [required]="true"
[error]="form.get('address')?.invalid && form.get('address')?.touched ? ('COMMON.REQUIRED' | translate) : null"> [error]="
form.get('address')?.invalid && form.get('address')?.touched
? ('COMMON.REQUIRED' | translate)
: null
"
>
</app-input> </app-input>
<!-- Zip & City --> <!-- Zip & City -->
@@ -67,7 +91,12 @@
[label]="'USER_DETAILS.ZIP' | translate" [label]="'USER_DETAILS.ZIP' | translate"
[placeholder]="'USER_DETAILS.ZIP_PLACEHOLDER' | translate" [placeholder]="'USER_DETAILS.ZIP_PLACEHOLDER' | translate"
[required]="true" [required]="true"
[error]="form.get('zip')?.invalid && form.get('zip')?.touched ? ('COMMON.REQUIRED' | translate) : null"> [error]="
form.get('zip')?.invalid && form.get('zip')?.touched
? ('COMMON.REQUIRED' | translate)
: null
"
>
</app-input> </app-input>
</div> </div>
<div class="col-md-8"> <div class="col-md-8">
@@ -76,40 +105,50 @@
[label]="'USER_DETAILS.CITY' | translate" [label]="'USER_DETAILS.CITY' | translate"
[placeholder]="'USER_DETAILS.CITY_PLACEHOLDER' | translate" [placeholder]="'USER_DETAILS.CITY_PLACEHOLDER' | translate"
[required]="true" [required]="true"
[error]="form.get('city')?.invalid && form.get('city')?.touched ? ('COMMON.REQUIRED' | translate) : null"> [error]="
form.get('city')?.invalid && form.get('city')?.touched
? ('COMMON.REQUIRED' | translate)
: null
"
>
</app-input> </app-input>
</div> </div>
</div> </div>
<div class="legal-consent"> <div class="legal-consent">
<label> <label>
<input type="checkbox" formControlName="acceptLegal"> <input type="checkbox" formControlName="acceptLegal" />
<span> <span>
{{ 'LEGAL.CONSENT.LABEL_PREFIX' | translate }} {{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
<a href="/terms" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.TERMS_LINK' | translate }}</a> <a href="/terms" target="_blank" rel="noopener">{{
{{ 'LEGAL.CONSENT.AND' | translate }} "LEGAL.CONSENT.TERMS_LINK" | translate
<a href="/privacy" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.PRIVACY_LINK' | translate }}</a>. }}</a>
{{ "LEGAL.CONSENT.AND" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.PRIVACY_LINK" | translate
}}</a
>.
</span> </span>
</label> </label>
<div class="consent-error" *ngIf="form.get('acceptLegal')?.invalid && form.get('acceptLegal')?.touched"> <div
{{ 'LEGAL.CONSENT.REQUIRED_ERROR' | translate }} class="consent-error"
*ngIf="
form.get('acceptLegal')?.invalid &&
form.get('acceptLegal')?.touched
"
>
{{ "LEGAL.CONSENT.REQUIRED_ERROR" | translate }}
</div> </div>
</div> </div>
<div class="actions"> <div class="actions">
<app-button <app-button type="button" variant="outline" (click)="onCancel()">
type="button" {{ "COMMON.BACK" | translate }}
variant="outline"
(click)="onCancel()">
{{ 'COMMON.BACK' | translate }}
</app-button> </app-button>
<app-button <app-button type="submit" [disabled]="form.invalid || submitting()">
type="submit" {{ "USER_DETAILS.SUBMIT" | translate }}
[disabled]="form.invalid || submitting()">
{{ 'USER_DETAILS.SUBMIT' | translate }}
</app-button> </app-button>
</div> </div>
</form> </form>
</app-card> </app-card>
</div> </div>
@@ -117,30 +156,38 @@
<!-- Order Summary Column --> <!-- Order Summary Column -->
<div class="col-md-6"> <div class="col-md-6">
<app-card [title]="'USER_DETAILS.SUMMARY_TITLE' | translate"> <app-card [title]="'USER_DETAILS.SUMMARY_TITLE' | translate">
<div class="summary-content" *ngIf="quote()"> <div class="summary-content" *ngIf="quote()">
<div class="summary-item" *ngFor="let item of quote()!.items"> <div class="summary-item" *ngFor="let item of quote()!.items">
<div class="item-info"> <div class="item-info">
<span class="item-name">{{ item.fileName }}</span> <span class="item-name">{{ item.fileName }}</span>
<span class="item-meta">{{ item.material }} - {{ item.color || ('USER_DETAILS.DEFAULT_COLOR' | translate) }}</span> <span class="item-meta"
>{{ item.material }} -
{{
item.color || ("USER_DETAILS.DEFAULT_COLOR" | translate)
}}</span
>
</div> </div>
<div class="item-qty">x{{ item.quantity }}</div> <div class="item-qty">x{{ item.quantity }}</div>
<div class="item-price"> <div class="item-price">
<span class="item-total-price">{{ (item.unitPrice * item.quantity) | currency:'CHF' }}</span> <span class="item-total-price">{{
item.unitPrice * item.quantity | currency: "CHF"
}}</span>
<small class="item-unit-price" *ngIf="item.quantity > 1"> <small class="item-unit-price" *ngIf="item.quantity > 1">
{{ item.unitPrice | currency:'CHF' }} {{ 'CHECKOUT.PER_PIECE' | translate }} {{ item.unitPrice | currency: "CHF" }}
{{ "CHECKOUT.PER_PIECE" | translate }}
</small> </small>
</div> </div>
</div> </div>
<hr> <hr />
<div class="total-row"> <div class="total-row">
<span>{{ 'QUOTE.TOTAL' | translate }}</span> <span>{{ "QUOTE.TOTAL" | translate }}</span>
<span class="total-price">{{ quote()!.totalPrice | currency:'CHF' }}</span> <span class="total-price">{{
quote()!.totalPrice | currency: "CHF"
}}</span>
</div> </div>
</div> </div>
</app-card> </app-card>
</div> </div>
</div> </div>

View File

@@ -7,7 +7,7 @@
flex-wrap: wrap; flex-wrap: wrap;
margin: 0 -0.5rem; margin: 0 -0.5rem;
> [class*='col-'] { > [class*="col-"] {
padding: 0 0.5rem; padding: 0 0.5rem;
} }
} }
@@ -55,7 +55,7 @@
line-height: 1.4; line-height: 1.4;
} }
input[type='checkbox'] { input[type="checkbox"] {
margin-top: 0.2rem; margin-top: 0.2rem;
} }
@@ -136,6 +136,6 @@
border-top: 2px solid rgba(255, 255, 255, 0.2); border-top: 2px solid rgba(255, 255, 255, 0.2);
.total-price { .total-price {
color: var(--primary-color, #00C853); // Fallback color color: var(--primary-color, #00c853); // Fallback color
} }
} }

View File

@@ -1,6 +1,11 @@
import { Component, input, output, signal } from '@angular/core'; import { Component, input, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import {
ReactiveFormsModule,
FormBuilder,
FormGroup,
Validators,
} from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component'; import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component';
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component'; import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
@@ -10,9 +15,16 @@ import { QuoteResult } from '../../services/quote-estimator.service';
@Component({ @Component({
selector: 'app-user-details', selector: 'app-user-details',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppCardComponent, AppInputComponent, AppButtonComponent], imports: [
CommonModule,
ReactiveFormsModule,
TranslateModule,
AppCardComponent,
AppInputComponent,
AppButtonComponent,
],
templateUrl: './user-details.component.html', templateUrl: './user-details.component.html',
styleUrl: './user-details.component.scss' styleUrl: './user-details.component.scss',
}) })
export class UserDetailsComponent { export class UserDetailsComponent {
quote = input<QuoteResult>(); quote = input<QuoteResult>();
@@ -31,7 +43,7 @@ export class UserDetailsComponent {
address: ['', Validators.required], address: ['', Validators.required],
zip: ['', Validators.required], zip: ['', Validators.required],
city: ['', Validators.required], city: ['', Validators.required],
acceptLegal: [false, Validators.requiredTrue] acceptLegal: [false, Validators.requiredTrue],
}); });
} }
@@ -41,7 +53,7 @@ export class UserDetailsComponent {
const orderData = { const orderData = {
customer: this.form.value, customer: this.form.value,
quote: this.quote() quote: this.quote(),
}; };
// Simulate API delay // Simulate API delay

View File

@@ -5,7 +5,12 @@ import { map, catchError, tap } from 'rxjs/operators';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
export interface QuoteRequest { export interface QuoteRequest {
items: { file: File, quantity: number, color?: string, filamentVariantId?: number }[]; items: {
file: File;
quantity: number;
color?: string;
filamentVariantId?: number;
}[];
material: string; material: string;
quality: string; quality: string;
notes?: string; notes?: string;
@@ -110,7 +115,7 @@ export interface SimpleOption {
} }
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class QuoteEstimatorService { export class QuoteEstimatorService {
private http = inject(HttpClient); private http = inject(HttpClient);
@@ -131,7 +136,7 @@ export class QuoteEstimatorService {
layerHeight: 0.12, layerHeight: 0.12,
infillDensity: 20, infillDensity: 20,
infillPattern: 'grid', infillPattern: 'grid',
nozzleDiameter: 0.4 nozzleDiameter: 0.4,
}; };
} }
@@ -141,7 +146,7 @@ export class QuoteEstimatorService {
layerHeight: 0.24, layerHeight: 0.24,
infillDensity: 12, infillDensity: 12,
infillPattern: 'grid', infillPattern: 'grid',
nozzleDiameter: 0.4 nozzleDiameter: 0.4,
}; };
} }
@@ -150,18 +155,24 @@ export class QuoteEstimatorService {
layerHeight: 0.2, layerHeight: 0.2,
infillDensity: 15, infillDensity: 15,
infillPattern: 'grid', infillPattern: 'grid',
nozzleDiameter: 0.4 nozzleDiameter: 0.4,
}; };
} }
getOptions(): Observable<OptionsResponse> { getOptions(): Observable<OptionsResponse> {
console.log('QuoteEstimatorService: Requesting options...'); console.log('QuoteEstimatorService: Requesting options...');
const headers: any = {}; const headers: any = {};
return this.http.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, { headers }).pipe( return this.http
tap({ .get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, {
next: (res) => console.log('QuoteEstimatorService: Options loaded', res), headers,
error: (err) => console.error('QuoteEstimatorService: Options failed', err)
}) })
.pipe(
tap({
next: (res) =>
console.log('QuoteEstimatorService: Options loaded', res),
error: (err) =>
console.error('QuoteEstimatorService: Options failed', err),
}),
); );
} }
@@ -169,48 +180,73 @@ export class QuoteEstimatorService {
getQuoteSession(sessionId: string): Observable<any> { getQuoteSession(sessionId: string): Observable<any> {
const headers: any = {}; const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { headers }); return this.http.get(
`${environment.apiUrl}/api/quote-sessions/${sessionId}`,
{ headers },
);
} }
updateLineItem(lineItemId: string, changes: any): Observable<any> { updateLineItem(lineItemId: string, changes: any): Observable<any> {
const headers: any = {}; const headers: any = {};
return this.http.patch(`${environment.apiUrl}/api/quote-sessions/line-items/${lineItemId}`, changes, { headers }); return this.http.patch(
`${environment.apiUrl}/api/quote-sessions/line-items/${lineItemId}`,
changes,
{ headers },
);
} }
createOrder(sessionId: string, orderDetails: any): Observable<any> { createOrder(sessionId: string, orderDetails: any): Observable<any> {
const headers: any = {}; const headers: any = {};
return this.http.post(`${environment.apiUrl}/api/orders/from-quote/${sessionId}`, orderDetails, { headers }); return this.http.post(
`${environment.apiUrl}/api/orders/from-quote/${sessionId}`,
orderDetails,
{ headers },
);
} }
getOrder(orderId: string): Observable<any> { getOrder(orderId: string): Observable<any> {
const headers: any = {}; const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, { headers }); return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, {
headers,
});
} }
reportPayment(orderId: string, method: string): Observable<any> { reportPayment(orderId: string, method: string): Observable<any> {
const headers: any = {}; const headers: any = {};
return this.http.post(`${environment.apiUrl}/api/orders/${orderId}/payments/report`, { method }, { headers }); return this.http.post(
`${environment.apiUrl}/api/orders/${orderId}/payments/report`,
{ method },
{ headers },
);
} }
getOrderInvoice(orderId: string): Observable<Blob> { getOrderInvoice(orderId: string): Observable<Blob> {
const headers: any = {}; const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/invoice`, { return this.http.get(
`${environment.apiUrl}/api/orders/${orderId}/invoice`,
{
headers, headers,
responseType: 'blob' responseType: 'blob',
}); },
);
} }
getOrderConfirmation(orderId: string): Observable<Blob> { getOrderConfirmation(orderId: string): Observable<Blob> {
const headers: any = {}; const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/confirmation`, { return this.http.get(
`${environment.apiUrl}/api/orders/${orderId}/confirmation`,
{
headers, headers,
responseType: 'blob' responseType: 'blob',
}); },
);
} }
getTwintPayment(orderId: string): Observable<any> { getTwintPayment(orderId: string): Observable<any> {
const headers: any = {}; const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/twint`, { headers }); return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/twint`, {
headers,
});
} }
calculate(request: QuoteRequest): Observable<number | QuoteResult> { calculate(request: QuoteRequest): Observable<number | QuoteResult> {
@@ -220,11 +256,13 @@ export class QuoteEstimatorService {
return of(); return of();
} }
return new Observable(observer => { return new Observable((observer) => {
// 1. Create Session first // 1. Create Session first
const headers: any = {}; const headers: any = {};
this.http.post<any>(`${environment.apiUrl}/api/quote-sessions`, {}, { headers }).subscribe({ this.http
.post<any>(`${environment.apiUrl}/api/quote-sessions`, {}, { headers })
.subscribe({
next: (sessionRes) => { next: (sessionRes) => {
const sessionId = sessionRes.id; const sessionId = sessionRes.id;
const sessionSetupCost = sessionRes.setupCostChf || 0; const sessionSetupCost = sessionRes.setupCostChf || 0;
@@ -236,7 +274,9 @@ export class QuoteEstimatorService {
let completedRequests = 0; let completedRequests = 0;
const checkCompletion = () => { const checkCompletion = () => {
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems); const avg = Math.round(
allProgress.reduce((a, b) => a + b, 0) / totalItems,
);
observer.next(avg); observer.next(avg);
if (completedRequests === totalItems) { if (completedRequests === totalItems) {
@@ -248,59 +288,101 @@ export class QuoteEstimatorService {
const formData = new FormData(); const formData = new FormData();
formData.append('file', item.file); formData.append('file', item.file);
const easyPreset = request.mode === 'easy' const easyPreset =
request.mode === 'easy'
? this.buildEasyModePreset(request.quality) ? this.buildEasyModePreset(request.quality)
: null; : null;
const settings = { const settings = {
complexityMode: request.mode === 'easy' ? 'ADVANCED' : request.mode.toUpperCase(), complexityMode:
request.mode === 'easy'
? 'ADVANCED'
: request.mode.toUpperCase(),
material: request.material, material: request.material,
filamentVariantId: item.filamentVariantId, filamentVariantId: item.filamentVariantId,
quality: easyPreset ? easyPreset.quality : request.quality, quality: easyPreset ? easyPreset.quality : request.quality,
supportsEnabled: request.supportEnabled, supportsEnabled: request.supportEnabled,
color: item.color || '#FFFFFF', color: item.color || '#FFFFFF',
layerHeight: easyPreset ? easyPreset.layerHeight : request.layerHeight, layerHeight: easyPreset
infillDensity: easyPreset ? easyPreset.infillDensity : request.infillDensity, ? easyPreset.layerHeight
infillPattern: easyPreset ? easyPreset.infillPattern : request.infillPattern, : request.layerHeight,
nozzleDiameter: easyPreset ? easyPreset.nozzleDiameter : request.nozzleDiameter infillDensity: easyPreset
? easyPreset.infillDensity
: request.infillDensity,
infillPattern: easyPreset
? easyPreset.infillPattern
: request.infillPattern,
nozzleDiameter: easyPreset
? easyPreset.nozzleDiameter
: request.nozzleDiameter,
}; };
const settingsBlob = new Blob([JSON.stringify(settings)], { type: 'application/json' }); const settingsBlob = new Blob([JSON.stringify(settings)], {
type: 'application/json',
});
formData.append('settings', settingsBlob); formData.append('settings', settingsBlob);
this.http.post<any>(`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items`, formData, { this.http
.post<any>(
`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items`,
formData,
{
headers, headers,
reportProgress: true, reportProgress: true,
observe: 'events' observe: 'events',
}).subscribe({ },
)
.subscribe({
next: (event) => { next: (event) => {
if (event.type === HttpEventType.UploadProgress && event.total) { if (
allProgress[index] = Math.round((100 * event.loaded) / event.total); event.type === HttpEventType.UploadProgress &&
event.total
) {
allProgress[index] = Math.round(
(100 * event.loaded) / event.total,
);
checkCompletion(); checkCompletion();
} else if (event.type === HttpEventType.Response) { } else if (event.type === HttpEventType.Response) {
allProgress[index] = 100; allProgress[index] = 100;
finalResponses[index] = { ...event.body, success: true, fileName: item.file.name, originalQty: item.quantity, originalItem: item }; finalResponses[index] = {
...event.body,
success: true,
fileName: item.file.name,
originalQty: item.quantity,
originalItem: item,
};
completedRequests++; completedRequests++;
checkCompletion(); checkCompletion();
} }
}, },
error: (err) => { error: (err) => {
console.error('Item upload failed', err); console.error('Item upload failed', err);
finalResponses[index] = { success: false, fileName: item.file.name }; finalResponses[index] = {
success: false,
fileName: item.file.name,
};
completedRequests++; completedRequests++;
checkCompletion(); checkCompletion();
} },
}); });
}); });
}, },
error: (err) => { error: (err) => {
console.error('Failed to create session', err); console.error('Failed to create session', err);
observer.error('Could not initialize quote session'); observer.error('Could not initialize quote session');
} },
}); });
const finalize = (responses: any[], setupCost: number, sessionId: string) => { const finalize = (
this.http.get<any>(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { headers }).subscribe({ responses: any[],
setupCost: number,
sessionId: string,
) => {
this.http
.get<any>(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, {
headers,
})
.subscribe({
next: (sessionData) => { next: (sessionData) => {
observer.next(100); observer.next(100);
const result = this.mapSessionToQuoteResult(sessionData); const result = this.mapSessionToQuoteResult(sessionData);
@@ -311,16 +393,19 @@ export class QuoteEstimatorService {
error: (err) => { error: (err) => {
console.error('Failed to fetch final session calculation', err); console.error('Failed to fetch final session calculation', err);
observer.error('Failed to calculate final quote'); observer.error('Failed to calculate final quote');
} },
}); });
}; };
}); });
} }
// Consultation Data Transfer // Consultation Data Transfer
private pendingConsultation = signal<{files: File[], message: string} | null>(null); private pendingConsultation = signal<{
files: File[];
message: string;
} | null>(null);
setPendingConsultation(data: {files: File[], message: string}) { setPendingConsultation(data: { files: File[]; message: string }) {
this.pendingConsultation.set(data); this.pendingConsultation.set(data);
} }
@@ -333,17 +418,28 @@ export class QuoteEstimatorService {
// Session File Retrieval // Session File Retrieval
getLineItemContent(sessionId: string, lineItemId: string): Observable<Blob> { getLineItemContent(sessionId: string, lineItemId: string): Observable<Blob> {
const headers: any = {}; const headers: any = {};
return this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/content`, { return this.http.get(
`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/content`,
{
headers, headers,
responseType: 'blob' responseType: 'blob',
}); },
);
} }
mapSessionToQuoteResult(sessionData: any): QuoteResult { mapSessionToQuoteResult(sessionData: any): QuoteResult {
const session = sessionData.session; const session = sessionData.session;
const items = sessionData.items || []; const items = sessionData.items || [];
const totalTime = items.reduce((acc: number, item: any) => acc + (item.printTimeSeconds || 0) * item.quantity, 0); const totalTime = items.reduce(
const totalWeight = items.reduce((acc: number, item: any) => acc + (item.materialGrams || 0) * item.quantity, 0); (acc: number, item: any) =>
acc + (item.printTimeSeconds || 0) * item.quantity,
0,
);
const totalWeight = items.reduce(
(acc: number, item: any) =>
acc + (item.materialGrams || 0) * item.quantity,
0,
);
return { return {
sessionId: session.id, sessionId: session.id,
@@ -358,16 +454,19 @@ export class QuoteEstimatorService {
// Backend model QuoteSession has materialCode. // Backend model QuoteSession has materialCode.
// But line items might have different colors. // But line items might have different colors.
color: item.colorCode, color: item.colorCode,
filamentVariantId: item.filamentVariantId filamentVariantId: item.filamentVariantId,
})), })),
setupCost: session.setupCostChf || 0, setupCost: session.setupCostChf || 0,
globalMachineCost: sessionData.globalMachineCostChf || 0, globalMachineCost: sessionData.globalMachineCostChf || 0,
currency: 'CHF', // Fixed for now currency: 'CHF', // Fixed for now
totalPrice: (sessionData.itemsTotalChf || 0) + (session.setupCostChf || 0) + (sessionData.shippingCostChf || 0), totalPrice:
(sessionData.itemsTotalChf || 0) +
(session.setupCostChf || 0) +
(sessionData.shippingCostChf || 0),
totalTimeHours: Math.floor(totalTime / 3600), totalTimeHours: Math.floor(totalTime / 3600),
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60), totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
totalWeight: Math.ceil(totalWeight), totalWeight: Math.ceil(totalWeight),
notes: session.notes notes: session.notes,
}; };
} }
} }

View File

@@ -1,11 +1,10 @@
<div class="checkout-page"> <div class="checkout-page">
<div class="container hero"> <div class="container hero">
<h1 class="section-title">{{ 'CHECKOUT.TITLE' | translate }}</h1> <h1 class="section-title">{{ "CHECKOUT.TITLE" | translate }}</h1>
</div> </div>
<div class="container"> <div class="container">
<div class="checkout-layout"> <div class="checkout-layout">
<!-- LEFT COLUMN: Form --> <!-- LEFT COLUMN: Form -->
<div class="checkout-form-section"> <div class="checkout-form-section">
<!-- Error Message --> <!-- Error Message -->
@@ -14,50 +13,105 @@
</div> </div>
<form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error"> <form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error">
<!-- Contact Info Card --> <!-- Contact Info Card -->
<app-card class="mb-6"> <app-card class="mb-6">
<div class="card-header-simple"> <div class="card-header-simple">
<h3>{{ 'CHECKOUT.CONTACT_INFO' | translate }}</h3> <h3>{{ "CHECKOUT.CONTACT_INFO" | translate }}</h3>
</div> </div>
<div class="form-row"> <div class="form-row">
<app-input formControlName="email" type="email" [label]="'CHECKOUT.EMAIL' | translate" [required]="true" [error]="checkoutForm.get('email')?.hasError('email') ? ('CHECKOUT.INVALID_EMAIL' | translate) : null"></app-input> <app-input
<app-input formControlName="phone" type="tel" [label]="'CHECKOUT.PHONE' | translate" [required]="true"></app-input> formControlName="email"
type="email"
[label]="'CHECKOUT.EMAIL' | translate"
[required]="true"
[error]="
checkoutForm.get('email')?.hasError('email')
? ('CHECKOUT.INVALID_EMAIL' | translate)
: null
"
></app-input>
<app-input
formControlName="phone"
type="tel"
[label]="'CHECKOUT.PHONE' | translate"
[required]="true"
></app-input>
</div> </div>
</app-card> </app-card>
<!-- Billing Address Card --> <!-- Billing Address Card -->
<app-card class="mb-6"> <app-card class="mb-6">
<div class="card-header-simple"> <div class="card-header-simple">
<h3>{{ 'CHECKOUT.BILLING_ADDR' | translate }}</h3> <h3>{{ "CHECKOUT.BILLING_ADDR" | translate }}</h3>
</div> </div>
<div formGroupName="billingAddress"> <div formGroupName="billingAddress">
<!-- User Type Selector --> <!-- User Type Selector -->
<app-toggle-selector class="mb-4 user-type-selector-compact" <app-toggle-selector
class="mb-4 user-type-selector-compact"
[options]="userTypeOptions" [options]="userTypeOptions"
[selectedValue]="checkoutForm.get('customerType')?.value" [selectedValue]="checkoutForm.get('customerType')?.value"
(selectionChange)="setCustomerType($event)"> (selectionChange)="setCustomerType($event)"
>
</app-toggle-selector> </app-toggle-selector>
<!-- Private Person Fields --> <!-- Private Person Fields -->
<div *ngIf="!isCompany" class="form-row"> <div *ngIf="!isCompany" class="form-row">
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate" [required]="true"></app-input> <app-input
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate" [required]="true"></app-input> formControlName="firstName"
[label]="'CHECKOUT.FIRST_NAME' | translate"
[required]="true"
></app-input>
<app-input
formControlName="lastName"
[label]="'CHECKOUT.LAST_NAME' | translate"
[required]="true"
></app-input>
</div> </div>
<!-- Company Fields --> <!-- Company Fields -->
<div *ngIf="isCompany" class="company-fields mb-4"> <div *ngIf="isCompany" class="company-fields mb-4">
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate" [required]="true" [placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"></app-input> <app-input
<app-input formControlName="referencePerson" [label]="'CONTACT.REF_PERSON' | translate" [required]="true" [placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"></app-input> formControlName="companyName"
[label]="'CHECKOUT.COMPANY_NAME' | translate"
[required]="true"
[placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"
></app-input>
<app-input
formControlName="referencePerson"
[label]="'CONTACT.REF_PERSON' | translate"
[required]="true"
[placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"
></app-input>
</div> </div>
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate" [required]="true"></app-input> <app-input
<app-input formControlName="addressLine2" [label]="'CHECKOUT.ADDRESS_2' | translate"></app-input> formControlName="addressLine1"
[label]="'CHECKOUT.ADDRESS_1' | translate"
[required]="true"
></app-input>
<app-input
formControlName="addressLine2"
[label]="'CHECKOUT.ADDRESS_2' | translate"
></app-input>
<div class="form-row three-cols"> <div class="form-row three-cols">
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate" [required]="true"></app-input> <app-input
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field" [required]="true"></app-input> formControlName="zip"
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true" [required]="true"></app-input> [label]="'CHECKOUT.ZIP' | translate"
[required]="true"
></app-input>
<app-input
formControlName="city"
[label]="'CHECKOUT.CITY' | translate"
class="city-field"
[required]="true"
></app-input>
<app-input
formControlName="countryCode"
[label]="'CHECKOUT.COUNTRY' | translate"
[disabled]="true"
[required]="true"
></app-input>
</div> </div>
</div> </div>
</app-card> </app-card>
@@ -65,60 +119,108 @@
<!-- Shipping Option --> <!-- Shipping Option -->
<div class="shipping-option"> <div class="shipping-option">
<label class="checkbox-container"> <label class="checkbox-container">
<input type="checkbox" formControlName="shippingSameAsBilling"> <input type="checkbox" formControlName="shippingSameAsBilling" />
<span class="checkmark"></span> <span class="checkmark"></span>
{{ 'CHECKOUT.SHIPPING_SAME' | translate }} {{ "CHECKOUT.SHIPPING_SAME" | translate }}
</label> </label>
</div> </div>
<!-- Shipping Address Card (Conditional) --> <!-- Shipping Address Card (Conditional) -->
<app-card *ngIf="!checkoutForm.get('shippingSameAsBilling')?.value" class="mb-6"> <app-card
*ngIf="!checkoutForm.get('shippingSameAsBilling')?.value"
class="mb-6"
>
<div class="card-header-simple"> <div class="card-header-simple">
<h3>{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}</h3> <h3>{{ "CHECKOUT.SHIPPING_ADDR" | translate }}</h3>
</div> </div>
<div formGroupName="shippingAddress"> <div formGroupName="shippingAddress">
<div class="form-row"> <div class="form-row">
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate"></app-input> <app-input
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate"></app-input> formControlName="firstName"
[label]="'CHECKOUT.FIRST_NAME' | translate"
></app-input>
<app-input
formControlName="lastName"
[label]="'CHECKOUT.LAST_NAME' | translate"
></app-input>
</div> </div>
<div *ngIf="isCompany" class="company-fields"> <div *ngIf="isCompany" class="company-fields">
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_OPTIONAL' | translate"></app-input> <app-input
<app-input formControlName="referencePerson" [label]="'CHECKOUT.REF_PERSON_OPTIONAL' | translate"></app-input> formControlName="companyName"
[label]="'CHECKOUT.COMPANY_OPTIONAL' | translate"
></app-input>
<app-input
formControlName="referencePerson"
[label]="'CHECKOUT.REF_PERSON_OPTIONAL' | translate"
></app-input>
</div> </div>
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate"></app-input> <app-input
formControlName="addressLine1"
[label]="'CHECKOUT.ADDRESS_1' | translate"
></app-input>
<div class="form-row three-cols"> <div class="form-row three-cols">
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate"></app-input> <app-input
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field"></app-input> formControlName="zip"
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true"></app-input> [label]="'CHECKOUT.ZIP' | translate"
></app-input>
<app-input
formControlName="city"
[label]="'CHECKOUT.CITY' | translate"
class="city-field"
></app-input>
<app-input
formControlName="countryCode"
[label]="'CHECKOUT.COUNTRY' | translate"
[disabled]="true"
></app-input>
</div> </div>
</div> </div>
</app-card> </app-card>
<div class="legal-consent"> <div class="legal-consent">
<label class="checkbox-container"> <label class="checkbox-container">
<input type="checkbox" formControlName="acceptLegal"> <input type="checkbox" formControlName="acceptLegal" />
<span class="checkmark"></span> <span class="checkmark"></span>
<span> <span>
{{ 'LEGAL.CONSENT.LABEL_PREFIX' | translate }} {{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
<a href="/terms" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.TERMS_LINK' | translate }}</a> <a href="/terms" target="_blank" rel="noopener">{{
{{ 'LEGAL.CONSENT.AND' | translate }} "LEGAL.CONSENT.TERMS_LINK" | translate
<a href="/privacy" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.PRIVACY_LINK' | translate }}</a>. }}</a>
{{ "LEGAL.CONSENT.AND" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.PRIVACY_LINK" | translate
}}</a
>.
</span> </span>
</label> </label>
<div class="consent-error" *ngIf="checkoutForm.get('acceptLegal')?.invalid && checkoutForm.get('acceptLegal')?.touched"> <div
{{ 'LEGAL.CONSENT.REQUIRED_ERROR' | translate }} class="consent-error"
*ngIf="
checkoutForm.get('acceptLegal')?.invalid &&
checkoutForm.get('acceptLegal')?.touched
"
>
{{ "LEGAL.CONSENT.REQUIRED_ERROR" | translate }}
</div> </div>
</div> </div>
<div class="actions"> <div class="actions">
<app-button type="submit" [disabled]="checkoutForm.invalid || isSubmitting()" [fullWidth]="true"> <app-button
{{ (isSubmitting() ? 'CHECKOUT.PROCESSING' : 'CHECKOUT.PLACE_ORDER') | translate }} type="submit"
[disabled]="checkoutForm.invalid || isSubmitting()"
[fullWidth]="true"
>
{{
(isSubmitting()
? "CHECKOUT.PROCESSING"
: "CHECKOUT.PLACE_ORDER"
) | translate
}}
</app-button> </app-button>
</div> </div>
</form> </form>
</div> </div>
@@ -126,7 +228,7 @@
<div class="checkout-summary-section"> <div class="checkout-summary-section">
<app-card class="sticky-card"> <app-card class="sticky-card">
<div class="card-header-simple"> <div class="card-header-simple">
<h3>{{ 'CHECKOUT.SUMMARY_TITLE' | translate }}</h3> <h3>{{ "CHECKOUT.SUMMARY_TITLE" | translate }}</h3>
</div> </div>
<div class="summary-items" *ngIf="quoteSession() as session"> <div class="summary-items" *ngIf="quoteSession() as session">
@@ -134,19 +236,27 @@
<div class="item-details"> <div class="item-details">
<span class="item-name">{{ item.originalFilename }}</span> <span class="item-name">{{ item.originalFilename }}</span>
<div class="item-specs"> <div class="item-specs">
<span>{{ 'CHECKOUT.QTY' | translate }}: {{ item.quantity }}</span> <span
<span *ngIf="item.colorCode" class="color-dot" [style.background-color]="item.colorCode"></span> >{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span
>
<span
*ngIf="item.colorCode"
class="color-dot"
[style.background-color]="item.colorCode"
></span>
</div> </div>
<div class="item-specs-sub"> <div class="item-specs-sub">
{{ (item.printTimeSeconds / 3600) | number:'1.1-1' }}h | {{ item.materialGrams | number:'1.0-0' }}g {{ item.printTimeSeconds / 3600 | number: "1.1-1" }}h |
{{ item.materialGrams | number: "1.0-0" }}g
</div> </div>
</div> </div>
<div class="item-price"> <div class="item-price">
<span class="item-total-price"> <span class="item-total-price">
{{ (item.unitPriceChf * item.quantity) | currency:'CHF' }} {{ item.unitPriceChf * item.quantity | currency: "CHF" }}
</span> </span>
<small class="item-unit-price" *ngIf="item.quantity > 1"> <small class="item-unit-price" *ngIf="item.quantity > 1">
{{ item.unitPriceChf | currency:'CHF' }} {{ 'CHECKOUT.PER_PIECE' | translate }} {{ item.unitPriceChf | currency: "CHF" }}
{{ "CHECKOUT.PER_PIECE" | translate }}
</small> </small>
</div> </div>
</div> </div>
@@ -154,25 +264,24 @@
<div class="summary-totals" *ngIf="quoteSession() as session"> <div class="summary-totals" *ngIf="quoteSession() as session">
<div class="total-row"> <div class="total-row">
<span>{{ 'CHECKOUT.SUBTOTAL' | translate }}</span> <span>{{ "CHECKOUT.SUBTOTAL" | translate }}</span>
<span>{{ session.itemsTotalChf | currency:'CHF' }}</span> <span>{{ session.itemsTotalChf | currency: "CHF" }}</span>
</div> </div>
<div class="total-row"> <div class="total-row">
<span>{{ 'CHECKOUT.SETUP_FEE' | translate }}</span> <span>{{ "CHECKOUT.SETUP_FEE" | translate }}</span>
<span>{{ session.session.setupCostChf | currency:'CHF' }}</span> <span>{{ session.session.setupCostChf | currency: "CHF" }}</span>
</div> </div>
<div class="total-row"> <div class="total-row">
<span>{{ 'CHECKOUT.SHIPPING' | translate }}</span> <span>{{ "CHECKOUT.SHIPPING" | translate }}</span>
<span>{{ session.shippingCostChf | currency:'CHF' }}</span> <span>{{ session.shippingCostChf | currency: "CHF" }}</span>
</div> </div>
<div class="grand-total"> <div class="grand-total">
<span>{{ 'CHECKOUT.TOTAL' | translate }}</span> <span>{{ "CHECKOUT.TOTAL" | translate }}</span>
<span>{{ session.grandTotalChf | currency:'CHF' }}</span> <span>{{ session.grandTotalChf | currency: "CHF" }}</span>
</div> </div>
</div> </div>
</app-card> </app-card>
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -42,7 +42,9 @@
@media (min-width: 768px) { @media (min-width: 768px) {
flex-direction: row; flex-direction: row;
& > * { flex: 1; } & > * {
flex: 1;
}
} }
&.no-margin { &.no-margin {
@@ -197,8 +199,12 @@ app-toggle-selector.user-type-selector-compact {
padding: var(--space-4) 0; padding: var(--space-4) 0;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
&:first-child { padding-top: 0; } &:first-child {
&:last-child { border-bottom: none; } padding-top: 0;
}
&:last-child {
border-bottom: none;
}
.item-details { .item-details {
flex: 1; flex: 1;
@@ -298,4 +304,6 @@ app-toggle-selector.user-type-selector-compact {
font-weight: 500; font-weight: 500;
} }
.mb-6 { margin-bottom: var(--space-6); } .mb-6 {
margin-bottom: var(--space-6);
}

View File

@@ -1,13 +1,21 @@
import { Component, inject, OnInit, signal } from '@angular/core'; import { Component, inject, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import {
FormBuilder,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router'; import { Router, ActivatedRoute } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service'; import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
import { AppInputComponent } from '../../shared/components/app-input/app-input.component'; import { AppInputComponent } from '../../shared/components/app-input/app-input.component';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { AppToggleSelectorComponent, ToggleOption } from '../../shared/components/app-toggle-selector/app-toggle-selector.component'; import {
AppToggleSelectorComponent,
ToggleOption,
} from '../../shared/components/app-toggle-selector/app-toggle-selector.component';
import { LanguageService } from '../../core/services/language.service'; import { LanguageService } from '../../core/services/language.service';
@Component({ @Component({
@@ -20,10 +28,10 @@ import { LanguageService } from '../../core/services/language.service';
AppInputComponent, AppInputComponent,
AppButtonComponent, AppButtonComponent,
AppCardComponent, AppCardComponent,
AppToggleSelectorComponent AppToggleSelectorComponent,
], ],
templateUrl: './checkout.component.html', templateUrl: './checkout.component.html',
styleUrls: ['./checkout.component.scss'] styleUrls: ['./checkout.component.scss'],
}) })
export class CheckoutComponent implements OnInit { export class CheckoutComponent implements OnInit {
private fb = inject(FormBuilder); private fb = inject(FormBuilder);
@@ -41,7 +49,7 @@ export class CheckoutComponent implements OnInit {
userTypeOptions: ToggleOption[] = [ userTypeOptions: ToggleOption[] = [
{ label: 'CONTACT.TYPE_PRIVATE', value: 'PRIVATE' }, { label: 'CONTACT.TYPE_PRIVATE', value: 'PRIVATE' },
{ label: 'CONTACT.TYPE_COMPANY', value: 'BUSINESS' } { label: 'CONTACT.TYPE_COMPANY', value: 'BUSINESS' },
]; ];
constructor() { constructor() {
@@ -62,7 +70,7 @@ export class CheckoutComponent implements OnInit {
addressLine2: [''], addressLine2: [''],
zip: ['', Validators.required], zip: ['', Validators.required],
city: ['', Validators.required], city: ['', Validators.required],
countryCode: ['CH', Validators.required] countryCode: ['CH', Validators.required],
}), }),
shippingAddress: this.fb.group({ shippingAddress: this.fb.group({
@@ -74,8 +82,8 @@ export class CheckoutComponent implements OnInit {
addressLine2: [''], addressLine2: [''],
zip: [''], zip: [''],
city: [''], city: [''],
countryCode: ['CH'] countryCode: ['CH'],
}) }),
}); });
} }
@@ -111,7 +119,7 @@ export class CheckoutComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
this.route.queryParams.subscribe(params => { this.route.queryParams.subscribe((params) => {
this.sessionId = params['session']; this.sessionId = params['session'];
if (!this.sessionId) { if (!this.sessionId) {
this.error = 'CHECKOUT.ERR_NO_SESSION_START'; this.error = 'CHECKOUT.ERR_NO_SESSION_START';
@@ -123,8 +131,12 @@ export class CheckoutComponent implements OnInit {
}); });
// Toggle shipping validation based on checkbox // Toggle shipping validation based on checkbox
this.checkoutForm.get('shippingSameAsBilling')?.valueChanges.subscribe(isSame => { this.checkoutForm
const shippingGroup = this.checkoutForm.get('shippingAddress') as FormGroup; .get('shippingSameAsBilling')
?.valueChanges.subscribe((isSame) => {
const shippingGroup = this.checkoutForm.get(
'shippingAddress',
) as FormGroup;
if (isSame) { if (isSame) {
shippingGroup.disable(); shippingGroup.disable();
} else { } else {
@@ -146,7 +158,7 @@ export class CheckoutComponent implements OnInit {
error: (err) => { error: (err) => {
console.error('Failed to load session', err); console.error('Failed to load session', err);
this.error = 'CHECKOUT.ERR_LOAD_SESSION'; this.error = 'CHECKOUT.ERR_LOAD_SESSION';
} },
}); });
} }
@@ -168,7 +180,7 @@ export class CheckoutComponent implements OnInit {
// Assuming firstName, lastName, companyName for customer come from billingAddress if not explicitly in contact group // Assuming firstName, lastName, companyName for customer come from billingAddress if not explicitly in contact group
firstName: formVal.billingAddress.firstName, firstName: formVal.billingAddress.firstName,
lastName: formVal.billingAddress.lastName, lastName: formVal.billingAddress.lastName,
companyName: formVal.billingAddress.companyName companyName: formVal.billingAddress.companyName,
}, },
billingAddress: { billingAddress: {
firstName: formVal.billingAddress.firstName, firstName: formVal.billingAddress.firstName,
@@ -179,9 +191,11 @@ export class CheckoutComponent implements OnInit {
addressLine2: formVal.billingAddress.addressLine2, addressLine2: formVal.billingAddress.addressLine2,
zip: formVal.billingAddress.zip, zip: formVal.billingAddress.zip,
city: formVal.billingAddress.city, city: formVal.billingAddress.city,
countryCode: formVal.billingAddress.countryCode countryCode: formVal.billingAddress.countryCode,
}, },
shippingAddress: formVal.shippingSameAsBilling ? null : { shippingAddress: formVal.shippingSameAsBilling
? null
: {
firstName: formVal.shippingAddress.firstName, firstName: formVal.shippingAddress.firstName,
lastName: formVal.shippingAddress.lastName, lastName: formVal.shippingAddress.lastName,
companyName: formVal.shippingAddress.companyName, companyName: formVal.shippingAddress.companyName,
@@ -190,12 +204,12 @@ export class CheckoutComponent implements OnInit {
addressLine2: formVal.shippingAddress.addressLine2, addressLine2: formVal.shippingAddress.addressLine2,
zip: formVal.shippingAddress.zip, zip: formVal.shippingAddress.zip,
city: formVal.shippingAddress.city, city: formVal.shippingAddress.city,
countryCode: formVal.shippingAddress.countryCode countryCode: formVal.shippingAddress.countryCode,
}, },
shippingSameAsBilling: formVal.shippingSameAsBilling, shippingSameAsBilling: formVal.shippingSameAsBilling,
language: this.languageService.selectedLang(), language: this.languageService.selectedLang(),
acceptTerms: formVal.acceptLegal, acceptTerms: formVal.acceptLegal,
acceptPrivacy: formVal.acceptLegal acceptPrivacy: formVal.acceptLegal,
}; };
if (!this.sessionId) { if (!this.sessionId) {
@@ -212,13 +226,18 @@ export class CheckoutComponent implements OnInit {
this.error = 'CHECKOUT.ERR_CREATE_ORDER'; this.error = 'CHECKOUT.ERR_CREATE_ORDER';
return; return;
} }
this.router.navigate(['/', this.languageService.selectedLang(), 'order', orderId]); this.router.navigate([
'/',
this.languageService.selectedLang(),
'order',
orderId,
]);
}, },
error: (err) => { error: (err) => {
console.error('Order creation failed', err); console.error('Order creation failed', err);
this.isSubmitting.set(false); this.isSubmitting.set(false);
this.error = 'CHECKOUT.ERR_CREATE_ORDER'; this.error = 'CHECKOUT.ERR_CREATE_ORDER';
} },
}); });
} }
} }

View File

@@ -1,10 +1,13 @@
@if (sent()) { @if (sent()) {
<app-success-state context="contact" (action)="resetForm()"></app-success-state> <app-success-state
context="contact"
(action)="resetForm()"
></app-success-state>
} @else { } @else {
<form [formGroup]="form" (ngSubmit)="onSubmit()"> <form [formGroup]="form" (ngSubmit)="onSubmit()">
<!-- Request Type --> <!-- Request Type -->
<div class="form-group"> <div class="form-group">
<label>{{ 'CONTACT.REQ_TYPE_LABEL' | translate }} *</label> <label>{{ "CONTACT.REQ_TYPE_LABEL" | translate }} *</label>
<select formControlName="requestType" class="form-control"> <select formControlName="requestType" class="form-control">
<option *ngFor="let type of requestTypes" [value]="type.value"> <option *ngFor="let type of requestTypes" [value]="type.value">
{{ type.label | translate }} {{ type.label | translate }}
@@ -14,49 +17,99 @@
<div class="row"> <div class="row">
<!-- Phone --> <!-- Phone -->
<app-input formControlName="email" type="email" [label]="'CONTACT.LABEL_EMAIL' | translate" [placeholder]="'CONTACT.PLACEHOLDER_EMAIL' | translate" class="col"></app-input> <app-input
formControlName="email"
type="email"
[label]="'CONTACT.LABEL_EMAIL' | translate"
[placeholder]="'CONTACT.PLACEHOLDER_EMAIL' | translate"
class="col"
></app-input>
<!-- Phone --> <!-- Phone -->
<app-input formControlName="phone" type="tel" [label]="('CONTACT.PHONE' | translate)" [placeholder]="'CONTACT.PLACEHOLDER_PHONE' | translate" class="col"></app-input> <app-input
formControlName="phone"
type="tel"
[label]="'CONTACT.PHONE' | translate"
[placeholder]="'CONTACT.PLACEHOLDER_PHONE' | translate"
class="col"
></app-input>
</div> </div>
<!-- User Type Selector (Segmented Control) --> <!-- User Type Selector (Segmented Control) -->
<div class="user-type-selector"> <div class="user-type-selector">
<div class="type-option" [class.selected]="!isCompany" (click)="setCompanyMode(false)"> <div
{{ 'CONTACT.TYPE_PRIVATE' | translate }} class="type-option"
[class.selected]="!isCompany"
(click)="setCompanyMode(false)"
>
{{ "CONTACT.TYPE_PRIVATE" | translate }}
</div> </div>
<div class="type-option" [class.selected]="isCompany" (click)="setCompanyMode(true)"> <div
{{ 'CONTACT.TYPE_COMPANY' | translate }} class="type-option"
[class.selected]="isCompany"
(click)="setCompanyMode(true)"
>
{{ "CONTACT.TYPE_COMPANY" | translate }}
</div> </div>
</div> </div>
<!-- Personal Name (Only if NOT Company) --> <!-- Personal Name (Only if NOT Company) -->
<app-input *ngIf="!isCompany" formControlName="name" [label]="'CONTACT.LABEL_NAME' | translate" [placeholder]="'CONTACT.PLACEHOLDER_NAME' | translate"></app-input> <app-input
*ngIf="!isCompany"
formControlName="name"
[label]="'CONTACT.LABEL_NAME' | translate"
[placeholder]="'CONTACT.PLACEHOLDER_NAME' | translate"
></app-input>
<!-- Company Fields (Only if Company) --> <!-- Company Fields (Only if Company) -->
<div *ngIf="isCompany" class="company-fields"> <div *ngIf="isCompany" class="company-fields">
<app-input formControlName="companyName" [label]="('CONTACT.COMPANY_NAME' | translate) + ' *'" [placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"></app-input> <app-input
<app-input formControlName="referencePerson" [label]="('CONTACT.REF_PERSON' | translate) + ' *'" [placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"></app-input> formControlName="companyName"
[label]="('CONTACT.COMPANY_NAME' | translate) + ' *'"
[placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"
></app-input>
<app-input
formControlName="referencePerson"
[label]="('CONTACT.REF_PERSON' | translate) + ' *'"
[placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"
></app-input>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>{{ 'CONTACT.LABEL_MESSAGE' | translate }}</label> <label>{{ "CONTACT.LABEL_MESSAGE" | translate }}</label>
<textarea formControlName="message" class="form-control" rows="4"></textarea> <textarea
formControlName="message"
class="form-control"
rows="4"
></textarea>
</div> </div>
<!-- File Upload Section --> <!-- File Upload Section -->
<div class="form-group"> <div class="form-group">
<label>{{ 'CONTACT.UPLOAD_LABEL' | translate }}</label> <label>{{ "CONTACT.UPLOAD_LABEL" | translate }}</label>
<p class="hint">{{ 'CONTACT.UPLOAD_HINT' | translate }}</p> <p class="hint">{{ "CONTACT.UPLOAD_HINT" | translate }}</p>
<p class="hint upload-privacy-note"> <p class="hint upload-privacy-note">
{{ 'LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX' | translate }} {{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.UPLOAD_NOTICE_LINK' | translate }}</a>. <a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate
}}</a
>.
</p> </p>
<div class="drop-zone" (click)="fileInput.click()" <div
(dragover)="onDragOver($event)" (drop)="onDrop($event)"> class="drop-zone"
<input #fileInput type="file" multiple (change)="onFileSelected($event)" hidden (click)="fileInput.click()"
[accept]="acceptedFormats"> (dragover)="onDragOver($event)"
<p>{{ 'CONTACT.DROP_FILES' | translate }}</p> (drop)="onDrop($event)"
>
<input
#fileInput
type="file"
multiple
(change)="onFileSelected($event)"
hidden
[accept]="acceptedFormats"
/>
<p>{{ "CONTACT.DROP_FILES" | translate }}</p>
</div> </div>
<div class="file-grid" *ngIf="files().length > 0"> <div class="file-grid" *ngIf="files().length > 0">
@@ -65,39 +118,80 @@
type="button" type="button"
class="remove-btn" class="remove-btn"
(click)="removeFile(i)" (click)="removeFile(i)"
[attr.aria-label]="'CONTACT.REMOVE_FILE' | translate">×</button> [attr.aria-label]="'CONTACT.REMOVE_FILE' | translate"
<img *ngIf="file.type === 'image'" [src]="file.url" class="preview-img"> >
<video *ngIf="file.type === 'video'" [src]="file.url" class="preview-video" muted playsinline preload="metadata"></video> ×
<div *ngIf="file.type !== 'image' && file.type !== 'video'" class="file-icon"> </button>
<span *ngIf="file.type === 'pdf'">{{ 'CONTACT.FILE_TYPE_PDF' | translate }}</span> <img
<span *ngIf="file.type === '3d'">{{ 'CONTACT.FILE_TYPE_3D' | translate }}</span> *ngIf="file.type === 'image'"
<span *ngIf="file.type === 'document'">{{ 'CONTACT.FILE_TYPE_DOC' | translate }}</span> [src]="file.url"
<span *ngIf="file.type === 'other'">{{ 'CONTACT.FILE_TYPE_FILE' | translate }}</span> class="preview-img"
/>
<video
*ngIf="file.type === 'video'"
[src]="file.url"
class="preview-video"
muted
playsinline
preload="metadata"
></video>
<div
*ngIf="file.type !== 'image' && file.type !== 'video'"
class="file-icon"
>
<span *ngIf="file.type === 'pdf'">{{
"CONTACT.FILE_TYPE_PDF" | translate
}}</span>
<span *ngIf="file.type === '3d'">{{
"CONTACT.FILE_TYPE_3D" | translate
}}</span>
<span *ngIf="file.type === 'document'">{{
"CONTACT.FILE_TYPE_DOC" | translate
}}</span>
<span *ngIf="file.type === 'other'">{{
"CONTACT.FILE_TYPE_FILE" | translate
}}</span>
</div>
<div class="file-name" [title]="file.file.name">
{{ file.file.name }}
</div> </div>
<div class="file-name" [title]="file.file.name">{{ file.file.name }}</div>
</div> </div>
</div> </div>
</div> </div>
<div class="legal-consent"> <div class="legal-consent">
<label class="checkbox-container"> <label class="checkbox-container">
<input type="checkbox" formControlName="acceptLegal"> <input type="checkbox" formControlName="acceptLegal" />
<span class="checkmark"></span> <span class="checkmark"></span>
<span> <span>
{{ 'LEGAL.CONSENT.LABEL_PREFIX' | translate }} {{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
<a href="/terms" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.TERMS_LINK' | translate }}</a> <a href="/terms" target="_blank" rel="noopener">{{
{{ 'LEGAL.CONSENT.AND' | translate }} "LEGAL.CONSENT.TERMS_LINK" | translate
<a href="/privacy" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.PRIVACY_LINK' | translate }}</a>. }}</a>
{{ "LEGAL.CONSENT.AND" | translate }}
<a href="/privacy" target="_blank" rel="noopener">{{
"LEGAL.CONSENT.PRIVACY_LINK" | translate
}}</a
>.
</span> </span>
</label> </label>
<div class="consent-error" *ngIf="form.get('acceptLegal')?.invalid && form.get('acceptLegal')?.touched"> <div
{{ 'LEGAL.CONSENT.REQUIRED_ERROR' | translate }} class="consent-error"
*ngIf="
form.get('acceptLegal')?.invalid && form.get('acceptLegal')?.touched
"
>
{{ "LEGAL.CONSENT.REQUIRED_ERROR" | translate }}
</div> </div>
</div> </div>
<div class="actions"> <div class="actions">
<app-button type="submit" [disabled]="form.invalid || sent()"> <app-button type="submit" [disabled]="form.invalid || sent()">
{{ sent() ? ('CONTACT.MSG_SENT' | translate) : ('CONTACT.SEND' | translate) }} {{
sent()
? ("CONTACT.MSG_SENT" | translate)
: ("CONTACT.SEND" | translate)
}}
</app-button> </app-button>
</div> </div>
</form> </form>

View File

@@ -1,7 +1,23 @@
.form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); } .form-group {
label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); } display: flex;
.hint { font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: var(--space-2); } flex-direction: column;
.upload-privacy-note { margin-top: calc(var(--space-2) * -1); font-size: 0.78rem; } margin-bottom: var(--space-4);
}
label {
font-size: 0.875rem;
font-weight: 500;
margin-bottom: var(--space-2);
color: var(--color-text);
}
.hint {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-bottom: var(--space-2);
}
.upload-privacy-note {
margin-top: calc(var(--space-2) * -1);
font-size: 0.78rem;
}
.form-control { .form-control {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
@@ -11,7 +27,10 @@ label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); co
background: var(--color-bg-card); background: var(--color-bg-card);
color: var(--color-text); color: var(--color-text);
font-family: inherit; font-family: inherit;
&:focus { outline: none; border-color: var(--color-brand); } &:focus {
outline: none;
border-color: var(--color-brand);
}
} }
select.form-control { select.form-control {
@@ -29,11 +48,16 @@ select.form-control {
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
@media (min-width: 768px) { @media (min-width: 768px) {
flex-direction: row; flex-direction: row;
.col { flex: 1; margin-bottom: 0; } .col {
flex: 1;
margin-bottom: 0;
}
} }
} }
app-input.col { width: 100%; } app-input.col {
width: 100%;
}
/* User Type Selector Styles */ /* User Type Selector Styles */
.user-type-selector { .user-type-selector {
@@ -59,7 +83,9 @@ app-input.col { width: 100%; }
transition: all 0.2s ease; transition: all 0.2s ease;
user-select: none; user-select: none;
&:hover { color: var(--color-text); } &:hover {
color: var(--color-text);
}
&.selected { &.selected {
background-color: var(--color-brand); background-color: var(--color-brand);
@@ -87,7 +113,10 @@ app-input.col { width: 100%; }
cursor: pointer; cursor: pointer;
color: var(--color-text-muted); color: var(--color-text-muted);
transition: all 0.2s; transition: all 0.2s;
&:hover { border-color: var(--color-brand); color: var(--color-brand); } &:hover {
border-color: var(--color-brand);
color: var(--color-brand);
}
} }
.file-grid { .file-grid {
@@ -111,7 +140,12 @@ app-input.col { width: 100%; }
} }
.preview-img { .preview-img {
width: 100%; height: 100%; object-fit: cover; position: absolute; top:0; left:0; width: 100%;
height: 100%;
object-fit: cover;
position: absolute;
top: 0;
left: 0;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
@@ -126,21 +160,46 @@ app-input.col { width: 100%; }
} }
.file-icon { .file-icon {
font-weight: 700; color: var(--color-text-muted); font-size: 0.8rem; font-weight: 700;
color: var(--color-text-muted);
font-size: 0.8rem;
} }
.file-name { .file-name {
font-size: 0.65rem; color: var(--color-text); white-space: nowrap; overflow: hidden; font-size: 0.65rem;
text-overflow: ellipsis; width: 100%; text-align: center; position: absolute; bottom: 2px; color: var(--color-text);
padding: 0 4px; z-index: 2; background: rgba(255,255,255,0.8); white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
text-align: center;
position: absolute;
bottom: 2px;
padding: 0 4px;
z-index: 2;
background: rgba(255, 255, 255, 0.8);
} }
.remove-btn { .remove-btn {
position: absolute; top: 2px; right: 2px; z-index: 10; position: absolute;
background: rgba(0,0,0,0.5); color: white; border: none; border-radius: 50%; top: 2px;
width: 18px; height: 18px; font-size: 12px; cursor: pointer; right: 2px;
display: flex; align-items: center; justify-content: center; line-height: 1; z-index: 10;
&:hover { background: red; } background: rgba(0, 0, 0, 0.5);
color: white;
border: none;
border-radius: 50%;
width: 18px;
height: 18px;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
&:hover {
background: red;
}
} }
.legal-consent { .legal-consent {

View File

@@ -1,6 +1,11 @@
import { Component, signal, effect, inject, OnDestroy } from '@angular/core'; import { Component, signal, effect, inject, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import {
ReactiveFormsModule,
FormBuilder,
FormGroup,
Validators,
} from '@angular/forms';
import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component'; import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
@@ -18,15 +23,23 @@ import { SuccessStateComponent } from '../../../../shared/components/success-sta
@Component({ @Component({
selector: 'app-contact-form', selector: 'app-contact-form',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppButtonComponent, SuccessStateComponent], imports: [
CommonModule,
ReactiveFormsModule,
TranslateModule,
AppInputComponent,
AppButtonComponent,
SuccessStateComponent,
],
templateUrl: './contact-form.component.html', templateUrl: './contact-form.component.html',
styleUrl: './contact-form.component.scss' styleUrl: './contact-form.component.scss',
}) })
export class ContactFormComponent implements OnDestroy { export class ContactFormComponent implements OnDestroy {
form: FormGroup; form: FormGroup;
sent = signal(false); sent = signal(false);
files = signal<FilePreview[]>([]); files = signal<FilePreview[]>([]);
readonly acceptedFormats = '.jpg,.jpeg,.png,.webp,.gif,.bmp,.svg,.heic,.heif,.pdf,.stl,.step,.stp,.3mf,.obj,.iges,.igs,.dwg,.dxf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.rtf,.csv,.mp4,.mov,.avi,.mkv,.webm,.m4v,.wmv'; readonly acceptedFormats =
'.jpg,.jpeg,.png,.webp,.gif,.bmp,.svg,.heic,.heif,.pdf,.stl,.step,.stp,.3mf,.obj,.iges,.igs,.dwg,.dxf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.rtf,.csv,.mp4,.mov,.avi,.mkv,.webm,.m4v,.wmv';
get isCompany(): boolean { get isCompany(): boolean {
return this.form.get('isCompany')?.value; return this.form.get('isCompany')?.value;
@@ -36,7 +49,7 @@ export class ContactFormComponent implements OnDestroy {
{ value: 'custom', label: 'CONTACT.REQ_TYPE_CUSTOM' }, { value: 'custom', label: 'CONTACT.REQ_TYPE_CUSTOM' },
{ value: 'series', label: 'CONTACT.REQ_TYPE_SERIES' }, { value: 'series', label: 'CONTACT.REQ_TYPE_SERIES' },
{ value: 'consult', label: 'CONTACT.REQ_TYPE_CONSULT' }, { value: 'consult', label: 'CONTACT.REQ_TYPE_CONSULT' },
{ value: 'question', label: 'CONTACT.REQ_TYPE_QUESTION' } { value: 'question', label: 'CONTACT.REQ_TYPE_QUESTION' },
]; ];
private quoteRequestService = inject(QuoteRequestService); private quoteRequestService = inject(QuoteRequestService);
@@ -44,7 +57,7 @@ export class ContactFormComponent implements OnDestroy {
constructor( constructor(
private fb: FormBuilder, private fb: FormBuilder,
private translate: TranslateService, private translate: TranslateService,
private estimator: QuoteEstimatorService private estimator: QuoteEstimatorService,
) { ) {
this.form = this.fb.group({ this.form = this.fb.group({
requestType: ['custom', Validators.required], requestType: ['custom', Validators.required],
@@ -55,11 +68,11 @@ export class ContactFormComponent implements OnDestroy {
isCompany: [false], isCompany: [false],
companyName: [''], companyName: [''],
referencePerson: [''], referencePerson: [''],
acceptLegal: [false, Validators.requiredTrue] acceptLegal: [false, Validators.requiredTrue],
}); });
// Handle conditional validation for Company fields // Handle conditional validation for Company fields
this.form.get('isCompany')?.valueChanges.subscribe(isCompany => { this.form.get('isCompany')?.valueChanges.subscribe((isCompany) => {
const nameControl = this.form.get('name'); const nameControl = this.form.get('name');
const companyNameControl = this.form.get('companyName'); const companyNameControl = this.form.get('companyName');
const refPersonControl = this.form.get('referencePerson'); const refPersonControl = this.form.get('referencePerson');
@@ -94,16 +107,18 @@ export class ContactFormComponent implements OnDestroy {
if (pending) { if (pending) {
this.form.patchValue({ this.form.patchValue({
requestType: 'consult', requestType: 'consult',
message: pending.message message: pending.message,
}); });
// Process files // Process files
const filePreviews: FilePreview[] = pending.files.map(f => { const filePreviews: FilePreview[] = pending.files.map((f) => {
const type = this.getFileType(f); const type = this.getFileType(f);
return { return {
file: f, file: f,
type, type,
url: this.shouldCreatePreview(type) ? URL.createObjectURL(f) : undefined url: this.shouldCreatePreview(type)
? URL.createObjectURL(f)
: undefined,
}; };
}); });
this.files.set(filePreviews); this.files.set(filePreviews);
@@ -124,22 +139,29 @@ export class ContactFormComponent implements OnDestroy {
} }
onDragOver(event: DragEvent) { onDragOver(event: DragEvent) {
event.preventDefault(); event.stopPropagation(); event.preventDefault();
event.stopPropagation();
} }
onDrop(event: DragEvent) { onDrop(event: DragEvent) {
event.preventDefault(); event.stopPropagation(); event.preventDefault();
if (event.dataTransfer?.files) this.handleFiles(Array.from(event.dataTransfer.files)); event.stopPropagation();
if (event.dataTransfer?.files)
this.handleFiles(Array.from(event.dataTransfer.files));
} }
handleFiles(newFiles: File[]) { handleFiles(newFiles: File[]) {
const currentFiles = this.files(); const currentFiles = this.files();
const blockedCompressed = newFiles.filter(file => this.isCompressedFile(file)); const blockedCompressed = newFiles.filter((file) =>
this.isCompressedFile(file),
);
if (blockedCompressed.length > 0) { if (blockedCompressed.length > 0) {
alert(this.translate.instant('CONTACT.ERR_COMPRESSED_FILES')); alert(this.translate.instant('CONTACT.ERR_COMPRESSED_FILES'));
} }
const allowedFiles = newFiles.filter(file => !this.isCompressedFile(file)); const allowedFiles = newFiles.filter(
(file) => !this.isCompressedFile(file),
);
if (allowedFiles.length === 0) return; if (allowedFiles.length === 0) return;
if (currentFiles.length + allowedFiles.length > 15) { if (currentFiles.length + allowedFiles.length > 15) {
@@ -147,39 +169,83 @@ export class ContactFormComponent implements OnDestroy {
return; return;
} }
allowedFiles.forEach(file => { allowedFiles.forEach((file) => {
const type = this.getFileType(file); const type = this.getFileType(file);
const preview: FilePreview = { const preview: FilePreview = {
file, file,
type, type,
url: this.shouldCreatePreview(type) ? URL.createObjectURL(file) : undefined url: this.shouldCreatePreview(type)
? URL.createObjectURL(file)
: undefined,
}; };
this.files.update(files => [...files, preview]); this.files.update((files) => [...files, preview]);
}); });
} }
removeFile(index: number) { removeFile(index: number) {
this.files.update(files => { this.files.update((files) => {
const fileToRemove = files[index]; const fileToRemove = files[index];
if (fileToRemove) this.revokePreviewUrl(fileToRemove); if (fileToRemove) this.revokePreviewUrl(fileToRemove);
return files.filter((_, i) => i !== index); return files.filter((_, i) => i !== index);
}); });
} }
getFileType(file: File): 'image' | 'video' | 'pdf' | '3d' | 'document' | 'other' { getFileType(
file: File,
): 'image' | 'video' | 'pdf' | '3d' | 'document' | 'other' {
const ext = this.getExtension(file.name); const ext = this.getExtension(file.name);
if (file.type.startsWith('image/') || ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'svg', 'heic', 'heif'].includes(ext)) { if (
file.type.startsWith('image/') ||
[
'jpg',
'jpeg',
'png',
'webp',
'gif',
'bmp',
'svg',
'heic',
'heif',
].includes(ext)
) {
return 'image'; return 'image';
} }
if (file.type.startsWith('video/') || ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'wmv'].includes(ext)) { if (
file.type.startsWith('video/') ||
['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'wmv'].includes(ext)
) {
return 'video'; return 'video';
} }
if (file.type === 'application/pdf' || ext === 'pdf') return 'pdf'; if (file.type === 'application/pdf' || ext === 'pdf') return 'pdf';
if (['stl', 'step', 'stp', '3mf', 'obj', 'iges', 'igs', 'dwg', 'dxf'].includes(ext)) return '3d'; if (
if ([ [
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf', 'csv', 'stl',
].includes(ext)) return 'document'; 'step',
'stp',
'3mf',
'obj',
'iges',
'igs',
'dwg',
'dxf',
].includes(ext)
)
return '3d';
if (
[
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'txt',
'rtf',
'csv',
].includes(ext)
)
return 'document';
return 'other'; return 'other';
} }
@@ -195,7 +261,7 @@ export class ContactFormComponent implements OnDestroy {
phone: formVal.phone, phone: formVal.phone,
message: formVal.message, message: formVal.message,
acceptTerms: formVal.acceptLegal, acceptTerms: formVal.acceptLegal,
acceptPrivacy: formVal.acceptLegal acceptPrivacy: formVal.acceptLegal,
}; };
if (isCompany) { if (isCompany) {
@@ -205,16 +271,20 @@ export class ContactFormComponent implements OnDestroy {
requestDto.name = formVal.name; requestDto.name = formVal.name;
} }
this.quoteRequestService.createRequest(requestDto, this.files().map(f => f.file)).subscribe({ this.quoteRequestService
.createRequest(
requestDto,
this.files().map((f) => f.file),
)
.subscribe({
next: () => { next: () => {
this.sent.set(true); this.sent.set(true);
}, },
error: (err) => { error: (err) => {
console.error('Submission failed', err); console.error('Submission failed', err);
alert(this.translate.instant('CONTACT.ERROR_SUBMIT')); alert(this.translate.instant('CONTACT.ERROR_SUBMIT'));
} },
}); });
} else { } else {
this.form.markAllAsTouched(); this.form.markAllAsTouched();
} }
@@ -239,7 +309,17 @@ export class ContactFormComponent implements OnDestroy {
private isCompressedFile(file: File): boolean { private isCompressedFile(file: File): boolean {
const ext = this.getExtension(file.name); const ext = this.getExtension(file.name);
const compressedExtensions = [ const compressedExtensions = [
'zip', 'rar', '7z', 'tar', 'gz', 'tgz', 'bz2', 'tbz2', 'xz', 'txz', 'zst' 'zip',
'rar',
'7z',
'tar',
'gz',
'tgz',
'bz2',
'tbz2',
'xz',
'txz',
'zst',
]; ];
const compressedMimeTypes = [ const compressedMimeTypes = [
'application/zip', 'application/zip',
@@ -253,9 +333,12 @@ export class ContactFormComponent implements OnDestroy {
'application/x-bzip2', 'application/x-bzip2',
'application/x-xz', 'application/x-xz',
'application/zstd', 'application/zstd',
'application/x-zstd' 'application/x-zstd',
]; ];
return compressedExtensions.includes(ext) || compressedMimeTypes.includes((file.type || '').toLowerCase()); return (
compressedExtensions.includes(ext) ||
compressedMimeTypes.includes((file.type || '').toLowerCase())
);
} }
private revokePreviewUrl(file: FilePreview): void { private revokePreviewUrl(file: FilePreview): void {
@@ -265,6 +348,6 @@ export class ContactFormComponent implements OnDestroy {
} }
private revokeAllPreviewUrls(): void { private revokeAllPreviewUrls(): void {
this.files().forEach(file => this.revokePreviewUrl(file)); this.files().forEach((file) => this.revokePreviewUrl(file));
} }
} }

View File

@@ -1,7 +1,7 @@
<section class="contact-hero"> <section class="contact-hero">
<div class="container"> <div class="container">
<h1>{{ 'CONTACT.TITLE' | translate }}</h1> <h1>{{ "CONTACT.TITLE" | translate }}</h1>
<p class="subtitle">{{ 'CONTACT.HERO_SUBTITLE' | translate }}</p> <p class="subtitle">{{ "CONTACT.HERO_SUBTITLE" | translate }}</p>
</div> </div>
</section> </section>

View File

@@ -7,8 +7,13 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
@Component({ @Component({
selector: 'app-contact-page', selector: 'app-contact-page',
standalone: true, standalone: true,
imports: [CommonModule, TranslateModule, ContactFormComponent, AppCardComponent], imports: [
CommonModule,
TranslateModule,
ContactFormComponent,
AppCardComponent,
],
templateUrl: './contact-page.component.html', templateUrl: './contact-page.component.html',
styleUrl: './contact-page.component.scss' styleUrl: './contact-page.component.scss',
}) })
export class ContactPageComponent {} export class ContactPageComponent {}

View File

@@ -3,6 +3,7 @@ import { Routes } from '@angular/router';
export const CONTACT_ROUTES: Routes = [ export const CONTACT_ROUTES: Routes = [
{ {
path: '', path: '',
loadComponent: () => import('./contact-page.component').then(m => m.ContactPageComponent) loadComponent: () =>
} import('./contact-page.component').then((m) => m.ContactPageComponent),
},
]; ];

View File

@@ -2,18 +2,24 @@
<section class="hero"> <section class="hero">
<div class="container hero-grid"> <div class="container hero-grid">
<div class="hero-copy"> <div class="hero-copy">
<p class="eyebrow">{{ 'HOME.HERO_EYEBROW' | translate }}</p> <p class="eyebrow">{{ "HOME.HERO_EYEBROW" | translate }}</p>
<h1 class="hero-title" [innerHTML]="'HOME.HERO_TITLE' | translate"></h1> <h1 class="hero-title" [innerHTML]="'HOME.HERO_TITLE' | translate"></h1>
<p class="hero-lead"> <p class="hero-lead">
{{ 'HOME.HERO_LEAD' | translate }} {{ "HOME.HERO_LEAD" | translate }}
</p> </p>
<p class="hero-subtitle"> <p class="hero-subtitle">
{{ 'HOME.HERO_SUBTITLE' | translate }} {{ "HOME.HERO_SUBTITLE" | translate }}
</p> </p>
<div class="hero-actions"> <div class="hero-actions">
<app-button variant="primary" routerLink="/calculator/basic">{{ 'HOME.BTN_CALCULATE' | translate }}</app-button> <app-button variant="primary" routerLink="/calculator/basic">{{
<app-button variant="outline" routerLink="/shop">{{ 'HOME.BTN_SHOP' | translate }}</app-button> "HOME.BTN_CALCULATE" | translate
<app-button variant="text" routerLink="/contact">{{ 'HOME.BTN_CONTACT' | translate }}</app-button> }}</app-button>
<app-button variant="outline" routerLink="/shop">{{
"HOME.BTN_SHOP" | translate
}}</app-button>
<app-button variant="text" routerLink="/contact">{{
"HOME.BTN_CONTACT" | translate
}}</app-button>
</div> </div>
</div> </div>
</div> </div>
@@ -23,39 +29,39 @@
<div class="capabilities-bg"></div> <div class="capabilities-bg"></div>
<div class="container"> <div class="container">
<div class="section-head"> <div class="section-head">
<h2 class="section-title">{{ 'HOME.SEC_CAP_TITLE' | translate }}</h2> <h2 class="section-title">{{ "HOME.SEC_CAP_TITLE" | translate }}</h2>
<p class="section-subtitle"> <p class="section-subtitle">
{{ 'HOME.SEC_CAP_SUBTITLE' | translate }} {{ "HOME.SEC_CAP_SUBTITLE" | translate }}
</p> </p>
</div> </div>
<div class="cap-cards"> <div class="cap-cards">
<app-card> <app-card>
<div class="card-image-placeholder"> <div class="card-image-placeholder">
<img src="assets/images/home/prototipi.jpg" alt=""> <img src="assets/images/home/prototipi.jpg" alt="" />
</div> </div>
<h3>{{ 'HOME.CAP_1_TITLE' | translate }}</h3> <h3>{{ "HOME.CAP_1_TITLE" | translate }}</h3>
<p class="text-muted">{{ 'HOME.CAP_1_TEXT' | translate }}</p> <p class="text-muted">{{ "HOME.CAP_1_TEXT" | translate }}</p>
</app-card> </app-card>
<app-card> <app-card>
<div class="card-image-placeholder"> <div class="card-image-placeholder">
<img src="assets/images/home/original-vs-3dprinted.jpg" alt=""> <img src="assets/images/home/original-vs-3dprinted.jpg" alt="" />
</div> </div>
<h3>{{ 'HOME.CAP_2_TITLE' | translate }}</h3> <h3>{{ "HOME.CAP_2_TITLE" | translate }}</h3>
<p class="text-muted">{{ 'HOME.CAP_2_TEXT' | translate }}</p> <p class="text-muted">{{ "HOME.CAP_2_TEXT" | translate }}</p>
</app-card> </app-card>
<app-card> <app-card>
<div class="card-image-placeholder"> <div class="card-image-placeholder">
<img src="assets/images/home/serie.jpg" alt=""> <img src="assets/images/home/serie.jpg" alt="" />
</div> </div>
<h3>{{ 'HOME.CAP_3_TITLE' | translate }}</h3> <h3>{{ "HOME.CAP_3_TITLE" | translate }}</h3>
<p class="text-muted">{{ 'HOME.CAP_3_TEXT' | translate }}</p> <p class="text-muted">{{ "HOME.CAP_3_TEXT" | translate }}</p>
</app-card> </app-card>
<app-card> <app-card>
<div class="card-image-placeholder"> <div class="card-image-placeholder">
<img src="assets/images/home/cad.jpg" alt=""> <img src="assets/images/home/cad.jpg" alt="" />
</div> </div>
<h3>{{ 'HOME.CAP_4_TITLE' | translate }}</h3> <h3>{{ "HOME.CAP_4_TITLE" | translate }}</h3>
<p class="text-muted">{{ 'HOME.CAP_4_TEXT' | translate }}</p> <p class="text-muted">{{ "HOME.CAP_4_TEXT" | translate }}</p>
</app-card> </app-card>
</div> </div>
</div> </div>
@@ -64,30 +70,44 @@
<section class="section calculator"> <section class="section calculator">
<div class="container calculator-grid"> <div class="container calculator-grid">
<div class="calculator-copy"> <div class="calculator-copy">
<h2 class="section-title">{{ 'HOME.SEC_CALC_TITLE' | translate }}</h2> <h2 class="section-title">{{ "HOME.SEC_CALC_TITLE" | translate }}</h2>
<p class="section-subtitle"> <p class="section-subtitle">
{{ 'HOME.SEC_CALC_SUBTITLE' | translate }} {{ "HOME.SEC_CALC_SUBTITLE" | translate }}
</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>
</ul> </ul>
</div> </div>
<app-card class="quote-card"> <app-card class="quote-card">
<div class="quote-header"> <div class="quote-header">
<div> <div>
<p class="quote-eyebrow">{{ 'HOME.CARD_CALC_EYEBROW' | translate }}</p> <p class="quote-eyebrow">
<h3 class="quote-title">{{ 'HOME.CARD_CALC_TITLE' | translate }}</h3> {{ "HOME.CARD_CALC_EYEBROW" | translate }}
</p>
<h3 class="quote-title">
{{ "HOME.CARD_CALC_TITLE" | translate }}
</h3>
</div> </div>
<span class="quote-tag">{{ 'HOME.CARD_CALC_TAG' | translate }}</span> <span class="quote-tag">{{ "HOME.CARD_CALC_TAG" | translate }}</span>
</div> </div>
<ul class="quote-steps"> <ul class="quote-steps">
<li>{{ 'HOME.CARD_CALC_STEP_1' | translate }}</li> <li>{{ "HOME.CARD_CALC_STEP_1" | translate }}</li>
<li>{{ 'HOME.CARD_CALC_STEP_2' | translate }}</li> <li>{{ "HOME.CARD_CALC_STEP_2" | translate }}</li>
<li>{{ 'HOME.CARD_CALC_STEP_3' | translate }}</li> <li>{{ "HOME.CARD_CALC_STEP_3" | translate }}</li>
</ul> </ul>
<div class="quote-actions"> <div class="quote-actions">
<app-button variant="primary" [fullWidth]="true" routerLink="/calculator/basic">{{ 'HOME.BTN_OPEN_CALC' | translate }}</app-button> <app-button
<app-button variant="outline" [fullWidth]="true" routerLink="/contact">{{ 'HOME.BTN_CONTACT' | translate }}</app-button> variant="primary"
[fullWidth]="true"
routerLink="/calculator/basic"
>{{ "HOME.BTN_OPEN_CALC" | translate }}</app-button
>
<app-button
variant="outline"
[fullWidth]="true"
routerLink="/contact"
>{{ "HOME.BTN_CONTACT" | translate }}</app-button
>
</div> </div>
</app-card> </app-card>
</div> </div>
@@ -96,37 +116,48 @@
<section class="section shop"> <section class="section shop">
<div class="container split"> <div class="container split">
<div class="shop-copy"> <div class="shop-copy">
<h2 class="section-title">{{ 'HOME.SEC_SHOP_TITLE' | translate }}</h2> <h2 class="section-title">{{ "HOME.SEC_SHOP_TITLE" | translate }}</h2>
<p> <p>
{{ 'HOME.SEC_SHOP_TEXT' | translate }} {{ "HOME.SEC_SHOP_TEXT" | translate }}
</p> </p>
<ul class="shop-list"> <ul class="shop-list">
<li>{{ 'HOME.SEC_SHOP_LIST_1' | translate }}</li> <li>{{ "HOME.SEC_SHOP_LIST_1" | translate }}</li>
<li>{{ 'HOME.SEC_SHOP_LIST_2' | translate }}</li> <li>{{ "HOME.SEC_SHOP_LIST_2" | translate }}</li>
<li>{{ 'HOME.SEC_SHOP_LIST_3' | translate }}</li> <li>{{ "HOME.SEC_SHOP_LIST_3" | translate }}</li>
</ul> </ul>
<div class="shop-actions"> <div class="shop-actions">
<app-button variant="primary" routerLink="/shop">{{ 'HOME.BTN_DISCOVER' | translate }}</app-button> <app-button variant="primary" routerLink="/shop">{{
<app-button variant="outline" routerLink="/contact">{{ 'HOME.BTN_REQ_SOLUTION' | translate }}</app-button> "HOME.BTN_DISCOVER" | translate
}}</app-button>
<app-button variant="outline" routerLink="/contact">{{
"HOME.BTN_REQ_SOLUTION" | translate
}}</app-button>
</div> </div>
</div> </div>
<div class="shop-gallery" tabindex="0" [attr.aria-label]="'HOME.SHOP_GALLERY_ARIA' | translate"> <div
<figure class="shop-gallery-item" *ngFor="let image of shopGalleryImages"> class="shop-gallery"
<img [src]="image.src" [alt]="image.alt | translate"> tabindex="0"
[attr.aria-label]="'HOME.SHOP_GALLERY_ARIA' | translate"
>
<figure
class="shop-gallery-item"
*ngFor="let image of shopGalleryImages"
>
<img [src]="image.src" [alt]="image.alt | translate" />
</figure> </figure>
</div> </div>
<div class="shop-cards"> <div class="shop-cards">
<app-card> <app-card>
<h3>{{ 'HOME.CARD_SHOP_1_TITLE' | translate }}</h3> <h3>{{ "HOME.CARD_SHOP_1_TITLE" | translate }}</h3>
<p class="text-muted">{{ 'HOME.CARD_SHOP_1_TEXT' | translate }}</p> <p class="text-muted">{{ "HOME.CARD_SHOP_1_TEXT" | translate }}</p>
</app-card> </app-card>
<app-card> <app-card>
<h3>{{ 'HOME.CARD_SHOP_2_TITLE' | translate }}</h3> <h3>{{ "HOME.CARD_SHOP_2_TITLE" | translate }}</h3>
<p class="text-muted">{{ 'HOME.CARD_SHOP_2_TEXT' | translate }}</p> <p class="text-muted">{{ "HOME.CARD_SHOP_2_TEXT" | translate }}</p>
</app-card> </app-card>
<app-card> <app-card>
<h3>{{ 'HOME.CARD_SHOP_3_TITLE' | translate }}</h3> <h3>{{ "HOME.CARD_SHOP_3_TITLE" | translate }}</h3>
<p class="text-muted">{{ 'HOME.CARD_SHOP_3_TEXT' | translate }}</p> <p class="text-muted">{{ "HOME.CARD_SHOP_3_TEXT" | translate }}</p>
</app-card> </app-card>
</div> </div>
</div> </div>
@@ -135,13 +166,17 @@
<section class="section about"> <section class="section about">
<div class="container about-grid"> <div class="container about-grid">
<div class="about-copy"> <div class="about-copy">
<h2 class="section-title">{{ 'HOME.SEC_ABOUT_TITLE' | translate }}</h2> <h2 class="section-title">{{ "HOME.SEC_ABOUT_TITLE" | translate }}</h2>
<p> <p>
{{ 'HOME.SEC_ABOUT_TEXT' | translate }} {{ "HOME.SEC_ABOUT_TEXT" | translate }}
</p> </p>
<div class="about-actions"> <div class="about-actions">
<app-button variant="primary" routerLink="/about">{{ 'HOME.SEC_ABOUT_TITLE' | translate }}</app-button> <app-button variant="primary" routerLink="/about">{{
<app-button variant="outline" routerLink="/contact">{{ 'HOME.BTN_CONTACT' | translate }}</app-button> "HOME.SEC_ABOUT_TITLE" | translate
}}</app-button>
<app-button variant="outline" routerLink="/contact">{{
"HOME.BTN_CONTACT" | translate
}}</app-button>
</div> </div>
</div> </div>
<div class="about-media"> <div class="about-media">
@@ -152,7 +187,7 @@
[alt]="founderImages[founderImageIndex].alt | translate" [alt]="founderImages[founderImageIndex].alt | translate"
width="1200" width="1200"
height="900" height="900"
> />
<button <button
type="button" type="button"
class="founder-nav founder-nav-prev" class="founder-nav founder-nav-prev"

View File

@@ -1,4 +1,4 @@
@use '../../../styles/patterns'; @use "../../../styles/patterns";
.home-page { .home-page {
--home-bg: #faf9f6; --home-bg: #faf9f6;
@@ -13,7 +13,7 @@
background: var(--home-bg); background: var(--home-bg);
// Enhanced Grid Pattern // Enhanced Grid Pattern
&::after { &::after {
content: ''; content: "";
position: absolute; position: absolute;
inset: 0; inset: 0;
@include patterns.pattern-grid(var(--color-neutral-900), 40px, 1px); @include patterns.pattern-grid(var(--color-neutral-900), 40px, 1px);
@@ -26,13 +26,17 @@
// Keep the accent blob // Keep the accent blob
.hero::before { .hero::before {
content: ''; content: "";
position: absolute; position: absolute;
width: 420px; width: 420px;
height: 420px; height: 420px;
right: -120px; right: -120px;
top: -160px; top: -160px;
background: radial-gradient(circle at 30% 30%, rgba(0, 0, 0, 0.03), transparent 70%); background: radial-gradient(
circle at 30% 30%,
rgba(0, 0, 0, 0.03),
transparent 70%
);
opacity: 0.8; opacity: 0.8;
z-index: 0; z-index: 0;
animation: floatGlow 12s ease-in-out infinite; animation: floatGlow 12s ease-in-out infinite;
@@ -46,8 +50,12 @@
z-index: 1; z-index: 1;
} }
.hero-copy { animation: fadeUp 0.8s ease both; } .hero-copy {
.hero-panel { animation: fadeUp 0.8s ease 0.15s both; } animation: fadeUp 0.8s ease both;
}
.hero-panel {
animation: fadeUp 0.8s ease 0.15s both;
}
.eyebrow { .eyebrow {
text-transform: uppercase; text-transform: uppercase;
@@ -115,7 +123,7 @@
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.focus-list li::before { .focus-list li::before {
content: ''; content: "";
color: var(--color-brand); color: var(--color-brand);
margin-right: var(--space-2); margin-right: var(--space-2);
} }
@@ -138,7 +146,10 @@
color: var(--color-secondary-600); color: var(--color-secondary-600);
margin: 0 0 var(--space-2); margin: 0 0 var(--space-2);
} }
.quote-title { margin: 0; font-size: 1.35rem; } .quote-title {
margin: 0;
font-size: 1.35rem;
}
.quote-tag { .quote-tag {
background: var(--color-neutral-100); background: var(--color-neutral-100);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -163,7 +174,7 @@
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.quote-steps li::before { .quote-steps li::before {
content: ''; content: "";
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
@@ -186,8 +197,13 @@
color: var(--color-secondary-600); color: var(--color-secondary-600);
margin-bottom: var(--space-1); margin-bottom: var(--space-1);
} }
.meta-value { font-weight: 600; } .meta-value {
.quote-actions { display: grid; gap: var(--space-3); } font-weight: 600;
}
.quote-actions {
display: grid;
gap: var(--space-3);
}
.capabilities { .capabilities {
position: relative; position: relative;
@@ -198,11 +214,24 @@
display: none; display: none;
} }
.section { padding: 5.5rem 0; position: relative; } .section {
.section-head { margin-bottom: var(--space-8); } padding: 5.5rem 0;
.section-title { font-size: clamp(2rem, 1.8vw + 1.2rem, 2.8rem); margin-bottom: var(--space-3); } position: relative;
.section-subtitle { color: var(--color-text-muted); max-width: 620px; } }
.text-muted { color: var(--color-text-muted); } .section-head {
margin-bottom: var(--space-8);
}
.section-title {
font-size: clamp(2rem, 1.8vw + 1.2rem, 2.8rem);
margin-bottom: var(--space-3);
}
.section-subtitle {
color: var(--color-text-muted);
max-width: 620px;
}
.text-muted {
color: var(--color-text-muted);
}
.calculator { .calculator {
position: relative; position: relative;
@@ -253,7 +282,9 @@
background: var(--home-bg); background: var(--home-bg);
position: relative; position: relative;
} }
.shop .split { align-items: start; } .shop .split {
align-items: start;
}
.shop-copy { .shop-copy {
max-width: 760px; max-width: 760px;
} }
@@ -386,8 +417,12 @@
background: rgba(17, 24, 39, 0.7); background: rgba(17, 24, 39, 0.7);
} }
.founder-nav-prev { left: 0.75rem; } .founder-nav-prev {
.founder-nav-next { right: 0.75rem; } left: 0.75rem;
}
.founder-nav-next {
right: 0.75rem;
}
.founder-nav:focus-visible { .founder-nav:focus-visible {
outline: 2px solid var(--color-brand); outline: 2px solid var(--color-brand);
@@ -403,22 +438,41 @@
} }
@media (min-width: 960px) { @media (min-width: 960px) {
.hero-grid { grid-template-columns: 1.1fr 0.9fr; } .hero-grid {
.calculator-grid { grid-template-columns: 1.1fr 0.9fr; } grid-template-columns: 1.1fr 0.9fr;
.calculator-grid { grid-template-columns: 1.1fr 0.9fr; } }
.split { grid-template-columns: 1.1fr 0.9fr; } .calculator-grid {
.shop-copy { grid-column: 1; } grid-template-columns: 1.1fr 0.9fr;
.shop-gallery { grid-column: 2; } }
.calculator-grid {
grid-template-columns: 1.1fr 0.9fr;
}
.split {
grid-template-columns: 1.1fr 0.9fr;
}
.shop-copy {
grid-column: 1;
}
.shop-gallery {
grid-column: 2;
}
.shop-cards { .shop-cards {
grid-column: 1 / -1; grid-column: 1 / -1;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.about-grid { grid-template-columns: 1.1fr 0.9fr; } .about-grid {
grid-template-columns: 1.1fr 0.9fr;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.hero-actions { flex-direction: column; align-items: stretch; } .hero-actions {
.quote-meta { grid-template-columns: 1fr; } flex-direction: column;
align-items: stretch;
}
.quote-meta {
grid-template-columns: 1fr;
}
.shop-gallery { .shop-gallery {
width: 100%; width: 100%;
max-width: none; max-width: none;
@@ -427,7 +481,9 @@
.shop-gallery-item { .shop-gallery-item {
aspect-ratio: 16 / 11; aspect-ratio: 16 / 11;
} }
.shop-cards { grid-template-columns: 1fr; } .shop-cards {
grid-template-columns: 1fr;
}
.about-media { .about-media {
justify-content: flex-start; justify-content: flex-start;
} }
@@ -443,15 +499,31 @@
} }
@keyframes fadeUp { @keyframes fadeUp {
from { opacity: 0; transform: translateY(18px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
@keyframes floatGlow { @keyframes floatGlow {
0%, 100% { transform: translateY(0); } 0%,
50% { transform: translateY(20px); } 100% {
transform: translateY(0);
}
50% {
transform: translateY(20px);
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.hero-copy, .hero-panel { animation: none; } .hero-copy,
.hero::before { animation: none; } .hero-panel {
animation: none;
}
.hero::before {
animation: none;
}
} }

View File

@@ -8,27 +8,33 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
@Component({ @Component({
selector: 'app-home-page', selector: 'app-home-page',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent, AppCardComponent], imports: [
CommonModule,
RouterLink,
TranslateModule,
AppButtonComponent,
AppCardComponent,
],
templateUrl: './home.component.html', templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'] styleUrls: ['./home.component.scss'],
}) })
export class HomeComponent { export class HomeComponent {
readonly shopGalleryImages = [ readonly shopGalleryImages = [
{ {
src: 'assets/images/home/supporto-bici.jpg', src: 'assets/images/home/supporto-bici.jpg',
alt: 'HOME.SHOP_IMAGE_ALT_1' alt: 'HOME.SHOP_IMAGE_ALT_1',
} },
]; ];
readonly founderImages = [ readonly founderImages = [
{ {
src: 'assets/images/home/da-cambiare.jpg', src: 'assets/images/home/da-cambiare.jpg',
alt: 'HOME.FOUNDER_IMAGE_ALT_1' alt: 'HOME.FOUNDER_IMAGE_ALT_1',
}, },
{ {
src: 'assets/images/home/vino.JPG', src: 'assets/images/home/vino.JPG',
alt: 'HOME.FOUNDER_IMAGE_ALT_2' alt: 'HOME.FOUNDER_IMAGE_ALT_2',
} },
]; ];
founderImageIndex = 0; founderImageIndex = 0;

View File

@@ -3,10 +3,12 @@ import { Routes } from '@angular/router';
export const LEGAL_ROUTES: Routes = [ export const LEGAL_ROUTES: Routes = [
{ {
path: 'privacy', path: 'privacy',
loadComponent: () => import('./privacy/privacy.component').then(m => m.PrivacyComponent) loadComponent: () =>
import('./privacy/privacy.component').then((m) => m.PrivacyComponent),
}, },
{ {
path: 'terms', path: 'terms',
loadComponent: () => import('./terms/terms.component').then(m => m.TermsComponent) loadComponent: () =>
} import('./terms/terms.component').then((m) => m.TermsComponent),
},
]; ];

View File

@@ -1,38 +1,39 @@
<section class="legal-page"> <section class="legal-page">
<div class="container narrow"> <div class="container narrow">
<h1>{{ 'LEGAL.PRIVACY_TITLE' | translate }}</h1> <h1>{{ "LEGAL.PRIVACY_TITLE" | translate }}</h1>
<div class="content"> <div class="content">
<p class="intro"> <p class="intro">
{{ 'LEGAL.LAST_UPDATE' | translate }}: {{ 'LEGAL.PRIVACY_UPDATE_DATE' | translate }} {{ "LEGAL.LAST_UPDATE" | translate }}:
{{ "LEGAL.PRIVACY_UPDATE_DATE" | translate }}
</p> </p>
<p>{{ 'LEGAL.PRIVACY.META.CONTROLLER' | translate }}</p> <p>{{ "LEGAL.PRIVACY.META.CONTROLLER" | translate }}</p>
<p>{{ 'LEGAL.PRIVACY.META.CONTACT' | translate }}</p> <p>{{ "LEGAL.PRIVACY.META.CONTACT" | translate }}</p>
<h2>{{ 'LEGAL.PRIVACY.S1.TITLE' | translate }}</h2> <h2>{{ "LEGAL.PRIVACY.S1.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.PRIVACY.S1.P1' | translate }}</p> <p>{{ "LEGAL.PRIVACY.S1.P1" | translate }}</p>
<p>{{ 'LEGAL.PRIVACY.S1.P2' | translate }}</p> <p>{{ "LEGAL.PRIVACY.S1.P2" | translate }}</p>
<h2>{{ 'LEGAL.PRIVACY.S2.TITLE' | translate }}</h2> <h2>{{ "LEGAL.PRIVACY.S2.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.PRIVACY.S2.P1' | translate }}</p> <p>{{ "LEGAL.PRIVACY.S2.P1" | translate }}</p>
<p>{{ 'LEGAL.PRIVACY.S2.P2' | translate }}</p> <p>{{ "LEGAL.PRIVACY.S2.P2" | translate }}</p>
<h2>{{ 'LEGAL.PRIVACY.S3.TITLE' | translate }}</h2> <h2>{{ "LEGAL.PRIVACY.S3.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.PRIVACY.S3.P1' | translate }}</p> <p>{{ "LEGAL.PRIVACY.S3.P1" | translate }}</p>
<p>{{ 'LEGAL.PRIVACY.S3.P2' | translate }}</p> <p>{{ "LEGAL.PRIVACY.S3.P2" | translate }}</p>
<p>{{ 'LEGAL.PRIVACY.S3.P3' | translate }}</p> <p>{{ "LEGAL.PRIVACY.S3.P3" | translate }}</p>
<h2>{{ 'LEGAL.PRIVACY.S4.TITLE' | translate }}</h2> <h2>{{ "LEGAL.PRIVACY.S4.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.PRIVACY.S4.P1' | translate }}</p> <p>{{ "LEGAL.PRIVACY.S4.P1" | translate }}</p>
<p>{{ 'LEGAL.PRIVACY.S4.P2' | translate }}</p> <p>{{ "LEGAL.PRIVACY.S4.P2" | translate }}</p>
<h2>{{ 'LEGAL.PRIVACY.S5.TITLE' | translate }}</h2> <h2>{{ "LEGAL.PRIVACY.S5.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.PRIVACY.S5.P1' | translate }}</p> <p>{{ "LEGAL.PRIVACY.S5.P1" | translate }}</p>
<p>{{ 'LEGAL.PRIVACY.S5.P2' | translate }}</p> <p>{{ "LEGAL.PRIVACY.S5.P2" | translate }}</p>
<h2>{{ 'LEGAL.PRIVACY.S6.TITLE' | translate }}</h2> <h2>{{ "LEGAL.PRIVACY.S6.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.PRIVACY.S6.P1' | translate }}</p> <p>{{ "LEGAL.PRIVACY.S6.P1" | translate }}</p>
<p>{{ 'LEGAL.PRIVACY.S6.P2' | translate }}</p> <p>{{ "LEGAL.PRIVACY.S6.P2" | translate }}</p>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -6,6 +6,6 @@ import { TranslateModule } from '@ngx-translate/core';
standalone: true, standalone: true,
imports: [TranslateModule], imports: [TranslateModule],
templateUrl: './privacy.component.html', templateUrl: './privacy.component.html',
styleUrl: './privacy.component.scss' styleUrl: './privacy.component.scss',
}) })
export class PrivacyComponent {} export class PrivacyComponent {}

View File

@@ -1,100 +1,101 @@
<section class="legal-page"> <section class="legal-page">
<div class="container narrow"> <div class="container narrow">
<h1>{{ 'LEGAL.TERMS_TITLE' | translate }}</h1> <h1>{{ "LEGAL.TERMS_TITLE" | translate }}</h1>
<div class="content"> <div class="content">
<p class="intro"> <p class="intro">
{{ 'LEGAL.LAST_UPDATE' | translate }}: {{ 'LEGAL.TERMS_UPDATE_DATE' | translate }} {{ "LEGAL.LAST_UPDATE" | translate }}:
{{ "LEGAL.TERMS_UPDATE_DATE" | translate }}
</p> </p>
<p>{{ 'LEGAL.TERMS.META.PROVIDER' | translate }}</p> <p>{{ "LEGAL.TERMS.META.PROVIDER" | translate }}</p>
<p>{{ 'LEGAL.TERMS.META.VERSION' | translate }}</p> <p>{{ "LEGAL.TERMS.META.VERSION" | translate }}</p>
<p>{{ 'LEGAL.TERMS.META.SCOPE' | translate }}</p> <p>{{ "LEGAL.TERMS.META.SCOPE" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S1.TITLE' | translate }}</h2> <h2>{{ "LEGAL.TERMS.S1.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S1.P1' | translate }}</p> <p>{{ "LEGAL.TERMS.S1.P1" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S1.P2' | translate }}</p> <p>{{ "LEGAL.TERMS.S1.P2" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S1.P3' | translate }}</p> <p>{{ "LEGAL.TERMS.S1.P3" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S2.TITLE' | translate }}</h2> <h2>{{ "LEGAL.TERMS.S2.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S2.P1' | translate }}</p> <p>{{ "LEGAL.TERMS.S2.P1" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S2.P2' | translate }}</p> <p>{{ "LEGAL.TERMS.S2.P2" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S2.P3' | translate }}</p> <p>{{ "LEGAL.TERMS.S2.P3" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S3.TITLE' | translate }}</h2> <h2>{{ "LEGAL.TERMS.S3.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S3.P1' | translate }}</p> <p>{{ "LEGAL.TERMS.S3.P1" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S4.TITLE' | translate }}</h2> <h2>{{ "LEGAL.TERMS.S4.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S4.P1' | translate }}</p> <p>{{ "LEGAL.TERMS.S4.P1" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S4.P2' | translate }}</p> <p>{{ "LEGAL.TERMS.S4.P2" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S4.P3' | translate }}</p> <p>{{ "LEGAL.TERMS.S4.P3" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S5.TITLE' | translate }}</h2> <h2>{{ "LEGAL.TERMS.S5.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S5.P1' | translate }}</p> <p>{{ "LEGAL.TERMS.S5.P1" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S5.P2' | translate }}</p> <p>{{ "LEGAL.TERMS.S5.P2" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S5.P3' | translate }}</p> <p>{{ "LEGAL.TERMS.S5.P3" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S6.TITLE' | translate }}</h2> <h2>{{ "LEGAL.TERMS.S6.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S6.P1' | translate }}</p> <p>{{ "LEGAL.TERMS.S6.P1" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S6.P2' | translate }}</p> <p>{{ "LEGAL.TERMS.S6.P2" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S6.P3' | translate }}</p> <p>{{ "LEGAL.TERMS.S6.P3" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S6.P4' | translate }}</p> <p>{{ "LEGAL.TERMS.S6.P4" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S7.TITLE' | translate }}</h2> <h2>{{ "LEGAL.TERMS.S7.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S7.P1' | translate }}</p> <p>{{ "LEGAL.TERMS.S7.P1" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S7.P2' | translate }}</p> <p>{{ "LEGAL.TERMS.S7.P2" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S7.P3' | translate }}</p> <p>{{ "LEGAL.TERMS.S7.P3" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S8.TITLE' | translate }}</h2> <h2>{{ "LEGAL.TERMS.S8.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S8.P1' | translate }}</p> <p>{{ "LEGAL.TERMS.S8.P1" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S8.P2' | translate }}</p> <p>{{ "LEGAL.TERMS.S8.P2" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S8.P3' | translate }}</p> <p>{{ "LEGAL.TERMS.S8.P3" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S9.TITLE' | translate }}</h2> <h2>{{ "LEGAL.TERMS.S9.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S9.P1' | translate }}</p> <p>{{ "LEGAL.TERMS.S9.P1" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S9.P2' | translate }}</p> <p>{{ "LEGAL.TERMS.S9.P2" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S10.TITLE' | translate }}</h2> <h2>{{ "LEGAL.TERMS.S10.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S10.P1' | translate }}</p> <p>{{ "LEGAL.TERMS.S10.P1" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S10.P2' | translate }}</p> <p>{{ "LEGAL.TERMS.S10.P2" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S10.P3' | translate }}</p> <p>{{ "LEGAL.TERMS.S10.P3" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S11.TITLE' | translate }}</h2> <h2>{{ "LEGAL.TERMS.S11.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S11.P1' | translate }}</p> <p>{{ "LEGAL.TERMS.S11.P1" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S11.P2' | translate }}</p> <p>{{ "LEGAL.TERMS.S11.P2" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S11.P3' | translate }}</p> <p>{{ "LEGAL.TERMS.S11.P3" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S11.P4' | translate }}</p> <p>{{ "LEGAL.TERMS.S11.P4" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S12.TITLE' | translate }}</h2> <h2>{{ "LEGAL.TERMS.S12.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S12.P1' | translate }}</p> <p>{{ "LEGAL.TERMS.S12.P1" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S12.P2' | translate }}</p> <p>{{ "LEGAL.TERMS.S12.P2" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S12.P3' | translate }}</p> <p>{{ "LEGAL.TERMS.S12.P3" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S12.P4' | translate }}</p> <p>{{ "LEGAL.TERMS.S12.P4" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S13.TITLE' | translate }}</h2> <h2>{{ "LEGAL.TERMS.S13.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S13.P1' | translate }}</p> <p>{{ "LEGAL.TERMS.S13.P1" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S13.P2' | translate }}</p> <p>{{ "LEGAL.TERMS.S13.P2" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S14.TITLE' | translate }}</h2> <h2>{{ "LEGAL.TERMS.S14.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S14.P1' | translate }}</p> <p>{{ "LEGAL.TERMS.S14.P1" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S14.P2' | translate }}</p> <p>{{ "LEGAL.TERMS.S14.P2" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S14.P3' | translate }}</p> <p>{{ "LEGAL.TERMS.S14.P3" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S15.TITLE' | translate }}</h2> <h2>{{ "LEGAL.TERMS.S15.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S15.P1' | translate }}</p> <p>{{ "LEGAL.TERMS.S15.P1" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S15.P2' | translate }}</p> <p>{{ "LEGAL.TERMS.S15.P2" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S16.TITLE' | translate }}</h2> <h2>{{ "LEGAL.TERMS.S16.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S16.P1' | translate }}</p> <p>{{ "LEGAL.TERMS.S16.P1" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S16.P2' | translate }}</p> <p>{{ "LEGAL.TERMS.S16.P2" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S16.P3' | translate }}</p> <p>{{ "LEGAL.TERMS.S16.P3" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S17.TITLE' | translate }}</h2> <h2>{{ "LEGAL.TERMS.S17.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S17.P1' | translate }}</p> <p>{{ "LEGAL.TERMS.S17.P1" | translate }}</p>
<h2>{{ 'LEGAL.TERMS.S18.TITLE' | translate }}</h2> <h2>{{ "LEGAL.TERMS.S18.TITLE" | translate }}</h2>
<p>{{ 'LEGAL.TERMS.S18.P1' | translate }}</p> <p>{{ "LEGAL.TERMS.S18.P1" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S18.P2' | translate }}</p> <p>{{ "LEGAL.TERMS.S18.P2" | translate }}</p>
<p>{{ 'LEGAL.TERMS.S18.P3' | translate }}</p> <p>{{ "LEGAL.TERMS.S18.P3" | translate }}</p>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -6,6 +6,6 @@ import { TranslateModule } from '@ngx-translate/core';
standalone: true, standalone: true,
imports: [TranslateModule], imports: [TranslateModule],
templateUrl: './terms.component.html', templateUrl: './terms.component.html',
styleUrl: './terms.component.scss' styleUrl: './terms.component.scss',
}) })
export class TermsComponent {} export class TermsComponent {}

View File

@@ -1,47 +1,71 @@
<div class="container hero"> <div class="container hero">
<h1> <h1>
{{ 'TRACKING.TITLE' | translate }} {{ "TRACKING.TITLE" | translate }}
<ng-container *ngIf="order()"> <ng-container *ngIf="order()">
<br/><span class="order-id-title">#{{ getDisplayOrderNumber(order()) }}</span> <br /><span class="order-id-title"
>#{{ getDisplayOrderNumber(order()) }}</span
>
</ng-container> </ng-container>
</h1> </h1>
<p class="subtitle">{{ 'TRACKING.SUBTITLE' | translate }}</p> <p class="subtitle">{{ "TRACKING.SUBTITLE" | translate }}</p>
</div> </div>
<div class="container"> <div class="container">
<ng-container *ngIf="order() as o"> <ng-container *ngIf="order() as o">
<div class="status-timeline mb-6"> <div class="status-timeline mb-6">
<div class="timeline-step" <div
[class.active]="o.status === 'PENDING_PAYMENT' && o.paymentStatus !== 'REPORTED'" class="timeline-step"
[class.completed]="o.paymentStatus === 'REPORTED' || o.status !== 'PENDING_PAYMENT'"> [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="circle">1</div>
<div class="label">{{ 'TRACKING.STEP_PENDING' | translate }}</div> <div class="label">{{ "TRACKING.STEP_PENDING" | translate }}</div>
</div> </div>
<div class="timeline-step" <div
[class.active]="o.paymentStatus === 'REPORTED' && o.status === 'PENDING_PAYMENT'" class="timeline-step"
[class.completed]="o.status === 'PAID' || o.status === 'IN_PRODUCTION' || o.status === 'SHIPPED' || o.status === 'COMPLETED'"> [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="circle">2</div>
<div class="label">{{ 'TRACKING.STEP_REPORTED' | translate }}</div> <div class="label">{{ "TRACKING.STEP_REPORTED" | translate }}</div>
</div> </div>
<div class="timeline-step" <div
class="timeline-step"
[class.active]="o.status === 'PAID' || o.status === 'IN_PRODUCTION'" [class.active]="o.status === 'PAID' || o.status === 'IN_PRODUCTION'"
[class.completed]="o.status === 'SHIPPED' || o.status === 'COMPLETED'"> [class.completed]="o.status === 'SHIPPED' || o.status === 'COMPLETED'"
>
<div class="circle">3</div> <div class="circle">3</div>
<div class="label">{{ 'TRACKING.STEP_PRODUCTION' | translate }}</div> <div class="label">{{ "TRACKING.STEP_PRODUCTION" | translate }}</div>
</div> </div>
<div class="timeline-step" <div
class="timeline-step"
[class.active]="o.status === 'SHIPPED'" [class.active]="o.status === 'SHIPPED'"
[class.completed]="o.status === 'COMPLETED'"> [class.completed]="o.status === 'COMPLETED'"
>
<div class="circle">4</div> <div class="circle">4</div>
<div class="label">{{ 'TRACKING.STEP_SHIPPED' | translate }}</div> <div class="label">{{ "TRACKING.STEP_SHIPPED" | translate }}</div>
</div> </div>
</div> </div>
<ng-container *ngIf="o.status === 'PENDING_PAYMENT'"> <ng-container *ngIf="o.status === 'PENDING_PAYMENT'">
<app-card class="mb-6 status-reported-card" *ngIf="o.paymentStatus === 'REPORTED'"> <app-card
class="mb-6 status-reported-card"
*ngIf="o.paymentStatus === 'REPORTED'"
>
<div class="status-content text-center"> <div class="status-content text-center">
<h3>{{ 'PAYMENT.STATUS_REPORTED_TITLE' | translate }}</h3> <h3>{{ "PAYMENT.STATUS_REPORTED_TITLE" | translate }}</h3>
<p>{{ 'PAYMENT.STATUS_REPORTED_DESC' | translate }}</p> <p>{{ "PAYMENT.STATUS_REPORTED_DESC" | translate }}</p>
</div> </div>
</app-card> </app-card>
@@ -49,23 +73,38 @@
<div class="payment-main"> <div class="payment-main">
<app-card class="mb-6"> <app-card class="mb-6">
<div class="card-header-simple"> <div class="card-header-simple">
<h3>{{ 'PAYMENT.METHOD' | translate }}</h3> <h3>{{ "PAYMENT.METHOD" | translate }}</h3>
</div> </div>
<div class="payment-selection"> <div class="payment-selection">
<div class="methods-grid"> <div class="methods-grid">
<div class="type-option" [class.selected]="selectedPaymentMethod === 'twint'" (click)="selectPayment('twint')"> <div
<span class="method-name">{{ 'PAYMENT.METHOD_TWINT' | translate }}</span> class="type-option"
[class.selected]="selectedPaymentMethod === 'twint'"
(click)="selectPayment('twint')"
>
<span class="method-name">{{
"PAYMENT.METHOD_TWINT" | translate
}}</span>
</div> </div>
<div class="type-option" [class.selected]="selectedPaymentMethod === 'bill'" (click)="selectPayment('bill')"> <div
<span class="method-name">{{ 'PAYMENT.METHOD_BANK' | translate }}</span> class="type-option"
[class.selected]="selectedPaymentMethod === 'bill'"
(click)="selectPayment('bill')"
>
<span class="method-name">{{
"PAYMENT.METHOD_BANK" | translate
}}</span>
</div> </div>
</div> </div>
</div> </div>
<div class="payment-details fade-in text-center" *ngIf="selectedPaymentMethod === 'twint'"> <div
class="payment-details fade-in text-center"
*ngIf="selectedPaymentMethod === 'twint'"
>
<div class="details-header"> <div class="details-header">
<h4>{{ 'PAYMENT.TWINT_TITLE' | translate }}</h4> <h4>{{ "PAYMENT.TWINT_TITLE" | translate }}</h4>
</div> </div>
<div class="qr-placeholder"> <div class="qr-placeholder">
<img <img
@@ -73,46 +112,75 @@
class="twint-qr" class="twint-qr"
[src]="getTwintQrUrl()" [src]="getTwintQrUrl()"
(error)="onTwintQrError()" (error)="onTwintQrError()"
[attr.alt]="'PAYMENT.TWINT_QR_ALT' | translate" /> [attr.alt]="'PAYMENT.TWINT_QR_ALT' | translate"
<p>{{ 'PAYMENT.TWINT_DESC' | translate }}</p> />
<p class="billing-hint">{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}</p> <p>{{ "PAYMENT.TWINT_DESC" | translate }}</p>
<p class="billing-hint">
{{ "PAYMENT.BILLING_INFO_HINT" | translate }}
</p>
<div class="twint-mobile-action twint-button-container"> <div class="twint-mobile-action twint-button-container">
<button style="width: auto; height: 58px; <button
style="
width: auto;
height: 58px;
border-radius: 6px; border-radius: 6px;
display: flex; display: flex;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
background-color: transparent; background-color: transparent;
border: none; border: none;
align-items: center;" (click)="openTwintPayment()"> align-items: center;
"
(click)="openTwintPayment()"
>
<img <img
style="width: auto; height: 58px" style="width: auto; height: 58px"
[attr.alt]="'PAYMENT.TWINT_BUTTON_ALT' | translate" [attr.alt]="'PAYMENT.TWINT_BUTTON_ALT' | translate"
[src]="getTwintButtonImageUrl()"/> [src]="getTwintButtonImageUrl()"
/>
</button> </button>
</div> </div>
<p class="amount">{{ 'PAYMENT.TOTAL' | translate }}: {{ o.totalChf | currency:'CHF' }}</p> <p class="amount">
{{ "PAYMENT.TOTAL" | translate }}:
{{ o.totalChf | currency: "CHF" }}
</p>
</div> </div>
</div> </div>
<div class="payment-details fade-in text-center" *ngIf="selectedPaymentMethod === 'bill'"> <div
class="payment-details fade-in text-center"
*ngIf="selectedPaymentMethod === 'bill'"
>
<div class="details-header"> <div class="details-header">
<h4>{{ 'PAYMENT.BANK_TITLE' | translate }}</h4> <h4>{{ "PAYMENT.BANK_TITLE" | translate }}</h4>
</div> </div>
<div class="bank-details"> <div class="bank-details">
<p class="billing-hint">{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}</p> <p class="billing-hint">
<br> {{ "PAYMENT.BILLING_INFO_HINT" | translate }}
</p>
<br />
<div class="qr-bill-actions"> <div class="qr-bill-actions">
<app-button (click)="downloadQrInvoice()"> <app-button (click)="downloadQrInvoice()">
{{ 'PAYMENT.DOWNLOAD_QR' | translate }} {{ "PAYMENT.DOWNLOAD_QR" | translate }}
</app-button> </app-button>
</div> </div>
</div> </div>
</div> </div>
<div class="actions"> <div class="actions">
<app-button variant="outline" (click)="completeOrder()" [disabled]="!selectedPaymentMethod || o.paymentStatus === 'REPORTED'" [fullWidth]="true"> <app-button
{{ o.paymentStatus === 'REPORTED' ? ('PAYMENT.IN_VERIFICATION' | translate) : ('PAYMENT.CONFIRM' | translate) }} variant="outline"
(click)="completeOrder()"
[disabled]="
!selectedPaymentMethod || o.paymentStatus === 'REPORTED'
"
[fullWidth]="true"
>
{{
o.paymentStatus === "REPORTED"
? ("PAYMENT.IN_VERIFICATION" | translate)
: ("PAYMENT.CONFIRM" | translate)
}}
</app-button> </app-button>
</div> </div>
</app-card> </app-card>
@@ -121,29 +189,28 @@ align-items: center;" (click)="openTwintPayment()">
<div class="payment-summary"> <div class="payment-summary">
<app-card class="sticky-card"> <app-card class="sticky-card">
<div class="card-header-simple"> <div class="card-header-simple">
<h3>{{ 'PAYMENT.SUMMARY_TITLE' | translate }}</h3> <h3>{{ "PAYMENT.SUMMARY_TITLE" | translate }}</h3>
<p class="order-id">#{{ getDisplayOrderNumber(o) }}</p> <p class="order-id">#{{ getDisplayOrderNumber(o) }}</p>
</div> </div>
<div class="summary-totals"> <div class="summary-totals">
<div class="total-row"> <div class="total-row">
<span>{{ 'PAYMENT.SUBTOTAL' | translate }}</span> <span>{{ "PAYMENT.SUBTOTAL" | translate }}</span>
<span>{{ o.subtotalChf | currency:'CHF' }}</span> <span>{{ o.subtotalChf | currency: "CHF" }}</span>
</div> </div>
<div class="total-row"> <div class="total-row">
<span>{{ 'PAYMENT.SHIPPING' | translate }}</span> <span>{{ "PAYMENT.SHIPPING" | translate }}</span>
<span>{{ o.shippingCostChf | currency:'CHF' }}</span> <span>{{ o.shippingCostChf | currency: "CHF" }}</span>
</div> </div>
<div class="total-row"> <div class="total-row">
<span>{{ 'PAYMENT.SETUP_FEE' | translate }}</span> <span>{{ "PAYMENT.SETUP_FEE" | translate }}</span>
<span>{{ o.setupCostChf | currency:'CHF' }}</span> <span>{{ o.setupCostChf | currency: "CHF" }}</span>
</div> </div>
<div class="grand-total-row"> <div class="grand-total-row">
<span>{{ 'PAYMENT.TOTAL' | translate }}</span> <span>{{ "PAYMENT.TOTAL" | translate }}</span>
<span>{{ o.totalChf | currency:'CHF' }}</span> <span>{{ o.totalChf | currency: "CHF" }}</span>
</div> </div>
</div> </div>
</app-card> </app-card>
</div> </div>
</div> </div>
@@ -152,7 +219,7 @@ align-items: center;" (click)="openTwintPayment()">
<div *ngIf="loading()" class="loading-state"> <div *ngIf="loading()" class="loading-state">
<app-card> <app-card>
<p>{{ 'PAYMENT.LOADING' | translate }}</p> <p>{{ "PAYMENT.LOADING" | translate }}</p>
</app-card> </app-card>
</div> </div>

View File

@@ -127,7 +127,6 @@
} }
} }
.qr-placeholder { .qr-placeholder {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -229,7 +228,9 @@
} }
} }
.mb-6 { margin-bottom: var(--space-6); } .mb-6 {
margin-bottom: var(--space-6);
}
.error-message, .error-message,
.loading-state { .loading-state {
@@ -245,7 +246,7 @@
/* padding: var(--space-6); */ /* Removed if it was here to match non-card layout */ /* padding: var(--space-6); */ /* Removed if it was here to match non-card layout */
&::before { &::before {
content: ''; content: "";
position: absolute; position: absolute;
top: 15px; top: 15px;
left: 12.5%; left: 12.5%;

View File

@@ -10,9 +10,14 @@ import { environment } from '../../../environments/environment';
@Component({ @Component({
selector: 'app-order', selector: 'app-order',
standalone: true, standalone: true,
imports: [CommonModule, AppButtonComponent, AppCardComponent, TranslateModule], imports: [
CommonModule,
AppButtonComponent,
AppCardComponent,
TranslateModule,
],
templateUrl: './order.component.html', templateUrl: './order.component.html',
styleUrl: './order.component.scss' styleUrl: './order.component.scss',
}) })
export class OrderComponent implements OnInit { export class OrderComponent implements OnInit {
private route = inject(ActivatedRoute); private route = inject(ActivatedRoute);
@@ -50,7 +55,7 @@ export class OrderComponent implements OnInit {
console.error('Failed to load order', err); console.error('Failed to load order', err);
this.error.set('ORDER.ERR_LOAD_ORDER'); this.error.set('ORDER.ERR_LOAD_ORDER');
this.loading.set(false); this.loading.set(false);
} },
}); });
} }
@@ -72,7 +77,7 @@ export class OrderComponent implements OnInit {
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
}, },
error: (err) => console.error('Failed to download QR invoice', err) error: (err) => console.error('Failed to download QR invoice', err),
}); });
} }
@@ -80,14 +85,18 @@ export class OrderComponent implements OnInit {
if (!this.orderId) return; if (!this.orderId) return;
this.quoteService.getTwintPayment(this.orderId).subscribe({ this.quoteService.getTwintPayment(this.orderId).subscribe({
next: (res) => { next: (res) => {
const qrPath = typeof res.qrImageUrl === 'string' ? `${res.qrImageUrl}?size=360` : null; const qrPath =
const qrDataUri = typeof res.qrImageDataUri === 'string' ? res.qrImageDataUri : null; typeof res.qrImageUrl === 'string'
? `${res.qrImageUrl}?size=360`
: null;
const qrDataUri =
typeof res.qrImageDataUri === 'string' ? res.qrImageDataUri : null;
this.twintOpenUrl.set(this.resolveApiUrl(res.openUrl)); this.twintOpenUrl.set(this.resolveApiUrl(res.openUrl));
this.twintQrUrl.set(qrDataUri ?? this.resolveApiUrl(qrPath)); this.twintQrUrl.set(qrDataUri ?? this.resolveApiUrl(qrPath));
}, },
error: (err) => { error: (err) => {
console.error('Failed to load TWINT payment details', err); console.error('Failed to load TWINT payment details', err);
} },
}); });
} }
@@ -136,7 +145,9 @@ export class OrderComponent implements OnInit {
return; return;
} }
this.quoteService.reportPayment(this.orderId, this.selectedPaymentMethod).subscribe({ this.quoteService
.reportPayment(this.orderId, this.selectedPaymentMethod)
.subscribe({
next: (order) => { next: (order) => {
this.order.set(order); this.order.set(order);
// The UI will re-render and show the 'REPORTED' state. // The UI will re-render and show the 'REPORTED' state.
@@ -146,7 +157,7 @@ export class OrderComponent implements OnInit {
error: (err) => { error: (err) => {
console.error('Failed to report payment', err); console.error('Failed to report payment', err);
this.error.set('ORDER.ERR_REPORT_PAYMENT'); this.error.set('ORDER.ERR_REPORT_PAYMENT');
} },
}); });
} }

View File

@@ -3,11 +3,15 @@
<div class="content"> <div class="content">
<span class="category">{{ product().category | translate }}</span> <span class="category">{{ product().category | translate }}</span>
<h3 class="name"> <h3 class="name">
<a [routerLink]="['/shop', product().id]">{{ product().name | translate }}</a> <a [routerLink]="['/shop', product().id]">{{
product().name | translate
}}</a>
</h3> </h3>
<div class="footer"> <div class="footer">
<span class="price">{{ product().price | currency:'EUR' }}</span> <span class="price">{{ product().price | currency: "EUR" }}</span>
<a [routerLink]="['/shop', product().id]" class="view-btn">{{ 'SHOP.DETAILS' | translate }}</a> <a [routerLink]="['/shop', product().id]" class="view-btn">{{
"SHOP.DETAILS" | translate
}}</a>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -4,15 +4,45 @@
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden; overflow: hidden;
transition: box-shadow 0.2s; transition: box-shadow 0.2s;
&:hover { box-shadow: var(--shadow-md); } &:hover {
box-shadow: var(--shadow-md);
}
} }
.image-placeholder { .image-placeholder {
height: 200px; height: 200px;
background-color: var(--color-neutral-200); background-color: var(--color-neutral-200);
} }
.content { padding: var(--space-4); } .content {
.category { font-size: 0.75rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; } padding: var(--space-4);
.name { font-size: 1.125rem; margin: var(--space-2) 0; a { color: var(--color-text); text-decoration: none; &:hover { color: var(--color-brand); } } } }
.footer { display: flex; justify-content: space-between; align-items: center; margin-top: var(--space-4); } .category {
.price { font-weight: 700; color: var(--color-brand); } font-size: 0.75rem;
.view-btn { font-size: 0.875rem; font-weight: 500; } color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.name {
font-size: 1.125rem;
margin: var(--space-2) 0;
a {
color: var(--color-text);
text-decoration: none;
&:hover {
color: var(--color-brand);
}
}
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--space-4);
}
.price {
font-weight: 700;
color: var(--color-brand);
}
.view-btn {
font-size: 0.875rem;
font-weight: 500;
}

View File

@@ -9,7 +9,7 @@ import { Product } from '../../services/shop.service';
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, TranslateModule], imports: [CommonModule, RouterLink, TranslateModule],
templateUrl: './product-card.component.html', templateUrl: './product-card.component.html',
styleUrl: './product-card.component.scss' styleUrl: './product-card.component.scss',
}) })
export class ProductCardComponent { export class ProductCardComponent {
product = input.required<Product>(); product = input.required<Product>();

View File

@@ -1,5 +1,5 @@
<div class="container wrapper"> <div class="container wrapper">
<a routerLink="/shop" class="back-link">← {{ 'SHOP.BACK' | translate }}</a> <a routerLink="/shop" class="back-link">← {{ "SHOP.BACK" | translate }}</a>
@if (product(); as p) { @if (product(); as p) {
<div class="detail-grid"> <div class="detail-grid">
@@ -8,18 +8,18 @@
<div class="info"> <div class="info">
<span class="category">{{ p.category | translate }}</span> <span class="category">{{ p.category | translate }}</span>
<h1>{{ p.name | translate }}</h1> <h1>{{ p.name | translate }}</h1>
<p class="price">{{ p.price | currency:'EUR' }}</p> <p class="price">{{ p.price | currency: "EUR" }}</p>
<p class="desc">{{ p.description | translate }}</p> <p class="desc">{{ p.description | translate }}</p>
<div class="actions"> <div class="actions">
<app-button variant="primary" (click)="addToCart()"> <app-button variant="primary" (click)="addToCart()">
{{ 'SHOP.ADD_CART' | translate }} {{ "SHOP.ADD_CART" | translate }}
</app-button> </app-button>
</div> </div>
</div> </div>
</div> </div>
} @else { } @else {
<p>{{ 'SHOP.NOT_FOUND' | translate }}</p> <p>{{ "SHOP.NOT_FOUND" | translate }}</p>
} }
</div> </div>

View File

@@ -1,5 +1,11 @@
.wrapper { padding-top: var(--space-8); } .wrapper {
.back-link { display: inline-block; margin-bottom: var(--space-6); color: var(--color-text-muted); } padding-top: var(--space-8);
}
.back-link {
display: inline-block;
margin-bottom: var(--space-6);
color: var(--color-text-muted);
}
.detail-grid { .detail-grid {
display: grid; display: grid;
@@ -15,6 +21,20 @@
aspect-ratio: 1; aspect-ratio: 1;
} }
.category { color: var(--color-brand); font-weight: 600; text-transform: uppercase; font-size: 0.875rem; } .category {
.price { font-size: 1.5rem; font-weight: 700; color: var(--color-text); margin: var(--space-4) 0; } color: var(--color-brand);
.desc { color: var(--color-text-muted); line-height: 1.6; margin-bottom: var(--space-8); } font-weight: 600;
text-transform: uppercase;
font-size: 0.875rem;
}
.price {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text);
margin: var(--space-4) 0;
}
.desc {
color: var(--color-text-muted);
line-height: 1.6;
margin-bottom: var(--space-8);
}

View File

@@ -10,7 +10,7 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent], imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent],
templateUrl: './product-detail.component.html', templateUrl: './product-detail.component.html',
styleUrl: './product-detail.component.scss' styleUrl: './product-detail.component.scss',
}) })
export class ProductDetailComponent { export class ProductDetailComponent {
// Input binding from router // Input binding from router
@@ -20,13 +20,15 @@ export class ProductDetailComponent {
constructor( constructor(
private shopService: ShopService, private shopService: ShopService,
private translate: TranslateService private translate: TranslateService,
) {} ) {}
ngOnInit() { ngOnInit() {
const productId = this.id(); const productId = this.id();
if (productId) { if (productId) {
this.shopService.getProductById(productId).subscribe(p => this.product.set(p)); this.shopService
.getProductById(productId)
.subscribe((p) => this.product.set(p));
} }
} }

View File

@@ -10,7 +10,7 @@ export interface Product {
} }
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class ShopService { export class ShopService {
// Dati statici per ora // Dati statici per ora
@@ -19,23 +19,23 @@ export class ShopService {
id: '1', id: '1',
name: 'SHOP.PRODUCTS.P1.NAME', name: 'SHOP.PRODUCTS.P1.NAME',
description: 'SHOP.PRODUCTS.P1.DESC', description: 'SHOP.PRODUCTS.P1.DESC',
price: 24.90, price: 24.9,
category: 'SHOP.CATEGORIES.FILAMENTS' category: 'SHOP.CATEGORIES.FILAMENTS',
}, },
{ {
id: '2', id: '2',
name: 'SHOP.PRODUCTS.P2.NAME', name: 'SHOP.PRODUCTS.P2.NAME',
description: 'SHOP.PRODUCTS.P2.DESC', description: 'SHOP.PRODUCTS.P2.DESC',
price: 29.90, price: 29.9,
category: 'SHOP.CATEGORIES.FILAMENTS' category: 'SHOP.CATEGORIES.FILAMENTS',
}, },
{ {
id: '3', id: '3',
name: 'SHOP.PRODUCTS.P3.NAME', name: 'SHOP.PRODUCTS.P3.NAME',
description: 'SHOP.PRODUCTS.P3.DESC', description: 'SHOP.PRODUCTS.P3.DESC',
price: 15.00, price: 15.0,
category: 'SHOP.CATEGORIES.ACCESSORIES' category: 'SHOP.CATEGORIES.ACCESSORIES',
} },
]; ];
getProducts(): Observable<Product[]> { getProducts(): Observable<Product[]> {
@@ -43,6 +43,6 @@ export class ShopService {
} }
getProductById(id: string): Observable<Product | undefined> { getProductById(id: string): Observable<Product | undefined> {
return of(this.staticProducts.find(p => p.id === id)); return of(this.staticProducts.find((p) => p.id === id));
} }
} }

View File

@@ -1,18 +1,18 @@
<section class="wip-section"> <section class="wip-section">
<div class="container"> <div class="container">
<div class="wip-card"> <div class="wip-card">
<p class="wip-eyebrow">{{ 'SHOP.WIP_EYEBROW' | translate }}</p> <p class="wip-eyebrow">{{ "SHOP.WIP_EYEBROW" | translate }}</p>
<h1>{{ 'SHOP.WIP_TITLE' | translate }}</h1> <h1>{{ "SHOP.WIP_TITLE" | translate }}</h1>
<p class="wip-subtitle">{{ 'SHOP.WIP_SUBTITLE' | translate }}</p> <p class="wip-subtitle">{{ "SHOP.WIP_SUBTITLE" | translate }}</p>
<div class="wip-actions"> <div class="wip-actions">
<app-button variant="primary" routerLink="/calculator/basic"> <app-button variant="primary" routerLink="/calculator/basic">
{{ 'SHOP.WIP_CTA_CALC' | translate }} {{ "SHOP.WIP_CTA_CALC" | translate }}
</app-button> </app-button>
</div> </div>
<p class="wip-return-later">{{ 'SHOP.WIP_RETURN_LATER' | translate }}</p> <p class="wip-return-later">{{ "SHOP.WIP_RETURN_LATER" | translate }}</p>
<p class="wip-note">{{ 'SHOP.WIP_NOTE' | translate }}</p> <p class="wip-note">{{ "SHOP.WIP_NOTE" | translate }}</p>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -9,6 +9,6 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent], 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 {}

Some files were not shown because too many files have changed in this diff Show More