23 Commits

Author SHA1 Message Date
c1652798b4 feat(back-end front-end): new UX for
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 32s
Build, Test and Deploy / build-and-push (push) Successful in 39s
Build, Test and Deploy / deploy (push) Successful in 10s
2026-02-23 18:56:24 +01:00
ec4d512136 feat(back-end front-end): uuid truncated for better UX
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 36s
Build, Test and Deploy / build-and-push (push) Successful in 41s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-23 17:30:43 +01:00
abf47e0003 feat(back-end): email service and test
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 33s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-23 16:20:11 +01:00
0438ba3ae5 feat(back-end): email service and test
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 49s
Build, Test and Deploy / build-and-push (push) Successful in 55s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-23 15:23:11 +01:00
c3f9539988 feat(front-enc):
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 34s
Build, Test and Deploy / build-and-push (push) Successful in 23s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-20 17:47:34 +01:00
1d82230564 feat(front-enc): fix back-ground
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 37s
Build, Test and Deploy / build-and-push (push) Successful in 39s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-20 17:11:44 +01:00
15d5d31d06 feat(back-end, front-enc): twint payment 2026-02-20 17:09:42 +01:00
ccc53b7d4f feat(back-end): bill and qr
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 33s
Build, Test and Deploy / build-and-push (push) Successful in 1m8s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-20 14:54:28 +01:00
8e12b3bcdf feat(back-end): add ClamAV service remember to add env and compose.deploy on server 2026-02-20 10:32:07 +01:00
0d23521cac feat(back-end): add ClamAV service remember to add env and compose.deploy on server 2026-02-19 15:59:33 +01:00
2189e58cc6 fix(deploy): update env
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 26s
Build, Test and Deploy / build-and-push (push) Successful in 13s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-18 22:04:22 +01:00
87f43f2239 fix(deploy): update env
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 27s
Build, Test and Deploy / build-and-push (push) Successful in 13s
Build, Test and Deploy / deploy (push) Failing after 9s
2026-02-18 22:00:06 +01:00
0ddfed4f07 fix(back-end): try fix profile manager
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 26s
Build, Test and Deploy / build-and-push (push) Successful in 14s
Build, Test and Deploy / deploy (push) Failing after 5s
2026-02-18 21:57:23 +01:00
e7daf79394 fix(back-end): try fix profile manager
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 26s
Build, Test and Deploy / build-and-push (push) Successful in 14s
Build, Test and Deploy / deploy (push) Failing after 5s
2026-02-18 21:53:34 +01:00
7bb94da45b fix(back-end): try fix profile manager
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 28s
Build, Test and Deploy / build-and-push (push) Successful in 17s
Build, Test and Deploy / deploy (push) Failing after 4s
2026-02-18 21:48:09 +01:00
d28609ee95 fix(back-end): try fix profile manager
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 28s
Build, Test and Deploy / build-and-push (push) Successful in 20s
Build, Test and Deploy / deploy (push) Successful in 8s
2026-02-18 20:07:41 +01:00
8364ad0671 fix(back-end): try fix profile manager
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 27s
Build, Test and Deploy / build-and-push (push) Successful in 18s
Build, Test and Deploy / deploy (push) Failing after 7s
2026-02-18 19:50:21 +01:00
797b10e4ad fix(back-end): try fix profile manager
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 27s
Build, Test and Deploy / build-and-push (push) Has been cancelled
Build, Test and Deploy / deploy (push) Has been cancelled
2026-02-18 19:49:56 +01:00
ec77b76abb fix(back-end): try fix profile manager
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 25s
Build, Test and Deploy / build-and-push (push) Successful in 28s
Build, Test and Deploy / deploy (push) Successful in 10s
2026-02-18 19:33:57 +01:00
bb269d84a5 fix(back-end): shift model
Some checks failed
Build, Test and Deploy / build-and-push (push) Has been cancelled
Build, Test and Deploy / deploy (push) Has been cancelled
Build, Test and Deploy / test-backend (push) Has been cancelled
2026-02-17 16:34:40 +01:00
46eb980e24 fix(back-end): shift model
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m13s
Build, Test and Deploy / build-and-push (push) Successful in 28s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-17 16:32:46 +01:00
85a4db1630 fix(back-end): shift model
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m13s
Build, Test and Deploy / build-and-push (push) Successful in 29s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-17 16:25:21 +01:00
701a10e886 fix(back-end): shift model
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 2m31s
Build, Test and Deploy / build-and-push (push) Successful in 1m47s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-17 15:35:17 +01:00
87 changed files with 3100 additions and 2331 deletions

View File

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

5
.gitignore vendored
View File

@@ -41,3 +41,8 @@ target/
build/ build/
.gradle/ .gradle/
.mvn/ .mvn/
./storage_orders
./storage_quotes
storage_orders
storage_quotes

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 # Install system dependencies for OrcaSlicer (same as before)
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
wget \ wget \
p7zip-full \ p7zip-full \
@@ -20,14 +20,6 @@ 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,6 +37,7 @@ 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,13 +3,25 @@ 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 "----------------------------------------------------------------"
# Exec java with explicit properties from env # Determine which environment variables to use for database connection
exec java -jar app.jar \ # This allows compatibility with different docker-compose configurations
--spring.datasource.url="${DB_URL}" \ FINAL_DB_URL="${DB_URL:-$SPRING_DATASOURCE_URL}"
--spring.datasource.username="${DB_USERNAME}" \ FINAL_DB_USER="${DB_USERNAME:-$SPRING_DATASOURCE_USERNAME}"
--spring.datasource.password="${DB_PASSWORD}" FINAL_DB_PASS="${DB_PASSWORD:-$SPRING_DATASOURCE_PASSWORD}"
if [ -n "$FINAL_DB_URL" ]; then
echo "Using database URL: $FINAL_DB_URL"
exec java -jar app.jar \
--spring.datasource.url="${FINAL_DB_URL}" \
--spring.datasource.username="${FINAL_DB_USER}" \
--spring.datasource.password="${FINAL_DB_PASS}"
else
echo "No database URL specified in environment, relying on application.properties defaults."
exec java -jar app.jar
fi

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,12 +3,14 @@ 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,15 +25,17 @@ 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;
private final com.printcalculator.service.StorageService storageService; // TODO: Inject Storage Service
private static final String STORAGE_ROOT = "storage_requests";
public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo, public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
CustomQuoteRequestAttachmentRepository attachmentRepo, CustomQuoteRequestAttachmentRepository attachmentRepo,
com.printcalculator.service.StorageService storageService) { com.printcalculator.service.ClamAVService clamAVService) {
this.requestRepo = requestRepo; this.requestRepo = requestRepo;
this.attachmentRepo = attachmentRepo; this.attachmentRepo = attachmentRepo;
this.storageService = storageService; this.clamAVService = clamAVService;
} }
// 1. Create Custom Quote Request // 1. Create Custom Quote Request
@@ -69,6 +71,9 @@ 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());
@@ -92,8 +97,10 @@ public class CustomQuoteRequestController {
attachment.setStoredRelativePath(relativePath); attachment.setStoredRelativePath(relativePath);
attachmentRepo.save(attachment); attachmentRepo.save(attachment);
// Save file to disk via StorageService // Save file to disk
storageService.store(file, Paths.get(relativePath)); Path absolutePath = Paths.get(STORAGE_ROOT, relativePath);
Files.createDirectories(absolutePath.getParent());
Files.copy(file.getInputStream(), absolutePath);
} }
} }

View File

@@ -0,0 +1,47 @@
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,20 +64,6 @@ 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

@@ -5,8 +5,10 @@ import com.printcalculator.entity.*;
import com.printcalculator.repository.*; import com.printcalculator.repository.*;
import com.printcalculator.service.InvoicePdfRenderingService; import com.printcalculator.service.InvoicePdfRenderingService;
import com.printcalculator.service.OrderService; import com.printcalculator.service.OrderService;
import com.printcalculator.service.PaymentService;
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;
@@ -24,7 +26,9 @@ 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")
@@ -39,6 +43,9 @@ 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;
private final PaymentService paymentService;
private final PaymentRepository paymentRepo;
public OrderController(OrderService orderService, public OrderController(OrderService orderService,
@@ -49,7 +56,10 @@ public class OrderController {
CustomerRepository customerRepo, CustomerRepository customerRepo,
StorageService storageService, StorageService storageService,
InvoicePdfRenderingService invoiceService, InvoicePdfRenderingService invoiceService,
QrBillService qrBillService) { QrBillService qrBillService,
TwintPaymentService twintPaymentService,
PaymentService paymentService,
PaymentRepository paymentRepo) {
this.orderService = orderService; this.orderService = orderService;
this.orderRepo = orderRepo; this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo; this.orderItemRepo = orderItemRepo;
@@ -59,6 +69,9 @@ public class OrderController {
this.storageService = storageService; this.storageService = storageService;
this.invoiceService = invoiceService; this.invoiceService = invoiceService;
this.qrBillService = qrBillService; this.qrBillService = qrBillService;
this.twintPaymentService = twintPaymentService;
this.paymentService = paymentService;
this.paymentRepo = paymentRepo;
} }
@@ -116,6 +129,17 @@ public class OrderController {
.orElse(ResponseEntity.notFound().build()); .orElse(ResponseEntity.notFound().build());
} }
@PostMapping("/{orderId}/payments/report")
@Transactional
public ResponseEntity<OrderDto> reportPayment(
@PathVariable UUID orderId,
@RequestBody Map<String, String> payload
) {
String method = payload.get("method");
paymentService.reportPayment(orderId, method);
return getOrder(orderId);
}
@GetMapping("/{orderId}/invoice") @GetMapping("/{orderId}/invoice")
public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) { public ResponseEntity<byte[]> getInvoice(@PathVariable UUID orderId) {
Order order = orderRepo.findById(orderId) Order order = orderRepo.findById(orderId)
@@ -129,7 +153,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-" + order.getId().toString().substring(0, 8).toUpperCase()); vars.put("invoiceNumber", "INV-" + getDisplayOrderNumber(order).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));
@@ -181,10 +205,55 @@ 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-" + orderId + ".pdf\"") .header("Content-Disposition", "attachment; filename=\"invoice-" + getDisplayOrderNumber(order) + ".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";
@@ -198,7 +267,14 @@ 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());
paymentRepo.findByOrder_Id(order.getId()).ifPresent(p -> {
dto.setPaymentStatus(p.getStatus());
dto.setPaymentMethod(p.getMethod());
});
dto.setCustomerEmail(order.getCustomerEmail()); dto.setCustomerEmail(order.getCustomerEmail());
dto.setCustomerPhone(order.getCustomerPhone()); dto.setCustomerPhone(order.getCustomerPhone());
dto.setBillingCustomerType(order.getBillingCustomerType()); dto.setBillingCustomerType(order.getBillingCustomerType());
@@ -255,4 +331,12 @@ 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,15 +1,11 @@
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;
@@ -19,29 +15,24 @@ 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 ProfileManager profileManager; private final com.printcalculator.service.ClamAVService clamAVService;
// 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, StlService stlService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, ProfileManager profileManager) { public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo, com.printcalculator.service.ClamAVService clamAVService) {
this.slicerService = slicerService; this.slicerService = slicerService;
this.stlService = stlService;
this.quoteCalculator = quoteCalculator; this.quoteCalculator = quoteCalculator;
this.machineRepo = machineRepo; this.machineRepo = machineRepo;
this.profileManager = profileManager; this.clamAVService = clamAVService;
} }
@PostMapping("/api/quote") @PostMapping("/api/quote")
@@ -55,7 +46,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, defaultValue = "false") Boolean supportEnabled @RequestParam(value = "support_enabled", required = false) Boolean supportEnabled
) throws IOException { ) throws IOException {
// ... process selection logic ... // ... process selection logic ...
@@ -83,9 +74,6 @@ 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) {
@@ -95,7 +83,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, nozzleDiameter); return processRequest(file, filament, actualProcess, machineOverrides, processOverrides);
} }
@PostMapping("/calculate/stl") @PostMapping("/calculate/stl")
@@ -103,91 +91,42 @@ 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, null); return processRequest(file, DEFAULT_FILAMENT, DEFAULT_PROCESS, 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, Map<String, String> processOverrides) throws IOException {
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());
// Use profile from machine or fallback String slicerMachineProfile = "bambu_a1"; // TODO: Add to PrinterMachine entity
String slicerMachineProfile = machine.getSlicerMachineProfile();
if (slicerMachineProfile == null || slicerMachineProfile.isEmpty()) {
slicerMachineProfile = "bambu_a1";
}
slicerMachineProfile = profileManager.resolveMachineProfileName(slicerMachineProfile, nozzleDiameter);
// Validate model size against machine volume PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides);
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,17 +3,13 @@ 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;
@@ -32,24 +28,19 @@ 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.StorageService storageService; private final com.printcalculator.service.ClamAVService clamAVService;
// Defaults // Defaults
private static final String DEFAULT_FILAMENT = "pla_basic"; private static final String DEFAULT_FILAMENT = "pla_basic";
@@ -58,21 +49,17 @@ 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.StorageService storageService) { com.printcalculator.service.ClamAVService clamAVService) {
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.storageService = storageService; this.clamAVService = clamAVService;
} }
// 1. Start a new empty session // 1. Start a new empty session
@@ -115,8 +102,13 @@ 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: quotes/{sessionId}/{uuid}.{ext} (inside storage root) // Structure: storage_quotes/{sessionId}/{uuid}.{ext}
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(".")
@@ -124,15 +116,11 @@ public class QuoteSessionController {
: ".stl"; : ".stl";
String storedFilename = UUID.randomUUID() + ext; String storedFilename = UUID.randomUUID() + ext;
Path relativePath = Paths.get("quotes", session.getId().toString(), storedFilename); Path persistentPath = Paths.get(storageDir, storedFilename);
// Save file // Save file
storageService.store(file, relativePath); Files.copy(file.getInputStream(), persistentPath);
// 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);
@@ -141,33 +129,13 @@ 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()));
}
// 3. Pick Profiles // 2. Pick Profiles
String machineProfile = machine.getSlicerMachineProfile(); String machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle"
if (machineProfile == null || machineProfile.isBlank()) { // If the display name doesn't match the json profile name, we might need a mapping key in DB.
machineProfile = machine.getPrinterDisplayName(); // e.g. "Bambu Lab A1 0.4 nozzle" // For now assuming display name works or we use a tough default
} machineProfile = "Bambu Lab A1 0.4 nozzle"; // Force known good for now? Or use DB field if exists.
if (machineProfile == null || machineProfile.isBlank()) { // Ideally: machine.getSlicerProfileName();
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"
@@ -176,24 +144,7 @@ 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
@@ -206,40 +157,26 @@ 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");
}
}
Map<String, String> machineOverrides = new HashMap<>(); // 3. Slice (Use persistent path)
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(
sliceInput, persistentPath.toFile(),
machineProfile, machineProfile,
filamentProfile, filamentProfile,
processProfile, processProfile,
machineOverrides, // machine overrides null, // machine overrides
processOverrides processOverrides
); );
// 5. Calculate Quote // 4. Calculate Quote
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile); QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filamentProfile);
// 6. Create Line Item // 5. 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());
@@ -248,8 +185,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.getPrintTimeSeconds()); item.setPrintTimeSeconds((int) stats.printTimeSeconds());
item.setMaterialGrams(BigDecimal.valueOf(stats.getFilamentWeightGrams())); item.setMaterialGrams(BigDecimal.valueOf(stats.filamentWeightGrams()));
item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice())); item.setUnitPriceChf(BigDecimal.valueOf(result.getTotalPrice()));
// Store breakdown // Store breakdown
@@ -259,10 +196,14 @@ public class QuoteSessionController {
breakdown.put("setup_fee", result.getSetupCost()); breakdown.put("setup_fee", result.getSetupCost());
item.setPricingBreakdown(breakdown); item.setPricingBreakdown(breakdown);
// Dimensions from STL // Dimensions
item.setBoundingBoxXMm(BigDecimal.valueOf(bounds.sizeX())); // Cannot get bb from GCodeParser yet?
item.setBoundingBoxYMm(BigDecimal.valueOf(bounds.sizeY())); // If GCodeParser doesn't return size, we might defaults or 0.
item.setBoundingBoxZMm(BigDecimal.valueOf(bounds.sizeZ())); // Stats has filament used.
// 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());
@@ -271,46 +212,11 @@ public class QuoteSessionController {
} catch (Exception e) { } catch (Exception e) {
// Cleanup if failed // Cleanup if failed
try { Files.deleteIfExists(persistentPath);
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
@@ -321,30 +227,24 @@ 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);
} }
} }
@@ -436,24 +336,6 @@ 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,10 @@ 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 paymentStatus;
private String paymentMethod;
private String customerEmail; private String customerEmail;
private String customerPhone; private String customerPhone;
private String billingCustomerType; private String billingCustomerType;
@@ -27,9 +30,18 @@ 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; }
public String getPaymentStatus() { return paymentStatus; }
public void setPaymentStatus(String paymentStatus) { this.paymentStatus = paymentStatus; }
public String getPaymentMethod() { return paymentMethod; }
public void setPaymentMethod(String paymentMethod) { this.paymentMethod = paymentMethod; }
public String getCustomerEmail() { return customerEmail; } public String getCustomerEmail() { return customerEmail; }
public void setCustomerEmail(String customerEmail) { this.customerEmail = customerEmail; } public void setCustomerEmail(String customerEmail) { this.customerEmail = customerEmail; }

View File

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

View File

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

View File

@@ -67,6 +67,16 @@ 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;
} }
@@ -195,4 +205,4 @@ public class OrderItem {
this.createdAt = createdAt; this.createdAt = createdAt;
} }
} }

View File

@@ -52,6 +52,9 @@ public class Payment {
@Column(name = "initiated_at", nullable = false) @Column(name = "initiated_at", nullable = false)
private OffsetDateTime initiatedAt; private OffsetDateTime initiatedAt;
@Column(name = "reported_at")
private OffsetDateTime reportedAt;
@Column(name = "received_at") @Column(name = "received_at")
private OffsetDateTime receivedAt; private OffsetDateTime receivedAt;
@@ -135,6 +138,14 @@ public class Payment {
this.initiatedAt = initiatedAt; this.initiatedAt = initiatedAt;
} }
public OffsetDateTime getReportedAt() {
return reportedAt;
}
public void setReportedAt(OffsetDateTime reportedAt) {
this.reportedAt = reportedAt;
}
public OffsetDateTime getReceivedAt() { public OffsetDateTime getReceivedAt() {
return receivedAt; return receivedAt;
} }

View File

@@ -41,9 +41,6 @@ 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;
} }
@@ -60,14 +57,6 @@ 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

@@ -0,0 +1,16 @@
package com.printcalculator.event;
import com.printcalculator.entity.Order;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
@Getter
public class OrderCreatedEvent extends ApplicationEvent {
private final Order order;
public OrderCreatedEvent(Object source, Order order) {
super(source);
this.order = order;
}
}

View File

@@ -0,0 +1,25 @@
package com.printcalculator.event;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.Payment;
import org.springframework.context.ApplicationEvent;
public class PaymentReportedEvent extends ApplicationEvent {
private final Order order;
private final Payment payment;
public PaymentReportedEvent(Object source, Order order, Payment payment) {
super(source);
this.order = order;
this.payment = payment;
}
public Order getOrder() {
return order;
}
public Payment getPayment() {
return payment;
}
}

View File

@@ -0,0 +1,127 @@
package com.printcalculator.event.listener;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.Payment;
import com.printcalculator.event.OrderCreatedEvent;
import com.printcalculator.event.PaymentReportedEvent;
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);
}
}
@Async
@EventListener
public void handlePaymentReportedEvent(PaymentReportedEvent event) {
Order order = event.getOrder();
log.info("Processing PaymentReportedEvent for order id: {}", order.getId());
try {
sendPaymentReportedEmail(order);
} catch (Exception e) {
log.error("Failed to send payment reported email for order id: {}", order.getId(), e);
}
}
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 sendPaymentReportedEmail(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));
emailNotificationService.sendEmail(
order.getCustomer().getEmail(),
"Stiamo verificando il tuo pagamento (Ordine #" + getDisplayOrderNumber(order) + ")",
"payment-reported",
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,71 +1,27 @@
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.multipart.MaxUploadSizeExceededException; import org.springframework.web.context.request.WebRequest;
import java.util.HashMap; import java.time.LocalDateTime;
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(StorageException.class) @ExceptionHandler(VirusDetectedException.class)
public ResponseEntity<?> handleStorageException(StorageException exc) { public ResponseEntity<Object> handleVirusDetectedException(
// Log the full exception for internal debugging VirusDetectedException ex, WebRequest request) {
log.error("Storage Exception occurred", exc);
Map<String, String> response = new HashMap<>();
// Check for specific virus case Map<String, Object> body = new LinkedHashMap<>();
if (exc.getMessage() != null && exc.getMessage().contains("antivirus scanner")) { body.put("timestamp", LocalDateTime.now());
response.put("error", "Security Violation"); body.put("message", ex.getMessage());
// Safe message for client body.put("error", "Virus Detected");
response.put("message", "File rejected by security policy.");
response.put("code", "VIRUS_DETECTED");
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(response);
}
// Generic fallback for other storage errors to avoid leaking internal paths/details return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
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

@@ -1,45 +0,0 @@
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

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

View File

@@ -1,29 +1,8 @@
package com.printcalculator.model; package com.printcalculator.model;
import lombok.AllArgsConstructor; public record PrintStats(
import lombok.Builder; long printTimeSeconds,
import lombok.Data; String printTimeFormatted,
import lombok.NoArgsConstructor; double filamentWeightGrams,
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

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

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

View File

@@ -3,7 +3,9 @@ package com.printcalculator.repository;
import com.printcalculator.entity.Payment; import com.printcalculator.entity.Payment;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
public interface PaymentRepository extends JpaRepository<Payment, UUID> { public interface PaymentRepository extends JpaRepository<Payment, UUID> {
Optional<Payment> findByOrder_Id(UUID orderId);
} }

View File

@@ -1,5 +1,6 @@
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;
@@ -22,18 +23,15 @@ 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:false}") boolean enabled @Value("${clamav.enabled:true}") 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 {
client = new ClamavClient(host, port); if (enabled) {
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());
} }
@@ -51,11 +49,13 @@ 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);
return false; throw new VirusDetectedException("Virus detected in the uploaded file: " + viruses);
} 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,15 +26,13 @@ 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*([^;\\(\\n\\r]+)(?:\\s*\\(([^,]+) model,\\s*([^ ]+) support\\))?"); private static final Pattern FILAMENT_G_PATTERN = Pattern.compile(";\\s*filament used \\[g\\]\\s*=\\s*(.*)");
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))) {
@@ -80,14 +78,7 @@ 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 total weight: " + weightG + "g"); System.out.println("GCodeParser: Found 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) {}
} }
@@ -101,14 +92,7 @@ public class GCodeParser {
} }
} }
return PrintStats.builder() return new PrintStats(seconds, timeFormatted, weightG, lengthMm);
.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,9 +8,10 @@ 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;
@@ -34,6 +35,8 @@ 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;
private final PaymentService paymentService;
public OrderService(OrderRepository orderRepo, public OrderService(OrderRepository orderRepo,
OrderItemRepository orderItemRepo, OrderItemRepository orderItemRepo,
@@ -42,7 +45,9 @@ public class OrderService {
CustomerRepository customerRepo, CustomerRepository customerRepo,
StorageService storageService, StorageService storageService,
InvoicePdfRenderingService invoiceService, InvoicePdfRenderingService invoiceService,
QrBillService qrBillService) { QrBillService qrBillService,
ApplicationEventPublisher eventPublisher,
PaymentService paymentService) {
this.orderRepo = orderRepo; this.orderRepo = orderRepo;
this.orderItemRepo = orderItemRepo; this.orderItemRepo = orderItemRepo;
this.quoteSessionRepo = quoteSessionRepo; this.quoteSessionRepo = quoteSessionRepo;
@@ -51,6 +56,8 @@ public class OrderService {
this.storageService = storageService; this.storageService = storageService;
this.invoiceService = invoiceService; this.invoiceService = invoiceService;
this.qrBillService = qrBillService; this.qrBillService = qrBillService;
this.eventPublisher = eventPublisher;
this.paymentService = paymentService;
} }
@Transactional @Transactional
@@ -195,7 +202,14 @@ public class OrderService {
// Generate Invoice and QR Bill // Generate Invoice and QR Bill
generateAndSaveDocuments(order, savedItems); generateAndSaveDocuments(order, savedItems);
return orderRepo.save(order); Order savedOrder = orderRepo.save(order);
// ALWAYS initialize payment as PENDING
paymentService.getOrCreatePaymentForOrder(savedOrder, "OTHER");
eventPublisher.publishEvent(new OrderCreatedEvent(this, savedOrder));
return savedOrder;
} }
private void generateAndSaveDocuments(Order order, List<OrderItem> items) { private void generateAndSaveDocuments(Order order, List<OrderItem> items) {
@@ -223,7 +237,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-" + order.getId().toString().substring(0, 8).toUpperCase()); vars.put("invoiceNumber", "INV-" + getDisplayOrderNumber(order).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));
@@ -297,4 +311,12 @@ 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

@@ -0,0 +1,74 @@
package com.printcalculator.service;
import com.printcalculator.entity.Order;
import com.printcalculator.entity.Payment;
import com.printcalculator.event.PaymentReportedEvent;
import com.printcalculator.repository.OrderRepository;
import com.printcalculator.repository.PaymentRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.Optional;
import java.util.UUID;
@Service
public class PaymentService {
private final PaymentRepository paymentRepo;
private final OrderRepository orderRepo;
private final ApplicationEventPublisher eventPublisher;
public PaymentService(PaymentRepository paymentRepo,
OrderRepository orderRepo,
ApplicationEventPublisher eventPublisher) {
this.paymentRepo = paymentRepo;
this.orderRepo = orderRepo;
this.eventPublisher = eventPublisher;
}
@Transactional
public Payment getOrCreatePaymentForOrder(Order order, String defaultMethod) {
Optional<Payment> existing = paymentRepo.findByOrder_Id(order.getId());
if (existing.isPresent()) {
return existing.get();
}
Payment payment = new Payment();
payment.setOrder(order);
payment.setMethod(defaultMethod != null ? defaultMethod : "OTHER");
payment.setStatus("PENDING");
payment.setCurrency(order.getCurrency() != null ? order.getCurrency() : "CHF");
payment.setAmountChf(order.getTotalChf() != null ? order.getTotalChf() : BigDecimal.ZERO);
payment.setInitiatedAt(OffsetDateTime.now());
return paymentRepo.save(payment);
}
@Transactional
public Payment reportPayment(UUID orderId, String method) {
Order order = orderRepo.findById(orderId)
.orElseThrow(() -> new RuntimeException("Order not found with id " + orderId));
Payment payment = paymentRepo.findByOrder_Id(orderId)
.orElseThrow(() -> new RuntimeException("No active payment found for order " + orderId));
if (!"PENDING".equals(payment.getStatus())) {
throw new IllegalStateException("Payment is not in PENDING state. Current state: " + payment.getStatus());
}
payment.setStatus("REPORTED");
payment.setReportedAt(OffsetDateTime.now());
if (method != null && !method.isBlank()) {
payment.setMethod(method);
}
payment = paymentRepo.save(payment);
eventPublisher.publishEvent(new PaymentReportedEvent(this, order, payment));
return payment;
}
}

View File

@@ -10,19 +10,23 @@ 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.file.Paths; import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Iterator; import java.util.Iterator;
import java.util.Optional; import java.util.Optional;
import java.util.logging.Logger; 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.math.BigDecimal; import java.util.List;
import java.util.LinkedHashSet;
import java.util.Set;
@Service @Service
public class ProfileManager { public class ProfileManager {
private static final Logger logger = Logger.getLogger(ProfileManager.class.getName()); private static final Logger logger = Logger.getLogger(ProfileManager.class.getName());
private final String profilesRoot; private final String profilesRoot;
private final Path resolvedProfilesRoot;
private final ObjectMapper mapper; private final ObjectMapper mapper;
private final Map<String, String> profileAliases; private final Map<String, String> profileAliases;
@@ -32,6 +36,8 @@ public class ProfileManager {
this.mapper = mapper; this.mapper = mapper;
this.profileAliases = new HashMap<>(); this.profileAliases = new HashMap<>();
initializeAliases(); initializeAliases();
this.resolvedProfilesRoot = resolveProfilesRoot(profilesRoot);
logger.info("Profiles root configured as '" + this.profilesRoot + "', resolved to '" + this.resolvedProfilesRoot + "'");
} }
private void initializeAliases() { private void initializeAliases() {
@@ -55,42 +61,82 @@ public class ProfileManager {
public ObjectNode getMergedProfile(String profileName, String type) throws IOException { public ObjectNode getMergedProfile(String profileName, String type) throws IOException {
Path profilePath = findProfileFile(profileName, type); Path profilePath = findProfileFile(profileName, type);
if (profilePath == null) { if (profilePath == null) {
throw new IOException("Profile not found: " + profileName); throw new IOException("Profile not found: " + profileName + " (root=" + resolvedProfilesRoot + ")");
} }
logger.info("Resolved " + type + " profile '" + profileName + "' -> " + profilePath);
return resolveInheritance(profilePath); 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) {
if (!Files.isDirectory(resolvedProfilesRoot)) {
logger.severe("Profiles root does not exist or is not a directory: " + resolvedProfilesRoot);
return null;
}
// Check aliases first // Check aliases first
String resolvedName = profileAliases.getOrDefault(name, name); String resolvedName = profileAliases.getOrDefault(name, name);
// Simple search: look for name.json in the profiles_root recursively // Look for name.json under the expected type directory first to avoid
// Type could be "machine", "process", "filament" to narrow down, but for now global search // collisions across vendors/profile families with same filename.
String filename = resolvedName.endsWith(".json") ? resolvedName : resolvedName + ".json"; String filename = toJsonFilename(resolvedName);
try (Stream<Path> stream = Files.walk(Paths.get(profilesRoot))) { try (Stream<Path> stream = Files.walk(resolvedProfilesRoot)) {
Optional<Path> found = stream List<Path> candidates = stream
.filter(p -> p.getFileName().toString().equals(filename)) .filter(p -> p.getFileName().toString().equals(filename))
.findFirst(); .sorted()
return found.orElse(null); .toList();
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;
} }
} }
private Path resolveProfilesRoot(String configuredRoot) {
Set<Path> candidates = new LinkedHashSet<>();
Path cwd = Paths.get("").toAbsolutePath().normalize();
if (configuredRoot != null && !configuredRoot.isBlank()) {
Path configured = Paths.get(configuredRoot);
candidates.add(configured.toAbsolutePath().normalize());
if (!configured.isAbsolute()) {
candidates.add(cwd.resolve(configuredRoot).normalize());
}
}
candidates.add(cwd.resolve("profiles").normalize());
candidates.add(cwd.resolve("backend/profiles").normalize());
candidates.add(Paths.get("/app/profiles").toAbsolutePath().normalize());
List<String> checkedPaths = new ArrayList<>();
for (Path candidate : candidates) {
checkedPaths.add(candidate.toString());
if (Files.isDirectory(candidate)) {
return candidate;
}
}
logger.warning("No profiles directory found. Checked: " + String.join(", ", checkedPaths));
if (configuredRoot != null && !configuredRoot.isBlank()) {
return Paths.get(configuredRoot).toAbsolutePath().normalize();
}
return cwd.resolve("profiles").normalize();
}
private ObjectNode resolveInheritance(Path currentPath) throws IOException { private ObjectNode resolveInheritance(Path currentPath) throws IOException {
// 1. Load current // 1. Load current
JsonNode currentNode = mapper.readTree(currentPath.toFile()); JsonNode currentNode = mapper.readTree(currentPath.toFile());
@@ -98,14 +144,20 @@ 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 to find parent in same directory or standard search // Try local directory first with explicit .json filename.
Path parentPath = currentPath.getParent().resolve(parentName); String parentFilename = toJsonFilename(parentName);
Path parentPath = currentPath.getParent().resolve(parentFilename);
if (!Files.exists(parentPath)) { if (!Files.exists(parentPath)) {
// If not in same dir, search globally // Fallback to the same profile type directory before global.
String inferredType = inferTypeFromPath(currentPath);
parentPath = findProfileFile(parentName, inferredType);
}
if (parentPath == null || !Files.exists(parentPath)) {
parentPath = findProfileFile(parentName, "any"); 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)
@@ -136,4 +188,30 @@ 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,7 +51,8 @@ public class QrBillService {
// Reference // Reference
// bill.setReference(QRBill.createCreditorReference("...")); // If using QRR // bill.setReference(QRBill.createCreditorReference("...")); // If using QRR
bill.setUnstructuredMessage("Order " + order.getId()); String orderRef = order.getOrderNumber() != null ? order.getOrderNumber() : order.getId().toString();
bill.setUnstructuredMessage("Order " + orderRef);
return bill; return bill;
} }

View File

@@ -76,21 +76,11 @@ public class QuoteCalculator {
// --- CALCULATIONS --- // --- CALCULATIONS ---
// Material Cost: (weight / 1000) * costPerKg // Material Cost: (weight / 1000) * costPerKg
// DISCOUNTED Support material to avoid penalizing users for default supports BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
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.getPrintTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP); BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()).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,15 +41,27 @@ 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");
if (machineOverrides != null) machineOverrides.forEach(machineProfile::put); logger.info("Slicer profiles: machine='" + machineName + "', filament='" + filamentName + "', process='" + processName + "'");
if (processOverrides != null) processOverrides.forEach(processProfile::put); logger.info("Machine limits: printable_area=" + machineProfile.path("printable_area")
+ ", printable_height=" + machineProfile.path("printable_height")
+ ", bed_exclude_area=" + machineProfile.path("bed_exclude_area")
+ ", head_wrap_detect_zone=" + machineProfile.path("head_wrap_detect_zone"));
// Apply Overrides
if (machineOverrides != null) {
machineOverrides.forEach(machineProfile::put);
}
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();
@@ -59,61 +71,110 @@ public class SlicerService {
mapper.writeValue(fFile, filamentProfile); mapper.writeValue(fFile, filamentProfile);
mapper.writeValue(pFile, processProfile); mapper.writeValue(pFile, processProfile);
List<String> command = new ArrayList<>(); String basename = inputStl.getName();
command.add(slicerPath); if (basename.toLowerCase().endsWith(".stl")) {
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(e); throw new IOException("Interrupted during slicing", e);
} finally {
deleteRecursively(tempDir);
} }
} }
protected void runSlicerCommand(List<String> command, Path tempDir) throws IOException, InterruptedException { private void deleteRecursively(Path path) {
ProcessBuilder pb = new ProcessBuilder(command); if (path == null || !Files.exists(path)) {
pb.directory(tempDir.toFile()); return;
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");
} }
if (process.exitValue() != 0) { try (var walk = Files.walk(path)) {
String out = new String(process.getInputStream().readAllBytes()); walk.sorted(Comparator.reverseOrder()).forEach(p -> {
String err = new String(process.getErrorStream().readAllBytes()); try {
throw new IOException("Slicer failed with exit code " + process.exitValue() + "\nERR: " + err + "\nOUT: " + out); Files.deleteIfExists(p);
} catch (IOException e) {
logger.warning("Failed to delete temp path " + p + ": " + e.getMessage());
}
});
} catch (IOException e) {
logger.warning("Failed to walk temp directory " + path + ": " + e.getMessage());
} }
} }
private boolean isOutOfVolumeError(String errorLog) {
if (errorLog == null || errorLog.isBlank()) {
return false;
}
String normalized = errorLog.toLowerCase();
return normalized.contains("nothing to be sliced")
|| normalized.contains("no object is fully inside the print volume")
|| normalized.contains("calc_exclude_triangles");
}
} }

View File

@@ -1,255 +0,0 @@
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

@@ -0,0 +1,66 @@
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

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,62 @@
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;
@Value("${app.mail.enabled:true}")
private boolean mailEnabled;
@Override
public void sendEmail(String to, String subject, String templateName, Map<String, Object> contextData) {
if (!mailEnabled) {
log.info("Email sending disabled (app.mail.enabled=false). Skipping email to {}", to);
return;
}
log.info("Preparing to send email to {} with template {}", to, templateName);
try {
Context context = new Context();
context.setVariables(contextData);
String process = templateEngine.process("email/" + templateName, context);
MimeMessage mimeMessage = emailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8");
helper.setFrom(fromAddress);
helper.setTo(to);
helper.setSubject(subject);
helper.setText(process, true); // true indicates HTML format
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

@@ -0,0 +1,2 @@
app.mail.enabled=false
app.mail.admin.enabled=false

View File

@@ -24,3 +24,20 @@ 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.enabled=${APP_MAIL_ENABLED:true}
app.mail.from=${APP_MAIL_FROM:${MAIL_USERNAME:noreply@printcalculator.local}}
app.mail.admin.enabled=${APP_MAIL_ADMIN_ENABLED:true}
app.mail.admin.address=${APP_MAIL_ADMIN_ADDRESS:admin@printcalculator.local}
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}

View File

@@ -0,0 +1,93 @@
<!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,81 +3,356 @@
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<style> <style>
@page { size: A4; margin: 18mm 15mm; } @page invoice { size: A4; margin: 14mm 14mm 12mm 14mm; }
body { font-family: sans-serif; font-size: 10.5pt; } @page qrpage { size: A4; margin: 0; }
.header { display: flex; justify-content: space-between; }
.addresses { margin-top: 10mm; display: flex; justify-content: space-between; } body {
table { width: 100%; border-collapse: collapse; margin-top: 8mm; } page: invoice;
th, td { padding: 6px; border-bottom: 1px solid #ccc; } font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
th { text-align: left; } font-size: 9.5pt;
.totals { margin-top: 6mm; width: 40%; margin-left: auto; } margin: 0;
.totals td { border: none; } padding: 0;
.page-break { page-break-before: always; } background: #fff;
color: #191919;
line-height: 1.35;
}
.invoice-page {
page: invoice;
width: 100%;
}
.top-layout {
width: 100%;
border-collapse: collapse;
margin-bottom: 8mm;
}
.top-layout td {
vertical-align: top;
padding: 0;
}
.doc-title {
font-size: 18pt;
font-weight: 700;
margin: 0 0 1.5mm 0;
letter-spacing: 0.2px;
}
.doc-subtitle {
color: #4b4b4b;
font-size: 10pt;
}
.seller-block {
text-align: right;
line-height: 1.45;
width: 42%;
}
.seller-name {
font-size: 11pt;
font-weight: 700;
}
.meta-layout {
width: 100%;
border-collapse: collapse;
margin-bottom: 8mm;
}
.meta-layout td {
vertical-align: top;
padding: 0;
}
.order-details {
width: 60%;
padding-right: 5mm;
}
.customer-box {
width: 40%;
background: #f7f7f7;
border: 1px solid #e2e2e2;
padding: 3mm 3.2mm;
}
.box-title {
font-size: 8.8pt;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #5a5a5a;
margin-bottom: 2mm;
font-weight: 700;
}
.details-table {
width: 100%;
border-collapse: collapse;
}
.details-table td {
padding: 1.1mm 0;
border-bottom: 1px solid #ececec;
vertical-align: top;
}
.details-label {
color: #636363;
width: 56%;
white-space: nowrap;
padding-right: 3mm;
}
.details-value {
text-align: left;
font-weight: 600;
}
.line-items {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
margin-top: 3mm;
border-top: 1px solid #cfcfcf;
}
.line-items th,
.line-items td {
border-bottom: 1px solid #dedede;
padding: 2.4mm 2mm;
vertical-align: top;
word-wrap: break-word;
}
.line-items th {
text-align: left;
font-weight: 700;
background: #f2f2f2;
color: #2c2c2c;
font-size: 9pt;
text-transform: uppercase;
letter-spacing: 0.25px;
}
.line-items th:nth-child(1),
.line-items td:nth-child(1) {
width: 50%;
}
.line-items th:nth-child(2),
.line-items td:nth-child(2) {
width: 10%;
text-align: right;
white-space: nowrap;
}
.line-items th:nth-child(3),
.line-items td:nth-child(3) {
width: 20%;
text-align: right;
white-space: nowrap;
}
.line-items th:nth-child(4),
.line-items td:nth-child(4) {
width: 20%;
text-align: right;
white-space: nowrap;
font-weight: 600;
}
.summary-layout {
width: 100%;
border-collapse: collapse;
margin-top: 6mm;
}
.summary-layout td {
vertical-align: top;
padding: 0;
}
.notes {
width: 58%;
padding-right: 5mm;
color: #383838;
line-height: 1.45;
}
.notes .section-caption {
font-weight: 700;
margin: 0 0 1.2mm 0;
color: #2a2a2a;
}
.totals {
width: 42%;
margin-left: auto;
border-collapse: collapse;
}
.totals td {
border: none;
padding: 1.3mm 0;
}
.totals-label {
text-align: left;
color: #4a4a4a;
}
.totals-value {
text-align: right;
white-space: nowrap;
font-weight: 600;
}
.total-strong td {
font-size: 10.5pt;
font-weight: 700;
padding-top: 2mm;
border-top: 1px solid #cfcfcf;
}
.due-row td {
font-size: 10pt;
font-weight: 700;
border-top: 1px solid #cfcfcf;
padding-top: 2.2mm;
}
.qr-only-page {
page: qrpage;
position: relative;
width: 210mm;
height: 297mm;
background: #fff;
page-break-before: always;
}
.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">
<div class="header"> <table class="top-layout">
<div> <tr>
<div><strong th:text="${sellerDisplayName}">Nome Cognome</strong></div> <td>
<div th:text="${sellerAddressLine1}">Via Esempio 12</div> <div class="doc-title">Conferma ordine</div>
<div th:text="${sellerAddressLine2}">6500 Bellinzona, CH</div> <div class="doc-subtitle">Ricevuta semplificata</div>
<div th:text="${sellerEmail}">email@example.com</div> </td>
</div> <td class="seller-block">
<div class="seller-name" th:text="${sellerDisplayName}">3D Fab Switzerland</div>
<div th:text="${sellerAddressLine1}">Sede Ticino, Svizzera</div>
<div th:text="${sellerAddressLine2}">Sede Bienne, Svizzera</div>
<div th:text="${sellerEmail}">info@3dfab.ch</div>
</td>
</tr>
</table>
<div> <table class="meta-layout">
<div><strong>Fattura</strong></div> <tr>
<div>Numero: <span th:text="${invoiceNumber}">2026-000123</span></div> <td class="order-details">
<div>Data: <span th:text="${invoiceDate}">2026-02-13</span></div> <table class="details-table">
<div>Scadenza: <span th:text="${dueDate}">2026-02-20</span></div> <tr>
</div> <td class="details-label">Data ordine / fattura</td>
</div> <td class="details-value" th:text="${invoiceDate}">2026-02-13</td>
</tr>
<tr>
<td class="details-label">Numero documento</td>
<td class="details-value" th:text="${invoiceNumber}">INV-2026-000123</td>
</tr>
<tr>
<td class="details-label">Data di scadenza</td>
<td class="details-value" th:text="${dueDate}">2026-02-20</td>
</tr>
<tr>
<td class="details-label">Valuta</td>
<td class="details-value">CHF</td>
</tr>
</table>
</td>
<td class="customer-box">
<div class="box-title">Cliente</div>
<div th:text="${buyerDisplayName}">Cliente SA</div>
<div th:text="${buyerAddressLine1}">Via Cliente 7</div>
<div th:text="${buyerAddressLine2}">8000 Zürich, CH</div>
</td>
</tr>
</table>
<div class="addresses"> <table class="line-items">
<div>
<div><strong>Fatturare a</strong></div>
<div th:text="${buyerDisplayName}">Cliente SA</div>
<div th:text="${buyerAddressLine1}">Via Cliente 7</div>
<div th:text="${buyerAddressLine2}">8000 Zürich, CH</div>
</div>
</div>
<table>
<thead> <thead>
<tr> <tr>
<th>Descrizione</th> <th>Descrizione</th>
<th style="text-align:right;">Qtà</th> <th>Qtà</th>
<th style="text-align:right;">Prezzo</th> <th>Prezzo unit.</th>
<th style="text-align:right;">Totale</th> <th>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 style="text-align:right;" th:text="${lineItem.quantity}">1</td> <td th:text="${lineItem.quantity}">1</td>
<td style="text-align:right;" th:text="${lineItem.unitPriceFormatted}">CHF 10.00</td> <td th:text="${lineItem.unitPriceFormatted}">CHF 10.00</td>
<td style="text-align:right;" th:text="${lineItem.lineTotalFormatted}">CHF 10.00</td> <td th:text="${lineItem.lineTotalFormatted}">CHF 10.00</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<table class="totals"> <table class="summary-layout">
<tr> <tr>
<td>Subtotale</td> <td class="notes">
<td style="text-align:right;" th:text="${subtotalFormatted}">CHF 10.00</td> <div class="section-caption">Informazioni</div>
</tr> <div th:text="${paymentTermsText}">
<tr> Appena riceviamo il pagamento l'ordine entra nella coda di stampa. Grazie per la fiducia.
<td><strong>Totale</strong></td> </div>
<td style="text-align:right;"><strong th:text="${grandTotalFormatted}">CHF 10.00</strong></td> <div style="margin-top: 2.5mm;">
Verifica i dettagli dell'ordine al ricevimento. Per assistenza, rispondi alla nostra email di conferma.
</div>
</td>
<td>
<table class="totals">
<tr>
<td class="totals-label">Subtotale</td>
<td class="totals-value" th:text="${subtotalFormatted}">CHF 10.00</td>
</tr>
<tr class="total-strong">
<td class="totals-label">Totale ordine</td>
<td class="totals-value" th:text="${grandTotalFormatted}">CHF 10.00</td>
</tr>
<tr class="due-row">
<td class="totals-label">Importo dovuto</td>
<td class="totals-value" th:text="${grandTotalFormatted}">CHF 10.00</td>
</tr>
</table>
</td>
</tr> </tr>
</table> </table>
<div style="margin-top:6mm;" th:text="${paymentTermsText}">
Pagamento entro 7 giorni. Grazie.
</div> </div>
<div style="page-break-before: always;"></div> <div class="qr-only-page">
<div style="position: absolute; bottom: 0; left: 0; width: 210mm; height: 105mm;" th:utext="${qrBillSvg}"> <div class="qr-bill-bottom" th:utext="${qrBillSvg}">
</div>
</div> </div>
</body> </body>

View File

@@ -1,151 +0,0 @@
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

@@ -1,23 +0,0 @@
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

@@ -1,176 +0,0 @@
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

@@ -0,0 +1,131 @@
package com.printcalculator.event.listener;
import com.printcalculator.entity.Customer;
import com.printcalculator.entity.Order;
import com.printcalculator.event.OrderCreatedEvent;
import com.printcalculator.service.email.EmailNotificationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class OrderEmailListenerTest {
@Mock
private EmailNotificationService emailNotificationService;
@InjectMocks
private OrderEmailListener orderEmailListener;
@Captor
private ArgumentCaptor<Map<String, Object>> templateDataCaptor;
private Order order;
private OrderCreatedEvent event;
@BeforeEach
void setUp() {
Customer customer = new Customer();
customer.setFirstName("John");
customer.setLastName("Doe");
customer.setEmail("john.doe@test.com");
order = new Order();
order.setId(UUID.randomUUID());
order.setCustomer(customer);
order.setCreatedAt(OffsetDateTime.parse("2026-02-21T10:00:00Z"));
order.setTotalChf(new BigDecimal("150.50"));
event = new OrderCreatedEvent(this, order);
ReflectionTestUtils.setField(orderEmailListener, "adminMailEnabled", true);
ReflectionTestUtils.setField(orderEmailListener, "adminMailAddress", "admin@printcalculator.local");
ReflectionTestUtils.setField(orderEmailListener, "frontendBaseUrl", "https://tuosito.it");
}
@Test
void handleOrderCreatedEvent_ShouldSendCustomerAndAdminEmails() {
// Act
orderEmailListener.handleOrderCreatedEvent(event);
// Assert Customer Email
verify(emailNotificationService, times(1)).sendEmail(
eq("john.doe@test.com"),
eq("Conferma Ordine #" + order.getOrderNumber() + " - 3D-Fab"),
eq("order-confirmation"),
templateDataCaptor.capture()
);
Map<String, Object> customerData = templateDataCaptor.getAllValues().get(0);
assertEquals("John", customerData.get("customerName"));
assertEquals(order.getId(), customerData.get("orderId"));
assertEquals(order.getOrderNumber(), customerData.get("orderNumber"));
assertEquals("https://tuosito.it/ordine/" + order.getId(), customerData.get("orderDetailsUrl"));
assertEquals(order.getCreatedAt().format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm")), customerData.get("orderDate"));
assertEquals("150.50", customerData.get("totalCost"));
// Assert Admin Email
verify(emailNotificationService, times(1)).sendEmail(
eq("admin@printcalculator.local"),
eq("Nuovo Ordine Ricevuto #" + order.getOrderNumber() + " - Doe"),
eq("order-confirmation"),
templateDataCaptor.capture()
);
Map<String, Object> adminData = templateDataCaptor.getAllValues().get(1);
assertEquals("John Doe", adminData.get("customerName"));
}
@Test
void handleOrderCreatedEvent_WithAdminDisabled_ShouldOnlySendCustomerEmail() {
// Arrange
ReflectionTestUtils.setField(orderEmailListener, "adminMailEnabled", false);
// Act
orderEmailListener.handleOrderCreatedEvent(event);
// Assert
verify(emailNotificationService, times(1)).sendEmail(
eq("john.doe@test.com"),
anyString(),
anyString(),
any()
);
verify(emailNotificationService, never()).sendEmail(
eq("admin@printcalculator.local"),
anyString(),
anyString(),
any()
);
}
@Test
void handleOrderCreatedEvent_ExceptionHandling_ShouldNotPropagate() {
// Arrange
doThrow(new RuntimeException("Simulated Mail Failure"))
.when(emailNotificationService).sendEmail(anyString(), anyString(), anyString(), any());
// Act & Assert
// Event listener shouldn't throw exception back, thus passing the test.
orderEmailListener.handleOrderCreatedEvent(event);
verify(emailNotificationService, times(1)).sendEmail(anyString(), anyString(), anyString(), any());
}
}

View File

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

View File

@@ -1,123 +0,0 @@
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());
}
}

View File

@@ -0,0 +1,83 @@
package com.printcalculator.service.email;
import jakarta.mail.internet.MimeMessage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.test.util.ReflectionTestUtils;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class SmtpEmailNotificationServiceTest {
@Mock
private JavaMailSender emailSender;
@Mock
private TemplateEngine templateEngine;
@Mock
private MimeMessage mimeMessage;
@InjectMocks
private SmtpEmailNotificationService emailNotificationService;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(emailNotificationService, "fromAddress", "noreply@test.com");
ReflectionTestUtils.setField(emailNotificationService, "mailEnabled", true);
}
@Test
void sendEmail_Success() {
// Arrange
String to = "user@test.com";
String subject = "Test Subject";
String templateName = "test-template";
Map<String, Object> contextData = new HashMap<>();
contextData.put("key", "value");
when(templateEngine.process(eq("email/" + templateName), any(Context.class))).thenReturn("<html>Test</html>");
when(emailSender.createMimeMessage()).thenReturn(mimeMessage);
// Act
emailNotificationService.sendEmail(to, subject, templateName, contextData);
// Assert
verify(templateEngine, times(1)).process(eq("email/" + templateName), any(Context.class));
verify(emailSender, times(1)).createMimeMessage();
verify(emailSender, times(1)).send(mimeMessage);
}
@Test
void sendEmail_Exception_ShouldNotThrow() {
// Arrange
String to = "user@test.com";
String subject = "Test Subject";
String templateName = "test-template";
Map<String, Object> contextData = new HashMap<>();
when(templateEngine.process(eq("email/" + templateName), any(Context.class))).thenThrow(new RuntimeException("Template error"));
// Act & Assert
// We expect the exception to be caught and logged, not propagated
assertDoesNotThrow(() -> emailNotificationService.sendEmail(to, subject, templateName, contextData));
verify(emailSender, never()).createMimeMessage();
verify(emailSender, never()).send(any(MimeMessage.class));
}
}

4
db.sql
View File

@@ -12,7 +12,6 @@ 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()
); );
@@ -554,7 +553,7 @@ CREATE TABLE IF NOT EXISTS payments
order_id uuid NOT NULL REFERENCES orders (order_id) ON DELETE CASCADE, order_id uuid NOT NULL REFERENCES orders (order_id) ON DELETE CASCADE,
method text NOT NULL CHECK (method IN ('QR_BILL', 'BANK_TRANSFER', 'TWINT', 'CARD', 'OTHER')), method text NOT NULL CHECK (method IN ('QR_BILL', 'BANK_TRANSFER', 'TWINT', 'CARD', 'OTHER')),
status text NOT NULL CHECK (status IN ('PENDING', 'RECEIVED', 'FAILED', 'CANCELLED', 'REFUNDED')), status text NOT NULL CHECK (status IN ('PENDING', 'REPORTED', 'RECEIVED', 'FAILED', 'CANCELLED', 'REFUNDED')),
currency char(3) NOT NULL DEFAULT 'CHF', currency char(3) NOT NULL DEFAULT 'CHF',
amount_chf numeric(12, 2) NOT NULL CHECK (amount_chf >= 0), amount_chf numeric(12, 2) NOT NULL CHECK (amount_chf >= 0),
@@ -565,6 +564,7 @@ CREATE TABLE IF NOT EXISTS payments
qr_payload text, -- se vuoi salvare contenuto QR/Swiss QR bill qr_payload text, -- se vuoi salvare contenuto QR/Swiss QR bill
initiated_at timestamptz NOT NULL DEFAULT now(), initiated_at timestamptz NOT NULL DEFAULT now(),
reported_at timestamptz,
received_at timestamptz received_at timestamptz
); );

View File

@@ -7,4 +7,7 @@ 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,4 +7,7 @@ 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,4 +7,7 @@ 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,5 +1,3 @@
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
@@ -7,26 +5,39 @@ 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
volumes: logging:
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}
@@ -35,6 +46,11 @@ 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

@@ -1,48 +1,4 @@
services: services:
backend:
platform: linux/amd64
build:
context: ./backend
platforms:
- linux/amd64
container_name: print-calculator-backend
ports:
- "8000:8000"
environment:
- DB_URL=jdbc:postgresql://db:5432/printcalc
- DB_USERNAME=printcalc
- DB_PASSWORD=printcalc_secret
- SPRING_PROFILES_ACTIVE=local
- TEMP_DIR=/app/temp
- PROFILES_DIR=/app/profiles
- CLAMAV_HOST=clamav
- CLAMAV_PORT=3310
- STORAGE_LOCATION=/app/storage
depends_on:
- db
- clamav
restart: unless-stopped
clamav:
platform: linux/amd64
image: clamav/clamav:latest
container_name: print-calculator-clamav
ports:
- "3310:3310"
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
container_name: print-calculator-frontend
ports:
- "80:80"
depends_on:
- backend
- db
restart: unless-stopped
db: db:
image: postgres:15-alpine image: postgres:15-alpine
container_name: print-calculator-db container_name: print-calculator-db
@@ -56,5 +12,16 @@ 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,6 +33,14 @@ 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,10 +2,8 @@
<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() === 'VIRUS_DETECTED') { @if (error()) {
<app-alert type="error">{{ 'CALC.ERROR_VIRUS_DETECTED' | translate }}</app-alert> <app-alert type="error">{{ 'CALC.ERROR_GENERIC' | translate }}</app-alert>
} @else if (error()) {
<app-alert type="error">{{ 'CALC.ERROR_' + error() | translate }}</app-alert>
} }
</div> </div>
@@ -21,12 +19,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)="setMode('easy')"> (click)="mode.set('easy')">
{{ 'CALC.MODE_EASY' | translate }} {{ 'CALC.MODE_EASY' | translate }}
</div> </div>
<div class="mode-option" <div class="mode-option"
[class.active]="mode() === 'advanced'" [class.active]="mode() === 'advanced'"
(click)="setMode('advanced')"> (click)="mode.set('advanced')">
{{ 'CALC.MODE_ADVANCED' | translate }} {{ 'CALC.MODE_ADVANCED' | translate }}
</div> </div>
</div> </div>
@@ -37,7 +35,6 @@
[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>
@@ -45,25 +42,15 @@
<!-- Right Column: Result or Info --> <!-- Right Column: Result or Info -->
<div class="col-result" #resultCol> <div class="col-result" #resultCol>
@if (loading() && !result()) { @if (loading()) {
<!-- 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">Analisi in corso...</h3> <h3 class="loading-title">{{ 'CALC.ANALYZING_TITLE' | translate }}</h3>
<p class="loading-text">Stiamo analizzando la geometria e calcolando il percorso utensile.</p> <p class="loading-text">{{ 'CALC.ANALYZING_TEXT' | translate }}</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<string | null>(null); error = signal<boolean>(false);
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 && sessionId !== this.result()?.sessionId) { if (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('Failed to load session'); this.error.set(true);
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, colors); this.uploadForm.setFiles(files);
this.uploadForm.patchSettings(session); this.uploadForm.patchSettings(session);
// Also restore colors? // Also restore colors?
// setFiles inits with correct colors now. // setFiles inits with 'Black'. We need to update them if they differ.
// items has colorCode.
setTimeout(() => { setTimeout(() => {
if (this.uploadForm) { if (this.uploadForm) {
items.forEach((item, index) => { items.forEach((item, index) => {
@@ -122,11 +122,7 @@ 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));
} }
}); });
} }
@@ -145,7 +141,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(null); this.error.set(false);
this.result.set(null); this.result.set(null);
this.orderSuccess.set(false); this.orderSuccess.set(false);
@@ -161,45 +157,26 @@ 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 (partial or final) // It's the result
const res = event as QuoteResult; const res = event as QuoteResult;
this.result.set(res); this.result.set(res);
this.loading.set(false);
// Show result immediately if not already showing this.uploadProgress.set(100);
if (this.step() !== 'quote') { this.step.set('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) {
// Check if we need to update URL to avoid redundant navigations this.router.navigate([], {
const currentSession = this.route.snapshot.queryParamMap.get('session'); relativeTo: this.route,
if (currentSession !== res.sessionId) { queryParams: { session: res.sessionId },
this.router.navigate([], { queryParamsHandling: 'merge', // merge with existing params like 'mode' if any
relativeTo: this.route, replaceUrl: true // prevent cluttering history, or false if we want back button to work. replaceUrl seems safer for "state update"
queryParams: { session: res.sessionId }, });
queryParamsHandling: 'merge',
replaceUrl: true
});
}
} }
} }
}, },
complete: () => { error: () => {
this.loading.set(false); this.error.set(true);
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);
} }
}); });
@@ -219,10 +196,10 @@ export class CalculatorPageComponent implements OnInit {
this.step.set('quote'); this.step.set('quote');
} }
onItemChange(event: {id?: string, fileName: string, quantity: number, index: number}) { onItemChange(event: {id?: string, fileName: string, quantity: number}) {
// 1. Update local form for consistency (UI feedback) // 1. Update local form for consistency (UI feedback)
if (this.uploadForm) { if (this.uploadForm) {
this.uploadForm.updateItemQuantityAtIndex(event.index, event.quantity); this.uploadForm.updateItemQuantityByName(event.fileName, event.quantity);
} }
// 2. Update backend session if ID exists // 2. Update backend session if ID exists
@@ -234,43 +211,6 @@ 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);
@@ -316,12 +256,4 @@ 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>* Include {{ result().setupCost | currency:result().currency }} Setup Cost</small> <small>{{ 'CALC.SETUP_NOTE' | translate:{cost: (result().setupCost | currency:result().currency)} }}</small>
</div> </div>
@if (result().notes) { @if (result().notes) {
@@ -35,45 +35,28 @@
<!-- 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; let i = $index) { @for (item of items(); track item.fileName; let i = $index) {
<div class="item-row" [class.has-error]="item.error"> <div class="item-row">
<div class="item-info"> <div class="item-info">
<span class="file-name">{{ item.fileName }}</span> <span class="file-name">{{ item.fileName }}</span>
@if (item.error) { <span class="file-details">
<span class="file-error">{{ 'CALC.ERROR_' + item.error | translate }}</span> {{ (item.unitTime / 3600) | number:'1.1-1' }}h | {{ item.unitWeight | number:'1.0-0' }}g
} @else if (item.status === 'pending') { </span>
<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">
@if (!item.error) { <div class="qty-control">
<div class="qty-control"> <label>{{ 'CHECKOUT.QTY' | translate }}:</label>
<label>Qtà:</label> <input
<input type="number"
type="number" min="1"
min="1" [ngModel]="item.quantity"
[ngModel]="item.quantity" (ngModelChange)="updateQuantity(i, $event)"
(ngModelChange)="updateQuantity(i, $event)" class="qty-input">
class="qty-input"> </div>
</div> <div class="item-price">
<div class="item-price"> {{ (item.unitPrice * item.quantity) | currency:result().currency }}
{{ (item.unitPrice * item.quantity) | currency:result().currency }} </div>
</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,11 +21,6 @@
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 {
@@ -36,21 +31,7 @@
} }
.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 { .file-details { font-size: 0.8rem; color: var(--color-text-muted); }
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,7 +6,6 @@ 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',
@@ -19,13 +18,11 @@ 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, index: number}>(); itemChange = output<{id?: string, fileName: string, quantity: number}>();
// Local mutable state for items to handle quantity changes // Local mutable state for items to handle quantity changes
items = signal<QuoteItem[]>([]); items = signal<QuoteItem[]>([]);
getColorHex = getColorHex;
constructor() { constructor() {
effect(() => { effect(() => {
// Initialize local items when result inputs change // Initialize local items when result inputs change
@@ -47,8 +44,7 @@ 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
}); });
} }
@@ -61,11 +57,9 @@ export class QuoteResultComponent {
let weight = 0; let weight = 0;
currentItems.forEach(i => { currentItems.forEach(i => {
if (i.status === 'done' && !i.error) { price += i.unitPrice * i.quantity;
price += i.unitPrice * i.quantity; time += i.unitTime * i.quantity;
time += i.unitTime * i.quantity; weight += i.unitWeight * i.quantity;
weight += i.unitWeight * i.quantity;
}
}); });
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; let i = $index) { @for (item of items(); track item.file.name; let i = $index) {
<div class="file-card" [class.active]="item.file === selectedFile()" (click)="selectFile(item.file)"> <div class="file-card" [class.active]="item.file === selectedFile()" (click)="selectFile(item.file)">
<div class="card-header"> <div class="card-header">
<span class="file-name" [title]="item.file.name">{{ item.file.name }}</span> <span class="file-name" [title]="item.file.name">{{ item.file.name }}</span>
@@ -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>QTÀ</label> <label>{{ 'CALC.QTY_SHORT' | translate }}</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>COLORE</label> <label>{{ 'CALC.COLOR_LABEL' | translate }}</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="Istruzioni specifiche..." [placeholder]="'CALC.NOTES_PLACEHOLDER' | translate"
></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 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }} {{ loading() ? (uploadProgress() < 100 ? ('CALC.UPLOADING' | translate) : ('CALC.PROCESSING' | translate)) : ('CALC.CALCULATE' | translate) }}
</app-button> </app-button>
</div> </div>
</form> </form>

View File

@@ -12,7 +12,6 @@ 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;
@@ -30,7 +29,6 @@ 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);
@@ -77,7 +75,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: [true] supportEnabled: [false]
}); });
// Listen to material changes to update variants // Listen to material changes to update variants
@@ -114,9 +112,7 @@ 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) {
// Prefer PLA Basic, otherwise first available this.form.get('material')?.setValue(this.materials()[0].value);
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
@@ -180,37 +176,6 @@ 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
@@ -241,7 +206,11 @@ 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.updateItemQuantityAtIndex(index, val); this.items.update(current => {
const updated = [...current];
updated[index] = { ...updated[index], quantity: val };
return updated;
});
} }
updateItemColor(index: number, newColor: string) { updateItemColor(index: number, newColor: string) {
@@ -253,7 +222,6 @@ 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];
@@ -262,15 +230,14 @@ export class UploadFormComponent implements OnInit {
} }
return updated; return updated;
}); });
this.itemRemoved.emit({ index, id: itemToRemove.id });
} }
setFiles(files: File[], colors?: string[]) { setFiles(files: File[]) {
const validItems: FormItem[] = []; const validItems: FormItem[] = [];
files.forEach((file, i) => { for (const file of files) {
const color = (colors && colors[i]) ? colors[i] : 'Black'; // Default color is Black or derive from somewhere if possible, but here we just init
validItems.push({ file, quantity: 1, color: color }); validItems.push({ file, quantity: 1, color: 'Black' });
}); }
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" [label]="'USER_DETAILS.NAME' | translate"
placeholder="USER_DETAILS.NAME_PLACEHOLDER" [placeholder]="'USER_DETAILS.NAME_PLACEHOLDER' | translate"
[required]="true" [required]="true"
[error]="form.get('name')?.invalid && form.get('name')?.touched ? ('COMMON.REQUIRED' | translate) : null"> [error]="form.get('name')?.invalid && form.get('name')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input> </app-input>
@@ -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" [label]="'USER_DETAILS.SURNAME' | translate"
placeholder="USER_DETAILS.SURNAME_PLACEHOLDER" [placeholder]="'USER_DETAILS.SURNAME_PLACEHOLDER' | translate"
[required]="true" [required]="true"
[error]="form.get('surname')?.invalid && form.get('surname')?.touched ? ('COMMON.REQUIRED' | translate) : null"> [error]="form.get('surname')?.invalid && form.get('surname')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input> </app-input>
@@ -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" [label]="'USER_DETAILS.EMAIL' | translate"
type="email" type="email"
placeholder="USER_DETAILS.EMAIL_PLACEHOLDER" [placeholder]="'USER_DETAILS.EMAIL_PLACEHOLDER' | translate"
[required]="true" [required]="true"
[error]="form.get('email')?.invalid && form.get('email')?.touched ? ('COMMON.INVALID_EMAIL' | translate) : null"> [error]="form.get('email')?.invalid && form.get('email')?.touched ? ('COMMON.INVALID_EMAIL' | translate) : null">
</app-input> </app-input>
@@ -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" [label]="'USER_DETAILS.PHONE' | translate"
type="tel" type="tel"
placeholder="USER_DETAILS.PHONE_PLACEHOLDER" [placeholder]="'USER_DETAILS.PHONE_PLACEHOLDER' | translate"
[required]="true" [required]="true"
[error]="form.get('phone')?.invalid && form.get('phone')?.touched ? ('COMMON.REQUIRED' | translate) : null"> [error]="form.get('phone')?.invalid && form.get('phone')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input> </app-input>
@@ -53,8 +53,8 @@
<!-- Address --> <!-- Address -->
<app-input <app-input
formControlName="address" formControlName="address"
label="USER_DETAILS.ADDRESS" [label]="'USER_DETAILS.ADDRESS' | translate"
placeholder="USER_DETAILS.ADDRESS_PLACEHOLDER" [placeholder]="'USER_DETAILS.ADDRESS_PLACEHOLDER' | translate"
[required]="true" [required]="true"
[error]="form.get('address')?.invalid && form.get('address')?.touched ? ('COMMON.REQUIRED' | translate) : null"> [error]="form.get('address')?.invalid && form.get('address')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input> </app-input>
@@ -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" [label]="'USER_DETAILS.ZIP' | translate"
placeholder="USER_DETAILS.ZIP_PLACEHOLDER" [placeholder]="'USER_DETAILS.ZIP_PLACEHOLDER' | translate"
[required]="true" [required]="true"
[error]="form.get('zip')?.invalid && form.get('zip')?.touched ? ('COMMON.REQUIRED' | translate) : null"> [error]="form.get('zip')?.invalid && form.get('zip')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input> </app-input>
@@ -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" [label]="'USER_DETAILS.CITY' | translate"
placeholder="USER_DETAILS.CITY_PLACEHOLDER" [placeholder]="'USER_DETAILS.CITY_PLACEHOLDER' | translate"
[required]="true" [required]="true"
[error]="form.get('city')?.invalid && form.get('city')?.touched ? ('COMMON.REQUIRED' | translate) : null"> [error]="form.get('city')?.invalid && form.get('city')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input> </app-input>

View File

@@ -26,8 +26,6 @@ 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 {
@@ -140,13 +138,6 @@ 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,6 +152,13 @@ export class QuoteEstimatorService {
return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, { headers }); return this.http.get(`${environment.apiUrl}/api/orders/${orderId}`, { headers });
} }
reportPayment(orderId: string, method: string): Observable<any> {
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.post(`${environment.apiUrl}/api/orders/${orderId}/payments/report`, { method }, { headers });
}
getOrderInvoice(orderId: string): Observable<Blob> { getOrderInvoice(orderId: string): Observable<Blob> {
const headers: any = {}; const headers: any = {};
// @ts-ignore // @ts-ignore
@@ -170,6 +168,13 @@ 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);
@@ -189,74 +194,18 @@ 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 emitUpdate = () => { const checkCompletion = () => {
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems); const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
observer.next(avg); observer.next(avg);
// 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) {
observer.complete(); finalize(finalResponses, sessionSetupCost, sessionId);
} }
}; };
@@ -286,42 +235,20 @@ 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((70 * event.loaded) / event.total); // Upload is 70% of "progress" for user perception allProgress[index] = Math.round((100 * event.loaded) / event.total);
emitUpdate(); checkCompletion();
} else if (event.type === HttpEventType.Response) { } else if (event.type === HttpEventType.Response) {
allProgress[index] = 100; allProgress[index] = 100;
const resBody = event.body as any; finalResponses[index] = { ...event.body, success: true, fileName: item.file.name, originalQty: item.quantity, originalItem: item };
// 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++;
emitUpdate(); checkCompletion();
} }
}, },
error: (err) => { error: (err) => {
console.error('Item upload failed', err); console.error('Item upload failed', err);
const errorMsg = err.error?.code === 'VIRUS_DETECTED' ? 'VIRUS_DETECTED' : 'UPLOAD_FAILED'; finalResponses[index] = { success: false, fileName: item.file.name };
currentItems[index] = {
...currentItems[index],
status: 'error',
error: errorMsg
};
allProgress[index] = 100; // Mark as done despite error
completedRequests++; completedRequests++;
emitUpdate(); checkCompletion();
} }
}); });
}); });
@@ -331,6 +258,62 @@ 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();
};
}); });
} }
@@ -384,8 +367,7 @@ 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,154 +1,161 @@
<div class="container hero"> <div class="checkout-page">
<h1>{{ 'CHECKOUT.TITLE' | translate }}</h1> <div class="container hero">
<p class="subtitle">{{ 'CHECKOUT.SUBTITLE' | translate }}</p> <h1 class="section-title">{{ 'CHECKOUT.TITLE' | translate }}</h1>
</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>
<div class="container"> <form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error">
<div class="checkout-layout">
<!-- Contact Info Card -->
<!-- LEFT COLUMN: Form --> <app-card class="mb-6">
<div class="checkout-form-section"> <div class="card-header-simple">
<!-- Error Message --> <h3>{{ 'CHECKOUT.CONTACT_INFO' | translate }}</h3>
<div *ngIf="error" class="error-message">
{{ error }}
</div>
<form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error">
<!-- Contact Info Card -->
<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 class="type-option" [class.selected]="isCompany" (click)="setCustomerType(true)"> <div class="form-row">
{{ 'CHECKOUT.COMPANY' | translate }} <app-input formControlName="email" type="email" [label]="'CHECKOUT.EMAIL' | translate" [required]="true" [error]="checkoutForm.get('email')?.hasError('email') ? ('CHECKOUT.INVALID_EMAIL' | translate) : null"></app-input>
<app-input formControlName="phone" type="tel" [label]="'CHECKOUT.PHONE' | translate" [required]="true"></app-input>
</div> </div>
</div> </app-card>
<div formGroupName="billingAddress"> <!-- Billing Address Card -->
<div *ngIf="isCompany" class="company-fields"> <app-card class="mb-6">
<app-input formControlName="companyName" [label]="'CHECKOUT.COMPANY_NAME' | translate" [required]="true"></app-input> <div class="card-header-simple">
<div class="form-row no-margin"> <h3>{{ 'CHECKOUT.BILLING_ADDR' | translate }}</h3>
</div>
<div formGroupName="billingAddress">
<!-- Private Person Fields -->
<div *ngIf="!isCompany" class="form-row">
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate" [required]="true"></app-input> <app-input formControlName="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>
</div>
<div *ngIf="!isCompany" class="form-row no-margin">
<app-input formControlName="firstName" [label]="'CHECKOUT.FIRST_NAME' | translate" [required]="true"></app-input>
<app-input formControlName="lastName" [label]="'CHECKOUT.LAST_NAME' | translate" [required]="true"></app-input>
</div>
</div>
</app-card>
<!-- Billing Address Card --> <!-- Company Fields -->
<app-card class="mb-6"> <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>
</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>
<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"> <div class="card-header-simple">
<h3>{{ 'CHECKOUT.BILLING_ADDR' | translate }}</h3> <h3>{{ 'CHECKOUT.SUMMARY_TITLE' | translate }}</h3>
</div> </div>
<div formGroupName="billingAddress">
<app-input formControlName="addressLine1" [label]="'CHECKOUT.ADDRESS_1' | translate" [required]="true"></app-input> <div class="summary-items" *ngIf="quoteSession() as session">
<app-input formControlName="addressLine2" [label]="'CHECKOUT.ADDRESS_2' | translate"></app-input> <div class="summary-item" *ngFor="let item of session.items">
<div class="item-details">
<div class="form-row three-cols"> <span class="item-name">{{ item.originalFilename }}</span>
<app-input formControlName="zip" [label]="'CHECKOUT.ZIP' | translate" [required]="true"></app-input> <div class="item-specs">
<app-input formControlName="city" [label]="'CHECKOUT.CITY' | translate" class="city-field" [required]="true"></app-input> <span>{{ 'CHECKOUT.QTY' | translate }}: {{ item.quantity }}</span>
<app-input formControlName="countryCode" [label]="'CHECKOUT.COUNTRY' | translate" [disabled]="true" [required]="true"></app-input> <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>
<!-- 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,23 +1,16 @@
.hero { .hero {
padding: var(--space-12) 0 var(--space-8); padding: var(--space-8) 0;
text-align: center; text-align: center;
h1 { .section-title {
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 400px; grid-template-columns: 1fr 420px;
gap: var(--space-8); gap: var(--space-8);
align-items: start; align-items: start;
margin-bottom: var(--space-12); margin-bottom: var(--space-12);
@@ -77,7 +70,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-bottom: var(--space-4); margin: var(--space-6) 0;
gap: 4px; gap: 4px;
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
@@ -195,6 +188,7 @@
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;
@@ -260,19 +254,19 @@
.summary-totals { .summary-totals {
background: var(--color-neutral-100); background: var(--color-neutral-100);
padding: var(--space-6); padding: var(--space-4);
border-radius: var(--radius-md); border-radius: var(--radius-md);
margin-top: var(--space-4); margin-top: var(--space-6);
.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-muted); color: var(--color-text);
} }
.grand-total-row { .grand-total {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
color: var(--color-text); color: var(--color-text);
@@ -303,4 +297,3 @@
} }
.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',
styleUrl: './checkout.component.scss' styleUrls: ['./checkout.component.scss']
}) })
export class CheckoutComponent implements OnInit { export class CheckoutComponent implements OnInit {
private fb = inject(FormBuilder); private fb = inject(FormBuilder);
@@ -47,6 +47,7 @@ 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],
@@ -58,6 +59,7 @@ export class CheckoutComponent implements OnInit {
firstName: [''], firstName: [''],
lastName: [''], lastName: [''],
companyName: [''], companyName: [''],
referencePerson: [''],
addressLine1: [''], addressLine1: [''],
addressLine2: [''], addressLine2: [''],
zip: [''], zip: [''],
@@ -75,16 +77,27 @@ 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 {
@@ -151,6 +164,7 @@ 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,
@@ -161,6 +175,7 @@ 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="10"></textarea> <textarea formControlName="message" class="form-control" rows="4"></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'">PDF</span> <span *ngIf="file.type === 'pdf'">{{ 'CONTACT.FILE_TYPE_PDF' | translate }}</span>
<span *ngIf="file.type === '3d'">3D</span> <span *ngIf="file.type === '3d'">{{ 'CONTACT.FILE_TYPE_3D' | translate }}</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">Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.</p> <p class="subtitle">{{ 'CONTACT.HERO_SUBTITLE' | translate }}</p>
</div> </div>
</section> </section>

View File

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

View File

@@ -0,0 +1,52 @@
<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" *ngIf="order() as o">
<app-card class="status-card">
<div class="status-badge">{{ o.status === 'SHIPPED' ? ('TRACKING.STEP_SHIPPED' | translate) : ('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="status-timeline">
<div class="timeline-step"
[class.active]="o.status === 'PENDING_PAYMENT' && o.paymentStatus !== 'REPORTED'"
[class.completed]="o.paymentStatus === 'REPORTED' || o.status !== 'PENDING_PAYMENT'">
<div class="circle">1</div>
<div class="label">{{ 'TRACKING.STEP_PENDING' | translate }}</div>
</div>
<div class="timeline-step"
[class.active]="o.paymentStatus === 'REPORTED' && o.status === 'PENDING_PAYMENT'"
[class.completed]="o.status === 'PAID' || o.status === 'IN_PRODUCTION' || o.status === 'SHIPPED' || o.status === 'COMPLETED'">
<div class="circle">2</div>
<div class="label">{{ 'TRACKING.STEP_REPORTED' | translate }}</div>
</div>
<div class="timeline-step"
[class.active]="o.status === 'PAID' || o.status === 'IN_PRODUCTION'"
[class.completed]="o.status === 'SHIPPED' || o.status === 'COMPLETED'">
<div class="circle">3</div>
<div class="label">{{ 'TRACKING.STEP_PRODUCTION' | translate }}</div>
</div>
<div class="timeline-step"
[class.active]="o.status === 'SHIPPED'"
[class.completed]="o.status === 'COMPLETED'">
<div class="circle">4</div>
<div class="label">{{ 'TRACKING.STEP_SHIPPED' | translate }}</div>
</div>
</div>
<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

@@ -0,0 +1,159 @@
.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;
}
.status-timeline {
display: flex;
justify-content: space-between;
margin-bottom: var(--space-6);
position: relative;
&::before {
content: '';
position: absolute;
top: 15px;
left: 20px;
right: 20px;
height: 2px;
background: var(--color-border);
z-index: 1;
}
}
.timeline-step {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 2;
flex: 1;
text-align: center;
.circle {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--color-neutral-100);
border: 2px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
margin-bottom: var(--space-2);
color: var(--color-text-muted);
transition: all 0.3s ease;
}
.label {
font-size: 0.85rem;
color: var(--color-text-muted);
font-weight: 500;
}
&.active {
.circle {
border-color: var(--color-primary);
background: var(--color-primary-light);
color: var(--color-primary);
}
.label {
color: var(--color-text);
font-weight: 600;
}
}
&.completed {
.circle {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
.label {
color: var(--color-text);
}
}
}
@media (max-width: 600px) {
.status-timeline {
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
&::before {
top: 10px;
bottom: 10px;
left: 15px;
width: 2px;
height: auto;
}
.timeline-step {
flex-direction: row;
gap: var(--space-3);
.circle {
margin-bottom: 0;
}
}
}
}

View File

@@ -0,0 +1,50 @@
import { Component, OnInit, inject, signal } 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;
order = signal<any>(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.order.set(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,8 +5,14 @@
<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 status-reported-card" *ngIf="o.paymentStatus === 'REPORTED'">
<div class="status-content text-center">
<h3>{{ 'PAYMENT.STATUS_REPORTED_TITLE' | translate }}</h3>
<p>{{ 'PAYMENT.STATUS_REPORTED_DESC' | translate }}</p>
</div>
</app-card>
<app-card class="mb-6"> <app-card class="mb-6">
<div class="card-header-simple"> <div class="card-header-simple">
<h3>{{ 'PAYMENT.METHOD' | translate }}</h3> <h3>{{ 'PAYMENT.METHOD' | translate }}</h3>
@@ -14,36 +20,43 @@
<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">TWINT</span> <span class="method-name">{{ 'PAYMENT.METHOD_TWINT' | translate }}</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">QR Bill / Bank Transfer</span> <span class="method-name">{{ 'PAYMENT.METHOD_BANK' | translate }}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- TWINT Details --> <div class="payment-details fade-in text-center" *ngIf="selectedPaymentMethod === 'twint'">
<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">
<div class="qr-box"> <img
<span>QR CODE</span> *ngIf="twintQrUrl()"
</div> class="twint-qr"
[src]="getTwintQrUrl()"
(error)="onTwintQrError()"
alt="TWINT payment QR" />
<p>{{ 'PAYMENT.TWINT_DESC' | translate }}</p> <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>
<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>
@@ -51,8 +64,9 @@
<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> {{ o.id }}</p> <p><strong>{{ 'PAYMENT.BANK_REF' | translate }}:</strong> {{ getDisplayOrderNumber(o) }}</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 }}
@@ -62,8 +76,11 @@
</div> </div>
<div class="actions"> <div class="actions">
<app-button (click)="completeOrder()" [disabled]="!selectedPaymentMethod" [fullWidth]="true"> <app-button
{{ 'PAYMENT.CONFIRM' | translate }} (click)="completeOrder()"
[disabled]="!selectedPaymentMethod || o.paymentStatus === 'REPORTED'"
[fullWidth]="true">
{{ o.paymentStatus === 'REPORTED' ? ('PAYMENT.IN_VERIFICATION' | translate) : ('PAYMENT.CONFIRM' | translate) }}
</app-button> </app-button>
</div> </div>
</app-card> </app-card>
@@ -73,7 +90,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">#{{ o.id.substring(0, 8) }}</p> <p class="order-id">#{{ getDisplayOrderNumber(o) }}</p>
</div> </div>
<div class="summary-totals"> <div class="summary-totals">
@@ -96,7 +113,6 @@
</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,6 +95,28 @@
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 {
@@ -105,23 +127,29 @@
} }
} }
.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;
.qr-box { .twint-qr {
width: 180px; width: 240px;
height: 180px; height: 240px;
background-color: white; background-color: #fff;
border: 2px solid var(--color-neutral-900); border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-bottom: var(--space-4);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: var(--space-2);
margin-bottom: var(--space-4);
object-fit: contain;
box-shadow: 0 6px 18px rgba(44, 37, 84, 0.08);
}
.twint-mobile-action {
width: 100%;
max-width: 320px;
margin-top: var(--space-3);
} }
.amount { .amount {
@@ -132,6 +160,12 @@
} }
} }
.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);
@@ -183,13 +217,20 @@
} }
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(-5px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 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, .loading-state { .error-message,
.loading-state {
margin-top: var(--space-12); margin-top: var(--space-12);
text-align: center; text-align: center;
} }

View File

@@ -5,6 +5,7 @@ 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',
@@ -23,11 +24,14 @@ 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);
@@ -54,13 +58,16 @@ export class PaymentComponent implements OnInit {
} }
downloadInvoice() { downloadInvoice() {
if (!this.orderId) return; const orderId = this.orderId;
this.quoteService.getOrderInvoice(this.orderId).subscribe({ if (!orderId) return;
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;
a.download = `invoice-${this.orderId}.pdf`; const fallbackOrderNumber = this.extractOrderNumber(orderId);
const orderNumber = this.order()?.orderNumber ?? fallbackOrderNumber;
a.download = `invoice-${orderNumber}.pdf`;
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
}, },
@@ -68,9 +75,76 @@ 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 {
// Simulate payment completion if (!this.orderId || !this.selectedPaymentMethod) {
alert('Payment Simulated! Order marked as PAID.'); return;
this.router.navigate(['/']); }
this.quoteService.reportPayment(this.orderId, this.selectedPaymentMethod).subscribe({
next: (order) => {
this.order.set(order);
// The UI will re-render and show the 'REPORTED' state.
// We stay on this page to let the user see the "In verifica"
// status along with payment instructions.
},
error: (err) => {
console.error('Failed to report payment', err);
this.error.set('Failed to report payment. Please try again.');
}
});
}
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>Prodotto non trovato.</p> <p>{{ 'SHOP.NOT_FOUND' | translate }}</p>
} }
</div> </div>

View File

@@ -32,12 +32,14 @@
.btn-outline { .btn-outline {
background-color: transparent; background-color: transparent;
border-color: var(--color-border); border-color: var(--color-brand);
color: var(--color-text); border-width: 2px;
padding: calc(0.5rem - 1px) calc(1rem - 1px);
color: var(--color-neutral-900);
font-weight: 600;
&:hover:not(:disabled) { &:hover:not(:disabled) {
border-color: var(--color-brand); background-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,6 +25,7 @@ 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;
@@ -38,14 +39,14 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
} }
if (changes['color'] && this.currentMesh && !changes['file']) { if (changes['color'] && this.currentMesh && !changes['file']) {
// Update existing mesh color if only color changed this.applyColorStyle(this.color);
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();
} }
@@ -54,28 +55,51 @@ 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(0xf7f6f2); // Neutral-50 this.scene.background = new THREE.Color(0xf4f8fc);
// Lights // Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); const ambientLight = new THREE.AmbientLight(0xffffff, 0.75);
this.scene.add(ambientLight); this.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); const hemiLight = new THREE.HemisphereLight(0xf8fbff, 0xc8d3df, 0.95);
directionalLight.position.set(1, 1, 1); hemiLight.position.set(0, 30, 0);
this.scene.add(directionalLight); this.scene.add(hemiLight);
const directionalLight1 = new THREE.DirectionalLight(0xffffff, 1.35);
directionalLight1.position.set(6, 8, 6);
this.scene.add(directionalLight1);
const directionalLight2 = new THREE.DirectionalLight(0xe8f0ff, 0.85);
directionalLight2.position.set(-7, 4, -5);
this.scene.add(directionalLight2);
const directionalLight3 = new THREE.DirectionalLight(0xffffff, 0.55);
directionalLight3.position.set(0, 5, -9);
this.scene.add(directionalLight3);
// Camera // 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 }); this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.2;
this.renderer.setSize(width, height); this.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();
@@ -95,24 +119,27 @@ 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);
if (this.currentMesh) { this.clearCurrentMesh();
this.scene.remove(this.currentMesh);
this.currentMesh.geometry.dispose();
}
const material = new THREE.MeshPhongMaterial({ geometry.computeVertexNormals();
color: this.color,
specular: 0x111111, const material = new THREE.MeshStandardMaterial({
shininess: 200 color: this.color,
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();
@@ -140,9 +167,10 @@ 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.5; // Tighter zoom (reduced from 2.5) cameraZ *= 1.72;
this.camera.position.z = cameraZ; this.camera.position.set(cameraZ * 0.65, cameraZ * 0.95, cameraZ * 1.1);
this.camera.lookAt(0, 0, 0);
this.camera.updateProjectionMatrix(); this.camera.updateProjectionMatrix();
this.controls.update(); this.controls.update();
@@ -157,9 +185,63 @@ 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": "Easy Print", "MODE_EASY": "Quick",
"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,9 +61,6 @@
"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.",
@@ -158,16 +155,6 @@
"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",
@@ -177,17 +164,29 @@
"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,6 +194,27 @@
"SHIPPING": "Shipping", "SHIPPING": "Shipping",
"SETUP_FEE": "Setup Fee", "SETUP_FEE": "Setup Fee",
"TOTAL": "Total", "TOTAL": "Total",
"LOADING": "Loading order details..." "LOADING": "Loading order details...",
"METHOD_TWINT": "TWINT",
"METHOD_BANK": "Bank Transfer / QR",
"STATUS_REPORTED_TITLE": "Payment Reported",
"STATUS_REPORTED_DESC": "We are verifying your transaction. Your order will move to production as soon as the payment is confirmed.",
"IN_VERIFICATION": "Verifying Payment"
},
"TRACKING": {
"STEP_PENDING": "Pending",
"STEP_REPORTED": "Verifying",
"STEP_PRODUCTION": "Production",
"STEP_SHIPPED": "Shipped"
},
"ORDER_CONFIRMED": {
"TITLE": "Order Confirmed",
"SUBTITLE": "Payment received. Your order is now being processed.",
"STATUS": "Processing",
"HEADING": "We are preparing your order",
"ORDER_REF": "Order reference",
"PROCESSING_TEXT": "As soon as payment is confirmed, your order will move to production.",
"EMAIL_TEXT": "We will send you an email update with status and next steps.",
"BACK_HOME": "Back to Home"
} }
} }

View File

@@ -11,13 +11,59 @@
"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": "Stampa Facile", "MODE_EASY": "Base",
"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",
@@ -40,9 +86,6 @@
"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.",
@@ -50,13 +93,53 @@
"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",
@@ -129,7 +212,10 @@
"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",
@@ -137,36 +223,41 @@
"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 riga 1", "ADDRESS_1": "Indirizzo (Via e numero)",
"ADDRESS_2": "Indirizzo riga 2 (Opzionale)", "ADDRESS_2": "Informazioni aggiuntive (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",
@@ -174,6 +265,27 @@
"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",
"STATUS_REPORTED_TITLE": "Abbiamo ricevuto la tua segnalazione",
"STATUS_REPORTED_DESC": "Stiamo verificando la transazione. Il tuo ordine passerà in produzione non appena l'accredito sarà confermato.",
"IN_VERIFICATION": "Pagamento in verifica"
},
"TRACKING": {
"STEP_PENDING": "In attesa",
"STEP_REPORTED": "In verifica",
"STEP_PRODUCTION": "In Produzione",
"STEP_SHIPPED": "Spedito"
},
"ORDER_CONFIRMED": {
"TITLE": "Ordine Confermato",
"SUBTITLE": "Pagamento registrato. Il tuo ordine è ora in elaborazione.",
"STATUS": "In elaborazione",
"HEADING": "Stiamo preparando il tuo ordine",
"ORDER_REF": "Riferimento ordine",
"PROCESSING_TEXT": "Non appena confermiamo il pagamento, il tuo ordine passerà in produzione.",
"EMAIL_TEXT": "Ti invieremo una email con aggiornamento stato e prossimi step.",
"BACK_HOME": "Torna alla Home"
} }
} }