diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/deploy.yaml similarity index 73% rename from .gitea/workflows/cicd.yaml rename to .gitea/workflows/deploy.yaml index 8975867..b96224a 100644 --- a/.gitea/workflows/cicd.yaml +++ b/.gitea/workflows/deploy.yaml @@ -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,10 +182,9 @@ 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 - + printf 'REGISTRY_URL="%s"\nREPO_OWNER="%s"\nTAG="%s"\n' \ "${{ secrets.REGISTRY_URL }}" "$DEPLOY_OWNER" "$DEPLOY_TAG" >> /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 }}" diff --git a/.gitea/workflows/pr-checks.yaml b/.gitea/workflows/pr-checks.yaml new file mode 100644 index 0000000..f964f6b --- /dev/null +++ b/.gitea/workflows/pr-checks.yaml @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile index fc44418..2239f80 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java index b3918c5..b407bcb 100644 --- a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java +++ b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java @@ -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,10 +41,18 @@ 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(); private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$"); @@ -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 createCustomQuoteRequest( - @Valid @RequestPart("request") com.printcalculator.dto.QuoteRequestDto requestDto, + @Valid @RequestPart("request") QuoteRequestDto requestDto, @RequestPart(value = "files", required = false) List 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 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; + } } diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java index b8d3c46..0fa09b3 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java @@ -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 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"); + } } diff --git a/backend/src/main/java/com/printcalculator/security/AdminLoginThrottleService.java b/backend/src/main/java/com/printcalculator/security/AdminLoginThrottleService.java index 1b95e81..d89bc4f 100644 --- a/backend/src/main/java/com/printcalculator/security/AdminLoginThrottleService.java +++ b/backend/src/main/java/com/printcalculator/security/AdminLoginThrottleService.java @@ -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 attemptsByClient = new ConcurrentHashMap<>(); private final boolean trustProxyHeaders; diff --git a/backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java b/backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java index 7db4aa8..38e1e25 100644 --- a/backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java +++ b/backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java @@ -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; + } } diff --git a/backend/src/main/java/com/printcalculator/service/SlicerService.java b/backend/src/main/java/com/printcalculator/service/SlicerService.java index f9ef570..e489db7 100644 --- a/backend/src/main/java/com/printcalculator/service/SlicerService.java +++ b/backend/src/main/java/com/printcalculator/service/SlicerService.java @@ -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 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 command = new ArrayList<>(); - command.add(slicerPath); + // Build process arguments explicitly to avoid shell interpretation and command injection. + ProcessBuilder pb = new ProcessBuilder(); + List 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()); @@ -157,17 +183,17 @@ public class SlicerService { } public Optional inspectModelDimensions(File inputModel) { - Path tempDir = null; + Path tempDir = null; try { tempDir = Files.createTempDirectory("slicer_info_"); Path infoLogPath = tempDir.resolve("orcaslicer-info.log"); + String inputModelPath = requireSafeArgument(inputModel.getAbsolutePath(), "input model path"); - List command = new ArrayList<>(); - command.add(slicerPath); - command.add("--info"); - command.add(inputModel.getAbsolutePath()); - - ProcessBuilder pb = new ProcessBuilder(command); + ProcessBuilder pb = new ProcessBuilder(); + List 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 resolveSlicerInputPaths(File inputModel, String inputModelPath, Path tempDir) + throws IOException, InterruptedException { + if (!inputModel.getName().toLowerCase().endsWith(".3mf")) { + return List.of(inputModelPath); + } + + List convertedStlPaths = convert3mfToStlInputPaths(inputModel, tempDir); + logger.info("Converted 3MF to " + convertedStlPaths.size() + " STL file(s) for slicing."); + return convertedStlPaths; + } + + private List 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 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 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 modelCache, + String modelPath, + String objectId, + Transform transform, + BufferedWriter writer, + long[] triangleCount, + Set 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 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 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 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 findChildrenByLocalName(Element parent, String localName) { + List 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 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; + } } diff --git a/backend/src/main/java/com/printcalculator/service/TwintPaymentService.java b/backend/src/main/java/com/printcalculator/service/TwintPaymentService.java index 539d339..6ed915b 100644 --- a/backend/src/main/java/com/printcalculator/service/TwintPaymentService.java +++ b/backend/src/main/java/com/printcalculator/service/TwintPaymentService.java @@ -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) { diff --git a/backend/src/main/resources/application-local.properties b/backend/src/main/resources/application-local.properties index 84e4ff5..04cf953 100644 --- a/backend/src/main/resources/application-local.properties +++ b/backend/src/main/resources/application-local.properties @@ -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 diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index a24a15c..37dab8d 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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 diff --git a/backend/src/main/resources/templates/email/contact-request-admin.html b/backend/src/main/resources/templates/email/contact-request-admin.html new file mode 100644 index 0000000..4341c34 --- /dev/null +++ b/backend/src/main/resources/templates/email/contact-request-admin.html @@ -0,0 +1,121 @@ + + + + + Nuova richiesta di contatto + + + +
+

Nuova richiesta di contatto

+

E' stata ricevuta una nuova richiesta dal form contatti/su misura.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID richiesta00000000-0000-0000-0000-000000000000
Data2026-03-03T10:00:00Z
Tipo richiestaPRINT_SERVICE
Tipo clientePRIVATE
NomeMario Rossi
Azienda3D Fab SA
ContattoMario Rossi
Emailcliente@example.com
Telefono+41 00 000 00 00
MessaggioTesto richiesta cliente...
Allegati0
+ + +
+ + diff --git a/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java b/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java index e5cd834..62511f7 100644 --- a/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java +++ b/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java @@ -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 { diff --git a/docker-compose.yml b/docker-compose.yml index 83bc72e..c144f47 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/frontend/angular.json b/frontend/angular.json index bc7951c..6107515 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -107,6 +107,7 @@ "test": { "builder": "@angular-devkit/build-angular:karma", "options": { + "karmaConfig": "karma.conf.js", "polyfills": [ "zone.js", "zone.js/testing" diff --git a/frontend/karma.conf.js b/frontend/karma.conf.js new file mode 100644 index 0000000..9c1b695 --- /dev/null +++ b/frontend/karma.conf.js @@ -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, + }); +}; diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 90c6b64..0680b43 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 966ecc2..cb93b2a 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -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 {} diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index e0733e9..ae0b8c6 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -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, + }, + }), + ), + ], }; diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index d79b55f..20652f6 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -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: '', + }, ]; diff --git a/frontend/src/app/core/constants/colors.const.ts b/frontend/src/app/core/constants/colors.const.ts index e4f6105..9a744c4 100644 --- a/frontend/src/app/core/constants/colors.const.ts +++ b/frontend/src/app/core/constants/colors.const.ts @@ -17,26 +17,31 @@ 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); - if (found) return found.hex; - } - return '#facf0a'; // Default Brand Color if not found + for (const cat of PRODUCT_COLORS) { + const found = cat.colors.find((c) => c.value === value); + if (found) return found.hex; + } + return '#facf0a'; // Default Brand Color if not found } diff --git a/frontend/src/app/core/interceptors/admin-auth.interceptor.ts b/frontend/src/app/core/interceptors/admin-auth.interceptor.ts index 156f6b3..67ba0c1 100644 --- a/frontend/src/app/core/interceptors/admin-auth.interceptor.ts +++ b/frontend/src/app/core/interceptors/admin-auth.interceptor.ts @@ -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); - }) + }), ); }; diff --git a/frontend/src/app/core/layout/footer.component.html b/frontend/src/app/core/layout/footer.component.html index d828dcd..4092c69 100644 --- a/frontend/src/app/core/layout/footer.component.html +++ b/frontend/src/app/core/layout/footer.component.html @@ -1,21 +1,21 @@ - diff --git a/frontend/src/app/core/layout/footer.component.scss b/frontend/src/app/core/layout/footer.component.scss index 65cc6eb..3e02f3f 100644 --- a/frontend/src/app/core/layout/footer.component.scss +++ b/frontend/src/app/core/layout/footer.component.scss @@ -1,59 +1,75 @@ - @use '../../../styles/patterns'; +@use "../../../styles/patterns"; - .footer { - background: var(--color-neutral-900); - color: var(--color-neutral-50); - padding: var(--space-8) 0 var(--space-4); - font-size: 0.9rem; - position: relative; - margin-top: auto; /* Push to bottom if content is short */ - // Cross Hatch Pattern - &::before { - content: ''; - position: absolute; - inset: 0; - @include patterns.pattern-cross-hatch(var(--color-neutral-50), 20px, 1px); - opacity: 0.05; - pointer-events: none; - } - } - .footer-inner { - display: flex; - justify-content: space-between; - align-items: center; - gap: var(--space-6); - } +.footer { + background: var(--color-neutral-900); + color: var(--color-neutral-50); + padding: var(--space-8) 0 var(--space-4); + font-size: 0.9rem; + position: relative; + margin-top: auto; /* Push to bottom if content is short */ + // Cross Hatch Pattern + &::before { + content: ""; + position: absolute; + inset: 0; + @include patterns.pattern-cross-hatch(var(--color-neutral-50), 20px, 1px); + opacity: 0.05; + pointer-events: none; + } +} +.footer-inner { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-6); +} - @media (max-width: 768px) { - .footer-inner { - flex-direction: column; - text-align: center; - gap: var(--space-8); - } - - .links { - flex-direction: column; - gap: var(--space-3); - } - } +@media (max-width: 768px) { + .footer-inner { + flex-direction: column; + text-align: center; + gap: var(--space-8); + } - .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 { - 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; } - } - } + .links { + flex-direction: column; + gap: var(--space-3); + } +} - .social { display: flex; gap: var(--space-3); } - .social-icon { - width: 24px; height: 24px; - background-color: var(--color-neutral-800); - border-radius: 50%; +.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 { + 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; } + } +} + +.social { + display: flex; + gap: var(--space-3); +} +.social-icon { + width: 24px; + height: 24px; + background-color: var(--color-neutral-800); + border-radius: 50%; +} diff --git a/frontend/src/app/core/layout/footer.component.ts b/frontend/src/app/core/layout/footer.component.ts index 5f2fe89..cbd9b69 100644 --- a/frontend/src/app/core/layout/footer.component.ts +++ b/frontend/src/app/core/layout/footer.component.ts @@ -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 {} diff --git a/frontend/src/app/core/layout/layout.component.ts b/frontend/src/app/core/layout/layout.component.ts index 7e90e14..873f795 100644 --- a/frontend/src/app/core/layout/layout.component.ts +++ b/frontend/src/app/core/layout/layout.component.ts @@ -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 {} diff --git a/frontend/src/app/core/layout/navbar.component.html b/frontend/src/app/core/layout/navbar.component.html index a53b967..201febd 100644 --- a/frontend/src/app/core/layout/navbar.component.html +++ b/frontend/src/app/core/layout/navbar.component.html @@ -1,35 +1,74 @@ - diff --git a/frontend/src/app/core/layout/navbar.component.scss b/frontend/src/app/core/layout/navbar.component.scss index 6b0da84..f46ba24 100644 --- a/frontend/src/app/core/layout/navbar.component.scss +++ b/frontend/src/app/core/layout/navbar.component.scss @@ -1,155 +1,175 @@ - .navbar { - height: 64px; - border-bottom: 1px solid var(--color-border); - background-color: var(--color-bg-card); - position: sticky; - top: 0; - z-index: 100; +.navbar { + height: 64px; + border-bottom: 1px solid var(--color-border); + background-color: var(--color-bg-card); + position: sticky; + top: 0; + z-index: 100; + display: flex; + align-items: center; +} +.navbar-inner { + display: flex; + align-items: center; + justify-content: space-between; +} +.brand { + font-size: 1.25rem; + font-weight: 700; + color: var(--color-text); + text-decoration: none; +} +.highlight { + color: var(--color-brand); +} + +.nav-links { + display: flex; + gap: var(--space-6); + + a { + color: var(--color-text-muted); + font-weight: 500; + text-decoration: none; + transition: color 0.2s; + + &:hover, + &.active { + color: var(--color-brand); + } + } +} + +.actions { + display: flex; + align-items: center; + gap: var(--space-4); +} + +.lang-switch { + background-color: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: 2px 22px 2px 8px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-muted); + appearance: none; + background-image: + linear-gradient(45deg, transparent 50%, currentColor 50%), + linear-gradient(135deg, currentColor 50%, transparent 50%); + background-position: + calc(100% - 10px) calc(50% - 2px), + calc(100% - 5px) calc(50% - 2px); + background-size: + 5px 5px, + 5px 5px; + background-repeat: no-repeat; + + &:hover { + color: var(--color-text); + border-color: var(--color-text); + } + &:focus-visible { + outline: 2px solid var(--color-brand); + outline-offset: 1px; + } +} + +.icon-placeholder { + width: 32px; + height: 32px; + border-radius: 50%; + background-color: var(--color-neutral-100); + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-muted); +} + +/* Mobile Toggle */ +.mobile-toggle { + display: none; + flex-direction: column; + justify-content: space-between; + width: 24px; + height: 18px; + cursor: pointer; + z-index: 101; + + span { + display: block; + height: 2px; + width: 100%; + background-color: var(--color-text); + border-radius: 2px; + transition: all 0.3s ease; + } + + &.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); + } + } +} + +/* Responsive Design */ +@media (max-width: 768px) { + .mobile-toggle { + display: flex; + order: 2; /* Place after actions */ + margin-left: var(--space-4); + } + + .actions { + order: 1; /* Place before toggle */ + margin-left: auto; /* Push to right */ + } + + .nav-links { + position: absolute; + top: 64px; + left: 0; + right: 0; + background-color: var(--color-bg-card); + flex-direction: column; + padding: var(--space-4); + border-bottom: 1px solid var(--color-border); + gap: var(--space-4); + display: none; + z-index: 1000; + + &.open { display: flex; - align-items: center; + animation: slideDown 0.3s ease forwards; } - .navbar-inner { - display: flex; - align-items: center; - justify-content: space-between; - } - .brand { - font-size: 1.25rem; - font-weight: 700; - color: var(--color-text); - text-decoration: none; - } - .highlight { color: var(--color-brand); } - - .nav-links { - display: flex; - gap: var(--space-6); - - a { - color: var(--color-text-muted); - font-weight: 500; - text-decoration: none; - transition: color 0.2s; - - &:hover, &.active { - color: var(--color-brand); - } + + a { + font-size: 1.1rem; + padding: var(--space-2) 0; + border-bottom: 1px solid var(--color-neutral-100); + + &:last-child { + border-bottom: none; } } + } +} - .actions { - display: flex; - align-items: center; - gap: var(--space-4); - } - - .lang-switch { - background-color: var(--color-bg-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - padding: 2px 22px 2px 8px; - cursor: pointer; - font-size: 0.875rem; - font-weight: 600; - color: var(--color-text-muted); - appearance: none; - background-image: - linear-gradient(45deg, transparent 50%, currentColor 50%), - linear-gradient(135deg, currentColor 50%, transparent 50%); - background-position: - calc(100% - 10px) calc(50% - 2px), - calc(100% - 5px) calc(50% - 2px); - background-size: 5px 5px, 5px 5px; - background-repeat: no-repeat; - - &:hover { color: var(--color-text); border-color: var(--color-text); } - &:focus-visible { - outline: 2px solid var(--color-brand); - outline-offset: 1px; - } - } - - .icon-placeholder { - width: 32px; - height: 32px; - border-radius: 50%; - background-color: var(--color-neutral-100); - display: flex; - align-items: center; - justify-content: center; - color: var(--color-text-muted); - } - - /* Mobile Toggle */ - .mobile-toggle { - display: none; - flex-direction: column; - justify-content: space-between; - width: 24px; - height: 18px; - cursor: pointer; - z-index: 101; - - span { - display: block; - height: 2px; - width: 100%; - background-color: var(--color-text); - border-radius: 2px; - transition: all 0.3s ease; - } - - &.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); } - } - } - - /* Responsive Design */ - @media (max-width: 768px) { - .mobile-toggle { - display: flex; - order: 2; /* Place after actions */ - margin-left: var(--space-4); - } - - .actions { - order: 1; /* Place before toggle */ - margin-left: auto; /* Push to right */ - } - - .nav-links { - position: absolute; - top: 64px; - left: 0; - right: 0; - background-color: var(--color-bg-card); - flex-direction: column; - padding: var(--space-4); - border-bottom: 1px solid var(--color-border); - gap: var(--space-4); - display: none; - z-index: 1000; - - &.open { - display: flex; - animation: slideDown 0.3s ease forwards; - } - - a { - font-size: 1.1rem; - padding: var(--space-2) 0; - border-bottom: 1px solid var(--color-neutral-100); - - &:last-child { - border-bottom: none; - } - } - } - } - - @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); + } +} diff --git a/frontend/src/app/core/layout/navbar.component.ts b/frontend/src/app/core/layout/navbar.component.ts index 0cfe61a..e5fd8fc 100644 --- a/frontend/src/app/core/layout/navbar.component.ts +++ b/frontend/src/app/core/layout/navbar.component.ts @@ -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) {} diff --git a/frontend/src/app/core/services/language.service.spec.ts b/frontend/src/app/core/services/language.service.spec.ts new file mode 100644 index 0000000..1e45064 --- /dev/null +++ b/frontend/src/app/core/services/language.service.spec.ts @@ -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(); + + const createUrlTree = ( + commands: unknown[], + extras?: { queryParams?: Record; 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', + ); + }); +}); diff --git a/frontend/src/app/core/services/language.service.ts b/frontend/src/app/core/services/language.service.ts index f431344..b21e21d 100644 --- a/frontend/src/app/core/services/language.service.ts +++ b/frontend/src/app/core/services/language.service.ts @@ -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,9 +78,10 @@ export class LanguageService { } selectedLang(): 'it' | 'en' | 'de' | 'fr' { - const activeLang = typeof this.translate.currentLang === 'string' - ? this.translate.currentLang.toLowerCase() - : null; + 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; } diff --git a/frontend/src/app/core/services/quote-request.service.ts b/frontend/src/app/core/services/quote-request.service.ts index 21eddd3..bb08dde 100644 --- a/frontend/src/app/core/services/quote-request.service.ts +++ b/frontend/src/app/core/services/quote-request.service.ts @@ -17,7 +17,7 @@ export interface QuoteRequestDto { } @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class QuoteRequestService { private http = inject(HttpClient); @@ -25,15 +25,15 @@ export class QuoteRequestService { createRequest(request: QuoteRequestDto, files: File[]): Observable { const formData = new FormData(); - + // 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); }); diff --git a/frontend/src/app/features/about/about-page.component.html b/frontend/src/app/features/about/about-page.component.html index 5672ff1..0323287 100644 --- a/frontend/src/app/features/about/about-page.component.html +++ b/frontend/src/app/features/about/about-page.component.html @@ -1,17 +1,16 @@
-
-

{{ 'ABOUT.EYEBROW' | translate }}

-

{{ 'ABOUT.TITLE' | translate }}

-

{{ 'ABOUT.SUBTITLE' | translate }}

+

{{ "ABOUT.EYEBROW" | translate }}

+

{{ "ABOUT.TITLE" | translate }}

+

{{ "ABOUT.SUBTITLE" | translate }}

-

{{ 'ABOUT.HOW_TEXT' | translate }}

-
-

{{ 'ABOUT.PASSIONS_TITLE' | translate }}

+

{{ "ABOUT.HOW_TEXT" | translate }}

+
+

{{ "ABOUT.PASSIONS_TITLE" | translate }}

@for (passion of passions; track passion.id) { @@ -40,11 +39,18 @@ (keydown.space)="toggleSelectedMember('joe'); $event.preventDefault()" >
- +
- {{ 'ABOUT.MEMBER_JOE_NAME' | translate }} - {{ 'ABOUT.MEMBER_JOE_ROLE' | translate }} + {{ + "ABOUT.MEMBER_JOE_NAME" | translate + }} + {{ + "ABOUT.MEMBER_JOE_ROLE" | translate + }}
- +
- {{ 'ABOUT.MEMBER_MATTEO_NAME' | translate }} - {{ 'ABOUT.MEMBER_MATTEO_ROLE' | translate }} + {{ + "ABOUT.MEMBER_MATTEO_NAME" | translate + }} + {{ + "ABOUT.MEMBER_MATTEO_ROLE" | translate + }}
-
diff --git a/frontend/src/app/features/about/about-page.component.scss b/frontend/src/app/features/about/about-page.component.scss index ea63580..007ce8b 100644 --- a/frontend/src/app/features/about/about-page.component.scss +++ b/frontend/src/app/features/about/about-page.component.scss @@ -12,8 +12,8 @@ gap: 4rem; 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; } @@ -86,8 +86,8 @@ h1 { flex-wrap: wrap; 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 { @@ -119,8 +123,8 @@ h1 { justify-content: center; 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); diff --git a/frontend/src/app/features/about/about-page.component.ts b/frontend/src/app/features/about/about-page.component.ts index 15f22da..2b093c8 100644 --- a/frontend/src/app/features/about/about-page.component.ts +++ b/frontend/src/app/features/about/about-page.component.ts @@ -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>> = { + private readonly memberPassions: Readonly< + Record> + > = { 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 { diff --git a/frontend/src/app/features/about/about.routes.ts b/frontend/src/app/features/about/about.routes.ts index 9ff57a6..0128c8e 100644 --- a/frontend/src/app/features/about/about.routes.ts +++ b/frontend/src/app/features/about/about.routes.ts @@ -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 }, ]; diff --git a/frontend/src/app/features/admin/admin.routes.ts b/frontend/src/app/features/admin/admin.routes.ts index 51bf68f..26341a7 100644 --- a/frontend/src/app/features/admin/admin.routes.ts +++ b/frontend/src/app/features/admin/admin.routes.ts @@ -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, + ), + }, + ], + }, ]; diff --git a/frontend/src/app/features/admin/guards/admin-auth.guard.ts b/frontend/src/app/features/admin/guards/admin-auth.guard.ts index f11046e..69e730e 100644 --- a/frontend/src/app/features/admin/guards/admin-auth.guard.ts +++ b/frontend/src/app/features/admin/guards/admin-auth.guard.ts @@ -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 => { 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( - router.createUrlTree(['/', lang, 'admin', 'login'], { - queryParams: { redirect: state.url } - }) - )) + catchError(() => + of( + router.createUrlTree(['/', lang, 'admin', 'login'], { + queryParams: { redirect: state.url }, + }), + ), + ), ); }; diff --git a/frontend/src/app/features/admin/pages/admin-contact-requests.component.html b/frontend/src/app/features/admin/pages/admin-contact-requests.component.html index 8822f83..cd524eb 100644 --- a/frontend/src/app/features/admin/pages/admin-contact-requests.component.html +++ b/frontend/src/app/features/admin/pages/admin-contact-requests.component.html @@ -5,7 +5,9 @@

Richieste preventivo personalizzato ricevute dal sito.

{{ requests.length }} richieste - +

{{ errorMessage }}

@@ -32,10 +34,19 @@ [class.selected]="isSelected(request.id)" (click)="openDetails(request.id)" > - {{ request.createdAt | date:'short' }} + + {{ request.createdAt | date: "short" }} + -

{{ request.name || request.companyName || '-' }}

-

{{ request.companyName }}

+

+ {{ request.name || request.companyName || "-" }} +

+

+ {{ request.companyName }} +

{{ request.email }} @@ -45,7 +56,11 @@ {{ request.customerType }} - {{ request.status }} + {{ request.status }} @@ -60,25 +75,58 @@

Dettaglio richiesta

-

ID{{ selectedRequest.id }}

+

+ ID{{ selectedRequest.id }} +

- {{ selectedRequest.status }} - {{ selectedRequest.requestType }} - {{ selectedRequest.customerType }} + {{ selectedRequest.status }} + {{ + selectedRequest.requestType + }} + {{ + selectedRequest.customerType + }}
-

Caricamento dettaglio...

+

+ Caricamento dettaglio... +

-
Creata
{{ selectedRequest.createdAt | date:'medium' }}
-
Aggiornata
{{ selectedRequest.updatedAt | date:'medium' }}
-
Email
{{ selectedRequest.email }}
-
Telefono
{{ selectedRequest.phone || '-' }}
-
Nome
{{ selectedRequest.name || '-' }}
-
Azienda
{{ selectedRequest.companyName || '-' }}
-
Referente
{{ selectedRequest.contactPerson || '-' }}
+
+
Creata
+
{{ selectedRequest.createdAt | date: "medium" }}
+
+
+
Aggiornata
+
{{ selectedRequest.updatedAt | date: "medium" }}
+
+
+
Email
+
{{ selectedRequest.email }}
+
+
+
Telefono
+
{{ selectedRequest.phone || "-" }}
+
+
+
Nome
+
{{ selectedRequest.name || "-" }}
+
+
+
Azienda
+
{{ selectedRequest.companyName || "-" }}
+
+
+
Referente
+
{{ selectedRequest.contactPerson || "-" }}
+
@@ -87,36 +135,61 @@

Messaggio

-

{{ selectedRequest.message || '-' }}

+

{{ selectedRequest.message || "-" }}

Allegati

-
-
+
+

{{ attachment.originalFilename }}

{{ formatFileSize(attachment.fileSizeBytes) }} - | {{ attachment.mimeType }} - | {{ attachment.createdAt | date:'short' }} + + | {{ attachment.mimeType }} + + | {{ attachment.createdAt | date: "short" }}

- +
diff --git a/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss b/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss index 25e5e2d..cfbf87a 100644 --- a/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss +++ b/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss @@ -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; } diff --git a/frontend/src/app/features/admin/pages/admin-contact-requests.component.ts b/frontend/src/app/features/admin/pages/admin-contact-requests.component.ts index dd55cbd..c99e810 100644 --- a/frontend/src/app/features/admin/pages/admin-contact-requests.component.ts +++ b/frontend/src/app/features/admin/pages/admin-contact-requests.component.ts @@ -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,12 +86,18 @@ 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}`), - error: () => { - this.errorMessage = 'Download allegato non riuscito.'; - } - }); + 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.'; + }, + }); } formatFileSize(bytes?: number): string { @@ -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,26 +142,31 @@ export class AdminContactRequestsComponent implements OnInit { this.successMessage = null; this.updatingStatus = true; - 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 => - request.id === updated.id - ? { - ...request, - status: updated.status - } - : request - ); - this.updatingStatus = false; - this.successMessage = 'Stato richiesta aggiornato.'; - }, - error: () => { - this.updatingStatus = false; - this.errorMessage = 'Impossibile aggiornare lo stato della richiesta.'; - } - }); + 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) => + request.id === updated.id + ? { + ...request, + status: updated.status, + } + : request, + ); + this.updatingStatus = false; + this.successMessage = 'Stato richiesta aggiornato.'; + }, + error: () => { + this.updatingStatus = false; + this.errorMessage = + 'Impossibile aggiornare lo stato della richiesta.'; + }, + }); } private downloadBlob(blob: Blob, filename: string): void { diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.html b/frontend/src/app/features/admin/pages/admin-dashboard.component.html index b60facc..ce669d2 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.html +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.html @@ -5,7 +5,9 @@

Seleziona un ordine a sinistra e gestiscilo nel dettaglio a destra.

- +
@@ -32,7 +34,12 @@ [ngModel]="paymentStatusFilter" (ngModelChange)="onPaymentStatusFilterChange($event)" > - + @@ -65,12 +77,16 @@ > {{ order.orderNumber }} {{ order.customerEmail }} - {{ order.paymentStatus || 'PENDING' }} + {{ order.paymentStatus || "PENDING" }} {{ order.status }} - {{ order.totalChf | currency:'CHF':'symbol':'1.2-2' }} + + {{ order.totalChf | currency: "CHF" : "symbol" : "1.2-2" }} + - Nessun ordine trovato per i filtri selezionati. + + Nessun ordine trovato per i filtri selezionati. + @@ -80,39 +96,74 @@

Dettaglio ordine {{ selectedOrder.orderNumber }}

-

UUID: {{ selectedOrder.id }}

+

+ UUID: {{ selectedOrder.id }} +

Caricamento dettaglio...

-
Cliente{{ selectedOrder.customerEmail }}
-
Stato pagamento{{ selectedOrder.paymentStatus || 'PENDING' }}
-
Stato ordine{{ selectedOrder.status }}
-
Totale{{ selectedOrder.totalChf | currency:'CHF':'symbol':'1.2-2' }}
+
+ Cliente{{ selectedOrder.customerEmail }} +
+
+ Stato pagamento{{ selectedOrder.paymentStatus || "PENDING" }} +
+
+ Stato ordine{{ selectedOrder.status }} +
+
+ Totale{{ + selectedOrder.totalChf | currency: "CHF" : "symbol" : "1.2-2" + }} +
- + -
- +
@@ -132,17 +183,26 @@
-

{{ item.originalFilename }}

+

+ {{ item.originalFilename }} +

- Qta: {{ item.quantity }} | - Colore: - - {{ item.colorCode || '-' }} - | - Riga: {{ item.lineTotalChf | currency:'CHF':'symbol':'1.2-2' }} + Qta: {{ item.quantity }} | Colore: + + {{ item.colorCode || "-" }} + | Riga: + {{ item.lineTotalChf | currency: "CHF" : "symbol" : "1.2-2" }}

-
@@ -160,21 +220,52 @@

Caricamento ordini...

-

- Stock spools: {{ newVariant.stockSpools | number:'1.0-3' }} | - Filamento totale: {{ computeStockFilamentGrams(newVariant.stockSpools, newVariant.spoolNetKg) | number:'1.0-0' }} g + Stock spools: + {{ newVariant.stockSpools | number: "1.0-3" }} | + Filamento totale: + {{ + computeStockFilamentGrams( + newVariant.stockSpools, + newVariant.spoolNetKg + ) | number: "1.0-0" + }} + g

-
@@ -140,37 +204,68 @@

Varianti filamento

-
+
{{ variant.variantDisplayName }} -
+
- - {{ variant.colorName || 'N/D' }} + + {{ variant.colorName || "N/D" }} - Stock spools: {{ variant.stockSpools | number:'1.0-3' }} - Filamento: {{ computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) | number:'1.0-0' }} g + Stock spools: + {{ variant.stockSpools | number: "1.0-3" }} + Filamento: + {{ + computeStockFilamentGrams( + variant.stockSpools, + variant.spoolNetKg + ) | number: "1.0-0" + }} + g
- Stock basso - Stock ok + Stock basso + Stock ok
@@ -179,7 +274,10 @@
@@ -241,33 +356,57 @@

- Stock spools: {{ variant.stockSpools | number:'1.0-3' }} | - Filamento totale: {{ computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) | number:'1.0-0' }} g + Stock spools: + {{ variant.stockSpools | number: "1.0-3" }} | + Filamento totale: + {{ + computeStockFilamentGrams( + variant.stockSpools, + variant.spoolNetKg + ) | number: "1.0-0" + }} + g

-

Nessuna variante configurata.

+

+ Nessuna variante configurata. +

Materiali

-
-
+
@@ -290,12 +433,22 @@
-
-

Nessun materiale configurato.

+

+ Nessun materiale configurato. +

@@ -313,19 +466,38 @@

Sezione collassata.

-
+

Sei sicuro?

-

Vuoi eliminare la variante {{ variantToDelete.variantDisplayName }}?

+

+ Vuoi eliminare la variante + {{ variantToDelete.variantDisplayName }}? +

L'operazione non è reversibile.

- +
diff --git a/frontend/src/app/features/admin/pages/admin-filament-stock.component.ts b/frontend/src/app/features/admin/pages/admin-filament-stock.component.ts index f79f238..7ec33ea 100644 --- a/frontend/src/app/features/admin/pages/admin-filament-stock.component.ts +++ b/frontend/src/app/features/admin/pages/admin-filament-stock.component.ts @@ -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,34 +151,41 @@ 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({ - next: (updated) => { - this.materials = this.sortMaterials( - this.materials.map((m) => (m.id === updated.id ? updated : m)) - ); - this.variants = this.variants.map((variant) => { - if (variant.materialTypeId !== updated.id) { - return variant; - } - return { - ...variant, - materialCode: updated.materialCode, - materialIsFlexible: updated.isFlexible, - materialIsTechnical: updated.isTechnical, - materialTechnicalTypeLabel: updated.technicalTypeLabel - }; - }); - this.savingMaterialIds.delete(material.id); - this.successMessage = 'Materiale aggiornato.'; - }, - error: (err) => { - this.savingMaterialIds.delete(material.id); - this.errorMessage = this.extractErrorMessage(err, 'Aggiornamento materiale non riuscito.'); - } - }); + this.adminOperationsService + .updateFilamentMaterial(material.id, payload) + .subscribe({ + next: (updated) => { + this.materials = this.sortMaterials( + this.materials.map((m) => (m.id === updated.id ? updated : m)), + ); + this.variants = this.variants.map((variant) => { + if (variant.materialTypeId !== updated.id) { + return variant; + } + return { + ...variant, + materialCode: updated.materialCode, + materialIsFlexible: updated.isFlexible, + materialIsTechnical: updated.isTechnical, + materialTechnicalTypeLabel: updated.technicalTypeLabel, + }; + }); + this.savingMaterialIds.delete(material.id); + this.successMessage = 'Materiale aggiornato.'; + }, + error: (err) => { + this.savingMaterialIds.delete(material.id); + this.errorMessage = this.extractErrorMessage( + err, + 'Aggiornamento materiale non riuscito.', + ); + }, + }); } createVariant(): void { @@ -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({ - next: (updated) => { - this.variants = this.sortVariants( - 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.adminOperationsService + .updateFilamentVariant(variant.id, payload) + .subscribe({ + next: (updated) => { + this.variants = this.sortVariants( + 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.', + ); + }, + }); } 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 || '', + ); }); } diff --git a/frontend/src/app/features/admin/pages/admin-login.component.html b/frontend/src/app/features/admin/pages/admin-login.component.html index 4fb6e1c..82fd10b 100644 --- a/frontend/src/app/features/admin/pages/admin-login.component.html +++ b/frontend/src/app/features/admin/pages/admin-login.component.html @@ -15,8 +15,11 @@ required /> - diff --git a/frontend/src/app/features/admin/pages/admin-login.component.ts b/frontend/src/app/features/admin/pages/admin-login.component.ts index 10b0a3a..9c79d27 100644 --- a/frontend/src/app/features/admin/pages/admin-login.component.ts +++ b/frontend/src/app/features/admin/pages/admin-login.component.ts @@ -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 | 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)); - } + }, }); } diff --git a/frontend/src/app/features/admin/pages/admin-sessions.component.html b/frontend/src/app/features/admin/pages/admin-sessions.component.html index 298ca0a..6c16d74 100644 --- a/frontend/src/app/features/admin/pages/admin-sessions.component.html +++ b/frontend/src/app/features/admin/pages/admin-sessions.component.html @@ -4,7 +4,14 @@

Sessioni quote

Sessioni create dal configuratore con stato e conversione ordine.

- +

{{ errorMessage }}

@@ -26,41 +33,73 @@ - {{ session.id | slice:0:8 }} - {{ session.createdAt | date:'short' }} - {{ session.expiresAt | date:'short' }} + {{ session.id | slice: 0 : 8 }} + {{ session.createdAt | date: "short" }} + {{ session.expiresAt | date: "short" }} {{ session.materialCode }} {{ session.status }} - {{ session.convertedOrderId || '-' }} + {{ session.convertedOrderId || "-" }} -
Caricamento dettaglio...
-
+
+ Caricamento dettaglio... +
+
-
Elementi: {{ detail.items.length }}
-
Totale articoli: {{ detail.itemsTotalChf | currency:'CHF' }}
-
Spedizione: {{ detail.shippingCostChf | currency:'CHF' }}
-
Totale sessione: {{ detail.grandTotalChf | currency:'CHF' }}
+
+ Elementi: {{ detail.items.length }} +
+
+ Totale articoli: + {{ detail.itemsTotalChf | currency: "CHF" }} +
+
+ Spedizione: + {{ detail.shippingCostChf | currency: "CHF" }} +
+
+ Totale sessione: + {{ detail.grandTotalChf | currency: "CHF" }} +
- +
@@ -76,9 +115,15 @@ - + - +
File{{ item.originalFilename }} {{ item.quantity }} {{ formatPrintTime(item.printTimeSeconds) }}{{ item.materialGrams ? (item.materialGrams | number:'1.0-2') + ' g' : '-' }} + {{ + item.materialGrams + ? (item.materialGrams | number: "1.0-2") + " g" + : "-" + }} + {{ item.status }}{{ item.unitPriceChf | currency:'CHF' }}{{ item.unitPriceChf | currency: "CHF" }}
diff --git a/frontend/src/app/features/admin/pages/admin-sessions.component.ts b/frontend/src/app/features/admin/pages/admin-sessions.component.ts index 236f741..d99b9fa 100644 --- a/frontend/src/app/features/admin/pages/admin-sessions.component.ts +++ b/frontend/src/app/features/admin/pages/admin-sessions.component.ts @@ -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.', + ); + }, }); } diff --git a/frontend/src/app/features/admin/pages/admin-shell.component.html b/frontend/src/app/features/admin/pages/admin-shell.component.html index 8e39e15..d6c71b7 100644 --- a/frontend/src/app/features/admin/pages/admin-shell.component.html +++ b/frontend/src/app/features/admin/pages/admin-shell.component.html @@ -9,8 +9,12 @@ diff --git a/frontend/src/app/features/admin/pages/admin-shell.component.scss b/frontend/src/app/features/admin/pages/admin-shell.component.scss index 5cf0645..8b5fda9 100644 --- a/frontend/src/app/features/admin/pages/admin-shell.component.scss +++ b/frontend/src/app/features/admin/pages/admin-shell.component.scss @@ -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 { diff --git a/frontend/src/app/features/admin/pages/admin-shell.component.ts b/frontend/src/app/features/admin/pages/admin-shell.component.ts index 7617b0c..405154e 100644 --- a/frontend/src/app/features/admin/pages/admin-shell.component.ts +++ b/frontend/src/app/features/admin/pages/admin-shell.component.ts @@ -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']); - } + }, }); } diff --git a/frontend/src/app/features/admin/services/admin-auth.service.ts b/frontend/src/app/features/admin/services/admin-auth.service.ts index 0eb7491..99d00bb 100644 --- a/frontend/src/app/features/admin/services/admin-auth.service.ts +++ b/frontend/src/app/features/admin/services/admin-auth.service.ts @@ -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 { - return this.http.post(`${this.baseUrl}/login`, { password }, { withCredentials: true }); + return this.http.post( + `${this.baseUrl}/login`, + { password }, + { withCredentials: true }, + ); } logout(): Observable { - return this.http.post(`${this.baseUrl}/logout`, {}, { withCredentials: true }); + return this.http.post( + `${this.baseUrl}/logout`, + {}, + { withCredentials: true }, + ); } me(): Observable { - return this.http.get(`${this.baseUrl}/me`, { withCredentials: true }).pipe( - map((response) => Boolean(response?.authenticated)) - ); + return this.http + .get(`${this.baseUrl}/me`, { withCredentials: true }) + .pipe(map((response) => Boolean(response?.authenticated))); } } diff --git a/frontend/src/app/features/admin/services/admin-operations.service.ts b/frontend/src/app/features/admin/services/admin-operations.service.ts index de517c9..f6cc51b 100644 --- a/frontend/src/app/features/admin/services/admin-operations.service.ts +++ b/frontend/src/app/features/admin/services/admin-operations.service.ts @@ -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 { - return this.http.get(`${this.baseUrl}/filament-stock`, { withCredentials: true }); + return this.http.get( + `${this.baseUrl}/filament-stock`, + { withCredentials: true }, + ); } getFilamentMaterials(): Observable { - return this.http.get(`${this.baseUrl}/filaments/materials`, { withCredentials: true }); + return this.http.get( + `${this.baseUrl}/filaments/materials`, + { withCredentials: true }, + ); } getFilamentVariants(): Observable { - return this.http.get(`${this.baseUrl}/filaments/variants`, { withCredentials: true }); + return this.http.get( + `${this.baseUrl}/filaments/variants`, + { withCredentials: true }, + ); } - createFilamentMaterial(payload: AdminUpsertFilamentMaterialTypePayload): Observable { - return this.http.post(`${this.baseUrl}/filaments/materials`, payload, { withCredentials: true }); + createFilamentMaterial( + payload: AdminUpsertFilamentMaterialTypePayload, + ): Observable { + return this.http.post( + `${this.baseUrl}/filaments/materials`, + payload, + { withCredentials: true }, + ); } - updateFilamentMaterial(materialId: number, payload: AdminUpsertFilamentMaterialTypePayload): Observable { - return this.http.put(`${this.baseUrl}/filaments/materials/${materialId}`, payload, { withCredentials: true }); + updateFilamentMaterial( + materialId: number, + payload: AdminUpsertFilamentMaterialTypePayload, + ): Observable { + return this.http.put( + `${this.baseUrl}/filaments/materials/${materialId}`, + payload, + { withCredentials: true }, + ); } - createFilamentVariant(payload: AdminUpsertFilamentVariantPayload): Observable { - return this.http.post(`${this.baseUrl}/filaments/variants`, payload, { withCredentials: true }); + createFilamentVariant( + payload: AdminUpsertFilamentVariantPayload, + ): Observable { + return this.http.post( + `${this.baseUrl}/filaments/variants`, + payload, + { withCredentials: true }, + ); } - updateFilamentVariant(variantId: number, payload: AdminUpsertFilamentVariantPayload): Observable { - return this.http.put(`${this.baseUrl}/filaments/variants/${variantId}`, payload, { withCredentials: true }); + updateFilamentVariant( + variantId: number, + payload: AdminUpsertFilamentVariantPayload, + ): Observable { + return this.http.put( + `${this.baseUrl}/filaments/variants/${variantId}`, + payload, + { withCredentials: true }, + ); } deleteFilamentVariant(variantId: number): Observable { - return this.http.delete(`${this.baseUrl}/filaments/variants/${variantId}`, { withCredentials: true }); + return this.http.delete( + `${this.baseUrl}/filaments/variants/${variantId}`, + { withCredentials: true }, + ); } getContactRequests(): Observable { - return this.http.get(`${this.baseUrl}/contact-requests`, { withCredentials: true }); + return this.http.get( + `${this.baseUrl}/contact-requests`, + { withCredentials: true }, + ); } - getContactRequestDetail(requestId: string): Observable { - return this.http.get(`${this.baseUrl}/contact-requests/${requestId}`, { withCredentials: true }); + getContactRequestDetail( + requestId: string, + ): Observable { + return this.http.get( + `${this.baseUrl}/contact-requests/${requestId}`, + { withCredentials: true }, + ); } updateContactRequestStatus( requestId: string, - payload: AdminUpdateContactRequestStatusPayload + payload: AdminUpdateContactRequestStatusPayload, ): Observable { return this.http.patch( `${this.baseUrl}/contact-requests/${requestId}/status`, payload, - { withCredentials: true } + { withCredentials: true }, ); } - downloadContactRequestAttachment(requestId: string, attachmentId: string): Observable { - return this.http.get(`${this.baseUrl}/contact-requests/${requestId}/attachments/${attachmentId}/file`, { - withCredentials: true, - responseType: 'blob' - }); + downloadContactRequestAttachment( + requestId: string, + attachmentId: string, + ): Observable { + return this.http.get( + `${this.baseUrl}/contact-requests/${requestId}/attachments/${attachmentId}/file`, + { + withCredentials: true, + responseType: 'blob', + }, + ); } getSessions(): Observable { - return this.http.get(`${this.baseUrl}/sessions`, { withCredentials: true }); + return this.http.get(`${this.baseUrl}/sessions`, { + withCredentials: true, + }); } deleteSession(sessionId: string): Observable { - return this.http.delete(`${this.baseUrl}/sessions/${sessionId}`, { withCredentials: true }); + return this.http.delete(`${this.baseUrl}/sessions/${sessionId}`, { + withCredentials: true, + }); } getSessionDetail(sessionId: string): Observable { return this.http.get( `${environment.apiUrl}/api/quote-sessions/${sessionId}`, - { withCredentials: true } + { withCredentials: true }, ); } } diff --git a/frontend/src/app/features/admin/services/admin-orders.service.ts b/frontend/src/app/features/admin/services/admin-orders.service.ts index 392f10f..c9b7822 100644 --- a/frontend/src/app/features/admin/services/admin-orders.service.ts +++ b/frontend/src/app/features/admin/services/admin-orders.service.ts @@ -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 { - return this.http.get(`${this.baseUrl}/${orderId}`, { withCredentials: true }); + return this.http.get(`${this.baseUrl}/${orderId}`, { + withCredentials: true, + }); } confirmPayment(orderId: string, method: string): Observable { - return this.http.post(`${this.baseUrl}/${orderId}/payments/confirm`, { method }, { withCredentials: true }); + return this.http.post( + `${this.baseUrl}/${orderId}/payments/confirm`, + { method }, + { withCredentials: true }, + ); } - updateOrderStatus(orderId: string, payload: AdminUpdateOrderStatusPayload): Observable { - return this.http.post(`${this.baseUrl}/${orderId}/status`, payload, { withCredentials: true }); + updateOrderStatus( + orderId: string, + payload: AdminUpdateOrderStatusPayload, + ): Observable { + return this.http.post( + `${this.baseUrl}/${orderId}/status`, + payload, + { withCredentials: true }, + ); } - downloadOrderItemFile(orderId: string, orderItemId: string): Observable { - return this.http.get(`${this.baseUrl}/${orderId}/items/${orderItemId}/file`, { - withCredentials: true, - responseType: 'blob' - }); + downloadOrderItemFile( + orderId: string, + orderItemId: string, + ): Observable { + return this.http.get( + `${this.baseUrl}/${orderId}/items/${orderItemId}/file`, + { + withCredentials: true, + responseType: 'blob', + }, + ); } downloadOrderConfirmation(orderId: string): Observable { return this.http.get(`${this.baseUrl}/${orderId}/documents/confirmation`, { withCredentials: true, - responseType: 'blob' + responseType: 'blob', }); } downloadOrderInvoice(orderId: string): Observable { return this.http.get(`${this.baseUrl}/${orderId}/documents/invoice`, { withCredentials: true, - responseType: 'blob' + responseType: 'blob', }); } } diff --git a/frontend/src/app/features/calculator/calculator-page.component.html b/frontend/src/app/features/calculator/calculator-page.component.html index 07b1912..ab7ae3a 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.html +++ b/frontend/src/app/features/calculator/calculator-page.component.html @@ -1,72 +1,80 @@
-

{{ 'CALC.TITLE' | translate }}

-

{{ 'CALC.SUBTITLE' | translate }}

+

{{ "CALC.TITLE" | translate }}

+

{{ "CALC.SUBTITLE" | translate }}

@if (error()) { {{ errorKey() | translate }} }
-@if (step() === 'success') { -
- -
+@if (step() === "success") { +
+ +
} @else { -
- -
- -
-
- {{ 'CALC.MODE_EASY' | translate }} -
-
- {{ 'CALC.MODE_ADVANCED' | translate }} -
+
+ +
+ +
+
+ {{ "CALC.MODE_EASY" | translate }}
- - - -
- - -
- - @if (loading()) { - -
-
-

{{ 'CALC.ANALYZING_TITLE' | translate }}

-

{{ 'CALC.ANALYZING_TEXT' | translate }}

-
-
- } @else if (result()) { - - } @else { - -

{{ 'CALC.BENEFITS_TITLE' | translate }}

-
    -
  • {{ 'CALC.BENEFITS_1' | translate }}
  • -
  • {{ 'CALC.BENEFITS_2' | translate }}
  • -
  • {{ 'CALC.BENEFITS_3' | translate }}
  • -
-
- } -
+
+ {{ "CALC.MODE_ADVANCED" | translate }} +
+
+ + +
+ + +
+ @if (loading()) { + +
+
+

+ {{ "CALC.ANALYZING_TITLE" | translate }} +

+

{{ "CALC.ANALYZING_TEXT" | translate }}

+
+
+ } @else if (result()) { + + } @else { + +

{{ "CALC.BENEFITS_TITLE" | translate }}

+
    +
  • {{ "CALC.BENEFITS_1" | translate }}
  • +
  • {{ "CALC.BENEFITS_2" | translate }}
  • +
  • {{ "CALC.BENEFITS_3" | translate }}
  • +
+
+ } +
+
} diff --git a/frontend/src/app/features/calculator/calculator-page.component.scss b/frontend/src/app/features/calculator/calculator-page.component.scss index f118857..1eb6b2e 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.scss +++ b/frontend/src/app/features/calculator/calculator-page.component.scss @@ -1,36 +1,44 @@ -.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); } } .centered-col { - align-self: flex-start; /* Default */ - @media(min-width: 768px) { - align-self: center; - } + align-self: flex-start; /* Default */ + @media (min-width: 768px) { + align-self: center; + } } .col-input { - min-width: 0; + min-width: 0; } .col-result { - min-width: 0; - display: flex; - flex-direction: column; + min-width: 0; + display: flex; + flex-direction: column; } /* Stretch only the loading card so the spinner stays centered */ .col-result > .loading-state { - flex: 1; + flex: 1; } /* Mode Selector (Segmented Control style) */ @@ -56,55 +64,64 @@ 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; - max-width: 300px; - margin: 0 auto; - - /* Center content vertically within the stretched card */ - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; + text-align: center; + max-width: 300px; + margin: 0 auto; + + /* Center content vertically within the stretched card */ + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; } .loading-title { - font-size: 1.1rem; - font-weight: 600; - margin: var(--space-4) 0 var(--space-2); - color: var(--color-text); + font-size: 1.1rem; + font-weight: 600; + margin: var(--space-4) 0 var(--space-2); + color: var(--color-text); } .loading-text { - font-size: 0.9rem; - color: var(--color-text-muted); - line-height: 1.5; + font-size: 0.9rem; + color: var(--color-text-muted); + line-height: 1.5; } .spinner { - border: 3px solid var(--color-neutral-200); - border-left-color: var(--color-brand); - border-radius: 50%; - width: 48px; - height: 48px; - animation: spin 1s linear infinite; - margin: 0 auto; + border: 3px solid var(--color-neutral-200); + border-left-color: var(--color-brand); + border-radius: 50%; + width: 48px; + height: 48px; + animation: spin 1s linear infinite; + margin: 0 auto; } @keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } } diff --git a/frontend/src/app/features/calculator/calculator-page.component.spec.ts b/frontend/src/app/features/calculator/calculator-page.component.spec.ts new file mode 100644 index 0000000..92e9033 --- /dev/null +++ b/frontend/src/app/features/calculator/calculator-page.component.spec.ts @@ -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', + ['updateLineItem', 'getQuoteSession', 'mapSessionToQuoteResult'], + ); + const router = jasmine.createSpyObj('Router', ['navigate']); + const route = { + data: of({}), + queryParams: of({}), + } as unknown as ActivatedRoute; + const languageService = jasmine.createSpyObj( + 'LanguageService', + ['selectedLang'], + ); + + const component = new CalculatorPageComponent( + estimator, + router, + route, + languageService, + ); + + const uploadForm = jasmine.createSpyObj( + '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); + }); +}); diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts index 2f67113..14e7a12 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.ts @@ -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,142 +26,155 @@ 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('easy'); step = signal<'upload' | 'quote' | 'details' | 'success'>('upload'); - + loading = signal(false); uploadProgress = signal(0); result = signal(null); error = signal(false); errorKey = signal('CALC.ERROR_GENERIC'); - + orderSuccess = signal(false); - + @ViewChild('uploadForm') uploadForm!: UploadFormComponent; @ViewChild('resultCol') resultCol!: ElementRef; constructor( - private estimator: QuoteEstimatorService, + 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 => { - const sessionId = params['session']; - if (sessionId) { - // Avoid reloading if we just calculated this session - const currentRes = this.result(); - if (!currentRes || currentRes.sessionId !== sessionId) { - this.loadSession(sessionId); - } + this.route.queryParams.subscribe((params) => { + const sessionId = params['session']; + if (sessionId) { + // Avoid reloading if we just calculated this session + const currentRes = this.result(); + if (!currentRes || currentRes.sessionId !== sessionId) { + this.loadSession(sessionId); } + } }); } loadSession(sessionId: string) { - this.loading.set(true); - this.estimator.getQuoteSession(sessionId).subscribe({ - next: (data) => { - // 1. Map to Result - const result = this.estimator.mapSessionToQuoteResult(data); - if (this.isInvalidQuote(result)) { - this.setQuoteError('CALC.ERROR_ZERO_PRICE'); - this.loading.set(false); - return; - } + this.loading.set(true); + this.estimator.getQuoteSession(sessionId).subscribe({ + next: (data) => { + // 1. Map to Result + const result = this.estimator.mapSessionToQuoteResult(data); + if (this.isInvalidQuote(result)) { + this.setQuoteError('CALC.ERROR_ZERO_PRICE'); + this.loading.set(false); + return; + } - this.error.set(false); - this.errorKey.set('CALC.ERROR_GENERIC'); - this.result.set(result); - this.step.set('quote'); - - // 2. Determine Mode (Heuristic) - // If we have custom settings, maybe Advanced? - // For now, let's stick to current mode or infer from URL if possible. - // Actually, we can check if settings deviate from Easy defaults. - // But let's leave it as is or default to Advanced if not sure. - // data.session.materialCode etc. - - // 3. Download Files & Restore Form - this.restoreFilesAndSettings(data.session, data.items); - }, - error: (err) => { - console.error('Failed to load session', err); - this.setQuoteError('CALC.ERROR_GENERIC'); - this.loading.set(false); - } - }); + this.error.set(false); + this.errorKey.set('CALC.ERROR_GENERIC'); + this.result.set(result); + this.step.set('quote'); + + // 2. Determine Mode (Heuristic) + // If we have custom settings, maybe Advanced? + // For now, let's stick to current mode or infer from URL if possible. + // Actually, we can check if settings deviate from Easy defaults. + // But let's leave it as is or default to Advanced if not sure. + // data.session.materialCode etc. + + // 3. Download Files & Restore Form + this.restoreFilesAndSettings(data.session, data.items); + }, + error: (err) => { + console.error('Failed to load session', err); + this.setQuoteError('CALC.ERROR_GENERIC'); + this.loading.set(false); + }, + }); } restoreFilesAndSettings(session: any, items: any[]) { - if (!items || items.length === 0) { - this.loading.set(false); - return; - } + if (!items || items.length === 0) { + this.loading.set(false); + return; + } - // Download all files - const downloads = items.map(item => - this.estimator.getLineItemContent(session.id, item.id).pipe( - map((blob: Blob) => { - return { - blob, - fileName: item.originalFilename, - // We need to match the file object to the item so we can set colors ideally. - // UploadForm.setFiles takes File[]. - // 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' })); - - if (this.uploadForm) { - this.uploadForm.setFiles(files); - this.uploadForm.patchSettings(session); - - // Also restore colors? - // setFiles inits with 'Black'. We need to update them if they differ. - // items has colorCode. - setTimeout(() => { - if (this.uploadForm) { - items.forEach((item, index) => { - // Assuming index matches. - // Need to be careful if items order changed, but usually ID sort or insert order. - if (item.colorCode) { - this.uploadForm.updateItemColor(index, { - colorName: item.colorCode, - filamentVariantId: item.filamentVariantId - }); - } - }); - } + // Download all files + const downloads = items.map((item) => + this.estimator.getLineItemContent(session.id, item.id).pipe( + map((blob: Blob) => { + return { + blob, + fileName: item.originalFilename, + // We need to match the file object to the item so we can set colors ideally. + // UploadForm.setFiles takes File[]. + // 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', + }), + ); + + if (this.uploadForm) { + this.uploadForm.setFiles(files); + this.uploadForm.patchSettings(session); + + // Also restore colors? + // setFiles inits with 'Black'. We need to update them if they differ. + // items has colorCode. + setTimeout(() => { + if (this.uploadForm) { + items.forEach((item, index) => { + // Assuming index matches. + // Need to be careful if items order changed, but usually ID sort or insert order. + if (item.colorCode) { + this.uploadForm.updateItemColor(index, { + colorName: item.colorCode, + filamentVariantId: item.filamentVariantId, }); - } - this.loading.set(false); - }, - error: (err: any) => { - console.error('Failed to download files', err); - this.loading.set(false); - // Still show result? Yes. - } - }); + } + }); + } + }); + } + this.loading.set(false); + }, + error: (err: any) => { + console.error('Failed to download files', err); + this.loading.set(false); + // Still show result? Yes. + }, + }); } onCalculate(req: QuoteRequest) { @@ -166,46 +189,49 @@ 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' }); - } + if (this.resultCol && window.innerWidth < 768) { + this.resultCol.nativeElement.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + } }, 100); this.estimator.calculate(req).subscribe({ next: (event) => { if (typeof event === 'number') { - this.uploadProgress.set(event); + this.uploadProgress.set(event); } else { - // It's the result - const res = event as QuoteResult; - if (this.isInvalidQuote(res)) { - this.setQuoteError('CALC.ERROR_ZERO_PRICE'); - this.loading.set(false); - return; - } - - this.error.set(false); - this.errorKey.set('CALC.ERROR_GENERIC'); - this.result.set(res); + // It's the result + const res = event as QuoteResult; + if (this.isInvalidQuote(res)) { + this.setQuoteError('CALC.ERROR_ZERO_PRICE'); this.loading.set(false); - this.uploadProgress.set(100); - this.step.set('quote'); + return; + } - // Update URL with session ID without reloading - if (res.sessionId) { - this.router.navigate([], { - 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" - }); - } + this.error.set(false); + this.errorKey.set('CALC.ERROR_GENERIC'); + this.result.set(res); + this.loading.set(false); + this.uploadProgress.set(100); + this.step.set('quote'); + + // Update URL with session ID without reloading + if (res.sessionId) { + this.router.navigate([], { + 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" + }); + } } }, 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,59 +252,67 @@ export class CalculatorPageComponent implements OnInit { this.step.set('quote'); } - 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); - this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity); - } - - // 2. Update backend session if ID exists - if (event.id) { - const currentSessionId = this.result()?.sessionId; - if (!currentSessionId) return; + 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); + this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity); + } - 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); - // Preserve notes - newResult.notes = this.result()?.notes; + // 2. Update backend session if ID exists + if (event.id) { + const currentSessionId = this.result()?.sessionId; + if (!currentSessionId) return; - if (this.isInvalidQuote(newResult)) { - this.setQuoteError('CALC.ERROR_ZERO_PRICE'); - return; - } + 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); + // Preserve notes + newResult.notes = this.result()?.notes; - this.error.set(false); - this.errorKey.set('CALC.ERROR_GENERIC'); - this.result.set(newResult); - }, - error: (err) => { - console.error('Failed to refresh session totals', err); - } - }); + if (this.isInvalidQuote(newResult)) { + this.setQuoteError('CALC.ERROR_ZERO_PRICE'); + return; + } + + this.error.set(false); + this.errorKey.set('CALC.ERROR_GENERIC'); + this.result.set(newResult); }, error: (err) => { - console.error('Failed to update line item', err); - } - }); - } + console.error('Failed to refresh session totals', err); + }, + }); + }, + error: (err) => { + console.error('Failed to update line item', err); + }, + }); + } } onSubmitOrder(orderData: any) { console.log('Order Submitted:', orderData); this.orderSuccess.set(true); - this.step.set('success'); + this.step.set('success'); } - + onNewQuote() { - this.step.set('upload'); - this.result.set(null); - this.orderSuccess.set(false); - this.mode.set('easy'); // Reset to default + this.step.set('upload'); + this.result.set(null); + this.orderSuccess.set(false); + this.mode.set('easy'); // Reset to default } private currentRequest: QuoteRequest | null = null; @@ -290,25 +324,25 @@ export class CalculatorPageComponent implements OnInit { let details = `Richiesta Preventivo:\n`; details += `- Materiale: ${req.material}\n`; details += `- Qualità: ${req.quality}\n`; - + details += `- File:\n`; - req.items.forEach(item => { - details += ` * ${item.file.name} (Qtà: ${item.quantity}`; - if (item.color) { - details += `, Colore: ${item.color}`; - } - details += `)\n`; + req.items.forEach((item) => { + details += ` * ${item.file.name} (Qtà: ${item.quantity}`; + if (item.color) { + details += `, Colore: ${item.color}`; + } + details += `)\n`; }); if (req.mode === 'advanced') { - if (req.infillDensity) details += `- Infill: ${req.infillDensity}%\n`; + if (req.infillDensity) details += `- Infill: ${req.infillDensity}%\n`; } 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']); diff --git a/frontend/src/app/features/calculator/calculator.routes.ts b/frontend/src/app/features/calculator/calculator.routes.ts index 739f736..5a670cd 100644 --- a/frontend/src/app/features/calculator/calculator.routes.ts +++ b/frontend/src/app/features/calculator/calculator.routes.ts @@ -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' }, + }, ]; diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html index 4b009da..58783cb 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.html @@ -1,14 +1,15 @@ -

{{ 'CALC.RESULT' | translate }}

- +

{{ "CALC.RESULT" | translate }}

+
- - {{ totals().price | currency:result().currency }} + + {{ totals().price | currency: result().currency }} @@ -21,19 +22,26 @@
- {{ 'CALC.SETUP_NOTE' | translate:{cost: (result().setupCost | currency:result().currency)} }}
- {{ 'CALC.SHIPPING_NOTE' | translate }} + {{ + "CALC.SETUP_NOTE" + | translate + : { cost: (result().setupCost | currency: result().currency) } + }}
+ {{ + "CALC.SHIPPING_NOTE" | translate + }}
@if (result().notes) {
- -

{{ result().notes }}

+ +

{{ result().notes }}

}
- +
@for (item of items(); track item.fileName; let i = $index) { @@ -41,33 +49,41 @@
{{ item.fileName }} - {{ (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
- +
-
- - -
-
- - {{ (item.unitPrice * item.quantity) | currency:result().currency }} - - - {{ item.unitPrice | currency:result().currency }} {{ 'CHECKOUT.PER_PIECE' | translate }} - - -   - -
+
+ + +
+
+ + {{ item.unitPrice * item.quantity | currency: result().currency }} + + + {{ item.unitPrice | currency: result().currency }} + {{ "CHECKOUT.PER_PIECE" | translate }} + + +   + +
} @@ -75,15 +91,17 @@
- {{ 'QUOTE.CONSULT' | translate }} + {{ "QUOTE.CONSULT" | translate }} @if (!hasQuantityOverLimit()) { - {{ 'QUOTE.PROCEED_ORDER' | translate }} + {{ "QUOTE.PROCEED_ORDER" | translate }} } @else { - {{ 'QUOTE.MAX_QTY_NOTICE' | translate:{ max: directOrderLimit } }} + {{ + "QUOTE.MAX_QTY_NOTICE" | translate: { max: directOrderLimit } + }} }
diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss index effe200..ae639d0 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.scss @@ -1,86 +1,105 @@ -.title { margin-bottom: var(--space-6); text-align: center; } +.title { + margin-bottom: var(--space-6); + text-align: center; +} -.divider { - height: 1px; - background: var(--color-border); - margin: var(--space-4) 0; +.divider { + height: 1px; + background: var(--color-border); + margin: var(--space-4) 0; } .items-list { - display: flex; - flex-direction: column; - gap: var(--space-3); - margin-bottom: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-3); + margin-bottom: var(--space-4); } .item-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: var(--space-3); - background: var(--color-neutral-50); - border-radius: var(--radius-md); - border: 1px solid var(--color-border); + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--space-3); + background: var(--color-neutral-50); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); } .item-info { - display: flex; - flex-direction: column; - min-width: 0; - flex: 1; /* Ensure it takes available space */ + display: flex; + flex-direction: column; + min-width: 0; + 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; - align-items: center; - gap: var(--space-4); + display: flex; + align-items: center; + gap: var(--space-4); } .qty-control { - display: flex; - align-items: center; - gap: var(--space-2); - - label { font-size: 0.8rem; color: var(--color-text-muted); } + display: flex; + align-items: center; + gap: var(--space-2); + + label { + font-size: 0.8rem; + color: var(--color-text-muted); + } } .qty-input { - width: 60px; - padding: 4px 8px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - text-align: center; - &:focus { outline: none; border-color: var(--color-brand); } + width: 60px; + padding: 4px 8px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + text-align: center; + &:focus { + outline: none; + border-color: var(--color-brand); + } } .item-price { - font-weight: 600; - min-width: 60px; - text-align: right; - display: flex; - flex-direction: column; - align-items: flex-end; - justify-content: center; - min-height: 2.1rem; + font-weight: 600; + min-width: 60px; + text-align: right; + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: center; + min-height: 2.1rem; } .item-total-price { - line-height: 1.1; + line-height: 1.1; } .item-unit-price { - margin-top: 2px; - font-size: 0.72rem; - font-weight: 400; - color: var(--color-text-muted); - line-height: 1.2; + margin-top: 2px; + font-size: 0.72rem; + font-weight: 400; + color: var(--color-text-muted); + line-height: 1.2; } .item-unit-price--placeholder { - visibility: hidden; + visibility: hidden; } .result-grid { @@ -88,50 +107,56 @@ grid-template-columns: 1fr; gap: var(--space-3); margin-bottom: var(--space-2); - - @media(min-width: 500px) { - grid-template-columns: 1fr 1fr; - gap: var(--space-4); + + @media (min-width: 500px) { + grid-template-columns: 1fr 1fr; + gap: var(--space-4); } } -.full-width { grid-column: span 2; } - -.setup-note { - text-align: center; - margin-bottom: var(--space-6); - color: var(--color-text-muted); - font-size: 0.8rem; +.full-width { + grid-column: span 2; } -.actions { display: flex; flex-direction: column; gap: var(--space-3); } +.setup-note { + text-align: center; + margin-bottom: var(--space-6); + color: var(--color-text-muted); + font-size: 0.8rem; +} + +.actions { + display: flex; + flex-direction: column; + gap: var(--space-3); +} .limit-note { - font-size: 0.8rem; - color: var(--color-text-muted); - text-align: center; - margin-top: calc(var(--space-2) * -1); + font-size: 0.8rem; + color: var(--color-text-muted); + text-align: center; + margin-top: calc(var(--space-2) * -1); } .notes-section { - margin-top: var(--space-4); - margin-bottom: var(--space-4); - padding: var(--space-3); - background: var(--color-neutral-50); - border-radius: var(--radius-md); - border: 1px solid var(--color-border); - - label { - font-weight: 500; - font-size: 0.9rem; - color: var(--color-text-muted); - display: block; - margin-bottom: var(--space-2); - } - - p { - margin: 0; - font-size: 0.95rem; - color: var(--color-text); - white-space: pre-wrap; /* Preserve line breaks */ - } + margin-top: var(--space-4); + margin-bottom: var(--space-4); + padding: var(--space-3); + background: var(--color-neutral-50); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + + label { + font-weight: 500; + font-size: 0.9rem; + color: var(--color-text-muted); + display: block; + margin-bottom: var(--space-2); + } + + p { + margin: 0; + font-size: 0.95rem; + color: var(--color-text); + white-space: pre-wrap; /* Preserve line breaks */ + } } diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.spec.ts b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.spec.ts new file mode 100644 index 0000000..5784b3a --- /dev/null +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.spec.ts @@ -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; + 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(); + }); +}); diff --git a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts index a97b1b8..583dd66 100644 --- a/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts +++ b/frontend/src/app/features/calculator/components/quote-result/quote-result.component.ts @@ -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(); consult = output(); proceed = output(); - 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([]); @@ -30,120 +50,124 @@ export class QuoteResultComponent implements OnDestroy { private quantityTimers = new Map>(); constructor() { - effect(() => { - this.clearAllQuantityTimers(); + 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})); - this.items.set(nextItems); + // 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 })); + this.items.set(nextItems); - this.lastSentQuantities.clear(); - nextItems.forEach(item => { - const key = item.id ?? item.fileName; - this.lastSentQuantities.set(key, item.quantity); - }); - }, { allowSignalWrites: true }); + this.lastSentQuantities.clear(); + nextItems.forEach((item) => { + const key = item.id ?? item.fileName; + this.lastSentQuantities.set(key, item.quantity); + }); + }, + { allowSignalWrites: true }, + ); } ngOnDestroy(): void { - this.clearAllQuantityTimers(); + this.clearAllQuantityTimers(); } updateQuantity(index: number, newQty: number | string) { - const normalizedQty = this.normalizeQuantity(newQty); - if (normalizedQty === null) return; + const normalizedQty = this.normalizeQuantity(newQty); + if (normalizedQty === null) return; - const item = this.items()[index]; - if (!item) return; - const key = item.id ?? item.fileName; + const item = this.items()[index]; + if (!item) return; + const key = item.id ?? item.fileName; - this.items.update(current => { - const updated = [...current]; - updated[index] = { ...updated[index], quantity: normalizedQty }; - return updated; - }); + this.items.update((current) => { + const updated = [...current]; + updated[index] = { ...updated[index], quantity: normalizedQty }; + return updated; + }); - this.scheduleQuantityRefresh(index, key); + this.scheduleQuantityRefresh(index, key); } flushQuantityUpdate(index: number): void { - const item = this.items()[index]; - if (!item) return; + const item = this.items()[index]; + if (!item) return; - const key = item.id ?? item.fileName; - this.clearQuantityRefreshTimer(key); + const key = item.id ?? item.fileName; + this.clearQuantityRefreshTimer(key); - const normalizedQty = this.normalizeQuantity(item.quantity); - if (normalizedQty === null) return; + const normalizedQty = this.normalizeQuantity(item.quantity); + if (normalizedQty === null) return; - if (this.lastSentQuantities.get(key) === normalizedQty) { - return; - } + if (this.lastSentQuantities.get(key) === normalizedQty) { + return; + } - this.itemChange.emit({ - id: item.id, - index, - fileName: item.fileName, - quantity: normalizedQty - }); - this.lastSentQuantities.set(key, normalizedQty); + this.itemChange.emit({ + id: item.id, + index, + fileName: item.fileName, + 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(); - const setup = this.result().setupCost; - - let price = setup; - let time = 0; - let weight = 0; - - currentItems.forEach(i => { - price += i.unitPrice * i.quantity; - time += i.unitTime * i.quantity; - weight += i.unitWeight * i.quantity; - }); - - const hours = Math.floor(time / 3600); - const minutes = Math.ceil((time % 3600) / 60); - - return { - price: Math.round(price * 100) / 100, - hours, - minutes, - weight: Math.ceil(weight) - }; + const currentItems = this.items(); + const setup = this.result().setupCost; + + let price = setup; + let time = 0; + let weight = 0; + + currentItems.forEach((i) => { + price += i.unitPrice * i.quantity; + time += i.unitTime * i.quantity; + weight += i.unitWeight * i.quantity; + }); + + const hours = Math.floor(time / 3600); + const minutes = Math.ceil((time % 3600) / 60); + + return { + price: Math.round(price * 100) / 100, + hours, + minutes, + weight: Math.ceil(weight), + }; }); private normalizeQuantity(newQty: number | string): number | null { - const qty = typeof newQty === 'string' ? parseInt(newQty, 10) : newQty; - if (!Number.isFinite(qty) || qty < 1) { - return null; - } - return Math.min(qty, this.maxInputQuantity); + const qty = typeof newQty === 'string' ? parseInt(newQty, 10) : newQty; + if (!Number.isFinite(qty) || qty < 1) { + return null; + } + return Math.min(qty, this.maxInputQuantity); } private scheduleQuantityRefresh(index: number, key: string): void { - this.clearQuantityRefreshTimer(key); - const timer = setTimeout(() => { - this.quantityTimers.delete(key); - this.flushQuantityUpdate(index); - }, this.quantityAutoRefreshMs); - this.quantityTimers.set(key, timer); + this.clearQuantityRefreshTimer(key); + const timer = setTimeout(() => { + this.quantityTimers.delete(key); + this.flushQuantityUpdate(index); + }, this.quantityAutoRefreshMs); + this.quantityTimers.set(key, timer); } private clearQuantityRefreshTimer(key: string): void { - const timer = this.quantityTimers.get(key); - if (!timer) return; - clearTimeout(timer); - this.quantityTimers.delete(key); + const timer = this.quantityTimers.get(key); + if (!timer) return; + clearTimeout(timer); + this.quantityTimers.delete(key); } private clearAllQuantityTimers(): void { - this.quantityTimers.forEach(timer => clearTimeout(timer)); - this.quantityTimers.clear(); + this.quantityTimers.forEach((timer) => clearTimeout(timer)); + this.quantityTimers.clear(); } - } diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html index 1681515..87d7b97 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.html @@ -1,95 +1,119 @@
-
@if (selectedFile()) {
- @if (!isStepFile(selectedFile())) { -
-

{{ 'CALC.STEP_WARNING' | translate }}

-
- } @else { - - - } - + @if (!isStepFile(selectedFile())) { +
+

{{ "CALC.STEP_WARNING" | translate }}

+
+ } @else { + + + } +
} @if (items().length === 0) { - - + + } @if (items().length > 0) { -
- @for (item of items(); track item.file.name; let i = $index) { -
-
- {{ item.file.name }} -
+
+ @for (item of items(); track item.file.name; let i = $index) { +
+
+ {{ + item.file.name + }} +
-
-
-
- - -
- -
- - - -
-
- - -
+
+
+
+ +
- } -
- -
- +
+ + + +
+
- -
+ +
+
+ } +
+ + +
+ + + +
} - @if (items().length === 0 && form.get('itemsTouched')?.value) { -
{{ 'CALC.ERR_FILE_REQUIRED' | translate }}
+ @if (items().length === 0 && form.get("itemsTouched")?.value) { +
{{ "CALC.ERR_FILE_REQUIRED" | translate }}
}

- {{ 'LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX' | translate }} - {{ 'LEGAL.CONSENT.UPLOAD_NOTICE_LINK' | translate }}. + {{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }} + {{ + "LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate + }}.

@@ -100,51 +124,50 @@ [options]="materials()" > - @if (mode() === 'easy') { - + @if (mode() === "easy") { + } @else { - + }
- @if (mode() === 'advanced') { + @if (mode() === "advanced") {
- + - +
- + -
- - -
+
+ + +
- } @if (loading() && uploadProgress() < 100) { -
-
-
-
+
+
+
+
} - {{ 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) + }}
diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss index 94fb022..16ac239 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.scss @@ -1,226 +1,246 @@ -.section { margin-bottom: var(--space-6); } +.section { + margin-bottom: var(--space-6); +} .upload-privacy-note { - margin-top: var(--space-3); - margin-bottom: 0; - font-size: 0.78rem; - color: var(--color-text-muted); - text-align: left; + margin-top: var(--space-3); + margin-bottom: 0; + font-size: 0.78rem; + color: var(--color-text-muted); + text-align: left; } -.grid { - display: grid; - grid-template-columns: 1fr; - gap: var(--space-4); - - @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; } +.grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-4); -.viewer-wrapper { position: relative; margin-bottom: var(--space-4); } + @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; +} + +.viewer-wrapper { + position: relative; + margin-bottom: var(--space-4); +} /* Grid Layout for Files */ .items-grid { - display: grid; - grid-template-columns: 1fr 1fr; /* Force 2 columns on mobile */ - gap: var(--space-2); /* Tighten gap for mobile */ - margin-top: var(--space-4); - margin-bottom: var(--space-4); - - @media(min-width: 640px) { - gap: var(--space-3); - } + display: grid; + grid-template-columns: 1fr 1fr; /* Force 2 columns on mobile */ + gap: var(--space-2); /* Tighten gap for mobile */ + margin-top: var(--space-4); + margin-bottom: var(--space-4); + + @media (min-width: 640px) { + gap: var(--space-3); + } } .file-card { - padding: var(--space-2); /* Reduced from space-3 */ - background: var(--color-neutral-100); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - transition: all 0.2s; - cursor: pointer; - display: flex; - flex-direction: column; - gap: 4px; /* Reduced gap */ - 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); } - &.active { - border-color: var(--color-brand); - background: rgba(250, 207, 10, 0.05); - box-shadow: 0 0 0 1px var(--color-brand); - } + padding: var(--space-2); /* Reduced from space-3 */ + background: var(--color-neutral-100); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + transition: all 0.2s; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 4px; /* Reduced gap */ + 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); + } + &.active { + border-color: var(--color-brand); + background: rgba(250, 207, 10, 0.05); + box-shadow: 0 0 0 1px var(--color-brand); + } } .card-header { - overflow: hidden; - padding-right: 25px; /* Adjusted */ - margin-bottom: 2px; + overflow: hidden; + padding-right: 25px; /* Adjusted */ + margin-bottom: 2px; } -.file-name { - font-weight: 500; - font-size: 0.8rem; /* Smaller font */ - color: var(--color-text); - display: block; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.file-name { + font-weight: 500; + font-size: 0.8rem; /* Smaller font */ + color: var(--color-text); + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .card-body { - display: flex; - align-items: center; - padding-top: 0; + display: flex; + align-items: center; + padding-top: 0; } .card-controls { - display: flex; - align-items: flex-end; /* Align bottom of input and color circle */ - gap: 16px; /* Space between Qty and Color */ - width: 100%; + display: flex; + align-items: flex-end; /* Align bottom of input and color circle */ + gap: 16px; /* Space between Qty and Color */ + width: 100%; } -.qty-group, .color-group { - display: flex; - flex-direction: column; /* Stack label and input */ - align-items: flex-start; - gap: 0px; - - label { - font-size: 0.6rem; - color: var(--color-text-muted); - text-transform: uppercase; - letter-spacing: 0.5px; - font-weight: 600; - margin-bottom: 2px; - } +.qty-group, +.color-group { + display: flex; + flex-direction: column; /* Stack label and input */ + align-items: flex-start; + gap: 0px; + + label { + font-size: 0.6rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 600; + margin-bottom: 2px; + } } .color-group { - align-items: flex-start; /* Align label left */ - /* margin-right removed */ - - /* Override margin in selector for this context */ - ::ng-deep .color-selector-container { - margin-left: 0; - } + align-items: flex-start; /* Align label left */ + /* margin-right removed */ + + /* Override margin in selector for this context */ + ::ng-deep .color-selector-container { + margin-left: 0; + } } .qty-input { - width: 36px; /* Slightly smaller */ - padding: 1px 2px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - text-align: center; - font-size: 0.85rem; - background: white; - height: 24px; /* Explicit height to match color circle somewhat */ - &:focus { outline: none; border-color: var(--color-brand); } + width: 36px; /* Slightly smaller */ + padding: 1px 2px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + text-align: center; + font-size: 0.85rem; + background: white; + height: 24px; /* Explicit height to match color circle somewhat */ + &:focus { + outline: none; + border-color: var(--color-brand); + } } .btn-remove { - position: absolute; - top: 4px; - right: 4px; - width: 18px; - height: 18px; - border-radius: 4px; - border: none; - background: transparent; - color: var(--color-text-muted); - font-weight: bold; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - font-size: 0.8rem; - - &:hover { - background: var(--color-danger-100); - color: var(--color-danger-500); - } + position: absolute; + top: 4px; + right: 4px; + width: 18px; + height: 18px; + border-radius: 4px; + border: none; + background: transparent; + color: var(--color-text-muted); + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + font-size: 0.8rem; + + &:hover { + background: var(--color-danger-100); + color: var(--color-danger-500); + } } /* Prominent Add Button */ .add-more-container { - margin-top: var(--space-2); + margin-top: var(--space-2); } .btn-add-more { - width: 100%; - padding: var(--space-3); - background: var(--color-neutral-800); - color: white; - border: none; - border-radius: var(--radius-md); - font-weight: 600; - font-size: 0.9rem; - cursor: pointer; - transition: all 0.2s; - display: flex; - align-items: center; - justify-content: center; - gap: var(--space-2); - - &:hover { - background: var(--color-neutral-900); - transform: translateY(-1px); - } - &:active { transform: translateY(0); } + width: 100%; + padding: var(--space-3); + background: var(--color-neutral-800); + color: white; + border: none; + border-radius: var(--radius-md); + font-weight: 600; + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + + &:hover { + background: var(--color-neutral-900); + transform: translateY(-1px); + } + &:active { + transform: translateY(0); + } } .checkbox-row { - display: flex; - align-items: center; - gap: var(--space-3); - height: 100%; - padding-top: var(--space-4); - - input[type="checkbox"] { - width: 20px; - height: 20px; - accent-color: var(--color-brand); - } - label { - font-weight: 500; - cursor: pointer; - } + display: flex; + align-items: center; + gap: var(--space-3); + height: 100%; + padding-top: var(--space-4); + + input[type="checkbox"] { + width: 20px; + height: 20px; + accent-color: var(--color-brand); + } + label { + font-weight: 500; + cursor: pointer; + } } /* Progress Bar */ .progress-container { - margin-bottom: var(--space-3); - text-align: center; - width: 100%; + margin-bottom: var(--space-3); + text-align: center; + width: 100%; } .progress-bar { - height: 4px; - background: var(--color-border); - border-radius: 2px; - overflow: hidden; - margin-bottom: 0; - position: relative; - width: 100%; + height: 4px; + background: var(--color-border); + border-radius: 2px; + overflow: hidden; + margin-bottom: 0; + position: relative; + width: 100%; } .progress-fill { - height: 100%; - background: var(--color-brand); + height: 100%; + background: var(--color-brand); } .step-warning { - display: flex; - justify-content: center; - align-items: center; - height: 300px; - background: var(--color-neutral-100); - border: 1px dashed var(--color-border); - border-radius: var(--radius-md); - padding: var(--space-4); - text-align: center; - color: var(--color-text-muted); - font-weight: 500; + display: flex; + justify-content: center; + align-items: center; + height: 300px; + background: var(--color-neutral-100); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-4); + text-align: center; + color: var(--color-text-muted); + font-weight: 500; } diff --git a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts index eb9d39d..b8a023c 100644 --- a/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts +++ b/frontend/src/app/features/calculator/components/upload-form/upload-form.component.ts @@ -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,22 +20,39 @@ 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 { - file: File; - quantity: number; - color: string; - filamentVariantId?: number; + file: File; + quantity: number; + color: string; + filamentVariantId?: number; } @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'); @@ -55,22 +84,22 @@ export class UploadFormComponent implements OnInit { currentMaterialVariants = signal([]); private updateVariants() { - const matCode = this.form.get('material')?.value; - if (matCode && this.fullMaterialOptions.length > 0) { - const found = this.fullMaterialOptions.find(m => m.code === matCode); - this.currentMaterialVariants.set(found ? found.variants : []); - this.syncItemVariantSelections(); - } else { - this.currentMaterialVariants.set([]); - } + const matCode = this.form.get('material')?.value; + if (matCode && this.fullMaterialOptions.length > 0) { + const found = this.fullMaterialOptions.find((m) => m.code === matCode); + this.currentMaterialVariants.set(found ? found.variants : []); + this.syncItemVariantSelections(); + } else { + this.currentMaterialVariants.set([]); + } } acceptedFormats = '.stl,.3mf,.step,.stp'; isStepFile(file: File | null): boolean { - if (!file) return false; - const name = file.name.toLowerCase(); - return name.endsWith('.stl'); + if (!file) return false; + const name = file.name.toLowerCase(); + return name.endsWith('.stl'); } constructor() { @@ -85,78 +114,140 @@ 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 this.form.get('material')?.valueChanges.subscribe(() => { - this.updateVariants(); + this.updateVariants(); }); this.form.get('quality')?.valueChanges.subscribe((quality) => { - if (this.mode() !== 'easy' || this.isPatchingSettings) return; - this.applyAdvancedPresetFromQuality(quality); + if (this.mode() !== 'easy' || this.isPatchingSettings) return; + this.applyAdvancedPresetFromQuality(quality); }); } private applyAdvancedPresetFromQuality(quality: string | null | undefined) { - const normalized = (quality || 'standard').toLowerCase(); + const normalized = (quality || 'standard').toLowerCase(); - const presets: Record = { - 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']; - this.form.patchValue(preset, { emitEvent: false }); + const preset = presets[normalized] || presets['standard']; + this.form.patchValue(preset, { emitEvent: false }); } ngOnInit() { - this.estimator.getOptions().subscribe({ - next: (options: OptionsResponse) => { - this.fullMaterialOptions = options.materials; - this.updateVariants(); // Trigger initial update + this.estimator.getOptions().subscribe({ + next: (options: OptionsResponse) => { + 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(); + 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', }, - 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.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]); - this.setDefaults(); - } - }); + ]); + 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(); + }, + }); } private setDefaults() { - // Set Defaults if available - if (this.materials().length > 0 && !this.form.get('material')?.value) { - this.form.get('material')?.setValue(this.materials()[0].value); - } - 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); - } - 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) { - this.form.get('layerHeight')?.setValue(0.2); // Prefer 0.2 - } - if (this.infillPatterns().length > 0 && !this.form.get('infillPattern')?.value) { - this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value); - } + // Set Defaults if available + if (this.materials().length > 0 && !this.form.get('material')?.value) { + this.form.get('material')?.setValue(this.materials()[0].value); + } + 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); + } + 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 + ) { + this.form.get('layerHeight')?.setValue(0.2); // Prefer 0.2 + } + if ( + this.infillPatterns().length > 0 && + !this.form.get('infillPattern')?.value + ) { + this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value); + } } onFilesDropped(newFiles: File[]) { @@ -165,214 +256,233 @@ export class UploadFormComponent implements OnInit { let hasError = false; for (const file of newFiles) { - if (file.size > MAX_SIZE) { - hasError = true; - } else { - const defaultSelection = this.getDefaultVariantSelection(); - validItems.push({ - file, - quantity: 1, - color: defaultSelection.colorName, - filamentVariantId: defaultSelection.filamentVariantId - }); - } + if (file.size > MAX_SIZE) { + hasError = true; + } else { + const defaultSelection = this.getDefaultVariantSelection(); + validItems.push({ + file, + quantity: 1, + color: defaultSelection.colorName, + filamentVariantId: defaultSelection.filamentVariantId, + }); + } } if (hasError) { - alert(this.translate.instant('CALC.ERR_FILE_TOO_LARGE')); + alert(this.translate.instant('CALC.ERR_FILE_TOO_LARGE')); } if (validItems.length > 0) { - this.items.update(current => [...current, ...validItems]); - this.form.get('itemsTouched')?.setValue(true); - // Auto select last added - this.selectedFile.set(validItems[validItems.length - 1].file); + this.items.update((current) => [...current, ...validItems]); + this.form.get('itemsTouched')?.setValue(true); + // Auto select last added + this.selectedFile.set(validItems[validItems.length - 1].file); } } onAdditionalFilesSelected(event: Event) { - const input = event.target as HTMLInputElement; - if (input.files && input.files.length > 0) { - this.onFilesDropped(Array.from(input.files)); - // Reset input so same files can be selected again if needed - input.value = ''; - } + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + this.onFilesDropped(Array.from(input.files)); + // Reset input so same files can be selected again if needed + input.value = ''; + } } updateItemQuantityByIndex(index: number, quantity: number) { - if (!Number.isInteger(index) || index < 0) return; - const normalizedQty = this.normalizeQuantity(quantity); + if (!Number.isInteger(index) || index < 0) return; + const normalizedQty = this.normalizeQuantity(quantity); - this.items.update(current => { - if (index >= current.length) return current; - const updated = [...current]; - updated[index] = { ...updated[index], quantity: normalizedQty }; - return updated; - }); + this.items.update((current) => { + if (index >= current.length) return current; + const updated = [...current]; + updated[index] = { ...updated[index], quantity: normalizedQty }; + return updated; + }); } updateItemQuantityByName(fileName: string, quantity: number) { - const targetName = this.normalizeFileName(fileName); - const normalizedQty = this.normalizeQuantity(quantity); + const targetName = this.normalizeFileName(fileName); + const normalizedQty = this.normalizeQuantity(quantity); - this.items.update(current => { - let matched = false; - return current.map(item => { - if (!matched && this.normalizeFileName(item.file.name) === targetName) { - matched = true; - return { ...item, quantity: normalizedQty }; - } - return item; - }); + this.items.update((current) => { + let matched = false; + return current.map((item) => { + if (!matched && this.normalizeFileName(item.file.name) === targetName) { + matched = true; + return { ...item, quantity: normalizedQty }; + } + return item; }); + }); } selectFile(file: File) { - if (this.selectedFile() === file) { - // toggle off? no, keep active - } else { - this.selectedFile.set(file); - } + if (this.selectedFile() === file) { + // toggle off? no, keep active + } else { + this.selectedFile.set(file); + } } // Helper to get color of currently selected file getSelectedFileColor(): string { - const file = this.selectedFile(); - if (!file) return '#facf0a'; // Default + const file = this.selectedFile(); + if (!file) return '#facf0a'; // Default - 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); - if (found) return found.hexColor; - } - return getColorHex(item.color); + 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); + if (found) return found.hexColor; } - return '#facf0a'; + return getColorHex(item.color); + } + return '#facf0a'; } updateItemQuantity(index: number, event: Event) { - const input = event.target as HTMLInputElement; - const parsed = parseInt(input.value, 10); - const quantity = Number.isFinite(parsed) ? parsed : 1; - this.updateItemQuantityByIndex(index, quantity); + const input = event.target as HTMLInputElement; + const parsed = parseInt(input.value, 10); + const quantity = Number.isFinite(parsed) ? parsed : 1; + 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 => { - const updated = [...current]; - updated[index] = { ...updated[index], color: colorName, filamentVariantId }; - return updated; - }); + 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, + }; + return updated; + }); } removeItem(index: number) { - this.items.update(current => { - const updated = [...current]; - const removed = updated.splice(index, 1)[0]; - if (this.selectedFile() === removed.file) { - this.selectedFile.set(null); - } - return updated; - }); + this.items.update((current) => { + const updated = [...current]; + const removed = updated.splice(index, 1)[0]; + if (this.selectedFile() === removed.file) { + this.selectedFile.set(null); + } + return updated; + }); } setFiles(files: File[]) { - const validItems: FormItem[] = []; - const defaultSelection = this.getDefaultVariantSelection(); - for (const file of files) { - validItems.push({ - file, - quantity: 1, - color: defaultSelection.colorName, - filamentVariantId: defaultSelection.filamentVariantId - }); - } + const validItems: FormItem[] = []; + const defaultSelection = this.getDefaultVariantSelection(); + for (const file of files) { + validItems.push({ + file, + quantity: 1, + color: defaultSelection.colorName, + filamentVariantId: defaultSelection.filamentVariantId, + }); + } - if (validItems.length > 0) { - this.items.set(validItems); - this.form.get('itemsTouched')?.setValue(true); - // Auto select last added - this.selectedFile.set(validItems[validItems.length - 1].file); - } + if (validItems.length > 0) { + this.items.set(validItems); + this.form.get('itemsTouched')?.setValue(true); + // Auto select last added + this.selectedFile.set(validItems[validItems.length - 1].file); + } } - private getDefaultVariantSelection(): { colorName: string; filamentVariantId?: number } { - const vars = this.currentMaterialVariants(); - if (vars && vars.length > 0) { - const preferred = vars.find(v => !v.isOutOfStock) || vars[0]; - return { - colorName: preferred.colorName, - filamentVariantId: preferred.id - }; - } - return { colorName: 'Black' }; + private getDefaultVariantSelection(): { + colorName: string; + filamentVariantId?: number; + } { + const vars = this.currentMaterialVariants(); + if (vars && vars.length > 0) { + const preferred = vars.find((v) => !v.isOutOfStock) || vars[0]; + return { + colorName: preferred.colorName, + filamentVariantId: preferred.id, + }; + } + return { colorName: 'Black' }; } private syncItemVariantSelections(): void { - const vars = this.currentMaterialVariants(); - if (!vars || vars.length === 0) { - return; - } + const vars = this.currentMaterialVariants(); + if (!vars || vars.length === 0) { + 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) - : null; - const byColor = vars.find(v => v.colorName === item.color); - const selected = byId || byColor || fallback; - return { - ...item, - color: selected.colorName, - filamentVariantId: selected.id - }; - })); + 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 selected = byId || byColor || fallback; + return { + ...item, + color: selected.colorName, + filamentVariantId: selected.id, + }; + }), + ); } patchSettings(settings: any) { - if (!settings) return; - // settings object matches keys in our form? - // Session has: materialCode, etc. derived from QuoteSession entity properties - // We need to map them if names differ. + if (!settings) return; + // settings object matches keys in our form? + // Session has: materialCode, etc. derived from QuoteSession entity properties + // We need to map them if names differ. - const patch: any = {}; - if (settings.materialCode) patch.material = settings.materialCode; + const patch: any = {}; + if (settings.materialCode) patch.material = settings.materialCode; - // Heuristic for Quality if not explicitly stored as "draft/standard/high" - // But we stored it in session creation? - // QuoteSession entity does NOT store "quality" string directly, only layerHeight/infill. - // So we might need to deduce it or just set Custom/Advanced. - // But for Easy mode, we want to show "Standard" etc. + // Heuristic for Quality if not explicitly stored as "draft/standard/high" + // But we stored it in session creation? + // QuoteSession entity does NOT store "quality" string directly, only layerHeight/infill. + // So we might need to deduce it or just set Custom/Advanced. + // But for Easy mode, we want to show "Standard" etc. - // Actually, let's look at what we have in QuoteSession. - // layerHeightMm, infillPercent, etc. - // If we are in Easy mode, we might just set the "quality" dropdown to match approx? - // Or if we stored "quality" in notes or separate field? We didn't. + // Actually, let's look at what we have in QuoteSession. + // layerHeightMm, infillPercent, etc. + // If we are in Easy mode, we might just set the "quality" dropdown to match approx? + // Or if we stored "quality" in notes or separate field? We didn't. - // Let's try to reverse map or defaults. - if (settings.layerHeightMm) { - if (settings.layerHeightMm >= 0.24) patch.quality = 'draft'; - else if (settings.layerHeightMm <= 0.12) patch.quality = 'extra_fine'; - else patch.quality = 'standard'; + // Let's try to reverse map or defaults. + if (settings.layerHeightMm) { + if (settings.layerHeightMm >= 0.24) patch.quality = 'draft'; + else if (settings.layerHeightMm <= 0.12) patch.quality = 'extra_fine'; + else patch.quality = 'standard'; - patch.layerHeight = settings.layerHeightMm; - } + patch.layerHeight = settings.layerHeightMm; + } - 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.notes) patch.notes = settings.notes; + 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.notes) patch.notes = settings.notes; - this.isPatchingSettings = true; - this.form.patchValue(patch, { emitEvent: false }); - this.isPatchingSettings = false; + this.isPatchingSettings = true; + this.form.patchValue(patch, { emitEvent: false }); + this.isPatchingSettings = false; } onSubmit() { @@ -380,20 +490,29 @@ 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 => { - const control = this.form.get(key); - if (control?.invalid) { - console.log('Invalid Control:', key, control.errors, 'Value:', control.value); - } + 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, + ); + } }); this.form.markAllAsTouched(); this.form.get('itemsTouched')?.setValue(true); @@ -401,17 +520,13 @@ export class UploadFormComponent implements OnInit { } private normalizeQuantity(quantity: number): number { - if (!Number.isFinite(quantity) || quantity < 1) { - return 1; - } - return Math.floor(quantity); + if (!Number.isFinite(quantity) || quantity < 1) { + return 1; + } + return Math.floor(quantity); } private normalizeFileName(fileName: string): string { - return (fileName || '') - .split(/[\\/]/) - .pop() - ?.trim() - .toLowerCase() ?? ''; + return (fileName || '').split(/[\\/]/).pop()?.trim().toLowerCase() ?? ''; } } diff --git a/frontend/src/app/features/calculator/components/user-details/user-details.component.html b/frontend/src/app/features/calculator/components/user-details/user-details.component.html index f6b282f..9bf8332 100644 --- a/frontend/src/app/features/calculator/components/user-details/user-details.component.html +++ b/frontend/src/app/features/calculator/components/user-details/user-details.component.html @@ -3,25 +3,34 @@
-
- + [error]=" + form.get('name')?.invalid && form.get('name')?.touched + ? ('COMMON.REQUIRED' | translate) + : null + " + >
- + [error]=" + form.get('surname')?.invalid && form.get('surname')?.touched + ? ('COMMON.REQUIRED' | translate) + : null + " + >
@@ -29,87 +38,117 @@
- + [error]=" + form.get('email')?.invalid && form.get('email')?.touched + ? ('COMMON.INVALID_EMAIL' | translate) + : null + " + >
- + [error]=" + form.get('phone')?.invalid && form.get('phone')?.touched + ? ('COMMON.REQUIRED' | translate) + : null + " + >
- + [error]=" + form.get('address')?.invalid && form.get('address')?.touched + ? ('COMMON.REQUIRED' | translate) + : null + " + >
- + [error]=" + form.get('zip')?.invalid && form.get('zip')?.touched + ? ('COMMON.REQUIRED' | translate) + : null + " + >
- + [error]=" + form.get('city')?.invalid && form.get('city')?.touched + ? ('COMMON.REQUIRED' | translate) + : null + " + >
@@ -117,30 +156,38 @@
-
{{ item.fileName }} - {{ item.material }} - {{ item.color || ('USER_DETAILS.DEFAULT_COLOR' | translate) }} + {{ item.material }} - + {{ + item.color || ("USER_DETAILS.DEFAULT_COLOR" | translate) + }}
x{{ item.quantity }}
- {{ (item.unitPrice * item.quantity) | currency:'CHF' }} + {{ + item.unitPrice * item.quantity | currency: "CHF" + }} - {{ item.unitPrice | currency:'CHF' }} {{ 'CHECKOUT.PER_PIECE' | translate }} + {{ item.unitPrice | currency: "CHF" }} + {{ "CHECKOUT.PER_PIECE" | translate }}
-
+
- {{ 'QUOTE.TOTAL' | translate }} - {{ quote()!.totalPrice | currency:'CHF' }} + {{ "QUOTE.TOTAL" | translate }} + {{ + quote()!.totalPrice | currency: "CHF" + }}
-
diff --git a/frontend/src/app/features/calculator/components/user-details/user-details.component.scss b/frontend/src/app/features/calculator/components/user-details/user-details.component.scss index 16dd410..a32916a 100644 --- a/frontend/src/app/features/calculator/components/user-details/user-details.component.scss +++ b/frontend/src/app/features/calculator/components/user-details/user-details.component.scss @@ -6,15 +6,15 @@ display: flex; flex-wrap: wrap; margin: 0 -0.5rem; - - > [class*='col-'] { + + > [class*="col-"] { padding: 0 0.5rem; } } .col-md-6 { width: 100%; - + @media (min-width: 768px) { width: 50%; } @@ -22,7 +22,7 @@ .col-md-4 { width: 100%; - + @media (min-width: 768px) { width: 33.333%; } @@ -30,7 +30,7 @@ .col-md-8 { width: 100%; - + @media (min-width: 768px) { width: 66.666%; } @@ -55,7 +55,7 @@ line-height: 1.4; } - input[type='checkbox'] { + input[type="checkbox"] { margin-top: 0.2rem; } @@ -84,7 +84,7 @@ justify-content: space-between; padding: 0.5rem 0; border-bottom: 1px solid rgba(255, 255, 255, 0.1); - + &:last-child { border-bottom: none; } @@ -134,8 +134,8 @@ margin-top: 1rem; padding-top: 1rem; 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 } } diff --git a/frontend/src/app/features/calculator/components/user-details/user-details.component.ts b/frontend/src/app/features/calculator/components/user-details/user-details.component.ts index dab074a..674917c 100644 --- a/frontend/src/app/features/calculator/components/user-details/user-details.component.ts +++ b/frontend/src/app/features/calculator/components/user-details/user-details.component.ts @@ -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(); @@ -31,17 +43,17 @@ export class UserDetailsComponent { address: ['', Validators.required], zip: ['', Validators.required], city: ['', Validators.required], - acceptLegal: [false, Validators.requiredTrue] + acceptLegal: [false, Validators.requiredTrue], }); } onSubmit() { if (this.form.valid) { this.submitting.set(true); - + const orderData = { customer: this.form.value, - quote: this.quote() + quote: this.quote(), }; // Simulate API delay diff --git a/frontend/src/app/features/calculator/services/quote-estimator.service.ts b/frontend/src/app/features/calculator/services/quote-estimator.service.ts index 9c65986..85d1d44 100644 --- a/frontend/src/app/features/calculator/services/quote-estimator.service.ts +++ b/frontend/src/app/features/calculator/services/quote-estimator.service.ts @@ -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; @@ -68,306 +73,400 @@ interface BackendQuoteResult { // Options Interfaces export interface MaterialOption { - code: string; - label: string; - variants: VariantOption[]; + code: string; + label: string; + variants: VariantOption[]; } export interface VariantOption { - id: number; - name: string; - colorName: string; - hexColor: string; - finishType: string; - stockSpools: number; - stockFilamentGrams: number; - isOutOfStock: boolean; + id: number; + name: string; + colorName: string; + hexColor: string; + finishType: string; + stockSpools: number; + stockFilamentGrams: number; + isOutOfStock: boolean; } export interface QualityOption { - id: string; - label: string; + id: string; + label: string; } export interface InfillOption { - id: string; - label: string; + id: string; + label: string; } export interface NumericOption { - value: number; - label: string; + value: number; + label: string; } export interface OptionsResponse { - materials: MaterialOption[]; - qualities: QualityOption[]; - infillPatterns: InfillOption[]; - layerHeights: NumericOption[]; - nozzleDiameters: NumericOption[]; + materials: MaterialOption[]; + qualities: QualityOption[]; + infillPatterns: InfillOption[]; + layerHeights: NumericOption[]; + nozzleDiameters: NumericOption[]; } // UI Option for Select Component export interface SimpleOption { - value: string | number; - label: string; + value: string | number; + label: string; } @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class QuoteEstimatorService { private http = inject(HttpClient); private buildEasyModePreset(quality: string | undefined): { - quality: string; - layerHeight: number; - infillDensity: number; - infillPattern: string; - nozzleDiameter: number; + quality: string; + layerHeight: number; + infillDensity: number; + infillPattern: string; + nozzleDiameter: number; } { - const normalized = (quality || 'standard').toLowerCase(); - - // Legacy alias support. - if (normalized === 'high' || normalized === 'extra_fine') { - return { - quality: 'extra_fine', - layerHeight: 0.12, - infillDensity: 20, - infillPattern: 'grid', - nozzleDiameter: 0.4 - }; - } - - if (normalized === 'draft') { - return { - quality: 'extra_fine', - layerHeight: 0.24, - infillDensity: 12, - infillPattern: 'grid', - nozzleDiameter: 0.4 - }; - } + const normalized = (quality || 'standard').toLowerCase(); + // Legacy alias support. + if (normalized === 'high' || normalized === 'extra_fine') { return { - quality: 'standard', - layerHeight: 0.2, - infillDensity: 15, - infillPattern: 'grid', - nozzleDiameter: 0.4 + quality: 'extra_fine', + layerHeight: 0.12, + infillDensity: 20, + infillPattern: 'grid', + nozzleDiameter: 0.4, }; + } + + if (normalized === 'draft') { + return { + quality: 'extra_fine', + layerHeight: 0.24, + infillDensity: 12, + infillPattern: 'grid', + nozzleDiameter: 0.4, + }; + } + + return { + quality: 'standard', + layerHeight: 0.2, + infillDensity: 15, + infillPattern: 'grid', + nozzleDiameter: 0.4, + }; } - + getOptions(): Observable { - console.log('QuoteEstimatorService: Requesting options...'); - const headers: any = {}; - return this.http.get(`${environment.apiUrl}/api/calculator/options`, { headers }).pipe( - tap({ - next: (res) => console.log('QuoteEstimatorService: Options loaded', res), - error: (err) => console.error('QuoteEstimatorService: Options failed', err) - }) + console.log('QuoteEstimatorService: Requesting options...'); + const headers: any = {}; + return this.http + .get(`${environment.apiUrl}/api/calculator/options`, { + headers, + }) + .pipe( + tap({ + next: (res) => + console.log('QuoteEstimatorService: Options loaded', res), + error: (err) => + console.error('QuoteEstimatorService: Options failed', err), + }), ); } // NEW METHODS for Order Flow - + getQuoteSession(sessionId: string): Observable { - const headers: any = {}; - return this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { headers }); + const headers: any = {}; + return this.http.get( + `${environment.apiUrl}/api/quote-sessions/${sessionId}`, + { headers }, + ); } updateLineItem(lineItemId: string, changes: any): Observable { - const headers: any = {}; - return this.http.patch(`${environment.apiUrl}/api/quote-sessions/line-items/${lineItemId}`, changes, { headers }); + const headers: any = {}; + return this.http.patch( + `${environment.apiUrl}/api/quote-sessions/line-items/${lineItemId}`, + changes, + { headers }, + ); } createOrder(sessionId: string, orderDetails: any): Observable { - const headers: any = {}; - return this.http.post(`${environment.apiUrl}/api/orders/from-quote/${sessionId}`, orderDetails, { headers }); + const headers: any = {}; + return this.http.post( + `${environment.apiUrl}/api/orders/from-quote/${sessionId}`, + orderDetails, + { headers }, + ); } getOrder(orderId: string): Observable { - const headers: any = {}; - return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, { headers }); + const headers: any = {}; + return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, { + headers, + }); } reportPayment(orderId: string, method: string): Observable { - const headers: any = {}; - return this.http.post(`${environment.apiUrl}/api/orders/${orderId}/payments/report`, { method }, { headers }); + const headers: any = {}; + return this.http.post( + `${environment.apiUrl}/api/orders/${orderId}/payments/report`, + { method }, + { headers }, + ); } getOrderInvoice(orderId: string): Observable { - const headers: any = {}; - return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/invoice`, { - headers, - responseType: 'blob' - }); + const headers: any = {}; + return this.http.get( + `${environment.apiUrl}/api/orders/${orderId}/invoice`, + { + headers, + responseType: 'blob', + }, + ); } getOrderConfirmation(orderId: string): Observable { - const headers: any = {}; - return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/confirmation`, { - headers, - responseType: 'blob' - }); + const headers: any = {}; + return this.http.get( + `${environment.apiUrl}/api/orders/${orderId}/confirmation`, + { + headers, + responseType: 'blob', + }, + ); } getTwintPayment(orderId: string): Observable { - const headers: any = {}; - return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/twint`, { headers }); + const headers: any = {}; + return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/twint`, { + headers, + }); } - + calculate(request: QuoteRequest): Observable { console.log('QuoteEstimatorService: Calculating quote...', request); if (request.items.length === 0) { - console.warn('QuoteEstimatorService: No items to calculate'); - return of(); + console.warn('QuoteEstimatorService: No items to calculate'); + return of(); } - - return new Observable(observer => { - // 1. Create Session first - const headers: any = {}; - this.http.post(`${environment.apiUrl}/api/quote-sessions`, {}, { headers }).subscribe({ - next: (sessionRes) => { - const sessionId = sessionRes.id; - const sessionSetupCost = sessionRes.setupCostChf || 0; - - // 2. Upload files to this session - const totalItems = request.items.length; - const allProgress: number[] = new Array(totalItems).fill(0); - const finalResponses: any[] = []; - let completedRequests = 0; + return new Observable((observer) => { + // 1. Create Session first + const headers: any = {}; - const checkCompletion = () => { - const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems); - observer.next(avg); - - if (completedRequests === totalItems) { - finalize(finalResponses, sessionSetupCost, sessionId); - } - }; + this.http + .post(`${environment.apiUrl}/api/quote-sessions`, {}, { headers }) + .subscribe({ + next: (sessionRes) => { + const sessionId = sessionRes.id; + const sessionSetupCost = sessionRes.setupCostChf || 0; - request.items.forEach((item, index) => { - const formData = new FormData(); - formData.append('file', item.file); + // 2. Upload files to this session + const totalItems = request.items.length; + const allProgress: number[] = new Array(totalItems).fill(0); + const finalResponses: any[] = []; + let completedRequests = 0; - const easyPreset = request.mode === 'easy' - ? this.buildEasyModePreset(request.quality) - : null; - - const settings = { - 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 - }; - - const settingsBlob = new Blob([JSON.stringify(settings)], { type: 'application/json' }); - formData.append('settings', settingsBlob); + const checkCompletion = () => { + const avg = Math.round( + allProgress.reduce((a, b) => a + b, 0) / totalItems, + ); + observer.next(avg); - this.http.post(`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items`, formData, { - headers, - reportProgress: true, - observe: 'events' - }).subscribe({ - next: (event) => { - 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 }; - completedRequests++; - checkCompletion(); - } - }, - error: (err) => { - console.error('Item upload failed', err); - finalResponses[index] = { success: false, fileName: item.file.name }; - completedRequests++; - checkCompletion(); - } - }); + if (completedRequests === totalItems) { + finalize(finalResponses, sessionSetupCost, sessionId); + } + }; + + request.items.forEach((item, index) => { + const formData = new FormData(); + formData.append('file', item.file); + + const easyPreset = + request.mode === 'easy' + ? this.buildEasyModePreset(request.quality) + : null; + + const settings = { + 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, + }; + + const settingsBlob = new Blob([JSON.stringify(settings)], { + type: 'application/json', + }); + formData.append('settings', settingsBlob); + + this.http + .post( + `${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items`, + formData, + { + headers, + reportProgress: true, + observe: 'events', + }, + ) + .subscribe({ + next: (event) => { + 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, + }; + completedRequests++; + checkCompletion(); + } + }, + error: (err) => { + console.error('Item upload failed', err); + 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'); - } + }); + }, + 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(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { headers }).subscribe({ - next: (sessionData) => { - observer.next(100); - const result = this.mapSessionToQuoteResult(sessionData); - result.notes = request.notes; - observer.next(result); - observer.complete(); - }, - error: (err) => { - console.error('Failed to fetch final session calculation', err); - observer.error('Failed to calculate final quote'); - } - }); - }; + const finalize = ( + responses: any[], + setupCost: number, + sessionId: string, + ) => { + this.http + .get(`${environment.apiUrl}/api/quote-sessions/${sessionId}`, { + headers, + }) + .subscribe({ + next: (sessionData) => { + observer.next(100); + const result = this.mapSessionToQuoteResult(sessionData); + result.notes = request.notes; + observer.next(result); + observer.complete(); + }, + 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}) { - this.pendingConsultation.set(data); + setPendingConsultation(data: { files: File[]; message: string }) { + this.pendingConsultation.set(data); } getPendingConsultation() { - const data = this.pendingConsultation(); - this.pendingConsultation.set(null); // Clear after reading - return data; + const data = this.pendingConsultation(); + this.pendingConsultation.set(null); // Clear after reading + return data; } // Session File Retrieval getLineItemContent(sessionId: string, lineItemId: string): Observable { - const headers: any = {}; - return this.http.get(`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/content`, { - headers, - responseType: 'blob' - }); + const headers: any = {}; + return this.http.get( + `${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}/content`, + { + headers, + 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 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, + ); - return { - sessionId: session.id, - items: items.map((item: any) => ({ - id: item.id, - fileName: item.originalFilename, - unitPrice: item.unitPriceChf, - unitTime: item.printTimeSeconds, - unitWeight: item.materialGrams, - quantity: item.quantity, - material: session.materialCode, // Assumption: session has one material for all? or items have it? - // Backend model QuoteSession has materialCode. - // But line items might have different colors. - color: item.colorCode, - filamentVariantId: item.filamentVariantId - })), + return { + sessionId: session.id, + items: items.map((item: any) => ({ + id: item.id, + fileName: item.originalFilename, + unitPrice: item.unitPriceChf, + unitTime: item.printTimeSeconds, + unitWeight: item.materialGrams, + quantity: item.quantity, + material: session.materialCode, // Assumption: session has one material for all? or items have it? + // Backend model QuoteSession has materialCode. + // But line items might have different colors. + color: item.colorCode, + 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 - }; + totalTimeMinutes: Math.ceil((totalTime % 3600) / 60), + totalWeight: Math.ceil(totalWeight), + notes: session.notes, + }; } } diff --git a/frontend/src/app/features/checkout/checkout.component.html b/frontend/src/app/features/checkout/checkout.component.html index b335cf9..45de894 100644 --- a/frontend/src/app/features/checkout/checkout.component.html +++ b/frontend/src/app/features/checkout/checkout.component.html @@ -1,11 +1,10 @@
-

{{ 'CHECKOUT.TITLE' | translate }}

+

{{ "CHECKOUT.TITLE" | translate }}

- +
-
@@ -14,50 +13,105 @@
-
-

{{ 'CHECKOUT.CONTACT_INFO' | translate }}

+

{{ "CHECKOUT.CONTACT_INFO" | translate }}

- - + +
-

{{ 'CHECKOUT.BILLING_ADDR' | translate }}

+

{{ "CHECKOUT.BILLING_ADDR" | translate }}

- + (selectionChange)="setCustomerType($event)" + > - +
- - + +
- - + +
- - - + + +
- - - + + +
@@ -65,60 +119,108 @@
- -
-

{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}

+ +
+

{{ "CHECKOUT.SHIPPING_ADDR" | translate }}

-
- - -
- -
- - +
+ +
- - +
+ + +
+ + +
- - - + + +
@@ -126,53 +228,60 @@
-

{{ 'CHECKOUT.SUMMARY_TITLE' | translate }}

+

{{ "CHECKOUT.SUMMARY_TITLE" | translate }}

- +
-
-
- {{ item.originalFilename }} -
- {{ 'CHECKOUT.QTY' | translate }}: {{ item.quantity }} - -
-
- {{ (item.printTimeSeconds / 3600) | number:'1.1-1' }}h | {{ item.materialGrams | number:'1.0-0' }}g -
-
-
- - {{ (item.unitPriceChf * item.quantity) | currency:'CHF' }} - - - {{ item.unitPriceChf | currency:'CHF' }} {{ 'CHECKOUT.PER_PIECE' | translate }} - -
-
+
+
+ {{ item.originalFilename }} +
+ {{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }} + +
+
+ {{ item.printTimeSeconds / 3600 | number: "1.1-1" }}h | + {{ item.materialGrams | number: "1.0-0" }}g +
+
+
+ + {{ item.unitPriceChf * item.quantity | currency: "CHF" }} + + + {{ item.unitPriceChf | currency: "CHF" }} + {{ "CHECKOUT.PER_PIECE" | translate }} + +
+
- +
- {{ 'CHECKOUT.SUBTOTAL' | translate }} - {{ session.itemsTotalChf | currency:'CHF' }} + {{ "CHECKOUT.SUBTOTAL" | translate }} + {{ session.itemsTotalChf | currency: "CHF" }}
- {{ 'CHECKOUT.SETUP_FEE' | translate }} - {{ session.session.setupCostChf | currency:'CHF' }} + {{ "CHECKOUT.SETUP_FEE" | translate }} + {{ session.session.setupCostChf | currency: "CHF" }}
- {{ 'CHECKOUT.SHIPPING' | translate }} - {{ session.shippingCostChf | currency:'CHF' }} + {{ "CHECKOUT.SHIPPING" | translate }} + {{ session.shippingCostChf | currency: "CHF" }}
- {{ 'CHECKOUT.TOTAL' | translate }} - {{ session.grandTotalChf | currency:'CHF' }} + {{ "CHECKOUT.TOTAL" | translate }} + {{ session.grandTotalChf | currency: "CHF" }}
-
diff --git a/frontend/src/app/features/checkout/checkout.component.scss b/frontend/src/app/features/checkout/checkout.component.scss index d8383a9..dedee67 100644 --- a/frontend/src/app/features/checkout/checkout.component.scss +++ b/frontend/src/app/features/checkout/checkout.component.scss @@ -1,6 +1,6 @@ -.hero { +.hero { padding: var(--space-8) 0; - text-align: center; + text-align: center; .section-title { font-size: 2.5rem; @@ -25,7 +25,7 @@ margin-bottom: var(--space-6); padding-bottom: var(--space-4); border-bottom: 1px solid var(--color-border); - + h3 { font-size: 1.25rem; font-weight: 600; @@ -39,10 +39,12 @@ flex-direction: column; 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 { @@ -53,7 +55,7 @@ display: grid; grid-template-columns: 1.5fr 2fr 1fr; gap: var(--space-4); - + @media (max-width: 768px) { grid-template-columns: 1fr; } @@ -120,7 +122,7 @@ app-toggle-selector.user-type-selector-compact { &:checked ~ .checkmark { background-color: var(--color-brand); border-color: var(--color-brand); - + &:after { display: block; } @@ -197,12 +199,16 @@ 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; - + .item-name { display: block; font-weight: 600; @@ -218,7 +224,7 @@ app-toggle-selector.user-type-selector-compact { gap: var(--space-2); font-size: 0.85rem; color: var(--color-text-muted); - + .color-dot { width: 14px; height: 14px; @@ -227,11 +233,11 @@ app-toggle-selector.user-type-selector-compact { border: 1px solid var(--color-border); } } - + .item-specs-sub { font-size: 0.8rem; - color: var(--color-text-muted); - margin-top: 2px; + color: var(--color-text-muted); + margin-top: 2px; } } @@ -282,7 +288,7 @@ app-toggle-selector.user-type-selector-compact { .actions { margin-top: var(--space-8); - + app-button { width: 100%; } @@ -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); +} diff --git a/frontend/src/app/features/checkout/checkout.component.ts b/frontend/src/app/features/checkout/checkout.component.ts index 0bb9ea0..6d853cc 100644 --- a/frontend/src/app/features/checkout/checkout.component.ts +++ b/frontend/src/app/features/checkout/checkout.component.ts @@ -1,29 +1,37 @@ 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({ selector: 'app-checkout', standalone: true, imports: [ - CommonModule, - ReactiveFormsModule, + CommonModule, + ReactiveFormsModule, TranslateModule, 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() { @@ -49,22 +57,22 @@ export class CheckoutComponent implements OnInit { email: ['', [Validators.required, Validators.email]], phone: ['', Validators.required], customerType: ['PRIVATE', Validators.required], // Default to PRIVATE - + shippingSameAsBilling: [true], acceptLegal: [false, Validators.requiredTrue], - + billingAddress: this.fb.group({ firstName: ['', Validators.required], lastName: ['', Validators.required], - companyName: [''], + companyName: [''], referencePerson: [''], addressLine1: ['', Validators.required], addressLine2: [''], zip: ['', Validators.required], city: ['', Validators.required], - countryCode: ['CH', Validators.required] + countryCode: ['CH', Validators.required], }), - + shippingAddress: this.fb.group({ firstName: [''], lastName: [''], @@ -74,8 +82,8 @@ export class CheckoutComponent implements OnInit { addressLine2: [''], zip: [''], city: [''], - countryCode: ['CH'] - }) + countryCode: ['CH'], + }), }); } @@ -86,13 +94,13 @@ export class CheckoutComponent implements OnInit { setCustomerType(type: string) { this.checkoutForm.patchValue({ customerType: type }); const isCompany = type === 'BUSINESS'; - + const billingGroup = this.checkoutForm.get('billingAddress') as FormGroup; const companyControl = billingGroup.get('companyName'); const referenceControl = billingGroup.get('referencePerson'); const firstNameControl = billingGroup.get('firstName'); const lastNameControl = billingGroup.get('lastName'); - + if (isCompany) { companyControl?.setValidators([Validators.required]); referenceControl?.setValidators([Validators.required]); @@ -111,43 +119,47 @@ 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'; this.router.navigate(['/']); // Redirect if no session return; } - + this.loadSessionDetails(); }); // Toggle shipping validation based on checkbox - this.checkoutForm.get('shippingSameAsBilling')?.valueChanges.subscribe(isSame => { - const shippingGroup = this.checkoutForm.get('shippingAddress') as FormGroup; - if (isSame) { - shippingGroup.disable(); - } else { - shippingGroup.enable(); - } - }); - + this.checkoutForm + .get('shippingSameAsBilling') + ?.valueChanges.subscribe((isSame) => { + const shippingGroup = this.checkoutForm.get( + 'shippingAddress', + ) as FormGroup; + if (isSame) { + shippingGroup.disable(); + } else { + shippingGroup.enable(); + } + }); + // Initial state this.checkoutForm.get('shippingAddress')?.disable(); } loadSessionDetails() { - if (!this.sessionId) return; // Ensure sessionId is present before fetching - this.quoteService.getQuoteSession(this.sessionId).subscribe({ - next: (session) => { - this.quoteSession.set(session); - console.log('Loaded session:', session); - }, - error: (err) => { - console.error('Failed to load session', err); - this.error = 'CHECKOUT.ERR_LOAD_SESSION'; - } - }); + if (!this.sessionId) return; // Ensure sessionId is present before fetching + this.quoteService.getQuoteSession(this.sessionId).subscribe({ + next: (session) => { + this.quoteSession.set(session); + console.log('Loaded session:', session); + }, + error: (err) => { + console.error('Failed to load session', err); + this.error = 'CHECKOUT.ERR_LOAD_SESSION'; + }, + }); } onSubmit() { @@ -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,23 +191,25 @@ export class CheckoutComponent implements OnInit { addressLine2: formVal.billingAddress.addressLine2, zip: formVal.billingAddress.zip, city: formVal.billingAddress.city, - countryCode: formVal.billingAddress.countryCode - }, - shippingAddress: formVal.shippingSameAsBilling ? null : { - firstName: formVal.shippingAddress.firstName, - lastName: formVal.shippingAddress.lastName, - companyName: formVal.shippingAddress.companyName, - contactPerson: formVal.shippingAddress.referencePerson, - addressLine1: formVal.shippingAddress.addressLine1, - addressLine2: formVal.shippingAddress.addressLine2, - zip: formVal.shippingAddress.zip, - city: formVal.shippingAddress.city, - countryCode: formVal.shippingAddress.countryCode + countryCode: formVal.billingAddress.countryCode, }, + shippingAddress: formVal.shippingSameAsBilling + ? null + : { + firstName: formVal.shippingAddress.firstName, + lastName: formVal.shippingAddress.lastName, + companyName: formVal.shippingAddress.companyName, + contactPerson: formVal.shippingAddress.referencePerson, + addressLine1: formVal.shippingAddress.addressLine1, + addressLine2: formVal.shippingAddress.addressLine2, + zip: formVal.shippingAddress.zip, + city: formVal.shippingAddress.city, + 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'; - } + }, }); } } diff --git a/frontend/src/app/features/contact/components/contact-form/contact-form.component.html b/frontend/src/app/features/contact/components/contact-form/contact-form.component.html index 6e0964b..1e746f8 100644 --- a/frontend/src/app/features/contact/components/contact-form/contact-form.component.html +++ b/frontend/src/app/features/contact/components/contact-form/contact-form.component.html @@ -1,10 +1,13 @@ @if (sent()) { - + } @else {
- + + +
- -

{{ 'CONTACT.UPLOAD_HINT' | translate }}

+ +

{{ "CONTACT.UPLOAD_HINT" | translate }}

- {{ 'LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX' | translate }} - {{ 'LEGAL.CONSENT.UPLOAD_NOTICE_LINK' | translate }}. + {{ "LEGAL.CONSENT.UPLOAD_NOTICE_PREFIX" | translate }} + {{ + "LEGAL.CONSENT.UPLOAD_NOTICE_LINK" | translate + }}.

-
- -

{{ 'CONTACT.DROP_FILES' | translate }}

+
+ +

{{ "CONTACT.DROP_FILES" | translate }}

@@ -65,39 +118,80 @@ type="button" class="remove-btn" (click)="removeFile(i)" - [attr.aria-label]="'CONTACT.REMOVE_FILE' | translate">× - - -
- {{ 'CONTACT.FILE_TYPE_PDF' | translate }} - {{ 'CONTACT.FILE_TYPE_3D' | translate }} - {{ 'CONTACT.FILE_TYPE_DOC' | translate }} - {{ 'CONTACT.FILE_TYPE_FILE' | translate }} + [attr.aria-label]="'CONTACT.REMOVE_FILE' | translate" + > + × + + + +
+ {{ + "CONTACT.FILE_TYPE_PDF" | translate + }} + {{ + "CONTACT.FILE_TYPE_3D" | translate + }} + {{ + "CONTACT.FILE_TYPE_DOC" | translate + }} + {{ + "CONTACT.FILE_TYPE_FILE" | translate + }} +
+
+ {{ file.file.name }}
-
{{ file.file.name }}
+ -
-
-
-
-

{{ 'HOME.SEC_CAP_TITLE' | translate }}

-

- {{ 'HOME.SEC_CAP_SUBTITLE' | translate }} -

+
+
+
+
+

{{ "HOME.SEC_CAP_TITLE" | translate }}

+

+ {{ "HOME.SEC_CAP_SUBTITLE" | translate }} +

+
+
+ +
+
-
- -
- -
-

{{ 'HOME.CAP_1_TITLE' | translate }}

-

{{ 'HOME.CAP_1_TEXT' | translate }}

-
- -
- -
-

{{ 'HOME.CAP_2_TITLE' | translate }}

-

{{ 'HOME.CAP_2_TEXT' | translate }}

-
- -
- -
-

{{ 'HOME.CAP_3_TITLE' | translate }}

-

{{ 'HOME.CAP_3_TEXT' | translate }}

-
- -
- -
-

{{ 'HOME.CAP_4_TITLE' | translate }}

-

{{ 'HOME.CAP_4_TEXT' | translate }}

-
+

{{ "HOME.CAP_1_TITLE" | translate }}

+

{{ "HOME.CAP_1_TEXT" | translate }}

+ + +
+
-
-
+

{{ "HOME.CAP_2_TITLE" | translate }}

+

{{ "HOME.CAP_2_TEXT" | translate }}

+ + +
+ +
+

{{ "HOME.CAP_3_TITLE" | translate }}

+

{{ "HOME.CAP_3_TEXT" | translate }}

+
+ +
+ +
+

{{ "HOME.CAP_4_TITLE" | translate }}

+

{{ "HOME.CAP_4_TEXT" | translate }}

+
+
+
+
-
-
-
-

{{ 'HOME.SEC_CALC_TITLE' | translate }}

-

- {{ 'HOME.SEC_CALC_SUBTITLE' | translate }} +

+
+
+

{{ "HOME.SEC_CALC_TITLE" | translate }}

+

+ {{ "HOME.SEC_CALC_SUBTITLE" | translate }} +

+
    +
  • {{ "HOME.SEC_CALC_LIST_1" | translate }}
  • +
+
+ +
+
+

+ {{ "HOME.CARD_CALC_EYEBROW" | translate }}

-
    -
  • {{ 'HOME.SEC_CALC_LIST_1' | translate }}
  • -
+

+ {{ "HOME.CARD_CALC_TITLE" | translate }} +

- -
-
-

{{ 'HOME.CARD_CALC_EYEBROW' | translate }}

-

{{ 'HOME.CARD_CALC_TITLE' | translate }}

-
- {{ 'HOME.CARD_CALC_TAG' | translate }} -
-
    -
  • {{ 'HOME.CARD_CALC_STEP_1' | translate }}
  • -
  • {{ 'HOME.CARD_CALC_STEP_2' | translate }}
  • -
  • {{ 'HOME.CARD_CALC_STEP_3' | translate }}
  • -
-
- {{ 'HOME.BTN_OPEN_CALC' | translate }} - {{ 'HOME.BTN_CONTACT' | translate }} -
-
+ {{ "HOME.CARD_CALC_TAG" | translate }}
-
+
    +
  • {{ "HOME.CARD_CALC_STEP_1" | translate }}
  • +
  • {{ "HOME.CARD_CALC_STEP_2" | translate }}
  • +
  • {{ "HOME.CARD_CALC_STEP_3" | translate }}
  • +
+
+ {{ "HOME.BTN_OPEN_CALC" | translate }} + {{ "HOME.BTN_CONTACT" | translate }} +
+ +
+
-
-
-
-

{{ 'HOME.SEC_SHOP_TITLE' | translate }}

-

- {{ 'HOME.SEC_SHOP_TEXT' | translate }} -

-
    -
  • {{ 'HOME.SEC_SHOP_LIST_1' | translate }}
  • -
  • {{ 'HOME.SEC_SHOP_LIST_2' | translate }}
  • -
  • {{ 'HOME.SEC_SHOP_LIST_3' | translate }}
  • -
-
- {{ 'HOME.BTN_DISCOVER' | translate }} - {{ 'HOME.BTN_REQ_SOLUTION' | translate }} -
-
- -
- -

{{ 'HOME.CARD_SHOP_1_TITLE' | translate }}

-

{{ 'HOME.CARD_SHOP_1_TEXT' | translate }}

-
- -

{{ 'HOME.CARD_SHOP_2_TITLE' | translate }}

-

{{ 'HOME.CARD_SHOP_2_TEXT' | translate }}

-
- -

{{ 'HOME.CARD_SHOP_3_TITLE' | translate }}

-

{{ 'HOME.CARD_SHOP_3_TEXT' | translate }}

-
-
+
+
+
+

{{ "HOME.SEC_SHOP_TITLE" | translate }}

+

+ {{ "HOME.SEC_SHOP_TEXT" | translate }} +

+
    +
  • {{ "HOME.SEC_SHOP_LIST_1" | translate }}
  • +
  • {{ "HOME.SEC_SHOP_LIST_2" | translate }}
  • +
  • {{ "HOME.SEC_SHOP_LIST_3" | translate }}
  • +
+
+ {{ + "HOME.BTN_DISCOVER" | translate + }} + {{ + "HOME.BTN_REQ_SOLUTION" | translate + }}
-
+
+ +
+ +

{{ "HOME.CARD_SHOP_1_TITLE" | translate }}

+

{{ "HOME.CARD_SHOP_1_TEXT" | translate }}

+
+ +

{{ "HOME.CARD_SHOP_2_TITLE" | translate }}

+

{{ "HOME.CARD_SHOP_2_TEXT" | translate }}

+
+ +

{{ "HOME.CARD_SHOP_3_TITLE" | translate }}

+

{{ "HOME.CARD_SHOP_3_TEXT" | translate }}

+
+
+
+ -
-
-
-

{{ 'HOME.SEC_ABOUT_TITLE' | translate }}

-

- {{ 'HOME.SEC_ABOUT_TEXT' | translate }} -

-
- {{ 'HOME.SEC_ABOUT_TITLE' | translate }} - {{ 'HOME.BTN_CONTACT' | translate }} -
-
-
-
- - - -
-
+
+
+
+

{{ "HOME.SEC_ABOUT_TITLE" | translate }}

+

+ {{ "HOME.SEC_ABOUT_TEXT" | translate }} +

+
+ {{ + "HOME.SEC_ABOUT_TITLE" | translate + }} + {{ + "HOME.BTN_CONTACT" | translate + }}
-
- +
+
+
+ + + +
+
+
+ + diff --git a/frontend/src/app/features/home/home.component.scss b/frontend/src/app/features/home/home.component.scss index c55fff4..416e16a 100644 --- a/frontend/src/app/features/home/home.component.scss +++ b/frontend/src/app/features/home/home.component.scss @@ -1,457 +1,529 @@ - @use '../../../styles/patterns'; +@use "../../../styles/patterns"; - .home-page { - --home-bg: #faf9f6; - --color-bg-card: #ffffff; - background: var(--home-bg); - } +.home-page { + --home-bg: #faf9f6; + --color-bg-card: #ffffff; + background: var(--home-bg); +} - .hero { - position: relative; - padding: 6rem 0 5rem; - overflow: hidden; - background: var(--home-bg); - // Enhanced Grid Pattern - &::after { - content: ''; - position: absolute; - inset: 0; - @include patterns.pattern-grid(var(--color-neutral-900), 40px, 1px); - opacity: 0.06; - z-index: 0; - pointer-events: none; - mask-image: linear-gradient(to bottom, black 70%, transparent 100%); - } - } +.hero { + position: relative; + padding: 6rem 0 5rem; + overflow: hidden; + background: var(--home-bg); + // Enhanced Grid Pattern + &::after { + content: ""; + position: absolute; + inset: 0; + @include patterns.pattern-grid(var(--color-neutral-900), 40px, 1px); + opacity: 0.06; + z-index: 0; + pointer-events: none; + mask-image: linear-gradient(to bottom, black 70%, transparent 100%); + } +} - // 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%); - opacity: 0.8; - z-index: 0; - animation: floatGlow 12s ease-in-out infinite; - } +// 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% + ); + opacity: 0.8; + z-index: 0; + animation: floatGlow 12s ease-in-out infinite; +} - .hero-grid { - display: grid; - gap: var(--space-12); - align-items: center; - position: relative; - z-index: 1; - } +.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 { - 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 { - 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 { - font-size: 1.35rem; - font-weight: 500; - color: var(--color-neutral-900); - margin-bottom: var(--space-3); - max-width: 600px; - } - .hero-subtitle { - font-size: 1.1rem; - color: var(--color-text-muted); - max-width: 560px; - line-height: 1.6; - } - .hero-actions { - display: flex; - gap: var(--space-4); - flex-wrap: wrap; - margin: var(--space-6) 0 var(--space-4); - } - .hero-badges { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - } - .hero-badges span { - display: inline-flex; - padding: 0.35rem 0.75rem; - border-radius: 999px; - background: var(--color-neutral-100); - color: var(--color-neutral-900); - font-size: 0.85rem; - font-weight: 600; - border: 1px solid var(--color-border); - } +.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 { + 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 { + font-size: 1.35rem; + font-weight: 500; + color: var(--color-neutral-900); + margin-bottom: var(--space-3); + max-width: 600px; +} +.hero-subtitle { + font-size: 1.1rem; + color: var(--color-text-muted); + max-width: 560px; + line-height: 1.6; +} +.hero-actions { + display: flex; + gap: var(--space-4); + flex-wrap: wrap; + margin: var(--space-6) 0 var(--space-4); +} +.hero-badges { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} +.hero-badges span { + display: inline-flex; + padding: 0.35rem 0.75rem; + border-radius: 999px; + background: var(--color-neutral-100); + color: var(--color-neutral-900); + font-size: 0.85rem; + font-weight: 600; + border: 1px solid var(--color-border); +} - .quote-card { - display: block; - } - .focus-card { - display: grid; - gap: var(--space-4); - } - .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: '•'; - color: var(--color-brand); - margin-right: var(--space-2); - } - .focus-list li { - display: flex; - align-items: baseline; - gap: var(--space-2); - } - .quote-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: var(--space-4); - margin-bottom: var(--space-4); - } - .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 { - background: var(--color-neutral-100); - border: 1px solid var(--color-border); - border-radius: 999px; - padding: 0.35rem 0.75rem; - font-size: 0.8rem; - font-weight: 600; - color: var(--color-brand-600); - background: var(--color-brand-50); - border-color: var(--color-brand-200); - } - .quote-steps { - list-style: none; - padding: 0; - margin: 0 0 var(--space-5); - display: grid; - gap: var(--space-2); - } - .quote-steps li { - position: relative; - padding-left: 1.5rem; - color: var(--color-text-muted); - } - .quote-steps li::before { - content: ''; - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--color-brand); - position: absolute; - left: 0.25rem; - top: 0.5rem; - } - .quote-meta { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-4); - margin-bottom: var(--space-5); - } - .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); } +.quote-card { + display: block; +} +.focus-card { + display: grid; + gap: var(--space-4); +} +.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: "•"; + color: var(--color-brand); + margin-right: var(--space-2); +} +.focus-list li { + display: flex; + align-items: baseline; + gap: var(--space-2); +} +.quote-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); + margin-bottom: var(--space-4); +} +.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 { + background: var(--color-neutral-100); + border: 1px solid var(--color-border); + border-radius: 999px; + padding: 0.35rem 0.75rem; + font-size: 0.8rem; + font-weight: 600; + color: var(--color-brand-600); + background: var(--color-brand-50); + border-color: var(--color-brand-200); +} +.quote-steps { + list-style: none; + padding: 0; + margin: 0 0 var(--space-5); + display: grid; + gap: var(--space-2); +} +.quote-steps li { + position: relative; + padding-left: 1.5rem; + color: var(--color-text-muted); +} +.quote-steps li::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-brand); + position: absolute; + left: 0.25rem; + top: 0.5rem; +} +.quote-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-4); + margin-bottom: var(--space-5); +} +.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); +} .capabilities { position: relative; border-bottom: 1px solid var(--color-border); padding-top: 3rem; } - .capabilities-bg { - display: none; - } +.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 { - position: relative; - border-bottom: 1px solid var(--color-border); - } - .calculator-grid { - display: grid; - gap: var(--space-10); - align-items: start; - position: relative; - z-index: 1; - } - .calculator-list { - padding-left: var(--space-4); - color: var(--color-text-muted); - margin: var(--space-6) 0 0; - } - .cap-cards { - display: grid; - gap: var(--space-4); - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - } +.calculator { + position: relative; + border-bottom: 1px solid var(--color-border); +} +.calculator-grid { + display: grid; + gap: var(--space-10); + align-items: start; + position: relative; + z-index: 1; +} +.calculator-list { + padding-left: var(--space-4); + color: var(--color-text-muted); + margin: var(--space-6) 0 0; +} +.cap-cards { + display: grid; + gap: var(--space-4); + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} - .card-image-placeholder { - width: 100%; - height: 160px; - background: #f5f5f5; - margin: -1.5rem -1.5rem 1.5rem -1.5rem; /* Negative margins to bleed to edge */ - width: calc(100% + 3rem); - border-top-left-radius: var(--radius-lg); - border-top-right-radius: var(--radius-lg); - border-bottom: 1px solid var(--color-neutral-300); - display: flex; - align-items: center; - justify-content: center; - color: var(--color-neutral-400); - overflow: hidden; - } +.card-image-placeholder { + width: 100%; + height: 160px; + background: #f5f5f5; + margin: -1.5rem -1.5rem 1.5rem -1.5rem; /* Negative margins to bleed to edge */ + width: calc(100% + 3rem); + border-top-left-radius: var(--radius-lg); + border-top-right-radius: var(--radius-lg); + border-bottom: 1px solid var(--color-neutral-300); + display: flex; + align-items: center; + justify-content: center; + color: var(--color-neutral-400); + overflow: hidden; +} - .card-image-placeholder img { - width: 100%; - height: 100%; - display: block; - object-fit: cover; - } +.card-image-placeholder img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} - .shop { - background: var(--home-bg); - position: relative; - } - .shop .split { align-items: start; } - .shop-copy { - max-width: 760px; - } - .split { - display: grid; - gap: var(--space-10); - align-items: center; - position: relative; - z-index: 1; - } - .shop-list { - padding-left: var(--space-4); - color: var(--color-text-muted); - margin-bottom: var(--space-6); - } - .shop-actions { - display: flex; - flex-wrap: wrap; - gap: var(--space-3); - } - .shop-gallery { - display: flex; - gap: var(--space-4); - overflow-x: auto; - scroll-snap-type: x mandatory; - padding-bottom: var(--space-2); - scrollbar-width: thin; - width: min(100%, 440px); - justify-self: end; - aspect-ratio: 16 / 11; - } +.shop { + background: var(--home-bg); + position: relative; +} +.shop .split { + align-items: start; +} +.shop-copy { + max-width: 760px; +} +.split { + display: grid; + gap: var(--space-10); + align-items: center; + position: relative; + z-index: 1; +} +.shop-list { + padding-left: var(--space-4); + color: var(--color-text-muted); + margin-bottom: var(--space-6); +} +.shop-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); +} +.shop-gallery { + display: flex; + gap: var(--space-4); + overflow-x: auto; + scroll-snap-type: x mandatory; + padding-bottom: var(--space-2); + scrollbar-width: thin; + width: min(100%, 440px); + justify-self: end; + aspect-ratio: 16 / 11; +} - .shop-gallery-item { - flex: 0 0 100%; - margin: 0; - border-radius: var(--radius-lg); - overflow: hidden; - border: 1px solid var(--color-border); - background: var(--color-neutral-100); - box-shadow: var(--shadow-sm); - scroll-snap-align: start; - aspect-ratio: 16 / 10; - } +.shop-gallery-item { + flex: 0 0 100%; + margin: 0; + border-radius: var(--radius-lg); + overflow: hidden; + border: 1px solid var(--color-border); + background: var(--color-neutral-100); + box-shadow: var(--shadow-sm); + scroll-snap-align: start; + aspect-ratio: 16 / 10; +} - .shop-gallery-item img { - width: 100%; - height: 100%; - display: block; - object-fit: cover; - } +.shop-gallery-item img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} - .shop-cards { - display: grid; - gap: var(--space-4); - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - } +.shop-cards { + display: grid; + gap: var(--space-4); + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} - .shop-cards h3 { - margin-top: 0; - margin-bottom: var(--space-2); - } +.shop-cards h3 { + margin-top: 0; + margin-bottom: var(--space-2); +} - .shop-cards p { - margin: 0; - } +.shop-cards p { + margin: 0; +} - .about { - background: transparent; - border-top: 1px solid var(--color-border); - position: relative; - } - .about-actions { - display: flex; - flex-wrap: wrap; - gap: var(--space-3); - } - .about-grid { - display: grid; - gap: var(--space-10); - align-items: center; - } - .about-media { - position: relative; - display: flex; - justify-content: flex-end; - } +.about { + background: transparent; + border-top: 1px solid var(--color-border); + position: relative; +} +.about-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); +} +.about-grid { + display: grid; + gap: var(--space-10); + align-items: center; +} +.about-media { + position: relative; + display: flex; + justify-content: flex-end; +} - .about-feature-image { - width: 100%; - max-width: 620px; - aspect-ratio: 16 / 10; - border-radius: var(--radius-lg); - background: var(--color-neutral-100); - border: 1px solid var(--color-border); - position: relative; - overflow: hidden; - contain: layout paint; - } +.about-feature-image { + width: 100%; + max-width: 620px; + aspect-ratio: 16 / 10; + border-radius: var(--radius-lg); + background: var(--color-neutral-100); + border: 1px solid var(--color-border); + position: relative; + overflow: hidden; + contain: layout paint; +} - .about-feature-photo { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - display: block; - object-fit: cover; - } +.about-feature-photo { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} - .founder-nav { - position: absolute; - top: 50%; - transform: translateY(-50%); - width: 2.25rem; - height: 2.25rem; - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.6); - background: rgba(17, 24, 39, 0.45); - color: #fff; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 1.5rem; - line-height: 1; - cursor: pointer; - z-index: 1; - transition: background-color 0.2s ease; - } +.founder-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 2.25rem; + height: 2.25rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.6); + background: rgba(17, 24, 39, 0.45); + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + z-index: 1; + transition: background-color 0.2s ease; +} - .founder-nav:hover { - background: rgba(17, 24, 39, 0.7); - } +.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 { - outline: 2px solid var(--color-brand); - outline-offset: 2px; - } - .media-tile p { - margin: 0; - color: var(--color-text-muted); - font-size: 0.9rem; - } - .about-note { - padding: var(--space-5); - } +.founder-nav:focus-visible { + outline: 2px solid var(--color-brand); + outline-offset: 2px; +} +.media-tile p { + margin: 0; + color: var(--color-text-muted); + font-size: 0.9rem; +} +.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; } - .shop-cards { - grid-column: 1 / -1; - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - .about-grid { grid-template-columns: 1.1fr 0.9fr; } - } +@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; + } +} - @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; - justify-self: stretch; - } - .shop-gallery-item { - aspect-ratio: 16 / 11; - } - .shop-cards { grid-template-columns: 1fr; } - .about-media { - justify-content: flex-start; - } - .about-feature-image { - max-width: min(100%, 360px); - aspect-ratio: 16 / 11; - } - .founder-nav { - width: 2rem; - height: 2rem; - font-size: 1.25rem; - } - } +@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; + justify-self: stretch; + } + .shop-gallery-item { + aspect-ratio: 16 / 11; + } + .shop-cards { + grid-template-columns: 1fr; + } + .about-media { + justify-content: flex-start; + } + .about-feature-image { + max-width: min(100%, 360px); + aspect-ratio: 16 / 11; + } + .founder-nav { + width: 2rem; + height: 2rem; + font-size: 1.25rem; + } +} - @keyframes fadeUp { - from { opacity: 0; transform: translateY(18px); } - to { opacity: 1; transform: translateY(0); } - } - @keyframes floatGlow { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(20px); } - } +@keyframes fadeUp { + from { + opacity: 0; + transform: translateY(18px); + } + 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; + } +} diff --git a/frontend/src/app/features/home/home.component.ts b/frontend/src/app/features/home/home.component.ts index 53137f3..47a4b5f 100644 --- a/frontend/src/app/features/home/home.component.ts +++ b/frontend/src/app/features/home/home.component.ts @@ -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; diff --git a/frontend/src/app/features/legal/legal.routes.ts b/frontend/src/app/features/legal/legal.routes.ts index 0f4d4fb..78cc23e 100644 --- a/frontend/src/app/features/legal/legal.routes.ts +++ b/frontend/src/app/features/legal/legal.routes.ts @@ -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), + }, ]; diff --git a/frontend/src/app/features/legal/privacy/privacy.component.html b/frontend/src/app/features/legal/privacy/privacy.component.html index b831a18..dc79db4 100644 --- a/frontend/src/app/features/legal/privacy/privacy.component.html +++ b/frontend/src/app/features/legal/privacy/privacy.component.html @@ -1,38 +1,39 @@ diff --git a/frontend/src/app/features/legal/privacy/privacy.component.scss b/frontend/src/app/features/legal/privacy/privacy.component.scss index 22a2780..5aadf6c 100644 --- a/frontend/src/app/features/legal/privacy/privacy.component.scss +++ b/frontend/src/app/features/legal/privacy/privacy.component.scss @@ -29,7 +29,7 @@ .content { line-height: 1.8; color: var(--color-text-main); - + p { margin-bottom: 1.5rem; } diff --git a/frontend/src/app/features/legal/privacy/privacy.component.ts b/frontend/src/app/features/legal/privacy/privacy.component.ts index eca5688..be55300 100644 --- a/frontend/src/app/features/legal/privacy/privacy.component.ts +++ b/frontend/src/app/features/legal/privacy/privacy.component.ts @@ -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 {} diff --git a/frontend/src/app/features/legal/terms/terms.component.html b/frontend/src/app/features/legal/terms/terms.component.html index b6d4113..3cf3172 100644 --- a/frontend/src/app/features/legal/terms/terms.component.html +++ b/frontend/src/app/features/legal/terms/terms.component.html @@ -1,100 +1,101 @@ diff --git a/frontend/src/app/features/legal/terms/terms.component.scss b/frontend/src/app/features/legal/terms/terms.component.scss index 22a2780..5aadf6c 100644 --- a/frontend/src/app/features/legal/terms/terms.component.scss +++ b/frontend/src/app/features/legal/terms/terms.component.scss @@ -29,7 +29,7 @@ .content { line-height: 1.8; color: var(--color-text-main); - + p { margin-bottom: 1.5rem; } diff --git a/frontend/src/app/features/legal/terms/terms.component.ts b/frontend/src/app/features/legal/terms/terms.component.ts index 3dc5955..6d1b7d4 100644 --- a/frontend/src/app/features/legal/terms/terms.component.ts +++ b/frontend/src/app/features/legal/terms/terms.component.ts @@ -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 {} diff --git a/frontend/src/app/features/order/order.component.html b/frontend/src/app/features/order/order.component.html index 10c14e3..74c5ad0 100644 --- a/frontend/src/app/features/order/order.component.html +++ b/frontend/src/app/features/order/order.component.html @@ -1,71 +1,110 @@

- {{ 'TRACKING.TITLE' | translate }} + {{ "TRACKING.TITLE" | translate }} -
#{{ getDisplayOrderNumber(order()) }} +
#{{ getDisplayOrderNumber(order()) }}

-

{{ 'TRACKING.SUBTITLE' | translate }}

+

{{ "TRACKING.SUBTITLE" | translate }}

-
-
1
-
{{ 'TRACKING.STEP_PENDING' | translate }}
-
-
-
2
-
{{ 'TRACKING.STEP_REPORTED' | translate }}
-
-
-
3
-
{{ 'TRACKING.STEP_PRODUCTION' | translate }}
-
-
-
4
-
{{ 'TRACKING.STEP_SHIPPED' | translate }}
-
+
+
1
+
{{ "TRACKING.STEP_PENDING" | translate }}
+
+
+
2
+
{{ "TRACKING.STEP_REPORTED" | translate }}
+
+
+
3
+
{{ "TRACKING.STEP_PRODUCTION" | translate }}
+
+
+
4
+
{{ "TRACKING.STEP_SHIPPED" | translate }}
+
- -
-

{{ 'PAYMENT.STATUS_REPORTED_TITLE' | translate }}

-

{{ 'PAYMENT.STATUS_REPORTED_DESC' | translate }}

-
+ +
+

{{ "PAYMENT.STATUS_REPORTED_TITLE" | translate }}

+

{{ "PAYMENT.STATUS_REPORTED_DESC" | translate }}

+
-

{{ 'PAYMENT.METHOD' | translate }}

+

{{ "PAYMENT.METHOD" | translate }}

-
- {{ 'PAYMENT.METHOD_TWINT' | translate }} +
+ {{ + "PAYMENT.METHOD_TWINT" | translate + }}
-
- {{ 'PAYMENT.METHOD_BANK' | translate }} +
+ {{ + "PAYMENT.METHOD_BANK" | translate + }}
-
+
-

{{ 'PAYMENT.TWINT_TITLE' | translate }}

+

{{ "PAYMENT.TWINT_TITLE" | translate }}

-

{{ 'PAYMENT.TWINT_DESC' | translate }}

-

{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}

+ [attr.alt]="'PAYMENT.TWINT_QR_ALT' | translate" + /> +

{{ "PAYMENT.TWINT_DESC" | translate }}

+

+ {{ "PAYMENT.BILLING_INFO_HINT" | translate }} +

-
-

{{ 'PAYMENT.TOTAL' | translate }}: {{ o.totalChf | currency:'CHF' }}

+

+ {{ "PAYMENT.TOTAL" | translate }}: + {{ o.totalChf | currency: "CHF" }} +

-
+
-

{{ 'PAYMENT.BANK_TITLE' | translate }}

+

{{ "PAYMENT.BANK_TITLE" | translate }}

-

{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}

-
+

+ {{ "PAYMENT.BILLING_INFO_HINT" | translate }} +

+
- {{ 'PAYMENT.DOWNLOAD_QR' | translate }} + {{ "PAYMENT.DOWNLOAD_QR" | translate }}
- - {{ o.paymentStatus === 'REPORTED' ? ('PAYMENT.IN_VERIFICATION' | translate) : ('PAYMENT.CONFIRM' | translate) }} + + {{ + o.paymentStatus === "REPORTED" + ? ("PAYMENT.IN_VERIFICATION" | translate) + : ("PAYMENT.CONFIRM" | translate) + }}
@@ -121,29 +189,28 @@ align-items: center;" (click)="openTwintPayment()">
-

{{ 'PAYMENT.SUMMARY_TITLE' | translate }}

+

{{ "PAYMENT.SUMMARY_TITLE" | translate }}

#{{ getDisplayOrderNumber(o) }}

- {{ 'PAYMENT.SUBTOTAL' | translate }} - {{ o.subtotalChf | currency:'CHF' }} + {{ "PAYMENT.SUBTOTAL" | translate }} + {{ o.subtotalChf | currency: "CHF" }}
- {{ 'PAYMENT.SHIPPING' | translate }} - {{ o.shippingCostChf | currency:'CHF' }} + {{ "PAYMENT.SHIPPING" | translate }} + {{ o.shippingCostChf | currency: "CHF" }}
- {{ 'PAYMENT.SETUP_FEE' | translate }} - {{ o.setupCostChf | currency:'CHF' }} + {{ "PAYMENT.SETUP_FEE" | translate }} + {{ o.setupCostChf | currency: "CHF" }}
- {{ 'PAYMENT.TOTAL' | translate }} - {{ o.totalChf | currency:'CHF' }} + {{ "PAYMENT.TOTAL" | translate }} + {{ o.totalChf | currency: "CHF" }}
-
@@ -152,7 +219,7 @@ align-items: center;" (click)="openTwintPayment()">
-

{{ 'PAYMENT.LOADING' | translate }}

+

{{ "PAYMENT.LOADING" | translate }}

diff --git a/frontend/src/app/features/order/order.component.scss b/frontend/src/app/features/order/order.component.scss index c3db3ba..766fa4b 100644 --- a/frontend/src/app/features/order/order.component.scss +++ b/frontend/src/app/features/order/order.component.scss @@ -107,7 +107,7 @@ width: 100%; text-align: center; } - + .qr-placeholder { width: 100%; display: flex; @@ -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 { @@ -243,9 +244,9 @@ margin-bottom: var(--space-8); position: relative; /* padding: var(--space-6); */ /* Removed if it was here to match non-card layout */ - + &::before { - content: ''; + content: ""; position: absolute; top: 15px; left: 12.5%; @@ -315,7 +316,7 @@ flex-direction: column; align-items: flex-start; gap: var(--space-4); - + &::before { top: 10px; bottom: 10px; @@ -327,7 +328,7 @@ .timeline-step { flex-direction: row; gap: var(--space-3); - + .circle { margin-bottom: 0; } diff --git a/frontend/src/app/features/order/order.component.ts b/frontend/src/app/features/order/order.component.ts index 0423560..3ad4c05 100644 --- a/frontend/src/app/features/order/order.component.ts +++ b/frontend/src/app/features/order/order.component.ts @@ -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 @@ -135,19 +144,21 @@ export class OrderComponent implements OnInit { if (!this.orderId || !this.selectedPaymentMethod) { return; } - - this.quoteService.reportPayment(this.orderId, this.selectedPaymentMethod).subscribe({ - next: (order) => { - this.order.set(order); - // The UI will re-render and show the 'REPORTED' state. - // We stay on this page to let the user see the "In verifica" - // status along with payment instructions. - }, - error: (err) => { - console.error('Failed to report payment', err); - this.error.set('ORDER.ERR_REPORT_PAYMENT'); - } - }); + + this.quoteService + .reportPayment(this.orderId, this.selectedPaymentMethod) + .subscribe({ + next: (order) => { + this.order.set(order); + // The UI will re-render and show the 'REPORTED' state. + // We stay on this page to let the user see the "In verifica" + // status along with payment instructions. + }, + error: (err) => { + console.error('Failed to report payment', err); + this.error.set('ORDER.ERR_REPORT_PAYMENT'); + }, + }); } getDisplayOrderNumber(order: any): string { diff --git a/frontend/src/app/features/shop/components/product-card/product-card.component.html b/frontend/src/app/features/shop/components/product-card/product-card.component.html index 01cf2a8..293d1d6 100644 --- a/frontend/src/app/features/shop/components/product-card/product-card.component.html +++ b/frontend/src/app/features/shop/components/product-card/product-card.component.html @@ -3,11 +3,15 @@
{{ product().category | translate }}

- {{ product().name | translate }} + {{ + product().name | translate + }}

diff --git a/frontend/src/app/features/shop/components/product-card/product-card.component.scss b/frontend/src/app/features/shop/components/product-card/product-card.component.scss index db3d954..5819227 100644 --- a/frontend/src/app/features/shop/components/product-card/product-card.component.scss +++ b/frontend/src/app/features/shop/components/product-card/product-card.component.scss @@ -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; +} diff --git a/frontend/src/app/features/shop/components/product-card/product-card.component.ts b/frontend/src/app/features/shop/components/product-card/product-card.component.ts index 5a7e37e..79e7db8 100644 --- a/frontend/src/app/features/shop/components/product-card/product-card.component.ts +++ b/frontend/src/app/features/shop/components/product-card/product-card.component.ts @@ -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(); diff --git a/frontend/src/app/features/shop/product-detail.component.html b/frontend/src/app/features/shop/product-detail.component.html index 7773cdb..604c453 100644 --- a/frontend/src/app/features/shop/product-detail.component.html +++ b/frontend/src/app/features/shop/product-detail.component.html @@ -1,25 +1,25 @@
- ← {{ 'SHOP.BACK' | translate }} - + ← {{ "SHOP.BACK" | translate }} + @if (product(); as p) {
- +
{{ p.category | translate }}

{{ p.name | translate }}

-

{{ p.price | currency:'EUR' }}

- +

{{ p.price | currency: "EUR" }}

+

{{ p.description | translate }}

- +
- {{ 'SHOP.ADD_CART' | translate }} + {{ "SHOP.ADD_CART" | translate }}
} @else { -

{{ 'SHOP.NOT_FOUND' | translate }}

+

{{ "SHOP.NOT_FOUND" | translate }}

}
diff --git a/frontend/src/app/features/shop/product-detail.component.scss b/frontend/src/app/features/shop/product-detail.component.scss index 4e81bb4..5fc4e68 100644 --- a/frontend/src/app/features/shop/product-detail.component.scss +++ b/frontend/src/app/features/shop/product-detail.component.scss @@ -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); +} diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts index 873dad7..45124b8 100644 --- a/frontend/src/app/features/shop/product-detail.component.ts +++ b/frontend/src/app/features/shop/product-detail.component.ts @@ -10,23 +10,25 @@ 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 id = input(); - + product = signal(undefined); 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)); } } diff --git a/frontend/src/app/features/shop/services/shop.service.ts b/frontend/src/app/features/shop/services/shop.service.ts index 7974b74..e50a4ec 100644 --- a/frontend/src/app/features/shop/services/shop.service.ts +++ b/frontend/src/app/features/shop/services/shop.service.ts @@ -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 { @@ -43,6 +43,6 @@ export class ShopService { } getProductById(id: string): Observable { - return of(this.staticProducts.find(p => p.id === id)); + return of(this.staticProducts.find((p) => p.id === id)); } } diff --git a/frontend/src/app/features/shop/shop-page.component.html b/frontend/src/app/features/shop/shop-page.component.html index 6479737..f6998b6 100644 --- a/frontend/src/app/features/shop/shop-page.component.html +++ b/frontend/src/app/features/shop/shop-page.component.html @@ -1,18 +1,18 @@
-

{{ 'SHOP.WIP_EYEBROW' | translate }}

-

{{ 'SHOP.WIP_TITLE' | translate }}

-

{{ 'SHOP.WIP_SUBTITLE' | translate }}

+

{{ "SHOP.WIP_EYEBROW" | translate }}

+

{{ "SHOP.WIP_TITLE" | translate }}

+

{{ "SHOP.WIP_SUBTITLE" | translate }}

- {{ 'SHOP.WIP_CTA_CALC' | translate }} + {{ "SHOP.WIP_CTA_CALC" | translate }}
-

{{ 'SHOP.WIP_RETURN_LATER' | translate }}

-

{{ 'SHOP.WIP_NOTE' | translate }}

+

{{ "SHOP.WIP_RETURN_LATER" | translate }}

+

{{ "SHOP.WIP_NOTE" | translate }}

diff --git a/frontend/src/app/features/shop/shop-page.component.ts b/frontend/src/app/features/shop/shop-page.component.ts index a2312f9..589e743 100644 --- a/frontend/src/app/features/shop/shop-page.component.ts +++ b/frontend/src/app/features/shop/shop-page.component.ts @@ -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 {} diff --git a/frontend/src/app/features/shop/shop.routes.ts b/frontend/src/app/features/shop/shop.routes.ts index 744d165..22a7fb3 100644 --- a/frontend/src/app/features/shop/shop.routes.ts +++ b/frontend/src/app/features/shop/shop.routes.ts @@ -4,5 +4,5 @@ import { ProductDetailComponent } from './product-detail.component'; export const SHOP_ROUTES: Routes = [ { path: '', component: ShopPageComponent }, - { path: ':id', component: ProductDetailComponent } + { path: ':id', component: ProductDetailComponent }, ]; diff --git a/frontend/src/app/shared/components/app-alert/app-alert.component.html b/frontend/src/app/shared/components/app-alert/app-alert.component.html index e377c49..8357ea0 100644 --- a/frontend/src/app/shared/components/app-alert/app-alert.component.html +++ b/frontend/src/app/shared/components/app-alert/app-alert.component.html @@ -1,9 +1,17 @@
- @if(type() === 'info') { ℹ️ } - @if(type() === 'warning') { ⚠️ } - @if(type() === 'error') { ❌ } - @if(type() === 'success') { ✅ } + @if (type() === "info") { + ℹ️ + } + @if (type() === "warning") { + ⚠️ + } + @if (type() === "error") { + ❌ + } + @if (type() === "success") { + ✅ + }
diff --git a/frontend/src/app/shared/components/app-alert/app-alert.component.scss b/frontend/src/app/shared/components/app-alert/app-alert.component.scss index 2d4285b..db2f21f 100644 --- a/frontend/src/app/shared/components/app-alert/app-alert.component.scss +++ b/frontend/src/app/shared/components/app-alert/app-alert.component.scss @@ -6,7 +6,22 @@ font-size: 0.875rem; margin-bottom: var(--space-4); } -.info { background: var(--color-neutral-100); color: var(--color-neutral-800); } -.warning { background: #fefce8; color: #854d0e; border: 1px solid #fde047; } -.error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; } -.success { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; } +.info { + background: var(--color-neutral-100); + color: var(--color-neutral-800); +} +.warning { + background: #fefce8; + color: #854d0e; + border: 1px solid #fde047; +} +.error { + background: #fef2f2; + color: #991b1b; + border: 1px solid #fecaca; +} +.success { + background: #f0fdf4; + color: #166534; + border: 1px solid #bbf7d0; +} diff --git a/frontend/src/app/shared/components/app-alert/app-alert.component.ts b/frontend/src/app/shared/components/app-alert/app-alert.component.ts index 9ad0b51..2797688 100644 --- a/frontend/src/app/shared/components/app-alert/app-alert.component.ts +++ b/frontend/src/app/shared/components/app-alert/app-alert.component.ts @@ -6,7 +6,7 @@ import { CommonModule } from '@angular/common'; standalone: true, imports: [CommonModule], templateUrl: './app-alert.component.html', - styleUrl: './app-alert.component.scss' + styleUrl: './app-alert.component.scss', }) export class AppAlertComponent { type = input<'info' | 'warning' | 'error' | 'success'>('info'); diff --git a/frontend/src/app/shared/components/app-button/app-button.component.html b/frontend/src/app/shared/components/app-button/app-button.component.html index c4328a6..315f22f 100644 --- a/frontend/src/app/shared/components/app-button/app-button.component.html +++ b/frontend/src/app/shared/components/app-button/app-button.component.html @@ -1,7 +1,8 @@ - diff --git a/frontend/src/app/shared/components/app-button/app-button.component.scss b/frontend/src/app/shared/components/app-button/app-button.component.scss index 55bb342..6b06a58 100644 --- a/frontend/src/app/shared/components/app-button/app-button.component.scss +++ b/frontend/src/app/shared/components/app-button/app-button.component.scss @@ -6,28 +6,37 @@ border-radius: var(--radius-md); font-weight: 500; cursor: pointer; - transition: background-color 0.2s, color 0.2s, border-color 0.2s; + transition: + background-color 0.2s, + color 0.2s, + border-color 0.2s; border: 1px solid transparent; font-family: inherit; font-size: 1rem; - + &:disabled { opacity: 0.6; cursor: not-allowed; } } -.w-full { width: 100%; } +.w-full { + width: 100%; +} .btn-primary { background-color: var(--color-brand); color: var(--color-neutral-900); - &:hover:not(:disabled) { background-color: var(--color-brand-hover); } + &:hover:not(:disabled) { + background-color: var(--color-brand-hover); + } } .btn-secondary { background-color: var(--color-neutral-200); color: var(--color-neutral-900); - &:hover:not(:disabled) { background-color: var(--color-neutral-300); } + &:hover:not(:disabled) { + background-color: var(--color-neutral-300); + } } .btn-outline { @@ -37,8 +46,8 @@ padding: calc(0.5rem - 1px) calc(1rem - 1px); color: var(--color-neutral-900); font-weight: 600; - &:hover:not(:disabled) { - background-color: var(--color-brand); + &:hover:not(:disabled) { + background-color: var(--color-brand); color: var(--color-neutral-900); } } @@ -47,5 +56,7 @@ background-color: transparent; color: var(--color-text-muted); padding: 0.5rem; - &:hover:not(:disabled) { color: var(--color-text); } + &:hover:not(:disabled) { + color: var(--color-text); + } } diff --git a/frontend/src/app/shared/components/app-button/app-button.component.ts b/frontend/src/app/shared/components/app-button/app-button.component.ts index 7ea1863..53c8fee 100644 --- a/frontend/src/app/shared/components/app-button/app-button.component.ts +++ b/frontend/src/app/shared/components/app-button/app-button.component.ts @@ -6,7 +6,7 @@ import { CommonModule } from '@angular/common'; standalone: true, imports: [CommonModule], templateUrl: './app-button.component.html', - styleUrl: './app-button.component.scss' + styleUrl: './app-button.component.scss', }) export class AppButtonComponent { variant = input<'primary' | 'secondary' | 'outline' | 'text'>('primary'); diff --git a/frontend/src/app/shared/components/app-card/app-card.component.scss b/frontend/src/app/shared/components/app-card/app-card.component.scss index f394ac1..6d1eab2 100644 --- a/frontend/src/app/shared/components/app-card/app-card.component.scss +++ b/frontend/src/app/shared/components/app-card/app-card.component.scss @@ -9,10 +9,13 @@ border: 1px solid var(--color-border); box-shadow: var(--shadow-sm); padding: var(--space-6); - transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease; + transition: + box-shadow 0.2s ease, + transform 0.2s ease, + border-color 0.2s ease; height: 100%; box-sizing: border-box; - + &:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); diff --git a/frontend/src/app/shared/components/app-card/app-card.component.ts b/frontend/src/app/shared/components/app-card/app-card.component.ts index 9c5bcf2..304ebb9 100644 --- a/frontend/src/app/shared/components/app-card/app-card.component.ts +++ b/frontend/src/app/shared/components/app-card/app-card.component.ts @@ -4,6 +4,6 @@ import { Component } from '@angular/core'; selector: 'app-card', standalone: true, templateUrl: './app-card.component.html', - styleUrl: './app-card.component.scss' + styleUrl: './app-card.component.scss', }) export class AppCardComponent {} diff --git a/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.html b/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.html index d76e9bb..2574e92 100644 --- a/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.html +++ b/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.html @@ -1,4 +1,4 @@ -
- - + +
- + + + + + +

{{ label() | translate }}

{{ subtext() | translate }}

- + @if (fileNames().length > 0) { -
- @for (name of fileNames(); track name) { -
{{ name }}
- } -
+
+ @for (name of fileNames(); track name) { +
{{ name }}
+ } +
}
diff --git a/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.scss b/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.scss index 42c3b1c..32377a8 100644 --- a/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.scss +++ b/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.scss @@ -6,27 +6,37 @@ cursor: pointer; transition: all 0.2s; background-color: var(--color-neutral-50); - - &:hover, &.dragover { + + &:hover, + &.dragover { border-color: var(--color-brand); background-color: var(--color-neutral-100); } } -.icon { color: var(--color-brand); margin-bottom: var(--space-4); } -.text { font-weight: 600; margin-bottom: var(--space-2); } -.subtext { font-size: 0.875rem; color: var(--color-text-muted); } +.icon { + color: var(--color-brand); + margin-bottom: var(--space-4); +} +.text { + font-weight: 600; + margin-bottom: var(--space-2); +} +.subtext { + font-size: 0.875rem; + color: var(--color-text-muted); +} .file-badges { - margin-top: var(--space-4); - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - justify-content: center; + margin-top: var(--space-4); + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + justify-content: center; } .file-badge { - padding: var(--space-2) var(--space-4); - background: var(--color-neutral-200); - border-radius: var(--radius-md); - font-weight: 600; - color: var(--color-primary-700); - font-size: 0.85rem; + padding: var(--space-2) var(--space-4); + background: var(--color-neutral-200); + border-radius: var(--radius-md); + font-weight: 600; + color: var(--color-primary-700); + font-size: 0.85rem; } diff --git a/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.ts b/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.ts index 84591bf..a04f704 100644 --- a/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.ts +++ b/frontend/src/app/shared/components/app-dropzone/app-dropzone.component.ts @@ -7,16 +7,16 @@ import { TranslateModule } from '@ngx-translate/core'; standalone: true, imports: [CommonModule, TranslateModule], templateUrl: './app-dropzone.component.html', - styleUrl: './app-dropzone.component.scss' + styleUrl: './app-dropzone.component.scss', }) export class AppDropzoneComponent { label = input('DROPZONE.DEFAULT_LABEL'); subtext = input('DROPZONE.DEFAULT_SUBTEXT'); accept = input('.stl,.3mf,.step,.stp'); multiple = input(true); - + filesDropped = output(); - + isDragOver = signal(false); fileNames = signal([]); @@ -51,8 +51,8 @@ export class AppDropzoneComponent { } handleFiles(files: File[]) { - const newNames = files.map(f => f.name); - this.fileNames.update(current => [...current, ...newNames]); + const newNames = files.map((f) => f.name); + this.fileNames.update((current) => [...current, ...newNames]); this.filesDropped.emit(files); } } diff --git a/frontend/src/app/shared/components/app-input/app-input.component.html b/frontend/src/app/shared/components/app-input/app-input.component.html index 6ec048e..14fc2e6 100644 --- a/frontend/src/app/shared/components/app-input/app-input.component.html +++ b/frontend/src/app/shared/components/app-input/app-input.component.html @@ -1,9 +1,11 @@
- @if (label()) { + @if (label()) { + @if (required()) { + * + } + } - @if (error()) { {{ error() }} } + @if (error()) { + {{ error() }} + }
diff --git a/frontend/src/app/shared/components/app-input/app-input.component.scss b/frontend/src/app/shared/components/app-input/app-input.component.scss index e5de85e..e385221 100644 --- a/frontend/src/app/shared/components/app-input/app-input.component.scss +++ b/frontend/src/app/shared/components/app-input/app-input.component.scss @@ -1,6 +1,18 @@ -.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); } -.required-mark { color: var(--color-text); margin-left: 2px; } +.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); +} +.required-mark { + color: var(--color-text); + margin-left: 2px; +} .form-control { padding: 0.5rem 0.75rem; border: 1px solid var(--color-border); @@ -9,7 +21,18 @@ label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); co width: 100%; background: var(--color-bg-card); color: var(--color-text); - &:focus { outline: none; border-color: var(--color-brand); box-shadow: 0 0 0 2px rgba(250, 207, 10, 0.25); } - &:disabled { background: var(--color-neutral-100); cursor: not-allowed; } + &:focus { + outline: none; + border-color: var(--color-brand); + box-shadow: 0 0 0 2px rgba(250, 207, 10, 0.25); + } + &:disabled { + background: var(--color-neutral-100); + cursor: not-allowed; + } +} +.error-text { + color: var(--color-danger-500); + font-size: 0.75rem; + margin-top: var(--space-1); } -.error-text { color: var(--color-danger-500); font-size: 0.75rem; margin-top: var(--space-1); } diff --git a/frontend/src/app/shared/components/app-input/app-input.component.ts b/frontend/src/app/shared/components/app-input/app-input.component.ts index 6dc4f4d..0e5b0d3 100644 --- a/frontend/src/app/shared/components/app-input/app-input.component.ts +++ b/frontend/src/app/shared/components/app-input/app-input.component.ts @@ -1,5 +1,9 @@ import { Component, input, forwardRef } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, +} from '@angular/forms'; import { CommonModule } from '@angular/common'; @Component({ @@ -10,11 +14,11 @@ import { CommonModule } from '@angular/common'; { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => AppInputComponent), - multi: true - } + multi: true, + }, ], templateUrl: './app-input.component.html', - styleUrl: './app-input.component.scss' + styleUrl: './app-input.component.scss', }) export class AppInputComponent implements ControlValueAccessor { label = input(''); @@ -30,11 +34,19 @@ export class AppInputComponent implements ControlValueAccessor { onChange: any = () => {}; onTouched: any = () => {}; - writeValue(obj: any): void { this.value = obj || ''; } - registerOnChange(fn: any): void { this.onChange = fn; } - registerOnTouched(fn: any): void { this.onTouched = fn; } - setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; } - + writeValue(obj: any): void { + this.value = obj || ''; + } + registerOnChange(fn: any): void { + this.onChange = fn; + } + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + onInput(event: Event) { const val = (event.target as HTMLInputElement).value; this.value = val; diff --git a/frontend/src/app/shared/components/app-locations/app-locations.component.html b/frontend/src/app/shared/components/app-locations/app-locations.component.html index 6551a4d..7b5c275 100644 --- a/frontend/src/app/shared/components/app-locations/app-locations.component.html +++ b/frontend/src/app/shared/components/app-locations/app-locations.component.html @@ -1,8 +1,8 @@
-

{{ 'LOCATIONS.TITLE' | translate }}

-

{{ 'LOCATIONS.SUBTITLE' | translate }}

+

{{ "LOCATIONS.TITLE" | translate }}

+

{{ "LOCATIONS.SUBTITLE" | translate }}

@@ -11,23 +11,24 @@ + (selectionChange)="selectLocation($event)" + >
-

{{ 'LOCATIONS.BIASCA' | translate }}

-

{{ 'LOCATIONS.ADDRESS_TICINO' | translate }}

+

{{ "LOCATIONS.BIASCA" | translate }}

+

{{ "LOCATIONS.ADDRESS_TICINO" | translate }}

-

{{ 'LOCATIONS.BIENNE' | translate }}

-

{{ 'LOCATIONS.ADDRESS_BIENNE' | translate }}

+

{{ "LOCATIONS.BIENNE" | translate }}

+

{{ "LOCATIONS.ADDRESS_BIENNE" | translate }}

@@ -37,12 +38,24 @@
diff --git a/frontend/src/app/shared/components/app-locations/app-locations.component.scss b/frontend/src/app/shared/components/app-locations/app-locations.component.scss index 8c6a666..5144d29 100644 --- a/frontend/src/app/shared/components/app-locations/app-locations.component.scss +++ b/frontend/src/app/shared/components/app-locations/app-locations.component.scss @@ -28,7 +28,7 @@ gap: 3rem; align-items: start; - @media(min-width: 992px) { + @media (min-width: 992px) { grid-template-columns: repeat(2, minmax(320px, 420px)); justify-content: center; } @@ -44,7 +44,6 @@ width: 100%; } - .location-details { padding: 2rem; background: var(--color-bg); diff --git a/frontend/src/app/shared/components/app-locations/app-locations.component.ts b/frontend/src/app/shared/components/app-locations/app-locations.component.ts index b8e3471..83f69bd 100644 --- a/frontend/src/app/shared/components/app-locations/app-locations.component.ts +++ b/frontend/src/app/shared/components/app-locations/app-locations.component.ts @@ -2,14 +2,22 @@ import { Component } from '@angular/core'; import { CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; import { RouterLink } from '@angular/router'; -import { AppToggleSelectorComponent, ToggleOption } from '../app-toggle-selector/app-toggle-selector.component'; +import { + AppToggleSelectorComponent, + ToggleOption, +} from '../app-toggle-selector/app-toggle-selector.component'; @Component({ selector: 'app-locations', standalone: true, - imports: [CommonModule, TranslateModule, RouterLink, AppToggleSelectorComponent], + imports: [ + CommonModule, + TranslateModule, + RouterLink, + AppToggleSelectorComponent, + ], templateUrl: './app-locations.component.html', - styleUrl: './app-locations.component.scss' + styleUrl: './app-locations.component.scss', }) export class AppLocationsComponent { selectedLocation: 'ticino' | 'bienne' = 'ticino'; diff --git a/frontend/src/app/shared/components/app-select/app-select.component.html b/frontend/src/app/shared/components/app-select/app-select.component.html index dee40ad..f5c9042 100644 --- a/frontend/src/app/shared/components/app-select/app-select.component.html +++ b/frontend/src/app/shared/components/app-select/app-select.component.html @@ -1,5 +1,7 @@
- @if (label()) { } + @if (label()) { + + } - @if (error()) { {{ error() }} } + @if (error()) { + {{ error() }} + }
diff --git a/frontend/src/app/shared/components/app-select/app-select.component.scss b/frontend/src/app/shared/components/app-select/app-select.component.scss index bcf488a..7b39476 100644 --- a/frontend/src/app/shared/components/app-select/app-select.component.scss +++ b/frontend/src/app/shared/components/app-select/app-select.component.scss @@ -1,5 +1,14 @@ -.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); } +.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); +} .form-control { padding: 0.5rem 0.75rem; border: 1px solid var(--color-border); @@ -8,6 +17,13 @@ label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); co width: 100%; background: var(--color-bg-card); color: var(--color-text); - &:focus { outline: none; border-color: var(--color-brand); } + &:focus { + outline: none; + border-color: var(--color-brand); + } +} +.error-text { + color: var(--color-danger-500); + font-size: 0.75rem; + margin-top: var(--space-1); } -.error-text { color: var(--color-danger-500); font-size: 0.75rem; margin-top: var(--space-1); } diff --git a/frontend/src/app/shared/components/app-select/app-select.component.ts b/frontend/src/app/shared/components/app-select/app-select.component.ts index 4f49c97..73ff96b 100644 --- a/frontend/src/app/shared/components/app-select/app-select.component.ts +++ b/frontend/src/app/shared/components/app-select/app-select.component.ts @@ -1,5 +1,10 @@ import { Component, input, output, forwardRef } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR, ReactiveFormsModule, FormsModule } from '@angular/forms'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, + FormsModule, +} from '@angular/forms'; import { CommonModule } from '@angular/common'; @Component({ @@ -10,16 +15,16 @@ import { CommonModule } from '@angular/common'; { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => AppSelectComponent), - multi: true - } + multi: true, + }, ], templateUrl: './app-select.component.html', - styleUrl: './app-select.component.scss' + styleUrl: './app-select.component.scss', }) export class AppSelectComponent implements ControlValueAccessor { label = input(''); id = input('select-' + Math.random().toString(36).substr(2, 9)); - options = input<{label: string, value: any}[]>([]); + options = input<{ label: string; value: any }[]>([]); error = input(null); value: any = ''; @@ -28,13 +33,21 @@ export class AppSelectComponent implements ControlValueAccessor { onChange: any = () => {}; onTouched: any = () => {}; - writeValue(obj: any): void { this.value = obj; } - registerOnChange(fn: any): void { this.onChange = fn; } - registerOnTouched(fn: any): void { this.onTouched = fn; } - setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; } - + writeValue(obj: any): void { + this.value = obj; + } + registerOnChange(fn: any): void { + this.onChange = fn; + } + registerOnTouched(fn: any): void { + this.onTouched = fn; + } + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + onModelChange(val: any) { - this.value = val; - this.onChange(val); + this.value = val; + this.onChange(val); } } diff --git a/frontend/src/app/shared/components/app-tabs/app-tabs.component.html b/frontend/src/app/shared/components/app-tabs/app-tabs.component.html index d85c910..41cc95b 100644 --- a/frontend/src/app/shared/components/app-tabs/app-tabs.component.html +++ b/frontend/src/app/shared/components/app-tabs/app-tabs.component.html @@ -1,9 +1,10 @@
@for (tab of tabs(); track tab.value) { - } diff --git a/frontend/src/app/shared/components/app-tabs/app-tabs.component.scss b/frontend/src/app/shared/components/app-tabs/app-tabs.component.scss index 2825a0e..2d02fd6 100644 --- a/frontend/src/app/shared/components/app-tabs/app-tabs.component.scss +++ b/frontend/src/app/shared/components/app-tabs/app-tabs.component.scss @@ -12,8 +12,10 @@ color: var(--color-text-muted); border-bottom: 2px solid transparent; transition: all 0.2s; - - &:hover { color: var(--color-text); } + + &:hover { + color: var(--color-text); + } &.active { color: var(--color-brand); border-bottom-color: var(--color-brand); diff --git a/frontend/src/app/shared/components/app-tabs/app-tabs.component.ts b/frontend/src/app/shared/components/app-tabs/app-tabs.component.ts index 28ed4ec..6085954 100644 --- a/frontend/src/app/shared/components/app-tabs/app-tabs.component.ts +++ b/frontend/src/app/shared/components/app-tabs/app-tabs.component.ts @@ -7,10 +7,10 @@ import { TranslateModule } from '@ngx-translate/core'; standalone: true, imports: [CommonModule, TranslateModule], templateUrl: './app-tabs.component.html', - styleUrl: './app-tabs.component.scss' + styleUrl: './app-tabs.component.scss', }) export class AppTabsComponent { - tabs = input<{label: string, value: string}[]>([]); + tabs = input<{ label: string; value: string }[]>([]); activeTab = input(''); tabChange = output(); diff --git a/frontend/src/app/shared/components/app-toggle-selector/app-toggle-selector.component.html b/frontend/src/app/shared/components/app-toggle-selector/app-toggle-selector.component.html index 5702aeb..79ecb00 100644 --- a/frontend/src/app/shared/components/app-toggle-selector/app-toggle-selector.component.html +++ b/frontend/src/app/shared/components/app-toggle-selector/app-toggle-selector.component.html @@ -1,8 +1,10 @@
@for (option of options(); track option.value) { -
+
{{ option.label | translate }}
} diff --git a/frontend/src/app/shared/components/app-toggle-selector/app-toggle-selector.component.scss b/frontend/src/app/shared/components/app-toggle-selector/app-toggle-selector.component.scss index 6e2737a..a7b059e 100644 --- a/frontend/src/app/shared/components/app-toggle-selector/app-toggle-selector.component.scss +++ b/frontend/src/app/shared/components/app-toggle-selector/app-toggle-selector.component.scss @@ -22,13 +22,15 @@ color: var(--color-text-muted); 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); } } diff --git a/frontend/src/app/shared/components/app-toggle-selector/app-toggle-selector.component.ts b/frontend/src/app/shared/components/app-toggle-selector/app-toggle-selector.component.ts index 35af98d..fab24e7 100644 --- a/frontend/src/app/shared/components/app-toggle-selector/app-toggle-selector.component.ts +++ b/frontend/src/app/shared/components/app-toggle-selector/app-toggle-selector.component.ts @@ -12,12 +12,12 @@ export interface ToggleOption { standalone: true, imports: [CommonModule, TranslateModule], templateUrl: './app-toggle-selector.component.html', - styleUrl: './app-toggle-selector.component.scss' + styleUrl: './app-toggle-selector.component.scss', }) export class AppToggleSelectorComponent { options = input.required(); selectedValue = input.required(); - + selectionChange = output(); selectOption(value: any) { diff --git a/frontend/src/app/shared/components/color-selector/color-selector.component.html b/frontend/src/app/shared/components/color-selector/color-selector.component.html index 89757fe..cc5e770 100644 --- a/frontend/src/app/shared/components/color-selector/color-selector.component.html +++ b/frontend/src/app/shared/components/color-selector/color-selector.component.html @@ -1,14 +1,14 @@
@if (isOpen()) { -
+
} -
-
+ (click)="toggleOpen()" + >
@if (isOpen()) {
@@ -17,17 +17,26 @@
{{ category.name | translate }}
@for (color of category.colors; track color.value) { -
- -
-
+ [class.disabled]="color.outOfStock" + > +
+
- + {{ color.label | translate }}
} diff --git a/frontend/src/app/shared/components/color-selector/color-selector.component.scss b/frontend/src/app/shared/components/color-selector/color-selector.component.scss index 9d0843a..3138116 100644 --- a/frontend/src/app/shared/components/color-selector/color-selector.component.scss +++ b/frontend/src/app/shared/components/color-selector/color-selector.component.scss @@ -5,14 +5,14 @@ } .backdrop { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - z-index: 999; - background: transparent; - cursor: default; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 999; + background: transparent; + cursor: default; } .color-circle { @@ -21,12 +21,14 @@ border-radius: 50%; border: 2px solid #ddd; cursor: pointer; - transition: transform 0.2s, box-shadow 0.2s; - box-shadow: 0 1px 2px rgba(0,0,0,0.1); + transition: + transform 0.2s, + box-shadow 0.2s; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); &.trigger:hover { transform: scale(1.1); - box-shadow: 0 2px 4px rgba(0,0,0,0.15); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); } &.small { @@ -44,17 +46,17 @@ border: 1px solid #eee; border-radius: 8px; /* Slightly tighter radius */ padding: 12px; - box-shadow: 0 4px 12px rgba(0,0,0,0.15); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 1000; width: 230px; /* Increased size */ max-height: min(62vh, 360px); overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; - + // Little triangle arrow &::before { - content: ''; + content: ""; position: absolute; top: -6px; left: 8px; // Align arrow to left side near trigger @@ -72,7 +74,7 @@ width: 280px; /* Provide enough width for touch targets */ max-width: 90vw; /* Safety constraint */ max-height: min(72vh, 420px); - box-shadow: 0 10px 25px rgba(0,0,0,0.2); /* Stronger shadow for modal feel */ + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); /* Stronger shadow for modal feel */ /* Hide arrow on mobile since it's detached from trigger */ &::before { @@ -89,7 +91,7 @@ .category { margin-bottom: 12px; - + &:last-child { margin-bottom: 0; } @@ -117,15 +119,17 @@ gap: 4px; cursor: pointer; text-align: center; - + &:hover .selection-ring { transform: scale(1.1); } - + &.disabled { cursor: not-allowed; opacity: 0.6; - &:hover .selection-ring { transform: none; } + &:hover .selection-ring { + transform: none; + } } } @@ -135,29 +139,29 @@ border: 2px solid transparent; transition: all 0.2s; padding: 2px; /* Space for ring */ - + &.active { border-color: var(--color-brand, #facf0a); box-shadow: 0 0 0 1px var(--color-brand, #facf0a); } - + &.out-of-stock::after { - content: ''; - position: absolute; - top: 50%; - left: 50%; - width: 100%; - height: 2px; - background: #cc0000; - transform: translate(-50%, -50%) rotate(-45deg); + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 100%; + height: 2px; + background: #cc0000; + transform: translate(-50%, -50%) rotate(-45deg); } } .color-name { - font-size: 0.65rem; - color: #444; - line-height: 1.1; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; + font-size: 0.65rem; + color: #444; + line-height: 1.1; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; } diff --git a/frontend/src/app/shared/components/color-selector/color-selector.component.ts b/frontend/src/app/shared/components/color-selector/color-selector.component.ts index bf6cc55..a45231b 100644 --- a/frontend/src/app/shared/components/color-selector/color-selector.component.ts +++ b/frontend/src/app/shared/components/color-selector/color-selector.component.ts @@ -1,7 +1,12 @@ import { Component, input, output, signal, computed } from '@angular/core'; import { CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; -import { PRODUCT_COLORS, getColorHex, ColorCategory, ColorOption } from '../../../core/constants/colors.const'; +import { + PRODUCT_COLORS, + getColorHex, + ColorCategory, + ColorOption, +} from '../../../core/constants/colors.const'; import { VariantOption } from '../../../features/calculator/services/quote-estimator.service'; @Component({ @@ -9,7 +14,7 @@ import { VariantOption } from '../../../features/calculator/services/quote-estim standalone: true, imports: [CommonModule, TranslateModule], templateUrl: './color-selector.component.html', - styleUrl: './color-selector.component.scss' + styleUrl: './color-selector.component.scss', }) export class ColorSelectorComponent { selectedColor = input('Black'); @@ -20,32 +25,32 @@ export class ColorSelectorComponent { isOpen = signal(false); categories = computed(() => { - const vars = this.variants(); - if (vars && vars.length > 0) { - const byFinish = new Map(); - vars.forEach(v => { - const finish = v.finishType || 'AVAILABLE_COLORS'; - const bucket = byFinish.get(finish) || []; - bucket.push({ - label: v.colorName, - value: v.colorName, - hex: v.hexColor, - variantId: v.id, - outOfStock: v.isOutOfStock - }); - byFinish.set(finish, bucket); - }); + const vars = this.variants(); + if (vars && vars.length > 0) { + const byFinish = new Map(); + vars.forEach((v) => { + const finish = v.finishType || 'AVAILABLE_COLORS'; + const bucket = byFinish.get(finish) || []; + bucket.push({ + label: v.colorName, + value: v.colorName, + hex: v.hexColor, + variantId: v.id, + outOfStock: v.isOutOfStock, + }); + byFinish.set(finish, bucket); + }); - return Array.from(byFinish.entries()).map(([finish, colors]) => ({ - name: finish, - colors - })) as ColorCategory[]; - } - return PRODUCT_COLORS; + return Array.from(byFinish.entries()).map(([finish, colors]) => ({ + name: finish, + colors, + })) as ColorCategory[]; + } + return PRODUCT_COLORS; }); toggleOpen() { - this.isOpen.update(v => !v); + this.isOpen.update((v) => !v); } selectColor(color: ColorOption) { @@ -53,24 +58,24 @@ export class ColorSelectorComponent { this.colorSelected.emit({ colorName: color.value, - filamentVariantId: color.variantId + filamentVariantId: color.variantId, }); this.isOpen.set(false); } // Helper to find hex for the current selected value getCurrentHex(): string { - // Check in dynamic variants first - const vars = this.variants(); - if (vars && vars.length > 0) { - const found = vars.find(v => v.colorName === this.selectedColor()); - if (found) return found.hexColor; - } + // Check in dynamic variants first + const vars = this.variants(); + if (vars && vars.length > 0) { + const found = vars.find((v) => v.colorName === this.selectedColor()); + if (found) return found.hexColor; + } return getColorHex(this.selectedColor()); } close() { - this.isOpen.set(false); + this.isOpen.set(false); } } diff --git a/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.html b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.html index dcfd874..bdce4b0 100644 --- a/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.html +++ b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.html @@ -2,12 +2,12 @@ @if (loading) {
- {{ 'STL_VIEWER.LOADING' | translate }} + {{ "STL_VIEWER.LOADING" | translate }}
} @if (file && !loading) {
- {{ dimensions.x }} x {{ dimensions.y }} x {{ dimensions.z }} mm + {{ dimensions.x }} x {{ dimensions.y }} x {{ dimensions.z }} mm
}
diff --git a/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.scss b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.scss index 32c4c4a..e397437 100644 --- a/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.scss +++ b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.scss @@ -28,17 +28,19 @@ animation: spin 1s linear infinite; } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } .dims-overlay { - position: absolute; - bottom: 8px; - right: 8px; - background: rgba(0,0,0,0.6); - color: white; - padding: 4px 8px; - border-radius: 4px; - font-size: 0.75rem; - font-family: monospace; - pointer-events: none; + position: absolute; + bottom: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.6); + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-family: monospace; + pointer-events: none; } diff --git a/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts index d283437..f3e5196 100644 --- a/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts +++ b/frontend/src/app/shared/components/stl-viewer/stl-viewer.component.ts @@ -1,4 +1,13 @@ -import { Component, ElementRef, Input, OnChanges, OnDestroy, OnInit, ViewChild, SimpleChanges } from '@angular/core'; +import { + Component, + ElementRef, + Input, + OnChanges, + OnDestroy, + OnInit, + ViewChild, + SimpleChanges, +} from '@angular/core'; import { CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; import * as THREE from 'three'; @@ -12,13 +21,14 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; standalone: true, imports: [CommonModule, TranslateModule], templateUrl: './stl-viewer.component.html', - styleUrl: './stl-viewer.component.scss' + styleUrl: './stl-viewer.component.scss', }) export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { @Input() file: File | null = null; @Input() color: string = '#facf0a'; // Default Brand Color - @ViewChild('rendererContainer', { static: true }) rendererContainer!: ElementRef; + @ViewChild('rendererContainer', { static: true }) + rendererContainer!: ElementRef; private scene!: THREE.Scene; private camera!: THREE.PerspectiveCamera; @@ -38,7 +48,7 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { if (changes['file'] && this.file) { this.loadFile(this.file); } - + if (changes['color'] && this.currentMesh && !changes['file']) { this.applyColorStyle(this.color); } @@ -83,7 +93,11 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { this.camera.position.z = 100; // Renderer - this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' }); + this.renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: false, + powerPreference: 'high-performance', + }); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); this.renderer.outputColorSpace = THREE.SRGBColorSpace; this.renderer.toneMapping = THREE.ACESFilmicToneMapping; @@ -106,12 +120,12 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { // Handle resize const resizeObserver = new ResizeObserver(() => { - if (!this.rendererContainer) return; - const w = this.rendererContainer.nativeElement.clientWidth; - const h = this.rendererContainer.nativeElement.clientHeight; - this.camera.aspect = w / h; - this.camera.updateProjectionMatrix(); - this.renderer.setSize(w, h); + if (!this.rendererContainer) return; + const w = this.rendererContainer.nativeElement.clientWidth; + const h = this.rendererContainer.nativeElement.clientHeight; + this.camera.aspect = w / h; + this.camera.updateProjectionMatrix(); + this.renderer.setSize(w, h); }); resizeObserver.observe(this.rendererContainer.nativeElement); } @@ -126,7 +140,7 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { try { const loader = new STLLoader(); const geometry = loader.parse(event.target?.result as ArrayBuffer); - + this.clearCurrentMesh(); geometry.computeVertexNormals(); @@ -136,12 +150,12 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { roughness: 0.42, metalness: 0.05, emissive: 0x000000, - emissiveIntensity: 0 + emissiveIntensity: 0, }); - + this.currentMesh = new THREE.Mesh(geometry, material); this.applyColorStyle(this.color); - + // Center geometry geometry.computeBoundingBox(); geometry.center(); @@ -150,11 +164,11 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { const boundingBox = geometry.boundingBox!; const size = new THREE.Vector3(); boundingBox.getSize(size); - + this.dimensions = { - x: Math.round(size.x * 10) / 10, - y: Math.round(size.y * 10) / 10, - z: Math.round(size.z * 10) / 10 + x: Math.round(size.x * 10) / 10, + y: Math.round(size.y * 10) / 10, + z: Math.round(size.z * 10) / 10, }; // Rotate to stand upright (usually necessary for STLs) @@ -165,16 +179,15 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { // Adjust camera to fit object const maxDim = Math.max(size.x, size.y, size.z); const fov = this.camera.fov * (Math.PI / 180); - + // Calculate distance towards camera (z-axis) let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)); cameraZ *= 1.72; - + this.camera.position.set(cameraZ * 0.65, cameraZ * 0.95, cameraZ * 1.1); this.camera.lookAt(0, 0, 0); this.camera.updateProjectionMatrix(); this.controls.update(); - } catch (err) { console.error('Error loading STL:', err); } finally { @@ -186,14 +199,14 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { private animate() { this.animationId = requestAnimationFrame(() => this.animate()); - + if (this.currentMesh && this.autoRotate) { this.currentMesh.rotation.z += 0.0025; } if (this.controls) this.controls.update(); if (this.renderer && this.scene && this.camera) { - this.renderer.render(this.scene, this.camera); + this.renderer.render(this.scene, this.camera); } } diff --git a/frontend/src/app/shared/components/success-state/success-state.component.html b/frontend/src/app/shared/components/success-state/success-state.component.html index d118121..11901cb 100644 --- a/frontend/src/app/shared/components/success-state/success-state.component.html +++ b/frontend/src/app/shared/components/success-state/success-state.component.html @@ -1,26 +1,40 @@
- +
- + @switch (context()) { - @case ('contact') { -

{{ 'CONTACT.SUCCESS_TITLE' | translate }}

-

{{ 'CONTACT.SUCCESS_DESC' | translate }}

- {{ 'CONTACT.SEND_ANOTHER' | translate }} + @case ("contact") { +

{{ "CONTACT.SUCCESS_TITLE" | translate }}

+

{{ "CONTACT.SUCCESS_DESC" | translate }}

+ {{ + "CONTACT.SEND_ANOTHER" | translate + }} } - @case ('calc') { -

{{ 'CALC.ORDER_SUCCESS_TITLE' | translate }}

-

{{ 'CALC.ORDER_SUCCESS_DESC' | translate }}

- {{ 'CALC.NEW_QUOTE' | translate }} + @case ("calc") { +

{{ "CALC.ORDER_SUCCESS_TITLE" | translate }}

+

{{ "CALC.ORDER_SUCCESS_DESC" | translate }}

+ {{ + "CALC.NEW_QUOTE" | translate + }} } - @case ('shop') { -

{{ 'SHOP.SUCCESS_TITLE' | translate }}

-

{{ 'SHOP.SUCCESS_DESC' | translate }}

- {{ 'SHOP.CONTINUE' | translate }} + @case ("shop") { +

{{ "SHOP.SUCCESS_TITLE" | translate }}

+

{{ "SHOP.SUCCESS_DESC" | translate }}

+ {{ + "SHOP.CONTINUE" | translate + }} } }
diff --git a/frontend/src/app/shared/components/success-state/success-state.component.scss b/frontend/src/app/shared/components/success-state/success-state.component.scss index dc86115..d580092 100644 --- a/frontend/src/app/shared/components/success-state/success-state.component.scss +++ b/frontend/src/app/shared/components/success-state/success-state.component.scss @@ -13,7 +13,7 @@ height: 64px; color: var(--color-success, #10b981); margin-bottom: var(--space-2); - + svg { width: 100%; height: 100%; diff --git a/frontend/src/app/shared/components/success-state/success-state.component.ts b/frontend/src/app/shared/components/success-state/success-state.component.ts index 3cf4048..0423a78 100644 --- a/frontend/src/app/shared/components/success-state/success-state.component.ts +++ b/frontend/src/app/shared/components/success-state/success-state.component.ts @@ -10,7 +10,7 @@ export type SuccessContext = 'contact' | 'calc' | 'shop'; standalone: true, imports: [CommonModule, TranslateModule, AppButtonComponent], templateUrl: './success-state.component.html', - styleUrl: './success-state.component.scss' + styleUrl: './success-state.component.scss', }) export class SuccessStateComponent { context = input.required(); diff --git a/frontend/src/app/shared/components/summary-card/summary-card.component.ts b/frontend/src/app/shared/components/summary-card/summary-card.component.ts index 68a8f3f..ddaa7ee 100644 --- a/frontend/src/app/shared/components/summary-card/summary-card.component.ts +++ b/frontend/src/app/shared/components/summary-card/summary-card.component.ts @@ -6,7 +6,7 @@ import { CommonModule } from '@angular/common'; standalone: true, imports: [CommonModule], templateUrl: './summary-card.component.html', - styleUrl: './summary-card.component.scss' + styleUrl: './summary-card.component.scss', }) export class SummaryCardComponent { label = input.required(); diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index b6b0b00..49b29ed 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -176,9 +176,9 @@ "CONSENT": { "UPLOAD_NOTICE_PREFIX": "Durch das Hochladen einer Datei akzeptieren Sie unsere", "UPLOAD_NOTICE_LINK": "Datenschutzerklärung", - "LABEL_PREFIX": "Ich habe gelesen und akzeptiere die", + "LABEL_PREFIX": "Ich habe die", "TERMS_LINK": "Allgemeinen Geschäftsbedingungen", - "AND": "und die", + "AND": "gelesen und akzeptiere die", "PRIVACY_LINK": "Datenschutzerklärung", "REQUIRED_ERROR": "Um fortzufahren müssen Sie AGB und Datenschutz akzeptieren." }, @@ -509,7 +509,7 @@ "CARD_SHOP_3_TITLE": "Auf Anfrage", "CARD_SHOP_3_TEXT": "Sie finden nicht, was Sie brauchen? Wir entwickeln und produzieren es für Sie.", "SEC_ABOUT_TITLE": "Über uns", - "SEC_ABOUT_TEXT": "Wir sind zwei Ingenieurstudenten: 3D-Druck hat uns aus einem einfachen Grund begeistert – ein Problem sehen und die Lösung bauen. Aus dieser Idee entstehen Prototypen und Objekte, die im Alltag funktionieren.", + "SEC_ABOUT_TEXT": "Wir sind zwei Ingenieurstudenten: 3D-Druck hat uns aus einem einfachen Grund begeistert ein Problem sehen und die Lösung finden. Aus dieser Idee entstehen Prototypen und Objekte, die im Alltag funktionieren.", "FOUNDERS_PHOTO": "Foto der Gründer" }, "ORDER": { diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts index e76a9fe..ccc74ef 100644 --- a/frontend/src/environments/environment.prod.ts +++ b/frontend/src/environments/environment.prod.ts @@ -1,4 +1,4 @@ export const environment = { production: true, - apiUrl: '' + apiUrl: '', }; diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index 837417a..861d5c1 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -1,4 +1,4 @@ export const environment = { production: false, - apiUrl: 'http://localhost:8000' + apiUrl: 'http://localhost:8000', }; diff --git a/frontend/src/index.html b/frontend/src/index.html index 633451d..91a4429 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -1,16 +1,22 @@ - - - - 3D fab - - - - - - - - - + + + + 3D fab + + + + + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts index f8097ae..62ab7f1 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -8,5 +8,6 @@ import { AppComponent } from './app/app.component'; registerLocaleData(localeDeCH); registerLocaleData(localeItCH); -bootstrapApplication(AppComponent, appConfig) - .catch((err) => console.error(err)); +bootstrapApplication(AppComponent, appConfig).catch((err) => + console.error(err), +); diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index fa81a71..e3c7219 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -1,9 +1,11 @@ /* src/styles.scss */ -@use './styles/theme'; -@use './styles/patterns'; +@use "./styles/theme"; +@use "./styles/patterns"; /* Reset / Base */ -*, *::before, *::after { +*, +*::before, +*::after { box-sizing: border-box; } @@ -16,7 +18,12 @@ body { -webkit-font-smoothing: antialiased; } -h1, h2, h3, h4, h5, h6 { +h1, +h2, +h3, +h4, +h5, +h6 { margin: 0; font-weight: 600; line-height: 1.2; @@ -44,6 +51,12 @@ a { padding: 0 var(--space-4); } -.text-center { text-align: center; } -.mb-4 { margin-bottom: var(--space-4); } -.mt-4 { margin-top: var(--space-4); } +.text-center { + text-align: center; +} +.mb-4 { + margin-bottom: var(--space-4); +} +.mt-4 { + margin-top: var(--space-4); +} diff --git a/frontend/src/styles/_patterns.scss b/frontend/src/styles/_patterns.scss index 928ad2e..4c5df60 100644 --- a/frontend/src/styles/_patterns.scss +++ b/frontend/src/styles/_patterns.scss @@ -1,4 +1,4 @@ -@use 'sass:math'; +@use "sass:math"; // ============================================================================= // 3D Print Infill Patterns @@ -37,32 +37,74 @@ // 4. Diagonal (Rectilinear 45deg) @mixin pattern-diagonal($color, $size: 20px, $weight: 1px) { background-image: - repeating-linear-gradient(45deg, $color 0, $color $weight, transparent $weight, transparent 50%), - repeating-linear-gradient(-45deg, $color 0, $color $weight, transparent $weight, transparent 50%); + repeating-linear-gradient( + 45deg, + $color 0, + $color $weight, + transparent $weight, + transparent 50% + ), + repeating-linear-gradient( + -45deg, + $color 0, + $color $weight, + transparent $weight, + transparent 50% + ); background-size: $size $size; } // 5. Gyroid (Approximation via Curves) // Using radial gradients to simulate organic curves @mixin pattern-gyroid($color, $size: 40px) { - background-image: - radial-gradient(circle at 0 0, transparent 47%, $color 48%, $color 50%, transparent 51%), - radial-gradient(circle at 100% 100%, transparent 47%, $color 48%, $color 50%, transparent 51%); - background-size: $size $size; + background-image: + radial-gradient( + circle at 0 0, + transparent 47%, + $color 48%, + $color 50%, + transparent 51% + ), + radial-gradient( + circle at 100% 100%, + transparent 47%, + $color 48%, + $color 50%, + transparent 51% + ); + background-size: $size $size; } // 6. Cross Hatch (45deg Grid) // Intersecting diagonal lines @mixin pattern-cross-hatch($color, $size: 24px, $weight: 1px) { background-image: - repeating-linear-gradient(45deg, $color 0, $color $weight, transparent $weight, transparent 50%), - repeating-linear-gradient(-45deg, $color 0, $color $weight, transparent $weight, transparent 50%); + repeating-linear-gradient( + 45deg, + $color 0, + $color $weight, + transparent $weight, + transparent 50% + ), + repeating-linear-gradient( + -45deg, + $color 0, + $color $weight, + transparent $weight, + transparent 50% + ); background-size: $size $size; } // 7. Rectilinear (Alternating Lines) // Simulating a raster scan path or simple linear infill @mixin pattern-rectilinear($color, $size: 20px, $weight: 1px) { - background-image: repeating-linear-gradient(90deg, $color 0, $color $weight, transparent $weight, transparent $size); + background-image: repeating-linear-gradient( + 90deg, + $color 0, + $color $weight, + transparent $weight, + transparent $size + ); background-size: $size $size; } diff --git a/frontend/src/styles/theme.scss b/frontend/src/styles/theme.scss index 046496c..ab740f1 100644 --- a/frontend/src/styles/theme.scss +++ b/frontend/src/styles/theme.scss @@ -1,5 +1,5 @@ /* src/styles/theme.scss */ -@use './tokens'; +@use "./tokens"; :root { /* Semantic Colors - Theming Layer */ @@ -7,17 +7,17 @@ --color-bg-card: #ffffff; --color-text: var(--color-neutral-900); --color-text-muted: var(--color-secondary-500); - + --color-brand: var(--color-primary-500); --color-brand-hover: var(--color-primary-600); - + --color-border: var(--color-neutral-200); --color-success: var(--color-success-500); --color-warning: var(--color-warning-500); --color-error: var(--color-danger-500); - + /* Font */ - --font-family-sans: 'IBM Plex Sans', 'Space Grotesk', sans-serif; - --font-family-display: 'Space Grotesk', 'IBM Plex Sans', sans-serif; + --font-family-sans: "IBM Plex Sans", "Space Grotesk", sans-serif; + --font-family-display: "Space Grotesk", "IBM Plex Sans", sans-serif; } diff --git a/frontend/src/styles/tokens.scss b/frontend/src/styles/tokens.scss index 09eb7c3..d61dd26 100644 --- a/frontend/src/styles/tokens.scss +++ b/frontend/src/styles/tokens.scss @@ -1,10 +1,10 @@ /* src/styles/tokens.scss */ :root { /* Colors - Palette */ - --color-primary-500: #FACF0A; - --color-primary-600: #E3BA07; - --color-primary-700: #C8A204; - + --color-primary-500: #facf0a; + --color-primary-600: #e3ba07; + --color-primary-700: #c8a204; + --color-secondary-500: #6b675c; --color-secondary-600: #514d43; @@ -35,7 +35,7 @@ --radius-md: 0.375rem; --radius-lg: 0.5rem; --radius-xl: 1rem; - + /* Shadows */ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);