diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml index 386d3cd..c2b1f54 100644 --- a/.gitea/workflows/cicd.yaml +++ b/.gitea/workflows/cicd.yaml @@ -1,14 +1,36 @@ -name: Build, Test and Deploy +name: Build, Test, Deploy and Analysis on: push: branches: [main, int, dev] + workflow_dispatch: concurrency: group: print-calculator-${{ gitea.ref }} cancel-in-progress: true jobs: + # --- JOB DI ANALISI (In parallelo) --- + qodana: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fondamentale per Qodana per analizzare la storia + + - name: 'Qodana Scan' + uses: JetBrains/qodana-action@v2025.3 + env: + QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} + with: + # 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: runs-on: ubuntu-latest steps: @@ -21,16 +43,6 @@ jobs: java-version: '21' distribution: 'temurin' - - name: Cache Gradle - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ hashFiles('backend/gradle/wrapper/gradle-wrapper.properties', 'backend/**/*.gradle*', 'backend/gradle.properties') }} - restore-keys: | - gradle-${{ runner.os }}- - - name: Run Tests with Gradle run: | cd backend @@ -135,13 +147,23 @@ jobs: ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null - - name: Write env to server + - name: Write env and compose to server shell: bash run: | - # 1. Start with the static env file content + # 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 + DEPLOY_TAG="int" + else + DEPLOY_TAG="dev" + 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 - # 2. Determine DB credentials + # 3. Determine DB credentials if [[ "${{ env.ENV }}" == "prod" ]]; then DB_URL="${{ secrets.DB_URL_PROD }}" DB_USER="${{ secrets.DB_USERNAME_PROD }}" @@ -156,17 +178,29 @@ jobs: DB_PASS="${{ secrets.DB_PASSWORD_DEV }}" fi - # 3. Append DB credentials - printf '\nDB_URL=%s\nDB_USERNAME=%s\nDB_PASSWORD=%s\n' \ + # 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 - # 4. Debug: print content (for debug purposes) + ADMIN_TTL="${{ secrets.ADMIN_SESSION_TTL_MINUTES }}" + ADMIN_TTL="${ADMIN_TTL:-480}" + 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 -v "PASSWORD" /tmp/full_env.env || true + grep -Ev "PASSWORD|SECRET" /tmp/full_env.env || true - # 5. Send to server + # 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 diff --git a/.gitignore b/.gitignore index bb7e6b2..ab81c7a 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,11 @@ target/ build/ .gradle/ .mvn/ + +./storage_orders +./storage_quotes +storage_orders +storage_quotes + +# Qodana local reports/artifacts +backend/.qodana/ diff --git a/GEMINI.md b/GEMINI.md index 997d781..6fc8544 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -4,39 +4,42 @@ Questo file serve a dare contesto all'AI (Antigravity/Gemini) sulla struttura e ## Project Overview **Nome**: Print Calculator -**Scopo**: Calcolare costi e tempi di stampa 3D da file STL. +**Scopo**: Calcolare costi e tempi di stampa 3D da file STL in modo preciso tramite slicing reale. **Stack**: -- **Backend**: Python (FastAPI), libreria `trimesh` per analisi geometrica. -- **Frontend**: Angular 19 (TypeScript). +- **Backend**: Java 21 (Spring Boot 3.4), PostgreSQL, Flyway. +- **Frontend**: Angular 19 (TypeScript), Angular Material, Three.js per visualizzazione 3D. ## Architecture ### Backend (`/backend`) -- **`main.py`**: Entrypoint dell'applicazione FastAPI. - - Definisce l'API `POST /calculate/stl`. - - Gestisce l'upload del file, invoca lo slicer e restituisce il preventivo. - - Configura CORS per permettere chiamate dal frontend. -- **`slicer.py`**: Wrappa l'eseguibile di **OrcaSlicer** per effettuare lo slicing reale del modello. - - Gestisce i profili di stampa (Macchina, Processo, Filamento). - - Crea configurazioni on-the-fly per supportare mesh di grandi dimensioni. -- **`calculator.py`**: Analizza il G-Code generato. - - `GCodeParser`: Estrae tempo di stampa e materiale usato dai metadati del G-Code. - - `QuoteCalculator`: Applica i costi (orari, energia, materiale) per generare il prezzo finale. +- **`BackendApplication.java`**: Entrypoint dell'applicazione Spring Boot. +- **`controller/`**: Espone le API REST per l'upload e il calcolo dei preventivi. +- **`service/SlicerService.java`**: Wrappa l'eseguibile di **OrcaSlicer** per effettuare lo slicing reale del modello. + - Gestisce i profili di stampa (Macchina, Processo, Filamento) caricati da file JSON. + - Crea configurazioni on-the-fly e invoca OrcaSlicer in modalità headless. +- **`service/GCodeParser.java`**: Analizza il G-Code generato per estrarre tempo di stampa e peso del materiale dai metadati del file. +- **`service/QuoteCalculator.java`**: Calcola il prezzo finale basandosi su politiche di prezzo salvate nel database. + - Gestisce costi macchina a scaglioni (tiered pricing). + - Calcola costi energetici basati sulla potenza della stampante e costo del kWh. + - Applica markup percentuali e fee fissi per job. ### Frontend (`/frontend`) -- Applicazione Angular standard. -- Usa Angular Material. -- Service per upload STL e visualizzazione preventivo. +- Applicazione Angular 19 con architettura modulare (core, features, shared). +- **Three.js**: Utilizzato per il rendering dei file STL caricati dall'utente. +- **Angular Material**: Per l'interfaccia utente. +- **ngx-translate**: Per il supporto multilingua. ## Key Concepts -- **Real Slicing**: Il backend esegue un vero slicing usando OrcaSlicer in modalità headless. Questo garantisce stime di tempo e materiale estremamente precise, identiche a quelle che si otterrebbero preparando il file per la stampa. -- **G-Code Parsing**: Invece di stimare geometricamente, l'applicazione legge direttamene i commenti generati dallo slicer nel G-Code (es. `estimated printing time`, `filament used`). +- **Real Slicing**: Il backend esegue un vero slicing usando OrcaSlicer. Questo garantisce stime di tempo e materiale estremamente precise. +- **Database-Driven Pricing**: A differenza di versioni precedenti, il calcolo del preventivo è ora guidato da entità DB (`PricingPolicy`, `PrinterMachine`, `FilamentVariant`). +- **G-Code Metadata**: L'applicazione legge direttamene i commenti generati dallo slicer nel G-Code (es. `; estimated printing time`, `; filament used [g]`). ## Development Notes -- Per eseguire il backend serve `uvicorn`. -- Il frontend richiede `npm install` al primo avvio. -- Le configurazioni di stampa (layer height, wall thickness, infill) sono attualmente hardcoded o con valori di default nel backend, ma potrebbero essere esposte come parametri API in futuro. +- **Backend**: Richiede JDK 21. Si avvia con `./gradlew bootRun`. +- **Database**: Richiede PostgreSQL. Le migrazioni sono gestite da Flyway. +- **Frontend**: Richiede Node.js 22. Si avvia con `npm start`. +- **OrcaSlicer**: Deve essere installato sul sistema e il percorso configurato in `application.properties` o tramite variabile d'ambiente `SLICER_PATH`. ## AI Agent Rules - **No Inline Code**: Tutti i componenti Angular DEVONO usare file separati per HTML (`templateUrl`) e SCSS (`styleUrl`). È vietato usare `template` o `styles` inline nel decoratore `@Component`. - +- **Spring Boot Conventions**: Seguire i pattern standard di Spring Boot (Service-Repository-Controller). diff --git a/README.md b/README.md index 14a9c95..f7a89c4 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,67 @@ # Print Calculator (OrcaSlicer Edition) -Un'applicazione Full Stack (Angular + Python/FastAPI) per calcolare preventivi di stampa 3D precisi utilizzando **OrcaSlicer** in modalità headless. +Un'applicazione Full Stack (Angular + Spring Boot) per calcolare preventivi di stampa 3D precisi utilizzando **OrcaSlicer** in modalità headless. ## Funzionalità -* **Slicing Reale**: Usa il motore di OrcaSlicer per stimare tempo e materiale, non semplici approssimazioni geometriche. -* **Preventivazione Completa**: Calcola costo materiale, ammortamento macchina, energia e ricarico. -* **Configurabile**: Prezzi e parametri macchina modificabili via variabili d'ambiente. -* **Docker Ready**: Tutto containerizzato per un facile deployment. +* **Slicing Reale**: Usa il motore di OrcaSlicer per stimare tempo e materiale, garantendo la massima precisione. +* **Preventivazione Database-Driven**: Calcolo basato su politiche di prezzo configurabili nel database (costo materiale, ammortamento macchina a scaglioni, energia e markup). +* **Visualizzazione 3D**: Anteprima del file STL caricato tramite Three.js. +* **Multi-Profilo**: Supporto per diverse stampanti, materiali e profili di processo. + +## Stack Tecnologico + +- **Backend**: Java 21, Spring Boot 3.4, PostgreSQL, Flyway. +- **Frontend**: Angular 19, Angular Material, Three.js. +- **Slicer**: OrcaSlicer (invocato via CLI). ## Prerequisiti -* Docker Desktop & Docker Compose installati. +* **Java 21** installato. +* **Node.js 22** e **npm** installati. +* **PostgreSQL** attivo. +* **OrcaSlicer** installato sul sistema. ## Avvio Rapido -1. Clona il repository. -2. Esegui lo script di avvio o docker-compose: - ```bash - docker-compose up --build - ``` - *Nota: La prima build impiegherà alcuni minuti per scaricare OrcaSlicer (~200MB) e compilare il Frontend.* +### 1. Database +Crea un database PostgreSQL chiamato `printcalc`. Le tabelle verranno create automaticamente al primo avvio tramite Flyway. -3. Accedi all'applicazione: - * **Frontend**: [http://localhost](http://localhost) - * **API Docs**: [http://localhost:8000/docs](http://localhost:8000/docs) +### 2. Backend +Configura il percorso di OrcaSlicer in `backend/src/main/resources/application.properties` o tramite la variabile d'ambiente `SLICER_PATH`. -## Configurazione Prezzi - -Puoi modificare i prezzi nel file `docker-compose.yml` (sezione `environment` del servizio backend): - -* `FILAMENT_COST_PER_KG`: Costo filamento al kg (es. 25.0). -* `MACHINE_COST_PER_HOUR`: Costo orario macchina (ammortamento/manutenzione). -* `ENERGY_COST_PER_KWH`: Costo energia elettrica. -* `MARKUP_PERCENT`: Margine di profitto percentuale (es. 20 = +20%). - -## Struttura del Progetto - -* `/backend`: API Python FastAPI. Include Dockerfile che scarica OrcaSlicer AppImage. -* `/frontend`: Applicazione Angular 19+ con Material Design. -* `/backend/profiles`: Contiene i profili di slicing (.ini). Attualmente configurato per una stima generica simil-Bambu Lab A1. - -## Troubleshooting - -### Errore Download OrcaSlicer -Se la build del backend fallisce durante il download di `OrcaSlicer.AppImage`, verifica la tua connessione internet o aggiorna l'URL nel `backend/Dockerfile`. - -### Slicing Fallito (Costo 0 o Errore) -Se l'API ritorna errore o valori nulli: -1. Controlla che il file STL sia valido (manifold). -2. Controlla i log del backend: `docker logs print-calculator-backend`. - -## Sviluppo Locale (Senza Docker) - -**Backend**: -Richiede Linux (o WSL2) per eseguire l'AppImage di OrcaSlicer. ```bash cd backend -pip install -r requirements.txt -# Assicurati di avere OrcaSlicer installato e nel PATH o aggiorna SLICER_PATH in slicer.py -uvicorn main:app --reload +./gradlew bootRun ``` -**Frontend**: +### 3. Frontend ```bash cd frontend npm install npm start ``` + +Accedi a [http://localhost:4200](http://localhost:4200). + +## Configurazione Prezzi + +I prezzi non sono più gestiti tramite variabili d'ambiente fisse ma tramite tabelle nel database: +- `pricing_policy`: Definisce markup, fee fissi e costi elettrici. +- `pricing_policy_machine_hour_tier`: Definisce i costi orari delle macchine in base alla durata della stampa. +- `printer_machine`: Anagrafica stampanti e consumi energetici. +- `filament_material_type` / `filament_variant`: Listino prezzi materiali. + +## Struttura del Progetto + +* `/backend`: API Spring Boot. +* `/frontend`: Applicazione Angular. +* `/backend/profiles`: Contiene i file di configurazione per OrcaSlicer. + +## Troubleshooting + +### Percorso OrcaSlicer +Assicurati che `slicer.path` punti al binario corretto. Su macOS è solitamente `/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer`. Su Linux è il percorso all'AppImage (estratta o meno). + +### Database connection +Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente. diff --git a/backend/build.gradle b/backend/build.gradle index 35b1154..627b2cc 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -24,11 +24,27 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - runtimeOnly 'org.postgresql:postgresql' + implementation 'xyz.capybara:clamav-client:2.1.2' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + runtimeOnly 'org.postgresql:postgresql' developmentOnly 'org.springframework.boot:spring-boot-devtools' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testRuntimeOnly 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + implementation 'io.github.openhtmltopdf:openhtmltopdf-pdfbox:1.1.37' + implementation 'io.github.openhtmltopdf:openhtmltopdf-svg-support:1.1.37' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0' + implementation 'org.springframework.boot:spring-boot-starter-mail' + + + } tasks.named('test') { diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 1dcaaa2..8013e19 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -3,13 +3,25 @@ echo "----------------------------------------------------------------" echo "Starting Backend Application" echo "DB_URL: $DB_URL" echo "DB_USERNAME: $DB_USERNAME" +echo "SPRING_DATASOURCE_URL: $SPRING_DATASOURCE_URL" echo "SLICER_PATH: $SLICER_PATH" echo "--- ALL ENV VARS ---" env echo "----------------------------------------------------------------" -# Exec java with explicit properties from env -exec java -jar app.jar \ - --spring.datasource.url="${DB_URL}" \ - --spring.datasource.username="${DB_USERNAME}" \ - --spring.datasource.password="${DB_PASSWORD}" +# Determine which environment variables to use for database connection +# This allows compatibility with different docker-compose configurations +FINAL_DB_URL="${DB_URL:-$SPRING_DATASOURCE_URL}" +FINAL_DB_USER="${DB_USERNAME:-$SPRING_DATASOURCE_USERNAME}" +FINAL_DB_PASS="${DB_PASSWORD:-$SPRING_DATASOURCE_PASSWORD}" + +if [ -n "$FINAL_DB_URL" ]; then + echo "Using database URL: $FINAL_DB_URL" + exec java -jar app.jar \ + --spring.datasource.url="${FINAL_DB_URL}" \ + --spring.datasource.username="${FINAL_DB_USER}" \ + --spring.datasource.password="${FINAL_DB_PASS}" +else + echo "No database URL specified in environment, relying on application.properties defaults." + exec java -jar app.jar +fi diff --git a/backend/profiles/profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json b/backend/profiles/profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json index 6b75717..af260c3 100644 --- a/backend/profiles/profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json +++ b/backend/profiles/profiles/BBL/machine/Bambu Lab A1 0.4 nozzle.json @@ -21,12 +21,7 @@ "extruder_clearance_height_to_rod": "25", "extruder_clearance_max_radius": "73", "extruder_clearance_dist_to_rod": "56.5", - "head_wrap_detect_zone": [ - "226x224", - "256x224", - "256x256", - "226x256" - ], + "head_wrap_detect_zone": [], "machine_load_filament_time": "25", "machine_max_acceleration_extruding": [ "12000", @@ -64,7 +59,7 @@ "scan_first_layer": "0", "machine_start_gcode": ";===== machine: A1 =========================\n;===== date: 20240620 =====================\nG392 S0\nM9833.2\n;M400\n;M73 P1.717\n\n;===== start to heat heatbead&hotend==========\nM1002 gcode_claim_action : 2\nM1002 set_filament_type:{filament_type[initial_no_support_extruder]}\nM104 S140\nM140 S[bed_temperature_initial_layer_single]\n\n;=====start printer sound ===================\nM17\nM400 S1\nM1006 S1\nM1006 A0 B10 L100 C37 D10 M60 E37 F10 N60\nM1006 A0 B10 L100 C41 D10 M60 E41 F10 N60\nM1006 A0 B10 L100 C44 D10 M60 E44 F10 N60\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N60\nM1006 A43 B10 L100 C46 D10 M70 E39 F10 N80\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N80\nM1006 A0 B10 L100 C43 D10 M60 E39 F10 N80\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N80\nM1006 A0 B10 L100 C41 D10 M80 E41 F10 N80\nM1006 A0 B10 L100 C44 D10 M80 E44 F10 N80\nM1006 A0 B10 L100 C49 D10 M80 E49 F10 N80\nM1006 A0 B10 L100 C0 D10 M80 E0 F10 N80\nM1006 A44 B10 L100 C48 D10 M60 E39 F10 N80\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N80\nM1006 A0 B10 L100 C44 D10 M80 E39 F10 N80\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N80\nM1006 A43 B10 L100 C46 D10 M60 E39 F10 N80\nM1006 W\nM18 \n;=====start printer sound ===================\n\n;=====avoid end stop =================\nG91\nG380 S2 Z40 F1200\nG380 S3 Z-15 F1200\nG90\n\n;===== reset machine status =================\n;M290 X39 Y39 Z8\nM204 S6000\n\nM630 S0 P0\nG91\nM17 Z0.3 ; lower the z-motor current\n\nG90\nM17 X0.65 Y1.2 Z0.6 ; reset motor current to default\nM960 S5 P1 ; turn on logo lamp\nG90\nM220 S100 ;Reset Feedrate\nM221 S100 ;Reset Flowrate\nM73.2 R1.0 ;Reset left time magnitude\n;M211 X0 Y0 Z0 ; turn off soft endstop to prevent protential logic problem\n\n;====== cog noise reduction=================\nM982.2 S1 ; turn on cog noise reduction\n\nM1002 gcode_claim_action : 13\n\nG28 X\nG91\nG1 Z5 F1200\nG90\nG0 X128 F30000\nG0 Y254 F3000\nG91\nG1 Z-5 F1200\n\nM109 S25 H140\n\nM17 E0.3\nM83\nG1 E10 F1200\nG1 E-0.5 F30\nM17 D\n\nG28 Z P0 T140; home z with low precision,permit 300deg temperature\nM104 S{nozzle_temperature_initial_layer[initial_extruder]}\n\nM1002 judge_flag build_plate_detect_flag\nM622 S1\n G39.4\n G90\n G1 Z5 F1200\nM623\n\n;M400\n;M73 P1.717\n\n;===== prepare print temperature and material ==========\nM1002 gcode_claim_action : 24\n\nM400\n;G392 S1\nM211 X0 Y0 Z0 ;turn off soft endstop\nM975 S1 ; turn on\n\nG90\nG1 X-28.5 F30000\nG1 X-48.2 F3000\n\nM620 M ;enable remap\nM620 S[initial_no_support_extruder]A ; switch material if AMS exist\n M1002 gcode_claim_action : 4\n M400\n M1002 set_filament_type:UNKNOWN\n M109 S[nozzle_temperature_initial_layer]\n M104 S250\n M400\n T[initial_no_support_extruder]\n G1 X-48.2 F3000\n M400\n\n M620.1 E F{filament_max_volumetric_speed[initial_no_support_extruder]/2.4053*60} T{nozzle_temperature_range_high[initial_no_support_extruder]}\n M109 S250 ;set nozzle to common flush temp\n M106 P1 S0\n G92 E0\n G1 E50 F200\n M400\n M1002 set_filament_type:{filament_type[initial_no_support_extruder]}\nM621 S[initial_no_support_extruder]A\n\nM109 S{nozzle_temperature_range_high[initial_no_support_extruder]} H300\nG92 E0\nG1 E50 F200 ; lower extrusion speed to avoid clog\nM400\nM106 P1 S178\nG92 E0\nG1 E5 F200\nM104 S{nozzle_temperature_initial_layer[initial_no_support_extruder]}\nG92 E0\nG1 E-0.5 F300\n\nG1 X-28.5 F30000\nG1 X-48.2 F3000\nG1 X-28.5 F30000 ;wipe and shake\nG1 X-48.2 F3000\nG1 X-28.5 F30000 ;wipe and shake\nG1 X-48.2 F3000\n\n;G392 S0\n\nM400\nM106 P1 S0\n;===== prepare print temperature and material end =====\n\n;M400\n;M73 P1.717\n\n;===== auto extrude cali start =========================\nM975 S1\n;G392 S1\n\nG90\nM83\nT1000\nG1 X-48.2 Y0 Z10 F10000\nM400\nM1002 set_filament_type:UNKNOWN\n\nM412 S1 ; ===turn on filament runout detection===\nM400 P10\nM620.3 W1; === turn on filament tangle detection===\nM400 S2\n\nM1002 set_filament_type:{filament_type[initial_no_support_extruder]}\n\n;M1002 set_flag extrude_cali_flag=1\nM1002 judge_flag extrude_cali_flag\n\nM622 J1\n M1002 gcode_claim_action : 8\n\n M109 S{nozzle_temperature[initial_extruder]}\n G1 E10 F{outer_wall_volumetric_speed/2.4*60}\n M983 F{outer_wall_volumetric_speed/2.4} A0.3 H[nozzle_diameter]; cali dynamic extrusion compensation\n\n M106 P1 S255\n M400 S5\n G1 X-28.5 F18000\n G1 X-48.2 F3000\n G1 X-28.5 F18000 ;wipe and shake\n G1 X-48.2 F3000\n G1 X-28.5 F12000 ;wipe and shake\n G1 X-48.2 F3000\n M400\n M106 P1 S0\n\n M1002 judge_last_extrude_cali_success\n M622 J0\n M983 F{outer_wall_volumetric_speed/2.4} A0.3 H[nozzle_diameter]; cali dynamic extrusion compensation\n M106 P1 S255\n M400 S5\n G1 X-28.5 F18000\n G1 X-48.2 F3000\n G1 X-28.5 F18000 ;wipe and shake\n G1 X-48.2 F3000\n G1 X-28.5 F12000 ;wipe and shake\n M400\n M106 P1 S0\n M623\n \n G1 X-48.2 F3000\n M400\n M984 A0.1 E1 S1 F{outer_wall_volumetric_speed/2.4} H[nozzle_diameter]\n M106 P1 S178\n M400 S7\n G1 X-28.5 F18000\n G1 X-48.2 F3000\n G1 X-28.5 F18000 ;wipe and shake\n G1 X-48.2 F3000\n G1 X-28.5 F12000 ;wipe and shake\n G1 X-48.2 F3000\n M400\n M106 P1 S0\nM623 ; end of \"draw extrinsic para cali paint\"\n\n;G392 S0\n;===== auto extrude cali end ========================\n\n;M400\n;M73 P1.717\n\nM104 S170 ; prepare to wipe nozzle\nM106 S255 ; turn on fan\n\n;===== mech mode fast check start =====================\nM1002 gcode_claim_action : 3\n\nG1 X128 Y128 F20000\nG1 Z5 F1200\nM400 P200\nM970.3 Q1 A5 K0 O3\nM974 Q1 S2 P0\n\nM970.2 Q1 K1 W58 Z0.1\nM974 S2\n\nG1 X128 Y128 F20000\nG1 Z5 F1200\nM400 P200\nM970.3 Q0 A10 K0 O1\nM974 Q0 S2 P0\n\nM970.2 Q0 K1 W78 Z0.1\nM974 S2\n\nM975 S1\nG1 F30000\nG1 X0 Y5\nG28 X ; re-home XY\n\nG1 Z4 F1200\n\n;===== mech mode fast check end =======================\n\n;M400\n;M73 P1.717\n\n;===== wipe nozzle ===============================\nM1002 gcode_claim_action : 14\n\nM975 S1\nM106 S255 ; turn on fan (G28 has turn off fan)\nM211 S; push soft endstop status\nM211 X0 Y0 Z0 ;turn off Z axis endstop\n\n;===== remove waste by touching start =====\n\nM104 S170 ; set temp down to heatbed acceptable\n\nM83\nG1 E-1 F500\nG90\nM83\n\nM109 S170\nG0 X108 Y-0.5 F30000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X110 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X112 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X114 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X116 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X118 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X120 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X122 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X124 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X126 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X128 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X130 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X132 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X134 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X136 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X138 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X140 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X142 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X144 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X146 F10000\nG380 S3 Z-5 F1200\nG1 Z2 F1200\nG1 X148 F10000\nG380 S3 Z-5 F1200\n\nG1 Z5 F30000\n;===== remove waste by touching end =====\n\nG1 Z10 F1200\nG0 X118 Y261 F30000\nG1 Z5 F1200\nM109 S{nozzle_temperature_initial_layer[initial_extruder]-50}\n\nG28 Z P0 T300; home z with low precision,permit 300deg temperature\nG29.2 S0 ; turn off ABL\nM104 S140 ; prepare to abl\nG0 Z5 F20000\n\nG0 X128 Y261 F20000 ; move to exposed steel surface\nG0 Z-1.01 F1200 ; stop the nozzle\n\nG91\nG2 I1 J0 X2 Y0 F2000.1\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\n\nG90\nG1 Z10 F1200\n\n;===== brush material wipe nozzle =====\n\nG90\nG1 Y250 F30000\nG1 X55\nG1 Z1.300 F1200\nG1 Y262.5 F6000\nG91\nG1 X-35 F30000\nG1 Y-0.5\nG1 X45\nG1 Y-0.5\nG1 X-45\nG1 Y-0.5\nG1 X45\nG1 Y-0.5\nG1 X-45\nG1 Y-0.5\nG1 X45\nG1 Z5.000 F1200\n\nG90\nG1 X30 Y250.000 F30000\nG1 Z1.300 F1200\nG1 Y262.5 F6000\nG91\nG1 X35 F30000\nG1 Y-0.5\nG1 X-45\nG1 Y-0.5\nG1 X45\nG1 Y-0.5\nG1 X-45\nG1 Y-0.5\nG1 X45\nG1 Y-0.5\nG1 X-45\nG1 Z10.000 F1200\n\n;===== brush material wipe nozzle end =====\n\nG90\n;G0 X128 Y261 F20000 ; move to exposed steel surface\nG1 Y250 F30000\nG1 X138\nG1 Y261\nG0 Z-1.01 F1200 ; stop the nozzle\n\nG91\nG2 I1 J0 X2 Y0 F2000.1\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\nG2 I1 J0 X2\nG2 I-0.75 J0 X-1.5\n\nM109 S140\nM106 S255 ; turn on fan (G28 has turn off fan)\n\nM211 R; pop softend status\n\n;===== wipe nozzle end ================================\n\n;M400\n;M73 P1.717\n\n;===== bed leveling ==================================\nM1002 judge_flag g29_before_print_flag\n\nG90\nG1 Z5 F1200\nG1 X0 Y0 F30000\nG29.2 S1 ; turn on ABL\n\nM190 S[bed_temperature_initial_layer_single]; ensure bed temp\nM109 S140\nM106 S0 ; turn off fan , too noisy\n\nM622 J1\n M1002 gcode_claim_action : 1\n G29 A1 X{first_layer_print_min[0]} Y{first_layer_print_min[1]} I{first_layer_print_size[0]} J{first_layer_print_size[1]}\n M400\n M500 ; save cali data\nM623\n;===== bed leveling end ================================\n\n;===== home after wipe mouth============================\nM1002 judge_flag g29_before_print_flag\nM622 J0\n\n M1002 gcode_claim_action : 13\n G28\n\nM623\n\n;===== home after wipe mouth end =======================\n\n;M400\n;M73 P1.717\n\nG1 X108.000 Y-0.500 F30000\nG1 Z0.300 F1200\nM400\nG2814 Z0.32\n\nM104 S{nozzle_temperature_initial_layer[initial_extruder]} ; prepare to print\n\n;===== nozzle load line ===============================\n;G90\n;M83\n;G1 Z5 F1200\n;G1 X88 Y-0.5 F20000\n;G1 Z0.3 F1200\n\n;M109 S{nozzle_temperature_initial_layer[initial_extruder]}\n\n;G1 E2 F300\n;G1 X168 E4.989 F6000\n;G1 Z1 F1200\n;===== nozzle load line end ===========================\n\n;===== extrude cali test ===============================\n\nM400\n M900 S\n M900 C\n G90\n M83\n\n M109 S{nozzle_temperature_initial_layer[initial_extruder]}\n G0 X128 E8 F{outer_wall_volumetric_speed/(24/20) * 60}\n G0 X133 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5)/4 * 60}\n G0 X138 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5) * 60}\n G0 X143 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5)/4 * 60}\n G0 X148 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5) * 60}\n G0 X153 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5)/4 * 60}\n G91\n G1 X1 Z-0.300\n G1 X4\n G1 Z1 F1200\n G90\n M400\n\nM900 R\n\nM1002 judge_flag extrude_cali_flag\nM622 J1\n G90\n G1 X108.000 Y1.000 F30000\n G91\n G1 Z-0.700 F1200\n G90\n M83\n G0 X128 E10 F{outer_wall_volumetric_speed/(24/20) * 60}\n G0 X133 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5)/4 * 60}\n G0 X138 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5) * 60}\n G0 X143 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5)/4 * 60}\n G0 X148 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5) * 60}\n G0 X153 E.3742 F{outer_wall_volumetric_speed/(0.3*0.5)/4 * 60}\n G91\n G1 X1 Z-0.300\n G1 X4\n G1 Z1 F1200\n G90\n M400\nM623\n\nG1 Z0.2\n\n;M400\n;M73 P1.717\n\n;========turn off light and wait extrude temperature =============\nM1002 gcode_claim_action : 0\nM400\n\n;===== for Textured PEI Plate , lower the nozzle as the nozzle was touching topmost of the texture when homing ==\n;curr_bed_type={curr_bed_type}\n{if curr_bed_type==\"Textured PEI Plate\"}\nG29.1 Z{-0.02} ; for Textured PEI Plate\n{endif}\n\nM960 S1 P0 ; turn off laser\nM960 S2 P0 ; turn off laser\nM106 S0 ; turn off fan\nM106 P2 S0 ; turn off big fan\nM106 P3 S0 ; turn off chamber fan\n\nM975 S1 ; turn on mech mode supression\nG90\nM83\nT1000\n\nM211 X0 Y0 Z0 ;turn off soft endstop\n;G392 S1 ; turn on clog detection\nM1007 S1 ; turn on mass estimation\nG29.4\n", "machine_end_gcode": ";===== date: 20231229 =====================\nG392 S0 ;turn off nozzle clog detect\n\nM400 ; wait for buffer to clear\nG92 E0 ; zero the extruder\nG1 E-0.8 F1800 ; retract\nG1 Z{max_layer_z + 0.5} F900 ; lower z a little\nG1 X0 Y{first_layer_center_no_wipe_tower[1]} F18000 ; move to safe pos\nG1 X-13.0 F3000 ; move to safe pos\n{if !spiral_mode && print_sequence != \"by object\"}\nM1002 judge_flag timelapse_record_flag\nM622 J1\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM400 P100\nM971 S11 C11 O0\nM991 S0 P-1 ;end timelapse at safe pos\nM623\n{endif}\n\nM140 S0 ; turn off bed\nM106 S0 ; turn off fan\nM106 P2 S0 ; turn off remote part cooling fan\nM106 P3 S0 ; turn off chamber cooling fan\n\n;G1 X27 F15000 ; wipe\n\n; pull back filament to AMS\nM620 S255\nG1 X267 F15000\nT255\nG1 X-28.5 F18000\nG1 X-48.2 F3000\nG1 X-28.5 F18000\nG1 X-48.2 F3000\nM621 S255\n\nM104 S0 ; turn off hotend\n\nM400 ; wait all motion done\nM17 S\nM17 Z0.4 ; lower z motor current to reduce impact if there is something in the bottom\n{if (max_layer_z + 100.0) < 256}\n G1 Z{max_layer_z + 100.0} F600\n G1 Z{max_layer_z +98.0}\n{else}\n G1 Z256 F600\n G1 Z256\n{endif}\nM400 P100\nM17 R ; restore z current\n\nG90\nG1 X-48 Y180 F3600\n\nM220 S100 ; Reset feedrate magnitude\nM201.2 K1.0 ; Reset acc magnitude\nM73.2 R1.0 ;Reset left time magnitude\nM1002 set_gcode_claim_speed_level : 0\n\n;=====printer finish sound=========\nM17\nM400 S1\nM1006 S1\nM1006 A0 B20 L100 C37 D20 M40 E42 F20 N60\nM1006 A0 B10 L100 C44 D10 M60 E44 F10 N60\nM1006 A0 B10 L100 C46 D10 M80 E46 F10 N80\nM1006 A44 B20 L100 C39 D20 M60 E48 F20 N60\nM1006 A0 B10 L100 C44 D10 M60 E44 F10 N60\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N60\nM1006 A0 B10 L100 C39 D10 M60 E39 F10 N60\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N60\nM1006 A0 B10 L100 C44 D10 M60 E44 F10 N60\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N60\nM1006 A0 B10 L100 C39 D10 M60 E39 F10 N60\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N60\nM1006 A0 B10 L100 C48 D10 M60 E44 F10 N80\nM1006 A0 B10 L100 C0 D10 M60 E0 F10 N80\nM1006 A44 B20 L100 C49 D20 M80 E41 F20 N80\nM1006 A0 B20 L100 C0 D20 M60 E0 F20 N80\nM1006 A0 B20 L100 C37 D20 M30 E37 F20 N60\nM1006 W\n;=====printer finish sound=========\n\n;M17 X0.8 Y0.8 Z0.5 ; lower motor current to 45% power\nM400\nM18 X Y Z\n\n", - "layer_change_gcode": "; layer num/total_layer_count: {layer_num+1}/[total_layer_count]\n; update layer progress\nM73 L{layer_num+1}\nM991 S0 P{layer_num} ;notify layer change", + "layer_change_gcode": "; layer num/total_layer_count: {layer_num+1}/[total_layer_count]\nG92 E0\n; update layer progress\nM73 L{layer_num+1}\nM991 S0 P{layer_num} ;notify layer change", "time_lapse_gcode": ";===================== date: 20240606 =====================\n{if !spiral_mode && print_sequence != \"by object\"}\n; don't support timelapse gcode in spiral_mode and by object sequence for I3 structure printer\nM622.1 S1 ; for prev firmware, default turned on\nM1002 judge_flag timelapse_record_flag\nM622 J1\nG92 E0\nG17\nG2 Z{layer_z + 0.4} I0.86 J0.86 P1 F20000 ; spiral lift a little\nG1 Z{max_layer_z + 0.4}\nG1 X0 Y{first_layer_center_no_wipe_tower[1]} F18000 ; move to safe pos\nG1 X-48.2 F3000 ; move to safe pos\nM400 P300\nM971 S11 C11 O0\nG92 E0\nG1 X0 F18000\nM623\n\nM622.1 S1\nM1002 judge_flag g39_3rd_layer_detect_flag\nM622 J1\n ; enable nozzle clog detect at 3rd layer\n {if layer_num == 2}\n M400\n G90\n M83\n M204 S5000\n G0 Z2 F4000\n G0 X261 Y250 F20000\n M400 P200\n G39 S1\n G0 Z2 F4000\n {endif}\n\n\n M622.1 S1\n M1002 judge_flag g39_detection_flag\n M622 J1\n {if !in_head_wrap_detect_zone}\n M622.1 S0\n M1002 judge_flag g39_mass_exceed_flag\n M622 J1\n {if layer_num > 2}\n G392 S0\n M400\n G90\n M83\n M204 S5000\n G0 Z{max_layer_z + 0.4} F4000\n G39.3 S1\n G0 Z{max_layer_z + 0.4} F4000\n G392 S0\n {endif}\n M623\n {endif}\n M623\nM623\n{endif}\n", "change_filament_gcode": ";===== A1 20240913 =======================\nM1007 S0 ; turn off mass estimation\nG392 S0\nM620 S[next_extruder]A\nM204 S9000\n{if toolchange_count > 1}\nG17\nG2 Z{max_layer_z + 0.4} I0.86 J0.86 P1 F10000 ; spiral lift a little from second lift\n{endif}\nG1 Z{max_layer_z + 3.0} F1200\n\nM400\nM106 P1 S0\nM106 P2 S0\n{if old_filament_temp > 142 && next_extruder < 255}\nM104 S[old_filament_temp]\n{endif}\n\nG1 X267 F18000\n\n{if long_retractions_when_cut[previous_extruder]}\nM620.11 S1 I[previous_extruder] E-{retraction_distances_when_cut[previous_extruder]} F1200\n{else}\nM620.11 S0\n{endif}\nM400\n\nM620.1 E F[old_filament_e_feedrate] T{nozzle_temperature_range_high[previous_extruder]}\nM620.10 A0 F[old_filament_e_feedrate]\nT[next_extruder]\nM620.1 E F[new_filament_e_feedrate] T{nozzle_temperature_range_high[next_extruder]}\nM620.10 A1 F[new_filament_e_feedrate] L[flush_length] H[nozzle_diameter] T[nozzle_temperature_range_high]\n\nG1 Y128 F9000\n\n{if next_extruder < 255}\n\n{if long_retractions_when_cut[previous_extruder]}\nM620.11 S1 I[previous_extruder] E{retraction_distances_when_cut[previous_extruder]} F{old_filament_e_feedrate}\nM628 S1\nG92 E0\nG1 E{retraction_distances_when_cut[previous_extruder]} F[old_filament_e_feedrate]\nM400\nM629 S1\n{else}\nM620.11 S0\n{endif}\n\nM400\nG92 E0\nM628 S0\n\n{if flush_length_1 > 1}\n; FLUSH_START\n; always use highest temperature to flush\nM400\nM1002 set_filament_type:UNKNOWN\nM109 S[nozzle_temperature_range_high]\nM106 P1 S60\n{if flush_length_1 > 23.7}\nG1 E23.7 F{old_filament_e_feedrate} ; do not need pulsatile flushing for start part\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{old_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\n{else}\nG1 E{flush_length_1} F{old_filament_e_feedrate}\n{endif}\n; FLUSH_END\nG1 E-[old_retract_length_toolchange] F1800\nG1 E[old_retract_length_toolchange] F300\nM400\nM1002 set_filament_type:{filament_type[next_extruder]}\n{endif}\n\n{if flush_length_1 > 45 && flush_length_2 > 1}\n; WIPE\nM400\nM106 P1 S178\nM400 S3\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nM400\nM106 P1 S0\n{endif}\n\n{if flush_length_2 > 1}\nM106 P1 S60\n; FLUSH_START\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_2 > 45 && flush_length_3 > 1}\n; WIPE\nM400\nM106 P1 S178\nM400 S3\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nM400\nM106 P1 S0\n{endif}\n\n{if flush_length_3 > 1}\nM106 P1 S60\n; FLUSH_START\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_3 > 45 && flush_length_4 > 1}\n; WIPE\nM400\nM106 P1 S178\nM400 S3\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nM400\nM106 P1 S0\n{endif}\n\n{if flush_length_4 > 1}\nM106 P1 S60\n; FLUSH_START\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\n; FLUSH_END\n{endif}\n\nM629\n\nM400\nM106 P1 S60\nM109 S[new_filament_temp]\nG1 E6 F{new_filament_e_feedrate} ;Compensate for filament spillage during waiting temperature\nM400\nG92 E0\nG1 E-[new_retract_length_toolchange] F1800\nM400\nM106 P1 S178\nM400 S3\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nG1 X-38.2 F18000\nG1 X-48.2 F3000\nM400\nG1 Z{max_layer_z + 3.0} F3000\nM106 P1 S0\n{if layer_z <= (initial_layer_print_height + 0.001)}\nM204 S[initial_layer_acceleration]\n{else}\nM204 S[default_acceleration]\n{endif}\n{else}\nG1 X[x_after_toolchange] Y[y_after_toolchange] Z[z_after_toolchange] F12000\n{endif}\n\nM622.1 S0\nM9833 F{outer_wall_volumetric_speed/2.4} A0.3 ; cali dynamic extrusion compensation\nM1002 judge_flag filament_need_cali_flag\nM622 J1\n G92 E0\n G1 E-[new_retract_length_toolchange] F1800\n M400\n \n M106 P1 S178\n M400 S4\n G1 X-38.2 F18000\n G1 X-48.2 F3000\n G1 X-38.2 F18000 ;wipe and shake\n G1 X-48.2 F3000\n G1 X-38.2 F12000 ;wipe and shake\n G1 X-48.2 F3000\n M400\n M106 P1 S0 \nM623\n\nM621 S[next_extruder]A\nG392 S0\n\nM1007 S1\n" -} \ No newline at end of file +} diff --git a/backend/profiles/profiles/BBL/machine/fdm_bbl_3dp_001_common.json b/backend/profiles/profiles/BBL/machine/fdm_bbl_3dp_001_common.json index dfdb9e7..a19df24 100644 --- a/backend/profiles/profiles/BBL/machine/fdm_bbl_3dp_001_common.json +++ b/backend/profiles/profiles/BBL/machine/fdm_bbl_3dp_001_common.json @@ -139,4 +139,4 @@ "layer_change_gcode": "; layer num/total_layer_count: {layer_num+1}/[total_layer_count]\nM622.1 S1 ; for prev firmware, default turned on\nM1002 judge_flag timelapse_record_flag\nM622 J1\n{if timelapse_type == 0} ; timelapse without wipe tower\nM971 S11 C10 O0\n{elsif timelapse_type == 1} ; timelapse with wipe tower\nG92 E0\nG1 E-[retraction_length] F1800\nG17\nG2 Z{layer_z + 0.4} I0.86 J0.86 P1 F20000 ; spiral lift a little\nG1 X65 Y245 F20000 ; move to safe pos\nG17\nG2 Z{layer_z} I0.86 J0.86 P1 F20000\nG1 Y265 F3000\nM400 P300\nM971 S11 C10 O0\nG92 E0\nG1 E[retraction_length] F300\nG1 X100 F5000\nG1 Y255 F20000\n{endif}\nM623\n; update layer progress\nM73 L{layer_num+1}\nM991 S0 P{layer_num} ;notify layer change", "change_filament_gcode": "M620 S[next_extruder]A\nM204 S9000\n{if toolchange_count > 1 && (z_hop_types[current_extruder] == 0 || z_hop_types[current_extruder] == 3)}\nG17\nG2 Z{z_after_toolchange + 0.4} I0.86 J0.86 P1 F10000 ; spiral lift a little from second lift\n{endif}\nG1 Z{max_layer_z + 3.0} F1200\n\nG1 X70 F21000\nG1 Y245\nG1 Y265 F3000\nM400\nM106 P1 S0\nM106 P2 S0\n{if old_filament_temp > 142 && next_extruder < 255}\nM104 S[old_filament_temp]\n{endif}\nG1 X90 F3000\nG1 Y255 F4000\nG1 X100 F5000\nG1 X120 F15000\n\nG1 X20 Y50 F21000\nG1 Y-3\n{if toolchange_count == 2}\n; get travel path for change filament\nM620.1 X[travel_point_1_x] Y[travel_point_1_y] F21000 P0\nM620.1 X[travel_point_2_x] Y[travel_point_2_y] F21000 P1\nM620.1 X[travel_point_3_x] Y[travel_point_3_y] F21000 P2\n{endif}\nM620.1 E F[old_filament_e_feedrate] T{nozzle_temperature_range_high[previous_extruder]}\nT[next_extruder]\nM620.1 E F[new_filament_e_feedrate] T{nozzle_temperature_range_high[next_extruder]}\n\n{if next_extruder < 255}\nM400\n\nG92 E0\n{if flush_length_1 > 1}\n; FLUSH_START\n; always use highest temperature to flush\nM400\nM109 S[nozzle_temperature_range_high]\n{if flush_length_1 > 23.7}\nG1 E23.7 F{old_filament_e_feedrate} ; do not need pulsatile flushing for start part\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{old_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\nG1 E{(flush_length_1 - 23.7) * 0.02} F50\nG1 E{(flush_length_1 - 23.7) * 0.23} F{new_filament_e_feedrate}\n{else}\nG1 E{flush_length_1} F{old_filament_e_feedrate}\n{endif}\n; FLUSH_END\nG1 E-[old_retract_length_toolchange] F1800\nG1 E[old_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_2 > 1}\n; FLUSH_START\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\nG1 E{flush_length_2 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_2 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_3 > 1}\n; FLUSH_START\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\nG1 E{flush_length_3 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_3 * 0.02} F50\n; FLUSH_END\nG1 E-[new_retract_length_toolchange] F1800\nG1 E[new_retract_length_toolchange] F300\n{endif}\n\n{if flush_length_4 > 1}\n; FLUSH_START\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\nG1 E{flush_length_4 * 0.18} F{new_filament_e_feedrate}\nG1 E{flush_length_4 * 0.02} F50\n; FLUSH_END\n{endif}\n; FLUSH_START\nM400\nM109 S[new_filament_temp]\nG1 E2 F{new_filament_e_feedrate} ;Compensate for filament spillage during waiting temperature\n; FLUSH_END\nM400\nG92 E0\nG1 E-[new_retract_length_toolchange] F1800\nM106 P1 S255\nM400 S3\nG1 X80 F15000\nG1 X60 F15000\nG1 X80 F15000\nG1 X60 F15000; shake to put down garbage\n\nG1 X70 F5000\nG1 X90 F3000\nG1 Y255 F4000\nG1 X100 F5000\nG1 Y265 F5000\nG1 X70 F10000\nG1 X100 F5000\nG1 X70 F10000\nG1 X100 F5000\nG1 X165 F15000; wipe and shake\nG1 Y256 ; move Y to aside, prevent collision\nM400\nG1 Z{max_layer_z + 3.0} F3000\n{if layer_z <= (initial_layer_print_height + 0.001)}\nM204 S[initial_layer_acceleration]\n{else}\nM204 S[default_acceleration]\n{endif}\n{else}\nG1 X[x_after_toolchange] Y[y_after_toolchange] Z[z_after_toolchange] F12000\n{endif}\nM621 S[next_extruder]A", "machine_pause_gcode": "M400 U1" -} \ No newline at end of file +} diff --git a/backend/qodana.yaml b/backend/qodana.yaml new file mode 100644 index 0000000..05a38ce --- /dev/null +++ b/backend/qodana.yaml @@ -0,0 +1,48 @@ +#-------------------------------------------------------------------------------# +# Qodana analysis is configured by qodana.yaml file # +# https://www.jetbrains.com/help/qodana/qodana-yaml.html # +#-------------------------------------------------------------------------------# + +################################################################################# +# WARNING: Do not store sensitive information in this file, # +# as its contents will be included in the Qodana report. # +################################################################################# +version: "1.0" + +#Specify inspection profile for code analysis +profile: + name: qodana.starter + +#Enable inspections +#include: +# - name: + +#Disable inspections +#exclude: +# - name: +# paths: +# - + +projectJDK: "21" #(Applied in CI/CD pipeline) + +#Execute shell command before Qodana execution (Applied in CI/CD pipeline) +#bootstrap: sh ./prepare-qodana.sh + +#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) +#plugins: +# - id: #(plugin id can be found at https://plugins.jetbrains.com) + +# Quality gate. Will fail the CI/CD pipeline if any condition is not met +# severityThresholds - configures maximum thresholds for different problem severities +# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code +# Code Coverage is available in Ultimate and Ultimate Plus plans +#failureConditions: +# severityThresholds: +# any: 15 +# critical: 5 +# testCoverageThresholds: +# fresh: 70 +# total: 50 + +#Specify Qodana linter for analysis (Applied in CI/CD pipeline) +linter: jetbrains/qodana-jvm:2025.3 diff --git a/backend/src/main/java/com/printcalculator/BackendApplication.java b/backend/src/main/java/com/printcalculator/BackendApplication.java index f8209a7..5203f11 100644 --- a/backend/src/main/java/com/printcalculator/BackendApplication.java +++ b/backend/src/main/java/com/printcalculator/BackendApplication.java @@ -2,8 +2,15 @@ package com.printcalculator; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.transaction.annotation.EnableTransactionManagement; -@SpringBootApplication +@SpringBootApplication(exclude = {UserDetailsServiceAutoConfiguration.class}) +@EnableTransactionManagement +@EnableScheduling +@EnableAsync public class BackendApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/com/printcalculator/config/CorsConfig.java b/backend/src/main/java/com/printcalculator/config/CorsConfig.java index 2e6f92c..b3a9869 100644 --- a/backend/src/main/java/com/printcalculator/config/CorsConfig.java +++ b/backend/src/main/java/com/printcalculator/config/CorsConfig.java @@ -11,8 +11,16 @@ public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") - .allowedOrigins("http://localhost", "http://localhost:4200", "http://localhost:80", "http://127.0.0.1") - .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedOrigins( + "http://localhost", + "http://localhost:4200", + "http://localhost:80", + "http://127.0.0.1", + "https://dev.3d-fab.ch", + "https://int.3d-fab.ch", + "https://3d-fab.ch" + ) + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") .allowedHeaders("*") .allowCredentials(true); } diff --git a/backend/src/main/java/com/printcalculator/config/SecurityConfig.java b/backend/src/main/java/com/printcalculator/config/SecurityConfig.java new file mode 100644 index 0000000..e7e6670 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/config/SecurityConfig.java @@ -0,0 +1,47 @@ +package com.printcalculator.config; + +import com.printcalculator.security.AdminSessionAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + AdminSessionAuthenticationFilter adminSessionAuthenticationFilter + ) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(Customizer.withDefaults()) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers("/actuator/health", "/actuator/health/**").permitAll() + .requestMatchers("/actuator/**").denyAll() + .requestMatchers("/api/admin/auth/login").permitAll() + .requestMatchers("/api/admin/**").authenticated() + .anyRequest().permitAll() + ) + .exceptionHandling(ex -> ex.authenticationEntryPoint((request, response, authException) -> { + response.setStatus(401); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}"); + })) + .addFilterBefore(adminSessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java new file mode 100644 index 0000000..b3918c5 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/CustomQuoteRequestController.java @@ -0,0 +1,206 @@ +package com.printcalculator.controller; + +import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.entity.CustomQuoteRequestAttachment; +import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; +import com.printcalculator.repository.CustomQuoteRequestRepository; +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.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; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; + +@RestController +@RequestMapping("/api/custom-quote-requests") +public class CustomQuoteRequestController { + + private final CustomQuoteRequestRepository requestRepo; + private final CustomQuoteRequestAttachmentRepository attachmentRepo; + private final com.printcalculator.service.ClamAVService clamAVService; + + // 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}$"); + private static final Set FORBIDDEN_COMPRESSED_EXTENSIONS = Set.of( + "zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "tbz2", "xz", "txz", "zst" + ); + private static final Set FORBIDDEN_COMPRESSED_MIME_TYPES = Set.of( + "application/zip", + "application/x-zip-compressed", + "application/x-rar-compressed", + "application/vnd.rar", + "application/x-7z-compressed", + "application/gzip", + "application/x-gzip", + "application/x-tar", + "application/x-bzip2", + "application/x-xz", + "application/zstd", + "application/x-zstd" + ); + + public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo, + CustomQuoteRequestAttachmentRepository attachmentRepo, + com.printcalculator.service.ClamAVService clamAVService) { + this.requestRepo = requestRepo; + this.attachmentRepo = attachmentRepo; + this.clamAVService = clamAVService; + } + + // 1. Create Custom Quote Request + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Transactional + public ResponseEntity createCustomQuoteRequest( + @Valid @RequestPart("request") com.printcalculator.dto.QuoteRequestDto requestDto, + @RequestPart(value = "files", required = false) List files + ) throws IOException { + if (!requestDto.isAcceptTerms() || !requestDto.isAcceptPrivacy()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Accettazione Termini e Privacy obbligatoria." + ); + } + + // 1. Create Request + CustomQuoteRequest request = new CustomQuoteRequest(); + request.setRequestType(requestDto.getRequestType()); + request.setCustomerType(requestDto.getCustomerType()); + request.setEmail(requestDto.getEmail()); + request.setPhone(requestDto.getPhone()); + request.setName(requestDto.getName()); + request.setCompanyName(requestDto.getCompanyName()); + request.setContactPerson(requestDto.getContactPerson()); + request.setMessage(requestDto.getMessage()); + request.setStatus("PENDING"); + request.setCreatedAt(OffsetDateTime.now()); + request.setUpdatedAt(OffsetDateTime.now()); + + request = requestRepo.save(request); + + // 2. Handle Attachments + if (files != null && !files.isEmpty()) { + if (files.size() > 15) { + throw new IOException("Too many files. Max 15 allowed."); + } + + for (MultipartFile file : files) { + if (file.isEmpty()) continue; + + if (isCompressedFile(file)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Compressed files are not allowed." + ); + } + + // Scan for virus + clamAVService.scan(file.getInputStream()); + + CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment(); + attachment.setRequest(request); + attachment.setOriginalFilename(file.getOriginalFilename()); + attachment.setMimeType(file.getContentType()); + attachment.setFileSizeBytes(file.getSize()); + attachment.setCreatedAt(OffsetDateTime.now()); + + // Generate path + UUID fileUuid = UUID.randomUUID(); + String storedFilename = fileUuid + ".upload"; + + // Note: We don't have attachment ID yet. + // We'll save attachment first to get ID. + attachment.setStoredFilename(storedFilename); + attachment.setStoredRelativePath("PENDING"); + + attachment = attachmentRepo.save(attachment); + + Path relativePath = Path.of( + "quote-requests", + request.getId().toString(), + "attachments", + attachment.getId().toString(), + storedFilename + ); + attachment.setStoredRelativePath(relativePath.toString()); + attachmentRepo.save(attachment); + + // Save file to disk + Path absolutePath = resolveWithinStorageRoot(relativePath); + Files.createDirectories(absolutePath.getParent()); + try (InputStream inputStream = file.getInputStream()) { + Files.copy(inputStream, absolutePath, StandardCopyOption.REPLACE_EXISTING); + } + } + } + + return ResponseEntity.ok(request); + } + + // 2. Get Request + @GetMapping("/{id}") + public ResponseEntity getCustomQuoteRequest(@PathVariable UUID id) { + return requestRepo.findById(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + // Helper + private String getExtension(String filename) { + if (filename == null) return "dat"; + String cleaned = StringUtils.cleanPath(filename); + if (cleaned.contains("..")) { + return "dat"; + } + int i = cleaned.lastIndexOf('.'); + if (i > 0 && i < cleaned.length() - 1) { + String ext = cleaned.substring(i + 1).toLowerCase(Locale.ROOT); + if (SAFE_EXTENSION_PATTERN.matcher(ext).matches()) { + return ext; + } + } + return "dat"; + } + + private boolean isCompressedFile(MultipartFile file) { + String ext = getExtension(file.getOriginalFilename()); + if (FORBIDDEN_COMPRESSED_EXTENSIONS.contains(ext)) { + return true; + } + String mime = file.getContentType(); + return mime != null && FORBIDDEN_COMPRESSED_MIME_TYPES.contains(mime.toLowerCase()); + } + + private Path resolveWithinStorageRoot(Path relativePath) { + try { + Path normalizedRelative = relativePath.normalize(); + if (normalizedRelative.isAbsolute()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); + } + Path absolutePath = STORAGE_ROOT.resolve(normalizedRelative).normalize(); + if (!absolutePath.startsWith(STORAGE_ROOT)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); + } + return absolutePath; + } catch (InvalidPathException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid attachment path"); + } + } +} diff --git a/backend/src/main/java/com/printcalculator/controller/OptionsController.java b/backend/src/main/java/com/printcalculator/controller/OptionsController.java index 7eb7251..3b2295b 100644 --- a/backend/src/main/java/com/printcalculator/controller/OptionsController.java +++ b/backend/src/main/java/com/printcalculator/controller/OptionsController.java @@ -3,18 +3,27 @@ package com.printcalculator.controller; import com.printcalculator.dto.OptionsResponse; import com.printcalculator.entity.FilamentMaterialType; import com.printcalculator.entity.FilamentVariant; -import com.printcalculator.entity.*; // This line replaces specific entity imports +import com.printcalculator.entity.LayerHeightOption; +import com.printcalculator.entity.MaterialOrcaProfileMap; +import com.printcalculator.entity.NozzleOption; +import com.printcalculator.entity.PrinterMachine; +import com.printcalculator.entity.PrinterMachineProfile; import com.printcalculator.repository.FilamentMaterialTypeRepository; import com.printcalculator.repository.FilamentVariantRepository; import com.printcalculator.repository.LayerHeightOptionRepository; +import com.printcalculator.repository.MaterialOrcaProfileMapRepository; import com.printcalculator.repository.NozzleOptionRepository; +import com.printcalculator.repository.PrinterMachineRepository; +import com.printcalculator.service.OrcaProfileResolver; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.ArrayList; +import java.math.BigDecimal; import java.util.Comparator; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; @RestController @@ -24,89 +33,177 @@ public class OptionsController { private final FilamentVariantRepository variantRepo; private final LayerHeightOptionRepository layerHeightRepo; private final NozzleOptionRepository nozzleRepo; + private final PrinterMachineRepository printerMachineRepo; + private final MaterialOrcaProfileMapRepository materialOrcaMapRepo; + private final OrcaProfileResolver orcaProfileResolver; public OptionsController(FilamentMaterialTypeRepository materialRepo, FilamentVariantRepository variantRepo, LayerHeightOptionRepository layerHeightRepo, - NozzleOptionRepository nozzleRepo) { + NozzleOptionRepository nozzleRepo, + PrinterMachineRepository printerMachineRepo, + MaterialOrcaProfileMapRepository materialOrcaMapRepo, + OrcaProfileResolver orcaProfileResolver) { this.materialRepo = materialRepo; this.variantRepo = variantRepo; this.layerHeightRepo = layerHeightRepo; this.nozzleRepo = nozzleRepo; + this.printerMachineRepo = printerMachineRepo; + this.materialOrcaMapRepo = materialOrcaMapRepo; + this.orcaProfileResolver = orcaProfileResolver; } @GetMapping("/api/calculator/options") - public ResponseEntity getOptions() { - // 1. Materials & Variants + public ResponseEntity getOptions( + @RequestParam(value = "printerMachineId", required = false) Long printerMachineId, + @RequestParam(value = "nozzleDiameter", required = false) Double nozzleDiameter + ) { List types = materialRepo.findAll(); - List allVariants = variantRepo.findAll(); + List allVariants = variantRepo.findAll().stream() + .filter(v -> Boolean.TRUE.equals(v.getIsActive())) + .sorted(Comparator + .comparing((FilamentVariant v) -> safeMaterialCode(v.getFilamentMaterialType()), String.CASE_INSENSITIVE_ORDER) + .thenComparing(v -> safeString(v.getVariantDisplayName()), String.CASE_INSENSITIVE_ORDER)) + .toList(); + + Set compatibleMaterialTypeIds = resolveCompatibleMaterialTypeIds(printerMachineId, nozzleDiameter); List materialOptions = types.stream() + .sorted(Comparator.comparing(t -> safeString(t.getMaterialCode()), String.CASE_INSENSITIVE_ORDER)) .map(type -> { + if (!compatibleMaterialTypeIds.isEmpty() && !compatibleMaterialTypeIds.contains(type.getId())) { + return null; + } + List variants = allVariants.stream() - .filter(v -> v.getFilamentMaterialType().getId().equals(type.getId()) && v.getIsActive()) + .filter(v -> v.getFilamentMaterialType() != null + && v.getFilamentMaterialType().getId().equals(type.getId())) .map(v -> new OptionsResponse.VariantOption( + v.getId(), v.getVariantDisplayName(), v.getColorName(), - getColorHex(v.getColorName()), // Need helper or store hex in DB - v.getStockSpools().doubleValue() <= 0 + resolveHexColor(v), + v.getFinishType() != null ? v.getFinishType() : "GLOSSY", + v.getStockSpools() != null ? v.getStockSpools().doubleValue() : 0d, + toStockFilamentGrams(v), + v.getStockSpools() == null || v.getStockSpools().doubleValue() <= 0 )) .collect(Collectors.toList()); - // Only include material if it has active variants - if (variants.isEmpty()) return null; + if (variants.isEmpty()) { + return null; + } return new OptionsResponse.MaterialOption( type.getMaterialCode(), - type.getMaterialCode() + (type.getIsFlexible() ? " (Flexible)" : " (Standard)"), + type.getMaterialCode() + (Boolean.TRUE.equals(type.getIsFlexible()) ? " (Flexible)" : " (Standard)"), variants ); }) .filter(m -> m != null) - .collect(Collectors.toList()); + .toList(); - // 2. Qualities (Static as per user request) List qualities = List.of( new OptionsResponse.QualityOption("draft", "Draft"), new OptionsResponse.QualityOption("standard", "Standard"), new OptionsResponse.QualityOption("extra_fine", "High Definition") ); - // 3. Infill Patterns (Static as per user request) List patterns = List.of( new OptionsResponse.InfillPatternOption("grid", "Grid"), new OptionsResponse.InfillPatternOption("gyroid", "Gyroid"), new OptionsResponse.InfillPatternOption("cubic", "Cubic") ); - // 4. Layer Heights List layers = layerHeightRepo.findAll().stream() - .filter(l -> l.getIsActive()) + .filter(l -> Boolean.TRUE.equals(l.getIsActive())) .sorted(Comparator.comparing(LayerHeightOption::getLayerHeightMm)) .map(l -> new OptionsResponse.LayerHeightOptionDTO( l.getLayerHeightMm().doubleValue(), String.format("%.2f mm", l.getLayerHeightMm()) )) - .collect(Collectors.toList()); + .toList(); - // 5. Nozzles List nozzles = nozzleRepo.findAll().stream() - .filter(n -> n.getIsActive()) + .filter(n -> Boolean.TRUE.equals(n.getIsActive())) .sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm)) .map(n -> new OptionsResponse.NozzleOptionDTO( n.getNozzleDiameterMm().doubleValue(), String.format("%.1f mm%s", n.getNozzleDiameterMm(), - n.getExtraNozzleChangeFeeChf().doubleValue() > 0 - ? String.format(" (+ %.2f CHF)", n.getExtraNozzleChangeFeeChf()) - : " (Standard)") + n.getExtraNozzleChangeFeeChf().doubleValue() > 0 + ? String.format(" (+ %.2f CHF)", n.getExtraNozzleChangeFeeChf()) + : " (Standard)") )) - .collect(Collectors.toList()); + .toList(); return ResponseEntity.ok(new OptionsResponse(materialOptions, qualities, patterns, layers, nozzles)); } - // Temporary helper until we add hex to DB + private Set resolveCompatibleMaterialTypeIds(Long printerMachineId, Double nozzleDiameter) { + PrinterMachine machine = null; + if (printerMachineId != null) { + machine = printerMachineRepo.findById(printerMachineId).orElse(null); + } + if (machine == null) { + machine = printerMachineRepo.findFirstByIsActiveTrue().orElse(null); + } + if (machine == null) { + return Set.of(); + } + + BigDecimal nozzle = nozzleDiameter != null + ? BigDecimal.valueOf(nozzleDiameter) + : BigDecimal.valueOf(0.40); + + PrinterMachineProfile machineProfile = orcaProfileResolver + .resolveMachineProfile(machine, nozzle) + .orElse(null); + + if (machineProfile == null) { + return Set.of(); + } + + List maps = materialOrcaMapRepo.findByPrinterMachineProfileAndIsActiveTrue(machineProfile); + return maps.stream() + .map(MaterialOrcaProfileMap::getFilamentMaterialType) + .filter(m -> m != null && m.getId() != null) + .map(FilamentMaterialType::getId) + .collect(Collectors.toSet()); + } + + private String resolveHexColor(FilamentVariant variant) { + if (variant.getColorHex() != null && !variant.getColorHex().isBlank()) { + return variant.getColorHex(); + } + return getColorHex(variant.getColorName()); + } + + private double toStockFilamentGrams(FilamentVariant variant) { + if (variant.getStockSpools() == null || variant.getSpoolNetKg() == null) { + return 0d; + } + return variant.getStockSpools() + .multiply(variant.getSpoolNetKg()) + .multiply(BigDecimal.valueOf(1000)) + .doubleValue(); + } + + private String safeMaterialCode(FilamentMaterialType type) { + if (type == null || type.getMaterialCode() == null) { + return ""; + } + return type.getMaterialCode(); + } + + private String safeString(String value) { + return value == null ? "" : value; + } + + // Temporary helper for legacy values where color hex is not yet set in DB private String getColorHex(String colorName) { + if (colorName == null) { + return "#9e9e9e"; + } String lower = colorName.toLowerCase(); if (lower.contains("black") || lower.contains("nero")) return "#1a1a1a"; if (lower.contains("white") || lower.contains("bianco")) return "#f5f5f5"; @@ -120,6 +217,6 @@ public class OptionsController { } if (lower.contains("purple") || lower.contains("viola")) return "#7b1fa2"; if (lower.contains("yellow") || lower.contains("giallo")) return "#fbc02d"; - return "#9e9e9e"; // Default grey + return "#9e9e9e"; } } diff --git a/backend/src/main/java/com/printcalculator/controller/OrderController.java b/backend/src/main/java/com/printcalculator/controller/OrderController.java new file mode 100644 index 0000000..e002fb9 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/OrderController.java @@ -0,0 +1,360 @@ +package com.printcalculator.controller; + +import com.printcalculator.dto.*; +import com.printcalculator.entity.*; +import com.printcalculator.repository.*; +import com.printcalculator.service.InvoicePdfRenderingService; +import com.printcalculator.service.OrderService; +import com.printcalculator.service.PaymentService; +import com.printcalculator.service.QrBillService; +import com.printcalculator.service.StorageService; +import com.printcalculator.service.TwintPaymentService; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import jakarta.validation.Valid; + +import java.io.IOException; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; + +import java.util.List; +import java.util.UUID; +import java.util.Map; +import java.util.HashMap; +import java.util.Base64; +import java.util.stream.Collectors; +import java.net.URI; +import java.util.Locale; +import java.util.regex.Pattern; + +@RestController +@RequestMapping("/api/orders") +public class OrderController { + private static final Pattern SAFE_EXTENSION_PATTERN = Pattern.compile("^[a-z0-9]{1,10}$"); + + private final OrderService orderService; + private final OrderRepository orderRepo; + private final OrderItemRepository orderItemRepo; + private final QuoteSessionRepository quoteSessionRepo; + private final QuoteLineItemRepository quoteLineItemRepo; + private final CustomerRepository customerRepo; + private final StorageService storageService; + private final InvoicePdfRenderingService invoiceService; + private final QrBillService qrBillService; + private final TwintPaymentService twintPaymentService; + private final PaymentService paymentService; + private final PaymentRepository paymentRepo; + + + public OrderController(OrderService orderService, + OrderRepository orderRepo, + OrderItemRepository orderItemRepo, + QuoteSessionRepository quoteSessionRepo, + QuoteLineItemRepository quoteLineItemRepo, + CustomerRepository customerRepo, + StorageService storageService, + InvoicePdfRenderingService invoiceService, + QrBillService qrBillService, + TwintPaymentService twintPaymentService, + PaymentService paymentService, + PaymentRepository paymentRepo) { + this.orderService = orderService; + this.orderRepo = orderRepo; + this.orderItemRepo = orderItemRepo; + this.quoteSessionRepo = quoteSessionRepo; + this.quoteLineItemRepo = quoteLineItemRepo; + this.customerRepo = customerRepo; + this.storageService = storageService; + this.invoiceService = invoiceService; + this.qrBillService = qrBillService; + this.twintPaymentService = twintPaymentService; + this.paymentService = paymentService; + this.paymentRepo = paymentRepo; + } + + + // 1. Create Order from Quote + @PostMapping("/from-quote/{quoteSessionId}") + @Transactional + public ResponseEntity createOrderFromQuote( + @PathVariable UUID quoteSessionId, + @Valid @RequestBody com.printcalculator.dto.CreateOrderRequest request + ) { + Order order = orderService.createOrderFromQuote(quoteSessionId, request); + List items = orderItemRepo.findByOrder_Id(order.getId()); + return ResponseEntity.ok(convertToDto(order, items)); + } + + @PostMapping(value = "/{orderId}/items/{orderItemId}/file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Transactional + public ResponseEntity uploadOrderItemFile( + @PathVariable UUID orderId, + @PathVariable UUID orderItemId, + @RequestParam("file") MultipartFile file + ) throws IOException { + + OrderItem item = orderItemRepo.findById(orderItemId) + .orElseThrow(() -> new RuntimeException("OrderItem not found")); + + if (!item.getOrder().getId().equals(orderId)) { + return ResponseEntity.badRequest().build(); + } + + String relativePath = item.getStoredRelativePath(); + Path destinationRelativePath; + if (relativePath == null || relativePath.equals("PENDING")) { + String ext = getExtension(file.getOriginalFilename()); + String storedFilename = UUID.randomUUID() + "." + ext; + destinationRelativePath = Path.of("orders", orderId.toString(), "3d-files", orderItemId.toString(), storedFilename); + item.setStoredRelativePath(destinationRelativePath.toString()); + item.setStoredFilename(storedFilename); + } else { + destinationRelativePath = resolveOrderItemRelativePath(relativePath, orderId, orderItemId); + if (destinationRelativePath == null) { + return ResponseEntity.badRequest().build(); + } + } + + storageService.store(file, destinationRelativePath); + item.setFileSizeBytes(file.getSize()); + item.setMimeType(file.getContentType()); + orderItemRepo.save(item); + + return ResponseEntity.ok().build(); + } + + @GetMapping("/{orderId}") + public ResponseEntity getOrder(@PathVariable UUID orderId) { + return orderRepo.findById(orderId) + .map(o -> { + List items = orderItemRepo.findByOrder_Id(o.getId()); + return ResponseEntity.ok(convertToDto(o, items)); + }) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping("/{orderId}/payments/report") + @Transactional + public ResponseEntity reportPayment( + @PathVariable UUID orderId, + @RequestBody Map payload + ) { + String method = payload.get("method"); + paymentService.reportPayment(orderId, method); + return getOrder(orderId); + } + + @GetMapping("/{orderId}/confirmation") + public ResponseEntity getConfirmation(@PathVariable UUID orderId) { + return generateDocument(orderId, true); + } + + @GetMapping("/{orderId}/invoice") + public ResponseEntity getInvoice(@PathVariable UUID orderId) { + // Paid invoices are sent by email after back-office payment confirmation. + // The public endpoint must not expose a "paid" invoice download. + return ResponseEntity.notFound().build(); + } + + private ResponseEntity generateDocument(UUID orderId, boolean isConfirmation) { + Order order = orderRepo.findById(orderId) + .orElseThrow(() -> new RuntimeException("Order not found")); + + if (isConfirmation) { + Path relativePath = buildConfirmationPdfRelativePath(order); + try { + byte[] existingPdf = storageService.loadAsResource(relativePath).getInputStream().readAllBytes(); + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=\"confirmation-" + getDisplayOrderNumber(order) + ".pdf\"") + .contentType(MediaType.APPLICATION_PDF) + .body(existingPdf); + } catch (Exception ignored) { + // Fallback to on-the-fly generation if the stored file is missing or unreadable. + } + } + + List items = orderItemRepo.findByOrder_Id(orderId); + Payment payment = paymentRepo.findByOrder_Id(orderId).orElse(null); + + byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment); + String typePrefix = isConfirmation ? "confirmation-" : "invoice-"; + String truncatedUuid = order.getId().toString().substring(0, 8); + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=\"" + typePrefix + truncatedUuid + ".pdf\"") + .contentType(MediaType.APPLICATION_PDF) + .body(pdf); + } + + private Path buildConfirmationPdfRelativePath(Order order) { + return Path.of( + "orders", + order.getId().toString(), + "documents", + "confirmation-" + getDisplayOrderNumber(order) + ".pdf" + ); + } + + @GetMapping("/{orderId}/twint") + public ResponseEntity> getTwintPayment(@PathVariable UUID orderId) { + Order order = orderRepo.findById(orderId).orElse(null); + if (order == null) { + return ResponseEntity.notFound().build(); + } + + byte[] qrPng = twintPaymentService.generateQrPng(order, 360); + String qrDataUri = "data:image/png;base64," + Base64.getEncoder().encodeToString(qrPng); + + Map data = new HashMap<>(); + data.put("paymentUrl", twintPaymentService.getTwintPaymentUrl(order)); + data.put("openUrl", "/api/orders/" + orderId + "/twint/open"); + data.put("qrImageUrl", "/api/orders/" + orderId + "/twint/qr"); + data.put("qrImageDataUri", qrDataUri); + return ResponseEntity.ok(data); + } + + @GetMapping("/{orderId}/twint/open") + public ResponseEntity openTwintPayment(@PathVariable UUID orderId) { + Order order = orderRepo.findById(orderId).orElse(null); + if (order == null) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.status(302) + .location(URI.create(twintPaymentService.getTwintPaymentUrl(order))) + .build(); + } + + @GetMapping("/{orderId}/twint/qr") + public ResponseEntity getTwintQr( + @PathVariable UUID orderId, + @RequestParam(defaultValue = "320") int size + ) { + Order order = orderRepo.findById(orderId).orElse(null); + if (order == null) { + return ResponseEntity.notFound().build(); + } + + int normalizedSize = Math.max(200, Math.min(size, 600)); + byte[] png = twintPaymentService.generateQrPng(order, normalizedSize); + + return ResponseEntity.ok() + .contentType(MediaType.IMAGE_PNG) + .body(png); + } + + private String getExtension(String filename) { + if (filename == null) return "stl"; + String cleaned = StringUtils.cleanPath(filename); + if (cleaned.contains("..")) { + return "stl"; + } + int i = cleaned.lastIndexOf('.'); + if (i > 0 && i < cleaned.length() - 1) { + String ext = cleaned.substring(i + 1).toLowerCase(Locale.ROOT); + if (SAFE_EXTENSION_PATTERN.matcher(ext).matches()) { + return ext; + } + } + return "stl"; + } + + 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 OrderDto convertToDto(Order order, List items) { + OrderDto dto = new OrderDto(); + dto.setId(order.getId()); + dto.setOrderNumber(getDisplayOrderNumber(order)); + dto.setStatus(order.getStatus()); + + paymentRepo.findByOrder_Id(order.getId()).ifPresent(p -> { + dto.setPaymentStatus(p.getStatus()); + dto.setPaymentMethod(p.getMethod()); + }); + + dto.setCustomerEmail(order.getCustomerEmail()); + dto.setCustomerPhone(order.getCustomerPhone()); + dto.setPreferredLanguage(order.getPreferredLanguage()); + dto.setBillingCustomerType(order.getBillingCustomerType()); + dto.setCurrency(order.getCurrency()); + dto.setSetupCostChf(order.getSetupCostChf()); + dto.setShippingCostChf(order.getShippingCostChf()); + dto.setDiscountChf(order.getDiscountChf()); + dto.setSubtotalChf(order.getSubtotalChf()); + dto.setTotalChf(order.getTotalChf()); + dto.setCreatedAt(order.getCreatedAt()); + dto.setShippingSameAsBilling(order.getShippingSameAsBilling()); + + AddressDto billing = new AddressDto(); + billing.setFirstName(order.getBillingFirstName()); + billing.setLastName(order.getBillingLastName()); + billing.setCompanyName(order.getBillingCompanyName()); + billing.setContactPerson(order.getBillingContactPerson()); + billing.setAddressLine1(order.getBillingAddressLine1()); + billing.setAddressLine2(order.getBillingAddressLine2()); + billing.setZip(order.getBillingZip()); + billing.setCity(order.getBillingCity()); + billing.setCountryCode(order.getBillingCountryCode()); + dto.setBillingAddress(billing); + + if (!order.getShippingSameAsBilling()) { + AddressDto shipping = new AddressDto(); + shipping.setFirstName(order.getShippingFirstName()); + shipping.setLastName(order.getShippingLastName()); + shipping.setCompanyName(order.getShippingCompanyName()); + shipping.setContactPerson(order.getShippingContactPerson()); + shipping.setAddressLine1(order.getShippingAddressLine1()); + shipping.setAddressLine2(order.getShippingAddressLine2()); + shipping.setZip(order.getShippingZip()); + shipping.setCity(order.getShippingCity()); + shipping.setCountryCode(order.getShippingCountryCode()); + dto.setShippingAddress(shipping); + } + + List itemDtos = items.stream().map(i -> { + OrderItemDto idto = new OrderItemDto(); + idto.setId(i.getId()); + idto.setOriginalFilename(i.getOriginalFilename()); + idto.setMaterialCode(i.getMaterialCode()); + idto.setColorCode(i.getColorCode()); + idto.setQuantity(i.getQuantity()); + idto.setPrintTimeSeconds(i.getPrintTimeSeconds()); + idto.setMaterialGrams(i.getMaterialGrams()); + idto.setUnitPriceChf(i.getUnitPriceChf()); + idto.setLineTotalChf(i.getLineTotalChf()); + return idto; + }).collect(Collectors.toList()); + dto.setItems(itemDtos); + + return dto; + } + + private String getDisplayOrderNumber(Order order) { + String orderNumber = order.getOrderNumber(); + if (orderNumber != null && !orderNumber.isBlank()) { + return orderNumber; + } + return order.getId() != null ? order.getId().toString() : "unknown"; + } + +} diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteController.java b/backend/src/main/java/com/printcalculator/controller/QuoteController.java index 018b613..45369c1 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteController.java @@ -22,15 +22,17 @@ public class QuoteController { private final SlicerService slicerService; private final QuoteCalculator quoteCalculator; private final PrinterMachineRepository machineRepo; + private final com.printcalculator.service.ClamAVService clamAVService; // Defaults (using aliases defined in ProfileManager) private static final String DEFAULT_FILAMENT = "pla_basic"; private static final String DEFAULT_PROCESS = "standard"; - public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo) { + public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, com.printcalculator.service.ClamAVService clamAVService) { this.slicerService = slicerService; this.quoteCalculator = quoteCalculator; this.machineRepo = machineRepo; + this.clamAVService = clamAVService; } @PostMapping("/api/quote") @@ -99,6 +101,9 @@ public class QuoteController { return ResponseEntity.badRequest().build(); } + // Scan for virus + clamAVService.scan(file.getInputStream()); + // Fetch Default Active Machine PrinterMachine machine = machineRepo.findFirstByIsActiveTrue() .orElseThrow(() -> new IOException("No active printer found in database")); diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java new file mode 100644 index 0000000..58ccb50 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -0,0 +1,534 @@ +package com.printcalculator.controller; + +import com.printcalculator.entity.FilamentMaterialType; +import com.printcalculator.entity.FilamentVariant; +import com.printcalculator.entity.PrinterMachine; +import com.printcalculator.entity.QuoteLineItem; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.model.ModelDimensions; +import com.printcalculator.model.PrintStats; +import com.printcalculator.model.QuoteResult; +import com.printcalculator.repository.FilamentMaterialTypeRepository; +import com.printcalculator.repository.FilamentVariantRepository; +import com.printcalculator.repository.PrinterMachineRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.repository.QuoteSessionRepository; +import com.printcalculator.service.OrcaProfileResolver; +import com.printcalculator.service.QuoteCalculator; +import com.printcalculator.service.SlicerService; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.Optional; +import java.util.Locale; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; + +@RestController +@RequestMapping("/api/quote-sessions") + +public class QuoteSessionController { + private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize(); + + private final QuoteSessionRepository sessionRepo; + private final QuoteLineItemRepository lineItemRepo; + private final SlicerService slicerService; + private final QuoteCalculator quoteCalculator; + private final PrinterMachineRepository machineRepo; + private final FilamentMaterialTypeRepository materialRepo; + private final FilamentVariantRepository variantRepo; + private final OrcaProfileResolver orcaProfileResolver; + private final com.printcalculator.repository.PricingPolicyRepository pricingRepo; + private final com.printcalculator.service.ClamAVService clamAVService; + + public QuoteSessionController(QuoteSessionRepository sessionRepo, + QuoteLineItemRepository lineItemRepo, + SlicerService slicerService, + QuoteCalculator quoteCalculator, + PrinterMachineRepository machineRepo, + FilamentMaterialTypeRepository materialRepo, + FilamentVariantRepository variantRepo, + OrcaProfileResolver orcaProfileResolver, + com.printcalculator.repository.PricingPolicyRepository pricingRepo, + com.printcalculator.service.ClamAVService clamAVService) { + this.sessionRepo = sessionRepo; + this.lineItemRepo = lineItemRepo; + this.slicerService = slicerService; + this.quoteCalculator = quoteCalculator; + this.machineRepo = machineRepo; + this.materialRepo = materialRepo; + this.variantRepo = variantRepo; + this.orcaProfileResolver = orcaProfileResolver; + this.pricingRepo = pricingRepo; + this.clamAVService = clamAVService; + } + + // 1. Start a new empty session + @PostMapping(value = "") + @Transactional + public ResponseEntity createSession() { + QuoteSession session = new QuoteSession(); + session.setStatus("ACTIVE"); + session.setPricingVersion("v1"); + // Default material/settings will be set when items are added or updated? + // For now set safe defaults + session.setMaterialCode("PLA"); + session.setSupportsEnabled(false); + session.setCreatedAt(OffsetDateTime.now()); + session.setExpiresAt(OffsetDateTime.now().plusDays(30)); + + var policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); + session.setSetupCostChf(quoteCalculator.calculateSessionSetupFee(policy)); + + session = sessionRepo.save(session); + return ResponseEntity.ok(session); + } + + // 2. Add item to existing session + @PostMapping(value = "/{id}/line-items", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Transactional + public ResponseEntity addItemToExistingSession( + @PathVariable UUID id, + @RequestPart("settings") com.printcalculator.dto.PrintSettingsDto settings, + @RequestPart("file") MultipartFile file + ) throws IOException { + QuoteSession session = sessionRepo.findById(id) + .orElseThrow(() -> new RuntimeException("Session not found")); + + QuoteLineItem item = addItemToSession(session, file, settings); + return ResponseEntity.ok(item); + } + + // Helper to add item + private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException { + if (file.isEmpty()) throw new IOException("File is empty"); + + // Scan for virus + clamAVService.scan(file.getInputStream()); + + // 1. Define Persistent Storage Path + // Structure: storage_quotes/{sessionId}/{uuid}.{ext} + Path sessionStorageDir = QUOTE_STORAGE_ROOT.resolve(session.getId().toString()).normalize(); + if (!sessionStorageDir.startsWith(QUOTE_STORAGE_ROOT)) { + throw new IOException("Invalid quote session storage path"); + } + Files.createDirectories(sessionStorageDir); + + String originalFilename = file.getOriginalFilename(); + String ext = getSafeExtension(originalFilename, "stl"); + String storedFilename = UUID.randomUUID() + "." + ext; + Path persistentPath = sessionStorageDir.resolve(storedFilename).normalize(); + if (!persistentPath.startsWith(sessionStorageDir)) { + throw new IOException("Invalid quote line-item storage path"); + } + + // Save file + try (InputStream inputStream = file.getInputStream()) { + Files.copy(inputStream, persistentPath, StandardCopyOption.REPLACE_EXISTING); + } + + try { + // Apply Basic/Advanced Logic + applyPrintSettings(settings); + + BigDecimal nozzleDiameter = BigDecimal.valueOf(settings.getNozzleDiameter() != null ? settings.getNozzleDiameter() : 0.4); + + // Pick machine (selected machine if provided, otherwise first active) + PrinterMachine machine = resolvePrinterMachine(settings.getPrinterMachineId()); + + // Resolve selected filament variant + FilamentVariant selectedVariant = resolveFilamentVariant(settings); + + // Update session global settings from the most recent item added + session.setMaterialCode(selectedVariant.getFilamentMaterialType().getMaterialCode()); + session.setNozzleDiameterMm(nozzleDiameter); + session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight() != null ? settings.getLayerHeight() : 0.2)); + session.setInfillPattern(settings.getInfillPattern()); + session.setInfillPercent(settings.getInfillDensity() != null ? settings.getInfillDensity().intValue() : 20); + session.setSupportsEnabled(settings.getSupportsEnabled() != null ? settings.getSupportsEnabled() : false); + sessionRepo.save(session); + + OrcaProfileResolver.ResolvedProfiles profiles = orcaProfileResolver.resolve(machine, nozzleDiameter, selectedVariant); + String machineProfile = profiles.machineProfileName(); + String filamentProfile = profiles.filamentProfileName(); + + String processProfile = "standard"; + if (settings.getLayerHeight() != null) { + if (settings.getLayerHeight() >= 0.28) processProfile = "draft"; + else if (settings.getLayerHeight() <= 0.12) processProfile = "extra_fine"; + } + + // Build overrides map from settings + Map processOverrides = new HashMap<>(); + if (settings.getLayerHeight() != null) processOverrides.put("layer_height", String.valueOf(settings.getLayerHeight())); + if (settings.getInfillDensity() != null) processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%"); + if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern()); + + // 3. Slice (Use persistent path) + PrintStats stats = slicerService.slice( + persistentPath.toFile(), + machineProfile, + filamentProfile, + processProfile, + null, // machine overrides + processOverrides + ); + + Optional modelDimensions = slicerService.inspectModelDimensions(persistentPath.toFile()); + + // 4. Calculate Quote + QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), selectedVariant); + + // 5. Create Line Item + QuoteLineItem item = new QuoteLineItem(); + item.setQuoteSession(session); + item.setOriginalFilename(file.getOriginalFilename()); + item.setStoredPath(QUOTE_STORAGE_ROOT.relativize(persistentPath).toString()); // SAVE PATH (relative to root) + item.setQuantity(1); + item.setColorCode(selectedVariant.getColorName()); + item.setFilamentVariant(selectedVariant); + item.setStatus("READY"); // or CALCULATED + + item.setPrintTimeSeconds((int) stats.printTimeSeconds()); + item.setMaterialGrams(BigDecimal.valueOf(stats.filamentWeightGrams())); + item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice())); + + // Store breakdown + Map breakdown = new HashMap<>(); + breakdown.put("machine_cost", result.getTotalPrice()); // Excludes setup fee which is at session level + breakdown.put("setup_fee", 0); + item.setPricingBreakdown(breakdown); + + // Dimensions for shipping/package checks are computed server-side from the uploaded model. + item.setBoundingBoxXMm(modelDimensions + .map(dim -> BigDecimal.valueOf(dim.xMm())) + .orElseGet(() -> settings.getBoundingBoxX() != null ? BigDecimal.valueOf(settings.getBoundingBoxX()) : BigDecimal.ZERO)); + item.setBoundingBoxYMm(modelDimensions + .map(dim -> BigDecimal.valueOf(dim.yMm())) + .orElseGet(() -> settings.getBoundingBoxY() != null ? BigDecimal.valueOf(settings.getBoundingBoxY()) : BigDecimal.ZERO)); + item.setBoundingBoxZMm(modelDimensions + .map(dim -> BigDecimal.valueOf(dim.zMm())) + .orElseGet(() -> settings.getBoundingBoxZ() != null ? BigDecimal.valueOf(settings.getBoundingBoxZ()) : BigDecimal.ZERO)); + + item.setCreatedAt(OffsetDateTime.now()); + item.setUpdatedAt(OffsetDateTime.now()); + + return lineItemRepo.save(item); + + } catch (Exception e) { + // Cleanup if failed + Files.deleteIfExists(persistentPath); + throw e; + } + } + + private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) { + if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) { + // Set defaults based on Quality + String quality = settings.getQuality() != null ? settings.getQuality().toLowerCase() : "standard"; + + switch (quality) { + case "draft": + settings.setLayerHeight(0.28); + settings.setInfillDensity(15.0); + settings.setInfillPattern("grid"); + break; + case "high": + settings.setLayerHeight(0.12); + settings.setInfillDensity(20.0); + settings.setInfillPattern("gyroid"); + break; + case "standard": + default: + settings.setLayerHeight(0.20); + settings.setInfillDensity(15.0); + settings.setInfillPattern("grid"); + break; + } + } else { + // ADVANCED Mode: Use values from Frontend, set defaults if missing + if (settings.getLayerHeight() == null) settings.setLayerHeight(0.20); + if (settings.getInfillDensity() == null) settings.setInfillDensity(20.0); + if (settings.getInfillPattern() == null) settings.setInfillPattern("grid"); + } + } + + private PrinterMachine resolvePrinterMachine(Long printerMachineId) { + if (printerMachineId != null) { + PrinterMachine selected = machineRepo.findById(printerMachineId) + .orElseThrow(() -> new RuntimeException("Printer machine not found: " + printerMachineId)); + if (!Boolean.TRUE.equals(selected.getIsActive())) { + throw new RuntimeException("Selected printer machine is not active"); + } + return selected; + } + + return machineRepo.findFirstByIsActiveTrue() + .orElseThrow(() -> new RuntimeException("No active printer found")); + } + + private FilamentVariant resolveFilamentVariant(com.printcalculator.dto.PrintSettingsDto settings) { + if (settings.getFilamentVariantId() != null) { + FilamentVariant variant = variantRepo.findById(settings.getFilamentVariantId()) + .orElseThrow(() -> new RuntimeException("Filament variant not found: " + settings.getFilamentVariantId())); + if (!Boolean.TRUE.equals(variant.getIsActive())) { + throw new RuntimeException("Selected filament variant is not active"); + } + return variant; + } + + String requestedMaterialCode = normalizeRequestedMaterialCode(settings.getMaterial()); + + FilamentMaterialType materialType = materialRepo.findByMaterialCode(requestedMaterialCode) + .orElseGet(() -> materialRepo.findByMaterialCode("PLA") + .orElseThrow(() -> new RuntimeException("Fallback material PLA not configured"))); + + String requestedColor = settings.getColor() != null ? settings.getColor().trim() : null; + if (requestedColor != null && !requestedColor.isBlank()) { + Optional byColor = variantRepo.findByFilamentMaterialTypeAndColorName(materialType, requestedColor); + if (byColor.isPresent() && Boolean.TRUE.equals(byColor.get().getIsActive())) { + return byColor.get(); + } + } + + return variantRepo.findFirstByFilamentMaterialTypeAndIsActiveTrue(materialType) + .orElseThrow(() -> new RuntimeException("No active variant for material: " + requestedMaterialCode)); + } + + private String normalizeRequestedMaterialCode(String value) { + if (value == null || value.isBlank()) { + return "PLA"; + } + + return value.trim() + .toUpperCase(Locale.ROOT) + .replace('_', ' ') + .replace('-', ' ') + .replaceAll("\\s+", " "); + } + + // 3. Update Line Item + @PatchMapping("/line-items/{lineItemId}") + @Transactional + public ResponseEntity updateLineItem( + @PathVariable UUID lineItemId, + @RequestBody Map updates + ) { + QuoteLineItem item = lineItemRepo.findById(lineItemId) + .orElseThrow(() -> new RuntimeException("Item not found")); + + if (updates.containsKey("quantity")) { + item.setQuantity((Integer) updates.get("quantity")); + } + if (updates.containsKey("color_code")) { + item.setColorCode((String) updates.get("color_code")); + } + + // Recalculate price if needed? + // For now, unit price is fixed in mock. Total is calculated on GET. + + item.setUpdatedAt(OffsetDateTime.now()); + return ResponseEntity.ok(lineItemRepo.save(item)); + } + + // 4. Delete Line Item + @DeleteMapping("/{sessionId}/line-items/{lineItemId}") + @Transactional + public ResponseEntity deleteLineItem( + @PathVariable UUID sessionId, + @PathVariable UUID lineItemId + ) { + // Verify item belongs to session? + QuoteLineItem item = lineItemRepo.findById(lineItemId) + .orElseThrow(() -> new RuntimeException("Item not found")); + + if (!item.getQuoteSession().getId().equals(sessionId)) { + return ResponseEntity.badRequest().build(); + } + + lineItemRepo.delete(item); + return ResponseEntity.noContent().build(); + } + + // 5. Get Session (Session + Items + Total) + @GetMapping("/{id}") + public ResponseEntity> getQuoteSession(@PathVariable UUID id) { + QuoteSession session = sessionRepo.findById(id) + .orElseThrow(() -> new RuntimeException("Session not found")); + + List items = lineItemRepo.findByQuoteSessionId(id); + + // Calculate Totals and global session hours + BigDecimal itemsTotal = BigDecimal.ZERO; + BigDecimal totalSeconds = BigDecimal.ZERO; + + for (QuoteLineItem item : items) { + BigDecimal lineTotal = item.getUnitPriceChf().multiply(BigDecimal.valueOf(item.getQuantity())); + itemsTotal = itemsTotal.add(lineTotal); + + if (item.getPrintTimeSeconds() != null) { + totalSeconds = totalSeconds.add(BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(item.getQuantity()))); + } + } + + BigDecimal totalHours = totalSeconds.divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP); + com.printcalculator.entity.PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); + BigDecimal globalMachineCost = quoteCalculator.calculateSessionMachineCost(policy, totalHours); + + itemsTotal = itemsTotal.add(globalMachineCost); + + // Map items to DTO to embed distributed machine cost + List> itemsDto = new ArrayList<>(); + for (QuoteLineItem item : items) { + Map dto = new HashMap<>(); + dto.put("id", item.getId()); + dto.put("originalFilename", item.getOriginalFilename()); + dto.put("quantity", item.getQuantity()); + dto.put("printTimeSeconds", item.getPrintTimeSeconds()); + dto.put("materialGrams", item.getMaterialGrams()); + dto.put("colorCode", item.getColorCode()); + dto.put("filamentVariantId", item.getFilamentVariant() != null ? item.getFilamentVariant().getId() : null); + dto.put("status", item.getStatus()); + + BigDecimal unitPrice = item.getUnitPriceChf(); + if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && item.getPrintTimeSeconds() != null) { + BigDecimal itemSeconds = BigDecimal.valueOf(item.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(item.getQuantity())); + BigDecimal share = itemSeconds.divide(totalSeconds, 8, RoundingMode.HALF_UP); + BigDecimal itemMachineCost = globalMachineCost.multiply(share); + BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(item.getQuantity()), 2, RoundingMode.HALF_UP); + unitPrice = unitPrice.add(unitMachineCost); + } + dto.put("unitPriceChf", unitPrice); + itemsDto.add(dto); + } + + BigDecimal setupFee = session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO; + + // Calculate shipping cost based on dimensions + boolean exceedsBaseSize = false; + for (QuoteLineItem item : items) { + BigDecimal x = item.getBoundingBoxXMm() != null ? item.getBoundingBoxXMm() : BigDecimal.ZERO; + BigDecimal y = item.getBoundingBoxYMm() != null ? item.getBoundingBoxYMm() : BigDecimal.ZERO; + BigDecimal z = item.getBoundingBoxZMm() != null ? item.getBoundingBoxZMm() : BigDecimal.ZERO; + + BigDecimal[] dims = {x, y, z}; + java.util.Arrays.sort(dims); + + if (dims[2].compareTo(BigDecimal.valueOf(250.0)) > 0 || + dims[1].compareTo(BigDecimal.valueOf(176.0)) > 0 || + dims[0].compareTo(BigDecimal.valueOf(20.0)) > 0) { + exceedsBaseSize = true; + break; + } + } + int totalQuantity = items.stream() + .mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 1) + .sum(); + + BigDecimal shippingCostChf; + if (exceedsBaseSize) { + shippingCostChf = totalQuantity > 5 ? BigDecimal.valueOf(9.00) : BigDecimal.valueOf(4.00); + } else { + shippingCostChf = BigDecimal.valueOf(2.00); + } + + BigDecimal grandTotal = itemsTotal.add(setupFee).add(shippingCostChf); + + Map response = new HashMap<>(); + response.put("session", session); + response.put("items", itemsDto); + response.put("itemsTotalChf", itemsTotal); // Includes the base cost of all items + the global tiered machine cost + response.put("shippingCostChf", shippingCostChf); + response.put("globalMachineCostChf", globalMachineCost); // Provide it so frontend knows how much it was (optional now) + response.put("grandTotalChf", grandTotal); + + return ResponseEntity.ok(response); + } + + // 6. Download Line Item Content + @GetMapping(value = "/{sessionId}/line-items/{lineItemId}/content") + public ResponseEntity downloadLineItemContent( + @PathVariable UUID sessionId, + @PathVariable UUID lineItemId + ) throws IOException { + QuoteLineItem item = lineItemRepo.findById(lineItemId) + .orElseThrow(() -> new RuntimeException("Item not found")); + + if (!item.getQuoteSession().getId().equals(sessionId)) { + return ResponseEntity.badRequest().build(); + } + + if (item.getStoredPath() == null) { + return ResponseEntity.notFound().build(); + } + + Path path = resolveStoredQuotePath(item.getStoredPath(), sessionId); + if (path == null || !Files.exists(path)) { + return ResponseEntity.notFound().build(); + } + + org.springframework.core.io.Resource resource = new UrlResource(path.toUri()); + + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + item.getOriginalFilename() + "\"") + .body(resource); + } + + private String getSafeExtension(String filename, String fallback) { + if (filename == null) { + return fallback; + } + String cleaned = StringUtils.cleanPath(filename); + if (cleaned.contains("..")) { + return fallback; + } + int index = cleaned.lastIndexOf('.'); + if (index <= 0 || index >= cleaned.length() - 1) { + return fallback; + } + String ext = cleaned.substring(index + 1).toLowerCase(Locale.ROOT); + return switch (ext) { + case "stl" -> "stl"; + case "3mf" -> "3mf"; + case "step", "stp" -> "step"; + default -> fallback; + }; + } + + private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) { + if (storedPath == null || storedPath.isBlank()) { + return null; + } + try { + Path raw = Path.of(storedPath).normalize(); + Path resolved = raw.isAbsolute() ? raw : QUOTE_STORAGE_ROOT.resolve(raw).normalize(); + Path expectedSessionRoot = QUOTE_STORAGE_ROOT.resolve(expectedSessionId.toString()).normalize(); + if (!resolved.startsWith(expectedSessionRoot)) { + return null; + } + return resolved; + } catch (InvalidPathException e) { + return null; + } + } +} diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminAuthController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminAuthController.java new file mode 100644 index 0000000..7c7a4d2 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminAuthController.java @@ -0,0 +1,83 @@ +package com.printcalculator.controller.admin; + +import com.printcalculator.dto.AdminLoginRequest; +import com.printcalculator.security.AdminLoginThrottleService; +import com.printcalculator.security.AdminSessionService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; +import java.util.OptionalLong; + +@RestController +@RequestMapping("/api/admin/auth") +public class AdminAuthController { + + private final AdminSessionService adminSessionService; + private final AdminLoginThrottleService adminLoginThrottleService; + + public AdminAuthController( + AdminSessionService adminSessionService, + AdminLoginThrottleService adminLoginThrottleService + ) { + this.adminSessionService = adminSessionService; + this.adminLoginThrottleService = adminLoginThrottleService; + } + + @PostMapping("/login") + public ResponseEntity> login( + @Valid @RequestBody AdminLoginRequest request, + HttpServletRequest httpRequest, + HttpServletResponse response + ) { + String clientKey = adminLoginThrottleService.resolveClientKey(httpRequest); + OptionalLong remainingLock = adminLoginThrottleService.getRemainingLockSeconds(clientKey); + if (remainingLock.isPresent()) { + long retryAfter = remainingLock.getAsLong(); + return ResponseEntity.status(429) + .header(HttpHeaders.RETRY_AFTER, String.valueOf(retryAfter)) + .body(Map.of( + "authenticated", false, + "retryAfterSeconds", retryAfter + )); + } + + if (!adminSessionService.isPasswordValid(request.getPassword())) { + long retryAfter = adminLoginThrottleService.registerFailure(clientKey); + return ResponseEntity.status(401) + .header(HttpHeaders.RETRY_AFTER, String.valueOf(retryAfter)) + .body(Map.of( + "authenticated", false, + "retryAfterSeconds", retryAfter + )); + } + + adminLoginThrottleService.reset(clientKey); + String token = adminSessionService.createSessionToken(); + response.addHeader(HttpHeaders.SET_COOKIE, adminSessionService.buildLoginCookie(token).toString()); + + return ResponseEntity.ok(Map.of( + "authenticated", true, + "expiresInMinutes", adminSessionService.getSessionTtlMinutes() + )); + } + + @PostMapping("/logout") + public ResponseEntity> logout(HttpServletResponse response) { + response.addHeader(HttpHeaders.SET_COOKIE, adminSessionService.buildLogoutCookie().toString()); + return ResponseEntity.ok(Map.of("authenticated", false)); + } + + @GetMapping("/me") + public ResponseEntity> me() { + return ResponseEntity.ok(Map.of("authenticated", true)); + } +} diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminFilamentController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminFilamentController.java new file mode 100644 index 0000000..2d469e6 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminFilamentController.java @@ -0,0 +1,355 @@ +package com.printcalculator.controller.admin; + +import com.printcalculator.dto.AdminFilamentMaterialTypeDto; +import com.printcalculator.dto.AdminFilamentVariantDto; +import com.printcalculator.dto.AdminUpsertFilamentMaterialTypeRequest; +import com.printcalculator.dto.AdminUpsertFilamentVariantRequest; +import com.printcalculator.entity.FilamentMaterialType; +import com.printcalculator.entity.FilamentVariant; +import com.printcalculator.repository.FilamentMaterialTypeRepository; +import com.printcalculator.repository.FilamentVariantRepository; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@RestController +@RequestMapping("/api/admin/filaments") +@Transactional(readOnly = true) +public class AdminFilamentController { + private static final BigDecimal MAX_NUMERIC_6_3 = new BigDecimal("999.999"); + private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#[0-9A-Fa-f]{6}$"); + private static final Set ALLOWED_FINISH_TYPES = Set.of( + "GLOSSY", "MATTE", "MARBLE", "SILK", "TRANSLUCENT", "SPECIAL" + ); + + private final FilamentMaterialTypeRepository materialRepo; + private final FilamentVariantRepository variantRepo; + private final QuoteLineItemRepository quoteLineItemRepo; + private final OrderItemRepository orderItemRepo; + + public AdminFilamentController( + FilamentMaterialTypeRepository materialRepo, + FilamentVariantRepository variantRepo, + QuoteLineItemRepository quoteLineItemRepo, + OrderItemRepository orderItemRepo + ) { + this.materialRepo = materialRepo; + this.variantRepo = variantRepo; + this.quoteLineItemRepo = quoteLineItemRepo; + this.orderItemRepo = orderItemRepo; + } + + @GetMapping("/materials") + public ResponseEntity> getMaterials() { + List response = materialRepo.findAll().stream() + .sorted(Comparator.comparing(FilamentMaterialType::getMaterialCode, String.CASE_INSENSITIVE_ORDER)) + .map(this::toMaterialDto) + .toList(); + return ResponseEntity.ok(response); + } + + @GetMapping("/variants") + public ResponseEntity> getVariants() { + List response = variantRepo.findAll().stream() + .sorted(Comparator + .comparing((FilamentVariant v) -> { + FilamentMaterialType type = v.getFilamentMaterialType(); + return type != null && type.getMaterialCode() != null ? type.getMaterialCode() : ""; + }, String.CASE_INSENSITIVE_ORDER) + .thenComparing(v -> v.getVariantDisplayName() != null ? v.getVariantDisplayName() : "", String.CASE_INSENSITIVE_ORDER)) + .map(this::toVariantDto) + .toList(); + return ResponseEntity.ok(response); + } + + @PostMapping("/materials") + @Transactional + public ResponseEntity createMaterial( + @RequestBody AdminUpsertFilamentMaterialTypeRequest payload + ) { + String materialCode = normalizeAndValidateMaterialCode(payload); + ensureMaterialCodeAvailable(materialCode, null); + + FilamentMaterialType material = new FilamentMaterialType(); + applyMaterialPayload(material, payload, materialCode); + FilamentMaterialType saved = materialRepo.save(material); + return ResponseEntity.ok(toMaterialDto(saved)); + } + + @PutMapping("/materials/{materialTypeId}") + @Transactional + public ResponseEntity updateMaterial( + @PathVariable Long materialTypeId, + @RequestBody AdminUpsertFilamentMaterialTypeRequest payload + ) { + FilamentMaterialType material = materialRepo.findById(materialTypeId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament material not found")); + + String materialCode = normalizeAndValidateMaterialCode(payload); + ensureMaterialCodeAvailable(materialCode, materialTypeId); + + applyMaterialPayload(material, payload, materialCode); + FilamentMaterialType saved = materialRepo.save(material); + return ResponseEntity.ok(toMaterialDto(saved)); + } + + @PostMapping("/variants") + @Transactional + public ResponseEntity createVariant( + @RequestBody AdminUpsertFilamentVariantRequest payload + ) { + FilamentMaterialType material = validateAndResolveMaterial(payload); + String normalizedDisplayName = normalizeAndValidateVariantDisplayName(payload.getVariantDisplayName()); + String normalizedColorName = normalizeAndValidateColorName(payload.getColorName()); + validateNumericPayload(payload); + ensureVariantDisplayNameAvailable(material, normalizedDisplayName, null); + + FilamentVariant variant = new FilamentVariant(); + variant.setCreatedAt(OffsetDateTime.now()); + applyVariantPayload(variant, payload, material, normalizedDisplayName, normalizedColorName); + FilamentVariant saved = variantRepo.save(variant); + return ResponseEntity.ok(toVariantDto(saved)); + } + + @PutMapping("/variants/{variantId}") + @Transactional + public ResponseEntity updateVariant( + @PathVariable Long variantId, + @RequestBody AdminUpsertFilamentVariantRequest payload + ) { + FilamentVariant variant = variantRepo.findById(variantId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament variant not found")); + + FilamentMaterialType material = validateAndResolveMaterial(payload); + String normalizedDisplayName = normalizeAndValidateVariantDisplayName(payload.getVariantDisplayName()); + String normalizedColorName = normalizeAndValidateColorName(payload.getColorName()); + validateNumericPayload(payload); + ensureVariantDisplayNameAvailable(material, normalizedDisplayName, variantId); + + applyVariantPayload(variant, payload, material, normalizedDisplayName, normalizedColorName); + FilamentVariant saved = variantRepo.save(variant); + return ResponseEntity.ok(toVariantDto(saved)); + } + + @DeleteMapping("/variants/{variantId}") + @Transactional + public ResponseEntity deleteVariant(@PathVariable Long variantId) { + FilamentVariant variant = variantRepo.findById(variantId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Filament variant not found")); + + if (quoteLineItemRepo.existsByFilamentVariant_Id(variantId) || orderItemRepo.existsByFilamentVariant_Id(variantId)) { + throw new ResponseStatusException(CONFLICT, "Variant is already used in quotes/orders and cannot be deleted"); + } + + variantRepo.delete(variant); + return ResponseEntity.noContent().build(); + } + + private void applyMaterialPayload( + FilamentMaterialType material, + AdminUpsertFilamentMaterialTypeRequest payload, + String normalizedMaterialCode + ) { + boolean isFlexible = payload != null && Boolean.TRUE.equals(payload.getIsFlexible()); + boolean isTechnical = payload != null && Boolean.TRUE.equals(payload.getIsTechnical()); + String technicalTypeLabel = payload != null && payload.getTechnicalTypeLabel() != null + ? payload.getTechnicalTypeLabel().trim() + : null; + + material.setMaterialCode(normalizedMaterialCode); + material.setIsFlexible(isFlexible); + material.setIsTechnical(isTechnical); + material.setTechnicalTypeLabel(isTechnical && technicalTypeLabel != null && !technicalTypeLabel.isBlank() + ? technicalTypeLabel + : null); + } + + private void applyVariantPayload( + FilamentVariant variant, + AdminUpsertFilamentVariantRequest payload, + FilamentMaterialType material, + String normalizedDisplayName, + String normalizedColorName + ) { + String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex()); + String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte()); + String normalizedBrand = normalizeOptional(payload.getBrand()); + + variant.setFilamentMaterialType(material); + variant.setVariantDisplayName(normalizedDisplayName); + variant.setColorName(normalizedColorName); + variant.setColorHex(normalizedColorHex); + variant.setFinishType(normalizedFinishType); + variant.setBrand(normalizedBrand); + variant.setIsMatte(Boolean.TRUE.equals(payload.getIsMatte()) || "MATTE".equals(normalizedFinishType)); + variant.setIsSpecial(Boolean.TRUE.equals(payload.getIsSpecial())); + variant.setCostChfPerKg(payload.getCostChfPerKg()); + variant.setStockSpools(payload.getStockSpools()); + variant.setSpoolNetKg(payload.getSpoolNetKg()); + variant.setIsActive(payload.getIsActive() == null || payload.getIsActive()); + } + + private String normalizeAndValidateMaterialCode(AdminUpsertFilamentMaterialTypeRequest payload) { + if (payload == null || payload.getMaterialCode() == null || payload.getMaterialCode().isBlank()) { + throw new ResponseStatusException(BAD_REQUEST, "Material code is required"); + } + return payload.getMaterialCode().trim().toUpperCase(); + } + + private String normalizeAndValidateVariantDisplayName(String value) { + if (value == null || value.isBlank()) { + throw new ResponseStatusException(BAD_REQUEST, "Variant display name is required"); + } + return value.trim(); + } + + private String normalizeAndValidateColorName(String value) { + if (value == null || value.isBlank()) { + throw new ResponseStatusException(BAD_REQUEST, "Color name is required"); + } + return value.trim(); + } + + private String normalizeAndValidateColorHex(String value) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.trim(); + if (!HEX_COLOR_PATTERN.matcher(normalized).matches()) { + throw new ResponseStatusException(BAD_REQUEST, "Color hex must be in format #RRGGBB"); + } + return normalized.toUpperCase(Locale.ROOT); + } + + private String normalizeAndValidateFinishType(String finishType, Boolean isMatte) { + String normalized = finishType == null || finishType.isBlank() + ? (Boolean.TRUE.equals(isMatte) ? "MATTE" : "GLOSSY") + : finishType.trim().toUpperCase(Locale.ROOT); + if (!ALLOWED_FINISH_TYPES.contains(normalized)) { + throw new ResponseStatusException(BAD_REQUEST, "Invalid finish type"); + } + return normalized; + } + + private String normalizeOptional(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isBlank() ? null : normalized; + } + + private FilamentMaterialType validateAndResolveMaterial(AdminUpsertFilamentVariantRequest payload) { + if (payload == null || payload.getMaterialTypeId() == null) { + throw new ResponseStatusException(BAD_REQUEST, "Material type id is required"); + } + + return materialRepo.findById(payload.getMaterialTypeId()) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Material type not found")); + } + + private void validateNumericPayload(AdminUpsertFilamentVariantRequest payload) { + if (payload.getCostChfPerKg() == null || payload.getCostChfPerKg().compareTo(BigDecimal.ZERO) < 0) { + throw new ResponseStatusException(BAD_REQUEST, "Cost CHF/kg must be >= 0"); + } + validateNumeric63(payload.getStockSpools(), "Stock spools", true); + validateNumeric63(payload.getSpoolNetKg(), "Spool net kg", false); + } + + private void validateNumeric63(BigDecimal value, String fieldName, boolean allowZero) { + if (value == null) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " is required"); + } + + if (allowZero) { + if (value.compareTo(BigDecimal.ZERO) < 0) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be >= 0"); + } + } else if (value.compareTo(BigDecimal.ZERO) <= 0) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be > 0"); + } + + if (value.scale() > 3) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " must have at most 3 decimal places"); + } + + if (value.compareTo(MAX_NUMERIC_6_3) > 0) { + throw new ResponseStatusException(BAD_REQUEST, fieldName + " must be <= 999.999"); + } + } + + private void ensureMaterialCodeAvailable(String materialCode, Long currentMaterialId) { + materialRepo.findByMaterialCode(materialCode).ifPresent(existing -> { + if (currentMaterialId == null || !existing.getId().equals(currentMaterialId)) { + throw new ResponseStatusException(BAD_REQUEST, "Material code already exists"); + } + }); + } + + private void ensureVariantDisplayNameAvailable(FilamentMaterialType material, String displayName, Long currentVariantId) { + variantRepo.findByFilamentMaterialTypeAndVariantDisplayName(material, displayName).ifPresent(existing -> { + if (currentVariantId == null || !existing.getId().equals(currentVariantId)) { + throw new ResponseStatusException(BAD_REQUEST, "Variant display name already exists for this material"); + } + }); + } + + private AdminFilamentMaterialTypeDto toMaterialDto(FilamentMaterialType material) { + AdminFilamentMaterialTypeDto dto = new AdminFilamentMaterialTypeDto(); + dto.setId(material.getId()); + dto.setMaterialCode(material.getMaterialCode()); + dto.setIsFlexible(material.getIsFlexible()); + dto.setIsTechnical(material.getIsTechnical()); + dto.setTechnicalTypeLabel(material.getTechnicalTypeLabel()); + return dto; + } + + private AdminFilamentVariantDto toVariantDto(FilamentVariant variant) { + AdminFilamentVariantDto dto = new AdminFilamentVariantDto(); + dto.setId(variant.getId()); + + FilamentMaterialType material = variant.getFilamentMaterialType(); + if (material != null) { + dto.setMaterialTypeId(material.getId()); + dto.setMaterialCode(material.getMaterialCode()); + dto.setMaterialIsFlexible(material.getIsFlexible()); + dto.setMaterialIsTechnical(material.getIsTechnical()); + dto.setMaterialTechnicalTypeLabel(material.getTechnicalTypeLabel()); + } + + dto.setVariantDisplayName(variant.getVariantDisplayName()); + dto.setColorName(variant.getColorName()); + dto.setColorHex(variant.getColorHex()); + dto.setFinishType(variant.getFinishType()); + dto.setBrand(variant.getBrand()); + dto.setIsMatte(variant.getIsMatte()); + dto.setIsSpecial(variant.getIsSpecial()); + dto.setCostChfPerKg(variant.getCostChfPerKg()); + dto.setStockSpools(variant.getStockSpools()); + dto.setSpoolNetKg(variant.getSpoolNetKg()); + BigDecimal stockKg = BigDecimal.ZERO; + if (variant.getStockSpools() != null && variant.getSpoolNetKg() != null) { + stockKg = variant.getStockSpools().multiply(variant.getSpoolNetKg()); + } + dto.setStockKg(stockKg); + dto.setStockFilamentGrams(stockKg.multiply(BigDecimal.valueOf(1000))); + dto.setIsActive(variant.getIsActive()); + dto.setCreatedAt(variant.getCreatedAt()); + return dto; + } +} diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java new file mode 100644 index 0000000..f026884 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminOperationsController.java @@ -0,0 +1,372 @@ +package com.printcalculator.controller.admin; + +import com.printcalculator.dto.AdminContactRequestDto; +import com.printcalculator.dto.AdminContactRequestAttachmentDto; +import com.printcalculator.dto.AdminContactRequestDetailDto; +import com.printcalculator.dto.AdminFilamentStockDto; +import com.printcalculator.dto.AdminQuoteSessionDto; +import com.printcalculator.dto.AdminUpdateContactRequestStatusRequest; +import com.printcalculator.entity.CustomQuoteRequest; +import com.printcalculator.entity.CustomQuoteRequestAttachment; +import com.printcalculator.entity.FilamentVariant; +import com.printcalculator.entity.FilamentVariantStockKg; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; +import com.printcalculator.repository.CustomQuoteRequestRepository; +import com.printcalculator.repository.FilamentVariantRepository; +import com.printcalculator.repository.FilamentVariantStockKgRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.QuoteSessionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.data.domain.Sort; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.math.BigDecimal; +import java.net.MalformedURLException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.OffsetDateTime; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.CONFLICT; +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@RestController +@RequestMapping("/api/admin") +@Transactional(readOnly = true) +public class AdminOperationsController { + private static final Logger logger = LoggerFactory.getLogger(AdminOperationsController.class); + private static final Path CONTACT_ATTACHMENTS_ROOT = Paths.get("storage_requests").toAbsolutePath().normalize(); + private static final Set CONTACT_REQUEST_ALLOWED_STATUSES = Set.of( + "NEW", "PENDING", "IN_PROGRESS", "DONE", "CLOSED" + ); + + private final FilamentVariantStockKgRepository filamentStockRepo; + private final FilamentVariantRepository filamentVariantRepo; + private final CustomQuoteRequestRepository customQuoteRequestRepo; + private final CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo; + private final QuoteSessionRepository quoteSessionRepo; + private final OrderRepository orderRepo; + + public AdminOperationsController( + FilamentVariantStockKgRepository filamentStockRepo, + FilamentVariantRepository filamentVariantRepo, + CustomQuoteRequestRepository customQuoteRequestRepo, + CustomQuoteRequestAttachmentRepository customQuoteRequestAttachmentRepo, + QuoteSessionRepository quoteSessionRepo, + OrderRepository orderRepo + ) { + this.filamentStockRepo = filamentStockRepo; + this.filamentVariantRepo = filamentVariantRepo; + this.customQuoteRequestRepo = customQuoteRequestRepo; + this.customQuoteRequestAttachmentRepo = customQuoteRequestAttachmentRepo; + this.quoteSessionRepo = quoteSessionRepo; + this.orderRepo = orderRepo; + } + + @GetMapping("/filament-stock") + public ResponseEntity> getFilamentStock() { + List stocks = filamentStockRepo.findAll(Sort.by(Sort.Direction.ASC, "stockKg")); + Set variantIds = stocks.stream() + .map(FilamentVariantStockKg::getFilamentVariantId) + .collect(Collectors.toSet()); + + Map variantsById; + if (variantIds.isEmpty()) { + variantsById = Collections.emptyMap(); + } else { + variantsById = filamentVariantRepo.findAllById(variantIds).stream() + .collect(Collectors.toMap(FilamentVariant::getId, variant -> variant)); + } + + List response = stocks.stream().map(stock -> { + FilamentVariant variant = variantsById.get(stock.getFilamentVariantId()); + AdminFilamentStockDto dto = new AdminFilamentStockDto(); + dto.setFilamentVariantId(stock.getFilamentVariantId()); + dto.setStockSpools(stock.getStockSpools()); + dto.setSpoolNetKg(stock.getSpoolNetKg()); + dto.setStockKg(stock.getStockKg()); + BigDecimal grams = stock.getStockKg() != null + ? stock.getStockKg().multiply(BigDecimal.valueOf(1000)) + : BigDecimal.ZERO; + dto.setStockFilamentGrams(grams); + + if (variant != null) { + dto.setMaterialCode( + variant.getFilamentMaterialType() != null + ? variant.getFilamentMaterialType().getMaterialCode() + : "UNKNOWN" + ); + dto.setVariantDisplayName(variant.getVariantDisplayName()); + dto.setColorName(variant.getColorName()); + dto.setActive(variant.getIsActive()); + } else { + dto.setMaterialCode("UNKNOWN"); + dto.setVariantDisplayName("Variant " + stock.getFilamentVariantId()); + dto.setColorName("-"); + dto.setActive(false); + } + + return dto; + }).toList(); + + return ResponseEntity.ok(response); + } + + @GetMapping("/contact-requests") + public ResponseEntity> getContactRequests() { + List response = customQuoteRequestRepo.findAll( + Sort.by(Sort.Direction.DESC, "createdAt") + ) + .stream() + .map(this::toContactRequestDto) + .toList(); + + return ResponseEntity.ok(response); + } + + @GetMapping("/contact-requests/{requestId}") + public ResponseEntity getContactRequestDetail(@PathVariable UUID requestId) { + CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found")); + + List attachments = customQuoteRequestAttachmentRepo + .findByRequest_IdOrderByCreatedAtAsc(requestId) + .stream() + .map(this::toContactRequestAttachmentDto) + .toList(); + + return ResponseEntity.ok(toContactRequestDetailDto(request, attachments)); + } + + @PatchMapping("/contact-requests/{requestId}/status") + @Transactional + public ResponseEntity updateContactRequestStatus( + @PathVariable UUID requestId, + @RequestBody AdminUpdateContactRequestStatusRequest payload + ) { + CustomQuoteRequest request = customQuoteRequestRepo.findById(requestId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Contact request not found")); + + String requestedStatus = payload != null && payload.getStatus() != null + ? payload.getStatus().trim().toUpperCase(Locale.ROOT) + : ""; + + if (!CONTACT_REQUEST_ALLOWED_STATUSES.contains(requestedStatus)) { + throw new ResponseStatusException( + BAD_REQUEST, + "Invalid status. Allowed: " + String.join(", ", CONTACT_REQUEST_ALLOWED_STATUSES) + ); + } + + request.setStatus(requestedStatus); + request.setUpdatedAt(OffsetDateTime.now()); + CustomQuoteRequest saved = customQuoteRequestRepo.save(request); + + List attachments = customQuoteRequestAttachmentRepo + .findByRequest_IdOrderByCreatedAtAsc(requestId) + .stream() + .map(this::toContactRequestAttachmentDto) + .toList(); + + return ResponseEntity.ok(toContactRequestDetailDto(saved, attachments)); + } + + @GetMapping("/contact-requests/{requestId}/attachments/{attachmentId}/file") + public ResponseEntity downloadContactRequestAttachment( + @PathVariable UUID requestId, + @PathVariable UUID attachmentId + ) { + CustomQuoteRequestAttachment attachment = customQuoteRequestAttachmentRepo.findById(attachmentId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Attachment not found")); + + if (!attachment.getRequest().getId().equals(requestId)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment not found for request"); + } + + String relativePath = attachment.getStoredRelativePath(); + if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + String expectedPrefix = "quote-requests/" + requestId + "/attachments/" + attachmentId + "/"; + if (!relativePath.startsWith(expectedPrefix)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + Path filePath = CONTACT_ATTACHMENTS_ROOT.resolve(relativePath).normalize(); + if (!filePath.startsWith(CONTACT_ATTACHMENTS_ROOT)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + if (!Files.exists(filePath)) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + try { + Resource resource = new UrlResource(filePath.toUri()); + if (!resource.exists() || !resource.isReadable()) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + + MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM; + String mimeType = attachment.getMimeType(); + if (mimeType != null && !mimeType.isBlank()) { + try { + mediaType = MediaType.parseMediaType(mimeType); + } catch (Exception ignored) { + mediaType = MediaType.APPLICATION_OCTET_STREAM; + } + } + + String filename = attachment.getOriginalFilename(); + if (filename == null || filename.isBlank()) { + filename = attachment.getStoredFilename() != null && !attachment.getStoredFilename().isBlank() + ? attachment.getStoredFilename() + : "attachment-" + attachmentId; + } + + return ResponseEntity.ok() + .contentType(mediaType) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(filename, StandardCharsets.UTF_8) + .build() + .toString()) + .body(resource); + } catch (MalformedURLException e) { + throw new ResponseStatusException(NOT_FOUND, "Attachment file not available"); + } + } + + @GetMapping("/sessions") + public ResponseEntity> getQuoteSessions() { + List response = quoteSessionRepo.findAll( + Sort.by(Sort.Direction.DESC, "createdAt") + ) + .stream() + .map(this::toQuoteSessionDto) + .toList(); + + return ResponseEntity.ok(response); + } + + @DeleteMapping("/sessions/{sessionId}") + @Transactional + public ResponseEntity deleteQuoteSession(@PathVariable UUID sessionId) { + QuoteSession session = quoteSessionRepo.findById(sessionId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Session not found")); + + if (orderRepo.existsBySourceQuoteSession_Id(sessionId)) { + throw new ResponseStatusException(CONFLICT, "Cannot delete session already linked to an order"); + } + + deleteSessionFiles(sessionId); + quoteSessionRepo.delete(session); + return ResponseEntity.noContent().build(); + } + + private AdminContactRequestDto toContactRequestDto(CustomQuoteRequest request) { + AdminContactRequestDto dto = new AdminContactRequestDto(); + dto.setId(request.getId()); + dto.setRequestType(request.getRequestType()); + dto.setCustomerType(request.getCustomerType()); + dto.setEmail(request.getEmail()); + dto.setPhone(request.getPhone()); + dto.setName(request.getName()); + dto.setCompanyName(request.getCompanyName()); + dto.setStatus(request.getStatus()); + dto.setCreatedAt(request.getCreatedAt()); + return dto; + } + + private AdminContactRequestAttachmentDto toContactRequestAttachmentDto(CustomQuoteRequestAttachment attachment) { + AdminContactRequestAttachmentDto dto = new AdminContactRequestAttachmentDto(); + dto.setId(attachment.getId()); + dto.setOriginalFilename(attachment.getOriginalFilename()); + dto.setMimeType(attachment.getMimeType()); + dto.setFileSizeBytes(attachment.getFileSizeBytes()); + dto.setCreatedAt(attachment.getCreatedAt()); + return dto; + } + + private AdminContactRequestDetailDto toContactRequestDetailDto( + CustomQuoteRequest request, + List attachments + ) { + AdminContactRequestDetailDto dto = new AdminContactRequestDetailDto(); + dto.setId(request.getId()); + dto.setRequestType(request.getRequestType()); + dto.setCustomerType(request.getCustomerType()); + dto.setEmail(request.getEmail()); + dto.setPhone(request.getPhone()); + dto.setName(request.getName()); + dto.setCompanyName(request.getCompanyName()); + dto.setContactPerson(request.getContactPerson()); + dto.setMessage(request.getMessage()); + dto.setStatus(request.getStatus()); + dto.setCreatedAt(request.getCreatedAt()); + dto.setUpdatedAt(request.getUpdatedAt()); + dto.setAttachments(attachments); + return dto; + } + + private AdminQuoteSessionDto toQuoteSessionDto(QuoteSession session) { + AdminQuoteSessionDto dto = new AdminQuoteSessionDto(); + dto.setId(session.getId()); + dto.setStatus(session.getStatus()); + dto.setMaterialCode(session.getMaterialCode()); + dto.setCreatedAt(session.getCreatedAt()); + dto.setExpiresAt(session.getExpiresAt()); + dto.setConvertedOrderId(session.getConvertedOrderId()); + return dto; + } + + private void deleteSessionFiles(UUID sessionId) { + Path sessionDir = Paths.get("storage_quotes", sessionId.toString()); + if (!Files.exists(sessionDir)) { + return; + } + + try (Stream walk = Files.walk(sessionDir)) { + walk.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (IOException | UncheckedIOException e) { + logger.error("Failed to delete files for session {}", sessionId, e); + throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Unable to delete session files"); + } + } +} diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java new file mode 100644 index 0000000..b8d3c46 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminOrderController.java @@ -0,0 +1,301 @@ +package com.printcalculator.controller.admin; + +import com.printcalculator.dto.AddressDto; +import com.printcalculator.dto.AdminOrderStatusUpdateRequest; +import com.printcalculator.dto.OrderDto; +import com.printcalculator.dto.OrderItemDto; +import com.printcalculator.entity.Order; +import com.printcalculator.entity.OrderItem; +import com.printcalculator.entity.Payment; +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.PaymentRepository; +import com.printcalculator.service.InvoicePdfRenderingService; +import com.printcalculator.service.PaymentService; +import com.printcalculator.service.QrBillService; +import com.printcalculator.service.StorageService; +import org.springframework.core.io.Resource; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Paths; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +@RestController +@RequestMapping("/api/admin/orders") +@Transactional(readOnly = true) +public class AdminOrderController { + private static final List ALLOWED_ORDER_STATUSES = List.of( + "PENDING_PAYMENT", + "PAID", + "IN_PRODUCTION", + "SHIPPED", + "COMPLETED", + "CANCELLED" + ); + + private final OrderRepository orderRepo; + private final OrderItemRepository orderItemRepo; + private final PaymentRepository paymentRepo; + private final PaymentService paymentService; + private final StorageService storageService; + private final InvoicePdfRenderingService invoiceService; + private final QrBillService qrBillService; + + public AdminOrderController( + OrderRepository orderRepo, + OrderItemRepository orderItemRepo, + PaymentRepository paymentRepo, + PaymentService paymentService, + StorageService storageService, + InvoicePdfRenderingService invoiceService, + QrBillService qrBillService + ) { + this.orderRepo = orderRepo; + this.orderItemRepo = orderItemRepo; + this.paymentRepo = paymentRepo; + this.paymentService = paymentService; + this.storageService = storageService; + this.invoiceService = invoiceService; + this.qrBillService = qrBillService; + } + + @GetMapping + public ResponseEntity> listOrders() { + List response = orderRepo.findAllByOrderByCreatedAtDesc() + .stream() + .map(this::toOrderDto) + .toList(); + return ResponseEntity.ok(response); + } + + @GetMapping("/{orderId}") + public ResponseEntity getOrder(@PathVariable UUID orderId) { + return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId))); + } + + @PostMapping("/{orderId}/payments/confirm") + @Transactional + public ResponseEntity confirmPayment( + @PathVariable UUID orderId, + @RequestBody(required = false) Map payload + ) { + getOrderOrThrow(orderId); + String method = payload != null ? payload.get("method") : null; + paymentService.confirmPayment(orderId, method); + return ResponseEntity.ok(toOrderDto(getOrderOrThrow(orderId))); + } + + @PostMapping("/{orderId}/status") + @Transactional + public ResponseEntity updateOrderStatus( + @PathVariable UUID orderId, + @RequestBody AdminOrderStatusUpdateRequest payload + ) { + if (payload == null || payload.getStatus() == null || payload.getStatus().isBlank()) { + throw new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "Status is required"); + } + + Order order = getOrderOrThrow(orderId); + String normalizedStatus = payload.getStatus().trim().toUpperCase(Locale.ROOT); + if (!ALLOWED_ORDER_STATUSES.contains(normalizedStatus)) { + throw new ResponseStatusException( + BAD_REQUEST, + "Invalid order status. Allowed values: " + String.join(", ", ALLOWED_ORDER_STATUSES) + ); + } + order.setStatus(normalizedStatus); + orderRepo.save(order); + + return ResponseEntity.ok(toOrderDto(order)); + } + + @GetMapping("/{orderId}/items/{orderItemId}/file") + public ResponseEntity downloadOrderItemFile( + @PathVariable UUID orderId, + @PathVariable UUID orderItemId + ) { + OrderItem item = orderItemRepo.findById(orderItemId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order item not found")); + + if (!item.getOrder().getId().equals(orderId)) { + throw new ResponseStatusException(NOT_FOUND, "Order item not found for order"); + } + + String relativePath = item.getStoredRelativePath(); + if (relativePath == null || relativePath.isBlank() || "PENDING".equals(relativePath)) { + throw new ResponseStatusException(NOT_FOUND, "File not available"); + } + + try { + Resource resource = storageService.loadAsResource(Paths.get(relativePath)); + MediaType contentType = MediaType.APPLICATION_OCTET_STREAM; + if (item.getMimeType() != null && !item.getMimeType().isBlank()) { + try { + contentType = MediaType.parseMediaType(item.getMimeType()); + } catch (Exception ignored) { + contentType = MediaType.APPLICATION_OCTET_STREAM; + } + } + + String filename = item.getOriginalFilename() != null && !item.getOriginalFilename().isBlank() + ? item.getOriginalFilename() + : "order-item-" + orderItemId; + + return ResponseEntity.ok() + .contentType(contentType) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(filename, StandardCharsets.UTF_8) + .build() + .toString()) + .body(resource); + } catch (Exception e) { + throw new ResponseStatusException(NOT_FOUND, "File not available"); + } + } + + @GetMapping("/{orderId}/documents/confirmation") + public ResponseEntity downloadOrderConfirmation(@PathVariable UUID orderId) { + return generateDocument(getOrderOrThrow(orderId), true); + } + + @GetMapping("/{orderId}/documents/invoice") + public ResponseEntity downloadOrderInvoice(@PathVariable UUID orderId) { + return generateDocument(getOrderOrThrow(orderId), false); + } + + private Order getOrderOrThrow(UUID orderId) { + return orderRepo.findById(orderId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Order not found")); + } + + private OrderDto toOrderDto(Order order) { + List items = orderItemRepo.findByOrder_Id(order.getId()); + OrderDto dto = new OrderDto(); + dto.setId(order.getId()); + dto.setOrderNumber(getDisplayOrderNumber(order)); + dto.setStatus(order.getStatus()); + + paymentRepo.findByOrder_Id(order.getId()).ifPresent(p -> { + dto.setPaymentStatus(p.getStatus()); + dto.setPaymentMethod(p.getMethod()); + }); + + dto.setCustomerEmail(order.getCustomerEmail()); + dto.setCustomerPhone(order.getCustomerPhone()); + dto.setPreferredLanguage(order.getPreferredLanguage()); + dto.setBillingCustomerType(order.getBillingCustomerType()); + dto.setCurrency(order.getCurrency()); + dto.setSetupCostChf(order.getSetupCostChf()); + dto.setShippingCostChf(order.getShippingCostChf()); + dto.setDiscountChf(order.getDiscountChf()); + dto.setSubtotalChf(order.getSubtotalChf()); + dto.setTotalChf(order.getTotalChf()); + dto.setCreatedAt(order.getCreatedAt()); + dto.setShippingSameAsBilling(order.getShippingSameAsBilling()); + QuoteSession sourceSession = order.getSourceQuoteSession(); + if (sourceSession != null) { + dto.setPrintMaterialCode(sourceSession.getMaterialCode()); + dto.setPrintNozzleDiameterMm(sourceSession.getNozzleDiameterMm()); + dto.setPrintLayerHeightMm(sourceSession.getLayerHeightMm()); + dto.setPrintInfillPattern(sourceSession.getInfillPattern()); + dto.setPrintInfillPercent(sourceSession.getInfillPercent()); + dto.setPrintSupportsEnabled(sourceSession.getSupportsEnabled()); + } + + AddressDto billing = new AddressDto(); + billing.setFirstName(order.getBillingFirstName()); + billing.setLastName(order.getBillingLastName()); + billing.setCompanyName(order.getBillingCompanyName()); + billing.setContactPerson(order.getBillingContactPerson()); + billing.setAddressLine1(order.getBillingAddressLine1()); + billing.setAddressLine2(order.getBillingAddressLine2()); + billing.setZip(order.getBillingZip()); + billing.setCity(order.getBillingCity()); + billing.setCountryCode(order.getBillingCountryCode()); + dto.setBillingAddress(billing); + + if (!Boolean.TRUE.equals(order.getShippingSameAsBilling())) { + AddressDto shipping = new AddressDto(); + shipping.setFirstName(order.getShippingFirstName()); + shipping.setLastName(order.getShippingLastName()); + shipping.setCompanyName(order.getShippingCompanyName()); + shipping.setContactPerson(order.getShippingContactPerson()); + shipping.setAddressLine1(order.getShippingAddressLine1()); + shipping.setAddressLine2(order.getShippingAddressLine2()); + shipping.setZip(order.getShippingZip()); + shipping.setCity(order.getShippingCity()); + shipping.setCountryCode(order.getShippingCountryCode()); + dto.setShippingAddress(shipping); + } + + List itemDtos = items.stream().map(i -> { + OrderItemDto idto = new OrderItemDto(); + idto.setId(i.getId()); + idto.setOriginalFilename(i.getOriginalFilename()); + idto.setMaterialCode(i.getMaterialCode()); + idto.setColorCode(i.getColorCode()); + idto.setQuantity(i.getQuantity()); + idto.setPrintTimeSeconds(i.getPrintTimeSeconds()); + idto.setMaterialGrams(i.getMaterialGrams()); + idto.setUnitPriceChf(i.getUnitPriceChf()); + idto.setLineTotalChf(i.getLineTotalChf()); + return idto; + }).toList(); + dto.setItems(itemDtos); + + return dto; + } + + private String getDisplayOrderNumber(Order order) { + String orderNumber = order.getOrderNumber(); + if (orderNumber != null && !orderNumber.isBlank()) { + return orderNumber; + } + return order.getId() != null ? order.getId().toString() : "unknown"; + } + + private ResponseEntity generateDocument(Order order, boolean isConfirmation) { + String displayOrderNumber = getDisplayOrderNumber(order); + if (isConfirmation) { + String relativePath = "orders/" + order.getId() + "/documents/confirmation-" + displayOrderNumber + ".pdf"; + try { + byte[] existingPdf = storageService.loadAsResource(Paths.get(relativePath)).getInputStream().readAllBytes(); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"confirmation-" + displayOrderNumber + ".pdf\"") + .contentType(MediaType.APPLICATION_PDF) + .body(existingPdf); + } catch (Exception ignored) { + // fallback to generated confirmation document + } + } + + List items = orderItemRepo.findByOrder_Id(order.getId()); + Payment payment = paymentRepo.findByOrder_Id(order.getId()).orElse(null); + byte[] pdf = invoiceService.generateDocumentPdf(order, items, isConfirmation, qrBillService, payment); + + String prefix = isConfirmation ? "confirmation-" : "invoice-"; + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + prefix + displayOrderNumber + ".pdf\"") + .contentType(MediaType.APPLICATION_PDF) + .body(pdf); + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AddressDto.java b/backend/src/main/java/com/printcalculator/dto/AddressDto.java new file mode 100644 index 0000000..3b1c748 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AddressDto.java @@ -0,0 +1,16 @@ +package com.printcalculator.dto; + +import lombok.Data; + +@Data +public class AddressDto { + private String firstName; + private String lastName; + private String companyName; + private String contactPerson; + private String addressLine1; + private String addressLine2; + private String zip; + private String city; + private String countryCode; +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminContactRequestAttachmentDto.java b/backend/src/main/java/com/printcalculator/dto/AdminContactRequestAttachmentDto.java new file mode 100644 index 0000000..0d7a0ad --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminContactRequestAttachmentDto.java @@ -0,0 +1,52 @@ +package com.printcalculator.dto; + +import java.time.OffsetDateTime; +import java.util.UUID; + +public class AdminContactRequestAttachmentDto { + private UUID id; + private String originalFilename; + private String mimeType; + private Long fileSizeBytes; + private OffsetDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getOriginalFilename() { + return originalFilename; + } + + public void setOriginalFilename(String originalFilename) { + this.originalFilename = originalFilename; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public Long getFileSizeBytes() { + return fileSizeBytes; + } + + public void setFileSizeBytes(Long fileSizeBytes) { + this.fileSizeBytes = fileSizeBytes; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminContactRequestDetailDto.java b/backend/src/main/java/com/printcalculator/dto/AdminContactRequestDetailDto.java new file mode 100644 index 0000000..867a0ee --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminContactRequestDetailDto.java @@ -0,0 +1,125 @@ +package com.printcalculator.dto; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +public class AdminContactRequestDetailDto { + private UUID id; + private String requestType; + private String customerType; + private String email; + private String phone; + private String name; + private String companyName; + private String contactPerson; + private String message; + private String status; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + private List attachments; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getRequestType() { + return requestType; + } + + public void setRequestType(String requestType) { + this.requestType = requestType; + } + + public String getCustomerType() { + return customerType; + } + + public void setCustomerType(String customerType) { + this.customerType = customerType; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCompanyName() { + return companyName; + } + + public void setCompanyName(String companyName) { + this.companyName = companyName; + } + + public String getContactPerson() { + return contactPerson; + } + + public void setContactPerson(String contactPerson) { + this.contactPerson = contactPerson; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public List getAttachments() { + return attachments; + } + + public void setAttachments(List attachments) { + this.attachments = attachments; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminContactRequestDto.java b/backend/src/main/java/com/printcalculator/dto/AdminContactRequestDto.java new file mode 100644 index 0000000..a9d4c37 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminContactRequestDto.java @@ -0,0 +1,88 @@ +package com.printcalculator.dto; + +import java.time.OffsetDateTime; +import java.util.UUID; + +public class AdminContactRequestDto { + private UUID id; + private String requestType; + private String customerType; + private String email; + private String phone; + private String name; + private String companyName; + private String status; + private OffsetDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getRequestType() { + return requestType; + } + + public void setRequestType(String requestType) { + this.requestType = requestType; + } + + public String getCustomerType() { + return customerType; + } + + public void setCustomerType(String customerType) { + this.customerType = customerType; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCompanyName() { + return companyName; + } + + public void setCompanyName(String companyName) { + this.companyName = companyName; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminFilamentMaterialTypeDto.java b/backend/src/main/java/com/printcalculator/dto/AdminFilamentMaterialTypeDto.java new file mode 100644 index 0000000..749faca --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminFilamentMaterialTypeDto.java @@ -0,0 +1,49 @@ +package com.printcalculator.dto; + +public class AdminFilamentMaterialTypeDto { + private Long id; + private String materialCode; + private Boolean isFlexible; + private Boolean isTechnical; + private String technicalTypeLabel; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getMaterialCode() { + return materialCode; + } + + public void setMaterialCode(String materialCode) { + this.materialCode = materialCode; + } + + public Boolean getIsFlexible() { + return isFlexible; + } + + public void setIsFlexible(Boolean isFlexible) { + this.isFlexible = isFlexible; + } + + public Boolean getIsTechnical() { + return isTechnical; + } + + public void setIsTechnical(Boolean isTechnical) { + this.isTechnical = isTechnical; + } + + public String getTechnicalTypeLabel() { + return technicalTypeLabel; + } + + public void setTechnicalTypeLabel(String technicalTypeLabel) { + this.technicalTypeLabel = technicalTypeLabel; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminFilamentStockDto.java b/backend/src/main/java/com/printcalculator/dto/AdminFilamentStockDto.java new file mode 100644 index 0000000..07c18e8 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminFilamentStockDto.java @@ -0,0 +1,87 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; + +public class AdminFilamentStockDto { + private Long filamentVariantId; + private String materialCode; + private String variantDisplayName; + private String colorName; + private BigDecimal stockSpools; + private BigDecimal spoolNetKg; + private BigDecimal stockKg; + private BigDecimal stockFilamentGrams; + private Boolean active; + + public Long getFilamentVariantId() { + return filamentVariantId; + } + + public void setFilamentVariantId(Long filamentVariantId) { + this.filamentVariantId = filamentVariantId; + } + + public String getMaterialCode() { + return materialCode; + } + + public void setMaterialCode(String materialCode) { + this.materialCode = materialCode; + } + + public String getVariantDisplayName() { + return variantDisplayName; + } + + public void setVariantDisplayName(String variantDisplayName) { + this.variantDisplayName = variantDisplayName; + } + + public String getColorName() { + return colorName; + } + + public void setColorName(String colorName) { + this.colorName = colorName; + } + + public BigDecimal getStockSpools() { + return stockSpools; + } + + public void setStockSpools(BigDecimal stockSpools) { + this.stockSpools = stockSpools; + } + + public BigDecimal getSpoolNetKg() { + return spoolNetKg; + } + + public void setSpoolNetKg(BigDecimal spoolNetKg) { + this.spoolNetKg = spoolNetKg; + } + + public BigDecimal getStockKg() { + return stockKg; + } + + public void setStockKg(BigDecimal stockKg) { + this.stockKg = stockKg; + } + + public BigDecimal getStockFilamentGrams() { + return stockFilamentGrams; + } + + public void setStockFilamentGrams(BigDecimal stockFilamentGrams) { + this.stockFilamentGrams = stockFilamentGrams; + } + + public Boolean getActive() { + return active; + } + + public void setActive(Boolean active) { + this.active = active; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminFilamentVariantDto.java b/backend/src/main/java/com/printcalculator/dto/AdminFilamentVariantDto.java new file mode 100644 index 0000000..88b32ac --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminFilamentVariantDto.java @@ -0,0 +1,187 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; + +public class AdminFilamentVariantDto { + private Long id; + private Long materialTypeId; + private String materialCode; + private Boolean materialIsFlexible; + private Boolean materialIsTechnical; + private String materialTechnicalTypeLabel; + private String variantDisplayName; + private String colorName; + private String colorHex; + private String finishType; + private String brand; + private Boolean isMatte; + private Boolean isSpecial; + private BigDecimal costChfPerKg; + private BigDecimal stockSpools; + private BigDecimal spoolNetKg; + private BigDecimal stockKg; + private BigDecimal stockFilamentGrams; + private Boolean isActive; + private OffsetDateTime createdAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getMaterialTypeId() { + return materialTypeId; + } + + public void setMaterialTypeId(Long materialTypeId) { + this.materialTypeId = materialTypeId; + } + + public String getMaterialCode() { + return materialCode; + } + + public void setMaterialCode(String materialCode) { + this.materialCode = materialCode; + } + + public Boolean getMaterialIsFlexible() { + return materialIsFlexible; + } + + public void setMaterialIsFlexible(Boolean materialIsFlexible) { + this.materialIsFlexible = materialIsFlexible; + } + + public Boolean getMaterialIsTechnical() { + return materialIsTechnical; + } + + public void setMaterialIsTechnical(Boolean materialIsTechnical) { + this.materialIsTechnical = materialIsTechnical; + } + + public String getMaterialTechnicalTypeLabel() { + return materialTechnicalTypeLabel; + } + + public void setMaterialTechnicalTypeLabel(String materialTechnicalTypeLabel) { + this.materialTechnicalTypeLabel = materialTechnicalTypeLabel; + } + + public String getVariantDisplayName() { + return variantDisplayName; + } + + public void setVariantDisplayName(String variantDisplayName) { + this.variantDisplayName = variantDisplayName; + } + + public String getColorName() { + return colorName; + } + + public void setColorName(String colorName) { + this.colorName = colorName; + } + + public String getColorHex() { + return colorHex; + } + + public void setColorHex(String colorHex) { + this.colorHex = colorHex; + } + + public String getFinishType() { + return finishType; + } + + public void setFinishType(String finishType) { + this.finishType = finishType; + } + + public String getBrand() { + return brand; + } + + public void setBrand(String brand) { + this.brand = brand; + } + + public Boolean getIsMatte() { + return isMatte; + } + + public void setIsMatte(Boolean isMatte) { + this.isMatte = isMatte; + } + + public Boolean getIsSpecial() { + return isSpecial; + } + + public void setIsSpecial(Boolean isSpecial) { + this.isSpecial = isSpecial; + } + + public BigDecimal getCostChfPerKg() { + return costChfPerKg; + } + + public void setCostChfPerKg(BigDecimal costChfPerKg) { + this.costChfPerKg = costChfPerKg; + } + + public BigDecimal getStockSpools() { + return stockSpools; + } + + public void setStockSpools(BigDecimal stockSpools) { + this.stockSpools = stockSpools; + } + + public BigDecimal getSpoolNetKg() { + return spoolNetKg; + } + + public void setSpoolNetKg(BigDecimal spoolNetKg) { + this.spoolNetKg = spoolNetKg; + } + + public BigDecimal getStockKg() { + return stockKg; + } + + public void setStockKg(BigDecimal stockKg) { + this.stockKg = stockKg; + } + + public BigDecimal getStockFilamentGrams() { + return stockFilamentGrams; + } + + public void setStockFilamentGrams(BigDecimal stockFilamentGrams) { + this.stockFilamentGrams = stockFilamentGrams; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminLoginRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminLoginRequest.java new file mode 100644 index 0000000..5a39f9d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminLoginRequest.java @@ -0,0 +1,17 @@ +package com.printcalculator.dto; + +import jakarta.validation.constraints.NotBlank; + +public class AdminLoginRequest { + + @NotBlank + private String password; + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminOrderStatusUpdateRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminOrderStatusUpdateRequest.java new file mode 100644 index 0000000..7a18fe2 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminOrderStatusUpdateRequest.java @@ -0,0 +1,13 @@ +package com.printcalculator.dto; + +public class AdminOrderStatusUpdateRequest { + private String status; + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminQuoteSessionDto.java b/backend/src/main/java/com/printcalculator/dto/AdminQuoteSessionDto.java new file mode 100644 index 0000000..47b0be5 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminQuoteSessionDto.java @@ -0,0 +1,61 @@ +package com.printcalculator.dto; + +import java.time.OffsetDateTime; +import java.util.UUID; + +public class AdminQuoteSessionDto { + private UUID id; + private String status; + private String materialCode; + private OffsetDateTime createdAt; + private OffsetDateTime expiresAt; + private UUID convertedOrderId; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getMaterialCode() { + return materialCode; + } + + public void setMaterialCode(String materialCode) { + this.materialCode = materialCode; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(OffsetDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public UUID getConvertedOrderId() { + return convertedOrderId; + } + + public void setConvertedOrderId(UUID convertedOrderId) { + this.convertedOrderId = convertedOrderId; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpdateContactRequestStatusRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpdateContactRequestStatusRequest.java new file mode 100644 index 0000000..c12abb0 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpdateContactRequestStatusRequest.java @@ -0,0 +1,13 @@ +package com.printcalculator.dto; + +public class AdminUpdateContactRequestStatusRequest { + private String status; + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentMaterialTypeRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentMaterialTypeRequest.java new file mode 100644 index 0000000..66cdd6b --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentMaterialTypeRequest.java @@ -0,0 +1,40 @@ +package com.printcalculator.dto; + +public class AdminUpsertFilamentMaterialTypeRequest { + private String materialCode; + private Boolean isFlexible; + private Boolean isTechnical; + private String technicalTypeLabel; + + public String getMaterialCode() { + return materialCode; + } + + public void setMaterialCode(String materialCode) { + this.materialCode = materialCode; + } + + public Boolean getIsFlexible() { + return isFlexible; + } + + public void setIsFlexible(Boolean isFlexible) { + this.isFlexible = isFlexible; + } + + public Boolean getIsTechnical() { + return isTechnical; + } + + public void setIsTechnical(Boolean isTechnical) { + this.isTechnical = isTechnical; + } + + public String getTechnicalTypeLabel() { + return technicalTypeLabel; + } + + public void setTechnicalTypeLabel(String technicalTypeLabel) { + this.technicalTypeLabel = technicalTypeLabel; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentVariantRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentVariantRequest.java new file mode 100644 index 0000000..89cd51c --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentVariantRequest.java @@ -0,0 +1,114 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; + +public class AdminUpsertFilamentVariantRequest { + private Long materialTypeId; + private String variantDisplayName; + private String colorName; + private String colorHex; + private String finishType; + private String brand; + private Boolean isMatte; + private Boolean isSpecial; + private BigDecimal costChfPerKg; + private BigDecimal stockSpools; + private BigDecimal spoolNetKg; + private Boolean isActive; + + public Long getMaterialTypeId() { + return materialTypeId; + } + + public void setMaterialTypeId(Long materialTypeId) { + this.materialTypeId = materialTypeId; + } + + public String getVariantDisplayName() { + return variantDisplayName; + } + + public void setVariantDisplayName(String variantDisplayName) { + this.variantDisplayName = variantDisplayName; + } + + public String getColorName() { + return colorName; + } + + public void setColorName(String colorName) { + this.colorName = colorName; + } + + public String getColorHex() { + return colorHex; + } + + public void setColorHex(String colorHex) { + this.colorHex = colorHex; + } + + public String getFinishType() { + return finishType; + } + + public void setFinishType(String finishType) { + this.finishType = finishType; + } + + public String getBrand() { + return brand; + } + + public void setBrand(String brand) { + this.brand = brand; + } + + public Boolean getIsMatte() { + return isMatte; + } + + public void setIsMatte(Boolean isMatte) { + this.isMatte = isMatte; + } + + public Boolean getIsSpecial() { + return isSpecial; + } + + public void setIsSpecial(Boolean isSpecial) { + this.isSpecial = isSpecial; + } + + public BigDecimal getCostChfPerKg() { + return costChfPerKg; + } + + public void setCostChfPerKg(BigDecimal costChfPerKg) { + this.costChfPerKg = costChfPerKg; + } + + public BigDecimal getStockSpools() { + return stockSpools; + } + + public void setStockSpools(BigDecimal stockSpools) { + this.stockSpools = stockSpools; + } + + public BigDecimal getSpoolNetKg() { + return spoolNetKg; + } + + public void setSpoolNetKg(BigDecimal spoolNetKg) { + this.spoolNetKg = spoolNetKg; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/CreateOrderRequest.java b/backend/src/main/java/com/printcalculator/dto/CreateOrderRequest.java new file mode 100644 index 0000000..5264502 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/CreateOrderRequest.java @@ -0,0 +1,19 @@ +package com.printcalculator.dto; + +import lombok.Data; +import jakarta.validation.constraints.AssertTrue; + +@Data +public class CreateOrderRequest { + private CustomerDto customer; + private AddressDto billingAddress; + private AddressDto shippingAddress; + private String language; + private boolean shippingSameAsBilling; + + @AssertTrue(message = "L'accettazione dei Termini e Condizioni e obbligatoria.") + private boolean acceptTerms; + + @AssertTrue(message = "L'accettazione dell'Informativa Privacy e obbligatoria.") + private boolean acceptPrivacy; +} diff --git a/backend/src/main/java/com/printcalculator/dto/CustomerDto.java b/backend/src/main/java/com/printcalculator/dto/CustomerDto.java new file mode 100644 index 0000000..432394f --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/CustomerDto.java @@ -0,0 +1,10 @@ +package com.printcalculator.dto; + +import lombok.Data; + +@Data +public class CustomerDto { + private String email; + private String phone; + private String customerType; // "PRIVATE", "BUSINESS" +} diff --git a/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java b/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java index dc60fb7..54d7e87 100644 --- a/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java +++ b/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java @@ -10,7 +10,16 @@ public record OptionsResponse( List nozzleDiameters ) { public record MaterialOption(String code, String label, List variants) {} - public record VariantOption(String name, String colorName, String hexColor, boolean isOutOfStock) {} + public record VariantOption( + Long id, + String name, + String colorName, + String hexColor, + String finishType, + Double stockSpools, + Double stockFilamentGrams, + boolean isOutOfStock + ) {} public record QualityOption(String id, String label) {} public record InfillPatternOption(String id, String label) {} public record LayerHeightOptionDTO(double value, String label) {} diff --git a/backend/src/main/java/com/printcalculator/dto/OrderDto.java b/backend/src/main/java/com/printcalculator/dto/OrderDto.java new file mode 100644 index 0000000..63e9f16 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/OrderDto.java @@ -0,0 +1,114 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +public class OrderDto { + private UUID id; + private String orderNumber; + private String status; + private String paymentStatus; + private String paymentMethod; + private String customerEmail; + private String customerPhone; + private String preferredLanguage; + private String billingCustomerType; + private AddressDto billingAddress; + private AddressDto shippingAddress; + private Boolean shippingSameAsBilling; + private String currency; + private BigDecimal setupCostChf; + private BigDecimal shippingCostChf; + private BigDecimal discountChf; + private BigDecimal subtotalChf; + private BigDecimal totalChf; + private OffsetDateTime createdAt; + private String printMaterialCode; + private BigDecimal printNozzleDiameterMm; + private BigDecimal printLayerHeightMm; + private String printInfillPattern; + private Integer printInfillPercent; + private Boolean printSupportsEnabled; + private List items; + + // Getters and Setters + public UUID getId() { return id; } + public void setId(UUID id) { this.id = id; } + + public String getOrderNumber() { return orderNumber; } + public void setOrderNumber(String orderNumber) { this.orderNumber = orderNumber; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public String getPaymentStatus() { return paymentStatus; } + public void setPaymentStatus(String paymentStatus) { this.paymentStatus = paymentStatus; } + + public String getPaymentMethod() { return paymentMethod; } + public void setPaymentMethod(String paymentMethod) { this.paymentMethod = paymentMethod; } + + public String getCustomerEmail() { return customerEmail; } + public void setCustomerEmail(String customerEmail) { this.customerEmail = customerEmail; } + + public String getCustomerPhone() { return customerPhone; } + public void setCustomerPhone(String customerPhone) { this.customerPhone = customerPhone; } + + public String getPreferredLanguage() { return preferredLanguage; } + public void setPreferredLanguage(String preferredLanguage) { this.preferredLanguage = preferredLanguage; } + + public String getBillingCustomerType() { return billingCustomerType; } + public void setBillingCustomerType(String billingCustomerType) { this.billingCustomerType = billingCustomerType; } + + public AddressDto getBillingAddress() { return billingAddress; } + public void setBillingAddress(AddressDto billingAddress) { this.billingAddress = billingAddress; } + + public AddressDto getShippingAddress() { return shippingAddress; } + public void setShippingAddress(AddressDto shippingAddress) { this.shippingAddress = shippingAddress; } + + public Boolean getShippingSameAsBilling() { return shippingSameAsBilling; } + public void setShippingSameAsBilling(Boolean shippingSameAsBilling) { this.shippingSameAsBilling = shippingSameAsBilling; } + + public String getCurrency() { return currency; } + public void setCurrency(String currency) { this.currency = currency; } + + public BigDecimal getSetupCostChf() { return setupCostChf; } + public void setSetupCostChf(BigDecimal setupCostChf) { this.setupCostChf = setupCostChf; } + + public BigDecimal getShippingCostChf() { return shippingCostChf; } + public void setShippingCostChf(BigDecimal shippingCostChf) { this.shippingCostChf = shippingCostChf; } + + public BigDecimal getDiscountChf() { return discountChf; } + public void setDiscountChf(BigDecimal discountChf) { this.discountChf = discountChf; } + + public BigDecimal getSubtotalChf() { return subtotalChf; } + public void setSubtotalChf(BigDecimal subtotalChf) { this.subtotalChf = subtotalChf; } + + public BigDecimal getTotalChf() { return totalChf; } + public void setTotalChf(BigDecimal totalChf) { this.totalChf = totalChf; } + + public OffsetDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; } + + public String getPrintMaterialCode() { return printMaterialCode; } + public void setPrintMaterialCode(String printMaterialCode) { this.printMaterialCode = printMaterialCode; } + + public BigDecimal getPrintNozzleDiameterMm() { return printNozzleDiameterMm; } + public void setPrintNozzleDiameterMm(BigDecimal printNozzleDiameterMm) { this.printNozzleDiameterMm = printNozzleDiameterMm; } + + public BigDecimal getPrintLayerHeightMm() { return printLayerHeightMm; } + public void setPrintLayerHeightMm(BigDecimal printLayerHeightMm) { this.printLayerHeightMm = printLayerHeightMm; } + + public String getPrintInfillPattern() { return printInfillPattern; } + public void setPrintInfillPattern(String printInfillPattern) { this.printInfillPattern = printInfillPattern; } + + public Integer getPrintInfillPercent() { return printInfillPercent; } + public void setPrintInfillPercent(Integer printInfillPercent) { this.printInfillPercent = printInfillPercent; } + + public Boolean getPrintSupportsEnabled() { return printSupportsEnabled; } + public void setPrintSupportsEnabled(Boolean printSupportsEnabled) { this.printSupportsEnabled = printSupportsEnabled; } + + public List getItems() { return items; } + public void setItems(List items) { this.items = items; } +} diff --git a/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java b/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java new file mode 100644 index 0000000..d31d208 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java @@ -0,0 +1,44 @@ +package com.printcalculator.dto; + +import java.math.BigDecimal; +import java.util.UUID; + +public class OrderItemDto { + private UUID id; + private String originalFilename; + private String materialCode; + private String colorCode; + private Integer quantity; + private Integer printTimeSeconds; + private BigDecimal materialGrams; + private BigDecimal unitPriceChf; + private BigDecimal lineTotalChf; + + // Getters and Setters + public UUID getId() { return id; } + public void setId(UUID id) { this.id = id; } + + public String getOriginalFilename() { return originalFilename; } + public void setOriginalFilename(String originalFilename) { this.originalFilename = originalFilename; } + + public String getMaterialCode() { return materialCode; } + public void setMaterialCode(String materialCode) { this.materialCode = materialCode; } + + public String getColorCode() { return colorCode; } + public void setColorCode(String colorCode) { this.colorCode = colorCode; } + + public Integer getQuantity() { return quantity; } + public void setQuantity(Integer quantity) { this.quantity = quantity; } + + public Integer getPrintTimeSeconds() { return printTimeSeconds; } + public void setPrintTimeSeconds(Integer printTimeSeconds) { this.printTimeSeconds = printTimeSeconds; } + + public BigDecimal getMaterialGrams() { return materialGrams; } + public void setMaterialGrams(BigDecimal materialGrams) { this.materialGrams = materialGrams; } + + public BigDecimal getUnitPriceChf() { return unitPriceChf; } + public void setUnitPriceChf(BigDecimal unitPriceChf) { this.unitPriceChf = unitPriceChf; } + + public BigDecimal getLineTotalChf() { return lineTotalChf; } + public void setLineTotalChf(BigDecimal lineTotalChf) { this.lineTotalChf = lineTotalChf; } +} diff --git a/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java b/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java new file mode 100644 index 0000000..e01cfc6 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/PrintSettingsDto.java @@ -0,0 +1,31 @@ +package com.printcalculator.dto; + +import lombok.Data; + +@Data +public class PrintSettingsDto { + // Mode: "BASIC" or "ADVANCED" + private String complexityMode; + + // Common + private String material; // e.g. "PLA", "PLA TOUGH", "PETG" + private String color; // e.g. "White", "#FFFFFF" + private Long filamentVariantId; + private Long printerMachineId; + + // Basic Mode + private String quality; // "draft", "standard", "high" + + // Advanced Mode (Optional in Basic) + private Double nozzleDiameter; + private Double layerHeight; + private Double infillDensity; + private String infillPattern; + private Boolean supportsEnabled; + private String notes; + + // Dimensions + private Double boundingBoxX; + private Double boundingBoxY; + private Double boundingBoxZ; +} diff --git a/backend/src/main/java/com/printcalculator/dto/QuoteRequestDto.java b/backend/src/main/java/com/printcalculator/dto/QuoteRequestDto.java new file mode 100644 index 0000000..70d36ba --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/QuoteRequestDto.java @@ -0,0 +1,22 @@ +package com.printcalculator.dto; + +import lombok.Data; +import jakarta.validation.constraints.AssertTrue; + +@Data +public class QuoteRequestDto { + private String requestType; // "PRINT_SERVICE" or "DESIGN_SERVICE" + private String customerType; // "PRIVATE" or "BUSINESS" + private String email; + private String phone; + private String name; + private String companyName; + private String contactPerson; + private String message; + + @AssertTrue(message = "L'accettazione dei Termini e Condizioni e obbligatoria.") + private boolean acceptTerms; + + @AssertTrue(message = "L'accettazione dell'Informativa Privacy e obbligatoria.") + private boolean acceptPrivacy; +} diff --git a/backend/src/main/java/com/printcalculator/entity/CustomQuoteRequest.java b/backend/src/main/java/com/printcalculator/entity/CustomQuoteRequest.java new file mode 100644 index 0000000..a9f9a36 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/CustomQuoteRequest.java @@ -0,0 +1,149 @@ +package com.printcalculator.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "custom_quote_requests", indexes = {@Index(name = "ix_custom_quote_requests_status", + columnList = "status")}) +public class CustomQuoteRequest { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "request_id", nullable = false) + private UUID id; + + @Column(name = "request_type", nullable = false, length = Integer.MAX_VALUE) + private String requestType; + + @Column(name = "customer_type", nullable = false, length = Integer.MAX_VALUE) + private String customerType; + + @Column(name = "email", nullable = false, length = Integer.MAX_VALUE) + private String email; + + @Column(name = "phone", length = Integer.MAX_VALUE) + private String phone; + + @Column(name = "name", length = Integer.MAX_VALUE) + private String name; + + @Column(name = "company_name", length = Integer.MAX_VALUE) + private String companyName; + + @Column(name = "contact_person", length = Integer.MAX_VALUE) + private String contactPerson; + + @Column(name = "message", nullable = false, length = Integer.MAX_VALUE) + private String message; + + @Column(name = "status", nullable = false, length = Integer.MAX_VALUE) + private String status; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getRequestType() { + return requestType; + } + + public void setRequestType(String requestType) { + this.requestType = requestType; + } + + public String getCustomerType() { + return customerType; + } + + public void setCustomerType(String customerType) { + this.customerType = customerType; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getCompanyName() { + return companyName; + } + + public void setCompanyName(String companyName) { + this.companyName = companyName; + } + + public String getContactPerson() { + return contactPerson; + } + + public void setContactPerson(String contactPerson) { + this.contactPerson = contactPerson; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/CustomQuoteRequestAttachment.java b/backend/src/main/java/com/printcalculator/entity/CustomQuoteRequestAttachment.java new file mode 100644 index 0000000..01f3406 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/CustomQuoteRequestAttachment.java @@ -0,0 +1,121 @@ +package com.printcalculator.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "custom_quote_request_attachments", indexes = {@Index(name = "ix_custom_quote_attachments_request", + columnList = "request_id")}) +public class CustomQuoteRequestAttachment { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "attachment_id", nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @OnDelete(action = OnDeleteAction.CASCADE) + @JoinColumn(name = "request_id", nullable = false) + private CustomQuoteRequest request; + + @Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE) + private String originalFilename; + + @Column(name = "stored_relative_path", nullable = false, length = Integer.MAX_VALUE) + private String storedRelativePath; + + @Column(name = "stored_filename", nullable = false, length = Integer.MAX_VALUE) + private String storedFilename; + + @Column(name = "file_size_bytes") + private Long fileSizeBytes; + + @Column(name = "mime_type", length = Integer.MAX_VALUE) + private String mimeType; + + @Column(name = "sha256_hex", length = Integer.MAX_VALUE) + private String sha256Hex; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public CustomQuoteRequest getRequest() { + return request; + } + + public void setRequest(CustomQuoteRequest request) { + this.request = request; + } + + public String getOriginalFilename() { + return originalFilename; + } + + public void setOriginalFilename(String originalFilename) { + this.originalFilename = originalFilename; + } + + public String getStoredRelativePath() { + return storedRelativePath; + } + + public void setStoredRelativePath(String storedRelativePath) { + this.storedRelativePath = storedRelativePath; + } + + public String getStoredFilename() { + return storedFilename; + } + + public void setStoredFilename(String storedFilename) { + this.storedFilename = storedFilename; + } + + public Long getFileSizeBytes() { + return fileSizeBytes; + } + + public void setFileSizeBytes(Long fileSizeBytes) { + this.fileSizeBytes = fileSizeBytes; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public String getSha256Hex() { + return sha256Hex; + } + + public void setSha256Hex(String sha256Hex) { + this.sha256Hex = sha256Hex; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public void setCustomQuoteRequest(CustomQuoteRequest request) { + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/Customer.java b/backend/src/main/java/com/printcalculator/entity/Customer.java new file mode 100644 index 0000000..50b96a2 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/Customer.java @@ -0,0 +1,126 @@ +package com.printcalculator.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "customers") +public class Customer { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "customer_id", nullable = false) + private UUID id; + + @Column(name = "customer_type", nullable = false, length = Integer.MAX_VALUE) + private String customerType; + + @Column(name = "email", nullable = false, length = Integer.MAX_VALUE) + private String email; + + @Column(name = "phone", length = Integer.MAX_VALUE) + private String phone; + + @Column(name = "first_name", length = Integer.MAX_VALUE) + private String firstName; + + @Column(name = "last_name", length = Integer.MAX_VALUE) + private String lastName; + + @Column(name = "company_name", length = Integer.MAX_VALUE) + private String companyName; + + @Column(name = "contact_person", length = Integer.MAX_VALUE) + private String contactPerson; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getCustomerType() { + return customerType; + } + + public void setCustomerType(String customerType) { + this.customerType = customerType; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getCompanyName() { + return companyName; + } + + public void setCompanyName(String companyName) { + this.companyName = companyName; + } + + public String getContactPerson() { + return contactPerson; + } + + public void setContactPerson(String contactPerson) { + this.contactPerson = contactPerson; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/FilamentVariant.java b/backend/src/main/java/com/printcalculator/entity/FilamentVariant.java index 22c9003..e2f8bf5 100644 --- a/backend/src/main/java/com/printcalculator/entity/FilamentVariant.java +++ b/backend/src/main/java/com/printcalculator/entity/FilamentVariant.java @@ -24,6 +24,16 @@ public class FilamentVariant { @Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE) private String colorName; + @Column(name = "color_hex", length = Integer.MAX_VALUE) + private String colorHex; + + @ColumnDefault("'GLOSSY'") + @Column(name = "finish_type", length = Integer.MAX_VALUE) + private String finishType; + + @Column(name = "brand", length = Integer.MAX_VALUE) + private String brand; + @ColumnDefault("false") @Column(name = "is_matte", nullable = false) private Boolean isMatte; @@ -83,6 +93,30 @@ public class FilamentVariant { this.colorName = colorName; } + public String getColorHex() { + return colorHex; + } + + public void setColorHex(String colorHex) { + this.colorHex = colorHex; + } + + public String getFinishType() { + return finishType; + } + + public void setFinishType(String finishType) { + this.finishType = finishType; + } + + public String getBrand() { + return brand; + } + + public void setBrand(String brand) { + this.brand = brand; + } + public Boolean getIsMatte() { return isMatte; } @@ -139,4 +173,4 @@ public class FilamentVariant { this.createdAt = createdAt; } -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/printcalculator/entity/FilamentVariantOrcaOverride.java b/backend/src/main/java/com/printcalculator/entity/FilamentVariantOrcaOverride.java new file mode 100644 index 0000000..bd85413 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/FilamentVariantOrcaOverride.java @@ -0,0 +1,72 @@ +package com.printcalculator.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; + +@Entity +@Table(name = "filament_variant_orca_override", uniqueConstraints = { + @UniqueConstraint(name = "ux_filament_variant_orca_override_variant_machine", columnNames = { + "filament_variant_id", "printer_machine_profile_id" + }) +}) +public class FilamentVariantOrcaOverride { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "filament_variant_orca_override_id", nullable = false) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "filament_variant_id", nullable = false) + private FilamentVariant filamentVariant; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "printer_machine_profile_id", nullable = false) + private PrinterMachineProfile printerMachineProfile; + + @Column(name = "orca_filament_profile_name", nullable = false, length = Integer.MAX_VALUE) + private String orcaFilamentProfileName; + + @ColumnDefault("true") + @Column(name = "is_active", nullable = false) + private Boolean isActive; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public FilamentVariant getFilamentVariant() { + return filamentVariant; + } + + public void setFilamentVariant(FilamentVariant filamentVariant) { + this.filamentVariant = filamentVariant; + } + + public PrinterMachineProfile getPrinterMachineProfile() { + return printerMachineProfile; + } + + public void setPrinterMachineProfile(PrinterMachineProfile printerMachineProfile) { + this.printerMachineProfile = printerMachineProfile; + } + + public String getOrcaFilamentProfileName() { + return orcaFilamentProfileName; + } + + public void setOrcaFilamentProfileName(String orcaFilamentProfileName) { + this.orcaFilamentProfileName = orcaFilamentProfileName; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/MaterialOrcaProfileMap.java b/backend/src/main/java/com/printcalculator/entity/MaterialOrcaProfileMap.java new file mode 100644 index 0000000..b162929 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/MaterialOrcaProfileMap.java @@ -0,0 +1,72 @@ +package com.printcalculator.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; + +@Entity +@Table(name = "material_orca_profile_map", uniqueConstraints = { + @UniqueConstraint(name = "ux_material_orca_profile_map_machine_material", columnNames = { + "printer_machine_profile_id", "filament_material_type_id" + }) +}) +public class MaterialOrcaProfileMap { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "material_orca_profile_map_id", nullable = false) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "printer_machine_profile_id", nullable = false) + private PrinterMachineProfile printerMachineProfile; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "filament_material_type_id", nullable = false) + private FilamentMaterialType filamentMaterialType; + + @Column(name = "orca_filament_profile_name", nullable = false, length = Integer.MAX_VALUE) + private String orcaFilamentProfileName; + + @ColumnDefault("true") + @Column(name = "is_active", nullable = false) + private Boolean isActive; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public PrinterMachineProfile getPrinterMachineProfile() { + return printerMachineProfile; + } + + public void setPrinterMachineProfile(PrinterMachineProfile printerMachineProfile) { + this.printerMachineProfile = printerMachineProfile; + } + + public FilamentMaterialType getFilamentMaterialType() { + return filamentMaterialType; + } + + public void setFilamentMaterialType(FilamentMaterialType filamentMaterialType) { + this.filamentMaterialType = filamentMaterialType; + } + + public String getOrcaFilamentProfileName() { + return orcaFilamentProfileName; + } + + public void setOrcaFilamentProfileName(String orcaFilamentProfileName) { + this.orcaFilamentProfileName = orcaFilamentProfileName; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/Order.java b/backend/src/main/java/com/printcalculator/entity/Order.java new file mode 100644 index 0000000..da1feb1 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/Order.java @@ -0,0 +1,435 @@ +package com.printcalculator.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "orders", indexes = {@Index(name = "ix_orders_status", + columnList = "status")}) +public class Order { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "order_id", nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "source_quote_session_id") + private QuoteSession sourceQuoteSession; + + @Column(name = "status", nullable = false, length = Integer.MAX_VALUE) + private String status; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "customer_id") + private Customer customer; + + @Column(name = "customer_email", nullable = false, length = Integer.MAX_VALUE) + private String customerEmail; + + @Column(name = "customer_phone", length = Integer.MAX_VALUE) + private String customerPhone; + + @Column(name = "billing_customer_type", nullable = false, length = Integer.MAX_VALUE) + private String billingCustomerType; + + @Column(name = "billing_first_name", length = Integer.MAX_VALUE) + private String billingFirstName; + + @Column(name = "billing_last_name", length = Integer.MAX_VALUE) + private String billingLastName; + + @Column(name = "billing_company_name", length = Integer.MAX_VALUE) + private String billingCompanyName; + + @Column(name = "billing_contact_person", length = Integer.MAX_VALUE) + private String billingContactPerson; + + @Column(name = "billing_address_line1", nullable = false, length = Integer.MAX_VALUE) + private String billingAddressLine1; + + @Column(name = "billing_address_line2", length = Integer.MAX_VALUE) + private String billingAddressLine2; + + @Column(name = "billing_zip", nullable = false, length = Integer.MAX_VALUE) + private String billingZip; + + @Column(name = "billing_city", nullable = false, length = Integer.MAX_VALUE) + private String billingCity; + + @ColumnDefault("'CH'") + @Column(name = "billing_country_code", nullable = false, length = 2) + private String billingCountryCode; + + @ColumnDefault("true") + @Column(name = "shipping_same_as_billing", nullable = false) + private Boolean shippingSameAsBilling; + + @Column(name = "shipping_first_name", length = Integer.MAX_VALUE) + private String shippingFirstName; + + @Column(name = "shipping_last_name", length = Integer.MAX_VALUE) + private String shippingLastName; + + @Column(name = "shipping_company_name", length = Integer.MAX_VALUE) + private String shippingCompanyName; + + @Column(name = "shipping_contact_person", length = Integer.MAX_VALUE) + private String shippingContactPerson; + + @Column(name = "shipping_address_line1", length = Integer.MAX_VALUE) + private String shippingAddressLine1; + + @Column(name = "shipping_address_line2", length = Integer.MAX_VALUE) + private String shippingAddressLine2; + + @Column(name = "shipping_zip", length = Integer.MAX_VALUE) + private String shippingZip; + + @Column(name = "shipping_city", length = Integer.MAX_VALUE) + private String shippingCity; + + @Column(name = "shipping_country_code", length = 2) + private String shippingCountryCode; + + @ColumnDefault("'it'") + @Column(name = "preferred_language", length = 2) + private String preferredLanguage; + + @ColumnDefault("'CHF'") + @Column(name = "currency", nullable = false, length = 3) + private String currency; + + @ColumnDefault("0.00") + @Column(name = "setup_cost_chf", nullable = false, precision = 12, scale = 2) + private BigDecimal setupCostChf; + + @ColumnDefault("0.00") + @Column(name = "shipping_cost_chf", nullable = false, precision = 12, scale = 2) + private BigDecimal shippingCostChf; + + @ColumnDefault("0.00") + @Column(name = "discount_chf", nullable = false, precision = 12, scale = 2) + private BigDecimal discountChf; + + @ColumnDefault("0.00") + @Column(name = "subtotal_chf", nullable = false, precision = 12, scale = 2) + private BigDecimal subtotalChf; + + @ColumnDefault("0.00") + @Column(name = "total_chf", nullable = false, precision = 12, scale = 2) + private BigDecimal totalChf; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @Column(name = "paid_at") + private OffsetDateTime paidAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + @Transient + public String getOrderNumber() { + if (id == null) { + return null; + } + String rawId = id.toString(); + int dashIndex = rawId.indexOf('-'); + return dashIndex > 0 ? rawId.substring(0, dashIndex) : rawId; + } + + public QuoteSession getSourceQuoteSession() { + return sourceQuoteSession; + } + + public void setSourceQuoteSession(QuoteSession sourceQuoteSession) { + this.sourceQuoteSession = sourceQuoteSession; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public Customer getCustomer() { + return customer; + } + + public void setCustomer(Customer customer) { + this.customer = customer; + } + + public String getCustomerEmail() { + return customerEmail; + } + + public void setCustomerEmail(String customerEmail) { + this.customerEmail = customerEmail; + } + + public String getCustomerPhone() { + return customerPhone; + } + + public void setCustomerPhone(String customerPhone) { + this.customerPhone = customerPhone; + } + + public String getBillingCustomerType() { + return billingCustomerType; + } + + public void setBillingCustomerType(String billingCustomerType) { + this.billingCustomerType = billingCustomerType; + } + + public String getBillingFirstName() { + return billingFirstName; + } + + public void setBillingFirstName(String billingFirstName) { + this.billingFirstName = billingFirstName; + } + + public String getBillingLastName() { + return billingLastName; + } + + public void setBillingLastName(String billingLastName) { + this.billingLastName = billingLastName; + } + + public String getBillingCompanyName() { + return billingCompanyName; + } + + public void setBillingCompanyName(String billingCompanyName) { + this.billingCompanyName = billingCompanyName; + } + + public String getBillingContactPerson() { + return billingContactPerson; + } + + public void setBillingContactPerson(String billingContactPerson) { + this.billingContactPerson = billingContactPerson; + } + + public String getBillingAddressLine1() { + return billingAddressLine1; + } + + public void setBillingAddressLine1(String billingAddressLine1) { + this.billingAddressLine1 = billingAddressLine1; + } + + public String getBillingAddressLine2() { + return billingAddressLine2; + } + + public void setBillingAddressLine2(String billingAddressLine2) { + this.billingAddressLine2 = billingAddressLine2; + } + + public String getBillingZip() { + return billingZip; + } + + public void setBillingZip(String billingZip) { + this.billingZip = billingZip; + } + + public String getBillingCity() { + return billingCity; + } + + public void setBillingCity(String billingCity) { + this.billingCity = billingCity; + } + + public String getBillingCountryCode() { + return billingCountryCode; + } + + public void setBillingCountryCode(String billingCountryCode) { + this.billingCountryCode = billingCountryCode; + } + + public Boolean getShippingSameAsBilling() { + return shippingSameAsBilling; + } + + public void setShippingSameAsBilling(Boolean shippingSameAsBilling) { + this.shippingSameAsBilling = shippingSameAsBilling; + } + + public String getShippingFirstName() { + return shippingFirstName; + } + + public void setShippingFirstName(String shippingFirstName) { + this.shippingFirstName = shippingFirstName; + } + + public String getShippingLastName() { + return shippingLastName; + } + + public void setShippingLastName(String shippingLastName) { + this.shippingLastName = shippingLastName; + } + + public String getShippingCompanyName() { + return shippingCompanyName; + } + + public void setShippingCompanyName(String shippingCompanyName) { + this.shippingCompanyName = shippingCompanyName; + } + + public String getShippingContactPerson() { + return shippingContactPerson; + } + + public void setShippingContactPerson(String shippingContactPerson) { + this.shippingContactPerson = shippingContactPerson; + } + + public String getShippingAddressLine1() { + return shippingAddressLine1; + } + + public void setShippingAddressLine1(String shippingAddressLine1) { + this.shippingAddressLine1 = shippingAddressLine1; + } + + public String getShippingAddressLine2() { + return shippingAddressLine2; + } + + public void setShippingAddressLine2(String shippingAddressLine2) { + this.shippingAddressLine2 = shippingAddressLine2; + } + + public String getShippingZip() { + return shippingZip; + } + + public void setShippingZip(String shippingZip) { + this.shippingZip = shippingZip; + } + + public String getShippingCity() { + return shippingCity; + } + + public void setShippingCity(String shippingCity) { + this.shippingCity = shippingCity; + } + + public String getShippingCountryCode() { + return shippingCountryCode; + } + + public void setShippingCountryCode(String shippingCountryCode) { + this.shippingCountryCode = shippingCountryCode; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public String getPreferredLanguage() { + return preferredLanguage; + } + + public void setPreferredLanguage(String preferredLanguage) { + this.preferredLanguage = preferredLanguage; + } + + public BigDecimal getSetupCostChf() { + return setupCostChf; + } + + public void setSetupCostChf(BigDecimal setupCostChf) { + this.setupCostChf = setupCostChf; + } + + public BigDecimal getShippingCostChf() { + return shippingCostChf; + } + + public void setShippingCostChf(BigDecimal shippingCostChf) { + this.shippingCostChf = shippingCostChf; + } + + public BigDecimal getDiscountChf() { + return discountChf; + } + + public void setDiscountChf(BigDecimal discountChf) { + this.discountChf = discountChf; + } + + public BigDecimal getSubtotalChf() { + return subtotalChf; + } + + public void setSubtotalChf(BigDecimal subtotalChf) { + this.subtotalChf = subtotalChf; + } + + public BigDecimal getTotalChf() { + return totalChf; + } + + public void setTotalChf(BigDecimal totalChf) { + this.totalChf = totalChf; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public OffsetDateTime getPaidAt() { + return paidAt; + } + + public void setPaidAt(OffsetDateTime paidAt) { + this.paidAt = paidAt; + } + +} diff --git a/backend/src/main/java/com/printcalculator/entity/OrderItem.java b/backend/src/main/java/com/printcalculator/entity/OrderItem.java new file mode 100644 index 0000000..e5d6f65 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/OrderItem.java @@ -0,0 +1,253 @@ +package com.printcalculator.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "order_items", indexes = {@Index(name = "ix_order_items_order", + columnList = "order_id")}) +public class OrderItem { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "order_item_id", nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @OnDelete(action = OnDeleteAction.CASCADE) + @JoinColumn(name = "order_id", nullable = false) + private Order order; + + @Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE) + private String originalFilename; + + @Column(name = "stored_relative_path", nullable = false, length = Integer.MAX_VALUE) + private String storedRelativePath; + + @Column(name = "stored_filename", nullable = false, length = Integer.MAX_VALUE) + private String storedFilename; + + @Column(name = "file_size_bytes") + private Long fileSizeBytes; + + @Column(name = "mime_type", length = Integer.MAX_VALUE) + private String mimeType; + + @Column(name = "sha256_hex", length = Integer.MAX_VALUE) + private String sha256Hex; + + @Column(name = "material_code", nullable = false, length = Integer.MAX_VALUE) + private String materialCode; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "filament_variant_id") + private FilamentVariant filamentVariant; + + @Column(name = "color_code", length = Integer.MAX_VALUE) + private String colorCode; + + @ColumnDefault("1") + @Column(name = "quantity", nullable = false) + private Integer quantity; + + @Column(name = "print_time_seconds") + private Integer printTimeSeconds; + + @Column(name = "material_grams", precision = 12, scale = 2) + private BigDecimal materialGrams; + + @Column(name = "bounding_box_x_mm", precision = 10, scale = 3) + private BigDecimal boundingBoxXMm; + + @Column(name = "bounding_box_y_mm", precision = 10, scale = 3) + private BigDecimal boundingBoxYMm; + + @Column(name = "bounding_box_z_mm", precision = 10, scale = 3) + private BigDecimal boundingBoxZMm; + + @Column(name = "unit_price_chf", nullable = false, precision = 12, scale = 2) + private BigDecimal unitPriceChf; + + @Column(name = "line_total_chf", nullable = false, precision = 12, scale = 2) + private BigDecimal lineTotalChf; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @PrePersist + private void onCreate() { + if (createdAt == null) { + createdAt = OffsetDateTime.now(); + } + if (quantity == null) { + quantity = 1; + } + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public Order getOrder() { + return order; + } + + public void setOrder(Order order) { + this.order = order; + } + + public String getOriginalFilename() { + return originalFilename; + } + + public void setOriginalFilename(String originalFilename) { + this.originalFilename = originalFilename; + } + + public String getStoredRelativePath() { + return storedRelativePath; + } + + public void setStoredRelativePath(String storedRelativePath) { + this.storedRelativePath = storedRelativePath; + } + + public String getStoredFilename() { + return storedFilename; + } + + public void setStoredFilename(String storedFilename) { + this.storedFilename = storedFilename; + } + + public Long getFileSizeBytes() { + return fileSizeBytes; + } + + public void setFileSizeBytes(Long fileSizeBytes) { + this.fileSizeBytes = fileSizeBytes; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public String getSha256Hex() { + return sha256Hex; + } + + public void setSha256Hex(String sha256Hex) { + this.sha256Hex = sha256Hex; + } + + public String getMaterialCode() { + return materialCode; + } + + public void setMaterialCode(String materialCode) { + this.materialCode = materialCode; + } + + public FilamentVariant getFilamentVariant() { + return filamentVariant; + } + + public void setFilamentVariant(FilamentVariant filamentVariant) { + this.filamentVariant = filamentVariant; + } + + public String getColorCode() { + return colorCode; + } + + public void setColorCode(String colorCode) { + this.colorCode = colorCode; + } + + public Integer getQuantity() { + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } + + public Integer getPrintTimeSeconds() { + return printTimeSeconds; + } + + public void setPrintTimeSeconds(Integer printTimeSeconds) { + this.printTimeSeconds = printTimeSeconds; + } + + public BigDecimal getMaterialGrams() { + return materialGrams; + } + + public void setMaterialGrams(BigDecimal materialGrams) { + this.materialGrams = materialGrams; + } + + public BigDecimal getBoundingBoxXMm() { + return boundingBoxXMm; + } + + public void setBoundingBoxXMm(BigDecimal boundingBoxXMm) { + this.boundingBoxXMm = boundingBoxXMm; + } + + public BigDecimal getBoundingBoxYMm() { + return boundingBoxYMm; + } + + public void setBoundingBoxYMm(BigDecimal boundingBoxYMm) { + this.boundingBoxYMm = boundingBoxYMm; + } + + public BigDecimal getBoundingBoxZMm() { + return boundingBoxZMm; + } + + public void setBoundingBoxZMm(BigDecimal boundingBoxZMm) { + this.boundingBoxZMm = boundingBoxZMm; + } + + public BigDecimal getUnitPriceChf() { + return unitPriceChf; + } + + public void setUnitPriceChf(BigDecimal unitPriceChf) { + this.unitPriceChf = unitPriceChf; + } + + public BigDecimal getLineTotalChf() { + return lineTotalChf; + } + + public void setLineTotalChf(BigDecimal lineTotalChf) { + this.lineTotalChf = lineTotalChf; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + +} diff --git a/backend/src/main/java/com/printcalculator/entity/Payment.java b/backend/src/main/java/com/printcalculator/entity/Payment.java new file mode 100644 index 0000000..4b13a93 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/Payment.java @@ -0,0 +1,157 @@ +package com.printcalculator.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "payments", indexes = { + @Index(name = "ix_payments_order", + columnList = "order_id"), + @Index(name = "ix_payments_reference", + columnList = "payment_reference")}) +public class Payment { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "payment_id", nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @OnDelete(action = OnDeleteAction.CASCADE) + @JoinColumn(name = "order_id", nullable = false) + private Order order; + + @Column(name = "method", nullable = false, length = Integer.MAX_VALUE) + private String method; + + @Column(name = "status", nullable = false, length = Integer.MAX_VALUE) + private String status; + + @ColumnDefault("'CHF'") + @Column(name = "currency", nullable = false, length = 3) + private String currency; + + @Column(name = "amount_chf", nullable = false, precision = 12, scale = 2) + private BigDecimal amountChf; + + @Column(name = "payment_reference", length = Integer.MAX_VALUE) + private String paymentReference; + + @Column(name = "provider_transaction_id", length = Integer.MAX_VALUE) + private String providerTransactionId; + + @Column(name = "qr_payload", length = Integer.MAX_VALUE) + private String qrPayload; + + @ColumnDefault("now()") + @Column(name = "initiated_at", nullable = false) + private OffsetDateTime initiatedAt; + + @Column(name = "reported_at") + private OffsetDateTime reportedAt; + + @Column(name = "received_at") + private OffsetDateTime receivedAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public Order getOrder() { + return order; + } + + public void setOrder(Order order) { + this.order = order; + } + + public String getMethod() { + return method; + } + + public void setMethod(String method) { + this.method = method; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public BigDecimal getAmountChf() { + return amountChf; + } + + public void setAmountChf(BigDecimal amountChf) { + this.amountChf = amountChf; + } + + public String getPaymentReference() { + return paymentReference; + } + + public void setPaymentReference(String paymentReference) { + this.paymentReference = paymentReference; + } + + public String getProviderTransactionId() { + return providerTransactionId; + } + + public void setProviderTransactionId(String providerTransactionId) { + this.providerTransactionId = providerTransactionId; + } + + public String getQrPayload() { + return qrPayload; + } + + public void setQrPayload(String qrPayload) { + this.qrPayload = qrPayload; + } + + public OffsetDateTime getInitiatedAt() { + return initiatedAt; + } + + public void setInitiatedAt(OffsetDateTime initiatedAt) { + this.initiatedAt = initiatedAt; + } + + public OffsetDateTime getReportedAt() { + return reportedAt; + } + + public void setReportedAt(OffsetDateTime reportedAt) { + this.reportedAt = reportedAt; + } + + public OffsetDateTime getReceivedAt() { + return receivedAt; + } + + public void setReceivedAt(OffsetDateTime receivedAt) { + this.receivedAt = receivedAt; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/entity/PrinterMachineProfile.java b/backend/src/main/java/com/printcalculator/entity/PrinterMachineProfile.java new file mode 100644 index 0000000..a600885 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/PrinterMachineProfile.java @@ -0,0 +1,85 @@ +package com.printcalculator.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; + +import java.math.BigDecimal; + +@Entity +@Table(name = "printer_machine_profile", uniqueConstraints = { + @UniqueConstraint(name = "ux_printer_machine_profile_machine_nozzle", columnNames = { + "printer_machine_id", "nozzle_diameter_mm" + }) +}) +public class PrinterMachineProfile { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "printer_machine_profile_id", nullable = false) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "printer_machine_id", nullable = false) + private PrinterMachine printerMachine; + + @Column(name = "nozzle_diameter_mm", nullable = false, precision = 4, scale = 2) + private BigDecimal nozzleDiameterMm; + + @Column(name = "orca_machine_profile_name", nullable = false, length = Integer.MAX_VALUE) + private String orcaMachineProfileName; + + @ColumnDefault("false") + @Column(name = "is_default", nullable = false) + private Boolean isDefault; + + @ColumnDefault("true") + @Column(name = "is_active", nullable = false) + private Boolean isActive; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public PrinterMachine getPrinterMachine() { + return printerMachine; + } + + public void setPrinterMachine(PrinterMachine printerMachine) { + this.printerMachine = printerMachine; + } + + public BigDecimal getNozzleDiameterMm() { + return nozzleDiameterMm; + } + + public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) { + this.nozzleDiameterMm = nozzleDiameterMm; + } + + public String getOrcaMachineProfileName() { + return orcaMachineProfileName; + } + + public void setOrcaMachineProfileName(String orcaMachineProfileName) { + this.orcaMachineProfileName = orcaMachineProfileName; + } + + public Boolean getIsDefault() { + return isDefault; + } + + public void setIsDefault(Boolean isDefault) { + this.isDefault = isDefault; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java new file mode 100644 index 0000000..321c705 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/QuoteLineItem.java @@ -0,0 +1,228 @@ +package com.printcalculator.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; +import org.hibernate.type.SqlTypes; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.UUID; + +@Entity +@Table(name = "quote_line_items", indexes = {@Index(name = "ix_quote_line_items_session", + columnList = "quote_session_id")}) +public class QuoteLineItem { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "quote_line_item_id", nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @OnDelete(action = OnDeleteAction.CASCADE) + @JoinColumn(name = "quote_session_id", nullable = false) + @com.fasterxml.jackson.annotation.JsonIgnore + private QuoteSession quoteSession; + + @Column(name = "status", nullable = false, length = Integer.MAX_VALUE) + private String status; + + @Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE) + private String originalFilename; + + @ColumnDefault("1") + @Column(name = "quantity", nullable = false) + private Integer quantity; + + @Column(name = "color_code", length = Integer.MAX_VALUE) + private String colorCode; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "filament_variant_id") + @com.fasterxml.jackson.annotation.JsonIgnore + private FilamentVariant filamentVariant; + + @Column(name = "bounding_box_x_mm", precision = 10, scale = 3) + private BigDecimal boundingBoxXMm; + + @Column(name = "bounding_box_y_mm", precision = 10, scale = 3) + private BigDecimal boundingBoxYMm; + + @Column(name = "bounding_box_z_mm", precision = 10, scale = 3) + private BigDecimal boundingBoxZMm; + + @Column(name = "print_time_seconds") + private Integer printTimeSeconds; + + @Column(name = "material_grams", precision = 12, scale = 2) + private BigDecimal materialGrams; + + @Column(name = "unit_price_chf", precision = 12, scale = 2) + private BigDecimal unitPriceChf; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "pricing_breakdown") + private Map pricingBreakdown; + + @Column(name = "error_message", length = Integer.MAX_VALUE) + private String errorMessage; + + @Column(name = "stored_path", length = Integer.MAX_VALUE) + private String storedPath; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public QuoteSession getQuoteSession() { + return quoteSession; + } + + public void setQuoteSession(QuoteSession quoteSession) { + this.quoteSession = quoteSession; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getOriginalFilename() { + return originalFilename; + } + + public void setOriginalFilename(String originalFilename) { + this.originalFilename = originalFilename; + } + + public Integer getQuantity() { + return quantity; + } + + public void setQuantity(Integer quantity) { + this.quantity = quantity; + } + + public String getColorCode() { + return colorCode; + } + + public void setColorCode(String colorCode) { + this.colorCode = colorCode; + } + + public FilamentVariant getFilamentVariant() { + return filamentVariant; + } + + public void setFilamentVariant(FilamentVariant filamentVariant) { + this.filamentVariant = filamentVariant; + } + + public BigDecimal getBoundingBoxXMm() { + return boundingBoxXMm; + } + + public void setBoundingBoxXMm(BigDecimal boundingBoxXMm) { + this.boundingBoxXMm = boundingBoxXMm; + } + + public BigDecimal getBoundingBoxYMm() { + return boundingBoxYMm; + } + + public void setBoundingBoxYMm(BigDecimal boundingBoxYMm) { + this.boundingBoxYMm = boundingBoxYMm; + } + + public BigDecimal getBoundingBoxZMm() { + return boundingBoxZMm; + } + + public void setBoundingBoxZMm(BigDecimal boundingBoxZMm) { + this.boundingBoxZMm = boundingBoxZMm; + } + + public Integer getPrintTimeSeconds() { + return printTimeSeconds; + } + + public void setPrintTimeSeconds(Integer printTimeSeconds) { + this.printTimeSeconds = printTimeSeconds; + } + + public BigDecimal getMaterialGrams() { + return materialGrams; + } + + public void setMaterialGrams(BigDecimal materialGrams) { + this.materialGrams = materialGrams; + } + + public BigDecimal getUnitPriceChf() { + return unitPriceChf; + } + + public void setUnitPriceChf(BigDecimal unitPriceChf) { + this.unitPriceChf = unitPriceChf; + } + + public Map getPricingBreakdown() { + return pricingBreakdown; + } + + public void setPricingBreakdown(Map pricingBreakdown) { + this.pricingBreakdown = pricingBreakdown; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public String getStoredPath() { + return storedPath; + } + + public void setStoredPath(String storedPath) { + this.storedPath = storedPath; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } + +} diff --git a/backend/src/main/java/com/printcalculator/entity/QuoteSession.java b/backend/src/main/java/com/printcalculator/entity/QuoteSession.java new file mode 100644 index 0000000..3979b54 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/QuoteSession.java @@ -0,0 +1,176 @@ +package com.printcalculator.entity; + +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "quote_sessions", indexes = { + @Index(name = "ix_quote_sessions_status", + columnList = "status"), + @Index(name = "ix_quote_sessions_expires_at", + columnList = "expires_at")}) +public class QuoteSession { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "quote_session_id", nullable = false) + private UUID id; + + @Column(name = "status", nullable = false, length = Integer.MAX_VALUE) + private String status; + + @Column(name = "pricing_version", nullable = false, length = Integer.MAX_VALUE) + private String pricingVersion; + + @Column(name = "material_code", nullable = false, length = Integer.MAX_VALUE) + private String materialCode; + + @Column(name = "nozzle_diameter_mm", precision = 5, scale = 2) + private BigDecimal nozzleDiameterMm; + + @Column(name = "layer_height_mm", precision = 6, scale = 3) + private BigDecimal layerHeightMm; + + @Column(name = "infill_pattern", length = Integer.MAX_VALUE) + private String infillPattern; + + @Column(name = "infill_percent") + private Integer infillPercent; + + @ColumnDefault("false") + @Column(name = "supports_enabled", nullable = false) + private Boolean supportsEnabled; + + @Column(name = "notes", length = Integer.MAX_VALUE) + private String notes; + + @ColumnDefault("0.00") + @Column(name = "setup_cost_chf", nullable = false, precision = 12, scale = 2) + private BigDecimal setupCostChf; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @Column(name = "expires_at", nullable = false) + private OffsetDateTime expiresAt; + + @Column(name = "converted_order_id") + private UUID convertedOrderId; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getPricingVersion() { + return pricingVersion; + } + + public void setPricingVersion(String pricingVersion) { + this.pricingVersion = pricingVersion; + } + + public String getMaterialCode() { + return materialCode; + } + + public void setMaterialCode(String materialCode) { + this.materialCode = materialCode; + } + + public BigDecimal getNozzleDiameterMm() { + return nozzleDiameterMm; + } + + public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) { + this.nozzleDiameterMm = nozzleDiameterMm; + } + + public BigDecimal getLayerHeightMm() { + return layerHeightMm; + } + + public void setLayerHeightMm(BigDecimal layerHeightMm) { + this.layerHeightMm = layerHeightMm; + } + + public String getInfillPattern() { + return infillPattern; + } + + public void setInfillPattern(String infillPattern) { + this.infillPattern = infillPattern; + } + + public Integer getInfillPercent() { + return infillPercent; + } + + public void setInfillPercent(Integer infillPercent) { + this.infillPercent = infillPercent; + } + + public Boolean getSupportsEnabled() { + return supportsEnabled; + } + + public void setSupportsEnabled(Boolean supportsEnabled) { + this.supportsEnabled = supportsEnabled; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } + + public BigDecimal getSetupCostChf() { + return setupCostChf; + } + + public void setSetupCostChf(BigDecimal setupCostChf) { + this.setupCostChf = setupCostChf; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(OffsetDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public UUID getConvertedOrderId() { + return convertedOrderId; + } + + public void setConvertedOrderId(UUID convertedOrderId) { + this.convertedOrderId = convertedOrderId; + } + +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/event/OrderCreatedEvent.java b/backend/src/main/java/com/printcalculator/event/OrderCreatedEvent.java new file mode 100644 index 0000000..29ccde1 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/event/OrderCreatedEvent.java @@ -0,0 +1,16 @@ +package com.printcalculator.event; + +import com.printcalculator.entity.Order; +import lombok.Getter; +import org.springframework.context.ApplicationEvent; + +@Getter +public class OrderCreatedEvent extends ApplicationEvent { + + private final Order order; + + public OrderCreatedEvent(Object source, Order order) { + super(source); + this.order = order; + } +} diff --git a/backend/src/main/java/com/printcalculator/event/PaymentConfirmedEvent.java b/backend/src/main/java/com/printcalculator/event/PaymentConfirmedEvent.java new file mode 100644 index 0000000..72e0f4a --- /dev/null +++ b/backend/src/main/java/com/printcalculator/event/PaymentConfirmedEvent.java @@ -0,0 +1,24 @@ +package com.printcalculator.event; + +import com.printcalculator.entity.Order; +import com.printcalculator.entity.Payment; +import org.springframework.context.ApplicationEvent; + +public class PaymentConfirmedEvent extends ApplicationEvent { + private final Order order; + private final Payment payment; + + public PaymentConfirmedEvent(Object source, Order order, Payment payment) { + super(source); + this.order = order; + this.payment = payment; + } + + public Order getOrder() { + return order; + } + + public Payment getPayment() { + return payment; + } +} diff --git a/backend/src/main/java/com/printcalculator/event/PaymentReportedEvent.java b/backend/src/main/java/com/printcalculator/event/PaymentReportedEvent.java new file mode 100644 index 0000000..51f378d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/event/PaymentReportedEvent.java @@ -0,0 +1,25 @@ +package com.printcalculator.event; + +import com.printcalculator.entity.Order; +import com.printcalculator.entity.Payment; +import org.springframework.context.ApplicationEvent; + +public class PaymentReportedEvent extends ApplicationEvent { + + private final Order order; + private final Payment payment; + + public PaymentReportedEvent(Object source, Order order, Payment payment) { + super(source); + this.order = order; + this.payment = payment; + } + + public Order getOrder() { + return order; + } + + public Payment getPayment() { + return payment; + } +} diff --git a/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java b/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java new file mode 100644 index 0000000..076b128 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java @@ -0,0 +1,496 @@ +package com.printcalculator.event.listener; + +import com.printcalculator.entity.Order; +import com.printcalculator.entity.OrderItem; +import com.printcalculator.entity.Payment; +import com.printcalculator.event.OrderCreatedEvent; +import com.printcalculator.event.PaymentConfirmedEvent; +import com.printcalculator.event.PaymentReportedEvent; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.service.InvoicePdfRenderingService; +import com.printcalculator.service.QrBillService; +import com.printcalculator.service.StorageService; +import com.printcalculator.service.email.EmailNotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.text.NumberFormat; +import java.time.Year; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.Currency; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.nio.file.Paths; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderEmailListener { + + private static final String DEFAULT_LANGUAGE = "it"; + + private final EmailNotificationService emailNotificationService; + private final InvoicePdfRenderingService invoicePdfRenderingService; + private final OrderItemRepository orderItemRepository; + private final QrBillService qrBillService; + private final StorageService storageService; + + @Value("${app.mail.admin.enabled:true}") + private boolean adminMailEnabled; + + @Value("${app.mail.admin.address:}") + private String adminMailAddress; + + @Value("${app.frontend.base-url:http://localhost:4200}") + private String frontendBaseUrl; + + @Async + @EventListener + public void handleOrderCreatedEvent(OrderCreatedEvent event) { + Order order = event.getOrder(); + log.info("Processing OrderCreatedEvent for order id: {}", order.getId()); + + try { + sendCustomerConfirmationEmail(order); + + if (adminMailEnabled && adminMailAddress != null && !adminMailAddress.isEmpty()) { + sendAdminNotificationEmail(order); + } + } catch (Exception e) { + log.error("Failed to process email notifications for order id: {}", order.getId(), e); + } + } + + @Async + @EventListener + public void handlePaymentReportedEvent(PaymentReportedEvent event) { + Order order = event.getOrder(); + log.info("Processing PaymentReportedEvent for order id: {}", order.getId()); + + try { + sendPaymentReportedEmail(order); + } catch (Exception e) { + log.error("Failed to send payment reported email for order id: {}", order.getId(), e); + } + } + + @Async + @EventListener + public void handlePaymentConfirmedEvent(PaymentConfirmedEvent event) { + Order order = event.getOrder(); + Payment payment = event.getPayment(); + log.info("Processing PaymentConfirmedEvent for order id: {}", order.getId()); + + try { + sendPaidInvoiceEmail(order, payment); + } catch (Exception e) { + log.error("Failed to send paid invoice email for order id: {}", order.getId(), e); + } + } + + private void sendCustomerConfirmationEmail(Order order) { + String language = resolveLanguage(order.getPreferredLanguage()); + String orderNumber = getDisplayOrderNumber(order); + + Map templateData = buildBaseTemplateData(order, language); + String subject = applyOrderConfirmationTexts(templateData, language, orderNumber); + byte[] confirmationPdf = loadOrGenerateConfirmationPdf(order); + + emailNotificationService.sendEmailWithAttachment( + order.getCustomer().getEmail(), + subject, + "order-confirmation", + templateData, + buildConfirmationAttachmentName(language, orderNumber), + confirmationPdf + ); + } + + private void sendPaymentReportedEmail(Order order) { + String language = resolveLanguage(order.getPreferredLanguage()); + String orderNumber = getDisplayOrderNumber(order); + + Map templateData = buildBaseTemplateData(order, language); + String subject = applyPaymentReportedTexts(templateData, language, orderNumber); + + emailNotificationService.sendEmail( + order.getCustomer().getEmail(), + subject, + "payment-reported", + templateData + ); + } + + private void sendPaidInvoiceEmail(Order order, Payment payment) { + String language = resolveLanguage(order.getPreferredLanguage()); + String orderNumber = getDisplayOrderNumber(order); + + Map templateData = buildBaseTemplateData(order, language); + String subject = applyPaymentConfirmedTexts(templateData, language, orderNumber); + + byte[] pdf = null; + try { + List items = orderItemRepository.findByOrder_Id(order.getId()); + pdf = invoicePdfRenderingService.generateDocumentPdf(order, items, false, qrBillService, payment); + } catch (Exception e) { + log.error("Failed to generate PDF for paid invoice email: {}", e.getMessage(), e); + } + + emailNotificationService.sendEmailWithAttachment( + order.getCustomer().getEmail(), + subject, + "payment-confirmed", + templateData, + buildPaidInvoiceAttachmentName(language, orderNumber), + pdf + ); + } + + private void sendAdminNotificationEmail(Order order) { + String orderNumber = getDisplayOrderNumber(order); + Map templateData = buildBaseTemplateData(order, DEFAULT_LANGUAGE); + templateData.put("customerName", buildCustomerFullName(order)); + + templateData.put("emailTitle", "Nuovo ordine ricevuto"); + templateData.put("headlineText", "Nuovo ordine #" + orderNumber); + templateData.put("greetingText", "Ciao team,"); + templateData.put("introText", "Un nuovo ordine e' stato creato dal cliente."); + templateData.put("detailsTitleText", "Dettagli ordine"); + templateData.put("labelOrderNumber", "Numero ordine"); + templateData.put("labelDate", "Data"); + templateData.put("labelTotal", "Totale"); + templateData.put("orderDetailsCtaText", "Apri dettaglio ordine"); + templateData.put("attachmentHintText", "La conferma cliente e il QR bill sono stati salvati nella cartella documenti dell'ordine."); + templateData.put("supportText", "Controlla i dettagli e procedi con la gestione operativa."); + templateData.put("footerText", "Notifica automatica sistema ordini."); + + emailNotificationService.sendEmail( + adminMailAddress, + "Nuovo Ordine Ricevuto #" + orderNumber + " - " + buildCustomerFullName(order), + "order-confirmation", + templateData + ); + } + + private Map buildBaseTemplateData(Order order, String language) { + Locale locale = localeForLanguage(language); + NumberFormat currencyFormatter = NumberFormat.getCurrencyInstance(locale); + currencyFormatter.setCurrency(Currency.getInstance("CHF")); + + Map templateData = new HashMap<>(); + templateData.put("customerName", buildCustomerFirstName(order, language)); + templateData.put("orderId", order.getId()); + templateData.put("orderNumber", getDisplayOrderNumber(order)); + templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order, language)); + templateData.put( + "orderDate", + order.getCreatedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(locale)) + ); + templateData.put("totalCost", currencyFormatter.format(order.getTotalChf())); + templateData.put("currentYear", Year.now().getValue()); + return templateData; + } + + private String applyOrderConfirmationTexts(Map templateData, String language, String orderNumber) { + return switch (language) { + case "en" -> { + templateData.put("emailTitle", "Order Confirmation"); + templateData.put("headlineText", "Thank you for your order #" + orderNumber); + templateData.put("greetingText", "Hi " + templateData.get("customerName") + ","); + templateData.put("introText", "We received your order and started processing it."); + templateData.put("detailsTitleText", "Order details"); + templateData.put("labelOrderNumber", "Order number"); + templateData.put("labelDate", "Date"); + templateData.put("labelTotal", "Total"); + templateData.put("orderDetailsCtaText", "View order status"); + templateData.put("attachmentHintText", "Attached you can find the order confirmation PDF with the QR bill."); + templateData.put("supportText", "If you have questions, reply to this email and we will help you."); + templateData.put("footerText", "Automated message from 3D-Fab."); + yield "Order Confirmation #" + orderNumber + " - 3D-Fab"; + } + case "de" -> { + templateData.put("emailTitle", "Bestellbestaetigung"); + templateData.put("headlineText", "Danke fuer Ihre Bestellung #" + orderNumber); + templateData.put("greetingText", "Hallo " + templateData.get("customerName") + ","); + templateData.put("introText", "Wir haben Ihre Bestellung erhalten und mit der Bearbeitung begonnen."); + templateData.put("detailsTitleText", "Bestelldetails"); + templateData.put("labelOrderNumber", "Bestellnummer"); + templateData.put("labelDate", "Datum"); + templateData.put("labelTotal", "Gesamtbetrag"); + templateData.put("orderDetailsCtaText", "Bestellstatus ansehen"); + templateData.put("attachmentHintText", "Im Anhang finden Sie die Bestellbestaetigung mit QR-Rechnung."); + templateData.put("supportText", "Bei Fragen antworten Sie einfach auf diese E-Mail."); + templateData.put("footerText", "Automatische Nachricht von 3D-Fab."); + yield "Bestellbestaetigung #" + orderNumber + " - 3D-Fab"; + } + case "fr" -> { + templateData.put("emailTitle", "Confirmation de commande"); + templateData.put("headlineText", "Merci pour votre commande #" + orderNumber); + templateData.put("greetingText", "Bonjour " + templateData.get("customerName") + ","); + templateData.put("introText", "Nous avons recu votre commande et commence son traitement."); + templateData.put("detailsTitleText", "Details de commande"); + templateData.put("labelOrderNumber", "Numero de commande"); + templateData.put("labelDate", "Date"); + templateData.put("labelTotal", "Total"); + templateData.put("orderDetailsCtaText", "Voir le statut de la commande"); + templateData.put("attachmentHintText", "Vous trouverez en piece jointe la confirmation de commande avec la facture QR."); + templateData.put("supportText", "Si vous avez des questions, repondez a cet email."); + templateData.put("footerText", "Message automatique de 3D-Fab."); + yield "Confirmation de commande #" + orderNumber + " - 3D-Fab"; + } + default -> { + templateData.put("emailTitle", "Conferma ordine"); + templateData.put("headlineText", "Grazie per il tuo ordine #" + orderNumber); + templateData.put("greetingText", "Ciao " + templateData.get("customerName") + ","); + templateData.put("introText", "Abbiamo ricevuto il tuo ordine e iniziato l'elaborazione."); + templateData.put("detailsTitleText", "Dettagli ordine"); + templateData.put("labelOrderNumber", "Numero ordine"); + templateData.put("labelDate", "Data"); + templateData.put("labelTotal", "Totale"); + templateData.put("orderDetailsCtaText", "Visualizza stato ordine"); + templateData.put("attachmentHintText", "In allegato trovi la conferma ordine in PDF con QR bill."); + templateData.put("supportText", "Se hai domande, rispondi a questa email e ti aiutiamo subito."); + templateData.put("footerText", "Messaggio automatico di 3D-Fab."); + yield "Conferma Ordine #" + orderNumber + " - 3D-Fab"; + } + }; + } + + private String applyPaymentReportedTexts(Map templateData, String language, String orderNumber) { + return switch (language) { + case "en" -> { + templateData.put("emailTitle", "Payment Reported"); + templateData.put("headlineText", "Payment reported for order #" + orderNumber); + templateData.put("greetingText", "Hi " + templateData.get("customerName") + ","); + templateData.put("introText", "We received your payment report and our team is now verifying it."); + templateData.put("statusText", "Current status: Payment under verification."); + templateData.put("orderDetailsCtaText", "Check order status"); + templateData.put("supportText", "You will receive another email as soon as the payment is confirmed."); + templateData.put("footerText", "Automated message from 3D-Fab."); + templateData.put("labelOrderNumber", "Order number"); + templateData.put("labelTotal", "Total"); + yield "We are verifying your payment (Order #" + orderNumber + ")"; + } + case "de" -> { + templateData.put("emailTitle", "Zahlung gemeldet"); + templateData.put("headlineText", "Zahlung fuer Bestellung #" + orderNumber + " gemeldet"); + templateData.put("greetingText", "Hallo " + templateData.get("customerName") + ","); + templateData.put("introText", "Wir haben Ihre Zahlungsmitteilung erhalten und pruefen sie aktuell."); + templateData.put("statusText", "Aktueller Status: Zahlung in Pruefung."); + templateData.put("orderDetailsCtaText", "Bestellstatus ansehen"); + templateData.put("supportText", "Sobald die Zahlung bestaetigt ist, erhalten Sie eine weitere E-Mail."); + templateData.put("footerText", "Automatische Nachricht von 3D-Fab."); + templateData.put("labelOrderNumber", "Bestellnummer"); + templateData.put("labelTotal", "Gesamtbetrag"); + yield "Wir pruefen Ihre Zahlung (Bestellung #" + orderNumber + ")"; + } + case "fr" -> { + templateData.put("emailTitle", "Paiement signale"); + templateData.put("headlineText", "Paiement signale pour la commande #" + orderNumber); + templateData.put("greetingText", "Bonjour " + templateData.get("customerName") + ","); + templateData.put("introText", "Nous avons recu votre signalement de paiement et nous le verifions."); + templateData.put("statusText", "Statut actuel: Paiement en verification."); + templateData.put("orderDetailsCtaText", "Consulter le statut de la commande"); + templateData.put("supportText", "Vous recevrez un nouvel email des que le paiement sera confirme."); + templateData.put("footerText", "Message automatique de 3D-Fab."); + templateData.put("labelOrderNumber", "Numero de commande"); + templateData.put("labelTotal", "Total"); + yield "Nous verifions votre paiement (Commande #" + orderNumber + ")"; + } + default -> { + templateData.put("emailTitle", "Pagamento segnalato"); + templateData.put("headlineText", "Pagamento segnalato per ordine #" + orderNumber); + templateData.put("greetingText", "Ciao " + templateData.get("customerName") + ","); + templateData.put("introText", "Abbiamo ricevuto la tua segnalazione di pagamento e la stiamo verificando."); + templateData.put("statusText", "Stato attuale: pagamento in verifica."); + templateData.put("orderDetailsCtaText", "Controlla lo stato ordine"); + templateData.put("supportText", "Riceverai una nuova email non appena il pagamento sara' confermato."); + templateData.put("footerText", "Messaggio automatico di 3D-Fab."); + templateData.put("labelOrderNumber", "Numero ordine"); + templateData.put("labelTotal", "Totale"); + yield "Stiamo verificando il tuo pagamento (Ordine #" + orderNumber + ")"; + } + }; + } + + private String applyPaymentConfirmedTexts(Map templateData, String language, String orderNumber) { + return switch (language) { + case "en" -> { + templateData.put("emailTitle", "Payment Confirmed"); + templateData.put("headlineText", "Payment confirmed for order #" + orderNumber); + templateData.put("greetingText", "Hi " + templateData.get("customerName") + ","); + templateData.put("introText", "Your payment has been confirmed and the order moved into production."); + templateData.put("statusText", "Current status: In production."); + templateData.put("attachmentHintText", "The paid invoice PDF is attached to this email."); + templateData.put("orderDetailsCtaText", "View order status"); + templateData.put("supportText", "We will notify you again when the shipment is ready."); + templateData.put("footerText", "Automated message from 3D-Fab."); + templateData.put("labelOrderNumber", "Order number"); + templateData.put("labelTotal", "Total"); + yield "Payment confirmed (Order #" + orderNumber + ") - 3D-Fab"; + } + case "de" -> { + templateData.put("emailTitle", "Zahlung bestaetigt"); + templateData.put("headlineText", "Zahlung fuer Bestellung #" + orderNumber + " bestaetigt"); + templateData.put("greetingText", "Hallo " + templateData.get("customerName") + ","); + templateData.put("introText", "Ihre Zahlung wurde bestaetigt und die Bestellung ist jetzt in Produktion."); + templateData.put("statusText", "Aktueller Status: In Produktion."); + templateData.put("attachmentHintText", "Die bezahlte Rechnung als PDF ist dieser E-Mail beigefuegt."); + templateData.put("orderDetailsCtaText", "Bestellstatus ansehen"); + templateData.put("supportText", "Wir informieren Sie erneut, sobald der Versand bereit ist."); + templateData.put("footerText", "Automatische Nachricht von 3D-Fab."); + templateData.put("labelOrderNumber", "Bestellnummer"); + templateData.put("labelTotal", "Gesamtbetrag"); + yield "Zahlung bestaetigt (Bestellung #" + orderNumber + ") - 3D-Fab"; + } + case "fr" -> { + templateData.put("emailTitle", "Paiement confirme"); + templateData.put("headlineText", "Paiement confirme pour la commande #" + orderNumber); + templateData.put("greetingText", "Bonjour " + templateData.get("customerName") + ","); + templateData.put("introText", "Votre paiement est confirme et la commande est passe en production."); + templateData.put("statusText", "Statut actuel: En production."); + templateData.put("attachmentHintText", "La facture payee en PDF est jointe a cet email."); + templateData.put("orderDetailsCtaText", "Voir le statut de la commande"); + templateData.put("supportText", "Nous vous informerons a nouveau des que l'expedition sera prete."); + templateData.put("footerText", "Message automatique de 3D-Fab."); + templateData.put("labelOrderNumber", "Numero de commande"); + templateData.put("labelTotal", "Total"); + yield "Paiement confirme (Commande #" + orderNumber + ") - 3D-Fab"; + } + default -> { + templateData.put("emailTitle", "Pagamento confermato"); + templateData.put("headlineText", "Pagamento confermato per ordine #" + orderNumber); + templateData.put("greetingText", "Ciao " + templateData.get("customerName") + ","); + templateData.put("introText", "Il tuo pagamento e' stato confermato e l'ordine e' entrato in produzione."); + templateData.put("statusText", "Stato attuale: in produzione."); + templateData.put("attachmentHintText", "In allegato trovi la fattura saldata in PDF."); + templateData.put("orderDetailsCtaText", "Visualizza stato ordine"); + templateData.put("supportText", "Ti aggiorneremo di nuovo quando la spedizione sara' pronta."); + templateData.put("footerText", "Messaggio automatico di 3D-Fab."); + templateData.put("labelOrderNumber", "Numero ordine"); + templateData.put("labelTotal", "Totale"); + yield "Pagamento confermato (Ordine #" + orderNumber + ") - 3D-Fab"; + } + }; + } + + private String getDisplayOrderNumber(Order order) { + String orderNumber = order.getOrderNumber(); + if (orderNumber != null && !orderNumber.isBlank()) { + return orderNumber; + } + return order.getId() != null ? order.getId().toString() : "unknown"; + } + + private String buildOrderDetailsUrl(Order order, String language) { + String baseUrl = frontendBaseUrl == null ? "" : frontendBaseUrl.replaceAll("/+$", ""); + return baseUrl + "/" + language + "/co/" + order.getId(); + } + + private String buildConfirmationAttachmentName(String language, String orderNumber) { + return switch (language) { + case "en" -> "Order-Confirmation-" + orderNumber + ".pdf"; + case "de" -> "Bestellbestaetigung-" + orderNumber + ".pdf"; + case "fr" -> "Confirmation-Commande-" + orderNumber + ".pdf"; + default -> "Conferma-Ordine-" + orderNumber + ".pdf"; + }; + } + + private String buildPaidInvoiceAttachmentName(String language, String orderNumber) { + return switch (language) { + case "en" -> "Paid-Invoice-" + orderNumber + ".pdf"; + case "de" -> "Bezahlte-Rechnung-" + orderNumber + ".pdf"; + case "fr" -> "Facture-Payee-" + orderNumber + ".pdf"; + default -> "Fattura-Pagata-" + orderNumber + ".pdf"; + }; + } + + private byte[] loadOrGenerateConfirmationPdf(Order order) { + byte[] stored = loadStoredConfirmationPdf(order); + if (stored != null) { + return stored; + } + + try { + List items = orderItemRepository.findByOrder_Id(order.getId()); + return invoicePdfRenderingService.generateDocumentPdf(order, items, true, qrBillService, null); + } catch (Exception e) { + log.error("Failed to generate fallback confirmation PDF for order id: {}", order.getId(), e); + return null; + } + } + + private byte[] loadStoredConfirmationPdf(Order order) { + String relativePath = buildConfirmationPdfRelativePath(order); + try { + return storageService.loadAsResource(Paths.get(relativePath)).getInputStream().readAllBytes(); + } catch (Exception e) { + log.warn("Confirmation PDF not found for order id {} at {}", order.getId(), relativePath); + return null; + } + } + + private String buildConfirmationPdfRelativePath(Order order) { + return "orders/" + order.getId() + "/documents/confirmation-" + getDisplayOrderNumber(order) + ".pdf"; + } + + private String buildCustomerFirstName(Order order, String language) { + if (order.getCustomer() != null && order.getCustomer().getFirstName() != null && !order.getCustomer().getFirstName().isBlank()) { + return order.getCustomer().getFirstName(); + } + if (order.getBillingFirstName() != null && !order.getBillingFirstName().isBlank()) { + return order.getBillingFirstName(); + } + return switch (language) { + case "en" -> "Customer"; + case "de" -> "Kunde"; + case "fr" -> "Client"; + default -> "Cliente"; + }; + } + + private String buildCustomerFullName(Order order) { + String firstName = order.getCustomer() != null ? order.getCustomer().getFirstName() : null; + String lastName = order.getCustomer() != null ? order.getCustomer().getLastName() : null; + if (firstName != null && !firstName.isBlank() && lastName != null && !lastName.isBlank()) { + return firstName + " " + lastName; + } + if (order.getBillingFirstName() != null && !order.getBillingFirstName().isBlank() + && order.getBillingLastName() != null && !order.getBillingLastName().isBlank()) { + return order.getBillingFirstName() + " " + order.getBillingLastName(); + } + return "Cliente"; + } + + private Locale localeForLanguage(String language) { + return switch (language) { + case "en" -> Locale.ENGLISH; + case "de" -> Locale.GERMAN; + case "fr" -> Locale.FRENCH; + default -> Locale.ITALIAN; + }; + } + + private String resolveLanguage(String language) { + if (language == null || language.isBlank()) { + return DEFAULT_LANGUAGE; + } + + String normalized = language.trim().toLowerCase(Locale.ROOT); + if (normalized.length() > 2) { + normalized = normalized.substring(0, 2); + } + + return switch (normalized) { + case "it", "en", "de", "fr" -> normalized; + default -> DEFAULT_LANGUAGE; + }; + } +} diff --git a/backend/src/main/java/com/printcalculator/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/printcalculator/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..dc49fbd --- /dev/null +++ b/backend/src/main/java/com/printcalculator/exception/GlobalExceptionHandler.java @@ -0,0 +1,61 @@ +package com.printcalculator.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.context.request.WebRequest; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(VirusDetectedException.class) + public ResponseEntity handleVirusDetectedException( + VirusDetectedException ex, WebRequest request) { + + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("message", ex.getMessage()); + body.put("error", "Virus Detected"); + + return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException( + MethodArgumentNotValidException ex, WebRequest request) { + + List details = new ArrayList<>(); + for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) { + details.add(fieldError.getField() + ": " + fieldError.getDefaultMessage()); + } + + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("message", "Dati non validi."); + body.put("error", "Validation Error"); + body.put("details", details); + + return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException( + IllegalArgumentException ex, WebRequest request) { + + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("message", ex.getMessage()); + body.put("error", "Bad Request"); + + return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); + } +} diff --git a/backend/src/main/java/com/printcalculator/exception/StorageException.java b/backend/src/main/java/com/printcalculator/exception/StorageException.java new file mode 100644 index 0000000..0a0da37 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/exception/StorageException.java @@ -0,0 +1,12 @@ +package com.printcalculator.exception; + +public class StorageException extends RuntimeException { + + public StorageException(String message) { + super(message); + } + + public StorageException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/backend/src/main/java/com/printcalculator/exception/VirusDetectedException.java b/backend/src/main/java/com/printcalculator/exception/VirusDetectedException.java new file mode 100644 index 0000000..6b64216 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/exception/VirusDetectedException.java @@ -0,0 +1,7 @@ +package com.printcalculator.exception; + +public class VirusDetectedException extends RuntimeException { + public VirusDetectedException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/printcalculator/model/ModelDimensions.java b/backend/src/main/java/com/printcalculator/model/ModelDimensions.java new file mode 100644 index 0000000..7d58b80 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/model/ModelDimensions.java @@ -0,0 +1,7 @@ +package com.printcalculator.model; + +public record ModelDimensions( + double xMm, + double yMm, + double zMm +) {} diff --git a/backend/src/main/java/com/printcalculator/model/QuoteResult.java b/backend/src/main/java/com/printcalculator/model/QuoteResult.java index df155d4..0fee392 100644 --- a/backend/src/main/java/com/printcalculator/model/QuoteResult.java +++ b/backend/src/main/java/com/printcalculator/model/QuoteResult.java @@ -4,13 +4,10 @@ public class QuoteResult { private double totalPrice; private String currency; private PrintStats stats; - private double setupCost; - - public QuoteResult(double totalPrice, String currency, PrintStats stats, double setupCost) { + public QuoteResult(double totalPrice, String currency, PrintStats stats) { this.totalPrice = totalPrice; this.currency = currency; this.stats = stats; - this.setupCost = setupCost; } public double getTotalPrice() { @@ -24,8 +21,4 @@ public class QuoteResult { public PrintStats getStats() { return stats; } - - public double getSetupCost() { - return setupCost; - } } diff --git a/backend/src/main/java/com/printcalculator/repository/CustomQuoteRequestAttachmentRepository.java b/backend/src/main/java/com/printcalculator/repository/CustomQuoteRequestAttachmentRepository.java new file mode 100644 index 0000000..ad44494 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/CustomQuoteRequestAttachmentRepository.java @@ -0,0 +1,11 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.CustomQuoteRequestAttachment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface CustomQuoteRequestAttachmentRepository extends JpaRepository { + List findByRequest_IdOrderByCreatedAtAsc(UUID requestId); +} diff --git a/backend/src/main/java/com/printcalculator/repository/CustomQuoteRequestRepository.java b/backend/src/main/java/com/printcalculator/repository/CustomQuoteRequestRepository.java new file mode 100644 index 0000000..4b7fb71 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/CustomQuoteRequestRepository.java @@ -0,0 +1,9 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.CustomQuoteRequest; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.UUID; + +public interface CustomQuoteRequestRepository extends JpaRepository { +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/CustomerRepository.java b/backend/src/main/java/com/printcalculator/repository/CustomerRepository.java new file mode 100644 index 0000000..4aa6cad --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/CustomerRepository.java @@ -0,0 +1,11 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.Customer; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface CustomerRepository extends JpaRepository { + Optional findByEmail(String email); +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/FilamentVariantOrcaOverrideRepository.java b/backend/src/main/java/com/printcalculator/repository/FilamentVariantOrcaOverrideRepository.java new file mode 100644 index 0000000..cacd823 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/FilamentVariantOrcaOverrideRepository.java @@ -0,0 +1,15 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.FilamentVariant; +import com.printcalculator.entity.FilamentVariantOrcaOverride; +import com.printcalculator.entity.PrinterMachineProfile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface FilamentVariantOrcaOverrideRepository extends JpaRepository { + Optional findByFilamentVariantAndPrinterMachineProfileAndIsActiveTrue( + FilamentVariant filamentVariant, + PrinterMachineProfile printerMachineProfile + ); +} diff --git a/backend/src/main/java/com/printcalculator/repository/FilamentVariantRepository.java b/backend/src/main/java/com/printcalculator/repository/FilamentVariantRepository.java index 1c04817..43b9e73 100644 --- a/backend/src/main/java/com/printcalculator/repository/FilamentVariantRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/FilamentVariantRepository.java @@ -9,5 +9,6 @@ import java.util.Optional; public interface FilamentVariantRepository extends JpaRepository { // We try to match by color name if possible, or get first active Optional findByFilamentMaterialTypeAndColorName(FilamentMaterialType type, String colorName); + Optional findByFilamentMaterialTypeAndVariantDisplayName(FilamentMaterialType type, String variantDisplayName); Optional findFirstByFilamentMaterialTypeAndIsActiveTrue(FilamentMaterialType type); -} \ No newline at end of file +} diff --git a/backend/src/main/java/com/printcalculator/repository/FilamentVariantStockKgRepository.java b/backend/src/main/java/com/printcalculator/repository/FilamentVariantStockKgRepository.java new file mode 100644 index 0000000..934ca46 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/FilamentVariantStockKgRepository.java @@ -0,0 +1,7 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.FilamentVariantStockKg; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FilamentVariantStockKgRepository extends JpaRepository { +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/MaterialOrcaProfileMapRepository.java b/backend/src/main/java/com/printcalculator/repository/MaterialOrcaProfileMapRepository.java new file mode 100644 index 0000000..0c2e61c --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/MaterialOrcaProfileMapRepository.java @@ -0,0 +1,18 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.FilamentMaterialType; +import com.printcalculator.entity.MaterialOrcaProfileMap; +import com.printcalculator.entity.PrinterMachineProfile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface MaterialOrcaProfileMapRepository extends JpaRepository { + Optional findByPrinterMachineProfileAndFilamentMaterialTypeAndIsActiveTrue( + PrinterMachineProfile printerMachineProfile, + FilamentMaterialType filamentMaterialType + ); + + List findByPrinterMachineProfileAndIsActiveTrue(PrinterMachineProfile printerMachineProfile); +} diff --git a/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java b/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java new file mode 100644 index 0000000..3503bb2 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/OrderItemRepository.java @@ -0,0 +1,12 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.OrderItem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface OrderItemRepository extends JpaRepository { + List findByOrder_Id(UUID orderId); + boolean existsByFilamentVariant_Id(Long filamentVariantId); +} diff --git a/backend/src/main/java/com/printcalculator/repository/OrderRepository.java b/backend/src/main/java/com/printcalculator/repository/OrderRepository.java new file mode 100644 index 0000000..478261c --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/OrderRepository.java @@ -0,0 +1,13 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface OrderRepository extends JpaRepository { + List findAllByOrderByCreatedAtDesc(); + + boolean existsBySourceQuoteSession_Id(UUID sourceQuoteSessionId); +} diff --git a/backend/src/main/java/com/printcalculator/repository/PaymentRepository.java b/backend/src/main/java/com/printcalculator/repository/PaymentRepository.java new file mode 100644 index 0000000..38cd466 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/PaymentRepository.java @@ -0,0 +1,11 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.Payment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface PaymentRepository extends JpaRepository { + Optional findByOrder_Id(UUID orderId); +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/PrinterFleetCurrentRepository.java b/backend/src/main/java/com/printcalculator/repository/PrinterFleetCurrentRepository.java new file mode 100644 index 0000000..805e0d0 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/PrinterFleetCurrentRepository.java @@ -0,0 +1,7 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.PrinterFleetCurrent; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PrinterFleetCurrentRepository extends JpaRepository { +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/repository/PrinterMachineProfileRepository.java b/backend/src/main/java/com/printcalculator/repository/PrinterMachineProfileRepository.java new file mode 100644 index 0000000..4213e25 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/PrinterMachineProfileRepository.java @@ -0,0 +1,15 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.PrinterMachine; +import com.printcalculator.entity.PrinterMachineProfile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +public interface PrinterMachineProfileRepository extends JpaRepository { + Optional findByPrinterMachineAndNozzleDiameterMmAndIsActiveTrue(PrinterMachine printerMachine, BigDecimal nozzleDiameterMm); + Optional findFirstByPrinterMachineAndIsDefaultTrueAndIsActiveTrue(PrinterMachine printerMachine); + List findByPrinterMachineAndIsActiveTrue(PrinterMachine printerMachine); +} diff --git a/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java b/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java new file mode 100644 index 0000000..7d39175 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java @@ -0,0 +1,12 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.QuoteLineItem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface QuoteLineItemRepository extends JpaRepository { + List findByQuoteSessionId(UUID quoteSessionId); + boolean existsByFilamentVariant_Id(Long filamentVariantId); +} diff --git a/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java b/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java new file mode 100644 index 0000000..51f4640 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/QuoteSessionRepository.java @@ -0,0 +1,11 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.QuoteSession; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface QuoteSessionRepository extends JpaRepository { + List findByCreatedAtBefore(java.time.OffsetDateTime cutoff); +} \ No newline at end of file diff --git a/backend/src/main/java/com/printcalculator/security/AdminLoginThrottleService.java b/backend/src/main/java/com/printcalculator/security/AdminLoginThrottleService.java new file mode 100644 index 0000000..1b95e81 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/security/AdminLoginThrottleService.java @@ -0,0 +1,101 @@ +package com.printcalculator.security; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.OptionalLong; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class AdminLoginThrottleService { + + private static final long BASE_DELAY_SECONDS = 2L; + private static final long MAX_DELAY_SECONDS = 3600L; + + private final ConcurrentHashMap attemptsByClient = new ConcurrentHashMap<>(); + private final boolean trustProxyHeaders; + + public AdminLoginThrottleService( + @Value("${admin.auth.trust-proxy-headers:false}") boolean trustProxyHeaders + ) { + this.trustProxyHeaders = trustProxyHeaders; + } + + public OptionalLong getRemainingLockSeconds(String clientKey) { + LoginAttemptState state = attemptsByClient.get(clientKey); + if (state == null) { + return OptionalLong.empty(); + } + + long now = Instant.now().getEpochSecond(); + long remaining = state.blockedUntilEpochSeconds - now; + if (remaining <= 0) { + attemptsByClient.remove(clientKey, state); + return OptionalLong.empty(); + } + + return OptionalLong.of(remaining); + } + + public long registerFailure(String clientKey) { + long now = Instant.now().getEpochSecond(); + LoginAttemptState state = attemptsByClient.compute(clientKey, (key, current) -> { + int nextFailures = current == null ? 1 : current.failures + 1; + long delay = calculateDelaySeconds(nextFailures); + return new LoginAttemptState(nextFailures, now + delay); + }); + + return calculateDelaySeconds(state.failures); + } + + public void reset(String clientKey) { + attemptsByClient.remove(clientKey); + } + + public String resolveClientKey(HttpServletRequest request) { + if (trustProxyHeaders) { + String forwardedFor = request.getHeader("X-Forwarded-For"); + if (forwardedFor != null && !forwardedFor.isBlank()) { + String[] parts = forwardedFor.split(","); + if (parts.length > 0 && !parts[0].trim().isEmpty()) { + return parts[0].trim(); + } + } + + String realIp = request.getHeader("X-Real-IP"); + if (realIp != null && !realIp.isBlank()) { + return realIp.trim(); + } + } + + String remoteAddress = request.getRemoteAddr(); + if (remoteAddress != null && !remoteAddress.isBlank()) { + return remoteAddress.trim(); + } + + return "unknown"; + } + + private long calculateDelaySeconds(int failures) { + long delay = BASE_DELAY_SECONDS; + for (int i = 1; i < failures; i++) { + if (delay >= MAX_DELAY_SECONDS) { + return MAX_DELAY_SECONDS; + } + delay *= 2; + } + return Math.min(delay, MAX_DELAY_SECONDS); + } + + private static class LoginAttemptState { + private final int failures; + private final long blockedUntilEpochSeconds; + + private LoginAttemptState(int failures, long blockedUntilEpochSeconds) { + this.failures = failures; + this.blockedUntilEpochSeconds = blockedUntilEpochSeconds; + } + } +} diff --git a/backend/src/main/java/com/printcalculator/security/AdminSessionAuthenticationFilter.java b/backend/src/main/java/com/printcalculator/security/AdminSessionAuthenticationFilter.java new file mode 100644 index 0000000..deac902 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/security/AdminSessionAuthenticationFilter.java @@ -0,0 +1,71 @@ +package com.printcalculator.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; +import java.util.Optional; + +@Component +public class AdminSessionAuthenticationFilter extends OncePerRequestFilter { + + private final AdminSessionService adminSessionService; + + public AdminSessionAuthenticationFilter(AdminSessionService adminSessionService) { + this.adminSessionService = adminSessionService; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = resolvePath(request); + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + return true; + } + if (!path.startsWith("/api/admin/")) { + return true; + } + return "/api/admin/auth/login".equals(path); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + Optional token = adminSessionService.extractTokenFromCookies(request); + Optional payload = token.flatMap(adminSessionService::validateSessionToken); + + if (payload.isEmpty()) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}"); + return; + } + + UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken.authenticated( + "admin", + null, + Collections.emptyList() + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + filterChain.doFilter(request, response); + } + + private String resolvePath(HttpServletRequest request) { + String path = request.getRequestURI(); + String contextPath = request.getContextPath(); + if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) { + return path.substring(contextPath.length()); + } + return path; + } +} diff --git a/backend/src/main/java/com/printcalculator/security/AdminSessionService.java b/backend/src/main/java/com/printcalculator/security/AdminSessionService.java new file mode 100644 index 0000000..2e797f8 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/security/AdminSessionService.java @@ -0,0 +1,203 @@ +package com.printcalculator.security; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Optional; +import java.util.UUID; + +@Service +public class AdminSessionService { + + public static final String COOKIE_NAME = "admin_session"; + private static final String COOKIE_PATH = "/api/admin"; + private static final String HMAC_ALGORITHM = "HmacSHA256"; + + private final ObjectMapper objectMapper; + private final String adminPassword; + private final byte[] sessionSecret; + private final long sessionTtlMinutes; + + public AdminSessionService( + ObjectMapper objectMapper, + @Value("${admin.password}") String adminPassword, + @Value("${admin.session.secret}") String sessionSecret, + @Value("${admin.session.ttl-minutes}") long sessionTtlMinutes + ) { + this.objectMapper = objectMapper; + this.adminPassword = adminPassword; + this.sessionSecret = sessionSecret.getBytes(StandardCharsets.UTF_8); + this.sessionTtlMinutes = sessionTtlMinutes; + + validateConfiguration(adminPassword, sessionSecret, sessionTtlMinutes); + } + + public boolean isPasswordValid(String candidatePassword) { + if (candidatePassword == null) { + return false; + } + + return MessageDigest.isEqual( + adminPassword.getBytes(StandardCharsets.UTF_8), + candidatePassword.getBytes(StandardCharsets.UTF_8) + ); + } + + public String createSessionToken() { + Instant now = Instant.now(); + AdminSessionPayload payload = new AdminSessionPayload( + now.getEpochSecond(), + now.plus(Duration.ofMinutes(sessionTtlMinutes)).getEpochSecond(), + UUID.randomUUID().toString() + ); + + try { + String payloadJson = objectMapper.writeValueAsString(payload); + String encodedPayload = base64UrlEncode(payloadJson.getBytes(StandardCharsets.UTF_8)); + String signature = base64UrlEncode(sign(encodedPayload)); + return encodedPayload + "." + signature; + } catch (JsonProcessingException e) { + throw new IllegalStateException("Cannot create admin session token", e); + } + } + + public Optional validateSessionToken(String token) { + if (token == null || token.isBlank()) { + return Optional.empty(); + } + + String[] parts = token.split("\\."); + if (parts.length != 2) { + return Optional.empty(); + } + + String encodedPayload = parts[0]; + String encodedSignature = parts[1]; + byte[] providedSignature; + try { + providedSignature = base64UrlDecode(encodedSignature); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + + byte[] expectedSignature = sign(encodedPayload); + if (!MessageDigest.isEqual(expectedSignature, providedSignature)) { + return Optional.empty(); + } + + try { + byte[] decodedPayload = base64UrlDecode(encodedPayload); + AdminSessionPayload payload = objectMapper.readValue(decodedPayload, AdminSessionPayload.class); + if (payload.exp <= Instant.now().getEpochSecond()) { + return Optional.empty(); + } + return Optional.of(payload); + } catch (IllegalArgumentException | IOException e) { + return Optional.empty(); + } + } + + public Optional extractTokenFromCookies(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null || cookies.length == 0) { + return Optional.empty(); + } + + for (Cookie cookie : cookies) { + if (COOKIE_NAME.equals(cookie.getName())) { + return Optional.ofNullable(cookie.getValue()); + } + } + + return Optional.empty(); + } + + public ResponseCookie buildLoginCookie(String token) { + return ResponseCookie.from(COOKIE_NAME, token) + .path(COOKIE_PATH) + .httpOnly(true) + .secure(true) + .sameSite("Strict") + .maxAge(Duration.ofMinutes(sessionTtlMinutes)) + .build(); + } + + public ResponseCookie buildLogoutCookie() { + return ResponseCookie.from(COOKIE_NAME, "") + .path(COOKIE_PATH) + .httpOnly(true) + .secure(true) + .sameSite("Strict") + .maxAge(Duration.ZERO) + .build(); + } + + public long getSessionTtlMinutes() { + return sessionTtlMinutes; + } + + private byte[] sign(String encodedPayload) { + try { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec(sessionSecret, HMAC_ALGORITHM)); + return mac.doFinal(encodedPayload.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new IllegalStateException("Cannot sign admin session token", e); + } + } + + private String base64UrlEncode(byte[] data) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(data); + } + + private byte[] base64UrlDecode(String data) { + return Base64.getUrlDecoder().decode(data); + } + + private void validateConfiguration(String password, String secret, long ttlMinutes) { + if (password == null || password.isBlank()) { + throw new IllegalStateException("ADMIN_PASSWORD must be configured and non-empty"); + } + if (secret == null || secret.isBlank()) { + throw new IllegalStateException("ADMIN_SESSION_SECRET must be configured and non-empty"); + } + if (secret.length() < 32) { + throw new IllegalStateException("ADMIN_SESSION_SECRET must be at least 32 characters long"); + } + if (ttlMinutes <= 0) { + throw new IllegalStateException("ADMIN_SESSION_TTL_MINUTES must be > 0"); + } + } + + public static class AdminSessionPayload { + @JsonProperty("iat") + public long iat; + @JsonProperty("exp") + public long exp; + @JsonProperty("nonce") + public String nonce; + + public AdminSessionPayload() { + } + + public AdminSessionPayload(long iat, long exp, String nonce) { + this.iat = iat; + this.exp = exp; + this.nonce = nonce; + } + } +} diff --git a/backend/src/main/java/com/printcalculator/service/ClamAVService.java b/backend/src/main/java/com/printcalculator/service/ClamAVService.java new file mode 100644 index 0000000..dc6532a --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/ClamAVService.java @@ -0,0 +1,64 @@ +package com.printcalculator.service; + +import com.printcalculator.exception.VirusDetectedException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import xyz.capybara.clamav.ClamavClient; +import xyz.capybara.clamav.commands.scan.result.ScanResult; + +import java.io.InputStream; +import java.util.Collection; +import java.util.Map; + +@Service +public class ClamAVService { + + private static final Logger logger = LoggerFactory.getLogger(ClamAVService.class); + + private final ClamavClient clamavClient; + private final boolean enabled; + + public ClamAVService( + @Value("${clamav.host:clamav}") String host, + @Value("${clamav.port:3310}") int port, + @Value("${clamav.enabled:true}") boolean enabled + ) { + this.enabled = enabled; + ClamavClient client = null; + try { + if (enabled) { + logger.info("Initializing ClamAV client at {}:{}", host, port); + client = new ClamavClient(host, port); + } + } catch (Exception e) { + logger.error("Failed to initialize ClamAV client: " + e.getMessage()); + } + this.clamavClient = client; + } + + public boolean scan(InputStream inputStream) { + if (!enabled || clamavClient == null) { + return true; + } + try { + ScanResult result = clamavClient.scan(inputStream); + if (result instanceof ScanResult.OK) { + return true; + } else if (result instanceof ScanResult.VirusFound) { + Map> viruses = ((ScanResult.VirusFound) result).getFoundViruses(); + logger.warn("VIRUS DETECTED: {}", viruses); + throw new VirusDetectedException("Virus detected in the uploaded file: " + viruses); + } else { + logger.warn("Unknown scan result: {}. Allowing file (FAIL-OPEN)", result); + return true; + } + } catch (VirusDetectedException e) { + throw e; + } catch (Exception e) { + logger.error("Error scanning file with ClamAV. Allowing file (FAIL-OPEN)", e); + return true; + } + } +} diff --git a/backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java b/backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java new file mode 100644 index 0000000..7db4aa8 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/FileSystemStorageService.java @@ -0,0 +1,94 @@ +package com.printcalculator.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import com.printcalculator.exception.StorageException; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; + +@Service +public class FileSystemStorageService implements StorageService { + + private final Path rootLocation; + private final ClamAVService clamAVService; + + public FileSystemStorageService(@Value("${storage.location:storage_orders}") String storageLocation, ClamAVService clamAVService) { + this.rootLocation = Paths.get(storageLocation); + this.clamAVService = clamAVService; + } + + @Override + public void init() { + try { + Files.createDirectories(rootLocation); + } catch (IOException e) { + throw new StorageException("Could not initialize storage", e); + } + } + + @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."); + } + + // 1. Salva prima il file su disco per evitare problemi di stream con file grandi + Files.createDirectories(destinationFile.getParent()); + file.transferTo(destinationFile.toFile()); + + // 2. Scansiona il file appena salvato aprendo un nuovo stream + try (InputStream inputStream = new FileInputStream(destinationFile.toFile())) { + if (!clamAVService.scan(inputStream)) { + // Se infetto, cancella il file e solleva eccezione + Files.deleteIfExists(destinationFile); + throw new StorageException("File rejected by antivirus scanner."); + } + } catch (Exception e) { + if (e instanceof StorageException) throw e; + // Se l'antivirus fallisce per motivi tecnici, lasciamo il file (fail-open come concordato) + } + } + + @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."); + } + Files.createDirectories(destinationFile.getParent()); + Files.copy(source, destinationFile, StandardCopyOption.REPLACE_EXISTING); + } + + @Override + public void delete(Path path) throws IOException { + Path file = rootLocation.resolve(path); + Files.deleteIfExists(file); + } + + @Override + public Resource loadAsResource(Path path) throws IOException { + try { + Path file = rootLocation.resolve(path); + Resource resource = new UrlResource(file.toUri()); + if (resource.exists() || resource.isReadable()) { + return resource; + } else { + throw new RuntimeException("Could not read file: " + path); + } + } catch (MalformedURLException e) { + throw new RuntimeException("Could not read file: " + path, e); + } + } +} diff --git a/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java b/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java new file mode 100644 index 0000000..04da768 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/InvoicePdfRenderingService.java @@ -0,0 +1,143 @@ +package com.printcalculator.service; + +import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; +import com.openhtmltopdf.svgsupport.BatikSVGDrawer; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +import java.io.ByteArrayOutputStream; +import java.util.stream.Collectors; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import com.printcalculator.entity.Order; +import com.printcalculator.entity.OrderItem; +import com.printcalculator.entity.Payment; + +@Service +public class InvoicePdfRenderingService { + + private final TemplateEngine thymeleafTemplateEngine; + + public InvoicePdfRenderingService(TemplateEngine thymeleafTemplateEngine) { + this.thymeleafTemplateEngine = thymeleafTemplateEngine; + } + + public byte[] generateInvoicePdfBytesFromTemplate(Map invoiceTemplateVariables, String qrBillSvg) { + try { + Context thymeleafContextWithInvoiceData = new Context(Locale.ITALY); + thymeleafContextWithInvoiceData.setVariables(invoiceTemplateVariables); + thymeleafContextWithInvoiceData.setVariable("qrBillSvg", qrBillSvg); + + String renderedInvoiceHtml = thymeleafTemplateEngine.process("invoice", thymeleafContextWithInvoiceData); + + String classpathBaseUrlForHtmlResources = new ClassPathResource("templates/").getURL().toExternalForm(); + + ByteArrayOutputStream generatedPdfByteArrayOutputStream = new ByteArrayOutputStream(); + + PdfRendererBuilder openHtmlToPdfRendererBuilder = new PdfRendererBuilder(); + openHtmlToPdfRendererBuilder.useFastMode(); + openHtmlToPdfRendererBuilder.useSVGDrawer(new BatikSVGDrawer()); + openHtmlToPdfRendererBuilder.withHtmlContent(renderedInvoiceHtml, classpathBaseUrlForHtmlResources); + openHtmlToPdfRendererBuilder.toStream(generatedPdfByteArrayOutputStream); + openHtmlToPdfRendererBuilder.run(); + + return generatedPdfByteArrayOutputStream.toByteArray(); + } catch (Exception pdfGenerationException) { + throw new IllegalStateException("PDF invoice generation failed", pdfGenerationException); + } + } + + public byte[] generateDocumentPdf(Order order, List items, boolean isConfirmation, QrBillService qrBillService, Payment payment) { + Map vars = new HashMap<>(); + vars.put("isConfirmation", isConfirmation); + vars.put("sellerDisplayName", "3D Fab Küng Caletti"); + vars.put("sellerAddressLine1", "Joe Küng e Matteo Caletti"); + vars.put("sellerAddressLine2", "Sede Bienne, Svizzera"); + vars.put("sellerEmail", "info@3dfab.ch"); + + String displayOrderNumber = order.getOrderNumber() != null && !order.getOrderNumber().isBlank() + ? order.getOrderNumber() + : order.getId().toString(); + + vars.put("invoiceNumber", "INV-" + displayOrderNumber.toUpperCase()); + vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE)); + vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE)); + + String buyerName = order.getBillingCustomerType().equals("BUSINESS") + ? order.getBillingCompanyName() + : order.getBillingFirstName() + " " + order.getBillingLastName(); + vars.put("buyerDisplayName", buyerName); + vars.put("buyerAddressLine1", order.getBillingAddressLine1()); + vars.put("buyerAddressLine2", order.getBillingZip() + " " + order.getBillingCity() + ", " + order.getBillingCountryCode()); + + // Setup Shipping Info + if (order.getShippingAddressLine1() != null && !order.getShippingAddressLine1().isBlank()) { + String shippingName = order.getShippingCompanyName() != null && !order.getShippingCompanyName().isBlank() + ? order.getShippingCompanyName() + : order.getShippingFirstName() + " " + order.getShippingLastName(); + vars.put("shippingDisplayName", shippingName); + vars.put("shippingAddressLine1", order.getShippingAddressLine1()); + vars.put("shippingAddressLine2", order.getShippingZip() + " " + order.getShippingCity() + ", " + order.getShippingCountryCode()); + } + + List> invoiceLineItems = items.stream().map(i -> { + Map line = new HashMap<>(); + line.put("description", "Stampa 3D: " + i.getOriginalFilename()); + line.put("quantity", i.getQuantity()); + line.put("unitPriceFormatted", String.format("CHF %.2f", i.getUnitPriceChf())); + line.put("lineTotalFormatted", String.format("CHF %.2f", i.getLineTotalChf())); + return line; + }).collect(Collectors.toList()); + + Map setupLine = new HashMap<>(); + setupLine.put("description", "Costo Setup"); + setupLine.put("quantity", 1); + setupLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getSetupCostChf())); + setupLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getSetupCostChf())); + invoiceLineItems.add(setupLine); + + Map shippingLine = new HashMap<>(); + shippingLine.put("description", "Spedizione"); + shippingLine.put("quantity", 1); + shippingLine.put("unitPriceFormatted", String.format("CHF %.2f", order.getShippingCostChf())); + shippingLine.put("lineTotalFormatted", String.format("CHF %.2f", order.getShippingCostChf())); + invoiceLineItems.add(shippingLine); + + vars.put("invoiceLineItems", invoiceLineItems); + vars.put("subtotalFormatted", String.format("CHF %.2f", order.getSubtotalChf())); + vars.put("grandTotalFormatted", String.format("CHF %.2f", order.getTotalChf())); + vars.put("paymentTermsText", isConfirmation ? "Pagamento entro 7 giorni via Bonifico o TWINT. Grazie." : "Pagato. Grazie per l'acquisto."); + + String paymentMethodText = "QR / Bonifico oppure TWINT"; + if (payment != null && payment.getMethod() != null) { + paymentMethodText = switch (payment.getMethod().toUpperCase()) { + case "TWINT" -> "TWINT"; + case "BANK_TRANSFER", "BONIFICO" -> "Bonifico Bancario"; + case "QR_BILL", "QR" -> "QR Bill"; + case "CASH" -> "Contanti"; + default -> payment.getMethod(); + }; + } + vars.put("paymentMethodText", paymentMethodText); + + String qrBillSvg = null; + if (isConfirmation) { + qrBillSvg = new String(qrBillService.generateQrBillSvg(order), java.nio.charset.StandardCharsets.UTF_8); + + if (qrBillSvg.contains(" machineProfileOpt = resolveMachineProfile(printerMachine, nozzleDiameterMm); + + String machineProfileName = machineProfileOpt + .map(PrinterMachineProfile::getOrcaMachineProfileName) + .orElseGet(() -> fallbackMachineProfile(printerMachine, nozzleDiameterMm)); + + String filamentProfileName = machineProfileOpt + .map(machineProfile -> resolveFilamentProfileWithMachineProfile(machineProfile, variant) + .orElseGet(() -> fallbackFilamentProfile(variant.getFilamentMaterialType()))) + .orElseGet(() -> fallbackFilamentProfile(variant.getFilamentMaterialType())); + + return new ResolvedProfiles(machineProfileName, filamentProfileName, machineProfileOpt.orElse(null)); + } + + public Optional resolveMachineProfile(PrinterMachine machine, BigDecimal nozzleDiameterMm) { + if (machine == null) { + return Optional.empty(); + } + + BigDecimal normalizedNozzle = normalizeNozzle(nozzleDiameterMm); + if (normalizedNozzle != null) { + Optional exact = machineProfileRepo + .findByPrinterMachineAndNozzleDiameterMmAndIsActiveTrue(machine, normalizedNozzle); + if (exact.isPresent()) { + return exact; + } + } + + Optional defaultProfile = machineProfileRepo + .findFirstByPrinterMachineAndIsDefaultTrueAndIsActiveTrue(machine); + if (defaultProfile.isPresent()) { + return defaultProfile; + } + + return machineProfileRepo.findByPrinterMachineAndIsActiveTrue(machine) + .stream() + .findFirst(); + } + + private Optional resolveFilamentProfileWithMachineProfile(PrinterMachineProfile machineProfile, FilamentVariant variant) { + if (machineProfile == null || variant == null) { + return Optional.empty(); + } + + Optional override = variantOverrideRepo + .findByFilamentVariantAndPrinterMachineProfileAndIsActiveTrue(variant, machineProfile); + + if (override.isPresent()) { + return Optional.ofNullable(override.get().getOrcaFilamentProfileName()); + } + + Optional map = materialMapRepo + .findByPrinterMachineProfileAndFilamentMaterialTypeAndIsActiveTrue( + machineProfile, + variant.getFilamentMaterialType() + ); + + return map.map(MaterialOrcaProfileMap::getOrcaFilamentProfileName); + } + + private String fallbackMachineProfile(PrinterMachine machine, BigDecimal nozzleDiameterMm) { + if (machine == null || machine.getPrinterDisplayName() == null || machine.getPrinterDisplayName().isBlank()) { + return "Bambu Lab A1 0.4 nozzle"; + } + + String displayName = machine.getPrinterDisplayName(); + if (displayName.toLowerCase().contains("bambulab a1") || displayName.toLowerCase().contains("bambu lab a1")) { + String nozzleForProfile = formatNozzleForProfileName(nozzleDiameterMm); + if (nozzleForProfile == null) { + return "Bambu Lab A1 0.4 nozzle"; + } + return "Bambu Lab A1 " + nozzleForProfile + " nozzle"; + } + + return displayName; + } + + private String fallbackFilamentProfile(FilamentMaterialType materialType) { + String materialCode = materialType != null && materialType.getMaterialCode() != null + ? materialType.getMaterialCode().trim().toUpperCase() + : "PLA"; + + return switch (materialCode) { + case "PLA TOUGH" -> "Bambu PLA Tough @BBL A1"; + case "PETG" -> "Generic PETG"; + case "TPU" -> "Generic TPU"; + case "PC" -> "Generic PC"; + case "ABS" -> "Generic ABS"; + default -> "Generic PLA"; + }; + } + + private BigDecimal normalizeNozzle(BigDecimal nozzleDiameterMm) { + if (nozzleDiameterMm == null) { + return null; + } + return nozzleDiameterMm.setScale(2, RoundingMode.HALF_UP); + } + + private String formatNozzleForProfileName(BigDecimal nozzleDiameterMm) { + BigDecimal normalizedNozzle = normalizeNozzle(nozzleDiameterMm); + if (normalizedNozzle == null) { + return null; + } + BigDecimal stripped = normalizedNozzle.stripTrailingZeros(); + if (stripped.scale() < 0) { + stripped = stripped.setScale(0); + } + return stripped.toPlainString(); + } + + public record ResolvedProfiles( + String machineProfileName, + String filamentProfileName, + PrinterMachineProfile machineProfile + ) {} +} diff --git a/backend/src/main/java/com/printcalculator/service/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java new file mode 100644 index 0000000..6ed8884 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -0,0 +1,355 @@ +package com.printcalculator.service; + +import com.printcalculator.dto.CreateOrderRequest; +import com.printcalculator.entity.*; +import com.printcalculator.repository.CustomerRepository; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.repository.QuoteSessionRepository; +import com.printcalculator.repository.PricingPolicyRepository; +import com.printcalculator.event.OrderCreatedEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.OffsetDateTime; +import java.util.*; + +@Service +public class OrderService { + + private final OrderRepository orderRepo; + private final OrderItemRepository orderItemRepo; + private final QuoteSessionRepository quoteSessionRepo; + private final QuoteLineItemRepository quoteLineItemRepo; + private final CustomerRepository customerRepo; + private final StorageService storageService; + private final InvoicePdfRenderingService invoiceService; + private final QrBillService qrBillService; + private final ApplicationEventPublisher eventPublisher; + private final PaymentService paymentService; + private final QuoteCalculator quoteCalculator; + private final PricingPolicyRepository pricingRepo; + + public OrderService(OrderRepository orderRepo, + OrderItemRepository orderItemRepo, + QuoteSessionRepository quoteSessionRepo, + QuoteLineItemRepository quoteLineItemRepo, + CustomerRepository customerRepo, + StorageService storageService, + InvoicePdfRenderingService invoiceService, + QrBillService qrBillService, + ApplicationEventPublisher eventPublisher, + PaymentService paymentService, + QuoteCalculator quoteCalculator, + PricingPolicyRepository pricingRepo) { + this.orderRepo = orderRepo; + this.orderItemRepo = orderItemRepo; + this.quoteSessionRepo = quoteSessionRepo; + this.quoteLineItemRepo = quoteLineItemRepo; + this.customerRepo = customerRepo; + this.storageService = storageService; + this.invoiceService = invoiceService; + this.qrBillService = qrBillService; + this.eventPublisher = eventPublisher; + this.paymentService = paymentService; + this.quoteCalculator = quoteCalculator; + this.pricingRepo = pricingRepo; + } + + @Transactional + public Order createOrderFromQuote(UUID quoteSessionId, CreateOrderRequest request) { + if (!request.isAcceptTerms() || !request.isAcceptPrivacy()) { + throw new IllegalArgumentException("Accettazione Termini e Privacy obbligatoria."); + } + + QuoteSession session = quoteSessionRepo.findById(quoteSessionId) + .orElseThrow(() -> new RuntimeException("Quote Session not found")); + + if (session.getConvertedOrderId() != null) { + throw new IllegalStateException("Quote session already converted to order"); + } + + Customer customer = customerRepo.findByEmail(request.getCustomer().getEmail()) + .orElseGet(() -> { + Customer newC = new Customer(); + newC.setEmail(request.getCustomer().getEmail()); + newC.setCustomerType(request.getCustomer().getCustomerType()); + newC.setCreatedAt(OffsetDateTime.now()); + newC.setUpdatedAt(OffsetDateTime.now()); + return customerRepo.save(newC); + }); + + customer.setPhone(request.getCustomer().getPhone()); + customer.setCustomerType(request.getCustomer().getCustomerType()); + + if (request.getBillingAddress() != null) { + customer.setFirstName(request.getBillingAddress().getFirstName()); + customer.setLastName(request.getBillingAddress().getLastName()); + customer.setCompanyName(request.getBillingAddress().getCompanyName()); + customer.setContactPerson(request.getBillingAddress().getContactPerson()); + } + + customer.setUpdatedAt(OffsetDateTime.now()); + customerRepo.save(customer); + + Order order = new Order(); + order.setSourceQuoteSession(session); + order.setCustomer(customer); + order.setCustomerEmail(request.getCustomer().getEmail()); + order.setCustomerPhone(request.getCustomer().getPhone()); + order.setStatus("PENDING_PAYMENT"); + order.setCreatedAt(OffsetDateTime.now()); + order.setUpdatedAt(OffsetDateTime.now()); + order.setPreferredLanguage(normalizeLanguage(request.getLanguage())); + order.setCurrency("CHF"); + + order.setBillingCustomerType(request.getCustomer().getCustomerType()); + if (request.getBillingAddress() != null) { + order.setBillingFirstName(request.getBillingAddress().getFirstName()); + order.setBillingLastName(request.getBillingAddress().getLastName()); + order.setBillingCompanyName(request.getBillingAddress().getCompanyName()); + order.setBillingContactPerson(request.getBillingAddress().getContactPerson()); + order.setBillingAddressLine1(request.getBillingAddress().getAddressLine1()); + order.setBillingAddressLine2(request.getBillingAddress().getAddressLine2()); + order.setBillingZip(request.getBillingAddress().getZip()); + order.setBillingCity(request.getBillingAddress().getCity()); + order.setBillingCountryCode(request.getBillingAddress().getCountryCode() != null ? request.getBillingAddress().getCountryCode() : "CH"); + } + + order.setShippingSameAsBilling(request.isShippingSameAsBilling()); + if (!request.isShippingSameAsBilling() && request.getShippingAddress() != null) { + order.setShippingFirstName(request.getShippingAddress().getFirstName()); + order.setShippingLastName(request.getShippingAddress().getLastName()); + order.setShippingCompanyName(request.getShippingAddress().getCompanyName()); + order.setShippingContactPerson(request.getShippingAddress().getContactPerson()); + order.setShippingAddressLine1(request.getShippingAddress().getAddressLine1()); + order.setShippingAddressLine2(request.getShippingAddress().getAddressLine2()); + order.setShippingZip(request.getShippingAddress().getZip()); + order.setShippingCity(request.getShippingAddress().getCity()); + order.setShippingCountryCode(request.getShippingAddress().getCountryCode() != null ? request.getShippingAddress().getCountryCode() : "CH"); + } else { + order.setShippingFirstName(order.getBillingFirstName()); + order.setShippingLastName(order.getBillingLastName()); + order.setShippingCompanyName(order.getBillingCompanyName()); + order.setShippingContactPerson(order.getBillingContactPerson()); + order.setShippingAddressLine1(order.getBillingAddressLine1()); + order.setShippingAddressLine2(order.getBillingAddressLine2()); + order.setShippingZip(order.getBillingZip()); + order.setShippingCity(order.getBillingCity()); + order.setShippingCountryCode(order.getBillingCountryCode()); + } + + List quoteItems = quoteLineItemRepo.findByQuoteSessionId(quoteSessionId); + + BigDecimal subtotal = BigDecimal.ZERO; + order.setSubtotalChf(BigDecimal.ZERO); + order.setTotalChf(BigDecimal.ZERO); + order.setDiscountChf(BigDecimal.ZERO); + order.setSetupCostChf(session.getSetupCostChf() != null ? session.getSetupCostChf() : BigDecimal.ZERO); + + // Calculate shipping cost based on dimensions before initial save + boolean exceedsBaseSize = false; + for (QuoteLineItem item : quoteItems) { + BigDecimal x = item.getBoundingBoxXMm() != null ? item.getBoundingBoxXMm() : BigDecimal.ZERO; + BigDecimal y = item.getBoundingBoxYMm() != null ? item.getBoundingBoxYMm() : BigDecimal.ZERO; + BigDecimal z = item.getBoundingBoxZMm() != null ? item.getBoundingBoxZMm() : BigDecimal.ZERO; + + BigDecimal[] dims = {x, y, z}; + java.util.Arrays.sort(dims); + + if (dims[2].compareTo(BigDecimal.valueOf(250.0)) > 0 || + dims[1].compareTo(BigDecimal.valueOf(176.0)) > 0 || + dims[0].compareTo(BigDecimal.valueOf(20.0)) > 0) { + exceedsBaseSize = true; + break; + } + } + int totalQuantity = quoteItems.stream() + .mapToInt(i -> i.getQuantity() != null ? i.getQuantity() : 1) + .sum(); + + if (exceedsBaseSize) { + order.setShippingCostChf(totalQuantity > 5 ? BigDecimal.valueOf(9.00) : BigDecimal.valueOf(4.00)); + } else { + order.setShippingCostChf(BigDecimal.valueOf(2.00)); + } + + order = orderRepo.save(order); + + List savedItems = new ArrayList<>(); + + // Calculate global machine cost upfront + BigDecimal totalSeconds = BigDecimal.ZERO; + for (QuoteLineItem qItem : quoteItems) { + if (qItem.getPrintTimeSeconds() != null) { + totalSeconds = totalSeconds.add(BigDecimal.valueOf(qItem.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(qItem.getQuantity()))); + } + } + BigDecimal totalHours = totalSeconds.divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP); + PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); + BigDecimal globalMachineCost = quoteCalculator.calculateSessionMachineCost(policy, totalHours); + + for (QuoteLineItem qItem : quoteItems) { + OrderItem oItem = new OrderItem(); + oItem.setOrder(order); + oItem.setOriginalFilename(qItem.getOriginalFilename()); + oItem.setQuantity(qItem.getQuantity()); + oItem.setColorCode(qItem.getColorCode()); + oItem.setFilamentVariant(qItem.getFilamentVariant()); + if (qItem.getFilamentVariant() != null + && qItem.getFilamentVariant().getFilamentMaterialType() != null + && qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode() != null) { + oItem.setMaterialCode(qItem.getFilamentVariant().getFilamentMaterialType().getMaterialCode()); + } else { + oItem.setMaterialCode(session.getMaterialCode()); + } + + BigDecimal distributedUnitPrice = qItem.getUnitPriceChf(); + if (totalSeconds.compareTo(BigDecimal.ZERO) > 0 && qItem.getPrintTimeSeconds() != null) { + BigDecimal itemSeconds = BigDecimal.valueOf(qItem.getPrintTimeSeconds()).multiply(BigDecimal.valueOf(qItem.getQuantity())); + BigDecimal share = itemSeconds.divide(totalSeconds, 8, RoundingMode.HALF_UP); + BigDecimal itemMachineCost = globalMachineCost.multiply(share); + BigDecimal unitMachineCost = itemMachineCost.divide(BigDecimal.valueOf(qItem.getQuantity()), 2, RoundingMode.HALF_UP); + distributedUnitPrice = distributedUnitPrice.add(unitMachineCost); + } + + oItem.setUnitPriceChf(distributedUnitPrice); + oItem.setLineTotalChf(distributedUnitPrice.multiply(BigDecimal.valueOf(qItem.getQuantity()))); + oItem.setPrintTimeSeconds(qItem.getPrintTimeSeconds()); + oItem.setMaterialGrams(qItem.getMaterialGrams()); + oItem.setBoundingBoxXMm(qItem.getBoundingBoxXMm()); + oItem.setBoundingBoxYMm(qItem.getBoundingBoxYMm()); + oItem.setBoundingBoxZMm(qItem.getBoundingBoxZMm()); + + UUID fileUuid = UUID.randomUUID(); + String ext = getExtension(qItem.getOriginalFilename()); + String storedFilename = fileUuid.toString() + "." + ext; + + oItem.setStoredFilename(storedFilename); + oItem.setStoredRelativePath("PENDING"); + oItem.setMimeType("application/octet-stream"); + oItem.setCreatedAt(OffsetDateTime.now()); + + oItem = orderItemRepo.save(oItem); + + String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename; + oItem.setStoredRelativePath(relativePath); + + if (qItem.getStoredPath() != null) { + try { + Path sourcePath = Paths.get(qItem.getStoredPath()); + if (Files.exists(sourcePath)) { + storageService.store(sourcePath, Paths.get(relativePath)); + oItem.setFileSizeBytes(Files.size(sourcePath)); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + + oItem = orderItemRepo.save(oItem); + savedItems.add(oItem); + subtotal = subtotal.add(oItem.getLineTotalChf()); + } + + order.setSubtotalChf(subtotal); + + BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO); + order.setTotalChf(total); + + session.setConvertedOrderId(order.getId()); + session.setStatus("CONVERTED"); + quoteSessionRepo.save(session); + + // Generate Invoice and QR Bill + generateAndSaveDocuments(order, savedItems); + + Order savedOrder = orderRepo.save(order); + + // ALWAYS initialize payment as PENDING + paymentService.getOrCreatePaymentForOrder(savedOrder, "OTHER"); + + eventPublisher.publishEvent(new OrderCreatedEvent(this, savedOrder)); + + return savedOrder; + } + + private void generateAndSaveDocuments(Order order, List items) { + try { + // 1. Generate and save the raw QR Bill for internal traceability. + byte[] qrBillSvgBytes = qrBillService.generateQrBillSvg(order); + saveFileBytes(qrBillSvgBytes, buildQrBillSvgRelativePath(order)); + + // 2. Generate and save the same confirmation PDF served by /api/orders/{id}/confirmation. + byte[] confirmationPdfBytes = invoiceService.generateDocumentPdf(order, items, true, qrBillService, null); + saveFileBytes(confirmationPdfBytes, buildConfirmationPdfRelativePath(order)); + + } catch (Exception e) { + e.printStackTrace(); + // Don't fail the order if document generation fails, but log it + // TODO: Better error handling + } + } + + private void saveFileBytes(byte[] content, String relativePath) { + // Since StorageService takes paths, we might need to write to temp first or check if it supports bytes/streams + // Simulating via temp file for now as StorageService.store takes a Path + try { + Path tempFile = Files.createTempFile("print-calc-upload", ".tmp"); + Files.write(tempFile, content); + storageService.store(tempFile, Paths.get(relativePath)); + Files.delete(tempFile); + } catch (IOException e) { + throw new RuntimeException("Failed to save file " + relativePath, e); + } + } + + private String getExtension(String filename) { + if (filename == null) return "stl"; + int i = filename.lastIndexOf('.'); + if (i > 0) { + return filename.substring(i + 1); + } + return "stl"; + } + + private String getDisplayOrderNumber(Order order) { + String orderNumber = order.getOrderNumber(); + if (orderNumber != null && !orderNumber.isBlank()) { + return orderNumber; + } + return order.getId() != null ? order.getId().toString() : "unknown"; + } + + private String buildQrBillSvgRelativePath(Order order) { + return "orders/" + order.getId() + "/documents/qr-bill.svg"; + } + + private String buildConfirmationPdfRelativePath(Order order) { + return "orders/" + order.getId() + "/documents/confirmation-" + getDisplayOrderNumber(order) + ".pdf"; + } + + private String normalizeLanguage(String language) { + if (language == null || language.isBlank()) { + return "it"; + } + + String normalized = language.trim().toLowerCase(Locale.ROOT); + if (normalized.length() > 2) { + normalized = normalized.substring(0, 2); + } + + return switch (normalized) { + case "it", "en", "de", "fr" -> normalized; + default -> "it"; + }; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/PaymentService.java b/backend/src/main/java/com/printcalculator/service/PaymentService.java new file mode 100644 index 0000000..5087bb6 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/PaymentService.java @@ -0,0 +1,101 @@ +package com.printcalculator.service; + +import com.printcalculator.entity.Order; +import com.printcalculator.entity.Payment; +import com.printcalculator.event.PaymentReportedEvent; +import com.printcalculator.event.PaymentConfirmedEvent; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.PaymentRepository; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.Optional; +import java.util.UUID; + +@Service +public class PaymentService { + + private final PaymentRepository paymentRepo; + private final OrderRepository orderRepo; + private final ApplicationEventPublisher eventPublisher; + + public PaymentService(PaymentRepository paymentRepo, + OrderRepository orderRepo, + ApplicationEventPublisher eventPublisher) { + this.paymentRepo = paymentRepo; + this.orderRepo = orderRepo; + this.eventPublisher = eventPublisher; + } + + @Transactional + public Payment getOrCreatePaymentForOrder(Order order, String defaultMethod) { + Optional existing = paymentRepo.findByOrder_Id(order.getId()); + if (existing.isPresent()) { + return existing.get(); + } + + Payment payment = new Payment(); + payment.setOrder(order); + // Default to "OTHER" always, as payment method should only be set by the admin explicitly + payment.setMethod("OTHER"); + payment.setStatus("PENDING"); + payment.setCurrency(order.getCurrency() != null ? order.getCurrency() : "CHF"); + payment.setAmountChf(order.getTotalChf() != null ? order.getTotalChf() : BigDecimal.ZERO); + payment.setInitiatedAt(OffsetDateTime.now()); + + return paymentRepo.save(payment); + } + + @Transactional + public Payment reportPayment(UUID orderId, String method) { + Order order = orderRepo.findById(orderId) + .orElseThrow(() -> new RuntimeException("Order not found with id " + orderId)); + + Payment payment = paymentRepo.findByOrder_Id(orderId) + .orElseGet(() -> getOrCreatePaymentForOrder(order, "OTHER")); + + if (!"PENDING".equals(payment.getStatus())) { + throw new IllegalStateException("Payment is not in PENDING state. Current state: " + payment.getStatus()); + } + + payment.setStatus("REPORTED"); + payment.setReportedAt(OffsetDateTime.now()); + + // We intentionally do not update the payment method here based on user input, + // because the user cannot reliably determine the actual method without an integration. + // It will be updated by the backoffice admin manually. + + payment = paymentRepo.save(payment); + + eventPublisher.publishEvent(new PaymentReportedEvent(this, order, payment)); + + return payment; + } + + @Transactional + public Payment confirmPayment(UUID orderId, String method) { + Order order = orderRepo.findById(orderId) + .orElseThrow(() -> new RuntimeException("Order not found with id " + orderId)); + + Payment payment = paymentRepo.findByOrder_Id(orderId) + .orElseGet(() -> getOrCreatePaymentForOrder(order, method != null ? method : "OTHER")); + + payment.setStatus("COMPLETED"); + if (method != null && !method.isBlank()) { + payment.setMethod(method.toUpperCase()); + } + payment.setReceivedAt(OffsetDateTime.now()); + payment = paymentRepo.save(payment); + + order.setStatus("IN_PRODUCTION"); + order.setPaidAt(OffsetDateTime.now()); + orderRepo.save(order); + + eventPublisher.publishEvent(new PaymentConfirmedEvent(this, order, payment)); + + return payment; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/ProfileManager.java b/backend/src/main/java/com/printcalculator/service/ProfileManager.java index c6bd78a..67eee52 100644 --- a/backend/src/main/java/com/printcalculator/service/ProfileManager.java +++ b/backend/src/main/java/com/printcalculator/service/ProfileManager.java @@ -10,18 +10,23 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.Iterator; import java.util.Optional; import java.util.logging.Logger; import java.util.stream.Stream; import java.util.Map; import java.util.HashMap; +import java.util.List; +import java.util.LinkedHashSet; +import java.util.Set; @Service public class ProfileManager { private static final Logger logger = Logger.getLogger(ProfileManager.class.getName()); private final String profilesRoot; + private final Path resolvedProfilesRoot; private final ObjectMapper mapper; private final Map profileAliases; @@ -31,6 +36,8 @@ public class ProfileManager { this.mapper = mapper; this.profileAliases = new HashMap<>(); initializeAliases(); + this.resolvedProfilesRoot = resolveProfilesRoot(profilesRoot); + logger.info("Profiles root configured as '" + this.profilesRoot + "', resolved to '" + this.resolvedProfilesRoot + "'"); } private void initializeAliases() { @@ -39,6 +46,7 @@ public class ProfileManager { // Material Aliases profileAliases.put("pla_basic", "Bambu PLA Basic @BBL A1"); + profileAliases.put("pla_tough", "Bambu PLA Tough @BBL A1"); profileAliases.put("petg_basic", "Bambu PETG Basic @BBL A1"); profileAliases.put("tpu_95a", "Bambu TPU 95A @BBL A1"); @@ -54,30 +62,82 @@ public class ProfileManager { public ObjectNode getMergedProfile(String profileName, String type) throws IOException { Path profilePath = findProfileFile(profileName, type); if (profilePath == null) { - throw new IOException("Profile not found: " + profileName); + throw new IOException("Profile not found: " + profileName + " (root=" + resolvedProfilesRoot + ")"); } + logger.info("Resolved " + type + " profile '" + profileName + "' -> " + profilePath); return resolveInheritance(profilePath); } private Path findProfileFile(String name, String type) { + if (!Files.isDirectory(resolvedProfilesRoot)) { + logger.severe("Profiles root does not exist or is not a directory: " + resolvedProfilesRoot); + return null; + } + // Check aliases first String resolvedName = profileAliases.getOrDefault(name, name); - - // Simple search: look for name.json in the profiles_root recursively - // Type could be "machine", "process", "filament" to narrow down, but for now global search - String filename = resolvedName.endsWith(".json") ? resolvedName : resolvedName + ".json"; - - try (Stream stream = Files.walk(Paths.get(profilesRoot))) { - Optional found = stream + + // Look for name.json under the expected type directory first to avoid + // collisions across vendors/profile families with same filename. + String filename = toJsonFilename(resolvedName); + + try (Stream stream = Files.walk(resolvedProfilesRoot)) { + List candidates = stream .filter(p -> p.getFileName().toString().equals(filename)) - .findFirst(); - return found.orElse(null); + .sorted() + .toList(); + + if (candidates.isEmpty()) { + return null; + } + + if (type != null && !type.isBlank() && !"any".equalsIgnoreCase(type)) { + Optional typed = candidates.stream() + .filter(p -> pathContainsSegment(p, type)) + .findFirst(); + if (typed.isPresent()) { + return typed.get(); + } + } + + return candidates.get(0); } catch (IOException e) { logger.severe("Error searching for profile: " + e.getMessage()); return null; } } + private Path resolveProfilesRoot(String configuredRoot) { + Set candidates = new LinkedHashSet<>(); + Path cwd = Paths.get("").toAbsolutePath().normalize(); + + if (configuredRoot != null && !configuredRoot.isBlank()) { + Path configured = Paths.get(configuredRoot); + candidates.add(configured.toAbsolutePath().normalize()); + if (!configured.isAbsolute()) { + candidates.add(cwd.resolve(configuredRoot).normalize()); + } + } + + candidates.add(cwd.resolve("profiles").normalize()); + candidates.add(cwd.resolve("backend/profiles").normalize()); + candidates.add(Paths.get("/app/profiles").toAbsolutePath().normalize()); + + List checkedPaths = new ArrayList<>(); + for (Path candidate : candidates) { + checkedPaths.add(candidate.toString()); + if (Files.isDirectory(candidate)) { + return candidate; + } + } + + logger.warning("No profiles directory found. Checked: " + String.join(", ", checkedPaths)); + if (configuredRoot != null && !configuredRoot.isBlank()) { + return Paths.get(configuredRoot).toAbsolutePath().normalize(); + } + return cwd.resolve("profiles").normalize(); + } + private ObjectNode resolveInheritance(Path currentPath) throws IOException { // 1. Load current JsonNode currentNode = mapper.readTree(currentPath.toFile()); @@ -85,14 +145,20 @@ public class ProfileManager { // 2. Check inherits if (currentNode.has("inherits")) { String parentName = currentNode.get("inherits").asText(); - // Try to find parent in same directory or standard search - Path parentPath = currentPath.getParent().resolve(parentName); + // Try local directory first with explicit .json filename. + String parentFilename = toJsonFilename(parentName); + Path parentPath = currentPath.getParent().resolve(parentFilename); if (!Files.exists(parentPath)) { - // If not in same dir, search globally + // Fallback to the same profile type directory before global. + String inferredType = inferTypeFromPath(currentPath); + parentPath = findProfileFile(parentName, inferredType); + } + if (parentPath == null || !Files.exists(parentPath)) { parentPath = findProfileFile(parentName, "any"); } if (parentPath != null && Files.exists(parentPath)) { + logger.info("Resolved inherits '" + parentName + "' for " + currentPath + " -> " + parentPath); // Recursive call ObjectNode parentNode = resolveInheritance(parentPath); // Merge current into parent (child overrides parent) @@ -123,4 +189,30 @@ public class ProfileManager { mainNode.set(fieldName, jsonNode); } } + + private String toJsonFilename(String name) { + return name.endsWith(".json") ? name : name + ".json"; + } + + private boolean pathContainsSegment(Path path, String segment) { + String normalized = path.toString().replace('\\', '/'); + String needle = "/" + segment + "/"; + return normalized.contains(needle); + } + + private String inferTypeFromPath(Path path) { + if (path == null) { + return "any"; + } + if (pathContainsSegment(path, "machine")) { + return "machine"; + } + if (pathContainsSegment(path, "process")) { + return "process"; + } + if (pathContainsSegment(path, "filament")) { + return "filament"; + } + return "any"; + } } diff --git a/backend/src/main/java/com/printcalculator/service/QrBillService.java b/backend/src/main/java/com/printcalculator/service/QrBillService.java new file mode 100644 index 0000000..71eb47c --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/QrBillService.java @@ -0,0 +1,66 @@ +package com.printcalculator.service; + +import com.printcalculator.entity.Order; +import net.codecrete.qrbill.generator.Bill; +import net.codecrete.qrbill.generator.GraphicsFormat; +import net.codecrete.qrbill.generator.QRBill; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; + +@Service +public class QrBillService { + + public byte[] generateQrBillSvg(Order order) { + Bill bill = createBillFromOrder(order); + return QRBill.generate(bill); + } + + public Bill createBillFromOrder(Order order) { + Bill bill = new Bill(); + + // Creditor (Merchant) + bill.setAccount("CH7409000000154821581"); // TODO: Configurable IBAN + bill.setCreditor(createAddress( + "Joe Küng", + "Via G. Pioda 29a", + "6710", + "Biasca", + "CH" + )); + + // Debtor (Customer) + String debtorName; + if ("BUSINESS".equals(order.getBillingCustomerType())) { + debtorName = order.getBillingCompanyName(); + } else { + debtorName = order.getBillingFirstName() + " " + order.getBillingLastName(); + } + + bill.setDebtor(createAddress( + debtorName, + order.getBillingAddressLine1(), // Assuming simple address for now. Splitting might be needed if street/house number are separate + order.getBillingZip(), + order.getBillingCity(), + order.getBillingCountryCode() + )); + + // Amount + bill.setAmount(order.getTotalChf()); + bill.setCurrency("CHF"); + + bill.setUnstructuredMessage(order.getId().toString()); + + return bill; + } + + private net.codecrete.qrbill.generator.Address createAddress(String name, String street, String zip, String city, String country) { + net.codecrete.qrbill.generator.Address address = new net.codecrete.qrbill.generator.Address(); + address.setName(name); + address.setStreet(street); + address.setPostalCode(zip); + address.setTown(city); + address.setCountryCode(country); + return address; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java b/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java index 432c62f..5b8dbdf 100644 --- a/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java +++ b/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java @@ -21,6 +21,8 @@ import java.util.List; @Service public class QuoteCalculator { + private static final BigDecimal SETUP_FEE_DOUBLE_THRESHOLD_CHF = BigDecimal.TEN; + private static final BigDecimal SETUP_FEE_MULTIPLIER_BELOW_THRESHOLD = BigDecimal.valueOf(2); private final PricingPolicyRepository pricingRepo; private final PricingPolicyMachineHourTierRepository tierRepo; @@ -60,44 +62,70 @@ public class QuoteCalculator { .orElseThrow(() -> new RuntimeException("No active printer found")); } - // 3. Fetch Filament Info - // filamentProfileName might be "bambu_pla_basic_black" or "Generic PLA" - // We try to extract material code (PLA, PETG) String materialCode = detectMaterialCode(filamentProfileName); FilamentMaterialType materialType = materialRepo.findByMaterialCode(materialCode) .orElseThrow(() -> new RuntimeException("Unknown material type: " + materialCode)); - - // Try to find specific variant (e.g. by color if we could parse it) - // For now, get default/first active variant for this material FilamentVariant variant = variantRepo.findFirstByFilamentMaterialTypeAndIsActiveTrue(materialType) .orElseThrow(() -> new RuntimeException("No active variant for material: " + materialCode)); + return calculate(stats, machine, policy, variant); + } - // --- CALCULATIONS --- + public QuoteResult calculate(PrintStats stats, String machineName, FilamentVariant variant) { + PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc(); + if (policy == null) { + throw new RuntimeException("No active pricing policy found"); + } - // Material Cost: (weight / 1000) * costPerKg - BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP); + PrinterMachine machine = machineRepo.findByPrinterDisplayName(machineName).orElse(null); + if (machine == null) { + machine = machineRepo.findFirstByIsActiveTrue() + .orElseThrow(() -> new RuntimeException("No active printer found")); + } + + return calculate(stats, machine, policy, variant); + } + + private QuoteResult calculate(PrintStats stats, PrinterMachine machine, PricingPolicy policy, FilamentVariant variant) { + BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()) + .divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP); BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg()); - // Machine Cost: Tiered - BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP); - BigDecimal machineCost = calculateMachineCost(policy, totalHours); + BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()) + .divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP); - // Energy Cost: (watts / 1000) * hours * costPerKwh - BigDecimal kw = BigDecimal.valueOf(machine.getPowerWatts()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP); + BigDecimal kw = BigDecimal.valueOf(machine.getPowerWatts()) + .divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP); BigDecimal kwh = kw.multiply(totalHours); BigDecimal energyCost = kwh.multiply(policy.getElectricityCostChfPerKwh()); - // Subtotal (Costs + Fixed Fees) - BigDecimal fixedFee = policy.getFixedJobFeeChf(); - BigDecimal subtotal = materialCost.add(machineCost).add(energyCost).add(fixedFee); + BigDecimal subtotal = materialCost.add(energyCost); + BigDecimal markupFactor = BigDecimal.ONE.add( + policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP) + ); + subtotal = subtotal.multiply(markupFactor); - // Markup - // Markup is percentage (e.g. 20.0) + return new QuoteResult(subtotal.doubleValue(), "CHF", stats); + } + public BigDecimal calculateSessionMachineCost(PricingPolicy policy, BigDecimal hours) { + BigDecimal rawCost = calculateMachineCost(policy, hours); BigDecimal markupFactor = BigDecimal.ONE.add(policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP)); - BigDecimal totalPrice = subtotal.multiply(markupFactor).setScale(2, RoundingMode.HALF_UP); + return rawCost.multiply(markupFactor).setScale(2, RoundingMode.HALF_UP); + } - return new QuoteResult(totalPrice.doubleValue(), "CHF", stats, fixedFee.doubleValue()); + public BigDecimal calculateSessionSetupFee(PricingPolicy policy) { + if (policy == null || policy.getFixedJobFeeChf() == null) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + + BigDecimal baseSetupFee = policy.getFixedJobFeeChf(); + if (baseSetupFee.compareTo(SETUP_FEE_DOUBLE_THRESHOLD_CHF) < 0) { + return baseSetupFee + .multiply(SETUP_FEE_MULTIPLIER_BELOW_THRESHOLD) + .setScale(2, RoundingMode.HALF_UP); + } + + return baseSetupFee.setScale(2, RoundingMode.HALF_UP); } private BigDecimal calculateMachineCost(PricingPolicy policy, BigDecimal hours) { @@ -147,6 +175,7 @@ public class QuoteCalculator { private String detectMaterialCode(String profileName) { String lower = profileName.toLowerCase(); + if (lower.contains("pla tough") || lower.contains("pla_tough")) return "PLA TOUGH"; if (lower.contains("petg")) return "PETG"; if (lower.contains("tpu")) return "TPU"; if (lower.contains("abs")) return "ABS"; diff --git a/backend/src/main/java/com/printcalculator/service/SessionCleanupService.java b/backend/src/main/java/com/printcalculator/service/SessionCleanupService.java new file mode 100644 index 0000000..ef333fe --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/SessionCleanupService.java @@ -0,0 +1,79 @@ +package com.printcalculator.service; + +import com.printcalculator.entity.QuoteSession; +import com.printcalculator.repository.QuoteSessionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.stream.Stream; + +@Service +public class SessionCleanupService { + + private static final Logger logger = LoggerFactory.getLogger(SessionCleanupService.class); + private final QuoteSessionRepository sessionRepository; + + public SessionCleanupService(QuoteSessionRepository sessionRepository) { + this.sessionRepository = sessionRepository; + } + + // Run every day at 3 AM + @Scheduled(cron = "0 0 3 * * ?") + @Transactional + public void cleanupOldSessions() { + logger.info("Starting session cleanup job..."); + + OffsetDateTime cutoff = OffsetDateTime.now().minusDays(15); + List oldSessions = sessionRepository.findByCreatedAtBefore(cutoff); + + int deletedCount = 0; + for (QuoteSession session : oldSessions) { + // We only delete sessions that are NOT ordered? + // The user request was "delete old ones". + // Safest is to check status if we had one. + // QuoteSession entity has 'status' field. + // Let's assume we delete 'PENDING' or similar, but maybe we just delete all old inputs? + // "rimangono in memoria... cancella quelle vecchie di 7 giorni". + // Implementation plan said: status != 'ORDERED'. + + // User specified statuses: ACTIVE, EXPIRED, CONVERTED. + // We should NOT delete sessions that have been converted to an order. + if ("CONVERTED".equals(session.getStatus())) { + continue; + } + + try { + // Delete ACTIVE or EXPIRED sessions older than 7 days + deleteSessionFiles(session.getId().toString()); + sessionRepository.delete(session); + deletedCount++; + } catch (Exception e) { + logger.error("Failed to cleanup session {}", session.getId(), e); + } + } + + logger.info("Session cleanup job finished. Deleted {} sessions.", deletedCount); + } + + private void deleteSessionFiles(String sessionId) { + Path sessionDir = Paths.get("storage_quotes", sessionId); + if (Files.exists(sessionDir)) { + try (Stream walk = Files.walk(sessionDir)) { + walk.sorted(java.util.Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(java.io.File::delete); + } catch (IOException e) { + logger.error("Failed to delete directory: {}", sessionDir, e); + } + } + } +} diff --git a/backend/src/main/java/com/printcalculator/service/SlicerService.java b/backend/src/main/java/com/printcalculator/service/SlicerService.java index 573b668..f9ef570 100644 --- a/backend/src/main/java/com/printcalculator/service/SlicerService.java +++ b/backend/src/main/java/com/printcalculator/service/SlicerService.java @@ -2,6 +2,7 @@ package com.printcalculator.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.printcalculator.model.ModelDimensions; import com.printcalculator.model.PrintStats; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -10,16 +11,24 @@ import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @Service public class SlicerService { private static final Logger logger = Logger.getLogger(SlicerService.class.getName()); + private static final Pattern SIZE_X_PATTERN = Pattern.compile("(?m)^\\s*size_x\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$"); + private static final Pattern SIZE_Y_PATTERN = Pattern.compile("(?m)^\\s*size_y\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$"); + private static final Pattern SIZE_Z_PATTERN = Pattern.compile("(?m)^\\s*size_z\\s*=\\s*([-+]?\\d+(?:\\.\\d+)?)\\s*$"); private final String slicerPath; private final ProfileManager profileManager; @@ -44,6 +53,12 @@ public class SlicerService { ObjectNode filamentProfile = profileManager.getMergedProfile(filamentName, "filament"); ObjectNode processProfile = profileManager.getMergedProfile(processName, "process"); + logger.info("Slicer profiles: machine='" + machineName + "', filament='" + filamentName + "', process='" + processName + "'"); + logger.info("Machine limits: printable_area=" + machineProfile.path("printable_area") + + ", printable_height=" + machineProfile.path("printable_height") + + ", bed_exclude_area=" + machineProfile.path("bed_exclude_area") + + ", head_wrap_detect_zone=" + machineProfile.path("head_wrap_detect_zone")); + // Apply Overrides if (machineOverrides != null) { machineOverrides.forEach(machineProfile::put); @@ -63,84 +78,193 @@ public class SlicerService { mapper.writeValue(fFile, filamentProfile); mapper.writeValue(pFile, processProfile); - // 3. Build Command - // --load-settings "machine.json;process.json" --load-filaments "filament.json" - List command = new ArrayList<>(); - command.add(slicerPath); - - // Load machine settings - command.add("--load-settings"); - command.add(mFile.getAbsolutePath()); - - // Load process settings - command.add("--load-settings"); - command.add(pFile.getAbsolutePath()); - command.add("--load-filaments"); - command.add(fFile.getAbsolutePath()); - command.add("--ensure-on-bed"); - command.add("--arrange"); - command.add("1"); // force arrange - command.add("--slice"); - command.add("0"); // slice plate 0 - command.add("--outputdir"); - command.add(tempDir.toAbsolutePath().toString()); - // Need to handle Mac structure for console if needed? - // Usually the binary at Contents/MacOS/OrcaSlicer works fine as console app. - - command.add(inputStl.getAbsolutePath()); - - logger.info("Executing Slicer: " + String.join(" ", command)); - - // 4. Run Process - ProcessBuilder pb = new ProcessBuilder(command); - pb.directory(tempDir.toFile()); - // pb.inheritIO(); // Useful for debugging, but maybe capture instead? - - Process process = pb.start(); - boolean finished = process.waitFor(5, TimeUnit.MINUTES); - - if (!finished) { - process.destroy(); - throw new IOException("Slicer timed out"); - } - - if (process.exitValue() != 0) { - // Read stderr - String error = new String(process.getErrorStream().readAllBytes()); - throw new IOException("Slicer failed with exit code " + process.exitValue() + ": " + error); - } - - // 5. Find Output GCode - // Usually [basename].gcode or plate_1.gcode String basename = inputStl.getName(); if (basename.toLowerCase().endsWith(".stl")) { basename = basename.substring(0, basename.length() - 4); } - - File gcodeFile = tempDir.resolve(basename + ".gcode").toFile(); - if (!gcodeFile.exists()) { - // Try plate_1.gcode fallback - File alt = tempDir.resolve("plate_1.gcode").toFile(); - if (alt.exists()) { - gcodeFile = alt; - } else { - throw new IOException("GCode output not found in " + tempDir); + Path slicerLogPath = tempDir.resolve("orcaslicer.log"); + + // 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); + command.add("--load-settings"); + command.add(mFile.getAbsolutePath()); + command.add("--load-settings"); + command.add(pFile.getAbsolutePath()); + command.add("--load-filaments"); + command.add(fFile.getAbsolutePath()); + command.add("--ensure-on-bed"); + if (useArrange) { + command.add("--arrange"); + command.add("1"); } + command.add("--slice"); + command.add("0"); + command.add("--outputdir"); + command.add(tempDir.toAbsolutePath().toString()); + command.add(inputStl.getAbsolutePath()); + + 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()); + + Process process = pb.start(); + boolean finished = process.waitFor(5, TimeUnit.MINUTES); + + if (!finished) { + process.destroyForcibly(); + throw new IOException("Slicer timed out"); + } + + if (process.exitValue() != 0) { + String error = ""; + if (Files.exists(slicerLogPath)) { + error = Files.readString(slicerLogPath, StandardCharsets.UTF_8); + } + if (!useArrange && isOutOfVolumeError(error)) { + logger.warning("Slicer reported model out of printable area, retrying with arrange."); + continue; + } + throw new IOException("Slicer failed with exit code " + process.exitValue() + ": " + error); + } + + File gcodeFile = tempDir.resolve(basename + ".gcode").toFile(); + if (!gcodeFile.exists()) { + File alt = tempDir.resolve("plate_1.gcode").toFile(); + if (alt.exists()) { + gcodeFile = alt; + } else { + throw new IOException("GCode output not found in " + tempDir); + } + } + + return gCodeParser.parse(gcodeFile); } - // 6. Parse Results - return gCodeParser.parse(gcodeFile); + throw new IOException("Slicer failed after retry"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("Interrupted during slicing", e); } finally { - // Cleanup temp dir - // In production we should delete, for debugging we might want to keep? - // Let's delete for now on success. - // recursiveDelete(tempDir); - // Leaving it effectively "leaks" temp, but safer for persistent debugging? - // Implementation detail: Use a utility to clean up. + deleteRecursively(tempDir); } } + + public Optional inspectModelDimensions(File inputModel) { + Path tempDir = null; + try { + tempDir = Files.createTempDirectory("slicer_info_"); + Path infoLogPath = tempDir.resolve("orcaslicer-info.log"); + + List command = new ArrayList<>(); + command.add(slicerPath); + command.add("--info"); + command.add(inputModel.getAbsolutePath()); + + ProcessBuilder pb = new ProcessBuilder(command); + pb.directory(tempDir.toFile()); + pb.redirectErrorStream(true); + pb.redirectOutput(infoLogPath.toFile()); + + Process process = pb.start(); + boolean finished = process.waitFor(2, TimeUnit.MINUTES); + if (!finished) { + process.destroyForcibly(); + logger.warning("Model info extraction timed out for " + inputModel.getName()); + return Optional.empty(); + } + + String output = Files.exists(infoLogPath) + ? Files.readString(infoLogPath, StandardCharsets.UTF_8) + : ""; + + if (process.exitValue() != 0) { + logger.warning("OrcaSlicer --info failed (exit " + process.exitValue() + ") for " + + inputModel.getName() + ": " + output); + return Optional.empty(); + } + + Optional parsed = parseModelDimensionsFromInfoOutput(output); + if (parsed.isEmpty()) { + logger.warning("Could not parse size_x/size_y/size_z from OrcaSlicer --info output for " + + inputModel.getName() + ": " + output); + } + return parsed; + } catch (Exception e) { + logger.warning("Failed to inspect model dimensions for " + inputModel.getName() + ": " + e.getMessage()); + return Optional.empty(); + } finally { + if (tempDir != null) { + deleteRecursively(tempDir); + } + } + } + + static Optional parseModelDimensionsFromInfoOutput(String output) { + if (output == null || output.isBlank()) { + return Optional.empty(); + } + + Double x = extractDouble(SIZE_X_PATTERN, output); + Double y = extractDouble(SIZE_Y_PATTERN, output); + Double z = extractDouble(SIZE_Z_PATTERN, output); + + if (x == null || y == null || z == null) { + return Optional.empty(); + } + + if (x <= 0 || y <= 0 || z <= 0) { + return Optional.empty(); + } + + return Optional.of(new ModelDimensions(x, y, z)); + } + + private static Double extractDouble(Pattern pattern, String text) { + Matcher matcher = pattern.matcher(text); + if (!matcher.find()) { + return null; + } + + try { + return Double.parseDouble(matcher.group(1)); + } catch (NumberFormatException ignored) { + return null; + } + } + + private void deleteRecursively(Path path) { + if (path == null || !Files.exists(path)) { + return; + } + + try (var walk = Files.walk(path)) { + walk.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException e) { + logger.warning("Failed to delete temp path " + p + ": " + e.getMessage()); + } + }); + } catch (IOException e) { + logger.warning("Failed to walk temp directory " + path + ": " + e.getMessage()); + } + } + + private boolean isOutOfVolumeError(String errorLog) { + if (errorLog == null || errorLog.isBlank()) { + return false; + } + + String normalized = errorLog.toLowerCase(); + return normalized.contains("nothing to be sliced") + || normalized.contains("no object is fully inside the print volume") + || normalized.contains("calc_exclude_triangles"); + } } diff --git a/backend/src/main/java/com/printcalculator/service/StorageService.java b/backend/src/main/java/com/printcalculator/service/StorageService.java new file mode 100644 index 0000000..5fe2321 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/StorageService.java @@ -0,0 +1,14 @@ +package com.printcalculator.service; + +import org.springframework.core.io.Resource; +import org.springframework.web.multipart.MultipartFile; +import java.nio.file.Path; +import java.io.IOException; + +public interface StorageService { + void init(); + void store(MultipartFile file, Path destination) throws IOException; + void store(Path source, Path destination) throws IOException; + void delete(Path path) throws IOException; + Resource loadAsResource(Path path) throws IOException; +} diff --git a/backend/src/main/java/com/printcalculator/service/TwintPaymentService.java b/backend/src/main/java/com/printcalculator/service/TwintPaymentService.java new file mode 100644 index 0000000..539d339 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/TwintPaymentService.java @@ -0,0 +1,89 @@ +package com.printcalculator.service; + +import io.nayuki.qrcodegen.QrCode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; + +@Service +public class TwintPaymentService { + + private final String twintPaymentUrl; + + public TwintPaymentService( + @Value("${payment.twint.url:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}") + String twintPaymentUrl + ) { + this.twintPaymentUrl = twintPaymentUrl; + } + + public String getTwintPaymentUrl(com.printcalculator.entity.Order order) { + StringBuilder urlBuilder = new StringBuilder(twintPaymentUrl); + + if (order != null) { + if (order.getTotalChf() != null) { + urlBuilder.append("&amount=").append(order.getTotalChf().toPlainString()); + } + + String orderNumber = order.getOrderNumber(); + if (orderNumber == null && order.getId() != null) { + orderNumber = order.getId().toString(); + } + + if (orderNumber != null) { + try { + urlBuilder.append("&trxInfo=").append(order.getId()); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + } + + return urlBuilder.toString(); + } + + public byte[] generateQrPng(com.printcalculator.entity.Order order, int sizePx) { + try { + String url = getTwintPaymentUrl(order); + // Use High Error Correction for financial QR codes + QrCode qrCode = QrCode.encodeText(url, QrCode.Ecc.HIGH); + + // Standard QR quiet zone is 4 modules + int borderModules = 4; + int fullModules = qrCode.size + borderModules * 2; + int scale = Math.max(1, sizePx / fullModules); + int imageSize = fullModules * scale; + + BufferedImage image = new BufferedImage(imageSize, imageSize, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + try { + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, imageSize, imageSize); + graphics.setColor(Color.BLACK); + + for (int y = 0; y < qrCode.size; y++) { + for (int x = 0; x < qrCode.size; x++) { + if (qrCode.getModule(x, y)) { + int px = (x + borderModules) * scale; + int py = (y + borderModules) * scale; + graphics.fillRect(px, py, scale, scale); + } + } + } + } finally { + graphics.dispose(); + } + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ImageIO.write(image, "png", outputStream); + return outputStream.toByteArray(); + } catch (Exception ex) { + throw new IllegalStateException("Unable to generate TWINT QR image.", ex); + } + } +} diff --git a/backend/src/main/java/com/printcalculator/service/email/EmailNotificationService.java b/backend/src/main/java/com/printcalculator/service/email/EmailNotificationService.java new file mode 100644 index 0000000..f4bf4fb --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/email/EmailNotificationService.java @@ -0,0 +1,29 @@ +package com.printcalculator.service.email; + +import java.util.Map; + +public interface EmailNotificationService { + + /** + * Sends an HTML email using a Thymeleaf template. + * + * @param to The recipient email address. + * @param subject The subject of the email. + * @param templateName The name of the Thymeleaf template (e.g., "order-confirmation"). + * @param contextData The data to populate the template with. + */ + void sendEmail(String to, String subject, String templateName, Map contextData); + + /** + * Sends an HTML email using a Thymeleaf template, with an optional attachment. + * + * @param to The recipient email address. + * @param subject The subject of the email. + * @param templateName The name of the Thymeleaf template (e.g., "order-confirmation"). + * @param contextData The data to populate the template with. + * @param attachmentName The name for the attachment file. + * @param attachmentData The raw bytes of the attachment. + */ + void sendEmailWithAttachment(String to, String subject, String templateName, Map contextData, String attachmentName, byte[] attachmentData); + +} diff --git a/backend/src/main/java/com/printcalculator/service/email/SmtpEmailNotificationService.java b/backend/src/main/java/com/printcalculator/service/email/SmtpEmailNotificationService.java new file mode 100644 index 0000000..42b716b --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/email/SmtpEmailNotificationService.java @@ -0,0 +1,72 @@ +package com.printcalculator.service.email; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import org.springframework.core.io.ByteArrayResource; + +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SmtpEmailNotificationService implements EmailNotificationService { + + private final JavaMailSender emailSender; + private final TemplateEngine templateEngine; + + @Value("${app.mail.from}") + private String fromAddress; + + @Value("${app.mail.enabled:true}") + private boolean mailEnabled; + + @Override + public void sendEmail(String to, String subject, String templateName, Map contextData) { + sendEmailWithAttachment(to, subject, templateName, contextData, null, null); + } + + @Override + public void sendEmailWithAttachment(String to, String subject, String templateName, Map contextData, String attachmentName, byte[] attachmentData) { + if (!mailEnabled) { + log.info("Email sending disabled (app.mail.enabled=false). Skipping email to {}", to); + return; + } + + log.info("Preparing to send email to {} with template {}", to, templateName); + + try { + Context context = new Context(); + context.setVariables(contextData); + + String process = templateEngine.process("email/" + templateName, context); + MimeMessage mimeMessage = emailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + + helper.setFrom(fromAddress); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(process, true); // true indicates HTML format + + if (attachmentName != null && attachmentData != null) { + helper.addAttachment(attachmentName, new ByteArrayResource(attachmentData)); + } + + emailSender.send(mimeMessage); + log.info("Email successfully sent to {}", to); + + } catch (MessagingException e) { + log.error("Failed to send email to {}", to, e); + // Non blocco l'ordine se l'email fallisce, ma loggo l'errore adeguatamente. + } catch (Exception e) { + log.error("Unexpected error while sending email to {}", to, e); + } + } +} diff --git a/backend/src/main/resources/application-local.properties b/backend/src/main/resources/application-local.properties new file mode 100644 index 0000000..84e4ff5 --- /dev/null +++ b/backend/src/main/resources/application-local.properties @@ -0,0 +1,7 @@ +app.mail.enabled=false +app.mail.admin.enabled=false + +# Admin back-office local test credentials +admin.password=ciaociao +admin.session.secret=local-admin-session-secret-0123456789abcdef0123456789 +admin.session.ttl-minutes=480 diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index fc32567..a24a15c 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -7,6 +7,7 @@ spring.datasource.username=${DB_USERNAME:printcalc} spring.datasource.password=${DB_PASSWORD:printcalc_secret} spring.jpa.hibernate.ddl-auto=update spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.open-in-view=false # Slicer Configuration @@ -18,3 +19,35 @@ profiles.root=${PROFILES_DIR:profiles} # File Upload Limits spring.servlet.multipart.max-file-size=200MB spring.servlet.multipart.max-request-size=200MB + +# ClamAV Configuration +clamav.host=${CLAMAV_HOST:clamav} +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.} + +# Mail Configuration +spring.mail.host=${MAIL_HOST:mail.infomaniak.com} +spring.mail.port=${MAIL_PORT:587} +spring.mail.username=${MAIL_USERNAME:info@3d-fab.ch} +spring.mail.password=${MAIL_PASSWORD:} +spring.mail.properties.mail.smtp.auth=${MAIL_SMTP_AUTH:false} +spring.mail.properties.mail.smtp.starttls.enable=${MAIL_SMTP_STARTTLS:false} + +# Application Mail Settings +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.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200} + +# Admin back-office authentication +admin.password=${ADMIN_PASSWORD} +admin.session.secret=${ADMIN_SESSION_SECRET} +admin.session.ttl-minutes=${ADMIN_SESSION_TTL_MINUTES:480} +admin.auth.trust-proxy-headers=${ADMIN_AUTH_TRUST_PROXY_HEADERS:false} + +# Expose only liveness endpoint by default. +management.endpoints.web.exposure.include=health diff --git a/backend/src/main/resources/templates/email/order-confirmation.html b/backend/src/main/resources/templates/email/order-confirmation.html new file mode 100644 index 0000000..37a6082 --- /dev/null +++ b/backend/src/main/resources/templates/email/order-confirmation.html @@ -0,0 +1,110 @@ + + + + + Order Confirmation + + + +
+
+

Thank you for your order #00000000

+
+ +
+

Hi Customer,

+

We received your order and started processing it.

+ +
+

Order details

+ + + + + + + + + + + + + +
Order number00000000
DateJan 1, 2026, 10:00:00 AM
TotalCHF 0.00
+
+ +

+ View order status: + https://example.com/en/co/00000000-0000-0000-0000-000000000000 +

+ +

The order confirmation PDF is attached.

+

If you have questions, reply to this email.

+
+ + +
+ + diff --git a/backend/src/main/resources/templates/email/payment-confirmed.html b/backend/src/main/resources/templates/email/payment-confirmed.html new file mode 100644 index 0000000..657f1ef --- /dev/null +++ b/backend/src/main/resources/templates/email/payment-confirmed.html @@ -0,0 +1,111 @@ + + + + + Payment Confirmed + + + +
+
+

Payment confirmed for order #00000000

+
+ +
+

Hi Customer,

+

Your payment has been confirmed and your order is now in production.

+ +
+ Current status: In production. +
+ +
+ + + + + + + + + +
Order number00000000
TotalCHF 0.00
+
+ +

The paid invoice PDF is attached to this email.

+

+ View order status: + https://example.com/en/co/00000000-0000-0000-0000-000000000000 +

+

We will notify you when shipment is ready.

+
+ + +
+ + diff --git a/backend/src/main/resources/templates/email/payment-reported.html b/backend/src/main/resources/templates/email/payment-reported.html new file mode 100644 index 0000000..c7d2b72 --- /dev/null +++ b/backend/src/main/resources/templates/email/payment-reported.html @@ -0,0 +1,110 @@ + + + + + Payment Reported + + + +
+
+

Payment reported for order #00000000

+
+ +
+

Hi Customer,

+

We received your payment report and we are now verifying it.

+ +
+ Current status: Payment under verification. +
+ +
+ + + + + + + + + +
Order number00000000
TotalCHF 0.00
+
+ +

+ Check order status: + https://example.com/en/co/00000000-0000-0000-0000-000000000000 +

+

You will receive another email once payment is confirmed.

+
+ + +
+ + diff --git a/backend/src/main/resources/templates/invoice.html b/backend/src/main/resources/templates/invoice.html new file mode 100644 index 0000000..620b3f3 --- /dev/null +++ b/backend/src/main/resources/templates/invoice.html @@ -0,0 +1,413 @@ + + + + + + + +
+ + + + + + + + +
+ 3D fab +
Küng Caletti
+
+
3D Fab Switzerland
+
Via G. pioda 29a - 6710 Biasca, Svizzera
+
Lyss-Strasse 71 - 2560 Nidau, Svizzera
+
+ www.3d-fab.ch +
+ + +
+ Conferma dell'ordine + Fattura + 141052743 + PAGATO +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
Data dell'ordine / fattura07.03.2025
Numero documentoINV-2026-000123
Data di scadenza07.03.2025
Metodo di pagamentoQR / Bonifico oppure TWINT
ValutaCHF
+
+
Indirizzo di fatturazione:
+
Joe Küng
+
Via G.Pioda, 29a
+
6710 biasca
+
Svizzera
+
+
+
Indirizzo di spedizione:
+
Joe Küng
+
Via G.Pioda, 29a
+
6710 biasca
+
Svizzera
+
+
+ + + + + + + + + + + + + + + + + + + +
DescrizioneQuantitàPrezzo unitarioPrezzo incl.
Apple iPhone 16 Pro1968.551'047.00
+ + + + + + + + + + + + + + + + + + + +
Importo totale1'012.86
Totale di tutte le consegne e di tutti i servizi CHF1'094.90
Importo dovuto1'094.90
Importo dovutoCHF 0.00
+ + + + + + + + + + + + + +
+ + +
+ + + + +
+
+
+
+
+ + + diff --git a/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java b/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java new file mode 100644 index 0000000..e5cd834 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java @@ -0,0 +1,134 @@ +package com.printcalculator.controller; + +import com.printcalculator.config.SecurityConfig; +import com.printcalculator.controller.admin.AdminAuthController; +import com.printcalculator.security.AdminLoginThrottleService; +import com.printcalculator.security.AdminSessionAuthenticationFilter; +import com.printcalculator.security.AdminSessionService; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(controllers = AdminAuthController.class) +@Import({ + SecurityConfig.class, + AdminSessionAuthenticationFilter.class, + AdminSessionService.class, + AdminLoginThrottleService.class +}) +@TestPropertySource(properties = { + "admin.password=test-admin-password", + "admin.session.secret=0123456789abcdef0123456789abcdef", + "admin.session.ttl-minutes=60" +}) +class AdminAuthSecurityTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void loginOk_ShouldReturnCookie() throws Exception { + MvcResult result = mockMvc.perform(post("/api/admin/auth/login") + .with(req -> { + req.setRemoteAddr("10.0.0.1"); + return req; + }) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"password\":\"test-admin-password\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(true)) + .andReturn(); + + String setCookie = result.getResponse().getHeader(HttpHeaders.SET_COOKIE); + assertNotNull(setCookie); + assertTrue(setCookie.contains("admin_session=")); + assertTrue(setCookie.contains("HttpOnly")); + assertTrue(setCookie.contains("Secure")); + assertTrue(setCookie.contains("SameSite=Strict")); + assertTrue(setCookie.contains("Path=/api/admin")); + } + + @Test + void loginKo_ShouldReturnUnauthorized() throws Exception { + mockMvc.perform(post("/api/admin/auth/login") + .with(req -> { + req.setRemoteAddr("10.0.0.2"); + return req; + }) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"password\":\"wrong-password\"}")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.authenticated").value(false)) + .andExpect(jsonPath("$.retryAfterSeconds").value(2)); + } + + @Test + void loginKoSecondAttemptDuringLock_ShouldReturnTooManyRequests() throws Exception { + mockMvc.perform(post("/api/admin/auth/login") + .with(req -> { + req.setRemoteAddr("10.0.0.3"); + return req; + }) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"password\":\"wrong-password\"}")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.retryAfterSeconds").value(2)); + + mockMvc.perform(post("/api/admin/auth/login") + .with(req -> { + req.setRemoteAddr("10.0.0.3"); + return req; + }) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"password\":\"wrong-password\"}")) + .andExpect(status().isTooManyRequests()) + .andExpect(jsonPath("$.authenticated").value(false)); + } + + @Test + void adminAccessWithoutCookie_ShouldReturn401() throws Exception { + mockMvc.perform(get("/api/admin/auth/me")) + .andExpect(status().isUnauthorized()); + } + + @Test + void adminAccessWithValidCookie_ShouldReturn200() throws Exception { + MvcResult login = mockMvc.perform(post("/api/admin/auth/login") + .with(req -> { + req.setRemoteAddr("10.0.0.4"); + return req; + }) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"password\":\"test-admin-password\"}")) + .andExpect(status().isOk()) + .andReturn(); + + String setCookie = login.getResponse().getHeader(HttpHeaders.SET_COOKIE); + assertNotNull(setCookie); + + Cookie adminCookie = toCookie(setCookie); + mockMvc.perform(get("/api/admin/auth/me").cookie(adminCookie)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(true)); + } + + private Cookie toCookie(String setCookieHeader) { + String[] parts = setCookieHeader.split(";", 2); + String[] keyValue = parts[0].split("=", 2); + return new Cookie(keyValue[0], keyValue.length > 1 ? keyValue[1] : ""); + } +} diff --git a/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java new file mode 100644 index 0000000..ee5e96a --- /dev/null +++ b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerStatusValidationTest.java @@ -0,0 +1,107 @@ +package com.printcalculator.controller.admin; + +import com.printcalculator.dto.AdminOrderStatusUpdateRequest; +import com.printcalculator.dto.OrderDto; +import com.printcalculator.entity.Order; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.OrderRepository; +import com.printcalculator.repository.PaymentRepository; +import com.printcalculator.service.InvoicePdfRenderingService; +import com.printcalculator.service.PaymentService; +import com.printcalculator.service.QrBillService; +import com.printcalculator.service.StorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminOrderControllerStatusValidationTest { + + @Mock + private OrderRepository orderRepository; + @Mock + private OrderItemRepository orderItemRepository; + @Mock + private PaymentRepository paymentRepository; + @Mock + private PaymentService paymentService; + @Mock + private StorageService storageService; + @Mock + private InvoicePdfRenderingService invoicePdfRenderingService; + @Mock + private QrBillService qrBillService; + + private AdminOrderController controller; + + @BeforeEach + void setUp() { + controller = new AdminOrderController( + orderRepository, + orderItemRepository, + paymentRepository, + paymentService, + storageService, + invoicePdfRenderingService, + qrBillService + ); + } + + @Test + void updateOrderStatus_withInvalidStatus_shouldReturn400AndNotSave() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus("PENDING_PAYMENT"); + + when(orderRepository.findById(orderId)).thenReturn(Optional.of(order)); + + AdminOrderStatusUpdateRequest payload = new AdminOrderStatusUpdateRequest(); + payload.setStatus("REPORTED"); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> controller.updateOrderStatus(orderId, payload) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verify(orderRepository, never()).save(any(Order.class)); + } + + @Test + void updateOrderStatus_withValidStatus_shouldReturn200() { + UUID orderId = UUID.randomUUID(); + Order order = new Order(); + order.setId(orderId); + order.setStatus("PENDING_PAYMENT"); + + when(orderRepository.findById(orderId)).thenReturn(Optional.of(order)); + when(orderItemRepository.findByOrder_Id(orderId)).thenReturn(List.of()); + when(paymentRepository.findByOrder_Id(orderId)).thenReturn(Optional.empty()); + + AdminOrderStatusUpdateRequest payload = new AdminOrderStatusUpdateRequest(); + payload.setStatus("PAID"); + + ResponseEntity response = controller.updateOrderStatus(orderId, payload); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("PAID", response.getBody().getStatus()); + verify(orderRepository).save(order); + } +} diff --git a/backend/src/test/java/com/printcalculator/event/listener/OrderEmailListenerTest.java b/backend/src/test/java/com/printcalculator/event/listener/OrderEmailListenerTest.java new file mode 100644 index 0000000..b0f62c4 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/event/listener/OrderEmailListenerTest.java @@ -0,0 +1,160 @@ +package com.printcalculator.event.listener; + +import com.printcalculator.entity.Customer; +import com.printcalculator.entity.Order; +import com.printcalculator.event.OrderCreatedEvent; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.service.InvoicePdfRenderingService; +import com.printcalculator.service.QrBillService; +import com.printcalculator.service.StorageService; +import com.printcalculator.service.email.EmailNotificationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.charset.StandardCharsets; +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OrderEmailListenerTest { + + @Mock + private EmailNotificationService emailNotificationService; + + @Mock + private InvoicePdfRenderingService invoicePdfRenderingService; + + @Mock + private OrderItemRepository orderItemRepository; + + @Mock + private QrBillService qrBillService; + + @Mock + private StorageService storageService; + + @InjectMocks + private OrderEmailListener orderEmailListener; + + @Captor + private ArgumentCaptor> templateDataCaptor; + + @Captor + private ArgumentCaptor attachmentDataCaptor; + + private Order order; + private OrderCreatedEvent event; + + @BeforeEach + void setUp() throws Exception { + Customer customer = new Customer(); + customer.setFirstName("John"); + customer.setLastName("Doe"); + customer.setEmail("john.doe@test.com"); + + order = new Order(); + order.setId(UUID.randomUUID()); + order.setCustomer(customer); + order.setCreatedAt(OffsetDateTime.parse("2026-02-21T10:00:00Z")); + order.setTotalChf(new BigDecimal("150.50")); + + event = new OrderCreatedEvent(this, order); + + ReflectionTestUtils.setField(orderEmailListener, "adminMailEnabled", true); + ReflectionTestUtils.setField(orderEmailListener, "adminMailAddress", "admin@printcalculator.local"); + ReflectionTestUtils.setField(orderEmailListener, "frontendBaseUrl", "https://3d-fab.ch"); + + when(storageService.loadAsResource(any())).thenReturn(new ByteArrayResource("PDF".getBytes(StandardCharsets.UTF_8))); + } + + @Test + void handleOrderCreatedEvent_ShouldSendCustomerAndAdminEmails() { + orderEmailListener.handleOrderCreatedEvent(event); + + verify(emailNotificationService, times(1)).sendEmailWithAttachment( + eq("john.doe@test.com"), + eq("Conferma Ordine #" + order.getOrderNumber() + " - 3D-Fab"), + eq("order-confirmation"), + templateDataCaptor.capture(), + eq("Conferma-Ordine-" + order.getOrderNumber() + ".pdf"), + attachmentDataCaptor.capture() + ); + + Map customerData = templateDataCaptor.getValue(); + assertEquals("John", customerData.get("customerName")); + assertEquals(order.getId(), customerData.get("orderId")); + assertEquals(order.getOrderNumber(), customerData.get("orderNumber")); + assertEquals("https://3d-fab.ch/it/co/" + order.getId(), customerData.get("orderDetailsUrl")); + assertNotNull(customerData.get("orderDate")); + assertTrue(customerData.get("orderDate").toString().contains("2026")); + assertTrue(customerData.get("totalCost").toString().contains("150")); + assertArrayEquals("PDF".getBytes(StandardCharsets.UTF_8), attachmentDataCaptor.getValue()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> adminTemplateCaptor = (ArgumentCaptor>) (ArgumentCaptor) ArgumentCaptor.forClass(Map.class); + verify(emailNotificationService, times(1)).sendEmail( + eq("admin@printcalculator.local"), + eq("Nuovo Ordine Ricevuto #" + order.getOrderNumber() + " - John Doe"), + eq("order-confirmation"), + adminTemplateCaptor.capture() + ); + + Map adminData = adminTemplateCaptor.getValue(); + assertEquals("John Doe", adminData.get("customerName")); + } + + @Test + void handleOrderCreatedEvent_WithAdminDisabled_ShouldOnlySendCustomerEmail() { + ReflectionTestUtils.setField(orderEmailListener, "adminMailEnabled", false); + + orderEmailListener.handleOrderCreatedEvent(event); + + verify(emailNotificationService, times(1)).sendEmailWithAttachment( + eq("john.doe@test.com"), + anyString(), + anyString(), + anyMap(), + anyString(), + any() + ); + + verify(emailNotificationService, never()).sendEmail( + eq("admin@printcalculator.local"), + anyString(), + anyString(), + anyMap() + ); + } + + @Test + void handleOrderCreatedEvent_ExceptionHandling_ShouldNotPropagate() { + doThrow(new RuntimeException("Simulated Mail Failure")) + .when(emailNotificationService).sendEmailWithAttachment(anyString(), anyString(), anyString(), anyMap(), anyString(), any()); + + assertDoesNotThrow(() -> orderEmailListener.handleOrderCreatedEvent(event)); + + verify(emailNotificationService, times(1)) + .sendEmailWithAttachment(anyString(), anyString(), anyString(), anyMap(), anyString(), any()); + } +} diff --git a/backend/src/test/java/com/printcalculator/security/AdminLoginThrottleServiceTest.java b/backend/src/test/java/com/printcalculator/security/AdminLoginThrottleServiceTest.java new file mode 100644 index 0000000..c8f0064 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/security/AdminLoginThrottleServiceTest.java @@ -0,0 +1,40 @@ +package com.printcalculator.security; + +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AdminLoginThrottleServiceTest { + + private final AdminLoginThrottleService service = new AdminLoginThrottleService(false); + + @Test + void registerFailure_ShouldDoubleDelay() { + assertEquals(2L, service.registerFailure("127.0.0.1")); + assertEquals(4L, service.registerFailure("127.0.0.1")); + assertEquals(8L, service.registerFailure("127.0.0.1")); + } + + @Test + void resolveClientKey_ShouldUseRemoteAddress_WhenProxyHeadersAreNotTrusted() { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getHeader("X-Forwarded-For")).thenReturn("203.0.113.10"); + when(request.getHeader("X-Real-IP")).thenReturn("203.0.113.11"); + when(request.getRemoteAddr()).thenReturn("10.0.0.5"); + + assertEquals("10.0.0.5", service.resolveClientKey(request)); + } + + @Test + void resolveClientKey_ShouldUseForwardedFor_WhenProxyHeadersAreTrusted() { + AdminLoginThrottleService trustedService = new AdminLoginThrottleService(true); + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getHeader("X-Forwarded-For")).thenReturn("203.0.113.10, 10.0.0.5"); + when(request.getRemoteAddr()).thenReturn("10.0.0.5"); + + assertEquals("203.0.113.10", trustedService.resolveClientKey(request)); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/SlicerServiceTest.java b/backend/src/test/java/com/printcalculator/service/SlicerServiceTest.java new file mode 100644 index 0000000..f6872aa --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/SlicerServiceTest.java @@ -0,0 +1,61 @@ +package com.printcalculator.service; + +import com.printcalculator.model.ModelDimensions; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +class SlicerServiceTest { + + @Test + void parseModelDimensionsFromInfoOutput_validOutput_returnsDimensions() { + String output = """ + [file.stl] + size_x = 130.860428 + size_y = 225.000000 + size_z = 140.000000 + min_x = 0.000000 + """; + + Optional dimensions = SlicerService.parseModelDimensionsFromInfoOutput(output); + + assertTrue(dimensions.isPresent()); + assertEquals(130.860428, dimensions.get().xMm(), 0.000001); + assertEquals(225.0, dimensions.get().yMm(), 0.000001); + assertEquals(140.0, dimensions.get().zMm(), 0.000001); + } + + @Test + void parseModelDimensionsFromInfoOutput_withNoise_returnsDimensions() { + String output = """ + [2026-02-27 10:26:30.306251] [0x1] [trace] Initializing StaticPrintConfigs + [model.3mf] + size_x = 97.909241 + size_y = 97.909241 + size_z = 70.000008 + [2026-02-27 10:26:30.314575] [0x1] [error] calc_exclude_triangles + """; + + Optional dimensions = SlicerService.parseModelDimensionsFromInfoOutput(output); + + assertTrue(dimensions.isPresent()); + assertEquals(97.909241, dimensions.get().xMm(), 0.000001); + assertEquals(97.909241, dimensions.get().yMm(), 0.000001); + assertEquals(70.000008, dimensions.get().zMm(), 0.000001); + } + + @Test + void parseModelDimensionsFromInfoOutput_missingValues_returnsEmpty() { + String output = """ + [model.step] + size_x = 10.0 + size_y = 20.0 + """; + + Optional dimensions = SlicerService.parseModelDimensionsFromInfoOutput(output); + + assertTrue(dimensions.isEmpty()); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/email/SmtpEmailNotificationServiceTest.java b/backend/src/test/java/com/printcalculator/service/email/SmtpEmailNotificationServiceTest.java new file mode 100644 index 0000000..b653e34 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/email/SmtpEmailNotificationServiceTest.java @@ -0,0 +1,83 @@ +package com.printcalculator.service.email; + +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.util.ReflectionTestUtils; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SmtpEmailNotificationServiceTest { + + @Mock + private JavaMailSender emailSender; + + @Mock + private TemplateEngine templateEngine; + + @Mock + private MimeMessage mimeMessage; + + @InjectMocks + private SmtpEmailNotificationService emailNotificationService; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(emailNotificationService, "fromAddress", "noreply@test.com"); + ReflectionTestUtils.setField(emailNotificationService, "mailEnabled", true); + } + + @Test + void sendEmail_Success() { + // Arrange + String to = "user@test.com"; + String subject = "Test Subject"; + String templateName = "test-template"; + Map contextData = new HashMap<>(); + contextData.put("key", "value"); + + when(templateEngine.process(eq("email/" + templateName), any(Context.class))).thenReturn("Test"); + when(emailSender.createMimeMessage()).thenReturn(mimeMessage); + + // Act + emailNotificationService.sendEmail(to, subject, templateName, contextData); + + // Assert + verify(templateEngine, times(1)).process(eq("email/" + templateName), any(Context.class)); + verify(emailSender, times(1)).createMimeMessage(); + verify(emailSender, times(1)).send(mimeMessage); + } + + @Test + void sendEmail_Exception_ShouldNotThrow() { + // Arrange + String to = "user@test.com"; + String subject = "Test Subject"; + String templateName = "test-template"; + Map contextData = new HashMap<>(); + + when(templateEngine.process(eq("email/" + templateName), any(Context.class))).thenThrow(new RuntimeException("Template error")); + + // Act & Assert + // We expect the exception to be caught and logged, not propagated + assertDoesNotThrow(() -> emailNotificationService.sendEmail(to, subject, templateName, contextData)); + + verify(emailSender, never()).createMimeMessage(); + verify(emailSender, never()).send(any(MimeMessage.class)); + } +} diff --git a/db.sql b/db.sql index f7b376b..8b1fe79 100644 --- a/db.sql +++ b/db.sql @@ -16,11 +16,10 @@ create table printer_machine ); create view printer_fleet_current as -select 1 as fleet_id, - case +select case when sum(fleet_weight) = 0 then null else round(sum(power_watts * fleet_weight) / sum(fleet_weight))::integer - end as weighted_average_power_watts, + end as weighted_average_power_watts, max(build_volume_x_mm) as fleet_max_build_x_mm, max(build_volume_y_mm) as fleet_max_build_y_mm, max(build_volume_z_mm) as fleet_max_build_z_mm @@ -45,6 +44,10 @@ create table filament_variant variant_display_name text not null, -- es: "PLA Nero Opaco BrandX" color_name text not null, -- Nero, Bianco, ecc. + color_hex text, + finish_type text not null default 'GLOSSY' + check (finish_type in ('GLOSSY', 'MATTE', 'MARBLE', 'SILK', 'TRANSLUCENT', 'SPECIAL')), + brand text, is_matte boolean not null default false, is_special boolean not null default false, @@ -60,7 +63,6 @@ create table filament_variant unique (filament_material_type_id, variant_display_name) ); --- (opzionale) kg disponibili calcolati create view filament_variant_stock_kg as select filament_variant_id, stock_spools, @@ -68,6 +70,37 @@ select filament_variant_id, (stock_spools * spool_net_kg) as stock_kg from filament_variant; +create table printer_machine_profile +( + printer_machine_profile_id bigserial primary key, + printer_machine_id bigint not null references printer_machine (printer_machine_id) on delete cascade, + nozzle_diameter_mm numeric(4, 2) not null check (nozzle_diameter_mm > 0), + orca_machine_profile_name text not null, + is_default boolean not null default false, + is_active boolean not null default true, + unique (printer_machine_id, nozzle_diameter_mm) +); + +create table material_orca_profile_map +( + material_orca_profile_map_id bigserial primary key, + printer_machine_profile_id bigint not null references printer_machine_profile (printer_machine_profile_id) on delete cascade, + filament_material_type_id bigint not null references filament_material_type (filament_material_type_id), + orca_filament_profile_name text not null, + is_active boolean not null default true, + unique (printer_machine_profile_id, filament_material_type_id) +); + +create table filament_variant_orca_override +( + filament_variant_orca_override_id bigserial primary key, + filament_variant_id bigint not null references filament_variant (filament_variant_id) on delete cascade, + printer_machine_profile_id bigint not null references printer_machine_profile (printer_machine_profile_id) on delete cascade, + orca_filament_profile_name text not null, + is_active boolean not null default true, + unique (filament_variant_id, printer_machine_profile_id) +); + create table pricing_policy @@ -156,54 +189,63 @@ begin; set timezone = 'Europe/Zurich'; - is_active = excluded.is_active; +-- ========================================================= +-- 0) (Solo se non esiste) tabella infill_pattern + seed +-- ========================================================= +-- Se la tabella esiste già, commenta questo blocco. +create table if not exists infill_pattern +( + infill_pattern_id bigserial primary key, + pattern_code text not null unique, -- es: grid, gyroid + display_name text not null, + is_active boolean not null default true +); + +insert into infill_pattern (pattern_code, display_name, is_active) +values ('grid', 'Grid', true), + ('gyroid', 'Gyroid', true) +on conflict (pattern_code) do update + set display_name = excluded.display_name, + is_active = excluded.is_active; -- ========================================================= -- 1) Pricing policy (valori ESATTI da Excel) -- Valid from: 2026-01-01, valid_to: NULL -- ========================================================= -insert into pricing_policy ( - policy_name, - valid_from, - valid_to, - electricity_cost_chf_per_kwh, - markup_percent, - fixed_job_fee_chf, - nozzle_change_base_fee_chf, - cad_cost_chf_per_hour, - is_active -) values ( - 'Excel Tariffe 2026-01-01', - '2026-01-01 00:00:00+01'::timestamptz, - null, - 0.156, -- Costo elettricità CHF/kWh (Excel) - 0.000, -- Markup non specificato -> 0 (puoi cambiarlo dopo) - 1.00, -- Costo fisso macchina CHF (Excel) - 0.00, -- Base cambio ugello: non specificato -> 0 - 25.00, -- Tariffa CAD CHF/h (Excel) - true - ) +insert into pricing_policy (policy_name, + valid_from, + valid_to, + electricity_cost_chf_per_kwh, + markup_percent, + fixed_job_fee_chf, + nozzle_change_base_fee_chf, + cad_cost_chf_per_hour, + is_active) +values ('Excel Tariffe 2026-01-01', + '2026-01-01 00:00:00+01'::timestamptz, + null, + 0.156, -- Costo elettricità CHF/kWh (Excel) + 0.000, -- Markup non specificato -> 0 (puoi cambiarlo dopo) + 1.00, -- Costo fisso macchina CHF (Excel) + 0.00, -- Base cambio ugello: non specificato -> 0 + 25.00, -- Tariffa CAD CHF/h (Excel) + true) on conflict do nothing; -- scaglioni tariffa stampa (Excel) -insert into pricing_policy_machine_hour_tier ( - pricing_policy_id, - tier_start_hours, - tier_end_hours, - machine_cost_chf_per_hour -) -select - p.pricing_policy_id, - tiers.tier_start_hours, - tiers.tier_end_hours, - tiers.machine_cost_chf_per_hour +insert into pricing_policy_machine_hour_tier (pricing_policy_id, + tier_start_hours, + tier_end_hours, + machine_cost_chf_per_hour) +select p.pricing_policy_id, + tiers.tier_start_hours, + tiers.tier_end_hours, + tiers.machine_cost_chf_per_hour from pricing_policy p - cross join ( - values - (0.00::numeric, 10.00::numeric, 2.00::numeric), -- 0–10 h - (10.00::numeric, 20.00::numeric, 1.40::numeric), -- 10–20 h - (20.00::numeric, null::numeric, 0.50::numeric) -- >20 h + cross join (values (0.00::numeric, 10.00::numeric, 2.00::numeric), -- 0–10 h + (10.00::numeric, 20.00::numeric, 1.40::numeric), -- 10–20 h + (20.00::numeric, null::numeric, 0.50::numeric) -- >20 h ) as tiers(tier_start_hours, tier_end_hours, machine_cost_chf_per_hour) where p.policy_name = 'Excel Tariffe 2026-01-01' on conflict do nothing; @@ -212,52 +254,47 @@ on conflict do nothing; -- ========================================================= -- 2) Stampante: BambuLab A1 -- ========================================================= -insert into printer_machine ( - printer_display_name, - build_volume_x_mm, - build_volume_y_mm, - build_volume_z_mm, - power_watts, - fleet_weight, - is_active -) values ( - 'BambuLab A1', - 256, - 256, - 256, - 150, -- hai detto "150, 140": qui ho messo 150 - 1.000, - true - ) +insert into printer_machine (printer_display_name, + build_volume_x_mm, + build_volume_y_mm, + build_volume_z_mm, + power_watts, + fleet_weight, + is_active) +values ('BambuLab A1', + 256, + 256, + 256, + 150, -- hai detto "150, 140": qui ho messo 150 + 1.000, + true) on conflict (printer_display_name) do update - set - build_volume_x_mm = excluded.build_volume_x_mm, + set build_volume_x_mm = excluded.build_volume_x_mm, build_volume_y_mm = excluded.build_volume_y_mm, build_volume_z_mm = excluded.build_volume_z_mm, - power_watts = excluded.power_watts, - fleet_weight = excluded.fleet_weight, - is_active = excluded.is_active; + power_watts = excluded.power_watts, + fleet_weight = excluded.fleet_weight, + is_active = excluded.is_active; -- ========================================================= -- 3) Material types (da Excel) - per ora niente technical -- ========================================================= -insert into filament_material_type ( - material_code, - is_flexible, - is_technical, - technical_type_label -) values - ('PLA', false, false, null), - ('PETG', false, false, null), - ('TPU', true, false, null), - ('ABS', false, false, null), - ('Nylon', false, false, null), - ('Carbon PLA', false, false, null) +insert into filament_material_type (material_code, + is_flexible, + is_technical, + technical_type_label) +values ('PLA', false, false, null), + ('PLA TOUGH', false, false, null), + ('PETG', false, false, null), + ('TPU', true, false, null), + ('PC', false, true, 'engineering'), + ('ABS', false, false, null), + ('Nylon', false, false, null), + ('Carbon PLA', false, false, null) on conflict (material_code) do update - set - is_flexible = excluded.is_flexible, - is_technical = excluded.is_technical, + set is_flexible = excluded.is_flexible, + is_technical = excluded.is_technical, technical_type_label = excluded.technical_type_label; @@ -268,99 +305,547 @@ on conflict (material_code) do update -- ========================================================= -- helper: ID PLA -with pla as ( - select filament_material_type_id - from filament_material_type - where material_code = 'PLA' -) -insert into filament_variant ( - filament_material_type_id, - variant_display_name, - color_name, - is_matte, - is_special, - cost_chf_per_kg, - stock_spools, - spool_net_kg, - is_active -) -select - pla.filament_material_type_id, - v.variant_display_name, - v.color_name, - v.is_matte, - v.is_special, - 18.00, -- PLA da Excel - v.stock_spools, - 1.000, - true +with pla as (select filament_material_type_id + from filament_material_type + where material_code = 'PLA') +insert +into filament_variant (filament_material_type_id, + variant_display_name, + color_name, + color_hex, + finish_type, + brand, + is_matte, + is_special, + cost_chf_per_kg, + stock_spools, + spool_net_kg, + is_active) +select pla.filament_material_type_id, + v.variant_display_name, + v.color_name, + v.color_hex, + v.finish_type, + null::text as brand, + v.is_matte, + v.is_special, + 18.00, -- PLA da Excel + v.stock_spools, + 1.000, + true from pla - cross join ( - values - ('PLA Bianco', 'Bianco', false, false, 3.000::numeric), - ('PLA Nero', 'Nero', false, false, 3.000::numeric), - ('PLA Blu', 'Blu', false, false, 1.000::numeric), - ('PLA Arancione', 'Arancione', false, false, 1.000::numeric), - ('PLA Grigio', 'Grigio', false, false, 1.000::numeric), - ('PLA Grigio Scuro', 'Grigio scuro', false, false, 1.000::numeric), - ('PLA Grigio Chiaro', 'Grigio chiaro', false, false, 1.000::numeric), - ('PLA Viola', 'Viola', false, false, 1.000::numeric) -) as v(variant_display_name, color_name, is_matte, is_special, stock_spools) + cross join (values ('PLA Bianco', 'Bianco', '#F5F5F5', 'GLOSSY', false, false, 3.000::numeric), + ('PLA Nero', 'Nero', '#1A1A1A', 'GLOSSY', false, false, 3.000::numeric), + ('PLA Blu', 'Blu', '#1976D2', 'GLOSSY', false, false, 1.000::numeric), + ('PLA Arancione', 'Arancione', '#FFA726', 'GLOSSY', false, false, 1.000::numeric), + ('PLA Grigio', 'Grigio', '#BDBDBD', 'GLOSSY', false, false, 1.000::numeric), + ('PLA Grigio Scuro', 'Grigio scuro', '#424242', 'MATTE', true, false, 1.000::numeric), + ('PLA Grigio Chiaro', 'Grigio chiaro', '#D6D6D6', 'MATTE', true, false, 1.000::numeric), + ('PLA Viola', 'Viola', '#7B1FA2', 'GLOSSY', false, false, + 1.000::numeric)) as v(variant_display_name, color_name, color_hex, finish_type, is_matte, is_special, stock_spools) on conflict (filament_material_type_id, variant_display_name) do update - set - color_name = excluded.color_name, - is_matte = excluded.is_matte, - is_special = excluded.is_special, + set color_name = excluded.color_name, + color_hex = excluded.color_hex, + finish_type = excluded.finish_type, + brand = excluded.brand, + is_matte = excluded.is_matte, + is_special = excluded.is_special, cost_chf_per_kg = excluded.cost_chf_per_kg, - stock_spools = excluded.stock_spools, - spool_net_kg = excluded.spool_net_kg, - is_active = excluded.is_active; + stock_spools = excluded.stock_spools, + spool_net_kg = excluded.spool_net_kg, + is_active = excluded.is_active; + +-- Varianti base per materiali principali del calcolatore +with mat as (select filament_material_type_id + from filament_material_type + where material_code = 'PLA TOUGH') +insert +into filament_variant (filament_material_type_id, variant_display_name, color_name, color_hex, finish_type, brand, + is_matte, is_special, cost_chf_per_kg, stock_spools, spool_net_kg, is_active) +select mat.filament_material_type_id, + 'PLA Tough Nero', + 'Nero', + '#1A1A1A', + 'GLOSSY', + 'Bambu', + false, + false, + 18.00, + 1.000, + 1.000, + true +from mat +on conflict (filament_material_type_id, variant_display_name) do update + set color_name = excluded.color_name, + color_hex = excluded.color_hex, + finish_type = excluded.finish_type, + brand = excluded.brand, + is_matte = excluded.is_matte, + is_special = excluded.is_special, + cost_chf_per_kg = excluded.cost_chf_per_kg, + stock_spools = excluded.stock_spools, + spool_net_kg = excluded.spool_net_kg, + is_active = excluded.is_active; + +with mat as (select filament_material_type_id + from filament_material_type + where material_code = 'PETG') +insert +into filament_variant (filament_material_type_id, variant_display_name, color_name, color_hex, finish_type, brand, + is_matte, is_special, cost_chf_per_kg, stock_spools, spool_net_kg, is_active) +select mat.filament_material_type_id, + 'PETG Nero', + 'Nero', + '#1A1A1A', + 'GLOSSY', + 'Bambu', + false, + false, + 24.00, + 1.000, + 1.000, + true +from mat +on conflict (filament_material_type_id, variant_display_name) do update + set color_name = excluded.color_name, + color_hex = excluded.color_hex, + finish_type = excluded.finish_type, + brand = excluded.brand, + is_matte = excluded.is_matte, + is_special = excluded.is_special, + cost_chf_per_kg = excluded.cost_chf_per_kg, + stock_spools = excluded.stock_spools, + spool_net_kg = excluded.spool_net_kg, + is_active = excluded.is_active; + +with mat as (select filament_material_type_id + from filament_material_type + where material_code = 'TPU') +insert +into filament_variant (filament_material_type_id, variant_display_name, color_name, color_hex, finish_type, brand, + is_matte, is_special, cost_chf_per_kg, stock_spools, spool_net_kg, is_active) +select mat.filament_material_type_id, + 'TPU Nero', + 'Nero', + '#1A1A1A', + 'GLOSSY', + 'Bambu', + false, + false, + 42.00, + 1.000, + 1.000, + true +from mat +on conflict (filament_material_type_id, variant_display_name) do update + set color_name = excluded.color_name, + color_hex = excluded.color_hex, + finish_type = excluded.finish_type, + brand = excluded.brand, + is_matte = excluded.is_matte, + is_special = excluded.is_special, + cost_chf_per_kg = excluded.cost_chf_per_kg, + stock_spools = excluded.stock_spools, + spool_net_kg = excluded.spool_net_kg, + is_active = excluded.is_active; + +with mat as (select filament_material_type_id + from filament_material_type + where material_code = 'PC') +insert +into filament_variant (filament_material_type_id, variant_display_name, color_name, color_hex, finish_type, brand, + is_matte, is_special, cost_chf_per_kg, stock_spools, spool_net_kg, is_active) +select mat.filament_material_type_id, + 'PC Naturale', + 'Naturale', + '#D9D9D9', + 'TRANSLUCENT', + 'Generic', + false, + true, + 48.00, + 1.000, + 1.000, + true +from mat +on conflict (filament_material_type_id, variant_display_name) do update + set color_name = excluded.color_name, + color_hex = excluded.color_hex, + finish_type = excluded.finish_type, + brand = excluded.brand, + is_matte = excluded.is_matte, + is_special = excluded.is_special, + cost_chf_per_kg = excluded.cost_chf_per_kg, + stock_spools = excluded.stock_spools, + spool_net_kg = excluded.spool_net_kg, + is_active = excluded.is_active; -- ========================================================= -- 5) Ugelli -- 0.4 standard (0 extra), 0.6 con attivazione 50 CHF -- ========================================================= -insert into nozzle_option ( - nozzle_diameter_mm, - owned_quantity, - extra_nozzle_change_fee_chf, - is_active -) values - (0.40, 1, 0.00, true), - (0.60, 1, 50.00, true) +insert into nozzle_option (nozzle_diameter_mm, + owned_quantity, + extra_nozzle_change_fee_chf, + is_active) +values (0.40, 1, 0.00, true), + (0.60, 1, 50.00, true) on conflict (nozzle_diameter_mm) do update - set - owned_quantity = excluded.owned_quantity, + set owned_quantity = excluded.owned_quantity, extra_nozzle_change_fee_chf = excluded.extra_nozzle_change_fee_chf, - is_active = excluded.is_active; + is_active = excluded.is_active; + +-- ========================================================= +-- 5b) Orca machine/material mapping (data-driven) +-- ========================================================= +with a1 as (select printer_machine_id + from printer_machine + where printer_display_name = 'BambuLab A1') +insert +into printer_machine_profile (printer_machine_id, nozzle_diameter_mm, orca_machine_profile_name, is_default, is_active) +select a1.printer_machine_id, v.nozzle_diameter_mm, v.profile_name, v.is_default, true +from a1 + cross join (values (0.40::numeric, 'Bambu Lab A1 0.4 nozzle', true), + (0.20::numeric, 'Bambu Lab A1 0.2 nozzle', false), + (0.60::numeric, 'Bambu Lab A1 0.6 nozzle', false), + (0.80::numeric, 'Bambu Lab A1 0.8 nozzle', false)) + as v(nozzle_diameter_mm, profile_name, is_default) +on conflict (printer_machine_id, nozzle_diameter_mm) do update + set orca_machine_profile_name = excluded.orca_machine_profile_name, + is_default = excluded.is_default, + is_active = excluded.is_active; + +with p as (select printer_machine_profile_id + from printer_machine_profile pmp + join printer_machine pm on pm.printer_machine_id = pmp.printer_machine_id + where pm.printer_display_name = 'BambuLab A1' + and pmp.nozzle_diameter_mm = 0.40::numeric), + m as (select filament_material_type_id, material_code + from filament_material_type + where material_code in ('PLA', 'PLA TOUGH', 'PETG', 'TPU', 'PC')) +insert +into material_orca_profile_map (printer_machine_profile_id, filament_material_type_id, orca_filament_profile_name, is_active) +select p.printer_machine_profile_id, + m.filament_material_type_id, + case m.material_code + when 'PLA' then 'Bambu PLA Basic @BBL A1' + when 'PLA TOUGH' then 'Bambu PLA Tough @BBL A1' + when 'PETG' then 'Bambu PETG Basic @BBL A1' + when 'TPU' then 'Bambu TPU 95A @BBL A1' + when 'PC' then 'Generic PC @BBL A1' + end, + true +from p + cross join m +on conflict (printer_machine_profile_id, filament_material_type_id) do update + set orca_filament_profile_name = excluded.orca_filament_profile_name, + is_active = excluded.is_active; -- ========================================================= -- 6) Layer heights (opzioni) -- ========================================================= -insert into layer_height_option ( - layer_height_mm, - time_multiplier, - is_active -) values - (0.080, 1.000, true), - (0.120, 1.000, true), - (0.160, 1.000, true), - (0.200, 1.000, true), - (0.240, 1.000, true), - (0.280, 1.000, true) +insert into layer_height_option (layer_height_mm, + time_multiplier, + is_active) +values (0.080, 1.000, true), + (0.120, 1.000, true), + (0.160, 1.000, true), + (0.200, 1.000, true), + (0.240, 1.000, true), + (0.280, 1.000, true) on conflict (layer_height_mm) do update - set - time_multiplier = excluded.time_multiplier, - is_active = excluded.is_active; + set time_multiplier = excluded.time_multiplier, + is_active = excluded.is_active; commit; - - -- Sostituisci __MULTIPLIER__ con il tuo valore (es. 1.10) update layer_height_option set time_multiplier = 0.1 where layer_height_mm = 0.080; + + +-- ========================= +-- CUSTOMERS (minimo indispensabile) +-- ========================= +CREATE TABLE IF NOT EXISTS customers +( + customer_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + customer_type text NOT NULL CHECK (customer_type IN ('PRIVATE', 'COMPANY')), + email text NOT NULL, + phone text, + + -- per PRIVATE + first_name text, + last_name text, + + -- per COMPANY + company_name text, + contact_person text, + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS ux_customers_email + ON customers (lower(email)); + +-- ========================= +-- QUOTE SESSIONS (carrello preventivo) +-- ========================= +CREATE TABLE IF NOT EXISTS quote_sessions +( + quote_session_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + status text NOT NULL CHECK (status IN ('ACTIVE', 'EXPIRED', 'CONVERTED')), + pricing_version text NOT NULL, + + -- Parametri "globali" (dalla tua UI avanzata) + material_code text NOT NULL, -- es: PLA, PETG... + nozzle_diameter_mm numeric(5, 2), -- es: 0.40 + layer_height_mm numeric(6, 3), -- es: 0.20 + infill_pattern text, -- es: grid + infill_percent integer CHECK (infill_percent BETWEEN 0 AND 100), + supports_enabled boolean NOT NULL DEFAULT false, + notes text, + + setup_cost_chf numeric(12, 2) NOT NULL DEFAULT 0.00, + + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + converted_order_id uuid +); + +CREATE INDEX IF NOT EXISTS ix_quote_sessions_status + ON quote_sessions (status); + +CREATE INDEX IF NOT EXISTS ix_quote_sessions_expires_at + ON quote_sessions (expires_at); + +-- ========================= +-- QUOTE LINE ITEMS (1 file = 1 riga) +-- ========================= +CREATE TABLE IF NOT EXISTS quote_line_items +( + quote_line_item_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + quote_session_id uuid NOT NULL REFERENCES quote_sessions (quote_session_id) ON DELETE CASCADE, + + status text NOT NULL CHECK (status IN ('CALCULATING', 'READY', 'FAILED')), + + original_filename text NOT NULL, + quantity integer NOT NULL DEFAULT 1 CHECK (quantity >= 1), + color_code text, -- es: white/black o codice interno + filament_variant_id bigint REFERENCES filament_variant (filament_variant_id), + + -- Output slicing / calcolo + bounding_box_x_mm numeric(10, 3), + bounding_box_y_mm numeric(10, 3), + bounding_box_z_mm numeric(10, 3), + print_time_seconds integer CHECK (print_time_seconds >= 0), + material_grams numeric(12, 2) CHECK (material_grams >= 0), + + unit_price_chf numeric(12, 2) CHECK (unit_price_chf >= 0), + pricing_breakdown jsonb, -- opzionale: costi dettagliati senza creare tabelle + + error_message text, + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_quote_line_items_session + ON quote_line_items (quote_session_id); + +-- Vista utile per totale quote +CREATE OR REPLACE VIEW quote_session_totals AS +SELECT qs.quote_session_id, + qs.setup_cost_chf + + COALESCE(SUM(qli.unit_price_chf * qli.quantity), 0.00) AS total_chf +FROM quote_sessions qs + LEFT JOIN quote_line_items qli + ON qli.quote_session_id = qs.quote_session_id + AND qli.status = 'READY' +GROUP BY qs.quote_session_id; + +-- ========================= +-- ORDERS +-- ========================= +CREATE TABLE IF NOT EXISTS orders +( + order_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + source_quote_session_id uuid REFERENCES quote_sessions (quote_session_id), + + status text NOT NULL CHECK (status IN ( + 'PENDING_PAYMENT', 'PAID', 'IN_PRODUCTION', + 'SHIPPED', 'COMPLETED', 'CANCELLED' + )), + + customer_id uuid REFERENCES customers (customer_id), + customer_email text NOT NULL, + customer_phone text, + preferred_language char(2) NOT NULL DEFAULT 'it', + + -- Snapshot indirizzo/fatturazione (evita tabella addresses e mantiene storico) + billing_customer_type text NOT NULL CHECK (billing_customer_type IN ('PRIVATE', 'COMPANY')), + billing_first_name text, + billing_last_name text, + billing_company_name text, + billing_contact_person text, + + billing_address_line1 text NOT NULL, + billing_address_line2 text, + billing_zip text NOT NULL, + billing_city text NOT NULL, + billing_country_code char(2) NOT NULL DEFAULT 'CH', + + shipping_same_as_billing boolean NOT NULL DEFAULT true, + shipping_first_name text, + shipping_last_name text, + shipping_company_name text, + shipping_contact_person text, + shipping_address_line1 text, + shipping_address_line2 text, + shipping_zip text, + shipping_city text, + shipping_country_code char(2), + + currency char(3) NOT NULL DEFAULT 'CHF', + setup_cost_chf numeric(12, 2) NOT NULL DEFAULT 0.00, + shipping_cost_chf numeric(12, 2) NOT NULL DEFAULT 0.00, + discount_chf numeric(12, 2) NOT NULL DEFAULT 0.00, + + subtotal_chf numeric(12, 2) NOT NULL DEFAULT 0.00, + total_chf numeric(12, 2) NOT NULL DEFAULT 0.00, + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + paid_at timestamptz +); + +CREATE INDEX IF NOT EXISTS ix_orders_status + ON orders (status); + +CREATE INDEX IF NOT EXISTS ix_orders_customer_email + ON orders (lower(customer_email)); + +-- ========================= +-- ORDER ITEMS (1 file 3D = 1 riga, file salvato su disco) +-- ========================= +CREATE TABLE IF NOT EXISTS order_items +( + order_item_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + order_id uuid NOT NULL REFERENCES orders (order_id) ON DELETE CASCADE, + + original_filename text NOT NULL, + stored_relative_path text NOT NULL, -- es: orders//3d-files//.stl + stored_filename text NOT NULL, -- es: .stl + + file_size_bytes bigint CHECK (file_size_bytes >= 0), + mime_type text, + sha256_hex text, -- opzionale, utile anche per dedup interno + + material_code text NOT NULL, + filament_variant_id bigint REFERENCES filament_variant (filament_variant_id), + color_code text, + quantity integer NOT NULL DEFAULT 1 CHECK (quantity >= 1), + + -- Snapshot output + print_time_seconds integer CHECK (print_time_seconds >= 0), + material_grams numeric(12, 2) CHECK (material_grams >= 0), + + bounding_box_x_mm numeric(10, 3), + bounding_box_y_mm numeric(10, 3), + bounding_box_z_mm numeric(10, 3), + + unit_price_chf numeric(12, 2) NOT NULL CHECK (unit_price_chf >= 0), + line_total_chf numeric(12, 2) NOT NULL CHECK (line_total_chf >= 0), + + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_order_items_order + ON order_items (order_id); + +-- ========================= +-- PAYMENTS (supporta più tentativi / metodi) +-- ========================= +CREATE TABLE IF NOT EXISTS payments +( + payment_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + order_id uuid NOT NULL REFERENCES orders (order_id) ON DELETE CASCADE, + + method text NOT NULL CHECK (method IN ('QR_BILL', 'TWINT', 'OTHER')), + status text NOT NULL CHECK (status IN ('PENDING', 'REPORTED', 'RECEIVED', 'FAILED', 'CANCELLED', 'REFUNDED')), + + currency char(3) NOT NULL DEFAULT 'CHF', + amount_chf numeric(12, 2) NOT NULL CHECK (amount_chf >= 0), + + -- riferimento pagamento (molto utile per QR bill / riconciliazione) + payment_reference text, + provider_transaction_id text, + + qr_payload text, -- se vuoi salvare contenuto QR/Swiss QR bill + initiated_at timestamptz NOT NULL DEFAULT now(), + reported_at timestamptz, + received_at timestamptz +); + +CREATE INDEX IF NOT EXISTS ix_payments_order + ON payments (order_id); + +CREATE INDEX IF NOT EXISTS ix_payments_reference + ON payments (payment_reference); + +-- ========================= +-- CUSTOM QUOTE REQUESTS (preventivo personalizzato, form che hai mostrato) +-- ========================= +CREATE TABLE IF NOT EXISTS custom_quote_requests +( + request_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + request_type text NOT NULL, -- es: "PREVENTIVO_PERSONALIZZATO" o come preferisci + + customer_type text NOT NULL CHECK (customer_type IN ('PRIVATE', 'COMPANY')), + email text NOT NULL, + phone text, + + -- PRIVATE + name text, + + -- COMPANY + company_name text, + contact_person text, + + message text NOT NULL, + status text NOT NULL CHECK (status IN ('NEW', 'PENDING', 'IN_PROGRESS', 'DONE', 'CLOSED')), + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_custom_quote_requests_status + ON custom_quote_requests (status); + +CREATE INDEX IF NOT EXISTS ix_custom_quote_requests_email + ON custom_quote_requests (lower(email)); + +-- Allegati della richiesta (max 15 come UI) +CREATE TABLE IF NOT EXISTS custom_quote_request_attachments +( + attachment_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + request_id uuid NOT NULL REFERENCES custom_quote_requests (request_id) ON DELETE CASCADE, + + original_filename text NOT NULL, + stored_relative_path text NOT NULL, -- es: quote-requests//attachments//.stl + stored_filename text NOT NULL, + + file_size_bytes bigint CHECK (file_size_bytes >= 0), + mime_type text, + sha256_hex text, + + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_custom_quote_attachments_request + ON custom_quote_request_attachments (request_id); diff --git a/deploy/envs/dev.env b/deploy/envs/dev.env index 0364437..8de2ed7 100644 --- a/deploy/envs/dev.env +++ b/deploy/envs/dev.env @@ -7,4 +7,10 @@ TAG=dev BACKEND_PORT=18002 FRONTEND_PORT=18082 - +CLAMAV_HOST=192.168.1.147 +CLAMAV_PORT=3310 +CLAMAV_ENABLED=true +APP_FRONTEND_BASE_URL=https://dev.3d-fab.ch +ADMIN_PASSWORD= +ADMIN_SESSION_SECRET= +ADMIN_SESSION_TTL_MINUTES=480 diff --git a/deploy/envs/int.env b/deploy/envs/int.env index 79f7a35..9f1118e 100644 --- a/deploy/envs/int.env +++ b/deploy/envs/int.env @@ -7,4 +7,10 @@ TAG=int BACKEND_PORT=18001 FRONTEND_PORT=18081 - +CLAMAV_HOST=192.168.1.147 +CLAMAV_PORT=3310 +CLAMAV_ENABLED=true +APP_FRONTEND_BASE_URL=https://int.3d-fab.ch +ADMIN_PASSWORD= +ADMIN_SESSION_SECRET= +ADMIN_SESSION_TTL_MINUTES=480 diff --git a/deploy/envs/prod.env b/deploy/envs/prod.env index 878558b..a85cf00 100644 --- a/deploy/envs/prod.env +++ b/deploy/envs/prod.env @@ -4,7 +4,13 @@ ENV=prod TAG=prod # Ports -BACKEND_PORT=8000 -FRONTEND_PORT=80 - +BACKEND_PORT=18000 +FRONTEND_PORT=18080 +CLAMAV_HOST=192.168.1.147 +CLAMAV_PORT=3310 +CLAMAV_ENABLED=true +APP_FRONTEND_BASE_URL=https://3d-fab.ch +ADMIN_PASSWORD= +ADMIN_SESSION_SECRET= +ADMIN_SESSION_TTL_MINUTES=480 diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 5adbe15..1a141fe 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: backend: # L'immagine usa il tag specificato nel file .env o passato da riga di comando @@ -7,18 +5,42 @@ services: container_name: print-calculator-backend-${ENV} ports: - "${BACKEND_PORT}:8000" - env_file: - - .env environment: + - SPRING_PROFILES_ACTIVE=${ENV} - DB_URL=${DB_URL} - DB_USERNAME=${DB_USERNAME} - DB_PASSWORD=${DB_PASSWORD} + - CLAMAV_HOST=${CLAMAV_HOST} + - CLAMAV_PORT=${CLAMAV_PORT} + - CLAMAV_ENABLED=${CLAMAV_ENABLED} + - MAIL_HOST=${MAIL_HOST:-mail.infomaniak.com} + - MAIL_PORT=${MAIL_PORT:-587} + - MAIL_USERNAME=${MAIL_USERNAME:-info@3d-fab.ch} + - MAIL_PASSWORD=${MAIL_PASSWORD:-} + - MAIL_SMTP_AUTH=${MAIL_SMTP_AUTH:-true} + - MAIL_SMTP_STARTTLS=${MAIL_SMTP_STARTTLS:-true} + - APP_MAIL_FROM=${APP_MAIL_FROM:-info@3d-fab.ch} + - APP_MAIL_ADMIN_ENABLED=${APP_MAIL_ADMIN_ENABLED:-true} + - APP_MAIL_ADMIN_ADDRESS=${APP_MAIL_ADMIN_ADDRESS:-info@3d-fab.ch} + - APP_FRONTEND_BASE_URL=${APP_FRONTEND_BASE_URL:-http://localhost:4200} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + - ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET} + - ADMIN_SESSION_TTL_MINUTES=${ADMIN_SESSION_TTL_MINUTES:-480} - TEMP_DIR=/app/temp - PROFILES_DIR=/app/profiles restart: always - volumes: + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + volumes: - backend_profiles_${ENV}:/app/profiles - + - /mnt/cache/appdata/print-calculator/${ENV}/storage_quotes:/app/storage_quotes + - /mnt/cache/appdata/print-calculator/${ENV}/storage_orders:/app/storage_orders + - /mnt/cache/appdata/print-calculator/${ENV}/storage_requests:/app/storage_requests + extra_hosts: + - "host.docker.internal:host-gateway" frontend: image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-frontend:${TAG} @@ -28,6 +50,11 @@ services: depends_on: - backend restart: always + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" volumes: backend_profiles_prod: diff --git a/docker-compose.yml b/docker-compose.yml index faf3593..83bc72e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,41 +1,4 @@ services: - backend: - platform: linux/amd64 - build: - context: ./backend - platforms: - - linux/amd64 - container_name: print-calculator-backend - ports: - - "8000:8000" - environment: - - DB_URL=jdbc:postgresql://db:5432/printcalc - - DB_USERNAME=printcalc - - DB_PASSWORD=printcalc_secret - - SPRING_PROFILES_ACTIVE=local - - FILAMENT_COST_PER_KG=22.0 - - MACHINE_COST_PER_HOUR=2.50 - - ENERGY_COST_PER_KWH=0.30 - - PRINTER_POWER_WATTS=150 - - MARKUP_PERCENT=20 - - TEMP_DIR=/app/temp - - PROFILES_DIR=/app/profiles - depends_on: - - db - restart: unless-stopped - - frontend: - build: - context: ./frontend - dockerfile: Dockerfile.dev - container_name: print-calculator-frontend - ports: - - "80:80" - depends_on: - - backend - - db - restart: unless-stopped - db: image: postgres:15-alpine container_name: print-calculator-db @@ -49,5 +12,16 @@ services: - postgres_data:/var/lib/postgresql/data restart: unless-stopped + clamav: + platform: linux/amd64 + image: clamav/clamav:latest + container_name: print-calculator-clamav + ports: + - "3310:3310" + volumes: + - clamav_db:/var/lib/clamav + restart: unless-stopped + volumes: postgres_data: + clamav_db: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e3aaedd..1a67380 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -57,13 +57,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.1902.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.19.tgz", - "integrity": "sha512-iexYDIYpGAeAU7T60bGcfrGwtq1bxpZixYxWuHYiaD1b5baQgNSfd1isGEOh37GgDNsf4In9i2LOLPm0wBdtgQ==", + "version": "0.1902.20", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1902.20.tgz", + "integrity": "sha512-tEM8PX9RTIvgEPJH/9nDaGlhbjZf9BBFS2FXKuOwKB+NFvfZuuDpPH7CzJKyyvkQLPtoNh2Y9C92m2f+RXsBmQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.19", + "@angular-devkit/core": "19.2.20", "rxjs": "7.8.1" }, "engines": { @@ -83,17 +83,17 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-19.2.19.tgz", - "integrity": "sha512-uIxi6Vzss6+ycljVhkyPUPWa20w8qxJL9lEn0h6+sX/fhM8Djt0FHIuTQjoX58EoMaQ/1jrXaRaGimkbaFcG9A==", + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-19.2.20.tgz", + "integrity": "sha512-m7J+k0lJEFvr6STGUQROx6TyoGn0WQsQiooO8WTkM8QUWKxSUmq4WImlPSq6y+thc+Jzx1EBw3yn73+phNIZag==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1902.19", - "@angular-devkit/build-webpack": "0.1902.19", - "@angular-devkit/core": "19.2.19", - "@angular/build": "19.2.19", + "@angular-devkit/architect": "0.1902.20", + "@angular-devkit/build-webpack": "0.1902.20", + "@angular-devkit/core": "19.2.20", + "@angular/build": "19.2.20", "@babel/core": "7.26.10", "@babel/generator": "7.26.10", "@babel/helper-annotate-as-pure": "7.25.9", @@ -104,7 +104,7 @@ "@babel/preset-env": "7.26.9", "@babel/runtime": "7.26.10", "@discoveryjs/json-ext": "0.6.3", - "@ngtools/webpack": "19.2.19", + "@ngtools/webpack": "19.2.20", "@vitejs/plugin-basic-ssl": "1.2.0", "ansi-colors": "4.1.3", "autoprefixer": "10.4.20", @@ -139,7 +139,7 @@ "terser": "5.39.0", "tree-kill": "1.2.2", "tslib": "2.8.1", - "webpack": "5.98.0", + "webpack": "5.105.0", "webpack-dev-middleware": "7.4.2", "webpack-dev-server": "5.2.2", "webpack-merge": "6.0.1", @@ -158,7 +158,7 @@ "@angular/localize": "^19.0.0 || ^19.2.0-next.0", "@angular/platform-server": "^19.0.0 || ^19.2.0-next.0", "@angular/service-worker": "^19.0.0 || ^19.2.0-next.0", - "@angular/ssr": "^19.2.19", + "@angular/ssr": "^19.2.20", "@web/test-runner": "^0.20.0", "browser-sync": "^3.0.2", "jest": "^29.5.0", @@ -219,13 +219,13 @@ } }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.1902.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1902.19.tgz", - "integrity": "sha512-x2tlGg5CsUveFzuRuqeHknSbGirSAoRynEh+KqPRGK0G3WpMViW/M8SuVurecasegfIrDWtYZ4FnVxKqNbKwXQ==", + "version": "0.1902.20", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1902.20.tgz", + "integrity": "sha512-T8RLKZOR0+l3FBMBTUQk83I/Dr5RpNPCOE6tWqGjAMRPKoL1m5BbqhkQ7ygnyd8/ZRz/x1RUVM08l0AeuzWUmA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1902.19", + "@angular-devkit/architect": "0.1902.20", "rxjs": "7.8.1" }, "engines": { @@ -249,9 +249,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.19.tgz", - "integrity": "sha512-JbLL+4IMLMBgjLZlnPG4lYDfz4zGrJ/s6Aoon321NJKuw1Kb1k5KpFu9dUY0BqLIe8xPQ2UJBpI+xXdK5MXMHQ==", + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.20.tgz", + "integrity": "sha512-4AAmHlv+H1/2Nmsp6QsX8YQxjC/v5QAzc+76He7K/x3iIuLCntQE2BYxonSZMiQ3M8gc/yxTfyZoPYjSDDvWMA==", "dev": true, "license": "MIT", "dependencies": { @@ -287,13 +287,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.19.tgz", - "integrity": "sha512-J4Jarr0SohdrHcb40gTL4wGPCQ952IMWF1G/MSAQfBAPvA9ZKApYhpxcY7PmehVePve+ujpus1dGsJ7dPxz8Kg==", + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.20.tgz", + "integrity": "sha512-o2eexF1fLZU93V3utiQLNgyNaGvFhDqpITNQcI1qzv2ZkvFHg9WZjFtZKtm805JAE/DND8oAJ1p+BoxU++Qg8g==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.19", + "@angular-devkit/core": "19.2.20", "jsonc-parser": "3.3.1", "magic-string": "0.30.17", "ora": "5.4.1", @@ -316,14 +316,14 @@ } }, "node_modules/@angular/build": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.2.19.tgz", - "integrity": "sha512-SFzQ1bRkNFiOVu+aaz+9INmts7tDUrsHLEr9HmARXr9qk5UmR8prlw39p2u+Bvi6/lCiJ18TZMQQl9mGyr63lg==", + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-19.2.20.tgz", + "integrity": "sha512-8bQ1afN8AJ6N9lJJgxYF08M0gp4R/4SIedSJfSLohscgHumYJ1mITEygoB1JK5O9CEKlr4YyLYfgay8xr92wbQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1902.19", + "@angular-devkit/architect": "0.1902.20", "@babel/core": "7.26.10", "@babel/helper-annotate-as-pure": "7.25.9", "@babel/helper-split-export-declaration": "7.24.7", @@ -363,7 +363,7 @@ "@angular/localize": "^19.0.0 || ^19.2.0-next.0", "@angular/platform-server": "^19.0.0 || ^19.2.0-next.0", "@angular/service-worker": "^19.0.0 || ^19.2.0-next.0", - "@angular/ssr": "^19.2.19", + "@angular/ssr": "^19.2.20", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^19.0.0 || ^19.2.0-next.0", @@ -417,18 +417,18 @@ } }, "node_modules/@angular/cli": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.19.tgz", - "integrity": "sha512-e9tAzFNOL4mMWfMnpC9Up83OCTOp2siIj8W41FCp8jfoEnw79AXDDLh3d70kOayiObchksTJVShslTogLUyhMw==", + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-19.2.20.tgz", + "integrity": "sha512-3vw49xDGqOi63FES/6D+Lw0Sl42FSZKowUxBMY0CnXD8L93Qwvcf4ASFmUoNJRSTOJuuife1+55vY62cpOWBdg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1902.19", - "@angular-devkit/core": "19.2.19", - "@angular-devkit/schematics": "19.2.19", + "@angular-devkit/architect": "0.1902.20", + "@angular-devkit/core": "19.2.20", + "@angular-devkit/schematics": "19.2.20", "@inquirer/prompts": "7.3.2", "@listr2/prompt-adapter-inquirer": "2.0.18", - "@schematics/angular": "19.2.19", + "@schematics/angular": "19.2.20", "@yarnpkg/lockfile": "1.1.0", "ini": "5.0.0", "jsonc-parser": "3.3.1", @@ -3341,9 +3341,9 @@ } }, "node_modules/@jsonjoy.com/buffers": { - "version": "17.65.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.65.0.tgz", - "integrity": "sha512-eBrIXd0/Ld3p9lpDDlMaMn6IEfWqtHMD+z61u0JrIiPzsV1r7m6xDZFRxJyvIFTEO+SWdYF9EiQbXZGd8BzPfA==", + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", + "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3549,9 +3549,9 @@ } }, "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/base64": { - "version": "17.65.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.65.0.tgz", - "integrity": "sha512-Xrh7Fm/M0QAYpekSgmskdZYnFdSGnsxJ/tHaolA4bNwWdG9i65S8m83Meh7FOxyJyQAdo4d4J97NOomBLEfkDQ==", + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", + "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3566,9 +3566,9 @@ } }, "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/codegen": { - "version": "17.65.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.65.0.tgz", - "integrity": "sha512-7MXcRYe7n3BG+fo3jicvjB0+6ypl2Y/bQp79Sp7KeSiiCgLqw4Oled6chVv07/xLVTdo3qa1CD0VCCnPaw+RGA==", + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", + "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3583,17 +3583,17 @@ } }, "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { - "version": "17.65.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.65.0.tgz", - "integrity": "sha512-e0SG/6qUCnVhHa0rjDJHgnXnbsacooHVqQHxspjvlYQSkHm+66wkHw6Gql+3u/WxI/b1VsOdUi0M+fOtkgKGdQ==", + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", + "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/base64": "17.65.0", - "@jsonjoy.com/buffers": "17.65.0", - "@jsonjoy.com/codegen": "17.65.0", - "@jsonjoy.com/json-pointer": "17.65.0", - "@jsonjoy.com/util": "17.65.0", + "@jsonjoy.com/base64": "17.67.0", + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0", + "@jsonjoy.com/json-pointer": "17.67.0", + "@jsonjoy.com/util": "17.67.0", "hyperdyperid": "^1.2.0", "thingies": "^2.5.0", "tree-dump": "^1.1.0" @@ -3610,13 +3610,13 @@ } }, "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pointer": { - "version": "17.65.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.65.0.tgz", - "integrity": "sha512-uhTe+XhlIZpWOxgPcnO+iSCDgKKBpwkDVTyYiXX9VayGV8HSFVJM67M6pUE71zdnXF1W0Da21AvnhlmdwYPpow==", + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", + "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/util": "17.65.0" + "@jsonjoy.com/util": "17.67.0" }, "engines": { "node": ">=10.0" @@ -3630,14 +3630,14 @@ } }, "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { - "version": "17.65.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.65.0.tgz", - "integrity": "sha512-cWiEHZccQORf96q2y6zU3wDeIVPeidmGqd9cNKJRYoVHTV0S1eHPy5JTbHpMnGfDvtvujQwQozOqgO9ABu6h0w==", + "version": "17.67.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", + "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/buffers": "17.65.0", - "@jsonjoy.com/codegen": "17.65.0" + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0" }, "engines": { "node": ">=10.0" @@ -4291,9 +4291,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.2.19.tgz", - "integrity": "sha512-R9aeTrOBiRVl8I698JWPniUAAEpSvzc8SUGWSM5UXWMcHnWqd92cOnJJ1aXDGJZKXrbhMhCBx9Dglmcks5IDpg==", + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-19.2.20.tgz", + "integrity": "sha512-nuCjcxLmFrn0s53G67V5R19mUpYjewZBLz6Wrg7BtJkjq08xfO0QgaJg3e6wzEmj1AclH7eMKRnuQhm5otyutg==", "dev": true, "license": "MIT", "engines": { @@ -4489,9 +4489,9 @@ } }, "node_modules/@npmcli/package-json": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-6.1.1.tgz", - "integrity": "sha512-d5qimadRAUCO4A/Txw71VM7UrRZzV+NPclxz/dc+M6B2oYwjWTjqh8HA/sGQgs9VZuJ6I/P7XIAlJvgrl27ZOw==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-6.2.0.tgz", + "integrity": "sha512-rCNLSB/JzNvot0SEyXqWZ7tX2B5dD2a1br2Dp0vSYVo5jh8Z0EZ7lS9TsZ1UtziddB1UfNUaMCc538/HztnJGA==", "dev": true, "license": "ISC", "dependencies": { @@ -5131,9 +5131,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz", - "integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", "cpu": [ "loong64" ], @@ -5145,9 +5145,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz", - "integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", "cpu": [ "loong64" ], @@ -5187,9 +5187,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz", - "integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", "cpu": [ "ppc64" ], @@ -5201,9 +5201,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz", - "integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", "cpu": [ "ppc64" ], @@ -5229,9 +5229,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz", - "integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", "cpu": [ "riscv64" ], @@ -5285,9 +5285,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz", - "integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", "cpu": [ "x64" ], @@ -5299,9 +5299,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz", - "integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", "cpu": [ "arm64" ], @@ -5341,9 +5341,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz", - "integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", "cpu": [ "x64" ], @@ -5369,14 +5369,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "19.2.19", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.2.19.tgz", - "integrity": "sha512-6/0pvbPCY4UHeB4lnM/5r250QX5gcLgOYbR5FdhFu+22mOPHfWpRc5tNuY9kCephDHzAHjo6fTW1vefOOmA4jw==", + "version": "19.2.20", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.2.20.tgz", + "integrity": "sha512-xDrYxZvk9dGA2eVqufqLYmVSMSXxVtv30pBHGGU/2xr4QzHzdmMHflk4It8eh4WMNLhn7kqnzMREwtNI3eW/Gw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "19.2.19", - "@angular-devkit/schematics": "19.2.19", + "@angular-devkit/core": "19.2.20", + "@angular-devkit/schematics": "19.2.20", "jsonc-parser": "3.3.1" }, "engines": { @@ -6053,9 +6053,9 @@ } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -6065,6 +6065,19 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/adjust-sourcemap-loader": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", @@ -6407,6 +6420,19 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", @@ -6554,9 +6580,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", - "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -6574,10 +6600,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001716", - "electron-to-chromium": "^1.5.149", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -6764,9 +6791,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001718", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", - "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", "dev": true, "funding": [ { @@ -7469,9 +7496,9 @@ } }, "node_modules/default-browser": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", - "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", "dev": true, "license": "MIT", "dependencies": { @@ -7685,9 +7712,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.155", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz", - "integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==", + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", "dev": true, "license": "ISC" }, @@ -7793,14 +7820,14 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" @@ -7910,9 +7937,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -9414,9 +9441,9 @@ "license": "MIT" }, "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "dev": true, "license": "MIT", "dependencies": { @@ -10046,9 +10073,9 @@ } }, "node_modules/launch-editor": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.12.0.tgz", - "integrity": "sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.0.tgz", + "integrity": "sha512-u+9asUHMJ99lA15VRMXw5XKfySFR9dGXwgsgS14YTbUq3GITP58mIM32At90P5fZ+MUId5Yw+IwI/yKub7jnCQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10270,13 +10297,17 @@ } }, "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "dev": true, "license": "MIT", "engines": { "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/loader-utils": { @@ -11070,9 +11101,9 @@ } }, "node_modules/node-gyp": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.2.0.tgz", - "integrity": "sha512-T0S1zqskVUSxcsSTkAsLc7xCycrRYmtDHadDinzocrThjyQCn5kMlEBSj6H4qDbgsIOSLmmlRIeb0lZXj+UArA==", + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", + "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11137,9 +11168,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT" }, @@ -12090,9 +12121,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -12647,9 +12678,9 @@ "optional": true }, "node_modules/schema-utils": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", - "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", "dependencies": { @@ -13602,13 +13633,17 @@ } }, "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tar": { @@ -13658,9 +13693,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -13806,15 +13841,15 @@ "license": "0BSD" }, "node_modules/tuf-js": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.0.1.tgz", - "integrity": "sha512-+68OP1ZzSF84rTckf3FA95vJ1Zlx/uaXyiiKyPd1pA4rZNkpEvDAKmsu1xUSmbF/chCRYgZ6UZkDwC7PmzmAyA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-3.1.0.tgz", + "integrity": "sha512-3T3T04WzowbwV2FDiGXBbr81t64g1MUGGJRgT4x5o97N+8ArdhVCAF9IxFrxuSJmM3E5Asn7nKHkao0ibcZXAg==", "dev": true, "license": "MIT", "dependencies": { "@tufjs/models": "3.0.1", - "debug": "^4.3.6", - "make-fetch-happen": "^14.0.1" + "debug": "^4.4.1", + "make-fetch-happen": "^14.0.3" }, "engines": { "node": "^18.17.0 || >=20.5.0" @@ -14006,9 +14041,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -14170,9 +14205,9 @@ } }, "node_modules/vite/node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz", - "integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", "cpu": [ "arm" ], @@ -14184,9 +14219,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz", - "integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", "cpu": [ "arm64" ], @@ -14198,9 +14233,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz", - "integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", "cpu": [ "arm64" ], @@ -14212,9 +14247,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz", - "integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", "cpu": [ "x64" ], @@ -14226,9 +14261,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz", - "integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", "cpu": [ "arm64" ], @@ -14240,9 +14275,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz", - "integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", "cpu": [ "x64" ], @@ -14254,9 +14289,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz", - "integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", "cpu": [ "arm" ], @@ -14268,9 +14303,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz", - "integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", "cpu": [ "arm" ], @@ -14282,9 +14317,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz", - "integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", "cpu": [ "arm64" ], @@ -14296,9 +14331,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz", - "integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", "cpu": [ "arm64" ], @@ -14310,9 +14345,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz", - "integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", "cpu": [ "riscv64" ], @@ -14324,9 +14359,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz", - "integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", "cpu": [ "s390x" ], @@ -14338,9 +14373,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz", - "integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "cpu": [ "x64" ], @@ -14352,9 +14387,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz", - "integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", "cpu": [ "x64" ], @@ -14366,9 +14401,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz", - "integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", "cpu": [ "arm64" ], @@ -14380,9 +14415,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz", - "integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", "cpu": [ "ia32" ], @@ -14394,9 +14429,9 @@ ] }, "node_modules/vite/node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz", - "integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", "cpu": [ "x64" ], @@ -14444,9 +14479,9 @@ } }, "node_modules/vite/node_modules/rollup": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", - "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "dev": true, "license": "MIT", "dependencies": { @@ -14460,31 +14495,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.0", - "@rollup/rollup-android-arm64": "4.57.0", - "@rollup/rollup-darwin-arm64": "4.57.0", - "@rollup/rollup-darwin-x64": "4.57.0", - "@rollup/rollup-freebsd-arm64": "4.57.0", - "@rollup/rollup-freebsd-x64": "4.57.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", - "@rollup/rollup-linux-arm-musleabihf": "4.57.0", - "@rollup/rollup-linux-arm64-gnu": "4.57.0", - "@rollup/rollup-linux-arm64-musl": "4.57.0", - "@rollup/rollup-linux-loong64-gnu": "4.57.0", - "@rollup/rollup-linux-loong64-musl": "4.57.0", - "@rollup/rollup-linux-ppc64-gnu": "4.57.0", - "@rollup/rollup-linux-ppc64-musl": "4.57.0", - "@rollup/rollup-linux-riscv64-gnu": "4.57.0", - "@rollup/rollup-linux-riscv64-musl": "4.57.0", - "@rollup/rollup-linux-s390x-gnu": "4.57.0", - "@rollup/rollup-linux-x64-gnu": "4.57.0", - "@rollup/rollup-linux-x64-musl": "4.57.0", - "@rollup/rollup-openbsd-x64": "4.57.0", - "@rollup/rollup-openharmony-arm64": "4.57.0", - "@rollup/rollup-win32-arm64-msvc": "4.57.0", - "@rollup/rollup-win32-ia32-msvc": "4.57.0", - "@rollup/rollup-win32-x64-gnu": "4.57.0", - "@rollup/rollup-win32-x64-msvc": "4.57.0", + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" } }, @@ -14541,35 +14576,37 @@ "optional": true }, "node_modules/webpack": { - "version": "5.98.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz", - "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", + "version": "5.105.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", + "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.14.0", - "browserslist": "^4.24.0", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.19.0", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^4.3.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" @@ -14802,9 +14839,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", "dev": true, "license": "MIT", "engines": { @@ -14833,6 +14870,13 @@ } } }, + "node_modules/webpack/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/webpack/node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -14840,6 +14884,20 @@ "dev": true, "license": "MIT" }, + "node_modules/webpack/node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index e17af19..e0733e9 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,15 +1,25 @@ import { ApplicationConfig, provideZoneChangeDetection, importProvidersFrom } from '@angular/core'; -import { provideRouter, withComponentInputBinding, withViewTransitions } from '@angular/router'; +import { provideRouter, withComponentInputBinding, withInMemoryScrolling, withViewTransitions } from '@angular/router'; import { routes } from './app.routes'; -import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; import { provideTranslateHttpLoader, TranslateHttpLoader } from '@ngx-translate/http-loader'; +import { adminAuthInterceptor } from './core/interceptors/admin-auth.interceptor'; export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), - provideRouter(routes, withComponentInputBinding(), withViewTransitions()), - provideHttpClient(), + provideRouter( + routes, + withComponentInputBinding(), + withViewTransitions(), + withInMemoryScrolling({ + scrollPositionRestoration: 'top' + }) + ), + provideHttpClient( + withInterceptors([adminAuthInterceptor]) + ), provideTranslateHttpLoader({ prefix: './assets/i18n/', suffix: '.json' @@ -24,4 +34,4 @@ export const appConfig: ApplicationConfig = { }) ) ] -}; \ No newline at end of file +}; diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 183f658..d79b55f 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -1,34 +1,65 @@ import { Routes } from '@angular/router'; +const appChildRoutes: Routes = [ + { + path: '', + loadComponent: () => import('./features/home/home.component').then(m => m.HomeComponent) + }, + { + path: 'calculator', + loadChildren: () => import('./features/calculator/calculator.routes').then(m => m.CALCULATOR_ROUTES) + }, + { + path: 'shop', + loadChildren: () => import('./features/shop/shop.routes').then(m => m.SHOP_ROUTES) + }, + { + path: 'about', + loadChildren: () => import('./features/about/about.routes').then(m => m.ABOUT_ROUTES) + }, + { + path: 'contact', + loadChildren: () => import('./features/contact/contact.routes').then(m => m.CONTACT_ROUTES) + }, + { + path: 'checkout', + loadComponent: () => import('./features/checkout/checkout.component').then(m => m.CheckoutComponent) + }, + { + path: 'order/:orderId', + loadComponent: () => import('./features/order/order.component').then(m => m.OrderComponent) + }, + { + path: 'co/:orderId', + loadComponent: () => import('./features/order/order.component').then(m => m.OrderComponent) + }, + { + path: '', + loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES) + }, + { + path: 'admin', + loadChildren: () => import('./features/admin/admin.routes').then(m => m.ADMIN_ROUTES) + }, + { + path: '**', + redirectTo: '' + } +]; + export const routes: Routes = [ + { + path: ':lang', + loadComponent: () => import('./core/layout/layout.component').then(m => m.LayoutComponent), + children: appChildRoutes + }, { path: '', loadComponent: () => import('./core/layout/layout.component').then(m => m.LayoutComponent), - children: [ - { - path: '', - loadComponent: () => import('./features/home/home.component').then(m => m.HomeComponent) - }, - { - path: 'calculator', - loadChildren: () => import('./features/calculator/calculator.routes').then(m => m.CALCULATOR_ROUTES) - }, - { - path: 'shop', - loadChildren: () => import('./features/shop/shop.routes').then(m => m.SHOP_ROUTES) - }, - { - path: 'about', - loadChildren: () => import('./features/about/about.routes').then(m => m.ABOUT_ROUTES) - }, - { - path: 'contact', - loadChildren: () => import('./features/contact/contact.routes').then(m => m.CONTACT_ROUTES) - }, - { - path: '', - loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES) - } - ] + children: appChildRoutes + }, + { + path: '**', + redirectTo: '' } ]; diff --git a/frontend/src/app/core/constants/colors.const.ts b/frontend/src/app/core/constants/colors.const.ts index 66b1336..e4f6105 100644 --- a/frontend/src/app/core/constants/colors.const.ts +++ b/frontend/src/app/core/constants/colors.const.ts @@ -2,6 +2,7 @@ export interface ColorOption { label: string; value: string; hex: string; + variantId?: number; outOfStock?: boolean; } @@ -12,22 +13,22 @@ export interface ColorCategory { export const PRODUCT_COLORS: ColorCategory[] = [ { - name: 'Lucidi', // Glossy + name: 'COLOR.CATEGORY_GLOSSY', colors: [ - { label: 'Black', value: 'Black', hex: '#1a1a1a' }, // Not pure black for visibility - { label: 'White', value: 'White', hex: '#f5f5f5' }, - { label: 'Red', value: 'Red', hex: '#d32f2f', outOfStock: true }, - { label: 'Blue', value: 'Blue', hex: '#1976d2' }, - { label: 'Green', value: 'Green', hex: '#388e3c' }, - { label: 'Yellow', value: 'Yellow', hex: '#fbc02d' } + { 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.BLUE', value: 'Blue', hex: '#1976d2' }, + { label: 'COLOR.NAME.GREEN', value: 'Green', hex: '#388e3c' }, + { label: 'COLOR.NAME.YELLOW', value: 'Yellow', hex: '#fbc02d' } ] }, { - name: 'Opachi', // Matte + name: 'COLOR.CATEGORY_MATTE', colors: [ - { label: 'Matte Black', value: 'Matte Black', hex: '#2c2c2c' }, // Lighter charcoal for matte - { label: 'Matte White', value: 'Matte White', hex: '#e0e0e0' }, - { label: 'Matte Gray', value: 'Matte Gray', hex: '#757575' } + { 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' } ] } ]; diff --git a/frontend/src/app/core/interceptors/admin-auth.interceptor.ts b/frontend/src/app/core/interceptors/admin-auth.interceptor.ts new file mode 100644 index 0000000..156f6b3 --- /dev/null +++ b/frontend/src/app/core/interceptors/admin-auth.interceptor.ts @@ -0,0 +1,37 @@ +import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { catchError, throwError } from 'rxjs'; + +const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']); + +function resolveLangFromUrl(url: string): string { + const cleanUrl = (url || '').split('?')[0].split('#')[0]; + const segments = cleanUrl.split('/').filter(Boolean); + if (segments.length > 0 && SUPPORTED_LANGS.has(segments[0])) { + return segments[0]; + } + return 'it'; +} + +export const adminAuthInterceptor: HttpInterceptorFn = (req, next) => { + if (!req.url.includes('/api/admin/')) { + return next(req); + } + + const router = inject(Router); + const request = req.clone({ withCredentials: true }); + const isLoginRequest = request.url.includes('/api/admin/auth/login'); + + return next(request).pipe( + catchError((error: unknown) => { + 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/navbar.component.html b/frontend/src/app/core/layout/navbar.component.html index 189ff6f..a53b967 100644 --- a/frontend/src/app/core/layout/navbar.component.html +++ b/frontend/src/app/core/layout/navbar.component.html @@ -17,9 +17,15 @@
- +
diff --git a/frontend/src/app/core/layout/navbar.component.scss b/frontend/src/app/core/layout/navbar.component.scss index f132074..6b0da84 100644 --- a/frontend/src/app/core/layout/navbar.component.scss +++ b/frontend/src/app/core/layout/navbar.component.scss @@ -44,15 +44,29 @@ } .lang-switch { - background: none; + background-color: var(--color-bg-card); border: 1px solid var(--color-border); border-radius: var(--radius-sm); - padding: 2px 6px; + 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 { diff --git a/frontend/src/app/core/layout/navbar.component.ts b/frontend/src/app/core/layout/navbar.component.ts index a2e548f..0cfe61a 100644 --- a/frontend/src/app/core/layout/navbar.component.ts +++ b/frontend/src/app/core/layout/navbar.component.ts @@ -2,7 +2,6 @@ import { Component } from '@angular/core'; import { RouterLink, RouterLinkActive } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { LanguageService } from '../services/language.service'; -import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; @Component({ selector: 'app-navbar', @@ -13,12 +12,19 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto }) export class NavbarComponent { isMenuOpen = false; + 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' } + ]; constructor(public langService: LanguageService) {} - toggleLang() { - const newLang = this.langService.currentLang() === 'it' ? 'en' : 'it'; - this.langService.switchLang(newLang); + onLanguageChange(event: Event): void { + const select = event.target as HTMLSelectElement; + const lang = select.value as 'it' | 'en' | 'de' | 'fr'; + this.langService.switchLang(lang); } toggleMenu() { diff --git a/frontend/src/app/core/services/language.service.ts b/frontend/src/app/core/services/language.service.ts index ead4dc0..f431344 100644 --- a/frontend/src/app/core/services/language.service.ts +++ b/frontend/src/app/core/services/language.service.ts @@ -1,20 +1,139 @@ import { Injectable, signal } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; +import { NavigationEnd, PRIMARY_OUTLET, Router, UrlTree } from '@angular/router'; @Injectable({ providedIn: 'root' }) export class LanguageService { - currentLang = signal('it'); + currentLang = signal<'it' | 'en' | 'de' | 'fr'>('it'); + private readonly supportedLangs: Array<'it' | 'en' | 'de' | 'fr'> = ['it', 'en', 'de', 'fr']; - constructor(private translate: TranslateService) { - this.translate.addLangs(['it', 'en']); + constructor( + private translate: TranslateService, + private router: Router + ) { + this.translate.addLangs(this.supportedLangs); this.translate.setDefaultLang('it'); - this.translate.use('it'); + 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); + } + }); + + const initialTree = this.router.parseUrl(this.router.url); + const initialSegments = this.getPrimarySegments(initialTree); + const queryLang = this.getQueryLang(initialTree); + const initialLang = this.isSupportedLang(initialSegments[0]) + ? initialSegments[0] + : (this.isSupportedLang(queryLang) ? queryLang : 'it'); + this.applyLanguage(initialLang); + this.ensureLanguageInPath(initialTree); + + this.router.events.subscribe(event => { + if (!(event instanceof NavigationEnd)) { + return; + } + + this.ensureLanguageInPath(this.router.parseUrl(this.router.url)); + }); } - switchLang(lang: string) { + switchLang(lang: 'it' | 'en' | 'de' | 'fr') { + if (!this.isSupportedLang(lang)) { + return; + } + this.applyLanguage(lang); + + const currentTree = this.router.parseUrl(this.router.url); + const segments = this.getPrimarySegments(currentTree); + + let targetSegments: string[]; + if (segments.length === 0) { + targetSegments = [lang]; + } else if (this.isSupportedLang(segments[0]) || this.looksLikeLangToken(segments[0])) { + targetSegments = [lang, ...segments.slice(1)]; + } else { + targetSegments = [lang, ...segments]; + } + + this.navigateIfChanged(currentTree, targetSegments); + } + + selectedLang(): 'it' | 'en' | 'de' | 'fr' { + const activeLang = typeof this.translate.currentLang === 'string' + ? this.translate.currentLang.toLowerCase() + : null; + return this.isSupportedLang(activeLang) ? activeLang : this.currentLang(); + } + + private ensureLanguageInPath(urlTree: UrlTree): void { + const segments = this.getPrimarySegments(urlTree); + + if (segments.length > 0 && this.isSupportedLang(segments[0])) { + this.applyLanguage(segments[0]); + return; + } + + const queryLang = this.getQueryLang(urlTree); + const activeLang = this.isSupportedLang(queryLang) ? queryLang : this.currentLang(); + if (activeLang !== this.currentLang()) { + this.applyLanguage(activeLang); + } + let targetSegments: string[]; + + if (segments.length === 0) { + targetSegments = [activeLang]; + } else if (this.looksLikeLangToken(segments[0])) { + targetSegments = [activeLang, ...segments.slice(1)]; + } else { + targetSegments = [activeLang, ...segments]; + } + + this.navigateIfChanged(urlTree, targetSegments); + } + + private getPrimarySegments(urlTree: UrlTree): string[] { + const primaryGroup = urlTree.root.children[PRIMARY_OUTLET]; + if (!primaryGroup) { + return []; + } + return primaryGroup.segments.map(segment => segment.path.toLowerCase()); + } + + private getQueryLang(urlTree: UrlTree): string | null { + const lang = urlTree.queryParams['lang']; + 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 looksLikeLangToken(segment: string | null | undefined): boolean { + return typeof segment === 'string' && /^[a-z]{2}(?:-[a-z]{2})?$/i.test(segment); + } + + private applyLanguage(lang: 'it' | 'en' | 'de' | 'fr'): void { + if (this.currentLang() === lang && this.translate.currentLang === lang) { + return; + } this.translate.use(lang); this.currentLang.set(lang); } + + private navigateIfChanged(currentTree: UrlTree, targetSegments: string[]): void { + const { lang: _unusedLang, ...queryParams } = currentTree.queryParams; + const targetTree = this.router.createUrlTree(['/', ...targetSegments], { + queryParams, + fragment: currentTree.fragment ?? undefined + }); + + if (this.router.serializeUrl(targetTree) === this.router.serializeUrl(currentTree)) { + return; + } + + this.router.navigateByUrl(targetTree, { replaceUrl: true }); + } } diff --git a/frontend/src/app/core/services/quote-request.service.ts b/frontend/src/app/core/services/quote-request.service.ts new file mode 100644 index 0000000..21eddd3 --- /dev/null +++ b/frontend/src/app/core/services/quote-request.service.ts @@ -0,0 +1,42 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../../environments/environment'; + +export interface QuoteRequestDto { + requestType: string; + customerType: string; + email: string; + phone?: string; + name?: string; + companyName?: string; + contactPerson?: string; + message: string; + acceptTerms: boolean; + acceptPrivacy: boolean; +} + +@Injectable({ + providedIn: 'root' +}) +export class QuoteRequestService { + private http = inject(HttpClient); + private apiUrl = `${environment.apiUrl}/api/custom-quote-requests`; + + 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' + }); + formData.append('request', requestBlob); + + // Append Files + files.forEach(file => { + formData.append('files', file); + }); + + return this.http.post(this.apiUrl, formData); + } +} diff --git a/frontend/src/app/features/about/about-page.component.html b/frontend/src/app/features/about/about-page.component.html index 9dd3f12..5672ff1 100644 --- a/frontend/src/app/features/about/about-page.component.html +++ b/frontend/src/app/features/about/about-page.component.html @@ -1,39 +1,73 @@
- +

{{ 'ABOUT.EYEBROW' | translate }}

{{ 'ABOUT.TITLE' | translate }}

{{ 'ABOUT.SUBTITLE' | translate }}

- +

{{ 'ABOUT.HOW_TEXT' | translate }}

+
+

{{ 'ABOUT.PASSIONS_TITLE' | translate }}

- {{ 'ABOUT.PILL_1' | translate }} - {{ 'ABOUT.PILL_2' | translate }} - {{ 'ABOUT.PILL_3' | translate }} - {{ 'ABOUT.SERVICE_1' | translate }} - {{ 'ABOUT.SERVICE_2' | translate }} + @for (passion of passions; track passion.id) { + + {{ passion.labelKey | translate }} + + }
-
-
+
+
+ +
- Member 1 - Founder + {{ 'ABOUT.MEMBER_JOE_NAME' | translate }} + {{ 'ABOUT.MEMBER_JOE_ROLE' | translate }}
-
-
+
+
+ +
- Member 2 - Co-Founder + {{ '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 1a926a5..ea63580 100644 --- a/frontend/src/app/features/about/about-page.component.scss +++ b/frontend/src/app/features/about/about-page.component.scss @@ -29,7 +29,7 @@ text-transform: uppercase; letter-spacing: 0.15em; font-size: 0.875rem; - color: var(--color-primary-500); + color: var(--color-secondary-600); font-weight: 700; margin-bottom: var(--space-2); display: block; @@ -69,7 +69,16 @@ h1 { font-size: 1.1rem; line-height: 1.7; color: var(--color-text-main); - margin-bottom: var(--space-8); + margin-bottom: var(--space-4); + white-space: pre-line; +} + +.passions-title { + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-secondary-600); + margin-bottom: var(--space-4); } .tags-container { @@ -92,13 +101,14 @@ h1 { font-weight: 500; font-size: 0.9rem; box-shadow: var(--shadow-sm); - transition: all 0.2s ease; + transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease; } -.tag:hover { - transform: translateY(-2px); - border-color: var(--color-primary-500); - color: var(--color-primary-500); +.tag.is-active { + background: var(--color-primary-500); + border-color: var(--color-primary-600); + color: var(--color-neutral-900); + font-weight: 600; box-shadow: var(--shadow-md); } @@ -119,13 +129,37 @@ h1 { } .photo-card { - background: var(--color-surface-card); + background: #ffffff; padding: 1rem; border-radius: var(--radius-lg); - box-shadow: var(--shadow-lg); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-sm); 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; + cursor: pointer; + outline: 2px solid transparent; + outline-offset: 2px; +} + +.photo-card:hover { + transform: translateY(-3px); + box-shadow: var(--shadow-md); + border-color: var(--color-neutral-300); +} + +.photo-card:focus-visible { + outline-color: var(--color-primary-600); +} + +.photo-card.is-active { + border-color: var(--color-primary-600); + box-shadow: 0 0 0 3px rgb(250 207 10 / 30%), var(--shadow-md); +} + +.photo-card.is-selected { + border-color: var(--color-primary-700); } .placeholder-img { @@ -134,6 +168,15 @@ h1 { 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); + overflow: hidden; +} + +.placeholder-img img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; } .member-info { diff --git a/frontend/src/app/features/about/about-page.component.ts b/frontend/src/app/features/about/about-page.component.ts index dcb93f8..15f22da 100644 --- a/frontend/src/app/features/about/about-page.component.ts +++ b/frontend/src/app/features/about/about-page.component.ts @@ -2,6 +2,27 @@ import { Component } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; import { AppLocationsComponent } from '../../shared/components/app-locations/app-locations.component'; +type MemberId = 'joe' | 'matteo'; +type PassionId = + | 'bike-trial' + | 'mountain' + | 'ski' + | 'self-hosting' + | 'print-3d' + | 'travel' + | 'snowboard' + | 'snowboard-instructor' + | 'electronics' + | 'woodworking' + | 'van-life' + | 'coffee' + | 'software-development'; + +interface PassionChip { + id: PassionId; + labelKey: string; +} + @Component({ selector: 'app-about-page', standalone: true, @@ -9,5 +30,71 @@ import { AppLocationsComponent } from '../../shared/components/app-locations/app templateUrl: './about-page.component.html', styleUrl: './about-page.component.scss' }) -export class AboutPageComponent {} +export class AboutPageComponent { + selectedMember: MemberId | null = null; + hoveredMember: MemberId | null = null; + readonly passions: ReadonlyArray = [ + { id: 'mountain', labelKey: 'ABOUT.PASSION_MOUNTAIN' }, + { id: 'coffee', labelKey: 'ABOUT.PASSION_COFFEE' }, + { id: 'bike-trial', labelKey: 'ABOUT.PASSION_BIKE_TRIAL' }, + { id: 'electronics', labelKey: 'ABOUT.PASSION_ELECTRONICS' }, + { id: 'travel', labelKey: 'ABOUT.PASSION_TRAVEL' }, + { 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: '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' } + ]; + + private readonly memberPassions: Readonly>> = { + joe: [ + 'bike-trial', + 'mountain', + 'ski', + 'self-hosting', + 'print-3d', + 'travel', + 'coffee', + 'software-development' + ], + matteo: [ + 'bike-trial', + 'mountain', + 'snowboard', + 'snowboard-instructor', + 'electronics', + 'print-3d', + 'woodworking', + 'van-life' + ] + }; + + get activeMember(): MemberId | null { + return this.hoveredMember ?? this.selectedMember; + } + + toggleSelectedMember(member: MemberId): void { + this.selectedMember = this.selectedMember === member ? null : member; + } + + setHoveredMember(member: MemberId | null): void { + this.hoveredMember = member; + } + + isMemberActive(member: MemberId): boolean { + return this.activeMember === member; + } + + isMemberSelected(member: MemberId): boolean { + return this.selectedMember === member; + } + + isPassionActive(passionId: PassionId): boolean { + const member = this.activeMember; + return member !== null && this.memberPassions[member].includes(passionId); + } +} diff --git a/frontend/src/app/features/admin/admin.routes.ts b/frontend/src/app/features/admin/admin.routes.ts new file mode 100644 index 0000000..51bf68f --- /dev/null +++ b/frontend/src/app/features/admin/admin.routes.ts @@ -0,0 +1,37 @@ +import { Routes } from '@angular/router'; +import { adminAuthGuard } from './guards/admin-auth.guard'; + +export const ADMIN_ROUTES: Routes = [ + { + path: 'login', + loadComponent: () => import('./pages/admin-login.component').then(m => m.AdminLoginComponent) + }, + { + path: '', + canActivate: [adminAuthGuard], + loadComponent: () => import('./pages/admin-shell.component').then(m => m.AdminShellComponent), + children: [ + { + path: '', + pathMatch: 'full', + redirectTo: 'orders' + }, + { + path: 'orders', + loadComponent: () => import('./pages/admin-dashboard.component').then(m => m.AdminDashboardComponent) + }, + { + path: 'filament-stock', + 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) + }, + { + path: 'sessions', + 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 new file mode 100644 index 0000000..f11046e --- /dev/null +++ b/frontend/src/app/features/admin/guards/admin-auth.guard.ts @@ -0,0 +1,41 @@ +import { inject } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; +import { catchError, map, Observable, of } from 'rxjs'; +import { AdminAuthService } from '../services/admin-auth.service'; + +const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']); + +function resolveLang(route: ActivatedRouteSnapshot): string { + for (const level of route.pathFromRoot) { + const candidate = level.paramMap.get('lang'); + if (candidate && SUPPORTED_LANGS.has(candidate)) { + return candidate; + } + } + return 'it'; +} + +export const adminAuthGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot +): Observable => { + const authService = inject(AdminAuthService); + const router = inject(Router); + const lang = resolveLang(route); + + return authService.me().pipe( + map((isAuthenticated) => { + if (isAuthenticated) { + return true; + } + return 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 new file mode 100644 index 0000000..8822f83 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-contact-requests.component.html @@ -0,0 +1,138 @@ +
+
+
+

Richieste di contatto

+

Richieste preventivo personalizzato ricevute dal sito.

+ {{ requests.length }} richieste +
+ +
+ +

{{ errorMessage }}

+

{{ successMessage }}

+ +
+
+

Lista richieste

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
DataNome / AziendaEmailTipo richiestaTipo clienteStato
{{ request.createdAt | date:'short' }} +

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

+

{{ request.companyName }}

+
+ {{ request.requestType }} + + {{ request.customerType }} + + {{ request.status }} +
Nessuna richiesta presente.
+
+
+ +
+
+
+

Dettaglio richiesta

+

ID{{ selectedRequest.id }}

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

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 || '-' }}
+
+ +
+
+ + +
+ +
+ +
+

Messaggio

+

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

+
+ +
+

Allegati

+
+
+
+

{{ attachment.originalFilename }}

+

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

+
+ +
+
+
+
+ +
+

Nessuna richiesta selezionata

+

Seleziona una riga dalla lista per vedere il dettaglio.

+
+
+
+ + +

Caricamento richieste...

+
+ + +

Nessun allegato disponibile.

+
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 new file mode 100644 index 0000000..25e5e2d --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-contact-requests.component.scss @@ -0,0 +1,455 @@ +.section-card { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: clamp(12px, 2vw, 24px); + box-shadow: var(--shadow-sm); +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); + margin-bottom: var(--space-4); +} + +.section-header h2 { + margin: 0; + font-size: 1.4rem; +} + +.section-header p { + margin: var(--space-2) 0 0; + color: var(--color-text-muted); +} + +.header-copy { + display: grid; + gap: var(--space-1); +} + +.total-pill { + width: fit-content; + margin-top: var(--space-1); + border-radius: 999px; + border: 1px solid var(--color-border); + background: var(--color-neutral-100); + color: var(--color-text-muted); + font-size: 0.78rem; + font-weight: 700; + line-height: 1; + padding: 6px 10px; +} + +.workspace { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-4); + align-items: start; +} + +.list-panel, +.detail-panel { + min-width: 0; +} + +button { + border: 0; + border-radius: var(--radius-md); + background: var(--color-brand); + color: var(--color-neutral-900); + padding: var(--space-2) var(--space-4); + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease, opacity 0.2s ease; + line-height: 1.2; +} + +button:hover:not(:disabled) { + background: var(--color-brand-hover); +} + +button.ghost { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + color: var(--color-text); +} + +.list-panel h3 { + margin: 0 0 var(--space-2); + font-size: 1.02rem; +} + +.table-wrap { + overflow: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + max-height: 72vh; +} + +table { + width: 100%; + border-collapse: collapse; + min-width: 760px; +} + +thead { + position: sticky; + top: 0; + z-index: 1; + background: var(--color-neutral-100); +} + +th, +td { + text-align: left; + padding: var(--space-3); + border-bottom: 1px solid var(--color-border); + font-size: 0.92rem; + vertical-align: top; +} + +th { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-text-muted); +} + +.name-cell .primary { + margin: 0; + font-weight: 600; +} + +.name-cell .secondary { + margin: 2px 0 0; + font-size: 0.82rem; + color: var(--color-text-muted); +} + +.email-cell, +.created-at { + color: var(--color-text-muted); +} + +tbody tr { + cursor: pointer; + transition: background-color 0.15s ease; +} + +tbody tr:hover { + background: #fff9d9; +} + +tbody tr.selected { + background: #fff5b8; +} + +.empty-row { + cursor: default; +} + +.empty-row:hover { + background: transparent; +} + +.detail-panel { + display: grid; + gap: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + padding: var(--space-4); + min-height: 500px; +} + +.detail-panel.empty { + display: grid; + align-content: center; + justify-items: center; + text-align: center; +} + +.detail-panel.empty h3 { + margin: 0 0 var(--space-2); +} + +.detail-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-3); +} + +.detail-header h3 { + margin: 0; +} + +.detail-chips { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-2); +} + +.request-id { + margin: var(--space-2) 0 0; + display: flex; + align-items: center; + gap: 8px; + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.request-id code { + display: inline-block; + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-text); + background: var(--color-neutral-100); + border: 1px solid var(--color-border); + border-radius: 7px; + padding: 3px 8px; +} + +.loading-detail { + margin: 0; + color: var(--color-text-muted); + font-size: 0.85rem; +} + +.meta-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: var(--space-2); + margin: 0; +} + +.meta-item { + margin: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-3); + background: var(--color-neutral-100); + display: grid; + gap: 4px; +} + +.meta-item dt { + margin: 0; + font-size: 0.78rem; + font-weight: 700; + color: var(--color-text-muted); +} + +.meta-item dd { + margin: 0; + overflow-wrap: anywhere; + font-size: 0.93rem; +} + +.message-box { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-neutral-100); + padding: var(--space-3); +} + +.message-box h4 { + margin: 0 0 var(--space-2); + font-size: 0.86rem; + color: var(--color-text-muted); +} + +.message-box p { + margin: 0; + white-space: pre-wrap; +} + +.attachments h4 { + margin: 0 0 var(--space-2); +} + +.attachment-list { + display: grid; + gap: var(--space-2); +} + +.attachment-item { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + padding: var(--space-3); + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + box-shadow: var(--shadow-sm); +} + +.filename { + margin: 0; + font-weight: 600; + font-size: 0.92rem; +} + +.meta { + margin: 2px 0 0; + color: var(--color-text-muted); + font-size: 0.82rem; + overflow-wrap: anywhere; +} + +.muted { + color: var(--color-text-muted); + margin: 0; +} + +.error { + color: var(--color-danger-500); + margin-bottom: var(--space-3); +} + +.success { + color: #157347; + margin-bottom: var(--space-3); +} + +.status-editor { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-neutral-100); + padding: var(--space-3); + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--space-2); +} + +.status-editor-field { + display: grid; + gap: var(--space-1); + min-width: 200px; +} + +.status-editor-field label { + font-size: 0.8rem; + font-weight: 600; + color: var(--color-text-muted); +} + +.status-editor-field select { + width: 100%; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + background: var(--color-bg-card); + font: inherit; +} + +.chip { + display: inline-flex; + align-items: center; + border-radius: 999px; + border: 1px solid transparent; + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.02em; + line-height: 1; + padding: 5px 9px; + text-transform: uppercase; +} + +.chip-neutral { + background: #e9f4ff; + border-color: #c8def4; + color: #1e4d78; +} + +.chip-light { + background: #f4f5f8; + border-color: #dde1e8; + color: #4a5567; +} + +.chip-warning { + background: #fff4cd; + border-color: #f7dd85; + color: #684b00; +} + +.chip-success { + background: #dff6ea; + border-color: #b6e2cb; + color: #14543a; +} + +.chip-danger { + background: #fde4e2; + border-color: #f3c0ba; + color: #812924; +} + +button:disabled { + opacity: 0.68; + cursor: default; +} + +@media (min-width: 1460px) { + .workspace { + grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.9fr); + } +} + +@media (max-width: 760px) { + .section-card { + padding: var(--space-4); + } + + .section-header { + flex-direction: column; + align-items: stretch; + } + + .detail-header { + flex-direction: column; + } + + .detail-chips { + justify-content: flex-start; + } + + .attachment-item { + flex-direction: column; + align-items: flex-start; + padding: var(--space-3); + } + + .request-id code { + max-width: 100%; + } + + .status-editor { + align-items: stretch; + } + + .status-editor button { + width: 100%; + } +} + +@media (max-width: 520px) { + .section-card { + padding: var(--space-3); + } + + th, + td { + padding: var(--space-2); + font-size: 0.86rem; + } +} 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 new file mode 100644 index 0000000..dd55cbd --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-contact-requests.component.ts @@ -0,0 +1,161 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + AdminContactRequest, + AdminContactRequestAttachment, + AdminContactRequestDetail, + AdminOperationsService +} from '../services/admin-operations.service'; + +@Component({ + selector: 'app-admin-contact-requests', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './admin-contact-requests.component.html', + styleUrl: './admin-contact-requests.component.scss' +}) +export class AdminContactRequestsComponent implements OnInit { + private readonly adminOperationsService = inject(AdminOperationsService); + + readonly statusOptions = ['NEW', 'PENDING', 'IN_PROGRESS', 'DONE', 'CLOSED']; + requests: AdminContactRequest[] = []; + selectedRequest: AdminContactRequestDetail | null = null; + selectedRequestId: string | null = null; + loading = false; + detailLoading = false; + updatingStatus = false; + selectedStatus = ''; + errorMessage: string | null = null; + successMessage: string | null = null; + + ngOnInit(): void { + this.loadRequests(); + } + + loadRequests(): void { + this.loading = true; + this.errorMessage = null; + this.successMessage = null; + this.adminOperationsService.getContactRequests().subscribe({ + next: (requests) => { + this.requests = requests; + if (requests.length === 0) { + this.selectedRequest = null; + this.selectedRequestId = null; + } else if (this.selectedRequestId && requests.some(r => r.id === this.selectedRequestId)) { + this.openDetails(this.selectedRequestId); + } else { + this.openDetails(requests[0].id); + } + this.loading = false; + }, + error: () => { + this.loading = false; + this.errorMessage = 'Impossibile caricare le richieste di contatto.'; + } + }); + } + + openDetails(requestId: string): void { + this.selectedRequestId = requestId; + this.detailLoading = true; + this.errorMessage = null; + this.adminOperationsService.getContactRequestDetail(requestId).subscribe({ + next: (detail) => { + this.selectedRequest = detail; + this.selectedStatus = detail.status || ''; + this.detailLoading = false; + }, + error: () => { + this.detailLoading = false; + this.errorMessage = 'Impossibile caricare il dettaglio richiesta.'; + } + }); + } + + isSelected(requestId: string): boolean { + return this.selectedRequestId === requestId; + } + + downloadAttachment(attachment: AdminContactRequestAttachment): void { + if (!this.selectedRequest) { + 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.'; + } + }); + } + + formatFileSize(bytes?: number): string { + if (!bytes || bytes <= 0) { + return '-'; + } + const units = ['B', 'KB', 'MB', 'GB']; + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; + } + + getStatusChipClass(status?: string): string { + const normalized = (status || '').trim().toUpperCase(); + if (['PENDING', 'NEW', 'OPEN', 'IN_PROGRESS'].includes(normalized)) { + return 'chip-warning'; + } + if (['DONE', 'COMPLETED', 'RESOLVED', 'CLOSED'].includes(normalized)) { + return 'chip-success'; + } + if (['REJECTED', 'FAILED', 'ERROR', 'SPAM'].includes(normalized)) { + return 'chip-danger'; + } + return 'chip-light'; + } + + updateRequestStatus(): void { + if (!this.selectedRequest || !this.selectedRequestId || !this.selectedStatus || this.updatingStatus) { + return; + } + + this.errorMessage = null; + 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.'; + } + }); + } + + private downloadBlob(blob: Blob, filename: string): void { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + window.URL.revokeObjectURL(url); + } +} diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.html b/frontend/src/app/features/admin/pages/admin-dashboard.component.html new file mode 100644 index 0000000..b60facc --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.html @@ -0,0 +1,191 @@ +
+
+
+

Ordini

+

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

+
+
+ +
+
+ +

{{ errorMessage }}

+ +
+
+

Lista ordini

+
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + +
OrdineEmailPagamentoStato ordineTotale
{{ order.orderNumber }}{{ order.customerEmail }}{{ order.paymentStatus || 'PENDING' }}{{ order.status }}{{ order.totalChf | currency:'CHF':'symbol':'1.2-2' }}
Nessun ordine trovato per i filtri selezionati.
+
+
+ +
+
+

Dettaglio ordine {{ selectedOrder.orderNumber }}

+

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' }}
+
+ +
+
+ + + +
+ +
+ + + +
+
+ +
+ + + +
+ +
+
+
+

{{ item.originalFilename }}

+

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

+
+ +
+
+
+ +
+

Nessun ordine selezionato

+

Seleziona un ordine dalla lista per vedere i dettagli.

+
+
+
+ + +

Caricamento ordini...

+
+ + diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.scss b/frontend/src/app/features/admin/pages/admin-dashboard.component.scss new file mode 100644 index 0000000..0e406f2 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.scss @@ -0,0 +1,462 @@ +.admin-dashboard { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: clamp(12px, 2vw, 20px); + box-shadow: var(--shadow-sm); +} + +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); + margin-bottom: var(--space-4); +} + +.dashboard-header h1 { + margin: 0; + font-size: 1.45rem; +} + +.dashboard-header p { + margin: var(--space-2) 0 0; + color: var(--color-text-muted); +} + +.header-actions { + display: flex; + gap: var(--space-2); +} + +.workspace { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.9fr); + gap: var(--space-4); + align-items: start; +} + +.list-panel, +.detail-panel { + min-width: 0; +} + +button { + border: 0; + border-radius: var(--radius-md); + background: var(--color-brand); + color: var(--color-neutral-900); + padding: var(--space-2) var(--space-4); + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease; + line-height: 1.2; +} + +button:hover:not(:disabled) { + background: var(--color-brand-hover); +} + +button.ghost { + background: var(--color-bg-card); + color: var(--color-text); + border: 1px solid var(--color-border); +} + +button:disabled { + opacity: 0.65; + cursor: default; +} + +.list-panel h2 { + font-size: 1.05rem; + margin-bottom: var(--space-2); +} + +.list-toolbar { + display: grid; + grid-template-columns: minmax(230px, 1.6fr) minmax(170px, 1fr) minmax(190px, 1fr); + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.toolbar-field { + display: grid; + gap: var(--space-1); +} + +.toolbar-field span { + font-size: 0.78rem; + font-weight: 600; + color: var(--color-text-muted); +} + +.toolbar-field input, +.toolbar-field select { + width: 100%; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + background: var(--color-bg-card); + font: inherit; +} + +.table-wrap { + overflow: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + max-height: 72vh; +} + +table { + width: 100%; + border-collapse: collapse; + min-width: 760px; +} + +thead { + background: var(--color-neutral-100); +} + +th, +td { + text-align: left; + padding: var(--space-3); + border-bottom: 1px solid var(--color-border); + vertical-align: top; + font-size: 0.93rem; +} + +tbody tr { + cursor: pointer; + transition: background-color 0.15s ease; +} + +tbody tr:hover { + background: #fff9d9; +} + +tbody tr.selected { + background: #fff5b8; +} + +tbody tr.no-results { + cursor: default; +} + +tbody tr.no-results:hover { + background: transparent; +} + +.detail-panel { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-4); + min-height: 520px; +} + +.detail-panel.empty { + display: grid; + align-content: center; + justify-items: center; + text-align: center; + color: var(--color-text-muted); +} + +.order-uuid { + font-size: 0.84rem; + color: var(--color-text-muted); +} + +.order-uuid code { + font-size: 0.82rem; + color: var(--color-text); + background: var(--color-neutral-100); + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 2px 6px; +} + +.detail-header h2 { + margin: 0 0 var(--space-2); +} + +.meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.meta-grid > div { + background: var(--color-neutral-100); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-3); + display: grid; + gap: 2px; +} + +.meta-grid strong { + font-size: 0.78rem; + color: var(--color-text-muted); +} + +.actions-block { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + align-items: flex-end; + margin-bottom: var(--space-3); +} + +.status-editor { + display: grid; + gap: var(--space-2); +} + +.status-editor label { + font-size: 0.8rem; + font-weight: 600; + color: var(--color-text-muted); +} + +.status-editor select { + min-width: 210px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + background: var(--color-bg-card); + font: inherit; +} + +.doc-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.items { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--space-3); +} + +.item { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-3); + background: var(--color-bg-card); + box-shadow: var(--shadow-sm); + display: grid; + gap: var(--space-3); +} + +.item-main { + min-width: 0; +} + +.file-name { + margin: 0; + font-size: 0.92rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.item-meta { + margin: var(--space-1) 0 0; + font-size: 0.84rem; + color: var(--color-text-muted); + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.item button { + justify-self: start; +} + +.color-swatch { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 50%; + border: 1px solid var(--color-border); +} + +.error { + color: var(--color-danger-500); + margin-bottom: var(--space-3); +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(16, 24, 32, 0.35); + display: flex; + align-items: center; + justify-content: center; + z-index: 4000; + padding: var(--space-4); +} + +.modal-card { + width: min(860px, 100%); + max-height: 88vh; + overflow: auto; + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + padding: var(--space-4); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-3); +} + +.modal-header h3 { + margin: 0; +} + +.close-btn { + white-space: nowrap; +} + +.modal-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2); + margin-bottom: var(--space-4); +} + +.modal-grid > div { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-neutral-100); + padding: var(--space-3); + display: grid; + gap: 2px; +} + +.modal-grid strong { + font-size: 0.78rem; + color: var(--color-text-muted); +} + +h4 { + margin: 0 0 var(--space-2); +} + +.file-color-list { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow: hidden; +} + +.file-color-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + border-bottom: 1px solid var(--color-border); + background: var(--color-bg-card); +} + +.file-color-row:last-child { + border-bottom: 0; +} + +.filename { + font-size: 0.9rem; +} + +.file-color { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--color-text-muted); + font-size: 0.88rem; +} + +@media (max-width: 1280px) { + .workspace { + grid-template-columns: 1fr; + } + + .detail-panel { + min-height: unset; + } +} + +@media (max-width: 820px) { + .admin-dashboard { + padding: var(--space-4); + } + + .list-toolbar { + grid-template-columns: 1fr; + } + + .dashboard-header { + flex-direction: column; + } + + .meta-grid, + .modal-grid { + grid-template-columns: 1fr; + } + + .item { + align-items: flex-start; + } + + .actions-block { + flex-direction: column; + align-items: stretch; + } + + .status-editor { + width: 100%; + } + + .status-editor select { + width: 100%; + min-width: 0; + } + + .doc-actions button { + width: 100%; + justify-content: center; + } + + .items { + grid-template-columns: 1fr; + } +} + +@media (max-width: 520px) { + .admin-dashboard { + padding: var(--space-3); + } + + th, + td { + padding: var(--space-2); + font-size: 0.88rem; + } + + .modal-backdrop { + padding: var(--space-2); + } +} diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.ts b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts new file mode 100644 index 0000000..7483ddd --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.ts @@ -0,0 +1,284 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { AdminOrder, AdminOrdersService } from '../services/admin-orders.service'; + +@Component({ + selector: 'app-admin-dashboard', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './admin-dashboard.component.html', + styleUrl: './admin-dashboard.component.scss' +}) +export class AdminDashboardComponent implements OnInit { + private readonly adminOrdersService = inject(AdminOrdersService); + + orders: AdminOrder[] = []; + filteredOrders: AdminOrder[] = []; + selectedOrder: AdminOrder | null = null; + selectedStatus = ''; + selectedPaymentMethod = 'OTHER'; + orderSearchTerm = ''; + paymentStatusFilter = 'ALL'; + orderStatusFilter = 'ALL'; + showPrintDetails = false; + loading = false; + detailLoading = false; + confirmingPayment = false; + updatingStatus = false; + errorMessage: string | null = null; + readonly orderStatusOptions = [ + 'PENDING_PAYMENT', + 'PAID', + 'IN_PRODUCTION', + 'SHIPPED', + 'COMPLETED', + 'CANCELLED' + ]; + readonly paymentMethodOptions = ['TWINT', 'BANK_TRANSFER', 'CARD', 'CASH', 'OTHER']; + readonly paymentStatusFilterOptions = ['ALL', 'PENDING', 'REPORTED', 'COMPLETED']; + readonly orderStatusFilterOptions = [ + 'ALL', + 'PENDING_PAYMENT', + 'PAID', + 'IN_PRODUCTION', + 'SHIPPED', + 'COMPLETED', + 'CANCELLED' + ]; + + ngOnInit(): void { + this.loadOrders(); + } + + loadOrders(): void { + this.loading = true; + this.errorMessage = null; + this.adminOrdersService.listOrders().subscribe({ + next: (orders) => { + this.orders = orders; + this.refreshFilteredOrders(); + + if (!this.selectedOrder && this.filteredOrders.length > 0) { + this.openDetails(this.filteredOrders[0].id); + } else if (this.selectedOrder) { + const exists = orders.find(order => order.id === this.selectedOrder?.id); + const selectedIsVisible = this.filteredOrders.some(order => order.id === this.selectedOrder?.id); + if (exists && selectedIsVisible) { + this.openDetails(exists.id); + } else if (this.filteredOrders.length > 0) { + this.openDetails(this.filteredOrders[0].id); + } else { + this.selectedOrder = null; + this.selectedStatus = ''; + } + } + this.loading = false; + }, + error: () => { + this.loading = false; + this.errorMessage = 'Impossibile caricare gli ordini.'; + } + }); + } + + onSearchChange(value: string): void { + this.orderSearchTerm = value; + this.applyListFiltersAndSelection(); + } + + onPaymentStatusFilterChange(value: string): void { + this.paymentStatusFilter = value || 'ALL'; + this.applyListFiltersAndSelection(); + } + + onOrderStatusFilterChange(value: string): void { + this.orderStatusFilter = value || 'ALL'; + this.applyListFiltersAndSelection(); + } + + openDetails(orderId: string): void { + this.detailLoading = true; + this.adminOrdersService.getOrder(orderId).subscribe({ + next: (order) => { + this.selectedOrder = order; + this.selectedStatus = order.status; + this.selectedPaymentMethod = order.paymentMethod || 'OTHER'; + this.detailLoading = false; + }, + error: () => { + this.detailLoading = false; + this.errorMessage = 'Impossibile caricare il dettaglio ordine.'; + } + }); + } + + confirmPayment(): void { + if (!this.selectedOrder || this.confirmingPayment) { + return; + } + + this.confirmingPayment = true; + this.adminOrdersService.confirmPayment(this.selectedOrder.id, this.selectedPaymentMethod).subscribe({ + next: (updatedOrder) => { + this.confirmingPayment = false; + this.applyOrderUpdate(updatedOrder); + }, + error: () => { + this.confirmingPayment = false; + this.errorMessage = 'Conferma pagamento non riuscita.'; + } + }); + } + + updateStatus(): void { + if (!this.selectedOrder || this.updatingStatus || !this.selectedStatus.trim()) { + return; + } + + this.updatingStatus = true; + this.adminOrdersService.updateOrderStatus(this.selectedOrder.id, { + status: this.selectedStatus.trim() + }).subscribe({ + next: (updatedOrder) => { + this.updatingStatus = false; + this.applyOrderUpdate(updatedOrder); + }, + error: () => { + this.updatingStatus = false; + this.errorMessage = 'Aggiornamento stato ordine non riuscito.'; + } + }); + } + + downloadItemFile(itemId: string, filename: string): void { + if (!this.selectedOrder) { + return; + } + + this.adminOrdersService.downloadOrderItemFile(this.selectedOrder.id, itemId).subscribe({ + next: (blob) => { + this.downloadBlob(blob, filename || `order-item-${itemId}`); + }, + error: () => { + this.errorMessage = 'Download file non riuscito.'; + } + }); + } + + downloadConfirmation(): void { + if (!this.selectedOrder) { + return; + } + + this.adminOrdersService.downloadOrderConfirmation(this.selectedOrder.id).subscribe({ + next: (blob) => { + this.downloadBlob(blob, `conferma-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`); + }, + error: () => { + this.errorMessage = 'Download conferma ordine non riuscito.'; + } + }); + } + + downloadInvoice(): void { + if (!this.selectedOrder) { + return; + } + + this.adminOrdersService.downloadOrderInvoice(this.selectedOrder.id).subscribe({ + next: (blob) => { + this.downloadBlob(blob, `fattura-${this.selectedOrder?.orderNumber || this.selectedOrder?.id}.pdf`); + }, + error: () => { + this.errorMessage = 'Download fattura non riuscito.'; + } + }); + } + + onStatusChange(event: Event): void { + const value = (event.target as HTMLSelectElement | null)?.value ?? ''; + this.selectedStatus = value; + } + + onPaymentMethodChange(event: Event): void { + const value = (event.target as HTMLSelectElement | null)?.value ?? 'OTHER'; + this.selectedPaymentMethod = value; + } + + openPrintDetails(): void { + this.showPrintDetails = true; + } + + closePrintDetails(): void { + this.showPrintDetails = false; + } + + getQualityLabel(layerHeight?: number): string { + if (!layerHeight || Number.isNaN(layerHeight)) { + return '-'; + } + if (layerHeight <= 0.12) { + return 'Alta'; + } + if (layerHeight <= 0.2) { + return 'Standard'; + } + return 'Bozza'; + } + + isHexColor(value?: string): boolean { + return typeof value === 'string' && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value); + } + + isSelected(orderId: string): boolean { + return this.selectedOrder?.id === orderId; + } + + private applyOrderUpdate(updatedOrder: AdminOrder): void { + this.orders = this.orders.map((order) => order.id === updatedOrder.id ? updatedOrder : order); + this.applyListFiltersAndSelection(); + this.selectedOrder = updatedOrder; + this.selectedStatus = updatedOrder.status; + this.selectedPaymentMethod = updatedOrder.paymentMethod || this.selectedPaymentMethod; + } + + private applyListFiltersAndSelection(): void { + this.refreshFilteredOrders(); + + if (this.filteredOrders.length === 0) { + this.selectedOrder = null; + this.selectedStatus = ''; + return; + } + + if (!this.selectedOrder || !this.filteredOrders.some(order => order.id === this.selectedOrder?.id)) { + this.openDetails(this.filteredOrders[0].id); + } + } + + private refreshFilteredOrders(): void { + const term = this.orderSearchTerm.trim().toLowerCase(); + this.filteredOrders = this.orders.filter((order) => { + const fullUuid = order.id.toLowerCase(); + const shortUuid = (order.orderNumber || '').toLowerCase(); + const paymentStatus = (order.paymentStatus || 'PENDING').toUpperCase(); + const orderStatus = (order.status || '').toUpperCase(); + + const matchesSearch = !term || fullUuid.includes(term) || shortUuid.includes(term); + const matchesPayment = this.paymentStatusFilter === 'ALL' || paymentStatus === this.paymentStatusFilter; + const matchesOrderStatus = this.orderStatusFilter === 'ALL' || orderStatus === this.orderStatusFilter; + + return matchesSearch && matchesPayment && matchesOrderStatus; + }); + } + + private downloadBlob(blob: Blob, filename: string): void { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + window.URL.revokeObjectURL(url); + } +} diff --git a/frontend/src/app/features/admin/pages/admin-filament-stock.component.html b/frontend/src/app/features/admin/pages/admin-filament-stock.component.html new file mode 100644 index 0000000..d4dfd8d --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-filament-stock.component.html @@ -0,0 +1,331 @@ +
+
+
+

Stock filamenti

+

Gestione materiali, varianti e stock per il calcolatore.

+
+ +
+ +
+

{{ errorMessage }}

+

{{ successMessage }}

+
+ +
+
+
+

Inserimento rapido

+ +
+ +
+
+
+

Nuovo materiale

+
+ + +
+ +
+ + +
+ + +
+ +
+

Nuova variante

+
+ + + + + + + + + +
+ +
+ + + +
+ +

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

+ + +
+
+
+
+ +
+

Varianti filamento

+
+
+
+ + +
+ {{ variant.variantDisplayName }} +
+ + + {{ variant.colorName || 'N/D' }} + + Stock spools: {{ variant.stockSpools | number:'1.0-3' }} + Filamento: {{ computeStockFilamentGrams(variant.stockSpools, variant.spoolNetKg) | number:'1.0-0' }} g +
+
+ +
+ Stock basso + Stock ok + +
+
+ +
+ + + + + + + + + +
+ +
+ + + +
+ +

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

+ + +
+
+

Nessuna variante configurata.

+
+ +
+
+

Materiali

+ +
+ +
+
+
+
+ + +
+ +
+ + +
+ + +
+
+

Nessun materiale configurato.

+
+
+
+
+ + +

Caricamento filamenti...

+
+ + +

Sezione collassata ({{ materials.length }} materiali).

+
+ + +

Sezione collassata.

+
+ +
+
+

Sei sicuro?

+

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

+

L'operazione non è reversibile.

+
+ + +
+
diff --git a/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss b/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss new file mode 100644 index 0000000..49bdf6c --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-filament-stock.component.scss @@ -0,0 +1,412 @@ +.section-card { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: clamp(12px, 2vw, 24px); + box-shadow: var(--shadow-sm); +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); + margin-bottom: var(--space-4); +} + +.section-header h2 { + margin: 0; +} + +.section-header p { + margin: var(--space-2) 0 0; + color: var(--color-text-muted); +} + +.alerts { + display: grid; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.content { + display: grid; + gap: var(--space-4); +} + +.panel { + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-4); + background: var(--color-bg-card); +} + +.panel > h3 { + margin: 0 0 var(--space-3); +} + +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.panel-header h3 { + margin: 0; +} + +.create-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.subpanel { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-3); + background: var(--color-neutral-100); +} + +.subpanel h4 { + margin: 0 0 var(--space-3); +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2) var(--space-3); + margin-bottom: var(--space-3); +} + +.form-field { + display: grid; + gap: var(--space-1); +} + +.form-field--wide { + grid-column: 1 / -1; +} + +.form-field > span { + font-size: 0.8rem; + color: var(--color-text-muted); + font-weight: 600; +} + +input, +select { + width: 100%; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + background: var(--color-bg-card); + font: inherit; + color: var(--color-text); +} + +input:disabled, +select:disabled { + opacity: 0.65; +} + +.toggle-group { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.toggle { + display: inline-flex; + align-items: center; + gap: 0.4rem; + border: 1px solid var(--color-border); + border-radius: 999px; + padding: 0.35rem 0.65rem; + background: var(--color-bg-card); +} + +.toggle input { + width: 16px; + height: 16px; + margin: 0; +} + +.toggle span { + font-size: 0.88rem; + font-weight: 600; +} + +.material-grid, +.variant-list { + display: grid; + gap: var(--space-3); +} + +.material-card, +.variant-row { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-neutral-100); + padding: var(--space-3); +} + +.material-grid { + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); +} + +.variant-list { + grid-template-columns: 1fr; +} + +.variant-header { + display: flex; + align-items: flex-start; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.variant-header strong { + font-size: 1rem; +} + +.variant-head-main { + display: grid; + gap: var(--space-1); + flex: 1; + min-width: 0; +} + +.variant-collapsed-summary { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + color: var(--color-text-muted); + font-size: 0.92rem; +} + +.color-summary { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.color-dot { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1px solid var(--color-border); + display: inline-block; +} + +.variant-head-actions { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); +} + +.expand-toggle { + min-width: 34px; + height: 34px; + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + color: var(--color-text); + font-size: 1rem; + line-height: 1; +} + +.expand-toggle:hover:not(:disabled) { + background: var(--color-neutral-100); +} + +.variant-meta { + margin: 0 0 var(--space-3); + font-size: 0.9rem; + color: var(--color-text-muted); +} + +button { + border: 0; + border-radius: var(--radius-md); + background: var(--color-brand); + color: var(--color-neutral-900); + padding: var(--space-2) var(--space-4); + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease; +} + +button:hover:not(:disabled) { + background: var(--color-brand-hover); +} + +button:disabled { + opacity: 0.65; + cursor: default; +} + +.panel-toggle { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + color: var(--color-text); +} + +.panel-toggle:hover:not(:disabled) { + background: var(--color-neutral-100); +} + +.btn-delete { + background: #dc3545; + color: #ffffff; +} + +.btn-delete:hover:not(:disabled) { + background: #bb2d3b; +} + +.btn-secondary { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + color: var(--color-text); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--color-neutral-100); +} + +.badge { + display: inline-block; + border-radius: 999px; + padding: 0.15rem 0.5rem; + font-size: 0.75rem; + font-weight: 700; +} + +.badge.low { + background: #ffebee; + color: var(--color-danger-500); +} + +.badge.ok { + background: #e6f5ea; + color: #157347; +} + +.error { + color: var(--color-danger-500); + margin: 0; +} + +.success { + color: #157347; + margin: 0; +} + +.muted { + margin: 0; + color: var(--color-text-muted); +} + +.dialog-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.32); + z-index: 1100; +} + +.confirm-dialog { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: min(460px, calc(100vw - 2rem)); + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: var(--space-4); + z-index: 1101; + display: grid; + gap: var(--space-3); +} + +.confirm-dialog h4 { + margin: 0; +} + +.confirm-dialog p { + margin: 0; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: var(--space-2); +} + +@media (max-width: 1080px) { + .create-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 900px) { + .section-header { + flex-direction: column; + align-items: stretch; + } + + .panel-header { + flex-wrap: wrap; + } + + .material-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .section-card { + padding: var(--space-4); + } + + .form-grid { + grid-template-columns: 1fr; + } + + .variant-header { + flex-wrap: wrap; + } + + .variant-head-main { + width: 100%; + order: 2; + } + + .variant-head-actions { + width: 100%; + order: 3; + } + + .expand-toggle { + order: 1; + } + + .panel button, + .subpanel button { + width: 100%; + } +} + +@media (max-width: 520px) { + .section-card { + padding: var(--space-3); + } +} 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 new file mode 100644 index 0000000..f79f238 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-filament-stock.component.ts @@ -0,0 +1,357 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + AdminFilamentMaterialType, + AdminFilamentVariant, + AdminOperationsService, + AdminUpsertFilamentMaterialTypePayload, + AdminUpsertFilamentVariantPayload +} from '../services/admin-operations.service'; +import { forkJoin } from 'rxjs'; +import { getColorHex } from '../../../core/constants/colors.const'; + +@Component({ + selector: 'app-admin-filament-stock', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './admin-filament-stock.component.html', + styleUrl: './admin-filament-stock.component.scss' +}) +export class AdminFilamentStockComponent implements OnInit { + private readonly adminOperationsService = inject(AdminOperationsService); + + materials: AdminFilamentMaterialType[] = []; + variants: AdminFilamentVariant[] = []; + loading = false; + quickInsertCollapsed = false; + materialsCollapsed = true; + creatingMaterial = false; + creatingVariant = false; + savingMaterialIds = new Set(); + savingVariantIds = new Set(); + deletingVariantIds = new Set(); + expandedVariantIds = new Set(); + variantToDelete: AdminFilamentVariant | null = null; + errorMessage: string | null = null; + successMessage: string | null = null; + + newMaterial: AdminUpsertFilamentMaterialTypePayload = { + materialCode: '', + isFlexible: false, + isTechnical: false, + technicalTypeLabel: '' + }; + + newVariant: AdminUpsertFilamentVariantPayload = { + materialTypeId: 0, + variantDisplayName: '', + colorName: '', + colorHex: '', + finishType: 'GLOSSY', + brand: '', + isMatte: false, + isSpecial: false, + costChfPerKg: 0, + stockSpools: 0, + spoolNetKg: 1, + isActive: true + }; + + ngOnInit(): void { + this.loadData(); + } + + loadData(): void { + this.loading = true; + this.errorMessage = null; + this.successMessage = null; + + forkJoin({ + materials: this.adminOperationsService.getFilamentMaterials(), + 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 => { + if (!existingIds.has(id)) { + this.expandedVariantIds.delete(id); + } + }); + if (!this.newVariant.materialTypeId && this.materials.length > 0) { + this.newVariant.materialTypeId = this.materials[0].id; + } + this.loading = false; + }, + error: (err) => { + this.loading = false; + this.errorMessage = this.extractErrorMessage(err, 'Impossibile caricare i filamenti.'); + } + }); + } + + createMaterial(): void { + if (this.creatingMaterial) { + return; + } + + this.errorMessage = null; + this.successMessage = null; + this.creatingMaterial = true; + + const payload: AdminUpsertFilamentMaterialTypePayload = { + materialCode: (this.newMaterial.materialCode || '').trim(), + isFlexible: !!this.newMaterial.isFlexible, + isTechnical: !!this.newMaterial.isTechnical, + technicalTypeLabel: this.newMaterial.isTechnical + ? (this.newMaterial.technicalTypeLabel || '').trim() + : '' + }; + + this.adminOperationsService.createFilamentMaterial(payload).subscribe({ + next: (created) => { + this.materials = this.sortMaterials([...this.materials, created]); + if (!this.newVariant.materialTypeId) { + this.newVariant.materialTypeId = created.id; + } + this.newMaterial = { + materialCode: '', + isFlexible: false, + isTechnical: false, + technicalTypeLabel: '' + }; + this.creatingMaterial = false; + this.successMessage = 'Materiale aggiunto.'; + }, + error: (err) => { + this.creatingMaterial = false; + this.errorMessage = this.extractErrorMessage(err, 'Creazione materiale non riuscita.'); + } + }); + } + + saveMaterial(material: AdminFilamentMaterialType): void { + if (this.savingMaterialIds.has(material.id)) { + return; + } + + this.errorMessage = null; + this.successMessage = null; + this.savingMaterialIds.add(material.id); + + const payload: AdminUpsertFilamentMaterialTypePayload = { + materialCode: (material.materialCode || '').trim(), + isFlexible: !!material.isFlexible, + isTechnical: !!material.isTechnical, + 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.'); + } + }); + } + + createVariant(): void { + if (this.creatingVariant) { + return; + } + + this.errorMessage = null; + this.successMessage = null; + this.creatingVariant = true; + + const payload = this.toVariantPayload(this.newVariant); + this.adminOperationsService.createFilamentVariant(payload).subscribe({ + next: (created) => { + this.variants = this.sortVariants([...this.variants, created]); + this.newVariant = { + materialTypeId: this.newVariant.materialTypeId || this.materials[0]?.id || 0, + variantDisplayName: '', + colorName: '', + colorHex: '', + finishType: 'GLOSSY', + brand: '', + isMatte: false, + isSpecial: false, + costChfPerKg: 0, + stockSpools: 0, + spoolNetKg: 1, + isActive: true + }; + this.creatingVariant = false; + this.successMessage = 'Variante aggiunta.'; + }, + error: (err) => { + this.creatingVariant = false; + this.errorMessage = this.extractErrorMessage(err, 'Creazione variante non riuscita.'); + } + }); + } + + saveVariant(variant: AdminFilamentVariant): void { + if (this.savingVariantIds.has(variant.id)) { + return; + } + + this.errorMessage = null; + this.successMessage = null; + 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.'); + } + }); + } + + isLowStock(variant: AdminFilamentVariant): boolean { + 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) { + return 0; + } + return spools * netKg; + } + + computeStockFilamentGrams(stockSpools?: number, spoolNetKg?: number): number { + return this.computeStockKg(stockSpools, spoolNetKg) * 1000; + } + + trackById(index: number, item: { id: number }): number { + return item.id; + } + + isVariantExpanded(variantId: number): boolean { + return this.expandedVariantIds.has(variantId); + } + + toggleVariantExpanded(variantId: number): void { + if (this.expandedVariantIds.has(variantId)) { + this.expandedVariantIds.delete(variantId); + return; + } + this.expandedVariantIds.add(variantId); + } + + getVariantColorHex(variant: AdminFilamentVariant): string { + if (variant.colorHex && variant.colorHex.trim().length > 0) { + return variant.colorHex; + } + return getColorHex(variant.colorName || ''); + } + + openDeleteVariant(variant: AdminFilamentVariant): void { + this.variantToDelete = variant; + } + + closeDeleteVariantDialog(): void { + this.variantToDelete = null; + } + + confirmDeleteVariant(): void { + const variant = this.variantToDelete; + if (!variant || this.deletingVariantIds.has(variant.id)) { + return; + } + + this.errorMessage = null; + this.successMessage = null; + this.deletingVariantIds.add(variant.id); + + this.adminOperationsService.deleteFilamentVariant(variant.id).subscribe({ + next: () => { + this.variants = this.variants.filter(v => v.id !== variant.id); + this.expandedVariantIds.delete(variant.id); + this.deletingVariantIds.delete(variant.id); + this.variantToDelete = null; + this.successMessage = 'Variante eliminata.'; + }, + error: (err) => { + this.deletingVariantIds.delete(variant.id); + this.errorMessage = this.extractErrorMessage(err, 'Eliminazione variante non riuscita.'); + } + }); + } + + toggleMaterialsCollapsed(): void { + this.materialsCollapsed = !this.materialsCollapsed; + } + + toggleQuickInsertCollapsed(): void { + this.quickInsertCollapsed = !this.quickInsertCollapsed; + } + + private toVariantPayload(source: AdminUpsertFilamentVariantPayload | AdminFilamentVariant): AdminUpsertFilamentVariantPayload { + return { + materialTypeId: Number(source.materialTypeId), + variantDisplayName: (source.variantDisplayName || '').trim(), + colorName: (source.colorName || '').trim(), + colorHex: (source.colorHex || '').trim() || undefined, + finishType: (source.finishType || 'GLOSSY').trim().toUpperCase(), + brand: (source.brand || '').trim() || undefined, + isMatte: !!source.isMatte, + isSpecial: !!source.isSpecial, + costChfPerKg: Number(source.costChfPerKg ?? 0), + stockSpools: Number(source.stockSpools ?? 0), + spoolNetKg: Number(source.spoolNetKg ?? 0), + isActive: source.isActive !== false + }; + } + + private sortMaterials(materials: AdminFilamentMaterialType[]): AdminFilamentMaterialType[] { + return [...materials].sort((a, b) => a.materialCode.localeCompare(b.materialCode)); + } + + private sortVariants(variants: AdminFilamentVariant[]): AdminFilamentVariant[] { + return [...variants].sort((a, b) => { + const byMaterial = (a.materialCode || '').localeCompare(b.materialCode || ''); + if (byMaterial !== 0) { + return byMaterial; + } + return (a.variantDisplayName || '').localeCompare(b.variantDisplayName || ''); + }); + } + + private extractErrorMessage(error: unknown, fallback: string): string { + const err = error as { error?: { message?: string } }; + return err?.error?.message || fallback; + } +} diff --git a/frontend/src/app/features/admin/pages/admin-login.component.html b/frontend/src/app/features/admin/pages/admin-login.component.html new file mode 100644 index 0000000..4fb6e1c --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-login.component.html @@ -0,0 +1,31 @@ +
+ +
diff --git a/frontend/src/app/features/admin/pages/admin-login.component.scss b/frontend/src/app/features/admin/pages/admin-login.component.scss new file mode 100644 index 0000000..c5bc32c --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-login.component.scss @@ -0,0 +1,90 @@ +.admin-login-page { + display: flex; + justify-content: center; + align-items: center; + min-height: 70vh; + padding: var(--space-8) var(--space-3); +} + +.admin-login-card { + width: 100%; + max-width: 420px; + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: var(--space-6); + box-shadow: var(--shadow-sm); +} + +h1 { + margin: 0; + font-size: 1.5rem; +} + +p { + margin: var(--space-2) 0 var(--space-5); + color: var(--color-text-muted); +} + +form { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +label { + font-weight: 600; +} + +input { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-3); + font-size: 1rem; +} + +button { + border: 0; + border-radius: var(--radius-md); + background: var(--color-brand); + color: var(--color-neutral-900); + padding: var(--space-3) var(--space-4); + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease; +} + +button:hover:not(:disabled) { + background: var(--color-brand-hover); +} + +button:disabled { + opacity: 0.65; + cursor: default; +} + +.error { + margin-top: var(--space-4); + margin-bottom: var(--space-2); + color: var(--color-danger-500); +} + +.hint { + margin: 0; + color: var(--color-text-muted); +} + +@media (max-width: 520px) { + .admin-login-page { + min-height: 64vh; + padding: var(--space-5) var(--space-2); + } + + .admin-login-card { + padding: var(--space-4); + } + + h1 { + font-size: 1.25rem; + } +} diff --git a/frontend/src/app/features/admin/pages/admin-login.component.ts b/frontend/src/app/features/admin/pages/admin-login.component.ts new file mode 100644 index 0000000..10b0a3a --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-login.component.ts @@ -0,0 +1,124 @@ +import { CommonModule } from '@angular/common'; +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'; + +const SUPPORTED_LANGS = new Set(['it', 'en', 'de', 'fr']); + +@Component({ + selector: 'app-admin-login', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './admin-login.component.html', + styleUrl: './admin-login.component.scss' +}) +export class AdminLoginComponent implements OnDestroy { + private readonly authService = inject(AdminAuthService); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + + password = ''; + loading = false; + errorMessage: string | null = null; + lockSecondsRemaining = 0; + private lockTimer: ReturnType | null = null; + + submit(): void { + if (!this.password.trim() || this.loading || this.lockSecondsRemaining > 0) { + return; + } + + this.loading = true; + this.errorMessage = null; + + this.authService.login(this.password).subscribe({ + next: (response: AdminAuthResponse) => { + this.loading = false; + if (!response?.authenticated) { + this.handleLoginFailure(response?.retryAfterSeconds); + return; + } + + this.clearLock(); + const redirect = this.route.snapshot.queryParamMap.get('redirect'); + if (redirect && redirect.startsWith('/')) { + void this.router.navigateByUrl(redirect); + return; + } + + void this.router.navigate(['/', this.resolveLang(), 'admin', 'orders']); + }, + error: (error: HttpErrorResponse) => { + this.loading = false; + this.handleLoginFailure(this.extractRetryAfterSeconds(error)); + } + }); + } + + private resolveLang(): string { + for (const level of this.route.pathFromRoot) { + const lang = level.snapshot.paramMap.get('lang'); + if (lang && SUPPORTED_LANGS.has(lang)) { + return lang; + } + } + return 'it'; + } + + private handleLoginFailure(retryAfterSeconds: number | undefined): void { + const timeout = this.normalizeTimeout(retryAfterSeconds); + this.errorMessage = 'Password non valida.'; + this.startLock(timeout); + } + + private extractRetryAfterSeconds(error: HttpErrorResponse): number { + const fromBody = Number(error?.error?.retryAfterSeconds); + if (Number.isFinite(fromBody) && fromBody > 0) { + return Math.floor(fromBody); + } + + const fromHeader = Number(error?.headers?.get('Retry-After')); + if (Number.isFinite(fromHeader) && fromHeader > 0) { + return Math.floor(fromHeader); + } + + return 2; + } + + private normalizeTimeout(value: number | undefined): number { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed > 0) { + return Math.floor(parsed); + } + return 2; + } + + private startLock(seconds: number): void { + this.lockSecondsRemaining = Math.max(this.lockSecondsRemaining, seconds); + this.stopTimer(); + this.lockTimer = setInterval(() => { + this.lockSecondsRemaining = Math.max(0, this.lockSecondsRemaining - 1); + if (this.lockSecondsRemaining === 0) { + this.stopTimer(); + } + }, 1000); + } + + private clearLock(): void { + this.lockSecondsRemaining = 0; + this.stopTimer(); + } + + private stopTimer(): void { + if (this.lockTimer !== null) { + clearInterval(this.lockTimer); + this.lockTimer = null; + } + } + + ngOnDestroy(): void { + this.stopTimer(); + } +} diff --git a/frontend/src/app/features/admin/pages/admin-sessions.component.html b/frontend/src/app/features/admin/pages/admin-sessions.component.html new file mode 100644 index 0000000..298ca0a --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-sessions.component.html @@ -0,0 +1,99 @@ +
+
+
+

Sessioni quote

+

Sessioni create dal configuratore con stato e conversione ordine.

+
+ +
+ +

{{ errorMessage }}

+

{{ successMessage }}

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SessioneData creazioneScadenzaMaterialeStatoOrdine convertitoAzioni
{{ session.id | slice:0:8 }}{{ session.createdAt | date:'short' }}{{ session.expiresAt | date:'short' }}{{ session.materialCode }}{{ session.status }}{{ session.convertedOrderId || '-' }} + + +
+
Caricamento dettaglio...
+
+
+
Elementi: {{ detail.items.length }}
+
Totale articoli: {{ detail.itemsTotalChf | currency:'CHF' }}
+
Spedizione: {{ detail.shippingCostChf | currency:'CHF' }}
+
Totale sessione: {{ detail.grandTotalChf | currency:'CHF' }}
+
+ + + + + + + + + + + + + + + + + + + + + + +
FileQtaTempoMaterialeStatoPrezzo unit.
{{ item.originalFilename }}{{ item.quantity }}{{ formatPrintTime(item.printTimeSeconds) }}{{ item.materialGrams ? (item.materialGrams | number:'1.0-2') + ' g' : '-' }}{{ item.status }}{{ item.unitPriceChf | currency:'CHF' }}
+ +

Nessun elemento in questa sessione.

+
+
+
+
+
+ + +

Caricamento sessioni...

+
diff --git a/frontend/src/app/features/admin/pages/admin-sessions.component.scss b/frontend/src/app/features/admin/pages/admin-sessions.component.scss new file mode 100644 index 0000000..10b0ed1 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-sessions.component.scss @@ -0,0 +1,164 @@ +.section-card { + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: clamp(12px, 2vw, 24px); + box-shadow: var(--shadow-sm); +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); + margin-bottom: var(--space-5); +} + +h2 { + margin: 0; +} + +p { + margin: var(--space-2) 0 0; + color: var(--color-text-muted); +} + +button { + border: 0; + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-4); + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.btn-primary { + background: var(--color-brand); + color: var(--color-neutral-900); +} + +.btn-primary:hover:not(:disabled) { + background: var(--color-brand-hover); +} + +.btn-danger { + background: var(--color-danger-500); + color: #fff; +} + +.btn-danger:hover:not(:disabled) { + background: #dc2626; +} + +.btn-secondary { + background: transparent; + color: var(--color-text); + border: 1px solid var(--color-border); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--color-neutral-100); +} + +.table-wrap { + overflow: auto; +} + +table { + width: 100%; + border-collapse: collapse; + min-width: 920px; +} + +th, +td { + text-align: left; + padding: var(--space-3); + border-bottom: 1px solid var(--color-border); +} + +.error { + color: var(--color-danger-500); +} + +.success { + color: var(--color-success-500); +} + +.actions { + display: flex; + gap: var(--space-2); + white-space: nowrap; +} + +.detail-cell { + background: var(--color-neutral-100); + padding: var(--space-4); +} + +.detail-box { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + padding: var(--space-4); +} + +.detail-summary { + display: flex; + flex-wrap: wrap; + gap: var(--space-4); + margin-bottom: var(--space-3); +} + +.detail-table { + width: 100%; + border-collapse: collapse; + min-width: 620px; +} + +.detail-table th, +.detail-table td { + text-align: left; + padding: var(--space-2); + border-bottom: 1px solid var(--color-border); +} + +.muted { + color: var(--color-text-muted); +} + +@media (max-width: 900px) { + .section-header { + flex-direction: column; + align-items: stretch; + } + + .actions { + flex-wrap: wrap; + } + + .detail-cell { + padding: var(--space-3); + } + + .detail-box { + padding: var(--space-3); + } + + .detail-summary { + gap: var(--space-2); + font-size: 0.92rem; + } +} + +@media (max-width: 520px) { + .section-card { + padding: var(--space-3); + } + + th, + td { + padding: var(--space-2); + font-size: 0.86rem; + } +} diff --git a/frontend/src/app/features/admin/pages/admin-sessions.component.ts b/frontend/src/app/features/admin/pages/admin-sessions.component.ts new file mode 100644 index 0000000..236f741 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-sessions.component.ts @@ -0,0 +1,133 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, OnInit } from '@angular/core'; +import { + AdminOperationsService, + AdminQuoteSession, + AdminQuoteSessionDetail +} from '../services/admin-operations.service'; + +@Component({ + selector: 'app-admin-sessions', + standalone: true, + imports: [CommonModule], + templateUrl: './admin-sessions.component.html', + styleUrl: './admin-sessions.component.scss' +}) +export class AdminSessionsComponent implements OnInit { + private readonly adminOperationsService = inject(AdminOperationsService); + + sessions: AdminQuoteSession[] = []; + sessionDetailsById: Record = {}; + loading = false; + deletingSessionIds = new Set(); + loadingDetailSessionIds = new Set(); + expandedSessionId: string | null = null; + errorMessage: string | null = null; + successMessage: string | null = null; + + ngOnInit(): void { + this.loadSessions(); + } + + loadSessions(): void { + this.loading = true; + this.errorMessage = null; + this.successMessage = null; + this.adminOperationsService.getSessions().subscribe({ + next: (sessions) => { + this.sessions = sessions; + this.loading = false; + }, + error: () => { + this.loading = false; + this.errorMessage = 'Impossibile caricare le sessioni.'; + } + }); + } + + deleteSession(session: AdminQuoteSession): void { + if (this.deletingSessionIds.has(session.id)) { + return; + } + + const confirmed = window.confirm( + `Vuoi eliminare la sessione ${session.id}? Questa azione non si puo annullare.` + ); + if (!confirmed) { + return; + } + + this.errorMessage = null; + this.successMessage = null; + this.deletingSessionIds.add(session.id); + + this.adminOperationsService.deleteSession(session.id).subscribe({ + next: () => { + this.sessions = this.sessions.filter((item) => item.id !== session.id); + this.deletingSessionIds.delete(session.id); + this.successMessage = 'Sessione eliminata.'; + }, + error: (err) => { + this.deletingSessionIds.delete(session.id); + this.errorMessage = this.extractErrorMessage(err, 'Impossibile eliminare la sessione.'); + } + }); + } + + isDeletingSession(sessionId: string): boolean { + return this.deletingSessionIds.has(sessionId); + } + + toggleSessionDetail(session: AdminQuoteSession): void { + if (this.expandedSessionId === session.id) { + this.expandedSessionId = null; + return; + } + + this.expandedSessionId = session.id; + if (this.sessionDetailsById[session.id] || this.loadingDetailSessionIds.has(session.id)) { + return; + } + + this.loadingDetailSessionIds.add(session.id); + this.adminOperationsService.getSessionDetail(session.id).subscribe({ + next: (detail) => { + this.sessionDetailsById = { + ...this.sessionDetailsById, + [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.'); + } + }); + } + + isDetailOpen(sessionId: string): boolean { + return this.expandedSessionId === sessionId; + } + + isLoadingDetail(sessionId: string): boolean { + return this.loadingDetailSessionIds.has(sessionId); + } + + getSessionDetail(sessionId: string): AdminQuoteSessionDetail | undefined { + return this.sessionDetailsById[sessionId]; + } + + formatPrintTime(seconds?: number): string { + if (!seconds || seconds <= 0) { + return '-'; + } + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return `${hours}h ${minutes}m`; + } + + private extractErrorMessage(error: unknown, fallback: string): string { + const err = error as { error?: { message?: string } }; + return err?.error?.message || fallback; + } +} diff --git a/frontend/src/app/features/admin/pages/admin-shell.component.html b/frontend/src/app/features/admin/pages/admin-shell.component.html new file mode 100644 index 0000000..8e39e15 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-shell.component.html @@ -0,0 +1,25 @@ +
+
+ + +
+ +
+
+
diff --git a/frontend/src/app/features/admin/pages/admin-shell.component.scss b/frontend/src/app/features/admin/pages/admin-shell.component.scss new file mode 100644 index 0000000..5cf0645 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-shell.component.scss @@ -0,0 +1,157 @@ +.admin-container { + margin-top: var(--space-8); + max-width: min(1720px, 96vw); + padding: 0 clamp(12px, 2.2vw, 24px); +} + +.admin-shell { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + min-height: calc(100vh - 220px); + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + overflow: hidden; + background: var(--color-bg-card); + box-shadow: var(--shadow-sm); +} + +.sidebar { + background: var(--color-neutral-100); + color: var(--color-text); + padding: var(--space-6); + display: flex; + flex-direction: column; + gap: var(--space-4); + border-right: 1px solid var(--color-border); +} + +.brand h1 { + margin: 0; + font-size: 1.15rem; +} + +.brand p { + margin: var(--space-2) 0 0; + color: var(--color-text-muted); + font-size: 0.875rem; +} + +.menu { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.menu-scroll { + min-width: 0; +} + +.menu a { + text-decoration: none; + color: var(--color-text-muted); + border-radius: var(--radius-md); + padding: var(--space-3) var(--space-4); + 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; + white-space: nowrap; +} + +.menu a:hover { + border-color: var(--color-brand); + color: var(--color-text); +} + +.menu a.active { + background: #fff5b8; + color: var(--color-text); + border-color: var(--color-brand); +} + +.logout { + margin-top: auto; + border: 1px solid var(--color-border); + color: var(--color-text); + background: var(--color-bg-card); + border-radius: var(--radius-md); + padding: var(--space-3) var(--space-4); + font-weight: 600; + cursor: pointer; + transition: border-color 0.2s ease, background-color 0.2s ease; +} + +.logout:hover { + border-color: var(--color-brand); + background: #fff8cc; +} + +.content { + background: var(--color-bg); + padding: var(--space-6); + min-width: 0; +} + +@media (max-width: 1240px) { + .admin-shell { + grid-template-columns: 220px minmax(0, 1fr); + } + + .sidebar { + padding: var(--space-5); + } +} + +@media (max-width: 1360px) { + .admin-container { + margin-top: var(--space-6); + padding: 0 var(--space-3); + } + + .admin-shell { + grid-template-columns: 1fr; + min-height: unset; + } + + .sidebar { + border-right: 0; + border-bottom: 1px solid var(--color-border); + padding: var(--space-4); + } + + .menu-scroll { + overflow-x: auto; + padding-bottom: 2px; + margin: 0 calc(-1 * var(--space-1)); + padding-inline: var(--space-1); + } + + .menu { + flex-direction: row; + flex-wrap: nowrap; + min-width: max-content; + } + + .logout { + margin-top: var(--space-1); + width: fit-content; + } + + .content { + padding: var(--space-4); + } +} + +@media (max-width: 520px) { + .brand h1 { + font-size: 1.02rem; + } + + .brand p { + font-size: 0.8rem; + } + + .content { + padding: var(--space-3); + } +} diff --git a/frontend/src/app/features/admin/pages/admin-shell.component.ts b/frontend/src/app/features/admin/pages/admin-shell.component.ts new file mode 100644 index 0000000..7617b0c --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-shell.component.ts @@ -0,0 +1,40 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +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']); + +@Component({ + selector: 'app-admin-shell', + standalone: true, + imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive], + templateUrl: './admin-shell.component.html', + styleUrl: './admin-shell.component.scss' +}) +export class AdminShellComponent { + private readonly adminAuthService = inject(AdminAuthService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + logout(): void { + this.adminAuthService.logout().subscribe({ + next: () => { + void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']); + }, + error: () => { + void this.router.navigate(['/', this.resolveLang(), 'admin', 'login']); + } + }); + } + + private resolveLang(): string { + for (const level of this.route.pathFromRoot) { + const lang = level.snapshot.paramMap.get('lang'); + if (lang && SUPPORTED_LANGS.has(lang)) { + return lang; + } + } + return 'it'; + } +} diff --git a/frontend/src/app/features/admin/services/admin-auth.service.ts b/frontend/src/app/features/admin/services/admin-auth.service.ts new file mode 100644 index 0000000..0eb7491 --- /dev/null +++ b/frontend/src/app/features/admin/services/admin-auth.service.ts @@ -0,0 +1,33 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { environment } from '../../../../environments/environment'; + +export interface AdminAuthResponse { + authenticated: boolean; + retryAfterSeconds?: number; + expiresInMinutes?: number; +} + +@Injectable({ + 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 }); + } + + logout(): Observable { + 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)) + ); + } +} diff --git a/frontend/src/app/features/admin/services/admin-operations.service.ts b/frontend/src/app/features/admin/services/admin-operations.service.ts new file mode 100644 index 0000000..de517c9 --- /dev/null +++ b/frontend/src/app/features/admin/services/admin-operations.service.ts @@ -0,0 +1,226 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../../../environments/environment'; + +export interface AdminFilamentStockRow { + filamentVariantId: number; + materialCode: string; + variantDisplayName: string; + colorName: string; + stockSpools: number; + spoolNetKg: number; + stockKg: number; + stockFilamentGrams: number; + active: boolean; +} + +export interface AdminFilamentMaterialType { + id: number; + materialCode: string; + isFlexible: boolean; + isTechnical: boolean; + technicalTypeLabel?: string; +} + +export interface AdminFilamentVariant { + id: number; + materialTypeId: number; + materialCode: string; + materialIsFlexible: boolean; + materialIsTechnical: boolean; + materialTechnicalTypeLabel?: string; + variantDisplayName: string; + colorName: string; + colorHex?: string; + finishType?: string; + brand?: string; + isMatte: boolean; + isSpecial: boolean; + costChfPerKg: number; + stockSpools: number; + spoolNetKg: number; + stockKg: number; + stockFilamentGrams: number; + isActive: boolean; + createdAt: string; +} + +export interface AdminUpsertFilamentMaterialTypePayload { + materialCode: string; + isFlexible: boolean; + isTechnical: boolean; + technicalTypeLabel?: string; +} + +export interface AdminUpsertFilamentVariantPayload { + materialTypeId: number; + variantDisplayName: string; + colorName: string; + colorHex?: string; + finishType?: string; + brand?: string; + isMatte: boolean; + isSpecial: boolean; + costChfPerKg: number; + stockSpools: number; + spoolNetKg: number; + isActive: boolean; +} + +export interface AdminContactRequest { + id: string; + requestType: string; + customerType: string; + email: string; + phone?: string; + name?: string; + companyName?: string; + status: string; + createdAt: string; +} + +export interface AdminContactRequestAttachment { + id: string; + originalFilename: string; + mimeType?: string; + fileSizeBytes?: number; + createdAt: string; +} + +export interface AdminContactRequestDetail { + id: string; + requestType: string; + customerType: string; + email: string; + phone?: string; + name?: string; + companyName?: string; + contactPerson?: string; + message: string; + status: string; + createdAt: string; + updatedAt: string; + attachments: AdminContactRequestAttachment[]; +} + +export interface AdminUpdateContactRequestStatusPayload { + status: string; +} + +export interface AdminQuoteSession { + id: string; + status: string; + materialCode: string; + createdAt: string; + expiresAt: string; + convertedOrderId?: string; +} + +export interface AdminQuoteSessionDetailItem { + id: string; + originalFilename: string; + quantity: number; + printTimeSeconds?: number; + materialGrams?: number; + colorCode?: string; + status: string; + unitPriceChf: number; +} + +export interface AdminQuoteSessionDetail { + session: { + id: string; + status: string; + materialCode: string; + setupCostChf?: number; + supportsEnabled?: boolean; + notes?: string; + }; + items: AdminQuoteSessionDetailItem[]; + itemsTotalChf: number; + shippingCostChf: number; + globalMachineCostChf: number; + grandTotalChf: number; +} + +@Injectable({ + 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 }); + } + + getFilamentMaterials(): Observable { + return this.http.get(`${this.baseUrl}/filaments/materials`, { withCredentials: true }); + } + + getFilamentVariants(): Observable { + 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 }); + } + + 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 }); + } + + 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 }); + } + + getContactRequests(): Observable { + 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 }); + } + + updateContactRequestStatus( + requestId: string, + payload: AdminUpdateContactRequestStatusPayload + ): Observable { + return this.http.patch( + `${this.baseUrl}/contact-requests/${requestId}/status`, + payload, + { withCredentials: true } + ); + } + + 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 }); + } + + deleteSession(sessionId: string): Observable { + 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 } + ); + } +} diff --git a/frontend/src/app/features/admin/services/admin-orders.service.ts b/frontend/src/app/features/admin/services/admin-orders.service.ts new file mode 100644 index 0000000..392f10f --- /dev/null +++ b/frontend/src/app/features/admin/services/admin-orders.service.ts @@ -0,0 +1,83 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../../../environments/environment'; + +export interface AdminOrderItem { + id: string; + originalFilename: string; + materialCode: string; + colorCode: string; + quantity: number; + printTimeSeconds: number; + materialGrams: number; + unitPriceChf: number; + lineTotalChf: number; +} + +export interface AdminOrder { + id: string; + orderNumber: string; + status: string; + paymentStatus?: string; + paymentMethod?: string; + customerEmail: string; + totalChf: number; + createdAt: string; + printMaterialCode?: string; + printNozzleDiameterMm?: number; + printLayerHeightMm?: number; + printInfillPattern?: string; + printInfillPercent?: number; + printSupportsEnabled?: boolean; + items: AdminOrderItem[]; +} + +export interface AdminUpdateOrderStatusPayload { + status: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class AdminOrdersService { + private readonly http = inject(HttpClient); + private readonly baseUrl = `${environment.apiUrl}/api/admin/orders`; + + listOrders(): Observable { + return this.http.get(this.baseUrl, { withCredentials: true }); + } + + getOrder(orderId: string): Observable { + 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 }); + } + + 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' + }); + } + + downloadOrderConfirmation(orderId: string): Observable { + return this.http.get(`${this.baseUrl}/${orderId}/documents/confirmation`, { + withCredentials: true, + responseType: 'blob' + }); + } + + downloadOrderInvoice(orderId: string): Observable { + return this.http.get(`${this.baseUrl}/${orderId}/documents/invoice`, { + withCredentials: true, + 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 3de51d9..07b1912 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.html +++ b/frontend/src/app/features/calculator/calculator-page.component.html @@ -3,7 +3,7 @@

{{ 'CALC.SUBTITLE' | translate }}

@if (error()) { - {{ 'CALC.ERROR_GENERIC' | translate }} + {{ errorKey() | translate }} }
@@ -11,14 +11,6 @@
-} @else if (step() === 'details' && result()) { -
- - -
} @else {
@@ -54,8 +46,8 @@
-

Analisi in corso...

-

Stiamo analizzando la geometria e calcolando il percorso utensile.

+

{{ 'CALC.ANALYZING_TITLE' | translate }}

+

{{ 'CALC.ANALYZING_TEXT' | translate }}

} @else if (result()) { @@ -63,7 +55,7 @@ [result]="result()!" (consult)="onConsult()" (proceed)="onProceed()" - (itemChange)="uploadForm.updateItemQuantityByName($event.fileName, $event.quantity)" + (itemChange)="onItemChange($event)" > } @else { diff --git a/frontend/src/app/features/calculator/calculator-page.component.ts b/frontend/src/app/features/calculator/calculator-page.component.ts index dc08f1e..d8b1dbb 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.ts +++ b/frontend/src/app/features/calculator/calculator-page.component.ts @@ -1,20 +1,22 @@ import { Component, signal, ViewChild, ElementRef, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; +import { forkJoin } from 'rxjs'; +import { map } from 'rxjs/operators'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; 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 { UserDetailsComponent } from './components/user-details/user-details.component'; import { SuccessStateComponent } from '../../shared/components/success-state/success-state.component'; import { Router, ActivatedRoute } from '@angular/router'; +import { LanguageService } from '../../core/services/language.service'; @Component({ selector: 'app-calculator-page', standalone: true, - imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, UserDetailsComponent, SuccessStateComponent], + imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, SuccessStateComponent], templateUrl: './calculator-page.component.html', styleUrl: './calculator-page.component.scss' }) @@ -26,6 +28,7 @@ export class CalculatorPageComponent implements OnInit { uploadProgress = signal(0); result = signal(null); error = signal(false); + errorKey = signal('CALC.ERROR_GENERIC'); orderSuccess = signal(false); @@ -35,7 +38,8 @@ export class CalculatorPageComponent implements OnInit { constructor( private estimator: QuoteEstimatorService, private router: Router, - private route: ActivatedRoute + private route: ActivatedRoute, + private languageService: LanguageService ) {} ngOnInit() { @@ -44,6 +48,110 @@ export class CalculatorPageComponent implements OnInit { 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); + } + } + }); + } + + 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.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; + } + + // 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. + } + }); } onCalculate(req: QuoteRequest) { @@ -52,6 +160,7 @@ export class CalculatorPageComponent implements OnInit { this.loading.set(true); this.uploadProgress.set(0); this.error.set(false); + this.errorKey.set('CALC.ERROR_GENERIC'); this.result.set(null); this.orderSuccess.set(false); @@ -68,27 +177,96 @@ export class CalculatorPageComponent implements OnInit { this.uploadProgress.set(event); } else { // It's the result - this.result.set(event as QuoteResult); + 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); 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.error.set(true); + this.setQuoteError('CALC.ERROR_GENERIC'); this.loading.set(false); } }); } onProceed() { - this.step.set('details'); + const res = this.result(); + if (res && res.sessionId) { + this.router.navigate( + ['/', this.languageService.selectedLang(), 'checkout'], + { queryParams: { session: res.sessionId } } + ); + } else { + console.error('No session ID found in quote result'); + // Fallback or error handling + } } onCancelDetails() { this.step.set('quote'); } + onItemChange(event: {id?: string, fileName: string, quantity: number}) { + // 1. Update local form for consistency (UI feedback) + if (this.uploadForm) { + 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; + + 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; + + 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 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); @@ -132,6 +310,16 @@ export class CalculatorPageComponent implements OnInit { message: details }); - this.router.navigate(['/contact']); + this.router.navigate(['/', this.languageService.selectedLang(), 'contact']); + } + + private isInvalidQuote(result: QuoteResult): boolean { + return !Number.isFinite(result.totalPrice) || result.totalPrice <= 0; + } + + private setQuoteError(key: string): void { + this.errorKey.set(key); + this.error.set(true); + this.result.set(null); } } 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 f33740d..4b009da 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 @@ -21,7 +21,8 @@
- * Include {{ result().setupCost | currency:result().currency }} Setup Cost + {{ 'CALC.SETUP_NOTE' | translate:{cost: (result().setupCost | currency:result().currency)} }}
+ {{ 'CALC.SHIPPING_NOTE' | translate }}
@if (result().notes) { @@ -46,16 +47,26 @@
- +
- {{ (item.unitPrice * item.quantity) | currency:result().currency }} + + {{ (item.unitPrice * item.quantity) | currency:result().currency }} + + + {{ item.unitPrice | currency:result().currency }} {{ 'CHECKOUT.PER_PIECE' | translate }} + + +   +
@@ -66,9 +77,13 @@ {{ 'QUOTE.CONSULT' | translate }} - - - {{ 'QUOTE.PROCEED_ORDER' | translate }} - + + @if (!hasQuantityOverLimit()) { + + {{ 'QUOTE.PROCEED_ORDER' | translate }} + + } @else { + {{ '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 c9f6973..effe200 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 @@ -60,6 +60,27 @@ 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; +} + +.item-unit-price { + 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; } .result-grid { @@ -84,6 +105,13 @@ .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); +} + .notes-section { margin-top: var(--space-4); margin-bottom: var(--space-4); 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 daeb3cd..3320816 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,4 @@ -import { Component, 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'; @@ -14,39 +14,83 @@ import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service'; templateUrl: './quote-result.component.html', styleUrl: './quote-result.component.scss' }) -export class QuoteResultComponent { +export class QuoteResultComponent implements OnDestroy { + readonly maxInputQuantity = 500; + readonly directOrderLimit = 100; + readonly quantityAutoRefreshMs = 2000; + result = input.required(); consult = output(); proceed = output(); - itemChange = output<{fileName: string, quantity: number}>(); + itemChange = output<{id?: string, fileName: string, quantity: number}>(); // Local mutable state for items to handle quantity changes items = signal([]); + private lastSentQuantities = new Map(); + private quantityTimers = new Map>(); constructor() { 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 - this.items.set(this.result().items.map(i => ({...i}))); + 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 }); } + ngOnDestroy(): void { + this.clearAllQuantityTimers(); + } + updateQuantity(index: number, newQty: number | string) { - const qty = typeof newQty === 'string' ? parseInt(newQty, 10) : newQty; - if (qty < 1 || isNaN(qty)) return; + const normalizedQty = this.normalizeQuantity(newQty); + if (normalizedQty === null) return; + + 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: qty }; + updated[index] = { ...updated[index], quantity: normalizedQty }; return updated; }); - - this.itemChange.emit({ - fileName: this.items()[index].fileName, - quantity: qty - }); + + this.scheduleQuantityRefresh(index, key); } + flushQuantityUpdate(index: number): void { + const item = this.items()[index]; + if (!item) return; + + const key = item.id ?? item.fileName; + this.clearQuantityRefreshTimer(key); + + const normalizedQty = this.normalizeQuantity(item.quantity); + if (normalizedQty === null) return; + + if (this.lastSentQuantities.get(key) === normalizedQty) { + return; + } + + this.itemChange.emit({ + id: item.id, + fileName: item.fileName, + quantity: normalizedQty + }); + this.lastSentQuantities.set(key, normalizedQty); + } + + hasQuantityOverLimit = computed(() => this.items().some(item => item.quantity > this.directOrderLimit)); + totals = computed(() => { const currentItems = this.items(); const setup = this.result().setupCost; @@ -71,4 +115,34 @@ export class QuoteResultComponent { 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); + } + + 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); + } + + private clearQuantityRefreshTimer(key: string): void { + 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(); + } + } 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 eba5371..1681515 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,21 +1,27 @@
- +
@if (selectedFile()) {
- - + @if (!isStepFile(selectedFile())) { +
+

{{ 'CALC.STEP_WARNING' | translate }}

+
+ } @else { + + + }
} - + @if (items().length === 0) { - @@ -30,14 +36,14 @@
{{ item.file.name }}
- +
- - {{ 'CALC.QTY_SHORT' | translate }} +
- - {{ 'CALC.COLOR_LABEL' | 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 }}. +

@@ -98,7 +114,7 @@ > }
- + @if (mode() === 'advanced') { @@ -122,7 +138,7 @@ type="number" [label]="'CALC.INFILL' | translate" > - +
@@ -134,7 +150,7 @@
@@ -147,11 +163,11 @@
} - - {{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }} + {{ 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 3331860..94fb022 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,4 +1,11 @@ .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; +} .grid { display: grid; grid-template-columns: 1fr; @@ -202,6 +209,18 @@ .progress-fill { height: 100%; background: var(--color-brand); - width: 0%; - transition: width 0.2s ease-out; +} + +.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; } 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 726a4a5..9bacf3f 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,7 +1,7 @@ -import { Component, input, output, signal, effect, 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 { TranslateModule } from '@ngx-translate/core'; +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'; import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone/app-dropzone.component'; @@ -15,6 +15,7 @@ interface FormItem { file: File; quantity: number; color: string; + filamentVariantId?: number; } @Component({ @@ -32,9 +33,10 @@ export class UploadFormComponent implements OnInit { private estimator = inject(QuoteEstimatorService); private fb = inject(FormBuilder); + private translate = inject(TranslateService); form: FormGroup; - + items = signal([]); selectedFile = signal(null); @@ -44,24 +46,32 @@ export class UploadFormComponent implements OnInit { nozzleDiameters = signal([]); infillPatterns = signal([]); layerHeights = signal([]); - + // Store full material options to lookup variants/colors if needed later private fullMaterialOptions: MaterialOption[] = []; - + private isPatchingSettings = false; + // Computed variants for valid material 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([]); } } - acceptedFormats = '.stl,.3mf,.step,.stp,.obj,.amf,.ply,.igs,.iges'; + acceptedFormats = '.stl,.3mf,.step,.stp'; + + isStepFile(file: File | null): boolean { + if (!file) return false; + const name = file.name.toLowerCase(); + return name.endsWith('.stl'); + } constructor() { this.form = this.fb.group({ @@ -71,17 +81,36 @@ export class UploadFormComponent implements OnInit { items: [[]], // Track items in form for validation if needed notes: [''], // Advanced fields - infillDensity: [20, [Validators.min(0), Validators.max(100)]], + infillDensity: [15, [Validators.min(0), Validators.max(100)]], layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]], nozzleDiameter: [0.4, Validators.required], infillPattern: ['grid'], supportEnabled: [false] }); - + // Listen to material changes to update variants this.form.get('material')?.valueChanges.subscribe(() => { this.updateVariants(); }); + + this.form.get('quality')?.valueChanges.subscribe((quality) => { + if (this.mode() !== 'easy' || this.isPatchingSettings) return; + this.applyAdvancedPresetFromQuality(quality); + }); + } + + private applyAdvancedPresetFromQuality(quality: string | null | undefined) { + 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 preset = presets[normalized] || presets['standard']; + this.form.patchValue(preset, { emitEvent: false }); } ngOnInit() { @@ -89,7 +118,7 @@ export class UploadFormComponent implements OnInit { 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 }))); @@ -101,8 +130,8 @@ export class UploadFormComponent implements OnInit { error: (err) => { console.error('Failed to load options', err); // Fallback for debugging/offline dev - this.materials.set([{ label: 'PLA (Fallback)', value: 'PLA' }]); - this.qualities.set([{ label: 'Standard', value: 'standard' }]); + this.materials.set([{ label: this.translate.instant('CALC.FALLBACK_MATERIAL'), value: 'PLA' }]); + this.qualities.set([{ label: this.translate.instant('CALC.FALLBACK_QUALITY_STANDARD'), value: 'standard' }]); this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]); this.setDefaults(); } @@ -139,13 +168,18 @@ export class UploadFormComponent implements OnInit { if (file.size > MAX_SIZE) { hasError = true; } else { - // Default color is Black - validItems.push({ file, quantity: 1, color: 'Black' }); + const defaultSelection = this.getDefaultVariantSelection(); + validItems.push({ + file, + quantity: 1, + color: defaultSelection.colorName, + filamentVariantId: defaultSelection.filamentVariantId + }); } } if (hasError) { - alert("Alcuni file superano il limite di 200MB e non sono stati aggiunti."); + alert(this.translate.instant('CALC.ERR_FILE_TOO_LARGE')); } if (validItems.length > 0) { @@ -188,12 +222,14 @@ export class UploadFormComponent implements OnInit { getSelectedFileColor(): string { 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 = vars.find(v => v.colorName === item.color); + 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); @@ -205,7 +241,7 @@ export class UploadFormComponent implements OnInit { const input = event.target as HTMLInputElement; let val = parseInt(input.value, 10); if (isNaN(val) || val < 1) val = 1; - + this.items.update(current => { const updated = [...current]; updated[index] = { ...updated[index], quantity: val }; @@ -213,10 +249,12 @@ export class UploadFormComponent implements OnInit { }); } - updateItemColor(index: number, newColor: string) { + 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: newColor }; + updated[index] = { ...updated[index], color: colorName, filamentVariantId }; return updated; }); } @@ -232,10 +270,103 @@ export class UploadFormComponent implements OnInit { }); } + 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 + }); + } + + 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 syncItemVariantSelections(): void { + 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 + }; + })); + } + + 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. + + 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. + + // 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'; + + 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; + + this.isPatchingSettings = true; + this.form.patchValue(patch, { emitEvent: false }); + this.isPatchingSettings = false; + } + onSubmit() { console.log('UploadFormComponent: onSubmit triggered'); 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); this.submitRequest.emit({ 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 a16b8e2..f6b282f 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 @@ -9,8 +9,8 @@
@@ -18,8 +18,8 @@
@@ -31,9 +31,9 @@
@@ -41,9 +41,9 @@
@@ -53,8 +53,8 @@ @@ -64,8 +64,8 @@
@@ -73,14 +73,29 @@
+ +
{{ item.fileName }} - {{ item.material }} - {{ item.color || 'Default' }} + {{ 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 }} + +

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 734880a..16dd410 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 @@ -43,6 +43,34 @@ margin-top: 1.5rem; } +.legal-consent { + margin-top: 1rem; + + label { + display: flex; + align-items: flex-start; + gap: 0.6rem; + font-size: 0.95rem; + color: var(--color-text-main); + line-height: 1.4; + } + + input[type='checkbox'] { + margin-top: 0.2rem; + } + + a { + color: var(--color-brand); + text-decoration: underline; + } +} + +.consent-error { + margin-top: 0.4rem; + color: var(--color-error); + font-size: 0.85rem; +} + // Summary Styles .summary-content { display: flex; @@ -84,6 +112,17 @@ .item-price { font-weight: 600; + display: flex; + flex-direction: column; + align-items: flex-end; +} + +.item-unit-price { + margin-top: 2px; + font-size: 0.72rem; + font-weight: 400; + color: var(--color-text-muted); + line-height: 1.2; } .total-row { 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 9656001..dab074a 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 @@ -30,7 +30,8 @@ export class UserDetailsComponent { phone: ['', Validators.required], address: ['', Validators.required], zip: ['', Validators.required], - city: ['', Validators.required] + city: ['', Validators.required], + acceptLegal: [false, Validators.requiredTrue] }); } 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 fccb3ac..9c65986 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,7 @@ import { map, catchError, tap } from 'rxjs/operators'; import { environment } from '../../../../environments/environment'; export interface QuoteRequest { - items: { file: File, quantity: number, color?: string }[]; + items: { file: File, quantity: number, color?: string, filamentVariantId?: number }[]; material: string; quality: string; notes?: string; @@ -18,6 +18,7 @@ export interface QuoteRequest { } export interface QuoteItem { + id?: string; fileName: string; unitPrice: number; unitTime: number; // seconds @@ -25,11 +26,14 @@ export interface QuoteItem { quantity: number; material?: string; color?: string; + filamentVariantId?: number; } export interface QuoteResult { + sessionId?: string; items: QuoteItem[]; setupCost: number; + globalMachineCost: number; currency: string; totalPrice: number; totalTimeHours: number; @@ -69,9 +73,13 @@ export interface MaterialOption { variants: VariantOption[]; } export interface VariantOption { + id: number; name: string; colorName: string; hexColor: string; + finishType: string; + stockSpools: number; + stockFilamentGrams: number; isOutOfStock: boolean; } export interface QualityOption { @@ -106,12 +114,49 @@ export interface SimpleOption { }) export class QuoteEstimatorService { private http = inject(HttpClient); + + private buildEasyModePreset(quality: string | undefined): { + 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 + }; + } + + return { + quality: 'standard', + layerHeight: 0.2, + infillDensity: 15, + infillPattern: 'grid', + nozzleDiameter: 0.4 + }; + } getOptions(): Observable { console.log('QuoteEstimatorService: Requesting options...'); const headers: any = {}; - // @ts-ignore - if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); return this.http.get(`${environment.apiUrl}/api/calculator/options`, { headers }).pipe( tap({ next: (res) => console.log('QuoteEstimatorService: Options loaded', res), @@ -119,6 +164,54 @@ export class QuoteEstimatorService { }) ); } + + // NEW METHODS for Order Flow + + getQuoteSession(sessionId: string): Observable { + 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 }); + } + + createOrder(sessionId: string, orderDetails: any): Observable { + 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 }); + } + + reportPayment(orderId: string, method: string): Observable { + 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' + }); + } + + getOrderConfirmation(orderId: string): Observable { + 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 }); + } calculate(request: QuoteRequest): Observable { console.log('QuoteEstimatorService: Calculating quote...', request); @@ -128,218 +221,102 @@ export class QuoteEstimatorService { } return new Observable(observer => { - const totalItems = request.items.length; - const allProgress: number[] = new Array(totalItems).fill(0); - const finalResponses: any[] = []; - let completedRequests = 0; + // 1. Create Session first + const headers: any = {}; - const uploads = request.items.map((item, index) => { - const formData = new FormData(); - formData.append('file', item.file); - // machine param removed - backend uses default active - - // Map material? Or trust frontend to send correct code? - // Since we fetch options now, we should send the code directly. - // But for backward compat/safety/mapping logic in mapMaterial, let's keep it or update it. - // If frontend sends 'PLA', mapMaterial returns 'pla_basic'. - // We should check if request.material is already a code from options. - // For now, let's assume request.material IS the code if it matches our new options, - // or fallback to mapper if it's old legacy string. - // Let's keep mapMaterial but update it to be smarter if needed, or rely on UploadForm to send correct codes. - // For now, let's use mapMaterial as safety, assuming frontend sends short codes 'PLA'. - // Wait, if we use dynamic options, the 'value' in select will be the 'code' from backend (e.g. 'PLA'). - // Backend expects 'pla_basic' or just 'PLA'? - // QuoteController -> processRequest -> SlicerService.slice -> assumes 'filament' is a profile name like 'pla_basic'. - // So we MUST map 'PLA' to 'pla_basic' UNLESS backend options return 'pla_basic' as code. - // Backend OptionsController returns type.getMaterialCode() which is 'PLA'. - // So we still need mapping to slicer profile names. - - formData.append('filament', this.mapMaterial(request.material)); - formData.append('quality', this.mapQuality(request.quality)); - - // Send color for both modes if present, defaulting to Black - formData.append('material_color', item.color || 'Black'); + 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; - if (request.mode === 'advanced') { - if (request.infillDensity) formData.append('infill_density', request.infillDensity.toString()); - if (request.infillPattern) formData.append('infill_pattern', request.infillPattern); - if (request.supportEnabled) formData.append('support_enabled', 'true'); - if (request.layerHeight) formData.append('layer_height', request.layerHeight.toString()); - if (request.nozzleDiameter) formData.append('nozzle_diameter', request.nozzleDiameter.toString()); - } - - const headers: any = {}; - // @ts-ignore - if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth); - - return this.http.post(`${environment.apiUrl}/api/quote`, formData, { - headers, - reportProgress: true, - observe: 'events' - }).pipe( - map(event => ({ item, event, index })), - catchError(err => of({ item, error: err, index })) - ); - }); - - // Subscribe to all - uploads.forEach((obs) => { - obs.subscribe({ - next: (wrapper: any) => { - const idx = wrapper.index; - - if (wrapper.error) { - finalResponses[idx] = { success: false, fileName: wrapper.item.file.name }; - } - - const event = wrapper.event; - if (event && event.type === HttpEventType.UploadProgress) { - if (event.total) { - const percent = Math.round((100 * event.loaded) / event.total); - allProgress[idx] = percent; - // Emit average progress - const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems); - observer.next(avg); - } - } else if ((event && event.type === HttpEventType.Response) || wrapper.error) { - // It's done (either response or error caught above) - if (!finalResponses[idx]) { // only if not already set by error - allProgress[idx] = 100; - if (wrapper.error) { - finalResponses[idx] = { success: false, fileName: wrapper.item.file.name }; - } else { - finalResponses[idx] = { ...event.body, fileName: wrapper.item.file.name, originalQty: wrapper.item.quantity }; - } - completedRequests++; - } - - if (completedRequests === totalItems) { - // All done - observer.next(100); - - // Calculate Results - let setupCost = 10; - let setupCostFromBackend: number | null = null; - let currencyFromBackend: string | null = null; - - if (request.nozzleDiameter && request.nozzleDiameter !== 0.4) { - setupCost += 2; - } - - const items: QuoteItem[] = []; - - finalResponses.forEach((res, idx) => { - if (!res) return; - const originalItem = request.items[idx]; - const normalized = this.normalizeResponse(res); - if (!normalized.success) return; - - if (normalized.currency && currencyFromBackend == null) { - currencyFromBackend = normalized.currency; - } - if (normalized.setupCost != null && setupCostFromBackend == null) { - setupCostFromBackend = normalized.setupCost; - } - - items.push({ - fileName: res.fileName, - unitPrice: normalized.unitPrice, - unitTime: normalized.unitTime, - unitWeight: normalized.unitWeight, - quantity: res.originalQty, // Use the requested quantity - material: request.material, - color: originalItem.color || 'Default' - }); - }); - - if (items.length === 0) { - observer.error('All calculations failed.'); - return; - } - - // Initial Aggregation - const useBackendSetup = setupCostFromBackend != null; - let grandTotal = useBackendSetup ? 0 : setupCost; - let totalTime = 0; - let totalWeight = 0; - - items.forEach(item => { - grandTotal += item.unitPrice * item.quantity; - totalTime += item.unitTime * item.quantity; - totalWeight += item.unitWeight * item.quantity; - }); - - const totalHours = Math.floor(totalTime / 3600); - const totalMinutes = Math.ceil((totalTime % 3600) / 60); - - const result: QuoteResult = { - items, - setupCost: useBackendSetup ? setupCostFromBackend! : setupCost, - currency: currencyFromBackend || 'CHF', - totalPrice: Math.round(grandTotal * 100) / 100, - totalTimeHours: totalHours, - totalTimeMinutes: totalMinutes, - totalWeight: Math.ceil(totalWeight), - notes: request.notes - }; - - observer.next(result); - observer.complete(); - } - } - }, - error: (err) => { - console.error('Error in request subscription', err); - completedRequests++; + const checkCompletion = () => { + const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems); + observer.next(avg); + if (completedRequests === totalItems) { - observer.error('Requests failed'); - } - } - }); + 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'); + } }); + + 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'); + } + }); + }; }); } - private normalizeResponse(res: any): { success: boolean; unitPrice: number; unitTime: number; unitWeight: number; setupCost?: number; currency?: string } { - if (res && typeof res.totalPrice === 'number' && res.stats && typeof res.stats.printTimeSeconds === 'number') { - return { - success: true, - unitPrice: res.totalPrice, - unitTime: res.stats.printTimeSeconds, - unitWeight: res.stats.filamentWeightGrams, - setupCost: res.setupCost, - currency: res.currency - }; - } - - if (res && res.success && res.data) { - return { - success: true, - unitPrice: res.data.cost.total, - unitTime: res.data.print_time_seconds, - unitWeight: res.data.material_grams, - currency: 'CHF' - }; - } - - return { success: false, unitPrice: 0, unitTime: 0, unitWeight: 0 }; - } - - private mapMaterial(mat: string): string { - const m = mat.toUpperCase(); - if (m.includes('PLA')) return 'pla_basic'; - if (m.includes('PETG')) return 'petg_basic'; - if (m.includes('TPU')) return 'tpu_95a'; - return 'pla_basic'; - } - - private mapQuality(qual: string): string { - const q = qual.toLowerCase(); - if (q.includes('draft')) return 'draft'; - if (q.includes('high')) return 'extra_fine'; - return 'standard'; - } - // Consultation Data Transfer private pendingConsultation = signal<{files: File[], message: string} | null>(null); @@ -352,4 +329,45 @@ export class QuoteEstimatorService { 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' + }); + } + + 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); + + 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), + totalTimeHours: Math.floor(totalTime / 3600), + 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 new file mode 100644 index 0000000..b335cf9 --- /dev/null +++ b/frontend/src/app/features/checkout/checkout.component.html @@ -0,0 +1,178 @@ +
+
+

{{ 'CHECKOUT.TITLE' | translate }}

+
+ +
+
+ + +
+ +
+ {{ error | translate }} +
+ +
+ + + +
+

{{ 'CHECKOUT.CONTACT_INFO' | translate }}

+
+
+ + +
+
+ + + +
+

{{ 'CHECKOUT.BILLING_ADDR' | translate }}

+
+
+ + + + + +
+ + +
+ + +
+ + +
+ + + + +
+ + + +
+
+
+ + +
+ +
+ + + +
+

{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}

+
+
+
+ + +
+ +
+ + +
+ + + +
+ + + +
+
+
+ + + +
+ + {{ (isSubmitting() ? 'CHECKOUT.PROCESSING' : 'CHECKOUT.PLACE_ORDER') | 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 }} + +
+
+
+ +
+
+ {{ 'CHECKOUT.SUBTOTAL' | translate }} + {{ session.itemsTotalChf | currency:'CHF' }} +
+
+ {{ 'CHECKOUT.SETUP_FEE' | translate }} + {{ session.session.setupCostChf | currency:'CHF' }} +
+
+ {{ 'CHECKOUT.SHIPPING' | translate }} + {{ session.shippingCostChf | 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 new file mode 100644 index 0000000..d8383a9 --- /dev/null +++ b/frontend/src/app/features/checkout/checkout.component.scss @@ -0,0 +1,301 @@ +.hero { + padding: var(--space-8) 0; + text-align: center; + + .section-title { + font-size: 2.5rem; + margin-bottom: var(--space-2); + } +} + +.checkout-layout { + display: grid; + grid-template-columns: 1fr 420px; + gap: var(--space-8); + align-items: start; + margin-bottom: var(--space-12); + + @media (max-width: 1024px) { + grid-template-columns: 1fr; + gap: var(--space-8); + } +} + +.card-header-simple { + 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; + color: var(--color-text); + margin: 0; + } +} + +.form-row { + display: flex; + flex-direction: column; + gap: var(--space-4); + margin-bottom: var(--space-4); + + @media(min-width: 768px) { + flex-direction: row; + & > * { flex: 1; } + } + + &.no-margin { + margin-bottom: 0; + } + + &.three-cols { + display: grid; + grid-template-columns: 1.5fr 2fr 1fr; + gap: var(--space-4); + + @media (max-width: 768px) { + grid-template-columns: 1fr; + } + } + + app-input { + width: 100%; + } +} + +/* User Type Selector CSS has been extracted to app-toggle-selector component */ +app-toggle-selector.user-type-selector-compact { + display: block; + width: 100%; + max-width: 400px; +} + +.company-fields { + display: flex; + flex-direction: column; + gap: var(--space-4); + padding-left: var(--space-4); + border-left: 2px solid var(--color-border); + margin-bottom: var(--space-4); +} + +.shipping-option { + margin: var(--space-6) 0; + padding: var(--space-4); + background: var(--color-neutral-100); + border-radius: var(--radius-md); +} + +.legal-consent { + margin: var(--space-4) 0 var(--space-4); + + .checkbox-container { + font-size: 0.95rem; + line-height: 1.4; + align-items: flex-start; + min-height: 24px; + } +} + +/* Custom Checkbox */ +.checkbox-container { + display: flex; + align-items: center; + position: relative; + padding-left: 36px; + cursor: pointer; + font-size: 1rem; + font-weight: 500; + user-select: none; + color: var(--color-text); + + input { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; + + &:checked ~ .checkmark { + background-color: var(--color-brand); + border-color: var(--color-brand); + + &:after { + display: block; + } + } + } + + .checkmark { + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); + height: 24px; + width: 24px; + background-color: var(--color-bg-card); + border: 2px solid var(--color-border); + border-radius: var(--radius-sm); + transition: all 0.2s; + + &:after { + content: ""; + position: absolute; + display: none; + left: 7px; + top: 3px; + width: 6px; + height: 12px; + border: solid #000; + border-width: 0 2.5px 2.5px 0; + transform: rotate(45deg); + } + } + + &:hover input ~ .checkmark { + border-color: var(--color-brand); + } +} + +.consent-error { + margin-top: var(--space-2); + margin-left: 36px; + color: var(--color-danger-500, #ef4444); + font-size: 0.9rem; +} + +.checkout-summary-section { + position: relative; +} + +.sticky-card { + position: sticky; + top: var(--space-6); +} + +.summary-items { + margin-bottom: var(--space-6); + max-height: 450px; + overflow-y: auto; + padding-right: var(--space-2); + padding-top: var(--space-2); + + &::-webkit-scrollbar { + width: 4px; + } + &::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 4px; + } +} + +.summary-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: var(--space-4) 0; + border-bottom: 1px solid var(--color-border); + + &:first-child { padding-top: 0; } + &:last-child { border-bottom: none; } + + .item-details { + flex: 1; + + .item-name { + display: block; + font-weight: 600; + font-size: 0.95rem; + margin-bottom: var(--space-1); + word-break: break-all; + color: var(--color-text); + } + + .item-specs { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: 0.85rem; + color: var(--color-text-muted); + + .color-dot { + width: 14px; + height: 14px; + border-radius: 50%; + display: inline-block; + border: 1px solid var(--color-border); + } + } + + .item-specs-sub { + font-size: 0.8rem; + color: var(--color-text-muted); + margin-top: 2px; + } + } + + .item-price { + font-weight: 600; + margin-left: var(--space-3); + white-space: nowrap; + color: var(--color-text); + display: flex; + flex-direction: column; + align-items: flex-end; + + .item-unit-price { + margin-top: 2px; + font-size: 0.75rem; + font-weight: 400; + color: var(--color-text-muted); + line-height: 1.2; + } + } +} + +.summary-totals { + background: var(--color-neutral-100); + padding: var(--space-4); + border-radius: var(--radius-md); + margin-top: var(--space-6); + + .total-row { + display: flex; + justify-content: space-between; + margin-bottom: var(--space-2); + font-size: 0.95rem; + color: var(--color-text); + } + + .grand-total { + display: flex; + justify-content: space-between; + color: var(--color-text); + font-weight: 700; + font-size: 1.5rem; + margin-top: var(--space-4); + padding-top: var(--space-4); + border-top: 2px solid var(--color-border); + } +} + +.actions { + margin-top: var(--space-8); + + app-button { + width: 100%; + } +} + +.error-message { + color: var(--color-error); + background: #fef2f2; + padding: var(--space-4); + border-radius: var(--radius-md); + margin-bottom: var(--space-6); + border: 1px solid #fee2e2; + font-weight: 500; +} + +.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 new file mode 100644 index 0000000..0bb9ea0 --- /dev/null +++ b/frontend/src/app/features/checkout/checkout.component.ts @@ -0,0 +1,224 @@ +import { Component, inject, OnInit, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +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 { LanguageService } from '../../core/services/language.service'; + +@Component({ + selector: 'app-checkout', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + TranslateModule, + AppInputComponent, + AppButtonComponent, + AppCardComponent, + AppToggleSelectorComponent + ], + templateUrl: './checkout.component.html', + styleUrls: ['./checkout.component.scss'] +}) +export class CheckoutComponent implements OnInit { + private fb = inject(FormBuilder); + private quoteService = inject(QuoteEstimatorService); + private router = inject(Router); + private route = inject(ActivatedRoute); + private languageService = inject(LanguageService); + + checkoutForm: FormGroup; + sessionId: string | null = null; + loading = false; + error: string | null = null; + isSubmitting = signal(false); // Add signal for submit state + quoteSession = signal(null); // Add signal for session details + + userTypeOptions: ToggleOption[] = [ + { label: 'CONTACT.TYPE_PRIVATE', value: 'PRIVATE' }, + { label: 'CONTACT.TYPE_COMPANY', value: 'BUSINESS' } + ]; + + constructor() { + this.checkoutForm = this.fb.group({ + 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: [''], + referencePerson: [''], + addressLine1: ['', Validators.required], + addressLine2: [''], + zip: ['', Validators.required], + city: ['', Validators.required], + countryCode: ['CH', Validators.required] + }), + + shippingAddress: this.fb.group({ + firstName: [''], + lastName: [''], + companyName: [''], + referencePerson: [''], + addressLine1: [''], + addressLine2: [''], + zip: [''], + city: [''], + countryCode: ['CH'] + }) + }); + } + + get isCompany(): boolean { + return this.checkoutForm.get('customerType')?.value === 'BUSINESS'; + } + + 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]); + firstNameControl?.clearValidators(); + lastNameControl?.clearValidators(); + } else { + companyControl?.clearValidators(); + referenceControl?.clearValidators(); + firstNameControl?.setValidators([Validators.required]); + lastNameControl?.setValidators([Validators.required]); + } + companyControl?.updateValueAndValidity(); + referenceControl?.updateValueAndValidity(); + firstNameControl?.updateValueAndValidity(); + lastNameControl?.updateValueAndValidity(); + } + + ngOnInit(): void { + 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(); + } + }); + + // 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'; + } + }); + } + + onSubmit() { + if (this.checkoutForm.invalid) { + return; + } + + this.isSubmitting.set(true); + this.error = null; // Clear previous errors + const formVal = this.checkoutForm.getRawValue(); // Use getRawValue to include disabled fields + + // Construct request object matching backend DTO based on original form structure + const orderRequest = { + customer: { + email: formVal.email, + phone: formVal.phone, + customerType: formVal.customerType, + // 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 + }, + billingAddress: { + firstName: formVal.billingAddress.firstName, + lastName: formVal.billingAddress.lastName, + companyName: formVal.billingAddress.companyName, + contactPerson: formVal.billingAddress.referencePerson, + addressLine1: formVal.billingAddress.addressLine1, + 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 + }, + shippingSameAsBilling: formVal.shippingSameAsBilling, + language: this.languageService.selectedLang(), + acceptTerms: formVal.acceptLegal, + acceptPrivacy: formVal.acceptLegal + }; + + if (!this.sessionId) { + this.error = 'CHECKOUT.ERR_NO_SESSION_CREATE_ORDER'; + this.isSubmitting.set(false); + return; + } + + this.quoteService.createOrder(this.sessionId, orderRequest).subscribe({ + next: (order) => { + const orderId = order?.id ?? order?.orderId; + if (!orderId) { + this.isSubmitting.set(false); + this.error = 'CHECKOUT.ERR_CREATE_ORDER'; + return; + } + 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 b0f7bed..6e0964b 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 @@ -37,7 +37,7 @@
- +
@@ -47,27 +47,54 @@

{{ 'CONTACT.UPLOAD_HINT' | translate }}

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

+ +
- +

{{ 'CONTACT.DROP_FILES' | translate }}

- + -
- PDF - 3D + +
+ {{ 'CONTACT.FILE_TYPE_PDF' | translate }} + {{ 'CONTACT.FILE_TYPE_3D' | translate }} + {{ 'CONTACT.FILE_TYPE_DOC' | translate }} + {{ 'CONTACT.FILE_TYPE_FILE' | translate }}
{{ file.file.name }}
+ +
{{ sent() ? ('CONTACT.MSG_SENT' | translate) : ('CONTACT.SEND' | translate) }} diff --git a/frontend/src/app/features/contact/components/contact-form/contact-form.component.scss b/frontend/src/app/features/contact/components/contact-form/contact-form.component.scss index 76186ad..a20e050 100644 --- a/frontend/src/app/features/contact/components/contact-form/contact-form.component.scss +++ b/frontend/src/app/features/contact/components/contact-form/contact-form.component.scss @@ -1,6 +1,7 @@ .form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); } label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); } .hint { font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: var(--space-2); } +.upload-privacy-note { margin-top: calc(var(--space-2) * -1); font-size: 0.78rem; } .form-control { padding: 0.5rem 0.75rem; @@ -114,6 +115,16 @@ app-input.col { width: 100%; } border-radius: var(--radius-sm); } +.preview-video { + width: 100%; + height: 100%; + object-fit: cover; + position: absolute; + top: 0; + left: 0; + border-radius: var(--radius-sm); +} + .file-icon { font-weight: 700; color: var(--color-text-muted); font-size: 0.8rem; } @@ -132,4 +143,80 @@ app-input.col { width: 100%; } &:hover { background: red; } } +.legal-consent { + margin: var(--space-4) 0 var(--space-4); +} + +.checkbox-container { + display: flex; + align-items: center; + position: relative; + padding-left: 36px; + cursor: pointer; + font-size: 0.95rem; + font-weight: 500; + user-select: none; + color: var(--color-text); + line-height: 1.4; + + input { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; + + &:checked ~ .checkmark { + background-color: var(--color-brand); + border-color: var(--color-brand); + + &:after { + display: block; + } + } + } + + .checkmark { + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); + height: 24px; + width: 24px; + background-color: var(--color-bg-card); + border: 2px solid var(--color-border); + border-radius: var(--radius-sm); + transition: all 0.2s; + + &:after { + content: ""; + position: absolute; + display: none; + left: 7px; + top: 3px; + width: 6px; + height: 12px; + border: solid #000; + border-width: 0 2.5px 2.5px 0; + transform: rotate(45deg); + } + } + + &:hover input ~ .checkmark { + border-color: var(--color-brand); + } + + a { + color: var(--color-brand); + text-decoration: underline; + } +} + +.consent-error { + margin-top: var(--space-2); + margin-left: 36px; + color: var(--color-danger-500, #ef4444); + font-size: 0.9rem; +} + /* Success State styles moved to shared component */ diff --git a/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts b/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts index 30eec60..3206cff 100644 --- a/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts +++ b/frontend/src/app/features/contact/components/contact-form/contact-form.component.ts @@ -1,15 +1,16 @@ -import { Component, signal, effect } from '@angular/core'; +import { Component, signal, effect, inject, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component'; import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component'; import { QuoteEstimatorService } from '../../../calculator/services/quote-estimator.service'; +import { QuoteRequestService } from '../../../../core/services/quote-request.service'; interface FilePreview { file: File; url?: string; - type: 'image' | 'pdf' | '3d' | 'other'; + type: 'image' | 'video' | 'pdf' | '3d' | 'document' | 'other'; } import { SuccessStateComponent } from '../../../../shared/components/success-state/success-state.component'; @@ -21,10 +22,11 @@ import { SuccessStateComponent } from '../../../../shared/components/success-sta templateUrl: './contact-form.component.html', styleUrl: './contact-form.component.scss' }) -export class ContactFormComponent { +export class ContactFormComponent implements OnDestroy { form: FormGroup; sent = signal(false); files = signal([]); + readonly acceptedFormats = '.jpg,.jpeg,.png,.webp,.gif,.bmp,.svg,.heic,.heif,.pdf,.stl,.step,.stp,.3mf,.obj,.iges,.igs,.dwg,.dxf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.rtf,.csv,.mp4,.mov,.avi,.mkv,.webm,.m4v,.wmv'; get isCompany(): boolean { return this.form.get('isCompany')?.value; @@ -37,6 +39,8 @@ export class ContactFormComponent { { value: 'question', label: 'CONTACT.REQ_TYPE_QUESTION' } ]; + private quoteRequestService = inject(QuoteRequestService); + constructor( private fb: FormBuilder, private translate: TranslateService, @@ -50,7 +54,8 @@ export class ContactFormComponent { message: ['', Validators.required], isCompany: [false], companyName: [''], - referencePerson: [''] + referencePerson: [''], + acceptLegal: [false, Validators.requiredTrue] }); // Handle conditional validation for Company fields @@ -93,14 +98,22 @@ export class ContactFormComponent { }); // Process files - const filePreviews: FilePreview[] = []; - pending.files.forEach(f => { - filePreviews.push({ file: f, type: this.getFileType(f) }); + const filePreviews: FilePreview[] = pending.files.map(f => { + const type = this.getFileType(f); + return { + file: f, + type, + url: this.shouldCreatePreview(type) ? URL.createObjectURL(f) : undefined + }; }); this.files.set(filePreviews); } } + ngOnDestroy(): void { + this.revokeAllPreviewUrls(); + } + setCompanyMode(isCompany: boolean) { this.form.patchValue({ isCompany }); } @@ -121,48 +134,87 @@ export class ContactFormComponent { handleFiles(newFiles: File[]) { const currentFiles = this.files(); - if (currentFiles.length + newFiles.length > 15) { + const blockedCompressed = newFiles.filter(file => this.isCompressedFile(file)); + if (blockedCompressed.length > 0) { + alert(this.translate.instant('CONTACT.ERR_COMPRESSED_FILES')); + } + + const allowedFiles = newFiles.filter(file => !this.isCompressedFile(file)); + if (allowedFiles.length === 0) return; + + if (currentFiles.length + allowedFiles.length > 15) { alert(this.translate.instant('CONTACT.ERR_MAX_FILES')); return; } - newFiles.forEach(file => { + allowedFiles.forEach(file => { const type = this.getFileType(file); - const preview: FilePreview = { file, type }; - - if (type === 'image') { - const reader = new FileReader(); - reader.onload = (e) => { - preview.url = e.target?.result as string; - this.files.update(files => [...files]); - }; - reader.readAsDataURL(file); - } + const preview: FilePreview = { + file, + type, + url: this.shouldCreatePreview(type) ? URL.createObjectURL(file) : undefined + }; this.files.update(files => [...files, preview]); }); } removeFile(index: number) { - this.files.update(files => files.filter((_, i) => i !== index)); + this.files.update(files => { + const fileToRemove = files[index]; + if (fileToRemove) this.revokePreviewUrl(fileToRemove); + return files.filter((_, i) => i !== index); + }); } - getFileType(file: File): 'image' | 'pdf' | '3d' | 'other' { - if (file.type.startsWith('image/')) return 'image'; - if (file.type === 'application/pdf') return 'pdf'; - const ext = file.name.split('.').pop()?.toLowerCase(); - if (['stl', 'step', 'stp', '3mf', 'obj'].includes(ext || '')) return '3d'; + getFileType(file: File): 'image' | 'video' | 'pdf' | '3d' | 'document' | 'other' { + const ext = this.getExtension(file.name); + + if (file.type.startsWith('image/') || ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'svg', 'heic', 'heif'].includes(ext)) { + return 'image'; + } + if (file.type.startsWith('video/') || ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'wmv'].includes(ext)) { + return 'video'; + } + if (file.type === 'application/pdf' || ext === 'pdf') return 'pdf'; + if (['stl', 'step', 'stp', '3mf', 'obj', 'iges', 'igs', 'dwg', 'dxf'].includes(ext)) return '3d'; + if ([ + 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf', 'csv', + ].includes(ext)) return 'document'; return 'other'; } onSubmit() { if (this.form.valid) { - const formData = { - ...this.form.value, - files: this.files().map(f => f.file) - }; - console.log('Form Submit:', formData); + const formVal = this.form.value; + const isCompany = formVal.isCompany; + + const requestDto: any = { + requestType: formVal.requestType, + customerType: isCompany ? 'BUSINESS' : 'PRIVATE', + email: formVal.email, + phone: formVal.phone, + message: formVal.message, + acceptTerms: formVal.acceptLegal, + acceptPrivacy: formVal.acceptLegal + }; + + if (isCompany) { + requestDto.companyName = formVal.companyName; + requestDto.contactPerson = formVal.referencePerson; + } else { + requestDto.name = formVal.name; + } + + this.quoteRequestService.createRequest(requestDto, this.files().map(f => f.file)).subscribe({ + next: () => { + this.sent.set(true); + }, + error: (err) => { + console.error('Submission failed', err); + alert(this.translate.instant('CONTACT.ERROR_SUBMIT')); + } + }); - this.sent.set(true); } else { this.form.markAllAsTouched(); } @@ -171,6 +223,48 @@ export class ContactFormComponent { resetForm() { this.sent.set(false); this.form.reset({ requestType: 'custom', isCompany: false }); + this.revokeAllPreviewUrls(); this.files.set([]); } + + private getExtension(fileName: string): string { + const index = fileName.lastIndexOf('.'); + return index > -1 ? fileName.substring(index + 1).toLowerCase() : ''; + } + + private shouldCreatePreview(type: FilePreview['type']): boolean { + return type === 'image' || type === 'video'; + } + + private isCompressedFile(file: File): boolean { + const ext = this.getExtension(file.name); + const compressedExtensions = [ + 'zip', 'rar', '7z', 'tar', 'gz', 'tgz', 'bz2', 'tbz2', 'xz', 'txz', 'zst' + ]; + const compressedMimeTypes = [ + 'application/zip', + 'application/x-zip-compressed', + 'application/x-rar-compressed', + 'application/vnd.rar', + 'application/x-7z-compressed', + 'application/gzip', + 'application/x-gzip', + 'application/x-tar', + 'application/x-bzip2', + 'application/x-xz', + 'application/zstd', + 'application/x-zstd' + ]; + return compressedExtensions.includes(ext) || compressedMimeTypes.includes((file.type || '').toLowerCase()); + } + + private revokePreviewUrl(file: FilePreview): void { + if (file.url?.startsWith('blob:')) { + URL.revokeObjectURL(file.url); + } + } + + private revokeAllPreviewUrls(): void { + this.files().forEach(file => this.revokePreviewUrl(file)); + } } diff --git a/frontend/src/app/features/contact/contact-page.component.html b/frontend/src/app/features/contact/contact-page.component.html index 75d5c3c..efd8bb5 100644 --- a/frontend/src/app/features/contact/contact-page.component.html +++ b/frontend/src/app/features/contact/contact-page.component.html @@ -1,7 +1,7 @@

{{ 'CONTACT.TITLE' | translate }}

-

Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.

+

{{ 'CONTACT.HERO_SUBTITLE' | translate }}

diff --git a/frontend/src/app/features/home/home.component.html b/frontend/src/app/features/home/home.component.html index 4d4b943..d7d2444 100644 --- a/frontend/src/app/features/home/home.component.html +++ b/frontend/src/app/features/home/home.component.html @@ -2,132 +2,131 @@
-

Stampa 3D tecnica per aziende, freelance e maker

-

- Prezzo e tempi in pochi secondi.
- Dal file 3D al pezzo finito. -

+

{{ 'HOME.HERO_EYEBROW' | translate }}

+

- Il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese. + {{ 'HOME.HERO_LEAD' | translate }}

- Realizziamo pezzi unici e produzioni in serie. Se hai il file, il preventivo è istantaneo. - Se devi ancora crearlo, il nostro team di design lo progetterà per te. + {{ 'HOME.HERO_SUBTITLE' | translate }}

- Calcola Preventivo - Vai allo shop - Parla con noi + {{ 'HOME.BTN_CALCULATE' | translate }} + {{ 'HOME.BTN_SHOP' | translate }} + {{ 'HOME.BTN_CONTACT' | translate }}
-
-
-
-

Preventivo immediato in pochi secondi

-

- Nessuna registrazione necessaria. Non salviamo il tuo file fino al momento dell'ordine e la stima è effettuata tramite vero slicing. -

-
    -
  • Formati supportati: STL, 3MF, STEP, OBJ
  • -
  • Qualità: bozza, standard, alta definizione
  • -
-
- -
-
-

Calcolo automatico

-

Prezzo e tempi in un click

-
- Senza registrazione -
-
    -
  • Carica il file 3D
  • -
  • Scegli materiale e qualità
  • -
  • Ricevi subito costo e tempo
  • -
-
- Apri calcolatore - Parla con noi -
-
-
-
-
-

Cosa puoi ottenere

+

{{ 'HOME.SEC_CAP_TITLE' | translate }}

- Produzione su misura per prototipi, piccole serie e pezzi personalizzati. + {{ 'HOME.SEC_CAP_SUBTITLE' | translate }}

- +
-

Prototipazione veloce

-

Dal file digitale al modello fisico in 24/48 ore. Verifica ergonomia, incastri e funzionamento prima dello stampo definitivo.

+

{{ 'HOME.CAP_1_TITLE' | translate }}

+

{{ 'HOME.CAP_1_TEXT' | translate }}

- +
-

Pezzi personalizzati

-

Componenti unici impossibili da trovare in commercio. Riproduciamo parti rotte o creiamo adattamenti su misura.

+

{{ 'HOME.CAP_2_TITLE' | translate }}

+

{{ 'HOME.CAP_2_TEXT' | translate }}

- +
-

Piccole serie

-

Produzione ponte o serie limitate (10-500 pezzi) con qualità ripetibile. Ideale per validazione di mercato.

+

{{ 'HOME.CAP_3_TITLE' | translate }}

+

{{ 'HOME.CAP_3_TEXT' | translate }}

- +
-

Consulenza e CAD

-

Non hai il file 3D? Ti aiutiamo a progettarlo, ottimizzarlo per la stampa e scegliere il materiale giusto.

+

{{ 'HOME.CAP_4_TITLE' | translate }}

+

{{ 'HOME.CAP_4_TEXT' | translate }}

+
+
+
+

{{ 'HOME.SEC_CALC_TITLE' | translate }}

+

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

+
    +
  • {{ 'HOME.SEC_CALC_LIST_1' | 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 }} +
+
+
+
+
-

Shop di soluzioni tecniche pronte

+

{{ 'HOME.SEC_SHOP_TITLE' | translate }}

- Prodotti selezionati, testati in laboratorio e pronti all'uso. Risolvono problemi reali con - funzionalità concrete. + {{ 'HOME.SEC_SHOP_TEXT' | translate }}

    -
  • Accessori funzionali per officine e laboratori
  • -
  • Ricambi e componenti difficili da reperire
  • -
  • Supporti e organizzatori per migliorare i flussi di lavoro
  • +
  • {{ 'HOME.SEC_SHOP_LIST_1' | translate }}
  • +
  • {{ 'HOME.SEC_SHOP_LIST_2' | translate }}
  • +
  • {{ 'HOME.SEC_SHOP_LIST_3' | translate }}
- Scopri i prodotti - Richiedi una soluzione + {{ 'HOME.BTN_DISCOVER' | translate }} + {{ 'HOME.BTN_REQ_SOLUTION' | translate }}
+
-

Best seller tecnici

-

Soluzioni provate sul campo e già pronte alla spedizione.

+

{{ 'HOME.CARD_SHOP_1_TITLE' | translate }}

+

{{ 'HOME.CARD_SHOP_1_TEXT' | translate }}

-

Kit pronti all'uso

-

Componenti compatibili e facili da montare senza sorprese.

+

{{ 'HOME.CARD_SHOP_2_TITLE' | translate }}

+

{{ 'HOME.CARD_SHOP_2_TEXT' | translate }}

-

Su richiesta

-

Non trovi quello che serve? Lo progettiamo e lo produciamo per te.

+

{{ 'HOME.CARD_SHOP_3_TITLE' | translate }}

+

{{ 'HOME.CARD_SHOP_3_TEXT' | translate }}

@@ -136,17 +135,40 @@
-

Su di noi

+

{{ 'HOME.SEC_ABOUT_TITLE' | translate }}

- 3D fab è un laboratorio tecnico di stampa 3D. Seguiamo progetti dalla consulenza iniziale - alla produzione, con tempi chiari e supporto diretto. + {{ 'HOME.SEC_ABOUT_TEXT' | translate }}

- Contattaci +
+ {{ 'HOME.SEC_ABOUT_TITLE' | translate }} + {{ 'HOME.BTN_CONTACT' | translate }} +
- - Foto Founders + + +
diff --git a/frontend/src/app/features/home/home.component.scss b/frontend/src/app/features/home/home.component.scss index 147d469..c55fff4 100644 --- a/frontend/src/app/features/home/home.component.scss +++ b/frontend/src/app/features/home/home.component.scss @@ -1,14 +1,16 @@ @use '../../../styles/patterns'; .home-page { - background: var(--color-bg); + --home-bg: #faf9f6; + --color-bg-card: #ffffff; + background: var(--home-bg); } .hero { position: relative; padding: 6rem 0 5rem; overflow: hidden; - background: var(--color-bg); + background: var(--home-bg); // Enhanced Grid Pattern &::after { content: ''; @@ -43,7 +45,7 @@ position: relative; z-index: 1; } - + .hero-copy { animation: fadeUp 0.8s ease both; } .hero-panel { animation: fadeUp 0.8s ease 0.15s both; } @@ -187,14 +189,15 @@ .meta-value { font-weight: 600; } .quote-actions { display: grid; gap: var(--space-3); } - .capabilities { - position: relative; - border-bottom: 1px solid var(--color-border); - } +.capabilities { + position: relative; + border-bottom: 1px solid var(--color-border); + padding-top: 3rem; +} .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); } @@ -222,32 +225,37 @@ gap: var(--space-4); grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); } - + .card-image-placeholder { width: 100%; height: 160px; - background: var(--color-neutral-100); + background: #f5f5f5; margin: -1.5rem -1.5rem 1.5rem -1.5rem; /* Negative margins to bleed to edge */ width: calc(100% + 3rem); - border-bottom: 1px solid var(--color-border); + 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; } .shop { - background: var(--color-neutral-50); + background: var(--home-bg); position: relative; - // Triangular/Isogrid Pattern - &::before { - content: ''; - position: absolute; - inset: 0; - @include patterns.pattern-triangular(var(--color-neutral-900), 40px); - opacity: 0.03; - pointer-events: none; - } + } + .shop .split { align-items: start; } + .shop-copy { + max-width: 760px; } .split { display: grid; @@ -266,25 +274,61 @@ 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 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 h3 { + margin-top: 0; + margin-bottom: var(--space-2); + } + + .shop-cards p { + margin: 0; + } + .about { - background: var(--color-neutral-50); + background: transparent; border-top: 1px solid var(--color-border); position: relative; - // Gyroid Pattern - &::before { - content: ''; - position: absolute; - inset: 0; - @include patterns.pattern-gyroid(var(--color-neutral-900), 40px); - opacity: 0.03; - pointer-events: none; - } + } + .about-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); } .about-grid { display: grid; @@ -293,20 +337,61 @@ } .about-media { position: relative; + display: flex; + justify-content: flex-end; } - + .about-feature-image { width: 100%; - height: 100%; - min-height: 320px; - object-fit: cover; + max-width: 620px; + aspect-ratio: 16 / 10; border-radius: var(--radius-lg); background: var(--color-neutral-100); border: 1px solid var(--color-border); - display: flex; + position: relative; + overflow: hidden; + contain: layout paint; + } + + .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; - color: var(--color-text-muted); + 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-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; @@ -322,12 +407,39 @@ .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; + } } @keyframes fadeUp { diff --git a/frontend/src/app/features/home/home.component.ts b/frontend/src/app/features/home/home.component.ts index 2aec96c..53137f3 100644 --- a/frontend/src/app/features/home/home.component.ts +++ b/frontend/src/app/features/home/home.component.ts @@ -12,4 +12,38 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp templateUrl: './home.component.html', styleUrls: ['./home.component.scss'] }) -export class HomeComponent {} +export class HomeComponent { + readonly shopGalleryImages = [ + { + src: 'assets/images/home/supporto-bici.jpg', + alt: 'HOME.SHOP_IMAGE_ALT_1' + } + ]; + + readonly founderImages = [ + { + src: 'assets/images/home/da-cambiare.jpg', + alt: 'HOME.FOUNDER_IMAGE_ALT_1' + }, + { + src: 'assets/images/home/vino.JPG', + alt: 'HOME.FOUNDER_IMAGE_ALT_2' + } + ]; + + founderImageIndex = 0; + + prevFounderImage(): void { + this.founderImageIndex = + this.founderImageIndex === 0 + ? this.founderImages.length - 1 + : this.founderImageIndex - 1; + } + + nextFounderImage(): void { + this.founderImageIndex = + this.founderImageIndex === this.founderImages.length - 1 + ? 0 + : this.founderImageIndex + 1; + } +} diff --git a/frontend/src/app/features/legal/privacy/privacy.component.html b/frontend/src/app/features/legal/privacy/privacy.component.html index 1fb7a43..b831a18 100644 --- a/frontend/src/app/features/legal/privacy/privacy.component.html +++ b/frontend/src/app/features/legal/privacy/privacy.component.html @@ -2,16 +2,37 @@

{{ 'LEGAL.PRIVACY_TITLE' | translate }}

-

{{ 'LEGAL.LAST_UPDATE' | translate }}: February 2026

- -

{{ 'LEGAL.PRIVACY.SECTION_1' | translate }}

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

+

+ {{ 'LEGAL.LAST_UPDATE' | translate }}: {{ 'LEGAL.PRIVACY_UPDATE_DATE' | translate }} +

-

{{ 'LEGAL.PRIVACY.SECTION_2' | translate }}

-

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+

{{ 'LEGAL.PRIVACY.META.CONTROLLER' | translate }}

+

{{ 'LEGAL.PRIVACY.META.CONTACT' | translate }}

-

{{ 'LEGAL.PRIVACY.SECTION_3' | translate }}

-

Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra, est eros bibendum elit, nec luctus magna felis sollicitudin mauris.

+

{{ 'LEGAL.PRIVACY.S1.TITLE' | translate }}

+

{{ 'LEGAL.PRIVACY.S1.P1' | translate }}

+

{{ 'LEGAL.PRIVACY.S1.P2' | translate }}

+ +

{{ 'LEGAL.PRIVACY.S2.TITLE' | translate }}

+

{{ 'LEGAL.PRIVACY.S2.P1' | translate }}

+

{{ 'LEGAL.PRIVACY.S2.P2' | translate }}

+ +

{{ 'LEGAL.PRIVACY.S3.TITLE' | translate }}

+

{{ 'LEGAL.PRIVACY.S3.P1' | translate }}

+

{{ 'LEGAL.PRIVACY.S3.P2' | translate }}

+

{{ 'LEGAL.PRIVACY.S3.P3' | translate }}

+ +

{{ 'LEGAL.PRIVACY.S4.TITLE' | translate }}

+

{{ 'LEGAL.PRIVACY.S4.P1' | translate }}

+

{{ 'LEGAL.PRIVACY.S4.P2' | translate }}

+ +

{{ 'LEGAL.PRIVACY.S5.TITLE' | translate }}

+

{{ 'LEGAL.PRIVACY.S5.P1' | translate }}

+

{{ 'LEGAL.PRIVACY.S5.P2' | translate }}

+ +

{{ 'LEGAL.PRIVACY.S6.TITLE' | translate }}

+

{{ 'LEGAL.PRIVACY.S6.P1' | translate }}

+

{{ 'LEGAL.PRIVACY.S6.P2' | translate }}

diff --git a/frontend/src/app/features/legal/terms/terms.component.html b/frontend/src/app/features/legal/terms/terms.component.html index 17025a7..b6d4113 100644 --- a/frontend/src/app/features/legal/terms/terms.component.html +++ b/frontend/src/app/features/legal/terms/terms.component.html @@ -2,17 +2,99 @@

{{ 'LEGAL.TERMS_TITLE' | translate }}

-

{{ 'LEGAL.LAST_UPDATE' | translate }}: February 2026

- -

{{ 'LEGAL.TERMS.SECTION_1' | translate }}

-

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

+

+ {{ 'LEGAL.LAST_UPDATE' | translate }}: {{ 'LEGAL.TERMS_UPDATE_DATE' | translate }} +

-

{{ 'LEGAL.TERMS.SECTION_2' | translate }}

-

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

+

{{ 'LEGAL.TERMS.META.PROVIDER' | translate }}

+

{{ 'LEGAL.TERMS.META.VERSION' | translate }}

+

{{ 'LEGAL.TERMS.META.SCOPE' | translate }}

-

{{ 'LEGAL.TERMS.SECTION_3' | translate }}

-

I prodotti personalizzati e realizzati su misura tramite stampa 3D non sono soggetti al diritto di recesso, a meno di difetti di fabbricazione evidenti o errori rispetto al file fornito.

-

In caso di problemi, vi preghiamo di contattarci entro 14 giorni dalla ricezione per valutare una sostituzione o un rimborso parziale.

+

{{ 'LEGAL.TERMS.S1.TITLE' | translate }}

+

{{ 'LEGAL.TERMS.S1.P1' | translate }}

+

{{ 'LEGAL.TERMS.S1.P2' | translate }}

+

{{ 'LEGAL.TERMS.S1.P3' | translate }}

+ +

{{ 'LEGAL.TERMS.S2.TITLE' | translate }}

+

{{ 'LEGAL.TERMS.S2.P1' | translate }}

+

{{ 'LEGAL.TERMS.S2.P2' | translate }}

+

{{ 'LEGAL.TERMS.S2.P3' | translate }}

+ +

{{ 'LEGAL.TERMS.S3.TITLE' | translate }}

+

{{ 'LEGAL.TERMS.S3.P1' | translate }}

+ +

{{ 'LEGAL.TERMS.S4.TITLE' | translate }}

+

{{ 'LEGAL.TERMS.S4.P1' | translate }}

+

{{ 'LEGAL.TERMS.S4.P2' | translate }}

+

{{ 'LEGAL.TERMS.S4.P3' | translate }}

+ +

{{ 'LEGAL.TERMS.S5.TITLE' | translate }}

+

{{ 'LEGAL.TERMS.S5.P1' | translate }}

+

{{ 'LEGAL.TERMS.S5.P2' | translate }}

+

{{ 'LEGAL.TERMS.S5.P3' | translate }}

+ +

{{ 'LEGAL.TERMS.S6.TITLE' | translate }}

+

{{ 'LEGAL.TERMS.S6.P1' | translate }}

+

{{ 'LEGAL.TERMS.S6.P2' | translate }}

+

{{ 'LEGAL.TERMS.S6.P3' | translate }}

+

{{ 'LEGAL.TERMS.S6.P4' | translate }}

+ +

{{ 'LEGAL.TERMS.S7.TITLE' | translate }}

+

{{ 'LEGAL.TERMS.S7.P1' | translate }}

+

{{ 'LEGAL.TERMS.S7.P2' | translate }}

+

{{ 'LEGAL.TERMS.S7.P3' | translate }}

+ +

{{ 'LEGAL.TERMS.S8.TITLE' | translate }}

+

{{ 'LEGAL.TERMS.S8.P1' | translate }}

+

{{ 'LEGAL.TERMS.S8.P2' | translate }}

+

{{ 'LEGAL.TERMS.S8.P3' | translate }}

+ +

{{ 'LEGAL.TERMS.S9.TITLE' | translate }}

+

{{ 'LEGAL.TERMS.S9.P1' | translate }}

+

{{ 'LEGAL.TERMS.S9.P2' | translate }}

+ +

{{ 'LEGAL.TERMS.S10.TITLE' | translate }}

+

{{ 'LEGAL.TERMS.S10.P1' | translate }}

+

{{ 'LEGAL.TERMS.S10.P2' | translate }}

+

{{ 'LEGAL.TERMS.S10.P3' | translate }}

+ +

{{ 'LEGAL.TERMS.S11.TITLE' | translate }}

+

{{ 'LEGAL.TERMS.S11.P1' | translate }}

+

{{ 'LEGAL.TERMS.S11.P2' | translate }}

+

{{ 'LEGAL.TERMS.S11.P3' | translate }}

+

{{ 'LEGAL.TERMS.S11.P4' | translate }}

+ +

{{ 'LEGAL.TERMS.S12.TITLE' | translate }}

+

{{ 'LEGAL.TERMS.S12.P1' | translate }}

+

{{ 'LEGAL.TERMS.S12.P2' | translate }}

+

{{ 'LEGAL.TERMS.S12.P3' | translate }}

+

{{ 'LEGAL.TERMS.S12.P4' | translate }}

+ +

{{ 'LEGAL.TERMS.S13.TITLE' | translate }}

+

{{ 'LEGAL.TERMS.S13.P1' | translate }}

+

{{ 'LEGAL.TERMS.S13.P2' | translate }}

+ +

{{ 'LEGAL.TERMS.S14.TITLE' | translate }}

+

{{ 'LEGAL.TERMS.S14.P1' | translate }}

+

{{ 'LEGAL.TERMS.S14.P2' | translate }}

+

{{ 'LEGAL.TERMS.S14.P3' | translate }}

+ +

{{ 'LEGAL.TERMS.S15.TITLE' | translate }}

+

{{ 'LEGAL.TERMS.S15.P1' | translate }}

+

{{ 'LEGAL.TERMS.S15.P2' | translate }}

+ +

{{ 'LEGAL.TERMS.S16.TITLE' | translate }}

+

{{ 'LEGAL.TERMS.S16.P1' | translate }}

+

{{ 'LEGAL.TERMS.S16.P2' | translate }}

+

{{ 'LEGAL.TERMS.S16.P3' | translate }}

+ +

{{ 'LEGAL.TERMS.S17.TITLE' | translate }}

+

{{ 'LEGAL.TERMS.S17.P1' | translate }}

+ +

{{ 'LEGAL.TERMS.S18.TITLE' | translate }}

+

{{ 'LEGAL.TERMS.S18.P1' | translate }}

+

{{ 'LEGAL.TERMS.S18.P2' | translate }}

+

{{ 'LEGAL.TERMS.S18.P3' | translate }}

diff --git a/frontend/src/app/features/order/order.component.html b/frontend/src/app/features/order/order.component.html new file mode 100644 index 0000000..10c14e3 --- /dev/null +++ b/frontend/src/app/features/order/order.component.html @@ -0,0 +1,164 @@ +
+

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

+

{{ 'TRACKING.SUBTITLE' | 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.METHOD' | translate }}

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

{{ 'PAYMENT.TWINT_TITLE' | translate }}

+
+
+ +

{{ 'PAYMENT.TWINT_DESC' | translate }}

+

{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}

+
+ +
+

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

+
+
+ +
+
+

{{ 'PAYMENT.BANK_TITLE' | translate }}

+
+
+

{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}

+
+
+ + {{ 'PAYMENT.DOWNLOAD_QR' | translate }} + +
+
+
+ +
+ + {{ o.paymentStatus === 'REPORTED' ? ('PAYMENT.IN_VERIFICATION' | translate) : ('PAYMENT.CONFIRM' | translate) }} + +
+
+
+ +
+ +
+

{{ 'PAYMENT.SUMMARY_TITLE' | translate }}

+

#{{ getDisplayOrderNumber(o) }}

+
+ +
+
+ {{ 'PAYMENT.SUBTOTAL' | translate }} + {{ o.subtotalChf | currency:'CHF' }} +
+
+ {{ 'PAYMENT.SHIPPING' | translate }} + {{ o.shippingCostChf | currency:'CHF' }} +
+
+ {{ 'PAYMENT.SETUP_FEE' | translate }} + {{ o.setupCostChf | currency:'CHF' }} +
+
+ {{ 'PAYMENT.TOTAL' | translate }} + {{ o.totalChf | currency:'CHF' }} +
+
+ +
+
+
+
+
+ +
+ +

{{ 'PAYMENT.LOADING' | translate }}

+
+
+ +
+ +

{{ error()! | translate }}

+
+
+
diff --git a/frontend/src/app/features/order/order.component.scss b/frontend/src/app/features/order/order.component.scss new file mode 100644 index 0000000..c3db3ba --- /dev/null +++ b/frontend/src/app/features/order/order.component.scss @@ -0,0 +1,336 @@ +.hero { + padding: var(--space-12) 0 var(--space-8); + text-align: center; + + h1 { + font-size: 2.5rem; + margin-bottom: var(--space-2); + } +} + +.subtitle { + font-size: 1.125rem; + color: var(--color-text-muted); + max-width: 600px; + margin: 0 auto; +} + +.payment-layout { + display: grid; + grid-template-columns: 1fr 400px; + gap: var(--space-8); + align-items: start; + margin-bottom: var(--space-12); + + @media (max-width: 1024px) { + grid-template-columns: 1fr; + gap: var(--space-8); + } +} + +.card-header-simple { + 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; + color: var(--color-text); + margin: 0; + } + + .order-id { + font-size: 0.875rem; + color: var(--color-text-muted); + margin-top: 2px; + } +} + +.payment-selection { + margin-bottom: var(--space-6); +} + +.methods-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-4); + + @media (max-width: 600px) { + grid-template-columns: 1fr; + } +} + +.type-option { + border: 2px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-6); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + background: var(--color-bg-card); + text-align: center; + font-weight: 600; + color: var(--color-text-muted); + + &:hover { + border-color: var(--color-brand); + color: var(--color-text); + } + + &.selected { + border-color: var(--color-brand); + background-color: var(--color-neutral-100); + color: #000; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + } +} + +.payment-details { + background: var(--color-neutral-100); + border-radius: var(--radius-md); + padding: var(--space-6); + margin-bottom: var(--space-6); + border: 1px solid var(--color-border); + + &.text-center { + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + + .details-header { + width: 100%; + text-align: center; + } + + .qr-placeholder { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + } + } + + .details-header { + margin-bottom: var(--space-4); + h4 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + } + } +} + + +.qr-placeholder { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + + .twint-qr { + width: 240px; + height: 240px; + background-color: #fff; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-2); + margin-bottom: var(--space-4); + object-fit: contain; + box-shadow: 0 6px 18px rgba(44, 37, 84, 0.08); + } + + .twint-mobile-action { + width: 100%; + max-width: 320px; + margin-top: var(--space-3); + display: flex; + justify-content: center; + } + + .amount { + font-size: 1.25rem; + font-weight: 700; + margin-top: var(--space-2); + color: var(--color-text); + } +} + +.billing-hint { + margin-top: var(--space-3); + font-size: 0.95rem; + color: var(--color-text-muted); +} + +.bank-details { + p { + margin-bottom: var(--space-2); + font-size: 1rem; + color: var(--color-text); + } +} + +.qr-bill-actions { + margin-top: var(--space-4); +} + +.sticky-card { + position: sticky; + top: var(--space-6); +} + +.summary-totals { + background: var(--color-neutral-100); + padding: var(--space-6); + border-radius: var(--radius-md); + + .total-row { + display: flex; + justify-content: space-between; + margin-bottom: var(--space-2); + font-size: 0.95rem; + color: var(--color-text-muted); + } + + .grand-total-row { + display: flex; + justify-content: space-between; + color: var(--color-text); + font-weight: 700; + font-size: 1.5rem; + margin-top: var(--space-4); + padding-top: var(--space-4); + border-top: 2px solid var(--color-border); + } +} + +.actions { + margin-top: var(--space-8); +} + +.fade-in { + animation: fadeIn 0.4s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-5px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.mb-6 { margin-bottom: var(--space-6); } + +.error-message, +.loading-state { + margin-top: var(--space-12); + text-align: center; +} + +.status-timeline { + display: flex; + justify-content: space-between; + margin-bottom: var(--space-8); + position: relative; + /* padding: var(--space-6); */ /* Removed if it was here to match non-card layout */ + + &::before { + content: ''; + position: absolute; + top: 15px; + left: 12.5%; + right: 12.5%; + height: 2px; + background: var(--color-border); + z-index: 1; + } +} + +.timeline-step { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + z-index: 2; + flex: 1; + text-align: center; + + .circle { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--color-neutral-100); + border: 2px solid var(--color-border); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + margin-bottom: var(--space-2); + color: var(--color-text-muted); + transition: all 0.3s ease; + } + + .label { + font-size: 0.85rem; + color: var(--color-text-muted); + font-weight: 500; + } + + &.active { + .circle { + border-color: var(--color-brand); + background: var(--color-bg); + color: var(--color-brand); + } + .label { + color: var(--color-text); + font-weight: 600; + } + } + + &.completed { + .circle { + background: var(--color-brand); + border-color: var(--color-brand); + color: white; + } + .label { + color: var(--color-text); + } + } +} + +@media (max-width: 600px) { + .status-timeline { + flex-direction: column; + align-items: flex-start; + gap: var(--space-4); + + &::before { + top: 10px; + bottom: 10px; + left: 15px; + width: 2px; + height: auto; + } + + .timeline-step { + flex-direction: row; + gap: var(--space-3); + + .circle { + margin-bottom: 0; + } + } + } +} diff --git a/frontend/src/app/features/order/order.component.ts b/frontend/src/app/features/order/order.component.ts new file mode 100644 index 0000000..0423560 --- /dev/null +++ b/frontend/src/app/features/order/order.component.ts @@ -0,0 +1,166 @@ +import { Component, OnInit, inject, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; +import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; +import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { environment } from '../../../environments/environment'; + +@Component({ + selector: 'app-order', + standalone: true, + imports: [CommonModule, AppButtonComponent, AppCardComponent, TranslateModule], + templateUrl: './order.component.html', + styleUrl: './order.component.scss' +}) +export class OrderComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private quoteService = inject(QuoteEstimatorService); + private translate = inject(TranslateService); + + orderId: string | null = null; + selectedPaymentMethod: 'twint' | 'bill' | null = 'twint'; + order = signal(null); + loading = signal(true); + error = signal(null); + twintOpenUrl = signal(null); + twintQrUrl = signal(null); + + ngOnInit(): void { + this.orderId = this.route.snapshot.paramMap.get('orderId'); + if (this.orderId) { + this.loadOrder(); + this.loadTwintPayment(); + } else { + this.error.set('ORDER.ERR_ID_NOT_FOUND'); + this.loading.set(false); + } + } + + loadOrder() { + if (!this.orderId) return; + this.quoteService.getOrder(this.orderId).subscribe({ + next: (order) => { + this.order.set(order); + this.loading.set(false); + }, + error: (err) => { + console.error('Failed to load order', err); + this.error.set('ORDER.ERR_LOAD_ORDER'); + this.loading.set(false); + } + }); + } + + selectPayment(method: 'twint' | 'bill'): void { + this.selectedPaymentMethod = method; + } + + downloadQrInvoice() { + const orderId = this.orderId; + if (!orderId) return; + this.quoteService.getOrderConfirmation(orderId).subscribe({ + next: (blob) => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const fallbackOrderNumber = this.extractOrderNumber(orderId); + const orderNumber = this.order()?.orderNumber ?? fallbackOrderNumber; + a.download = `qr-invoice-${orderNumber}.pdf`; + a.click(); + window.URL.revokeObjectURL(url); + }, + error: (err) => console.error('Failed to download QR invoice', err) + }); + } + + loadTwintPayment() { + 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; + 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); + } + }); + } + + openTwintPayment(): void { + const openUrl = this.twintOpenUrl(); + if (typeof window !== 'undefined' && openUrl) { + window.open(openUrl, '_blank'); + } + } + + getTwintQrUrl(): string { + return this.twintQrUrl() ?? ''; + } + + getTwintButtonImageUrl(): string { + const lang = this.translate.currentLang; + if (lang === 'de') { + return 'https://go.twint.ch/static/img/button_dark_de.svg'; + } + if (lang === 'it'){ + return 'https://go.twint.ch/static/img/button_dark_it.svg'; + } + 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 + return 'https://go.twint.ch/static/img/button_dark_en.svg'; + } + + onTwintQrError(): void { + this.twintQrUrl.set(null); + } + + private resolveApiUrl(urlOrPath: string | null | undefined): string | null { + if (!urlOrPath) return null; + if (urlOrPath.startsWith('http://') || urlOrPath.startsWith('https://')) { + return urlOrPath; + } + const base = (environment.apiUrl || '').replace(/\/$/, ''); + const path = urlOrPath.startsWith('/') ? urlOrPath : `/${urlOrPath}`; + return `${base}${path}`; + } + + completeOrder(): void { + 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'); + } + }); + } + + getDisplayOrderNumber(order: any): string { + if (order?.orderNumber) { + return order.orderNumber; + } + if (order?.id) { + return this.extractOrderNumber(order.id); + } + return this.translate.instant('ORDER.NOT_AVAILABLE'); + } + + private extractOrderNumber(orderId: string): string { + return orderId.split('-')[0]; + } +} 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 cc862a0..01cf2a8 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 @@ -1,13 +1,13 @@
- {{ product().category }} + {{ product().category | translate }}

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

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 aa7833d..5a7e37e 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 @@ -1,12 +1,13 @@ import { Component, input } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; import { Product } from '../../services/shop.service'; @Component({ selector: 'app-product-card', standalone: true, - imports: [CommonModule, RouterLink], + imports: [CommonModule, RouterLink, TranslateModule], templateUrl: './product-card.component.html', styleUrl: './product-card.component.scss' }) diff --git a/frontend/src/app/features/shop/product-detail.component.html b/frontend/src/app/features/shop/product-detail.component.html index a08b543..7773cdb 100644 --- a/frontend/src/app/features/shop/product-detail.component.html +++ b/frontend/src/app/features/shop/product-detail.component.html @@ -6,11 +6,11 @@
- {{ p.category }} -

{{ p.name }}

+ {{ p.category | translate }} +

{{ p.name | translate }}

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

-

{{ p.description }}

+

{{ p.description | translate }}

@@ -20,6 +20,6 @@
} @else { -

Prodotto non trovato.

+

{{ 'SHOP.NOT_FOUND' | translate }}

}
diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts index 1e8fa74..873dad7 100644 --- a/frontend/src/app/features/shop/product-detail.component.ts +++ b/frontend/src/app/features/shop/product-detail.component.ts @@ -1,7 +1,7 @@ import { Component, input, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ShopService, Product } from './services/shop.service'; import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; @@ -18,7 +18,10 @@ export class ProductDetailComponent { product = signal(undefined); - constructor(private shopService: ShopService) {} + constructor( + private shopService: ShopService, + private translate: TranslateService + ) {} ngOnInit() { const productId = this.id(); @@ -28,6 +31,6 @@ export class ProductDetailComponent { } addToCart() { - alert('Aggiunto al carrello (Mock)'); + alert(this.translate.instant('SHOP.MOCK_ADD_CART')); } } diff --git a/frontend/src/app/features/shop/services/shop.service.ts b/frontend/src/app/features/shop/services/shop.service.ts index 644ec71..7974b74 100644 --- a/frontend/src/app/features/shop/services/shop.service.ts +++ b/frontend/src/app/features/shop/services/shop.service.ts @@ -17,24 +17,24 @@ export class ShopService { private staticProducts: Product[] = [ { id: '1', - name: 'Filamento PLA Standard', - description: 'Il classico per ogni stampa, facile e affidabile.', + name: 'SHOP.PRODUCTS.P1.NAME', + description: 'SHOP.PRODUCTS.P1.DESC', price: 24.90, - category: 'Filamenti' + category: 'SHOP.CATEGORIES.FILAMENTS' }, { id: '2', - name: 'Filamento PETG Tough', - description: 'Resistente agli urti e alle temperature.', + name: 'SHOP.PRODUCTS.P2.NAME', + description: 'SHOP.PRODUCTS.P2.DESC', price: 29.90, - category: 'Filamenti' + category: 'SHOP.CATEGORIES.FILAMENTS' }, { id: '3', - name: 'Kit Ugelli (0.4mm)', - description: 'Set di ricambio per estrusore FDM.', + name: 'SHOP.PRODUCTS.P3.NAME', + description: 'SHOP.PRODUCTS.P3.DESC', price: 15.00, - category: 'Accessori' + category: 'SHOP.CATEGORIES.ACCESSORIES' } ]; @@ -45,4 +45,4 @@ export class ShopService { getProductById(id: string): Observable { return of(this.staticProducts.find(p => p.id === id)); } -} \ No newline at end of file +} diff --git a/frontend/src/app/features/shop/shop-page.component.html b/frontend/src/app/features/shop/shop-page.component.html index f43032e..6479737 100644 --- a/frontend/src/app/features/shop/shop-page.component.html +++ b/frontend/src/app/features/shop/shop-page.component.html @@ -1,12 +1,18 @@ -
-

{{ 'SHOP.TITLE' | translate }}

-

{{ 'SHOP.SUBTITLE' | translate }}

-
+
+
+
+

{{ 'SHOP.WIP_EYEBROW' | translate }}

+

{{ 'SHOP.WIP_TITLE' | translate }}

+

{{ 'SHOP.WIP_SUBTITLE' | translate }}

-
-
- @for (product of products(); track product.id) { - - } +
+ + {{ 'SHOP.WIP_CTA_CALC' | translate }} + +
+ +

{{ 'SHOP.WIP_RETURN_LATER' | translate }}

+

{{ 'SHOP.WIP_NOTE' | translate }}

+
-
+
diff --git a/frontend/src/app/features/shop/shop-page.component.scss b/frontend/src/app/features/shop/shop-page.component.scss index 507fea1..3288aec 100644 --- a/frontend/src/app/features/shop/shop-page.component.scss +++ b/frontend/src/app/features/shop/shop-page.component.scss @@ -1,7 +1,72 @@ -.hero { padding: var(--space-8) 0; text-align: center; } -.subtitle { color: var(--color-text-muted); margin-bottom: var(--space-8); } -.grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: var(--space-6); +.wip-section { + position: relative; + padding: var(--space-12) 0; + background-color: var(--color-bg); +} + +.wip-card { + max-width: 760px; + margin: 0 auto; + padding: clamp(1.4rem, 3vw, 2.4rem); + border: 1px solid var(--color-border); + border-radius: var(--radius-xl); + background: rgba(255, 255, 255, 0.95); + box-shadow: var(--shadow-lg); + text-align: center; +} + +.wip-eyebrow { + display: inline-block; + margin-bottom: var(--space-3); + padding: 0.3rem 0.7rem; + border-radius: 999px; + border: 1px solid rgba(16, 24, 32, 0.14); + font-size: 0.78rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-secondary-600); + background: rgba(250, 207, 10, 0.28); +} + +h1 { + font-size: clamp(1.7rem, 4vw, 2.5rem); + margin-bottom: var(--space-4); + color: var(--color-text); +} + +.wip-subtitle { + max-width: 60ch; + margin: 0 auto var(--space-8); + color: var(--color-text-muted); +} + +.wip-actions { + display: flex; + gap: var(--space-4); + justify-content: center; + flex-wrap: wrap; +} + +.wip-note { + margin: var(--space-4) auto 0; + max-width: 62ch; + font-size: 0.95rem; + color: var(--color-secondary-600); +} + +.wip-return-later { + margin: var(--space-6) 0 0; + font-weight: 600; + color: var(--color-secondary-600); +} + +@media (max-width: 640px) { + .wip-section { + padding: var(--space-10) 0; + } + + .wip-actions { + flex-direction: column; + align-items: stretch; + } } diff --git a/frontend/src/app/features/shop/shop-page.component.ts b/frontend/src/app/features/shop/shop-page.component.ts index 8f89098..a2312f9 100644 --- a/frontend/src/app/features/shop/shop-page.component.ts +++ b/frontend/src/app/features/shop/shop-page.component.ts @@ -1,22 +1,14 @@ -import { Component, signal } from '@angular/core'; +import { Component } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { RouterLink } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; -import { ShopService, Product } from './services/shop.service'; -import { ProductCardComponent } from './components/product-card/product-card.component'; +import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; @Component({ selector: 'app-shop-page', standalone: true, - imports: [CommonModule, TranslateModule, ProductCardComponent], + imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent], templateUrl: './shop-page.component.html', styleUrl: './shop-page.component.scss' }) -export class ShopPageComponent { - products = signal([]); - - constructor(private shopService: ShopService) { - this.shopService.getProducts().subscribe(data => { - this.products.set(data); - }); - } -} +export class ShopPageComponent {} 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 407d667..55bb342 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 @@ -32,12 +32,14 @@ .btn-outline { background-color: transparent; - border-color: var(--color-border); - color: var(--color-text); + border-color: var(--color-brand); + border-width: 2px; + padding: calc(0.5rem - 1px) calc(1rem - 1px); + color: var(--color-neutral-900); + font-weight: 600; &:hover:not(:disabled) { - border-color: var(--color-brand); + background-color: var(--color-brand); color: var(--color-neutral-900); - background-color: rgba(250, 207, 10, 0.1); /* Low opacity brand color */ } } 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 c0e7484..f394ac1 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,11 +9,13 @@ border: 1px solid var(--color-border); box-shadow: var(--shadow-sm); padding: var(--space-6); - transition: box-shadow 0.2s; + 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); + border-color: var(--color-neutral-300); } } 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 692d3a4..d76e9bb 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 @@ -12,8 +12,8 @@
-

{{ label() }}

-

{{ subtext() }}

+

{{ label() | translate }}

+

{{ subtext() | translate }}

@if (fileNames().length > 0) {
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 fd1e563..84591bf 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 @@ -1,17 +1,18 @@ import { Component, input, output, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; @Component({ selector: 'app-dropzone', standalone: true, - imports: [CommonModule], + imports: [CommonModule, TranslateModule], templateUrl: './app-dropzone.component.html', styleUrl: './app-dropzone.component.scss' }) export class AppDropzoneComponent { - label = input('Drop files here or click to upload'); - subtext = input('Supports .stl, .obj'); - accept = input('.stl,.obj'); + label = input('DROPZONE.DEFAULT_LABEL'); + subtext = input('DROPZONE.DEFAULT_SUBTEXT'); + accept = input('.stl,.3mf,.step,.stp'); multiple = input(true); filesDropped = output(); 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 18eb1d7..e5de85e 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,6 @@ .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-danger-500); margin-left: 2px; } +.required-mark { color: var(--color-text); margin-left: 2px; } .form-control { padding: 0.5rem 0.75rem; border: 1px solid var(--color-border); 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 e8a827c..6551a4d 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 @@ -8,30 +8,23 @@
- - + +
-

{{ 'LOCATIONS.TICINO' | translate }}

+

{{ 'LOCATIONS.BIASCA' | translate }}

{{ 'LOCATIONS.ADDRESS_TICINO' | translate }}

{{ 'LOCATIONS.BIENNE' | translate }}

{{ 'LOCATIONS.ADDRESS_BIENNE' | translate }}

- +
- -
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 2396f8d..8c6a666 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 @@ -29,41 +29,21 @@ align-items: start; @media(min-width: 992px) { - grid-template-columns: 1fr 2fr; + grid-template-columns: repeat(2, minmax(320px, 420px)); + justify-content: center; } } +.locations-controls { + width: 100%; + max-width: 420px; +} + .location-tabs { - display: flex; - gap: 1rem; margin-bottom: 2rem; - background: var(--color-bg); - padding: 0.5rem; - border-radius: var(--radius-md); - border: 1px solid var(--color-border); + width: 100%; } -.tab-btn { - flex: 1; - padding: 0.75rem; - border: none; - background: transparent; - border-radius: var(--radius-sm); - color: var(--color-text-muted); - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; - - &:hover { - color: var(--color-text-main); - } - - &.active { - background: var(--color-primary-500); - color: var(--color-neutral-900); - box-shadow: var(--shadow-sm); - } -} .location-details { padding: 2rem; @@ -107,7 +87,15 @@ border: 1px solid var(--color-border); box-shadow: var(--shadow-lg); background: var(--color-bg); - height: 450px; + width: 100%; + max-width: 420px; + justify-self: stretch; + height: 320px; + + @media (max-width: 991px) { + max-width: 100%; + height: 300px; + } iframe { width: 100%; 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 89988ff..b8e3471 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,18 +2,24 @@ 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'; @Component({ selector: 'app-locations', standalone: true, - imports: [CommonModule, TranslateModule, RouterLink], + imports: [CommonModule, TranslateModule, RouterLink, AppToggleSelectorComponent], templateUrl: './app-locations.component.html', styleUrl: './app-locations.component.scss' }) export class AppLocationsComponent { selectedLocation: 'ticino' | 'bienne' = 'ticino'; - selectLocation(location: 'ticino' | 'bienne') { + locationOptions: ToggleOption[] = [ + { label: 'LOCATIONS.TICINO', value: 'ticino' }, + { label: 'LOCATIONS.BIENNE', value: 'bienne' }, + ]; + + selectLocation(location: any) { this.selectedLocation = location; } } 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 new file mode 100644 index 0000000..5702aeb --- /dev/null +++ b/frontend/src/app/shared/components/app-toggle-selector/app-toggle-selector.component.html @@ -0,0 +1,9 @@ +
+ @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 new file mode 100644 index 0000000..6e2737a --- /dev/null +++ b/frontend/src/app/shared/components/app-toggle-selector/app-toggle-selector.component.scss @@ -0,0 +1,34 @@ +:host { + display: block; +} + +.user-type-selector { + display: flex; + background-color: var(--color-neutral-100); + border-radius: var(--radius-md); + padding: 4px; + gap: 4px; + width: 100%; +} + +.type-option { + flex: 1; + text-align: center; + padding: 8px 16px; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text-muted); + transition: all 0.2s ease; + user-select: none; + + &: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); + } +} 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 new file mode 100644 index 0000000..35af98d --- /dev/null +++ b/frontend/src/app/shared/components/app-toggle-selector/app-toggle-selector.component.ts @@ -0,0 +1,26 @@ +import { Component, input, output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; + +export interface ToggleOption { + label: string; + value: any; +} + +@Component({ + selector: 'app-toggle-selector', + standalone: true, + imports: [CommonModule, TranslateModule], + templateUrl: './app-toggle-selector.component.html', + styleUrl: './app-toggle-selector.component.scss' +}) +export class AppToggleSelectorComponent { + options = input.required(); + selectedValue = input.required(); + + selectionChange = output(); + + selectOption(value: any) { + this.selectionChange.emit(value); + } +} 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 079a5b6..89757fe 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 @@ -14,7 +14,7 @@
@for (category of categories(); track category.name) {
-
{{ category.name }}
+
{{ category.name | translate }}
@for (color of category.colors; track color.value) {
- {{ color.label }} + {{ color.label | translate }}
}
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 7ac3185..bf6cc55 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 @@ -13,25 +13,33 @@ import { VariantOption } from '../../../features/calculator/services/quote-estim }) export class ColorSelectorComponent { selectedColor = input('Black'); + selectedVariantId = input(null); variants = input([]); - colorSelected = output(); + colorSelected = output<{ colorName: string; filamentVariantId?: number }>(); isOpen = signal(false); categories = computed(() => { const vars = this.variants(); if (vars && vars.length > 0) { - // Flatten variants into a single category for now - // We could try to group by extracting words, but "Colors" is fine. - return [{ - name: 'Available Colors', - colors: vars.map(v => ({ - label: v.colorName, // Display "Red" - value: v.colorName, // Send "Red" to backend + 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 - })) - }] as ColorCategory[]; + }); + byFinish.set(finish, bucket); + }); + + return Array.from(byFinish.entries()).map(([finish, colors]) => ({ + name: finish, + colors + })) as ColorCategory[]; } return PRODUCT_COLORS; }); @@ -42,8 +50,11 @@ export class ColorSelectorComponent { selectColor(color: ColorOption) { if (color.outOfStock) return; - - this.colorSelected.emit(color.value); + + this.colorSelected.emit({ + colorName: color.value, + filamentVariantId: color.variantId + }); 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 afeb836..dcfd874 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,7 +2,7 @@ @if (loading) {
- Loading 3D Model... + {{ 'STL_VIEWER.LOADING' | translate }}
} @if (file && !loading) { 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 b9ce5cc..d283437 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,5 +1,6 @@ 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'; // @ts-ignore import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'; @@ -9,7 +10,7 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; @Component({ selector: 'app-stl-viewer', standalone: true, - imports: [CommonModule], + imports: [CommonModule, TranslateModule], templateUrl: './stl-viewer.component.html', styleUrl: './stl-viewer.component.scss' }) @@ -25,6 +26,7 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { private controls!: OrbitControls; private animationId: number | null = null; private currentMesh: THREE.Mesh | null = null; + private autoRotate = true; loading = false; @@ -38,14 +40,14 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { } if (changes['color'] && this.currentMesh && !changes['file']) { - // Update existing mesh color if only color changed - const mat = this.currentMesh.material as THREE.MeshPhongMaterial; - mat.color.set(this.color); + this.applyColorStyle(this.color); } } ngOnDestroy() { if (this.animationId) cancelAnimationFrame(this.animationId); + this.clearCurrentMesh(); + if (this.controls) this.controls.dispose(); if (this.renderer) this.renderer.dispose(); } @@ -54,28 +56,51 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { const height = this.rendererContainer.nativeElement.clientHeight; this.scene = new THREE.Scene(); - this.scene.background = new THREE.Color(0xf7f6f2); // Neutral-50 + this.scene.background = new THREE.Color(0xf4f8fc); // Lights - const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); + const ambientLight = new THREE.AmbientLight(0xffffff, 0.75); this.scene.add(ambientLight); - const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); - directionalLight.position.set(1, 1, 1); - this.scene.add(directionalLight); + const hemiLight = new THREE.HemisphereLight(0xf8fbff, 0xc8d3df, 0.95); + hemiLight.position.set(0, 30, 0); + this.scene.add(hemiLight); + + const directionalLight1 = new THREE.DirectionalLight(0xffffff, 1.35); + directionalLight1.position.set(6, 8, 6); + this.scene.add(directionalLight1); + + const directionalLight2 = new THREE.DirectionalLight(0xe8f0ff, 0.85); + directionalLight2.position.set(-7, 4, -5); + this.scene.add(directionalLight2); + + const directionalLight3 = new THREE.DirectionalLight(0xffffff, 0.55); + directionalLight3.position.set(0, 5, -9); + this.scene.add(directionalLight3); // Camera this.camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000); this.camera.position.z = 100; // Renderer - this.renderer = new THREE.WebGLRenderer({ antialias: true }); + 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; + this.renderer.toneMappingExposure = 1.2; this.renderer.setSize(width, height); this.rendererContainer.nativeElement.appendChild(this.renderer.domElement); // Controls this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; + this.controls.dampingFactor = 0.06; + this.controls.enablePan = false; + this.controls.minDistance = 10; + this.controls.maxDistance = 600; + this.controls.addEventListener('start', () => { + this.autoRotate = false; + }); this.animate(); @@ -95,24 +120,27 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { private loadFile(file: File) { this.loading = true; + this.autoRotate = true; const reader = new FileReader(); reader.onload = (event) => { try { const loader = new STLLoader(); const geometry = loader.parse(event.target?.result as ArrayBuffer); - if (this.currentMesh) { - this.scene.remove(this.currentMesh); - this.currentMesh.geometry.dispose(); - } + this.clearCurrentMesh(); - const material = new THREE.MeshPhongMaterial({ - color: this.color, - specular: 0x111111, - shininess: 200 + geometry.computeVertexNormals(); + + const material = new THREE.MeshStandardMaterial({ + color: this.color, + roughness: 0.42, + metalness: 0.05, + emissive: 0x000000, + emissiveIntensity: 0 }); this.currentMesh = new THREE.Mesh(geometry, material); + this.applyColorStyle(this.color); // Center geometry geometry.computeBoundingBox(); @@ -140,9 +168,10 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { // Calculate distance towards camera (z-axis) let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)); - cameraZ *= 1.5; // Tighter zoom (reduced from 2.5) + cameraZ *= 1.72; - this.camera.position.z = cameraZ; + 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(); @@ -157,9 +186,63 @@ 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); } } + + private clearCurrentMesh() { + if (!this.currentMesh) { + return; + } + + this.scene.remove(this.currentMesh); + this.currentMesh.geometry.dispose(); + + const meshMaterial = this.currentMesh.material; + if (Array.isArray(meshMaterial)) { + meshMaterial.forEach((m) => m.dispose()); + } else { + meshMaterial.dispose(); + } + + this.currentMesh = null; + } + + private applyColorStyle(color: string) { + if (!this.currentMesh) { + return; + } + + const darkColor = this.isDarkColor(color); + const meshMaterial = this.currentMesh.material; + + if (meshMaterial instanceof THREE.MeshStandardMaterial) { + meshMaterial.color.set(color); + if (darkColor) { + meshMaterial.emissive.set(0x2a2f36); + meshMaterial.emissiveIntensity = 0.28; + meshMaterial.roughness = 0.5; + meshMaterial.metalness = 0.03; + } else { + meshMaterial.emissive.set(0x000000); + meshMaterial.emissiveIntensity = 0; + meshMaterial.roughness = 0.42; + meshMaterial.metalness = 0.05; + } + meshMaterial.needsUpdate = true; + } + } + + private isDarkColor(color: string): boolean { + const c = new THREE.Color(color); + const luminance = 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b; + return luminance < 0.22; + } } diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json new file mode 100644 index 0000000..b6b0b00 --- /dev/null +++ b/frontend/src/assets/i18n/de.json @@ -0,0 +1,547 @@ +{ + "NAV": { + "HOME": "Startseite", + "CALCULATOR": "Rechner", + "SHOP": "Shop", + "ABOUT": "Über uns", + "CONTACT": "Kontakt", + "LANGUAGE_SELECTOR": "Sprachauswahl" + }, + "QUOTE": { + "CONSULT": "Beratung anfragen", + "PROCEED_ORDER": "Zur Bestellung", + "TOTAL": "Gesamt", + "MAX_QTY_NOTICE": "Für Mengen über {{max}} Stück bitte Beratung anfragen." + }, + "USER_DETAILS": { + "TITLE": "Ihre Daten", + "SUMMARY_TITLE": "Zusammenfassung", + "NAME": "Vorname", + "NAME_PLACEHOLDER": "Ihr Vorname", + "SURNAME": "Nachname", + "SURNAME_PLACEHOLDER": "Ihr Nachname", + "EMAIL": "E-Mail", + "EMAIL_PLACEHOLDER": "ihre@email.com", + "PHONE": "Telefon", + "PHONE_PLACEHOLDER": "+41 ...", + "ADDRESS": "Adresse", + "ADDRESS_PLACEHOLDER": "Straße und Nummer", + "ZIP": "PLZ", + "ZIP_PLACEHOLDER": "0000", + "CITY": "Ort", + "CITY_PLACEHOLDER": "Ort", + "SUBMIT": "Weiter", + "DEFAULT_COLOR": "Standard" + }, + "FOOTER": { + "PRIVACY": "Datenschutz", + "TERMS": "AGB", + "CONTACT": "Kontakt" + }, + "CALC": { + "TITLE": "3D-Angebot berechnen", + "SUBTITLE": "Laden Sie Ihre 3D-Datei (STL, 3MF, STEP) hoch, stellen Sie Qualität und Farbe ein und berechnen Sie sofort Preis und Lieferzeit.", + "CTA_START": "Jetzt starten", + "BUSINESS": "Unternehmen", + "PRIVATE": "Privat", + "MODE_EASY": "Basis", + "MODE_ADVANCED": "Erweitert", + "UPLOAD_LABEL": "Ziehen Sie Ihre 3D-Datei hierher", + "UPLOAD_SUB": "Wir unterstützen STL, 3MF, STEP bis 50MB", + "MATERIAL": "Material", + "QUALITY": "Qualität", + "QUANTITY": "Menge", + "NOTES": "Zusätzliche Hinweise", + "NOZZLE": "Düsendurchmesser", + "INFILL": "Fülldichte (%)", + "PATTERN": "Füllmuster", + "LAYER_HEIGHT": "Schichthöhe", + "SUPPORT": "Stützstrukturen", + "SUPPORT_DESC": "Stützen für Überhänge aktivieren", + "CALCULATE": "Angebot berechnen", + "RESULT": "Geschätztes Angebot", + "TIME": "Druckzeit", + "MACHINE_COST": "Maschinenkosten", + "COST": "Gesamtkosten", + "ORDER": "Jetzt bestellen", + "CONSULT": "Beratung anfragen", + "ERROR_GENERIC": "Bei der Angebotsberechnung ist ein Fehler aufgetreten.", + "NEW_QUOTE": "Neues Angebot berechnen", + "ORDER_SUCCESS_TITLE": "Bestellung erfolgreich gesendet", + "ORDER_SUCCESS_DESC": "Wir haben die Details Ihrer Bestellung erhalten. Sie erhalten in Kürze eine Bestätigungs-E-Mail mit Zahlungsinformationen.", + "BENEFITS_TITLE": "Warum uns wählen?", + "BENEFITS_1": "Automatisches Angebot mit sofortigen Kosten und Zeiten", + "BENEFITS_2": "Ausgewählte Materialien und Qualitätskontrolle", + "BENEFITS_3": "CAD-Beratung, falls die Datei Änderungen benötigt", + "ERR_FILE_REQUIRED": "Die Datei ist erforderlich.", + "STEP_WARNING": "Die 3D-Ansicht ist mit STEP- und 3MF-Dateien nicht kompatibel", + "REMOVE_FILE": "Datei entfernen", + "FALLBACK_MATERIAL": "PLA (Fallback)", + "FALLBACK_QUALITY_STANDARD": "Standard", + "ERR_FILE_TOO_LARGE": "Einige Dateien überschreiten das 200MB-Limit und wurden nicht hinzugefügt.", + "PRINT_SPEED": "Druckgeschwindigkeit", + "COLOR": "Farbe", + "ANALYZING_TITLE": "Analyse läuft...", + "ANALYZING_TEXT": "Wir analysieren die Geometrie und berechnen den Werkzeugpfad.", + "QTY_SHORT": "MENGE", + "COLOR_LABEL": "FARBE", + "ADD_FILES": "Dateien hinzufügen", + "UPLOADING": "Hochladen...", + "PROCESSING": "Verarbeitung...", + "NOTES_PLACEHOLDER": "Spezifische Anweisungen...", + "SETUP_NOTE": "* Beinhaltet {{cost}} als Einrichtungskosten", + "SHIPPING_NOTE": "** Versandkosten ausgeschlossen, werden im nächsten Schritt berechnet", + "ERROR_ZERO_PRICE": "Etwas ist schiefgelaufen. Versuche ein anderes Format oder kontaktiere uns." + }, + "SHOP": { + "TITLE": "Technische Lösungen", + "SUBTITLE": "Fertige Produkte, die praktische Probleme lösen", + "WIP_EYEBROW": "Work in progress", + "WIP_TITLE": "Shop im Aufbau", + "WIP_SUBTITLE": "Wir bereiten einen Shop mit ausgewählten Produkten und Funktionen zur automatischen Erstellung vor!", + "WIP_CTA_CALC": "Zum Rechner", + "WIP_RETURN_LATER": "Kommen Sie später wieder", + "WIP_NOTE": "Wir legen Wert darauf, die Dinge richtig zu machen: In der Zwischenzeit können Sie Preis und Lieferzeit einer 3D-Datei sofort mit unserem Rechner berechnen.", + "ADD_CART": "In den Warenkorb", + "BACK": "Zurück zum Shop", + "NOT_FOUND": "Produkt nicht gefunden.", + "DETAILS": "Details", + "MOCK_ADD_CART": "Zum Warenkorb hinzugefügt (Mock)", + "SUCCESS_TITLE": "Zum Warenkorb hinzugefügt", + "SUCCESS_DESC": "Das Produkt wurde erfolgreich zum Warenkorb hinzugefügt.", + "CONTINUE": "Weiter", + "CATEGORIES": { + "FILAMENTS": "Filamente", + "ACCESSORIES": "Zubehör" + }, + "PRODUCTS": { + "P1": { + "NAME": "PLA-Standardfilament", + "DESC": "Der Klassiker für jeden Druck, einfach und zuverlässig." + }, + "P2": { + "NAME": "PETG Tough Filament", + "DESC": "Stoß- und temperaturbeständig." + }, + "P3": { + "NAME": "Düsenset (0.4mm)", + "DESC": "Ersatzset für FDM-Extruder." + } + } + }, + "ABOUT": { + "TITLE": "Über uns", + "EYEBROW": "3D-Druck-Labor", + "SUBTITLE": "Wir sind zwei Studenten mit viel Motivation und Lernbereitschaft.", + "HOW_TEXT": "3D Fab ist entstanden, um das Potenzial des 3D-Drucks in alltagstaugliche Lösungen zu verwandeln. Wir haben mit technischer Neugier begonnen und sind zur Herstellung von Ersatzteilen, Produkten und maßgeschneiderten Prototypen gekommen. Um von einer Idee zu einem konkreten Projekt zu gelangen, haben wir unseren automatischen Rechner eingeführt: klare Angebote in einem Klick, damit Sie einen professionellen Service ohne Preisüberraschungen erhalten.", + "PASSIONS_TITLE": "Unsere Interessen", + "PASSION_BIKE_TRIAL": "Bike Trial", + "PASSION_MOUNTAIN": "Berge", + "PASSION_SKI": "Ski", + "PASSION_SELF_HOSTING": "Self Hosting", + "PASSION_PRINT_3D": "3D-Druck", + "PASSION_TRAVEL": "Reisen", + "PASSION_SNOWBOARD": "Snowboard", + "PASSION_SNOWBOARD_INSTRUCTOR": "Snowboardlehrer", + "PASSION_ELECTRONICS": "Elektronik", + "PASSION_WOODWORKING": "Holzbearbeitung", + "PASSION_VAN_LIFE": "Van Life", + "PASSION_COFFEE": "Kaffee", + "PASSION_SOFTWARE_DEVELOPMENT": "Softwareentwicklung", + "SERVICES_TITLE": "Hauptleistungen", + "TARGET_TITLE": "Für wen", + "TARGET_TEXT": "Kleine Unternehmen, Freelancer, Tüftler und Kunden, die ein fertiges Produkt im Shop suchen.", + "TEAM_TITLE": "Unser Team", + "MEMBER_JOE_NAME": "Joe Küng", + "MEMBER_JOE_ROLE": "Informatik-Student", + "MEMBER_JOE_ALT": "Joe Küng", + "MEMBER_MATTEO_NAME": "Matteo Caletti", + "MEMBER_MATTEO_ROLE": "Elektrotechnik-Student", + "MEMBER_MATTEO_ALT": "Matteo Caletti" + }, + "LOCATIONS": { + "TITLE": "Unsere Standorte", + "SUBTITLE": "Wir sind an zwei Standorten vertreten. Wählen Sie einen Standort, um Details zu sehen.", + "TICINO": "Tessin", + "BIENNE": "Bienne", + "ADDRESS_TICINO": "Via G. Pioda 29a, Biasca", + "ADDRESS_BIENNE": "Lyss-strasse 71, Nidau 2560 Bienne", + "CONTACT_US": "Kontaktieren Sie uns", + "BIASCA": "Biasca" + }, + "LEGAL": { + "PRIVACY_TITLE": "Datenschutzerklärung", + "TERMS_TITLE": "Allgemeine Geschäftsbedingungen", + "LAST_UPDATE": "Letzte Aktualisierung", + "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", + "TERMS_LINK": "Allgemeinen Geschäftsbedingungen", + "AND": "und die", + "PRIVACY_LINK": "Datenschutzerklärung", + "REQUIRED_ERROR": "Um fortzufahren müssen Sie AGB und Datenschutz akzeptieren." + }, + "PRIVACY": { + "META": { + "CONTROLLER": "Verantwortliche: Matteo Caletti und Joe Kung (3D-Fab.ch).", + "CONTACT": "Datenschutzkontakt: info@3d-fab.ch" + }, + "S1": { + "TITLE": "1. Welche Daten wir verarbeiten", + "P1": "1.1. Wir erfassen die für Angebote und Bestellungen erforderlichen Daten: Vorname, Nachname, E-Mail, Telefon, Adresse, Versand-/Rechnungsdaten und Bestelldetails.", + "P2": "1.2. Wenn Sie 3D-Dateien oder technische Anhänge hochladen, werden die Dateien nur für Analyse, Produktion, Support und Bestellverwaltung verarbeitet." + }, + "S2": { + "TITLE": "2. Zweck der Verarbeitung", + "P1": "2.1. Wir verwenden Daten ausschließlich, um: Angebote zu erstellen, Bestellungen zu bestätigen, Zahlungen zu erhalten, Versand zu organisieren und After-Sales-Support bereitzustellen.", + "P2": "2.2. Wir verwenden Daten nicht für Profiling, automatisches Marketing oder Verkauf an Dritte." + }, + "S3": { + "TITLE": "3. Speicherung von Dateien und Daten", + "P1": "3.1. Dateien, die nur für ein Angebot hochgeladen werden, werden für die Dauer der Sitzung gespeichert und bei Ablauf automatisch entfernt.", + "P2": "3.2. Dateien und Daten im Zusammenhang mit bestätigten Bestellungen werden so lange gespeichert, wie es für Produktion, Lieferung, Support und administrative/rechtliche Pflichten erforderlich ist, danach gelöscht oder minimiert.", + "P3": "3.3. Im Rechner hochgeladene Dateien werden nur für technische Analyse und Angebotsschätzung verwendet und bleiben ausschließlich mit der Rechner-Sitzung verknüpft." + }, + "S4": { + "TITLE": "4. Cookies und Tracking", + "P1": "4.1. Wir verwenden keine Profiling-, Marketing- oder Drittanbieter-Tracking-Cookies.", + "P2": "4.2. Wir verwenden keine Advertising-Tracking-Systeme. Etwaige temporäre technische Daten werden nur für den Betrieb der Sitzung genutzt." + }, + "S5": { + "TITLE": "5. Datenweitergabe", + "P1": "5.1. Wir teilen Daten nur mit für den Service strikt notwendigen Anbietern (Hosting, Zahlungen, Versand) und beschränken uns auf unerlässliche Informationen.", + "P2": "5.2. Anbieter verarbeiten Daten gemäß vertraglichen Weisungen und angemessenen Sicherheitsmaßnahmen." + }, + "S6": { + "TITLE": "6. Rechte der Nutzer", + "P1": "6.1. Sie können Auskunft, Berichtigung, Löschung oder Einschränkung der Verarbeitung Ihrer Daten verlangen, im gesetzlich vorgesehenen Rahmen.", + "P2": "6.2. Für Datenschutzanfragen schreiben Sie an info@3d-fab.ch. Die italienische Version dieser Datenschutzerklärung ist die einzige rechtlich bindende Version, sofern nicht schriftlich anders vereinbart." + } + }, + "TERMS": { + "META": { + "PROVIDER": "Anbieter: 3D-Fab.ch (Matteo Caletti und Joe Kung)", + "VERSION": "Version: v1.0", + "SCOPE": "Gültig für FDM/FFF-3D-Druck und verbundene Dienstleistungen, mit Verkauf und Lieferung ausschließlich in der Schweiz (CH)." + }, + "S1": { + "TITLE": "1. Angaben zum Anbieter und Kontakte", + "P1": "1.1. Anbieter: Matteo Caletti und Joe Kung.", + "P2": "1.2. Adresse: Lyss-strasse 71, Nidau 2560, CH.", + "P3": "1.3. E-Mail: info@3d-fab.ch." + }, + "S2": { + "TITLE": "2. Geltungsbereich", + "P1": "2.1. Diese AGB regeln 3D-Druck auf Anfrage, Zusatzleistungen (technische Beratung, CAD, Dateiprüfung, Nachbearbeitung) sowie eventuelle Standardartikel.", + "P2": "2.2. Der Anbieter verkauft und liefert ausschließlich in der Schweiz (CH).", + "P3": "2.3. Sie gelten für Bestellungen über Website, E-Mail oder andere vereinbarte Kanäle; abweichende Bedingungen des Kunden gelten nur bei schriftlicher Annahme." + }, + "S3": { + "TITLE": "3. Definitionen", + "P1": "3.1. Kunde: natürliche oder juristische Person als Käufer. Kundendateien: bereitgestellte 3D-Modelle und technische Daten. Individuelles Produkt: nach Kundenspezifikation gefertigtes Teil. Bestellung: vom Anbieter angenommene Anfrage." + }, + "S4": { + "TITLE": "4. Vertragsschluss und Bestellung", + "P1": "4.1. Der Vertrag kommt mit Auftragsbestätigung (E-Mail/Portal) oder mit Produktionsstart nach Zahlung/Anzahlung zustande.", + "P2": "4.2. Automatische Angebote können nach minimaler technischer Prüfung (Druckbarkeit, Stützen, Maschinenlimits) bestätigt oder aktualisiert werden.", + "P3": "4.3. Der Anbieter kann technisch nicht umsetzbare oder nicht konforme Bestellungen ablehnen." + }, + "S5": { + "TITLE": "5. Preise, Steuern und Versand", + "P1": "5.1. Die Preise sind in CHF, sofern nicht anders angegeben bezüglich MwSt. inkl./exkl..", + "P2": "5.2. Versand und Verpackung werden, sofern anwendbar, separat ausgewiesen.", + "P3": "5.3. Für Kleinserien oder komplexes CAD können schriftliche Angebote mit spezifischen Bedingungen gelten (Muster, Staffelungen, Freigabe Erstteil)." + }, + "S6": { + "TITLE": "6. Zahlungen", + "P1": "6.1. Akzeptierte Methoden: TWINT und Banküberweisung.", + "P2": "6.2. Sofern nicht anders vereinbart, startet die Produktion erst nach Zahlung.", + "P3": "6.3. Für CAD-Beratung und Kleinserien kann eine Anzahlung (30-100%) und/oder Restzahlung vor Lieferung verlangt werden.", + "P4": "6.4. Überweisungsdaten: Joe Kung, IBAN CH74 0900 0000 1548 2158 1, Via G. Pioda 29a, 6710 Biasca." + }, + "S7": { + "TITLE": "7. Kundendateien und technische Verantwortung", + "P1": "7.1. Der Kunde garantiert, die Rechte an den Dateien zu haben und dass deren Nutzung keine Rechte Dritter verletzt (Urheberrecht, Patente, Lizenzen, Geschäftsgeheimnisse).", + "P2": "7.2. Der Kunde ist verantwortlich für Modellkorrektheit, Materialwahl, Eignung für den Endgebrauch und Einhaltung der anwendbaren Normen.", + "P3": "7.3. Der Anbieter kann Änderungen zur Verbesserung von Druckbarkeit und Ergebnis vorschlagen, übernimmt jedoch keine Zertifizierungsfunktion für das Endprodukt, außer bei schriftlicher Vereinbarung." + }, + "S8": { + "TITLE": "8. FDM/FFF-Qualität und Toleranzen", + "P1": "8.1. FDM/FFF-Druck hat intrinsische Eigenschaften: Schichtlinien, mechanische Anisotropie, Mikro-Unvollkommenheiten, Farbabweichungen und mögliche Schrumpfung/Verzug in Abhängigkeit von Geometrie und Material.", + "P2": "8.2. Standardtoleranzen (sofern nicht schriftlich anders vereinbart): bis 100 mm +/-0.3 mm; über 100 mm +/-0.5% (mindestens +/-0.3 mm).", + "P3": "8.3. Kritische ästhetische oder dimensionale Anforderungen müssen vor der Bestellung vereinbart werden (z. B. Muster, Kontrollmaße, spezifische Oberflächen)." + }, + "S9": { + "TITLE": "9. Nachbearbeitung und Zusatzarbeiten", + "P1": "9.1. Arbeiten wie Stützstrukturen entfernen, Schleifen, Grundieren/Lackieren oder Gewindeeinsätze erfolgen nur nach Vereinbarung und können dimensionale oder optische Abweichungen verursachen.", + "P2": "9.2. Auf Anfrage kann eine Freigabe des ersten Teils oder Musters vorgesehen werden, ggf. kostenpflichtig." + }, + "S10": { + "TITLE": "10. Produktions- und Lieferzeiten", + "P1": "10.1. Die angegebenen Zeiten sind Schätzungen basierend auf Auslastung, Komplexität und Materialverfügbarkeit.", + "P2": "10.2. Verzögerungen durch externe Ursachen (Kuriere, Lieferanten, Ausfälle, höhere Gewalt) begründen keinen automatischen Anspruch auf Vertragsstrafen, außer bei schriftlicher Vereinbarung.", + "P3": "10.3. Der Kunde muss korrekte und vollständige Adressen angeben; etwaige Kosten für erneute Zustellung aufgrund falscher Adresse trägt der Kunde." + }, + "S11": { + "TITLE": "11. Widerrufsrecht und Stornierung", + "P1": "11.1. Sofern nicht schriftlich bestätigte Ausnahmen vorliegen, besteht nach Bestellung kein Widerrufs- oder Rückgaberecht.", + "P2": "11.2. Individuelle Produkte sind nach Produktionsstart nicht stornierbar oder erstattbar.", + "P3": "11.3. Der Anbieter kann Ausnahmen vor Produktionsstart akzeptieren und bereits entstandene Kosten einbehalten.", + "P4": "11.4. Etwaige Sonderbedingungen müssen im Angebot, in der Auftragsbestätigung oder in schriftlicher Kommunikation festgehalten sein." + }, + "S12": { + "TITLE": "12. Reklamationen, Mängel und Rechtsbehelfe", + "P1": "12.1. Der Kunde muss die Produkte bei Lieferung prüfen und eventuelle Mängel innerhalb von 7 Tagen schriftlich mit Foto/Video und Beschreibung melden.", + "P2": "12.2. Typische FDM-Merkmale, kleine Farbabweichungen, nicht-funktionale Unvollkommenheiten oder Toleranzen innerhalb der vereinbarten Grenzen gelten nicht automatisch als Mangel.", + "P3": "12.3. Bei einem vom Anbieter zu vertretenden Mangel sind die Rechtsbehelfe nach Wahl des Anbieters: Neudruck/Ersatz oder Rückerstattung begrenzt auf den Wert des mangelhaften Teils bzw. der Bestellung.", + "P4": "12.4. Vor Neudruck oder Rückerstattung kann die Rückgabe des Teils verlangt werden; wird der Mangel bestätigt, trägt der Anbieter die Rücksendekosten." + }, + "S13": { + "TITLE": "13. Verbotene Nutzungen und Konformität", + "P1": "13.1. Der Anbieter kann Bestellungen zu illegalen Objekten, Waffen oder regulierten Teilen sowie sicherheitskritischen oder medizinischen Anwendungen ohne spezielle Vereinbarungen und Validierungen ablehnen.", + "P2": "13.2. Der Kunde bleibt für den Endgebrauch und die regulatorische Konformität des Produkts verantwortlich." + }, + "S14": { + "TITLE": "14. Geistiges Eigentum und Lizenzen", + "P1": "14.1. Rechte an Kundendateien bleiben beim Kunden bzw. den jeweiligen Rechteinhabern. Der Kunde gewährt dem Anbieter eine auf technische Bewertung und Produktion beschränkte Lizenz.", + "P2": "14.2. Sofern nicht anders vereinbart, werden vom Anbieter entwickelte Dateien und CAD-Projekte nach Zahlung mit Nutzungsrecht für die vereinbarten Zwecke übergeben.", + "P3": "14.3. Der Kunde stellt den Anbieter von Ansprüchen Dritter im Zusammenhang mit vom Kunden bereitgestellten Dateien oder Anweisungen frei." + }, + "S15": { + "TITLE": "15. Datenschutz", + "P1": "15.1. Der Anbieter verarbeitet personenbezogene Daten für Bestellverwaltung, Zahlungen, Versand und Support.", + "P2": "15.2. Daten können nur im erforderlichen Umfang zur Leistungserbringung mit technischen Anbietern und Kurieren geteilt werden." + }, + "S16": { + "TITLE": "16. Haftungsbeschränkung", + "P1": "16.1. Im gesetzlichen Rahmen haftet der Anbieter nur für vorhersehbare direkte Schäden und in jedem Fall nicht über den Auftragswert des betroffenen Produkts oder der beanstandeten Dienstleistung hinaus.", + "P2": "16.2. Mittelbare Schäden, entgangener Gewinn, Betriebsunterbrechung, Datenverlust und Folgeschäden sind im zulässigen Rahmen ausgeschlossen.", + "P3": "16.3. Gesetzlich nicht ausschließbare Haftungen bleiben unberührt (z. B. Vorsatz oder grobe Fahrlässigkeit)." + }, + "S17": { + "TITLE": "17. Höhere Gewalt", + "P1": "17.1. Ereignisse außerhalb der zumutbaren Kontrolle des Anbieters (Ausfälle, Blackout, Lieferverzögerungen, Streiks, behördliche Maßnahmen) können zu Fristverlängerungen oder Aussetzungen ohne Haftung führen." + }, + "S18": { + "TITLE": "18. Anwendbares Recht und Gerichtsstand", + "P1": "18.1. Es gilt schweizerisches Recht.", + "P2": "18.2. Gerichtsstand: Sitz des Anbieters, vorbehaltlich zwingender Verbraucherschutzvorschriften.", + "P3": "18.3. Bei Auslegungsunterschieden gilt die italienische Version dieser Bedingungen und ist die einzige rechtlich bindende Version, sofern nicht schriftlich anders vereinbart." + } + }, + "TERMS_UPDATE_DATE": "24. Februar 2026", + "PRIVACY_UPDATE_DATE": "24. Februar 2026" + }, + "CONTACT": { + "TITLE": "Kontaktieren Sie uns", + "SEND": "Nachricht senden", + "REQ_TYPE_LABEL": "Anfrageart", + "TYPE_PRIVATE": "Privat", + "TYPE_COMPANY": "Unternehmen", + "REQ_TYPE_CUSTOM": "Individuelles Angebot", + "REQ_TYPE_SERIES": "Serienfertigung", + "REQ_TYPE_CONSULT": "Beratung", + "REQ_TYPE_QUESTION": "Allgemeine Fragen", + "PHONE": "Telefon", + "IS_COMPANY": "Sind Sie ein Unternehmen?", + "COMPANY_NAME": "Firmenname", + "REF_PERSON": "Ansprechperson", + "UPLOAD_LABEL": "Anhänge", + "UPLOAD_HINT": "Max. 15 Dateien. Unterstützt: Bilder, Videos, PDF, Office-Dokumente, STL/STEP/3MF/OBJ/IGES, DWG/DXF. Komprimierte Dateien sind nicht erlaubt.", + "DROP_FILES": "Dateien hier ablegen oder klicken zum Hochladen", + "PLACEHOLDER_NAME": "Ihr Name", + "PLACEHOLDER_EMAIL": "ihre@email.com", + "PLACEHOLDER_PHONE": "+41 00 000 00 00", + "PLACEHOLDER_COMPANY": "Firmenname", + "PLACEHOLDER_REF_PERSON": "Ansprechperson", + "LABEL_MESSAGE": "Nachricht *", + "LABEL_EMAIL": "E-Mail *", + "LABEL_NAME": "Name *", + "MSG_SENT": "Gesendet!", + "ERR_MAX_FILES": "Maximallimit von 15 Dateien erreicht.", + "ERR_COMPRESSED_FILES": "Komprimierte Dateien sind nicht erlaubt (ZIP/RAR/7z/TAR/GZ).", + "SUCCESS_TITLE": "Nachricht erfolgreich gesendet", + "SUCCESS_DESC": "Vielen Dank für Ihre Kontaktaufnahme. Wir haben Ihre Nachricht erhalten und senden Ihnen in Kürze eine Zusammenfassungs-E-Mail.", + "SEND_ANOTHER": "Weitere Nachricht senden", + "FILE_TYPE_PDF": "PDF", + "FILE_TYPE_3D": "3D", + "FILE_TYPE_VIDEO": "Video", + "FILE_TYPE_DOC": "DOC", + "FILE_TYPE_FILE": "DATEI", + "ERROR_SUBMIT": "Fehler beim Senden der Anfrage. Bitte versuchen Sie es erneut.", + "REMOVE_FILE": "Angehängte Datei entfernen", + "HERO_SUBTITLE": "Wir sind hier, um Ihnen zu helfen. Füllen Sie das untenstehende Formular für jede Anfrage aus." + }, + "CHECKOUT": { + "TITLE": "Checkout", + "SUBTITLE": "Schließen Sie Ihre Bestellung ab, indem Sie Versand- und Zahlungsdetails eingeben.", + "CONTACT_INFO": "Kontaktinformationen", + "BILLING_ADDR": "Rechnungsadresse", + "SHIPPING_ADDR": "Lieferadresse", + "FIRST_NAME": "Vorname", + "LAST_NAME": "Nachname", + "EMAIL": "E-Mail", + "PHONE": "Telefon", + "COMPANY_NAME": "Firmenname", + "ADDRESS_1": "Adresse (Straße und Nummer)", + "ADDRESS_2": "Zusätzliche Informationen (optional)", + "ZIP": "PLZ", + "CITY": "Ort", + "COUNTRY": "Land", + "SHIPPING_SAME": "Die Lieferadresse ist gleich der Rechnungsadresse", + "PLACE_ORDER": "Bestellung absenden", + "PROCESSING": "Verarbeitung...", + "SUMMARY_TITLE": "Bestellübersicht", + "SUBTOTAL": "Zwischensumme", + "ITEMS_BASE_SUBTOTAL": "Artikelbasis-Zwischensumme", + "MACHINE_COST": "Maschinenkosten", + "SETUP_FEE": "Einrichtungskosten", + "TOTAL": "Gesamt", + "QTY": "Menge", + "PER_PIECE": "pro Stück", + "SHIPPING": "Versand (CH)", + "ERR_NO_SESSION_START": "Keine aktive Sitzung gefunden. Bitte starten Sie ein neues Angebot.", + "ERR_LOAD_SESSION": "Sitzungsdetails konnten nicht geladen werden. Bitte erneut versuchen.", + "ERR_NO_SESSION_CREATE_ORDER": "Keine aktive Sitzung gefunden. Bestellung kann nicht erstellt werden.", + "ERR_CREATE_ORDER": "Bestellung konnte nicht erstellt werden. Bitte erneut versuchen.", + "INVALID_EMAIL": "Ungültige E-Mail", + "COMPANY_OPTIONAL": "Firmenname (Optional)", + "REF_PERSON_OPTIONAL": "Ansprechperson (Optional)", + "SHIPPING_CALCULATED_NEXT_STEP": "Versandkosten werden im nächsten Schritt berechnet", + "EXCLUDES_SHIPPING": "Versandkosten ausgeschlossen" + }, + "PAYMENT": { + "TITLE": "Zahlung", + "METHOD": "Zahlungsmethode", + "TWINT_TITLE": "Mit TWINT bezahlen", + "TWINT_DESC": "Scannen Sie den Code mit der TWINT-App, auf Mobilgeräten klicken Sie auf die Schaltfläche.", + "TWINT_OPEN": "Direkt in TWINT öffnen", + "TWINT_LINK": "Zahlungslink öffnen", + "BANK_TITLE": "Banküberweisung", + "BANK_OWNER": "Kontoinhaber", + "BANK_IBAN": "IBAN", + "BANK_REF": "Referenz", + "BILLING_INFO_HINT": "Wir haben die Felder für Sie ausgefüllt, bitte den Zahlungszweck nicht ändern", + "DOWNLOAD_QR": "QR-Rechnung herunterladen (PDF)", + "DOWNLOAD_CONFIRMATION": "Bestätigung herunterladen (PDF)", + "CONFIRM": "Ich habe die Zahlung abgeschlossen", + "SUMMARY_TITLE": "Bestellübersicht", + "SUBTOTAL": "Zwischensumme", + "SHIPPING": "Versand", + "SETUP_FEE": "Einrichtungskosten", + "TOTAL": "Gesamt", + "LOADING": "Bestelldetails werden geladen...", + "METHOD_TWINT": "TWINT", + "METHOD_BANK": "QR-Rechnung / Überweisung", + "STATUS_REPORTED_TITLE": "Bestellung in Bearbeitung", + "STATUS_REPORTED_DESC": "Wir haben Ihren Vorgang registriert. Sobald wir die Zahlung bestätigen, geht die Bestellung in Produktion.", + "IN_VERIFICATION": "Zahlung gemeldet", + "TWINT_QR_ALT": "TWINT-Zahlungs-QR", + "TWINT_BUTTON_ALT": "Eingebettete TWINT-Schaltfläche" + }, + "TRACKING": { + "TITLE": "Bestellstatus", + "SUBTITLE": "Prüfen Sie den Status Ihrer Bestellung und verwalten Sie bei Bedarf die Zahlung.", + "STEP_PENDING": "Ausstehend", + "STEP_REPORTED": "In Prüfung", + "STEP_PRODUCTION": "In Produktion", + "STEP_SHIPPED": "Versendet" + }, + "ORDER_CONFIRMED": { + "TITLE": "Bestellung bestätigt", + "SUBTITLE": "Zahlung erfasst. Ihre Bestellung wird nun bearbeitet.", + "STATUS": "In Bearbeitung", + "HEADING": "Wir bereiten Ihre Bestellung vor", + "ORDER_REF": "Bestellreferenz", + "PROCESSING_TEXT": "Sobald wir die Zahlung bestätigen, geht Ihre Bestellung in Produktion.", + "EMAIL_TEXT": "Wir senden Ihnen eine E-Mail mit Statusupdate und nächsten Schritten.", + "BACK_HOME": "Zur Startseite" + }, + "STL_VIEWER": { + "LOADING": "3D-Modell wird geladen..." + }, + "HOME": { + "SHOP_GALLERY_ARIA": "Produktgalerie Shop", + "FOUNDER_PREV_ARIA": "Vorheriges Foto", + "FOUNDER_NEXT_ARIA": "Nächstes Foto", + "SHOP_IMAGE_ALT_1": "Technisches 3D-gedrucktes Produkt", + "FOUNDER_IMAGE_ALT_1": "Gründer - Foto 1", + "FOUNDER_IMAGE_ALT_2": "Gründer - Foto 2", + "HERO_EYEBROW": "Technischer 3D-Druck für Unternehmen, Freelancer und Maker", + "HERO_TITLE": "Preis und Lieferzeit in wenigen Sekunden.
Von der 3D-Datei zum fertigen Teil.", + "HERO_LEAD": "Der fortschrittlichste Rechner für Ihre 3D-Drucke: maximale Präzision und keine Überraschungen.", + "HERO_SUBTITLE": "Wir bieten auch CAD-Services für individuelle Teile an!", + "BTN_CALCULATE": "Angebot berechnen", + "BTN_SHOP": "Zum Shop", + "BTN_CONTACT": "Mit uns sprechen", + "SEC_CALC_TITLE": "Korrekte Preisberechnung in wenigen Sekunden", + "SEC_CALC_SUBTITLE": "Keine Registrierung erforderlich. Die Schätzung basiert auf echtem Slicing.", + "SEC_CALC_LIST_1": "Unterstützte Formate: STL, 3MF, STEP", + "CARD_CALC_EYEBROW": "Automatische Berechnung", + "CARD_CALC_TITLE": "Preis und Lieferzeit mit einem Klick", + "CARD_CALC_TAG": "Ohne Registrierung", + "CARD_CALC_STEP_1": "3D-Datei hochladen", + "CARD_CALC_STEP_2": "Material und Qualität wählen", + "CARD_CALC_STEP_3": "Kosten und Zeit sofort erhalten", + "BTN_OPEN_CALC": "Rechner öffnen", + "SEC_CAP_TITLE": "Was Sie erhalten können", + "SEC_CAP_SUBTITLE": "Maßgeschneiderte Produktion für Prototypen, Kleinserien und individuelle Teile.", + "CAP_1_TITLE": "Schnelles Prototyping", + "CAP_1_TEXT": "Von der digitalen Datei zum physischen Modell in 24/48 Stunden. Prüfen Sie Ergonomie, Passungen und Funktion vor der finalen Form.", + "CAP_2_TITLE": "Individuelle Teile", + "CAP_2_TEXT": "Einzigartige Komponenten, die am Markt schwer zu finden sind. Wir reproduzieren defekte Teile oder erstellen maßgeschneiderte Anpassungen.", + "CAP_3_TITLE": "Kleinserien", + "CAP_3_TEXT": "Brückenproduktion oder limitierte Serien (10-500 Stück) mit reproduzierbarer Qualität.", + "CAP_4_TITLE": "Beratung und CAD", + "CAP_4_TEXT": "Sie haben noch keine 3D-Datei? Wir helfen beim Entwurf, bei der Druckoptimierung und bei der Auswahl des richtigen Materials.", + "SEC_SHOP_TITLE": "Shop", + "SEC_SHOP_TEXT": "Ausgewählte Produkte, einsatzbereit.", + "SEC_SHOP_LIST_1": "Von uns entwickelte Produkte", + "SEC_SHOP_LIST_2": "Ersatzteile und Komponenten, die schwer zu beschaffen sind", + "SEC_SHOP_LIST_3": "Halterungen und Organizer zur Optimierung Ihrer Arbeitsabläufe", + "BTN_DISCOVER": "Produkte entdecken", + "BTN_REQ_SOLUTION": "Lösung anfragen", + "CARD_SHOP_1_TITLE": "Auswahl geprüfter Produkte", + "CARD_SHOP_1_TEXT": "Geprüft, um langfristige Funktionalität und Stabilität zu gewährleisten.", + "CARD_SHOP_2_TITLE": "Einsatzfertige Kits", + "CARD_SHOP_2_TEXT": "Kompatible Komponenten, leicht montierbar und ohne Überraschungen.", + "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.", + "FOUNDERS_PHOTO": "Foto der Gründer" + }, + "ORDER": { + "ERR_ID_NOT_FOUND": "Bestell-ID nicht gefunden.", + "ERR_LOAD_ORDER": "Bestelldetails konnten nicht geladen werden.", + "ERR_REPORT_PAYMENT": "Zahlung konnte nicht gemeldet werden. Bitte erneut versuchen.", + "NOT_AVAILABLE": "N/V" + }, + "DROPZONE": { + "DEFAULT_LABEL": "Dateien hierher ziehen oder klicken zum Hochladen", + "DEFAULT_SUBTEXT": "Unterstützt .STL, .3MF, .STEP" + }, + "COLOR": { + "AVAILABLE_COLORS": "Verfügbare Farben", + "CATEGORY_GLOSSY": "Glänzend", + "CATEGORY_MATTE": "Matt", + "NAME": { + "BLACK": "Schwarz", + "WHITE": "Weiß", + "RED": "Rot", + "BLUE": "Blau", + "GREEN": "Grün", + "YELLOW": "Gelb", + "MATTE_BLACK": "Matt Schwarz", + "MATTE_WHITE": "Matt Weiß", + "MATTE_GRAY": "Matt Grau" + } + }, + "COMMON": { + "REQUIRED": "Pflichtfeld", + "INVALID_EMAIL": "Ungültige E-Mail", + "BACK": "Zurück", + "OPTIONAL": "(Optional)" + } +} diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index e5d3916..a61b7d3 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -2,12 +2,16 @@ "NAV": { "HOME": "Home", "CALCULATOR": "Calculator", - "SHOP": "Shop" + "SHOP": "Shop", + "ABOUT": "About Us", + "CONTACT": "Contact Us", + "LANGUAGE_SELECTOR": "Language selector" }, "QUOTE": { "CONSULT": "Request Consultation", "PROCEED_ORDER": "Proceed to Order", - "TOTAL": "Total Estimate" + "TOTAL": "Total Estimate", + "MAX_QTY_NOTICE": "For quantities above {{max}} pieces, request consultation." }, "USER_DETAILS": { "TITLE": "Shipping Details", @@ -27,7 +31,7 @@ "CITY": "City", "CITY_PLACEHOLDER": "Zurich", "SUBMIT": "Submit Order", - "ORDER_SUCCESS": "Order submitted successfully! We will contact you shortly." + "DEFAULT_COLOR": "Default" }, "FOOTER": { "PRIVACY": "Privacy", @@ -43,7 +47,7 @@ "MODE_EASY": "Quick", "MODE_ADVANCED": "Advanced", "UPLOAD_LABEL": "Drag your 3D file here", - "UPLOAD_SUB": "Supports STL, 3MF, STEP, OBJ up to 50MB", + "UPLOAD_SUB": "Supports STL, 3MF, STEP up to 50MB", "MATERIAL": "Material", "QUALITY": "Quality", "QUANTITY": "Quantity", @@ -57,6 +61,7 @@ "CALCULATE": "Calculate Quote", "RESULT": "Estimated Quote", "TIME": "Print Time", + "MACHINE_COST": "Machine Cost", "COST": "Total Cost", "ORDER": "Order Now", "CONSULT": "Request Consultation", @@ -68,55 +73,265 @@ "BENEFITS_1": "Automatic quote with instant cost and time", "BENEFITS_2": "Selected materials and quality control", "BENEFITS_3": "CAD consultation if file needs modifications", - "ERR_FILE_REQUIRED": "File is required." + "ERR_FILE_REQUIRED": "File is required.", + "STEP_WARNING": "3D preview is not available for STEP files, but the calculator works perfectly. You can proceed with the quotation.", + "REMOVE_FILE": "Remove file", + "FALLBACK_MATERIAL": "PLA (fallback)", + "FALLBACK_QUALITY_STANDARD": "Standard", + "ERR_FILE_TOO_LARGE": "Some files exceed the 200MB limit and were not added.", + "PRINT_SPEED": "Print speed", + "COLOR": "Color", + "ANALYZING_TITLE": "Analysis in progress...", + "ANALYZING_TEXT": "We are analyzing the geometry and calculating the toolpath.", + "QTY_SHORT": "QTY", + "COLOR_LABEL": "COLOR", + "ADD_FILES": "Add files", + "UPLOADING": "Uploading...", + "PROCESSING": "Processing...", + "NOTES_PLACEHOLDER": "Specific instructions...", + "SETUP_NOTE": "* Includes {{cost}} as setup cost", + "SHIPPING_NOTE": "** Shipping costs excluded, calculated at the next step", + "ERROR_ZERO_PRICE": "Something went wrong. Try another format or contact us." }, "SHOP": { "TITLE": "Technical solutions", "SUBTITLE": "Ready-made products solving practical problems", + "WIP_EYEBROW": "Work in progress", + "WIP_TITLE": "Shop under construction", + "WIP_SUBTITLE": "We are building a curated technical shop with products that are genuinely useful and field-tested.", + "WIP_CTA_CALC": "Check our calculator", + "WIP_RETURN_LATER": "Come back soon", + "WIP_NOTE": "We care about doing this right. In the meantime, you can get instant pricing and lead time from our calculator.", "ADD_CART": "Add to Cart", - "BACK": "Back to Shop" + "BACK": "Back to Shop", + "NOT_FOUND": "Product not found.", + "DETAILS": "Details", + "MOCK_ADD_CART": "Added to cart (Mock)", + "SUCCESS_TITLE": "Added to cart", + "SUCCESS_DESC": "The product has been added to the cart.", + "CONTINUE": "Continue", + "CATEGORIES": { + "FILAMENTS": "Filaments", + "ACCESSORIES": "Accessories" + }, + "PRODUCTS": { + "P1": { + "NAME": "Standard PLA filament", + "DESC": "The classic choice for every print, easy and reliable." + }, + "P2": { + "NAME": "PETG Tough filament", + "DESC": "Impact and temperature resistant." + }, + "P3": { + "NAME": "Nozzle kit (0.4mm)", + "DESC": "Replacement set for FDM extruder." + } + } }, "ABOUT": { "TITLE": "About Us", "EYEBROW": "3D Printing Lab", - "SUBTITLE": "Transparency on price, quality and time. Technical and CAD consultation for businesses and individuals.", - "HOW_TITLE": "How we work", - "HOW_TEXT": "We offer an automatic quote for those who already have the 3D file, and a consultation path for those who need to design or optimize the model.", - "PILL_1": "Instant Quote", - "PILL_2": "Technical Consultation", - "PILL_3": "Small series up to 500 pcs", + "SUBTITLE": "We are two students with a strong desire to build and learn.", + "HOW_TEXT": "3D Fab was created to turn the potential of 3D printing into everyday solutions. We started from technical curiosity and grew into producing spare parts, products, and custom prototypes. To move from an idea to a concrete project, we launched our automatic calculator: clear quotes in one click, so you get a professional service with no price surprises.", + "PASSIONS_TITLE": "Our interests", + "PASSION_BIKE_TRIAL": "Bike trial", + "PASSION_MOUNTAIN": "Mountain", + "PASSION_SKI": "Ski", + "PASSION_SELF_HOSTING": "Self hosting", + "PASSION_PRINT_3D": "3D printing", + "PASSION_TRAVEL": "Travel", + "PASSION_SNOWBOARD": "Snowboard", + "PASSION_SNOWBOARD_INSTRUCTOR": "Snowboard instructor", + "PASSION_ELECTRONICS": "Electronics", + "PASSION_WOODWORKING": "Woodworking", + "PASSION_VAN_LIFE": "Van life", + "PASSION_COFFEE": "Coffee", + "PASSION_SOFTWARE_DEVELOPMENT": "Software development", "SERVICES_TITLE": "Main Services", - "SERVICE_1": "FDM 3D printing for prototypes and small series", - "SERVICE_2": "Technical materials on request", - "SERVICE_3": "CAD support and post-processing", - "SERVICE_4": "File verification and optimization for printing", "TARGET_TITLE": "Who is it for", "TARGET_TEXT": "Small businesses, freelancers, makers and customers looking for a ready-made product from the shop.", - "TEAM_TITLE": "Our Team" + "TEAM_TITLE": "Our Team", + "MEMBER_JOE_NAME": "Joe Küng", + "MEMBER_JOE_ROLE": "Computer Engineering Student", + "MEMBER_JOE_ALT": "Joe Küng", + "MEMBER_MATTEO_NAME": "Matteo Caletti", + "MEMBER_MATTEO_ROLE": "Electronics Engineering Student", + "MEMBER_MATTEO_ALT": "Matteo Caletti" }, "LOCATIONS": { "TITLE": "Our Locations", "SUBTITLE": "We have two locations to serve you better. Select a location to see details.", "TICINO": "Ticino", "BIENNE": "Bienne", - "ADDRESS_TICINO": "Ticino Office, Switzerland", - "ADDRESS_BIENNE": "Bienne Office, Switzerland", - "CONTACT_US": "Contact Us" + "ADDRESS_TICINO": "Via G. Pioda 29a, Biasca", + "ADDRESS_BIENNE": "Lyss-strasse 71, Nidau 2560 Bienne", + "CONTACT_US": "Contact Us", + "BIASCA": "Biasca" }, "LEGAL": { "PRIVACY_TITLE": "Privacy Policy", "TERMS_TITLE": "Terms and Conditions", "LAST_UPDATE": "Last update", + "CONSENT": { + "UPLOAD_NOTICE_PREFIX": "By uploading a file, you accept our", + "UPLOAD_NOTICE_LINK": "Privacy Policy", + "LABEL_PREFIX": "I have read and accept the", + "TERMS_LINK": "Terms and Conditions", + "AND": "and the", + "PRIVACY_LINK": "Privacy Policy", + "REQUIRED_ERROR": "To continue you must accept Terms and Privacy." + }, "PRIVACY": { - "SECTION_1": "1. Data Collection", - "SECTION_2": "2. Purpose of Processing", - "SECTION_3": "3. Cookies and Tracking" + "META": { + "CONTROLLER": "Data controllers: Matteo Caletti and Joe Kung (3D-Fab.ch).", + "CONTACT": "Privacy contact: info@3d-fab.ch" + }, + "S1": { + "TITLE": "1. What data we process", + "P1": "1.1. We collect the data required for quotes and orders: first name, last name, email, phone, address, shipping/billing data and order details.", + "P2": "1.2. When you upload 3D files or technical attachments, files are processed only for analysis, production, support and order management." + }, + "S2": { + "TITLE": "2. Purpose of processing", + "P1": "2.1. We use data exclusively to: prepare quotes, confirm orders, receive payments, organize shipments and provide after-sales support.", + "P2": "2.2. We do not use data for profiling, automated marketing or sale to third parties." + }, + "S3": { + "TITLE": "3. Storage of files and data", + "P1": "3.1. Files uploaded only for quoting are stored for the session duration and automatically removed when it expires.", + "P2": "3.2. Files and data linked to confirmed orders are stored for the time necessary for production, delivery, support and administrative/legal obligations, then deleted or minimized.", + "P3": "3.3. Files uploaded in the calculator are used only for technical analysis and quote estimation and remain associated exclusively with the calculator session." + }, + "S4": { + "TITLE": "4. Cookies and tracking", + "P1": "4.1. We do not use profiling, marketing or third-party tracking cookies.", + "P2": "4.2. We do not use advertising tracking systems. Any temporary technical data is used only for session operation." + }, + "S5": { + "TITLE": "5. Data sharing", + "P1": "5.1. We share data only with suppliers strictly necessary for the service (hosting, payments, shipping), limiting it to essential information.", + "P2": "5.2. Suppliers process data according to contractual instructions and appropriate security measures." + }, + "S6": { + "TITLE": "6. User rights", + "P1": "6.1. You can request access, rectification, deletion or restriction of processing of your data, within the limits provided by law.", + "P2": "6.2. For any privacy request write to info@3d-fab.ch. The Italian version of this privacy policy is the only legally binding version, unless otherwise agreed in writing." + } }, "TERMS": { - "SECTION_1": "1. Terms of Use", - "SECTION_2": "2. Orders and Payments", - "SECTION_3": "3. Refunds and Returns" - } + "META": { + "PROVIDER": "Provider: 3D-Fab.ch (Matteo Caletti and Joe Kung)", + "VERSION": "Version: v1.0", + "SCOPE": "Valid for FDM/FFF 3D printing and related services, with sale and delivery exclusively in Switzerland (CH)." + }, + "S1": { + "TITLE": "1. Provider details and contacts", + "P1": "1.1. Provider: Matteo Caletti and Joe Kung.", + "P2": "1.2. Address: Lyss-strasse 71, Nidau 2560, CH.", + "P3": "1.3. Email: info@3d-fab.ch." + }, + "S2": { + "TITLE": "2. Scope of application", + "P1": "2.1. These Terms and Conditions govern on-demand 3D printing, ancillary services (technical consulting, CAD, file verification, post-processing) and any standard items.", + "P2": "2.2. The provider sells and delivers exclusively in Switzerland (CH).", + "P3": "2.3. They apply to orders placed via website, email or other agreed channels; different customer conditions apply only if accepted in writing." + }, + "S3": { + "TITLE": "3. Definitions", + "P1": "3.1. Customer: natural or legal person buyer. Customer files: 3D models and technical data provided. Customized product: part made to customer specifications. Order: request accepted by the provider." + }, + "S4": { + "TITLE": "4. Contract conclusion and order", + "P1": "4.1. The contract is concluded with order confirmation (email/portal) or with production start after payment/down payment.", + "P2": "4.2. Automatic quotes may be confirmed or updated after minimum technical verification (printability, supports, machine limits).", + "P3": "4.3. The provider may refuse technically unfeasible orders or orders not compliant with these terms." + }, + "S5": { + "TITLE": "5. Prices, taxes and shipping", + "P1": "5.1. Prices are in CHF, unless otherwise indicated regarding VAT included/excluded.", + "P2": "5.2. Shipping and packaging are indicated separately when applicable.", + "P3": "5.3. For small series or complex CAD, written offers with specific conditions may apply (samples, tiers, first-part approval)." + }, + "S6": { + "TITLE": "6. Payments", + "P1": "6.1. Accepted methods: TWINT and bank transfer.", + "P2": "6.2. Unless otherwise agreed, production starts only after payment.", + "P3": "6.3. For CAD consulting and small series, an advance payment (30-100%) and/or balance before delivery may be required.", + "P4": "6.4. Bank transfer details: Joe Kung, IBAN CH74 0900 0000 1548 2158 1, Via G. Pioda 29a, 6710 Biasca." + }, + "S7": { + "TITLE": "7. Customer files and technical responsibility", + "P1": "7.1. The customer guarantees having rights to the files and that their use does not violate third-party rights (copyright, patents, licenses, trade secrets).", + "P2": "7.2. The customer is responsible for model correctness, material choice, suitability for end use and compliance with applicable regulations.", + "P3": "7.3. The provider may suggest changes to improve printability and performance, but does not perform final product certification unless agreed in writing." + }, + "S8": { + "TITLE": "8. FDM/FFF quality and tolerances", + "P1": "8.1. FDM/FFF printing involves intrinsic characteristics: layer lines, mechanical anisotropy, micro-imperfections, color variations and possible shrinkage/warping linked to geometry and material.", + "P2": "8.2. Standard tolerances (unless agreed in writing): up to 100 mm +/-0.3 mm; above 100 mm +/-0.5% (minimum +/-0.3 mm).", + "P3": "8.3. Critical aesthetic or dimensional requirements must be agreed before order (e.g. sample, control measurements, specific finish)." + }, + "S9": { + "TITLE": "9. Post-processing and additional operations", + "P1": "9.1. Operations such as support removal, sanding, primer/painting or threaded inserts are performed only if agreed and may introduce dimensional or aesthetic variations.", + "P2": "9.2. On request, first-part or sample approval may be provided, possibly for a fee." + }, + "S10": { + "TITLE": "10. Production and delivery times", + "P1": "10.1. Indicated times are estimates based on workload, complexity and material availability.", + "P2": "10.2. Delays due to external causes (couriers, suppliers, failures, force majeure) do not automatically entitle penalties unless agreed in writing.", + "P3": "10.3. The customer must provide correct and complete addresses; any redelivery costs due to wrong address remain at the customer’s expense." + }, + "S11": { + "TITLE": "11. Right of withdrawal and cancellation", + "P1": "11.1. Unless exceptions are confirmed in writing, there is no right of withdrawal or return after the order.", + "P2": "11.2. Customized products cannot be canceled or refunded after production starts.", + "P3": "11.3. The provider may accept exceptions before production starts, retaining any costs already incurred.", + "P4": "11.4. Any special conditions must be stated in the offer, order confirmation or written communications." + }, + "S12": { + "TITLE": "12. Claims, defects and remedies", + "P1": "12.1. The customer must inspect products upon delivery and report any defects within 7 days, in writing, with photos/videos and description.", + "P2": "12.2. Typical FDM marks, small color variations, non-functional imperfections or tolerances within agreed limits do not automatically constitute a defect.", + "P3": "12.3. In case of a defect attributable to the provider, remedies are at the provider’s choice: reprint/replacement or refund limited to the value of the defective part or order.", + "P4": "12.4. Before reprint or refund, return of the part may be requested; if the defect is confirmed, return is at the provider’s expense." + }, + "S13": { + "TITLE": "13. Prohibited uses and compliance", + "P1": "13.1. The provider may refuse orders related to illegal objects, weapons or regulated parts, safety-critical or medical applications without dedicated agreements and validations.", + "P2": "13.2. The customer remains responsible for end use and regulatory compliance of the product." + }, + "S14": { + "TITLE": "14. Intellectual property and licenses", + "P1": "14.1. Rights to customer files remain with the customer or respective rights holders. The customer grants the provider a limited license for use for technical evaluation and production.", + "P2": "14.2. Unless otherwise agreed, files and CAD projects developed by the provider are delivered after payment, with right of use for agreed purposes.", + "P3": "14.3. The customer indemnifies the provider against third-party claims related to files or instructions provided by the customer." + }, + "S15": { + "TITLE": "15. Data protection", + "P1": "15.1. The provider processes personal data for order management, payments, shipping and support.", + "P2": "15.2. Data may be shared with technical providers and couriers only to the extent necessary for service delivery." + }, + "S16": { + "TITLE": "16. Limitation of liability", + "P1": "16.1. Within legal limits, the provider is liable only for foreseeable direct damages and in any case not beyond the order value related to the contested product or service.", + "P2": "16.2. Indirect damages, loss of profit, business interruption, data loss and consequential damages are excluded within allowed limits.", + "P3": "16.3. Non-excludable liabilities by law remain unaffected (e.g. willful misconduct or gross negligence)." + }, + "S17": { + "TITLE": "17. Force majeure", + "P1": "17.1. Events beyond the provider’s reasonable control (failures, blackout, supplier delays, strikes, authority measures) may cause extensions or suspensions without liability." + }, + "S18": { + "TITLE": "18. Applicable law and jurisdiction", + "P1": "18.1. Swiss law applies.", + "P2": "18.2. Place of jurisdiction: provider’s registered office, unless mandatory consumer protection rules apply.", + "P3": "18.3. In case of interpretative discrepancies, the Italian version of these terms prevails and is the only legally binding version, unless otherwise agreed in writing." + } + }, + "TERMS_UPDATE_DATE": "24 February 2026", + "PRIVACY_UPDATE_DATE": "24 February 2026" }, "CONTACT": { "TITLE": "Contact Us", @@ -133,7 +348,7 @@ "COMPANY_NAME": "Company Name", "REF_PERSON": "Reference Person", "UPLOAD_LABEL": "Attachments", - "UPLOAD_HINT": "Max 15 files. Supported: Images, PDF, STL, STEP, 3MF, OBJ", + "UPLOAD_HINT": "Max 15 files. Supported: images, videos, PDF, Office documents, STL/STEP/3MF/OBJ/IGES, DWG/DXF. Compressed files are not allowed.", "DROP_FILES": "Drop files here or click to upload", "PLACEHOLDER_NAME": "Your Name", "PLACEHOLDER_EMAIL": "your@email.com", @@ -145,8 +360,188 @@ "LABEL_NAME": "Name *", "MSG_SENT": "Sent!", "ERR_MAX_FILES": "Max 15 files limit reached.", + "ERR_COMPRESSED_FILES": "Compressed files are not allowed (ZIP/RAR/7z/TAR/GZ).", "SUCCESS_TITLE": "Message Sent Successfully", "SUCCESS_DESC": "Thank you for contacting us. We have received your message and will send you a recap email shortly.", - "SEND_ANOTHER": "Send Another Message" + "SEND_ANOTHER": "Send Another Message", + "FILE_TYPE_PDF": "PDF", + "FILE_TYPE_3D": "3D", + "FILE_TYPE_VIDEO": "Video", + "FILE_TYPE_DOC": "DOC", + "FILE_TYPE_FILE": "FILE", + "ERROR_SUBMIT": "Error submitting request. Please try again.", + "REMOVE_FILE": "Remove attached file", + "HERO_SUBTITLE": "We are here to help you. Fill in the form below for any request." + }, + "CHECKOUT": { + "TITLE": "Checkout", + "SUBTITLE": "Complete your order by entering the shipping and payment details.", + "CONTACT_INFO": "Contact Information", + "BILLING_ADDR": "Billing Address", + "SHIPPING_ADDR": "Shipping Address", + "FIRST_NAME": "First Name", + "LAST_NAME": "Last Name", + "EMAIL": "Email", + "PHONE": "Phone", + "COMPANY_NAME": "Company Name", + "ADDRESS_1": "Address Line 1", + "ADDRESS_2": "Address Line 2 (Optional)", + "ZIP": "ZIP Code", + "CITY": "City", + "COUNTRY": "Country", + "SHIPPING_SAME": "Shipping address same as billing", + "PLACE_ORDER": "Place Order", + "PROCESSING": "Processing...", + "SUMMARY_TITLE": "Order Summary", + "SUBTOTAL": "Subtotal", + "ITEMS_BASE_SUBTOTAL": "Items Subtotal", + "MACHINE_COST": "Machine Cost", + "SETUP_FEE": "Setup Fee", + "TOTAL": "Total", + "QTY": "Qty", + "PER_PIECE": "per piece", + "SHIPPING": "Shipping", + "ERR_NO_SESSION_START": "No active session found. Please start a new quote.", + "ERR_LOAD_SESSION": "Failed to load session details. Please try again.", + "ERR_NO_SESSION_CREATE_ORDER": "No active session found. Cannot create order.", + "ERR_CREATE_ORDER": "Failed to create order. Please try again.", + "INVALID_EMAIL": "Invalid email", + "COMPANY_OPTIONAL": "Company Name (Optional)", + "REF_PERSON_OPTIONAL": "Reference Person (Optional)", + "SHIPPING_CALCULATED_NEXT_STEP": "shipping cost is calculated at the next step", + "EXCLUDES_SHIPPING": "Shipping cost excluded" + }, + "PAYMENT": { + "TITLE": "Payment", + "METHOD": "Payment Method", + "TWINT_TITLE": "Pay with TWINT", + "TWINT_DESC": "Scan the code with your TWINT app", + "TWINT_OPEN": "Open directly in TWINT", + "TWINT_LINK": "Open payment link", + "BANK_TITLE": "Bank Transfer", + "BANK_OWNER": "Owner", + "BANK_IBAN": "IBAN", + "BANK_REF": "Reference", + "BILLING_INFO_HINT": "Add the same information used in billing.", + "DOWNLOAD_QR": "Download QR-Invoice (PDF)", + "DOWNLOAD_CONFIRMATION": "Download Confirmation (PDF)", + "CONFIRM": "I have completed the payment", + "SUMMARY_TITLE": "Order Summary", + "SUBTOTAL": "Subtotal", + "SHIPPING": "Shipping", + "SETUP_FEE": "Setup Fee", + "TOTAL": "Total", + "LOADING": "Loading order details...", + "METHOD_TWINT": "TWINT", + "METHOD_BANK": "Bank Transfer / QR", + "STATUS_REPORTED_TITLE": "Order in progress", + "STATUS_REPORTED_DESC": "We have registered your operation. Your order will soon move to production.", + "IN_VERIFICATION": "Payment Reported", + "TWINT_QR_ALT": "TWINT payment QR", + "TWINT_BUTTON_ALT": "Embedded TWINT button" + }, + "TRACKING": { + "TITLE": "Order Status", + "SUBTITLE": "Check the status of your order and manage the payment if necessary.", + "STEP_PENDING": "Pending", + "STEP_REPORTED": "Received", + "STEP_PRODUCTION": "Production", + "STEP_SHIPPED": "Shipped" + }, + "ORDER_CONFIRMED": { + "TITLE": "Order Confirmed", + "SUBTITLE": "Payment received. Your order is now being processed.", + "STATUS": "Processing", + "HEADING": "We are preparing your order", + "ORDER_REF": "Order reference", + "PROCESSING_TEXT": "As soon as payment is confirmed, your order will move to production.", + "EMAIL_TEXT": "We will send you an email update with status and next steps.", + "BACK_HOME": "Back to Home" + }, + "STL_VIEWER": { + "LOADING": "Loading 3D model..." + }, + "HOME": { + "SHOP_GALLERY_ARIA": "Shop product gallery", + "FOUNDER_PREV_ARIA": "Previous photo", + "FOUNDER_NEXT_ARIA": "Next photo", + "SHOP_IMAGE_ALT_1": "Technical product printed in 3D", + "FOUNDER_IMAGE_ALT_1": "Founder - photo 1", + "FOUNDER_IMAGE_ALT_2": "Founder - photo 2", + "HERO_EYEBROW": "Technical 3D printing for businesses, freelancers and makers", + "HERO_TITLE": "Price and lead time in a few seconds.
From 3D file to finished part.", + "HERO_LEAD": "The most advanced calculator for your 3D prints: total precision and zero surprises.", + "HERO_SUBTITLE": "We also offer CAD services for custom parts!", + "BTN_CALCULATE": "Calculate Quote", + "BTN_SHOP": "Go to shop", + "BTN_CONTACT": "Talk to us", + "SEC_CALC_TITLE": "Accurate pricing in a few seconds", + "SEC_CALC_SUBTITLE": "No registration required. The estimate is calculated through real slicing.", + "SEC_CALC_LIST_1": "Supported formats: STL, 3MF, STEP", + "CARD_CALC_EYEBROW": "Automatic calculation", + "CARD_CALC_TITLE": "Price and lead time in one click", + "CARD_CALC_TAG": "No registration", + "CARD_CALC_STEP_1": "Upload the 3D file", + "CARD_CALC_STEP_2": "Choose material and quality", + "CARD_CALC_STEP_3": "Get cost and lead time instantly", + "BTN_OPEN_CALC": "Open calculator", + "SEC_CAP_TITLE": "What you can get", + "SEC_CAP_SUBTITLE": "Custom production for prototypes, small series and custom parts.", + "CAP_1_TITLE": "Fast prototyping", + "CAP_1_TEXT": "From digital file to physical model in 24/48 hours. Verify ergonomics, fits and operation before the final mold.", + "CAP_2_TITLE": "Custom parts", + "CAP_2_TEXT": "Unique components impossible to find on the market. We reproduce broken parts or create custom adaptations.", + "CAP_3_TITLE": "Small series", + "CAP_3_TEXT": "Bridge production or limited series (10-500 parts) with repeatable quality.", + "CAP_4_TITLE": "Consulting and CAD", + "CAP_4_TEXT": "No 3D file yet? We help you design it, optimize it for printing and choose the right material together.", + "SEC_SHOP_TITLE": "Shop", + "SEC_SHOP_TEXT": "Selected products, ready to use.", + "SEC_SHOP_LIST_1": "Products designed by us", + "SEC_SHOP_LIST_2": "Spare parts and components that are hard to source", + "SEC_SHOP_LIST_3": "Supports and organizers to improve workflows", + "BTN_DISCOVER": "Discover products", + "BTN_REQ_SOLUTION": "Request a solution", + "CARD_SHOP_1_TITLE": "Selection of verified products", + "CARD_SHOP_1_TEXT": "Checked to ensure long-term functionality and stability.", + "CARD_SHOP_2_TITLE": "Ready-to-use kits", + "CARD_SHOP_2_TEXT": "Compatible components, easy to assemble with no surprises.", + "CARD_SHOP_3_TITLE": "On request", + "CARD_SHOP_3_TEXT": "Can’t find what you need? We design and produce it for you.", + "SEC_ABOUT_TITLE": "About us", + "SEC_ABOUT_TEXT": "We are two engineering students: 3D printing won us over for one simple reason, seeing a problem and building the solution. From this idea come prototypes and objects designed to work in everyday life.", + "FOUNDERS_PHOTO": "Founders photo" + }, + "ORDER": { + "ERR_ID_NOT_FOUND": "Order ID not found.", + "ERR_LOAD_ORDER": "Failed to load order details.", + "ERR_REPORT_PAYMENT": "Failed to report payment. Please try again.", + "NOT_AVAILABLE": "N/A" + }, + "DROPZONE": { + "DEFAULT_LABEL": "Drop files here or click to upload", + "DEFAULT_SUBTEXT": "Supports .stl, .3mf, .step" + }, + "COLOR": { + "AVAILABLE_COLORS": "Available colors", + "CATEGORY_GLOSSY": "Glossy", + "CATEGORY_MATTE": "Matte", + "NAME": { + "BLACK": "Black", + "WHITE": "White", + "RED": "Red", + "BLUE": "Blue", + "GREEN": "Green", + "YELLOW": "Yellow", + "MATTE_BLACK": "Matte Black", + "MATTE_WHITE": "Matte White", + "MATTE_GRAY": "Matte Gray" + } + }, + "COMMON": { + "REQUIRED": "Required field", + "INVALID_EMAIL": "Invalid email", + "BACK": "Back", + "OPTIONAL": "(Optional)" } } diff --git a/frontend/src/assets/i18n/fr.json b/frontend/src/assets/i18n/fr.json new file mode 100644 index 0000000..e9f3ad4 --- /dev/null +++ b/frontend/src/assets/i18n/fr.json @@ -0,0 +1,547 @@ +{ + "NAV": { + "HOME": "Accueil", + "CALCULATOR": "Calculateur", + "SHOP": "Boutique", + "ABOUT": "Qui sommes-nous", + "CONTACT": "Contactez-nous", + "LANGUAGE_SELECTOR": "Sélecteur de langue" + }, + "FOOTER": { + "PRIVACY": "Confidentialité", + "TERMS": "Conditions générales", + "CONTACT": "Contactez-nous" + }, + "HOME": { + "HERO_EYEBROW": "Impression 3D technique pour entreprises, freelances et makers", + "HERO_TITLE": "Prix et délais en quelques secondes.
Du fichier 3D à la pièce finie.", + "HERO_LEAD": "Le calculateur le plus avancé pour vos impressions 3D : précision totale et zéro surprise.", + "HERO_SUBTITLE": "Nous proposons aussi des services CAD pour des pièces personnalisées !", + "BTN_CALCULATE": "Calculer un devis", + "BTN_SHOP": "Aller à la boutique", + "BTN_CONTACT": "Parlez avec nous", + "SEC_CALC_TITLE": "Prix correct en quelques secondes", + "SEC_CALC_SUBTITLE": "Aucune inscription requise. L'estimation est effectuée via un vrai slicing.", + "SEC_CALC_LIST_1": "Formats pris en charge : STL, 3MF, STEP", + "CARD_CALC_EYEBROW": "Calcul automatique", + "CARD_CALC_TITLE": "Prix et délais en un clic", + "CARD_CALC_TAG": "Sans inscription", + "CARD_CALC_STEP_1": "Chargez le fichier 3D", + "CARD_CALC_STEP_2": "Choisissez le matériau et la qualité", + "CARD_CALC_STEP_3": "Recevez immédiatement coût et délai", + "BTN_OPEN_CALC": "Ouvrir le calculateur", + "SEC_CAP_TITLE": "Ce que vous pouvez obtenir", + "SEC_CAP_SUBTITLE": "Production sur mesure pour prototypes, petites séries et pièces personnalisées.", + "CAP_1_TITLE": "Prototypage rapide", + "CAP_1_TEXT": "Du fichier numérique au modèle physique en 24/48 heures. Vérifiez ergonomie, emboîtements et fonctionnement avant le moule définitif.", + "CAP_2_TITLE": "Pièces personnalisées", + "CAP_2_TEXT": "Composants uniques introuvables dans le commerce. Nous reproduisons des pièces cassées ou créons des adaptations sur mesure.", + "CAP_3_TITLE": "Petites séries", + "CAP_3_TEXT": "Production intermédiaire ou séries limitées (10-500 pièces) avec qualité reproductible.", + "CAP_4_TITLE": "Conseil et CAD", + "CAP_4_TEXT": "Vous n'avez pas de fichier 3D ? Nous vous aidons à le concevoir, à l'optimiser pour l'impression et à choisir le bon matériau ensemble.", + "SEC_SHOP_TITLE": "Boutique", + "SEC_SHOP_TEXT": "Produits sélectionnés et prêts à l'emploi.", + "SEC_SHOP_LIST_1": "Produits conçus par nous", + "SEC_SHOP_LIST_2": "Pièces de rechange et composants difficiles à trouver", + "SEC_SHOP_LIST_3": "Supports et organiseurs pour améliorer les flux de travail", + "BTN_DISCOVER": "Découvrir les produits", + "BTN_REQ_SOLUTION": "Demander une solution", + "CARD_SHOP_1_TITLE": "Sélection de produits vérifiés", + "CARD_SHOP_1_TEXT": "Contrôlés pour garantir fonctionnalité et stabilité dans le temps.", + "CARD_SHOP_2_TITLE": "Kits prêts à l'emploi", + "CARD_SHOP_2_TEXT": "Composants compatibles et faciles à monter, sans surprise.", + "CARD_SHOP_3_TITLE": "Sur demande", + "CARD_SHOP_3_TEXT": "Vous ne trouvez pas ce qu'il vous faut ? Nous le concevons et le produisons pour vous.", + "SEC_ABOUT_TITLE": "À propos de nous", + "SEC_ABOUT_TEXT": "Nous sommes deux étudiants en ingénierie : l'impression 3D nous a conquis pour une raison simple, voir un problème et construire la solution. De cette idée naissent des prototypes et des objets conçus pour fonctionner au quotidien.", + "FOUNDERS_PHOTO": "Photo des fondateurs", + "SHOP_GALLERY_ARIA": "Galerie produits boutique", + "FOUNDER_PREV_ARIA": "Photo précédente", + "FOUNDER_NEXT_ARIA": "Photo suivante", + "SHOP_IMAGE_ALT_1": "Produit technique imprimé en 3D", + "FOUNDER_IMAGE_ALT_1": "Fondateur - photo 1", + "FOUNDER_IMAGE_ALT_2": "Fondateur - photo 2" + }, + "CALC": { + "TITLE": "Calculer un devis 3D", + "SUBTITLE": "Chargez votre fichier 3D (STL, 3MF, STEP), réglez la qualité et la couleur puis calculez immédiatement prix et délais.", + "CTA_START": "Commencer maintenant", + "BUSINESS": "Entreprises", + "PRIVATE": "Particuliers", + "MODE_EASY": "Base", + "MODE_ADVANCED": "Avancée", + "UPLOAD_LABEL": "Glissez votre fichier 3D ici", + "UPLOAD_SUB": "Nous prenons en charge STL, 3MF, STEP jusqu'à 50MB", + "MATERIAL": "Matériau", + "QUALITY": "Qualité", + "PRINT_SPEED": "Vitesse d'impression", + "QUANTITY": "Quantité", + "NOTES": "Notes supplémentaires", + "COLOR": "Couleur", + "INFILL": "Remplissage (%)", + "PATTERN": "Motif de remplissage", + "LAYER_HEIGHT": "Hauteur de couche", + "NOZZLE": "Diamètre de buse", + "SUPPORT": "Supports", + "SUPPORT_DESC": "Activer les supports pour les surplombs", + "CALCULATE": "Calculer le devis", + "RESULT": "Devis estimé", + "TIME": "Temps d'impression", + "MACHINE_COST": "Coût machine", + "COST": "Coût total", + "ORDER": "Commander maintenant", + "CONSULT": "Demander une consultation", + "ERROR_GENERIC": "Une erreur s'est produite pendant le calcul du devis.", + "NEW_QUOTE": "Calculer un nouveau devis", + "ORDER_SUCCESS_TITLE": "Commande envoyée avec succès", + "ORDER_SUCCESS_DESC": "Nous avons reçu les détails de votre commande. Vous recevrez bientôt un e-mail de confirmation avec les informations de paiement.", + "BENEFITS_TITLE": "Pourquoi nous choisir ?", + "BENEFITS_1": "Devis automatique avec coût et délai immédiats", + "BENEFITS_2": "Matériaux sélectionnés et qualité contrôlée", + "BENEFITS_3": "Conseil CAD si le fichier nécessite des modifications", + "ERR_FILE_REQUIRED": "Le fichier est obligatoire.", + "ANALYZING_TITLE": "Analyse en cours...", + "ANALYZING_TEXT": "Nous analysons la géométrie et calculons le parcours outil.", + "QTY_SHORT": "QTÉ", + "COLOR_LABEL": "COULEUR", + "ADD_FILES": "Ajouter des fichiers", + "UPLOADING": "Téléversement...", + "PROCESSING": "Traitement...", + "NOTES_PLACEHOLDER": "Instructions spécifiques...", + "SETUP_NOTE": "* Inclut {{cost}} comme coût de setup", + "SHIPPING_NOTE": "** Frais d'expédition exclus, calculés à l'étape suivante", + "STEP_WARNING": "La visualisation 3D n'est pas compatible avec les fichiers STEP et 3MF", + "REMOVE_FILE": "Supprimer le fichier", + "FALLBACK_MATERIAL": "PLA (fallback)", + "FALLBACK_QUALITY_STANDARD": "Standard", + "ERR_FILE_TOO_LARGE": "Certains fichiers dépassent la limite de 200MB et n'ont pas été ajoutés.", + "ERROR_ZERO_PRICE": "Quelque chose s'est mal passé. Essayez un autre format ou contactez-nous." + }, + "QUOTE": { + "PROCEED_ORDER": "Procéder à la commande", + "CONSULT": "Demander une consultation", + "TOTAL": "Total", + "MAX_QTY_NOTICE": "Pour des quantités supérieures à {{max}} pièces, demandez une consultation." + }, + "USER_DETAILS": { + "TITLE": "Vos données", + "NAME": "Prénom", + "NAME_PLACEHOLDER": "Votre prénom", + "SURNAME": "Nom", + "SURNAME_PLACEHOLDER": "Votre nom", + "EMAIL": "E-mail", + "EMAIL_PLACEHOLDER": "votre@email.com", + "PHONE": "Téléphone", + "PHONE_PLACEHOLDER": "+41 ...", + "ADDRESS": "Adresse", + "ADDRESS_PLACEHOLDER": "Rue et numéro", + "ZIP": "Code postal", + "ZIP_PLACEHOLDER": "0000", + "CITY": "Ville", + "CITY_PLACEHOLDER": "Ville", + "SUBMIT": "Continuer", + "SUMMARY_TITLE": "Récapitulatif", + "DEFAULT_COLOR": "Par défaut" + }, + "COMMON": { + "REQUIRED": "Champ obligatoire", + "INVALID_EMAIL": "E-mail invalide", + "BACK": "Retour", + "OPTIONAL": "(Optionnel)" + }, + "SHOP": { + "TITLE": "Solutions techniques", + "SUBTITLE": "Produits prêts à l'emploi qui résolvent des problèmes pratiques", + "WIP_EYEBROW": "Work in progress", + "WIP_TITLE": "Boutique en préparation", + "WIP_SUBTITLE": "Nous préparons une boutique avec des produits sélectionnés et des fonctionnalités de création automatique !", + "WIP_CTA_CALC": "Aller au calculateur", + "WIP_RETURN_LATER": "Revenez dans un moment", + "WIP_NOTE": "Nous tenons à bien faire les choses : en attendant, vous pouvez calculer immédiatement prix et délais d'un fichier 3D avec notre calculateur.", + "ADD_CART": "Ajouter au panier", + "BACK": "Retour à la boutique", + "NOT_FOUND": "Produit introuvable.", + "DETAILS": "Détails", + "MOCK_ADD_CART": "Ajouté au panier (Mock)", + "SUCCESS_TITLE": "Ajouté au panier", + "SUCCESS_DESC": "Le produit a été ajouté au panier avec succès.", + "CONTINUE": "Continuer", + "CATEGORIES": { + "FILAMENTS": "Filaments", + "ACCESSORIES": "Accessoires" + }, + "PRODUCTS": { + "P1": { + "NAME": "Filament PLA Standard", + "DESC": "Le classique pour chaque impression, simple et fiable." + }, + "P2": { + "NAME": "Filament PETG Tough", + "DESC": "Résistant aux chocs et aux températures." + }, + "P3": { + "NAME": "Kit de buses (0.4mm)", + "DESC": "Kit de remplacement pour extrudeur FDM." + } + } + }, + "ABOUT": { + "TITLE": "Qui sommes-nous", + "EYEBROW": "Atelier d'impression 3D", + "SUBTITLE": "Nous sommes deux étudiants avec beaucoup d'envie de faire et d'apprendre.", + "HOW_TEXT": "3D Fab est né pour transformer le potentiel de l'impression 3D en solutions du quotidien. Nous sommes partis de la curiosité technique et nous sommes arrivés à la production de pièces de rechange, de produits et de prototypes sur mesure. Pour passer d'une idée à un projet concret, nous avons lancé notre calculateur automatique : des devis clairs en un clic pour vous garantir un service professionnel, sans surprises sur le prix.", + "PASSIONS_TITLE": "Nos centres d'intérêt", + "PASSION_BIKE_TRIAL": "Bike trial", + "PASSION_MOUNTAIN": "Montagne", + "PASSION_SKI": "Ski", + "PASSION_SELF_HOSTING": "Self hosting", + "PASSION_PRINT_3D": "Impression 3D", + "PASSION_TRAVEL": "Voyage", + "PASSION_SNOWBOARD": "Snowboard", + "PASSION_SNOWBOARD_INSTRUCTOR": "Moniteur de snowboard", + "PASSION_ELECTRONICS": "Électronique", + "PASSION_WOODWORKING": "Travail du bois", + "PASSION_VAN_LIFE": "Van life", + "PASSION_COFFEE": "Café", + "PASSION_SOFTWARE_DEVELOPMENT": "Développement logiciel", + "SERVICES_TITLE": "Services principaux", + "TARGET_TITLE": "Pour qui", + "TARGET_TEXT": "Petites entreprises, freelances, makers et clients qui cherchent un produit prêt à l'emploi dans la boutique.", + "TEAM_TITLE": "Notre équipe", + "MEMBER_JOE_NAME": "Joe Küng", + "MEMBER_JOE_ROLE": "Étudiant en ingénierie informatique", + "MEMBER_JOE_ALT": "Joe Küng", + "MEMBER_MATTEO_NAME": "Matteo Caletti", + "MEMBER_MATTEO_ROLE": "Étudiant en ingénierie électronique", + "MEMBER_MATTEO_ALT": "Matteo Caletti" + }, + "LOCATIONS": { + "TITLE": "Nos sites", + "SUBTITLE": "Nous sommes présents sur deux sites. Sélectionnez le site pour voir les détails.", + "TICINO": "Tessin", + "BIASCA": "Biasca", + "BIENNE": "Bienne", + "ADDRESS_TICINO": "Via G. Pioda 29a, Biasca", + "ADDRESS_BIENNE": "Lyss-strasse 71, Nidau 2560 Bienne", + "CONTACT_US": "Contactez-nous" + }, + "LEGAL": { + "PRIVACY_TITLE": "Politique de confidentialité", + "TERMS_TITLE": "Conditions générales", + "LAST_UPDATE": "Dernière mise à jour", + "TERMS_UPDATE_DATE": "24 février 2026", + "PRIVACY_UPDATE_DATE": "24 février 2026", + "CONSENT": { + "LABEL_PREFIX": "J'ai lu et j'accepte les", + "TERMS_LINK": "Conditions générales", + "AND": "et la", + "PRIVACY_LINK": "Politique de confidentialité", + "REQUIRED_ERROR": "Pour continuer vous devez accepter les Conditions et la Confidentialité.", + "UPLOAD_NOTICE_PREFIX": "En téléversant un fichier vous acceptez notre", + "UPLOAD_NOTICE_LINK": "Politique de confidentialité" + }, + "PRIVACY": { + "META": { + "CONTROLLER": "Responsables du traitement : Matteo Caletti et Joe Kung (3D-Fab.ch).", + "CONTACT": "Contact confidentialité : info@3d-fab.ch" + }, + "S1": { + "TITLE": "1. Quelles données nous traitons", + "P1": "1.1. Nous collectons les données nécessaires aux devis et aux commandes : prénom, nom, e-mail, téléphone, adresse, données de livraison/facturation et détails de la commande.", + "P2": "1.2. Lorsque vous téléversez des fichiers 3D ou des pièces jointes techniques, les fichiers sont traités uniquement pour l'analyse, la production, l'assistance et la gestion de la commande." + }, + "S2": { + "TITLE": "2. Finalité du traitement", + "P1": "2.1. Nous utilisons les données exclusivement pour : préparer les devis, confirmer les commandes, recevoir les paiements, organiser les expéditions et fournir l'assistance après-vente.", + "P2": "2.2. Nous n'utilisons pas les données pour le profilage, le marketing automatisé ou la vente à des tiers." + }, + "S3": { + "TITLE": "3. Conservation des fichiers et des données", + "P1": "3.1. Les fichiers téléversés uniquement pour un devis sont conservés pendant la durée de la session et supprimés automatiquement à son expiration.", + "P2": "3.2. Les fichiers et données liés aux commandes confirmées sont conservés le temps nécessaire à la production, la livraison, l'assistance et les obligations administratives/légales, puis supprimés ou minimisés.", + "P3": "3.3. Les fichiers téléversés dans le calculateur sont utilisés uniquement pour l'analyse technique et l'estimation du devis et restent associés exclusivement à la session du calculateur." + }, + "S4": { + "TITLE": "4. Cookies et traçage", + "P1": "4.1. Nous n'utilisons pas de cookies de profilage, de marketing ou de traçage de tiers.", + "P2": "4.2. Nous n'utilisons pas de systèmes de tracking publicitaire. Les éventuelles données techniques temporaires sont utilisées uniquement pour le fonctionnement de la session." + }, + "S5": { + "TITLE": "5. Partage des données", + "P1": "5.1. Nous partageons les données uniquement avec des fournisseurs strictement nécessaires au service (hébergement, paiements, expéditions), en nous limitant aux informations indispensables.", + "P2": "5.2. Les fournisseurs traitent les données selon des instructions contractuelles et des mesures de sécurité appropriées." + }, + "S6": { + "TITLE": "6. Droits de l'utilisateur", + "P1": "6.1. Vous pouvez demander l'accès, la rectification, la suppression ou la limitation du traitement de vos données, dans les limites prévues par la loi.", + "P2": "6.2. Pour toute demande liée à la confidentialité, écrivez à info@3d-fab.ch. La version italienne de cette politique de confidentialité est la seule version juridiquement contraignante, sauf accord écrit contraire." + } + }, + "TERMS": { + "META": { + "PROVIDER": "Fournisseur : 3D-Fab.ch (Matteo Caletti et Joe Kung)", + "VERSION": "Version : v1.0", + "SCOPE": "Valables pour l'impression 3D FDM/FFF et les services associés, avec vente et livraison exclusivement en Suisse (CH)." + }, + "S1": { + "TITLE": "1. Données du fournisseur et contacts", + "P1": "1.1. Fournisseur : Matteo Caletti et Joe Kung.", + "P2": "1.2. Adresse : Lyss-strasse 71, Nidau 2560, CH.", + "P3": "1.3. E-mail : info@3d-fab.ch." + }, + "S2": { + "TITLE": "2. Champ d'application", + "P1": "2.1. Les présentes Conditions générales régissent l'impression 3D à la demande, les services accessoires (conseil technique, CAD, vérification de fichiers, post-traitement) et les éventuels articles standard.", + "P2": "2.2. Le fournisseur vend et livre exclusivement en Suisse (CH).", + "P3": "2.3. Elles s'appliquent aux commandes effectuées via site, e-mail ou autres canaux convenus ; des conditions différentes du client ne valent que si elles sont acceptées par écrit." + }, + "S3": { + "TITLE": "3. Définitions", + "P1": "3.1. Client : personne physique ou morale acheteuse. Fichiers du client : modèles 3D et données techniques fournis. Produit personnalisé : pièce réalisée selon les spécifications du client. Commande : demande acceptée par le fournisseur." + }, + "S4": { + "TITLE": "4. Conclusion du contrat et commande", + "P1": "4.1. Le contrat est conclu avec la confirmation de commande (e-mail/portail) ou avec le démarrage de la production après paiement/acompte.", + "P2": "4.2. Les devis automatiques peuvent être confirmés ou mis à jour après vérification technique minimale (imprimabilité, supports, limites machine).", + "P3": "4.3. Le fournisseur peut refuser des commandes techniquement non réalisables ou non conformes aux présentes conditions." + }, + "S5": { + "TITLE": "5. Prix, taxes et expédition", + "P1": "5.1. Les prix sont en CHF, sauf indication contraire concernant TVA incluse/exclue.", + "P2": "5.2. L'expédition et l'emballage sont indiqués séparément lorsque applicables.", + "P3": "5.3. Pour petites séries ou CAD complexe, des offres écrites avec conditions spécifiques peuvent être appliquées (échantillons, paliers, approbation première pièce)." + }, + "S6": { + "TITLE": "6. Paiements", + "P1": "6.1. Méthodes acceptées : TWINT et virement bancaire.", + "P2": "6.2. Sauf accord contraire, la production démarre seulement après paiement.", + "P3": "6.3. Pour le conseil CAD et les petites séries, un acompte (30-100%) et/ou le solde avant livraison peut être demandé.", + "P4": "6.4. Coordonnées bancaires : Joe Kung, IBAN CH74 0900 0000 1548 2158 1, Via G. Pioda 29a, 6710 Biasca." + }, + "S7": { + "TITLE": "7. Fichiers du client et responsabilité technique", + "P1": "7.1. Le client garantit disposer des droits sur les fichiers et que leur usage ne viole pas des droits de tiers (copyright, brevets, licences, secrets industriels).", + "P2": "7.2. Le client est responsable de la conformité du modèle, du choix du matériau, de l'adéquation à l'usage final et du respect des normes applicables.", + "P3": "7.3. Le fournisseur peut suggérer des modifications pour améliorer imprimabilité et résultat, mais n'exerce pas une fonction de certification du produit final sauf accord écrit." + }, + "S8": { + "TITLE": "8. Qualité FDM/FFF et tolérances", + "P1": "8.1. L'impression FDM/FFF comporte des caractéristiques intrinsèques : lignes de couche, anisotropie mécanique, micro-imperfections, variations de couleur et possibles retraits/déformations liés à la géométrie et au matériau.", + "P2": "8.2. Tolérances standard (sauf accord écrit) : jusqu'à 100 mm +/-0.3 mm ; au-delà de 100 mm +/-0.5% (minimum +/-0.3 mm).", + "P3": "8.3. Les exigences esthétiques ou dimensionnelles critiques doivent être convenues avant la commande (ex. échantillon, mesures de contrôle, finition spécifique)." + }, + "S9": { + "TITLE": "9. Post-traitement et travaux additionnels", + "P1": "9.1. Des travaux comme retrait des supports, ponçage, primaire/peinture ou inserts filetés sont réalisés seulement si convenus et peuvent introduire des variations dimensionnelles ou esthétiques.", + "P2": "9.2. Sur demande, une approbation de la première pièce ou d'un échantillon peut être prévue, éventuellement payante." + }, + "S10": { + "TITLE": "10. Délais de production et de livraison", + "P1": "10.1. Les délais indiqués sont des estimations basées sur charge de travail, complexité et disponibilité des matériaux.", + "P2": "10.2. Les retards dus à des causes externes (transporteurs, fournisseurs, pannes, force majeure) ne donnent pas automatiquement droit à des pénalités sauf accord écrit.", + "P3": "10.3. Le client doit fournir des adresses correctes et complètes ; les éventuels coûts de relivraison pour adresse erronée restent à la charge du client." + }, + "S11": { + "TITLE": "11. Droit de rétractation et annulation", + "P1": "11.1. Sauf exceptions confirmées par écrit, aucun droit de rétractation ou de retour n'est prévu après la commande.", + "P2": "11.2. Les produits personnalisés ne sont pas annulables ni remboursables après démarrage de la production.", + "P3": "11.3. Le fournisseur peut accepter des exceptions avant démarrage de la production, en retenant les éventuels coûts déjà engagés.", + "P4": "11.4. Toute condition spéciale doit figurer dans l'offre, la confirmation de commande ou des communications écrites." + }, + "S12": { + "TITLE": "12. Réclamations, défauts et recours", + "P1": "12.1. Le client doit contrôler les produits à la livraison et signaler d'éventuels défauts sous 7 jours, par écrit, avec photo/vidéo et description.", + "P2": "12.2. Les signes typiques FDM, petites variations chromatiques, imperfections non fonctionnelles ou tolérances dans les limites convenues ne constituent pas automatiquement un défaut.", + "P3": "12.3. En cas de défaut imputable au fournisseur, les recours sont au choix du fournisseur : réimpression/remplacement ou remboursement limité à la valeur de la pièce ou de la commande défectueuse.", + "P4": "12.4. Avant réimpression ou remboursement, la restitution de la pièce peut être demandée ; si le défaut est confirmé, le retour est à la charge du fournisseur." + }, + "S13": { + "TITLE": "13. Usages interdits et conformité", + "P1": "13.1. Le fournisseur peut refuser des commandes relatives à des objets illégaux, armes ou pièces réglementées, applications critiques pour la sécurité ou médicales sans accords et validations dédiés.", + "P2": "13.2. Le client reste responsable de l'usage final et de la conformité réglementaire du produit." + }, + "S14": { + "TITLE": "14. Propriété intellectuelle et licences", + "P1": "14.1. Les droits sur les fichiers du client restent au client ou aux titulaires concernés. Le client concède au fournisseur une licence limitée à l'usage pour l'évaluation technique et la production.", + "P2": "14.2. Sauf accord contraire, les fichiers et projets CAD développés par le fournisseur sont livrés après paiement, avec droit d'usage pour les objectifs convenus.", + "P3": "14.3. Le client dégage le fournisseur de toute réclamation de tiers liée à des fichiers ou instructions fournis par le client." + }, + "S15": { + "TITLE": "15. Protection des données", + "P1": "15.1. Le fournisseur traite les données personnelles pour la gestion des commandes, paiements, expéditions et assistance.", + "P2": "15.2. Les données peuvent être partagées avec des fournisseurs techniques et transporteurs uniquement dans la mesure nécessaire à la fourniture du service." + }, + "S16": { + "TITLE": "16. Limitation de responsabilité", + "P1": "16.1. Dans les limites légales, le fournisseur répond seulement des dommages directs prévisibles et, en tout cas, pas au-delà de la valeur de la commande relative au produit ou service contesté.", + "P2": "16.2. Sont exclus, dans les limites autorisées, les dommages indirects, perte de profit, interruption d'activité, perte de données et dommages consécutifs.", + "P3": "16.3. Restent réservées les responsabilités non excluables par la loi (ex. dol ou faute grave)." + }, + "S17": { + "TITLE": "17. Force majeure", + "P1": "17.1. Des événements hors du contrôle raisonnable du fournisseur (pannes, coupures, retards fournisseurs, grèves, mesures d'autorité) peuvent provoquer des prorogations ou suspensions sans responsabilité." + }, + "S18": { + "TITLE": "18. Droit applicable et juridiction compétente", + "P1": "18.1. Le droit suisse s'applique.", + "P2": "18.2. Juridiction compétente : siège du fournisseur, sauf normes impératives de protection des consommateurs.", + "P3": "18.3. En cas de divergences d'interprétation, la version italienne des présentes conditions prévaut et constitue la seule version juridiquement contraignante, sauf accord écrit contraire." + } + } + }, + "CONTACT": { + "TITLE": "Contactez-nous", + "SEND": "Envoyer le message", + "REQ_TYPE_LABEL": "Type de demande", + "TYPE_PRIVATE": "Privé", + "TYPE_COMPANY": "Entreprise", + "REQ_TYPE_CUSTOM": "Devis personnalisé", + "REQ_TYPE_SERIES": "Impression en série", + "REQ_TYPE_CONSULT": "Consultation", + "REQ_TYPE_QUESTION": "Questions générales", + "PHONE": "Téléphone", + "IS_COMPANY": "Êtes-vous une entreprise ?", + "COMPANY_NAME": "Raison sociale", + "REF_PERSON": "Personne de référence", + "UPLOAD_LABEL": "Pièces jointes", + "UPLOAD_HINT": "Max 15 fichiers. Pris en charge : images, vidéos, PDF, documents Office, STL/STEP/3MF/OBJ/IGES, DWG/DXF. Fichiers compressés non autorisés.", + "DROP_FILES": "Déposez les fichiers ici ou cliquez pour téléverser", + "PLACEHOLDER_NAME": "Votre nom", + "PLACEHOLDER_EMAIL": "votre@email.com", + "PLACEHOLDER_PHONE": "+41 00 000 00 00", + "PLACEHOLDER_COMPANY": "Nom de l'entreprise", + "PLACEHOLDER_REF_PERSON": "Personne de référence", + "LABEL_MESSAGE": "Message *", + "LABEL_EMAIL": "E-mail *", + "LABEL_NAME": "Nom *", + "MSG_SENT": "Envoyé !", + "ERR_MAX_FILES": "Limite maximale de 15 fichiers atteinte.", + "ERR_COMPRESSED_FILES": "Les fichiers compressés ne sont pas autorisés (ZIP/RAR/7z/TAR/GZ).", + "SUCCESS_TITLE": "Message envoyé avec succès", + "SUCCESS_DESC": "Merci de nous avoir contactés. Nous avons reçu votre message et vous enverrons un e-mail récapitulatif prochainement.", + "SEND_ANOTHER": "Envoyer un autre message", + "HERO_SUBTITLE": "Nous sommes là pour vous aider. Remplissez le formulaire ci-dessous pour toute demande.", + "FILE_TYPE_PDF": "PDF", + "FILE_TYPE_3D": "3D", + "FILE_TYPE_VIDEO": "Vidéo", + "FILE_TYPE_DOC": "DOC", + "FILE_TYPE_FILE": "FICHIER", + "ERROR_SUBMIT": "Erreur lors de l'envoi de la demande. Réessayez.", + "REMOVE_FILE": "Supprimer le fichier joint" + }, + "CHECKOUT": { + "TITLE": "Checkout", + "SUBTITLE": "Complétez votre commande en saisissant les détails de livraison et de paiement.", + "CONTACT_INFO": "Informations de contact", + "BILLING_ADDR": "Adresse de facturation", + "SHIPPING_ADDR": "Adresse de livraison", + "FIRST_NAME": "Prénom", + "LAST_NAME": "Nom", + "EMAIL": "E-mail", + "PHONE": "Téléphone", + "COMPANY_NAME": "Nom de l'entreprise", + "ADDRESS_1": "Adresse (Rue et numéro)", + "ADDRESS_2": "Informations supplémentaires (optionnel)", + "ZIP": "Code postal", + "CITY": "Ville", + "COUNTRY": "Pays", + "SHIPPING_SAME": "L'adresse de livraison est la même que l'adresse de facturation", + "PLACE_ORDER": "Envoyer la commande", + "PROCESSING": "Traitement...", + "SUMMARY_TITLE": "Récapitulatif de la commande", + "SUBTOTAL": "Sous-total", + "ITEMS_BASE_SUBTOTAL": "Sous-total de base des articles", + "MACHINE_COST": "Coût machine", + "SETUP_FEE": "Coût de setup", + "TOTAL": "Total", + "QTY": "Qté", + "PER_PIECE": "par pièce", + "SHIPPING": "Expédition (CH)", + "INVALID_EMAIL": "E-mail invalide", + "COMPANY_OPTIONAL": "Nom de l'entreprise (Optionnel)", + "REF_PERSON_OPTIONAL": "Personne de référence (Optionnel)", + "SHIPPING_CALCULATED_NEXT_STEP": "le coût d'expédition est calculé à l'étape suivante", + "EXCLUDES_SHIPPING": "Coût d'expédition exclu", + "ERR_NO_SESSION_START": "Aucune session active trouvée. Commencez un nouveau devis.", + "ERR_LOAD_SESSION": "Impossible de charger les détails de la session. Réessayez.", + "ERR_NO_SESSION_CREATE_ORDER": "Aucune session active trouvée. Impossible de créer la commande.", + "ERR_CREATE_ORDER": "Impossible de créer la commande. Réessayez." + }, + "PAYMENT": { + "TITLE": "Paiement", + "METHOD": "Méthode de paiement", + "TWINT_TITLE": "Payer avec TWINT", + "TWINT_DESC": "Scannez le code avec l'application TWINT, sur mobile cliquez sur le bouton.", + "TWINT_OPEN": "Ouvrir directement dans TWINT", + "TWINT_LINK": "Ouvrir le lien de paiement", + "BANK_TITLE": "Virement bancaire", + "BANK_OWNER": "Titulaire", + "BANK_IBAN": "IBAN", + "BANK_REF": "Référence", + "BILLING_INFO_HINT": "Nous avons rempli les champs pour vous, merci de ne pas modifier le motif de paiement", + "DOWNLOAD_QR": "Télécharger la facture QR (PDF)", + "DOWNLOAD_CONFIRMATION": "Télécharger la confirmation (PDF)", + "CONFIRM": "J'ai effectué le paiement", + "SUMMARY_TITLE": "Récapitulatif de la commande", + "SUBTOTAL": "Sous-total", + "SHIPPING": "Expédition", + "SETUP_FEE": "Coût de setup", + "TOTAL": "Total", + "LOADING": "Chargement des détails de la commande...", + "METHOD_TWINT": "TWINT", + "METHOD_BANK": "Facture QR / Virement", + "STATUS_REPORTED_TITLE": "Commande en cours", + "STATUS_REPORTED_DESC": "Nous avons enregistré votre opération. Dès que nous confirmons le paiement, la commande entrera en production.", + "IN_VERIFICATION": "Paiement signalé", + "TWINT_QR_ALT": "QR de paiement TWINT", + "TWINT_BUTTON_ALT": "Bouton TWINT intégré" + }, + "TRACKING": { + "TITLE": "Statut de la commande", + "SUBTITLE": "Consultez le statut de votre commande et gérez le paiement si nécessaire.", + "STEP_PENDING": "En attente", + "STEP_REPORTED": "En vérification", + "STEP_PRODUCTION": "En production", + "STEP_SHIPPED": "Expédié" + }, + "ORDER_CONFIRMED": { + "TITLE": "Commande confirmée", + "SUBTITLE": "Paiement enregistré. Votre commande est maintenant en traitement.", + "STATUS": "En traitement", + "HEADING": "Nous préparons votre commande", + "ORDER_REF": "Référence commande", + "PROCESSING_TEXT": "Dès que nous confirmons le paiement, votre commande passera en production.", + "EMAIL_TEXT": "Nous vous enverrons un e-mail avec mise à jour du statut et prochaines étapes.", + "BACK_HOME": "Retour à l'accueil" + }, + "STL_VIEWER": { + "LOADING": "Chargement du modèle 3D..." + }, + "ORDER": { + "ERR_ID_NOT_FOUND": "ID de commande introuvable.", + "ERR_LOAD_ORDER": "Impossible de charger les détails de la commande.", + "ERR_REPORT_PAYMENT": "Impossible de signaler le paiement. Réessayez.", + "NOT_AVAILABLE": "N/D" + }, + "DROPZONE": { + "DEFAULT_LABEL": "Glissez les fichiers ici ou cliquez pour téléverser", + "DEFAULT_SUBTEXT": "Prend en charge .STL, .3MF, .STEP" + }, + "COLOR": { + "AVAILABLE_COLORS": "Couleurs disponibles", + "CATEGORY_GLOSSY": "Brillants", + "CATEGORY_MATTE": "Mats", + "NAME": { + "BLACK": "Noir", + "WHITE": "Blanc", + "RED": "Rouge", + "BLUE": "Bleu", + "GREEN": "Vert", + "YELLOW": "Jaune", + "MATTE_BLACK": "Noir mat", + "MATTE_WHITE": "Blanc mat", + "MATTE_GRAY": "Gris mat" + } + } +} diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index c9a7be4..15437f2 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -4,23 +4,75 @@ "CALCULATOR": "Calcolatore", "SHOP": "Shop", "ABOUT": "Chi Siamo", - "CONTACT": "Contattaci" + "CONTACT": "Contattaci", + "LANGUAGE_SELECTOR": "Selettore lingua" }, "FOOTER": { "PRIVACY": "Privacy", "TERMS": "Termini & Condizioni", "CONTACT": "Contattaci" }, + "HOME": { + "HERO_EYEBROW": "Stampa 3D tecnica per aziende, freelance e maker", + "HERO_TITLE": "Prezzo e tempi in pochi secondi.
Dal file 3D al pezzo finito.", + "HERO_LEAD": "Il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.", + "HERO_SUBTITLE": "Offriamo anche servizi di CAD per pezzi personalizzati!", + "BTN_CALCULATE": "Calcola Preventivo", + "BTN_SHOP": "Vai allo shop", + "BTN_CONTACT": "Parla con noi", + "SEC_CALC_TITLE": "Prezzo corretto in pochi secondi", + "SEC_CALC_SUBTITLE": "Nessuna registrazione necessaria. La stima è effettuata tramite vero slicing.", + "SEC_CALC_LIST_1": "Formati supportati: STL, 3MF, STEP", + "CARD_CALC_EYEBROW": "Calcolo automatico", + "CARD_CALC_TITLE": "Prezzo e tempi in un click", + "CARD_CALC_TAG": "Senza registrazione", + "CARD_CALC_STEP_1": "Carica il file 3D", + "CARD_CALC_STEP_2": "Scegli materiale e qualità", + "CARD_CALC_STEP_3": "Ricevi subito costo e tempo", + "BTN_OPEN_CALC": "Apri calcolatore", + "SEC_CAP_TITLE": "Cosa puoi ottenere", + "SEC_CAP_SUBTITLE": "Produzione su misura per prototipi, piccole serie e pezzi personalizzati.", + "CAP_1_TITLE": "Prototipazione veloce", + "CAP_1_TEXT": "Dal file digitale al modello fisico in 24/48 ore. Verifica ergonomia, incastri e funzionamento prima dello stampo definitivo.", + "CAP_2_TITLE": "Pezzi personalizzati", + "CAP_2_TEXT": "Componenti unici impossibili da trovare in commercio. Riproduciamo parti rotte o creiamo adattamenti su misura.", + "CAP_3_TITLE": "Piccole serie", + "CAP_3_TEXT": "Produzione ponte o serie limitate (10-500 pezzi) con qualità ripetibile.", + "CAP_4_TITLE": "Consulenza e CAD", + "CAP_4_TEXT": "Non hai il file 3D? Ti aiutiamo a progettarlo, ottimizzarlo per la stampa e scegliere il materiale giusto insieme.", + "SEC_SHOP_TITLE": "Shop", + "SEC_SHOP_TEXT": "Prodotti selezionati, e pronti all'uso.", + "SEC_SHOP_LIST_1": "Prodotti disegnati da noi", + "SEC_SHOP_LIST_2": "Ricambi e componenti difficili da reperire", + "SEC_SHOP_LIST_3": "Supporti e organizzatori per migliorare i flussi di lavoro", + "BTN_DISCOVER": "Scopri i prodotti", + "BTN_REQ_SOLUTION": "Richiedi una soluzione", + "CARD_SHOP_1_TITLE": "Selezione di prodotti verificati", + "CARD_SHOP_1_TEXT": "Controllati per garantire funzionalità e stabilità nel lungo periodo.", + "CARD_SHOP_2_TITLE": "Kit pronti all'uso", + "CARD_SHOP_2_TEXT": "Componenti compatibili e facili da montare senza sorprese.", + "CARD_SHOP_3_TITLE": "Su richiesta", + "CARD_SHOP_3_TEXT": "Non trovi quello che serve? Lo progettiamo e lo produciamo per te.", + "SEC_ABOUT_TITLE": "Su di noi", + "SEC_ABOUT_TEXT": "Siamo due studenti di ingegneria: la stampa 3D ci ha conquistati per un motivo semplice, vedere un problema e costruire la soluzione. Da questa idea prendono forma prototipi, oggetti pensati per funzionare nella quotidianità. ", + "FOUNDERS_PHOTO": "Foto Founders", + "SHOP_GALLERY_ARIA": "Galleria prodotti shop", + "FOUNDER_PREV_ARIA": "Foto precedente", + "FOUNDER_NEXT_ARIA": "Foto successiva", + "SHOP_IMAGE_ALT_1": "Prodotto tecnico stampato in 3D", + "FOUNDER_IMAGE_ALT_1": "Founder - foto 1", + "FOUNDER_IMAGE_ALT_2": "Founder - foto 2" + }, "CALC": { "TITLE": "Calcola Preventivo 3D", - "SUBTITLE": "Carica il tuo file 3D (STL, 3MF, STEP...) e ricevi una stima immediata di costi e tempi di stampa.", + "SUBTITLE": "Carica il tuo file 3D (STL, 3MF, STEP), imposta la qualità, il colore e calcola immediatamente prezzo e tempi.", "CTA_START": "Inizia Ora", "BUSINESS": "Aziende", "PRIVATE": "Privati", "MODE_EASY": "Base", "MODE_ADVANCED": "Avanzata", "UPLOAD_LABEL": "Trascina il tuo file 3D qui", - "UPLOAD_SUB": "Supportiamo STL, 3MF, STEP, OBJ fino a 50MB", + "UPLOAD_SUB": "Supportiamo STL, 3MF, STEP fino a 50MB", "MATERIAL": "Materiale", "QUALITY": "Qualità", "PRINT_SPEED": "Velocità di Stampa", @@ -36,6 +88,7 @@ "CALCULATE": "Calcola Preventivo", "RESULT": "Preventivo Stimato", "TIME": "Tempo Stampa", + "MACHINE_COST": "Costo Macchina", "COST": "Costo Totale", "ORDER": "Ordina Ora", "CONSULT": "Richiedi Consulenza", @@ -47,54 +100,294 @@ "BENEFITS_1": "Preventivo automatico con costo e tempo immediati", "BENEFITS_2": "Materiali selezionati e qualità controllata", "BENEFITS_3": "Consulenza CAD se il file ha bisogno di modifiche", - "ERR_FILE_REQUIRED": "Il file è obbligatorio." + "ERR_FILE_REQUIRED": "Il file è obbligatorio.", + "ANALYZING_TITLE": "Analisi in corso...", + "ANALYZING_TEXT": "Stiamo analizzando la geometria e calcolando il percorso utensile.", + "QTY_SHORT": "QTÀ", + "COLOR_LABEL": "COLORE", + "ADD_FILES": "Aggiungi file", + "UPLOADING": "Caricamento...", + "PROCESSING": "Elaborazione...", + "NOTES_PLACEHOLDER": "Istruzioni specifiche...", + "SETUP_NOTE": "* Include {{cost}} come costo di setup", + "SHIPPING_NOTE": "** Costi di spedizione esclusi, calcolati al passaggio successivo", + "STEP_WARNING": "La visualizzazione 3D non è compatibile con i file step e 3mf", + "REMOVE_FILE": "Rimuovi file", + "FALLBACK_MATERIAL": "PLA (fallback)", + "FALLBACK_QUALITY_STANDARD": "Standard", + "ERR_FILE_TOO_LARGE": "Alcuni file superano il limite di 200MB e non sono stati aggiunti.", + "ERROR_ZERO_PRICE": "Qualcosa è andato storto. Prova con un altro formato oppure contattaci." + }, + "QUOTE": { + "PROCEED_ORDER": "Procedi con l'ordine", + "CONSULT": "Richiedi Consulenza", + "TOTAL": "Totale", + "MAX_QTY_NOTICE": "Per quantità oltre {{max}} pezzi, richiedi consulenza." + }, + "USER_DETAILS": { + "TITLE": "I tuoi dati", + "NAME": "Nome", + "NAME_PLACEHOLDER": "Il tuo nome", + "SURNAME": "Cognome", + "SURNAME_PLACEHOLDER": "Il tuo cognome", + "EMAIL": "Email", + "EMAIL_PLACEHOLDER": "tua@email.com", + "PHONE": "Telefono", + "PHONE_PLACEHOLDER": "+41 ...", + "ADDRESS": "Indirizzo", + "ADDRESS_PLACEHOLDER": "Via e numero", + "ZIP": "CAP", + "ZIP_PLACEHOLDER": "0000", + "CITY": "Città", + "CITY_PLACEHOLDER": "Città", + "SUBMIT": "Procedi", + "SUMMARY_TITLE": "Riepilogo", + "DEFAULT_COLOR": "Predefinito" + }, + "COMMON": { + "REQUIRED": "Campo obbligatorio", + "INVALID_EMAIL": "Email non valida", + "BACK": "Indietro", + "OPTIONAL": "(Opzionale)" }, "SHOP": { "TITLE": "Soluzioni tecniche", "SUBTITLE": "Prodotti pronti che risolvono problemi pratici", + "WIP_EYEBROW": "Work in progress", + "WIP_TITLE": "Shop in allestimento", + "WIP_SUBTITLE": "Stiamo preparando uno shop con prodotti selezionati e funzionalità di creazione automatica!", + "WIP_CTA_CALC": "Vai al calcolatore", + "WIP_RETURN_LATER": "Torna tra un po'", + "WIP_NOTE": "Ci teniamo a fare le cose fatte bene: nel frattempo puoi calcolare subito prezzo e tempi di un file 3D con il nostro calcolatore.", "ADD_CART": "Aggiungi al Carrello", - "BACK": "Torna allo Shop" + "BACK": "Torna allo Shop", + "NOT_FOUND": "Prodotto non trovato.", + "DETAILS": "Dettagli", + "MOCK_ADD_CART": "Aggiunto al carrello (Mock)", + "SUCCESS_TITLE": "Aggiunto al carrello", + "SUCCESS_DESC": "Il prodotto è stato aggiunto correttamente al carrello.", + "CONTINUE": "Continua", + "CATEGORIES": { + "FILAMENTS": "Filamenti", + "ACCESSORIES": "Accessori" + }, + "PRODUCTS": { + "P1": { + "NAME": "Filamento PLA Standard", + "DESC": "Il classico per ogni stampa, facile e affidabile." + }, + "P2": { + "NAME": "Filamento PETG Tough", + "DESC": "Resistente agli urti e alle temperature." + }, + "P3": { + "NAME": "Kit Ugelli (0.4mm)", + "DESC": "Set di ricambio per estrusore FDM." + } + } }, "ABOUT": { "TITLE": "Chi Siamo", "EYEBROW": "Laboratorio di stampa 3D", - "SUBTITLE": "Trasparenza su prezzo, qualità e tempi. Consulenza tecnica e CAD per aziende e privati.", - "HOW_TITLE": "Come lavoriamo", - "HOW_TEXT": "Offriamo un preventivo automatico per chi ha già il file 3D, e un percorso di consulenza per chi deve progettare o ottimizzare il modello.", - "PILL_1": "Preventivo immediato", - "PILL_2": "Consulenza tecnica", - "PILL_3": "Piccole serie fino a 500 pz", + "SUBTITLE": "Siamo due studenti con tanta voglia di fare e di imparare.", + "HOW_TEXT": "3D Fab nasce per trasformare le potenzialità della stampa 3D in soluzioni quotidiane. Siamo partiti dalla curiosità tecnica e siamo arrivati alla produzione di ricambi, prodotti e prototipi su misura. Per passare da un'idea a un progetto concreto abbiamo lanciato il nostro calcolatore automatico: preventivi chiari in un clic per garantirti un servizio professionale e senza sorprese sul prezzo.", + "PASSIONS_TITLE": "I nostri interessi", + "PASSION_BIKE_TRIAL": "Bike trial", + "PASSION_MOUNTAIN": "Montagna", + "PASSION_SKI": "Ski", + "PASSION_SELF_HOSTING": "Self hosting", + "PASSION_PRINT_3D": "Stampa 3D", + "PASSION_TRAVEL": "Travel", + "PASSION_SNOWBOARD": "Snowboard", + "PASSION_SNOWBOARD_INSTRUCTOR": "Istruttore snowboard", + "PASSION_ELECTRONICS": "Elettronica", + "PASSION_WOODWORKING": "Lavorazione del legno", + "PASSION_VAN_LIFE": "Van life", + "PASSION_COFFEE": "Caffè", + "PASSION_SOFTWARE_DEVELOPMENT": "Sviluppo software", "SERVICES_TITLE": "Servizi principali", - "SERVICE_1": "Stampa 3D FDM per prototipi e piccole serie", - "SERVICE_2": "Materiali tecnici su richiesta", - "SERVICE_3": "Supporto CAD e post-processing", - "SERVICE_4": "Verifica file e ottimizzazione per la stampa", "TARGET_TITLE": "Per chi è", "TARGET_TEXT": "Piccole aziende, freelance, smanettoni e clienti che cercano un prodotto già pronto dallo shop.", - "TEAM_TITLE": "Il Nostro Team" + "TEAM_TITLE": "Il Nostro Team", + "MEMBER_JOE_NAME": "Joe Küng", + "MEMBER_JOE_ROLE": "Studente Ingegneria Informatica", + "MEMBER_JOE_ALT": "Joe Küng", + "MEMBER_MATTEO_NAME": "Matteo Caletti", + "MEMBER_MATTEO_ROLE": "Studente Ingegneria Elettronica", + "MEMBER_MATTEO_ALT": "Matteo Caletti" }, "LOCATIONS": { "TITLE": "Le Nostre Sedi", - "SUBTITLE": "Siamo presenti in due sedi per coprire meglio il territorio. Seleziona la sede per vedere i dettagli.", + "SUBTITLE": "Siamo presenti in due sedi. Seleziona la sede per vedere i dettagli.", "TICINO": "Ticino", + "BIASCA": "Biasca", "BIENNE": "Bienne", - "ADDRESS_TICINO": "Sede Ticino, Svizzera", - "ADDRESS_BIENNE": "Sede Bienne, Svizzera", + "ADDRESS_TICINO": "Via G. Pioda 29a, Biasca", + "ADDRESS_BIENNE": "Lyss-strasse 71, Nidau 2560 Bienne", "CONTACT_US": "Contattaci" }, "LEGAL": { - "PRIVACY_TITLE": "Privacy Policy", + "PRIVACY_TITLE": "Informativa Privacy", "TERMS_TITLE": "Termini e Condizioni", "LAST_UPDATE": "Ultimo aggiornamento", + "TERMS_UPDATE_DATE": "24 febbraio 2026", + "PRIVACY_UPDATE_DATE": "24 febbraio 2026", + "CONSENT": { + "LABEL_PREFIX": "Ho letto e accetto i", + "TERMS_LINK": "Termini e Condizioni", + "AND": "e l'", + "PRIVACY_LINK": "Informativa Privacy", + "REQUIRED_ERROR": "Per continuare devi accettare Termini e Privacy.", + "UPLOAD_NOTICE_PREFIX": "Caricando un file accetti la nostra", + "UPLOAD_NOTICE_LINK": "Informativa Privacy" + }, "PRIVACY": { - "SECTION_1": "1. Raccolta dei Dati", - "SECTION_2": "2. Finalità del Trattamento", - "SECTION_3": "3. Cookie e Tracciamento" + "META": { + "CONTROLLER": "Titolari del trattamento: Matteo Caletti e Joe Kung (3D-Fab.ch).", + "CONTACT": "Contatto privacy: info@3d-fab.ch" + }, + "S1": { + "TITLE": "1. Quali dati trattiamo", + "P1": "1.1. Raccogliamo i dati necessari per preventivi e ordini: nome, cognome, email, telefono, indirizzo, dati di spedizione/fatturazione e dettagli dell'ordine.", + "P2": "1.2. Quando carichi file 3D o allegati tecnici, i file vengono trattati solo per analisi, produzione, assistenza e gestione dell'ordine." + }, + "S2": { + "TITLE": "2. Finalità del trattamento", + "P1": "2.1. Usiamo i dati esclusivamente per: preparare preventivi, confermare ordini, ricevere pagamenti, organizzare spedizioni e fornire supporto post-vendita.", + "P2": "2.2. Non usiamo i dati per profilazione, marketing automatico o vendita a terzi." + }, + "S3": { + "TITLE": "3. Conservazione dei file e dei dati", + "P1": "3.1. I file caricati per il solo preventivo vengono conservati per la durata della sessione e rimossi automaticamente alla sua scadenza.", + "P2": "3.2. I file e i dati legati a ordini confermati vengono conservati per il tempo necessario a produzione, consegna, assistenza e obblighi amministrativi/legali, poi eliminati o minimizzati.", + "P3": "3.3. I file caricati nel calcolatore vengono usati solo per analisi tecnica e stima del preventivo e restano associati esclusivamente alla sessione del calcolatore." + }, + "S4": { + "TITLE": "4. Cookie e tracciamento", + "P1": "4.1. Non utilizziamo cookie di profilazione, marketing o tracciamento di terze parti.", + "P2": "4.2. Non usiamo sistemi di advertising tracking. Eventuali dati tecnici temporanei sono usati solo per il funzionamento della sessione." + }, + "S5": { + "TITLE": "5. Condivisione dei dati", + "P1": "5.1. Condividiamo i dati solo con fornitori strettamente necessari al servizio (hosting, pagamenti, spedizioni), limitandoci alle informazioni indispensabili.", + "P2": "5.2. I fornitori trattano i dati secondo istruzioni contrattuali e misure di sicurezza adeguate." + }, + "S6": { + "TITLE": "6. Diritti dell'utente", + "P1": "6.1. Puoi richiedere accesso, rettifica, cancellazione o limitazione del trattamento dei tuoi dati, nei limiti previsti dalla legge.", + "P2": "6.2. Per qualsiasi richiesta privacy scrivi a info@3d-fab.ch." + } }, "TERMS": { - "SECTION_1": "1. Condizioni d'Uso", - "SECTION_2": "2. Ordini e Pagamenti", - "SECTION_3": "3. Rimborsi e Resi" + "META": { + "PROVIDER": "Fornitore: 3D-Fab.ch (Matteo Caletti e Joe Kung)", + "VERSION": "Versione: v1.0", + "SCOPE": "Validi per stampa 3D FDM/FFF e servizi collegati, con vendita e consegna esclusivamente in Svizzera (CH)." + }, + "S1": { + "TITLE": "1. Dati del fornitore e contatti", + "P1": "1.1. Fornitore: Matteo Caletti e Joe Kung.", + "P2": "1.2. Indirizzo: Lyss-strasse 71, Nidau 2560, CH.", + "P3": "1.3. Email: info@3d-fab.ch." + }, + "S2": { + "TITLE": "2. Ambito di applicazione", + "P1": "2.1. I presenti Termini e Condizioni disciplinano stampa 3D su richiesta, servizi accessori (consulenza tecnica, CAD, verifica file, post-processing) ed eventuali articoli standard.", + "P2": "2.2. Il fornitore vende e consegna esclusivamente in Svizzera (CH).", + "P3": "2.3. Si applicano agli ordini effettuati via sito, email o altri canali concordati; condizioni diverse del cliente valgono solo se accettate per iscritto." + }, + "S3": { + "TITLE": "3. Definizioni", + "P1": "3.1. Cliente: persona fisica o giuridica acquirente. File del cliente: modelli 3D e dati tecnici forniti. Prodotto personalizzato: pezzo realizzato su specifiche del cliente. Ordine: richiesta accettata dal fornitore." + }, + "S4": { + "TITLE": "4. Conclusione del contratto e ordine", + "P1": "4.1. Il contratto si conclude con conferma ordine (email/portale) o con avvio produzione dopo pagamento/anticipo.", + "P2": "4.2. I preventivi automatici possono essere confermati o aggiornati dopo verifica tecnica minima (stampabilità, supporti, limiti macchina).", + "P3": "4.3. Il fornitore può rifiutare ordini tecnicamente non fattibili o non conformi ai presenti termini." + }, + "S5": { + "TITLE": "5. Prezzi, imposte e spedizione", + "P1": "5.1. I prezzi sono in CHF, salvo diversa indicazione su IVA inclusa/esclusa.", + "P2": "5.2. Spedizione e imballaggio sono indicati separatamente quando applicabili.", + "P3": "5.3. Per piccole serie o CAD complesso possono essere applicate offerte scritte con condizioni specifiche (campioni, scaglioni, approvazione primo pezzo)." + }, + "S6": { + "TITLE": "6. Pagamenti", + "P1": "6.1. Metodi accettati: TWINT e bonifico bancario.", + "P2": "6.2. Salvo accordi diversi, la produzione parte solo dopo il pagamento.", + "P3": "6.3. Per consulenza CAD e piccole serie può essere richiesto un anticipo (30-100%) e/o saldo prima della consegna.", + "P4": "6.4. Dati bonifico: Joe Kung, IBAN CH74 0900 0000 1548 2158 1, Via G. Pioda 29a, 6710 Biasca." + }, + "S7": { + "TITLE": "7. File del cliente e responsabilità tecnica", + "P1": "7.1. Il cliente garantisce di avere i diritti sui file e che il loro uso non viola diritti di terzi (copyright, brevetti, licenze, segreti industriali).", + "P2": "7.2. Il cliente è responsabile di correttezza del modello, scelta materiale, adeguatezza all'uso finale e rispetto delle norme applicabili.", + "P3": "7.3. Il fornitore può suggerire modifiche per migliorare stampabilità e resa, ma non svolge funzione di certificazione del prodotto finale salvo accordo scritto." + }, + "S8": { + "TITLE": "8. Qualità FDM/FFF e tolleranze", + "P1": "8.1. La stampa FDM/FFF comporta caratteristiche intrinseche: linee di strato, anisotropia meccanica, micro-imperfezioni, variazioni cromatiche e possibili ritiri/deformazioni legati a geometria e materiale.", + "P2": "8.2. Tolleranze standard (salvo accordo scritto): fino a 100 mm +/-0.3 mm; oltre 100 mm +/-0.5% (minimo +/-0.3 mm).", + "P3": "8.3. Requisiti estetici o dimensionali critici devono essere concordati prima dell'ordine (es. campione, misure di controllo, finitura specifica)." + }, + "S9": { + "TITLE": "9. Post-processing e lavorazioni aggiuntive", + "P1": "9.1. Lavorazioni come rimozione supporti, levigatura, primer/verniciatura o inserti filettati sono eseguite solo se concordate e possono introdurre variazioni dimensionali o estetiche.", + "P2": "9.2. Su richiesta può essere prevista approvazione del primo pezzo o campione, eventualmente a pagamento." + }, + "S10": { + "TITLE": "10. Tempi di produzione e consegna", + "P1": "10.1. I tempi indicati sono stime basate su carico di lavoro, complessità e disponibilità materiali.", + "P2": "10.2. Ritardi dovuti a cause esterne (corrieri, fornitori, guasti, forza maggiore) non danno diritto automatico a penali salvo accordo scritto.", + "P3": "10.3. Il cliente deve fornire indirizzi corretti e completi; eventuali costi di riconsegna per indirizzo errato restano a carico del cliente." + }, + "S11": { + "TITLE": "11. Diritto di recesso e annullamento", + "P1": "11.1. Salvo eccezioni confermate per iscritto, non è previsto diritto di recesso o restituzione dopo l'ordine.", + "P2": "11.2. I prodotti personalizzati non sono annullabili né rimborsabili dopo l'avvio della produzione.", + "P3": "11.3. Il fornitore può accettare eccezioni prima dell'avvio produttivo, trattenendo eventuali costi già sostenuti.", + "P4": "11.4. Eventuali condizioni speciali devono risultare nell'offerta, nella conferma d'ordine o in comunicazioni scritte." + }, + "S12": { + "TITLE": "12. Reclami, difetti e rimedi", + "P1": "12.1. Il cliente deve controllare i prodotti alla consegna e segnalare eventuali difetti entro 7 giorni, per iscritto, con foto/video e descrizione.", + "P2": "12.2. Non costituiscono automaticamente difetto i segni tipici FDM, piccole variazioni cromatiche, imperfezioni non funzionali o tolleranze entro i limiti concordati.", + "P3": "12.3. In caso di difetto imputabile al fornitore, i rimedi sono a scelta del fornitore: ristampa/sostituzione oppure rimborso limitato al valore del pezzo o ordine difettoso.", + "P4": "12.4. Prima di ristampa o rimborso può essere richiesta la restituzione del pezzo; se il difetto è confermato la restituzione è a carico del fornitore." + }, + "S13": { + "TITLE": "13. Usi vietati e conformità", + "P1": "13.1. Il fornitore può rifiutare ordini relativi a oggetti illegali, armi o parti regolamentate, applicazioni safety-critical o medicali senza accordi e validazioni dedicate.", + "P2": "13.2. Il cliente resta responsabile dell'uso finale e della conformità normativa del prodotto." + }, + "S14": { + "TITLE": "14. Proprietà intellettuale e licenze", + "P1": "14.1. I diritti sui file del cliente restano al cliente o ai relativi titolari. Il cliente concede al fornitore una licenza limitata all'uso per valutazione tecnica e produzione.", + "P2": "14.2. Salvo accordi diversi, file e progetti CAD sviluppati dal fornitore vengono consegnati dopo pagamento, con diritto d'uso per gli scopi concordati.", + "P3": "14.3. Il cliente manleva il fornitore da pretese di terzi legate a file o istruzioni fornite dal cliente." + }, + "S15": { + "TITLE": "15. Protezione dei dati", + "P1": "15.1. Il fornitore tratta i dati personali per gestione ordini, pagamenti, spedizioni e assistenza.", + "P2": "15.2. I dati possono essere condivisi con fornitori tecnici e corrieri solo nella misura necessaria all'erogazione del servizio." + }, + "S16": { + "TITLE": "16. Limitazione di responsabilità", + "P1": "16.1. Nei limiti di legge, il fornitore risponde solo per danni diretti prevedibili e comunque non oltre il valore dell'ordine relativo al prodotto o servizio contestato.", + "P2": "16.2. Sono esclusi, nei limiti consentiti, danni indiretti, perdita di profitto, fermo attività, perdita dati e danni consequenziali.", + "P3": "16.3. Restano salve le responsabilità non escludibili per legge (es. dolo o colpa grave)." + }, + "S17": { + "TITLE": "17. Forza maggiore", + "P1": "17.1. Eventi fuori dal controllo ragionevole del fornitore (guasti, blackout, ritardi fornitori, scioperi, provvedimenti autorità) possono causare proroghe o sospensioni senza responsabilità." + }, + "S18": { + "TITLE": "18. Legge applicabile e foro competente", + "P1": "18.1. Si applica il diritto svizzero.", + "P2": "18.2. Foro competente: sede del fornitore, salvo norme inderogabili a tutela dei consumatori.", + "P3": "18.3. In caso di discrepanze interpretative prevale la versione italiana dei presenti termini, che è l'unica versione legalmente vincolante, salvo diverso accordo scritto." + } } }, "CONTACT": { @@ -112,7 +405,7 @@ "COMPANY_NAME": "Ragione Sociale", "REF_PERSON": "Persona di Riferimento", "UPLOAD_LABEL": "Allegati", - "UPLOAD_HINT": "Max 15 file. Supportati: Immagini, PDF, STL, STEP, 3MF, OBJ", + "UPLOAD_HINT": "Max 15 file. Supportati: immagini, video, PDF, documenti Office, STL/STEP/3MF/OBJ/IGES, DWG/DXF. File compressi non consentiti.", "DROP_FILES": "Trascina qui i file o clicca per caricare", "PLACEHOLDER_NAME": "Il tuo nome", "PLACEHOLDER_EMAIL": "tuo@email.com", @@ -124,8 +417,131 @@ "LABEL_NAME": "Nome *", "MSG_SENT": "Inviato!", "ERR_MAX_FILES": "Limite massimo di 15 file raggiunto.", + "ERR_COMPRESSED_FILES": "I file compressi non sono consentiti (ZIP/RAR/7z/TAR/GZ).", "SUCCESS_TITLE": "Messaggio Inviato con Successo", "SUCCESS_DESC": "Grazie per averci contattato. Abbiamo ricevuto il tuo messaggio e ti invieremo una email di riepilogo a breve.", - "SEND_ANOTHER": "Invia un altro messaggio" + "SEND_ANOTHER": "Invia un altro messaggio", + "HERO_SUBTITLE": "Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.", + "FILE_TYPE_PDF": "PDF", + "FILE_TYPE_3D": "3D", + "FILE_TYPE_VIDEO": "Video", + "FILE_TYPE_DOC": "DOC", + "FILE_TYPE_FILE": "FILE", + "ERROR_SUBMIT": "Errore durante l'invio della richiesta. Riprova.", + "REMOVE_FILE": "Rimuovi file allegato" + }, + "CHECKOUT": { + "TITLE": "Checkout", + "SUBTITLE": "Completa il tuo ordine inserendo i dettagli per la spedizione e il pagamento.", + "CONTACT_INFO": "Informazioni di Contatto", + "BILLING_ADDR": "Indirizzo di Fatturazione", + "SHIPPING_ADDR": "Indirizzo di Spedizione", + "FIRST_NAME": "Nome", + "LAST_NAME": "Cognome", + "EMAIL": "Email", + "PHONE": "Telefono", + "COMPANY_NAME": "Nome Azienda", + "ADDRESS_1": "Indirizzo (Via e numero)", + "ADDRESS_2": "Informazioni aggiuntive (opzionale)", + "ZIP": "CAP", + "CITY": "Città", + "COUNTRY": "Paese", + "SHIPPING_SAME": "L'indirizzo di spedizione è lo stesso di quello di fatturazione", + "PLACE_ORDER": "Invia Ordine", + "PROCESSING": "Elaborazione...", + "SUMMARY_TITLE": "Riepilogo Ordine", + "SUBTOTAL": "Subtotale", + "ITEMS_BASE_SUBTOTAL": "Costo Base Articoli", + "MACHINE_COST": "Costo Macchina", + "SETUP_FEE": "Costo di Avvio", + "TOTAL": "Totale", + "QTY": "Qtà", + "PER_PIECE": "al pezzo", + "SHIPPING": "Spedizione (CH)", + "INVALID_EMAIL": "Email non valida", + "COMPANY_OPTIONAL": "Nome Azienda (Opzionale)", + "REF_PERSON_OPTIONAL": "Persona di Riferimento (Opzionale)", + "SHIPPING_CALCULATED_NEXT_STEP": "il costo di spedizione viene calcolato al prossimo passaggio", + "EXCLUDES_SHIPPING": "Escluso costo di spedizione", + "ERR_NO_SESSION_START": "Nessuna sessione attiva trovata. Inizia un nuovo preventivo.", + "ERR_LOAD_SESSION": "Impossibile caricare i dettagli della sessione. Riprova.", + "ERR_NO_SESSION_CREATE_ORDER": "Nessuna sessione attiva trovata. Impossibile creare l'ordine.", + "ERR_CREATE_ORDER": "Impossibile creare l'ordine. Riprova." + }, + "PAYMENT": { + "TITLE": "Pagamento", + "METHOD": "Metodo di Pagamento", + "TWINT_TITLE": "Paga con TWINT", + "TWINT_DESC": "Inquadra il codice con l'app TWINT, da mobile clicca il bottone.", + "TWINT_OPEN": "Apri direttamente in TWINT", + "TWINT_LINK": "Apri link di pagamento", + "BANK_TITLE": "Bonifico Bancario", + "BANK_OWNER": "Titolare", + "BANK_IBAN": "IBAN", + "BANK_REF": "Riferimento", + "BILLING_INFO_HINT": "Abbiamo compilato i campi per te, per favore non modificare il motivo del pagamento", + "DOWNLOAD_QR": "Scarica QR-Fattura (PDF)", + "DOWNLOAD_CONFIRMATION": "Scarica Conferma (PDF)", + "CONFIRM": "Ho completato il pagamento", + "SUMMARY_TITLE": "Riepilogo Ordine", + "SUBTOTAL": "Subtotale", + "SHIPPING": "Spedizione", + "SETUP_FEE": "Costo Setup", + "TOTAL": "Totale", + "LOADING": "Caricamento dettagli ordine...", + "METHOD_TWINT": "TWINT", + "METHOD_BANK": "Fattura QR / Bonifico", + "STATUS_REPORTED_TITLE": "Ordine in lavorazione", + "STATUS_REPORTED_DESC": "Abbiamo registrato la tua operazione. Appena confermiamo il pagamento l'ordine entrerà in produzione", + "IN_VERIFICATION": "Pagamento Segnalato", + "TWINT_QR_ALT": "QR di pagamento TWINT", + "TWINT_BUTTON_ALT": "Pulsante TWINT incorporato" + }, + "TRACKING": { + "TITLE": "Stato dell'Ordine", + "SUBTITLE": "Consulta lo stato del tuo ordine e gestisci il pagamento se necessario.", + "STEP_PENDING": "In attesa", + "STEP_REPORTED": "In verifica", + "STEP_PRODUCTION": "In Produzione", + "STEP_SHIPPED": "Spedito" + }, + "ORDER_CONFIRMED": { + "TITLE": "Ordine Confermato", + "SUBTITLE": "Pagamento registrato. Il tuo ordine è ora in elaborazione.", + "STATUS": "In elaborazione", + "HEADING": "Stiamo preparando il tuo ordine", + "ORDER_REF": "Riferimento ordine", + "PROCESSING_TEXT": "Non appena confermiamo il pagamento, il tuo ordine passerà in produzione.", + "EMAIL_TEXT": "Ti invieremo una email con aggiornamento stato e prossimi step.", + "BACK_HOME": "Torna alla Home" + }, + "STL_VIEWER": { + "LOADING": "Caricamento modello 3D..." + }, + "ORDER": { + "ERR_ID_NOT_FOUND": "ID ordine non trovato.", + "ERR_LOAD_ORDER": "Impossibile caricare i dettagli dell'ordine.", + "ERR_REPORT_PAYMENT": "Impossibile segnalare il pagamento. Riprova.", + "NOT_AVAILABLE": "N/D" + }, + "DROPZONE": { + "DEFAULT_LABEL": "Trascina i file qui o clicca per caricare", + "DEFAULT_SUBTEXT": "Supporta .STL, .3MF, .STEP" + }, + "COLOR": { + "AVAILABLE_COLORS": "Colori disponibili", + "CATEGORY_GLOSSY": "Lucidi", + "CATEGORY_MATTE": "Opachi", + "NAME": { + "BLACK": "Nero", + "WHITE": "Bianco", + "RED": "Rosso", + "BLUE": "Blu", + "GREEN": "Verde", + "YELLOW": "Giallo", + "MATTE_BLACK": "Nero opaco", + "MATTE_WHITE": "Bianco opaco", + "MATTE_GRAY": "Grigio opaco" + } } } diff --git a/frontend/src/assets/images/Fav-icon.png b/frontend/src/assets/images/Fav-icon.png new file mode 100644 index 0000000..ff6550e Binary files /dev/null and b/frontend/src/assets/images/Fav-icon.png differ diff --git a/frontend/src/assets/images/home/cad.jpg b/frontend/src/assets/images/home/cad.jpg new file mode 100644 index 0000000..1c5dfe2 Binary files /dev/null and b/frontend/src/assets/images/home/cad.jpg differ diff --git a/frontend/src/assets/images/home/da-cambiare.jpg b/frontend/src/assets/images/home/da-cambiare.jpg new file mode 100644 index 0000000..b0eb618 Binary files /dev/null and b/frontend/src/assets/images/home/da-cambiare.jpg differ diff --git a/frontend/src/assets/images/home/original-vs-3dprinted.jpg b/frontend/src/assets/images/home/original-vs-3dprinted.jpg new file mode 100644 index 0000000..a1230e0 Binary files /dev/null and b/frontend/src/assets/images/home/original-vs-3dprinted.jpg differ diff --git a/frontend/src/assets/images/home/prototipi.jpg b/frontend/src/assets/images/home/prototipi.jpg new file mode 100644 index 0000000..3efc5d7 Binary files /dev/null and b/frontend/src/assets/images/home/prototipi.jpg differ diff --git a/frontend/src/assets/images/home/serie.jpg b/frontend/src/assets/images/home/serie.jpg new file mode 100644 index 0000000..668b3b6 Binary files /dev/null and b/frontend/src/assets/images/home/serie.jpg differ diff --git a/frontend/src/assets/images/home/supporto-bici.jpg b/frontend/src/assets/images/home/supporto-bici.jpg new file mode 100644 index 0000000..71ba26e Binary files /dev/null and b/frontend/src/assets/images/home/supporto-bici.jpg differ diff --git a/frontend/src/assets/images/home/vino.JPG b/frontend/src/assets/images/home/vino.JPG new file mode 100644 index 0000000..eec2da5 Binary files /dev/null and b/frontend/src/assets/images/home/vino.JPG differ diff --git a/frontend/src/assets/images/joe.jpg b/frontend/src/assets/images/joe.jpg new file mode 100644 index 0000000..45d4f48 Binary files /dev/null and b/frontend/src/assets/images/joe.jpg differ diff --git a/frontend/src/assets/images/matteo.jpg b/frontend/src/assets/images/matteo.jpg new file mode 100644 index 0000000..96234fe Binary files /dev/null and b/frontend/src/assets/images/matteo.jpg differ diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts index e1801b7..e76a9fe 100644 --- a/frontend/src/environments/environment.prod.ts +++ b/frontend/src/environments/environment.prod.ts @@ -1,5 +1,4 @@ export const environment = { production: true, - apiUrl: '', - basicAuth: '' + apiUrl: '' }; diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index 48dc8e5..837417a 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -1,5 +1,4 @@ export const environment = { production: false, - apiUrl: 'http://localhost:8000', - basicAuth: 'fab:0presura' // Format: 'username:password' + apiUrl: 'http://localhost:8000' }; diff --git a/frontend/src/index.html b/frontend/src/index.html index 61d676c..633451d 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -6,7 +6,7 @@ 3D fab - + diff --git a/frontend/src/styles/_patterns.scss b/frontend/src/styles/_patterns.scss index 519a103..928ad2e 100644 --- a/frontend/src/styles/_patterns.scss +++ b/frontend/src/styles/_patterns.scss @@ -25,14 +25,13 @@ } // 3. Hexagonal (Honeycomb Infill) -// Best structural integrity to weight ratio. +// Flat top-view hex lattice (no 3D depth effect). @mixin pattern-honeycomb($color, $size: 32px) { - background-image: - radial-gradient(circle, transparent 65%, $color 66%, $color 70%, transparent 71%); - background-size: $size $size; - // This creates a "Dot/Ring" pattern that resembles printed layers or nozzle paths - // A true CSS honeycomb is complex and brittle across browsers. - // Using this "Nozzle Path" aesthetic instead which feels organic. + background-color: transparent; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='52' height='45' viewBox='0 0 52 45'%3E%3Cg fill='none' stroke='%23000' stroke-width='1.25'%3E%3Cpath d='M13 0H39L52 22.5L39 45H13L0 22.5Z'/%3E%3Cpath d='M39 -22.5H65L78 0L65 22.5H39L26 0Z'/%3E%3Cpath d='M39 22.5H65L78 45L65 67.5H39L26 45Z'/%3E%3C/g%3E%3C/svg%3E"); + background-size: $size calc(#{$size} * 0.8653846); + background-position: 0 0; + background-repeat: repeat; } // 4. Diagonal (Rectilinear 45deg) diff --git a/frontend/src/styles/theme.scss b/frontend/src/styles/theme.scss index ec16db9..046496c 100644 --- a/frontend/src/styles/theme.scss +++ b/frontend/src/styles/theme.scss @@ -1,9 +1,9 @@ /* src/styles/theme.scss */ -@use 'tokens'; +@use './tokens'; :root { /* Semantic Colors - Theming Layer */ - --color-bg: #ffffff; + --color-bg: #faf9f6; --color-bg-card: #ffffff; --color-text: var(--color-neutral-900); --color-text-muted: var(--color-secondary-500);