45 Commits

Author SHA1 Message Date
5f73924572 fix(back-end): shift model
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 15s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-16 17:48:22 +01:00
8edc4af645 fix(back-end): shift model
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 24s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-16 17:47:41 +01:00
e1409d218b fix(back-end): shift model
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 35s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 17:46:00 +01:00
0c92f8b394 fix(back-end): shift model
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 32s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 17:36:53 +01:00
66de93a315 fix(back-end): shift model
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 32s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 17:32:29 +01:00
b337db03c4 fix(back-end): fix process and new feature
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 35s
Build, Test and Deploy / build-and-push (push) Successful in 32s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 17:14:25 +01:00
8c6c1e10b3 fix(back-end): fix process and new feature
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 32s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-16 16:19:18 +01:00
eac3006512 fix(back-end): fix process and new feature
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 38s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 16:16:03 +01:00
b26b582baf fix(back-end): fix process and new feature
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 33s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-16 16:13:42 +01:00
875c6ffd2d fix(back-end): fix process and new feature
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 32s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-16 16:09:36 +01:00
579ac3fcb6 fix(back-end): fix process files
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 29s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-16 16:01:01 +01:00
efa1371ffa fix(back-end): fix process files
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 15:56:55 +01:00
ab7f263aca fix(back-end): fix process files
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 17s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 15:55:16 +01:00
49bae8e186 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 15:51:04 +01:00
e2872c730c fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 32s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 15:40:16 +01:00
86266b31ee fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 32s
Build, Test and Deploy / build-and-push (push) Successful in 1m23s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 15:28:16 +01:00
5d0fb5fe6d fix(back-end): file error handling
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 25s
Build, Test and Deploy / deploy (push) Has been skipped
Build, Test and Deploy / build-and-push (push) Has been skipped
2026-02-16 15:25:15 +01:00
91af8f4f9c fix(back-end): file error handling
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 27s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-16 15:23:02 +01:00
a96c28fb39 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 32s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 15:07:21 +01:00
9b24ca529c fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 15:01:46 +01:00
6216d9a723 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 32s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 14:54:57 +01:00
4aa3f6adf1 fix(back-end): file error handling
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 26s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-16 14:52:14 +01:00
7baad738f5 fix(back-end): file error handling
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 26s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-16 14:48:46 +01:00
9feceb9b3c fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 22s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 14:36:26 +01:00
304ed942b8 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m22s
Build, Test and Deploy / build-and-push (push) Successful in 30s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 14:34:10 +01:00
881bd87392 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m21s
Build, Test and Deploy / build-and-push (push) Successful in 31s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-16 14:28:40 +01:00
3a5e4e3427 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / build-and-push (push) Successful in 39s
Build, Test and Deploy / deploy (push) Successful in 8s
Build, Test and Deploy / test-backend (push) Successful in 1m20s
2026-02-16 14:20:31 +01:00
8c82470401 fix(back-end): file error handling
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m21s
Build, Test and Deploy / build-and-push (push) Successful in 21s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 14:14:47 +01:00
ef6a5278a7 fix(back-end): revert changes in uplad file
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m22s
Build, Test and Deploy / build-and-push (push) Successful in 15s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-16 14:05:43 +01:00
bb276b6504 fix(back-end): fix exclude object 0
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m22s
Build, Test and Deploy / build-and-push (push) Successful in 57s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-16 13:57:44 +01:00
e351f2c05f fix(back-end): fix exclude object 0
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m23s
Build, Test and Deploy / build-and-push (push) Successful in 20s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-16 11:44:05 +01:00
165e12f216 fix(back-end): fix test gcode parser
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m22s
Build, Test and Deploy / build-and-push (push) Successful in 33s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-14 19:10:02 +01:00
475bfcc6fb fix(back-end): fix test gcode parser
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m19s
Build, Test and Deploy / build-and-push (push) Successful in 57s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-14 16:53:21 +01:00
becb15da73 fix invoice and support costs
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 49s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-14 16:47:49 +01:00
4d559901eb fix(front-end): cost displayed in order
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m19s
Build, Test and Deploy / build-and-push (push) Successful in 22s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-13 18:22:58 +01:00
06a036810a fix(back-end): forse risolviamo il problema
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m19s
Build, Test and Deploy / build-and-push (push) Successful in 30s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-13 18:19:26 +01:00
0b29aebfcf feat(back-end): invoice rotto ma pusshamolo lo stesso
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m21s
Build, Test and Deploy / build-and-push (push) Successful in 54s
Build, Test and Deploy / deploy (push) Successful in 10s
2026-02-13 18:14:52 +01:00
961109b04c feat: default pla filaments
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m31s
Build, Test and Deploy / build-and-push (push) Successful in 16s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-13 16:30:00 +01:00
b5bd68ed10 fix(back-end): back-end fix mantain settings
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m18s
Build, Test and Deploy / build-and-push (push) Successful in 30s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-13 16:21:28 +01:00
56fb504062 fix(front-end): error handling for session
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m18s
Build, Test and Deploy / build-and-push (push) Successful in 21s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-13 16:08:04 +01:00
f165d191be feat(back-end):improvement
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 1m19s
Build, Test and Deploy / build-and-push (push) Failing after 37s
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-13 16:03:44 +01:00
e1d9823b51 feat(back-end): integration of clamAVS
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m18s
Build, Test and Deploy / build-and-push (push) Successful in 15s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-12 22:11:47 +01:00
f829ccef4a feat(back-end): integration of clamAVS
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m17s
Build, Test and Deploy / build-and-push (push) Successful in 48s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-12 22:07:13 +01:00
59e881c3f4 feat(back-end): integration of clamAVS
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 51s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-12 21:59:48 +01:00
f5aa0f298e feat(back-end): integration of clamAVS
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 51s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-12 21:50:42 +01:00
79 changed files with 2285 additions and 2357 deletions

View File

@@ -125,23 +125,13 @@ jobs:
ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
- name: Write env and compose to server - name: Write env to server
shell: bash shell: bash
run: | run: |
# 1. Recalculate TAG and OWNER_LOWER (jobs don't share ENV) # 1. Start with the static env file content
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 cat "deploy/envs/${{ env.ENV }}.env" > /tmp/full_env.env
# 3. Determine DB credentials # 2. Determine DB credentials
if [[ "${{ env.ENV }}" == "prod" ]]; then if [[ "${{ env.ENV }}" == "prod" ]]; then
DB_URL="${{ secrets.DB_URL_PROD }}" DB_URL="${{ secrets.DB_URL_PROD }}"
DB_USER="${{ secrets.DB_USERNAME_PROD }}" DB_USER="${{ secrets.DB_USERNAME_PROD }}"
@@ -156,24 +146,17 @@ jobs:
DB_PASS="${{ secrets.DB_PASSWORD_DEV }}" DB_PASS="${{ secrets.DB_PASSWORD_DEV }}"
fi fi
# 4. Append DB and Docker credentials (quoted) # 3. Append DB credentials
printf '\nDB_URL="%s"\nDB_USERNAME="%s"\nDB_PASSWORD="%s"\n' \ printf '\nDB_URL=%s\nDB_USERNAME=%s\nDB_PASSWORD=%s\n' \
"$DB_URL" "$DB_USER" "$DB_PASS" >> /tmp/full_env.env "$DB_URL" "$DB_USER" "$DB_PASS" >> /tmp/full_env.env
printf 'REGISTRY_URL="%s"\nREPO_OWNER="%s"\nTAG="%s"\n' \
"${{ secrets.REGISTRY_URL }}" "$DEPLOY_OWNER" "$DEPLOY_TAG" >> /tmp/full_env.env
# 5. Debug: print content (for debug purposes) # 4. Debug: print content (for debug purposes)
echo "Preparing to send env file with variables:" echo "Preparing to send env file with variables:"
grep -v "PASSWORD" /tmp/full_env.env || true grep -v "PASSWORD" /tmp/full_env.env || true
# 5. Send env to server # 5. Send to server
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \ ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
"setenv ${{ env.ENV }}" < /tmp/full_env.env "setenv ${{ env.ENV }}" < /tmp/full_env.env
# 6. Send docker-compose.deploy.yml to server
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
"setcompose ${{ env.ENV }}" < docker-compose.deploy.yml

View File

@@ -11,7 +11,7 @@ RUN ./gradlew bootJar -x test --no-daemon
# Stage 2: Runtime Environment # Stage 2: Runtime Environment
FROM eclipse-temurin:21-jre-jammy FROM eclipse-temurin:21-jre-jammy
# Install system dependencies for OrcaSlicer (same as before) # Install system dependencies for OrcaSlicer
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
wget \ wget \
p7zip-full \ p7zip-full \
@@ -20,6 +20,14 @@ RUN apt-get update && apt-get install -y \
libgtk-3-0 \ libgtk-3-0 \
libdbus-1-3 \ libdbus-1-3 \
libwebkit2gtk-4.0-37 \ libwebkit2gtk-4.0-37 \
libx11-xcb1 \
libxcb-dri3-0 \
libxtst6 \
libnss3 \
libatk-bridge2.0-0 \
libxss1 \
libasound2 \
libgbm1 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install OrcaSlicer # Install OrcaSlicer

View File

@@ -37,7 +37,6 @@ dependencies {
implementation 'io.github.openhtmltopdf:openhtmltopdf-svg-support:1.1.37' implementation 'io.github.openhtmltopdf:openhtmltopdf-svg-support:1.1.37'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0' implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0'
implementation 'org.springframework.boot:spring-boot-starter-mail'

View File

@@ -3,25 +3,13 @@ echo "----------------------------------------------------------------"
echo "Starting Backend Application" echo "Starting Backend Application"
echo "DB_URL: $DB_URL" echo "DB_URL: $DB_URL"
echo "DB_USERNAME: $DB_USERNAME" echo "DB_USERNAME: $DB_USERNAME"
echo "SPRING_DATASOURCE_URL: $SPRING_DATASOURCE_URL"
echo "SLICER_PATH: $SLICER_PATH" echo "SLICER_PATH: $SLICER_PATH"
echo "--- ALL ENV VARS ---" echo "--- ALL ENV VARS ---"
env env
echo "----------------------------------------------------------------" echo "----------------------------------------------------------------"
# Determine which environment variables to use for database connection # Exec java with explicit properties from env
# This allows compatibility with different docker-compose configurations exec java -jar app.jar \
FINAL_DB_URL="${DB_URL:-$SPRING_DATASOURCE_URL}" --spring.datasource.url="${DB_URL}" \
FINAL_DB_USER="${DB_USERNAME:-$SPRING_DATASOURCE_USERNAME}" --spring.datasource.username="${DB_USERNAME}" \
FINAL_DB_PASS="${DB_PASSWORD:-$SPRING_DATASOURCE_PASSWORD}" --spring.datasource.password="${DB_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

File diff suppressed because one or more lines are too long

View File

@@ -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", "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", "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" "machine_pause_gcode": "M400 U1"
} }

View File

@@ -3,14 +3,12 @@ package com.printcalculator;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.transaction.annotation.EnableTransactionManagement; import org.springframework.transaction.annotation.EnableTransactionManagement;
@SpringBootApplication @SpringBootApplication
@EnableTransactionManagement @EnableTransactionManagement
@EnableScheduling @EnableScheduling
@EnableAsync
public class BackendApplication { public class BackendApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@@ -25,17 +25,15 @@ public class CustomQuoteRequestController {
private final CustomQuoteRequestRepository requestRepo; private final CustomQuoteRequestRepository requestRepo;
private final CustomQuoteRequestAttachmentRepository attachmentRepo; private final CustomQuoteRequestAttachmentRepository attachmentRepo;
private final com.printcalculator.service.ClamAVService clamAVService;
// TODO: Inject Storage Service private final com.printcalculator.service.StorageService storageService;
private static final String STORAGE_ROOT = "storage_requests";
public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo, public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
CustomQuoteRequestAttachmentRepository attachmentRepo, CustomQuoteRequestAttachmentRepository attachmentRepo,
com.printcalculator.service.ClamAVService clamAVService) { com.printcalculator.service.StorageService storageService) {
this.requestRepo = requestRepo; this.requestRepo = requestRepo;
this.attachmentRepo = attachmentRepo; this.attachmentRepo = attachmentRepo;
this.clamAVService = clamAVService; this.storageService = storageService;
} }
// 1. Create Custom Quote Request // 1. Create Custom Quote Request
@@ -71,9 +69,6 @@ public class CustomQuoteRequestController {
for (MultipartFile file : files) { for (MultipartFile file : files) {
if (file.isEmpty()) continue; if (file.isEmpty()) continue;
// Scan for virus
clamAVService.scan(file.getInputStream());
CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment(); CustomQuoteRequestAttachment attachment = new CustomQuoteRequestAttachment();
attachment.setRequest(request); attachment.setRequest(request);
attachment.setOriginalFilename(file.getOriginalFilename()); attachment.setOriginalFilename(file.getOriginalFilename());
@@ -97,10 +92,8 @@ public class CustomQuoteRequestController {
attachment.setStoredRelativePath(relativePath); attachment.setStoredRelativePath(relativePath);
attachmentRepo.save(attachment); attachmentRepo.save(attachment);
// Save file to disk // Save file to disk via StorageService
Path absolutePath = Paths.get(STORAGE_ROOT, relativePath); storageService.store(file, Paths.get(relativePath));
Files.createDirectories(absolutePath.getParent());
Files.copy(file.getInputStream(), absolutePath);
} }
} }

View File

@@ -1,47 +0,0 @@
package com.printcalculator.controller;
import org.springframework.context.annotation.Profile;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/dev/email")
@Profile("local")
public class DevEmailTestController {
private final TemplateEngine templateEngine;
public DevEmailTestController(TemplateEngine templateEngine) {
this.templateEngine = templateEngine;
}
@GetMapping("/test-template")
public ResponseEntity<String> testTemplate() {
Context context = new Context();
Map<String, Object> templateData = new HashMap<>();
UUID orderId = UUID.randomUUID();
templateData.put("customerName", "Mario Rossi");
templateData.put("orderId", orderId);
templateData.put("orderNumber", orderId.toString().split("-")[0]);
templateData.put("orderDetailsUrl", "https://tuosito.it/ordine/" + orderId);
templateData.put("orderDate", OffsetDateTime.now().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")));
templateData.put("totalCost", "45.50");
context.setVariables(templateData);
String html = templateEngine.process("email/order-confirmation", context);
return ResponseEntity.ok()
.header("Content-Type", "text/html; charset=utf-8")
.body(html);
}
}

View File

@@ -64,6 +64,20 @@ public class OptionsController {
}) })
.filter(m -> m != null) .filter(m -> m != null)
.collect(Collectors.toList()); .collect(Collectors.toList());
// Sort: PLA first, then PETG, then others alphabetically
materialOptions.sort((a, b) -> {
String codeA = a.code();
String codeB = b.code();
if (codeA.equals("pla_basic")) return -1;
if (codeB.equals("pla_basic")) return 1;
if (codeA.equals("petg_basic")) return -1;
if (codeB.equals("petg_basic")) return 1;
return codeA.compareTo(codeB);
});
// 2. Qualities (Static as per user request) // 2. Qualities (Static as per user request)
List<OptionsResponse.QualityOption> qualities = List.of( List<OptionsResponse.QualityOption> qualities = List.of(

View File

@@ -7,7 +7,6 @@ import com.printcalculator.service.InvoicePdfRenderingService;
import com.printcalculator.service.OrderService; import com.printcalculator.service.OrderService;
import com.printcalculator.service.QrBillService; import com.printcalculator.service.QrBillService;
import com.printcalculator.service.StorageService; import com.printcalculator.service.StorageService;
import com.printcalculator.service.TwintPaymentService;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -25,9 +24,7 @@ import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.Map; import java.util.Map;
import java.util.HashMap; import java.util.HashMap;
import java.util.Base64;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.net.URI;
@RestController @RestController
@RequestMapping("/api/orders") @RequestMapping("/api/orders")
@@ -42,7 +39,6 @@ public class OrderController {
private final StorageService storageService; private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService; private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService; private final QrBillService qrBillService;
private final TwintPaymentService twintPaymentService;
public OrderController(OrderService orderService, public OrderController(OrderService orderService,
@@ -53,8 +49,7 @@ public class OrderController {
CustomerRepository customerRepo, CustomerRepository customerRepo,
StorageService storageService, StorageService storageService,
InvoicePdfRenderingService invoiceService, InvoicePdfRenderingService invoiceService,
QrBillService qrBillService, QrBillService qrBillService) {
TwintPaymentService twintPaymentService) {
this.orderService = orderService; this.orderService = orderService;
this.orderRepo = orderRepo; this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo; this.orderItemRepo = orderItemRepo;
@@ -64,7 +59,6 @@ public class OrderController {
this.storageService = storageService; this.storageService = storageService;
this.invoiceService = invoiceService; this.invoiceService = invoiceService;
this.qrBillService = qrBillService; this.qrBillService = qrBillService;
this.twintPaymentService = twintPaymentService;
} }
@@ -135,7 +129,7 @@ public class OrderController {
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera"); vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
vars.put("sellerEmail", "info@3dfab.ch"); vars.put("sellerEmail", "info@3dfab.ch");
vars.put("invoiceNumber", "INV-" + getDisplayOrderNumber(order).toUpperCase()); vars.put("invoiceNumber", "INV-" + order.getId().toString().substring(0, 8).toUpperCase());
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE)); vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE)); vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
@@ -187,55 +181,10 @@ public class OrderController {
byte[] pdf = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg); byte[] pdf = invoiceService.generateInvoicePdfBytesFromTemplate(vars, qrBillSvg);
return ResponseEntity.ok() return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"invoice-" + getDisplayOrderNumber(order) + ".pdf\"") .header("Content-Disposition", "attachment; filename=\"invoice-" + orderId + ".pdf\"")
.contentType(MediaType.APPLICATION_PDF) .contentType(MediaType.APPLICATION_PDF)
.body(pdf); .body(pdf);
} }
@GetMapping("/{orderId}/twint")
public ResponseEntity<Map<String, String>> getTwintPayment(@PathVariable UUID orderId) {
if (!orderRepo.existsById(orderId)) {
return ResponseEntity.notFound().build();
}
byte[] qrPng = twintPaymentService.generateQrPng(360);
String qrDataUri = "data:image/png;base64," + Base64.getEncoder().encodeToString(qrPng);
Map<String, String> data = new HashMap<>();
data.put("paymentUrl", twintPaymentService.getTwintPaymentUrl());
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<Void> openTwintPayment(@PathVariable UUID orderId) {
if (!orderRepo.existsById(orderId)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.status(302)
.location(URI.create(twintPaymentService.getTwintPaymentUrl()))
.build();
}
@GetMapping("/{orderId}/twint/qr")
public ResponseEntity<byte[]> getTwintQr(
@PathVariable UUID orderId,
@RequestParam(defaultValue = "320") int size
) {
if (!orderRepo.existsById(orderId)) {
return ResponseEntity.notFound().build();
}
int normalizedSize = Math.max(200, Math.min(size, 600));
byte[] png = twintPaymentService.generateQrPng(normalizedSize);
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_PNG)
.body(png);
}
private String getExtension(String filename) { private String getExtension(String filename) {
if (filename == null) return "stl"; if (filename == null) return "stl";
@@ -249,7 +198,6 @@ public class OrderController {
private OrderDto convertToDto(Order order, List<OrderItem> items) { private OrderDto convertToDto(Order order, List<OrderItem> items) {
OrderDto dto = new OrderDto(); OrderDto dto = new OrderDto();
dto.setId(order.getId()); dto.setId(order.getId());
dto.setOrderNumber(getDisplayOrderNumber(order));
dto.setStatus(order.getStatus()); dto.setStatus(order.getStatus());
dto.setCustomerEmail(order.getCustomerEmail()); dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone()); dto.setCustomerPhone(order.getCustomerPhone());
@@ -307,12 +255,4 @@ public class OrderController {
return dto; 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";
}
} }

View File

@@ -1,11 +1,15 @@
package com.printcalculator.controller; package com.printcalculator.controller;
import com.printcalculator.entity.PrinterMachine; import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.exception.ModelTooLargeException;
import com.printcalculator.model.PrintStats; import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult; import com.printcalculator.model.QuoteResult;
import com.printcalculator.model.StlBounds;
import com.printcalculator.repository.PrinterMachineRepository; import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.ProfileManager;
import com.printcalculator.service.SlicerService; import com.printcalculator.service.SlicerService;
import com.printcalculator.service.StlService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -15,24 +19,29 @@ import java.util.HashMap;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.logging.Logger;
@RestController @RestController
public class QuoteController { public class QuoteController {
private static final Logger logger = Logger.getLogger(QuoteController.class.getName());
private final SlicerService slicerService; private final SlicerService slicerService;
private final StlService stlService;
private final QuoteCalculator quoteCalculator; private final QuoteCalculator quoteCalculator;
private final PrinterMachineRepository machineRepo; private final PrinterMachineRepository machineRepo;
private final com.printcalculator.service.ClamAVService clamAVService; private final ProfileManager profileManager;
// Defaults (using aliases defined in ProfileManager) // Defaults (using aliases defined in ProfileManager)
private static final String DEFAULT_FILAMENT = "pla_basic"; private static final String DEFAULT_FILAMENT = "pla_basic";
private static final String DEFAULT_PROCESS = "standard"; private static final String DEFAULT_PROCESS = "standard";
public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, com.printcalculator.service.ClamAVService clamAVService) { public QuoteController(SlicerService slicerService, StlService stlService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, ProfileManager profileManager) {
this.slicerService = slicerService; this.slicerService = slicerService;
this.stlService = stlService;
this.quoteCalculator = quoteCalculator; this.quoteCalculator = quoteCalculator;
this.machineRepo = machineRepo; this.machineRepo = machineRepo;
this.clamAVService = clamAVService; this.profileManager = profileManager;
} }
@PostMapping("/api/quote") @PostMapping("/api/quote")
@@ -46,7 +55,7 @@ public class QuoteController {
@RequestParam(value = "infill_pattern", required = false) String infillPattern, @RequestParam(value = "infill_pattern", required = false) String infillPattern,
@RequestParam(value = "layer_height", required = false) Double layerHeight, @RequestParam(value = "layer_height", required = false) Double layerHeight,
@RequestParam(value = "nozzle_diameter", required = false) Double nozzleDiameter, @RequestParam(value = "nozzle_diameter", required = false) Double nozzleDiameter,
@RequestParam(value = "support_enabled", required = false) Boolean supportEnabled @RequestParam(value = "support_enabled", required = false, defaultValue = "false") Boolean supportEnabled
) throws IOException { ) throws IOException {
// ... process selection logic ... // ... process selection logic ...
@@ -74,6 +83,9 @@ public class QuoteController {
} }
if (supportEnabled != null) { if (supportEnabled != null) {
processOverrides.put("enable_support", supportEnabled ? "1" : "0"); processOverrides.put("enable_support", supportEnabled ? "1" : "0");
if (supportEnabled) {
processOverrides.put("support_threshold_angle", "45");
}
} }
if (nozzleDiameter != null) { if (nozzleDiameter != null) {
@@ -83,7 +95,7 @@ public class QuoteController {
// For now, we trust the override key works on the base profile. // For now, we trust the override key works on the base profile.
} }
return processRequest(file, filament, actualProcess, machineOverrides, processOverrides); return processRequest(file, filament, actualProcess, machineOverrides, processOverrides, nozzleDiameter);
} }
@PostMapping("/calculate/stl") @PostMapping("/calculate/stl")
@@ -91,42 +103,91 @@ public class QuoteController {
@RequestParam("file") MultipartFile file @RequestParam("file") MultipartFile file
) throws IOException { ) throws IOException {
// Legacy endpoint uses defaults // Legacy endpoint uses defaults
return processRequest(file, DEFAULT_FILAMENT, DEFAULT_PROCESS, null, null); return processRequest(file, DEFAULT_FILAMENT, DEFAULT_PROCESS, null, null, null);
} }
private ResponseEntity<QuoteResult> processRequest(MultipartFile file, String filament, String process, private ResponseEntity<QuoteResult> processRequest(MultipartFile file, String filament, String process,
Map<String, String> machineOverrides, Map<String, String> machineOverrides,
Map<String, String> processOverrides) throws IOException { Map<String, String> processOverrides,
Double nozzleDiameter) throws IOException {
if (file.isEmpty()) { if (file.isEmpty()) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
// Scan for virus
clamAVService.scan(file.getInputStream());
// Fetch Default Active Machine // Fetch Default Active Machine
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue() PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new IOException("No active printer found in database")); .orElseThrow(() -> new IOException("No active printer found in database"));
// Save uploaded file temporarily // Save uploaded file temporarily
Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename()); Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename());
com.printcalculator.model.StlShiftResult shift = null;
try { try {
file.transferTo(tempInput.toFile()); file.transferTo(tempInput.toFile());
String slicerMachineProfile = "bambu_a1"; // TODO: Add to PrinterMachine entity // Use profile from machine or fallback
String slicerMachineProfile = machine.getSlicerMachineProfile();
if (slicerMachineProfile == null || slicerMachineProfile.isEmpty()) {
slicerMachineProfile = "bambu_a1";
}
slicerMachineProfile = profileManager.resolveMachineProfileName(slicerMachineProfile, nozzleDiameter);
PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides); // Validate model size against machine volume
StlBounds bounds = validateModelSize(tempInput.toFile(), machine);
// Auto-center if needed
shift = stlService.shiftToFitIfNeeded(
tempInput.toFile(),
bounds,
machine.getBuildVolumeXMm(),
machine.getBuildVolumeYMm(),
machine.getBuildVolumeZMm()
);
java.io.File sliceInput = shift.shifted() ? shift.shiftedPath().toFile() : tempInput.toFile();
if (shift.shifted()) {
logger.info(String.format("Auto-centered STL by offset (mm): x=%.3f y=%.3f z=%.3f",
shift.offsetX(), shift.offsetY(), shift.offsetZ()));
}
PrintStats stats = slicerService.slice(sliceInput, slicerMachineProfile, filament, process, machineOverrides, processOverrides);
// Calculate Quote (Pass machine display name for pricing lookup) // Calculate Quote (Pass machine display name for pricing lookup)
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filament); QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filament);
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} catch (Exception e) {
e.printStackTrace();
return ResponseEntity.internalServerError().build();
} finally { } finally {
Files.deleteIfExists(tempInput); Files.deleteIfExists(tempInput);
if (shift != null && shift.shifted()) {
try {
Files.deleteIfExists(shift.shiftedPath());
} catch (Exception ignored) {}
}
} }
} }
private StlBounds validateModelSize(java.io.File stlFile, PrinterMachine machine) throws IOException {
StlBounds bounds = stlService.readBounds(stlFile);
double x = bounds.sizeX();
double y = bounds.sizeY();
double z = bounds.sizeZ();
int bx = machine.getBuildVolumeXMm();
int by = machine.getBuildVolumeYMm();
int bz = machine.getBuildVolumeZMm();
logger.info(String.format(
"STL bounds (mm): min(%.3f,%.3f,%.3f) max(%.3f,%.3f,%.3f) size(%.3f,%.3f,%.3f) bed(%d,%d,%d)",
bounds.minX(), bounds.minY(), bounds.minZ(),
bounds.maxX(), bounds.maxY(), bounds.maxZ(),
x, y, z, bx, by, bz
));
double eps = 0.01;
boolean fits = (x <= bx + eps && y <= by + eps && z <= bz + eps)
|| (y <= bx + eps && x <= by + eps && z <= bz + eps);
if (!fits) {
throw new ModelTooLargeException(x, y, z, bx, by, bz);
}
return bounds;
}
} }

View File

@@ -3,13 +3,17 @@ package com.printcalculator.controller;
import com.printcalculator.entity.PrinterMachine; import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.entity.QuoteLineItem; import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession; import com.printcalculator.entity.QuoteSession;
import com.printcalculator.exception.ModelTooLargeException;
import com.printcalculator.model.PrintStats; import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult; import com.printcalculator.model.QuoteResult;
import com.printcalculator.model.StlBounds;
import com.printcalculator.repository.PrinterMachineRepository; import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository; import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.service.QuoteCalculator; import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.ProfileManager;
import com.printcalculator.service.SlicerService; import com.printcalculator.service.SlicerService;
import com.printcalculator.service.StlService;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -28,19 +32,24 @@ import java.util.Map;
import java.util.UUID; import java.util.UUID;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource; import org.springframework.core.io.UrlResource;
import java.util.logging.Logger;
@RestController @RestController
@RequestMapping("/api/quote-sessions") @RequestMapping("/api/quote-sessions")
public class QuoteSessionController { public class QuoteSessionController {
private static final Logger logger = Logger.getLogger(QuoteSessionController.class.getName());
private final QuoteSessionRepository sessionRepo; private final QuoteSessionRepository sessionRepo;
private final QuoteLineItemRepository lineItemRepo; private final QuoteLineItemRepository lineItemRepo;
private final SlicerService slicerService; private final SlicerService slicerService;
private final StlService stlService;
private final QuoteCalculator quoteCalculator; private final QuoteCalculator quoteCalculator;
private final ProfileManager profileManager;
private final PrinterMachineRepository machineRepo; private final PrinterMachineRepository machineRepo;
private final com.printcalculator.repository.PricingPolicyRepository pricingRepo; private final com.printcalculator.repository.PricingPolicyRepository pricingRepo;
private final com.printcalculator.service.ClamAVService clamAVService; private final com.printcalculator.service.StorageService storageService;
// Defaults // Defaults
private static final String DEFAULT_FILAMENT = "pla_basic"; private static final String DEFAULT_FILAMENT = "pla_basic";
@@ -49,17 +58,21 @@ public class QuoteSessionController {
public QuoteSessionController(QuoteSessionRepository sessionRepo, public QuoteSessionController(QuoteSessionRepository sessionRepo,
QuoteLineItemRepository lineItemRepo, QuoteLineItemRepository lineItemRepo,
SlicerService slicerService, SlicerService slicerService,
StlService stlService,
QuoteCalculator quoteCalculator, QuoteCalculator quoteCalculator,
ProfileManager profileManager,
PrinterMachineRepository machineRepo, PrinterMachineRepository machineRepo,
com.printcalculator.repository.PricingPolicyRepository pricingRepo, com.printcalculator.repository.PricingPolicyRepository pricingRepo,
com.printcalculator.service.ClamAVService clamAVService) { com.printcalculator.service.StorageService storageService) {
this.sessionRepo = sessionRepo; this.sessionRepo = sessionRepo;
this.lineItemRepo = lineItemRepo; this.lineItemRepo = lineItemRepo;
this.slicerService = slicerService; this.slicerService = slicerService;
this.stlService = stlService;
this.quoteCalculator = quoteCalculator; this.quoteCalculator = quoteCalculator;
this.profileManager = profileManager;
this.machineRepo = machineRepo; this.machineRepo = machineRepo;
this.pricingRepo = pricingRepo; this.pricingRepo = pricingRepo;
this.clamAVService = clamAVService; this.storageService = storageService;
} }
// 1. Start a new empty session // 1. Start a new empty session
@@ -102,13 +115,8 @@ public class QuoteSessionController {
private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException { private QuoteLineItem addItemToSession(QuoteSession session, MultipartFile file, com.printcalculator.dto.PrintSettingsDto settings) throws IOException {
if (file.isEmpty()) throw new IOException("File is empty"); if (file.isEmpty()) throw new IOException("File is empty");
// Scan for virus
clamAVService.scan(file.getInputStream());
// 1. Define Persistent Storage Path // 1. Define Persistent Storage Path
// Structure: storage_quotes/{sessionId}/{uuid}.{ext} // Structure: quotes/{sessionId}/{uuid}.{ext} (inside storage root)
String storageDir = "storage_quotes/" + session.getId();
Files.createDirectories(Paths.get(storageDir));
String originalFilename = file.getOriginalFilename(); String originalFilename = file.getOriginalFilename();
String ext = originalFilename != null && originalFilename.contains(".") String ext = originalFilename != null && originalFilename.contains(".")
@@ -116,11 +124,15 @@ public class QuoteSessionController {
: ".stl"; : ".stl";
String storedFilename = UUID.randomUUID() + ext; String storedFilename = UUID.randomUUID() + ext;
Path persistentPath = Paths.get(storageDir, storedFilename); Path relativePath = Paths.get("quotes", session.getId().toString(), storedFilename);
// Save file // Save file
Files.copy(file.getInputStream(), persistentPath); storageService.store(file, relativePath);
// Resolve absolute path for slicing and storage usage
Path persistentPath = storageService.loadAsResource(relativePath).getFile().toPath();
com.printcalculator.model.StlShiftResult shift = null;
try { try {
// Apply Basic/Advanced Logic // Apply Basic/Advanced Logic
applyPrintSettings(settings); applyPrintSettings(settings);
@@ -129,13 +141,33 @@ public class QuoteSessionController {
// 1. Pick Machine (default to first active or specific) // 1. Pick Machine (default to first active or specific)
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue() PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new RuntimeException("No active printer found")); .orElseThrow(() -> new RuntimeException("No active printer found"));
// 2. Validate model size against machine volume
StlBounds bounds = validateModelSize(persistentPath.toFile(), machine);
// 2b. Auto-center if needed (keeps the stored STL unchanged)
shift = stlService.shiftToFitIfNeeded(
persistentPath.toFile(),
bounds,
machine.getBuildVolumeXMm(),
machine.getBuildVolumeYMm(),
machine.getBuildVolumeZMm()
);
java.io.File sliceInput = shift.shifted() ? shift.shiftedPath().toFile() : persistentPath.toFile();
if (shift.shifted()) {
logger.info(String.format("Auto-centered STL by offset (mm): x=%.3f y=%.3f z=%.3f",
shift.offsetX(), shift.offsetY(), shift.offsetZ()));
}
// 2. Pick Profiles // 3. Pick Profiles
String machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle" String machineProfile = machine.getSlicerMachineProfile();
// If the display name doesn't match the json profile name, we might need a mapping key in DB. if (machineProfile == null || machineProfile.isBlank()) {
// For now assuming display name works or we use a tough default machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle"
machineProfile = "Bambu Lab A1 0.4 nozzle"; // Force known good for now? Or use DB field if exists. }
// Ideally: machine.getSlicerProfileName(); if (machineProfile == null || machineProfile.isBlank()) {
machineProfile = "bambu_a1"; // final fallback (alias handled in ProfileManager)
}
machineProfile = profileManager.resolveMachineProfileName(machineProfile, settings.getNozzleDiameter());
String filamentProfile = "Generic " + (settings.getMaterial() != null ? settings.getMaterial().toUpperCase() : "PLA"); String filamentProfile = "Generic " + (settings.getMaterial() != null ? settings.getMaterial().toUpperCase() : "PLA");
// Mapping: "pla_basic" -> "Generic PLA", "petg_basic" -> "Generic PETG" // Mapping: "pla_basic" -> "Generic PLA", "petg_basic" -> "Generic PETG"
@@ -144,7 +176,24 @@ public class QuoteSessionController {
else if (settings.getMaterial().toLowerCase().contains("petg")) filamentProfile = "Generic PETG"; else if (settings.getMaterial().toLowerCase().contains("petg")) filamentProfile = "Generic PETG";
else if (settings.getMaterial().toLowerCase().contains("tpu")) filamentProfile = "Generic TPU"; else if (settings.getMaterial().toLowerCase().contains("tpu")) filamentProfile = "Generic TPU";
else if (settings.getMaterial().toLowerCase().contains("abs")) filamentProfile = "Generic ABS"; else if (settings.getMaterial().toLowerCase().contains("abs")) filamentProfile = "Generic ABS";
// Update Session Material
session.setMaterialCode(settings.getMaterial());
} else {
// Fallback if null?
session.setMaterialCode("pla_basic");
} }
// Update Session Settings for Persistence
if (settings.getNozzleDiameter() != null) session.setNozzleDiameterMm(BigDecimal.valueOf(settings.getNozzleDiameter()));
if (settings.getLayerHeight() != null) session.setLayerHeightMm(BigDecimal.valueOf(settings.getLayerHeight()));
if (settings.getInfillDensity() != null) session.setInfillPercent(settings.getInfillDensity().intValue());
if (settings.getInfillPattern() != null) session.setInfillPattern(settings.getInfillPattern());
if (settings.getSupportsEnabled() != null) session.setSupportsEnabled(settings.getSupportsEnabled());
if (settings.getNotes() != null) session.setNotes(settings.getNotes());
// Save session updates
sessionRepo.save(session);
String processProfile = "0.20mm Standard @BBL A1"; String processProfile = "0.20mm Standard @BBL A1";
// Mapping quality to process // Mapping quality to process
@@ -157,26 +206,40 @@ public class QuoteSessionController {
else if (settings.getLayerHeight() <= 0.12) processProfile = "0.12mm Fine @BBL A1"; else if (settings.getLayerHeight() <= 0.12) processProfile = "0.12mm Fine @BBL A1";
} }
// Build overrides map from settings
// Build overrides map from settings // Build overrides map from settings
Map<String, String> processOverrides = new HashMap<>(); Map<String, String> processOverrides = new HashMap<>();
if (settings.getLayerHeight() != null) processOverrides.put("layer_height", String.valueOf(settings.getLayerHeight())); 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.getInfillDensity() != null) processOverrides.put("sparse_infill_density", settings.getInfillDensity() + "%");
if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern()); if (settings.getInfillPattern() != null) processOverrides.put("sparse_infill_pattern", settings.getInfillPattern());
if (settings.getSupportsEnabled() != null) {
processOverrides.put("enable_support", settings.getSupportsEnabled() ? "1" : "0");
// If enabled, use a more permissive threshold (45 deg) by default
// to avoid expensive supports on things that don't strictly need them
if (settings.getSupportsEnabled()) {
processOverrides.put("support_threshold_angle", "45");
}
}
// 3. Slice (Use persistent path) Map<String, String> machineOverrides = new HashMap<>();
if (settings.getNozzleDiameter() != null) {
machineOverrides.put("nozzle_diameter", String.valueOf(settings.getNozzleDiameter()));
}
// 4. Slice (Use persistent path)
PrintStats stats = slicerService.slice( PrintStats stats = slicerService.slice(
persistentPath.toFile(), sliceInput,
machineProfile, machineProfile,
filamentProfile, filamentProfile,
processProfile, processProfile,
null, // machine overrides machineOverrides, // machine overrides
processOverrides processOverrides
); );
// 4. Calculate Quote // 5. Calculate Quote
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile); QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
// 5. Create Line Item // 6. Create Line Item
QuoteLineItem item = new QuoteLineItem(); QuoteLineItem item = new QuoteLineItem();
item.setQuoteSession(session); item.setQuoteSession(session);
item.setOriginalFilename(file.getOriginalFilename()); item.setOriginalFilename(file.getOriginalFilename());
@@ -185,8 +248,8 @@ public class QuoteSessionController {
item.setColorCode(settings.getColor() != null ? settings.getColor() : "#FFFFFF"); item.setColorCode(settings.getColor() != null ? settings.getColor() : "#FFFFFF");
item.setStatus("READY"); // or CALCULATED item.setStatus("READY"); // or CALCULATED
item.setPrintTimeSeconds((int) stats.printTimeSeconds()); item.setPrintTimeSeconds((int) stats.getPrintTimeSeconds());
item.setMaterialGrams(BigDecimal.valueOf(stats.filamentWeightGrams())); item.setMaterialGrams(BigDecimal.valueOf(stats.getFilamentWeightGrams()));
item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice())); item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice()));
// Store breakdown // Store breakdown
@@ -196,14 +259,10 @@ public class QuoteSessionController {
breakdown.put("setup_fee", result.getSetupCost()); breakdown.put("setup_fee", result.getSetupCost());
item.setPricingBreakdown(breakdown); item.setPricingBreakdown(breakdown);
// Dimensions // Dimensions from STL
// Cannot get bb from GCodeParser yet? item.setBoundingBoxXMm(BigDecimal.valueOf(bounds.sizeX()));
// If GCodeParser doesn't return size, we might defaults or 0. item.setBoundingBoxYMm(BigDecimal.valueOf(bounds.sizeY()));
// Stats has filament used. item.setBoundingBoxZMm(BigDecimal.valueOf(bounds.sizeZ()));
// Let's set dummy for now or upgrade parser later.
item.setBoundingBoxXMm(BigDecimal.ZERO);
item.setBoundingBoxYMm(BigDecimal.ZERO);
item.setBoundingBoxZMm(BigDecimal.ZERO);
item.setCreatedAt(OffsetDateTime.now()); item.setCreatedAt(OffsetDateTime.now());
item.setUpdatedAt(OffsetDateTime.now()); item.setUpdatedAt(OffsetDateTime.now());
@@ -212,11 +271,46 @@ public class QuoteSessionController {
} catch (Exception e) { } catch (Exception e) {
// Cleanup if failed // Cleanup if failed
Files.deleteIfExists(persistentPath); try {
storageService.delete(Paths.get("quotes", session.getId().toString(), storedFilename));
} catch (Exception ignored) {}
throw e; throw e;
} finally {
if (shift != null && shift.shifted()) {
try {
Files.deleteIfExists(shift.shiftedPath());
} catch (Exception ignored) {}
}
} }
} }
private StlBounds validateModelSize(java.io.File stlFile, PrinterMachine machine) throws IOException {
StlBounds bounds = stlService.readBounds(stlFile);
double x = bounds.sizeX();
double y = bounds.sizeY();
double z = bounds.sizeZ();
int bx = machine.getBuildVolumeXMm();
int by = machine.getBuildVolumeYMm();
int bz = machine.getBuildVolumeZMm();
logger.info(String.format(
"STL bounds (mm): min(%.3f,%.3f,%.3f) max(%.3f,%.3f,%.3f) size(%.3f,%.3f,%.3f) bed(%d,%d,%d)",
bounds.minX(), bounds.minY(), bounds.minZ(),
bounds.maxX(), bounds.maxY(), bounds.maxZ(),
x, y, z, bx, by, bz
));
double eps = 0.01;
boolean fits = (x <= bx + eps && y <= by + eps && z <= bz + eps)
|| (y <= bx + eps && x <= by + eps && z <= bz + eps);
if (!fits) {
throw new ModelTooLargeException(x, y, z, bx, by, bz);
}
return bounds;
}
private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) { private void applyPrintSettings(com.printcalculator.dto.PrintSettingsDto settings) {
if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) { if ("BASIC".equalsIgnoreCase(settings.getComplexityMode())) {
// Set defaults based on Quality // Set defaults based on Quality
@@ -227,24 +321,30 @@ public class QuoteSessionController {
settings.setLayerHeight(0.28); settings.setLayerHeight(0.28);
settings.setInfillDensity(15.0); settings.setInfillDensity(15.0);
settings.setInfillPattern("grid"); settings.setInfillPattern("grid");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
break; break;
case "high": case "high":
settings.setLayerHeight(0.12); settings.setLayerHeight(0.12);
settings.setInfillDensity(20.0); settings.setInfillDensity(20.0);
settings.setInfillPattern("gyroid"); settings.setInfillPattern("gyroid");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
break; break;
case "standard": case "standard":
default: default:
settings.setLayerHeight(0.20); settings.setLayerHeight(0.20);
settings.setInfillDensity(20.0); settings.setInfillDensity(20.0);
settings.setInfillPattern("grid"); settings.setInfillPattern("grid");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
break; break;
} }
if (settings.getSupportsEnabled() == null) settings.setSupportsEnabled(false);
} else { } else {
// ADVANCED Mode: Use values from Frontend, set defaults if missing // ADVANCED Mode: Use values from Frontend, set defaults if missing
if (settings.getLayerHeight() == null) settings.setLayerHeight(0.20); if (settings.getLayerHeight() == null) settings.setLayerHeight(0.20);
if (settings.getInfillDensity() == null) settings.setInfillDensity(20.0); if (settings.getInfillDensity() == null) settings.setInfillDensity(20.0);
if (settings.getInfillPattern() == null) settings.setInfillPattern("grid"); if (settings.getInfillPattern() == null) settings.setInfillPattern("grid");
if (settings.getNozzleDiameter() == null) settings.setNozzleDiameter(0.4);
if (settings.getSupportsEnabled() == null) settings.setSupportsEnabled(false);
} }
} }
@@ -336,6 +436,24 @@ public class QuoteSessionController {
} }
Path path = Paths.get(item.getStoredPath()); Path path = Paths.get(item.getStoredPath());
// Since storedPath is absolute, we can't directly use loadAsResource with it unless we resolve relative.
// But loadAsResource expects relative path?
// Actually FileSystemStorageService.loadAsResource uses rootLocation.resolve(path).
// If path is absolute, resolve might fail or behave weirdly.
// But wait, we stored absolute path in DB: item.setStoredPath(persistentPath.toString());
// If we want to use storageService.loadAsResource, we need the relative path.
// Or we just access the file directly if we trust the absolute path.
// But we want to use StorageService abstraction.
// Option 1: Reconstruct relative path.
// We know structure: quotes/{sessionId}/{filename}...
// But filename is UUID+ext. We don't have storedFilename in QuoteLineItem easily?
// QuoteLineItem doesn't seem to have storedFilename field, only storedPath.
// If we trust the file is on disk, we can use UrlResource directly here as before,
// relying on the fact that storedPath is the absolute path to the file.
// But we should verify it exists.
if (!Files.exists(path)) { if (!Files.exists(path)) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }

View File

@@ -7,7 +7,6 @@ import java.util.UUID;
public class OrderDto { public class OrderDto {
private UUID id; private UUID id;
private String orderNumber;
private String status; private String status;
private String customerEmail; private String customerEmail;
private String customerPhone; private String customerPhone;
@@ -28,9 +27,6 @@ public class OrderDto {
public UUID getId() { return id; } public UUID getId() { return id; }
public void setId(UUID id) { this.id = 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 String getStatus() { return status; }
public void setStatus(String status) { this.status = status; } public void setStatus(String status) { this.status = status; }

View File

@@ -18,6 +18,7 @@ public class PrintSettingsDto {
private Double layerHeight; private Double layerHeight;
private Double infillDensity; private Double infillDensity;
private String infillPattern; private String infillPattern;
private Boolean supportsEnabled; private Boolean supportsEnabled = true;
private Double nozzleDiameter;
private String notes; private String notes;
} }

View File

@@ -138,16 +138,6 @@ public class Order {
this.id = 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() { public QuoteSession getSourceQuoteSession() {
return sourceQuoteSession; return sourceQuoteSession;
} }
@@ -420,4 +410,5 @@ public class Order {
this.paidAt = paidAt; this.paidAt = paidAt;
} }
}
}

View File

@@ -67,16 +67,6 @@ public class OrderItem {
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
@PrePersist
private void onCreate() {
if (createdAt == null) {
createdAt = OffsetDateTime.now();
}
if (quantity == null) {
quantity = 1;
}
}
public UUID getId() { public UUID getId() {
return id; return id;
} }
@@ -205,4 +195,4 @@ public class OrderItem {
this.createdAt = createdAt; this.createdAt = createdAt;
} }
} }

View File

@@ -41,6 +41,9 @@ public class PrinterMachine {
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
@Column(name = "slicer_machine_profile")
private String slicerMachineProfile;
public Long getId() { public Long getId() {
return id; return id;
} }
@@ -57,6 +60,14 @@ public class PrinterMachine {
this.printerDisplayName = printerDisplayName; this.printerDisplayName = printerDisplayName;
} }
public String getSlicerMachineProfile() {
return slicerMachineProfile;
}
public void setSlicerMachineProfile(String slicerMachineProfile) {
this.slicerMachineProfile = slicerMachineProfile;
}
public Integer getBuildVolumeXMm() { public Integer getBuildVolumeXMm() {
return buildVolumeXMm; return buildVolumeXMm;
} }

View File

@@ -1,16 +0,0 @@
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;
}
}

View File

@@ -1,97 +0,0 @@
package com.printcalculator.event.listener;
import com.printcalculator.entity.Order;
import com.printcalculator.event.OrderCreatedEvent;
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.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderEmailListener {
private final EmailNotificationService emailNotificationService;
@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);
}
}
private void sendCustomerConfirmationEmail(Order order) {
Map<String, Object> templateData = new HashMap<>();
templateData.put("customerName", order.getCustomer().getFirstName());
templateData.put("orderId", order.getId());
templateData.put("orderNumber", getDisplayOrderNumber(order));
templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order));
templateData.put("orderDate", order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")));
templateData.put("totalCost", String.format("%.2f", order.getTotalChf()));
emailNotificationService.sendEmail(
order.getCustomer().getEmail(),
"Conferma Ordine #" + getDisplayOrderNumber(order) + " - 3D-Fab",
"order-confirmation",
templateData
);
}
private void sendAdminNotificationEmail(Order order) {
Map<String, Object> templateData = new HashMap<>();
templateData.put("customerName", order.getCustomer().getFirstName() + " " + order.getCustomer().getLastName());
templateData.put("orderId", order.getId());
templateData.put("orderNumber", getDisplayOrderNumber(order));
templateData.put("orderDetailsUrl", buildOrderDetailsUrl(order));
templateData.put("orderDate", order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")));
templateData.put("totalCost", String.format("%.2f", order.getTotalChf()));
// Possiamo riutilizzare lo stesso template per ora o crearne uno ad-hoc in futuro
emailNotificationService.sendEmail(
adminMailAddress,
"Nuovo Ordine Ricevuto #" + getDisplayOrderNumber(order) + " - " + order.getCustomer().getLastName(),
"order-confirmation",
templateData
);
}
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 baseUrl = frontendBaseUrl == null ? "" : frontendBaseUrl.replaceAll("/+$", "");
return baseUrl + "/ordine/" + order.getId();
}
}

View File

@@ -1,27 +1,71 @@
package com.printcalculator.exception; package com.printcalculator.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest; import org.springframework.web.multipart.MaxUploadSizeExceededException;
import java.time.LocalDateTime; import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.math.BigDecimal;
import java.math.RoundingMode;
@ControllerAdvice @ControllerAdvice
@Slf4j
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
@ExceptionHandler(VirusDetectedException.class) @ExceptionHandler(StorageException.class)
public ResponseEntity<Object> handleVirusDetectedException( public ResponseEntity<?> handleStorageException(StorageException exc) {
VirusDetectedException ex, WebRequest request) { // Log the full exception for internal debugging
log.error("Storage Exception occurred", exc);
Map<String, String> response = new HashMap<>();
Map<String, Object> body = new LinkedHashMap<>(); // Check for specific virus case
body.put("timestamp", LocalDateTime.now()); if (exc.getMessage() != null && exc.getMessage().contains("antivirus scanner")) {
body.put("message", ex.getMessage()); response.put("error", "Security Violation");
body.put("error", "Virus Detected"); // Safe message for client
response.put("message", "File rejected by security policy.");
response.put("code", "VIRUS_DETECTED");
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(response);
}
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); // Generic fallback for other storage errors to avoid leaking internal paths/details
response.put("error", "Storage Operation Failed");
response.put("message", "Unable to process the file upload.");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<?> handleMaxSizeException(MaxUploadSizeExceededException exc) {
Map<String, String> response = new HashMap<>();
response.put("error", "File too large");
response.put("message", "The uploaded file exceeds the maximum allowed size.");
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(response);
}
@ExceptionHandler(ModelTooLargeException.class)
public ResponseEntity<?> handleModelTooLarge(ModelTooLargeException exc) {
Map<String, String> response = new HashMap<>();
response.put("error", "Model too large");
response.put("code", "MODEL_TOO_LARGE");
response.put("message", String.format(
"Model size %.2fx%.2fx%.2f mm exceeds build volume %dx%dx%d mm.",
exc.getModelX(), exc.getModelY(), exc.getModelZ(),
exc.getBuildX(), exc.getBuildY(), exc.getBuildZ()
));
response.put("model_x_mm", formatMm(exc.getModelX()));
response.put("model_y_mm", formatMm(exc.getModelY()));
response.put("model_z_mm", formatMm(exc.getModelZ()));
response.put("build_x_mm", String.valueOf(exc.getBuildX()));
response.put("build_y_mm", String.valueOf(exc.getBuildY()));
response.put("build_z_mm", String.valueOf(exc.getBuildZ()));
return ResponseEntity.unprocessableEntity().body(response);
}
private String formatMm(double value) {
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).toPlainString();
} }
} }

View File

@@ -0,0 +1,45 @@
package com.printcalculator.exception;
public class ModelTooLargeException extends RuntimeException {
private final double modelX;
private final double modelY;
private final double modelZ;
private final int buildX;
private final int buildY;
private final int buildZ;
public ModelTooLargeException(double modelX, double modelY, double modelZ,
int buildX, int buildY, int buildZ) {
super("Model size exceeds build volume");
this.modelX = modelX;
this.modelY = modelY;
this.modelZ = modelZ;
this.buildX = buildX;
this.buildY = buildY;
this.buildZ = buildZ;
}
public double getModelX() {
return modelX;
}
public double getModelY() {
return modelY;
}
public double getModelZ() {
return modelZ;
}
public int getBuildX() {
return buildX;
}
public int getBuildY() {
return buildY;
}
public int getBuildZ() {
return buildZ;
}
}

View File

@@ -1,7 +0,0 @@
package com.printcalculator.exception;
public class VirusDetectedException extends RuntimeException {
public VirusDetectedException(String message) {
super(message);
}
}

View File

@@ -1,8 +1,29 @@
package com.printcalculator.model; package com.printcalculator.model;
public record PrintStats( import lombok.AllArgsConstructor;
long printTimeSeconds, import lombok.Builder;
String printTimeFormatted, import lombok.Data;
double filamentWeightGrams, import lombok.NoArgsConstructor;
double filamentLengthMm
) {} @Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class PrintStats {
private long printTimeSeconds;
private String printTimeFormatted;
private double filamentWeightGrams;
private double filamentLengthMm;
// Breakdown if available
private Double modelWeightGrams;
private Double supportWeightGrams;
// Legacy constructor for compatibility
public PrintStats(long printTimeSeconds, String printTimeFormatted, double filamentWeightGrams, double filamentLengthMm) {
this.printTimeSeconds = printTimeSeconds;
this.printTimeFormatted = printTimeFormatted;
this.filamentWeightGrams = filamentWeightGrams;
this.filamentLengthMm = filamentLengthMm;
}
}

View File

@@ -0,0 +1,16 @@
package com.printcalculator.model;
public record StlBounds(double minX, double minY, double minZ,
double maxX, double maxY, double maxZ) {
public double sizeX() {
return maxX - minX;
}
public double sizeY() {
return maxY - minY;
}
public double sizeZ() {
return maxZ - minZ;
}
}

View File

@@ -0,0 +1,10 @@
package com.printcalculator.model;
import java.nio.file.Path;
public record StlShiftResult(Path shiftedPath,
double offsetX,
double offsetY,
double offsetZ,
boolean shifted) {
}

View File

@@ -1,6 +1,5 @@
package com.printcalculator.service; package com.printcalculator.service;
import com.printcalculator.exception.VirusDetectedException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -23,15 +22,18 @@ public class ClamAVService {
public ClamAVService( public ClamAVService(
@Value("${clamav.host:clamav}") String host, @Value("${clamav.host:clamav}") String host,
@Value("${clamav.port:3310}") int port, @Value("${clamav.port:3310}") int port,
@Value("${clamav.enabled:true}") boolean enabled @Value("${clamav.enabled:false}") boolean enabled
) { ) {
this.enabled = enabled; this.enabled = enabled;
if (!enabled) {
logger.info("ClamAV is DISABLED");
this.clamavClient = null;
return;
}
logger.info("Initializing ClamAV client at {}:{}", host, port);
ClamavClient client = null; ClamavClient client = null;
try { try {
if (enabled) { client = new ClamavClient(host, port);
logger.info("Initializing ClamAV client at {}:{}", host, port);
client = new ClamavClient(host, port);
}
} catch (Exception e) { } catch (Exception e) {
logger.error("Failed to initialize ClamAV client: " + e.getMessage()); logger.error("Failed to initialize ClamAV client: " + e.getMessage());
} }
@@ -49,13 +51,11 @@ public class ClamAVService {
} else if (result instanceof ScanResult.VirusFound) { } else if (result instanceof ScanResult.VirusFound) {
Map<String, Collection<String>> viruses = ((ScanResult.VirusFound) result).getFoundViruses(); Map<String, Collection<String>> viruses = ((ScanResult.VirusFound) result).getFoundViruses();
logger.warn("VIRUS DETECTED: {}", viruses); logger.warn("VIRUS DETECTED: {}", viruses);
throw new VirusDetectedException("Virus detected in the uploaded file: " + viruses); return false;
} else { } else {
logger.warn("Unknown scan result: {}. Allowing file (FAIL-OPEN)", result); logger.warn("Unknown scan result: {}. Allowing file (FAIL-OPEN)", result);
return true; return true;
} }
} catch (VirusDetectedException e) {
throw e;
} catch (Exception e) { } catch (Exception e) {
logger.error("Error scanning file with ClamAV. Allowing file (FAIL-OPEN)", e); logger.error("Error scanning file with ClamAV. Allowing file (FAIL-OPEN)", e);
return true; return true;

View File

@@ -26,13 +26,15 @@ public class GCodeParser {
private static final Pattern TIME_PATTERN = Pattern.compile( private static final Pattern TIME_PATTERN = Pattern.compile(
";\\s*(?:estimated\\s+printing\\s+time|estimated\\s+print\\s+time|print\\s+time).*?[:=]\\s*(.*)", ";\\s*(?:estimated\\s+printing\\s+time|estimated\\s+print\\s+time|print\\s+time).*?[:=]\\s*(.*)",
Pattern.CASE_INSENSITIVE); Pattern.CASE_INSENSITIVE);
private static final Pattern FILAMENT_G_PATTERN = Pattern.compile(";\\s*filament used \\[g\\]\\s*=\\s*(.*)"); private static final Pattern FILAMENT_G_PATTERN = Pattern.compile(";\\s*filament used \\[g\\]\\s*=\\s*([^;\\(\\n\\r]+)(?:\\s*\\(([^,]+) model,\\s*([^ ]+) support\\))?");
private static final Pattern FILAMENT_MM_PATTERN = Pattern.compile(";\\s*filament used \\[mm\\]\\s*=\\s*(.*)"); private static final Pattern FILAMENT_MM_PATTERN = Pattern.compile(";\\s*filament used \\[mm\\]\\s*=\\s*(.*)");
public PrintStats parse(File gcodeFile) throws IOException { public PrintStats parse(File gcodeFile) throws IOException {
long seconds = 0; long seconds = 0;
double weightG = 0; double weightG = 0;
double lengthMm = 0; double lengthMm = 0;
Double modelWeightG = null;
Double supportWeightG = null;
String timeFormatted = ""; String timeFormatted = "";
try (BufferedReader reader = new BufferedReader(new FileReader(gcodeFile))) { try (BufferedReader reader = new BufferedReader(new FileReader(gcodeFile))) {
@@ -78,7 +80,14 @@ public class GCodeParser {
if (weightMatcher.find()) { if (weightMatcher.find()) {
try { try {
weightG = Double.parseDouble(weightMatcher.group(1).trim()); weightG = Double.parseDouble(weightMatcher.group(1).trim());
System.out.println("GCodeParser: Found weight: " + weightG + "g"); System.out.println("GCodeParser: Found total weight: " + weightG + "g");
// Check if we have groups 2 and 3 for breakdown
if (weightMatcher.groupCount() >= 3 && weightMatcher.group(2) != null) {
modelWeightG = Double.parseDouble(weightMatcher.group(2).trim());
supportWeightG = Double.parseDouble(weightMatcher.group(3).trim());
System.out.println("GCodeParser: Found breakdown - Model: " + modelWeightG + "g, Support: " + supportWeightG + "g");
}
} catch (NumberFormatException ignored) {} } catch (NumberFormatException ignored) {}
} }
@@ -92,7 +101,14 @@ public class GCodeParser {
} }
} }
return new PrintStats(seconds, timeFormatted, weightG, lengthMm); return PrintStats.builder()
.printTimeSeconds(seconds)
.printTimeFormatted(timeFormatted)
.filamentWeightGrams(weightG)
.filamentLengthMm(lengthMm)
.modelWeightGrams(modelWeightG)
.supportWeightGrams(supportWeightG)
.build();
} }
private long parseTimeString(String timeStr) { private long parseTimeString(String timeStr) {

View File

@@ -8,10 +8,9 @@ import com.printcalculator.repository.OrderItemRepository;
import com.printcalculator.repository.OrderRepository; import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.QuoteLineItemRepository; import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository; import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.event.OrderCreatedEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -35,7 +34,6 @@ public class OrderService {
private final StorageService storageService; private final StorageService storageService;
private final InvoicePdfRenderingService invoiceService; private final InvoicePdfRenderingService invoiceService;
private final QrBillService qrBillService; private final QrBillService qrBillService;
private final ApplicationEventPublisher eventPublisher;
public OrderService(OrderRepository orderRepo, public OrderService(OrderRepository orderRepo,
OrderItemRepository orderItemRepo, OrderItemRepository orderItemRepo,
@@ -44,8 +42,7 @@ public class OrderService {
CustomerRepository customerRepo, CustomerRepository customerRepo,
StorageService storageService, StorageService storageService,
InvoicePdfRenderingService invoiceService, InvoicePdfRenderingService invoiceService,
QrBillService qrBillService, QrBillService qrBillService) {
ApplicationEventPublisher eventPublisher) {
this.orderRepo = orderRepo; this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo; this.orderItemRepo = orderItemRepo;
this.quoteSessionRepo = quoteSessionRepo; this.quoteSessionRepo = quoteSessionRepo;
@@ -54,7 +51,6 @@ public class OrderService {
this.storageService = storageService; this.storageService = storageService;
this.invoiceService = invoiceService; this.invoiceService = invoiceService;
this.qrBillService = qrBillService; this.qrBillService = qrBillService;
this.eventPublisher = eventPublisher;
} }
@Transactional @Transactional
@@ -198,12 +194,8 @@ public class OrderService {
// Generate Invoice and QR Bill // Generate Invoice and QR Bill
generateAndSaveDocuments(order, savedItems); generateAndSaveDocuments(order, savedItems);
Order savedOrder = orderRepo.save(order);
eventPublisher.publishEvent(new OrderCreatedEvent(this, savedOrder));
return savedOrder; return orderRepo.save(order);
} }
private void generateAndSaveDocuments(Order order, List<OrderItem> items) { private void generateAndSaveDocuments(Order order, List<OrderItem> items) {
@@ -231,7 +223,7 @@ public class OrderService {
vars.put("sellerAddressLine2", "Sede Bienne, Svizzera"); vars.put("sellerAddressLine2", "Sede Bienne, Svizzera");
vars.put("sellerEmail", "info@3dfab.ch"); vars.put("sellerEmail", "info@3dfab.ch");
vars.put("invoiceNumber", "INV-" + getDisplayOrderNumber(order).toUpperCase()); vars.put("invoiceNumber", "INV-" + order.getId().toString().substring(0, 8).toUpperCase());
vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE)); vars.put("invoiceDate", order.getCreatedAt().format(DateTimeFormatter.ISO_LOCAL_DATE));
vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE)); vars.put("dueDate", order.getCreatedAt().plusDays(7).format(DateTimeFormatter.ISO_LOCAL_DATE));
@@ -305,12 +297,4 @@ public class OrderService {
} }
return "stl"; 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";
}
} }

View File

@@ -16,7 +16,7 @@ import java.util.logging.Logger;
import java.util.stream.Stream; import java.util.stream.Stream;
import java.util.Map; import java.util.Map;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.math.BigDecimal;
@Service @Service
public class ProfileManager { public class ProfileManager {
@@ -57,38 +57,34 @@ public class ProfileManager {
if (profilePath == null) { if (profilePath == null) {
throw new IOException("Profile not found: " + profileName); throw new IOException("Profile not found: " + profileName);
} }
logger.info("Resolved " + type + " profile '" + profileName + "' -> " + profilePath);
return resolveInheritance(profilePath); return resolveInheritance(profilePath);
} }
public String resolveMachineProfileName(String machineName, Double nozzleDiameter) {
String resolvedName = profileAliases.getOrDefault(machineName, machineName);
if (nozzleDiameter == null) return resolvedName;
String base = resolvedName.replaceAll("\\s*\\d+(?:\\.\\d+)?\\s*nozzle$", "").trim();
String formatted = BigDecimal.valueOf(nozzleDiameter).stripTrailingZeros().toPlainString();
String candidate = base + " " + formatted + " nozzle";
Path exists = findProfileFile(candidate, "machine");
return exists != null ? candidate : resolvedName;
}
private Path findProfileFile(String name, String type) { private Path findProfileFile(String name, String type) {
// Check aliases first // Check aliases first
String resolvedName = profileAliases.getOrDefault(name, name); String resolvedName = profileAliases.getOrDefault(name, name);
// Look for name.json under the expected type directory first to avoid // Simple search: look for name.json in the profiles_root recursively
// collisions across vendors/profile families with same filename. // Type could be "machine", "process", "filament" to narrow down, but for now global search
String filename = toJsonFilename(resolvedName); String filename = resolvedName.endsWith(".json") ? resolvedName : resolvedName + ".json";
try (Stream<Path> stream = Files.walk(Paths.get(profilesRoot))) { try (Stream<Path> stream = Files.walk(Paths.get(profilesRoot))) {
List<Path> candidates = stream Optional<Path> found = stream
.filter(p -> p.getFileName().toString().equals(filename)) .filter(p -> p.getFileName().toString().equals(filename))
.sorted() .findFirst();
.toList(); return found.orElse(null);
if (candidates.isEmpty()) {
return null;
}
if (type != null && !type.isBlank() && !"any".equalsIgnoreCase(type)) {
Optional<Path> typed = candidates.stream()
.filter(p -> pathContainsSegment(p, type))
.findFirst();
if (typed.isPresent()) {
return typed.get();
}
}
return candidates.get(0);
} catch (IOException e) { } catch (IOException e) {
logger.severe("Error searching for profile: " + e.getMessage()); logger.severe("Error searching for profile: " + e.getMessage());
return null; return null;
@@ -102,20 +98,14 @@ public class ProfileManager {
// 2. Check inherits // 2. Check inherits
if (currentNode.has("inherits")) { if (currentNode.has("inherits")) {
String parentName = currentNode.get("inherits").asText(); String parentName = currentNode.get("inherits").asText();
// Try local directory first with explicit .json filename. // Try to find parent in same directory or standard search
String parentFilename = toJsonFilename(parentName); Path parentPath = currentPath.getParent().resolve(parentName);
Path parentPath = currentPath.getParent().resolve(parentFilename);
if (!Files.exists(parentPath)) { if (!Files.exists(parentPath)) {
// Fallback to the same profile type directory before global. // If not in same dir, search globally
String inferredType = inferTypeFromPath(currentPath);
parentPath = findProfileFile(parentName, inferredType);
}
if (parentPath == null || !Files.exists(parentPath)) {
parentPath = findProfileFile(parentName, "any"); parentPath = findProfileFile(parentName, "any");
} }
if (parentPath != null && Files.exists(parentPath)) { if (parentPath != null && Files.exists(parentPath)) {
logger.info("Resolved inherits '" + parentName + "' for " + currentPath + " -> " + parentPath);
// Recursive call // Recursive call
ObjectNode parentNode = resolveInheritance(parentPath); ObjectNode parentNode = resolveInheritance(parentPath);
// Merge current into parent (child overrides parent) // Merge current into parent (child overrides parent)
@@ -146,30 +136,4 @@ public class ProfileManager {
mainNode.set(fieldName, jsonNode); 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";
}
} }

View File

@@ -51,8 +51,7 @@ public class QrBillService {
// Reference // Reference
// bill.setReference(QRBill.createCreditorReference("...")); // If using QRR // bill.setReference(QRBill.createCreditorReference("...")); // If using QRR
String orderRef = order.getOrderNumber() != null ? order.getOrderNumber() : order.getId().toString(); bill.setUnstructuredMessage("Order " + order.getId());
bill.setUnstructuredMessage("Order " + orderRef);
return bill; return bill;
} }

View File

@@ -76,11 +76,21 @@ public class QuoteCalculator {
// --- CALCULATIONS --- // --- CALCULATIONS ---
// Material Cost: (weight / 1000) * costPerKg // Material Cost: (weight / 1000) * costPerKg
BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP); // DISCOUNTED Support material to avoid penalizing users for default supports
BigDecimal weightToCharge;
if (stats.getModelWeightGrams() != null && stats.getSupportWeightGrams() != null) {
// Charge 100% for model + 20% for support
weightToCharge = BigDecimal.valueOf(stats.getModelWeightGrams())
.add(BigDecimal.valueOf(stats.getSupportWeightGrams()).multiply(BigDecimal.valueOf(0.2)));
} else {
weightToCharge = BigDecimal.valueOf(stats.getFilamentWeightGrams());
}
BigDecimal weightKg = weightToCharge.divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg()); BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg());
// Machine Cost: Tiered // Machine Cost: Tiered
BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP); BigDecimal totalHours = BigDecimal.valueOf(stats.getPrintTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
BigDecimal machineCost = calculateMachineCost(policy, totalHours); BigDecimal machineCost = calculateMachineCost(policy, totalHours);
// Energy Cost: (watts / 1000) * hours * costPerKwh // Energy Cost: (watts / 1000) * hours * costPerKwh

View File

@@ -10,13 +10,13 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.logging.Logger; import java.util.logging.Logger;
import java.util.stream.Stream;
@Service @Service
public class SlicerService { public class SlicerService {
@@ -41,27 +41,15 @@ public class SlicerService {
public PrintStats slice(File inputStl, String machineName, String filamentName, String processName, public PrintStats slice(File inputStl, String machineName, String filamentName, String processName,
Map<String, String> machineOverrides, Map<String, String> processOverrides) throws IOException { Map<String, String> machineOverrides, Map<String, String> processOverrides) throws IOException {
// 1. Prepare Profiles
ObjectNode machineProfile = profileManager.getMergedProfile(machineName, "machine"); ObjectNode machineProfile = profileManager.getMergedProfile(machineName, "machine");
ObjectNode filamentProfile = profileManager.getMergedProfile(filamentName, "filament"); ObjectNode filamentProfile = profileManager.getMergedProfile(filamentName, "filament");
ObjectNode processProfile = profileManager.getMergedProfile(processName, "process"); ObjectNode processProfile = profileManager.getMergedProfile(processName, "process");
logger.info("Slicer profiles: machine='" + machineName + "', filament='" + filamentName + "', process='" + processName + "'"); if (machineOverrides != null) machineOverrides.forEach(machineProfile::put);
logger.info("Machine limits: printable_area=" + machineProfile.path("printable_area") if (processOverrides != null) processOverrides.forEach(processProfile::put);
+ ", 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);
}
if (processOverrides != null) {
processOverrides.forEach(processProfile::put);
}
// 2. Create Temp Dir
Path tempDir = Files.createTempDirectory("slicer_job_"); Path tempDir = Files.createTempDirectory("slicer_job_");
try { try {
File mFile = tempDir.resolve("machine.json").toFile(); File mFile = tempDir.resolve("machine.json").toFile();
File fFile = tempDir.resolve("filament.json").toFile(); File fFile = tempDir.resolve("filament.json").toFile();
@@ -71,110 +59,61 @@ public class SlicerService {
mapper.writeValue(fFile, filamentProfile); mapper.writeValue(fFile, filamentProfile);
mapper.writeValue(pFile, processProfile); mapper.writeValue(pFile, processProfile);
String basename = inputStl.getName(); List<String> command = new ArrayList<>();
if (basename.toLowerCase().endsWith(".stl")) { command.add(slicerPath);
basename = basename.substring(0, basename.length() - 4);
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");
command.add("--arrange");
command.add("1");
command.add("--outputdir");
command.add(tempDir.toAbsolutePath().toString());
command.add("--slice");
command.add("0");
command.add(inputStl.getAbsolutePath());
logger.info("Executing Slicer: " + String.join(" ", command));
runSlicerCommand(command, tempDir);
try (Stream<Path> s = Files.list(tempDir)) {
Optional<Path> found = s.filter(p -> p.toString().endsWith(".gcode")).findFirst();
if (found.isPresent()) return gCodeParser.parse(found.get().toFile());
else throw new IOException("No GCode 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<String> 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);
}
throw new IOException("Slicer failed after retry");
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
throw new IOException("Interrupted during slicing", e); throw new IOException(e);
} finally {
deleteRecursively(tempDir);
} }
} }
private void deleteRecursively(Path path) { protected void runSlicerCommand(List<String> command, Path tempDir) throws IOException, InterruptedException {
if (path == null || !Files.exists(path)) { ProcessBuilder pb = new ProcessBuilder(command);
return; pb.directory(tempDir.toFile());
Map<String, String> env = pb.environment();
env.put("HOME", "/tmp");
env.put("QT_QPA_PLATFORM", "offscreen");
Process process = pb.start();
if (!process.waitFor(5, TimeUnit.MINUTES)) {
process.destroy();
throw new IOException("Slicer timeout");
} }
try (var walk = Files.walk(path)) { if (process.exitValue() != 0) {
walk.sorted(Comparator.reverseOrder()).forEach(p -> { String out = new String(process.getInputStream().readAllBytes());
try { String err = new String(process.getErrorStream().readAllBytes());
Files.deleteIfExists(p); throw new IOException("Slicer failed with exit code " + process.exitValue() + "\nERR: " + err + "\nOUT: " + out);
} 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");
}
} }

View File

@@ -0,0 +1,255 @@
package com.printcalculator.service;
import com.printcalculator.model.StlBounds;
import com.printcalculator.model.StlShiftResult;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
@Service
public class StlService {
public StlBounds readBounds(File stlFile) throws IOException {
long size = stlFile.length();
if (size >= 84 && isBinaryStl(stlFile, size)) {
return readBinaryBounds(stlFile);
}
return readAsciiBounds(stlFile);
}
public StlShiftResult shiftToFitIfNeeded(File stlFile, StlBounds bounds,
int bedX, int bedY, int bedZ) throws IOException {
double sizeX = bounds.sizeX();
double sizeY = bounds.sizeY();
double sizeZ = bounds.sizeZ();
double targetMinX = (bedX - sizeX) / 2.0;
double targetMinY = (bedY - sizeY) / 2.0;
double targetMinZ = 0.0;
double offsetX = targetMinX - bounds.minX();
double offsetY = targetMinY - bounds.minY();
double offsetZ = targetMinZ - bounds.minZ();
boolean needsShift = Math.abs(offsetX) > 1e-6 || Math.abs(offsetY) > 1e-6 || Math.abs(offsetZ) > 1e-6;
if (!needsShift) {
return new StlShiftResult(null, offsetX, offsetY, offsetZ, false);
}
Path shiftedPath = Files.createTempFile("stl_shifted_", ".stl");
writeShifted(stlFile, shiftedPath.toFile(), offsetX, offsetY, offsetZ);
return new StlShiftResult(shiftedPath, offsetX, offsetY, offsetZ, true);
}
private boolean isBinaryStl(File stlFile, long size) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(stlFile, "r")) {
raf.seek(80);
long triangleCount = readLEUInt32(raf);
long expected = 84L + triangleCount * 50L;
return expected == size;
}
}
private StlBounds readBinaryBounds(File stlFile) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(stlFile, "r")) {
raf.seek(80);
long triangleCount = readLEUInt32(raf);
raf.seek(84);
BoundsAccumulator acc = new BoundsAccumulator();
for (long i = 0; i < triangleCount; i++) {
// skip normal
readLEFloat(raf);
readLEFloat(raf);
readLEFloat(raf);
// 3 vertices
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
acc.accept(readLEFloat(raf), readLEFloat(raf), readLEFloat(raf));
// skip attribute byte count
raf.skipBytes(2);
}
return acc.toBounds();
}
}
private StlBounds readAsciiBounds(File stlFile) throws IOException {
BoundsAccumulator acc = new BoundsAccumulator();
try (BufferedReader reader = Files.newBufferedReader(stlFile.toPath(), StandardCharsets.US_ASCII)) {
String line;
while ((line = reader.readLine()) != null) {
line = line.trim();
if (!line.startsWith("vertex")) continue;
String[] parts = line.split("\\s+");
if (parts.length < 4) continue;
double x = Double.parseDouble(parts[1]);
double y = Double.parseDouble(parts[2]);
double z = Double.parseDouble(parts[3]);
acc.accept(x, y, z);
}
}
return acc.toBounds();
}
private void writeShifted(File input, File output, double offsetX, double offsetY, double offsetZ) throws IOException {
long size = input.length();
if (size >= 84 && isBinaryStl(input, size)) {
writeShiftedBinary(input, output, offsetX, offsetY, offsetZ);
} else {
writeShiftedAscii(input, output, offsetX, offsetY, offsetZ);
}
}
private void writeShiftedAscii(File input, File output, double offsetX, double offsetY, double offsetZ) throws IOException {
try (BufferedReader reader = Files.newBufferedReader(input.toPath(), StandardCharsets.US_ASCII);
BufferedWriter writer = Files.newBufferedWriter(output.toPath(), StandardCharsets.US_ASCII)) {
String line;
while ((line = reader.readLine()) != null) {
String trimmed = line.trim();
if (!trimmed.startsWith("vertex")) {
writer.write(line);
writer.newLine();
continue;
}
String[] parts = trimmed.split("\\s+");
if (parts.length < 4) {
writer.write(line);
writer.newLine();
continue;
}
double x = Double.parseDouble(parts[1]) + offsetX;
double y = Double.parseDouble(parts[2]) + offsetY;
double z = Double.parseDouble(parts[3]) + offsetZ;
int idx = line.indexOf("vertex");
String indent = idx > 0 ? line.substring(0, idx) : "";
writer.write(indent + String.format(Locale.US, "vertex %.6f %.6f %.6f", x, y, z));
writer.newLine();
}
}
}
private void writeShiftedBinary(File input, File output, double offsetX, double offsetY, double offsetZ) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(input, "r");
OutputStream out = new FileOutputStream(output)) {
byte[] header = new byte[80];
raf.readFully(header);
out.write(header);
long triangleCount = readLEUInt32(raf);
writeLEUInt32(out, triangleCount);
for (long i = 0; i < triangleCount; i++) {
// normal
writeLEFloat(out, readLEFloat(raf));
writeLEFloat(out, readLEFloat(raf));
writeLEFloat(out, readLEFloat(raf));
// vertices
writeLEFloat(out, (float) (readLEFloat(raf) + offsetX));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetY));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetZ));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetX));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetY));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetZ));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetX));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetY));
writeLEFloat(out, (float) (readLEFloat(raf) + offsetZ));
// attribute byte count
int b1 = raf.read();
int b2 = raf.read();
if ((b1 | b2) < 0) throw new IOException("Unexpected EOF while reading STL");
out.write(b1);
out.write(b2);
}
}
}
private long readLEUInt32(RandomAccessFile raf) throws IOException {
int b1 = raf.read();
int b2 = raf.read();
int b3 = raf.read();
int b4 = raf.read();
if ((b1 | b2 | b3 | b4) < 0) throw new IOException("Unexpected EOF while reading STL");
return ((long) b1 & 0xFF)
| (((long) b2 & 0xFF) << 8)
| (((long) b3 & 0xFF) << 16)
| (((long) b4 & 0xFF) << 24);
}
private int readLEInt(RandomAccessFile raf) throws IOException {
int b1 = raf.read();
int b2 = raf.read();
int b3 = raf.read();
int b4 = raf.read();
if ((b1 | b2 | b3 | b4) < 0) throw new IOException("Unexpected EOF while reading STL");
return (b1 & 0xFF)
| ((b2 & 0xFF) << 8)
| ((b3 & 0xFF) << 16)
| ((b4 & 0xFF) << 24);
}
private float readLEFloat(RandomAccessFile raf) throws IOException {
return Float.intBitsToFloat(readLEInt(raf));
}
private void writeLEUInt32(OutputStream out, long value) throws IOException {
out.write((int) (value & 0xFF));
out.write((int) ((value >> 8) & 0xFF));
out.write((int) ((value >> 16) & 0xFF));
out.write((int) ((value >> 24) & 0xFF));
}
private void writeLEFloat(OutputStream out, float value) throws IOException {
int bits = Float.floatToIntBits(value);
out.write(bits & 0xFF);
out.write((bits >> 8) & 0xFF);
out.write((bits >> 16) & 0xFF);
out.write((bits >> 24) & 0xFF);
}
private static class BoundsAccumulator {
private boolean hasPoint = false;
private double minX;
private double minY;
private double minZ;
private double maxX;
private double maxY;
private double maxZ;
void accept(double x, double y, double z) {
if (!hasPoint) {
minX = maxX = x;
minY = maxY = y;
minZ = maxZ = z;
hasPoint = true;
return;
}
if (x < minX) minX = x;
if (y < minY) minY = y;
if (z < minZ) minZ = z;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
if (z > maxZ) maxZ = z;
}
StlBounds toBounds() throws IOException {
if (!hasPoint) {
throw new IOException("STL appears to contain no vertices");
}
return new StlBounds(minX, minY, minZ, maxX, maxY, maxZ);
}
}
}

View File

@@ -1,66 +0,0 @@
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() {
return twintPaymentUrl;
}
public byte[] generateQrPng(int sizePx) {
try {
// Use High Error Correction for financial QR codes
QrCode qrCode = QrCode.encodeText(twintPaymentUrl, 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);
}
}
}

View File

@@ -1,17 +0,0 @@
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<String, Object> contextData);
}

View File

@@ -1,54 +0,0 @@
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 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;
@Override
public void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData) {
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
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);
}
}
}

View File

@@ -24,19 +24,3 @@ clamav.host=${CLAMAV_HOST:clamav}
clamav.port=${CLAMAV_PORT:3310} clamav.port=${CLAMAV_PORT:3310}
clamav.enabled=${CLAMAV_ENABLED:false} 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:ht*44k+Tq39R+R-O}
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.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}

View File

@@ -1,93 +0,0 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Conferma Ordine</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 20px auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
border-bottom: 1px solid #eeeeee;
padding-bottom: 20px;
margin-bottom: 20px;
}
.header h1 {
color: #333333;
}
.content {
color: #555555;
line-height: 1.6;
}
.order-details {
background-color: #f9f9f9;
padding: 15px;
border-radius: 5px;
margin-top: 20px;
}
.order-details th {
text-align: left;
padding-right: 20px;
color: #333333;
}
.footer {
text-align: center;
font-size: 0.9em;
color: #999999;
margin-top: 30px;
border-top: 1px solid #eeeeee;
padding-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Grazie per il tuo ordine #<span th:text="${orderNumber}">00000000</span></h1>
</div>
<div class="content">
<p>Ciao <span th:text="${customerName}">Cliente</span>,</p>
<p>Abbiamo ricevuto il tuo ordine e stiamo iniziando a elaborarlo. Ecco un riepilogo dei dettagli:</p>
<div class="order-details">
<table>
<tr>
<th>Numero Ordine:</th>
<td th:text="${orderNumber}">00000000</td>
</tr>
<tr>
<th>Data:</th>
<td th:text="${orderDate}">01/01/2026</td>
</tr>
<tr>
<th>Costo totale:</th>
<td th:text="${totalCost} + ' CHF'">0.00 CHF</td>
</tr>
</table>
</div>
<p>
Clicca qui per i dettagli:
<a th:href="${orderDetailsUrl}" th:text="${orderDetailsUrl}">https://tuosito.it/ordine/00000000-0000-0000-0000-000000000000</a>
</p>
<p>Se hai domande o dubbi, non esitare a contattarci.</p>
</div>
<div class="footer">
<p>&copy; 2026 3D-Fab. Tutti i diritti riservati.</p>
</div>
</div>
</body>
</html>

View File

@@ -3,246 +3,81 @@
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<style> <style>
@page invoice { size: 8.5in 11in; margin: 0.65in; } @page { size: A4; margin: 18mm 15mm; }
@page qrpage { size: A4; margin: 0; } body { font-family: sans-serif; font-size: 10.5pt; }
.header { display: flex; justify-content: space-between; }
body { .addresses { margin-top: 10mm; display: flex; justify-content: space-between; }
page: invoice; table { width: 100%; border-collapse: collapse; margin-top: 8mm; }
font-family: Helvetica, Arial, sans-serif; th, td { padding: 6px; border-bottom: 1px solid #ccc; }
font-size: 10pt; th { text-align: left; }
margin: 0; .totals { margin-top: 6mm; width: 40%; margin-left: auto; }
padding: 0; .totals td { border: none; }
background: #fff; .page-break { page-break-before: always; }
color: #1a1a1a;
}
.invoice-page {
page: invoice;
width: 100%;
}
.header-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.header-table td {
vertical-align: top;
padding: 0;
}
.header-left {
width: 58%;
line-height: 1.35;
}
.header-right {
width: 42%;
text-align: right;
line-height: 1.35;
}
.invoice-title {
font-size: 15pt;
font-weight: 700;
margin: 0 0 4mm 0;
letter-spacing: 0.3px;
}
.section-title {
margin: 9mm 0 2mm 0;
font-size: 10.5pt;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.2px;
}
.buyer-box {
margin: 0;
line-height: 1.4;
min-height: 20mm;
}
.line-items {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-top: 8mm;
}
.line-items th,
.line-items td {
border-bottom: 1px solid #d8d8d8;
padding: 2.8mm 2.2mm;
vertical-align: top;
word-break: break-word;
}
.line-items th {
text-align: left;
font-weight: 700;
background: #f7f7f7;
}
.line-items th:nth-child(1),
.line-items td:nth-child(1) {
width: 54%;
}
.line-items th:nth-child(2),
.line-items td:nth-child(2) {
width: 12%;
text-align: right;
white-space: nowrap;
}
.line-items th:nth-child(3),
.line-items td:nth-child(3) {
width: 17%;
text-align: right;
white-space: nowrap;
}
.line-items th:nth-child(4),
.line-items td:nth-child(4) {
width: 17%;
text-align: right;
white-space: nowrap;
font-weight: 600;
}
.totals {
margin-top: 7mm;
margin-left: auto;
width: 76mm;
border-collapse: collapse;
}
.totals td {
border: none;
padding: 1.6mm 0;
}
.totals-label {
text-align: left;
color: #3a3a3a;
}
.totals-value {
text-align: right;
white-space: nowrap;
font-weight: 600;
}
.total-strong td {
font-size: 11pt;
font-weight: 700;
padding-top: 2.4mm;
border-top: 1px solid #d8d8d8;
}
.payment-terms {
margin-top: 9mm;
line-height: 1.4;
color: #2b2b2b;
}
.qr-only-page {
page: qrpage;
position: relative;
width: 210mm;
height: 297mm;
background: #fff;
page-break-before: always;
break-before: page;
}
.qr-bill-bottom {
position: absolute;
left: 0;
bottom: 0;
width: 210mm;
height: 105mm;
overflow: hidden;
background: #fff;
}
.qr-bill-bottom svg {
width: 210mm !important;
height: 105mm !important;
display: block;
}
</style> </style>
</head> </head>
<body> <body>
<div class="invoice-page">
<table class="header-table"> <div class="header">
<tr>
<td class="header-left">
<div><strong th:text="${sellerDisplayName}">Nome Cognome</strong></div>
<div th:text="${sellerAddressLine1}">Via Esempio 12</div>
<div th:text="${sellerAddressLine2}">6500 Bellinzona, CH</div>
<div th:text="${sellerEmail}">email@example.com</div>
</td>
<td class="header-right">
<div class="invoice-title">Fattura</div>
<div>Numero: <span th:text="${invoiceNumber}">2026-000123</span></div>
<div>Data: <span th:text="${invoiceDate}">2026-02-13</span></div>
<div>Scadenza: <span th:text="${dueDate}">2026-02-20</span></div>
</td>
</tr>
</table>
<div class="section-title">Fatturare a</div>
<div class="buyer-box">
<div> <div>
<div><strong th:text="${sellerDisplayName}">Nome Cognome</strong></div>
<div th:text="${sellerAddressLine1}">Via Esempio 12</div>
<div th:text="${sellerAddressLine2}">6500 Bellinzona, CH</div>
<div th:text="${sellerEmail}">email@example.com</div>
</div>
<div>
<div><strong>Fattura</strong></div>
<div>Numero: <span th:text="${invoiceNumber}">2026-000123</span></div>
<div>Data: <span th:text="${invoiceDate}">2026-02-13</span></div>
<div>Scadenza: <span th:text="${dueDate}">2026-02-20</span></div>
</div>
</div>
<div class="addresses">
<div>
<div><strong>Fatturare a</strong></div>
<div th:text="${buyerDisplayName}">Cliente SA</div> <div th:text="${buyerDisplayName}">Cliente SA</div>
<div th:text="${buyerAddressLine1}">Via Cliente 7</div> <div th:text="${buyerAddressLine1}">Via Cliente 7</div>
<div th:text="${buyerAddressLine2}">8000 Zürich, CH</div> <div th:text="${buyerAddressLine2}">8000 Zürich, CH</div>
</div> </div>
</div> </div>
<table class="line-items"> <table>
<thead> <thead>
<tr> <tr>
<th>Descrizione</th> <th>Descrizione</th>
<th>Qtà</th> <th style="text-align:right;">Qtà</th>
<th>Prezzo</th> <th style="text-align:right;">Prezzo</th>
<th>Totale</th> <th style="text-align:right;">Totale</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr th:each="lineItem : ${invoiceLineItems}"> <tr th:each="lineItem : ${invoiceLineItems}">
<td th:text="${lineItem.description}">Stampa 3D pezzo X</td> <td th:text="${lineItem.description}">Stampa 3D pezzo X</td>
<td th:text="${lineItem.quantity}">1</td> <td style="text-align:right;" th:text="${lineItem.quantity}">1</td>
<td th:text="${lineItem.unitPriceFormatted}">CHF 10.00</td> <td style="text-align:right;" th:text="${lineItem.unitPriceFormatted}">CHF 10.00</td>
<td th:text="${lineItem.lineTotalFormatted}">CHF 10.00</td> <td style="text-align:right;" th:text="${lineItem.lineTotalFormatted}">CHF 10.00</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<table class="totals"> <table class="totals">
<tr> <tr>
<td class="totals-label">Subtotale</td> <td>Subtotale</td>
<td class="totals-value" th:text="${subtotalFormatted}">CHF 10.00</td> <td style="text-align:right;" th:text="${subtotalFormatted}">CHF 10.00</td>
</tr> </tr>
<tr class="total-strong"> <tr>
<td class="totals-label">Totale</td> <td><strong>Totale</strong></td>
<td class="totals-value" th:text="${grandTotalFormatted}">CHF 10.00</td> <td style="text-align:right;"><strong th:text="${grandTotalFormatted}">CHF 10.00</strong></td>
</tr> </tr>
</table> </table>
<div class="payment-terms" th:text="${paymentTermsText}"> <div style="margin-top:6mm;" th:text="${paymentTermsText}">
Pagamento entro 7 giorni. Grazie. Pagamento entro 7 giorni. Grazie.
</div> </div>
</div> <div style="page-break-before: always;"></div>
<div style="position: absolute; bottom: 0; left: 0; width: 210mm; height: 105mm;" th:utext="${qrBillSvg}">
<div class="qr-only-page">
<div class="qr-bill-bottom" th:utext="${qrBillSvg}">
</div>
</div> </div>
</body> </body>

View File

@@ -0,0 +1,151 @@
package com.printcalculator;
import com.printcalculator.controller.QuoteSessionController;
import com.printcalculator.dto.PrintSettingsDto;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.repository.QuoteSessionRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.service.SlicerService;
import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.StorageService;
import com.printcalculator.service.StlService;
import com.printcalculator.service.ProfileManager;
import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.model.StlBounds;
import com.printcalculator.model.StlShiftResult;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import java.io.File;
import java.math.BigDecimal;
import java.nio.file.Path;
import java.util.Optional;
import java.util.Map;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
import static org.junit.jupiter.api.Assertions.*;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
@WebMvcTest(QuoteSessionController.class)
public class ManualSessionPersistenceTest {
@Autowired
private QuoteSessionController controller;
@MockitoBean
private QuoteSessionRepository sessionRepo;
@MockitoBean
private QuoteLineItemRepository lineItemRepo; // Mock this too
@MockitoBean
private SlicerService slicerService;
@MockitoBean
private StorageService storageService;
@MockitoBean
private StlService stlService;
@MockitoBean
private ProfileManager profileManager;
@MockitoBean
private QuoteCalculator quoteCalculator;
@MockitoBean
private PrinterMachineRepository machineRepo;
@MockitoBean
private com.printcalculator.repository.PricingPolicyRepository pricingRepo; // Add this if needed by controller
@Test
public void testSettingsPersistence() throws Exception {
// Prepare
UUID sessionId = UUID.randomUUID();
QuoteSession session = new QuoteSession();
session.setId(sessionId);
session.setMaterialCode("pla_basic"); // Initial state
when(sessionRepo.findById(sessionId)).thenReturn(Optional.of(session));
when(sessionRepo.save(any(QuoteSession.class))).thenAnswer(i -> i.getArguments()[0]);
when(lineItemRepo.save(any(QuoteLineItem.class))).thenAnswer(i -> i.getArguments()[0]);
// 2. Add Item with Custom Settings
PrintSettingsDto settings = new PrintSettingsDto();
settings.setComplexityMode("ADVANCED");
settings.setMaterial("petg_basic");
settings.setLayerHeight(0.12);
settings.setInfillDensity(50.0);
settings.setInfillPattern("gyroid");
settings.setSupportsEnabled(true);
settings.setNozzleDiameter(0.6);
settings.setNotes("Test Notes");
MockMultipartFile file = new MockMultipartFile("file", "test.stl", "application/octet-stream", "dummy content".getBytes());
// Mock dependencies
when(machineRepo.findFirstByIsActiveTrue()).thenReturn(Optional.of(new PrinterMachine(){{
setPrinterDisplayName("TestPrinter");
setSlicerMachineProfile("TestProfile");
setBuildVolumeXMm(256);
setBuildVolumeYMm(256);
setBuildVolumeZMm(256);
}}));
when(slicerService.slice(any(), any(), any(), any(), any(), any())).thenReturn(new PrintStats(100, "1m", 10.0, 100));
when(quoteCalculator.calculate(any(), any(), any())).thenReturn(
new QuoteResult(10.0, "CHF", new PrintStats(100, "1m", 10.0, 100), 0.0)
);
when(stlService.readBounds(any())).thenReturn(new StlBounds(0, 0, 0, 10, 10, 10));
when(stlService.shiftToFitIfNeeded(any(), any(), anyInt(), anyInt(), anyInt()))
.thenReturn(new StlShiftResult(null, 0, 0, 0, false));
when(profileManager.resolveMachineProfileName(any(), any())).thenAnswer(i -> i.getArguments()[0]);
when(storageService.loadAsResource(any())).thenReturn(new org.springframework.core.io.ByteArrayResource("dummy".getBytes()){
@Override
public File getFile() { return new File("dummy"); }
});
controller.addItemToExistingSession(sessionId, settings, file);
// 3. Verify Session Updated via Save Call capture
ArgumentCaptor<QuoteSession> captor = ArgumentCaptor.forClass(QuoteSession.class);
verify(sessionRepo).save(captor.capture());
QuoteSession updatedSession = captor.getValue();
assertEquals("petg_basic", updatedSession.getMaterialCode());
assertEquals(0, BigDecimal.valueOf(0.12).compareTo(updatedSession.getLayerHeightMm()));
assertEquals(50, updatedSession.getInfillPercent());
assertEquals("gyroid", updatedSession.getInfillPattern());
assertTrue(updatedSession.getSupportsEnabled());
assertEquals(0, BigDecimal.valueOf(0.6).compareTo(updatedSession.getNozzleDiameterMm()));
assertEquals("Test Notes", updatedSession.getNotes());
System.out.println("Verification Passed: Settings were persisted to Session.");
}
@org.springframework.boot.test.context.TestConfiguration
static class TestConfig {
@org.springframework.context.annotation.Bean
public org.springframework.transaction.PlatformTransactionManager transactionManager() {
return org.mockito.Mockito.mock(org.springframework.transaction.PlatformTransactionManager.class);
}
}
}

View File

@@ -0,0 +1,23 @@
package com.printcalculator.config;
import com.printcalculator.service.ClamAVService;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import java.io.InputStream;
@TestConfiguration
public class TestConfig {
@Bean
@Primary
public ClamAVService mockClamAVService() {
return new ClamAVService("localhost", 3310, true) {
@Override
public boolean scan(InputStream inputStream) {
return true; // Always clean for tests
}
};
}
}

View File

@@ -0,0 +1,176 @@
package com.printcalculator.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.printcalculator.dto.CreateOrderRequest;
import com.printcalculator.dto.CustomerDto;
import com.printcalculator.dto.AddressDto;
import com.printcalculator.entity.QuoteLineItem;
import com.printcalculator.entity.QuoteSession;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.QuoteLineItemRepository;
import com.printcalculator.repository.QuoteSessionRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.util.FileSystemUtils;
import java.io.File;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.OffsetDateTime;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import com.printcalculator.service.ClamAVService;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@SpringBootTest
@AutoConfigureMockMvc
@org.springframework.test.context.TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL",
"spring.datasource.driverClassName=org.h2.Driver",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
"spring.jpa.hibernate.ddl-auto=create-drop"
})
class OrderIntegrationTest {
@MockitoBean
private ClamAVService clamAVService;
@Autowired
private MockMvc mockMvc;
@Autowired
private QuoteSessionRepository sessionRepository;
@Autowired
private QuoteLineItemRepository lineItemRepository;
@Autowired
private OrderRepository orderRepository;
@Autowired
private ObjectMapper objectMapper;
private UUID sessionId;
private UUID lineItemId;
private final String TEST_FILENAME = "test_model.stl";
@BeforeEach
void setup() throws Exception {
// Mock ClamAV to always return true (safe)
when(clamAVService.scan(any())).thenReturn(true);
// 1. Create Quote Session
QuoteSession session = new QuoteSession();
session.setStatus("ACTIVE");
session.setMaterialCode("PLA");
session.setPricingVersion("v1");
session.setCreatedAt(OffsetDateTime.now());
session.setExpiresAt(OffsetDateTime.now().plusDays(7));
session.setSetupCostChf(BigDecimal.valueOf(5.00));
session.setSupportsEnabled(false);
session = sessionRepository.save(session);
this.sessionId = session.getId();
// 2. Create Dummy File on Disk (storage_quotes)
Path sessionDir = Paths.get("storage_quotes", sessionId.toString());
Files.createDirectories(sessionDir);
Path filePath = sessionDir.resolve(UUID.randomUUID() + ".stl");
Files.writeString(filePath, "dummy content");
// 3. Create Quote Line Item
QuoteLineItem item = new QuoteLineItem();
item.setQuoteSession(session);
item.setStatus("READY");
item.setOriginalFilename(TEST_FILENAME);
item.setStoredPath(filePath.toString());
item.setQuantity(2);
item.setPrintTimeSeconds(120);
item.setMaterialGrams(BigDecimal.valueOf(10.5));
item.setUnitPriceChf(BigDecimal.valueOf(10.00));
item.setCreatedAt(OffsetDateTime.now());
item.setUpdatedAt(OffsetDateTime.now());
item = lineItemRepository.save(item);
this.lineItemId = item.getId();
}
@AfterEach
void cleanup() throws Exception {
// Cleanup generated files
FileSystemUtils.deleteRecursively(Paths.get("storage_quotes"));
FileSystemUtils.deleteRecursively(Paths.get("storage_orders"));
// Clean DB
orderRepository.deleteAll();
lineItemRepository.deleteAll();
sessionRepository.deleteAll();
}
@Test
void testCreateOrderFromQuote_ShouldCopyFilesAndUpdateStatus() throws Exception {
// Prepare Request
CreateOrderRequest request = new CreateOrderRequest();
CustomerDto customer = new CustomerDto();
customer.setEmail("integration@test.com");
customer.setCustomerType("PRIVATE");
request.setCustomer(customer);
AddressDto billing = new AddressDto();
billing.setFirstName("John");
billing.setLastName("Doe");
billing.setAddressLine1("Street 1");
billing.setCity("City");
billing.setZip("1000");
billing.setCountryCode("CH");
request.setBillingAddress(billing);
request.setShippingSameAsBilling(true);
// Execute Request
mockMvc.perform(post("/api/orders/from-quote/" + sessionId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk());
// Verify Session Status
QuoteSession updatedSession = sessionRepository.findById(sessionId).orElseThrow();
assertEquals("CONVERTED", updatedSession.getStatus(), "Session status should be CONVERTED");
assertNotNull(updatedSession.getConvertedOrderId(), "Converted Order ID should be set");
UUID orderId = updatedSession.getConvertedOrderId();
// Verify File Copy
Path orderStorageDir = Paths.get("storage_orders");
// We need to find the specific file. Structure: storage_orders/orderId/3d-files/orderItemId/filename
// Since we don't know OrderItemId easily without querying DB, let's walk the dir.
try (var stream = Files.walk(orderStorageDir)) {
boolean fileFound = stream
.filter(Files::isRegularFile)
.anyMatch(path -> {
try {
return Files.readString(path).equals("dummy content");
} catch (Exception e) {
return false;
}
});
assertTrue(fileFound, "The file should have been copied to storage_orders with correct content");
}
}
}

View File

@@ -27,10 +27,10 @@ class GCodeParserTest {
PrintStats stats = parser.parse(tempFile); PrintStats stats = parser.parse(tempFile);
// Assert // Assert
assertEquals(3723, stats.printTimeSeconds()); // 3600 + 120 + 3 assertEquals(3723L, stats.getPrintTimeSeconds()); // 3600 + 120 + 3
assertEquals("1h 2m 3s", stats.printTimeFormatted()); assertEquals("1h 2m 3s", stats.getPrintTimeFormatted());
assertEquals(10.5, stats.filamentWeightGrams(), 0.001); assertEquals(10.5, stats.getFilamentWeightGrams(), 0.001);
assertEquals(3000.0, stats.filamentLengthMm(), 0.001); assertEquals(3000.0, stats.getFilamentLengthMm(), 0.001);
tempFile.delete(); tempFile.delete();
} }
@@ -49,8 +49,8 @@ class GCodeParserTest {
GCodeParser parser = new GCodeParser(); GCodeParser parser = new GCodeParser();
PrintStats stats = parser.parse(tempFile); PrintStats stats = parser.parse(tempFile);
assertEquals(750, stats.printTimeSeconds()); // 12*60 + 30 assertEquals(750L, stats.getPrintTimeSeconds()); // 12*60 + 30
assertEquals(5.0, stats.filamentWeightGrams(), 0.001); assertEquals(5.0, stats.getFilamentWeightGrams(), 0.001);
tempFile.delete(); tempFile.delete();
} }
@@ -69,8 +69,8 @@ class GCodeParserTest {
GCodeParser parser = new GCodeParser(); GCodeParser parser = new GCodeParser();
PrintStats stats = parser.parse(tempFile); PrintStats stats = parser.parse(tempFile);
assertEquals(3723L, stats.printTimeSeconds()); assertEquals(3723L, stats.getPrintTimeSeconds());
assertEquals("1h 2m 3s", stats.printTimeFormatted()); assertEquals("1h 2m 3s", stats.getPrintTimeFormatted());
tempFile.delete(); tempFile.delete();
} }
@@ -87,8 +87,8 @@ class GCodeParserTest {
GCodeParser parser = new GCodeParser(); GCodeParser parser = new GCodeParser();
PrintStats stats = parser.parse(tempFile); PrintStats stats = parser.parse(tempFile);
assertEquals(3723L, stats.printTimeSeconds()); assertEquals(3723L, stats.getPrintTimeSeconds());
assertEquals("01:02:03", stats.printTimeFormatted()); assertEquals("01:02:03", stats.getPrintTimeFormatted());
tempFile.delete(); tempFile.delete();
} }
@@ -105,8 +105,8 @@ class GCodeParserTest {
GCodeParser parser = new GCodeParser(); GCodeParser parser = new GCodeParser();
PrintStats stats = parser.parse(tempFile); PrintStats stats = parser.parse(tempFile);
assertEquals(321L, stats.printTimeSeconds()); assertEquals(321L, stats.getPrintTimeSeconds());
assertEquals("5m 21s", stats.printTimeFormatted()); assertEquals("5m 21s", stats.getPrintTimeFormatted());
tempFile.delete(); tempFile.delete();
} }

View File

@@ -0,0 +1,123 @@
package com.printcalculator.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.printcalculator.model.PrintStats;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.any;
class SlicerServiceTest {
@Mock
private ProfileManager profileManager;
@Mock
private GCodeParser gCodeParser;
private ObjectMapper mapper = new ObjectMapper();
private SlicerService slicerService;
@TempDir
Path tempDir;
// Captured execution details
private List<String> lastCommand;
private Path lastTempDir;
@BeforeEach
void setUp() throws IOException {
MockitoAnnotations.openMocks(this);
// Subclass to override runSlicerCommand
slicerService = new SlicerService("orca-slicer", profileManager, gCodeParser, mapper) {
@Override
protected void runSlicerCommand(List<String> command, Path tempDir) throws IOException, InterruptedException {
lastCommand = command;
lastTempDir = tempDir;
// Don't run actual process.
// Simulate GCode output creation for the parser to find?
// Or just let it fail at parser step since we only care about JSON generation here?
// For a full test, we should create a dummy GCode file.
File stl = new File(command.get(command.size() - 1));
String basename = stl.getName().replace(".stl", "");
Files.createFile(tempDir.resolve(basename + ".gcode"));
}
};
// Mock Profile Responses
ObjectNode emptyNode = mapper.createObjectNode();
when(profileManager.getMergedProfile(anyString(), eq("machine"))).thenReturn(emptyNode.deepCopy());
when(profileManager.getMergedProfile(anyString(), eq("filament"))).thenReturn(emptyNode.deepCopy());
when(profileManager.getMergedProfile(anyString(), eq("process"))).thenReturn(emptyNode.deepCopy());
// Mock Parser
when(gCodeParser.parse(any(File.class))).thenReturn(new PrintStats(100, "1m 40s", 10.5, 1000));
}
@Test
void testSlice_WithDefaults_ShouldGenerateConfig() throws IOException {
File dummyStl = tempDir.resolve("test.stl").toFile();
Files.createFile(dummyStl.toPath());
slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, null);
assertNotNull(lastTempDir);
assertTrue(Files.exists(lastTempDir.resolve("process.json")));
assertTrue(Files.exists(lastTempDir.resolve("machine.json")));
assertTrue(Files.exists(lastTempDir.resolve("filament.json")));
}
@Test
void testSlice_WithLayerHeightOverride_ShouldUpdateProcessJson() throws IOException {
File dummyStl = tempDir.resolve("test.stl").toFile();
Files.createFile(dummyStl.toPath());
Map<String, String> processOverrides = new HashMap<>();
processOverrides.put("layer_height", "0.12");
slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, processOverrides);
File processJsonFile = lastTempDir.resolve("process.json").toFile();
ObjectNode processJson = (ObjectNode) mapper.readTree(processJsonFile);
assertTrue(processJson.has("layer_height"));
assertEquals("0.12", processJson.get("layer_height").asText());
}
@Test
void testSlice_WithInfillAndSupportOverrides_ShouldUpdateProcessJson() throws IOException {
File dummyStl = tempDir.resolve("test.stl").toFile();
Files.createFile(dummyStl.toPath());
Map<String, String> processOverrides = new HashMap<>();
processOverrides.put("sparse_infill_density", "25%");
processOverrides.put("enable_support", "1");
slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, processOverrides);
File processJsonFile = lastTempDir.resolve("process.json").toFile();
ObjectNode processJson = (ObjectNode) mapper.readTree(processJsonFile);
assertEquals("25%", processJson.get("sparse_infill_density").asText());
assertEquals("1", processJson.get("enable_support").asText());
}
}

1
db.sql
View File

@@ -12,6 +12,7 @@ create table printer_machine
fleet_weight numeric(6, 3) not null default 1.000, fleet_weight numeric(6, 3) not null default 1.000,
is_active boolean not null default true, is_active boolean not null default true,
slicer_machine_profile varchar(255),
created_at timestamptz not null default now() created_at timestamptz not null default now()
); );

View File

@@ -7,7 +7,4 @@ TAG=dev
BACKEND_PORT=18002 BACKEND_PORT=18002
FRONTEND_PORT=18082 FRONTEND_PORT=18082
CLAMAV_HOST=192.168.1.147
CLAMAV_PORT=3310
CLAMAV_ENABLED=true

View File

@@ -7,7 +7,4 @@ TAG=int
BACKEND_PORT=18001 BACKEND_PORT=18001
FRONTEND_PORT=18081 FRONTEND_PORT=18081
CLAMAV_HOST=192.168.1.147
CLAMAV_PORT=3310
CLAMAV_ENABLED=true

View File

@@ -7,7 +7,4 @@ TAG=prod
BACKEND_PORT=8000 BACKEND_PORT=8000
FRONTEND_PORT=80 FRONTEND_PORT=80
CLAMAV_HOST=192.168.1.147
CLAMAV_PORT=3310
CLAMAV_ENABLED=true

View File

@@ -1,3 +1,5 @@
version: '3.8'
services: services:
backend: backend:
# L'immagine usa il tag specificato nel file .env o passato da riga di comando # L'immagine usa il tag specificato nel file .env o passato da riga di comando
@@ -5,39 +7,26 @@ services:
container_name: print-calculator-backend-${ENV} container_name: print-calculator-backend-${ENV}
ports: ports:
- "${BACKEND_PORT}:8000" - "${BACKEND_PORT}:8000"
env_file:
- .env
environment: environment:
- SPRING_PROFILES_ACTIVE=${ENV}
- DB_URL=${DB_URL} - DB_URL=${DB_URL}
- DB_USERNAME=${DB_USERNAME} - DB_USERNAME=${DB_USERNAME}
- DB_PASSWORD=${DB_PASSWORD} - 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}
- TEMP_DIR=/app/temp - TEMP_DIR=/app/temp
- PROFILES_DIR=/app/profiles - PROFILES_DIR=/app/profiles
- CLAMAV_HOST=host.docker.internal
- CLAMAV_PORT=3310
- STORAGE_LOCATION=/app/storage
restart: always restart: always
logging: volumes:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes:
- backend_profiles_${ENV}:/app/profiles - backend_profiles_${ENV}:/app/profiles
- /mnt/cache/appdata/print-calculator/${ENV}/storage_quotes:/app/storage_quotes - /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_orders:/app/storage/orders
- /mnt/cache/appdata/print-calculator/${ENV}/storage_requests:/app/storage_requests
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
frontend: frontend:
image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-frontend:${TAG} image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-frontend:${TAG}
container_name: print-calculator-frontend-${ENV} container_name: print-calculator-frontend-${ENV}
@@ -46,11 +35,6 @@ services:
depends_on: depends_on:
- backend - backend
restart: always restart: always
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes: volumes:
backend_profiles_prod: backend_profiles_prod:

View File

@@ -13,20 +13,24 @@ services:
- DB_USERNAME=printcalc - DB_USERNAME=printcalc
- DB_PASSWORD=printcalc_secret - DB_PASSWORD=printcalc_secret
- SPRING_PROFILES_ACTIVE=local - 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 - TEMP_DIR=/app/temp
- PROFILES_DIR=/app/profiles - PROFILES_DIR=/app/profiles
- CLAMAV_HOST=clamav - CLAMAV_HOST=clamav
- CLAMAV_PORT=3310 - CLAMAV_PORT=3310
- STORAGE_LOCATION=/app/storage
depends_on: depends_on:
- db - db
- clamav - clamav
restart: unless-stopped restart: unless-stopped
clamav:
platform: linux/amd64
image: clamav/clamav:latest
container_name: print-calculator-clamav
ports:
- "3310:3310"
restart: unless-stopped
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
@@ -52,16 +56,5 @@ services:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
restart: unless-stopped 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: volumes:
postgres_data: postgres_data:
clamav_db:

File diff suppressed because it is too large Load Diff

View File

@@ -33,14 +33,6 @@ export const routes: Routes = [
path: 'payment/:orderId', path: 'payment/:orderId',
loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent) loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent)
}, },
{
path: 'ordine/:orderId',
loadComponent: () => import('./features/payment/payment.component').then(m => m.PaymentComponent)
},
{
path: 'order-confirmed/:orderId',
loadComponent: () => import('./features/order-confirmed/order-confirmed.component').then(m => m.OrderConfirmedComponent)
},
{ {
path: '', path: '',
loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES) loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES)

View File

@@ -2,8 +2,10 @@
<h1>{{ 'CALC.TITLE' | translate }}</h1> <h1>{{ 'CALC.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p> <p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p>
@if (error()) { @if (error() === 'VIRUS_DETECTED') {
<app-alert type="error">{{ 'CALC.ERROR_GENERIC' | translate }}</app-alert> <app-alert type="error">{{ 'CALC.ERROR_VIRUS_DETECTED' | translate }}</app-alert>
} @else if (error()) {
<app-alert type="error">{{ 'CALC.ERROR_' + error() | translate }}</app-alert>
} }
</div> </div>
@@ -19,12 +21,12 @@
<div class="mode-selector"> <div class="mode-selector">
<div class="mode-option" <div class="mode-option"
[class.active]="mode() === 'easy'" [class.active]="mode() === 'easy'"
(click)="mode.set('easy')"> (click)="setMode('easy')">
{{ 'CALC.MODE_EASY' | translate }} {{ 'CALC.MODE_EASY' | translate }}
</div> </div>
<div class="mode-option" <div class="mode-option"
[class.active]="mode() === 'advanced'" [class.active]="mode() === 'advanced'"
(click)="mode.set('advanced')"> (click)="setMode('advanced')">
{{ 'CALC.MODE_ADVANCED' | translate }} {{ 'CALC.MODE_ADVANCED' | translate }}
</div> </div>
</div> </div>
@@ -35,6 +37,7 @@
[loading]="loading()" [loading]="loading()"
[uploadProgress]="uploadProgress()" [uploadProgress]="uploadProgress()"
(submitRequest)="onCalculate($event)" (submitRequest)="onCalculate($event)"
(itemRemoved)="onItemRemoved($event)"
></app-upload-form> ></app-upload-form>
</app-card> </app-card>
</div> </div>
@@ -42,15 +45,25 @@
<!-- Right Column: Result or Info --> <!-- Right Column: Result or Info -->
<div class="col-result" #resultCol> <div class="col-result" #resultCol>
@if (loading()) { @if (loading() && !result()) {
<!-- Initial Loading State (before first result) -->
<app-card class="loading-state"> <app-card class="loading-state">
<div class="loader-content"> <div class="loader-content">
<div class="spinner"></div> <div class="spinner"></div>
<h3 class="loading-title">{{ 'CALC.ANALYZING_TITLE' | translate }}</h3> <h3 class="loading-title">Analisi in corso...</h3>
<p class="loading-text">{{ 'CALC.ANALYZING_TEXT' | translate }}</p> <p class="loading-text">Stiamo analizzando la geometria e calcolando il percorso utensile.</p>
</div> </div>
</app-card> </app-card>
} @else if (result()) { } @else if (result()) {
<!-- Result State (Active or Finished) -->
@if (loading()) {
<!-- Small loader indicator when refining results -->
<div class="analyzing-bar">
<div class="spinner-small"></div>
<span>Analisi in corso... ({{ uploadProgress() }}%)</span>
</div>
}
<app-quote-result <app-quote-result
[result]="result()!" [result]="result()!"
(consult)="onConsult()" (consult)="onConsult()"

View File

@@ -26,7 +26,7 @@ export class CalculatorPageComponent implements OnInit {
loading = signal(false); loading = signal(false);
uploadProgress = signal(0); uploadProgress = signal(0);
result = signal<QuoteResult | null>(null); result = signal<QuoteResult | null>(null);
error = signal<boolean>(false); error = signal<string | null>(null);
orderSuccess = signal(false); orderSuccess = signal(false);
@@ -48,7 +48,7 @@ export class CalculatorPageComponent implements OnInit {
this.route.queryParams.subscribe(params => { this.route.queryParams.subscribe(params => {
const sessionId = params['session']; const sessionId = params['session'];
if (sessionId) { if (sessionId && sessionId !== this.result()?.sessionId) {
this.loadSession(sessionId); this.loadSession(sessionId);
} }
}); });
@@ -75,7 +75,7 @@ export class CalculatorPageComponent implements OnInit {
}, },
error: (err) => { error: (err) => {
console.error('Failed to load session', err); console.error('Failed to load session', err);
this.error.set(true); this.error.set('Failed to load session');
this.loading.set(false); this.loading.set(false);
} }
}); });
@@ -106,14 +106,14 @@ export class CalculatorPageComponent implements OnInit {
forkJoin(downloads).subscribe({ forkJoin(downloads).subscribe({
next: (results: any[]) => { next: (results: any[]) => {
const files = results.map(res => new File([res.blob], res.fileName, { type: 'application/octet-stream' })); const files = results.map(res => new File([res.blob], res.fileName, { type: 'application/octet-stream' }));
const colors = items.map(i => i.colorCode || 'Black');
if (this.uploadForm) { if (this.uploadForm) {
this.uploadForm.setFiles(files); this.uploadForm.setFiles(files, colors);
this.uploadForm.patchSettings(session); this.uploadForm.patchSettings(session);
// Also restore colors? // Also restore colors?
// setFiles inits with 'Black'. We need to update them if they differ. // setFiles inits with correct colors now.
// items has colorCode.
setTimeout(() => { setTimeout(() => {
if (this.uploadForm) { if (this.uploadForm) {
items.forEach((item, index) => { items.forEach((item, index) => {
@@ -122,7 +122,11 @@ export class CalculatorPageComponent implements OnInit {
if (item.colorCode) { if (item.colorCode) {
this.uploadForm.updateItemColor(index, item.colorCode); this.uploadForm.updateItemColor(index, item.colorCode);
} }
if (item.quantity) {
this.uploadForm.updateItemQuantityAtIndex(index, item.quantity);
}
}); });
this.uploadForm.updateItemIdsByIndex(items.map(i => i.id));
} }
}); });
} }
@@ -141,7 +145,7 @@ export class CalculatorPageComponent implements OnInit {
this.currentRequest = req; this.currentRequest = req;
this.loading.set(true); this.loading.set(true);
this.uploadProgress.set(0); this.uploadProgress.set(0);
this.error.set(false); this.error.set(null);
this.result.set(null); this.result.set(null);
this.orderSuccess.set(false); this.orderSuccess.set(false);
@@ -157,26 +161,45 @@ export class CalculatorPageComponent implements OnInit {
if (typeof event === 'number') { if (typeof event === 'number') {
this.uploadProgress.set(event); this.uploadProgress.set(event);
} else { } else {
// It's the result // It's the result (partial or final)
const res = event as QuoteResult; const res = event as QuoteResult;
this.result.set(res); this.result.set(res);
this.loading.set(false);
this.uploadProgress.set(100); // Show result immediately if not already showing
this.step.set('quote'); if (this.step() !== 'quote') {
this.step.set('quote');
}
// Sync IDs back to upload form for future updates
if (this.uploadForm) {
this.uploadForm.updateItemIdsByIndex(res.items.map(i => i.id));
}
// Update URL with session ID without reloading // Update URL with session ID without reloading
if (res.sessionId) { if (res.sessionId) {
this.router.navigate([], { // Check if we need to update URL to avoid redundant navigations
relativeTo: this.route, const currentSession = this.route.snapshot.queryParamMap.get('session');
queryParams: { session: res.sessionId }, if (currentSession !== res.sessionId) {
queryParamsHandling: 'merge', // merge with existing params like 'mode' if any this.router.navigate([], {
replaceUrl: true // prevent cluttering history, or false if we want back button to work. replaceUrl seems safer for "state update" relativeTo: this.route,
}); queryParams: { session: res.sessionId },
queryParamsHandling: 'merge',
replaceUrl: true
});
}
} }
} }
}, },
error: () => { complete: () => {
this.error.set(true); this.loading.set(false);
this.uploadProgress.set(100);
},
error: (err) => {
if (typeof err === 'string') {
this.error.set(err);
} else {
this.error.set('GENERIC');
}
this.loading.set(false); this.loading.set(false);
} }
}); });
@@ -196,10 +219,10 @@ export class CalculatorPageComponent implements OnInit {
this.step.set('quote'); this.step.set('quote');
} }
onItemChange(event: {id?: string, fileName: string, quantity: number}) { onItemChange(event: {id?: string, fileName: string, quantity: number, index: number}) {
// 1. Update local form for consistency (UI feedback) // 1. Update local form for consistency (UI feedback)
if (this.uploadForm) { if (this.uploadForm) {
this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity); this.uploadForm.updateItemQuantityAtIndex(event.index, event.quantity);
} }
// 2. Update backend session if ID exists // 2. Update backend session if ID exists
@@ -211,6 +234,43 @@ export class CalculatorPageComponent implements OnInit {
} }
} }
onItemRemoved(event: {index: number, id?: string}) {
// 1. Update local result if exists to keep UI in sync
const currentRes = this.result();
if (currentRes) {
const updatedItems = [...currentRes.items];
updatedItems.splice(event.index, 1);
// Recalculate totals locally for immediate feedback
let totalTime = 0;
let totalWeight = 0;
let itemsPrice = 0;
updatedItems.forEach(i => {
totalTime += i.unitTime * i.quantity;
totalWeight += i.unitWeight * i.quantity;
itemsPrice += i.unitPrice * i.quantity;
});
this.result.set({
...currentRes,
items: updatedItems,
totalPrice: Math.round((itemsPrice + currentRes.setupCost) * 100) / 100,
totalTimeHours: Math.floor(totalTime / 3600),
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
totalWeight: Math.ceil(totalWeight)
});
}
// 2. Delete from backend if ID exists
if (event.id && currentRes?.sessionId) {
this.estimator.deleteLineItem(currentRes.sessionId, event.id).subscribe({
next: () => console.log('Line item deleted from backend'),
error: (err) => console.error('Failed to delete line item', err)
});
}
}
onSubmitOrder(orderData: any) { onSubmitOrder(orderData: any) {
console.log('Order Submitted:', orderData); console.log('Order Submitted:', orderData);
this.orderSuccess.set(true); this.orderSuccess.set(true);
@@ -256,4 +316,12 @@ export class CalculatorPageComponent implements OnInit {
this.router.navigate(['/contact']); this.router.navigate(['/contact']);
} }
setMode(mode: 'easy' | 'advanced') {
const path = mode === 'easy' ? 'basic' : 'advanced';
this.router.navigate(['../', path], {
relativeTo: this.route,
queryParamsHandling: 'merge'
});
}
} }

View File

@@ -21,7 +21,7 @@
</div> </div>
<div class="setup-note"> <div class="setup-note">
<small>{{ 'CALC.SETUP_NOTE' | translate:{cost: (result().setupCost | currency:result().currency)} }}</small> <small>* Include {{ result().setupCost | currency:result().currency }} Setup Cost</small>
</div> </div>
@if (result().notes) { @if (result().notes) {
@@ -35,28 +35,45 @@
<!-- Detailed Items List (NOW ON BOTTOM) --> <!-- Detailed Items List (NOW ON BOTTOM) -->
<div class="items-list"> <div class="items-list">
@for (item of items(); track item.fileName; let i = $index) { @for (item of items(); track item; let i = $index) {
<div class="item-row"> <div class="item-row" [class.has-error]="item.error">
<div class="item-info"> <div class="item-info">
<span class="file-name">{{ item.fileName }}</span> <span class="file-name">{{ item.fileName }}</span>
<span class="file-details"> @if (item.error) {
{{ (item.unitTime / 3600) | number:'1.1-1' }}h | {{ item.unitWeight | number:'1.0-0' }}g <span class="file-error">{{ 'CALC.ERROR_' + item.error | translate }}</span>
</span> } @else if (item.status === 'pending') {
<span class="file-details pending">
<div class="spinner-mini"></div> Analisi...
</span>
} @else {
<span class="file-details">
<span class="color-badge" [title]="item.color" [style.background-color]="getColorHex(item.color!)"></span>
{{ (item.unitTime / 3600) | number:'1.1-1' }}h | {{ item.unitWeight | number:'1.0-0' }}g
</span>
}
</div> </div>
<div class="item-controls"> <div class="item-controls">
<div class="qty-control"> @if (!item.error) {
<label>{{ 'CHECKOUT.QTY' | translate }}:</label> <div class="qty-control">
<input <label>Qtà:</label>
type="number" <input
min="1" type="number"
[ngModel]="item.quantity" min="1"
(ngModelChange)="updateQuantity(i, $event)" [ngModel]="item.quantity"
class="qty-input"> (ngModelChange)="updateQuantity(i, $event)"
</div> class="qty-input">
<div class="item-price"> </div>
{{ (item.unitPrice * item.quantity) | currency:result().currency }} <div class="item-price">
</div> {{ (item.unitPrice * item.quantity) | currency:result().currency }}
</div>
} @else if (item.status === 'pending') {
<div class="item-price pending">
<div class="spinner-mini"></div>
</div>
} @else {
<div class="item-price error">-</div>
}
</div> </div>
</div> </div>
} }

View File

@@ -21,6 +21,11 @@
background: var(--color-neutral-50); background: var(--color-neutral-50);
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
&.has-error {
border-color: #ef4444;
background: #fef2f2;
}
} }
.item-info { .item-info {
@@ -31,7 +36,21 @@
} }
.file-name { font-weight: 500; font-size: 0.9rem; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .file-name { font-weight: 500; font-size: 0.9rem; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-details { font-size: 0.8rem; color: var(--color-text-muted); } .file-details {
font-size: 0.8rem;
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: var(--space-2);
}
.color-badge {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid var(--color-border);
display: inline-block;
}
.file-error { font-size: 0.8rem; color: #ef4444; font-weight: 500; }
.item-controls { .item-controls {
display: flex; display: flex;

View File

@@ -6,6 +6,7 @@ import { AppCardComponent } from '../../../../shared/components/app-card/app-car
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component'; import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component';
import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service'; import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
import { getColorHex } from '../../../../core/constants/colors.const';
@Component({ @Component({
selector: 'app-quote-result', selector: 'app-quote-result',
@@ -18,11 +19,13 @@ export class QuoteResultComponent {
result = input.required<QuoteResult>(); result = input.required<QuoteResult>();
consult = output<void>(); consult = output<void>();
proceed = output<void>(); proceed = output<void>();
itemChange = output<{id?: string, fileName: string, quantity: number}>(); itemChange = output<{id?: string, fileName: string, quantity: number, index: number}>();
// Local mutable state for items to handle quantity changes // Local mutable state for items to handle quantity changes
items = signal<QuoteItem[]>([]); items = signal<QuoteItem[]>([]);
getColorHex = getColorHex;
constructor() { constructor() {
effect(() => { effect(() => {
// Initialize local items when result inputs change // Initialize local items when result inputs change
@@ -44,7 +47,8 @@ export class QuoteResultComponent {
this.itemChange.emit({ this.itemChange.emit({
id: this.items()[index].id, id: this.items()[index].id,
fileName: this.items()[index].fileName, fileName: this.items()[index].fileName,
quantity: qty quantity: qty,
index: index
}); });
} }
@@ -57,9 +61,11 @@ export class QuoteResultComponent {
let weight = 0; let weight = 0;
currentItems.forEach(i => { currentItems.forEach(i => {
price += i.unitPrice * i.quantity; if (i.status === 'done' && !i.error) {
time += i.unitTime * i.quantity; price += i.unitPrice * i.quantity;
weight += i.unitWeight * i.quantity; time += i.unitTime * i.quantity;
weight += i.unitWeight * i.quantity;
}
}); });
const hours = Math.floor(time / 3600); const hours = Math.floor(time / 3600);

View File

@@ -25,7 +25,7 @@
<!-- New File List with Details --> <!-- New File List with Details -->
@if (items().length > 0) { @if (items().length > 0) {
<div class="items-grid"> <div class="items-grid">
@for (item of items(); track item.file.name; let i = $index) { @for (item of items(); track item; let i = $index) {
<div class="file-card" [class.active]="item.file === selectedFile()" (click)="selectFile(item.file)"> <div class="file-card" [class.active]="item.file === selectedFile()" (click)="selectFile(item.file)">
<div class="card-header"> <div class="card-header">
<span class="file-name" [title]="item.file.name">{{ item.file.name }}</span> <span class="file-name" [title]="item.file.name">{{ item.file.name }}</span>
@@ -34,7 +34,7 @@
<div class="card-body"> <div class="card-body">
<div class="card-controls"> <div class="card-controls">
<div class="qty-group"> <div class="qty-group">
<label>{{ 'CALC.QTY_SHORT' | translate }}</label> <label>QTÀ</label>
<input <input
type="number" type="number"
min="1" min="1"
@@ -45,7 +45,7 @@
</div> </div>
<div class="color-group"> <div class="color-group">
<label>{{ 'CALC.COLOR_LABEL' | translate }}</label> <label>COLORE</label>
<app-color-selector <app-color-selector
[selectedColor]="item.color" [selectedColor]="item.color"
[variants]="currentMaterialVariants()" [variants]="currentMaterialVariants()"
@@ -134,7 +134,7 @@
<app-input <app-input
formControlName="notes" formControlName="notes"
[label]="'CALC.NOTES' | translate" [label]="'CALC.NOTES' | translate"
[placeholder]="'CALC.NOTES_PLACEHOLDER' | translate" placeholder="Istruzioni specifiche..."
></app-input> ></app-input>
<div class="actions"> <div class="actions">
@@ -151,7 +151,7 @@
type="submit" type="submit"
[disabled]="items().length === 0 || loading()" [disabled]="items().length === 0 || loading()"
[fullWidth]="true"> [fullWidth]="true">
{{ loading() ? (uploadProgress() < 100 ? ('CALC.UPLOADING' | translate) : ('CALC.PROCESSING' | translate)) : ('CALC.CALCULATE' | translate) }} {{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
</app-button> </app-button>
</div> </div>
</form> </form>

View File

@@ -12,6 +12,7 @@ import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, Mat
import { getColorHex } from '../../../../core/constants/colors.const'; import { getColorHex } from '../../../../core/constants/colors.const';
interface FormItem { interface FormItem {
id?: string;
file: File; file: File;
quantity: number; quantity: number;
color: string; color: string;
@@ -29,6 +30,7 @@ export class UploadFormComponent implements OnInit {
loading = input<boolean>(false); loading = input<boolean>(false);
uploadProgress = input<number>(0); uploadProgress = input<number>(0);
submitRequest = output<QuoteRequest>(); submitRequest = output<QuoteRequest>();
itemRemoved = output<{index: number, id?: string}>();
private estimator = inject(QuoteEstimatorService); private estimator = inject(QuoteEstimatorService);
private fb = inject(FormBuilder); private fb = inject(FormBuilder);
@@ -75,7 +77,7 @@ export class UploadFormComponent implements OnInit {
layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]], layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]],
nozzleDiameter: [0.4, Validators.required], nozzleDiameter: [0.4, Validators.required],
infillPattern: ['grid'], infillPattern: ['grid'],
supportEnabled: [false] supportEnabled: [true]
}); });
// Listen to material changes to update variants // Listen to material changes to update variants
@@ -112,7 +114,9 @@ export class UploadFormComponent implements OnInit {
private setDefaults() { private setDefaults() {
// Set Defaults if available // Set Defaults if available
if (this.materials().length > 0 && !this.form.get('material')?.value) { if (this.materials().length > 0 && !this.form.get('material')?.value) {
this.form.get('material')?.setValue(this.materials()[0].value); // Prefer PLA Basic, otherwise first available
const pla = this.materials().find(m => m.value === 'pla_basic');
this.form.get('material')?.setValue(pla ? pla.value : this.materials()[0].value);
} }
if (this.qualities().length > 0 && !this.form.get('quality')?.value) { if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
// Try to find 'standard' or use first // Try to find 'standard' or use first
@@ -176,6 +180,37 @@ export class UploadFormComponent implements OnInit {
}); });
} }
updateItemQuantityAtIndex(index: number, quantity: number) {
this.items.update(current => {
const updated = [...current];
if (updated[index]) {
updated[index] = { ...updated[index], quantity };
}
return updated;
});
}
updateItemIds(itemsWithIds: { fileName: string, id: string }[]) {
this.items.update(current => {
return current.map(item => {
const match = itemsWithIds.find(i => i.fileName === item.file.name && !i.id); // This matching is weak
// Better: matching should be based on index if we trust order
return item;
});
});
}
updateItemIdsByIndex(ids: (string | undefined)[]) {
this.items.update(current => {
return current.map((item, i) => {
if (ids[i]) {
return { ...item, id: ids[i] };
}
return item;
});
});
}
selectFile(file: File) { selectFile(file: File) {
if (this.selectedFile() === file) { if (this.selectedFile() === file) {
// toggle off? no, keep active // toggle off? no, keep active
@@ -206,11 +241,7 @@ export class UploadFormComponent implements OnInit {
let val = parseInt(input.value, 10); let val = parseInt(input.value, 10);
if (isNaN(val) || val < 1) val = 1; if (isNaN(val) || val < 1) val = 1;
this.items.update(current => { this.updateItemQuantityAtIndex(index, val);
const updated = [...current];
updated[index] = { ...updated[index], quantity: val };
return updated;
});
} }
updateItemColor(index: number, newColor: string) { updateItemColor(index: number, newColor: string) {
@@ -222,6 +253,7 @@ export class UploadFormComponent implements OnInit {
} }
removeItem(index: number) { removeItem(index: number) {
const itemToRemove = this.items()[index];
this.items.update(current => { this.items.update(current => {
const updated = [...current]; const updated = [...current];
const removed = updated.splice(index, 1)[0]; const removed = updated.splice(index, 1)[0];
@@ -230,14 +262,15 @@ export class UploadFormComponent implements OnInit {
} }
return updated; return updated;
}); });
this.itemRemoved.emit({ index, id: itemToRemove.id });
} }
setFiles(files: File[]) { setFiles(files: File[], colors?: string[]) {
const validItems: FormItem[] = []; const validItems: FormItem[] = [];
for (const file of files) { files.forEach((file, i) => {
// Default color is Black or derive from somewhere if possible, but here we just init const color = (colors && colors[i]) ? colors[i] : 'Black';
validItems.push({ file, quantity: 1, color: 'Black' }); validItems.push({ file, quantity: 1, color: color });
} });
if (validItems.length > 0) { if (validItems.length > 0) {
this.items.set(validItems); this.items.set(validItems);

View File

@@ -9,8 +9,8 @@
<div class="col-md-6"> <div class="col-md-6">
<app-input <app-input
formControlName="name" formControlName="name"
[label]="'USER_DETAILS.NAME' | translate" label="USER_DETAILS.NAME"
[placeholder]="'USER_DETAILS.NAME_PLACEHOLDER' | translate" placeholder="USER_DETAILS.NAME_PLACEHOLDER"
[required]="true" [required]="true"
[error]="form.get('name')?.invalid && form.get('name')?.touched ? ('COMMON.REQUIRED' | translate) : null"> [error]="form.get('name')?.invalid && form.get('name')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input> </app-input>
@@ -18,8 +18,8 @@
<div class="col-md-6"> <div class="col-md-6">
<app-input <app-input
formControlName="surname" formControlName="surname"
[label]="'USER_DETAILS.SURNAME' | translate" label="USER_DETAILS.SURNAME"
[placeholder]="'USER_DETAILS.SURNAME_PLACEHOLDER' | translate" placeholder="USER_DETAILS.SURNAME_PLACEHOLDER"
[required]="true" [required]="true"
[error]="form.get('surname')?.invalid && form.get('surname')?.touched ? ('COMMON.REQUIRED' | translate) : null"> [error]="form.get('surname')?.invalid && form.get('surname')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input> </app-input>
@@ -31,9 +31,9 @@
<div class="col-md-6"> <div class="col-md-6">
<app-input <app-input
formControlName="email" formControlName="email"
[label]="'USER_DETAILS.EMAIL' | translate" label="USER_DETAILS.EMAIL"
type="email" type="email"
[placeholder]="'USER_DETAILS.EMAIL_PLACEHOLDER' | translate" placeholder="USER_DETAILS.EMAIL_PLACEHOLDER"
[required]="true" [required]="true"
[error]="form.get('email')?.invalid && form.get('email')?.touched ? ('COMMON.INVALID_EMAIL' | translate) : null"> [error]="form.get('email')?.invalid && form.get('email')?.touched ? ('COMMON.INVALID_EMAIL' | translate) : null">
</app-input> </app-input>
@@ -41,9 +41,9 @@
<div class="col-md-6"> <div class="col-md-6">
<app-input <app-input
formControlName="phone" formControlName="phone"
[label]="'USER_DETAILS.PHONE' | translate" label="USER_DETAILS.PHONE"
type="tel" type="tel"
[placeholder]="'USER_DETAILS.PHONE_PLACEHOLDER' | translate" placeholder="USER_DETAILS.PHONE_PLACEHOLDER"
[required]="true" [required]="true"
[error]="form.get('phone')?.invalid && form.get('phone')?.touched ? ('COMMON.REQUIRED' | translate) : null"> [error]="form.get('phone')?.invalid && form.get('phone')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input> </app-input>
@@ -53,8 +53,8 @@
<!-- Address --> <!-- Address -->
<app-input <app-input
formControlName="address" formControlName="address"
[label]="'USER_DETAILS.ADDRESS' | translate" label="USER_DETAILS.ADDRESS"
[placeholder]="'USER_DETAILS.ADDRESS_PLACEHOLDER' | translate" placeholder="USER_DETAILS.ADDRESS_PLACEHOLDER"
[required]="true" [required]="true"
[error]="form.get('address')?.invalid && form.get('address')?.touched ? ('COMMON.REQUIRED' | translate) : null"> [error]="form.get('address')?.invalid && form.get('address')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input> </app-input>
@@ -64,8 +64,8 @@
<div class="col-md-4"> <div class="col-md-4">
<app-input <app-input
formControlName="zip" formControlName="zip"
[label]="'USER_DETAILS.ZIP' | translate" label="USER_DETAILS.ZIP"
[placeholder]="'USER_DETAILS.ZIP_PLACEHOLDER' | translate" placeholder="USER_DETAILS.ZIP_PLACEHOLDER"
[required]="true" [required]="true"
[error]="form.get('zip')?.invalid && form.get('zip')?.touched ? ('COMMON.REQUIRED' | translate) : null"> [error]="form.get('zip')?.invalid && form.get('zip')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input> </app-input>
@@ -73,8 +73,8 @@
<div class="col-md-8"> <div class="col-md-8">
<app-input <app-input
formControlName="city" formControlName="city"
[label]="'USER_DETAILS.CITY' | translate" label="USER_DETAILS.CITY"
[placeholder]="'USER_DETAILS.CITY_PLACEHOLDER' | translate" placeholder="USER_DETAILS.CITY_PLACEHOLDER"
[required]="true" [required]="true"
[error]="form.get('city')?.invalid && form.get('city')?.touched ? ('COMMON.REQUIRED' | translate) : null"> [error]="form.get('city')?.invalid && form.get('city')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input> </app-input>

View File

@@ -26,6 +26,8 @@ export interface QuoteItem {
quantity: number; quantity: number;
material?: string; material?: string;
color?: string; color?: string;
error?: string;
status: 'pending' | 'done' | 'error';
} }
export interface QuoteResult { export interface QuoteResult {
@@ -138,6 +140,13 @@ export class QuoteEstimatorService {
return this.http.patch(`${environment.apiUrl}/api/quote-sessions/line-items/${lineItemId}`, changes, { headers }); return this.http.patch(`${environment.apiUrl}/api/quote-sessions/line-items/${lineItemId}`, changes, { headers });
} }
deleteLineItem(sessionId: string, lineItemId: string): Observable<any> {
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.delete(`${environment.apiUrl}/api/quote-sessions/${sessionId}/line-items/${lineItemId}`, { headers });
}
createOrder(sessionId: string, orderDetails: any): Observable<any> { createOrder(sessionId: string, orderDetails: any): Observable<any> {
const headers: any = {}; const headers: any = {};
// @ts-ignore // @ts-ignore
@@ -161,13 +170,6 @@ export class QuoteEstimatorService {
responseType: 'blob' responseType: 'blob'
}); });
} }
getTwintPayment(orderId: string): Observable<any> {
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}/twint`, { headers });
}
calculate(request: QuoteRequest): Observable<number | QuoteResult> { calculate(request: QuoteRequest): Observable<number | QuoteResult> {
console.log('QuoteEstimatorService: Calculating quote...', request); console.log('QuoteEstimatorService: Calculating quote...', request);
@@ -187,18 +189,74 @@ export class QuoteEstimatorService {
const sessionId = sessionRes.id; const sessionId = sessionRes.id;
const sessionSetupCost = sessionRes.setupCostChf || 0; const sessionSetupCost = sessionRes.setupCostChf || 0;
// Initialize items in pending state
const currentItems: QuoteItem[] = request.items.map(item => ({
fileName: item.file.name,
unitPrice: 0,
unitTime: 0,
unitWeight: 0,
quantity: item.quantity,
status: 'pending',
color: item.color || 'White' // Default color for UI
}));
// Emit initial state
const initialResult: QuoteResult = {
sessionId: sessionId,
items: [...currentItems],
setupCost: sessionSetupCost,
currency: 'CHF',
totalPrice: 0, // Will be calculated dynamically
totalTimeHours: 0,
totalTimeMinutes: 0,
totalWeight: 0,
notes: request.notes
};
observer.next(initialResult);
// 2. Upload files to this session // 2. Upload files to this session
const totalItems = request.items.length; const totalItems = request.items.length;
const allProgress: number[] = new Array(totalItems).fill(0); const allProgress: number[] = new Array(totalItems).fill(0);
const finalResponses: any[] = [];
let completedRequests = 0; let completedRequests = 0;
const checkCompletion = () => { const emitUpdate = () => {
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems); const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
observer.next(avg); observer.next(avg);
// Helper to calculate totals for current items
let grandTotal = 0;
let totalTime = 0;
let totalWeight = 0;
let validCount = 0;
currentItems.forEach(item => {
if (item.status === 'done') {
grandTotal += item.unitPrice * item.quantity;
totalTime += item.unitTime * item.quantity;
totalWeight += item.unitWeight * item.quantity;
validCount++;
}
});
if (validCount > 0) {
grandTotal += sessionSetupCost;
}
const result: QuoteResult = {
sessionId: sessionId,
items: [...currentItems], // Create copy to trigger change detection
setupCost: sessionSetupCost,
currency: 'CHF',
totalPrice: Math.round(grandTotal * 100) / 100,
totalTimeHours: Math.floor(totalTime / 3600),
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
totalWeight: Math.ceil(totalWeight),
notes: request.notes
};
observer.next(result);
if (completedRequests === totalItems) { if (completedRequests === totalItems) {
finalize(finalResponses, sessionSetupCost, sessionId); observer.complete();
} }
}; };
@@ -228,20 +286,42 @@ export class QuoteEstimatorService {
}).subscribe({ }).subscribe({
next: (event) => { next: (event) => {
if (event.type === HttpEventType.UploadProgress && event.total) { if (event.type === HttpEventType.UploadProgress && event.total) {
allProgress[index] = Math.round((100 * event.loaded) / event.total); allProgress[index] = Math.round((70 * event.loaded) / event.total); // Upload is 70% of "progress" for user perception
checkCompletion(); emitUpdate();
} else if (event.type === HttpEventType.Response) { } else if (event.type === HttpEventType.Response) {
allProgress[index] = 100; allProgress[index] = 100;
finalResponses[index] = { ...event.body, success: true, fileName: item.file.name, originalQty: item.quantity, originalItem: item }; const resBody = event.body as any;
// Update item in list
currentItems[index] = {
id: resBody.id,
fileName: resBody.originalFilename, // use returned filename
unitPrice: resBody.unitPriceChf || 0,
unitTime: resBody.printTimeSeconds || 0,
unitWeight: resBody.materialGrams || 0,
quantity: item.quantity, // Keep original quantity
material: request.material,
color: item.color || 'White',
status: 'done'
};
completedRequests++; completedRequests++;
checkCompletion(); emitUpdate();
} }
}, },
error: (err) => { error: (err) => {
console.error('Item upload failed', err); console.error('Item upload failed', err);
finalResponses[index] = { success: false, fileName: item.file.name }; const errorMsg = err.error?.code === 'VIRUS_DETECTED' ? 'VIRUS_DETECTED' : 'UPLOAD_FAILED';
currentItems[index] = {
...currentItems[index],
status: 'error',
error: errorMsg
};
allProgress[index] = 100; // Mark as done despite error
completedRequests++; completedRequests++;
checkCompletion(); emitUpdate();
} }
}); });
}); });
@@ -251,62 +331,6 @@ export class QuoteEstimatorService {
observer.error('Could not initialize quote session'); observer.error('Could not initialize quote session');
} }
}); });
const finalize = (responses: any[], setupCost: number, sessionId: string) => {
observer.next(100);
const items: QuoteItem[] = [];
let grandTotal = 0;
let totalTime = 0;
let totalWeight = 0;
let validCount = 0;
responses.forEach((res, idx) => {
if (!res || !res.success) return;
validCount++;
const unitPrice = res.unitPriceChf || 0;
const quantity = res.originalQty || 1;
items.push({
id: res.id,
fileName: res.fileName,
unitPrice: unitPrice,
unitTime: res.printTimeSeconds || 0,
unitWeight: res.materialGrams || 0,
quantity: quantity,
material: request.material,
color: res.originalItem.color || 'Default'
// Store ID if needed for updates? QuoteItem interface might need update
// or we map it in component
});
grandTotal += unitPrice * quantity;
totalTime += (res.printTimeSeconds || 0) * quantity;
totalWeight += (res.materialGrams || 0) * quantity;
});
if (validCount === 0) {
observer.error('All calculations failed.');
return;
}
grandTotal += setupCost;
const result: QuoteResult = {
sessionId: sessionId,
items,
setupCost: setupCost,
currency: 'CHF',
totalPrice: Math.round(grandTotal * 100) / 100,
totalTimeHours: Math.floor(totalTime / 3600),
totalTimeMinutes: Math.ceil((totalTime % 3600) / 60),
totalWeight: Math.ceil(totalWeight),
notes: request.notes
};
observer.next(result);
observer.complete();
};
}); });
} }
@@ -360,7 +384,8 @@ export class QuoteEstimatorService {
material: session.materialCode, // Assumption: session has one material for all? or items have it? material: session.materialCode, // Assumption: session has one material for all? or items have it?
// Backend model QuoteSession has materialCode. // Backend model QuoteSession has materialCode.
// But line items might have different colors. // But line items might have different colors.
color: item.colorCode color: item.colorCode,
status: 'done'
})), })),
setupCost: session.setupCostChf, setupCost: session.setupCostChf,
currency: 'CHF', // Fixed for now currency: 'CHF', // Fixed for now

View File

@@ -1,161 +1,154 @@
<div class="checkout-page"> <div class="container hero">
<div class="container hero"> <h1>{{ 'CHECKOUT.TITLE' | translate }}</h1>
<h1 class="section-title">{{ 'CHECKOUT.TITLE' | translate }}</h1> <p class="subtitle">{{ 'CHECKOUT.SUBTITLE' | translate }}</p>
</div> </div>
<div class="container">
<div class="checkout-layout">
<!-- LEFT COLUMN: Form -->
<div class="checkout-form-section">
<!-- Error Message -->
<div *ngIf="error" class="error-message">
{{ error }}
</div>
<form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error"> <div class="container">
<div class="checkout-layout">
<!-- Contact Info Card -->
<app-card class="mb-6"> <!-- LEFT COLUMN: Form -->
<div class="card-header-simple"> <div class="checkout-form-section">
<h3>{{ 'CHECKOUT.CONTACT_INFO' | translate }}</h3> <!-- Error Message -->
</div> <div *ngIf="error" class="error-message">
<div class="form-row"> {{ error }}
<app-input formControlName="email" type="email" [label]="'CHECKOUT.EMAIL' | translate" [required]="true" [error]="checkoutForm.get('email')?.hasError('email') ? ('CHECKOUT.INVALID_EMAIL' | translate) : null"></app-input> </div>
<app-input formControlName="phone" type="tel" [label]="'CHECKOUT.PHONE' | translate" [required]="true"></app-input>
</div>
</app-card>
<!-- Billing Address Card --> <form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error">
<app-card class="mb-6">
<div class="card-header-simple"> <!-- Contact Info Card -->
<h3>{{ 'CHECKOUT.BILLING_ADDR' | translate }}</h3> <app-card class="mb-6">
<div class="form-row">
<app-input formControlName="email" type="email" [label]="'CHECKOUT.EMAIL' | translate" [required]="true" [error]="checkoutForm.get('email')?.hasError('email') ? 'Invalid email' : null"></app-input>
<app-input formControlName="phone" type="tel" [label]="'CHECKOUT.PHONE' | translate" [required]="true"></app-input>
</div>
<div class="user-type-selector">
<div class="type-option" [class.selected]="!isCompany" (click)="setCustomerType(false)">
{{ 'CHECKOUT.PRIVATE' | translate }}
</div> </div>
<div formGroupName="billingAddress"> <div class="type-option" [class.selected]="isCompany" (click)="setCustomerType(true)">
{{ 'CHECKOUT.COMPANY' | translate }}
<!-- Private Person Fields --> </div>
<div *ngIf="!isCompany" class="form-row"> </div>
<div formGroupName="billingAddress">
<div *ngIf="isCompany" class="company-fields">
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate" [required]="true"></app-input>
<div class="form-row no-margin">
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate" [required]="true"></app-input> <app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate" [required]="true"></app-input>
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate" [required]="true"></app-input> <app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate" [required]="true"></app-input>
</div> </div>
<!-- Company Fields -->
<div *ngIf="isCompany" class="company-fields mb-4">
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate" [required]="true" [placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"></app-input>
<app-input formControlName="referencePerson" [label]="'CONTACT.REF_PERSON' | translate" [required]="true" [placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"></app-input>
</div>
<!-- User Type Selector -->
<div class="user-type-selector">
<div class="type-option" [class.selected]="!isCompany" (click)="setCustomerType(false)">
{{ 'CONTACT.TYPE_PRIVATE' | translate }}
</div>
<div class="type-option" [class.selected]="isCompany" (click)="setCustomerType(true)">
{{ 'CONTACT.TYPE_COMPANY' | translate }}
</div>
</div>
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate" [required]="true"></app-input>
<app-input formControlName="addressLine2" [label]="'CHECKOUT.ADDRESS_2' | translate"></app-input>
<div class="form-row three-cols">
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate" [required]="true"></app-input>
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field" [required]="true"></app-input>
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true" [required]="true"></app-input>
</div>
</div> </div>
</app-card>
<div *ngIf="!isCompany" class="form-row no-margin">
<!-- Shipping Option --> <app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate" [required]="true"></app-input>
<div class="shipping-option"> <app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate" [required]="true"></app-input>
<label class="checkbox-container">
<input type="checkbox" formControlName="shippingSameAsBilling">
<span class="checkmark"></span>
{{ 'CHECKOUT.SHIPPING_SAME' | translate }}
</label>
</div>
<!-- Shipping Address Card (Conditional) -->
<app-card *ngIf="!checkoutForm.get('shippingSameAsBilling')?.value" class="mb-6">
<div class="card-header-simple">
<h3>{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}</h3>
</div>
<div formGroupName="shippingAddress">
<div class="form-row">
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate"></app-input>
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate"></app-input>
</div>
<div *ngIf="isCompany" class="company-fields">
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_OPTIONAL' | translate"></app-input>
<app-input formControlName="referencePerson" [label]="'CHECKOUT.REF_PERSON_OPTIONAL' | translate"></app-input>
</div>
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate"></app-input>
<div class="form-row three-cols">
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate"></app-input>
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field"></app-input>
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true"></app-input>
</div>
</div>
</app-card>
<div class="actions">
<app-button type="submit" [disabled]="checkoutForm.invalid || isSubmitting()" [fullWidth]="true">
{{ (isSubmitting() ? 'CHECKOUT.PROCESSING' : 'CHECKOUT.PLACE_ORDER') | translate }}
</app-button>
</div>
</form>
</div>
<!-- RIGHT COLUMN: Order Summary -->
<div class="checkout-summary-section">
<app-card class="sticky-card">
<div class="card-header-simple">
<h3>{{ 'CHECKOUT.SUMMARY_TITLE' | translate }}</h3>
</div>
<div class="summary-items" *ngIf="quoteSession() as session">
<div class="summary-item" *ngFor="let item of session.items">
<div class="item-details">
<span class="item-name">{{ item.originalFilename }}</span>
<div class="item-specs">
<span>{{ 'CHECKOUT.QTY' | translate }}: {{ item.quantity }}</span>
<span *ngIf="item.colorCode" class="color-dot" [style.background-color]="item.colorCode"></span>
</div>
<div class="item-specs-sub">
{{ (item.printTimeSeconds / 3600) | number:'1.1-1' }}h | {{ item.materialGrams | number:'1.0-0' }}g
</div>
</div>
<div class="item-price">
{{ (item.unitPriceChf * item.quantity) | currency:'CHF' }}
</div>
</div>
</div>
<div class="summary-totals" *ngIf="quoteSession() as session">
<div class="total-row">
<span>{{ 'CHECKOUT.SUBTOTAL' | translate }}</span>
<span>{{ session.itemsTotalChf | currency:'CHF' }}</span>
</div>
<div class="total-row">
<span>{{ 'CHECKOUT.SETUP_FEE' | translate }}</span>
<span>{{ session.session.setupCostChf | currency:'CHF' }}</span>
</div>
<div class="total-row">
<span>{{ 'CHECKOUT.SHIPPING' | translate }}</span>
<span>{{ 9.00 | currency:'CHF' }}</span>
</div>
<div class="grand-total">
<span>{{ 'CHECKOUT.TOTAL' | translate }}</span>
<span>{{ (session.grandTotalChf + 9.00) | currency:'CHF' }}</span>
</div> </div>
</div> </div>
</app-card> </app-card>
</div>
<!-- Billing Address Card -->
<app-card class="mb-6">
<div class="card-header-simple">
<h3>{{ 'CHECKOUT.BILLING_ADDR' | translate }}</h3>
</div>
<div formGroupName="billingAddress">
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate" [required]="true"></app-input>
<app-input formControlName="addressLine2" [label]="'CHECKOUT.ADDRESS_2' | translate"></app-input>
<div class="form-row three-cols">
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate" [required]="true"></app-input>
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field" [required]="true"></app-input>
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true" [required]="true"></app-input>
</div>
</div>
</app-card>
<!-- Shipping Option -->
<div class="shipping-option">
<label class="checkbox-container">
<input type="checkbox" formControlName="shippingSameAsBilling">
<span class="checkmark"></span>
{{ 'CHECKOUT.SHIPPING_SAME' | translate }}
</label>
</div>
<!-- Shipping Address Card (Conditional) -->
<app-card *ngIf="!checkoutForm.get('shippingSameAsBilling')?.value" class="mb-6">
<div class="card-header-simple">
<h3>{{ 'CHECKOUT.SHIPPING_ADDR' | translate }}</h3>
</div>
<div formGroupName="shippingAddress">
<div class="form-row">
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate"></app-input>
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate"></app-input>
</div>
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate"></app-input>
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate"></app-input>
<div class="form-row three-cols">
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate"></app-input>
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field"></app-input>
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true"></app-input>
</div>
</div>
</app-card>
<div class="actions">
<app-button type="submit" [disabled]="checkoutForm.invalid || isSubmitting()" [fullWidth]="true">
{{ (isSubmitting() ? 'CHECKOUT.PROCESSING' : 'CHECKOUT.PLACE_ORDER') | translate }}
</app-button>
</div>
</form>
</div> </div>
<!-- RIGHT COLUMN: Order Summary -->
<div class="checkout-summary-section">
<app-card class="sticky-card">
<div class="card-header-simple">
<h3>{{ 'CHECKOUT.ORDER_SUMMARY' | translate }}</h3>
</div>
<div class="summary-items" *ngIf="quoteSession() as session">
<div class="summary-item" *ngFor="let item of session.items">
<div class="item-details">
<span class="item-name">{{ item.originalFilename }}</span>
<div class="item-specs">
<span>Qty: {{ item.quantity }}</span>
<span *ngIf="item.colorCode" class="color-dot" [style.background-color]="item.colorCode"></span>
</div>
<div class="item-specs-sub">
{{ (item.printTimeSeconds / 3600) | number:'1.1-1' }}h | {{ item.materialGrams | number:'1.0-0' }}g
</div>
</div>
<div class="item-price">
{{ (item.unitPriceChf * item.quantity) | currency:'CHF' }}
</div>
</div>
</div>
<div class="summary-totals" *ngIf="quoteSession() as session">
<div class="total-row">
<span>{{ 'CHECKOUT.SUBTOTAL' | translate }}</span>
<span>{{ session.itemsTotalChf | currency:'CHF' }}</span>
</div>
<div class="total-row">
<span>{{ 'CHECKOUT.SETUP_FEE' | translate }}</span>
<span>{{ session.session.setupCostChf | currency:'CHF' }}</span>
</div>
<div class="total-row">
<span>{{ 'CHECKOUT.SHIPPING' | translate }}</span>
<span>{{ 9.0 | currency:'CHF' }}</span>
</div>
<div class="grand-total-row">
<span>{{ 'CHECKOUT.TOTAL' | translate }}</span>
<span>{{ (session.grandTotalChf + 9.0) | currency:'CHF' }}</span>
</div>
</div>
</app-card>
</div>
</div> </div>
</div> </div>

View File

@@ -1,16 +1,23 @@
.hero { .hero {
padding: var(--space-8) 0; padding: var(--space-12) 0 var(--space-8);
text-align: center; text-align: center;
.section-title { h1 {
font-size: 2.5rem; font-size: 2.5rem;
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
} }
} }
.subtitle {
font-size: 1.125rem;
color: var(--color-text-muted);
max-width: 600px;
margin: 0 auto;
}
.checkout-layout { .checkout-layout {
display: grid; display: grid;
grid-template-columns: 1fr 420px; grid-template-columns: 1fr 400px;
gap: var(--space-8); gap: var(--space-8);
align-items: start; align-items: start;
margin-bottom: var(--space-12); margin-bottom: var(--space-12);
@@ -70,7 +77,7 @@
background-color: var(--color-neutral-100); background-color: var(--color-neutral-100);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: 4px; padding: 4px;
margin: var(--space-6) 0; margin-bottom: var(--space-4);
gap: 4px; gap: 4px;
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
@@ -188,7 +195,6 @@
max-height: 450px; max-height: 450px;
overflow-y: auto; overflow-y: auto;
padding-right: var(--space-2); padding-right: var(--space-2);
padding-top: var(--space-2);
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 4px; width: 4px;
@@ -254,19 +260,19 @@
.summary-totals { .summary-totals {
background: var(--color-neutral-100); background: var(--color-neutral-100);
padding: var(--space-4); padding: var(--space-6);
border-radius: var(--radius-md); border-radius: var(--radius-md);
margin-top: var(--space-6); margin-top: var(--space-4);
.total-row { .total-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
font-size: 0.95rem; font-size: 0.95rem;
color: var(--color-text); color: var(--color-text-muted);
} }
.grand-total { .grand-total-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
color: var(--color-text); color: var(--color-text);
@@ -297,3 +303,4 @@
} }
.mb-6 { margin-bottom: var(--space-6); } .mb-6 { margin-bottom: var(--space-6); }

View File

@@ -20,7 +20,7 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
AppCardComponent AppCardComponent
], ],
templateUrl: './checkout.component.html', templateUrl: './checkout.component.html',
styleUrls: ['./checkout.component.scss'] styleUrl: './checkout.component.scss'
}) })
export class CheckoutComponent implements OnInit { export class CheckoutComponent implements OnInit {
private fb = inject(FormBuilder); private fb = inject(FormBuilder);
@@ -47,7 +47,6 @@ export class CheckoutComponent implements OnInit {
firstName: ['', Validators.required], firstName: ['', Validators.required],
lastName: ['', Validators.required], lastName: ['', Validators.required],
companyName: [''], companyName: [''],
referencePerson: [''],
addressLine1: ['', Validators.required], addressLine1: ['', Validators.required],
addressLine2: [''], addressLine2: [''],
zip: ['', Validators.required], zip: ['', Validators.required],
@@ -59,7 +58,6 @@ export class CheckoutComponent implements OnInit {
firstName: [''], firstName: [''],
lastName: [''], lastName: [''],
companyName: [''], companyName: [''],
referencePerson: [''],
addressLine1: [''], addressLine1: [''],
addressLine2: [''], addressLine2: [''],
zip: [''], zip: [''],
@@ -77,27 +75,16 @@ export class CheckoutComponent implements OnInit {
const type = isCompany ? 'BUSINESS' : 'PRIVATE'; const type = isCompany ? 'BUSINESS' : 'PRIVATE';
this.checkoutForm.patchValue({ customerType: type }); this.checkoutForm.patchValue({ customerType: type });
// Update validators based on type
const billingGroup = this.checkoutForm.get('billingAddress') as FormGroup; const billingGroup = this.checkoutForm.get('billingAddress') as FormGroup;
const companyControl = billingGroup.get('companyName'); const companyControl = billingGroup.get('companyName');
const referenceControl = billingGroup.get('referencePerson');
const firstNameControl = billingGroup.get('firstName');
const lastNameControl = billingGroup.get('lastName');
if (isCompany) { if (isCompany) {
companyControl?.setValidators([Validators.required]); companyControl?.setValidators([Validators.required]);
referenceControl?.setValidators([Validators.required]);
firstNameControl?.clearValidators();
lastNameControl?.clearValidators();
} else { } else {
companyControl?.clearValidators(); companyControl?.clearValidators();
referenceControl?.clearValidators();
firstNameControl?.setValidators([Validators.required]);
lastNameControl?.setValidators([Validators.required]);
} }
companyControl?.updateValueAndValidity(); companyControl?.updateValueAndValidity();
referenceControl?.updateValueAndValidity();
firstNameControl?.updateValueAndValidity();
lastNameControl?.updateValueAndValidity();
} }
ngOnInit(): void { ngOnInit(): void {
@@ -164,7 +151,6 @@ export class CheckoutComponent implements OnInit {
firstName: formVal.billingAddress.firstName, firstName: formVal.billingAddress.firstName,
lastName: formVal.billingAddress.lastName, lastName: formVal.billingAddress.lastName,
companyName: formVal.billingAddress.companyName, companyName: formVal.billingAddress.companyName,
contactPerson: formVal.billingAddress.referencePerson,
addressLine1: formVal.billingAddress.addressLine1, addressLine1: formVal.billingAddress.addressLine1,
addressLine2: formVal.billingAddress.addressLine2, addressLine2: formVal.billingAddress.addressLine2,
zip: formVal.billingAddress.zip, zip: formVal.billingAddress.zip,
@@ -175,7 +161,6 @@ export class CheckoutComponent implements OnInit {
firstName: formVal.shippingAddress.firstName, firstName: formVal.shippingAddress.firstName,
lastName: formVal.shippingAddress.lastName, lastName: formVal.shippingAddress.lastName,
companyName: formVal.shippingAddress.companyName, companyName: formVal.shippingAddress.companyName,
contactPerson: formVal.shippingAddress.referencePerson,
addressLine1: formVal.shippingAddress.addressLine1, addressLine1: formVal.shippingAddress.addressLine1,
addressLine2: formVal.shippingAddress.addressLine2, addressLine2: formVal.shippingAddress.addressLine2,
zip: formVal.shippingAddress.zip, zip: formVal.shippingAddress.zip,

View File

@@ -40,7 +40,7 @@
<div class="form-group"> <div class="form-group">
<label>{{ 'CONTACT.LABEL_MESSAGE' | translate }}</label> <label>{{ 'CONTACT.LABEL_MESSAGE' | translate }}</label>
<textarea formControlName="message" class="form-control" rows="4"></textarea> <textarea formControlName="message" class="form-control" rows="10"></textarea>
</div> </div>
<!-- File Upload Section --> <!-- File Upload Section -->
@@ -60,8 +60,8 @@
<button type="button" class="remove-btn" (click)="removeFile(i)">×</button> <button type="button" class="remove-btn" (click)="removeFile(i)">×</button>
<img *ngIf="file.type === 'image'" [src]="file.url" class="preview-img"> <img *ngIf="file.type === 'image'" [src]="file.url" class="preview-img">
<div *ngIf="file.type !== 'image'" class="file-icon"> <div *ngIf="file.type !== 'image'" class="file-icon">
<span *ngIf="file.type === 'pdf'">{{ 'CONTACT.FILE_TYPE_PDF' | translate }}</span> <span *ngIf="file.type === 'pdf'">PDF</span>
<span *ngIf="file.type === '3d'">{{ 'CONTACT.FILE_TYPE_3D' | translate }}</span> <span *ngIf="file.type === '3d'">3D</span>
</div> </div>
<div class="file-name" [title]="file.file.name">{{ file.file.name }}</div> <div class="file-name" [title]="file.file.name">{{ file.file.name }}</div>
</div> </div>

View File

@@ -1,7 +1,7 @@
<section class="contact-hero"> <section class="contact-hero">
<div class="container"> <div class="container">
<h1>{{ 'CONTACT.TITLE' | translate }}</h1> <h1>{{ 'CONTACT.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'CONTACT.HERO_SUBTITLE' | translate }}</p> <p class="subtitle">Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.</p>
</div> </div>
</section> </section>

View File

@@ -2,18 +2,22 @@
<section class="hero"> <section class="hero">
<div class="container hero-grid"> <div class="container hero-grid">
<div class="hero-copy"> <div class="hero-copy">
<p class="eyebrow">{{ 'HOME.HERO_EYEBROW' | translate }}</p> <p class="eyebrow">Stampa 3D tecnica per aziende, freelance e maker</p>
<h1 class="hero-title" [innerHTML]="'HOME.HERO_TITLE' | translate"></h1> <h1 class="hero-title">
Prezzo e tempi in pochi secondi.<br>
Dal file 3D al pezzo finito.
</h1>
<p class="hero-lead"> <p class="hero-lead">
{{ 'HOME.HERO_LEAD' | translate }} Il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.
</p> </p>
<p class="hero-subtitle"> <p class="hero-subtitle">
{{ 'HOME.HERO_SUBTITLE' | 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.
</p> </p>
<div class="hero-actions"> <div class="hero-actions">
<app-button variant="primary" routerLink="/calculator/basic">{{ 'HOME.BTN_CALCULATE' | translate }}</app-button> <app-button variant="primary" routerLink="/calculator/basic">Calcola Preventivo</app-button>
<app-button variant="outline" routerLink="/shop">{{ 'HOME.BTN_SHOP' | translate }}</app-button> <app-button variant="outline" routerLink="/shop">Vai allo shop</app-button>
<app-button variant="text" routerLink="/contact">{{ 'HOME.BTN_CONTACT' | translate }}</app-button> <app-button variant="text" routerLink="/contact">Parla con noi</app-button>
</div> </div>
</div> </div>
</div> </div>
@@ -22,31 +26,31 @@
<section class="section calculator"> <section class="section calculator">
<div class="container calculator-grid"> <div class="container calculator-grid">
<div class="calculator-copy"> <div class="calculator-copy">
<h2 class="section-title">{{ 'HOME.SEC_CALC_TITLE' | translate }}</h2> <h2 class="section-title">Preventivo immediato in pochi secondi</h2>
<p class="section-subtitle"> <p class="section-subtitle">
{{ 'HOME.SEC_CALC_SUBTITLE' | translate }} Nessuna registrazione necessaria. Non salviamo il tuo file fino al momento dell'ordine e la stima è effettuata tramite vero slicing.
</p> </p>
<ul class="calculator-list"> <ul class="calculator-list">
<li>{{ 'HOME.SEC_CALC_LIST_1' | translate }}</li> <li>Formati supportati: STL, 3MF, STEP, OBJ</li>
<li>{{ 'HOME.SEC_CALC_LIST_2' | translate }}</li> <li>Qualità: bozza, standard, alta definizione</li>
</ul> </ul>
</div> </div>
<app-card class="quote-card"> <app-card class="quote-card">
<div class="quote-header"> <div class="quote-header">
<div> <div>
<p class="quote-eyebrow">{{ 'HOME.CARD_CALC_EYEBROW' | translate }}</p> <p class="quote-eyebrow">Calcolo automatico</p>
<h3 class="quote-title">{{ 'HOME.CARD_CALC_TITLE' | translate }}</h3> <h3 class="quote-title">Prezzo e tempi in un click</h3>
</div> </div>
<span class="quote-tag">{{ 'HOME.CARD_CALC_TAG' | translate }}</span> <span class="quote-tag">Senza registrazione</span>
</div> </div>
<ul class="quote-steps"> <ul class="quote-steps">
<li>{{ 'HOME.CARD_CALC_STEP_1' | translate }}</li> <li>Carica il file 3D</li>
<li>{{ 'HOME.CARD_CALC_STEP_2' | translate }}</li> <li>Scegli materiale e qualità</li>
<li>{{ 'HOME.CARD_CALC_STEP_3' | translate }}</li> <li>Ricevi subito costo e tempo</li>
</ul> </ul>
<div class="quote-actions"> <div class="quote-actions">
<app-button variant="primary" [fullWidth]="true" routerLink="/calculator/basic">{{ 'HOME.BTN_OPEN_CALC' | translate }}</app-button> <app-button variant="primary" [fullWidth]="true" routerLink="/calculator/basic">Apri calcolatore</app-button>
<app-button variant="outline" [fullWidth]="true" routerLink="/contact">{{ 'HOME.BTN_CONTACT' | translate }}</app-button> <app-button variant="outline" [fullWidth]="true" routerLink="/contact">Parla con noi</app-button>
</div> </div>
</app-card> </app-card>
</div> </div>
@@ -56,9 +60,9 @@
<div class="capabilities-bg"></div> <div class="capabilities-bg"></div>
<div class="container"> <div class="container">
<div class="section-head"> <div class="section-head">
<h2 class="section-title">{{ 'HOME.SEC_CAP_TITLE' | translate }}</h2> <h2 class="section-title">Cosa puoi ottenere</h2>
<p class="section-subtitle"> <p class="section-subtitle">
{{ 'HOME.SEC_CAP_SUBTITLE' | translate }} Produzione su misura per prototipi, piccole serie e pezzi personalizzati.
</p> </p>
</div> </div>
<div class="cap-cards"> <div class="cap-cards">
@@ -66,29 +70,29 @@
<div class="card-image-placeholder"> <div class="card-image-placeholder">
<!-- <img src="..." alt="..."> --> <!-- <img src="..." alt="..."> -->
</div> </div>
<h3>{{ 'HOME.CAP_1_TITLE' | translate }}</h3> <h3>Prototipazione veloce</h3>
<p class="text-muted">{{ 'HOME.CAP_1_TEXT' | translate }}</p> <p class="text-muted">Dal file digitale al modello fisico in 24/48 ore. Verifica ergonomia, incastri e funzionamento prima dello stampo definitivo.</p>
</app-card> </app-card>
<app-card> <app-card>
<div class="card-image-placeholder"> <div class="card-image-placeholder">
<!-- <img src="..." alt="..."> --> <!-- <img src="..." alt="..."> -->
</div> </div>
<h3>{{ 'HOME.CAP_2_TITLE' | translate }}</h3> <h3>Pezzi personalizzati</h3>
<p class="text-muted">{{ 'HOME.CAP_2_TEXT' | translate }}</p> <p class="text-muted">Componenti unici impossibili da trovare in commercio. Riproduciamo parti rotte o creiamo adattamenti su misura.</p>
</app-card> </app-card>
<app-card> <app-card>
<div class="card-image-placeholder"> <div class="card-image-placeholder">
<!-- <img src="..." alt="..."> --> <!-- <img src="..." alt="..."> -->
</div> </div>
<h3>{{ 'HOME.CAP_3_TITLE' | translate }}</h3> <h3>Piccole serie</h3>
<p class="text-muted">{{ 'HOME.CAP_3_TEXT' | translate }}</p> <p class="text-muted">Produzione ponte o serie limitate (10-500 pezzi) con qualità ripetibile. Ideale per validazione di mercato.</p>
</app-card> </app-card>
<app-card> <app-card>
<div class="card-image-placeholder"> <div class="card-image-placeholder">
<!-- <img src="..." alt="..."> --> <!-- <img src="..." alt="..."> -->
</div> </div>
<h3>{{ 'HOME.CAP_4_TITLE' | translate }}</h3> <h3>Consulenza e CAD</h3>
<p class="text-muted">{{ 'HOME.CAP_4_TEXT' | translate }}</p> <p class="text-muted">Non hai il file 3D? Ti aiutiamo a progettarlo, ottimizzarlo per la stampa e scegliere il materiale giusto.</p>
</app-card> </app-card>
</div> </div>
</div> </div>
@@ -97,32 +101,33 @@
<section class="section shop"> <section class="section shop">
<div class="container split"> <div class="container split">
<div class="shop-copy"> <div class="shop-copy">
<h2 class="section-title">{{ 'HOME.SEC_SHOP_TITLE' | translate }}</h2> <h2 class="section-title">Shop di soluzioni tecniche pronte</h2>
<p> <p>
{{ 'HOME.SEC_SHOP_TEXT' | translate }} Prodotti selezionati, testati in laboratorio e pronti all'uso. Risolvono problemi reali con
funzionalità concrete.
</p> </p>
<ul class="shop-list"> <ul class="shop-list">
<li>{{ 'HOME.SEC_SHOP_LIST_1' | translate }}</li> <li>Accessori funzionali per officine e laboratori</li>
<li>{{ 'HOME.SEC_SHOP_LIST_2' | translate }}</li> <li>Ricambi e componenti difficili da reperire</li>
<li>{{ 'HOME.SEC_SHOP_LIST_3' | translate }}</li> <li>Supporti e organizzatori per migliorare i flussi di lavoro</li>
</ul> </ul>
<div class="shop-actions"> <div class="shop-actions">
<app-button variant="primary" routerLink="/shop">{{ 'HOME.BTN_DISCOVER' | translate }}</app-button> <app-button variant="primary" routerLink="/shop">Scopri i prodotti</app-button>
<app-button variant="outline" routerLink="/contact">{{ 'HOME.BTN_REQ_SOLUTION' | translate }}</app-button> <app-button variant="outline" routerLink="/contact">Richiedi una soluzione</app-button>
</div> </div>
</div> </div>
<div class="shop-cards"> <div class="shop-cards">
<app-card> <app-card>
<h3>{{ 'HOME.CARD_SHOP_1_TITLE' | translate }}</h3> <h3>Best seller tecnici</h3>
<p class="text-muted">{{ 'HOME.CARD_SHOP_1_TEXT' | translate }}</p> <p class="text-muted">Soluzioni provate sul campo e già pronte alla spedizione.</p>
</app-card> </app-card>
<app-card> <app-card>
<h3>{{ 'HOME.CARD_SHOP_2_TITLE' | translate }}</h3> <h3>Kit pronti all'uso</h3>
<p class="text-muted">{{ 'HOME.CARD_SHOP_2_TEXT' | translate }}</p> <p class="text-muted">Componenti compatibili e facili da montare senza sorprese.</p>
</app-card> </app-card>
<app-card> <app-card>
<h3>{{ 'HOME.CARD_SHOP_3_TITLE' | translate }}</h3> <h3>Su richiesta</h3>
<p class="text-muted">{{ 'HOME.CARD_SHOP_3_TEXT' | translate }}</p> <p class="text-muted">Non trovi quello che serve? Lo progettiamo e lo produciamo per te.</p>
</app-card> </app-card>
</div> </div>
</div> </div>
@@ -131,16 +136,17 @@
<section class="section about"> <section class="section about">
<div class="container about-grid"> <div class="container about-grid">
<div class="about-copy"> <div class="about-copy">
<h2 class="section-title">{{ 'HOME.SEC_ABOUT_TITLE' | translate }}</h2> <h2 class="section-title">Su di noi</h2>
<p> <p>
{{ 'HOME.SEC_ABOUT_TEXT' | translate }} 3D fab è un laboratorio tecnico di stampa 3D. Seguiamo progetti dalla consulenza iniziale
alla produzione, con tempi chiari e supporto diretto.
</p> </p>
<app-button variant="outline" routerLink="/contact">{{ 'HOME.BTN_CONTACT' | translate }}</app-button> <app-button variant="outline" routerLink="/contact">Contattaci</app-button>
</div> </div>
<div class="about-media"> <div class="about-media">
<div class="about-feature-image"> <div class="about-feature-image">
<!-- Foto founders --> <!-- Foto founders -->
<span class="text-sm">{{ 'HOME.FOUNDERS_PHOTO' | translate }}</span> <span class="text-sm">Foto Founders</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,25 +0,0 @@
<div class="container hero">
<h1>{{ 'ORDER_CONFIRMED.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'ORDER_CONFIRMED.SUBTITLE' | translate }}</p>
</div>
<div class="container">
<div class="confirmation-layout">
<app-card class="status-card">
<div class="status-badge">{{ 'ORDER_CONFIRMED.STATUS' | translate }}</div>
<h2>{{ 'ORDER_CONFIRMED.HEADING' | translate }}</h2>
<p class="order-ref" *ngIf="orderNumber">
{{ 'ORDER_CONFIRMED.ORDER_REF' | translate }}: <strong>#{{ orderNumber }}</strong>
</p>
<div class="message-block">
<p>{{ 'ORDER_CONFIRMED.PROCESSING_TEXT' | translate }}</p>
<p>{{ 'ORDER_CONFIRMED.EMAIL_TEXT' | translate }}</p>
</div>
<div class="actions">
<app-button (click)="goHome()">{{ 'ORDER_CONFIRMED.BACK_HOME' | translate }}</app-button>
</div>
</app-card>
</div>
</div>

View File

@@ -1,62 +0,0 @@
.hero {
padding: var(--space-12) 0 var(--space-8);
text-align: center;
h1 {
font-size: 2.4rem;
margin-bottom: var(--space-2);
}
}
.subtitle {
font-size: 1.1rem;
color: var(--color-text-muted);
max-width: 720px;
margin: 0 auto;
}
.confirmation-layout {
max-width: 760px;
margin: 0 auto var(--space-12);
}
.status-badge {
display: inline-block;
padding: 0.35rem 0.65rem;
border-radius: 999px;
background: #eef8f0;
color: #136f2d;
font-weight: 700;
font-size: 0.85rem;
margin-bottom: var(--space-4);
}
h2 {
margin: 0 0 var(--space-3);
}
.order-ref {
margin: 0 0 var(--space-4);
color: var(--color-text-muted);
}
.message-block {
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-5);
margin-bottom: var(--space-6);
p {
margin: 0;
line-height: 1.45;
}
p + p {
margin-top: var(--space-3);
}
}
.actions {
max-width: 320px;
}

View File

@@ -1,48 +0,0 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
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';
@Component({
selector: 'app-order-confirmed',
standalone: true,
imports: [CommonModule, TranslateModule, AppButtonComponent, AppCardComponent],
templateUrl: './order-confirmed.component.html',
styleUrl: './order-confirmed.component.scss'
})
export class OrderConfirmedComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private quoteService = inject(QuoteEstimatorService);
orderId: string | null = null;
orderNumber: string | null = null;
ngOnInit(): void {
this.orderId = this.route.snapshot.paramMap.get('orderId');
if (!this.orderId) {
return;
}
this.orderNumber = this.extractOrderNumber(this.orderId);
this.quoteService.getOrder(this.orderId).subscribe({
next: (order) => {
this.orderNumber = order?.orderNumber ?? this.orderNumber;
},
error: () => {
// Keep fallback derived from UUID when API is unavailable.
}
});
}
goHome(): void {
this.router.navigate(['/']);
}
private extractOrderNumber(orderId: string): string {
return orderId.split('-')[0];
}
}

View File

@@ -5,6 +5,7 @@
<div class="container"> <div class="container">
<div class="payment-layout" *ngIf="order() as o"> <div class="payment-layout" *ngIf="order() as o">
<div class="payment-main"> <div class="payment-main">
<app-card class="mb-6"> <app-card class="mb-6">
<div class="card-header-simple"> <div class="card-header-simple">
@@ -13,43 +14,36 @@
<div class="payment-selection"> <div class="payment-selection">
<div class="methods-grid"> <div class="methods-grid">
<div <div
class="type-option" class="type-option"
[class.selected]="selectedPaymentMethod === 'twint'" [class.selected]="selectedPaymentMethod === 'twint'"
(click)="selectPayment('twint')"> (click)="selectPayment('twint')">
<span class="method-name">{{ 'PAYMENT.METHOD_TWINT' | translate }}</span> <span class="method-name">TWINT</span>
</div> </div>
<div <div
class="type-option" class="type-option"
[class.selected]="selectedPaymentMethod === 'bill'" [class.selected]="selectedPaymentMethod === 'bill'"
(click)="selectPayment('bill')"> (click)="selectPayment('bill')">
<span class="method-name">{{ 'PAYMENT.METHOD_BANK' | translate }}</span> <span class="method-name">QR Bill / Bank Transfer</span>
</div> </div>
</div> </div>
</div> </div>
<div class="payment-details fade-in text-center" *ngIf="selectedPaymentMethod === 'twint'"> <!-- TWINT Details -->
<div class="payment-details fade-in" *ngIf="selectedPaymentMethod === 'twint'">
<div class="details-header"> <div class="details-header">
<h4>{{ 'PAYMENT.TWINT_TITLE' | translate }}</h4> <h4>{{ 'PAYMENT.TWINT_TITLE' | translate }}</h4>
</div> </div>
<div class="qr-placeholder"> <div class="qr-placeholder">
<img <div class="qr-box">
*ngIf="twintQrUrl()" <span>QR CODE</span>
class="twint-qr"
[src]="getTwintQrUrl()"
(error)="onTwintQrError()"
alt="TWINT payment QR" />
<p>{{ 'PAYMENT.TWINT_DESC' | translate }}</p>
<p class="billing-hint">{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}</p>
<div class="twint-mobile-action">
<app-button variant="outline" (click)="openTwintPayment()" [fullWidth]="true">
{{ 'PAYMENT.TWINT_OPEN' | translate }}
</app-button>
</div> </div>
<p>{{ 'PAYMENT.TWINT_DESC' | translate }}</p>
<p class="amount">{{ 'PAYMENT.TOTAL' | translate }}: {{ o.totalChf | currency:'CHF' }}</p> <p class="amount">{{ 'PAYMENT.TOTAL' | translate }}: {{ o.totalChf | currency:'CHF' }}</p>
</div> </div>
</div> </div>
<!-- QR Bill Details -->
<div class="payment-details fade-in" *ngIf="selectedPaymentMethod === 'bill'"> <div class="payment-details fade-in" *ngIf="selectedPaymentMethod === 'bill'">
<div class="details-header"> <div class="details-header">
<h4>{{ 'PAYMENT.BANK_TITLE' | translate }}</h4> <h4>{{ 'PAYMENT.BANK_TITLE' | translate }}</h4>
@@ -57,9 +51,8 @@
<div class="bank-details"> <div class="bank-details">
<p><strong>{{ 'PAYMENT.BANK_OWNER' | translate }}:</strong> 3D Fab Switzerland</p> <p><strong>{{ 'PAYMENT.BANK_OWNER' | translate }}:</strong> 3D Fab Switzerland</p>
<p><strong>{{ 'PAYMENT.BANK_IBAN' | translate }}:</strong> CH98 0000 0000 0000 0000 0</p> <p><strong>{{ 'PAYMENT.BANK_IBAN' | translate }}:</strong> CH98 0000 0000 0000 0000 0</p>
<p><strong>{{ 'PAYMENT.BANK_REF' | translate }}:</strong> {{ getDisplayOrderNumber(o) }}</p> <p><strong>{{ 'PAYMENT.BANK_REF' | translate }}:</strong> {{ o.id }}</p>
<p class="billing-hint">{{ 'PAYMENT.BILLING_INFO_HINT' | translate }}</p>
<div class="qr-bill-actions"> <div class="qr-bill-actions">
<app-button variant="outline" (click)="downloadInvoice()"> <app-button variant="outline" (click)="downloadInvoice()">
{{ 'PAYMENT.DOWNLOAD_QR' | translate }} {{ 'PAYMENT.DOWNLOAD_QR' | translate }}
@@ -80,7 +73,7 @@
<app-card class="sticky-card"> <app-card class="sticky-card">
<div class="card-header-simple"> <div class="card-header-simple">
<h3>{{ 'PAYMENT.SUMMARY_TITLE' | translate }}</h3> <h3>{{ 'PAYMENT.SUMMARY_TITLE' | translate }}</h3>
<p class="order-id">#{{ getDisplayOrderNumber(o) }}</p> <p class="order-id">#{{ o.id.substring(0, 8) }}</p>
</div> </div>
<div class="summary-totals"> <div class="summary-totals">
@@ -103,6 +96,7 @@
</div> </div>
</app-card> </app-card>
</div> </div>
</div> </div>
<div *ngIf="loading()" class="loading-state"> <div *ngIf="loading()" class="loading-state">

View File

@@ -1,6 +1,6 @@
.hero { .hero {
padding: var(--space-12) 0 var(--space-8); padding: var(--space-12) 0 var(--space-8);
text-align: center; text-align: center;
h1 { h1 {
font-size: 2.5rem; font-size: 2.5rem;
@@ -8,11 +8,11 @@
} }
} }
.subtitle { .subtitle {
font-size: 1.125rem; font-size: 1.125rem;
color: var(--color-text-muted); color: var(--color-text-muted);
max-width: 600px; max-width: 600px;
margin: 0 auto; margin: 0 auto;
} }
.payment-layout { .payment-layout {
@@ -32,7 +32,7 @@
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
padding-bottom: var(--space-4); padding-bottom: var(--space-4);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
h3 { h3 {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
@@ -84,7 +84,7 @@
border-color: var(--color-brand); border-color: var(--color-brand);
background-color: var(--color-neutral-100); background-color: var(--color-neutral-100);
color: #000; color: #000;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); box-shadow: 0 1px 2px rgba(0,0,0,0.05);
} }
} }
@@ -95,28 +95,6 @@
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
border: 1px solid var(--color-border); 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 { .details-header {
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
h4 { h4 {
@@ -127,29 +105,23 @@
} }
} }
.qr-placeholder { .qr-placeholder {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center; text-align: center;
.twint-qr { .qr-box {
width: 240px; width: 180px;
height: 240px; height: 180px;
background-color: #fff; background-color: white;
border: 1px solid var(--color-border); border: 2px solid var(--color-neutral-900);
border-radius: var(--radius-md); display: flex;
padding: var(--space-2); align-items: center;
justify-content: center;
font-weight: bold;
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
object-fit: contain; border-radius: var(--radius-md);
box-shadow: 0 6px 18px rgba(44, 37, 84, 0.08);
}
.twint-mobile-action {
width: 100%;
max-width: 320px;
margin-top: var(--space-3);
} }
.amount { .amount {
@@ -160,12 +132,6 @@
} }
} }
.billing-hint {
margin-top: var(--space-3);
font-size: 0.95rem;
color: var(--color-text-muted);
}
.bank-details { .bank-details {
p { p {
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
@@ -217,20 +183,13 @@
} }
@keyframes fadeIn { @keyframes fadeIn {
from { from { opacity: 0; transform: translateY(-5px); }
opacity: 0; to { opacity: 1; transform: translateY(0); }
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
.mb-6 { margin-bottom: var(--space-6); } .mb-6 { margin-bottom: var(--space-6); }
.error-message, .error-message, .loading-state {
.loading-state {
margin-top: var(--space-12); margin-top: var(--space-12);
text-align: center; text-align: center;
} }

View File

@@ -5,7 +5,6 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto
import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service'; import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { environment } from '../../../environments/environment';
@Component({ @Component({
selector: 'app-payment', selector: 'app-payment',
@@ -24,14 +23,11 @@ export class PaymentComponent implements OnInit {
order = signal<any>(null); order = signal<any>(null);
loading = signal(true); loading = signal(true);
error = signal<string | null>(null); error = signal<string | null>(null);
twintOpenUrl = signal<string | null>(null);
twintQrUrl = signal<string | null>(null);
ngOnInit(): void { ngOnInit(): void {
this.orderId = this.route.snapshot.paramMap.get('orderId'); this.orderId = this.route.snapshot.paramMap.get('orderId');
if (this.orderId) { if (this.orderId) {
this.loadOrder(); this.loadOrder();
this.loadTwintPayment();
} else { } else {
this.error.set('Order ID not found.'); this.error.set('Order ID not found.');
this.loading.set(false); this.loading.set(false);
@@ -58,16 +54,13 @@ export class PaymentComponent implements OnInit {
} }
downloadInvoice() { downloadInvoice() {
const orderId = this.orderId; if (!this.orderId) return;
if (!orderId) return; this.quoteService.getOrderInvoice(this.orderId).subscribe({
this.quoteService.getOrderInvoice(orderId).subscribe({
next: (blob) => { next: (blob) => {
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
const fallbackOrderNumber = this.extractOrderNumber(orderId); a.download = `invoice-${this.orderId}.pdf`;
const orderNumber = this.order()?.orderNumber ?? fallbackOrderNumber;
a.download = `invoice-${orderNumber}.pdf`;
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
}, },
@@ -75,65 +68,9 @@ export class PaymentComponent implements OnInit {
}); });
} }
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() ?? '';
}
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 { completeOrder(): void {
if (!this.orderId) { // Simulate payment completion
this.router.navigate(['/']); alert('Payment Simulated! Order marked as PAID.');
return; this.router.navigate(['/']);
}
this.router.navigate(['/order-confirmed', this.orderId]);
}
getDisplayOrderNumber(order: any): string {
if (order?.orderNumber) {
return order.orderNumber;
}
if (order?.id) {
return this.extractOrderNumber(order.id);
}
return 'N/A';
}
private extractOrderNumber(orderId: string): string {
return orderId.split('-')[0];
} }
} }

View File

@@ -20,6 +20,6 @@
</div> </div>
</div> </div>
} @else { } @else {
<p>{{ 'SHOP.NOT_FOUND' | translate }}</p> <p>Prodotto non trovato.</p>
} }
</div> </div>

View File

@@ -32,14 +32,12 @@
.btn-outline { .btn-outline {
background-color: transparent; background-color: transparent;
border-color: var(--color-brand); border-color: var(--color-border);
border-width: 2px; color: var(--color-text);
padding: calc(0.5rem - 1px) calc(1rem - 1px);
color: var(--color-neutral-900);
font-weight: 600;
&:hover:not(:disabled) { &:hover:not(:disabled) {
background-color: var(--color-brand); border-color: var(--color-brand);
color: var(--color-neutral-900); color: var(--color-neutral-900);
background-color: rgba(250, 207, 10, 0.1); /* Low opacity brand color */
} }
} }

View File

@@ -25,7 +25,6 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
private controls!: OrbitControls; private controls!: OrbitControls;
private animationId: number | null = null; private animationId: number | null = null;
private currentMesh: THREE.Mesh | null = null; private currentMesh: THREE.Mesh | null = null;
private autoRotate = true;
loading = false; loading = false;
@@ -39,14 +38,14 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
} }
if (changes['color'] && this.currentMesh && !changes['file']) { if (changes['color'] && this.currentMesh && !changes['file']) {
this.applyColorStyle(this.color); // Update existing mesh color if only color changed
const mat = this.currentMesh.material as THREE.MeshPhongMaterial;
mat.color.set(this.color);
} }
} }
ngOnDestroy() { ngOnDestroy() {
if (this.animationId) cancelAnimationFrame(this.animationId); if (this.animationId) cancelAnimationFrame(this.animationId);
this.clearCurrentMesh();
if (this.controls) this.controls.dispose();
if (this.renderer) this.renderer.dispose(); if (this.renderer) this.renderer.dispose();
} }
@@ -55,51 +54,28 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
const height = this.rendererContainer.nativeElement.clientHeight; const height = this.rendererContainer.nativeElement.clientHeight;
this.scene = new THREE.Scene(); this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0xf4f8fc); this.scene.background = new THREE.Color(0xf7f6f2); // Neutral-50
// Lights // Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.75); const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
this.scene.add(ambientLight); this.scene.add(ambientLight);
const hemiLight = new THREE.HemisphereLight(0xf8fbff, 0xc8d3df, 0.95); const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
hemiLight.position.set(0, 30, 0); directionalLight.position.set(1, 1, 1);
this.scene.add(hemiLight); this.scene.add(directionalLight);
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 // Camera
this.camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000); this.camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000);
this.camera.position.z = 100; this.camera.position.z = 100;
// Renderer // Renderer
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' }); this.renderer = new THREE.WebGLRenderer({ antialias: true });
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.renderer.setSize(width, height);
this.rendererContainer.nativeElement.appendChild(this.renderer.domElement); this.rendererContainer.nativeElement.appendChild(this.renderer.domElement);
// Controls // Controls
this.controls = new OrbitControls(this.camera, this.renderer.domElement); this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true; 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(); this.animate();
@@ -119,27 +95,24 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
private loadFile(file: File) { private loadFile(file: File) {
this.loading = true; this.loading = true;
this.autoRotate = true;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
try { try {
const loader = new STLLoader(); const loader = new STLLoader();
const geometry = loader.parse(event.target?.result as ArrayBuffer); const geometry = loader.parse(event.target?.result as ArrayBuffer);
this.clearCurrentMesh(); if (this.currentMesh) {
this.scene.remove(this.currentMesh);
this.currentMesh.geometry.dispose();
}
geometry.computeVertexNormals(); const material = new THREE.MeshPhongMaterial({
color: this.color,
const material = new THREE.MeshStandardMaterial({ specular: 0x111111,
color: this.color, shininess: 200
roughness: 0.42,
metalness: 0.05,
emissive: 0x000000,
emissiveIntensity: 0
}); });
this.currentMesh = new THREE.Mesh(geometry, material); this.currentMesh = new THREE.Mesh(geometry, material);
this.applyColorStyle(this.color);
// Center geometry // Center geometry
geometry.computeBoundingBox(); geometry.computeBoundingBox();
@@ -167,10 +140,9 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
// Calculate distance towards camera (z-axis) // Calculate distance towards camera (z-axis)
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)); let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2));
cameraZ *= 1.72; cameraZ *= 1.5; // Tighter zoom (reduced from 2.5)
this.camera.position.set(cameraZ * 0.65, cameraZ * 0.95, cameraZ * 1.1); this.camera.position.z = cameraZ;
this.camera.lookAt(0, 0, 0);
this.camera.updateProjectionMatrix(); this.camera.updateProjectionMatrix();
this.controls.update(); this.controls.update();
@@ -185,63 +157,9 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
private animate() { private animate() {
this.animationId = requestAnimationFrame(() => this.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.controls) this.controls.update();
if (this.renderer && this.scene && this.camera) { if (this.renderer && this.scene && this.camera) {
this.renderer.render(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;
}
} }

View File

@@ -40,7 +40,7 @@
"CTA_START": "Start Now", "CTA_START": "Start Now",
"BUSINESS": "Business", "BUSINESS": "Business",
"PRIVATE": "Private", "PRIVATE": "Private",
"MODE_EASY": "Quick", "MODE_EASY": "Easy Print",
"MODE_ADVANCED": "Advanced", "MODE_ADVANCED": "Advanced",
"UPLOAD_LABEL": "Drag your 3D file here", "UPLOAD_LABEL": "Drag your 3D file here",
"UPLOAD_SUB": "Supports STL, 3MF, STEP, OBJ up to 50MB", "UPLOAD_SUB": "Supports STL, 3MF, STEP, OBJ up to 50MB",
@@ -61,6 +61,9 @@
"ORDER": "Order Now", "ORDER": "Order Now",
"CONSULT": "Request Consultation", "CONSULT": "Request Consultation",
"ERROR_GENERIC": "An error occurred while calculating the quote.", "ERROR_GENERIC": "An error occurred while calculating the quote.",
"ERROR_UPLOAD_FAILED": "File upload failed. Please try again.",
"ERROR_VIRUS_DETECTED": "File removed (virus detected)",
"ERROR_SLICING_FAILED": "Slicing error (complex geometry?)",
"NEW_QUOTE": "Calculate New Quote", "NEW_QUOTE": "Calculate New Quote",
"ORDER_SUCCESS_TITLE": "Order Submitted Successfully", "ORDER_SUCCESS_TITLE": "Order Submitted Successfully",
"ORDER_SUCCESS_DESC": "We have received your order details. You will receive a confirmation email shortly with the payment details.", "ORDER_SUCCESS_DESC": "We have received your order details. You will receive a confirmation email shortly with the payment details.",
@@ -155,6 +158,16 @@
"CONTACT_INFO": "Contact Information", "CONTACT_INFO": "Contact Information",
"BILLING_ADDR": "Billing Address", "BILLING_ADDR": "Billing Address",
"SHIPPING_ADDR": "Shipping Address", "SHIPPING_ADDR": "Shipping Address",
"SHIPPING_SAME": "Shipping address same as billing",
"ORDER_SUMMARY": "Order Summary",
"SUBTOTAL": "Subtotal",
"SETUP_FEE": "Setup Fee",
"SHIPPING": "Shipping",
"TOTAL": "Total",
"PLACE_ORDER": "Place Order",
"PROCESSING": "Processing...",
"PRIVATE": "Private",
"COMPANY": "Company",
"FIRST_NAME": "First Name", "FIRST_NAME": "First Name",
"LAST_NAME": "Last Name", "LAST_NAME": "Last Name",
"EMAIL": "Email", "EMAIL": "Email",
@@ -164,29 +177,17 @@
"ADDRESS_2": "Address Line 2 (Optional)", "ADDRESS_2": "Address Line 2 (Optional)",
"ZIP": "ZIP Code", "ZIP": "ZIP Code",
"CITY": "City", "CITY": "City",
"COUNTRY": "Country", "COUNTRY": "Country"
"SHIPPING_SAME": "Shipping address same as billing",
"PLACE_ORDER": "Place Order",
"PROCESSING": "Processing...",
"SUMMARY_TITLE": "Order Summary",
"SUBTOTAL": "Subtotal",
"SETUP_FEE": "Setup Fee",
"TOTAL": "Total",
"QTY": "Qty",
"SHIPPING": "Shipping"
}, },
"PAYMENT": { "PAYMENT": {
"TITLE": "Payment", "TITLE": "Payment",
"METHOD": "Payment Method", "METHOD": "Payment Method",
"TWINT_TITLE": "Pay with TWINT", "TWINT_TITLE": "Pay with TWINT",
"TWINT_DESC": "Scan the code with your TWINT app", "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_TITLE": "Bank Transfer",
"BANK_OWNER": "Owner", "BANK_OWNER": "Owner",
"BANK_IBAN": "IBAN", "BANK_IBAN": "IBAN",
"BANK_REF": "Reference", "BANK_REF": "Reference",
"BILLING_INFO_HINT": "Add the same information used in billing.",
"DOWNLOAD_QR": "Download QR-Invoice (PDF)", "DOWNLOAD_QR": "Download QR-Invoice (PDF)",
"CONFIRM": "Confirm Order", "CONFIRM": "Confirm Order",
"SUMMARY_TITLE": "Order Summary", "SUMMARY_TITLE": "Order Summary",
@@ -195,15 +196,5 @@
"SETUP_FEE": "Setup Fee", "SETUP_FEE": "Setup Fee",
"TOTAL": "Total", "TOTAL": "Total",
"LOADING": "Loading order details..." "LOADING": "Loading order details..."
},
"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"
} }
} }

View File

@@ -11,59 +11,13 @@
"TERMS": "Termini & Condizioni", "TERMS": "Termini & Condizioni",
"CONTACT": "Contattaci" "CONTACT": "Contattaci"
}, },
"HOME": {
"HERO_EYEBROW": "Stampa 3D tecnica per aziende, freelance e maker",
"HERO_TITLE": "Prezzo e tempi in pochi secondi.<br>Dal file 3D al pezzo finito.",
"HERO_LEAD": "Il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.",
"HERO_SUBTITLE": "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.",
"BTN_CALCULATE": "Calcola Preventivo",
"BTN_SHOP": "Vai allo shop",
"BTN_CONTACT": "Parla con noi",
"SEC_CALC_TITLE": "Preventivo immediato in pochi secondi",
"SEC_CALC_SUBTITLE": "Nessuna registrazione necessaria. Non salviamo il tuo file fino al momento dell'ordine e la stima è effettuata tramite vero slicing.",
"SEC_CALC_LIST_1": "Formati supportati: STL, 3MF, STEP, OBJ",
"SEC_CALC_LIST_2": "Qualità: bozza, standard, alta definizione",
"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. Ideale per validazione di mercato.",
"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.",
"SEC_SHOP_TITLE": "Shop di soluzioni tecniche pronte",
"SEC_SHOP_TEXT": "Prodotti selezionati, testati in laboratorio e pronti all'uso. Risolvono problemi reali con funzionalità concrete.",
"SEC_SHOP_LIST_1": "Accessori funzionali per officine e laboratori",
"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": "Best seller tecnici",
"CARD_SHOP_1_TEXT": "Soluzioni provate sul campo e già pronte alla spedizione.",
"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": "3D fab è un laboratorio tecnico di stampa 3D. Seguiamo progetti dalla consulenza iniziale alla produzione, con tempi chiari e supporto diretto.",
"FOUNDERS_PHOTO": "Foto Founders"
},
"CALC": { "CALC": {
"TITLE": "Calcola Preventivo 3D", "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...) e ricevi una stima immediata di costi e tempi di stampa.",
"CTA_START": "Inizia Ora", "CTA_START": "Inizia Ora",
"BUSINESS": "Aziende", "BUSINESS": "Aziende",
"PRIVATE": "Privati", "PRIVATE": "Privati",
"MODE_EASY": "Base", "MODE_EASY": "Stampa Facile",
"MODE_ADVANCED": "Avanzata", "MODE_ADVANCED": "Avanzata",
"UPLOAD_LABEL": "Trascina il tuo file 3D qui", "UPLOAD_LABEL": "Trascina il tuo file 3D qui",
"UPLOAD_SUB": "Supportiamo STL, 3MF, STEP, OBJ fino a 50MB", "UPLOAD_SUB": "Supportiamo STL, 3MF, STEP, OBJ fino a 50MB",
@@ -86,6 +40,9 @@
"ORDER": "Ordina Ora", "ORDER": "Ordina Ora",
"CONSULT": "Richiedi Consulenza", "CONSULT": "Richiedi Consulenza",
"ERROR_GENERIC": "Si è verificato un errore durante il calcolo del preventivo.", "ERROR_GENERIC": "Si è verificato un errore durante il calcolo del preventivo.",
"ERROR_UPLOAD_FAILED": "Caricamento file fallito. Riprova.",
"ERROR_VIRUS_DETECTED": "File rimosso (virus rilevato)",
"ERROR_SLICING_FAILED": "Errore slicing (geometria complessa?)",
"NEW_QUOTE": "Calcola Nuovo Preventivo", "NEW_QUOTE": "Calcola Nuovo Preventivo",
"ORDER_SUCCESS_TITLE": "Ordine Inviato con Successo", "ORDER_SUCCESS_TITLE": "Ordine Inviato con Successo",
"ORDER_SUCCESS_DESC": "Abbiamo ricevuto i dettagli del tuo ordine. Riceverai a breve una email di conferma con i dettagli per il pagamento.", "ORDER_SUCCESS_DESC": "Abbiamo ricevuto i dettagli del tuo ordine. Riceverai a breve una email di conferma con i dettagli per il pagamento.",
@@ -93,53 +50,13 @@
"BENEFITS_1": "Preventivo automatico con costo e tempo immediati", "BENEFITS_1": "Preventivo automatico con costo e tempo immediati",
"BENEFITS_2": "Materiali selezionati e qualità controllata", "BENEFITS_2": "Materiali selezionati e qualità controllata",
"BENEFITS_3": "Consulenza CAD se il file ha bisogno di modifiche", "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}} Costo di Setup"
},
"QUOTE": {
"PROCEED_ORDER": "Procedi con l'ordine",
"CONSULT": "Richiedi Consulenza",
"TOTAL": "Totale"
},
"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"
},
"COMMON": {
"REQUIRED": "Campo obbligatorio",
"INVALID_EMAIL": "Email non valida",
"BACK": "Indietro",
"OPTIONAL": "(Opzionale)"
}, },
"SHOP": { "SHOP": {
"TITLE": "Soluzioni tecniche", "TITLE": "Soluzioni tecniche",
"SUBTITLE": "Prodotti pronti che risolvono problemi pratici", "SUBTITLE": "Prodotti pronti che risolvono problemi pratici",
"ADD_CART": "Aggiungi al Carrello", "ADD_CART": "Aggiungi al Carrello",
"BACK": "Torna allo Shop", "BACK": "Torna allo Shop"
"NOT_FOUND": "Prodotto non trovato."
}, },
"ABOUT": { "ABOUT": {
"TITLE": "Chi Siamo", "TITLE": "Chi Siamo",
@@ -212,10 +129,7 @@
"ERR_MAX_FILES": "Limite massimo di 15 file raggiunto.", "ERR_MAX_FILES": "Limite massimo di 15 file raggiunto.",
"SUCCESS_TITLE": "Messaggio Inviato con Successo", "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.", "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"
}, },
"CHECKOUT": { "CHECKOUT": {
"TITLE": "Checkout", "TITLE": "Checkout",
@@ -223,41 +137,36 @@
"CONTACT_INFO": "Informazioni di Contatto", "CONTACT_INFO": "Informazioni di Contatto",
"BILLING_ADDR": "Indirizzo di Fatturazione", "BILLING_ADDR": "Indirizzo di Fatturazione",
"SHIPPING_ADDR": "Indirizzo di Spedizione", "SHIPPING_ADDR": "Indirizzo di Spedizione",
"SHIPPING_SAME": "Indirizzo di spedizione uguale a quello di fatturazione",
"ORDER_SUMMARY": "Riepilogo Ordine",
"SUBTOTAL": "Subtotale",
"SETUP_FEE": "Costo Setup",
"SHIPPING": "Spedizione",
"TOTAL": "Totale",
"PLACE_ORDER": "Conferma Ordine",
"PROCESSING": "Elaborazione...",
"PRIVATE": "Privato",
"COMPANY": "Azienda",
"FIRST_NAME": "Nome", "FIRST_NAME": "Nome",
"LAST_NAME": "Cognome", "LAST_NAME": "Cognome",
"EMAIL": "Email", "EMAIL": "Email",
"PHONE": "Telefono", "PHONE": "Telefono",
"COMPANY_NAME": "Nome Azienda", "COMPANY_NAME": "Nome Azienda",
"ADDRESS_1": "Indirizzo (Via e numero)", "ADDRESS_1": "Indirizzo riga 1",
"ADDRESS_2": "Informazioni aggiuntive (opzionale)", "ADDRESS_2": "Indirizzo riga 2 (Opzionale)",
"ZIP": "CAP", "ZIP": "CAP",
"CITY": "Città", "CITY": "Città",
"COUNTRY": "Paese", "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",
"SETUP_FEE": "Costo di Avvio",
"TOTAL": "Totale",
"QTY": "Qtà",
"SHIPPING": "Spedizione",
"INVALID_EMAIL": "Email non valida",
"COMPANY_OPTIONAL": "Nome Azienda (Opzionale)",
"REF_PERSON_OPTIONAL": "Persona di Riferimento (Opzionale)"
}, },
"PAYMENT": { "PAYMENT": {
"TITLE": "Pagamento", "TITLE": "Pagamento",
"METHOD": "Metodo di Pagamento", "METHOD": "Metodo di Pagamento",
"TWINT_TITLE": "Paga con TWINT", "TWINT_TITLE": "Paga con TWINT",
"TWINT_DESC": "Inquadra il codice con l'app TWINT", "TWINT_DESC": "Inquadra il codice con l'app TWINT",
"TWINT_OPEN": "Apri direttamente in TWINT",
"TWINT_LINK": "Apri link di pagamento",
"BANK_TITLE": "Bonifico Bancario", "BANK_TITLE": "Bonifico Bancario",
"BANK_OWNER": "Titolare", "BANK_OWNER": "Titolare",
"BANK_IBAN": "IBAN", "BANK_IBAN": "IBAN",
"BANK_REF": "Riferimento", "BANK_REF": "Riferimento",
"BILLING_INFO_HINT": "Aggiungi le informazioni uguali a quelle della fatturazione.",
"DOWNLOAD_QR": "Scarica QR-Fattura (PDF)", "DOWNLOAD_QR": "Scarica QR-Fattura (PDF)",
"CONFIRM": "Conferma Ordine", "CONFIRM": "Conferma Ordine",
"SUMMARY_TITLE": "Riepilogo Ordine", "SUMMARY_TITLE": "Riepilogo Ordine",
@@ -265,18 +174,6 @@
"SHIPPING": "Spedizione", "SHIPPING": "Spedizione",
"SETUP_FEE": "Costo Setup", "SETUP_FEE": "Costo Setup",
"TOTAL": "Totale", "TOTAL": "Totale",
"LOADING": "Caricamento dettagli ordine...", "LOADING": "Caricamento dettagli ordine..."
"METHOD_TWINT": "TWINT",
"METHOD_BANK": "Fattura QR / Bonifico"
},
"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"
} }
} }