dev #13
@@ -1,49 +1,15 @@
|
||||
name: Build, Test, Deploy and Analysis
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, int, dev]
|
||||
pull_request:
|
||||
branches: [main, int, dev]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: print-calculator-${{ gitea.ref }}
|
||||
group: print-calculator-deploy-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
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:
|
||||
if: ${{ gitea.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -52,8 +18,9 @@ jobs:
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
cache: gradle
|
||||
|
||||
- name: Run Tests with Gradle
|
||||
run: |
|
||||
@@ -61,8 +28,42 @@ jobs:
|
||||
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
|
||||
|
||||
build-and-push:
|
||||
if: ${{ gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch' }}
|
||||
needs: [test-backend, test-frontend]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -113,7 +114,6 @@ jobs:
|
||||
|
||||
deploy:
|
||||
needs: build-and-push
|
||||
if: ${{ gitea.event_name == 'push' || gitea.event_name == 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -141,21 +141,15 @@ jobs:
|
||||
mkdir -p ~/.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
|
||||
|
||||
# 2) (debug sicuro) stampa solo la lunghezza della base64
|
||||
echo "b64_len=$(wc -c < /tmp/key.b64)"
|
||||
|
||||
# 3) Decodifica in chiave privata
|
||||
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
|
||||
mv ~/.ssh/id_ed25519.clean ~/.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-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
@@ -163,7 +157,6 @@ jobs:
|
||||
- name: Write env and compose to server
|
||||
shell: bash
|
||||
run: |
|
||||
# 1. Recalculate TAG and OWNER_LOWER (jobs don't share ENV)
|
||||
if [[ "${{ gitea.ref }}" == "refs/heads/main" ]]; then
|
||||
DEPLOY_TAG="prod"
|
||||
elif [[ "${{ gitea.ref }}" == "refs/heads/int" ]]; then
|
||||
@@ -173,10 +166,8 @@ jobs:
|
||||
fi
|
||||
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
|
||||
|
||||
# 3. Determine DB credentials
|
||||
if [[ "${{ env.ENV }}" == "prod" ]]; then
|
||||
DB_URL="${{ secrets.DB_URL_PROD }}"
|
||||
DB_USER="${{ secrets.DB_USERNAME_PROD }}"
|
||||
@@ -191,7 +182,6 @@ jobs:
|
||||
DB_PASS="${{ secrets.DB_PASSWORD_DEV }}"
|
||||
fi
|
||||
|
||||
# 4. Append DB and Docker credentials (quoted)
|
||||
printf '\nDB_URL="%s"\nDB_USERNAME="%s"\nDB_PASSWORD="%s"\n' \
|
||||
"$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' \
|
||||
"${{ 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:"
|
||||
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 }}" \
|
||||
"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 }}" \
|
||||
"setcompose ${{ env.ENV }}" < docker-compose.deploy.yml
|
||||
|
||||
|
||||
|
||||
- name: Trigger deploy on Unraid (forced command key)
|
||||
shell: bash
|
||||
run: |
|
||||
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 }}"
|
||||
172
.gitea/workflows/pr-checks.yaml
Normal file
172
.gitea/workflows/pr-checks.yaml
Normal 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
|
||||
@@ -15,6 +15,7 @@ FROM eclipse-temurin:21-jre-jammy
|
||||
RUN apt-get update && apt-get install -y \
|
||||
wget \
|
||||
p7zip-full \
|
||||
assimp-utils \
|
||||
libgl1 \
|
||||
libglib2.0-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}"
|
||||
# Set Slicer Path env variable for Java app
|
||||
ENV SLICER_PATH="/opt/orcaslicer/AppRun"
|
||||
ENV ASSIMP_PATH="assimp"
|
||||
|
||||
WORKDIR /app
|
||||
# Copy JAR from build stage
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
package com.printcalculator.controller;
|
||||
|
||||
import com.printcalculator.dto.QuoteRequestDto;
|
||||
import com.printcalculator.entity.CustomQuoteRequest;
|
||||
import com.printcalculator.entity.CustomQuoteRequestAttachment;
|
||||
import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
|
||||
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.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -22,8 +28,11 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.Year;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
@@ -32,9 +41,17 @@ import java.util.regex.Pattern;
|
||||
@RequestMapping("/api/custom-quote-requests")
|
||||
public class CustomQuoteRequestController {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CustomQuoteRequestController.class);
|
||||
private final CustomQuoteRequestRepository requestRepo;
|
||||
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
|
||||
private static final Path STORAGE_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize();
|
||||
@@ -59,17 +76,19 @@ public class CustomQuoteRequestController {
|
||||
|
||||
public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
|
||||
CustomQuoteRequestAttachmentRepository attachmentRepo,
|
||||
com.printcalculator.service.ClamAVService clamAVService) {
|
||||
ClamAVService clamAVService,
|
||||
EmailNotificationService emailNotificationService) {
|
||||
this.requestRepo = requestRepo;
|
||||
this.attachmentRepo = attachmentRepo;
|
||||
this.clamAVService = clamAVService;
|
||||
this.emailNotificationService = emailNotificationService;
|
||||
}
|
||||
|
||||
// 1. Create Custom Quote Request
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Transactional
|
||||
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
|
||||
) throws IOException {
|
||||
if (!requestDto.isAcceptTerms() || !requestDto.isAcceptPrivacy()) {
|
||||
@@ -96,6 +115,7 @@ public class CustomQuoteRequestController {
|
||||
request = requestRepo.save(request);
|
||||
|
||||
// 2. Handle Attachments
|
||||
int attachmentsCount = 0;
|
||||
if (files != null && !files.isEmpty()) {
|
||||
if (files.size() > 15) {
|
||||
throw new IOException("Too many files. Max 15 allowed.");
|
||||
@@ -148,9 +168,12 @@ public class CustomQuoteRequestController {
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
Files.copy(inputStream, absolutePath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
attachmentsCount++;
|
||||
}
|
||||
}
|
||||
|
||||
sendAdminContactRequestNotification(request, attachmentsCount);
|
||||
|
||||
return ResponseEntity.ok(request);
|
||||
}
|
||||
|
||||
@@ -203,4 +226,42 @@ public class CustomQuoteRequestController {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -144,9 +146,13 @@ public class AdminOrderController {
|
||||
if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) {
|
||||
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 {
|
||||
Resource resource = storageService.loadAsResource(Paths.get(relativePath));
|
||||
Resource resource = storageService.loadAsResource(safeRelativePath);
|
||||
MediaType contentType = MediaType.APPLICATION_OCTET_STREAM;
|
||||
if (item.getMimeType() != null && !item.getMimeType().isBlank()) {
|
||||
try {
|
||||
@@ -276,9 +282,9 @@ public class AdminOrderController {
|
||||
private ResponseEntity<byte[]> generateDocument(Order order, boolean isConfirmation) {
|
||||
String displayOrderNumber = getDisplayOrderNumber(order);
|
||||
if (isConfirmation) {
|
||||
String relativePath = "orders/" + order.getId() + "/documents/confirmation-" + displayOrderNumber + ".pdf";
|
||||
Path relativePath = buildConfirmationPdfRelativePath(order.getId(), displayOrderNumber);
|
||||
try {
|
||||
byte[] existingPdf = storageService.loadAsResource(Paths.get(relativePath)).getInputStream().readAllBytes();
|
||||
byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes();
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"confirmation-" + displayOrderNumber + ".pdf\"")
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
@@ -298,4 +304,24 @@ public class AdminOrderController {
|
||||
.contentType(MediaType.APPLICATION_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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
public class AdminLoginThrottleService {
|
||||
|
||||
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 boolean trustProxyHeaders;
|
||||
|
||||
@@ -21,10 +21,12 @@ import java.nio.file.StandardCopyOption;
|
||||
public class FileSystemStorageService implements StorageService {
|
||||
|
||||
private final Path rootLocation;
|
||||
private final Path normalizedRootLocation;
|
||||
private final ClamAVService clamAVService;
|
||||
|
||||
public FileSystemStorageService(@Value("${storage.location:storage_orders}") String storageLocation, ClamAVService clamAVService) {
|
||||
this.rootLocation = Paths.get(storageLocation);
|
||||
this.normalizedRootLocation = this.rootLocation.toAbsolutePath().normalize();
|
||||
this.clamAVService = clamAVService;
|
||||
}
|
||||
|
||||
@@ -39,10 +41,7 @@ public class FileSystemStorageService implements StorageService {
|
||||
|
||||
@Override
|
||||
public void store(MultipartFile file, Path destinationRelativePath) throws IOException {
|
||||
Path destinationFile = this.rootLocation.resolve(destinationRelativePath).normalize().toAbsolutePath();
|
||||
if (!destinationFile.getParent().startsWith(this.rootLocation.toAbsolutePath())) {
|
||||
throw new StorageException("Cannot store file outside current directory.");
|
||||
}
|
||||
Path destinationFile = resolveInsideStorage(destinationRelativePath);
|
||||
|
||||
// 1. Salva prima il file su disco per evitare problemi di stream con file grandi
|
||||
Files.createDirectories(destinationFile.getParent());
|
||||
@@ -63,32 +62,46 @@ public class FileSystemStorageService implements StorageService {
|
||||
|
||||
@Override
|
||||
public void store(Path source, Path destinationRelativePath) throws IOException {
|
||||
Path destinationFile = this.rootLocation.resolve(destinationRelativePath).normalize().toAbsolutePath();
|
||||
if (!destinationFile.getParent().startsWith(this.rootLocation.toAbsolutePath())) {
|
||||
throw new StorageException("Cannot store file outside current directory.");
|
||||
}
|
||||
Path destinationFile = resolveInsideStorage(destinationRelativePath);
|
||||
Files.createDirectories(destinationFile.getParent());
|
||||
Files.copy(source, destinationFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(Path path) throws IOException {
|
||||
Path file = rootLocation.resolve(path);
|
||||
Path file = resolveInsideStorage(path);
|
||||
Files.deleteIfExists(file);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Resource loadAsResource(Path path) throws IOException {
|
||||
try {
|
||||
Path file = rootLocation.resolve(path);
|
||||
Path file = resolveInsideStorage(path);
|
||||
Resource resource = new UrlResource(file.toUri());
|
||||
if (resource.exists() || resource.isReadable()) {
|
||||
return resource;
|
||||
} else {
|
||||
throw new RuntimeException("Could not read file: " + path);
|
||||
throw new StorageException("Could not read file: " + path);
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,21 +6,37 @@ import com.printcalculator.model.ModelDimensions;
|
||||
import com.printcalculator.model.PrintStats;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
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.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.StringReader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
@Service
|
||||
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_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 GCodeParser gCodeParser;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public SlicerService(
|
||||
@Value("${slicer.path}") String slicerPath,
|
||||
@Value("${assimp.path:assimp}") String assimpPath,
|
||||
ProfileManager profileManager,
|
||||
GCodeParser gCodeParser,
|
||||
ObjectMapper mapper) {
|
||||
this.slicerPath = slicerPath;
|
||||
this.trustedSlicerPath = normalizeExecutablePath(slicerPath);
|
||||
this.trustedAssimpPath = normalizeExecutablePath(assimpPath);
|
||||
this.profileManager = profileManager;
|
||||
this.gCodeParser = gCodeParser;
|
||||
this.mapper = mapper;
|
||||
@@ -83,17 +102,25 @@ public class SlicerService {
|
||||
basename = basename.substring(0, basename.length() - 4);
|
||||
}
|
||||
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.
|
||||
for (boolean useArrange : new boolean[]{false, true}) {
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(slicerPath);
|
||||
// Build process arguments explicitly to avoid shell interpretation and command injection.
|
||||
ProcessBuilder pb = new ProcessBuilder();
|
||||
List<String> command = pb.command();
|
||||
command.add(trustedSlicerPath);
|
||||
command.add("--load-settings");
|
||||
command.add(mFile.getAbsolutePath());
|
||||
command.add(machineProfilePath);
|
||||
command.add("--load-settings");
|
||||
command.add(pFile.getAbsolutePath());
|
||||
command.add(processProfilePath);
|
||||
command.add("--load-filaments");
|
||||
command.add(fFile.getAbsolutePath());
|
||||
command.add(filamentProfilePath);
|
||||
command.add("--ensure-on-bed");
|
||||
if (useArrange) {
|
||||
command.add("--arrange");
|
||||
@@ -102,13 +129,12 @@ public class SlicerService {
|
||||
command.add("--slice");
|
||||
command.add("0");
|
||||
command.add("--outputdir");
|
||||
command.add(tempDir.toAbsolutePath().toString());
|
||||
command.add(inputStl.getAbsolutePath());
|
||||
command.add(outputDirPath);
|
||||
command.addAll(slicerInputPaths);
|
||||
|
||||
logger.info("Executing Slicer" + (useArrange ? " (retry with arrange)" : "") + ": " + String.join(" ", command));
|
||||
|
||||
Files.deleteIfExists(slicerLogPath);
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
pb.directory(tempDir.toFile());
|
||||
pb.redirectErrorStream(true);
|
||||
pb.redirectOutput(slicerLogPath.toFile());
|
||||
@@ -161,13 +187,13 @@ public class SlicerService {
|
||||
try {
|
||||
tempDir = Files.createTempDirectory("slicer_info_");
|
||||
Path infoLogPath = tempDir.resolve("orcaslicer-info.log");
|
||||
String inputModelPath = requireSafeArgument(inputModel.getAbsolutePath(), "input model path");
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(slicerPath);
|
||||
command.add("--info");
|
||||
command.add(inputModel.getAbsolutePath());
|
||||
|
||||
ProcessBuilder pb = new ProcessBuilder(command);
|
||||
ProcessBuilder pb = new ProcessBuilder();
|
||||
List<String> infoCommand = pb.command();
|
||||
infoCommand.add(trustedSlicerPath);
|
||||
infoCommand.add("--info");
|
||||
infoCommand.add(inputModelPath);
|
||||
pb.directory(tempDir.toFile());
|
||||
pb.redirectErrorStream(true);
|
||||
pb.redirectOutput(infoLogPath.toFile());
|
||||
@@ -267,4 +293,547 @@ public class SlicerService {
|
||||
|| normalized.contains("no object is fully inside the print volume")
|
||||
|| 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,17 @@ public class TwintPaymentService {
|
||||
private final String twintPaymentUrl;
|
||||
|
||||
public TwintPaymentService(
|
||||
@Value("${payment.twint.url:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}")
|
||||
@Value("${payment.twint.url:}")
|
||||
String twintPaymentUrl
|
||||
) {
|
||||
this.twintPaymentUrl = twintPaymentUrl;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (order != null) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
app.mail.enabled=false
|
||||
app.mail.admin.enabled=false
|
||||
app.mail.contact-request.admin.enabled=false
|
||||
|
||||
# Admin back-office local test credentials
|
||||
admin.password=ciaociao
|
||||
admin.session.secret=local-admin-session-secret-0123456789abcdef0123456789
|
||||
admin.password=local-admin-password
|
||||
admin.session.secret=local-session-secret-for-dev-only-000000000000000000000000
|
||||
admin.session.ttl-minutes=480
|
||||
|
||||
@@ -4,7 +4,7 @@ server.port=8000
|
||||
# Database Configuration
|
||||
spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/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.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
|
||||
spring.jpa.open-in-view=false
|
||||
@@ -26,7 +26,7 @@ clamav.port=${CLAMAV_PORT:3310}
|
||||
clamav.enabled=${CLAMAV_ENABLED:false}
|
||||
|
||||
# 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
|
||||
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.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true}
|
||||
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}
|
||||
|
||||
# Admin back-office authentication
|
||||
|
||||
@@ -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>© <span th:text="${currentYear}">2026</span> 3D-Fab - notifica automatica.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -32,7 +32,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
})
|
||||
@TestPropertySource(properties = {
|
||||
"admin.password=test-admin-password",
|
||||
"admin.session.secret=0123456789abcdef0123456789abcdef",
|
||||
"admin.session.secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"admin.session.ttl-minutes=60"
|
||||
})
|
||||
class AdminAuthSecurityTest {
|
||||
|
||||
@@ -4,7 +4,7 @@ services:
|
||||
container_name: print-calculator-db
|
||||
environment:
|
||||
- POSTGRES_USER=printcalc
|
||||
- POSTGRES_PASSWORD=printcalc_secret
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- POSTGRES_DB=printcalc
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"karmaConfig": "karma.conf.js",
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
|
||||
40
frontend/karma.conf.js
Normal file
40
frontend/karma.conf.js
Normal 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,
|
||||
});
|
||||
};
|
||||
@@ -6,6 +6,6 @@ import { RouterOutlet } from '@angular/router';
|
||||
standalone: true,
|
||||
imports: [RouterOutlet],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss'
|
||||
styleUrl: './app.component.scss',
|
||||
})
|
||||
export class AppComponent {}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { ApplicationConfig, provideZoneChangeDetection, importProvidersFrom } from '@angular/core';
|
||||
import { provideRouter, withComponentInputBinding, withInMemoryScrolling, withViewTransitions } from '@angular/router';
|
||||
import {
|
||||
ApplicationConfig,
|
||||
provideZoneChangeDetection,
|
||||
importProvidersFrom,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
provideRouter,
|
||||
withComponentInputBinding,
|
||||
withInMemoryScrolling,
|
||||
withViewTransitions,
|
||||
} from '@angular/router';
|
||||
import { routes } from './app.routes';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
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';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
@@ -14,24 +26,22 @@ export const appConfig: ApplicationConfig = {
|
||||
withComponentInputBinding(),
|
||||
withViewTransitions(),
|
||||
withInMemoryScrolling({
|
||||
scrollPositionRestoration: 'top'
|
||||
})
|
||||
),
|
||||
provideHttpClient(
|
||||
withInterceptors([adminAuthInterceptor])
|
||||
scrollPositionRestoration: 'top',
|
||||
}),
|
||||
),
|
||||
provideHttpClient(withInterceptors([adminAuthInterceptor])),
|
||||
provideTranslateHttpLoader({
|
||||
prefix: './assets/i18n/',
|
||||
suffix: '.json'
|
||||
suffix: '.json',
|
||||
}),
|
||||
importProvidersFrom(
|
||||
TranslateModule.forRoot({
|
||||
defaultLanguage: 'it',
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: TranslateHttpLoader
|
||||
}
|
||||
})
|
||||
)
|
||||
]
|
||||
useClass: TranslateHttpLoader,
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -3,63 +3,79 @@ import { Routes } from '@angular/router';
|
||||
const appChildRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./features/home/home.component').then(m => m.HomeComponent)
|
||||
loadComponent: () =>
|
||||
import('./features/home/home.component').then((m) => m.HomeComponent),
|
||||
},
|
||||
{
|
||||
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',
|
||||
loadChildren: () => import('./features/shop/shop.routes').then(m => m.SHOP_ROUTES)
|
||||
loadChildren: () =>
|
||||
import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES),
|
||||
},
|
||||
{
|
||||
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',
|
||||
loadChildren: () => import('./features/contact/contact.routes').then(m => m.CONTACT_ROUTES)
|
||||
loadChildren: () =>
|
||||
import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES),
|
||||
},
|
||||
{
|
||||
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',
|
||||
loadComponent: () => import('./features/order/order.component').then(m => m.OrderComponent)
|
||||
loadComponent: () =>
|
||||
import('./features/order/order.component').then((m) => m.OrderComponent),
|
||||
},
|
||||
{
|
||||
path: 'co/:orderId',
|
||||
loadComponent: () => import('./features/order/order.component').then(m => m.OrderComponent)
|
||||
loadComponent: () =>
|
||||
import('./features/order/order.component').then((m) => m.OrderComponent),
|
||||
},
|
||||
{
|
||||
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',
|
||||
loadChildren: () => import('./features/admin/admin.routes').then(m => m.ADMIN_ROUTES)
|
||||
loadChildren: () =>
|
||||
import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES),
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: ''
|
||||
}
|
||||
redirectTo: '',
|
||||
},
|
||||
];
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: ':lang',
|
||||
loadComponent: () => import('./core/layout/layout.component').then(m => m.LayoutComponent),
|
||||
children: appChildRoutes
|
||||
loadComponent: () =>
|
||||
import('./core/layout/layout.component').then((m) => m.LayoutComponent),
|
||||
children: appChildRoutes,
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./core/layout/layout.component').then(m => m.LayoutComponent),
|
||||
children: appChildRoutes
|
||||
loadComponent: () =>
|
||||
import('./core/layout/layout.component').then((m) => m.LayoutComponent),
|
||||
children: appChildRoutes,
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
redirectTo: ''
|
||||
}
|
||||
redirectTo: '',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -17,25 +17,30 @@ export const PRODUCT_COLORS: ColorCategory[] = [
|
||||
colors: [
|
||||
{ label: 'COLOR.NAME.BLACK', value: 'Black', hex: '#1a1a1a' }, // Not pure black for visibility
|
||||
{ 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.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',
|
||||
colors: [
|
||||
{ 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_GRAY', value: 'Matte Gray', hex: '#757575' }
|
||||
]
|
||||
}
|
||||
{ label: 'COLOR.NAME.MATTE_GRAY', value: 'Matte Gray', hex: '#757575' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function getColorHex(value: string): string {
|
||||
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;
|
||||
}
|
||||
return '#facf0a'; // Default Brand Color if not found
|
||||
|
||||
@@ -25,13 +25,17 @@ export const adminAuthInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
|
||||
return next(request).pipe(
|
||||
catchError((error: unknown) => {
|
||||
if (!isLoginRequest && error instanceof HttpErrorResponse && error.status === 401) {
|
||||
if (
|
||||
!isLoginRequest &&
|
||||
error instanceof HttpErrorResponse &&
|
||||
error.status === 401
|
||||
) {
|
||||
const lang = resolveLangFromUrl(router.url);
|
||||
if (!router.url.includes('/admin/login')) {
|
||||
void router.navigate(['/', lang, 'admin', 'login']);
|
||||
}
|
||||
}
|
||||
return throwError(() => error);
|
||||
})
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<footer class="footer">
|
||||
<footer class="footer">
|
||||
<div class="container footer-inner">
|
||||
<div class="col">
|
||||
<span class="brand">3D fab</span>
|
||||
@@ -6,9 +6,9 @@
|
||||
</div>
|
||||
|
||||
<div class="col links">
|
||||
<a routerLink="/privacy">{{ 'FOOTER.PRIVACY' | translate }}</a>
|
||||
<a routerLink="/terms">{{ 'FOOTER.TERMS' | translate }}</a>
|
||||
<a routerLink="/contact">{{ 'FOOTER.CONTACT' | translate }}</a>
|
||||
<a routerLink="/privacy">{{ "FOOTER.PRIVACY" | translate }}</a>
|
||||
<a routerLink="/terms">{{ "FOOTER.TERMS" | translate }}</a>
|
||||
<a routerLink="/contact">{{ "FOOTER.CONTACT" | translate }}</a>
|
||||
</div>
|
||||
|
||||
<div class="col social">
|
||||
@@ -18,4 +18,4 @@
|
||||
<div class="social-icon"></div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</footer>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@use '../../../styles/patterns';
|
||||
@use "../../../styles/patterns";
|
||||
|
||||
.footer {
|
||||
.footer {
|
||||
background: var(--color-neutral-900);
|
||||
color: var(--color-neutral-50);
|
||||
padding: var(--space-8) 0 var(--space-4);
|
||||
@@ -9,22 +9,22 @@
|
||||
margin-top: auto; /* Push to bottom if content is short */
|
||||
// Cross Hatch Pattern
|
||||
&::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@include patterns.pattern-cross-hatch(var(--color-neutral-50), 20px, 1px);
|
||||
opacity: 0.05;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
.footer-inner {
|
||||
}
|
||||
.footer-inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 768px) {
|
||||
.footer-inner {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
@@ -35,25 +35,41 @@
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.brand { font-weight: 700; color: white; display: block; margin-bottom: var(--space-2); }
|
||||
.copyright { font-size: 0.875rem; color: var(--color-secondary-500); margin: 0; }
|
||||
.brand {
|
||||
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;
|
||||
gap: var(--space-6);
|
||||
a {
|
||||
color: var(--color-neutral-300);
|
||||
font-size: 0.875rem;
|
||||
transition: color 0.2s;
|
||||
&:hover { color: white; text-decoration: underline; }
|
||||
&:hover {
|
||||
color: white;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.social { display: flex; gap: var(--space-3); }
|
||||
.social-icon {
|
||||
width: 24px; height: 24px;
|
||||
.social {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.social-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: var(--color-neutral-800);
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@ import { RouterLink } from '@angular/router';
|
||||
standalone: true,
|
||||
imports: [TranslateModule, RouterLink],
|
||||
templateUrl: './footer.component.html',
|
||||
styleUrls: ['./footer.component.scss']
|
||||
styleUrls: ['./footer.component.scss'],
|
||||
})
|
||||
export class FooterComponent {}
|
||||
|
||||
@@ -8,6 +8,6 @@ import { FooterComponent } from './footer.component';
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, NavbarComponent, FooterComponent],
|
||||
templateUrl: './layout.component.html',
|
||||
styleUrl: './layout.component.scss'
|
||||
styleUrl: './layout.component.scss',
|
||||
})
|
||||
export class LayoutComponent {}
|
||||
|
||||
@@ -1,19 +1,44 @@
|
||||
<header class="navbar">
|
||||
<header class="navbar">
|
||||
<div class="container navbar-inner">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<nav class="nav-links" [class.open]="isMenuOpen">
|
||||
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" (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>
|
||||
<a
|
||||
routerLink="/"
|
||||
routerLinkActive="active"
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
(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>
|
||||
|
||||
<div class="actions">
|
||||
@@ -21,15 +46,29 @@
|
||||
class="lang-switch"
|
||||
[value]="langService.selectedLang()"
|
||||
(change)="onLanguageChange($event)"
|
||||
[attr.aria-label]="'NAV.LANGUAGE_SELECTOR' | translate">
|
||||
[attr.aria-label]="'NAV.LANGUAGE_SELECTOR' | translate"
|
||||
>
|
||||
@for (option of languageOptions; track option.value) {
|
||||
<option [value]="option.value">{{ option.label }}</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
<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>
|
||||
</header>
|
||||
</header>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.navbar {
|
||||
.navbar {
|
||||
height: 64px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background-color: var(--color-bg-card);
|
||||
@@ -7,21 +7,23 @@
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.navbar-inner {
|
||||
}
|
||||
.navbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.brand {
|
||||
}
|
||||
.brand {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.highlight { color: var(--color-brand); }
|
||||
}
|
||||
.highlight {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: var(--space-6);
|
||||
|
||||
@@ -31,19 +33,20 @@
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover, &.active {
|
||||
&:hover,
|
||||
&.active {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
.lang-switch {
|
||||
.lang-switch {
|
||||
background-color: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -59,17 +62,22 @@
|
||||
background-position:
|
||||
calc(100% - 10px) 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;
|
||||
|
||||
&:hover { color: var(--color-text); border-color: var(--color-text); }
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-text);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon-placeholder {
|
||||
.icon-placeholder {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
@@ -78,10 +86,10 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Toggle */
|
||||
.mobile-toggle {
|
||||
/* Mobile Toggle */
|
||||
.mobile-toggle {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
@@ -100,14 +108,20 @@
|
||||
}
|
||||
|
||||
&.active {
|
||||
span:nth-child(1) { transform: translateY(8px) rotate(45deg); }
|
||||
span:nth-child(2) { opacity: 0; }
|
||||
span:nth-child(3) { transform: translateY(-8px) rotate(-45deg); }
|
||||
span:nth-child(1) {
|
||||
transform: translateY(8px) rotate(45deg);
|
||||
}
|
||||
span:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
span:nth-child(3) {
|
||||
transform: translateY(-8px) rotate(-45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.mobile-toggle {
|
||||
display: flex;
|
||||
order: 2; /* Place after actions */
|
||||
@@ -147,9 +161,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,15 +8,18 @@ import { LanguageService } from '../services/language.service';
|
||||
standalone: true,
|
||||
imports: [RouterLink, RouterLinkActive, TranslateModule],
|
||||
templateUrl: './navbar.component.html',
|
||||
styleUrls: ['./navbar.component.scss']
|
||||
styleUrls: ['./navbar.component.scss'],
|
||||
})
|
||||
export class NavbarComponent {
|
||||
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: 'en', label: 'EN' },
|
||||
{ value: 'de', label: 'DE' },
|
||||
{ value: 'fr', label: 'FR' }
|
||||
{ value: 'fr', label: 'FR' },
|
||||
];
|
||||
|
||||
constructor(public langService: LanguageService) {}
|
||||
|
||||
106
frontend/src/app/core/services/language.service.spec.ts
Normal file
106
frontend/src/app/core/services/language.service.spec.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,33 @@
|
||||
import { Injectable, signal } from '@angular/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({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class LanguageService {
|
||||
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(
|
||||
private translate: TranslateService,
|
||||
private router: Router
|
||||
private router: Router,
|
||||
) {
|
||||
this.translate.addLangs(this.supportedLangs);
|
||||
this.translate.setDefaultLang('it');
|
||||
this.translate.onLangChange.subscribe(event => {
|
||||
const lang = typeof event.lang === 'string' ? event.lang.toLowerCase() : null;
|
||||
this.translate.onLangChange.subscribe((event) => {
|
||||
const lang =
|
||||
typeof event.lang === 'string' ? event.lang.toLowerCase() : null;
|
||||
if (this.isSupportedLang(lang) && lang !== this.currentLang()) {
|
||||
this.currentLang.set(lang);
|
||||
}
|
||||
@@ -27,11 +38,13 @@ export class LanguageService {
|
||||
const queryLang = this.getQueryLang(initialTree);
|
||||
const initialLang = this.isSupportedLang(initialSegments[0])
|
||||
? initialSegments[0]
|
||||
: (this.isSupportedLang(queryLang) ? queryLang : 'it');
|
||||
: this.isSupportedLang(queryLang)
|
||||
? queryLang
|
||||
: 'it';
|
||||
this.applyLanguage(initialLang);
|
||||
this.ensureLanguageInPath(initialTree);
|
||||
|
||||
this.router.events.subscribe(event => {
|
||||
this.router.events.subscribe((event) => {
|
||||
if (!(event instanceof NavigationEnd)) {
|
||||
return;
|
||||
}
|
||||
@@ -52,7 +65,10 @@ export class LanguageService {
|
||||
let targetSegments: string[];
|
||||
if (segments.length === 0) {
|
||||
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)];
|
||||
} else {
|
||||
targetSegments = [lang, ...segments];
|
||||
@@ -62,7 +78,8 @@ export class LanguageService {
|
||||
}
|
||||
|
||||
selectedLang(): 'it' | 'en' | 'de' | 'fr' {
|
||||
const activeLang = typeof this.translate.currentLang === 'string'
|
||||
const activeLang =
|
||||
typeof this.translate.currentLang === 'string'
|
||||
? this.translate.currentLang.toLowerCase()
|
||||
: null;
|
||||
return this.isSupportedLang(activeLang) ? activeLang : this.currentLang();
|
||||
@@ -77,7 +94,9 @@ export class LanguageService {
|
||||
}
|
||||
|
||||
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()) {
|
||||
this.applyLanguage(activeLang);
|
||||
}
|
||||
@@ -99,7 +118,7 @@ export class LanguageService {
|
||||
if (!primaryGroup) {
|
||||
return [];
|
||||
}
|
||||
return primaryGroup.segments.map(segment => segment.path.toLowerCase());
|
||||
return primaryGroup.segments.map((segment) => segment.path.toLowerCase());
|
||||
}
|
||||
|
||||
private getQueryLang(urlTree: UrlTree): string | null {
|
||||
@@ -107,12 +126,19 @@ export class LanguageService {
|
||||
return typeof lang === 'string' ? lang.toLowerCase() : null;
|
||||
}
|
||||
|
||||
private isSupportedLang(lang: string | null | undefined): lang is 'it' | 'en' | 'de' | 'fr' {
|
||||
return typeof lang === 'string' && this.supportedLangs.includes(lang as 'it' | 'en' | 'de' | 'fr');
|
||||
private isSupportedLang(
|
||||
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 {
|
||||
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 {
|
||||
@@ -123,14 +149,20 @@ export class LanguageService {
|
||||
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 targetTree = this.router.createUrlTree(['/', ...targetSegments], {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface QuoteRequestDto {
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class QuoteRequestService {
|
||||
private http = inject(HttpClient);
|
||||
@@ -28,12 +28,12 @@ export class QuoteRequestService {
|
||||
|
||||
// Append Request DTO as JSON Blob
|
||||
const requestBlob = new Blob([JSON.stringify(request)], {
|
||||
type: 'application/json'
|
||||
type: 'application/json',
|
||||
});
|
||||
formData.append('request', requestBlob);
|
||||
|
||||
// Append Files
|
||||
files.forEach(file => {
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
<section class="about-section">
|
||||
<div class="container split-layout">
|
||||
|
||||
<!-- Left Column: Content -->
|
||||
<div class="text-content">
|
||||
<p class="eyebrow">{{ 'ABOUT.EYEBROW' | translate }}</p>
|
||||
<h1>{{ 'ABOUT.TITLE' | translate }}</h1>
|
||||
<p class="subtitle">{{ 'ABOUT.SUBTITLE' | translate }}</p>
|
||||
<p class="eyebrow">{{ "ABOUT.EYEBROW" | translate }}</p>
|
||||
<h1>{{ "ABOUT.TITLE" | translate }}</h1>
|
||||
<p class="subtitle">{{ "ABOUT.SUBTITLE" | translate }}</p>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<p class="description">{{ 'ABOUT.HOW_TEXT' | translate }}</p>
|
||||
<br>
|
||||
<h2 class="passions-title">{{ 'ABOUT.PASSIONS_TITLE' | translate }}</h2>
|
||||
<p class="description">{{ "ABOUT.HOW_TEXT" | translate }}</p>
|
||||
<br />
|
||||
<h2 class="passions-title">{{ "ABOUT.PASSIONS_TITLE" | translate }}</h2>
|
||||
|
||||
<div class="tags-container">
|
||||
@for (passion of passions; track passion.id) {
|
||||
@@ -40,11 +39,18 @@
|
||||
(keydown.space)="toggleSelectedMember('joe'); $event.preventDefault()"
|
||||
>
|
||||
<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 class="member-info">
|
||||
<span class="member-name">{{ 'ABOUT.MEMBER_JOE_NAME' | translate }}</span>
|
||||
<span class="member-role">{{ 'ABOUT.MEMBER_JOE_ROLE' | translate }}</span>
|
||||
<span class="member-name">{{
|
||||
"ABOUT.MEMBER_JOE_NAME" | translate
|
||||
}}</span>
|
||||
<span class="member-role">{{
|
||||
"ABOUT.MEMBER_JOE_ROLE" | translate
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -60,18 +66,26 @@
|
||||
(blur)="setHoveredMember(null)"
|
||||
(click)="toggleSelectedMember('matteo')"
|
||||
(keydown.enter)="toggleSelectedMember('matteo')"
|
||||
(keydown.space)="toggleSelectedMember('matteo'); $event.preventDefault()"
|
||||
(keydown.space)="
|
||||
toggleSelectedMember('matteo'); $event.preventDefault()
|
||||
"
|
||||
>
|
||||
<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 class="member-info">
|
||||
<span class="member-name">{{ 'ABOUT.MEMBER_MATTEO_NAME' | translate }}</span>
|
||||
<span class="member-role">{{ 'ABOUT.MEMBER_MATTEO_ROLE' | translate }}</span>
|
||||
<span class="member-name">{{
|
||||
"ABOUT.MEMBER_MATTEO_NAME" | translate
|
||||
}}</span>
|
||||
<span class="member-role">{{
|
||||
"ABOUT.MEMBER_MATTEO_ROLE" | translate
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
align-items: center;
|
||||
text-align: center; /* Center on mobile */
|
||||
|
||||
@media(min-width: 992px) {
|
||||
@media (min-width: 992px) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6rem;
|
||||
text-align: left; /* Reset to left on desktop */
|
||||
@@ -59,7 +59,7 @@ h1 {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
@media(min-width: 992px) {
|
||||
@media (min-width: 992px) {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
@@ -87,7 +87,7 @@ h1 {
|
||||
gap: 0.75rem;
|
||||
justify-content: center; /* Center tags on mobile */
|
||||
|
||||
@media(min-width: 992px) {
|
||||
@media (min-width: 992px) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,11 @@ h1 {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
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 {
|
||||
@@ -120,7 +124,7 @@ h1 {
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
|
||||
@media(min-width: 768px) {
|
||||
@media (min-width: 768px) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
align-items: start;
|
||||
@@ -137,7 +141,11 @@ h1 {
|
||||
width: 100%;
|
||||
max-width: 260px;
|
||||
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;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
@@ -155,7 +163,9 @@ h1 {
|
||||
|
||||
.photo-card.is-active {
|
||||
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 {
|
||||
@@ -165,7 +175,11 @@ h1 {
|
||||
.placeholder-img {
|
||||
width: 100%;
|
||||
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);
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--color-neutral-300);
|
||||
|
||||
@@ -28,7 +28,7 @@ interface PassionChip {
|
||||
standalone: true,
|
||||
imports: [TranslateModule, AppLocationsComponent],
|
||||
templateUrl: './about-page.component.html',
|
||||
styleUrl: './about-page.component.scss'
|
||||
styleUrl: './about-page.component.scss',
|
||||
})
|
||||
export class AboutPageComponent {
|
||||
selectedMember: MemberId | null = null;
|
||||
@@ -43,14 +43,22 @@ export class AboutPageComponent {
|
||||
{ id: 'woodworking', labelKey: 'ABOUT.PASSION_WOODWORKING' },
|
||||
{ id: 'print-3d', labelKey: 'ABOUT.PASSION_PRINT_3D' },
|
||||
{ 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: 'van-life', labelKey: 'ABOUT.PASSION_VAN_LIFE' },
|
||||
{ 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: [
|
||||
'bike-trial',
|
||||
'mountain',
|
||||
@@ -59,7 +67,7 @@ export class AboutPageComponent {
|
||||
'print-3d',
|
||||
'travel',
|
||||
'coffee',
|
||||
'software-development'
|
||||
'software-development',
|
||||
],
|
||||
matteo: [
|
||||
'bike-trial',
|
||||
@@ -69,8 +77,8 @@ export class AboutPageComponent {
|
||||
'electronics',
|
||||
'print-3d',
|
||||
'woodworking',
|
||||
'van-life'
|
||||
]
|
||||
'van-life',
|
||||
],
|
||||
};
|
||||
|
||||
get activeMember(): MemberId | null {
|
||||
|
||||
@@ -2,5 +2,5 @@ import { Routes } from '@angular/router';
|
||||
import { AboutPageComponent } from './about-page.component';
|
||||
|
||||
export const ABOUT_ROUTES: Routes = [
|
||||
{ path: '', component: AboutPageComponent }
|
||||
{ path: '', component: AboutPageComponent },
|
||||
];
|
||||
|
||||
@@ -4,34 +4,52 @@ import { adminAuthGuard } from './guards/admin-auth.guard';
|
||||
export const ADMIN_ROUTES: Routes = [
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () => import('./pages/admin-login.component').then(m => m.AdminLoginComponent)
|
||||
loadComponent: () =>
|
||||
import('./pages/admin-login.component').then(
|
||||
(m) => m.AdminLoginComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
canActivate: [adminAuthGuard],
|
||||
loadComponent: () => import('./pages/admin-shell.component').then(m => m.AdminShellComponent),
|
||||
loadComponent: () =>
|
||||
import('./pages/admin-shell.component').then(
|
||||
(m) => m.AdminShellComponent,
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'orders'
|
||||
redirectTo: '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',
|
||||
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',
|
||||
loadComponent: () => import('./pages/admin-contact-requests.component').then(m => m.AdminContactRequestsComponent)
|
||||
loadComponent: () =>
|
||||
import('./pages/admin-contact-requests.component').then(
|
||||
(m) => m.AdminContactRequestsComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'sessions',
|
||||
loadComponent: () => import('./pages/admin-sessions.component').then(m => m.AdminSessionsComponent)
|
||||
}
|
||||
]
|
||||
}
|
||||
loadComponent: () =>
|
||||
import('./pages/admin-sessions.component').then(
|
||||
(m) => m.AdminSessionsComponent,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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 { AdminAuthService } from '../services/admin-auth.service';
|
||||
|
||||
@@ -17,7 +23,7 @@ function resolveLang(route: ActivatedRouteSnapshot): string {
|
||||
|
||||
export const adminAuthGuard: CanActivateFn = (
|
||||
route: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot
|
||||
state: RouterStateSnapshot,
|
||||
): Observable<boolean | UrlTree> => {
|
||||
const authService = inject(AdminAuthService);
|
||||
const router = inject(Router);
|
||||
@@ -29,13 +35,15 @@ export const adminAuthGuard: CanActivateFn = (
|
||||
return true;
|
||||
}
|
||||
return router.createUrlTree(['/', lang, 'admin', 'login'], {
|
||||
queryParams: { redirect: state.url }
|
||||
queryParams: { redirect: state.url },
|
||||
});
|
||||
}),
|
||||
catchError(() => of(
|
||||
catchError(() =>
|
||||
of(
|
||||
router.createUrlTree(['/', lang, 'admin', 'login'], {
|
||||
queryParams: { redirect: state.url }
|
||||
})
|
||||
))
|
||||
queryParams: { redirect: state.url },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
<p>Richieste preventivo personalizzato ricevute dal sito.</p>
|
||||
<span class="total-pill">{{ requests.length }} richieste</span>
|
||||
</div>
|
||||
<button type="button" (click)="loadRequests()" [disabled]="loading">Aggiorna</button>
|
||||
<button type="button" (click)="loadRequests()" [disabled]="loading">
|
||||
Aggiorna
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||
@@ -32,10 +34,19 @@
|
||||
[class.selected]="isSelected(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">
|
||||
<p class="primary">{{ request.name || request.companyName || '-' }}</p>
|
||||
<p class="secondary" *ngIf="request.name && request.companyName">{{ request.companyName }}</p>
|
||||
<p class="primary">
|
||||
{{ request.name || request.companyName || "-" }}
|
||||
</p>
|
||||
<p
|
||||
class="secondary"
|
||||
*ngIf="request.name && request.companyName"
|
||||
>
|
||||
{{ request.companyName }}
|
||||
</p>
|
||||
</td>
|
||||
<td class="email-cell">{{ request.email }}</td>
|
||||
<td>
|
||||
@@ -45,7 +56,11 @@
|
||||
<span class="chip chip-light">{{ request.customerType }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="chip" [ngClass]="getStatusChipClass(request.status)">{{ request.status }}</span>
|
||||
<span
|
||||
class="chip"
|
||||
[ngClass]="getStatusChipClass(request.status)"
|
||||
>{{ request.status }}</span
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="empty-row" *ngIf="requests.length === 0">
|
||||
@@ -60,25 +75,58 @@
|
||||
<header class="detail-header">
|
||||
<div>
|
||||
<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 class="detail-chips">
|
||||
<span class="chip" [ngClass]="getStatusChipClass(selectedRequest.status)">{{ selectedRequest.status }}</span>
|
||||
<span class="chip chip-neutral">{{ selectedRequest.requestType }}</span>
|
||||
<span class="chip chip-light">{{ selectedRequest.customerType }}</span>
|
||||
<span
|
||||
class="chip"
|
||||
[ngClass]="getStatusChipClass(selectedRequest.status)"
|
||||
>{{ selectedRequest.status }}</span
|
||||
>
|
||||
<span class="chip chip-neutral">{{
|
||||
selectedRequest.requestType
|
||||
}}</span>
|
||||
<span class="chip chip-light">{{
|
||||
selectedRequest.customerType
|
||||
}}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="loading-detail" *ngIf="detailLoading">Caricamento dettaglio...</p>
|
||||
<p class="loading-detail" *ngIf="detailLoading">
|
||||
Caricamento dettaglio...
|
||||
</p>
|
||||
|
||||
<dl class="meta-grid">
|
||||
<div class="meta-item"><dt>Creata</dt><dd>{{ selectedRequest.createdAt | date:'medium' }}</dd></div>
|
||||
<div class="meta-item"><dt>Aggiornata</dt><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>
|
||||
<div class="meta-item">
|
||||
<dt>Creata</dt>
|
||||
<dd>{{ selectedRequest.createdAt | date: "medium" }}</dd>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<dt>Aggiornata</dt>
|
||||
<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>
|
||||
|
||||
<div class="status-editor">
|
||||
@@ -87,36 +135,61 @@
|
||||
<select
|
||||
id="contact-request-status"
|
||||
[ngModel]="selectedStatus"
|
||||
(ngModelChange)="selectedStatus = $event">
|
||||
<option *ngFor="let status of statusOptions" [ngValue]="status">{{ status }}</option>
|
||||
(ngModelChange)="selectedStatus = $event"
|
||||
>
|
||||
<option *ngFor="let status of statusOptions" [ngValue]="status">
|
||||
{{ status }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
(click)="updateRequestStatus()"
|
||||
[disabled]="!selectedRequest || updatingStatus || !selectedStatus || selectedStatus === selectedRequest.status">
|
||||
{{ updatingStatus ? 'Salvataggio...' : 'Aggiorna stato' }}
|
||||
[disabled]="
|
||||
!selectedRequest ||
|
||||
updatingStatus ||
|
||||
!selectedStatus ||
|
||||
selectedStatus === selectedRequest.status
|
||||
"
|
||||
>
|
||||
{{ updatingStatus ? "Salvataggio..." : "Aggiorna stato" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="message-box">
|
||||
<h4>Messaggio</h4>
|
||||
<p>{{ selectedRequest.message || '-' }}</p>
|
||||
<p>{{ selectedRequest.message || "-" }}</p>
|
||||
</div>
|
||||
|
||||
<div class="attachments">
|
||||
<h4>Allegati</h4>
|
||||
<div class="attachment-list" *ngIf="selectedRequest.attachments.length > 0; else noAttachmentsTpl">
|
||||
<article class="attachment-item" *ngFor="let attachment of selectedRequest.attachments">
|
||||
<div
|
||||
class="attachment-list"
|
||||
*ngIf="selectedRequest.attachments.length > 0; else noAttachmentsTpl"
|
||||
>
|
||||
<article
|
||||
class="attachment-item"
|
||||
*ngFor="let attachment of selectedRequest.attachments"
|
||||
>
|
||||
<div>
|
||||
<p class="filename">{{ attachment.originalFilename }}</p>
|
||||
<p class="meta">
|
||||
{{ formatFileSize(attachment.fileSizeBytes) }}
|
||||
<span *ngIf="attachment.mimeType"> | {{ attachment.mimeType }}</span>
|
||||
<span *ngIf="attachment.createdAt"> | {{ attachment.createdAt | date:'short' }}</span>
|
||||
<span *ngIf="attachment.mimeType">
|
||||
| {{ attachment.mimeType }}</span
|
||||
>
|
||||
<span *ngIf="attachment.createdAt">
|
||||
| {{ attachment.createdAt | date: "short" }}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="ghost" (click)="downloadAttachment(attachment)">Scarica file</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ghost"
|
||||
(click)="downloadAttachment(attachment)"
|
||||
>
|
||||
Scarica file
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,9 @@ button {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-weight: 600;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
AdminContactRequest,
|
||||
AdminContactRequestAttachment,
|
||||
AdminContactRequestDetail,
|
||||
AdminOperationsService
|
||||
AdminOperationsService,
|
||||
} from '../services/admin-operations.service';
|
||||
|
||||
@Component({
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './admin-contact-requests.component.html',
|
||||
styleUrl: './admin-contact-requests.component.scss'
|
||||
styleUrl: './admin-contact-requests.component.scss',
|
||||
})
|
||||
export class AdminContactRequestsComponent implements OnInit {
|
||||
private readonly adminOperationsService = inject(AdminOperationsService);
|
||||
@@ -43,7 +43,10 @@ export class AdminContactRequestsComponent implements OnInit {
|
||||
if (requests.length === 0) {
|
||||
this.selectedRequest = 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);
|
||||
} else {
|
||||
this.openDetails(requests[0].id);
|
||||
@@ -53,7 +56,7 @@ export class AdminContactRequestsComponent implements OnInit {
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
this.errorMessage = 'Impossibile caricare le richieste di contatto.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,7 +73,7 @@ export class AdminContactRequestsComponent implements OnInit {
|
||||
error: () => {
|
||||
this.detailLoading = false;
|
||||
this.errorMessage = 'Impossibile caricare il dettaglio richiesta.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,11 +86,17 @@ export class AdminContactRequestsComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.adminOperationsService.downloadContactRequestAttachment(this.selectedRequest.id, attachment.id).subscribe({
|
||||
next: (blob) => this.downloadBlob(blob, attachment.originalFilename || `attachment-${attachment.id}`),
|
||||
this.adminOperationsService
|
||||
.downloadContactRequestAttachment(this.selectedRequest.id, attachment.id)
|
||||
.subscribe({
|
||||
next: (blob) =>
|
||||
this.downloadBlob(
|
||||
blob,
|
||||
attachment.originalFilename || `attachment-${attachment.id}`,
|
||||
),
|
||||
error: () => {
|
||||
this.errorMessage = 'Download allegato non riuscito.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -120,7 +129,12 @@ export class AdminContactRequestsComponent implements OnInit {
|
||||
}
|
||||
|
||||
updateRequestStatus(): void {
|
||||
if (!this.selectedRequest || !this.selectedRequestId || !this.selectedStatus || this.updatingStatus) {
|
||||
if (
|
||||
!this.selectedRequest ||
|
||||
!this.selectedRequestId ||
|
||||
!this.selectedStatus ||
|
||||
this.updatingStatus
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,25 +142,30 @@ export class AdminContactRequestsComponent implements OnInit {
|
||||
this.successMessage = null;
|
||||
this.updatingStatus = true;
|
||||
|
||||
this.adminOperationsService.updateContactRequestStatus(this.selectedRequestId, { status: this.selectedStatus }).subscribe({
|
||||
this.adminOperationsService
|
||||
.updateContactRequestStatus(this.selectedRequestId, {
|
||||
status: this.selectedStatus,
|
||||
})
|
||||
.subscribe({
|
||||
next: (updated) => {
|
||||
this.selectedRequest = updated;
|
||||
this.selectedStatus = updated.status || this.selectedStatus;
|
||||
this.requests = this.requests.map(request =>
|
||||
this.requests = this.requests.map((request) =>
|
||||
request.id === updated.id
|
||||
? {
|
||||
...request,
|
||||
status: updated.status
|
||||
status: updated.status,
|
||||
}
|
||||
: request
|
||||
: request,
|
||||
);
|
||||
this.updatingStatus = false;
|
||||
this.successMessage = 'Stato richiesta aggiornato.';
|
||||
},
|
||||
error: () => {
|
||||
this.updatingStatus = false;
|
||||
this.errorMessage = 'Impossibile aggiornare lo stato della richiesta.';
|
||||
}
|
||||
this.errorMessage =
|
||||
'Impossibile aggiornare lo stato della richiesta.';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
<p>Seleziona un ordine a sinistra e gestiscilo nel dettaglio a destra.</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" (click)="loadOrders()" [disabled]="loading">Aggiorna</button>
|
||||
<button type="button" (click)="loadOrders()" [disabled]="loading">
|
||||
Aggiorna
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -32,7 +34,12 @@
|
||||
[ngModel]="paymentStatusFilter"
|
||||
(ngModelChange)="onPaymentStatusFilterChange($event)"
|
||||
>
|
||||
<option *ngFor="let option of paymentStatusFilterOptions" [ngValue]="option">{{ option }}</option>
|
||||
<option
|
||||
*ngFor="let option of paymentStatusFilterOptions"
|
||||
[ngValue]="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="toolbar-field" for="order-status-filter">
|
||||
@@ -42,7 +49,12 @@
|
||||
[ngModel]="orderStatusFilter"
|
||||
(ngModelChange)="onOrderStatusFilterChange($event)"
|
||||
>
|
||||
<option *ngFor="let option of orderStatusFilterOptions" [ngValue]="option">{{ option }}</option>
|
||||
<option
|
||||
*ngFor="let option of orderStatusFilterOptions"
|
||||
[ngValue]="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
@@ -65,12 +77,16 @@
|
||||
>
|
||||
<td>{{ order.orderNumber }}</td>
|
||||
<td>{{ order.customerEmail }}</td>
|
||||
<td>{{ order.paymentStatus || 'PENDING' }}</td>
|
||||
<td>{{ order.paymentStatus || "PENDING" }}</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 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>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -80,39 +96,74 @@
|
||||
<section class="detail-panel" *ngIf="selectedOrder">
|
||||
<div class="detail-header">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="meta-grid">
|
||||
<div><strong>Cliente</strong><span>{{ selectedOrder.customerEmail }}</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>
|
||||
<strong>Cliente</strong><span>{{ selectedOrder.customerEmail }}</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 class="actions-block">
|
||||
<div class="status-editor">
|
||||
<label for="order-status">Stato ordine</label>
|
||||
<select id="order-status" [value]="selectedStatus" (change)="onStatusChange($event)">
|
||||
<option *ngFor="let option of orderStatusOptions" [value]="option">{{ option }}</option>
|
||||
<select
|
||||
id="order-status"
|
||||
[value]="selectedStatus"
|
||||
(change)="onStatusChange($event)"
|
||||
>
|
||||
<option *ngFor="let option of orderStatusOptions" [value]="option">
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<button type="button" (click)="updateStatus()" [disabled]="updatingStatus">
|
||||
{{ updatingStatus ? 'Salvataggio...' : 'Aggiorna stato' }}
|
||||
<button
|
||||
type="button"
|
||||
(click)="updateStatus()"
|
||||
[disabled]="updatingStatus"
|
||||
>
|
||||
{{ updatingStatus ? "Salvataggio..." : "Aggiorna stato" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status-editor">
|
||||
<label for="payment-method">Metodo pagamento</label>
|
||||
<select id="payment-method" [value]="selectedPaymentMethod" (change)="onPaymentMethodChange($event)">
|
||||
<option *ngFor="let option of paymentMethodOptions" [value]="option">{{ option }}</option>
|
||||
<select
|
||||
id="payment-method"
|
||||
[value]="selectedPaymentMethod"
|
||||
(change)="onPaymentMethodChange($event)"
|
||||
>
|
||||
<option
|
||||
*ngFor="let option of paymentMethodOptions"
|
||||
[value]="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
(click)="confirmPayment()"
|
||||
[disabled]="confirmingPayment || selectedOrder.paymentStatus === 'COMPLETED'"
|
||||
[disabled]="
|
||||
confirmingPayment || selectedOrder.paymentStatus === 'COMPLETED'
|
||||
"
|
||||
>
|
||||
{{ confirmingPayment ? 'Invio...' : 'Conferma pagamento' }}
|
||||
{{ confirmingPayment ? "Invio..." : "Conferma pagamento" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,17 +183,26 @@
|
||||
<div class="items">
|
||||
<div class="item" *ngFor="let item of selectedOrder.items">
|
||||
<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">
|
||||
Qta: {{ item.quantity }} |
|
||||
Colore:
|
||||
<span class="color-swatch" *ngIf="isHexColor(item.colorCode)" [style.background-color]="item.colorCode"></span>
|
||||
<span>{{ item.colorCode || '-' }}</span>
|
||||
|
|
||||
Riga: {{ item.lineTotalChf | currency:'CHF':'symbol':'1.2-2' }}
|
||||
Qta: {{ item.quantity }} | Colore:
|
||||
<span
|
||||
class="color-swatch"
|
||||
*ngIf="isHexColor(item.colorCode)"
|
||||
[style.background-color]="item.colorCode"
|
||||
></span>
|
||||
<span>{{ item.colorCode || "-" }}</span>
|
||||
| Riga:
|
||||
{{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }}
|
||||
</p>
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
@@ -160,21 +220,52 @@
|
||||
<p>Caricamento ordini...</p>
|
||||
</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()">
|
||||
<header class="modal-header">
|
||||
<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>
|
||||
|
||||
<div class="modal-grid">
|
||||
<div><strong>Qualità</strong><span>{{ getQualityLabel(selectedOrder.printLayerHeightMm) }}</span></div>
|
||||
<div><strong>Materiale</strong><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>
|
||||
<strong>Qualità</strong
|
||||
><span>{{ getQualityLabel(selectedOrder.printLayerHeightMm) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Materiale</strong
|
||||
><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>
|
||||
|
||||
<h4>Colori file</h4>
|
||||
@@ -182,8 +273,12 @@
|
||||
<div class="file-color-row" *ngFor="let item of selectedOrder.items">
|
||||
<span class="filename">{{ item.originalFilename }}</span>
|
||||
<span class="file-color">
|
||||
<span class="color-swatch" *ngIf="isHexColor(item.colorCode)" [style.background-color]="item.colorCode"></span>
|
||||
{{ item.colorCode || '-' }}
|
||||
<span
|
||||
class="color-swatch"
|
||||
*ngIf="isHexColor(item.colorCode)"
|
||||
[style.background-color]="item.colorCode"
|
||||
></span>
|
||||
{{ item.colorCode || "-" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,10 @@ button:disabled {
|
||||
|
||||
.list-toolbar {
|
||||
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);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject, OnInit } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AdminOrder, AdminOrdersService } from '../services/admin-orders.service';
|
||||
import {
|
||||
AdminOrder,
|
||||
AdminOrdersService,
|
||||
} from '../services/admin-orders.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './admin-dashboard.component.html',
|
||||
styleUrl: './admin-dashboard.component.scss'
|
||||
styleUrl: './admin-dashboard.component.scss',
|
||||
})
|
||||
export class AdminDashboardComponent implements OnInit {
|
||||
private readonly adminOrdersService = inject(AdminOrdersService);
|
||||
@@ -33,10 +36,21 @@ export class AdminDashboardComponent implements OnInit {
|
||||
'IN_PRODUCTION',
|
||||
'SHIPPED',
|
||||
'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 = [
|
||||
'ALL',
|
||||
'PENDING_PAYMENT',
|
||||
@@ -44,7 +58,7 @@ export class AdminDashboardComponent implements OnInit {
|
||||
'IN_PRODUCTION',
|
||||
'SHIPPED',
|
||||
'COMPLETED',
|
||||
'CANCELLED'
|
||||
'CANCELLED',
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -62,8 +76,12 @@ export class AdminDashboardComponent implements OnInit {
|
||||
if (!this.selectedOrder && this.filteredOrders.length > 0) {
|
||||
this.openDetails(this.filteredOrders[0].id);
|
||||
} else if (this.selectedOrder) {
|
||||
const exists = orders.find(order => order.id === this.selectedOrder?.id);
|
||||
const selectedIsVisible = this.filteredOrders.some(order => order.id === this.selectedOrder?.id);
|
||||
const exists = orders.find(
|
||||
(order) => order.id === this.selectedOrder?.id,
|
||||
);
|
||||
const selectedIsVisible = this.filteredOrders.some(
|
||||
(order) => order.id === this.selectedOrder?.id,
|
||||
);
|
||||
if (exists && selectedIsVisible) {
|
||||
this.openDetails(exists.id);
|
||||
} else if (this.filteredOrders.length > 0) {
|
||||
@@ -78,7 +96,7 @@ export class AdminDashboardComponent implements OnInit {
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
this.errorMessage = 'Impossibile caricare gli ordini.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -109,7 +127,7 @@ export class AdminDashboardComponent implements OnInit {
|
||||
error: () => {
|
||||
this.detailLoading = false;
|
||||
this.errorMessage = 'Impossibile caricare il dettaglio ordine.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,7 +137,9 @@ export class AdminDashboardComponent implements OnInit {
|
||||
}
|
||||
|
||||
this.confirmingPayment = true;
|
||||
this.adminOrdersService.confirmPayment(this.selectedOrder.id, this.selectedPaymentMethod).subscribe({
|
||||
this.adminOrdersService
|
||||
.confirmPayment(this.selectedOrder.id, this.selectedPaymentMethod)
|
||||
.subscribe({
|
||||
next: (updatedOrder) => {
|
||||
this.confirmingPayment = false;
|
||||
this.applyOrderUpdate(updatedOrder);
|
||||
@@ -127,19 +147,25 @@ export class AdminDashboardComponent implements OnInit {
|
||||
error: () => {
|
||||
this.confirmingPayment = false;
|
||||
this.errorMessage = 'Conferma pagamento non riuscita.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateStatus(): void {
|
||||
if (!this.selectedOrder || this.updatingStatus || !this.selectedStatus.trim()) {
|
||||
if (
|
||||
!this.selectedOrder ||
|
||||
this.updatingStatus ||
|
||||
!this.selectedStatus.trim()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updatingStatus = true;
|
||||
this.adminOrdersService.updateOrderStatus(this.selectedOrder.id, {
|
||||
status: this.selectedStatus.trim()
|
||||
}).subscribe({
|
||||
this.adminOrdersService
|
||||
.updateOrderStatus(this.selectedOrder.id, {
|
||||
status: this.selectedStatus.trim(),
|
||||
})
|
||||
.subscribe({
|
||||
next: (updatedOrder) => {
|
||||
this.updatingStatus = false;
|
||||
this.applyOrderUpdate(updatedOrder);
|
||||
@@ -147,7 +173,7 @@ export class AdminDashboardComponent implements OnInit {
|
||||
error: () => {
|
||||
this.updatingStatus = false;
|
||||
this.errorMessage = 'Aggiornamento stato ordine non riuscito.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,13 +182,15 @@ export class AdminDashboardComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.adminOrdersService.downloadOrderItemFile(this.selectedOrder.id, itemId).subscribe({
|
||||
this.adminOrdersService
|
||||
.downloadOrderItemFile(this.selectedOrder.id, itemId)
|
||||
.subscribe({
|
||||
next: (blob) => {
|
||||
this.downloadBlob(blob, filename || `order-item-${itemId}`);
|
||||
},
|
||||
error: () => {
|
||||
this.errorMessage = 'Download file non riuscito.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,13 +199,18 @@ export class AdminDashboardComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.adminOrdersService.downloadOrderConfirmation(this.selectedOrder.id).subscribe({
|
||||
this.adminOrdersService
|
||||
.downloadOrderConfirmation(this.selectedOrder.id)
|
||||
.subscribe({
|
||||
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: () => {
|
||||
this.errorMessage = 'Download conferma ordine non riuscito.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -186,13 +219,18 @@ export class AdminDashboardComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.adminOrdersService.downloadOrderInvoice(this.selectedOrder.id).subscribe({
|
||||
this.adminOrdersService
|
||||
.downloadOrderInvoice(this.selectedOrder.id)
|
||||
.subscribe({
|
||||
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: () => {
|
||||
this.errorMessage = 'Download fattura non riuscito.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -228,7 +266,10 @@ export class AdminDashboardComponent implements OnInit {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -236,11 +277,14 @@ export class AdminDashboardComponent implements OnInit {
|
||||
}
|
||||
|
||||
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.selectedOrder = updatedOrder;
|
||||
this.selectedStatus = updatedOrder.status;
|
||||
this.selectedPaymentMethod = updatedOrder.paymentMethod || this.selectedPaymentMethod;
|
||||
this.selectedPaymentMethod =
|
||||
updatedOrder.paymentMethod || this.selectedPaymentMethod;
|
||||
}
|
||||
|
||||
private applyListFiltersAndSelection(): void {
|
||||
@@ -252,7 +296,10 @@ export class AdminDashboardComponent implements OnInit {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -265,9 +312,14 @@ export class AdminDashboardComponent implements OnInit {
|
||||
const paymentStatus = (order.paymentStatus || 'PENDING').toUpperCase();
|
||||
const orderStatus = (order.status || '').toUpperCase();
|
||||
|
||||
const matchesSearch = !term || fullUuid.includes(term) || shortUuid.includes(term);
|
||||
const matchesPayment = this.paymentStatusFilter === 'ALL' || paymentStatus === this.paymentStatusFilter;
|
||||
const matchesOrderStatus = this.orderStatusFilter === 'ALL' || orderStatus === this.orderStatusFilter;
|
||||
const matchesSearch =
|
||||
!term || fullUuid.includes(term) || shortUuid.includes(term);
|
||||
const matchesPayment =
|
||||
this.paymentStatusFilter === 'ALL' ||
|
||||
paymentStatus === this.paymentStatusFilter;
|
||||
const matchesOrderStatus =
|
||||
this.orderStatusFilter === 'ALL' ||
|
||||
orderStatus === this.orderStatusFilter;
|
||||
|
||||
return matchesSearch && matchesPayment && matchesOrderStatus;
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
<h2>Stock filamenti</h2>
|
||||
<p>Gestione materiali, varianti e stock per il calcolatore.</p>
|
||||
</div>
|
||||
<button type="button" (click)="loadData()" [disabled]="loading">Aggiorna</button>
|
||||
<button type="button" (click)="loadData()" [disabled]="loading">
|
||||
Aggiorna
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="alerts">
|
||||
@@ -16,8 +18,12 @@
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>Inserimento rapido</h3>
|
||||
<button type="button" class="panel-toggle" (click)="toggleQuickInsertCollapsed()">
|
||||
{{ quickInsertCollapsed ? 'Espandi' : 'Collassa' }}
|
||||
<button
|
||||
type="button"
|
||||
class="panel-toggle"
|
||||
(click)="toggleQuickInsertCollapsed()"
|
||||
>
|
||||
{{ quickInsertCollapsed ? "Espandi" : "Collassa" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +34,11 @@
|
||||
<div class="form-grid">
|
||||
<label class="form-field form-field--wide">
|
||||
<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 class="form-field form-field--wide">
|
||||
<span>Etichetta tecnico</span>
|
||||
@@ -52,8 +62,12 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="button" (click)="createMaterial()" [disabled]="creatingMaterial">
|
||||
{{ creatingMaterial ? 'Salvataggio...' : 'Aggiungi materiale' }}
|
||||
<button
|
||||
type="button"
|
||||
(click)="createMaterial()"
|
||||
[disabled]="creatingMaterial"
|
||||
>
|
||||
{{ creatingMaterial ? "Salvataggio..." : "Aggiungi materiale" }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@@ -63,22 +77,37 @@
|
||||
<label class="form-field">
|
||||
<span>Materiale</span>
|
||||
<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 }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<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 class="form-field">
|
||||
<span>Colore</span>
|
||||
<input type="text" [(ngModel)]="newVariant.colorName" placeholder="Nero, Bianco..." />
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newVariant.colorName"
|
||||
placeholder="Nero, Bianco..."
|
||||
/>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Hex colore</span>
|
||||
<input type="text" [(ngModel)]="newVariant.colorHex" placeholder="#1A1A1A" />
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newVariant.colorHex"
|
||||
placeholder="#1A1A1A"
|
||||
/>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Finitura</span>
|
||||
@@ -93,19 +122,40 @@
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Brand</span>
|
||||
<input type="text" [(ngModel)]="newVariant.brand" placeholder="Bambu, SUNLU..." />
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newVariant.brand"
|
||||
placeholder="Bambu, SUNLU..."
|
||||
/>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<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 class="form-field">
|
||||
<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 class="form-field">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -125,12 +175,26 @@
|
||||
</div>
|
||||
|
||||
<p class="variant-meta">
|
||||
Stock spools: <strong>{{ newVariant.stockSpools | number:'1.0-3' }}</strong> |
|
||||
Filamento totale: <strong>{{ computeStockFilamentGrams(newVariant.stockSpools, newVariant.spoolNetKg) | number:'1.0-0' }} g</strong>
|
||||
Stock spools:
|
||||
<strong>{{ newVariant.stockSpools | number: "1.0-3" }}</strong> |
|
||||
Filamento totale:
|
||||
<strong
|
||||
>{{
|
||||
computeStockFilamentGrams(
|
||||
newVariant.stockSpools,
|
||||
newVariant.spoolNetKg
|
||||
) | number: "1.0-0"
|
||||
}}
|
||||
g</strong
|
||||
>
|
||||
</p>
|
||||
|
||||
<button type="button" (click)="createVariant()" [disabled]="creatingVariant || !materials.length">
|
||||
{{ creatingVariant ? 'Salvataggio...' : 'Aggiungi variante' }}
|
||||
<button
|
||||
type="button"
|
||||
(click)="createVariant()"
|
||||
[disabled]="creatingVariant || !materials.length"
|
||||
>
|
||||
{{ creatingVariant ? "Salvataggio..." : "Aggiungi variante" }}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
@@ -140,37 +204,68 @@
|
||||
<section class="panel">
|
||||
<h3>Varianti filamento</h3>
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
class="expand-toggle"
|
||||
(click)="toggleVariantExpanded(variant.id)"
|
||||
[attr.aria-expanded]="isVariantExpanded(variant.id)">
|
||||
{{ isVariantExpanded(variant.id) ? '▾' : '▸' }}
|
||||
[attr.aria-expanded]="isVariantExpanded(variant.id)"
|
||||
>
|
||||
{{ isVariantExpanded(variant.id) ? "▾" : "▸" }}
|
||||
</button>
|
||||
|
||||
<div class="variant-head-main">
|
||||
<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-dot" [style.background-color]="getVariantColorHex(variant)"></span>
|
||||
{{ variant.colorName || 'N/D' }}
|
||||
<span
|
||||
class="color-dot"
|
||||
[style.background-color]="getVariantColorHex(variant)"
|
||||
></span>
|
||||
{{ variant.colorName || "N/D" }}
|
||||
</span>
|
||||
<span>Stock spools: {{ variant.stockSpools | number:'1.0-3' }}</span>
|
||||
<span>Filamento: {{ computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) | number:'1.0-0' }} g</span>
|
||||
<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 class="variant-head-actions">
|
||||
<span class="badge low" *ngIf="isLowStock(variant)">Stock basso</span>
|
||||
<span class="badge ok" *ngIf="!isLowStock(variant)">Stock ok</span>
|
||||
<span class="badge low" *ngIf="isLowStock(variant)"
|
||||
>Stock basso</span
|
||||
>
|
||||
<span class="badge ok" *ngIf="!isLowStock(variant)"
|
||||
>Stock ok</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-delete"
|
||||
(click)="openDeleteVariant(variant)"
|
||||
[disabled]="deletingVariantIds.has(variant.id)">
|
||||
{{ deletingVariantIds.has(variant.id) ? 'Eliminazione...' : 'Elimina' }}
|
||||
[disabled]="deletingVariantIds.has(variant.id)"
|
||||
>
|
||||
{{
|
||||
deletingVariantIds.has(variant.id)
|
||||
? "Eliminazione..."
|
||||
: "Elimina"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,7 +274,10 @@
|
||||
<label class="form-field">
|
||||
<span>Materiale</span>
|
||||
<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 }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -213,15 +311,32 @@
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<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 class="form-field">
|
||||
<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 class="form-field">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -241,33 +356,57 @@
|
||||
</div>
|
||||
|
||||
<p class="variant-meta" *ngIf="isVariantExpanded(variant.id)">
|
||||
Stock spools: <strong>{{ variant.stockSpools | number:'1.0-3' }}</strong> |
|
||||
Filamento totale: <strong>{{ computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) | number:'1.0-0' }} g</strong>
|
||||
Stock spools:
|
||||
<strong>{{ variant.stockSpools | number: "1.0-3" }}</strong> |
|
||||
Filamento totale:
|
||||
<strong
|
||||
>{{
|
||||
computeStockFilamentGrams(
|
||||
variant.stockSpools,
|
||||
variant.spoolNetKg
|
||||
) | number: "1.0-0"
|
||||
}}
|
||||
g</strong
|
||||
>
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
*ngIf="isVariantExpanded(variant.id)"
|
||||
(click)="saveVariant(variant)"
|
||||
[disabled]="savingVariantIds.has(variant.id)">
|
||||
{{ savingVariantIds.has(variant.id) ? 'Salvataggio...' : 'Salva variante' }}
|
||||
[disabled]="savingVariantIds.has(variant.id)"
|
||||
>
|
||||
{{
|
||||
savingVariantIds.has(variant.id)
|
||||
? "Salvataggio..."
|
||||
: "Salva variante"
|
||||
}}
|
||||
</button>
|
||||
</article>
|
||||
</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 class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>Materiali</h3>
|
||||
<button type="button" class="panel-toggle" (click)="toggleMaterialsCollapsed()">
|
||||
{{ materialsCollapsed ? 'Espandi' : 'Collassa' }}
|
||||
<button
|
||||
type="button"
|
||||
class="panel-toggle"
|
||||
(click)="toggleMaterialsCollapsed()"
|
||||
>
|
||||
{{ materialsCollapsed ? "Espandi" : "Collassa" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!materialsCollapsed; else materialsCollapsedTpl">
|
||||
<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">
|
||||
<label class="form-field form-field--wide">
|
||||
<span>Codice</span>
|
||||
@@ -275,7 +414,11 @@
|
||||
</label>
|
||||
<label class="form-field form-field--wide">
|
||||
<span>Etichetta tecnico</span>
|
||||
<input type="text" [(ngModel)]="material.technicalTypeLabel" [disabled]="!material.isTechnical" />
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="material.technicalTypeLabel"
|
||||
[disabled]="!material.isTechnical"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -290,12 +433,22 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="button" (click)="saveMaterial(material)" [disabled]="savingMaterialIds.has(material.id)">
|
||||
{{ savingMaterialIds.has(material.id) ? 'Salvataggio...' : 'Salva materiale' }}
|
||||
<button
|
||||
type="button"
|
||||
(click)="saveMaterial(material)"
|
||||
[disabled]="savingMaterialIds.has(material.id)"
|
||||
>
|
||||
{{
|
||||
savingMaterialIds.has(material.id)
|
||||
? "Salvataggio..."
|
||||
: "Salva materiale"
|
||||
}}
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
<p class="muted" *ngIf="materials.length === 0">Nessun materiale configurato.</p>
|
||||
<p class="muted" *ngIf="materials.length === 0">
|
||||
Nessun materiale configurato.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -313,19 +466,38 @@
|
||||
<p class="muted">Sezione collassata.</p>
|
||||
</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">
|
||||
<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>
|
||||
<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
|
||||
type="button"
|
||||
class="btn-delete"
|
||||
(click)="confirmDeleteVariant()"
|
||||
[disabled]="variantToDelete && deletingVariantIds.has(variantToDelete.id)">
|
||||
{{ variantToDelete && deletingVariantIds.has(variantToDelete.id) ? 'Eliminazione...' : 'Conferma elimina' }}
|
||||
[disabled]="variantToDelete && deletingVariantIds.has(variantToDelete.id)"
|
||||
>
|
||||
{{
|
||||
variantToDelete && deletingVariantIds.has(variantToDelete.id)
|
||||
? "Eliminazione..."
|
||||
: "Conferma elimina"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
AdminFilamentVariant,
|
||||
AdminOperationsService,
|
||||
AdminUpsertFilamentMaterialTypePayload,
|
||||
AdminUpsertFilamentVariantPayload
|
||||
AdminUpsertFilamentVariantPayload,
|
||||
} from '../services/admin-operations.service';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { getColorHex } from '../../../core/constants/colors.const';
|
||||
@@ -16,7 +16,7 @@ import { getColorHex } from '../../../core/constants/colors.const';
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './admin-filament-stock.component.html',
|
||||
styleUrl: './admin-filament-stock.component.scss'
|
||||
styleUrl: './admin-filament-stock.component.scss',
|
||||
})
|
||||
export class AdminFilamentStockComponent implements OnInit {
|
||||
private readonly adminOperationsService = inject(AdminOperationsService);
|
||||
@@ -40,7 +40,7 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
materialCode: '',
|
||||
isFlexible: false,
|
||||
isTechnical: false,
|
||||
technicalTypeLabel: ''
|
||||
technicalTypeLabel: '',
|
||||
};
|
||||
|
||||
newVariant: AdminUpsertFilamentVariantPayload = {
|
||||
@@ -55,7 +55,7 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
costChfPerKg: 0,
|
||||
stockSpools: 0,
|
||||
spoolNetKg: 1,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -69,13 +69,13 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
|
||||
forkJoin({
|
||||
materials: this.adminOperationsService.getFilamentMaterials(),
|
||||
variants: this.adminOperationsService.getFilamentVariants()
|
||||
variants: this.adminOperationsService.getFilamentVariants(),
|
||||
}).subscribe({
|
||||
next: ({ materials, variants }) => {
|
||||
this.materials = this.sortMaterials(materials);
|
||||
this.variants = this.sortVariants(variants);
|
||||
const existingIds = new Set(this.variants.map(v => v.id));
|
||||
this.expandedVariantIds.forEach(id => {
|
||||
const existingIds = new Set(this.variants.map((v) => v.id));
|
||||
this.expandedVariantIds.forEach((id) => {
|
||||
if (!existingIds.has(id)) {
|
||||
this.expandedVariantIds.delete(id);
|
||||
}
|
||||
@@ -87,8 +87,11 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
},
|
||||
error: (err) => {
|
||||
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,
|
||||
technicalTypeLabel: this.newMaterial.isTechnical
|
||||
? (this.newMaterial.technicalTypeLabel || '').trim()
|
||||
: ''
|
||||
: '',
|
||||
};
|
||||
|
||||
this.adminOperationsService.createFilamentMaterial(payload).subscribe({
|
||||
@@ -120,15 +123,18 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
materialCode: '',
|
||||
isFlexible: false,
|
||||
isTechnical: false,
|
||||
technicalTypeLabel: ''
|
||||
technicalTypeLabel: '',
|
||||
};
|
||||
this.creatingMaterial = false;
|
||||
this.successMessage = 'Materiale aggiunto.';
|
||||
},
|
||||
error: (err) => {
|
||||
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(),
|
||||
isFlexible: !!material.isFlexible,
|
||||
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) => {
|
||||
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) => {
|
||||
if (variant.materialTypeId !== updated.id) {
|
||||
@@ -162,7 +172,7 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
materialCode: updated.materialCode,
|
||||
materialIsFlexible: updated.isFlexible,
|
||||
materialIsTechnical: updated.isTechnical,
|
||||
materialTechnicalTypeLabel: updated.technicalTypeLabel
|
||||
materialTechnicalTypeLabel: updated.technicalTypeLabel,
|
||||
};
|
||||
});
|
||||
this.savingMaterialIds.delete(material.id);
|
||||
@@ -170,8 +180,11 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
},
|
||||
error: (err) => {
|
||||
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) => {
|
||||
this.variants = this.sortVariants([...this.variants, created]);
|
||||
this.newVariant = {
|
||||
materialTypeId: this.newVariant.materialTypeId || this.materials[0]?.id || 0,
|
||||
materialTypeId:
|
||||
this.newVariant.materialTypeId || this.materials[0]?.id || 0,
|
||||
variantDisplayName: '',
|
||||
colorName: '',
|
||||
colorHex: '',
|
||||
@@ -200,15 +214,18 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
costChfPerKg: 0,
|
||||
stockSpools: 0,
|
||||
spoolNetKg: 1,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
};
|
||||
this.creatingVariant = false;
|
||||
this.successMessage = 'Variante aggiunta.';
|
||||
},
|
||||
error: (err) => {
|
||||
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);
|
||||
|
||||
const payload = this.toVariantPayload(variant);
|
||||
this.adminOperationsService.updateFilamentVariant(variant.id, payload).subscribe({
|
||||
this.adminOperationsService
|
||||
.updateFilamentVariant(variant.id, payload)
|
||||
.subscribe({
|
||||
next: (updated) => {
|
||||
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.successMessage = 'Variante aggiornata.';
|
||||
},
|
||||
error: (err) => {
|
||||
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 {
|
||||
return this.computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) < 1000;
|
||||
return (
|
||||
this.computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) <
|
||||
1000
|
||||
);
|
||||
}
|
||||
|
||||
computeStockKg(stockSpools?: number, spoolNetKg?: number): number {
|
||||
const spools = Number(stockSpools ?? 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 spools * netKg;
|
||||
@@ -298,7 +328,7 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
|
||||
this.adminOperationsService.deleteFilamentVariant(variant.id).subscribe({
|
||||
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.deletingVariantIds.delete(variant.id);
|
||||
this.variantToDelete = null;
|
||||
@@ -306,8 +336,11 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
},
|
||||
error: (err) => {
|
||||
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;
|
||||
}
|
||||
|
||||
private toVariantPayload(source: AdminUpsertFilamentVariantPayload | AdminFilamentVariant): AdminUpsertFilamentVariantPayload {
|
||||
private toVariantPayload(
|
||||
source: AdminUpsertFilamentVariantPayload | AdminFilamentVariant,
|
||||
): AdminUpsertFilamentVariantPayload {
|
||||
return {
|
||||
materialTypeId: Number(source.materialTypeId),
|
||||
variantDisplayName: (source.variantDisplayName || '').trim(),
|
||||
@@ -332,21 +367,31 @@ export class AdminFilamentStockComponent implements OnInit {
|
||||
costChfPerKg: Number(source.costChfPerKg ?? 0),
|
||||
stockSpools: Number(source.stockSpools ?? 0),
|
||||
spoolNetKg: Number(source.spoolNetKg ?? 0),
|
||||
isActive: source.isActive !== false
|
||||
isActive: source.isActive !== false,
|
||||
};
|
||||
}
|
||||
|
||||
private sortMaterials(materials: AdminFilamentMaterialType[]): AdminFilamentMaterialType[] {
|
||||
return [...materials].sort((a, b) => a.materialCode.localeCompare(b.materialCode));
|
||||
private sortMaterials(
|
||||
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) => {
|
||||
const byMaterial = (a.materialCode || '').localeCompare(b.materialCode || '');
|
||||
const byMaterial = (a.materialCode || '').localeCompare(
|
||||
b.materialCode || '',
|
||||
);
|
||||
if (byMaterial !== 0) {
|
||||
return byMaterial;
|
||||
}
|
||||
return (a.variantDisplayName || '').localeCompare(b.variantDisplayName || '');
|
||||
return (a.variantDisplayName || '').localeCompare(
|
||||
b.variantDisplayName || '',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,11 @@
|
||||
required
|
||||
/>
|
||||
|
||||
<button type="submit" [disabled]="loading || !password.trim() || lockSecondsRemaining > 0">
|
||||
{{ loading ? 'Accesso...' : 'Accedi' }}
|
||||
<button
|
||||
type="submit"
|
||||
[disabled]="loading || !password.trim() || lockSecondsRemaining > 0"
|
||||
>
|
||||
{{ loading ? "Accesso..." : "Accedi" }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ import { Component, inject, OnDestroy } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
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']);
|
||||
|
||||
@@ -12,7 +15,7 @@ const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './admin-login.component.html',
|
||||
styleUrl: './admin-login.component.scss'
|
||||
styleUrl: './admin-login.component.scss',
|
||||
})
|
||||
export class AdminLoginComponent implements OnDestroy {
|
||||
private readonly authService = inject(AdminAuthService);
|
||||
@@ -26,7 +29,11 @@ export class AdminLoginComponent implements OnDestroy {
|
||||
private lockTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
submit(): void {
|
||||
if (!this.password.trim() || this.loading || this.lockSecondsRemaining > 0) {
|
||||
if (
|
||||
!this.password.trim() ||
|
||||
this.loading ||
|
||||
this.lockSecondsRemaining > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -53,7 +60,7 @@ export class AdminLoginComponent implements OnDestroy {
|
||||
error: (error: HttpErrorResponse) => {
|
||||
this.loading = false;
|
||||
this.handleLoginFailure(this.extractRetryAfterSeconds(error));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,14 @@
|
||||
<h2>Sessioni quote</h2>
|
||||
<p>Sessioni create dal configuratore con stato e conversione ordine.</p>
|
||||
</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>
|
||||
|
||||
<p class="error" *ngIf="errorMessage">{{ errorMessage }}</p>
|
||||
@@ -26,41 +33,73 @@
|
||||
<tbody>
|
||||
<ng-container *ngFor="let session of sessions">
|
||||
<tr>
|
||||
<td [title]="session.id">{{ session.id | slice:0:8 }}</td>
|
||||
<td>{{ session.createdAt | date:'short' }}</td>
|
||||
<td>{{ session.expiresAt | date:'short' }}</td>
|
||||
<td [title]="session.id">{{ session.id | slice: 0 : 8 }}</td>
|
||||
<td>{{ session.createdAt | date: "short" }}</td>
|
||||
<td>{{ session.expiresAt | date: "short" }}</td>
|
||||
<td>{{ session.materialCode }}</td>
|
||||
<td>{{ session.status }}</td>
|
||||
<td>{{ session.convertedOrderId || '-' }}</td>
|
||||
<td>{{ session.convertedOrderId || "-" }}</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
(click)="toggleSessionDetail(session)">
|
||||
{{ isDetailOpen(session.id) ? 'Nascondi' : 'Vedi' }}
|
||||
(click)="toggleSessionDetail(session)"
|
||||
>
|
||||
{{ isDetailOpen(session.id) ? "Nascondi" : "Vedi" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
(click)="deleteSession(session)"
|
||||
[disabled]="isDeletingSession(session.id) || !!session.convertedOrderId"
|
||||
[title]="session.convertedOrderId ? 'Sessione collegata a un ordine, non eliminabile.' : ''">
|
||||
{{ isDeletingSession(session.id) ? 'Eliminazione...' : 'Elimina' }}
|
||||
[disabled]="
|
||||
isDeletingSession(session.id) || !!session.convertedOrderId
|
||||
"
|
||||
[title]="
|
||||
session.convertedOrderId
|
||||
? 'Sessione collegata a un ordine, non eliminabile.'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
{{
|
||||
isDeletingSession(session.id) ? "Eliminazione..." : "Elimina"
|
||||
}}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="isDetailOpen(session.id)">
|
||||
<td colspan="7" class="detail-cell">
|
||||
<div *ngIf="isLoadingDetail(session.id)">Caricamento dettaglio...</div>
|
||||
<div *ngIf="!isLoadingDetail(session.id) && getSessionDetail(session.id) as detail" class="detail-box">
|
||||
<div *ngIf="isLoadingDetail(session.id)">
|
||||
Caricamento dettaglio...
|
||||
</div>
|
||||
<div
|
||||
*ngIf="
|
||||
!isLoadingDetail(session.id) &&
|
||||
getSessionDetail(session.id) as detail
|
||||
"
|
||||
class="detail-box"
|
||||
>
|
||||
<div class="detail-summary">
|
||||
<div><strong>Elementi:</strong> {{ detail.items.length }}</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>
|
||||
<strong>Elementi:</strong> {{ detail.items.length }}
|
||||
</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>
|
||||
|
||||
<table class="detail-table" *ngIf="detail.items.length > 0; else noItemsTpl">
|
||||
<table
|
||||
class="detail-table"
|
||||
*ngIf="detail.items.length > 0; else noItemsTpl"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
@@ -76,9 +115,15 @@
|
||||
<td>{{ item.originalFilename }}</td>
|
||||
<td>{{ item.quantity }}</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.unitPriceChf | currency:'CHF' }}</td>
|
||||
<td>{{ item.unitPriceChf | currency: "CHF" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Component, inject, OnInit } from '@angular/core';
|
||||
import {
|
||||
AdminOperationsService,
|
||||
AdminQuoteSession,
|
||||
AdminQuoteSessionDetail
|
||||
AdminQuoteSessionDetail,
|
||||
} from '../services/admin-operations.service';
|
||||
|
||||
@Component({
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './admin-sessions.component.html',
|
||||
styleUrl: './admin-sessions.component.scss'
|
||||
styleUrl: './admin-sessions.component.scss',
|
||||
})
|
||||
export class AdminSessionsComponent implements OnInit {
|
||||
private readonly adminOperationsService = inject(AdminOperationsService);
|
||||
@@ -41,7 +41,7 @@ export class AdminSessionsComponent implements OnInit {
|
||||
error: () => {
|
||||
this.loading = false;
|
||||
this.errorMessage = 'Impossibile caricare le sessioni.';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export class AdminSessionsComponent implements OnInit {
|
||||
}
|
||||
|
||||
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) {
|
||||
return;
|
||||
@@ -69,8 +69,11 @@ export class AdminSessionsComponent implements OnInit {
|
||||
},
|
||||
error: (err) => {
|
||||
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;
|
||||
if (this.sessionDetailsById[session.id] || this.loadingDetailSessionIds.has(session.id)) {
|
||||
if (
|
||||
this.sessionDetailsById[session.id] ||
|
||||
this.loadingDetailSessionIds.has(session.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,14 +100,17 @@ export class AdminSessionsComponent implements OnInit {
|
||||
next: (detail) => {
|
||||
this.sessionDetailsById = {
|
||||
...this.sessionDetailsById,
|
||||
[session.id]: detail
|
||||
[session.id]: detail,
|
||||
};
|
||||
this.loadingDetailSessionIds.delete(session.id);
|
||||
},
|
||||
error: (err) => {
|
||||
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.',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,12 @@
|
||||
<div class="menu-scroll">
|
||||
<nav class="menu">
|
||||
<a routerLink="orders" routerLinkActive="active">Ordini</a>
|
||||
<a routerLink="filament-stock" routerLinkActive="active">Stock filamenti</a>
|
||||
<a routerLink="contact-requests" routerLinkActive="active">Richieste contatto</a>
|
||||
<a routerLink="filament-stock" routerLinkActive="active"
|
||||
>Stock filamenti</a
|
||||
>
|
||||
<a routerLink="contact-requests" routerLinkActive="active"
|
||||
>Richieste contatto</a
|
||||
>
|
||||
<a routerLink="sessions" routerLinkActive="active">Sessioni</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -54,7 +54,10 @@
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--color-border);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -78,7 +81,9 @@
|
||||
padding: var(--space-3) var(--space-4);
|
||||
font-weight: 600;
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
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';
|
||||
|
||||
const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
|
||||
@@ -10,7 +16,7 @@ const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']);
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
|
||||
templateUrl: './admin-shell.component.html',
|
||||
styleUrl: './admin-shell.component.scss'
|
||||
styleUrl: './admin-shell.component.scss',
|
||||
})
|
||||
export class AdminShellComponent {
|
||||
private readonly adminAuthService = inject(AdminAuthService);
|
||||
@@ -24,7 +30,7 @@ export class AdminShellComponent {
|
||||
},
|
||||
error: () => {
|
||||
void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,23 +11,31 @@ export interface AdminAuthResponse {
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AdminAuthService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = `${environment.apiUrl}/api/admin/auth`;
|
||||
|
||||
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> {
|
||||
return this.http.post<void>(`${this.baseUrl}/logout`, {}, { withCredentials: true });
|
||||
return this.http.post<void>(
|
||||
`${this.baseUrl}/logout`,
|
||||
{},
|
||||
{ withCredentials: true },
|
||||
);
|
||||
}
|
||||
|
||||
me(): Observable<boolean> {
|
||||
return this.http.get<AdminAuthResponse>(`${this.baseUrl}/me`, { withCredentials: true }).pipe(
|
||||
map((response) => Boolean(response?.authenticated))
|
||||
);
|
||||
return this.http
|
||||
.get<AdminAuthResponse>(`${this.baseUrl}/me`, { withCredentials: true })
|
||||
.pipe(map((response) => Boolean(response?.authenticated)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,82 +145,138 @@ export interface AdminQuoteSessionDetail {
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AdminOperationsService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = `${environment.apiUrl}/api/admin`;
|
||||
|
||||
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[]> {
|
||||
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[]> {
|
||||
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> {
|
||||
return this.http.post<AdminFilamentMaterialType>(`${this.baseUrl}/filaments/materials`, payload, { withCredentials: true });
|
||||
createFilamentMaterial(
|
||||
payload: AdminUpsertFilamentMaterialTypePayload,
|
||||
): Observable<AdminFilamentMaterialType> {
|
||||
return this.http.post<AdminFilamentMaterialType>(
|
||||
`${this.baseUrl}/filaments/materials`,
|
||||
payload,
|
||||
{ withCredentials: true },
|
||||
);
|
||||
}
|
||||
|
||||
updateFilamentMaterial(materialId: number, payload: AdminUpsertFilamentMaterialTypePayload): Observable<AdminFilamentMaterialType> {
|
||||
return this.http.put<AdminFilamentMaterialType>(`${this.baseUrl}/filaments/materials/${materialId}`, payload, { withCredentials: true });
|
||||
updateFilamentMaterial(
|
||||
materialId: number,
|
||||
payload: AdminUpsertFilamentMaterialTypePayload,
|
||||
): Observable<AdminFilamentMaterialType> {
|
||||
return this.http.put<AdminFilamentMaterialType>(
|
||||
`${this.baseUrl}/filaments/materials/${materialId}`,
|
||||
payload,
|
||||
{ withCredentials: true },
|
||||
);
|
||||
}
|
||||
|
||||
createFilamentVariant(payload: AdminUpsertFilamentVariantPayload): Observable<AdminFilamentVariant> {
|
||||
return this.http.post<AdminFilamentVariant>(`${this.baseUrl}/filaments/variants`, payload, { withCredentials: true });
|
||||
createFilamentVariant(
|
||||
payload: AdminUpsertFilamentVariantPayload,
|
||||
): Observable<AdminFilamentVariant> {
|
||||
return this.http.post<AdminFilamentVariant>(
|
||||
`${this.baseUrl}/filaments/variants`,
|
||||
payload,
|
||||
{ withCredentials: true },
|
||||
);
|
||||
}
|
||||
|
||||
updateFilamentVariant(variantId: number, payload: AdminUpsertFilamentVariantPayload): Observable<AdminFilamentVariant> {
|
||||
return this.http.put<AdminFilamentVariant>(`${this.baseUrl}/filaments/variants/${variantId}`, payload, { withCredentials: true });
|
||||
updateFilamentVariant(
|
||||
variantId: number,
|
||||
payload: AdminUpsertFilamentVariantPayload,
|
||||
): Observable<AdminFilamentVariant> {
|
||||
return this.http.put<AdminFilamentVariant>(
|
||||
`${this.baseUrl}/filaments/variants/${variantId}`,
|
||||
payload,
|
||||
{ withCredentials: true },
|
||||
);
|
||||
}
|
||||
|
||||
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[]> {
|
||||
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> {
|
||||
return this.http.get<AdminContactRequestDetail>(`${this.baseUrl}/contact-requests/${requestId}`, { withCredentials: true });
|
||||
getContactRequestDetail(
|
||||
requestId: string,
|
||||
): Observable<AdminContactRequestDetail> {
|
||||
return this.http.get<AdminContactRequestDetail>(
|
||||
`${this.baseUrl}/contact-requests/${requestId}`,
|
||||
{ withCredentials: true },
|
||||
);
|
||||
}
|
||||
|
||||
updateContactRequestStatus(
|
||||
requestId: string,
|
||||
payload: AdminUpdateContactRequestStatusPayload
|
||||
payload: AdminUpdateContactRequestStatusPayload,
|
||||
): Observable<AdminContactRequestDetail> {
|
||||
return this.http.patch<AdminContactRequestDetail>(
|
||||
`${this.baseUrl}/contact-requests/${requestId}/status`,
|
||||
payload,
|
||||
{ withCredentials: true }
|
||||
{ withCredentials: true },
|
||||
);
|
||||
}
|
||||
|
||||
downloadContactRequestAttachment(requestId: string, attachmentId: string): Observable<Blob> {
|
||||
return this.http.get(`${this.baseUrl}/contact-requests/${requestId}/attachments/${attachmentId}/file`, {
|
||||
downloadContactRequestAttachment(
|
||||
requestId: string,
|
||||
attachmentId: string,
|
||||
): Observable<Blob> {
|
||||
return this.http.get(
|
||||
`${this.baseUrl}/contact-requests/${requestId}/attachments/${attachmentId}/file`,
|
||||
{
|
||||
withCredentials: true,
|
||||
responseType: 'blob'
|
||||
});
|
||||
responseType: 'blob',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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> {
|
||||
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> {
|
||||
return this.http.get<AdminQuoteSessionDetail>(
|
||||
`${environment.apiUrl}/api/quote-sessions/${sessionId}`,
|
||||
{ withCredentials: true }
|
||||
{ withCredentials: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export interface AdminUpdateOrderStatusPayload {
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AdminOrdersService {
|
||||
private readonly http = inject(HttpClient);
|
||||
@@ -49,35 +49,54 @@ export class AdminOrdersService {
|
||||
}
|
||||
|
||||
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> {
|
||||
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> {
|
||||
return this.http.post<AdminOrder>(`${this.baseUrl}/${orderId}/status`, payload, { withCredentials: true });
|
||||
updateOrderStatus(
|
||||
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> {
|
||||
return this.http.get(`${this.baseUrl}/${orderId}/items/${orderItemId}/file`, {
|
||||
downloadOrderItemFile(
|
||||
orderId: string,
|
||||
orderItemId: string,
|
||||
): Observable<Blob> {
|
||||
return this.http.get(
|
||||
`${this.baseUrl}/${orderId}/items/${orderItemId}/file`,
|
||||
{
|
||||
withCredentials: true,
|
||||
responseType: 'blob'
|
||||
});
|
||||
responseType: 'blob',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
downloadOrderConfirmation(orderId: string): Observable<Blob> {
|
||||
return this.http.get(`${this.baseUrl}/${orderId}/documents/confirmation`, {
|
||||
withCredentials: true,
|
||||
responseType: 'blob'
|
||||
responseType: 'blob',
|
||||
});
|
||||
}
|
||||
|
||||
downloadOrderInvoice(orderId: string): Observable<Blob> {
|
||||
return this.http.get(`${this.baseUrl}/${orderId}/documents/invoice`, {
|
||||
withCredentials: true,
|
||||
responseType: 'blob'
|
||||
responseType: 'blob',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<div class="container hero">
|
||||
<h1>{{ 'CALC.TITLE' | translate }}</h1>
|
||||
<p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p>
|
||||
<h1>{{ "CALC.TITLE" | translate }}</h1>
|
||||
<p class="subtitle">{{ "CALC.SUBTITLE" | translate }}</p>
|
||||
|
||||
@if (error()) {
|
||||
<app-alert type="error">{{ errorKey() | translate }}</app-alert>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (step() === 'success') {
|
||||
@if (step() === "success") {
|
||||
<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>
|
||||
} @else {
|
||||
<div class="container content-grid">
|
||||
@@ -17,15 +20,19 @@
|
||||
<div class="col-input">
|
||||
<app-card>
|
||||
<div class="mode-selector">
|
||||
<div class="mode-option"
|
||||
<div
|
||||
class="mode-option"
|
||||
[class.active]="mode() === 'easy'"
|
||||
(click)="mode.set('easy')">
|
||||
{{ 'CALC.MODE_EASY' | translate }}
|
||||
(click)="mode.set('easy')"
|
||||
>
|
||||
{{ "CALC.MODE_EASY" | translate }}
|
||||
</div>
|
||||
<div class="mode-option"
|
||||
<div
|
||||
class="mode-option"
|
||||
[class.active]="mode() === 'advanced'"
|
||||
(click)="mode.set('advanced')">
|
||||
{{ 'CALC.MODE_ADVANCED' | translate }}
|
||||
(click)="mode.set('advanced')"
|
||||
>
|
||||
{{ "CALC.MODE_ADVANCED" | translate }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,13 +48,14 @@
|
||||
|
||||
<!-- Right Column: Result or Info -->
|
||||
<div class="col-result" #resultCol>
|
||||
|
||||
@if (loading()) {
|
||||
<app-card class="loading-state">
|
||||
<div class="loader-content">
|
||||
<div class="spinner"></div>
|
||||
<h3 class="loading-title">{{ 'CALC.ANALYZING_TITLE' | translate }}</h3>
|
||||
<p class="loading-text">{{ 'CALC.ANALYZING_TEXT' | translate }}</p>
|
||||
<h3 class="loading-title">
|
||||
{{ "CALC.ANALYZING_TITLE" | translate }}
|
||||
</h3>
|
||||
<p class="loading-text">{{ "CALC.ANALYZING_TEXT" | translate }}</p>
|
||||
</div>
|
||||
</app-card>
|
||||
} @else if (result()) {
|
||||
@@ -59,11 +67,11 @@
|
||||
></app-quote-result>
|
||||
} @else {
|
||||
<app-card>
|
||||
<h3>{{ 'CALC.BENEFITS_TITLE' | translate }}</h3>
|
||||
<h3>{{ "CALC.BENEFITS_TITLE" | translate }}</h3>
|
||||
<ul class="benefits">
|
||||
<li>{{ 'CALC.BENEFITS_1' | translate }}</li>
|
||||
<li>{{ 'CALC.BENEFITS_2' | translate }}</li>
|
||||
<li>{{ 'CALC.BENEFITS_3' | translate }}</li>
|
||||
<li>{{ "CALC.BENEFITS_1" | translate }}</li>
|
||||
<li>{{ "CALC.BENEFITS_2" | translate }}</li>
|
||||
<li>{{ "CALC.BENEFITS_3" | translate }}</li>
|
||||
</ul>
|
||||
</app-card>
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
.hero { padding: var(--space-12) 0; text-align: center; }
|
||||
.subtitle { font-size: 1.25rem; color: var(--color-text-muted); max-width: 600px; margin: 0 auto; }
|
||||
.hero {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-6);
|
||||
@media(min-width: 768px) {
|
||||
@media (min-width: 768px) {
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
@@ -13,7 +21,7 @@
|
||||
|
||||
.centered-col {
|
||||
align-self: flex-start; /* Default */
|
||||
@media(min-width: 768px) {
|
||||
@media (min-width: 768px) {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
@@ -56,18 +64,23 @@
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
|
||||
&:hover { color: var(--color-text); }
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--color-brand);
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
text-align: center;
|
||||
@@ -105,6 +118,10 @@
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 { TranslateModule } from '@ngx-translate/core';
|
||||
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 { UploadFormComponent } from './components/upload-form/upload-form.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 { Router, ActivatedRoute } from '@angular/router';
|
||||
import { LanguageService } from '../../core/services/language.service';
|
||||
@@ -16,9 +26,17 @@ import { LanguageService } from '../../core/services/language.service';
|
||||
@Component({
|
||||
selector: 'app-calculator-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, SuccessStateComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
TranslateModule,
|
||||
AppCardComponent,
|
||||
AppAlertComponent,
|
||||
UploadFormComponent,
|
||||
QuoteResultComponent,
|
||||
SuccessStateComponent,
|
||||
],
|
||||
templateUrl: './calculator-page.component.html',
|
||||
styleUrl: './calculator-page.component.scss'
|
||||
styleUrl: './calculator-page.component.scss',
|
||||
})
|
||||
export class CalculatorPageComponent implements OnInit {
|
||||
mode = signal<any>('easy');
|
||||
@@ -39,17 +57,17 @@ export class CalculatorPageComponent implements OnInit {
|
||||
private estimator: QuoteEstimatorService,
|
||||
private router: Router,
|
||||
private route: ActivatedRoute,
|
||||
private languageService: LanguageService
|
||||
private languageService: LanguageService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.route.data.subscribe(data => {
|
||||
this.route.data.subscribe((data) => {
|
||||
if (data['mode']) {
|
||||
this.mode.set(data['mode']);
|
||||
}
|
||||
});
|
||||
|
||||
this.route.queryParams.subscribe(params => {
|
||||
this.route.queryParams.subscribe((params) => {
|
||||
const sessionId = params['session'];
|
||||
if (sessionId) {
|
||||
// Avoid reloading if we just calculated this session
|
||||
@@ -92,7 +110,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
console.error('Failed to load session', err);
|
||||
this.setQuoteError('CALC.ERROR_GENERIC');
|
||||
this.loading.set(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,7 +121,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
}
|
||||
|
||||
// Download all files
|
||||
const downloads = items.map(item =>
|
||||
const downloads = items.map((item) =>
|
||||
this.estimator.getLineItemContent(session.id, item.id).pipe(
|
||||
map((blob: Blob) => {
|
||||
return {
|
||||
@@ -114,13 +132,18 @@ export class CalculatorPageComponent implements OnInit {
|
||||
// We might need to handle matching but UploadForm just pushes them.
|
||||
// If order is preserved, we are good. items from backend are list.
|
||||
};
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
forkJoin(downloads).subscribe({
|
||||
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) {
|
||||
this.uploadForm.setFiles(files);
|
||||
@@ -137,7 +160,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
if (item.colorCode) {
|
||||
this.uploadForm.updateItemColor(index, {
|
||||
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);
|
||||
this.loading.set(false);
|
||||
// Still show result? Yes.
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -167,7 +190,10 @@ export class CalculatorPageComponent implements OnInit {
|
||||
// Auto-scroll on mobile to make analysis visible
|
||||
setTimeout(() => {
|
||||
if (this.resultCol && window.innerWidth < 768) {
|
||||
this.resultCol.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
this.resultCol.nativeElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
|
||||
@@ -197,7 +223,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
relativeTo: this.route,
|
||||
queryParams: { session: res.sessionId },
|
||||
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: () => {
|
||||
this.setQuoteError('CALC.ERROR_GENERIC');
|
||||
this.loading.set(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -214,7 +240,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
if (res && res.sessionId) {
|
||||
this.router.navigate(
|
||||
['/', this.languageService.selectedLang(), 'checkout'],
|
||||
{ queryParams: { session: res.sessionId } }
|
||||
{ queryParams: { session: res.sessionId } },
|
||||
);
|
||||
} else {
|
||||
console.error('No session ID found in quote result');
|
||||
@@ -226,7 +252,12 @@ export class CalculatorPageComponent implements OnInit {
|
||||
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)
|
||||
if (this.uploadForm) {
|
||||
this.uploadForm.updateItemQuantityByIndex(event.index, event.quantity);
|
||||
@@ -238,12 +269,15 @@ export class CalculatorPageComponent implements OnInit {
|
||||
const currentSessionId = this.result()?.sessionId;
|
||||
if (!currentSessionId) return;
|
||||
|
||||
this.estimator.updateLineItem(event.id, { quantity: event.quantity }).subscribe({
|
||||
this.estimator
|
||||
.updateLineItem(event.id, { quantity: event.quantity })
|
||||
.subscribe({
|
||||
next: () => {
|
||||
// 3. Fetch the updated session totals from the backend
|
||||
this.estimator.getQuoteSession(currentSessionId).subscribe({
|
||||
next: (sessionData) => {
|
||||
const newResult = this.estimator.mapSessionToQuoteResult(sessionData);
|
||||
const newResult =
|
||||
this.estimator.mapSessionToQuoteResult(sessionData);
|
||||
// Preserve notes
|
||||
newResult.notes = this.result()?.notes;
|
||||
|
||||
@@ -258,12 +292,12 @@ export class CalculatorPageComponent implements OnInit {
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to refresh session totals', err);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to update line item', err);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -292,7 +326,7 @@ export class CalculatorPageComponent implements OnInit {
|
||||
details += `- Qualità: ${req.quality}\n`;
|
||||
|
||||
details += `- File:\n`;
|
||||
req.items.forEach(item => {
|
||||
req.items.forEach((item) => {
|
||||
details += ` * ${item.file.name} (Qtà: ${item.quantity}`;
|
||||
if (item.color) {
|
||||
details += `, Colore: ${item.color}`;
|
||||
@@ -307,8 +341,8 @@ export class CalculatorPageComponent implements OnInit {
|
||||
if (req.notes) details += `\nNote: ${req.notes}`;
|
||||
|
||||
this.estimator.setPendingConsultation({
|
||||
files: req.items.map(i => i.file),
|
||||
message: details
|
||||
files: req.items.map((i) => i.file),
|
||||
message: details,
|
||||
});
|
||||
|
||||
this.router.navigate(['/', this.languageService.selectedLang(), 'contact']);
|
||||
|
||||
@@ -4,5 +4,9 @@ import { CalculatorPageComponent } from './calculator-page.component';
|
||||
export const CALCULATOR_ROUTES: Routes = [
|
||||
{ path: '', redirectTo: 'basic', pathMatch: 'full' },
|
||||
{ path: 'basic', component: CalculatorPageComponent, data: { mode: 'easy' } },
|
||||
{ path: 'advanced', component: CalculatorPageComponent, data: { mode: 'advanced' } }
|
||||
{
|
||||
path: 'advanced',
|
||||
component: CalculatorPageComponent,
|
||||
data: { mode: 'advanced' },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<app-card>
|
||||
<h3 class="title">{{ 'CALC.RESULT' | translate }}</h3>
|
||||
<h3 class="title">{{ "CALC.RESULT" | translate }}</h3>
|
||||
|
||||
<!-- Summary Grid (NOW ON TOP) -->
|
||||
<div class="result-grid">
|
||||
@@ -7,8 +7,9 @@
|
||||
class="item full-width"
|
||||
[label]="'CALC.COST' | translate"
|
||||
[large]="true"
|
||||
[highlight]="true">
|
||||
{{ totals().price | currency:result().currency }}
|
||||
[highlight]="true"
|
||||
>
|
||||
{{ totals().price | currency: result().currency }}
|
||||
</app-summary-card>
|
||||
|
||||
<app-summary-card [label]="'CALC.TIME' | translate">
|
||||
@@ -21,13 +22,20 @@
|
||||
</div>
|
||||
|
||||
<div class="setup-note">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@if (result().notes) {
|
||||
<div class="notes-section">
|
||||
<label>{{ 'CALC.NOTES' | translate }}:</label>
|
||||
<label>{{ "CALC.NOTES" | translate }}:</label>
|
||||
<p>{{ result().notes }}</p>
|
||||
</div>
|
||||
}
|
||||
@@ -41,13 +49,14 @@
|
||||
<div class="item-info">
|
||||
<span class="file-name">{{ item.fileName }}</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="item-controls">
|
||||
<div class="qty-control">
|
||||
<label>{{ 'CHECKOUT.QTY' | translate }}:</label>
|
||||
<label>{{ "CHECKOUT.QTY" | translate }}:</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -55,17 +64,24 @@
|
||||
[ngModel]="item.quantity"
|
||||
(ngModelChange)="updateQuantity(i, $event)"
|
||||
(blur)="flushQuantityUpdate(i)"
|
||||
class="qty-input">
|
||||
class="qty-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="item-price">
|
||||
<span class="item-total-price">
|
||||
{{ (item.unitPrice * item.quantity) | currency:result().currency }}
|
||||
{{ item.unitPrice * item.quantity | currency: result().currency }}
|
||||
</span>
|
||||
<small class="item-unit-price" *ngIf="item.quantity > 1; else unitPricePlaceholder">
|
||||
{{ item.unitPrice | currency:result().currency }} {{ 'CHECKOUT.PER_PIECE' | translate }}
|
||||
<small
|
||||
class="item-unit-price"
|
||||
*ngIf="item.quantity > 1; else unitPricePlaceholder"
|
||||
>
|
||||
{{ item.unitPrice | currency: result().currency }}
|
||||
{{ "CHECKOUT.PER_PIECE" | translate }}
|
||||
</small>
|
||||
<ng-template #unitPricePlaceholder>
|
||||
<small class="item-unit-price item-unit-price--placeholder"> </small>
|
||||
<small class="item-unit-price item-unit-price--placeholder"
|
||||
> </small
|
||||
>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,15 +91,17 @@
|
||||
|
||||
<div class="actions">
|
||||
<app-button variant="outline" (click)="consult.emit()">
|
||||
{{ 'QUOTE.CONSULT' | translate }}
|
||||
{{ "QUOTE.CONSULT" | translate }}
|
||||
</app-button>
|
||||
|
||||
@if (!hasQuantityOverLimit()) {
|
||||
<app-button (click)="proceed.emit()">
|
||||
{{ 'QUOTE.PROCEED_ORDER' | translate }}
|
||||
{{ "QUOTE.PROCEED_ORDER" | translate }}
|
||||
</app-button>
|
||||
} @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>
|
||||
</app-card>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.title { margin-bottom: var(--space-6); text-align: center; }
|
||||
.title {
|
||||
margin-bottom: var(--space-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
@@ -30,8 +33,18 @@
|
||||
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-details { font-size: 0.8rem; color: var(--color-text-muted); }
|
||||
.file-name {
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -44,7 +57,10 @@
|
||||
align-items: center;
|
||||
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 {
|
||||
@@ -53,7 +69,10 @@
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
text-align: center;
|
||||
&:focus { outline: none; border-color: var(--color-brand); }
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.item-price {
|
||||
@@ -89,12 +108,14 @@
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-2);
|
||||
|
||||
@media(min-width: 500px) {
|
||||
@media (min-width: 500px) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
}
|
||||
.full-width { grid-column: span 2; }
|
||||
.full-width {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.setup-note {
|
||||
text-align: center;
|
||||
@@ -103,7 +124,11 @@
|
||||
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 {
|
||||
font-size: 0.8rem;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 { FormsModule } from '@angular/forms';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
@@ -10,9 +18,16 @@ import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
|
||||
@Component({
|
||||
selector: 'app-quote-result',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TranslateModule, AppCardComponent, AppButtonComponent, SummaryCardComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
TranslateModule,
|
||||
AppCardComponent,
|
||||
AppButtonComponent,
|
||||
SummaryCardComponent,
|
||||
],
|
||||
templateUrl: './quote-result.component.html',
|
||||
styleUrl: './quote-result.component.scss'
|
||||
styleUrl: './quote-result.component.scss',
|
||||
})
|
||||
export class QuoteResultComponent implements OnDestroy {
|
||||
readonly maxInputQuantity = 500;
|
||||
@@ -22,7 +37,12 @@ export class QuoteResultComponent implements OnDestroy {
|
||||
result = input.required<QuoteResult>();
|
||||
consult = 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
|
||||
items = signal<QuoteItem[]>([]);
|
||||
@@ -30,20 +50,23 @@ export class QuoteResultComponent implements OnDestroy {
|
||||
private quantityTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
effect(
|
||||
() => {
|
||||
this.clearAllQuantityTimers();
|
||||
|
||||
// Initialize local items when result inputs change
|
||||
// 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.lastSentQuantities.clear();
|
||||
nextItems.forEach(item => {
|
||||
nextItems.forEach((item) => {
|
||||
const key = item.id ?? item.fileName;
|
||||
this.lastSentQuantities.set(key, item.quantity);
|
||||
});
|
||||
}, { allowSignalWrites: true });
|
||||
},
|
||||
{ allowSignalWrites: true },
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -58,7 +81,7 @@ export class QuoteResultComponent implements OnDestroy {
|
||||
if (!item) return;
|
||||
const key = item.id ?? item.fileName;
|
||||
|
||||
this.items.update(current => {
|
||||
this.items.update((current) => {
|
||||
const updated = [...current];
|
||||
updated[index] = { ...updated[index], quantity: normalizedQty };
|
||||
return updated;
|
||||
@@ -85,12 +108,14 @@ export class QuoteResultComponent implements OnDestroy {
|
||||
id: item.id,
|
||||
index,
|
||||
fileName: item.fileName,
|
||||
quantity: normalizedQty
|
||||
quantity: 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(() => {
|
||||
const currentItems = this.items();
|
||||
@@ -100,7 +125,7 @@ export class QuoteResultComponent implements OnDestroy {
|
||||
let time = 0;
|
||||
let weight = 0;
|
||||
|
||||
currentItems.forEach(i => {
|
||||
currentItems.forEach((i) => {
|
||||
price += i.unitPrice * i.quantity;
|
||||
time += i.unitTime * i.quantity;
|
||||
weight += i.unitWeight * i.quantity;
|
||||
@@ -113,7 +138,7 @@ export class QuoteResultComponent implements OnDestroy {
|
||||
price: Math.round(price * 100) / 100,
|
||||
hours,
|
||||
minutes,
|
||||
weight: Math.ceil(weight)
|
||||
weight: Math.ceil(weight),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -142,8 +167,7 @@ export class QuoteResultComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
private clearAllQuantityTimers(): void {
|
||||
this.quantityTimers.forEach(timer => clearTimeout(timer));
|
||||
this.quantityTimers.forEach((timer) => clearTimeout(timer));
|
||||
this.quantityTimers.clear();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
|
||||
<div class="section">
|
||||
@if (selectedFile()) {
|
||||
<div class="viewer-wrapper">
|
||||
@if (!isStepFile(selectedFile())) {
|
||||
<div class="step-warning">
|
||||
<p>{{ 'CALC.STEP_WARNING' | translate }}</p>
|
||||
<p>{{ "CALC.STEP_WARNING" | translate }}</p>
|
||||
</div>
|
||||
} @else {
|
||||
<app-stl-viewer
|
||||
[file]="selectedFile()"
|
||||
[color]="getSelectedFileColor()">
|
||||
[color]="getSelectedFileColor()"
|
||||
>
|
||||
</app-stl-viewer>
|
||||
}
|
||||
<!-- Close button removed as requested -->
|
||||
@@ -24,7 +24,8 @@
|
||||
[subtext]="'CALC.UPLOAD_SUB'"
|
||||
[accept]="acceptedFormats"
|
||||
[multiple]="true"
|
||||
(filesDropped)="onFilesDropped($event)">
|
||||
(filesDropped)="onFilesDropped($event)"
|
||||
>
|
||||
</app-dropzone>
|
||||
}
|
||||
|
||||
@@ -32,31 +33,39 @@
|
||||
@if (items().length > 0) {
|
||||
<div class="items-grid">
|
||||
@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">
|
||||
<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 class="card-body">
|
||||
<div class="card-controls">
|
||||
<div class="qty-group">
|
||||
<label>{{ 'CALC.QTY_SHORT' | translate }}</label>
|
||||
<label>{{ "CALC.QTY_SHORT" | translate }}</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
[value]="item.quantity"
|
||||
(change)="updateItemQuantity(i, $event)"
|
||||
class="qty-input"
|
||||
(click)="$event.stopPropagation()">
|
||||
(click)="$event.stopPropagation()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="color-group">
|
||||
<label>{{ 'CALC.COLOR_LABEL' | translate }}</label>
|
||||
<label>{{ "CALC.COLOR_LABEL" | translate }}</label>
|
||||
<app-color-selector
|
||||
[selectedColor]="item.color"
|
||||
[selectedVariantId]="item.filamentVariantId ?? null"
|
||||
[variants]="currentMaterialVariants()"
|
||||
(colorSelected)="updateItemColor(i, $event)">
|
||||
(colorSelected)="updateItemColor(i, $event)"
|
||||
>
|
||||
</app-color-selector>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,7 +74,8 @@
|
||||
type="button"
|
||||
class="btn-remove"
|
||||
(click)="removeItem(i); $event.stopPropagation()"
|
||||
[attr.title]="'CALC.REMOVE_FILE' | translate">
|
||||
[attr.title]="'CALC.REMOVE_FILE' | translate"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
@@ -75,21 +85,35 @@
|
||||
|
||||
<!-- "Add Files" Button (Visible only when files exist) -->
|
||||
<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()">
|
||||
+ {{ 'CALC.ADD_FILES' | translate }}
|
||||
<button
|
||||
type="button"
|
||||
class="btn-add-more"
|
||||
(click)="additionalInput.click()"
|
||||
>
|
||||
+ {{ "CALC.ADD_FILES" | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (items().length === 0 && form.get('itemsTouched')?.value) {
|
||||
<div class="error-msg">{{ 'CALC.ERR_FILE_REQUIRED' | translate }}</div>
|
||||
@if (items().length === 0 && form.get("itemsTouched")?.value) {
|
||||
<div class="error-msg">{{ "CALC.ERR_FILE_REQUIRED" | translate }}</div>
|
||||
}
|
||||
|
||||
<p class="upload-privacy-note">
|
||||
{{ 'LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX' | translate }}
|
||||
<a href="/privacy" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.UPLOAD_NOTICE_LINK' | translate }}</a>.
|
||||
{{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
|
||||
<a href="/privacy" target="_blank" rel="noopener">{{
|
||||
"LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate
|
||||
}}</a
|
||||
>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -100,7 +124,7 @@
|
||||
[options]="materials()"
|
||||
></app-select>
|
||||
|
||||
@if (mode() === 'easy') {
|
||||
@if (mode() === "easy") {
|
||||
<app-select
|
||||
formControlName="quality"
|
||||
[label]="'CALC.QUALITY' | translate"
|
||||
@@ -117,7 +141,7 @@
|
||||
|
||||
<!-- Global quantity removed, now per item -->
|
||||
|
||||
@if (mode() === 'advanced') {
|
||||
@if (mode() === "advanced") {
|
||||
<div class="grid">
|
||||
<app-select
|
||||
formControlName="infillPattern"
|
||||
@@ -140,11 +164,10 @@
|
||||
></app-input>
|
||||
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" formControlName="supportEnabled" id="support">
|
||||
<label for="support">{{ 'CALC.SUPPORT' | translate }}</label>
|
||||
<input type="checkbox" formControlName="supportEnabled" id="support" />
|
||||
<label for="support">{{ "CALC.SUPPORT" | translate }}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
<app-input
|
||||
@@ -166,8 +189,15 @@
|
||||
<app-button
|
||||
type="submit"
|
||||
[disabled]="items().length === 0 || loading()"
|
||||
[fullWidth]="true">
|
||||
{{ loading() ? (uploadProgress() < 100 ? ('CALC.UPLOADING' | translate) : ('CALC.PROCESSING' | translate)) : ('CALC.CALCULATE' | translate) }}
|
||||
[fullWidth]="true"
|
||||
>
|
||||
{{
|
||||
loading()
|
||||
? uploadProgress() < 100
|
||||
? ("CALC.UPLOADING" | translate)
|
||||
: ("CALC.PROCESSING" | translate)
|
||||
: ("CALC.CALCULATE" | translate)
|
||||
}}
|
||||
</app-button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
.section { margin-bottom: var(--space-6); }
|
||||
.section {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
.upload-privacy-note {
|
||||
margin-top: var(--space-3);
|
||||
margin-bottom: 0;
|
||||
@@ -11,14 +13,24 @@
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-4);
|
||||
|
||||
@media(min-width: 640px) {
|
||||
@media (min-width: 640px) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
.actions { margin-top: var(--space-6); }
|
||||
.error-msg { color: var(--color-danger-500); font-size: 0.875rem; margin-top: var(--space-2); text-align: center; }
|
||||
.actions {
|
||||
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 */
|
||||
.items-grid {
|
||||
@@ -28,7 +40,7 @@
|
||||
margin-top: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
@media(min-width: 640px) {
|
||||
@media (min-width: 640px) {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
}
|
||||
@@ -46,7 +58,9 @@
|
||||
position: relative; /* For absolute positioning of remove btn */
|
||||
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 {
|
||||
border-color: var(--color-brand);
|
||||
background: rgba(250, 207, 10, 0.05);
|
||||
@@ -83,7 +97,8 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qty-group, .color-group {
|
||||
.qty-group,
|
||||
.color-group {
|
||||
display: flex;
|
||||
flex-direction: column; /* Stack label and input */
|
||||
align-items: flex-start;
|
||||
@@ -118,7 +133,10 @@
|
||||
font-size: 0.85rem;
|
||||
background: white;
|
||||
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 {
|
||||
@@ -170,7 +188,9 @@
|
||||
background: var(--color-neutral-900);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
&:active { transform: translateY(0); }
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
|
||||
@@ -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 { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import {
|
||||
ReactiveFormsModule,
|
||||
FormBuilder,
|
||||
FormGroup,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.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 { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.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';
|
||||
|
||||
interface FormItem {
|
||||
@@ -21,9 +40,19 @@ interface FormItem {
|
||||
@Component({
|
||||
selector: 'app-upload-form',
|
||||
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',
|
||||
styleUrl: './upload-form.component.scss'
|
||||
styleUrl: './upload-form.component.scss',
|
||||
})
|
||||
export class UploadFormComponent implements OnInit {
|
||||
mode = input<'easy' | 'advanced'>('easy');
|
||||
@@ -57,7 +86,7 @@ export class UploadFormComponent implements OnInit {
|
||||
private updateVariants() {
|
||||
const matCode = this.form.get('material')?.value;
|
||||
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.syncItemVariantSelections();
|
||||
} else {
|
||||
@@ -85,7 +114,7 @@ export class UploadFormComponent implements OnInit {
|
||||
layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]],
|
||||
nozzleDiameter: [0.4, Validators.required],
|
||||
infillPattern: ['grid'],
|
||||
supportEnabled: [false]
|
||||
supportEnabled: [false],
|
||||
});
|
||||
|
||||
// Listen to material changes to update variants
|
||||
@@ -102,11 +131,39 @@ export class UploadFormComponent implements OnInit {
|
||||
private applyAdvancedPresetFromQuality(quality: string | null | undefined) {
|
||||
const normalized = (quality || 'standard').toLowerCase();
|
||||
|
||||
const presets: Record<string, { nozzleDiameter: number; 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 presets: Record<
|
||||
string,
|
||||
{
|
||||
nozzleDiameter: number;
|
||||
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'];
|
||||
@@ -119,22 +176,45 @@ export class UploadFormComponent implements OnInit {
|
||||
this.fullMaterialOptions = options.materials;
|
||||
this.updateVariants(); // Trigger initial update
|
||||
|
||||
this.materials.set(options.materials.map(m => ({ label: m.label, value: m.code })));
|
||||
this.qualities.set(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.materials.set(
|
||||
options.materials.map((m) => ({ label: m.label, value: m.code })),
|
||||
);
|
||||
this.qualities.set(
|
||||
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();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load options', err);
|
||||
// Fallback for debugging/offline dev
|
||||
this.materials.set([{ label: this.translate.instant('CALC.FALLBACK_MATERIAL'), value: 'PLA' }]);
|
||||
this.qualities.set([{ label: this.translate.instant('CALC.FALLBACK_QUALITY_STANDARD'), value: 'standard' }]);
|
||||
this.materials.set([
|
||||
{
|
||||
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.setDefaults();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,16 +225,27 @@ export class UploadFormComponent implements OnInit {
|
||||
}
|
||||
if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
|
||||
// Try to find 'standard' or use first
|
||||
const std = this.qualities().find(q => q.value === 'standard');
|
||||
this.form.get('quality')?.setValue(std ? std.value : this.qualities()[0].value);
|
||||
const std = this.qualities().find((q) => q.value === 'standard');
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -173,7 +264,7 @@ export class UploadFormComponent implements OnInit {
|
||||
file,
|
||||
quantity: 1,
|
||||
color: defaultSelection.colorName,
|
||||
filamentVariantId: defaultSelection.filamentVariantId
|
||||
filamentVariantId: defaultSelection.filamentVariantId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -183,7 +274,7 @@ export class UploadFormComponent implements OnInit {
|
||||
}
|
||||
|
||||
if (validItems.length > 0) {
|
||||
this.items.update(current => [...current, ...validItems]);
|
||||
this.items.update((current) => [...current, ...validItems]);
|
||||
this.form.get('itemsTouched')?.setValue(true);
|
||||
// Auto select last added
|
||||
this.selectedFile.set(validItems[validItems.length - 1].file);
|
||||
@@ -203,7 +294,7 @@ export class UploadFormComponent implements OnInit {
|
||||
if (!Number.isInteger(index) || index < 0) return;
|
||||
const normalizedQty = this.normalizeQuantity(quantity);
|
||||
|
||||
this.items.update(current => {
|
||||
this.items.update((current) => {
|
||||
if (index >= current.length) return current;
|
||||
const updated = [...current];
|
||||
updated[index] = { ...updated[index], quantity: normalizedQty };
|
||||
@@ -215,9 +306,9 @@ export class UploadFormComponent implements OnInit {
|
||||
const targetName = this.normalizeFileName(fileName);
|
||||
const normalizedQty = this.normalizeQuantity(quantity);
|
||||
|
||||
this.items.update(current => {
|
||||
this.items.update((current) => {
|
||||
let matched = false;
|
||||
return current.map(item => {
|
||||
return current.map((item) => {
|
||||
if (!matched && this.normalizeFileName(item.file.name) === targetName) {
|
||||
matched = true;
|
||||
return { ...item, quantity: normalizedQty };
|
||||
@@ -240,13 +331,13 @@ export class UploadFormComponent implements OnInit {
|
||||
const file = this.selectedFile();
|
||||
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) {
|
||||
const vars = this.currentMaterialVariants();
|
||||
if (vars && vars.length > 0) {
|
||||
const found = item.filamentVariantId
|
||||
? vars.find(v => v.id === item.filamentVariantId)
|
||||
: vars.find(v => v.colorName === item.color);
|
||||
? vars.find((v) => v.id === item.filamentVariantId)
|
||||
: vars.find((v) => v.colorName === item.color);
|
||||
if (found) return found.hexColor;
|
||||
}
|
||||
return getColorHex(item.color);
|
||||
@@ -261,18 +352,29 @@ export class UploadFormComponent implements OnInit {
|
||||
this.updateItemQuantityByIndex(index, quantity);
|
||||
}
|
||||
|
||||
updateItemColor(index: number, newSelection: string | { colorName: string; filamentVariantId?: number }) {
|
||||
const colorName = typeof newSelection === 'string' ? newSelection : newSelection.colorName;
|
||||
const filamentVariantId = typeof newSelection === 'string' ? undefined : newSelection.filamentVariantId;
|
||||
this.items.update(current => {
|
||||
updateItemColor(
|
||||
index: number,
|
||||
newSelection: string | { colorName: string; filamentVariantId?: number },
|
||||
) {
|
||||
const colorName =
|
||||
typeof newSelection === 'string' ? newSelection : newSelection.colorName;
|
||||
const filamentVariantId =
|
||||
typeof newSelection === 'string'
|
||||
? undefined
|
||||
: newSelection.filamentVariantId;
|
||||
this.items.update((current) => {
|
||||
const updated = [...current];
|
||||
updated[index] = { ...updated[index], color: colorName, filamentVariantId };
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
color: colorName,
|
||||
filamentVariantId,
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
removeItem(index: number) {
|
||||
this.items.update(current => {
|
||||
this.items.update((current) => {
|
||||
const updated = [...current];
|
||||
const removed = updated.splice(index, 1)[0];
|
||||
if (this.selectedFile() === removed.file) {
|
||||
@@ -290,7 +392,7 @@ export class UploadFormComponent implements OnInit {
|
||||
file,
|
||||
quantity: 1,
|
||||
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();
|
||||
if (vars && vars.length > 0) {
|
||||
const preferred = vars.find(v => !v.isOutOfStock) || vars[0];
|
||||
const preferred = vars.find((v) => !v.isOutOfStock) || vars[0];
|
||||
return {
|
||||
colorName: preferred.colorName,
|
||||
filamentVariantId: preferred.id
|
||||
filamentVariantId: preferred.id,
|
||||
};
|
||||
}
|
||||
return { colorName: 'Black' };
|
||||
@@ -320,19 +425,22 @@ export class UploadFormComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallback = vars.find(v => !v.isOutOfStock) || vars[0];
|
||||
this.items.update(current => current.map(item => {
|
||||
const byId = item.filamentVariantId != null
|
||||
? vars.find(v => v.id === item.filamentVariantId)
|
||||
const fallback = vars.find((v) => !v.isOutOfStock) || vars[0];
|
||||
this.items.update((current) =>
|
||||
current.map((item) => {
|
||||
const byId =
|
||||
item.filamentVariantId != null
|
||||
? vars.find((v) => v.id === item.filamentVariantId)
|
||||
: null;
|
||||
const byColor = vars.find(v => v.colorName === item.color);
|
||||
const byColor = vars.find((v) => v.colorName === item.color);
|
||||
const selected = byId || byColor || fallback;
|
||||
return {
|
||||
...item,
|
||||
color: selected.colorName,
|
||||
filamentVariantId: selected.id
|
||||
filamentVariantId: selected.id,
|
||||
};
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
patchSettings(settings: any) {
|
||||
@@ -364,10 +472,12 @@ export class UploadFormComponent implements OnInit {
|
||||
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.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;
|
||||
|
||||
this.isPatchingSettings = true;
|
||||
@@ -380,19 +490,28 @@ export class UploadFormComponent implements OnInit {
|
||||
console.log('Form Valid:', this.form.valid, 'Items:', this.items().length);
|
||||
|
||||
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.form.value,
|
||||
items: this.items(), // Pass the items array explicitly AFTER form value to prevent overwrite
|
||||
mode: this.mode()
|
||||
mode: this.mode(),
|
||||
});
|
||||
} else {
|
||||
console.warn('UploadFormComponent: Form Invalid or No Items');
|
||||
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);
|
||||
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();
|
||||
@@ -408,10 +527,6 @@ export class UploadFormComponent implements OnInit {
|
||||
}
|
||||
|
||||
private normalizeFileName(fileName: string): string {
|
||||
return (fileName || '')
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.trim()
|
||||
.toLowerCase() ?? '';
|
||||
return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<div class="col-md-6">
|
||||
<app-card [title]="'USER_DETAILS.TITLE' | translate">
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
|
||||
<!-- Name & Surname -->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
@@ -12,7 +11,12 @@
|
||||
[label]="'USER_DETAILS.NAME' | translate"
|
||||
[placeholder]="'USER_DETAILS.NAME_PLACEHOLDER' | translate"
|
||||
[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>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
@@ -21,7 +25,12 @@
|
||||
[label]="'USER_DETAILS.SURNAME' | translate"
|
||||
[placeholder]="'USER_DETAILS.SURNAME_PLACEHOLDER' | translate"
|
||||
[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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,7 +44,12 @@
|
||||
type="email"
|
||||
[placeholder]="'USER_DETAILS.EMAIL_PLACEHOLDER' | translate"
|
||||
[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>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
@@ -45,7 +59,12 @@
|
||||
type="tel"
|
||||
[placeholder]="'USER_DETAILS.PHONE_PLACEHOLDER' | translate"
|
||||
[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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,7 +75,12 @@
|
||||
[label]="'USER_DETAILS.ADDRESS' | translate"
|
||||
[placeholder]="'USER_DETAILS.ADDRESS_PLACEHOLDER' | translate"
|
||||
[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>
|
||||
|
||||
<!-- Zip & City -->
|
||||
@@ -67,7 +91,12 @@
|
||||
[label]="'USER_DETAILS.ZIP' | translate"
|
||||
[placeholder]="'USER_DETAILS.ZIP_PLACEHOLDER' | translate"
|
||||
[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>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
@@ -76,40 +105,50 @@
|
||||
[label]="'USER_DETAILS.CITY' | translate"
|
||||
[placeholder]="'USER_DETAILS.CITY_PLACEHOLDER' | translate"
|
||||
[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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-consent">
|
||||
<label>
|
||||
<input type="checkbox" formControlName="acceptLegal">
|
||||
<input type="checkbox" formControlName="acceptLegal" />
|
||||
<span>
|
||||
{{ 'LEGAL.CONSENT.LABEL_PREFIX' | translate }}
|
||||
<a href="/terms" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.TERMS_LINK' | translate }}</a>
|
||||
{{ 'LEGAL.CONSENT.AND' | translate }}
|
||||
<a href="/privacy" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.PRIVACY_LINK' | translate }}</a>.
|
||||
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
|
||||
<a href="/terms" target="_blank" rel="noopener">{{
|
||||
"LEGAL.CONSENT.TERMS_LINK" | translate
|
||||
}}</a>
|
||||
{{ "LEGAL.CONSENT.AND" | translate }}
|
||||
<a href="/privacy" target="_blank" rel="noopener">{{
|
||||
"LEGAL.CONSENT.PRIVACY_LINK" | translate
|
||||
}}</a
|
||||
>.
|
||||
</span>
|
||||
</label>
|
||||
<div class="consent-error" *ngIf="form.get('acceptLegal')?.invalid && form.get('acceptLegal')?.touched">
|
||||
{{ 'LEGAL.CONSENT.REQUIRED_ERROR' | translate }}
|
||||
<div
|
||||
class="consent-error"
|
||||
*ngIf="
|
||||
form.get('acceptLegal')?.invalid &&
|
||||
form.get('acceptLegal')?.touched
|
||||
"
|
||||
>
|
||||
{{ "LEGAL.CONSENT.REQUIRED_ERROR" | translate }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<app-button
|
||||
type="button"
|
||||
variant="outline"
|
||||
(click)="onCancel()">
|
||||
{{ 'COMMON.BACK' | translate }}
|
||||
<app-button type="button" variant="outline" (click)="onCancel()">
|
||||
{{ "COMMON.BACK" | translate }}
|
||||
</app-button>
|
||||
<app-button
|
||||
type="submit"
|
||||
[disabled]="form.invalid || submitting()">
|
||||
{{ 'USER_DETAILS.SUBMIT' | translate }}
|
||||
<app-button type="submit" [disabled]="form.invalid || submitting()">
|
||||
{{ "USER_DETAILS.SUBMIT" | translate }}
|
||||
</app-button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</app-card>
|
||||
</div>
|
||||
@@ -117,30 +156,38 @@
|
||||
<!-- Order Summary Column -->
|
||||
<div class="col-md-6">
|
||||
<app-card [title]="'USER_DETAILS.SUMMARY_TITLE' | translate">
|
||||
|
||||
<div class="summary-content" *ngIf="quote()">
|
||||
<div class="summary-item" *ngFor="let item of quote()!.items">
|
||||
<div class="item-info">
|
||||
<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 class="item-qty">x{{ item.quantity }}</div>
|
||||
<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">
|
||||
{{ item.unitPrice | currency:'CHF' }} {{ 'CHECKOUT.PER_PIECE' | translate }}
|
||||
{{ item.unitPrice | currency: "CHF" }}
|
||||
{{ "CHECKOUT.PER_PIECE" | translate }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<hr />
|
||||
|
||||
<div class="total-row">
|
||||
<span>{{ 'QUOTE.TOTAL' | translate }}</span>
|
||||
<span class="total-price">{{ quote()!.totalPrice | currency:'CHF' }}</span>
|
||||
<span>{{ "QUOTE.TOTAL" | translate }}</span>
|
||||
<span class="total-price">{{
|
||||
quote()!.totalPrice | currency: "CHF"
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</app-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
flex-wrap: wrap;
|
||||
margin: 0 -0.5rem;
|
||||
|
||||
> [class*='col-'] {
|
||||
> [class*="col-"] {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
input[type="checkbox"] {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
@@ -136,6 +136,6 @@
|
||||
border-top: 2px solid rgba(255, 255, 255, 0.2);
|
||||
|
||||
.total-price {
|
||||
color: var(--primary-color, #00C853); // Fallback color
|
||||
color: var(--primary-color, #00c853); // Fallback color
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
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 { AppCardComponent } from '../../../../shared/components/app-card/app-card.component';
|
||||
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
|
||||
@@ -10,9 +15,16 @@ import { QuoteResult } from '../../services/quote-estimator.service';
|
||||
@Component({
|
||||
selector: 'app-user-details',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppCardComponent, AppInputComponent, AppButtonComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
AppCardComponent,
|
||||
AppInputComponent,
|
||||
AppButtonComponent,
|
||||
],
|
||||
templateUrl: './user-details.component.html',
|
||||
styleUrl: './user-details.component.scss'
|
||||
styleUrl: './user-details.component.scss',
|
||||
})
|
||||
export class UserDetailsComponent {
|
||||
quote = input<QuoteResult>();
|
||||
@@ -31,7 +43,7 @@ export class UserDetailsComponent {
|
||||
address: ['', Validators.required],
|
||||
zip: ['', Validators.required],
|
||||
city: ['', Validators.required],
|
||||
acceptLegal: [false, Validators.requiredTrue]
|
||||
acceptLegal: [false, Validators.requiredTrue],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,7 +53,7 @@ export class UserDetailsComponent {
|
||||
|
||||
const orderData = {
|
||||
customer: this.form.value,
|
||||
quote: this.quote()
|
||||
quote: this.quote(),
|
||||
};
|
||||
|
||||
// Simulate API delay
|
||||
|
||||
@@ -5,7 +5,12 @@ import { map, catchError, tap } from 'rxjs/operators';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
|
||||
export interface QuoteRequest {
|
||||
items: { file: File, quantity: number, color?: string, filamentVariantId?: number }[];
|
||||
items: {
|
||||
file: File;
|
||||
quantity: number;
|
||||
color?: string;
|
||||
filamentVariantId?: number;
|
||||
}[];
|
||||
material: string;
|
||||
quality: string;
|
||||
notes?: string;
|
||||
@@ -110,7 +115,7 @@ export interface SimpleOption {
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class QuoteEstimatorService {
|
||||
private http = inject(HttpClient);
|
||||
@@ -131,7 +136,7 @@ export class QuoteEstimatorService {
|
||||
layerHeight: 0.12,
|
||||
infillDensity: 20,
|
||||
infillPattern: 'grid',
|
||||
nozzleDiameter: 0.4
|
||||
nozzleDiameter: 0.4,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,7 +146,7 @@ export class QuoteEstimatorService {
|
||||
layerHeight: 0.24,
|
||||
infillDensity: 12,
|
||||
infillPattern: 'grid',
|
||||
nozzleDiameter: 0.4
|
||||
nozzleDiameter: 0.4,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -150,18 +155,24 @@ export class QuoteEstimatorService {
|
||||
layerHeight: 0.2,
|
||||
infillDensity: 15,
|
||||
infillPattern: 'grid',
|
||||
nozzleDiameter: 0.4
|
||||
nozzleDiameter: 0.4,
|
||||
};
|
||||
}
|
||||
|
||||
getOptions(): Observable<OptionsResponse> {
|
||||
console.log('QuoteEstimatorService: Requesting options...');
|
||||
const headers: any = {};
|
||||
return this.http.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, { headers }).pipe(
|
||||
tap({
|
||||
next: (res) => console.log('QuoteEstimatorService: Options loaded', res),
|
||||
error: (err) => console.error('QuoteEstimatorService: Options failed', err)
|
||||
return this.http
|
||||
.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, {
|
||||
headers,
|
||||
})
|
||||
.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> {
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
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> {
|
||||
const headers: any = {};
|
||||
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/invoice`, {
|
||||
return this.http.get(
|
||||
`${environment.apiUrl}/api/orders/${orderId}/invoice`,
|
||||
{
|
||||
headers,
|
||||
responseType: 'blob'
|
||||
});
|
||||
responseType: 'blob',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
getOrderConfirmation(orderId: string): Observable<Blob> {
|
||||
const headers: any = {};
|
||||
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/confirmation`, {
|
||||
return this.http.get(
|
||||
`${environment.apiUrl}/api/orders/${orderId}/confirmation`,
|
||||
{
|
||||
headers,
|
||||
responseType: 'blob'
|
||||
});
|
||||
responseType: 'blob',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
getTwintPayment(orderId: string): Observable<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> {
|
||||
@@ -220,11 +256,13 @@ export class QuoteEstimatorService {
|
||||
return of();
|
||||
}
|
||||
|
||||
return new Observable(observer => {
|
||||
return new Observable((observer) => {
|
||||
// 1. Create Session first
|
||||
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) => {
|
||||
const sessionId = sessionRes.id;
|
||||
const sessionSetupCost = sessionRes.setupCostChf || 0;
|
||||
@@ -236,7 +274,9 @@ export class QuoteEstimatorService {
|
||||
let completedRequests = 0;
|
||||
|
||||
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);
|
||||
|
||||
if (completedRequests === totalItems) {
|
||||
@@ -248,59 +288,101 @@ export class QuoteEstimatorService {
|
||||
const formData = new FormData();
|
||||
formData.append('file', item.file);
|
||||
|
||||
const easyPreset = request.mode === 'easy'
|
||||
const easyPreset =
|
||||
request.mode === 'easy'
|
||||
? this.buildEasyModePreset(request.quality)
|
||||
: null;
|
||||
|
||||
const settings = {
|
||||
complexityMode: request.mode === 'easy' ? 'ADVANCED' : request.mode.toUpperCase(),
|
||||
complexityMode:
|
||||
request.mode === 'easy'
|
||||
? 'ADVANCED'
|
||||
: request.mode.toUpperCase(),
|
||||
material: request.material,
|
||||
filamentVariantId: item.filamentVariantId,
|
||||
quality: easyPreset ? easyPreset.quality : request.quality,
|
||||
supportsEnabled: request.supportEnabled,
|
||||
color: item.color || '#FFFFFF',
|
||||
layerHeight: easyPreset ? easyPreset.layerHeight : request.layerHeight,
|
||||
infillDensity: easyPreset ? easyPreset.infillDensity : request.infillDensity,
|
||||
infillPattern: easyPreset ? easyPreset.infillPattern : request.infillPattern,
|
||||
nozzleDiameter: easyPreset ? easyPreset.nozzleDiameter : request.nozzleDiameter
|
||||
layerHeight: easyPreset
|
||||
? easyPreset.layerHeight
|
||||
: request.layerHeight,
|
||||
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);
|
||||
|
||||
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,
|
||||
reportProgress: true,
|
||||
observe: 'events'
|
||||
}).subscribe({
|
||||
observe: 'events',
|
||||
},
|
||||
)
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
if (event.type === HttpEventType.UploadProgress && event.total) {
|
||||
allProgress[index] = Math.round((100 * event.loaded) / event.total);
|
||||
if (
|
||||
event.type === HttpEventType.UploadProgress &&
|
||||
event.total
|
||||
) {
|
||||
allProgress[index] = Math.round(
|
||||
(100 * event.loaded) / event.total,
|
||||
);
|
||||
checkCompletion();
|
||||
} else if (event.type === HttpEventType.Response) {
|
||||
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++;
|
||||
checkCompletion();
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Item upload failed', err);
|
||||
finalResponses[index] = { success: false, fileName: item.file.name };
|
||||
finalResponses[index] = {
|
||||
success: false,
|
||||
fileName: item.file.name,
|
||||
};
|
||||
completedRequests++;
|
||||
checkCompletion();
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to create session', err);
|
||||
observer.error('Could not initialize quote session');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const finalize = (responses: any[], setupCost: number, sessionId: string) => {
|
||||
this.http.get<any>(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { headers }).subscribe({
|
||||
const finalize = (
|
||||
responses: any[],
|
||||
setupCost: number,
|
||||
sessionId: string,
|
||||
) => {
|
||||
this.http
|
||||
.get<any>(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, {
|
||||
headers,
|
||||
})
|
||||
.subscribe({
|
||||
next: (sessionData) => {
|
||||
observer.next(100);
|
||||
const result = this.mapSessionToQuoteResult(sessionData);
|
||||
@@ -311,16 +393,19 @@ export class QuoteEstimatorService {
|
||||
error: (err) => {
|
||||
console.error('Failed to fetch final session calculation', err);
|
||||
observer.error('Failed to calculate final quote');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -333,17 +418,28 @@ export class QuoteEstimatorService {
|
||||
// Session File Retrieval
|
||||
getLineItemContent(sessionId: string, lineItemId: string): Observable<Blob> {
|
||||
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,
|
||||
responseType: 'blob'
|
||||
});
|
||||
responseType: 'blob',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
mapSessionToQuoteResult(sessionData: any): QuoteResult {
|
||||
const session = sessionData.session;
|
||||
const items = sessionData.items || [];
|
||||
const totalTime = items.reduce((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);
|
||||
const totalTime = items.reduce(
|
||||
(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 {
|
||||
sessionId: session.id,
|
||||
@@ -358,16 +454,19 @@ export class QuoteEstimatorService {
|
||||
// Backend model QuoteSession has materialCode.
|
||||
// But line items might have different colors.
|
||||
color: item.colorCode,
|
||||
filamentVariantId: item.filamentVariantId
|
||||
filamentVariantId: item.filamentVariantId,
|
||||
})),
|
||||
setupCost: session.setupCostChf || 0,
|
||||
globalMachineCost: sessionData.globalMachineCostChf || 0,
|
||||
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),
|
||||
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
|
||||
totalWeight: Math.ceil(totalWeight),
|
||||
notes: session.notes
|
||||
notes: session.notes,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<div class="checkout-page">
|
||||
<div class="container hero">
|
||||
<h1 class="section-title">{{ 'CHECKOUT.TITLE' | translate }}</h1>
|
||||
<h1 class="section-title">{{ "CHECKOUT.TITLE" | translate }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="checkout-layout">
|
||||
|
||||
<!-- LEFT COLUMN: Form -->
|
||||
<div class="checkout-form-section">
|
||||
<!-- Error Message -->
|
||||
@@ -14,50 +13,105 @@
|
||||
</div>
|
||||
|
||||
<form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error">
|
||||
|
||||
<!-- Contact Info Card -->
|
||||
<app-card class="mb-6">
|
||||
<div class="card-header-simple">
|
||||
<h3>{{ 'CHECKOUT.CONTACT_INFO' | translate }}</h3>
|
||||
<h3>{{ "CHECKOUT.CONTACT_INFO" | translate }}</h3>
|
||||
</div>
|
||||
<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 formControlName="phone" type="tel" [label]="'CHECKOUT.PHONE' | translate" [required]="true"></app-input>
|
||||
<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>
|
||||
</app-card>
|
||||
|
||||
<!-- Billing Address Card -->
|
||||
<app-card class="mb-6">
|
||||
<div class="card-header-simple">
|
||||
<h3>{{ 'CHECKOUT.BILLING_ADDR' | translate }}</h3>
|
||||
<h3>{{ "CHECKOUT.BILLING_ADDR" | translate }}</h3>
|
||||
</div>
|
||||
<div formGroupName="billingAddress">
|
||||
<!-- 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"
|
||||
[selectedValue]="checkoutForm.get('customerType')?.value"
|
||||
(selectionChange)="setCustomerType($event)">
|
||||
(selectionChange)="setCustomerType($event)"
|
||||
>
|
||||
</app-toggle-selector>
|
||||
|
||||
<!-- Private Person Fields -->
|
||||
<div *ngIf="!isCompany" class="form-row">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Company Fields -->
|
||||
<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 formControlName="referencePerson" [label]="'CONTACT.REF_PERSON' | translate" [required]="true" [placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"></app-input>
|
||||
<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>
|
||||
|
||||
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate" [required]="true"></app-input>
|
||||
<app-input formControlName="addressLine2" [label]="'CHECKOUT.ADDRESS_2' | translate"></app-input>
|
||||
<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">
|
||||
<app-input formControlName="zip" [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>
|
||||
<app-input
|
||||
formControlName="zip"
|
||||
[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>
|
||||
</app-card>
|
||||
@@ -65,60 +119,108 @@
|
||||
<!-- Shipping Option -->
|
||||
<div class="shipping-option">
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" formControlName="shippingSameAsBilling">
|
||||
<input type="checkbox" formControlName="shippingSameAsBilling" />
|
||||
<span class="checkmark"></span>
|
||||
{{ 'CHECKOUT.SHIPPING_SAME' | translate }}
|
||||
{{ "CHECKOUT.SHIPPING_SAME" | translate }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<h3>{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}</h3>
|
||||
<h3>{{ "CHECKOUT.SHIPPING_ADDR" | translate }}</h3>
|
||||
</div>
|
||||
<div formGroupName="shippingAddress">
|
||||
<div class="form-row">
|
||||
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate"></app-input>
|
||||
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate"></app-input>
|
||||
<app-input
|
||||
formControlName="firstName"
|
||||
[label]="'CHECKOUT.FIRST_NAME' | translate"
|
||||
></app-input>
|
||||
<app-input
|
||||
formControlName="lastName"
|
||||
[label]="'CHECKOUT.LAST_NAME' | translate"
|
||||
></app-input>
|
||||
</div>
|
||||
|
||||
<div *ngIf="isCompany" class="company-fields">
|
||||
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_OPTIONAL' | translate"></app-input>
|
||||
<app-input formControlName="referencePerson" [label]="'CHECKOUT.REF_PERSON_OPTIONAL' | translate"></app-input>
|
||||
<app-input
|
||||
formControlName="companyName"
|
||||
[label]="'CHECKOUT.COMPANY_OPTIONAL' | translate"
|
||||
></app-input>
|
||||
<app-input
|
||||
formControlName="referencePerson"
|
||||
[label]="'CHECKOUT.REF_PERSON_OPTIONAL' | translate"
|
||||
></app-input>
|
||||
</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">
|
||||
<app-input formControlName="zip" [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>
|
||||
<app-input
|
||||
formControlName="zip"
|
||||
[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>
|
||||
</app-card>
|
||||
|
||||
<div class="legal-consent">
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" formControlName="acceptLegal">
|
||||
<input type="checkbox" formControlName="acceptLegal" />
|
||||
<span class="checkmark"></span>
|
||||
<span>
|
||||
{{ 'LEGAL.CONSENT.LABEL_PREFIX' | translate }}
|
||||
<a href="/terms" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.TERMS_LINK' | translate }}</a>
|
||||
{{ 'LEGAL.CONSENT.AND' | translate }}
|
||||
<a href="/privacy" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.PRIVACY_LINK' | translate }}</a>.
|
||||
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
|
||||
<a href="/terms" target="_blank" rel="noopener">{{
|
||||
"LEGAL.CONSENT.TERMS_LINK" | translate
|
||||
}}</a>
|
||||
{{ "LEGAL.CONSENT.AND" | translate }}
|
||||
<a href="/privacy" target="_blank" rel="noopener">{{
|
||||
"LEGAL.CONSENT.PRIVACY_LINK" | translate
|
||||
}}</a
|
||||
>.
|
||||
</span>
|
||||
</label>
|
||||
<div class="consent-error" *ngIf="checkoutForm.get('acceptLegal')?.invalid && checkoutForm.get('acceptLegal')?.touched">
|
||||
{{ 'LEGAL.CONSENT.REQUIRED_ERROR' | translate }}
|
||||
<div
|
||||
class="consent-error"
|
||||
*ngIf="
|
||||
checkoutForm.get('acceptLegal')?.invalid &&
|
||||
checkoutForm.get('acceptLegal')?.touched
|
||||
"
|
||||
>
|
||||
{{ "LEGAL.CONSENT.REQUIRED_ERROR" | translate }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<app-button type="submit" [disabled]="checkoutForm.invalid || isSubmitting()" [fullWidth]="true">
|
||||
{{ (isSubmitting() ? 'CHECKOUT.PROCESSING' : 'CHECKOUT.PLACE_ORDER') | translate }}
|
||||
<app-button
|
||||
type="submit"
|
||||
[disabled]="checkoutForm.invalid || isSubmitting()"
|
||||
[fullWidth]="true"
|
||||
>
|
||||
{{
|
||||
(isSubmitting()
|
||||
? "CHECKOUT.PROCESSING"
|
||||
: "CHECKOUT.PLACE_ORDER"
|
||||
) | translate
|
||||
}}
|
||||
</app-button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -126,7 +228,7 @@
|
||||
<div class="checkout-summary-section">
|
||||
<app-card class="sticky-card">
|
||||
<div class="card-header-simple">
|
||||
<h3>{{ 'CHECKOUT.SUMMARY_TITLE' | translate }}</h3>
|
||||
<h3>{{ "CHECKOUT.SUMMARY_TITLE" | translate }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="summary-items" *ngIf="quoteSession() as session">
|
||||
@@ -134,19 +236,27 @@
|
||||
<div class="item-details">
|
||||
<span class="item-name">{{ item.originalFilename }}</span>
|
||||
<div class="item-specs">
|
||||
<span>{{ 'CHECKOUT.QTY' | translate }}: {{ item.quantity }}</span>
|
||||
<span *ngIf="item.colorCode" class="color-dot" [style.background-color]="item.colorCode"></span>
|
||||
<span
|
||||
>{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span
|
||||
>
|
||||
<span
|
||||
*ngIf="item.colorCode"
|
||||
class="color-dot"
|
||||
[style.background-color]="item.colorCode"
|
||||
></span>
|
||||
</div>
|
||||
<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 class="item-price">
|
||||
<span class="item-total-price">
|
||||
{{ (item.unitPriceChf * item.quantity) | currency:'CHF' }}
|
||||
{{ item.unitPriceChf * item.quantity | currency: "CHF" }}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,25 +264,24 @@
|
||||
|
||||
<div class="summary-totals" *ngIf="quoteSession() as session">
|
||||
<div class="total-row">
|
||||
<span>{{ 'CHECKOUT.SUBTOTAL' | translate }}</span>
|
||||
<span>{{ session.itemsTotalChf | currency:'CHF' }}</span>
|
||||
<span>{{ "CHECKOUT.SUBTOTAL" | translate }}</span>
|
||||
<span>{{ session.itemsTotalChf | currency: "CHF" }}</span>
|
||||
</div>
|
||||
<div class="total-row">
|
||||
<span>{{ 'CHECKOUT.SETUP_FEE' | translate }}</span>
|
||||
<span>{{ session.session.setupCostChf | currency:'CHF' }}</span>
|
||||
<span>{{ "CHECKOUT.SETUP_FEE" | translate }}</span>
|
||||
<span>{{ session.session.setupCostChf | currency: "CHF" }}</span>
|
||||
</div>
|
||||
<div class="total-row">
|
||||
<span>{{ 'CHECKOUT.SHIPPING' | translate }}</span>
|
||||
<span>{{ session.shippingCostChf | currency:'CHF' }}</span>
|
||||
<span>{{ "CHECKOUT.SHIPPING" | translate }}</span>
|
||||
<span>{{ session.shippingCostChf | currency: "CHF" }}</span>
|
||||
</div>
|
||||
<div class="grand-total">
|
||||
<span>{{ 'CHECKOUT.TOTAL' | translate }}</span>
|
||||
<span>{{ session.grandTotalChf | currency:'CHF' }}</span>
|
||||
<span>{{ "CHECKOUT.TOTAL" | translate }}</span>
|
||||
<span>{{ session.grandTotalChf | currency: "CHF" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,9 +40,11 @@
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
@media(min-width: 768px) {
|
||||
@media (min-width: 768px) {
|
||||
flex-direction: row;
|
||||
& > * { flex: 1; }
|
||||
& > * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.no-margin {
|
||||
@@ -197,8 +199,12 @@ app-toggle-selector.user-type-selector-compact {
|
||||
padding: var(--space-4) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
|
||||
&:first-child { padding-top: 0; }
|
||||
&:last-child { border-bottom: none; }
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item-details {
|
||||
flex: 1;
|
||||
@@ -298,4 +304,6 @@ app-toggle-selector.user-type-selector-compact {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mb-6 { margin-bottom: var(--space-6); }
|
||||
.mb-6 {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { Component, inject, OnInit, signal } from '@angular/core';
|
||||
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 { TranslateModule } from '@ngx-translate/core';
|
||||
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
|
||||
import { AppInputComponent } from '../../shared/components/app-input/app-input.component';
|
||||
import { AppButtonComponent } from '../../shared/components/app-button/app-button.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';
|
||||
|
||||
@Component({
|
||||
@@ -20,10 +28,10 @@ import { LanguageService } from '../../core/services/language.service';
|
||||
AppInputComponent,
|
||||
AppButtonComponent,
|
||||
AppCardComponent,
|
||||
AppToggleSelectorComponent
|
||||
AppToggleSelectorComponent,
|
||||
],
|
||||
templateUrl: './checkout.component.html',
|
||||
styleUrls: ['./checkout.component.scss']
|
||||
styleUrls: ['./checkout.component.scss'],
|
||||
})
|
||||
export class CheckoutComponent implements OnInit {
|
||||
private fb = inject(FormBuilder);
|
||||
@@ -41,7 +49,7 @@ export class CheckoutComponent implements OnInit {
|
||||
|
||||
userTypeOptions: ToggleOption[] = [
|
||||
{ label: 'CONTACT.TYPE_PRIVATE', value: 'PRIVATE' },
|
||||
{ label: 'CONTACT.TYPE_COMPANY', value: 'BUSINESS' }
|
||||
{ label: 'CONTACT.TYPE_COMPANY', value: 'BUSINESS' },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
@@ -62,7 +70,7 @@ export class CheckoutComponent implements OnInit {
|
||||
addressLine2: [''],
|
||||
zip: ['', Validators.required],
|
||||
city: ['', Validators.required],
|
||||
countryCode: ['CH', Validators.required]
|
||||
countryCode: ['CH', Validators.required],
|
||||
}),
|
||||
|
||||
shippingAddress: this.fb.group({
|
||||
@@ -74,8 +82,8 @@ export class CheckoutComponent implements OnInit {
|
||||
addressLine2: [''],
|
||||
zip: [''],
|
||||
city: [''],
|
||||
countryCode: ['CH']
|
||||
})
|
||||
countryCode: ['CH'],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,7 +119,7 @@ export class CheckoutComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.queryParams.subscribe(params => {
|
||||
this.route.queryParams.subscribe((params) => {
|
||||
this.sessionId = params['session'];
|
||||
if (!this.sessionId) {
|
||||
this.error = 'CHECKOUT.ERR_NO_SESSION_START';
|
||||
@@ -123,8 +131,12 @@ export class CheckoutComponent implements OnInit {
|
||||
});
|
||||
|
||||
// Toggle shipping validation based on checkbox
|
||||
this.checkoutForm.get('shippingSameAsBilling')?.valueChanges.subscribe(isSame => {
|
||||
const shippingGroup = this.checkoutForm.get('shippingAddress') as FormGroup;
|
||||
this.checkoutForm
|
||||
.get('shippingSameAsBilling')
|
||||
?.valueChanges.subscribe((isSame) => {
|
||||
const shippingGroup = this.checkoutForm.get(
|
||||
'shippingAddress',
|
||||
) as FormGroup;
|
||||
if (isSame) {
|
||||
shippingGroup.disable();
|
||||
} else {
|
||||
@@ -146,7 +158,7 @@ export class CheckoutComponent implements OnInit {
|
||||
error: (err) => {
|
||||
console.error('Failed to load session', err);
|
||||
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
|
||||
firstName: formVal.billingAddress.firstName,
|
||||
lastName: formVal.billingAddress.lastName,
|
||||
companyName: formVal.billingAddress.companyName
|
||||
companyName: formVal.billingAddress.companyName,
|
||||
},
|
||||
billingAddress: {
|
||||
firstName: formVal.billingAddress.firstName,
|
||||
@@ -179,9 +191,11 @@ export class CheckoutComponent implements OnInit {
|
||||
addressLine2: formVal.billingAddress.addressLine2,
|
||||
zip: formVal.billingAddress.zip,
|
||||
city: formVal.billingAddress.city,
|
||||
countryCode: formVal.billingAddress.countryCode
|
||||
countryCode: formVal.billingAddress.countryCode,
|
||||
},
|
||||
shippingAddress: formVal.shippingSameAsBilling ? null : {
|
||||
shippingAddress: formVal.shippingSameAsBilling
|
||||
? null
|
||||
: {
|
||||
firstName: formVal.shippingAddress.firstName,
|
||||
lastName: formVal.shippingAddress.lastName,
|
||||
companyName: formVal.shippingAddress.companyName,
|
||||
@@ -190,12 +204,12 @@ export class CheckoutComponent implements OnInit {
|
||||
addressLine2: formVal.shippingAddress.addressLine2,
|
||||
zip: formVal.shippingAddress.zip,
|
||||
city: formVal.shippingAddress.city,
|
||||
countryCode: formVal.shippingAddress.countryCode
|
||||
countryCode: formVal.shippingAddress.countryCode,
|
||||
},
|
||||
shippingSameAsBilling: formVal.shippingSameAsBilling,
|
||||
language: this.languageService.selectedLang(),
|
||||
acceptTerms: formVal.acceptLegal,
|
||||
acceptPrivacy: formVal.acceptLegal
|
||||
acceptPrivacy: formVal.acceptLegal,
|
||||
};
|
||||
|
||||
if (!this.sessionId) {
|
||||
@@ -212,13 +226,18 @@ export class CheckoutComponent implements OnInit {
|
||||
this.error = 'CHECKOUT.ERR_CREATE_ORDER';
|
||||
return;
|
||||
}
|
||||
this.router.navigate(['/', this.languageService.selectedLang(), 'order', orderId]);
|
||||
this.router.navigate([
|
||||
'/',
|
||||
this.languageService.selectedLang(),
|
||||
'order',
|
||||
orderId,
|
||||
]);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Order creation failed', err);
|
||||
this.isSubmitting.set(false);
|
||||
this.error = 'CHECKOUT.ERR_CREATE_ORDER';
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
@if (sent()) {
|
||||
<app-success-state context="contact" (action)="resetForm()"></app-success-state>
|
||||
<app-success-state
|
||||
context="contact"
|
||||
(action)="resetForm()"
|
||||
></app-success-state>
|
||||
} @else {
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
<!-- Request Type -->
|
||||
<div class="form-group">
|
||||
<label>{{ 'CONTACT.REQ_TYPE_LABEL' | translate }} *</label>
|
||||
<label>{{ "CONTACT.REQ_TYPE_LABEL" | translate }} *</label>
|
||||
<select formControlName="requestType" class="form-control">
|
||||
<option *ngFor="let type of requestTypes" [value]="type.value">
|
||||
{{ type.label | translate }}
|
||||
@@ -14,49 +17,99 @@
|
||||
|
||||
<div class="row">
|
||||
<!-- 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 -->
|
||||
<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>
|
||||
|
||||
<!-- User Type Selector (Segmented Control) -->
|
||||
<div class="user-type-selector">
|
||||
<div class="type-option" [class.selected]="!isCompany" (click)="setCompanyMode(false)">
|
||||
{{ 'CONTACT.TYPE_PRIVATE' | translate }}
|
||||
<div
|
||||
class="type-option"
|
||||
[class.selected]="!isCompany"
|
||||
(click)="setCompanyMode(false)"
|
||||
>
|
||||
{{ "CONTACT.TYPE_PRIVATE" | translate }}
|
||||
</div>
|
||||
<div class="type-option" [class.selected]="isCompany" (click)="setCompanyMode(true)">
|
||||
{{ 'CONTACT.TYPE_COMPANY' | translate }}
|
||||
<div
|
||||
class="type-option"
|
||||
[class.selected]="isCompany"
|
||||
(click)="setCompanyMode(true)"
|
||||
>
|
||||
{{ "CONTACT.TYPE_COMPANY" | translate }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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) -->
|
||||
<div *ngIf="isCompany" class="company-fields">
|
||||
<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>
|
||||
<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 class="form-group">
|
||||
<label>{{ 'CONTACT.LABEL_MESSAGE' | translate }}</label>
|
||||
<textarea formControlName="message" class="form-control" rows="4"></textarea>
|
||||
<label>{{ "CONTACT.LABEL_MESSAGE" | translate }}</label>
|
||||
<textarea
|
||||
formControlName="message"
|
||||
class="form-control"
|
||||
rows="4"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- File Upload Section -->
|
||||
<div class="form-group">
|
||||
<label>{{ 'CONTACT.UPLOAD_LABEL' | translate }}</label>
|
||||
<p class="hint">{{ 'CONTACT.UPLOAD_HINT' | translate }}</p>
|
||||
<label>{{ "CONTACT.UPLOAD_LABEL" | translate }}</label>
|
||||
<p class="hint">{{ "CONTACT.UPLOAD_HINT" | translate }}</p>
|
||||
<p class="hint upload-privacy-note">
|
||||
{{ 'LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX' | translate }}
|
||||
<a href="/privacy" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.UPLOAD_NOTICE_LINK' | translate }}</a>.
|
||||
{{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }}
|
||||
<a href="/privacy" target="_blank" rel="noopener">{{
|
||||
"LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate
|
||||
}}</a
|
||||
>.
|
||||
</p>
|
||||
|
||||
<div class="drop-zone" (click)="fileInput.click()"
|
||||
(dragover)="onDragOver($event)" (drop)="onDrop($event)">
|
||||
<input #fileInput type="file" multiple (change)="onFileSelected($event)" hidden
|
||||
[accept]="acceptedFormats">
|
||||
<p>{{ 'CONTACT.DROP_FILES' | translate }}</p>
|
||||
<div
|
||||
class="drop-zone"
|
||||
(click)="fileInput.click()"
|
||||
(dragover)="onDragOver($event)"
|
||||
(drop)="onDrop($event)"
|
||||
>
|
||||
<input
|
||||
#fileInput
|
||||
type="file"
|
||||
multiple
|
||||
(change)="onFileSelected($event)"
|
||||
hidden
|
||||
[accept]="acceptedFormats"
|
||||
/>
|
||||
<p>{{ "CONTACT.DROP_FILES" | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="file-grid" *ngIf="files().length > 0">
|
||||
@@ -65,39 +118,80 @@
|
||||
type="button"
|
||||
class="remove-btn"
|
||||
(click)="removeFile(i)"
|
||||
[attr.aria-label]="'CONTACT.REMOVE_FILE' | translate">×</button>
|
||||
<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">
|
||||
<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>
|
||||
[attr.aria-label]="'CONTACT.REMOVE_FILE' | translate"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
<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 class="file-name" [title]="file.file.name">{{ file.file.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legal-consent">
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" formControlName="acceptLegal">
|
||||
<input type="checkbox" formControlName="acceptLegal" />
|
||||
<span class="checkmark"></span>
|
||||
<span>
|
||||
{{ 'LEGAL.CONSENT.LABEL_PREFIX' | translate }}
|
||||
<a href="/terms" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.TERMS_LINK' | translate }}</a>
|
||||
{{ 'LEGAL.CONSENT.AND' | translate }}
|
||||
<a href="/privacy" target="_blank" rel="noopener">{{ 'LEGAL.CONSENT.PRIVACY_LINK' | translate }}</a>.
|
||||
{{ "LEGAL.CONSENT.LABEL_PREFIX" | translate }}
|
||||
<a href="/terms" target="_blank" rel="noopener">{{
|
||||
"LEGAL.CONSENT.TERMS_LINK" | translate
|
||||
}}</a>
|
||||
{{ "LEGAL.CONSENT.AND" | translate }}
|
||||
<a href="/privacy" target="_blank" rel="noopener">{{
|
||||
"LEGAL.CONSENT.PRIVACY_LINK" | translate
|
||||
}}</a
|
||||
>.
|
||||
</span>
|
||||
</label>
|
||||
<div class="consent-error" *ngIf="form.get('acceptLegal')?.invalid && form.get('acceptLegal')?.touched">
|
||||
{{ 'LEGAL.CONSENT.REQUIRED_ERROR' | translate }}
|
||||
<div
|
||||
class="consent-error"
|
||||
*ngIf="
|
||||
form.get('acceptLegal')?.invalid && form.get('acceptLegal')?.touched
|
||||
"
|
||||
>
|
||||
{{ "LEGAL.CONSENT.REQUIRED_ERROR" | translate }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
.form-group { display: flex; flex-direction: column; 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-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
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);
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
&:focus { outline: none; border-color: var(--color-brand); }
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand);
|
||||
}
|
||||
}
|
||||
|
||||
select.form-control {
|
||||
@@ -27,13 +46,18 @@ select.form-control {
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
@media(min-width: 768px) {
|
||||
@media (min-width: 768px) {
|
||||
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 {
|
||||
@@ -59,13 +83,15 @@ app-input.col { width: 100%; }
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
|
||||
&:hover { color: var(--color-text); }
|
||||
&:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--color-brand);
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +113,10 @@ app-input.col { width: 100%; }
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
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 {
|
||||
@@ -111,7 +140,12 @@ app-input.col { width: 100%; }
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -126,21 +160,46 @@ app-input.col { width: 100%; }
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-size: 0.65rem; color: var(--color-text); 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);
|
||||
font-size: 0.65rem;
|
||||
color: var(--color-text);
|
||||
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 {
|
||||
position: absolute; top: 2px; right: 2px; z-index: 10;
|
||||
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; }
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
z-index: 10;
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Component, signal, effect, inject, OnDestroy } from '@angular/core';
|
||||
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 { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
|
||||
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
|
||||
@@ -18,15 +23,23 @@ import { SuccessStateComponent } from '../../../../shared/components/success-sta
|
||||
@Component({
|
||||
selector: 'app-contact-form',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppButtonComponent, SuccessStateComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
TranslateModule,
|
||||
AppInputComponent,
|
||||
AppButtonComponent,
|
||||
SuccessStateComponent,
|
||||
],
|
||||
templateUrl: './contact-form.component.html',
|
||||
styleUrl: './contact-form.component.scss'
|
||||
styleUrl: './contact-form.component.scss',
|
||||
})
|
||||
export class ContactFormComponent implements OnDestroy {
|
||||
form: FormGroup;
|
||||
sent = signal(false);
|
||||
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 {
|
||||
return this.form.get('isCompany')?.value;
|
||||
@@ -36,7 +49,7 @@ export class ContactFormComponent implements OnDestroy {
|
||||
{ value: 'custom', label: 'CONTACT.REQ_TYPE_CUSTOM' },
|
||||
{ value: 'series', label: 'CONTACT.REQ_TYPE_SERIES' },
|
||||
{ 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);
|
||||
@@ -44,7 +57,7 @@ export class ContactFormComponent implements OnDestroy {
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private translate: TranslateService,
|
||||
private estimator: QuoteEstimatorService
|
||||
private estimator: QuoteEstimatorService,
|
||||
) {
|
||||
this.form = this.fb.group({
|
||||
requestType: ['custom', Validators.required],
|
||||
@@ -55,11 +68,11 @@ export class ContactFormComponent implements OnDestroy {
|
||||
isCompany: [false],
|
||||
companyName: [''],
|
||||
referencePerson: [''],
|
||||
acceptLegal: [false, Validators.requiredTrue]
|
||||
acceptLegal: [false, Validators.requiredTrue],
|
||||
});
|
||||
|
||||
// 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 companyNameControl = this.form.get('companyName');
|
||||
const refPersonControl = this.form.get('referencePerson');
|
||||
@@ -94,16 +107,18 @@ export class ContactFormComponent implements OnDestroy {
|
||||
if (pending) {
|
||||
this.form.patchValue({
|
||||
requestType: 'consult',
|
||||
message: pending.message
|
||||
message: pending.message,
|
||||
});
|
||||
|
||||
// Process files
|
||||
const filePreviews: FilePreview[] = pending.files.map(f => {
|
||||
const filePreviews: FilePreview[] = pending.files.map((f) => {
|
||||
const type = this.getFileType(f);
|
||||
return {
|
||||
file: f,
|
||||
type,
|
||||
url: this.shouldCreatePreview(type) ? URL.createObjectURL(f) : undefined
|
||||
url: this.shouldCreatePreview(type)
|
||||
? URL.createObjectURL(f)
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
this.files.set(filePreviews);
|
||||
@@ -124,22 +139,29 @@ export class ContactFormComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
onDragOver(event: DragEvent) {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
onDrop(event: DragEvent) {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
if (event.dataTransfer?.files) this.handleFiles(Array.from(event.dataTransfer.files));
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.dataTransfer?.files)
|
||||
this.handleFiles(Array.from(event.dataTransfer.files));
|
||||
}
|
||||
|
||||
handleFiles(newFiles: File[]) {
|
||||
const currentFiles = this.files();
|
||||
const blockedCompressed = newFiles.filter(file => this.isCompressedFile(file));
|
||||
const blockedCompressed = newFiles.filter((file) =>
|
||||
this.isCompressedFile(file),
|
||||
);
|
||||
if (blockedCompressed.length > 0) {
|
||||
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 (currentFiles.length + allowedFiles.length > 15) {
|
||||
@@ -147,39 +169,83 @@ export class ContactFormComponent implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
allowedFiles.forEach(file => {
|
||||
allowedFiles.forEach((file) => {
|
||||
const type = this.getFileType(file);
|
||||
const preview: FilePreview = {
|
||||
file,
|
||||
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) {
|
||||
this.files.update(files => {
|
||||
this.files.update((files) => {
|
||||
const fileToRemove = files[index];
|
||||
if (fileToRemove) this.revokePreviewUrl(fileToRemove);
|
||||
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);
|
||||
|
||||
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';
|
||||
}
|
||||
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';
|
||||
}
|
||||
if (file.type === 'application/pdf' || ext === 'pdf') return 'pdf';
|
||||
if (['stl', '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';
|
||||
if (
|
||||
[
|
||||
'stl',
|
||||
'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';
|
||||
}
|
||||
|
||||
@@ -195,7 +261,7 @@ export class ContactFormComponent implements OnDestroy {
|
||||
phone: formVal.phone,
|
||||
message: formVal.message,
|
||||
acceptTerms: formVal.acceptLegal,
|
||||
acceptPrivacy: formVal.acceptLegal
|
||||
acceptPrivacy: formVal.acceptLegal,
|
||||
};
|
||||
|
||||
if (isCompany) {
|
||||
@@ -205,16 +271,20 @@ export class ContactFormComponent implements OnDestroy {
|
||||
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: () => {
|
||||
this.sent.set(true);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Submission failed', err);
|
||||
alert(this.translate.instant('CONTACT.ERROR_SUBMIT'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
} else {
|
||||
this.form.markAllAsTouched();
|
||||
}
|
||||
@@ -239,7 +309,17 @@ export class ContactFormComponent implements OnDestroy {
|
||||
private isCompressedFile(file: File): boolean {
|
||||
const ext = this.getExtension(file.name);
|
||||
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 = [
|
||||
'application/zip',
|
||||
@@ -253,9 +333,12 @@ export class ContactFormComponent implements OnDestroy {
|
||||
'application/x-bzip2',
|
||||
'application/x-xz',
|
||||
'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 {
|
||||
@@ -265,6 +348,6 @@ export class ContactFormComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
private revokeAllPreviewUrls(): void {
|
||||
this.files().forEach(file => this.revokePreviewUrl(file));
|
||||
this.files().forEach((file) => this.revokePreviewUrl(file));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<section class="contact-hero">
|
||||
<div class="container">
|
||||
<h1>{{ 'CONTACT.TITLE' | translate }}</h1>
|
||||
<p class="subtitle">{{ 'CONTACT.HERO_SUBTITLE' | translate }}</p>
|
||||
<h1>{{ "CONTACT.TITLE" | translate }}</h1>
|
||||
<p class="subtitle">{{ "CONTACT.HERO_SUBTITLE" | translate }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -7,8 +7,13 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
|
||||
@Component({
|
||||
selector: 'app-contact-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, TranslateModule, ContactFormComponent, AppCardComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
TranslateModule,
|
||||
ContactFormComponent,
|
||||
AppCardComponent,
|
||||
],
|
||||
templateUrl: './contact-page.component.html',
|
||||
styleUrl: './contact-page.component.scss'
|
||||
styleUrl: './contact-page.component.scss',
|
||||
})
|
||||
export class ContactPageComponent {}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Routes } from '@angular/router';
|
||||
export const CONTACT_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./contact-page.component').then(m => m.ContactPageComponent)
|
||||
}
|
||||
loadComponent: () =>
|
||||
import('./contact-page.component').then((m) => m.ContactPageComponent),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
<main class="home-page">
|
||||
<main class="home-page">
|
||||
<section class="hero">
|
||||
<div class="container hero-grid">
|
||||
<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>
|
||||
<p class="hero-lead">
|
||||
{{ 'HOME.HERO_LEAD' | translate }}
|
||||
{{ "HOME.HERO_LEAD" | translate }}
|
||||
</p>
|
||||
<p class="hero-subtitle">
|
||||
{{ 'HOME.HERO_SUBTITLE' | translate }}
|
||||
{{ "HOME.HERO_SUBTITLE" | translate }}
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<app-button variant="primary" routerLink="/calculator/basic">{{ 'HOME.BTN_CALCULATE' | translate }}</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>
|
||||
<app-button variant="primary" routerLink="/calculator/basic">{{
|
||||
"HOME.BTN_CALCULATE" | translate
|
||||
}}</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>
|
||||
@@ -23,39 +29,39 @@
|
||||
<div class="capabilities-bg"></div>
|
||||
<div class="container">
|
||||
<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">
|
||||
{{ 'HOME.SEC_CAP_SUBTITLE' | translate }}
|
||||
{{ "HOME.SEC_CAP_SUBTITLE" | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="cap-cards">
|
||||
<app-card>
|
||||
<div class="card-image-placeholder">
|
||||
<img src="assets/images/home/prototipi.jpg" alt="">
|
||||
<img src="assets/images/home/prototipi.jpg" alt="" />
|
||||
</div>
|
||||
<h3>{{ 'HOME.CAP_1_TITLE' | translate }}</h3>
|
||||
<p class="text-muted">{{ 'HOME.CAP_1_TEXT' | translate }}</p>
|
||||
<h3>{{ "HOME.CAP_1_TITLE" | translate }}</h3>
|
||||
<p class="text-muted">{{ "HOME.CAP_1_TEXT" | translate }}</p>
|
||||
</app-card>
|
||||
<app-card>
|
||||
<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>
|
||||
<h3>{{ 'HOME.CAP_2_TITLE' | translate }}</h3>
|
||||
<p class="text-muted">{{ 'HOME.CAP_2_TEXT' | translate }}</p>
|
||||
<h3>{{ "HOME.CAP_2_TITLE" | translate }}</h3>
|
||||
<p class="text-muted">{{ "HOME.CAP_2_TEXT" | translate }}</p>
|
||||
</app-card>
|
||||
<app-card>
|
||||
<div class="card-image-placeholder">
|
||||
<img src="assets/images/home/serie.jpg" alt="">
|
||||
<img src="assets/images/home/serie.jpg" alt="" />
|
||||
</div>
|
||||
<h3>{{ 'HOME.CAP_3_TITLE' | translate }}</h3>
|
||||
<p class="text-muted">{{ 'HOME.CAP_3_TEXT' | translate }}</p>
|
||||
<h3>{{ "HOME.CAP_3_TITLE" | translate }}</h3>
|
||||
<p class="text-muted">{{ "HOME.CAP_3_TEXT" | translate }}</p>
|
||||
</app-card>
|
||||
<app-card>
|
||||
<div class="card-image-placeholder">
|
||||
<img src="assets/images/home/cad.jpg" alt="">
|
||||
<img src="assets/images/home/cad.jpg" alt="" />
|
||||
</div>
|
||||
<h3>{{ 'HOME.CAP_4_TITLE' | translate }}</h3>
|
||||
<p class="text-muted">{{ 'HOME.CAP_4_TEXT' | translate }}</p>
|
||||
<h3>{{ "HOME.CAP_4_TITLE" | translate }}</h3>
|
||||
<p class="text-muted">{{ "HOME.CAP_4_TEXT" | translate }}</p>
|
||||
</app-card>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,30 +70,44 @@
|
||||
<section class="section calculator">
|
||||
<div class="container calculator-grid">
|
||||
<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">
|
||||
{{ 'HOME.SEC_CALC_SUBTITLE' | translate }}
|
||||
{{ "HOME.SEC_CALC_SUBTITLE" | translate }}
|
||||
</p>
|
||||
<ul class="calculator-list">
|
||||
<li>{{ 'HOME.SEC_CALC_LIST_1' | translate }}</li>
|
||||
<li>{{ "HOME.SEC_CALC_LIST_1" | translate }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<app-card class="quote-card">
|
||||
<div class="quote-header">
|
||||
<div>
|
||||
<p class="quote-eyebrow">{{ 'HOME.CARD_CALC_EYEBROW' | translate }}</p>
|
||||
<h3 class="quote-title">{{ 'HOME.CARD_CALC_TITLE' | translate }}</h3>
|
||||
<p class="quote-eyebrow">
|
||||
{{ "HOME.CARD_CALC_EYEBROW" | translate }}
|
||||
</p>
|
||||
<h3 class="quote-title">
|
||||
{{ "HOME.CARD_CALC_TITLE" | translate }}
|
||||
</h3>
|
||||
</div>
|
||||
<span class="quote-tag">{{ 'HOME.CARD_CALC_TAG' | translate }}</span>
|
||||
<span class="quote-tag">{{ "HOME.CARD_CALC_TAG" | translate }}</span>
|
||||
</div>
|
||||
<ul class="quote-steps">
|
||||
<li>{{ 'HOME.CARD_CALC_STEP_1' | translate }}</li>
|
||||
<li>{{ 'HOME.CARD_CALC_STEP_2' | translate }}</li>
|
||||
<li>{{ 'HOME.CARD_CALC_STEP_3' | translate }}</li>
|
||||
<li>{{ "HOME.CARD_CALC_STEP_1" | translate }}</li>
|
||||
<li>{{ "HOME.CARD_CALC_STEP_2" | translate }}</li>
|
||||
<li>{{ "HOME.CARD_CALC_STEP_3" | translate }}</li>
|
||||
</ul>
|
||||
<div class="quote-actions">
|
||||
<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>
|
||||
<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>
|
||||
</app-card>
|
||||
</div>
|
||||
@@ -96,37 +116,48 @@
|
||||
<section class="section shop">
|
||||
<div class="container split">
|
||||
<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>
|
||||
{{ 'HOME.SEC_SHOP_TEXT' | translate }}
|
||||
{{ "HOME.SEC_SHOP_TEXT" | translate }}
|
||||
</p>
|
||||
<ul class="shop-list">
|
||||
<li>{{ 'HOME.SEC_SHOP_LIST_1' | translate }}</li>
|
||||
<li>{{ 'HOME.SEC_SHOP_LIST_2' | translate }}</li>
|
||||
<li>{{ 'HOME.SEC_SHOP_LIST_3' | translate }}</li>
|
||||
<li>{{ "HOME.SEC_SHOP_LIST_1" | translate }}</li>
|
||||
<li>{{ "HOME.SEC_SHOP_LIST_2" | translate }}</li>
|
||||
<li>{{ "HOME.SEC_SHOP_LIST_3" | translate }}</li>
|
||||
</ul>
|
||||
<div class="shop-actions">
|
||||
<app-button variant="primary" routerLink="/shop">{{ 'HOME.BTN_DISCOVER' | translate }}</app-button>
|
||||
<app-button variant="outline" routerLink="/contact">{{ 'HOME.BTN_REQ_SOLUTION' | translate }}</app-button>
|
||||
<app-button variant="primary" routerLink="/shop">{{
|
||||
"HOME.BTN_DISCOVER" | translate
|
||||
}}</app-button>
|
||||
<app-button variant="outline" routerLink="/contact">{{
|
||||
"HOME.BTN_REQ_SOLUTION" | translate
|
||||
}}</app-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shop-gallery" 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">
|
||||
<div
|
||||
class="shop-gallery"
|
||||
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>
|
||||
</div>
|
||||
<div class="shop-cards">
|
||||
<app-card>
|
||||
<h3>{{ 'HOME.CARD_SHOP_1_TITLE' | translate }}</h3>
|
||||
<p class="text-muted">{{ 'HOME.CARD_SHOP_1_TEXT' | translate }}</p>
|
||||
<h3>{{ "HOME.CARD_SHOP_1_TITLE" | translate }}</h3>
|
||||
<p class="text-muted">{{ "HOME.CARD_SHOP_1_TEXT" | translate }}</p>
|
||||
</app-card>
|
||||
<app-card>
|
||||
<h3>{{ 'HOME.CARD_SHOP_2_TITLE' | translate }}</h3>
|
||||
<p class="text-muted">{{ 'HOME.CARD_SHOP_2_TEXT' | translate }}</p>
|
||||
<h3>{{ "HOME.CARD_SHOP_2_TITLE" | translate }}</h3>
|
||||
<p class="text-muted">{{ "HOME.CARD_SHOP_2_TEXT" | translate }}</p>
|
||||
</app-card>
|
||||
<app-card>
|
||||
<h3>{{ 'HOME.CARD_SHOP_3_TITLE' | translate }}</h3>
|
||||
<p class="text-muted">{{ 'HOME.CARD_SHOP_3_TEXT' | translate }}</p>
|
||||
<h3>{{ "HOME.CARD_SHOP_3_TITLE" | translate }}</h3>
|
||||
<p class="text-muted">{{ "HOME.CARD_SHOP_3_TEXT" | translate }}</p>
|
||||
</app-card>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,13 +166,17 @@
|
||||
<section class="section about">
|
||||
<div class="container about-grid">
|
||||
<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>
|
||||
{{ 'HOME.SEC_ABOUT_TEXT' | translate }}
|
||||
{{ "HOME.SEC_ABOUT_TEXT" | translate }}
|
||||
</p>
|
||||
<div class="about-actions">
|
||||
<app-button variant="primary" routerLink="/about">{{ 'HOME.SEC_ABOUT_TITLE' | translate }}</app-button>
|
||||
<app-button variant="outline" routerLink="/contact">{{ 'HOME.BTN_CONTACT' | translate }}</app-button>
|
||||
<app-button variant="primary" routerLink="/about">{{
|
||||
"HOME.SEC_ABOUT_TITLE" | translate
|
||||
}}</app-button>
|
||||
<app-button variant="outline" routerLink="/contact">{{
|
||||
"HOME.BTN_CONTACT" | translate
|
||||
}}</app-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="about-media">
|
||||
@@ -152,7 +187,7 @@
|
||||
[alt]="founderImages[founderImageIndex].alt | translate"
|
||||
width="1200"
|
||||
height="900"
|
||||
>
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="founder-nav founder-nav-prev"
|
||||
@@ -173,4 +208,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</main>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
@use '../../../styles/patterns';
|
||||
@use "../../../styles/patterns";
|
||||
|
||||
.home-page {
|
||||
.home-page {
|
||||
--home-bg: #faf9f6;
|
||||
--color-bg-card: #ffffff;
|
||||
background: var(--home-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
.hero {
|
||||
position: relative;
|
||||
padding: 6rem 0 5rem;
|
||||
overflow: hidden;
|
||||
background: var(--home-bg);
|
||||
// Enhanced Grid Pattern
|
||||
&::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@include patterns.pattern-grid(var(--color-neutral-900), 40px, 1px);
|
||||
@@ -22,73 +22,81 @@
|
||||
pointer-events: none;
|
||||
mask-image: linear-gradient(to bottom, black 70%, transparent 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the accent blob
|
||||
.hero::before {
|
||||
content: '';
|
||||
// Keep the accent blob
|
||||
.hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
right: -120px;
|
||||
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;
|
||||
z-index: 0;
|
||||
animation: floatGlow 12s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
gap: var(--space-12);
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-copy { animation: fadeUp 0.8s ease both; }
|
||||
.hero-panel { animation: fadeUp 0.8s ease 0.15s both; }
|
||||
.hero-copy {
|
||||
animation: fadeUp 0.8s ease both;
|
||||
}
|
||||
.hero-panel {
|
||||
animation: fadeUp 0.8s ease 0.15s both;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-secondary-600);
|
||||
margin-bottom: var(--space-3);
|
||||
font-weight: 600;
|
||||
}
|
||||
.hero-title {
|
||||
}
|
||||
.hero-title {
|
||||
font-size: clamp(2.5rem, 2.4vw + 1.8rem, 4rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.hero-lead {
|
||||
}
|
||||
.hero-lead {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-neutral-900);
|
||||
margin-bottom: var(--space-3);
|
||||
max-width: 600px;
|
||||
}
|
||||
.hero-subtitle {
|
||||
}
|
||||
.hero-subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text-muted);
|
||||
max-width: 560px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.hero-actions {
|
||||
}
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
margin: var(--space-6) 0 var(--space-4);
|
||||
}
|
||||
.hero-badges {
|
||||
}
|
||||
.hero-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.hero-badges span {
|
||||
}
|
||||
.hero-badges span {
|
||||
display: inline-flex;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
@@ -97,49 +105,52 @@
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
.quote-card {
|
||||
.quote-card {
|
||||
display: block;
|
||||
}
|
||||
.focus-card {
|
||||
}
|
||||
.focus-card {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.focus-list {
|
||||
}
|
||||
.focus-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.focus-list li::before {
|
||||
content: '•';
|
||||
}
|
||||
.focus-list li::before {
|
||||
content: "•";
|
||||
color: var(--color-brand);
|
||||
margin-right: var(--space-2);
|
||||
}
|
||||
.focus-list li {
|
||||
}
|
||||
.focus-list li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.quote-header {
|
||||
}
|
||||
.quote-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.quote-eyebrow {
|
||||
}
|
||||
.quote-eyebrow {
|
||||
text-transform: uppercase;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--color-secondary-600);
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
.quote-title { margin: 0; font-size: 1.35rem; }
|
||||
.quote-tag {
|
||||
}
|
||||
.quote-title {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
.quote-tag {
|
||||
background: var(--color-neutral-100);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
@@ -149,21 +160,21 @@
|
||||
color: var(--color-brand-600);
|
||||
background: var(--color-brand-50);
|
||||
border-color: var(--color-brand-200);
|
||||
}
|
||||
.quote-steps {
|
||||
}
|
||||
.quote-steps {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 var(--space-5);
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.quote-steps li {
|
||||
}
|
||||
.quote-steps li {
|
||||
position: relative;
|
||||
padding-left: 1.5rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.quote-steps li::before {
|
||||
content: '';
|
||||
}
|
||||
.quote-steps li::before {
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
@@ -171,62 +182,80 @@
|
||||
position: absolute;
|
||||
left: 0.25rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
.quote-meta {
|
||||
}
|
||||
.quote-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.meta-label {
|
||||
}
|
||||
.meta-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-secondary-600);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
.meta-value { font-weight: 600; }
|
||||
.quote-actions { display: grid; gap: var(--space-3); }
|
||||
}
|
||||
.meta-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
.quote-actions {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.capabilities {
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-top: 3rem;
|
||||
}
|
||||
.capabilities-bg {
|
||||
.capabilities-bg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.section { padding: 5.5rem 0; position: relative; }
|
||||
.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); }
|
||||
.section {
|
||||
padding: 5.5rem 0;
|
||||
position: relative;
|
||||
}
|
||||
.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;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.calculator-grid {
|
||||
}
|
||||
.calculator-grid {
|
||||
display: grid;
|
||||
gap: var(--space-10);
|
||||
align-items: start;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.calculator-list {
|
||||
}
|
||||
.calculator-list {
|
||||
padding-left: var(--space-4);
|
||||
color: var(--color-text-muted);
|
||||
margin: var(--space-6) 0 0;
|
||||
}
|
||||
.cap-cards {
|
||||
}
|
||||
.cap-cards {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.card-image-placeholder {
|
||||
.card-image-placeholder {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
background: #f5f5f5;
|
||||
@@ -240,41 +269,43 @@
|
||||
justify-content: center;
|
||||
color: var(--color-neutral-400);
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.card-image-placeholder img {
|
||||
.card-image-placeholder img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.shop {
|
||||
.shop {
|
||||
background: var(--home-bg);
|
||||
position: relative;
|
||||
}
|
||||
.shop .split { align-items: start; }
|
||||
.shop-copy {
|
||||
}
|
||||
.shop .split {
|
||||
align-items: start;
|
||||
}
|
||||
.shop-copy {
|
||||
max-width: 760px;
|
||||
}
|
||||
.split {
|
||||
}
|
||||
.split {
|
||||
display: grid;
|
||||
gap: var(--space-10);
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.shop-list {
|
||||
}
|
||||
.shop-list {
|
||||
padding-left: var(--space-4);
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
.shop-actions {
|
||||
}
|
||||
.shop-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.shop-gallery {
|
||||
}
|
||||
.shop-gallery {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
overflow-x: auto;
|
||||
@@ -284,9 +315,9 @@
|
||||
width: min(100%, 440px);
|
||||
justify-self: end;
|
||||
aspect-ratio: 16 / 11;
|
||||
}
|
||||
}
|
||||
|
||||
.shop-gallery-item {
|
||||
.shop-gallery-item {
|
||||
flex: 0 0 100%;
|
||||
margin: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -296,52 +327,52 @@
|
||||
box-shadow: var(--shadow-sm);
|
||||
scroll-snap-align: start;
|
||||
aspect-ratio: 16 / 10;
|
||||
}
|
||||
}
|
||||
|
||||
.shop-gallery-item img {
|
||||
.shop-gallery-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.shop-cards {
|
||||
.shop-cards {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.shop-cards h3 {
|
||||
.shop-cards h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.shop-cards p {
|
||||
.shop-cards p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.about {
|
||||
.about {
|
||||
background: transparent;
|
||||
border-top: 1px solid var(--color-border);
|
||||
position: relative;
|
||||
}
|
||||
.about-actions {
|
||||
}
|
||||
.about-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.about-grid {
|
||||
}
|
||||
.about-grid {
|
||||
display: grid;
|
||||
gap: var(--space-10);
|
||||
align-items: center;
|
||||
}
|
||||
.about-media {
|
||||
}
|
||||
.about-media {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.about-feature-image {
|
||||
.about-feature-image {
|
||||
width: 100%;
|
||||
max-width: 620px;
|
||||
aspect-ratio: 16 / 10;
|
||||
@@ -351,18 +382,18 @@
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
contain: layout paint;
|
||||
}
|
||||
}
|
||||
|
||||
.about-feature-photo {
|
||||
.about-feature-photo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.founder-nav {
|
||||
.founder-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
@@ -380,45 +411,68 @@
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.founder-nav:hover {
|
||||
.founder-nav:hover {
|
||||
background: rgba(17, 24, 39, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.founder-nav-prev { left: 0.75rem; }
|
||||
.founder-nav-next { right: 0.75rem; }
|
||||
.founder-nav-prev {
|
||||
left: 0.75rem;
|
||||
}
|
||||
.founder-nav-next {
|
||||
right: 0.75rem;
|
||||
}
|
||||
|
||||
.founder-nav:focus-visible {
|
||||
.founder-nav:focus-visible {
|
||||
outline: 2px solid var(--color-brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.media-tile p {
|
||||
}
|
||||
.media-tile p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.about-note {
|
||||
}
|
||||
.about-note {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.hero-grid { grid-template-columns: 1.1fr 0.9fr; }
|
||||
.calculator-grid { grid-template-columns: 1.1fr 0.9fr; }
|
||||
.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; }
|
||||
@media (min-width: 960px) {
|
||||
.hero-grid {
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
}
|
||||
.calculator-grid {
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
}
|
||||
.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 {
|
||||
grid-column: 1 / -1;
|
||||
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) {
|
||||
.hero-actions { flex-direction: column; align-items: stretch; }
|
||||
.quote-meta { grid-template-columns: 1fr; }
|
||||
@media (max-width: 640px) {
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.quote-meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.shop-gallery {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
@@ -427,7 +481,9 @@
|
||||
.shop-gallery-item {
|
||||
aspect-ratio: 16 / 11;
|
||||
}
|
||||
.shop-cards { grid-template-columns: 1fr; }
|
||||
.shop-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.about-media {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -440,18 +496,34 @@
|
||||
height: 2rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(18px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px);
|
||||
}
|
||||
@keyframes floatGlow {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(20px); }
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes floatGlow {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(20px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-copy, .hero-panel { animation: none; }
|
||||
.hero::before { animation: none; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-copy,
|
||||
.hero-panel {
|
||||
animation: none;
|
||||
}
|
||||
.hero::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,27 +8,33 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
|
||||
@Component({
|
||||
selector: 'app-home-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent, AppCardComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterLink,
|
||||
TranslateModule,
|
||||
AppButtonComponent,
|
||||
AppCardComponent,
|
||||
],
|
||||
templateUrl: './home.component.html',
|
||||
styleUrls: ['./home.component.scss']
|
||||
styleUrls: ['./home.component.scss'],
|
||||
})
|
||||
export class HomeComponent {
|
||||
readonly shopGalleryImages = [
|
||||
{
|
||||
src: 'assets/images/home/supporto-bici.jpg',
|
||||
alt: 'HOME.SHOP_IMAGE_ALT_1'
|
||||
}
|
||||
alt: 'HOME.SHOP_IMAGE_ALT_1',
|
||||
},
|
||||
];
|
||||
|
||||
readonly founderImages = [
|
||||
{
|
||||
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',
|
||||
alt: 'HOME.FOUNDER_IMAGE_ALT_2'
|
||||
}
|
||||
alt: 'HOME.FOUNDER_IMAGE_ALT_2',
|
||||
},
|
||||
];
|
||||
|
||||
founderImageIndex = 0;
|
||||
|
||||
@@ -3,10 +3,12 @@ import { Routes } from '@angular/router';
|
||||
export const LEGAL_ROUTES: Routes = [
|
||||
{
|
||||
path: 'privacy',
|
||||
loadComponent: () => import('./privacy/privacy.component').then(m => m.PrivacyComponent)
|
||||
loadComponent: () =>
|
||||
import('./privacy/privacy.component').then((m) => m.PrivacyComponent),
|
||||
},
|
||||
{
|
||||
path: 'terms',
|
||||
loadComponent: () => import('./terms/terms.component').then(m => m.TermsComponent)
|
||||
}
|
||||
loadComponent: () =>
|
||||
import('./terms/terms.component').then((m) => m.TermsComponent),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,38 +1,39 @@
|
||||
<section class="legal-page">
|
||||
<div class="container narrow">
|
||||
<h1>{{ 'LEGAL.PRIVACY_TITLE' | translate }}</h1>
|
||||
<h1>{{ "LEGAL.PRIVACY_TITLE" | translate }}</h1>
|
||||
<div class="content">
|
||||
<p class="intro">
|
||||
{{ 'LEGAL.LAST_UPDATE' | translate }}: {{ 'LEGAL.PRIVACY_UPDATE_DATE' | translate }}
|
||||
{{ "LEGAL.LAST_UPDATE" | translate }}:
|
||||
{{ "LEGAL.PRIVACY_UPDATE_DATE" | translate }}
|
||||
</p>
|
||||
|
||||
<p>{{ 'LEGAL.PRIVACY.META.CONTROLLER' | translate }}</p>
|
||||
<p>{{ 'LEGAL.PRIVACY.META.CONTACT' | translate }}</p>
|
||||
<p>{{ "LEGAL.PRIVACY.META.CONTROLLER" | translate }}</p>
|
||||
<p>{{ "LEGAL.PRIVACY.META.CONTACT" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.PRIVACY.S1.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.PRIVACY.S1.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.PRIVACY.S1.P2' | translate }}</p>
|
||||
<h2>{{ "LEGAL.PRIVACY.S1.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.PRIVACY.S1.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.PRIVACY.S1.P2" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.PRIVACY.S2.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.PRIVACY.S2.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.PRIVACY.S2.P2' | translate }}</p>
|
||||
<h2>{{ "LEGAL.PRIVACY.S2.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.PRIVACY.S2.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.PRIVACY.S2.P2" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.PRIVACY.S3.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.PRIVACY.S3.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.PRIVACY.S3.P2' | translate }}</p>
|
||||
<p>{{ 'LEGAL.PRIVACY.S3.P3' | translate }}</p>
|
||||
<h2>{{ "LEGAL.PRIVACY.S3.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.PRIVACY.S3.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.PRIVACY.S3.P2" | translate }}</p>
|
||||
<p>{{ "LEGAL.PRIVACY.S3.P3" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.PRIVACY.S4.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.PRIVACY.S4.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.PRIVACY.S4.P2' | translate }}</p>
|
||||
<h2>{{ "LEGAL.PRIVACY.S4.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.PRIVACY.S4.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.PRIVACY.S4.P2" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.PRIVACY.S5.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.PRIVACY.S5.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.PRIVACY.S5.P2' | translate }}</p>
|
||||
<h2>{{ "LEGAL.PRIVACY.S5.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.PRIVACY.S5.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.PRIVACY.S5.P2" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.PRIVACY.S6.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.PRIVACY.S6.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.PRIVACY.S6.P2' | translate }}</p>
|
||||
<h2>{{ "LEGAL.PRIVACY.S6.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.PRIVACY.S6.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.PRIVACY.S6.P2" | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -6,6 +6,6 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
standalone: true,
|
||||
imports: [TranslateModule],
|
||||
templateUrl: './privacy.component.html',
|
||||
styleUrl: './privacy.component.scss'
|
||||
styleUrl: './privacy.component.scss',
|
||||
})
|
||||
export class PrivacyComponent {}
|
||||
|
||||
@@ -1,100 +1,101 @@
|
||||
<section class="legal-page">
|
||||
<div class="container narrow">
|
||||
<h1>{{ 'LEGAL.TERMS_TITLE' | translate }}</h1>
|
||||
<h1>{{ "LEGAL.TERMS_TITLE" | translate }}</h1>
|
||||
<div class="content">
|
||||
<p class="intro">
|
||||
{{ 'LEGAL.LAST_UPDATE' | translate }}: {{ 'LEGAL.TERMS_UPDATE_DATE' | translate }}
|
||||
{{ "LEGAL.LAST_UPDATE" | translate }}:
|
||||
{{ "LEGAL.TERMS_UPDATE_DATE" | translate }}
|
||||
</p>
|
||||
|
||||
<p>{{ 'LEGAL.TERMS.META.PROVIDER' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.META.VERSION' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.META.SCOPE' | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.META.PROVIDER" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.META.VERSION" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.META.SCOPE" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.TERMS.S1.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.TERMS.S1.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S1.P2' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S1.P3' | translate }}</p>
|
||||
<h2>{{ "LEGAL.TERMS.S1.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.TERMS.S1.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S1.P2" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S1.P3" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.TERMS.S2.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.TERMS.S2.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S2.P2' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S2.P3' | translate }}</p>
|
||||
<h2>{{ "LEGAL.TERMS.S2.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.TERMS.S2.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S2.P2" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S2.P3" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.TERMS.S3.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.TERMS.S3.P1' | translate }}</p>
|
||||
<h2>{{ "LEGAL.TERMS.S3.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.TERMS.S3.P1" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.TERMS.S4.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.TERMS.S4.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S4.P2' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S4.P3' | translate }}</p>
|
||||
<h2>{{ "LEGAL.TERMS.S4.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.TERMS.S4.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S4.P2" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S4.P3" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.TERMS.S5.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.TERMS.S5.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S5.P2' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S5.P3' | translate }}</p>
|
||||
<h2>{{ "LEGAL.TERMS.S5.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.TERMS.S5.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S5.P2" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S5.P3" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.TERMS.S6.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.TERMS.S6.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S6.P2' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S6.P3' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S6.P4' | translate }}</p>
|
||||
<h2>{{ "LEGAL.TERMS.S6.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.TERMS.S6.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S6.P2" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S6.P3" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S6.P4" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.TERMS.S7.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.TERMS.S7.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S7.P2' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S7.P3' | translate }}</p>
|
||||
<h2>{{ "LEGAL.TERMS.S7.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.TERMS.S7.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S7.P2" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S7.P3" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.TERMS.S8.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.TERMS.S8.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S8.P2' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S8.P3' | translate }}</p>
|
||||
<h2>{{ "LEGAL.TERMS.S8.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.TERMS.S8.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S8.P2" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S8.P3" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.TERMS.S9.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.TERMS.S9.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S9.P2' | translate }}</p>
|
||||
<h2>{{ "LEGAL.TERMS.S9.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.TERMS.S9.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S9.P2" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.TERMS.S10.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.TERMS.S10.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S10.P2' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S10.P3' | translate }}</p>
|
||||
<h2>{{ "LEGAL.TERMS.S10.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.TERMS.S10.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S10.P2" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S10.P3" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.TERMS.S11.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.TERMS.S11.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S11.P2' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S11.P3' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S11.P4' | translate }}</p>
|
||||
<h2>{{ "LEGAL.TERMS.S11.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.TERMS.S11.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S11.P2" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S11.P3" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S11.P4" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.TERMS.S12.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.TERMS.S12.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S12.P2' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S12.P3' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S12.P4' | translate }}</p>
|
||||
<h2>{{ "LEGAL.TERMS.S12.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.TERMS.S12.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S12.P2" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S12.P3" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S12.P4" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.TERMS.S13.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.TERMS.S13.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S13.P2' | translate }}</p>
|
||||
<h2>{{ "LEGAL.TERMS.S13.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.TERMS.S13.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S13.P2" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.TERMS.S14.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.TERMS.S14.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S14.P2' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S14.P3' | translate }}</p>
|
||||
<h2>{{ "LEGAL.TERMS.S14.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.TERMS.S14.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S14.P2" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S14.P3" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.TERMS.S15.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.TERMS.S15.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S15.P2' | translate }}</p>
|
||||
<h2>{{ "LEGAL.TERMS.S15.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.TERMS.S15.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S15.P2" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.TERMS.S16.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.TERMS.S16.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S16.P2' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S16.P3' | translate }}</p>
|
||||
<h2>{{ "LEGAL.TERMS.S16.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.TERMS.S16.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S16.P2" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S16.P3" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.TERMS.S17.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.TERMS.S17.P1' | translate }}</p>
|
||||
<h2>{{ "LEGAL.TERMS.S17.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.TERMS.S17.P1" | translate }}</p>
|
||||
|
||||
<h2>{{ 'LEGAL.TERMS.S18.TITLE' | translate }}</h2>
|
||||
<p>{{ 'LEGAL.TERMS.S18.P1' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S18.P2' | translate }}</p>
|
||||
<p>{{ 'LEGAL.TERMS.S18.P3' | translate }}</p>
|
||||
<h2>{{ "LEGAL.TERMS.S18.TITLE" | translate }}</h2>
|
||||
<p>{{ "LEGAL.TERMS.S18.P1" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S18.P2" | translate }}</p>
|
||||
<p>{{ "LEGAL.TERMS.S18.P3" | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -6,6 +6,6 @@ import { TranslateModule } from '@ngx-translate/core';
|
||||
standalone: true,
|
||||
imports: [TranslateModule],
|
||||
templateUrl: './terms.component.html',
|
||||
styleUrl: './terms.component.scss'
|
||||
styleUrl: './terms.component.scss',
|
||||
})
|
||||
export class TermsComponent {}
|
||||
|
||||
@@ -1,47 +1,71 @@
|
||||
<div class="container hero">
|
||||
<h1>
|
||||
{{ 'TRACKING.TITLE' | translate }}
|
||||
{{ "TRACKING.TITLE" | translate }}
|
||||
<ng-container *ngIf="order()">
|
||||
<br/><span class="order-id-title">#{{ getDisplayOrderNumber(order()) }}</span>
|
||||
<br /><span class="order-id-title"
|
||||
>#{{ getDisplayOrderNumber(order()) }}</span
|
||||
>
|
||||
</ng-container>
|
||||
</h1>
|
||||
<p class="subtitle">{{ 'TRACKING.SUBTITLE' | translate }}</p>
|
||||
<p class="subtitle">{{ "TRACKING.SUBTITLE" | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<ng-container *ngIf="order() as o">
|
||||
<div class="status-timeline mb-6">
|
||||
<div class="timeline-step"
|
||||
[class.active]="o.status === 'PENDING_PAYMENT' && o.paymentStatus !== 'REPORTED'"
|
||||
[class.completed]="o.paymentStatus === 'REPORTED' || o.status !== 'PENDING_PAYMENT'">
|
||||
<div
|
||||
class="timeline-step"
|
||||
[class.active]="
|
||||
o.status === 'PENDING_PAYMENT' && o.paymentStatus !== 'REPORTED'
|
||||
"
|
||||
[class.completed]="
|
||||
o.paymentStatus === 'REPORTED' || o.status !== 'PENDING_PAYMENT'
|
||||
"
|
||||
>
|
||||
<div class="circle">1</div>
|
||||
<div class="label">{{ 'TRACKING.STEP_PENDING' | translate }}</div>
|
||||
<div class="label">{{ "TRACKING.STEP_PENDING" | translate }}</div>
|
||||
</div>
|
||||
<div class="timeline-step"
|
||||
[class.active]="o.paymentStatus === 'REPORTED' && o.status === 'PENDING_PAYMENT'"
|
||||
[class.completed]="o.status === 'PAID' || o.status === 'IN_PRODUCTION' || o.status === 'SHIPPED' || o.status === 'COMPLETED'">
|
||||
<div
|
||||
class="timeline-step"
|
||||
[class.active]="
|
||||
o.paymentStatus === 'REPORTED' && o.status === 'PENDING_PAYMENT'
|
||||
"
|
||||
[class.completed]="
|
||||
o.status === 'PAID' ||
|
||||
o.status === 'IN_PRODUCTION' ||
|
||||
o.status === 'SHIPPED' ||
|
||||
o.status === 'COMPLETED'
|
||||
"
|
||||
>
|
||||
<div class="circle">2</div>
|
||||
<div class="label">{{ 'TRACKING.STEP_REPORTED' | translate }}</div>
|
||||
<div class="label">{{ "TRACKING.STEP_REPORTED" | translate }}</div>
|
||||
</div>
|
||||
<div class="timeline-step"
|
||||
<div
|
||||
class="timeline-step"
|
||||
[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="label">{{ 'TRACKING.STEP_PRODUCTION' | translate }}</div>
|
||||
<div class="label">{{ "TRACKING.STEP_PRODUCTION" | translate }}</div>
|
||||
</div>
|
||||
<div class="timeline-step"
|
||||
<div
|
||||
class="timeline-step"
|
||||
[class.active]="o.status === 'SHIPPED'"
|
||||
[class.completed]="o.status === 'COMPLETED'">
|
||||
[class.completed]="o.status === 'COMPLETED'"
|
||||
>
|
||||
<div class="circle">4</div>
|
||||
<div class="label">{{ 'TRACKING.STEP_SHIPPED' | translate }}</div>
|
||||
<div class="label">{{ "TRACKING.STEP_SHIPPED" | translate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="o.status === 'PENDING_PAYMENT'">
|
||||
<app-card class="mb-6 status-reported-card" *ngIf="o.paymentStatus === 'REPORTED'">
|
||||
<app-card
|
||||
class="mb-6 status-reported-card"
|
||||
*ngIf="o.paymentStatus === 'REPORTED'"
|
||||
>
|
||||
<div class="status-content text-center">
|
||||
<h3>{{ 'PAYMENT.STATUS_REPORTED_TITLE' | translate }}</h3>
|
||||
<p>{{ 'PAYMENT.STATUS_REPORTED_DESC' | translate }}</p>
|
||||
<h3>{{ "PAYMENT.STATUS_REPORTED_TITLE" | translate }}</h3>
|
||||
<p>{{ "PAYMENT.STATUS_REPORTED_DESC" | translate }}</p>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
@@ -49,23 +73,38 @@
|
||||
<div class="payment-main">
|
||||
<app-card class="mb-6">
|
||||
<div class="card-header-simple">
|
||||
<h3>{{ 'PAYMENT.METHOD' | translate }}</h3>
|
||||
<h3>{{ "PAYMENT.METHOD" | translate }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="payment-selection">
|
||||
<div class="methods-grid">
|
||||
<div class="type-option" [class.selected]="selectedPaymentMethod === 'twint'" (click)="selectPayment('twint')">
|
||||
<span class="method-name">{{ 'PAYMENT.METHOD_TWINT' | translate }}</span>
|
||||
<div
|
||||
class="type-option"
|
||||
[class.selected]="selectedPaymentMethod === 'twint'"
|
||||
(click)="selectPayment('twint')"
|
||||
>
|
||||
<span class="method-name">{{
|
||||
"PAYMENT.METHOD_TWINT" | translate
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="type-option" [class.selected]="selectedPaymentMethod === 'bill'" (click)="selectPayment('bill')">
|
||||
<span class="method-name">{{ 'PAYMENT.METHOD_BANK' | translate }}</span>
|
||||
<div
|
||||
class="type-option"
|
||||
[class.selected]="selectedPaymentMethod === 'bill'"
|
||||
(click)="selectPayment('bill')"
|
||||
>
|
||||
<span class="method-name">{{
|
||||
"PAYMENT.METHOD_BANK" | translate
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="payment-details fade-in text-center" *ngIf="selectedPaymentMethod === 'twint'">
|
||||
<div
|
||||
class="payment-details fade-in text-center"
|
||||
*ngIf="selectedPaymentMethod === 'twint'"
|
||||
>
|
||||
<div class="details-header">
|
||||
<h4>{{ 'PAYMENT.TWINT_TITLE' | translate }}</h4>
|
||||
<h4>{{ "PAYMENT.TWINT_TITLE" | translate }}</h4>
|
||||
</div>
|
||||
<div class="qr-placeholder">
|
||||
<img
|
||||
@@ -73,46 +112,75 @@
|
||||
class="twint-qr"
|
||||
[src]="getTwintQrUrl()"
|
||||
(error)="onTwintQrError()"
|
||||
[attr.alt]="'PAYMENT.TWINT_QR_ALT' | translate" />
|
||||
<p>{{ 'PAYMENT.TWINT_DESC' | translate }}</p>
|
||||
<p class="billing-hint">{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}</p>
|
||||
[attr.alt]="'PAYMENT.TWINT_QR_ALT' | translate"
|
||||
/>
|
||||
<p>{{ "PAYMENT.TWINT_DESC" | translate }}</p>
|
||||
<p class="billing-hint">
|
||||
{{ "PAYMENT.BILLING_INFO_HINT" | translate }}
|
||||
</p>
|
||||
<div class="twint-mobile-action twint-button-container">
|
||||
<button style="width: auto; height: 58px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
align-items: center;" (click)="openTwintPayment()">
|
||||
<button
|
||||
style="
|
||||
width: auto;
|
||||
height: 58px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
align-items: center;
|
||||
"
|
||||
(click)="openTwintPayment()"
|
||||
>
|
||||
<img
|
||||
style="width: auto; height: 58px"
|
||||
[attr.alt]="'PAYMENT.TWINT_BUTTON_ALT' | translate"
|
||||
[src]="getTwintButtonImageUrl()"/>
|
||||
[src]="getTwintButtonImageUrl()"
|
||||
/>
|
||||
</button>
|
||||
</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 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">
|
||||
<h4>{{ 'PAYMENT.BANK_TITLE' | translate }}</h4>
|
||||
<h4>{{ "PAYMENT.BANK_TITLE" | translate }}</h4>
|
||||
</div>
|
||||
<div class="bank-details">
|
||||
<p class="billing-hint">{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}</p>
|
||||
<br>
|
||||
<p class="billing-hint">
|
||||
{{ "PAYMENT.BILLING_INFO_HINT" | translate }}
|
||||
</p>
|
||||
<br />
|
||||
<div class="qr-bill-actions">
|
||||
<app-button (click)="downloadQrInvoice()">
|
||||
{{ 'PAYMENT.DOWNLOAD_QR' | translate }}
|
||||
{{ "PAYMENT.DOWNLOAD_QR" | translate }}
|
||||
</app-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<app-button variant="outline" (click)="completeOrder()" [disabled]="!selectedPaymentMethod || o.paymentStatus === 'REPORTED'" [fullWidth]="true">
|
||||
{{ o.paymentStatus === 'REPORTED' ? ('PAYMENT.IN_VERIFICATION' | translate) : ('PAYMENT.CONFIRM' | translate) }}
|
||||
<app-button
|
||||
variant="outline"
|
||||
(click)="completeOrder()"
|
||||
[disabled]="
|
||||
!selectedPaymentMethod || o.paymentStatus === 'REPORTED'
|
||||
"
|
||||
[fullWidth]="true"
|
||||
>
|
||||
{{
|
||||
o.paymentStatus === "REPORTED"
|
||||
? ("PAYMENT.IN_VERIFICATION" | translate)
|
||||
: ("PAYMENT.CONFIRM" | translate)
|
||||
}}
|
||||
</app-button>
|
||||
</div>
|
||||
</app-card>
|
||||
@@ -121,29 +189,28 @@ align-items: center;" (click)="openTwintPayment()">
|
||||
<div class="payment-summary">
|
||||
<app-card class="sticky-card">
|
||||
<div class="card-header-simple">
|
||||
<h3>{{ 'PAYMENT.SUMMARY_TITLE' | translate }}</h3>
|
||||
<h3>{{ "PAYMENT.SUMMARY_TITLE" | translate }}</h3>
|
||||
<p class="order-id">#{{ getDisplayOrderNumber(o) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="summary-totals">
|
||||
<div class="total-row">
|
||||
<span>{{ 'PAYMENT.SUBTOTAL' | translate }}</span>
|
||||
<span>{{ o.subtotalChf | currency:'CHF' }}</span>
|
||||
<span>{{ "PAYMENT.SUBTOTAL" | translate }}</span>
|
||||
<span>{{ o.subtotalChf | currency: "CHF" }}</span>
|
||||
</div>
|
||||
<div class="total-row">
|
||||
<span>{{ 'PAYMENT.SHIPPING' | translate }}</span>
|
||||
<span>{{ o.shippingCostChf | currency:'CHF' }}</span>
|
||||
<span>{{ "PAYMENT.SHIPPING" | translate }}</span>
|
||||
<span>{{ o.shippingCostChf | currency: "CHF" }}</span>
|
||||
</div>
|
||||
<div class="total-row">
|
||||
<span>{{ 'PAYMENT.SETUP_FEE' | translate }}</span>
|
||||
<span>{{ o.setupCostChf | currency:'CHF' }}</span>
|
||||
<span>{{ "PAYMENT.SETUP_FEE" | translate }}</span>
|
||||
<span>{{ o.setupCostChf | currency: "CHF" }}</span>
|
||||
</div>
|
||||
<div class="grand-total-row">
|
||||
<span>{{ 'PAYMENT.TOTAL' | translate }}</span>
|
||||
<span>{{ o.totalChf | currency:'CHF' }}</span>
|
||||
<span>{{ "PAYMENT.TOTAL" | translate }}</span>
|
||||
<span>{{ o.totalChf | currency: "CHF" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</app-card>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,7 +219,7 @@ align-items: center;" (click)="openTwintPayment()">
|
||||
|
||||
<div *ngIf="loading()" class="loading-state">
|
||||
<app-card>
|
||||
<p>{{ 'PAYMENT.LOADING' | translate }}</p>
|
||||
<p>{{ "PAYMENT.LOADING" | translate }}</p>
|
||||
</app-card>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -127,7 +127,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.qr-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -229,7 +228,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
.mb-6 { margin-bottom: var(--space-6); }
|
||||
.mb-6 {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.error-message,
|
||||
.loading-state {
|
||||
@@ -245,7 +246,7 @@
|
||||
/* padding: var(--space-6); */ /* Removed if it was here to match non-card layout */
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: 12.5%;
|
||||
|
||||
@@ -10,9 +10,14 @@ import { environment } from '../../../environments/environment';
|
||||
@Component({
|
||||
selector: 'app-order',
|
||||
standalone: true,
|
||||
imports: [CommonModule, AppButtonComponent, AppCardComponent, TranslateModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
AppButtonComponent,
|
||||
AppCardComponent,
|
||||
TranslateModule,
|
||||
],
|
||||
templateUrl: './order.component.html',
|
||||
styleUrl: './order.component.scss'
|
||||
styleUrl: './order.component.scss',
|
||||
})
|
||||
export class OrderComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
@@ -50,7 +55,7 @@ export class OrderComponent implements OnInit {
|
||||
console.error('Failed to load order', err);
|
||||
this.error.set('ORDER.ERR_LOAD_ORDER');
|
||||
this.loading.set(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,7 +77,7 @@ export class OrderComponent implements OnInit {
|
||||
a.click();
|
||||
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;
|
||||
this.quoteService.getTwintPayment(this.orderId).subscribe({
|
||||
next: (res) => {
|
||||
const qrPath = typeof res.qrImageUrl === 'string' ? `${res.qrImageUrl}?size=360` : null;
|
||||
const qrDataUri = typeof res.qrImageDataUri === 'string' ? res.qrImageDataUri : null;
|
||||
const qrPath =
|
||||
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.twintQrUrl.set(qrDataUri ?? this.resolveApiUrl(qrPath));
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load TWINT payment details', err);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,10 +116,10 @@ export class OrderComponent implements OnInit {
|
||||
if (lang === 'de') {
|
||||
return 'https://go.twint.ch/static/img/button_dark_de.svg';
|
||||
}
|
||||
if (lang === 'it'){
|
||||
if (lang === 'it') {
|
||||
return 'https://go.twint.ch/static/img/button_dark_it.svg';
|
||||
}
|
||||
if (lang === 'fr'){
|
||||
if (lang === 'fr') {
|
||||
return 'https://go.twint.ch/static/img/button_dark_fr.svg';
|
||||
}
|
||||
// Default to EN for everything else (it, fr, en) as instructed or if not DE
|
||||
@@ -136,7 +145,9 @@ export class OrderComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.quoteService.reportPayment(this.orderId, this.selectedPaymentMethod).subscribe({
|
||||
this.quoteService
|
||||
.reportPayment(this.orderId, this.selectedPaymentMethod)
|
||||
.subscribe({
|
||||
next: (order) => {
|
||||
this.order.set(order);
|
||||
// The UI will re-render and show the 'REPORTED' state.
|
||||
@@ -146,7 +157,7 @@ export class OrderComponent implements OnInit {
|
||||
error: (err) => {
|
||||
console.error('Failed to report payment', err);
|
||||
this.error.set('ORDER.ERR_REPORT_PAYMENT');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
<div class="content">
|
||||
<span class="category">{{ product().category | translate }}</span>
|
||||
<h3 class="name">
|
||||
<a [routerLink]="['/shop', product().id]">{{ product().name | translate }}</a>
|
||||
<a [routerLink]="['/shop', product().id]">{{
|
||||
product().name | translate
|
||||
}}</a>
|
||||
</h3>
|
||||
<div class="footer">
|
||||
<span class="price">{{ product().price | currency:'EUR' }}</span>
|
||||
<a [routerLink]="['/shop', product().id]" class="view-btn">{{ 'SHOP.DETAILS' | translate }}</a>
|
||||
<span class="price">{{ product().price | currency: "EUR" }}</span>
|
||||
<a [routerLink]="['/shop', product().id]" class="view-btn">{{
|
||||
"SHOP.DETAILS" | translate
|
||||
}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,15 +4,45 @@
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.2s;
|
||||
&:hover { box-shadow: var(--shadow-md); }
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
}
|
||||
.image-placeholder {
|
||||
height: 200px;
|
||||
background-color: var(--color-neutral-200);
|
||||
}
|
||||
.content { padding: var(--space-4); }
|
||||
.category { font-size: 0.75rem; 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; }
|
||||
.content {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.category {
|
||||
font-size: 0.75rem;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Product } from '../../services/shop.service';
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, TranslateModule],
|
||||
templateUrl: './product-card.component.html',
|
||||
styleUrl: './product-card.component.scss'
|
||||
styleUrl: './product-card.component.scss',
|
||||
})
|
||||
export class ProductCardComponent {
|
||||
product = input.required<Product>();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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) {
|
||||
<div class="detail-grid">
|
||||
@@ -8,18 +8,18 @@
|
||||
<div class="info">
|
||||
<span class="category">{{ p.category | translate }}</span>
|
||||
<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>
|
||||
|
||||
<div class="actions">
|
||||
<app-button variant="primary" (click)="addToCart()">
|
||||
{{ 'SHOP.ADD_CART' | translate }}
|
||||
{{ "SHOP.ADD_CART" | translate }}
|
||||
</app-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<p>{{ 'SHOP.NOT_FOUND' | translate }}</p>
|
||||
<p>{{ "SHOP.NOT_FOUND" | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
.wrapper { padding-top: var(--space-8); }
|
||||
.back-link { display: inline-block; margin-bottom: var(--space-6); color: var(--color-text-muted); }
|
||||
.wrapper {
|
||||
padding-top: var(--space-8);
|
||||
}
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: var(--space-6);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
gap: var(--space-8);
|
||||
@media(min-width: 768px) {
|
||||
@media (min-width: 768px) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +21,20 @@
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.category { color: var(--color-brand); 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); }
|
||||
.category {
|
||||
color: var(--color-brand);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent],
|
||||
templateUrl: './product-detail.component.html',
|
||||
styleUrl: './product-detail.component.scss'
|
||||
styleUrl: './product-detail.component.scss',
|
||||
})
|
||||
export class ProductDetailComponent {
|
||||
// Input binding from router
|
||||
@@ -20,13 +20,15 @@ export class ProductDetailComponent {
|
||||
|
||||
constructor(
|
||||
private shopService: ShopService,
|
||||
private translate: TranslateService
|
||||
private translate: TranslateService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
const productId = this.id();
|
||||
if (productId) {
|
||||
this.shopService.getProductById(productId).subscribe(p => this.product.set(p));
|
||||
this.shopService
|
||||
.getProductById(productId)
|
||||
.subscribe((p) => this.product.set(p));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface Product {
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ShopService {
|
||||
// Dati statici per ora
|
||||
@@ -19,23 +19,23 @@ export class ShopService {
|
||||
id: '1',
|
||||
name: 'SHOP.PRODUCTS.P1.NAME',
|
||||
description: 'SHOP.PRODUCTS.P1.DESC',
|
||||
price: 24.90,
|
||||
category: 'SHOP.CATEGORIES.FILAMENTS'
|
||||
price: 24.9,
|
||||
category: 'SHOP.CATEGORIES.FILAMENTS',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'SHOP.PRODUCTS.P2.NAME',
|
||||
description: 'SHOP.PRODUCTS.P2.DESC',
|
||||
price: 29.90,
|
||||
category: 'SHOP.CATEGORIES.FILAMENTS'
|
||||
price: 29.9,
|
||||
category: 'SHOP.CATEGORIES.FILAMENTS',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'SHOP.PRODUCTS.P3.NAME',
|
||||
description: 'SHOP.PRODUCTS.P3.DESC',
|
||||
price: 15.00,
|
||||
category: 'SHOP.CATEGORIES.ACCESSORIES'
|
||||
}
|
||||
price: 15.0,
|
||||
category: 'SHOP.CATEGORIES.ACCESSORIES',
|
||||
},
|
||||
];
|
||||
|
||||
getProducts(): Observable<Product[]> {
|
||||
@@ -43,6 +43,6 @@ export class ShopService {
|
||||
}
|
||||
|
||||
getProductById(id: string): Observable<Product | undefined> {
|
||||
return of(this.staticProducts.find(p => p.id === id));
|
||||
return of(this.staticProducts.find((p) => p.id === id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<section class="wip-section">
|
||||
<div class="container">
|
||||
<div class="wip-card">
|
||||
<p class="wip-eyebrow">{{ 'SHOP.WIP_EYEBROW' | translate }}</p>
|
||||
<h1>{{ 'SHOP.WIP_TITLE' | translate }}</h1>
|
||||
<p class="wip-subtitle">{{ 'SHOP.WIP_SUBTITLE' | translate }}</p>
|
||||
<p class="wip-eyebrow">{{ "SHOP.WIP_EYEBROW" | translate }}</p>
|
||||
<h1>{{ "SHOP.WIP_TITLE" | translate }}</h1>
|
||||
<p class="wip-subtitle">{{ "SHOP.WIP_SUBTITLE" | translate }}</p>
|
||||
|
||||
<div class="wip-actions">
|
||||
<app-button variant="primary" routerLink="/calculator/basic">
|
||||
{{ 'SHOP.WIP_CTA_CALC' | translate }}
|
||||
{{ "SHOP.WIP_CTA_CALC" | translate }}
|
||||
</app-button>
|
||||
</div>
|
||||
|
||||
<p class="wip-return-later">{{ 'SHOP.WIP_RETURN_LATER' | translate }}</p>
|
||||
<p class="wip-note">{{ 'SHOP.WIP_NOTE' | translate }}</p>
|
||||
<p class="wip-return-later">{{ "SHOP.WIP_RETURN_LATER" | translate }}</p>
|
||||
<p class="wip-note">{{ "SHOP.WIP_NOTE" | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -9,6 +9,6 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent],
|
||||
templateUrl: './shop-page.component.html',
|
||||
styleUrl: './shop-page.component.scss'
|
||||
styleUrl: './shop-page.component.scss',
|
||||
})
|
||||
export class ShopPageComponent {}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user