47 Commits

Author SHA1 Message Date
4f4e3def35 Merge pull request 'dev' (#7) from dev into int
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m13s
Build, Test and Deploy / build-and-push (push) Successful in 14s
Build, Test and Deploy / deploy (push) Successful in 8s
Reviewed-on: #7
2026-02-12 15:26:38 +01:00
bb151ae835 feat(web): calculator improvements
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m12s
Build, Test and Deploy / build-and-push (push) Successful in 21s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-12 14:49:23 +01:00
7ebaff322c feat(web): improvements in routes for calculator
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m12s
Build, Test and Deploy / build-and-push (push) Successful in 22s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-12 12:44:12 +01:00
e17da96c22 fix(web): enrivoments
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m18s
Build, Test and Deploy / build-and-push (push) Successful in 26s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-12 12:19:59 +01:00
3e9745c7cc feat(deploy): compose deploy fixed
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m20s
Build, Test and Deploy / build-and-push (push) Successful in 17s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-12 12:07:16 +01:00
3da3e6c60c feat(deploy): compose deploy fixed
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m17s
Build, Test and Deploy / build-and-push (push) Successful in 18s
Build, Test and Deploy / deploy (push) Successful in 7s
2026-02-12 11:56:31 +01:00
85b823d614 feat(deploy): compose deploy fixed
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m13s
Build, Test and Deploy / build-and-push (push) Successful in 14s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-11 16:25:20 +01:00
d20d12c1f4 feat(deploy): compose deploy fixed
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m12s
Build, Test and Deploy / build-and-push (push) Successful in 13s
Build, Test and Deploy / deploy (push) Successful in 4s
2026-02-11 16:19:31 +01:00
ab5f6a609d feat(deploy): compose deploy fixed
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m12s
Build, Test and Deploy / build-and-push (push) Successful in 15s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-11 16:14:00 +01:00
5ba203a8d1 feat(deploy): compose deploy fixed
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m13s
Build, Test and Deploy / build-and-push (push) Successful in 14s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-11 16:04:06 +01:00
3ca3f8e466 feat(deploy): compose deploy fixed
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m13s
Build, Test and Deploy / build-and-push (push) Successful in 14s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-11 15:54:06 +01:00
5620f6a8eb feat(deploy): compose deploy fixed
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m11s
Build, Test and Deploy / build-and-push (push) Successful in 13s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-11 15:50:09 +01:00
1583ff479c feat(deploy): compose deploy fixed
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m12s
Build, Test and Deploy / build-and-push (push) Successful in 13s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-11 15:46:06 +01:00
b7d81040e6 feat(deploy): fix deploy ports
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 1m14s
Build, Test and Deploy / build-and-push (push) Successful in 14s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-11 15:27:09 +01:00
b249cf2000 feat(deploy): fix deploy ports
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 1m12s
Build, Test and Deploy / build-and-push (push) Successful in 15s
Build, Test and Deploy / deploy (push) Failing after 3s
2026-02-11 15:21:41 +01:00
dfc27da142 feat(deploy): added cache Gradle step for faster deploy
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 1m40s
Build, Test and Deploy / build-and-push (push) Successful in 13s
Build, Test and Deploy / deploy (push) Failing after 3s
2026-02-11 15:10:03 +01:00
dde92af857 fix(deploy): fix regex
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 25s
Build, Test and Deploy / build-and-push (push) Successful in 35s
Build, Test and Deploy / deploy (push) Failing after 3s
2026-02-11 15:08:25 +01:00
7b92e63a49 fix(deploy): fix cicd.yaml printf fixed
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 25s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-11 15:03:07 +01:00
8fac8ac892 feat(back-end): db connections implemented and created users 2026-02-11 14:53:46 +01:00
e5183590c5 feat(back-end): db connections and other stuff
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 26s
Build, Test and Deploy / build-and-push (push) Successful in 15s
Build, Test and Deploy / deploy (push) Successful in 3s
2026-02-10 19:07:37 +01:00
a219825b28 Merge pull request 'dev' (#6) from dev into int
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 32s
Build, Test and Deploy / build-and-push (push) Successful in 15s
Build, Test and Deploy / deploy (push) Successful in 4s
Reviewed-on: #6
2026-02-10 19:05:40 +01:00
3b4ef37e58 feat(web): * for reaquired field
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 43s
Build, Test and Deploy / build-and-push (push) Successful in 49s
Build, Test and Deploy / deploy (push) Successful in 4s
2026-02-09 19:29:14 +01:00
eb4ad8b637 feat(web): * for reaquired field
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 22s
Build, Test and Deploy / build-and-push (push) Successful in 37s
Build, Test and Deploy / deploy (push) Successful in 9s
2026-02-09 18:55:38 +01:00
f0e0f57e7c feat(web): multiple feature
Some checks failed
Build, Test and Deploy / build-and-push (push) Has been cancelled
Build, Test and Deploy / test-backend (push) Successful in 23s
Build, Test and Deploy / deploy (push) Has been cancelled
2026-02-09 18:54:06 +01:00
150563a8f5 Merge pull request 'dev' (#5) from dev into int
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 24s
Build, Test and Deploy / deploy (push) Successful in 5s
Build, Test and Deploy / build-and-push (push) Successful in 15s
Reviewed-on: #5
2026-02-09 18:43:43 +01:00
05e1c224f0 feat(web): success comnponent
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 26s
Build, Test and Deploy / build-and-push (push) Successful in 21s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-09 18:07:29 +01:00
f1636d9057 feat(web): success message contact us
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 26s
Build, Test and Deploy / build-and-push (push) Successful in 27s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-09 17:59:51 +01:00
44d99b0a68 feat(web): new step for user details
Some checks failed
Build, Test and Deploy / test-backend (push) Has been cancelled
Build, Test and Deploy / build-and-push (push) Has been cancelled
Build, Test and Deploy / deploy (push) Has been cancelled
2026-02-09 17:53:43 +01:00
83b3008234 feat(web): new step for user details 2026-02-09 17:52:34 +01:00
78af87ac3c feat(web): new step for user details
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 25s
Build, Test and Deploy / build-and-push (push) Failing after 21s
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-09 17:49:36 +01:00
b3c0413b7c feat(web): improvements in home and about us
Some checks failed
Build, Test and Deploy / test-backend (push) Failing after 1m55s
Build, Test and Deploy / build-and-push (push) Has been skipped
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-09 17:38:02 +01:00
4f301b1652 feat(web): update quality print advanced and base
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 29s
Build, Test and Deploy / build-and-push (push) Successful in 23s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-09 16:06:19 +01:00
debf153f58 feat(web): update quality print advanced and base
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 22s
Build, Test and Deploy / build-and-push (push) Successful in 21s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-06 13:58:23 +01:00
f3d271ded2 feat(web): update color selector
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 21s
Build, Test and Deploy / build-and-push (push) Successful in 21s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-06 13:40:44 +01:00
13790f2055 feat(web): update color selector
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 27s
Build, Test and Deploy / build-and-push (push) Successful in 24s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-06 13:25:41 +01:00
bcdeafe119 chore(web): refractor
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 23s
Build, Test and Deploy / build-and-push (push) Successful in 22s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-06 11:33:25 +01:00
7978884ca6 feat(front-end): fix advanced view
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 23s
Build, Test and Deploy / build-and-push (push) Successful in 22s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-06 11:18:57 +01:00
cb7b44073c feat(front-end): responsive layout
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 31s
Build, Test and Deploy / build-and-push (push) Successful in 1m40s
Build, Test and Deploy / deploy (push) Successful in 6s
2026-02-06 11:09:46 +01:00
99ae6db064 feat(front-end): multiple file upload
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 22s
Build, Test and Deploy / build-and-push (push) Successful in 20s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-05 17:21:52 +01:00
fcf439e369 feat(front-end): multiple file upload
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 22s
Build, Test and Deploy / build-and-push (push) Successful in 20s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-05 17:14:26 +01:00
cecdfacd33 feat(front-end): multiple file upload
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 23s
Build, Test and Deploy / deploy (push) Has been cancelled
Build, Test and Deploy / build-and-push (push) Has been cancelled
2026-02-05 17:13:56 +01:00
5bc698815c fix(back-end): update profile inheritance
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 21s
Build, Test and Deploy / build-and-push (push) Failing after 19s
Build, Test and Deploy / deploy (push) Has been skipped
2026-02-05 17:12:18 +01:00
53e141f8ad Merge pull request 'dev' (#3) from dev into int
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 21s
Build, Test and Deploy / build-and-push (push) Successful in 14s
Build, Test and Deploy / deploy (push) Successful in 5s
Reviewed-on: #3
2026-02-05 15:30:04 +01:00
73ccf8f4de feat(web): fix col
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 23s
Build, Test and Deploy / build-and-push (push) Successful in 14s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-05 15:08:58 +01:00
0b4daed512 feat(web) improvements in ui for calculator
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 22s
Build, Test and Deploy / build-and-push (push) Successful in 21s
Build, Test and Deploy / deploy (push) Successful in 4s
2026-02-05 15:03:18 +01:00
8a7d736aa9 feat(web) improvements in ui for calculator
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 23s
Build, Test and Deploy / build-and-push (push) Successful in 23s
Build, Test and Deploy / deploy (push) Successful in 5s
2026-02-05 14:57:32 +01:00
ce179cac62 feat(web) linked calculator and contact form
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 27s
Build, Test and Deploy / build-and-push (push) Successful in 24s
Build, Test and Deploy / deploy (push) Successful in 4s
2026-02-05 11:42:48 +01:00
139 changed files with 5672 additions and 1787 deletions

View File

@@ -21,6 +21,16 @@ jobs:
java-version: '21'
distribution: 'temurin'
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('backend/gradle/wrapper/gradle-wrapper.properties', 'backend/**/*.gradle*', 'backend/gradle.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
- name: Run Tests with Gradle
run: |
cd backend
@@ -81,6 +91,9 @@ jobs:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set ENV
shell: bash
run: |
@@ -92,7 +105,7 @@ jobs:
echo "ENV=dev" >> "$GITHUB_ENV"
fi
- name: Trigger deploy on Unraid (forced command key)
- name: Setup SSH key
shell: bash
run: |
set -euo pipefail
@@ -120,9 +133,48 @@ jobs:
# 5) Validazione: se fallisce qui, la chiave NON è valida/corrotta
ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
# ... (resto del codice uguale)
ssh-keyscan -H "${{ secrets.SERVER_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
- name: Write env to server
shell: bash
run: |
# 1. Start with the static env file content
cat "deploy/envs/${{ env.ENV }}.env" > /tmp/full_env.env
# 2. Determine DB credentials
if [[ "${{ env.ENV }}" == "prod" ]]; then
DB_URL="${{ secrets.DB_URL_PROD }}"
DB_USER="${{ secrets.DB_USERNAME_PROD }}"
DB_PASS="${{ secrets.DB_PASSWORD_PROD }}"
elif [[ "${{ env.ENV }}" == "int" ]]; then
DB_URL="${{ secrets.DB_URL_INT }}"
DB_USER="${{ secrets.DB_USERNAME_INT }}"
DB_PASS="${{ secrets.DB_PASSWORD_INT }}"
else
DB_URL="${{ secrets.DB_URL_DEV }}"
DB_USER="${{ secrets.DB_USERNAME_DEV }}"
DB_PASS="${{ secrets.DB_PASSWORD_DEV }}"
fi
# 3. Append DB credentials
printf '\nDB_URL=%s\nDB_USERNAME=%s\nDB_PASSWORD=%s\n' \
"$DB_URL" "$DB_USER" "$DB_PASS" >> /tmp/full_env.env
# 4. Debug: print content (for debug purposes)
echo "Preparing to send env file with variables:"
grep -v "PASSWORD" /tmp/full_env.env || true
# 5. Send to server
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
"setenv ${{ env.ENV }}" < /tmp/full_env.env
- name: Trigger deploy on Unraid (forced command key)
shell: bash
run: |
set -euo pipefail
# Aggiungiamo le opzioni di verbosità se dovesse fallire ancora,
# e assicuriamoci che l'input sia pulito
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "${{ env.ENV }}"
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" "deploy ${{ env.ENV }}"

View File

@@ -36,3 +36,7 @@ Questo file serve a dare contesto all'AI (Antigravity/Gemini) sulla struttura e
- Per eseguire il backend serve `uvicorn`.
- Il frontend richiede `npm install` al primo avvio.
- Le configurazioni di stampa (layer height, wall thickness, infill) sono attualmente hardcoded o con valori di default nel backend, ma potrebbero essere esposte come parametri API in futuro.
## AI Agent Rules
- **No Inline Code**: Tutti i componenti Angular DEVONO usare file separati per HTML (`templateUrl`) e SCSS (`styleUrl`). È vietato usare `template` o `styles` inline nel decoratore `@Component`.

View File

@@ -1,10 +0,0 @@
.PHONY: install s
install:
@echo "Installing Backend dependencies..."
cd backend && pip install -r requirements.txt || pip install fastapi uvicorn trimesh python-multipart numpy
@echo "Installing Frontend dependencies..."
cd frontend && npm install
start:
@echo "Starting development environment..."
./start.sh

View File

@@ -19,7 +19,7 @@ RUN apt-get update && apt-get install -y \
libglib2.0-0 \
libgtk-3-0 \
libdbus-1-3 \
libwebkit2gtk-4.1-0 \
libwebkit2gtk-4.0-37 \
&& rm -rf /var/lib/apt/lists/*
# Install OrcaSlicer
@@ -41,4 +41,6 @@ COPY profiles ./profiles
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
ENTRYPOINT ["./entrypoint.sh"]

View File

@@ -1,5 +1,6 @@
plugins {
id 'java'
id 'application'
id 'org.springframework.boot' version '3.4.1'
id 'io.spring.dependency-management' version '1.1.7'
}
@@ -13,12 +14,18 @@ java {
}
}
application {
mainClass = 'com.printcalculator.BackendApplication'
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'org.postgresql:postgresql'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
@@ -27,3 +34,11 @@ dependencies {
tasks.named('test') {
useJUnitPlatform()
}
tasks.named('bootRun') {
args = ["--spring.profiles.active=local"]
}
application {
applicationDefaultJvmArgs = ["-Dspring.profiles.active=local"]
}

15
backend/entrypoint.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
echo "----------------------------------------------------------------"
echo "Starting Backend Application"
echo "DB_URL: $DB_URL"
echo "DB_USERNAME: $DB_USERNAME"
echo "SLICER_PATH: $SLICER_PATH"
echo "--- ALL ENV VARS ---"
env
echo "----------------------------------------------------------------"
# Exec java with explicit properties from env
exec java -jar app.jar \
--spring.datasource.url="${DB_URL}" \
--spring.datasource.username="${DB_USERNAME}" \
--spring.datasource.password="${DB_PASSWORD}"

View File

@@ -1,43 +0,0 @@
package com.printcalculator.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "pricing")
public class AppProperties {
private double filamentCostPerKg;
private double machineCostPerHour;
private double energyCostPerKwh;
private double printerPowerWatts;
private double markupPercent;
private String slicerPath;
private String profilesRoot;
// Getters and Setters needed for Spring binding
public double getFilamentCostPerKg() { return filamentCostPerKg; }
public void setFilamentCostPerKg(double filamentCostPerKg) { this.filamentCostPerKg = filamentCostPerKg; }
public double getMachineCostPerHour() { return machineCostPerHour; }
public void setMachineCostPerHour(double machineCostPerHour) { this.machineCostPerHour = machineCostPerHour; }
public double getEnergyCostPerKwh() { return energyCostPerKwh; }
public void setEnergyCostPerKwh(double energyCostPerKwh) { this.energyCostPerKwh = energyCostPerKwh; }
public double getPrinterPowerWatts() { return printerPowerWatts; }
public void setPrinterPowerWatts(double printerPowerWatts) { this.printerPowerWatts = printerPowerWatts; }
public double getMarkupPercent() { return markupPercent; }
public void setMarkupPercent(double markupPercent) { this.markupPercent = markupPercent; }
// Slicer props are not under "pricing" prefix in properties file?
// Wait, in application.properties I put them at root level/custom.
// Let's fix this class to map correctly or change prefix.
// I'll make a separate section or just bind manually.
// Actually, I'll just add @Value in services for simplicity or fix the prefix structure.
// Let's stick to standard @Value for simple paths if this is messy.
// Or better, creating a dedicated SlicerProperties.
}

View File

@@ -0,0 +1,19 @@
package com.printcalculator.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost", "http://localhost:4200", "http://localhost:80", "http://127.0.0.1")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);
}
}

View File

@@ -1,13 +0,0 @@
package com.printcalculator.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "")
// Hack: standard prefix is usually required. I'll use @Value in service or correct this.
// Better: make SlicerConfig class.
public class SlicerConfig {
// Intentionally empty, will use @Value in service for simplicity
// or fix in next step.
}

View File

@@ -0,0 +1,125 @@
package com.printcalculator.controller;
import com.printcalculator.dto.OptionsResponse;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.*; // This line replaces specific entity imports
import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.LayerHeightOptionRepository;
import com.printcalculator.repository.NozzleOptionRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
@RestController
public class OptionsController {
private final FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo;
private final LayerHeightOptionRepository layerHeightRepo;
private final NozzleOptionRepository nozzleRepo;
public OptionsController(FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo,
LayerHeightOptionRepository layerHeightRepo,
NozzleOptionRepository nozzleRepo) {
this.materialRepo = materialRepo;
this.variantRepo = variantRepo;
this.layerHeightRepo = layerHeightRepo;
this.nozzleRepo = nozzleRepo;
}
@GetMapping("/api/calculator/options")
public ResponseEntity<OptionsResponse> getOptions() {
// 1. Materials & Variants
List<FilamentMaterialType> types = materialRepo.findAll();
List<FilamentVariant> allVariants = variantRepo.findAll();
List<OptionsResponse.MaterialOption> materialOptions = types.stream()
.map(type -> {
List<OptionsResponse.VariantOption> variants = allVariants.stream()
.filter(v -> v.getFilamentMaterialType().getId().equals(type.getId()) && v.getIsActive())
.map(v -> new OptionsResponse.VariantOption(
v.getVariantDisplayName(),
v.getColorName(),
getColorHex(v.getColorName()), // Need helper or store hex in DB
v.getStockSpools().doubleValue() <= 0
))
.collect(Collectors.toList());
// Only include material if it has active variants
if (variants.isEmpty()) return null;
return new OptionsResponse.MaterialOption(
type.getMaterialCode(),
type.getMaterialCode() + (type.getIsFlexible() ? " (Flexible)" : " (Standard)"),
variants
);
})
.filter(m -> m != null)
.collect(Collectors.toList());
// 2. Qualities (Static as per user request)
List<OptionsResponse.QualityOption> qualities = List.of(
new OptionsResponse.QualityOption("draft", "Draft"),
new OptionsResponse.QualityOption("standard", "Standard"),
new OptionsResponse.QualityOption("extra_fine", "High Definition")
);
// 3. Infill Patterns (Static as per user request)
List<OptionsResponse.InfillPatternOption> patterns = List.of(
new OptionsResponse.InfillPatternOption("grid", "Grid"),
new OptionsResponse.InfillPatternOption("gyroid", "Gyroid"),
new OptionsResponse.InfillPatternOption("cubic", "Cubic")
);
// 4. Layer Heights
List<OptionsResponse.LayerHeightOptionDTO> layers = layerHeightRepo.findAll().stream()
.filter(l -> l.getIsActive())
.sorted(Comparator.comparing(LayerHeightOption::getLayerHeightMm))
.map(l -> new OptionsResponse.LayerHeightOptionDTO(
l.getLayerHeightMm().doubleValue(),
String.format("%.2f mm", l.getLayerHeightMm())
))
.collect(Collectors.toList());
// 5. Nozzles
List<OptionsResponse.NozzleOptionDTO> nozzles = nozzleRepo.findAll().stream()
.filter(n -> n.getIsActive())
.sorted(Comparator.comparing(NozzleOption::getNozzleDiameterMm))
.map(n -> new OptionsResponse.NozzleOptionDTO(
n.getNozzleDiameterMm().doubleValue(),
String.format("%.1f mm%s", n.getNozzleDiameterMm(),
n.getExtraNozzleChangeFeeChf().doubleValue() > 0
? String.format(" (+ %.2f CHF)", n.getExtraNozzleChangeFeeChf())
: " (Standard)")
))
.collect(Collectors.toList());
return ResponseEntity.ok(new OptionsResponse(materialOptions, qualities, patterns, layers, nozzles));
}
// Temporary helper until we add hex to DB
private String getColorHex(String colorName) {
String lower = colorName.toLowerCase();
if (lower.contains("black") || lower.contains("nero")) return "#1a1a1a";
if (lower.contains("white") || lower.contains("bianco")) return "#f5f5f5";
if (lower.contains("blue") || lower.contains("blu")) return "#1976d2";
if (lower.contains("red") || lower.contains("rosso")) return "#d32f2f";
if (lower.contains("green") || lower.contains("verde")) return "#388e3c";
if (lower.contains("orange") || lower.contains("arancione")) return "#ffa726";
if (lower.contains("grey") || lower.contains("gray") || lower.contains("grigio")) {
if (lower.contains("dark") || lower.contains("scuro")) return "#424242";
return "#bdbdbd";
}
if (lower.contains("purple") || lower.contains("viola")) return "#7b1fa2";
if (lower.contains("yellow") || lower.contains("giallo")) return "#fbc02d";
return "#9e9e9e"; // Default grey
}
}

View File

@@ -1,43 +1,87 @@
package com.printcalculator.controller;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
import com.printcalculator.repository.PrinterMachineRepository;
import com.printcalculator.service.QuoteCalculator;
import com.printcalculator.service.SlicerService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.Map;
import java.util.HashMap;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@RestController
@CrossOrigin(origins = "*") // Allow all for development
public class QuoteController {
private final SlicerService slicerService;
private final QuoteCalculator quoteCalculator;
private final PrinterMachineRepository machineRepo;
// Defaults
private static final String DEFAULT_MACHINE = "Bambu_Lab_A1_machine";
private static final String DEFAULT_FILAMENT = "Bambu_PLA_Basic";
private static final String DEFAULT_PROCESS = "Bambu_Process_0.20_Standard";
// Defaults (using aliases defined in ProfileManager)
private static final String DEFAULT_FILAMENT = "pla_basic";
private static final String DEFAULT_PROCESS = "standard";
public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator) {
public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator, PrinterMachineRepository machineRepo) {
this.slicerService = slicerService;
this.quoteCalculator = quoteCalculator;
this.machineRepo = machineRepo;
}
@PostMapping("/api/quote")
public ResponseEntity<QuoteResult> calculateQuote(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "machine", defaultValue = DEFAULT_MACHINE) String machine,
@RequestParam(value = "filament", defaultValue = DEFAULT_FILAMENT) String filament,
@RequestParam(value = "process", defaultValue = DEFAULT_PROCESS) String process
@RequestParam(value = "filament", required = false, defaultValue = DEFAULT_FILAMENT) String filament,
@RequestParam(value = "process", required = false) String process,
@RequestParam(value = "quality", required = false) String quality,
// Advanced Options
@RequestParam(value = "infill_density", required = false) Integer infillDensity,
@RequestParam(value = "infill_pattern", required = false) String infillPattern,
@RequestParam(value = "layer_height", required = false) Double layerHeight,
@RequestParam(value = "nozzle_diameter", required = false) Double nozzleDiameter,
@RequestParam(value = "support_enabled", required = false) Boolean supportEnabled
) throws IOException {
return processRequest(file, machine, filament, process);
// ... process selection logic ...
String actualProcess = process;
if (actualProcess == null || actualProcess.isEmpty()) {
if (quality != null && !quality.isEmpty()) {
actualProcess = quality;
} else {
actualProcess = DEFAULT_PROCESS;
}
}
// Prepare Overrides
Map<String, String> processOverrides = new HashMap<>();
Map<String, String> machineOverrides = new HashMap<>();
if (infillDensity != null) {
processOverrides.put("sparse_infill_density", infillDensity + "%");
}
if (infillPattern != null && !infillPattern.isEmpty()) {
processOverrides.put("sparse_infill_pattern", infillPattern);
}
if (layerHeight != null) {
processOverrides.put("layer_height", String.valueOf(layerHeight));
}
if (supportEnabled != null) {
processOverrides.put("enable_support", supportEnabled ? "1" : "0");
}
if (nozzleDiameter != null) {
machineOverrides.put("nozzle_diameter", String.valueOf(nozzleDiameter));
// Also need to ensure the printer profile is compatible or just override?
// Usually nozzle diameter changes require a different printer profile or deep overrides.
// For now, we trust the override key works on the base profile.
}
return processRequest(file, filament, actualProcess, machineOverrides, processOverrides);
}
@PostMapping("/calculate/stl")
@@ -45,30 +89,37 @@ public class QuoteController {
@RequestParam("file") MultipartFile file
) throws IOException {
// Legacy endpoint uses defaults
return processRequest(file, DEFAULT_MACHINE, DEFAULT_FILAMENT, DEFAULT_PROCESS);
return processRequest(file, DEFAULT_FILAMENT, DEFAULT_PROCESS, null, null);
}
private ResponseEntity<QuoteResult> processRequest(MultipartFile file, String machine, String filament, String process) throws IOException {
private ResponseEntity<QuoteResult> processRequest(MultipartFile file, String filament, String process,
Map<String, String> machineOverrides,
Map<String, String> processOverrides) throws IOException {
if (file.isEmpty()) {
return ResponseEntity.badRequest().build();
}
// Fetch Default Active Machine
PrinterMachine machine = machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new IOException("No active printer found in database"));
// Save uploaded file temporarily
Path tempInput = Files.createTempFile("upload_", "_" + file.getOriginalFilename());
try {
file.transferTo(tempInput.toFile());
// Slice
PrintStats stats = slicerService.slice(tempInput.toFile(), machine, filament, process);
String slicerMachineProfile = "bambu_a1"; // TODO: Add to PrinterMachine entity
// Calculate Quote
QuoteResult result = quoteCalculator.calculate(stats);
PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides);
// Calculate Quote (Pass machine display name for pricing lookup)
QuoteResult result = quoteCalculator.calculate(stats, machine.getPrinterDisplayName(), filament);
return ResponseEntity.ok(result);
} catch (Exception e) {
e.printStackTrace();
return ResponseEntity.internalServerError().build(); // Simplify error handling for now
return ResponseEntity.internalServerError().build();
} finally {
Files.deleteIfExists(tempInput);
}

View File

@@ -0,0 +1,18 @@
package com.printcalculator.dto;
import java.util.List;
public record OptionsResponse(
List<MaterialOption> materials,
List<QualityOption> qualities,
List<InfillPatternOption> infillPatterns,
List<LayerHeightOptionDTO> layerHeights,
List<NozzleOptionDTO> nozzleDiameters
) {
public record MaterialOption(String code, String label, List<VariantOption> variants) {}
public record VariantOption(String name, String colorName, String hexColor, boolean isOutOfStock) {}
public record QualityOption(String id, String label) {}
public record InfillPatternOption(String id, String label) {}
public record LayerHeightOptionDTO(double value, String label) {}
public record NozzleOptionDTO(double value, String label) {}
}

View File

@@ -0,0 +1,68 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
@Entity
@Table(name = "filament_material_type")
public class FilamentMaterialType {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "filament_material_type_id", nullable = false)
private Long id;
@Column(name = "material_code", nullable = false, length = Integer.MAX_VALUE)
private String materialCode;
@ColumnDefault("false")
@Column(name = "is_flexible", nullable = false)
private Boolean isFlexible;
@ColumnDefault("false")
@Column(name = "is_technical", nullable = false)
private Boolean isTechnical;
@Column(name = "technical_type_label", length = Integer.MAX_VALUE)
private String technicalTypeLabel;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getMaterialCode() {
return materialCode;
}
public void setMaterialCode(String materialCode) {
this.materialCode = materialCode;
}
public Boolean getIsFlexible() {
return isFlexible;
}
public void setIsFlexible(Boolean isFlexible) {
this.isFlexible = isFlexible;
}
public Boolean getIsTechnical() {
return isTechnical;
}
public void setIsTechnical(Boolean isTechnical) {
this.isTechnical = isTechnical;
}
public String getTechnicalTypeLabel() {
return technicalTypeLabel;
}
public void setTechnicalTypeLabel(String technicalTypeLabel) {
this.technicalTypeLabel = technicalTypeLabel;
}
}

View File

@@ -0,0 +1,142 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
@Entity
@Table(name = "filament_variant")
public class FilamentVariant {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "filament_variant_id", nullable = false)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "filament_material_type_id", nullable = false)
private FilamentMaterialType filamentMaterialType;
@Column(name = "variant_display_name", nullable = false, length = Integer.MAX_VALUE)
private String variantDisplayName;
@Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE)
private String colorName;
@ColumnDefault("false")
@Column(name = "is_matte", nullable = false)
private Boolean isMatte;
@ColumnDefault("false")
@Column(name = "is_special", nullable = false)
private Boolean isSpecial;
@Column(name = "cost_chf_per_kg", nullable = false, precision = 10, scale = 2)
private BigDecimal costChfPerKg;
@ColumnDefault("0.000")
@Column(name = "stock_spools", nullable = false, precision = 6, scale = 3)
private BigDecimal stockSpools;
@ColumnDefault("1.000")
@Column(name = "spool_net_kg", nullable = false, precision = 6, scale = 3)
private BigDecimal spoolNetKg;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public FilamentMaterialType getFilamentMaterialType() {
return filamentMaterialType;
}
public void setFilamentMaterialType(FilamentMaterialType filamentMaterialType) {
this.filamentMaterialType = filamentMaterialType;
}
public String getVariantDisplayName() {
return variantDisplayName;
}
public void setVariantDisplayName(String variantDisplayName) {
this.variantDisplayName = variantDisplayName;
}
public String getColorName() {
return colorName;
}
public void setColorName(String colorName) {
this.colorName = colorName;
}
public Boolean getIsMatte() {
return isMatte;
}
public void setIsMatte(Boolean isMatte) {
this.isMatte = isMatte;
}
public Boolean getIsSpecial() {
return isSpecial;
}
public void setIsSpecial(Boolean isSpecial) {
this.isSpecial = isSpecial;
}
public BigDecimal getCostChfPerKg() {
return costChfPerKg;
}
public void setCostChfPerKg(BigDecimal costChfPerKg) {
this.costChfPerKg = costChfPerKg;
}
public BigDecimal getStockSpools() {
return stockSpools;
}
public void setStockSpools(BigDecimal stockSpools) {
this.stockSpools = stockSpools;
}
public BigDecimal getSpoolNetKg() {
return spoolNetKg;
}
public void setSpoolNetKg(BigDecimal spoolNetKg) {
this.spoolNetKg = spoolNetKg;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,44 @@
package com.printcalculator.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import org.hibernate.annotations.Immutable;
import java.math.BigDecimal;
@Entity
@Immutable
@Table(name = "filament_variant_stock_kg")
public class FilamentVariantStockKg {
@Id
@Column(name = "filament_variant_id")
private Long filamentVariantId;
@Column(name = "stock_spools", precision = 6, scale = 3)
private BigDecimal stockSpools;
@Column(name = "spool_net_kg", precision = 6, scale = 3)
private BigDecimal spoolNetKg;
@Column(name = "stock_kg")
private BigDecimal stockKg;
public Long getFilamentVariantId() {
return filamentVariantId;
}
public BigDecimal getStockSpools() {
return stockSpools;
}
public BigDecimal getSpoolNetKg() {
return spoolNetKg;
}
public BigDecimal getStockKg() {
return stockKg;
}
}

View File

@@ -0,0 +1,56 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
@Entity
@Table(name = "infill_pattern")
public class InfillPattern {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "infill_pattern_id", nullable = false)
private Long id;
@Column(name = "pattern_code", nullable = false, length = Integer.MAX_VALUE)
private String patternCode;
@Column(name = "display_name", nullable = false, length = Integer.MAX_VALUE)
private String displayName;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getPatternCode() {
return patternCode;
}
public void setPatternCode(String patternCode) {
this.patternCode = patternCode;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -0,0 +1,59 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
@Entity
@Table(name = "layer_height_option")
public class LayerHeightOption {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "layer_height_option_id", nullable = false)
private Long id;
@Column(name = "layer_height_mm", nullable = false, precision = 5, scale = 3)
private BigDecimal layerHeightMm;
@ColumnDefault("1.000")
@Column(name = "time_multiplier", nullable = false, precision = 6, scale = 3)
private BigDecimal timeMultiplier;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public BigDecimal getLayerHeightMm() {
return layerHeightMm;
}
public void setLayerHeightMm(BigDecimal layerHeightMm) {
this.layerHeightMm = layerHeightMm;
}
public BigDecimal getTimeMultiplier() {
return timeMultiplier;
}
public void setTimeMultiplier(BigDecimal timeMultiplier) {
this.timeMultiplier = timeMultiplier;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
}

View File

@@ -0,0 +1,80 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
@Entity
@Table(name = "layer_height_profile")
public class LayerHeightProfile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "layer_height_profile_id", nullable = false)
private Long id;
@Column(name = "profile_name", nullable = false, length = Integer.MAX_VALUE)
private String profileName;
@Column(name = "min_layer_height_mm", nullable = false, precision = 5, scale = 3)
private BigDecimal minLayerHeightMm;
@Column(name = "max_layer_height_mm", nullable = false, precision = 5, scale = 3)
private BigDecimal maxLayerHeightMm;
@Column(name = "default_layer_height_mm", nullable = false, precision = 5, scale = 3)
private BigDecimal defaultLayerHeightMm;
@ColumnDefault("1.000")
@Column(name = "time_multiplier", nullable = false, precision = 6, scale = 3)
private BigDecimal timeMultiplier;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getProfileName() {
return profileName;
}
public void setProfileName(String profileName) {
this.profileName = profileName;
}
public BigDecimal getMinLayerHeightMm() {
return minLayerHeightMm;
}
public void setMinLayerHeightMm(BigDecimal minLayerHeightMm) {
this.minLayerHeightMm = minLayerHeightMm;
}
public BigDecimal getMaxLayerHeightMm() {
return maxLayerHeightMm;
}
public void setMaxLayerHeightMm(BigDecimal maxLayerHeightMm) {
this.maxLayerHeightMm = maxLayerHeightMm;
}
public BigDecimal getDefaultLayerHeightMm() {
return defaultLayerHeightMm;
}
public void setDefaultLayerHeightMm(BigDecimal defaultLayerHeightMm) {
this.defaultLayerHeightMm = defaultLayerHeightMm;
}
public BigDecimal getTimeMultiplier() {
return timeMultiplier;
}
public void setTimeMultiplier(BigDecimal timeMultiplier) {
this.timeMultiplier = timeMultiplier;
}
}

View File

@@ -0,0 +1,84 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
@Entity
@Table(name = "nozzle_option")
public class NozzleOption {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "nozzle_option_id", nullable = false)
private Long id;
@Column(name = "nozzle_diameter_mm", nullable = false, precision = 4, scale = 2)
private BigDecimal nozzleDiameterMm;
@ColumnDefault("0")
@Column(name = "owned_quantity", nullable = false)
private Integer ownedQuantity;
@ColumnDefault("0.00")
@Column(name = "extra_nozzle_change_fee_chf", nullable = false, precision = 10, scale = 2)
private BigDecimal extraNozzleChangeFeeChf;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public BigDecimal getNozzleDiameterMm() {
return nozzleDiameterMm;
}
public void setNozzleDiameterMm(BigDecimal nozzleDiameterMm) {
this.nozzleDiameterMm = nozzleDiameterMm;
}
public Integer getOwnedQuantity() {
return ownedQuantity;
}
public void setOwnedQuantity(Integer ownedQuantity) {
this.ownedQuantity = ownedQuantity;
}
public BigDecimal getExtraNozzleChangeFeeChf() {
return extraNozzleChangeFeeChf;
}
public void setExtraNozzleChangeFeeChf(BigDecimal extraNozzleChangeFeeChf) {
this.extraNozzleChangeFeeChf = extraNozzleChangeFeeChf;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,141 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
@Entity
@Table(name = "pricing_policy")
public class PricingPolicy {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "pricing_policy_id", nullable = false)
private Long id;
@Column(name = "policy_name", nullable = false, length = Integer.MAX_VALUE)
private String policyName;
@Column(name = "valid_from", nullable = false)
private OffsetDateTime validFrom;
@Column(name = "valid_to")
private OffsetDateTime validTo;
@Column(name = "electricity_cost_chf_per_kwh", nullable = false, precision = 10, scale = 6)
private BigDecimal electricityCostChfPerKwh;
@ColumnDefault("20.000")
@Column(name = "markup_percent", nullable = false, precision = 6, scale = 3)
private BigDecimal markupPercent;
@ColumnDefault("0.00")
@Column(name = "fixed_job_fee_chf", nullable = false, precision = 10, scale = 2)
private BigDecimal fixedJobFeeChf;
@ColumnDefault("0.00")
@Column(name = "nozzle_change_base_fee_chf", nullable = false, precision = 10, scale = 2)
private BigDecimal nozzleChangeBaseFeeChf;
@ColumnDefault("0.00")
@Column(name = "cad_cost_chf_per_hour", nullable = false, precision = 10, scale = 2)
private BigDecimal cadCostChfPerHour;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getPolicyName() {
return policyName;
}
public void setPolicyName(String policyName) {
this.policyName = policyName;
}
public OffsetDateTime getValidFrom() {
return validFrom;
}
public void setValidFrom(OffsetDateTime validFrom) {
this.validFrom = validFrom;
}
public OffsetDateTime getValidTo() {
return validTo;
}
public void setValidTo(OffsetDateTime validTo) {
this.validTo = validTo;
}
public BigDecimal getElectricityCostChfPerKwh() {
return electricityCostChfPerKwh;
}
public void setElectricityCostChfPerKwh(BigDecimal electricityCostChfPerKwh) {
this.electricityCostChfPerKwh = electricityCostChfPerKwh;
}
public BigDecimal getMarkupPercent() {
return markupPercent;
}
public void setMarkupPercent(BigDecimal markupPercent) {
this.markupPercent = markupPercent;
}
public BigDecimal getFixedJobFeeChf() {
return fixedJobFeeChf;
}
public void setFixedJobFeeChf(BigDecimal fixedJobFeeChf) {
this.fixedJobFeeChf = fixedJobFeeChf;
}
public BigDecimal getNozzleChangeBaseFeeChf() {
return nozzleChangeBaseFeeChf;
}
public void setNozzleChangeBaseFeeChf(BigDecimal nozzleChangeBaseFeeChf) {
this.nozzleChangeBaseFeeChf = nozzleChangeBaseFeeChf;
}
public BigDecimal getCadCostChfPerHour() {
return cadCostChfPerHour;
}
public void setCadCostChfPerHour(BigDecimal cadCostChfPerHour) {
this.cadCostChfPerHour = cadCostChfPerHour;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,68 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "pricing_policy_machine_hour_tier")
public class PricingPolicyMachineHourTier {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "pricing_policy_machine_hour_tier_id", nullable = false)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "pricing_policy_id", nullable = false)
private PricingPolicy pricingPolicy;
@Column(name = "tier_start_hours", nullable = false, precision = 10, scale = 2)
private BigDecimal tierStartHours;
@Column(name = "tier_end_hours", precision = 10, scale = 2)
private BigDecimal tierEndHours;
@Column(name = "machine_cost_chf_per_hour", nullable = false, precision = 10, scale = 2)
private BigDecimal machineCostChfPerHour;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public PricingPolicy getPricingPolicy() {
return pricingPolicy;
}
public void setPricingPolicy(PricingPolicy pricingPolicy) {
this.pricingPolicy = pricingPolicy;
}
public BigDecimal getTierStartHours() {
return tierStartHours;
}
public void setTierStartHours(BigDecimal tierStartHours) {
this.tierStartHours = tierStartHours;
}
public BigDecimal getTierEndHours() {
return tierEndHours;
}
public void setTierEndHours(BigDecimal tierEndHours) {
this.tierEndHours = tierEndHours;
}
public BigDecimal getMachineCostChfPerHour() {
return machineCostChfPerHour;
}
public void setMachineCostChfPerHour(BigDecimal machineCostChfPerHour) {
this.machineCostChfPerHour = machineCostChfPerHour;
}
}

View File

@@ -0,0 +1,46 @@
package com.printcalculator.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import org.hibernate.annotations.Immutable;
import jakarta.persistence.Id;
@Entity
@Immutable
@Table(name = "printer_fleet_current")
public class PrinterFleetCurrent {
@Id
@Column(name = "fleet_id")
private Long id;
@Column(name = "weighted_average_power_watts")
private Integer weightedAveragePowerWatts;
@Column(name = "fleet_max_build_x_mm")
private Integer fleetMaxBuildXMm;
@Column(name = "fleet_max_build_y_mm")
private Integer fleetMaxBuildYMm;
@Column(name = "fleet_max_build_z_mm")
private Integer fleetMaxBuildZMm;
public Integer getWeightedAveragePowerWatts() {
return weightedAveragePowerWatts;
}
public Integer getFleetMaxBuildXMm() {
return fleetMaxBuildXMm;
}
public Integer getFleetMaxBuildYMm() {
return fleetMaxBuildYMm;
}
public Integer getFleetMaxBuildZMm() {
return fleetMaxBuildZMm;
}
}

View File

@@ -0,0 +1,116 @@
package com.printcalculator.entity;
import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
@Entity
@Table(name = "printer_machine")
public class PrinterMachine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "printer_machine_id", nullable = false)
private Long id;
@Column(name = "printer_display_name", nullable = false, length = Integer.MAX_VALUE)
private String printerDisplayName;
@Column(name = "build_volume_x_mm", nullable = false)
private Integer buildVolumeXMm;
@Column(name = "build_volume_y_mm", nullable = false)
private Integer buildVolumeYMm;
@Column(name = "build_volume_z_mm", nullable = false)
private Integer buildVolumeZMm;
@Column(name = "power_watts", nullable = false)
private Integer powerWatts;
@ColumnDefault("1.000")
@Column(name = "fleet_weight", nullable = false, precision = 6, scale = 3)
private BigDecimal fleetWeight;
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getPrinterDisplayName() {
return printerDisplayName;
}
public void setPrinterDisplayName(String printerDisplayName) {
this.printerDisplayName = printerDisplayName;
}
public Integer getBuildVolumeXMm() {
return buildVolumeXMm;
}
public void setBuildVolumeXMm(Integer buildVolumeXMm) {
this.buildVolumeXMm = buildVolumeXMm;
}
public Integer getBuildVolumeYMm() {
return buildVolumeYMm;
}
public void setBuildVolumeYMm(Integer buildVolumeYMm) {
this.buildVolumeYMm = buildVolumeYMm;
}
public Integer getBuildVolumeZMm() {
return buildVolumeZMm;
}
public void setBuildVolumeZMm(Integer buildVolumeZMm) {
this.buildVolumeZMm = buildVolumeZMm;
}
public Integer getPowerWatts() {
return powerWatts;
}
public void setPowerWatts(Integer powerWatts) {
this.powerWatts = powerWatts;
}
public BigDecimal getFleetWeight() {
return fleetWeight;
}
public void setFleetWeight(BigDecimal fleetWeight) {
this.fleetWeight = fleetWeight;
}
public Boolean getIsActive() {
return isActive;
}
public void setIsActive(Boolean isActive) {
this.isActive = isActive;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -1,11 +0,0 @@
package com.printcalculator.model;
import java.math.BigDecimal;
public record CostBreakdown(
BigDecimal materialCost,
BigDecimal machineCost,
BigDecimal energyCost,
BigDecimal subtotal,
BigDecimal markupAmount
) {}

View File

@@ -1,12 +1,31 @@
package com.printcalculator.model;
import java.math.BigDecimal;
import java.util.List;
public class QuoteResult {
private double totalPrice;
private String currency;
private PrintStats stats;
private double setupCost;
public record QuoteResult(
BigDecimal totalPrice,
String currency,
PrintStats stats,
CostBreakdown breakdown,
List<String> notes
) {}
public QuoteResult(double totalPrice, String currency, PrintStats stats, double setupCost) {
this.totalPrice = totalPrice;
this.currency = currency;
this.stats = stats;
this.setupCost = setupCost;
}
public double getTotalPrice() {
return totalPrice;
}
public String getCurrency() {
return currency;
}
public PrintStats getStats() {
return stats;
}
public double getSetupCost() {
return setupCost;
}
}

View File

@@ -0,0 +1,10 @@
package com.printcalculator.repository;
import com.printcalculator.entity.FilamentMaterialType;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface FilamentMaterialTypeRepository extends JpaRepository<FilamentMaterialType, Long> {
Optional<FilamentMaterialType> findByMaterialCode(String materialCode);
}

View File

@@ -0,0 +1,13 @@
package com.printcalculator.repository;
import com.printcalculator.entity.FilamentVariant;
import org.springframework.data.jpa.repository.JpaRepository;
import com.printcalculator.entity.FilamentMaterialType;
import java.util.Optional;
public interface FilamentVariantRepository extends JpaRepository<FilamentVariant, Long> {
// We try to match by color name if possible, or get first active
Optional<FilamentVariant> findByFilamentMaterialTypeAndColorName(FilamentMaterialType type, String colorName);
Optional<FilamentVariant> findFirstByFilamentMaterialTypeAndIsActiveTrue(FilamentMaterialType type);
}

View File

@@ -0,0 +1,7 @@
package com.printcalculator.repository;
import com.printcalculator.entity.InfillPattern;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InfillPatternRepository extends JpaRepository<InfillPattern, Long> {
}

View File

@@ -0,0 +1,7 @@
package com.printcalculator.repository;
import com.printcalculator.entity.LayerHeightOption;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LayerHeightOptionRepository extends JpaRepository<LayerHeightOption, Long> {
}

View File

@@ -0,0 +1,7 @@
package com.printcalculator.repository;
import com.printcalculator.entity.LayerHeightProfile;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LayerHeightProfileRepository extends JpaRepository<LayerHeightProfile, Long> {
}

View File

@@ -0,0 +1,7 @@
package com.printcalculator.repository;
import com.printcalculator.entity.NozzleOption;
import org.springframework.data.jpa.repository.JpaRepository;
public interface NozzleOptionRepository extends JpaRepository<NozzleOption, Long> {
}

View File

@@ -0,0 +1,11 @@
package com.printcalculator.repository;
import com.printcalculator.entity.PricingPolicyMachineHourTier;
import org.springframework.data.jpa.repository.JpaRepository;
import com.printcalculator.entity.PricingPolicy;
import java.util.List;
public interface PricingPolicyMachineHourTierRepository extends JpaRepository<PricingPolicyMachineHourTier, Long> {
List<PricingPolicyMachineHourTier> findAllByPricingPolicyOrderByTierStartHoursAsc(PricingPolicy pricingPolicy);
}

View File

@@ -0,0 +1,8 @@
package com.printcalculator.repository;
import com.printcalculator.entity.PricingPolicy;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PricingPolicyRepository extends JpaRepository<PricingPolicy, Long> {
PricingPolicy findFirstByIsActiveTrueOrderByValidFromDesc();
}

View File

@@ -0,0 +1,11 @@
package com.printcalculator.repository;
import com.printcalculator.entity.PrinterMachine;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface PrinterMachineRepository extends JpaRepository<PrinterMachine, Long> {
Optional<PrinterMachine> findByPrinterDisplayName(String printerDisplayName);
Optional<PrinterMachine> findFirstByIsActiveTrue();
}

View File

@@ -13,9 +13,21 @@ import java.util.regex.Pattern;
@Service
public class GCodeParser {
private static final Pattern TIME_PATTERN = Pattern.compile("estimated printing time = (.*)");
private static final Pattern FILAMENT_G_PATTERN = Pattern.compile("filament used \\[g\\] = (.*)");
private static final Pattern FILAMENT_MM_PATTERN = Pattern.compile("filament used \\[mm\\] = (.*)");
// OrcaSlicer/BambuStudio format
// ; estimated printing time = 1h 2m 3s
// ; filament used [g] = 12.34
// ; filament used [mm] = 1234.56
private static final Pattern TOTAL_ESTIMATED_TIME_PATTERN = Pattern.compile(
";\\s*.*total\\s+estimated\\s+time\\s*[:=]\\s*([^;]+)",
Pattern.CASE_INSENSITIVE);
private static final Pattern MODEL_PRINTING_TIME_PATTERN = Pattern.compile(
";\\s*.*model\\s+printing\\s+time\\s*[:=]\\s*([^;]+)",
Pattern.CASE_INSENSITIVE);
private static final Pattern TIME_PATTERN = Pattern.compile(
";\\s*(?:estimated\\s+printing\\s+time|estimated\\s+print\\s+time|print\\s+time).*?[:=]\\s*(.*)",
Pattern.CASE_INSENSITIVE);
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*(.*)");
public PrintStats parse(File gcodeFile) throws IOException {
long seconds = 0;
@@ -25,12 +37,33 @@ public class GCodeParser {
try (BufferedReader reader = new BufferedReader(new FileReader(gcodeFile))) {
String line;
// Scan first 500 lines for efficiency
int count = 0;
while ((line = reader.readLine()) != null && count < 500) {
// Scan entire file as metadata is often at the end
while ((line = reader.readLine()) != null) {
line = line.trim();
// OrcaSlicer comments start with ;
if (!line.startsWith(";")) {
count++;
continue;
}
if (line.toLowerCase().contains("estimated printing time")) {
System.out.println("DEBUG: Found potential time line: '" + line + "'");
}
Matcher totalTimeMatcher = TOTAL_ESTIMATED_TIME_PATTERN.matcher(line);
if (totalTimeMatcher.find()) {
timeFormatted = totalTimeMatcher.group(1).trim();
seconds = parseTimeString(timeFormatted);
System.out.println("GCodeParser: Found total estimated time: " + timeFormatted + " (" + seconds + "s)");
continue;
}
Matcher modelTimeMatcher = MODEL_PRINTING_TIME_PATTERN.matcher(line);
if (modelTimeMatcher.find()) {
timeFormatted = modelTimeMatcher.group(1).trim();
seconds = parseTimeString(timeFormatted);
System.out.println("GCodeParser: Found model printing time: " + timeFormatted + " (" + seconds + "s)");
continue;
}
@@ -38,12 +71,14 @@ public class GCodeParser {
if (timeMatcher.find()) {
timeFormatted = timeMatcher.group(1).trim();
seconds = parseTimeString(timeFormatted);
System.out.println("GCodeParser: Found time: " + timeFormatted + " (" + seconds + "s)");
}
Matcher weightMatcher = FILAMENT_G_PATTERN.matcher(line);
if (weightMatcher.find()) {
try {
weightG = Double.parseDouble(weightMatcher.group(1).trim());
System.out.println("GCodeParser: Found weight: " + weightG + "g");
} catch (NumberFormatException ignored) {}
}
@@ -51,9 +86,9 @@ public class GCodeParser {
if (lengthMatcher.find()) {
try {
lengthMm = Double.parseDouble(lengthMatcher.group(1).trim());
System.out.println("GCodeParser: Found length: " + lengthMm + "mm");
} catch (NumberFormatException ignored) {}
}
count++;
}
}
@@ -61,21 +96,60 @@ public class GCodeParser {
}
private long parseTimeString(String timeStr) {
// Formats: "1d 2h 3m 4s" or "1h 20m 10s"
long totalSeconds = 0;
// Formats: "1d 2h 3m 4s", "1h 20m 10s", "01:23:45", "12:34"
String lower = timeStr.toLowerCase();
double totalSeconds = 0;
boolean matched = false;
Matcher d = Pattern.compile("(\\d+)d").matcher(timeStr);
if (d.find()) totalSeconds += Long.parseLong(d.group(1)) * 86400;
Matcher d = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*d").matcher(lower);
if (d.find()) {
totalSeconds += Double.parseDouble(d.group(1)) * 86400;
matched = true;
}
Matcher h = Pattern.compile("(\\d+)h").matcher(timeStr);
if (h.find()) totalSeconds += Long.parseLong(h.group(1)) * 3600;
Matcher h = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*h").matcher(lower);
if (h.find()) {
totalSeconds += Double.parseDouble(h.group(1)) * 3600;
matched = true;
}
Matcher m = Pattern.compile("(\\d+)m").matcher(timeStr);
if (m.find()) totalSeconds += Long.parseLong(m.group(1)) * 60;
Matcher m = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*m").matcher(lower);
if (m.find()) {
totalSeconds += Double.parseDouble(m.group(1)) * 60;
matched = true;
}
Matcher s = Pattern.compile("(\\d+)s").matcher(timeStr);
if (s.find()) totalSeconds += Long.parseLong(s.group(1));
Matcher s = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*s").matcher(lower);
if (s.find()) {
totalSeconds += Double.parseDouble(s.group(1));
matched = true;
}
return totalSeconds;
if (matched) {
return Math.round(totalSeconds);
}
long daySeconds = 0;
Matcher dayPrefix = Pattern.compile("(\\d+)\\s*d").matcher(lower);
if (dayPrefix.find()) {
daySeconds = Long.parseLong(dayPrefix.group(1)) * 86400;
}
Matcher hms = Pattern.compile("(\\d{1,2}):(\\d{2}):(\\d{2})").matcher(lower);
if (hms.find()) {
long hours = Long.parseLong(hms.group(1));
long minutes = Long.parseLong(hms.group(2));
long seconds = Long.parseLong(hms.group(3));
return daySeconds + hours * 3600 + minutes * 60 + seconds;
}
Matcher ms = Pattern.compile("(\\d{1,2}):(\\d{2})").matcher(lower);
if (ms.find()) {
long minutes = Long.parseLong(ms.group(1));
long seconds = Long.parseLong(ms.group(2));
return daySeconds + minutes * 60 + seconds;
}
return 0;
}
}

View File

@@ -14,6 +14,8 @@ import java.util.Iterator;
import java.util.Optional;
import java.util.logging.Logger;
import java.util.stream.Stream;
import java.util.Map;
import java.util.HashMap;
@Service
public class ProfileManager {
@@ -22,9 +24,31 @@ public class ProfileManager {
private final String profilesRoot;
private final ObjectMapper mapper;
private final Map<String, String> profileAliases;
public ProfileManager(@Value("${profiles.root:profiles}") String profilesRoot, ObjectMapper mapper) {
this.profilesRoot = profilesRoot;
this.mapper = mapper;
this.profileAliases = new HashMap<>();
initializeAliases();
}
private void initializeAliases() {
// Machine Aliases
profileAliases.put("bambu_a1", "Bambu Lab A1 0.4 nozzle");
// Material Aliases
profileAliases.put("pla_basic", "Bambu PLA Basic @BBL A1");
profileAliases.put("petg_basic", "Bambu PETG Basic @BBL A1");
profileAliases.put("tpu_95a", "Bambu TPU 95A @BBL A1");
// Quality/Process Aliases
profileAliases.put("draft", "0.24mm Draft @BBL A1");
profileAliases.put("standard", "0.20mm Standard @BBL A1"); // or 0.20mm Standard @BBL A1
profileAliases.put("extra_fine", "0.08mm High Quality @BBL A1");
// Additional aliases from error logs
profileAliases.put("Bambu_Process_0.20_Standard", "0.20mm Standard @BBL A1");
}
public ObjectNode getMergedProfile(String profileName, String type) throws IOException {
@@ -36,9 +60,12 @@ public class ProfileManager {
}
private Path findProfileFile(String name, String type) {
// Check aliases first
String resolvedName = profileAliases.getOrDefault(name, name);
// Simple search: look for name.json in the profiles_root recursively
// Type could be "machine", "process", "filament" to narrow down, but for now global search
String filename = name.endsWith(".json") ? name : name + ".json";
String filename = resolvedName.endsWith(".json") ? resolvedName : resolvedName + ".json";
try (Stream<Path> stream = Files.walk(Paths.get(profilesRoot))) {
Optional<Path> found = stream

View File

@@ -1,59 +1,158 @@
package com.printcalculator.service;
import com.printcalculator.config.AppProperties;
import com.printcalculator.model.CostBreakdown;
import com.printcalculator.entity.FilamentMaterialType;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.PricingPolicy;
import com.printcalculator.entity.PricingPolicyMachineHourTier;
import com.printcalculator.entity.PrinterMachine;
import com.printcalculator.model.PrintStats;
import com.printcalculator.model.QuoteResult;
import com.printcalculator.repository.FilamentMaterialTypeRepository;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.PricingPolicyMachineHourTierRepository;
import com.printcalculator.repository.PricingPolicyRepository;
import com.printcalculator.repository.PrinterMachineRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
@Service
public class QuoteCalculator {
private final AppProperties props;
private final PricingPolicyRepository pricingRepo;
private final PricingPolicyMachineHourTierRepository tierRepo;
private final PrinterMachineRepository machineRepo;
private final FilamentMaterialTypeRepository materialRepo;
private final FilamentVariantRepository variantRepo;
public QuoteCalculator(AppProperties props) {
this.props = props;
public QuoteCalculator(PricingPolicyRepository pricingRepo,
PricingPolicyMachineHourTierRepository tierRepo,
PrinterMachineRepository machineRepo,
FilamentMaterialTypeRepository materialRepo,
FilamentVariantRepository variantRepo) {
this.pricingRepo = pricingRepo;
this.tierRepo = tierRepo;
this.machineRepo = machineRepo;
this.materialRepo = materialRepo;
this.variantRepo = variantRepo;
}
public QuoteResult calculate(PrintStats stats) {
public QuoteResult calculate(PrintStats stats, String machineName, String filamentProfileName) {
// 1. Fetch Active Policy
PricingPolicy policy = pricingRepo.findFirstByIsActiveTrueOrderByValidFromDesc();
if (policy == null) {
throw new RuntimeException("No active pricing policy found");
}
// 2. Fetch Machine Info
// Map "bambu_a1" -> "BambuLab A1" or similar?
// Ideally we should use the display name from DB.
// For now, if machineName is a code, we might need a mapping or just fuzzy search.
// Let's assume machineName is mapped or we search by display name.
// If not found, fallback to first active.
PrinterMachine machine = machineRepo.findByPrinterDisplayName(machineName).orElse(null);
if (machine == null) {
// Try "BambuLab A1" if code was "bambu_a1" logic or just get first active
machine = machineRepo.findFirstByIsActiveTrue()
.orElseThrow(() -> new RuntimeException("No active printer found"));
}
// 3. Fetch Filament Info
// filamentProfileName might be "bambu_pla_basic_black" or "Generic PLA"
// We try to extract material code (PLA, PETG)
String materialCode = detectMaterialCode(filamentProfileName);
FilamentMaterialType materialType = materialRepo.findByMaterialCode(materialCode)
.orElseThrow(() -> new RuntimeException("Unknown material type: " + materialCode));
// Try to find specific variant (e.g. by color if we could parse it)
// For now, get default/first active variant for this material
FilamentVariant variant = variantRepo.findFirstByFilamentMaterialTypeAndIsActiveTrue(materialType)
.orElseThrow(() -> new RuntimeException("No active variant for material: " + materialCode));
// --- CALCULATIONS ---
// Material Cost: (weight / 1000) * costPerKg
BigDecimal weightKg = BigDecimal.valueOf(stats.filamentWeightGrams()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
BigDecimal materialCost = weightKg.multiply(BigDecimal.valueOf(props.getFilamentCostPerKg()));
BigDecimal materialCost = weightKg.multiply(variant.getCostChfPerKg());
// Machine Cost: (seconds / 3600) * costPerHour
BigDecimal hours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
BigDecimal machineCost = hours.multiply(BigDecimal.valueOf(props.getMachineCostPerHour()));
// Machine Cost: Tiered
BigDecimal totalHours = BigDecimal.valueOf(stats.printTimeSeconds()).divide(BigDecimal.valueOf(3600), 4, RoundingMode.HALF_UP);
BigDecimal machineCost = calculateMachineCost(policy, totalHours);
// Energy Cost: (watts / 1000) * hours * costPerKwh
BigDecimal kw = BigDecimal.valueOf(props.getPrinterPowerWatts()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
BigDecimal kwh = kw.multiply(hours);
BigDecimal energyCost = kwh.multiply(BigDecimal.valueOf(props.getEnergyCostPerKwh()));
BigDecimal kw = BigDecimal.valueOf(machine.getPowerWatts()).divide(BigDecimal.valueOf(1000), 4, RoundingMode.HALF_UP);
BigDecimal kwh = kw.multiply(totalHours);
BigDecimal energyCost = kwh.multiply(policy.getElectricityCostChfPerKwh());
// Subtotal
BigDecimal subtotal = materialCost.add(machineCost).add(energyCost);
// Subtotal (Costs + Fixed Fees)
BigDecimal fixedFee = policy.getFixedJobFeeChf();
BigDecimal subtotal = materialCost.add(machineCost).add(energyCost).add(fixedFee);
// Markup
BigDecimal markupFactor = BigDecimal.valueOf(1.0 + (props.getMarkupPercent() / 100.0));
// Markup is percentage (e.g. 20.0)
BigDecimal markupFactor = BigDecimal.ONE.add(policy.getMarkupPercent().divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP));
BigDecimal totalPrice = subtotal.multiply(markupFactor).setScale(2, RoundingMode.HALF_UP);
BigDecimal markupAmount = totalPrice.subtract(subtotal);
return new QuoteResult(totalPrice.doubleValue(), "CHF", stats, fixedFee.doubleValue());
}
CostBreakdown breakdown = new CostBreakdown(
materialCost.setScale(2, RoundingMode.HALF_UP),
machineCost.setScale(2, RoundingMode.HALF_UP),
energyCost.setScale(2, RoundingMode.HALF_UP),
subtotal.setScale(2, RoundingMode.HALF_UP),
markupAmount.setScale(2, RoundingMode.HALF_UP)
);
private BigDecimal calculateMachineCost(PricingPolicy policy, BigDecimal hours) {
List<PricingPolicyMachineHourTier> tiers = tierRepo.findAllByPricingPolicyOrderByTierStartHoursAsc(policy);
if (tiers.isEmpty()) {
return BigDecimal.ZERO; // Should not happen if DB is correct
}
List<String> notes = new ArrayList<>();
notes.add("Generated via Dynamic Slicer (Java Backend)");
BigDecimal remainingHours = hours;
BigDecimal totalCost = BigDecimal.ZERO;
BigDecimal processedHours = BigDecimal.ZERO;
return new QuoteResult(totalPrice, "EUR", stats, breakdown, notes);
for (PricingPolicyMachineHourTier tier : tiers) {
if (remainingHours.compareTo(BigDecimal.ZERO) <= 0) break;
BigDecimal tierStart = tier.getTierStartHours();
BigDecimal tierEnd = tier.getTierEndHours(); // can be null for infinity
// Determine duration in this tier
// Valid duration in this tier = (min(tierEnd, totalHours) - tierStart)
// But logic is simpler: we consume hours sequentially?
// "0-10h @ 2CHF, 10-20h @ 1.5CHF" implies:
// 5h job -> 5 * 2
// 15h job -> 10 * 2 + 5 * 1.5
BigDecimal tierDuration;
// Max hours applicable in this tier relative to 0
BigDecimal tierLimit = (tierEnd != null) ? tierEnd : BigDecimal.valueOf(Long.MAX_VALUE);
// The amount of hours falling into this bucket
// Upper bound for this calculation is min(totalHours, tierLimit)
// Lower bound is tierStart
// So hours in this bucket = max(0, min(totalHours, tierLimit) - tierStart)
BigDecimal upper = hours.min(tierLimit);
BigDecimal lower = tierStart;
if (upper.compareTo(lower) > 0) {
BigDecimal hoursInTier = upper.subtract(lower);
totalCost = totalCost.add(hoursInTier.multiply(tier.getMachineCostChfPerHour()));
}
}
return totalCost;
}
private String detectMaterialCode(String profileName) {
String lower = profileName.toLowerCase();
if (lower.contains("petg")) return "PETG";
if (lower.contains("tpu")) return "TPU";
if (lower.contains("abs")) return "ABS";
if (lower.contains("nylon")) return "Nylon";
if (lower.contains("asa")) return "ASA";
// Default to PLA
return "PLA";
}
}

View File

@@ -12,6 +12,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
@@ -36,12 +37,21 @@ public class SlicerService {
this.mapper = mapper;
}
public PrintStats slice(File inputStl, String machineName, String filamentName, String processName) throws IOException {
public PrintStats slice(File inputStl, String machineName, String filamentName, String processName,
Map<String, String> machineOverrides, Map<String, String> processOverrides) throws IOException {
// 1. Prepare Profiles
ObjectNode machineProfile = profileManager.getMergedProfile(machineName, "machine");
ObjectNode filamentProfile = profileManager.getMergedProfile(filamentName, "filament");
ObjectNode processProfile = profileManager.getMergedProfile(processName, "process");
// 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_");
try {
@@ -55,12 +65,16 @@ public class SlicerService {
// 3. Build Command
// --load-settings "machine.json;process.json" --load-filaments "filament.json"
String settingsArg = mFile.getAbsolutePath() + ";" + pFile.getAbsolutePath();
List<String> command = new ArrayList<>();
command.add(slicerPath);
// Load machine settings
command.add("--load-settings");
command.add(settingsArg);
command.add(mFile.getAbsolutePath());
// Load process settings
command.add("--load-settings");
command.add(pFile.getAbsolutePath());
command.add("--load-filaments");
command.add(fFile.getAbsolutePath());
command.add("--ensure-on-bed");

View File

@@ -1,18 +1,19 @@
spring.application.name=backend
server.port=8000
# Database Configuration
spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/printcalc}
spring.datasource.username=${DB_USERNAME:printcalc}
spring.datasource.password=${DB_PASSWORD:printcalc_secret}
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
# Slicer Configuration
# Map SLICER_PATH env var if present (default to /opt/orcaslicer/AppRun or Mac path)
slicer.path=${SLICER_PATH:/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer}
profiles.root=${PROFILES_DIR:profiles}
# Pricing Configuration
# Mapped to legacy environment variables for Docker compatibility
pricing.filament-cost-per-kg=${FILAMENT_COST_PER_KG:25.0}
pricing.machine-cost-per-hour=${MACHINE_COST_PER_HOUR:2.0}
pricing.energy-cost-per-kwh=${ENERGY_COST_PER_KWH:0.30}
pricing.printer-power-watts=${PRINTER_POWER_WATTS:150.0}
pricing.markup-percent=${MARKUP_PERCENT:20.0}
# File Upload Limits
spring.servlet.multipart.max-file-size=200MB

View File

@@ -52,6 +52,62 @@ class GCodeParserTest {
assertEquals(750, stats.printTimeSeconds()); // 12*60 + 30
assertEquals(5.0, stats.filamentWeightGrams(), 0.001);
tempFile.delete();
}
@Test
void parse_withExtraTextInTimeLine_returnsCorrectStats() throws IOException {
// Arrange
File tempFile = File.createTempFile("test_extra", ".gcode");
try (FileWriter writer = new FileWriter(tempFile)) {
writer.write("; generated by OrcaSlicer\n");
// Simulate the variation that was causing issues
writer.write("; estimated printing time (normal mode) = 1h 2m 3s\n");
writer.write("; filament used [g] = 10.5\n");
writer.write("; filament used [mm] = 3000.0\n");
}
GCodeParser parser = new GCodeParser();
PrintStats stats = parser.parse(tempFile);
assertEquals(3723L, stats.printTimeSeconds());
assertEquals("1h 2m 3s", stats.printTimeFormatted());
tempFile.delete();
}
@Test
void parse_colonFormattedTime_returnsCorrectStats() throws IOException {
File tempFile = File.createTempFile("test_colon", ".gcode");
try (FileWriter writer = new FileWriter(tempFile)) {
writer.write("; generated by OrcaSlicer\n");
writer.write("; print time: 01:02:03\n");
writer.write("; filament used [g] = 7.5\n");
}
GCodeParser parser = new GCodeParser();
PrintStats stats = parser.parse(tempFile);
assertEquals(3723L, stats.printTimeSeconds());
assertEquals("01:02:03", stats.printTimeFormatted());
tempFile.delete();
}
@Test
void parse_totalEstimatedTimeInline_returnsCorrectStats() throws IOException {
File tempFile = File.createTempFile("test_total", ".gcode");
try (FileWriter writer = new FileWriter(tempFile)) {
writer.write("; generated by OrcaSlicer\n");
writer.write("; model printing time: 5m 17s; total estimated time: 5m 21s\n");
writer.write("; filament used [g] = 2.0\n");
}
GCodeParser parser = new GCodeParser();
PrintStats stats = parser.parse(tempFile);
assertEquals(321L, stats.printTimeSeconds());
assertEquals("5m 21s", stats.printTimeFormatted());
tempFile.delete();
}
}

366
db.sql Normal file
View File

@@ -0,0 +1,366 @@
create table printer_machine
(
printer_machine_id bigserial primary key,
printer_display_name text not null unique,
build_volume_x_mm integer not null check (build_volume_x_mm > 0),
build_volume_y_mm integer not null check (build_volume_y_mm > 0),
build_volume_z_mm integer not null check (build_volume_z_mm > 0),
power_watts integer not null check (power_watts > 0),
fleet_weight numeric(6, 3) not null default 1.000,
is_active boolean not null default true,
created_at timestamptz not null default now()
);
create view printer_fleet_current as
select 1 as fleet_id,
case
when sum(fleet_weight) = 0 then null
else round(sum(power_watts * fleet_weight) / sum(fleet_weight))::integer
end as weighted_average_power_watts,
max(build_volume_x_mm) as fleet_max_build_x_mm,
max(build_volume_y_mm) as fleet_max_build_y_mm,
max(build_volume_z_mm) as fleet_max_build_z_mm
from printer_machine
where is_active = true;
create table filament_material_type
(
filament_material_type_id bigserial primary key,
material_code text not null unique, -- PLA, PETG, TPU, ASA...
is_flexible boolean not null default false, -- sì/no
is_technical boolean not null default false, -- sì/no
technical_type_label text -- es: "alta temperatura", "rinforzato", ecc.
);
create table filament_variant
(
filament_variant_id bigserial primary key,
filament_material_type_id bigint not null references filament_material_type (filament_material_type_id),
variant_display_name text not null, -- es: "PLA Nero Opaco BrandX"
color_name text not null, -- Nero, Bianco, ecc.
is_matte boolean not null default false,
is_special boolean not null default false,
cost_chf_per_kg numeric(10, 2) not null,
-- Stock espresso in rotoli anche frazionati
stock_spools numeric(6, 3) not null default 0.000,
spool_net_kg numeric(6, 3) not null default 1.000,
is_active boolean not null default true,
created_at timestamptz not null default now(),
unique (filament_material_type_id, variant_display_name)
);
-- (opzionale) kg disponibili calcolati
create view filament_variant_stock_kg as
select filament_variant_id,
stock_spools,
spool_net_kg,
(stock_spools * spool_net_kg) as stock_kg
from filament_variant;
create table pricing_policy
(
pricing_policy_id bigserial primary key,
policy_name text not null, -- es: "2026 Q1", "Default", ecc.
-- validità temporale (consiglio: valid_to esclusiva)
valid_from timestamptz not null,
valid_to timestamptz,
electricity_cost_chf_per_kwh numeric(10, 6) not null,
markup_percent numeric(6, 3) not null default 20.000,
fixed_job_fee_chf numeric(10, 2) not null default 0.00, -- "costo fisso"
nozzle_change_base_fee_chf numeric(10, 2) not null default 0.00, -- base cambio ugello, se vuoi
cad_cost_chf_per_hour numeric(10, 2) not null default 0.00,
is_active boolean not null default true,
created_at timestamptz not null default now()
);
create table pricing_policy_machine_hour_tier
(
pricing_policy_machine_hour_tier_id bigserial primary key,
pricing_policy_id bigint not null references pricing_policy (pricing_policy_id),
tier_start_hours numeric(10, 2) not null,
tier_end_hours numeric(10, 2), -- null = infinito
machine_cost_chf_per_hour numeric(10, 2) not null,
constraint chk_tier_start_non_negative check (tier_start_hours >= 0),
constraint chk_tier_end_gt_start check (tier_end_hours is null or tier_end_hours > tier_start_hours)
);
create index idx_pricing_policy_validity
on pricing_policy (valid_from, valid_to);
create index idx_pricing_tier_lookup
on pricing_policy_machine_hour_tier (pricing_policy_id, tier_start_hours);
create table nozzle_option
(
nozzle_option_id bigserial primary key,
nozzle_diameter_mm numeric(4, 2) not null unique, -- 0.4, 0.6, 0.8...
owned_quantity integer not null default 0 check (owned_quantity >= 0),
-- extra costo specifico oltre ad eventuale base fee della pricing_policy
extra_nozzle_change_fee_chf numeric(10, 2) not null default 0.00,
is_active boolean not null default true,
created_at timestamptz not null default now()
);
create table layer_height_option
(
layer_height_option_id bigserial primary key,
layer_height_mm numeric(5, 3) not null unique, -- 0.12, 0.20, 0.28...
-- opzionale: moltiplicatore costo/tempo (es: 0.12 costa di più)
time_multiplier numeric(6, 3) not null default 1.000,
is_active boolean not null default true
);
create table layer_height_profile
(
layer_height_profile_id bigserial primary key,
profile_name text not null unique, -- "Standard", "Fine", ecc.
min_layer_height_mm numeric(5, 3) not null,
max_layer_height_mm numeric(5, 3) not null,
default_layer_height_mm numeric(5, 3) not null,
time_multiplier numeric(6, 3) not null default 1.000,
constraint chk_layer_range check (max_layer_height_mm >= min_layer_height_mm)
);
begin;
set timezone = 'Europe/Zurich';
is_active = excluded.is_active;
-- =========================================================
-- 1) Pricing policy (valori ESATTI da Excel)
-- Valid from: 2026-01-01, valid_to: NULL
-- =========================================================
insert into pricing_policy (
policy_name,
valid_from,
valid_to,
electricity_cost_chf_per_kwh,
markup_percent,
fixed_job_fee_chf,
nozzle_change_base_fee_chf,
cad_cost_chf_per_hour,
is_active
) values (
'Excel Tariffe 2026-01-01',
'2026-01-01 00:00:00+01'::timestamptz,
null,
0.156, -- Costo elettricità CHF/kWh (Excel)
0.000, -- Markup non specificato -> 0 (puoi cambiarlo dopo)
1.00, -- Costo fisso macchina CHF (Excel)
0.00, -- Base cambio ugello: non specificato -> 0
25.00, -- Tariffa CAD CHF/h (Excel)
true
)
on conflict do nothing;
-- scaglioni tariffa stampa (Excel)
insert into pricing_policy_machine_hour_tier (
pricing_policy_id,
tier_start_hours,
tier_end_hours,
machine_cost_chf_per_hour
)
select
p.pricing_policy_id,
tiers.tier_start_hours,
tiers.tier_end_hours,
tiers.machine_cost_chf_per_hour
from pricing_policy p
cross join (
values
(0.00::numeric, 10.00::numeric, 2.00::numeric), -- 010 h
(10.00::numeric, 20.00::numeric, 1.40::numeric), -- 1020 h
(20.00::numeric, null::numeric, 0.50::numeric) -- >20 h
) as tiers(tier_start_hours, tier_end_hours, machine_cost_chf_per_hour)
where p.policy_name = 'Excel Tariffe 2026-01-01'
on conflict do nothing;
-- =========================================================
-- 2) Stampante: BambuLab A1
-- =========================================================
insert into printer_machine (
printer_display_name,
build_volume_x_mm,
build_volume_y_mm,
build_volume_z_mm,
power_watts,
fleet_weight,
is_active
) values (
'BambuLab A1',
256,
256,
256,
150, -- hai detto "150, 140": qui ho messo 150
1.000,
true
)
on conflict (printer_display_name) do update
set
build_volume_x_mm = excluded.build_volume_x_mm,
build_volume_y_mm = excluded.build_volume_y_mm,
build_volume_z_mm = excluded.build_volume_z_mm,
power_watts = excluded.power_watts,
fleet_weight = excluded.fleet_weight,
is_active = excluded.is_active;
-- =========================================================
-- 3) Material types (da Excel) - per ora niente technical
-- =========================================================
insert into filament_material_type (
material_code,
is_flexible,
is_technical,
technical_type_label
) values
('PLA', false, false, null),
('PETG', false, false, null),
('TPU', true, false, null),
('ABS', false, false, null),
('Nylon', false, false, null),
('Carbon PLA', false, false, null)
on conflict (material_code) do update
set
is_flexible = excluded.is_flexible,
is_technical = excluded.is_technical,
technical_type_label = excluded.technical_type_label;
-- =========================================================
-- 4) Filament variants (PLA colori) - costi da Excel
-- Excel: PLA = 18 CHF/kg, TPU = 42 CHF/kg (non inserito perché quantità non chiara)
-- Stock in "rotoli" (3 = 3 kg se spool_net_kg=1)
-- =========================================================
-- helper: ID PLA
with pla as (
select filament_material_type_id
from filament_material_type
where material_code = 'PLA'
)
insert into filament_variant (
filament_material_type_id,
variant_display_name,
color_name,
is_matte,
is_special,
cost_chf_per_kg,
stock_spools,
spool_net_kg,
is_active
)
select
pla.filament_material_type_id,
v.variant_display_name,
v.color_name,
v.is_matte,
v.is_special,
18.00, -- PLA da Excel
v.stock_spools,
1.000,
true
from pla
cross join (
values
('PLA Bianco', 'Bianco', false, false, 3.000::numeric),
('PLA Nero', 'Nero', false, false, 3.000::numeric),
('PLA Blu', 'Blu', false, false, 1.000::numeric),
('PLA Arancione', 'Arancione', false, false, 1.000::numeric),
('PLA Grigio', 'Grigio', false, false, 1.000::numeric),
('PLA Grigio Scuro', 'Grigio scuro', false, false, 1.000::numeric),
('PLA Grigio Chiaro', 'Grigio chiaro', false, false, 1.000::numeric),
('PLA Viola', 'Viola', false, false, 1.000::numeric)
) as v(variant_display_name, color_name, is_matte, is_special, stock_spools)
on conflict (filament_material_type_id, variant_display_name) do update
set
color_name = excluded.color_name,
is_matte = excluded.is_matte,
is_special = excluded.is_special,
cost_chf_per_kg = excluded.cost_chf_per_kg,
stock_spools = excluded.stock_spools,
spool_net_kg = excluded.spool_net_kg,
is_active = excluded.is_active;
-- =========================================================
-- 5) Ugelli
-- 0.4 standard (0 extra), 0.6 con attivazione 50 CHF
-- =========================================================
insert into nozzle_option (
nozzle_diameter_mm,
owned_quantity,
extra_nozzle_change_fee_chf,
is_active
) values
(0.40, 1, 0.00, true),
(0.60, 1, 50.00, true)
on conflict (nozzle_diameter_mm) do update
set
owned_quantity = excluded.owned_quantity,
extra_nozzle_change_fee_chf = excluded.extra_nozzle_change_fee_chf,
is_active = excluded.is_active;
-- =========================================================
-- 6) Layer heights (opzioni)
-- =========================================================
insert into layer_height_option (
layer_height_mm,
time_multiplier,
is_active
) values
(0.080, 1.000, true),
(0.120, 1.000, true),
(0.160, 1.000, true),
(0.200, 1.000, true),
(0.240, 1.000, true),
(0.280, 1.000, true)
on conflict (layer_height_mm) do update
set
time_multiplier = excluded.time_multiplier,
is_active = excluded.is_active;
commit;
-- Sostituisci __MULTIPLIER__ con il tuo valore (es. 1.10)
update layer_height_option
set time_multiplier = 0.1
where layer_height_mm = 0.080;

View File

@@ -1,5 +1,5 @@
REGISTRY_URL=git.joekung.ch
REPO_OWNER=JoeKung
REPO_OWNER=joekung
ENV=dev
TAG=dev
@@ -7,9 +7,4 @@ TAG=dev
BACKEND_PORT=18002
FRONTEND_PORT=18082
# Application Config
FILAMENT_COST_PER_KG=22.0
MACHINE_COST_PER_HOUR=2.50
ENERGY_COST_PER_KWH=0.30
PRINTER_POWER_WATTS=150
MARKUP_PERCENT=20

View File

@@ -1,5 +1,5 @@
REGISTRY_URL=git.joekung.ch
REPO_OWNER=JoeKung
REPO_OWNER=joekung
ENV=int
TAG=int
@@ -7,9 +7,4 @@ TAG=int
BACKEND_PORT=18001
FRONTEND_PORT=18081
# Application Config
FILAMENT_COST_PER_KG=22.0
MACHINE_COST_PER_HOUR=2.50
ENERGY_COST_PER_KWH=0.30
PRINTER_POWER_WATTS=150
MARKUP_PERCENT=20

View File

@@ -1,5 +1,5 @@
REGISTRY_URL=git.joekung.ch
REPO_OWNER=JoeKung
REPO_OWNER=joekung
ENV=prod
TAG=prod
@@ -7,9 +7,4 @@ TAG=prod
BACKEND_PORT=8000
FRONTEND_PORT=80
# Application Config
FILAMENT_COST_PER_KG=22.0
MACHINE_COST_PER_HOUR=2.50
ENERGY_COST_PER_KWH=0.30
PRINTER_POWER_WATTS=150
MARKUP_PERCENT=20

View File

@@ -7,26 +7,27 @@ services:
container_name: print-calculator-backend-${ENV}
ports:
- "${BACKEND_PORT}:8000"
env_file:
- .env
environment:
- FILAMENT_COST_PER_KG=${FILAMENT_COST_PER_KG}
- MACHINE_COST_PER_HOUR=${MACHINE_COST_PER_HOUR}
- ENERGY_COST_PER_KWH=${ENERGY_COST_PER_KWH}
- PRINTER_POWER_WATTS=${PRINTER_POWER_WATTS}
- MARKUP_PERCENT=${MARKUP_PERCENT}
- DB_URL=${DB_URL}
- DB_USERNAME=${DB_USERNAME}
- DB_PASSWORD=${DB_PASSWORD}
- TEMP_DIR=/app/temp
- PROFILES_DIR=/app/profiles
restart: unless-stopped
restart: always
volumes:
- backend_profiles_${ENV}:/app/profiles
frontend:
image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-frontend:${TAG}
container_name: print-calculator-frontend-${ENV}
ports:
- "${FRONTEND_PORT}:8008"
- "${FRONTEND_PORT}:80"
depends_on:
- backend
restart: unless-stopped
restart: always
volumes:
backend_profiles_prod:

View File

@@ -9,6 +9,10 @@ services:
ports:
- "8000:8000"
environment:
- DB_URL=jdbc:postgresql://db:5432/printcalc
- DB_USERNAME=printcalc
- DB_PASSWORD=printcalc_secret
- SPRING_PROFILES_ACTIVE=local
- FILAMENT_COST_PER_KG=22.0
- MACHINE_COST_PER_HOUR=2.50
- ENERGY_COST_PER_KWH=0.30
@@ -16,13 +20,34 @@ services:
- MARKUP_PERCENT=20
- TEMP_DIR=/app/temp
- PROFILES_DIR=/app/profiles
depends_on:
- db
restart: unless-stopped
frontend:
build: ./frontend
build:
context: ./frontend
dockerfile: Dockerfile.dev
container_name: print-calculator-frontend
ports:
- "80:80"
depends_on:
- backend
- db
restart: unless-stopped
db:
image: postgres:15-alpine
container_name: print-calculator-db
environment:
- POSTGRES_USER=printcalc
- POSTGRES_PASSWORD=printcalc_secret
- POSTGRES_DB=printcalc
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
volumes:
postgres_data:

15
frontend/Dockerfile.dev Normal file
View File

@@ -0,0 +1,15 @@
# Stage 1: Build
FROM node:20 as build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# Use development configuration to pick up environment.ts (localhost)
RUN npm run build -- --configuration=development
# Stage 2: Serve
FROM nginx:alpine
COPY --from=build /app/dist/frontend/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -70,6 +70,17 @@
"optimization": false,
"extractLicenses": false,
"sourceMap": true
},
"local": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.local.ts"
}
],
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
@@ -83,6 +94,9 @@
},
"development": {
"buildTarget": "frontend:build:development"
},
"local": {
"buildTarget": "frontend:build:local"
}
},
"defaultConfiguration": "development"
@@ -114,8 +128,5 @@
}
}
}
},
"cli": {
"analytics": false
}
}

View File

@@ -406,7 +406,6 @@
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.19.tgz",
"integrity": "sha512-PCpJagurPBqciqcq4Z8+3OtKLb7rSl4w/qBJoIMua8CgnrjvA1i+SWawhdtfI1zlY8FSwhzLwXV0CmWWfFzQPg==",
"license": "MIT",
"peer": true,
"dependencies": {
"parse5": "^7.1.2",
"tslib": "^2.3.0"
@@ -456,7 +455,6 @@
"resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.18.tgz",
"integrity": "sha512-CrV02Omzw/QtfjlEVXVPJVXipdx83NuA+qSASZYrxrhKFusUZyK3P/Zznqg+wiAeNDbedQwMUVqoAARHf0xQrw==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -473,7 +471,6 @@
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.18.tgz",
"integrity": "sha512-3MscvODxRVxc3Cs0ZlHI5Pk5rEvE80otfvxZTMksOZuPlv1B+S8MjWfc3X3jk9SbyUEzODBEH55iCaBHD48V3g==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -487,7 +484,6 @@
"integrity": "sha512-N4TMtLfImJIoMaRL6mx7885UBeQidywptHH6ACZj71Ar6++DBc1mMlcwuvbeJCd3r3y8MQ5nLv5PZSN/tHr13w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "7.26.9",
"@jridgewell/sourcemap-codec": "^1.4.14",
@@ -564,7 +560,6 @@
"resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.18.tgz",
"integrity": "sha512-+QRrf0Igt8ccUWXHA+7doK5W6ODyhHdqVyblSlcQ8OciwkzIIGGEYNZom5OZyWMh+oI54lcSeyV2O3xaDepSrQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -581,7 +576,6 @@
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.18.tgz",
"integrity": "sha512-pe40934jWhoS7DyGl7jyZdoj1gvBgur2t1zrJD+csEkTitYnW14+La2Pv6SW1pNX5nIzFsgsS9Nex1KcH5S6Tw==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -617,7 +611,6 @@
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.18.tgz",
"integrity": "sha512-eahtsHPyXTYLARs9YOlXhnXGgzw0wcyOcDkBvNWK/3lA0NHIgIHmQgXAmBo+cJ+g9skiEQTD2OmSrrwbFKWJkw==",
"license": "MIT",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@@ -702,7 +695,6 @@
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2",
@@ -3056,7 +3048,6 @@
"integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@inquirer/checkbox": "^4.1.2",
"@inquirer/confirm": "^5.1.6",
@@ -5701,7 +5692,6 @@
"integrity": "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -6120,7 +6110,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -6584,7 +6573,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001716",
"electron-to-chromium": "^1.5.149",
@@ -9580,8 +9568,7 @@
"resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.6.0.tgz",
"integrity": "sha512-niVlkeYVRwKFpmfWg6suo6H9CrNnydfBLEqefM5UjibYS+UoTjZdmvPJSiuyrRLGnFj1eYRhFd/ch+5hSlsFVA==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/jest-worker": {
"version": "27.5.1",
@@ -9620,7 +9607,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -9728,7 +9714,6 @@
"integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@colors/colors": "1.5.0",
"body-parser": "^1.19.0",
@@ -10077,7 +10062,6 @@
"integrity": "sha512-tkuLHQlvWUTeQ3doAqnHbNn8T6WX1KA8yvbKG9x4VtKtIjHsVKQZCH11zRgAfbDAXC2UNIg/K9BYAAcEzUIrNg==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"copy-anything": "^2.0.1",
"parse-node-version": "^1.0.1",
@@ -11893,7 +11877,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.8",
"picocolors": "^1.1.1",
@@ -12543,7 +12526,6 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@@ -13662,7 +13644,6 @@
"integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
@@ -13822,8 +13803,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"peer": true
"license": "0BSD"
},
"node_modules/tuf-js": {
"version": "3.0.1",
@@ -13880,7 +13860,6 @@
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -14567,7 +14546,6 @@
"integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.6",
@@ -14645,7 +14623,6 @@
"integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/bonjour": "^3.5.13",
"@types/connect-history-api-fallback": "^1.5.4",
@@ -15212,8 +15189,7 @@
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.0.tgz",
"integrity": "sha512-9oxn0IIjbCZkJ67L+LkhYWRyAy7axphb3VgE2MBDlOqnmHMPWGYMxJxBYFueFq/JGY2GMwS0rU+UCLunEmy5UA==",
"license": "MIT",
"peer": true
"license": "MIT"
}
}
}

View File

View File

@@ -5,6 +5,7 @@ import { RouterOutlet } from '@angular/router';
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
template: `<router-outlet></router-outlet>`
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
export class AppComponent {}

View File

@@ -10,7 +10,7 @@ export const routes: Routes = [
loadComponent: () => import('./features/home/home.component').then(m => m.HomeComponent)
},
{
path: 'cal',
path: 'calculator',
loadChildren: () => import('./features/calculator/calculator.routes').then(m => m.CALCULATOR_ROUTES)
},
{
@@ -24,6 +24,10 @@ export const routes: Routes = [
{
path: 'contact',
loadChildren: () => import('./features/contact/contact.routes').then(m => m.CONTACT_ROUTES)
},
{
path: '',
loadChildren: () => import('./features/legal/legal.routes').then(m => m.LEGAL_ROUTES)
}
]
}

View File

@@ -0,0 +1,41 @@
export interface ColorOption {
label: string;
value: string;
hex: string;
outOfStock?: boolean;
}
export interface ColorCategory {
name: string; // 'Glossy' | 'Matte'
colors: ColorOption[];
}
export const PRODUCT_COLORS: ColorCategory[] = [
{
name: 'Lucidi', // Glossy
colors: [
{ label: 'Black', value: 'Black', hex: '#1a1a1a' }, // Not pure black for visibility
{ label: 'White', value: 'White', hex: '#f5f5f5' },
{ label: 'Red', value: 'Red', hex: '#d32f2f', outOfStock: true },
{ label: 'Blue', value: 'Blue', hex: '#1976d2' },
{ label: 'Green', value: 'Green', hex: '#388e3c' },
{ label: 'Yellow', value: 'Yellow', hex: '#fbc02d' }
]
},
{
name: 'Opachi', // Matte
colors: [
{ label: 'Matte Black', value: 'Matte Black', hex: '#2c2c2c' }, // Lighter charcoal for matte
{ label: 'Matte White', value: 'Matte White', hex: '#e0e0e0' },
{ label: 'Matte Gray', value: 'Matte Gray', hex: '#757575' }
]
}
];
export function getColorHex(value: string): string {
for (const cat of PRODUCT_COLORS) {
const found = cat.colors.find(c => c.value === value);
if (found) return found.hex;
}
return '#facf0a'; // Default Brand Color if not found
}

View File

@@ -8,7 +8,7 @@
<div class="col links">
<a routerLink="/privacy">{{ 'FOOTER.PRIVACY' | translate }}</a>
<a routerLink="/terms">{{ 'FOOTER.TERMS' | translate }}</a>
<a routerLink="/about">{{ 'FOOTER.CONTACT' | translate }}</a>
<a routerLink="/contact">{{ 'FOOTER.CONTACT' | translate }}</a>
</div>
<div class="col social">

View File

@@ -0,0 +1,7 @@
<div class="layout-wrapper">
<app-navbar></app-navbar>
<main class="main-content">
<router-outlet></router-outlet>
</main>
<app-footer></app-footer>
</div>

View File

@@ -0,0 +1,9 @@
.layout-wrapper {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
flex: 1;
padding-bottom: var(--space-12);
}

View File

@@ -7,25 +7,7 @@ import { FooterComponent } from './footer.component';
selector: 'app-layout',
standalone: true,
imports: [RouterOutlet, NavbarComponent, FooterComponent],
template: `
<div class="layout-wrapper">
<app-navbar></app-navbar>
<main class="main-content">
<router-outlet></router-outlet>
</main>
<app-footer></app-footer>
</div>
`,
styles: [`
.layout-wrapper {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
flex: 1;
padding-bottom: var(--space-12);
}
`]
templateUrl: './layout.component.html',
styleUrl: './layout.component.scss'
})
export class LayoutComponent {}

View File

@@ -10,7 +10,7 @@
<nav class="nav-links" [class.open]="isMenuOpen">
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}" (click)="closeMenu()">{{ 'NAV.HOME' | translate }}</a>
<a routerLink="/cal" routerLinkActive="active" [routerLinkActiveOptions]="{exact: false}" (click)="closeMenu()">{{ 'NAV.CALCULATOR' | translate }}</a>
<a routerLink="/calculator/basic" routerLinkActive="active" [routerLinkActiveOptions]="{exact: false}" (click)="closeMenu()">{{ 'NAV.CALCULATOR' | translate }}</a>
<a routerLink="/shop" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.SHOP' | translate }}</a>
<a routerLink="/about" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.ABOUT' | translate }}</a>
<a routerLink="/contact" routerLinkActive="active" (click)="closeMenu()">{{ 'NAV.CONTACT' | translate }}</a>

View File

@@ -0,0 +1,44 @@
<section class="about-section">
<div class="container split-layout">
<!-- Left Column: Content -->
<div class="text-content">
<p class="eyebrow">{{ 'ABOUT.EYEBROW' | translate }}</p>
<h1>{{ 'ABOUT.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'ABOUT.SUBTITLE' | translate }}</p>
<div class="divider"></div>
<p class="description">{{ 'ABOUT.HOW_TEXT' | translate }}</p>
<div class="tags-container">
<span class="tag">{{ 'ABOUT.PILL_1' | translate }}</span>
<span class="tag">{{ 'ABOUT.PILL_2' | translate }}</span>
<span class="tag">{{ 'ABOUT.PILL_3' | translate }}</span>
<span class="tag">{{ 'ABOUT.SERVICE_1' | translate }}</span>
<span class="tag">{{ 'ABOUT.SERVICE_2' | translate }}</span>
</div>
</div>
<!-- Right Column: Visuals -->
<div class="visual-content">
<div class="photo-card card-1">
<div class="placeholder-img"></div>
<div class="member-info">
<span class="member-name">Member 1</span>
<span class="member-role">Founder</span>
</div>
</div>
<div class="photo-card card-2">
<div class="placeholder-img"></div>
<div class="member-info">
<span class="member-name">Member 2</span>
<span class="member-role">Co-Founder</span>
</div>
</div>
</div>
</div>
</section>
<app-locations></app-locations>

View File

@@ -0,0 +1,157 @@
.about-section {
padding: 6rem 0;
background: var(--color-bg);
min-height: 80vh;
display: flex;
align-items: center;
}
.split-layout {
display: grid;
grid-template-columns: 1fr;
gap: 4rem;
align-items: center;
text-align: center; /* Center on mobile */
@media(min-width: 992px) {
grid-template-columns: 1fr 1fr;
gap: 6rem;
text-align: left; /* Reset to left on desktop */
}
}
/* Left Column */
.text-content {
/* text-align: left; Removed to inherit from parent */
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.15em;
font-size: 0.875rem;
color: var(--color-primary-500);
font-weight: 700;
margin-bottom: var(--space-2);
display: block;
}
h1 {
font-size: 3rem;
line-height: 1.1;
margin-bottom: var(--space-4);
color: var(--color-text-main);
}
.subtitle {
font-size: 1.25rem;
color: var(--color-text-muted);
margin-bottom: var(--space-6);
font-weight: 300;
}
.divider {
height: 4px;
width: 60px;
background: var(--color-primary-500);
border-radius: 2px;
margin-bottom: var(--space-6);
/* Center divider on mobile */
margin-left: auto;
margin-right: auto;
@media(min-width: 992px) {
margin-left: 0;
margin-right: 0;
}
}
.description {
font-size: 1.1rem;
line-height: 1.7;
color: var(--color-text-main);
margin-bottom: var(--space-8);
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center; /* Center tags on mobile */
@media(min-width: 992px) {
justify-content: flex-start;
}
}
.tag {
padding: 0.5rem 1rem;
border-radius: 99px;
background: var(--color-surface-card);
border: 1px solid var(--color-border);
color: var(--color-text-main);
font-weight: 500;
font-size: 0.9rem;
box-shadow: var(--shadow-sm);
transition: all 0.2s ease;
}
.tag:hover {
transform: translateY(-2px);
border-color: var(--color-primary-500);
color: var(--color-primary-500);
box-shadow: var(--shadow-md);
}
/* Right Column */
.visual-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 2rem;
@media(min-width: 768px) {
display: grid;
grid-template-columns: repeat(2, 1fr);
align-items: start;
justify-items: center;
}
}
.photo-card {
background: var(--color-surface-card);
padding: 1rem;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 260px;
position: relative;
}
.placeholder-img {
width: 100%;
aspect-ratio: 3/4;
background: linear-gradient(45deg, var(--color-neutral-200), var(--color-neutral-100));
border-radius: var(--radius-md);
margin-bottom: 1rem;
}
.member-info {
text-align: center;
}
.member-name {
display: block;
font-weight: 700;
color: var(--color-text-main);
font-size: 1.1rem;
}
.member-role {
display: block;
font-size: 0.85rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
}

View File

@@ -1,214 +1,13 @@
import { Component } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { AppLocationsComponent } from '../../shared/components/app-locations/app-locations.component';
@Component({
selector: 'app-about-page',
standalone: true,
imports: [TranslateModule],
template: `
<section class="about-section">
<div class="container split-layout">
<!-- Left Column: Content -->
<div class="text-content">
<p class="eyebrow">{{ 'ABOUT.EYEBROW' | translate }}</p>
<h1>{{ 'ABOUT.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'ABOUT.SUBTITLE' | translate }}</p>
<div class="divider"></div>
<p class="description">{{ 'ABOUT.HOW_TEXT' | translate }}</p>
<div class="tags-container">
<span class="tag">{{ 'ABOUT.PILL_1' | translate }}</span>
<span class="tag">{{ 'ABOUT.PILL_2' | translate }}</span>
<span class="tag">{{ 'ABOUT.PILL_3' | translate }}</span>
<span class="tag">{{ 'ABOUT.SERVICE_1' | translate }}</span>
<span class="tag">{{ 'ABOUT.SERVICE_2' | translate }}</span>
</div>
</div>
<!-- Right Column: Visuals -->
<div class="visual-content">
<div class="photo-card card-1">
<div class="placeholder-img"></div>
<div class="member-info">
<span class="member-name">Member 1</span>
<span class="member-role">Founder</span>
</div>
</div>
<div class="photo-card card-2">
<div class="placeholder-img"></div>
<div class="member-info">
<span class="member-name">Member 2</span>
<span class="member-role">Co-Founder</span>
</div>
</div>
</div>
</div>
</section>
`,
styles: [`
.about-section {
padding: 6rem 0;
background: var(--color-bg);
min-height: 80vh;
display: flex;
align-items: center;
}
.split-layout {
display: grid;
grid-template-columns: 1fr;
gap: 4rem;
align-items: center;
text-align: center; /* Center on mobile */
@media(min-width: 992px) {
grid-template-columns: 1fr 1fr;
gap: 6rem;
text-align: left; /* Reset to left on desktop */
}
}
/* Left Column */
.text-content {
/* text-align: left; Removed to inherit from parent */
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.15em;
font-size: 0.875rem;
color: var(--color-primary-500);
font-weight: 700;
margin-bottom: var(--space-2);
display: block;
}
h1 {
font-size: 3rem;
line-height: 1.1;
margin-bottom: var(--space-4);
color: var(--color-text-main);
}
.subtitle {
font-size: 1.25rem;
color: var(--color-text-muted);
margin-bottom: var(--space-6);
font-weight: 300;
}
.divider {
height: 4px;
width: 60px;
background: var(--color-primary-500);
border-radius: 2px;
margin-bottom: var(--space-6);
/* Center divider on mobile */
margin-left: auto;
margin-right: auto;
@media(min-width: 992px) {
margin-left: 0;
margin-right: 0;
}
}
.description {
font-size: 1.1rem;
line-height: 1.7;
color: var(--color-text-main);
margin-bottom: var(--space-8);
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center; /* Center tags on mobile */
@media(min-width: 992px) {
justify-content: flex-start;
}
}
.tag {
padding: 0.5rem 1rem;
border-radius: 99px;
background: var(--color-surface-card);
border: 1px solid var(--color-border);
color: var(--color-text-main);
font-weight: 500;
font-size: 0.9rem;
box-shadow: var(--shadow-sm);
transition: all 0.2s ease;
}
.tag:hover {
transform: translateY(-2px);
border-color: var(--color-primary-500);
color: var(--color-primary-500);
box-shadow: var(--shadow-md);
}
/* Right Column */
.visual-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 2rem;
@media(min-width: 768px) {
display: grid;
grid-template-columns: repeat(2, 1fr);
align-items: start;
justify-items: center;
}
}
.photo-card {
background: var(--color-surface-card);
padding: 1rem;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 260px;
position: relative;
}
.placeholder-img {
width: 100%;
aspect-ratio: 3/4;
background: linear-gradient(45deg, var(--color-neutral-200), var(--color-neutral-100));
border-radius: var(--radius-md);
margin-bottom: 1rem;
}
.member-info {
text-align: center;
}
.member-name {
display: block;
font-weight: 700;
color: var(--color-text-main);
font-size: 1.1rem;
}
.member-role {
display: block;
font-size: 0.85rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
}
`]
imports: [TranslateModule, AppLocationsComponent],
templateUrl: './about-page.component.html',
styleUrl: './about-page.component.scss'
})
export class AboutPageComponent {}

View File

@@ -0,0 +1,80 @@
<div class="container hero">
<h1>{{ 'CALC.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p>
@if (error()) {
<app-alert type="error">{{ 'CALC.ERROR_GENERIC' | translate }}</app-alert>
}
</div>
@if (step() === 'success') {
<div class="container hero">
<app-success-state context="calc" (action)="onNewQuote()"></app-success-state>
</div>
} @else if (step() === 'details' && result()) {
<div class="container">
<app-user-details
[quote]="result()!"
(submitOrder)="onSubmitOrder($event)"
(cancel)="onCancelDetails()">
</app-user-details>
</div>
} @else {
<div class="container content-grid">
<!-- Left Column: Input -->
<div class="col-input">
<app-card>
<div class="mode-selector">
<div class="mode-option"
[class.active]="mode() === 'easy'"
(click)="mode.set('easy')">
{{ 'CALC.MODE_EASY' | translate }}
</div>
<div class="mode-option"
[class.active]="mode() === 'advanced'"
(click)="mode.set('advanced')">
{{ 'CALC.MODE_ADVANCED' | translate }}
</div>
</div>
<app-upload-form
#uploadForm
[mode]="mode()"
[loading]="loading()"
[uploadProgress]="uploadProgress()"
(submitRequest)="onCalculate($event)"
></app-upload-form>
</app-card>
</div>
<!-- Right Column: Result or Info -->
<div class="col-result" #resultCol>
@if (loading()) {
<app-card class="loading-state">
<div class="loader-content">
<div class="spinner"></div>
<h3 class="loading-title">Analisi in corso...</h3>
<p class="loading-text">Stiamo analizzando la geometria e calcolando il percorso utensile.</p>
</div>
</app-card>
} @else if (result()) {
<app-quote-result
[result]="result()!"
(consult)="onConsult()"
(proceed)="onProceed()"
(itemChange)="uploadForm.updateItemQuantityByName($event.fileName, $event.quantity)"
></app-quote-result>
} @else {
<app-card>
<h3>{{ 'CALC.BENEFITS_TITLE' | translate }}</h3>
<ul class="benefits">
<li>{{ 'CALC.BENEFITS_1' | translate }}</li>
<li>{{ 'CALC.BENEFITS_2' | translate }}</li>
<li>{{ 'CALC.BENEFITS_3' | translate }}</li>
</ul>
</app-card>
}
</div>
</div>
}

View File

@@ -0,0 +1,110 @@
.hero { padding: var(--space-12) 0; text-align: center; }
.subtitle { font-size: 1.25rem; color: var(--color-text-muted); max-width: 600px; margin: 0 auto; }
.content-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-6);
@media(min-width: 768px) {
grid-template-columns: 1.5fr 1fr;
gap: var(--space-8);
}
}
.centered-col {
align-self: flex-start; /* Default */
@media(min-width: 768px) {
align-self: center;
}
}
.col-input {
min-width: 0;
}
.col-result {
min-width: 0;
display: flex;
flex-direction: column;
}
/* Stretch only the loading card so the spinner stays centered */
.col-result > .loading-state {
flex: 1;
}
/* Mode Selector (Segmented Control style) */
.mode-selector {
display: flex;
background-color: var(--color-neutral-100);
border-radius: var(--radius-md);
padding: 4px;
margin-bottom: var(--space-6);
gap: 4px;
width: 100%;
}
.mode-option {
flex: 1;
text-align: center;
padding: 8px 16px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
transition: all 0.2s ease;
user-select: none;
&:hover { color: var(--color-text); }
&.active {
background-color: var(--color-brand);
color: #000;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
}
.benefits { padding-left: var(--space-4); color: var(--color-text-muted); line-height: 2; }
.loader-content {
text-align: center;
max-width: 300px;
margin: 0 auto;
/* Center content vertically within the stretched card */
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.loading-title {
font-size: 1.1rem;
font-weight: 600;
margin: var(--space-4) 0 var(--space-2);
color: var(--color-text);
}
.loading-text {
font-size: 0.9rem;
color: var(--color-text-muted);
line-height: 1.5;
}
.spinner {
border: 3px solid var(--color-neutral-200);
border-left-color: var(--color-brand);
border-radius: 50%;
width: 48px;
height: 48px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}

View File

@@ -1,4 +1,4 @@
import { Component, signal } from '@angular/core';
import { Component, signal, ViewChild, ElementRef, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
@@ -6,157 +6,73 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
import { UploadFormComponent } from './components/upload-form/upload-form.component';
import { QuoteResultComponent } from './components/quote-result/quote-result.component';
import { QuoteEstimatorService, QuoteRequest, QuoteResult } from './services/quote-estimator.service';
import { QuoteRequest, QuoteResult, QuoteEstimatorService } from './services/quote-estimator.service';
import { UserDetailsComponent } from './components/user-details/user-details.component';
import { SuccessStateComponent } from '../../shared/components/success-state/success-state.component';
import { Router, ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-calculator-page',
standalone: true,
imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent],
template: `
<div class="container hero">
<h1>{{ 'CALC.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p>
</div>
<div class="container content-grid">
<!-- Left Column: Input -->
<div class="col-input">
<app-card>
<div class="mode-selector">
<div class="mode-option"
[class.active]="mode() === 'easy'"
(click)="mode.set('easy')">
{{ 'CALC.MODE_EASY' | translate }}
</div>
<div class="mode-option"
[class.active]="mode() === 'advanced'"
(click)="mode.set('advanced')">
{{ 'CALC.MODE_ADVANCED' | translate }}
</div>
</div>
<app-upload-form
[mode]="mode()"
[loading]="loading()"
(submitRequest)="onCalculate($event)"
></app-upload-form>
</app-card>
</div>
<!-- Right Column: Result or Info -->
<div class="col-result">
@if (error()) {
<app-alert type="error">Si è verificato un errore durante il calcolo del preventivo.</app-alert>
}
@if (loading()) {
<app-card class="loading-state">
<div class="spinner"></div>
<p>Analisi geometria e slicing in corso...</p>
<small class="text-muted">Potrebbe richiedere qualche secondo.</small>
</app-card>
} @else if (result()) {
<app-quote-result [result]="result()!"></app-quote-result>
} @else {
<app-card>
<h3>{{ 'CALC.BENEFITS_TITLE' | translate }}</h3>
<ul class="benefits">
<li>{{ 'CALC.BENEFITS_1' | translate }}</li>
<li>{{ 'CALC.BENEFITS_2' | translate }}</li>
<li>{{ 'CALC.BENEFITS_3' | translate }}</li>
</ul>
</app-card>
}
</div>
</div>
`,
styles: [`
.hero { padding: var(--space-12) 0; text-align: center; }
.subtitle { font-size: 1.25rem; color: var(--color-text-muted); max-width: 600px; margin: 0 auto; }
.content-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-8);
@media(min-width: 768px) {
grid-template-columns: 1.5fr 1fr;
}
}
/* Mode Selector (Segmented Control style) */
.mode-selector {
display: flex;
background-color: var(--color-neutral-100);
border-radius: var(--radius-md);
padding: 4px;
margin-bottom: var(--space-6);
gap: 4px;
width: 100%;
}
.mode-option {
flex: 1;
text-align: center;
padding: 8px 16px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
transition: all 0.2s ease;
user-select: none;
&:hover { color: var(--color-text); }
&.active {
background-color: var(--color-brand);
color: #000;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
}
.benefits { padding-left: var(--space-4); color: var(--color-text-muted); line-height: 2; }
.loading-state {
text-align: center;
padding: var(--space-8);
color: var(--color-text-muted);
.spinner {
border: 3px solid rgba(0, 0, 0, 0.1);
border-left-color: var(--color-brand);
border-radius: 50%;
width: 32px;
height: 32px;
animation: spin 1s linear infinite;
margin: 0 auto var(--space-4);
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`]
imports: [CommonModule, TranslateModule, AppCardComponent, AppAlertComponent, UploadFormComponent, QuoteResultComponent, UserDetailsComponent, SuccessStateComponent],
templateUrl: './calculator-page.component.html',
styleUrl: './calculator-page.component.scss'
})
export class CalculatorPageComponent {
export class CalculatorPageComponent implements OnInit {
mode = signal<any>('easy');
step = signal<'upload' | 'quote' | 'details' | 'success'>('upload');
loading = signal(false);
uploadProgress = signal(0);
result = signal<QuoteResult | null>(null);
error = signal<boolean>(false);
constructor(private estimator: QuoteEstimatorService) {}
orderSuccess = signal(false);
@ViewChild('uploadForm') uploadForm!: UploadFormComponent;
@ViewChild('resultCol') resultCol!: ElementRef;
constructor(
private estimator: QuoteEstimatorService,
private router: Router,
private route: ActivatedRoute
) {}
ngOnInit() {
this.route.data.subscribe(data => {
if (data['mode']) {
this.mode.set(data['mode']);
}
});
}
onCalculate(req: QuoteRequest) {
// ... (logic remains the same, simplified for diff)
this.currentRequest = req;
this.loading.set(true);
this.uploadProgress.set(0);
this.error.set(false);
this.result.set(null);
this.orderSuccess.set(false);
// Auto-scroll on mobile to make analysis visible
setTimeout(() => {
if (this.resultCol && window.innerWidth < 768) {
this.resultCol.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 100);
this.estimator.calculate(req).subscribe({
next: (res) => {
this.result.set(res);
next: (event) => {
if (typeof event === 'number') {
this.uploadProgress.set(event);
} else {
// It's the result
this.result.set(event as QuoteResult);
this.loading.set(false);
this.uploadProgress.set(100);
this.step.set('quote');
}
},
error: () => {
this.error.set(true);
@@ -164,4 +80,58 @@ export class CalculatorPageComponent {
}
});
}
onProceed() {
this.step.set('details');
}
onCancelDetails() {
this.step.set('quote');
}
onSubmitOrder(orderData: any) {
console.log('Order Submitted:', orderData);
this.orderSuccess.set(true);
this.step.set('success');
}
onNewQuote() {
this.step.set('upload');
this.result.set(null);
this.orderSuccess.set(false);
this.mode.set('easy'); // Reset to default
}
private currentRequest: QuoteRequest | null = null;
onConsult() {
if (!this.currentRequest) return;
const req = this.currentRequest;
let details = `Richiesta Preventivo:\n`;
details += `- Materiale: ${req.material}\n`;
details += `- Qualità: ${req.quality}\n`;
details += `- File:\n`;
req.items.forEach(item => {
details += ` * ${item.file.name} (Qtà: ${item.quantity}`;
if (item.color) {
details += `, Colore: ${item.color}`;
}
details += `)\n`;
});
if (req.mode === 'advanced') {
if (req.infillDensity) details += `- Infill: ${req.infillDensity}%\n`;
}
if (req.notes) details += `\nNote: ${req.notes}`;
this.estimator.setPendingConsultation({
files: req.items.map(i => i.file),
message: details
});
this.router.navigate(['/contact']);
}
}

View File

@@ -2,5 +2,7 @@ import { Routes } from '@angular/router';
import { CalculatorPageComponent } from './calculator-page.component';
export const CALCULATOR_ROUTES: Routes = [
{ path: '', component: CalculatorPageComponent }
{ path: '', redirectTo: 'basic', pathMatch: 'full' },
{ path: 'basic', component: CalculatorPageComponent, data: { mode: 'easy' } },
{ path: 'advanced', component: CalculatorPageComponent, data: { mode: 'advanced' } }
];

View File

@@ -0,0 +1,74 @@
<app-card>
<h3 class="title">{{ 'CALC.RESULT' | translate }}</h3>
<!-- Summary Grid (NOW ON TOP) -->
<div class="result-grid">
<app-summary-card
class="item full-width"
[label]="'CALC.COST' | translate"
[large]="true"
[highlight]="true">
{{ totals().price | currency:result().currency }}
</app-summary-card>
<app-summary-card [label]="'CALC.TIME' | translate">
{{ totals().hours }}h {{ totals().minutes }}m
</app-summary-card>
<app-summary-card [label]="'CALC.MATERIAL' | translate">
{{ totals().weight }}g
</app-summary-card>
</div>
<div class="setup-note">
<small>* Include {{ result().setupCost | currency:result().currency }} Setup Cost</small>
</div>
@if (result().notes) {
<div class="notes-section">
<label>{{ 'CALC.NOTES' | translate }}:</label>
<p>{{ result().notes }}</p>
</div>
}
<div class="divider"></div>
<!-- Detailed Items List (NOW ON BOTTOM) -->
<div class="items-list">
@for (item of items(); track item.fileName; let i = $index) {
<div class="item-row">
<div class="item-info">
<span class="file-name">{{ item.fileName }}</span>
<span class="file-details">
{{ (item.unitTime / 3600) | number:'1.1-1' }}h | {{ item.unitWeight | number:'1.0-0' }}g
</span>
</div>
<div class="item-controls">
<div class="qty-control">
<label>Qtà:</label>
<input
type="number"
min="1"
[ngModel]="item.quantity"
(ngModelChange)="updateQuantity(i, $event)"
class="qty-input">
</div>
<div class="item-price">
{{ (item.unitPrice * item.quantity) | currency:result().currency }}
</div>
</div>
</div>
}
</div>
<div class="actions">
<app-button variant="outline" (click)="consult.emit()">
{{ 'QUOTE.CONSULT' | translate }}
</app-button>
<app-button (click)="proceed.emit()">
{{ 'QUOTE.PROCEED_ORDER' | translate }}
</app-button>
</div>
</app-card>

View File

@@ -0,0 +1,109 @@
.title { margin-bottom: var(--space-6); text-align: center; }
.divider {
height: 1px;
background: var(--color-border);
margin: var(--space-4) 0;
}
.items-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.item-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3);
background: var(--color-neutral-50);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.item-info {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1; /* Ensure it takes available space */
}
.file-name { font-weight: 500; font-size: 0.9rem; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-details { font-size: 0.8rem; color: var(--color-text-muted); }
.item-controls {
display: flex;
align-items: center;
gap: var(--space-4);
}
.qty-control {
display: flex;
align-items: center;
gap: var(--space-2);
label { font-size: 0.8rem; color: var(--color-text-muted); }
}
.qty-input {
width: 60px;
padding: 4px 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
text-align: center;
&:focus { outline: none; border-color: var(--color-brand); }
}
.item-price {
font-weight: 600;
min-width: 60px;
text-align: right;
}
.result-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-3);
margin-bottom: var(--space-2);
@media(min-width: 500px) {
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
}
.full-width { grid-column: span 2; }
.setup-note {
text-align: center;
margin-bottom: var(--space-6);
color: var(--color-text-muted);
font-size: 0.8rem;
}
.actions { display: flex; flex-direction: column; gap: var(--space-3); }
.notes-section {
margin-top: var(--space-4);
margin-bottom: var(--space-4);
padding: var(--space-3);
background: var(--color-neutral-50);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
label {
font-weight: 500;
font-size: 0.9rem;
color: var(--color-text-muted);
display: block;
margin-bottom: var(--space-2);
}
p {
margin: 0;
font-size: 0.95rem;
color: var(--color-text);
white-space: pre-wrap; /* Preserve line breaks */
}
}

View File

@@ -1,56 +1,74 @@
import { Component, input, output } from '@angular/core';
import { Component, input, output, signal, computed, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { SummaryCardComponent } from '../../../../shared/components/summary-card/summary-card.component';
import { QuoteResult } from '../../services/quote-estimator.service';
import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
@Component({
selector: 'app-quote-result',
standalone: true,
imports: [CommonModule, TranslateModule, AppCardComponent, AppButtonComponent, SummaryCardComponent],
template: `
<app-card>
<h3 class="title">{{ 'CALC.RESULT' | translate }}</h3>
<div class="result-grid">
<app-summary-card
class="item full-width"
[label]="'CALC.COST' | translate"
[large]="true"
[highlight]="true">
{{ result().price | currency:result().currency }}
</app-summary-card>
<app-summary-card [label]="'CALC.TIME' | translate">
{{ result().printTimeHours }}h {{ result().printTimeMinutes }}m
</app-summary-card>
<app-summary-card [label]="'CALC.MATERIAL' | translate">
{{ result().materialUsageGrams }}g
</app-summary-card>
</div>
<div class="actions">
<app-button variant="primary" [fullWidth]="true">{{ 'CALC.ORDER' | translate }}</app-button>
<app-button variant="outline" [fullWidth]="true">{{ 'CALC.CONSULT' | translate }}</app-button>
</div>
</app-card>
`,
styles: [`
.title { margin-bottom: var(--space-6); text-align: center; }
.result-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.full-width { grid-column: span 2; }
.actions { display: flex; flex-direction: column; gap: var(--space-3); }
`]
imports: [CommonModule, FormsModule, TranslateModule, AppCardComponent, AppButtonComponent, SummaryCardComponent],
templateUrl: './quote-result.component.html',
styleUrl: './quote-result.component.scss'
})
export class QuoteResultComponent {
result = input.required<QuoteResult>();
consult = output<void>();
proceed = output<void>();
itemChange = output<{fileName: string, quantity: number}>();
// Local mutable state for items to handle quantity changes
items = signal<QuoteItem[]>([]);
constructor() {
effect(() => {
// Initialize local items when result inputs change
// We map to new objects to avoid mutating the input directly if it was a reference
this.items.set(this.result().items.map(i => ({...i})));
}, { allowSignalWrites: true });
}
updateQuantity(index: number, newQty: number | string) {
const qty = typeof newQty === 'string' ? parseInt(newQty, 10) : newQty;
if (qty < 1 || isNaN(qty)) return;
this.items.update(current => {
const updated = [...current];
updated[index] = { ...updated[index], quantity: qty };
return updated;
});
this.itemChange.emit({
fileName: this.items()[index].fileName,
quantity: qty
});
}
totals = computed(() => {
const currentItems = this.items();
const setup = this.result().setupCost;
let price = setup;
let time = 0;
let weight = 0;
currentItems.forEach(i => {
price += i.unitPrice * i.quantity;
time += i.unitTime * i.quantity;
weight += i.unitWeight * i.quantity;
});
const hours = Math.floor(time / 3600);
const minutes = Math.ceil((time % 3600) / 60);
return {
price: Math.round(price * 100) / 100,
hours,
minutes,
weight: Math.ceil(weight)
};
});
}

View File

@@ -0,0 +1,157 @@
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="section">
@if (selectedFile()) {
<div class="viewer-wrapper">
<app-stl-viewer
[file]="selectedFile()"
[color]="getSelectedFileColor()">
</app-stl-viewer>
<!-- Close button removed as requested -->
</div>
}
<!-- Initial Dropzone (Visible only when no files) -->
@if (items().length === 0) {
<app-dropzone
[label]="'CALC.UPLOAD_LABEL' | translate"
[subtext]="'CALC.UPLOAD_SUB' | translate"
[accept]="acceptedFormats"
[multiple]="true"
(filesDropped)="onFilesDropped($event)">
</app-dropzone>
}
<!-- New File List with Details -->
@if (items().length > 0) {
<div class="items-grid">
@for (item of items(); track item.file.name; let i = $index) {
<div class="file-card" [class.active]="item.file === selectedFile()" (click)="selectFile(item.file)">
<div class="card-header">
<span class="file-name" [title]="item.file.name">{{ item.file.name }}</span>
</div>
<div class="card-body">
<div class="card-controls">
<div class="qty-group">
<label>QTÀ</label>
<input
type="number"
min="1"
[value]="item.quantity"
(change)="updateItemQuantity(i, $event)"
class="qty-input"
(click)="$event.stopPropagation()">
</div>
<div class="color-group">
<label>COLORE</label>
<app-color-selector
[selectedColor]="item.color"
[variants]="currentMaterialVariants()"
(colorSelected)="updateItemColor(i, $event)">
</app-color-selector>
</div>
</div>
<button type="button" class="btn-remove" (click)="removeItem(i); $event.stopPropagation()" title="Remove file">
X
</button>
</div>
</div>
}
</div>
<!-- "Add Files" Button (Visible only when files exist) -->
<div class="add-more-container">
<input #additionalInput type="file" [accept]="acceptedFormats" multiple hidden (change)="onAdditionalFilesSelected($event)">
<button type="button" class="btn-add-more" (click)="additionalInput.click()">
+ {{ 'CALC.ADD_FILES' | translate }}
</button>
</div>
}
@if (items().length === 0 && form.get('itemsTouched')?.value) {
<div class="error-msg">{{ 'CALC.ERR_FILE_REQUIRED' | translate }}</div>
}
</div>
<div class="grid">
<app-select
formControlName="material"
[label]="'CALC.MATERIAL' | translate"
[options]="materials()"
></app-select>
@if (mode() === 'easy') {
<app-select
formControlName="quality"
[label]="'CALC.QUALITY' | translate"
[options]="qualities()"
></app-select>
} @else {
<app-select
formControlName="nozzleDiameter"
[label]="'CALC.NOZZLE' | translate"
[options]="nozzleDiameters()"
></app-select>
}
</div>
<!-- Global quantity removed, now per item -->
@if (mode() === 'advanced') {
<div class="grid">
<app-select
formControlName="infillPattern"
[label]="'CALC.PATTERN' | translate"
[options]="infillPatterns()"
></app-select>
<app-select
formControlName="layerHeight"
[label]="'CALC.LAYER_HEIGHT' | translate"
[options]="layerHeights()"
></app-select>
</div>
<div class="grid">
<app-input
formControlName="infillDensity"
type="number"
[label]="'CALC.INFILL' | translate"
></app-input>
<div class="checkbox-row">
<input type="checkbox" formControlName="supportEnabled" id="support">
<label for="support">{{ 'CALC.SUPPORT' | translate }}</label>
</div>
</div>
}
<app-input
formControlName="notes"
[label]="'CALC.NOTES' | translate"
placeholder="Istruzioni specifiche..."
></app-input>
<div class="actions">
<!-- Progress Bar (Only when uploading i.e. progress < 100) -->
@if (loading() && uploadProgress() < 100) {
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill" [style.width.%]="uploadProgress()"></div>
</div>
</div>
}
<app-button
type="submit"
[disabled]="items().length === 0 || loading()"
[fullWidth]="true">
{{ loading() ? (uploadProgress() < 100 ? 'Uploading...' : 'Processing...') : ('CALC.CALCULATE' | translate) }}
</app-button>
</div>
</form>

View File

@@ -0,0 +1,207 @@
.section { margin-bottom: var(--space-6); }
.grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-4);
@media(min-width: 640px) {
grid-template-columns: 1fr 1fr;
}
}
.actions { margin-top: var(--space-6); }
.error-msg { color: var(--color-danger-500); font-size: 0.875rem; margin-top: var(--space-2); text-align: center; }
.viewer-wrapper { position: relative; margin-bottom: var(--space-4); }
/* Grid Layout for Files */
.items-grid {
display: grid;
grid-template-columns: 1fr 1fr; /* Force 2 columns on mobile */
gap: var(--space-2); /* Tighten gap for mobile */
margin-top: var(--space-4);
margin-bottom: var(--space-4);
@media(min-width: 640px) {
gap: var(--space-3);
}
}
.file-card {
padding: var(--space-2); /* Reduced from space-3 */
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition: all 0.2s;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 4px; /* Reduced gap */
position: relative; /* For absolute positioning of remove btn */
min-width: 0; /* Allow flex item to shrink below content size if needed */
&:hover { border-color: var(--color-neutral-300); }
&.active {
border-color: var(--color-brand);
background: rgba(250, 207, 10, 0.05);
box-shadow: 0 0 0 1px var(--color-brand);
}
}
.card-header {
overflow: hidden;
padding-right: 25px; /* Adjusted */
margin-bottom: 2px;
}
.file-name {
font-weight: 500;
font-size: 0.8rem; /* Smaller font */
color: var(--color-text);
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-body {
display: flex;
align-items: center;
padding-top: 0;
}
.card-controls {
display: flex;
align-items: flex-end; /* Align bottom of input and color circle */
gap: 16px; /* Space between Qty and Color */
width: 100%;
}
.qty-group, .color-group {
display: flex;
flex-direction: column; /* Stack label and input */
align-items: flex-start;
gap: 0px;
label {
font-size: 0.6rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
margin-bottom: 2px;
}
}
.color-group {
align-items: flex-start; /* Align label left */
/* margin-right removed */
/* Override margin in selector for this context */
::ng-deep .color-selector-container {
margin-left: 0;
}
}
.qty-input {
width: 36px; /* Slightly smaller */
padding: 1px 2px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
text-align: center;
font-size: 0.85rem;
background: white;
height: 24px; /* Explicit height to match color circle somewhat */
&:focus { outline: none; border-color: var(--color-brand); }
}
.btn-remove {
position: absolute;
top: 4px;
right: 4px;
width: 18px;
height: 18px;
border-radius: 4px;
border: none;
background: transparent;
color: var(--color-text-muted);
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
font-size: 0.8rem;
&:hover {
background: var(--color-danger-100);
color: var(--color-danger-500);
}
}
/* Prominent Add Button */
.add-more-container {
margin-top: var(--space-2);
}
.btn-add-more {
width: 100%;
padding: var(--space-3);
background: var(--color-neutral-800);
color: white;
border: none;
border-radius: var(--radius-md);
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
&:hover {
background: var(--color-neutral-900);
transform: translateY(-1px);
}
&:active { transform: translateY(0); }
}
.checkbox-row {
display: flex;
align-items: center;
gap: var(--space-3);
height: 100%;
padding-top: var(--space-4);
input[type="checkbox"] {
width: 20px;
height: 20px;
accent-color: var(--color-brand);
}
label {
font-weight: 500;
cursor: pointer;
}
}
/* Progress Bar */
.progress-container {
margin-bottom: var(--space-3);
text-align: center;
width: 100%;
}
.progress-bar {
height: 4px;
background: var(--color-border);
border-radius: 2px;
overflow: hidden;
margin-bottom: 0;
position: relative;
width: 100%;
}
.progress-fill {
height: 100%;
background: var(--color-brand);
width: 0%;
transition: width 0.2s ease-out;
}

View File

@@ -1,4 +1,4 @@
import { Component, input, output, signal } from '@angular/core';
import { Component, input, output, signal, effect, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
@@ -7,281 +7,140 @@ import { AppSelectComponent } from '../../../../shared/components/app-select/app
import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone/app-dropzone.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component';
import { QuoteRequest } from '../../services/quote-estimator.service';
import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component';
import { QuoteRequest, QuoteEstimatorService, OptionsResponse, SimpleOption, MaterialOption, VariantOption } from '../../services/quote-estimator.service';
import { getColorHex } from '../../../../core/constants/colors.const';
interface FormItem {
file: File;
quantity: number;
color: string;
}
@Component({
selector: 'app-upload-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppSelectComponent, AppDropzoneComponent, AppButtonComponent, StlViewerComponent],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="section">
@if (selectedFile()) {
<div class="viewer-wrapper">
<app-stl-viewer [file]="selectedFile()"></app-stl-viewer>
<button type="button" class="btn-clear" (click)="clearFiles()">
X
</button>
</div>
<div class="file-list">
@for (f of files(); track f.name) {
<div class="file-item" [class.active]="f === selectedFile()" (click)="selectFile(f)">
{{ f.name }}
</div>
}
</div>
} @else {
<app-dropzone
[label]="'CALC.UPLOAD_LABEL' | translate"
[subtext]="'CALC.UPLOAD_SUB' | translate"
[accept]="acceptedFormats"
[multiple]="true"
(filesDropped)="onFilesDropped($event)">
</app-dropzone>
}
@if (form.get('files')?.invalid && form.get('files')?.touched) {
<div class="error-msg">{{ 'CALC.ERR_FILE_REQUIRED' | translate }}</div>
}
</div>
<div class="grid">
<app-select
formControlName="material"
[label]="'CALC.MATERIAL' | translate"
[options]="materials"
></app-select>
<app-select
formControlName="quality"
[label]="'CALC.QUALITY' | translate"
[options]="qualities"
></app-select>
</div>
<app-input
formControlName="quantity"
type="number"
[label]="'CALC.QUANTITY' | translate"
></app-input>
@if (mode() === 'advanced') {
<div class="grid">
<app-select
formControlName="color"
[label]="'CALC.COLOR' | translate"
[options]="colors"
></app-select>
<app-select
formControlName="infillPattern"
[label]="'CALC.PATTERN' | translate"
[options]="infillPatterns"
></app-select>
</div>
<div class="grid">
<app-input
formControlName="infillDensity"
type="number"
[label]="'CALC.INFILL' | translate"
></app-input>
<div class="checkbox-row">
<input type="checkbox" formControlName="supportEnabled" id="support">
<label for="support">{{ 'CALC.SUPPORT' | translate }}</label>
</div>
</div>
<app-input
formControlName="notes"
[label]="'CALC.NOTES' | translate"
placeholder="Istruzioni specifiche..."
></app-input>
}
@if (loading()) {
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<p class="progress-text">Uploading & Analyzing...</p>
</div>
}
<div class="actions">
<app-button
type="submit"
[disabled]="form.invalid || loading()"
[fullWidth]="true">
@if (loading()) {
Slicing in progress...
} @else {
{{ 'CALC.CALCULATE' | translate }}
}
</app-button>
</div>
</form>
`,
styles: [`
.section { margin-bottom: var(--space-6); }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); }
.actions { margin-top: var(--space-6); }
.error-msg { color: var(--color-danger-500); font-size: 0.875rem; margin-top: var(--space-2); text-align: center; }
.viewer-wrapper { position: relative; margin-bottom: var(--space-4); }
.btn-clear {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0,0,0,0.5);
color: white;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
z-index: 10;
&:hover { background: rgba(0,0,0,0.7); }
}
.file-list {
display: flex;
gap: var(--space-2);
overflow-x: auto;
padding-bottom: var(--space-2);
}
.file-item {
padding: 0.5rem 1rem;
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: 0.85rem;
cursor: pointer;
white-space: nowrap;
&:hover { background: var(--color-neutral-200); }
&.active {
border-color: var(--color-brand);
background: rgba(250, 207, 10, 0.1);
font-weight: 600;
}
}
.checkbox-row {
display: flex;
align-items: center;
gap: var(--space-3);
height: 100%;
padding-top: var(--space-4);
input[type="checkbox"] {
width: 20px;
height: 20px;
accent-color: var(--color-brand);
}
label {
font-weight: 500;
cursor: pointer;
}
}
/* Progress Bar */
.progress-container {
margin-top: var(--space-4);
padding: var(--space-4);
background: var(--color-neutral-100);
border-radius: var(--radius-md);
text-align: center;
}
.progress-bar {
height: 6px;
background: var(--color-border);
border-radius: 3px;
overflow: hidden;
margin-bottom: var(--space-2);
position: relative;
}
.progress-fill {
height: 100%;
background: var(--color-brand);
width: 0%;
animation: progress 2s ease-in-out infinite;
}
.progress-text { font-size: 0.875rem; color: var(--color-text-muted); }
@keyframes progress {
0% { width: 0%; transform: translateX(-100%); }
50% { width: 100%; transform: translateX(0); }
100% { width: 100%; transform: translateX(100%); }
}
`]
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppSelectComponent, AppDropzoneComponent, AppButtonComponent, StlViewerComponent, ColorSelectorComponent],
templateUrl: './upload-form.component.html',
styleUrl: './upload-form.component.scss'
})
export class UploadFormComponent {
export class UploadFormComponent implements OnInit {
mode = input<'easy' | 'advanced'>('easy');
loading = input<boolean>(false);
uploadProgress = input<number>(0);
submitRequest = output<QuoteRequest>();
private estimator = inject(QuoteEstimatorService);
private fb = inject(FormBuilder);
form: FormGroup;
files = signal<File[]>([]);
items = signal<FormItem[]>([]);
selectedFile = signal<File | null>(null);
materials = [
{ label: 'PLA (Standard)', value: 'PLA' },
{ label: 'PETG (Resistente)', value: 'PETG' },
{ label: 'TPU (Flessibile)', value: 'TPU' }
];
// Dynamic Options
materials = signal<SimpleOption[]>([]);
qualities = signal<SimpleOption[]>([]);
nozzleDiameters = signal<SimpleOption[]>([]);
infillPatterns = signal<SimpleOption[]>([]);
layerHeights = signal<SimpleOption[]>([]);
qualities = [
{ label: 'Bozza (Fast)', value: 'Draft' },
{ label: 'Standard', value: 'Standard' },
{ label: 'Alta definizione', value: 'High' }
];
// Store full material options to lookup variants/colors if needed later
private fullMaterialOptions: MaterialOption[] = [];
colors = [
{ label: 'Black', value: 'Black' },
{ label: 'White', value: 'White' },
{ label: 'Gray', value: 'Gray' },
{ label: 'Red', value: 'Red' },
{ label: 'Blue', value: 'Blue' },
{ label: 'Green', value: 'Green' },
{ label: 'Yellow', value: 'Yellow' }
];
infillPatterns = [
{ label: 'Grid', value: 'grid' },
{ label: 'Gyroid', value: 'gyroid' },
{ label: 'Cubic', value: 'cubic' },
{ label: 'Triangles', value: 'triangles' }
];
// Computed variants for valid material
currentMaterialVariants = signal<VariantOption[]>([]);
private updateVariants() {
const matCode = this.form.get('material')?.value;
if (matCode && this.fullMaterialOptions.length > 0) {
const found = this.fullMaterialOptions.find(m => m.code === matCode);
this.currentMaterialVariants.set(found ? found.variants : []);
} else {
this.currentMaterialVariants.set([]);
}
}
acceptedFormats = '.stl,.3mf,.step,.stp,.obj,.amf,.ply,.igs,.iges';
constructor(private fb: FormBuilder) {
constructor() {
this.form = this.fb.group({
files: [[], Validators.required],
material: ['PLA', Validators.required],
quality: ['Standard', Validators.required],
quantity: [1, [Validators.required, Validators.min(1)]],
itemsTouched: [false], // Hack to track touched state for custom items list
material: ['', Validators.required],
quality: ['', Validators.required],
items: [[]], // Track items in form for validation if needed
notes: [''],
// Advanced fields
color: ['Black'],
infillDensity: [20, [Validators.min(0), Validators.max(100)]],
layerHeight: [0.2, [Validators.min(0.05), Validators.max(1.0)]],
nozzleDiameter: [0.4, Validators.required],
infillPattern: ['grid'],
supportEnabled: [false]
});
// Listen to material changes to update variants
this.form.get('material')?.valueChanges.subscribe(() => {
this.updateVariants();
});
}
ngOnInit() {
this.estimator.getOptions().subscribe({
next: (options: OptionsResponse) => {
this.fullMaterialOptions = options.materials;
this.updateVariants(); // Trigger initial update
this.materials.set(options.materials.map(m => ({ label: m.label, value: m.code })));
this.qualities.set(options.qualities.map(q => ({ label: q.label, value: q.id })));
this.infillPatterns.set(options.infillPatterns.map(p => ({ label: p.label, value: p.id })));
this.layerHeights.set(options.layerHeights.map(l => ({ label: l.label, value: l.value })));
this.nozzleDiameters.set(options.nozzleDiameters.map(n => ({ label: n.label, value: n.value })));
this.setDefaults();
},
error: (err) => {
console.error('Failed to load options', err);
// Fallback for debugging/offline dev
this.materials.set([{ label: 'PLA (Fallback)', value: 'PLA' }]);
this.qualities.set([{ label: 'Standard', value: 'standard' }]);
this.nozzleDiameters.set([{ label: '0.4 mm', value: 0.4 }]);
this.setDefaults();
}
});
}
private setDefaults() {
// Set Defaults if available
if (this.materials().length > 0 && !this.form.get('material')?.value) {
this.form.get('material')?.setValue(this.materials()[0].value);
}
if (this.qualities().length > 0 && !this.form.get('quality')?.value) {
// Try to find 'standard' or use first
const std = this.qualities().find(q => q.value === 'standard');
this.form.get('quality')?.setValue(std ? std.value : this.qualities()[0].value);
}
if (this.nozzleDiameters().length > 0 && !this.form.get('nozzleDiameter')?.value) {
this.form.get('nozzleDiameter')?.setValue(0.4); // Prefer 0.4
}
if (this.layerHeights().length > 0 && !this.form.get('layerHeight')?.value) {
this.form.get('layerHeight')?.setValue(0.2); // Prefer 0.2
}
if (this.infillPatterns().length > 0 && !this.form.get('infillPattern')?.value) {
this.form.get('infillPattern')?.setValue(this.infillPatterns()[0].value);
}
}
onFilesDropped(newFiles: File[]) {
const MAX_SIZE = 200 * 1024 * 1024; // 200MB
const validFiles: File[] = [];
const validItems: FormItem[] = [];
let hasError = false;
for (const file of newFiles) {
if (file.size > MAX_SIZE) {
hasError = true;
} else {
validFiles.push(file);
// Default color is Black
validItems.push({ file, quantity: 1, color: 'Black' });
}
}
@@ -289,32 +148,112 @@ export class UploadFormComponent {
alert("Alcuni file superano il limite di 200MB e non sono stati aggiunti.");
}
if (validFiles.length > 0) {
this.files.update(current => [...current, ...validFiles]);
this.form.patchValue({ files: this.files() });
this.form.get('files')?.markAsTouched();
this.selectedFile.set(validFiles[validFiles.length - 1]);
if (validItems.length > 0) {
this.items.update(current => [...current, ...validItems]);
this.form.get('itemsTouched')?.setValue(true);
// Auto select last added
this.selectedFile.set(validItems[validItems.length - 1].file);
}
}
onAdditionalFilesSelected(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
this.onFilesDropped(Array.from(input.files));
// Reset input so same files can be selected again if needed
input.value = '';
}
}
updateItemQuantityByName(fileName: string, quantity: number) {
this.items.update(current => {
return current.map(item => {
if (item.file.name === fileName) {
return { ...item, quantity };
}
return item;
});
});
}
selectFile(file: File) {
if (this.selectedFile() === file) {
// toggle off? no, keep active
} else {
this.selectedFile.set(file);
}
}
clearFiles() {
this.files.set([]);
// Helper to get color of currently selected file
getSelectedFileColor(): string {
const file = this.selectedFile();
if (!file) return '#facf0a'; // Default
const item = this.items().find(i => i.file === file);
if (item) {
const vars = this.currentMaterialVariants();
if (vars && vars.length > 0) {
const found = vars.find(v => v.colorName === item.color);
if (found) return found.hexColor;
}
return getColorHex(item.color);
}
return '#facf0a';
}
updateItemQuantity(index: number, event: Event) {
const input = event.target as HTMLInputElement;
let val = parseInt(input.value, 10);
if (isNaN(val) || val < 1) val = 1;
this.items.update(current => {
const updated = [...current];
updated[index] = { ...updated[index], quantity: val };
return updated;
});
}
updateItemColor(index: number, newColor: string) {
this.items.update(current => {
const updated = [...current];
updated[index] = { ...updated[index], color: newColor };
return updated;
});
}
removeItem(index: number) {
this.items.update(current => {
const updated = [...current];
const removed = updated.splice(index, 1)[0];
if (this.selectedFile() === removed.file) {
this.selectedFile.set(null);
this.form.patchValue({ files: [] });
}
return updated;
});
}
onSubmit() {
if (this.form.valid) {
console.log('UploadFormComponent: onSubmit triggered');
console.log('Form Valid:', this.form.valid, 'Items:', this.items().length);
if (this.form.valid && this.items().length > 0) {
console.log('UploadFormComponent: Emitting submitRequest', this.form.value);
this.submitRequest.emit({
...this.form.value,
items: this.items(), // Pass the items array explicitly AFTER form value to prevent overwrite
mode: this.mode()
});
} else {
console.warn('UploadFormComponent: Form Invalid or No Items');
console.log('Form Errors:', this.form.errors);
Object.keys(this.form.controls).forEach(key => {
const control = this.form.get(key);
if (control?.invalid) {
console.log('Invalid Control:', key, control.errors, 'Value:', control.value);
}
});
this.form.markAllAsTouched();
this.form.get('itemsTouched')?.setValue(true);
}
}
}

View File

@@ -0,0 +1,127 @@
<div class="user-details-container">
<div class="row">
<div class="col-md-6">
<app-card [title]="'USER_DETAILS.TITLE' | translate">
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<!-- Name & Surname -->
<div class="row">
<div class="col-md-6">
<app-input
formControlName="name"
label="USER_DETAILS.NAME"
placeholder="USER_DETAILS.NAME_PLACEHOLDER"
[required]="true"
[error]="form.get('name')?.invalid && form.get('name')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input>
</div>
<div class="col-md-6">
<app-input
formControlName="surname"
label="USER_DETAILS.SURNAME"
placeholder="USER_DETAILS.SURNAME_PLACEHOLDER"
[required]="true"
[error]="form.get('surname')?.invalid && form.get('surname')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input>
</div>
</div>
<!-- Email & Phone -->
<div class="row">
<div class="col-md-6">
<app-input
formControlName="email"
label="USER_DETAILS.EMAIL"
type="email"
placeholder="USER_DETAILS.EMAIL_PLACEHOLDER"
[required]="true"
[error]="form.get('email')?.invalid && form.get('email')?.touched ? ('COMMON.INVALID_EMAIL' | translate) : null">
</app-input>
</div>
<div class="col-md-6">
<app-input
formControlName="phone"
label="USER_DETAILS.PHONE"
type="tel"
placeholder="USER_DETAILS.PHONE_PLACEHOLDER"
[required]="true"
[error]="form.get('phone')?.invalid && form.get('phone')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input>
</div>
</div>
<!-- Address -->
<app-input
formControlName="address"
label="USER_DETAILS.ADDRESS"
placeholder="USER_DETAILS.ADDRESS_PLACEHOLDER"
[required]="true"
[error]="form.get('address')?.invalid && form.get('address')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input>
<!-- Zip & City -->
<div class="row">
<div class="col-md-4">
<app-input
formControlName="zip"
label="USER_DETAILS.ZIP"
placeholder="USER_DETAILS.ZIP_PLACEHOLDER"
[required]="true"
[error]="form.get('zip')?.invalid && form.get('zip')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input>
</div>
<div class="col-md-8">
<app-input
formControlName="city"
label="USER_DETAILS.CITY"
placeholder="USER_DETAILS.CITY_PLACEHOLDER"
[required]="true"
[error]="form.get('city')?.invalid && form.get('city')?.touched ? ('COMMON.REQUIRED' | translate) : null">
</app-input>
</div>
</div>
<div class="actions">
<app-button
type="button"
variant="outline"
(click)="onCancel()">
{{ 'COMMON.BACK' | translate }}
</app-button>
<app-button
type="submit"
[disabled]="form.invalid || submitting()">
{{ 'USER_DETAILS.SUBMIT' | translate }}
</app-button>
</div>
</form>
</app-card>
</div>
<!-- Order Summary Column -->
<div class="col-md-6">
<app-card [title]="'USER_DETAILS.SUMMARY_TITLE' | translate">
<div class="summary-content" *ngIf="quote()">
<div class="summary-item" *ngFor="let item of quote()!.items">
<div class="item-info">
<span class="item-name">{{ item.fileName }}</span>
<span class="item-meta">{{ item.material }} - {{ item.color || 'Default' }}</span>
</div>
<div class="item-qty">x{{ item.quantity }}</div>
<div class="item-price">{{ (item.unitPrice * item.quantity) | currency:'CHF' }}</div>
</div>
<hr>
<div class="total-row">
<span>{{ 'QUOTE.TOTAL' | translate }}</span>
<span class="total-price">{{ quote()!.totalPrice | currency:'CHF' }}</span>
</div>
</div>
</app-card>
</div>
</div>
</div>

View File

@@ -0,0 +1,102 @@
.user-details-container {
padding: 1rem 0;
}
.row {
display: flex;
flex-wrap: wrap;
margin: 0 -0.5rem;
> [class*='col-'] {
padding: 0 0.5rem;
}
}
.col-md-6 {
width: 100%;
@media (min-width: 768px) {
width: 50%;
}
}
.col-md-4 {
width: 100%;
@media (min-width: 768px) {
width: 33.333%;
}
}
.col-md-8 {
width: 100%;
@media (min-width: 768px) {
width: 66.666%;
}
}
.actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 1.5rem;
}
// Summary Styles
.summary-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.summary-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
&:last-child {
border-bottom: none;
}
}
.item-info {
display: flex;
flex-direction: column;
flex: 1;
}
.item-name {
font-weight: 500;
}
.item-meta {
font-size: 0.85rem;
opacity: 0.7;
}
.item-qty {
margin: 0 1rem;
opacity: 0.8;
}
.item-price {
font-weight: 600;
}
.total-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 1.2rem;
font-weight: 700;
margin-top: 1rem;
padding-top: 1rem;
border-top: 2px solid rgba(255, 255, 255, 0.2);
.total-price {
color: var(--primary-color, #00C853); // Fallback color
}
}

View File

@@ -0,0 +1,59 @@
import { Component, input, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { AppCardComponent } from '../../../../shared/components/app-card/app-card.component';
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { QuoteResult } from '../../services/quote-estimator.service';
@Component({
selector: 'app-user-details',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppCardComponent, AppInputComponent, AppButtonComponent],
templateUrl: './user-details.component.html',
styleUrl: './user-details.component.scss'
})
export class UserDetailsComponent {
quote = input<QuoteResult>();
submitOrder = output<any>();
cancel = output<void>();
form: FormGroup;
submitting = signal(false);
constructor(private fb: FormBuilder) {
this.form = this.fb.group({
name: ['', Validators.required],
surname: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
phone: ['', Validators.required],
address: ['', Validators.required],
zip: ['', Validators.required],
city: ['', Validators.required]
});
}
onSubmit() {
if (this.form.valid) {
this.submitting.set(true);
const orderData = {
customer: this.form.value,
quote: this.quote()
};
// Simulate API delay
setTimeout(() => {
this.submitOrder.emit(orderData);
this.submitting.set(false);
}, 1000);
} else {
this.form.markAllAsTouched();
}
}
onCancel() {
this.cancel.emit();
}
}

View File

@@ -1,29 +1,41 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, forkJoin, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient, HttpEventType } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { map, catchError, tap } from 'rxjs/operators';
import { environment } from '../../../../environments/environment';
export interface QuoteRequest {
files: File[];
items: { file: File, quantity: number, color?: string }[];
material: string;
quality: string;
quantity: number;
notes?: string;
color?: string;
infillDensity?: number;
infillPattern?: string;
supportEnabled?: boolean;
layerHeight?: number;
nozzleDiameter?: number;
mode: 'easy' | 'advanced';
}
export interface QuoteItem {
fileName: string;
unitPrice: number;
unitTime: number; // seconds
unitWeight: number; // grams
quantity: number;
material?: string;
color?: string;
}
export interface QuoteResult {
price: number;
currency: string;
printTimeHours: number;
printTimeMinutes: number;
materialUsageGrams: number;
items: QuoteItem[];
setupCost: number;
currency: string;
totalPrice: number;
totalTimeHours: number;
totalTimeMinutes: number;
totalWeight: number;
notes?: string;
}
interface BackendResponse {
@@ -38,86 +50,279 @@ interface BackendResponse {
error?: string;
}
interface BackendQuoteResult {
totalPrice: number;
currency: string;
setupCost: number;
stats: {
printTimeSeconds: number;
printTimeFormatted: string;
filamentWeightGrams: number;
filamentLengthMm: number;
};
}
// Options Interfaces
export interface MaterialOption {
code: string;
label: string;
variants: VariantOption[];
}
export interface VariantOption {
name: string;
colorName: string;
hexColor: string;
isOutOfStock: boolean;
}
export interface QualityOption {
id: string;
label: string;
}
export interface InfillOption {
id: string;
label: string;
}
export interface NumericOption {
value: number;
label: string;
}
export interface OptionsResponse {
materials: MaterialOption[];
qualities: QualityOption[];
infillPatterns: InfillOption[];
layerHeights: NumericOption[];
nozzleDiameters: NumericOption[];
}
// UI Option for Select Component
export interface SimpleOption {
value: string | number;
label: string;
}
@Injectable({
providedIn: 'root'
})
export class QuoteEstimatorService {
private http = inject(HttpClient);
calculate(request: QuoteRequest): Observable<QuoteResult> {
const requests: Observable<BackendResponse>[] = request.files.map(file => {
getOptions(): Observable<OptionsResponse> {
console.log('QuoteEstimatorService: Requesting options...');
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
return this.http.get<OptionsResponse>(`${environment.apiUrl}/api/calculator/options`, { headers }).pipe(
tap({
next: (res) => console.log('QuoteEstimatorService: Options loaded', res),
error: (err) => console.error('QuoteEstimatorService: Options failed', err)
})
);
}
calculate(request: QuoteRequest): Observable<number | QuoteResult> {
console.log('QuoteEstimatorService: Calculating quote...', request);
if (request.items.length === 0) {
console.warn('QuoteEstimatorService: No items to calculate');
return of();
}
return new Observable(observer => {
const totalItems = request.items.length;
const allProgress: number[] = new Array(totalItems).fill(0);
const finalResponses: any[] = [];
let completedRequests = 0;
const uploads = request.items.map((item, index) => {
const formData = new FormData();
formData.append('file', file);
formData.append('machine', 'bambu_a1'); // Hardcoded for now
formData.append('file', item.file);
// machine param removed - backend uses default active
// Map material? Or trust frontend to send correct code?
// Since we fetch options now, we should send the code directly.
// But for backward compat/safety/mapping logic in mapMaterial, let's keep it or update it.
// If frontend sends 'PLA', mapMaterial returns 'pla_basic'.
// We should check if request.material is already a code from options.
// For now, let's assume request.material IS the code if it matches our new options,
// or fallback to mapper if it's old legacy string.
// Let's keep mapMaterial but update it to be smarter if needed, or rely on UploadForm to send correct codes.
// For now, let's use mapMaterial as safety, assuming frontend sends short codes 'PLA'.
// Wait, if we use dynamic options, the 'value' in select will be the 'code' from backend (e.g. 'PLA').
// Backend expects 'pla_basic' or just 'PLA'?
// QuoteController -> processRequest -> SlicerService.slice -> assumes 'filament' is a profile name like 'pla_basic'.
// So we MUST map 'PLA' to 'pla_basic' UNLESS backend options return 'pla_basic' as code.
// Backend OptionsController returns type.getMaterialCode() which is 'PLA'.
// So we still need mapping to slicer profile names.
formData.append('filament', this.mapMaterial(request.material));
formData.append('quality', this.mapQuality(request.quality));
// Send color for both modes if present, defaulting to Black
formData.append('material_color', item.color || 'Black');
if (request.mode === 'advanced') {
if (request.color) formData.append('material_color', request.color);
if (request.infillDensity) formData.append('infill_density', request.infillDensity.toString());
if (request.infillPattern) formData.append('infill_pattern', request.infillPattern);
if (request.supportEnabled) formData.append('support_enabled', 'true');
if (request.layerHeight) formData.append('layer_height', request.layerHeight.toString());
if (request.nozzleDiameter) formData.append('nozzle_diameter', request.nozzleDiameter.toString());
}
const headers: any = {};
// @ts-ignore
if (environment.basicAuth) {
// @ts-ignore
headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
}
if (environment.basicAuth) headers['Authorization'] = 'Basic ' + btoa(environment.basicAuth);
console.log(`Sending file: ${file.name} to ${environment.apiUrl}/api/quote`);
return this.http.post<BackendResponse>(`${environment.apiUrl}/api/quote`, formData, { headers }).pipe(
map(res => {
console.log('Response for', file.name, res);
return res;
}),
catchError(err => {
console.error('Error calculating quote for', file.name, err);
return of({ success: false, data: { print_time_seconds: 0, material_grams: 0, cost: { total: 0 } }, error: err.message });
})
return this.http.post<BackendResponse | BackendQuoteResult>(`${environment.apiUrl}/api/quote`, formData, {
headers,
reportProgress: true,
observe: 'events'
}).pipe(
map(event => ({ item, event, index })),
catchError(err => of({ item, error: err, index }))
);
});
return forkJoin(requests).pipe(
map(responses => {
console.log('All responses:', responses);
// Subscribe to all
uploads.forEach((obs) => {
obs.subscribe({
next: (wrapper: any) => {
const idx = wrapper.index;
const validResponses = responses.filter(r => r.success);
if (validResponses.length === 0 && responses.length > 0) {
throw new Error('All calculations failed. Check backend connection.');
if (wrapper.error) {
finalResponses[idx] = { success: false, fileName: wrapper.item.file.name };
}
let totalPrice = 0;
let totalTime = 0;
let totalWeight = 0;
let setupCost = 10; // Base setup
const event = wrapper.event;
if (event && event.type === HttpEventType.UploadProgress) {
if (event.total) {
const percent = Math.round((100 * event.loaded) / event.total);
allProgress[idx] = percent;
// Emit average progress
const avg = Math.round(allProgress.reduce((a, b) => a + b, 0) / totalItems);
observer.next(avg);
}
} else if ((event && event.type === HttpEventType.Response) || wrapper.error) {
// It's done (either response or error caught above)
if (!finalResponses[idx]) { // only if not already set by error
allProgress[idx] = 100;
if (wrapper.error) {
finalResponses[idx] = { success: false, fileName: wrapper.item.file.name };
} else {
finalResponses[idx] = { ...event.body, fileName: wrapper.item.file.name, originalQty: wrapper.item.quantity };
}
completedRequests++;
}
validResponses.forEach(res => {
totalPrice += res.data.cost.total;
totalTime += res.data.print_time_seconds;
totalWeight += res.data.material_grams;
if (completedRequests === totalItems) {
// All done
observer.next(100);
// Calculate Results
let setupCost = 10;
let setupCostFromBackend: number | null = null;
let currencyFromBackend: string | null = null;
if (request.nozzleDiameter && request.nozzleDiameter !== 0.4) {
setupCost += 2;
}
const items: QuoteItem[] = [];
finalResponses.forEach((res, idx) => {
if (!res) return;
const originalItem = request.items[idx];
const normalized = this.normalizeResponse(res);
if (!normalized.success) return;
if (normalized.currency && currencyFromBackend == null) {
currencyFromBackend = normalized.currency;
}
if (normalized.setupCost != null && setupCostFromBackend == null) {
setupCostFromBackend = normalized.setupCost;
}
items.push({
fileName: res.fileName,
unitPrice: normalized.unitPrice,
unitTime: normalized.unitTime,
unitWeight: normalized.unitWeight,
quantity: res.originalQty, // Use the requested quantity
material: request.material,
color: originalItem.color || 'Default'
});
});
// Apply quantity multiplier
totalPrice = (totalPrice * request.quantity) + setupCost;
totalWeight = totalWeight * request.quantity;
// Total time usually parallel if we have multiple printers, but let's sum for now
totalTime = totalTime * request.quantity;
if (items.length === 0) {
observer.error('All calculations failed.');
return;
}
// Initial Aggregation
const useBackendSetup = setupCostFromBackend != null;
let grandTotal = useBackendSetup ? 0 : setupCost;
let totalTime = 0;
let totalWeight = 0;
items.forEach(item => {
grandTotal += item.unitPrice * item.quantity;
totalTime += item.unitTime * item.quantity;
totalWeight += item.unitWeight * item.quantity;
});
const totalHours = Math.floor(totalTime / 3600);
const totalMinutes = Math.ceil((totalTime % 3600) / 60);
return {
price: Math.round(totalPrice * 100) / 100, // Keep 2 decimals
currency: 'CHF',
printTimeHours: totalHours,
printTimeMinutes: totalMinutes,
materialUsageGrams: Math.ceil(totalWeight),
setupCost
const result: QuoteResult = {
items,
setupCost: useBackendSetup ? setupCostFromBackend! : setupCost,
currency: currencyFromBackend || 'CHF',
totalPrice: Math.round(grandTotal * 100) / 100,
totalTimeHours: totalHours,
totalTimeMinutes: totalMinutes,
totalWeight: Math.ceil(totalWeight),
notes: request.notes
};
})
);
observer.next(result);
observer.complete();
}
}
},
error: (err) => {
console.error('Error in request subscription', err);
completedRequests++;
if (completedRequests === totalItems) {
observer.error('Requests failed');
}
}
});
});
});
}
private normalizeResponse(res: any): { success: boolean; unitPrice: number; unitTime: number; unitWeight: number; setupCost?: number; currency?: string } {
if (res && typeof res.totalPrice === 'number' && res.stats && typeof res.stats.printTimeSeconds === 'number') {
return {
success: true,
unitPrice: res.totalPrice,
unitTime: res.stats.printTimeSeconds,
unitWeight: res.stats.filamentWeightGrams,
setupCost: res.setupCost,
currency: res.currency
};
}
if (res && res.success && res.data) {
return {
success: true,
unitPrice: res.data.cost.total,
unitTime: res.data.print_time_seconds,
unitWeight: res.data.material_grams,
currency: 'CHF'
};
}
return { success: false, unitPrice: 0, unitTime: 0, unitWeight: 0 };
}
private mapMaterial(mat: string): string {
@@ -134,4 +339,17 @@ export class QuoteEstimatorService {
if (q.includes('high')) return 'extra_fine';
return 'standard';
}
// Consultation Data Transfer
private pendingConsultation = signal<{files: File[], message: string} | null>(null);
setPendingConsultation(data: {files: File[], message: string}) {
this.pendingConsultation.set(data);
}
getPendingConsultation() {
const data = this.pendingConsultation();
this.pendingConsultation.set(null); // Clear after reading
return data;
}
}

View File

@@ -0,0 +1,77 @@
@if (sent()) {
<app-success-state context="contact" (action)="resetForm()"></app-success-state>
} @else {
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<!-- Request Type -->
<div class="form-group">
<label>{{ 'CONTACT.REQ_TYPE_LABEL' | translate }} *</label>
<select formControlName="requestType" class="form-control">
<option *ngFor="let type of requestTypes" [value]="type.value">
{{ type.label | translate }}
</option>
</select>
</div>
<div class="row">
<!-- Phone -->
<app-input formControlName="email" type="email" [label]="'CONTACT.LABEL_EMAIL' | translate" [placeholder]="'CONTACT.PLACEHOLDER_EMAIL' | translate" class="col"></app-input>
<!-- Phone -->
<app-input formControlName="phone" type="tel" [label]="('CONTACT.PHONE' | translate)" [placeholder]="'CONTACT.PLACEHOLDER_PHONE' | translate" class="col"></app-input>
</div>
<!-- User Type Selector (Segmented Control) -->
<div class="user-type-selector">
<div class="type-option" [class.selected]="!isCompany" (click)="setCompanyMode(false)">
{{ 'CONTACT.TYPE_PRIVATE' | translate }}
</div>
<div class="type-option" [class.selected]="isCompany" (click)="setCompanyMode(true)">
{{ 'CONTACT.TYPE_COMPANY' | translate }}
</div>
</div>
<!-- Personal Name (Only if NOT Company) -->
<app-input *ngIf="!isCompany" formControlName="name" [label]="'CONTACT.LABEL_NAME' | translate" [placeholder]="'CONTACT.PLACEHOLDER_NAME' | translate"></app-input>
<!-- Company Fields (Only if Company) -->
<div *ngIf="isCompany" class="company-fields">
<app-input formControlName="companyName" [label]="('CONTACT.COMPANY_NAME' | translate) + ' *'" [placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"></app-input>
<app-input formControlName="referencePerson" [label]="('CONTACT.REF_PERSON' | translate) + ' *'" [placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"></app-input>
</div>
<div class="form-group">
<label>{{ 'CONTACT.LABEL_MESSAGE' | translate }}</label>
<textarea formControlName="message" class="form-control" rows="4"></textarea>
</div>
<!-- File Upload Section -->
<div class="form-group">
<label>{{ 'CONTACT.UPLOAD_LABEL' | translate }}</label>
<p class="hint">{{ 'CONTACT.UPLOAD_HINT' | translate }}</p>
<div class="drop-zone" (click)="fileInput.click()"
(dragover)="onDragOver($event)" (drop)="onDrop($event)">
<input #fileInput type="file" multiple (change)="onFileSelected($event)" hidden
accept=".jpg,.jpeg,.png,.pdf,.stl,.step,.stp,.3mf,.obj">
<p>{{ 'CONTACT.DROP_FILES' | translate }}</p>
</div>
<div class="file-grid" *ngIf="files().length > 0">
<div class="file-item" *ngFor="let file of files(); let i = index">
<button type="button" class="remove-btn" (click)="removeFile(i)">×</button>
<img *ngIf="file.type === 'image'" [src]="file.url" class="preview-img">
<div *ngIf="file.type !== 'image'" class="file-icon">
<span *ngIf="file.type === 'pdf'">PDF</span>
<span *ngIf="file.type === '3d'">3D</span>
</div>
<div class="file-name" [title]="file.file.name">{{ file.file.name }}</div>
</div>
</div>
</div>
<div class="actions">
<app-button type="submit" [disabled]="form.invalid || sent()">
{{ sent() ? ('CONTACT.MSG_SENT' | translate) : ('CONTACT.SEND' | translate) }}
</app-button>
</div>
</form>
}

View File

@@ -0,0 +1,135 @@
.form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); }
label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); }
.hint { font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: var(--space-2); }
.form-control {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
width: 100%;
background: var(--color-bg-card);
color: var(--color-text);
font-family: inherit;
&:focus { outline: none; border-color: var(--color-brand); }
}
select.form-control {
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 1em;
}
.row {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-bottom: var(--space-4);
@media(min-width: 768px) {
flex-direction: row;
.col { flex: 1; margin-bottom: 0; }
}
}
app-input.col { width: 100%; }
/* User Type Selector Styles */
.user-type-selector {
display: flex;
background-color: var(--color-neutral-100);
border-radius: var(--radius-md);
padding: 4px;
margin-bottom: var(--space-4);
gap: 4px;
width: 100%; /* Full width */
max-width: 400px; /* Limit on desktop */
}
.type-option {
flex: 1; /* Equal width */
text-align: center;
padding: 8px 16px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
transition: all 0.2s ease;
user-select: none;
&:hover { color: var(--color-text); }
&.selected {
background-color: var(--color-brand);
color: #000;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
}
.company-fields {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding-left: var(--space-4);
border-left: 2px solid var(--color-border);
margin-bottom: var(--space-4);
}
/* File Upload Styles */
.drop-zone {
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-6);
text-align: center;
cursor: pointer;
color: var(--color-text-muted);
transition: all 0.2s;
&:hover { border-color: var(--color-brand); color: var(--color-brand); }
}
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: var(--space-3);
margin-top: var(--space-3);
}
.file-item {
position: relative;
background: var(--color-neutral-100);
border-radius: var(--radius-sm);
padding: var(--space-2);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
aspect-ratio: 1;
overflow: hidden;
}
.preview-img {
width: 100%; height: 100%; object-fit: cover; position: absolute; top:0; left:0;
border-radius: var(--radius-sm);
}
.file-icon {
font-weight: 700; color: var(--color-text-muted); font-size: 0.8rem;
}
.file-name {
font-size: 0.65rem; color: var(--color-text); white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; width: 100%; text-align: center; position: absolute; bottom: 2px;
padding: 0 4px; z-index: 2; background: rgba(255,255,255,0.8);
}
.remove-btn {
position: absolute; top: 2px; right: 2px; z-index: 10;
background: rgba(0,0,0,0.5); color: white; border: none; border-radius: 50%;
width: 18px; height: 18px; font-size: 12px; cursor: pointer;
display: flex; align-items: center; justify-content: center; line-height: 1;
&:hover { background: red; }
}
/* Success State styles moved to shared component */

View File

@@ -4,6 +4,7 @@ import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angula
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { QuoteEstimatorService } from '../../../calculator/services/quote-estimator.service';
interface FilePreview {
file: File;
@@ -11,220 +12,14 @@ interface FilePreview {
type: 'image' | 'pdf' | '3d' | 'other';
}
import { SuccessStateComponent } from '../../../../shared/components/success-state/success-state.component';
@Component({
selector: 'app-contact-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppButtonComponent],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<!-- Request Type -->
<div class="form-group">
<label>{{ 'CONTACT.REQ_TYPE_LABEL' | translate }} *</label>
<select formControlName="requestType" class="form-control">
<option *ngFor="let type of requestTypes" [value]="type.value">
{{ type.label | translate }}
</option>
</select>
</div>
<div class="row">
<!-- Phone -->
<app-input formControlName="email" type="email" [label]="'CONTACT.LABEL_EMAIL' | translate" [placeholder]="'CONTACT.PLACEHOLDER_EMAIL' | translate" class="col"></app-input>
<!-- Phone -->
<app-input formControlName="phone" type="tel" [label]="('CONTACT.PHONE' | translate)" [placeholder]="'CONTACT.PLACEHOLDER_PHONE' | translate" class="col"></app-input>
</div>
<!-- User Type Selector (Segmented Control) -->
<div class="user-type-selector">
<div class="type-option" [class.selected]="!isCompany" (click)="setCompanyMode(false)">
{{ 'CONTACT.TYPE_PRIVATE' | translate }}
</div>
<div class="type-option" [class.selected]="isCompany" (click)="setCompanyMode(true)">
{{ 'CONTACT.TYPE_COMPANY' | translate }}
</div>
</div>
<!-- Personal Name (Only if NOT Company) -->
<app-input *ngIf="!isCompany" formControlName="name" [label]="'CONTACT.LABEL_NAME' | translate" [placeholder]="'CONTACT.PLACEHOLDER_NAME' | translate"></app-input>
<!-- Company Fields (Only if Company) -->
<div *ngIf="isCompany" class="company-fields">
<app-input formControlName="companyName" [label]="('CONTACT.COMPANY_NAME' | translate) + ' *'" [placeholder]="'CONTACT.PLACEHOLDER_COMPANY' | translate"></app-input>
<app-input formControlName="referencePerson" [label]="('CONTACT.REF_PERSON' | translate) + ' *'" [placeholder]="'CONTACT.PLACEHOLDER_REF_PERSON' | translate"></app-input>
</div>
<div class="form-group">
<label>{{ 'CONTACT.LABEL_MESSAGE' | translate }}</label>
<textarea formControlName="message" class="form-control" rows="4"></textarea>
</div>
<!-- File Upload Section -->
<div class="form-group">
<label>{{ 'CONTACT.UPLOAD_LABEL' | translate }}</label>
<p class="hint">{{ 'CONTACT.UPLOAD_HINT' | translate }}</p>
<div class="drop-zone" (click)="fileInput.click()"
(dragover)="onDragOver($event)" (drop)="onDrop($event)">
<input #fileInput type="file" multiple (change)="onFileSelected($event)" hidden
accept=".jpg,.jpeg,.png,.pdf,.stl,.step,.stp,.3mf,.obj">
<p>{{ 'CONTACT.DROP_FILES' | translate }}</p>
</div>
<div class="file-grid" *ngIf="files().length > 0">
<div class="file-item" *ngFor="let file of files(); let i = index">
<button type="button" class="remove-btn" (click)="removeFile(i)">×</button>
<img *ngIf="file.type === 'image'" [src]="file.url" class="preview-img">
<div *ngIf="file.type !== 'image'" class="file-icon">
<span *ngIf="file.type === 'pdf'">PDF</span>
<span *ngIf="file.type === '3d'">3D</span>
</div>
<div class="file-name" [title]="file.file.name">{{ file.file.name }}</div>
</div>
</div>
</div>
<div class="actions">
<app-button type="submit" [disabled]="form.invalid || sent()">
{{ sent() ? ('CONTACT.MSG_SENT' | translate) : ('CONTACT.SEND' | translate) }}
</app-button>
</div>
</form>
`,
styles: [`
.form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); }
label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); }
.hint { font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: var(--space-2); }
.form-control {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
width: 100%;
background: var(--color-bg-card);
color: var(--color-text);
font-family: inherit;
&:focus { outline: none; border-color: var(--color-brand); }
}
select.form-control {
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 1em;
}
.row {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-bottom: var(--space-4);
@media(min-width: 768px) {
flex-direction: row;
.col { flex: 1; margin-bottom: 0; }
}
}
app-input.col { width: 100%; }
/* User Type Selector Styles */
.user-type-selector {
display: flex;
background-color: var(--color-neutral-100);
border-radius: var(--radius-md);
padding: 4px;
margin-bottom: var(--space-4);
gap: 4px;
width: 100%; /* Full width */
max-width: 400px; /* Limit on desktop */
}
.type-option {
flex: 1; /* Equal width */
text-align: center;
padding: 8px 16px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
transition: all 0.2s ease;
user-select: none;
&:hover { color: var(--color-text); }
&.selected {
background-color: var(--color-brand);
color: #000;
font-weight: 600;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
}
.company-fields {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding-left: var(--space-4);
border-left: 2px solid var(--color-border);
margin-bottom: var(--space-4);
}
/* File Upload Styles */
.drop-zone {
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-6);
text-align: center;
cursor: pointer;
color: var(--color-text-muted);
transition: all 0.2s;
&:hover { border-color: var(--color-brand); color: var(--color-brand); }
}
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: var(--space-3);
margin-top: var(--space-3);
}
.file-item {
position: relative;
background: var(--color-neutral-100);
border-radius: var(--radius-sm);
padding: var(--space-2);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
aspect-ratio: 1;
overflow: hidden;
}
.preview-img {
width: 100%; height: 100%; object-fit: cover; position: absolute; top:0; left:0;
border-radius: var(--radius-sm);
}
.file-icon {
font-weight: 700; color: var(--color-text-muted); font-size: 0.8rem;
}
.file-name {
font-size: 0.65rem; color: var(--color-text); white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; width: 100%; text-align: center; position: absolute; bottom: 2px;
padding: 0 4px; z-index: 2; background: rgba(255,255,255,0.8);
}
.remove-btn {
position: absolute; top: 2px; right: 2px; z-index: 10;
background: rgba(0,0,0,0.5); color: white; border: none; border-radius: 50%;
width: 18px; height: 18px; font-size: 12px; cursor: pointer;
display: flex; align-items: center; justify-content: center; line-height: 1;
&:hover { background: red; }
}
`]
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppButtonComponent, SuccessStateComponent],
templateUrl: './contact-form.component.html',
styleUrl: './contact-form.component.scss'
})
export class ContactFormComponent {
form: FormGroup;
@@ -242,7 +37,11 @@ export class ContactFormComponent {
{ value: 'question', label: 'CONTACT.REQ_TYPE_QUESTION' }
];
constructor(private fb: FormBuilder, private translate: TranslateService) {
constructor(
private fb: FormBuilder,
private translate: TranslateService,
private estimator: QuoteEstimatorService
) {
this.form = this.fb.group({
requestType: ['custom', Validators.required],
name: ['', Validators.required],
@@ -279,6 +78,27 @@ export class ContactFormComponent {
companyNameControl?.updateValueAndValidity();
refPersonControl?.updateValueAndValidity();
});
// Check for pending consultation data
effect(() => {
// Use timeout or run in constructor to ensure dependency availability?
// Actually best in constructor or ngOnInit. Let's stick to constructor logic but executed immediately.
});
const pending = this.estimator.getPendingConsultation();
if (pending) {
this.form.patchValue({
requestType: 'consult',
message: pending.message
});
// Process files
const filePreviews: FilePreview[] = [];
pending.files.forEach(f => {
filePreviews.push({ file: f, type: this.getFileType(f) });
});
this.files.set(filePreviews);
}
}
setCompanyMode(isCompany: boolean) {
@@ -343,13 +163,14 @@ export class ContactFormComponent {
console.log('Form Submit:', formData);
this.sent.set(true);
setTimeout(() => {
this.sent.set(false);
this.form.reset({ requestType: 'custom', isCompany: false });
this.files.set([]);
}, 3000);
} else {
this.form.markAllAsTouched();
}
}
resetForm() {
this.sent.set(false);
this.form.reset({ requestType: 'custom', isCompany: false });
this.files.set([]);
}
}

View File

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

View File

@@ -0,0 +1,14 @@
.contact-hero {
padding: 3rem 0 2rem;
background: var(--color-bg);
text-align: center;
}
.subtitle {
color: var(--color-text-muted);
max-width: 640px;
margin: var(--space-3) auto 0;
}
.content {
padding: 2rem 0 5rem;
max-width: 800px;
}

View File

@@ -8,35 +8,7 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
selector: 'app-contact-page',
standalone: true,
imports: [CommonModule, TranslateModule, ContactFormComponent, AppCardComponent],
template: `
<section class="contact-hero">
<div class="container">
<h1>{{ 'CONTACT.TITLE' | translate }}</h1>
<p class="subtitle">Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.</p>
</div>
</section>
<div class="container content">
<app-card>
<app-contact-form></app-contact-form>
</app-card>
</div>
`,
styles: [`
.contact-hero {
padding: 3rem 0 2rem;
background: var(--color-bg);
text-align: center;
}
.subtitle {
color: var(--color-text-muted);
max-width: 640px;
margin: var(--space-3) auto 0;
}
.content {
padding: 2rem 0 5rem;
max-width: 800px;
}
`]
templateUrl: './contact-page.component.html',
styleUrl: './contact-page.component.scss'
})
export class ContactPageComponent {}

View File

@@ -7,13 +7,17 @@
Prezzo e tempi in pochi secondi.<br>
Dal file 3D al pezzo finito.
</h1>
<p class="hero-lead">
Il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.
</p>
<p class="hero-subtitle">
Lavoriamo con trasparenza su costi, qualità e tempi. Produciamo prototipi, pezzi personalizzati
e piccole serie con supporto tecnico reale.
Realizziamo pezzi unici e produzioni in serie. Se hai il file, il preventivo è istantaneo.
Se devi ancora crearlo, il nostro team di design lo progetterà per te.
</p>
<div class="hero-actions">
<app-button variant="primary" routerLink="/about">Parla con noi</app-button>
<app-button variant="primary" routerLink="/calculator/basic">Calcola Preventivo</app-button>
<app-button variant="outline" routerLink="/shop">Vai allo shop</app-button>
<app-button variant="text" routerLink="/contact">Parla con noi</app-button>
</div>
</div>
</div>
@@ -22,13 +26,12 @@
<section class="section calculator">
<div class="container calculator-grid">
<div class="calculator-copy">
<h2 class="section-title">Preventivo immediato</h2>
<h2 class="section-title">Preventivo immediato in pochi secondi</h2>
<p class="section-subtitle">
Carica il file 3D e ottieni subito costo e tempo di stampa. Nessuna registrazione.
Nessuna registrazione necessaria. Non salviamo il tuo file fino al momento dell'ordine e la stima è effettuata tramite vero slicing.
</p>
<ul class="calculator-list">
<li>Formati supportati: STL, 3MF, STEP, OBJ</li>
<li>Materiali disponibili: PLA, PETG, TPU</li>
<li>Qualità: bozza, standard, alta definizione</li>
</ul>
</div>
@@ -45,19 +48,9 @@
<li>Scegli materiale e qualità</li>
<li>Ricevi subito costo e tempo</li>
</ul>
<div class="quote-meta">
<div>
<span class="meta-label">Modalità</span>
<span class="meta-value">Rapida / Avanzata</span>
</div>
<div>
<span class="meta-label">Output</span>
<span class="meta-value">Ordina o richiedi consulenza</span>
</div>
</div>
<div class="quote-actions">
<app-button variant="primary" [fullWidth]="true" routerLink="/cal">Apri calcolatore</app-button>
<app-button variant="outline" [fullWidth]="true" routerLink="/about">Parla con noi</app-button>
<app-button variant="primary" [fullWidth]="true" routerLink="/calculator/basic">Apri calcolatore</app-button>
<app-button variant="outline" [fullWidth]="true" routerLink="/contact">Parla con noi</app-button>
</div>
</app-card>
</div>
@@ -74,20 +67,32 @@
</div>
<div class="cap-cards">
<app-card>
<div class="card-image-placeholder">
<!-- <img src="..." alt="..."> -->
</div>
<h3>Prototipazione veloce</h3>
<p class="text-muted">Valida idee e funzioni in pochi giorni con preventivo immediato.</p>
<p class="text-muted">Dal file digitale al modello fisico in 24/48 ore. Verifica ergonomia, incastri e funzionamento prima dello stampo definitivo.</p>
</app-card>
<app-card>
<div class="card-image-placeholder">
<!-- <img src="..." alt="..."> -->
</div>
<h3>Pezzi personalizzati</h3>
<p class="text-muted">Componenti unici o in mini serie per clienti, macchine e prodotti.</p>
<p class="text-muted">Componenti unici impossibili da trovare in commercio. Riproduciamo parti rotte o creiamo adattamenti su misura.</p>
</app-card>
<app-card>
<div class="card-image-placeholder">
<!-- <img src="..." alt="..."> -->
</div>
<h3>Piccole serie</h3>
<p class="text-muted">Produzione controllata fino a 500 pezzi con qualità costante.</p>
<p class="text-muted">Produzione ponte o serie limitate (10-500 pezzi) con qualità ripetibile. Ideale per validazione di mercato.</p>
</app-card>
<app-card>
<div class="card-image-placeholder">
<!-- <img src="..." alt="..."> -->
</div>
<h3>Consulenza e CAD</h3>
<p class="text-muted">Supporto tecnico per progettazione, modifiche e ottimizzazione.</p>
<p class="text-muted">Non hai il file 3D? Ti aiutiamo a progettarlo, ottimizzarlo per la stampa e scegliere il materiale giusto.</p>
</app-card>
</div>
</div>
@@ -108,7 +113,7 @@
</ul>
<div class="shop-actions">
<app-button variant="primary" routerLink="/shop">Scopri i prodotti</app-button>
<app-button variant="outline" routerLink="/about">Richiedi una soluzione</app-button>
<app-button variant="outline" routerLink="/contact">Richiedi una soluzione</app-button>
</div>
</div>
<div class="shop-cards">
@@ -136,25 +141,12 @@
3D fab è un laboratorio tecnico di stampa 3D. Seguiamo progetti dalla consulenza iniziale
alla produzione, con tempi chiari e supporto diretto.
</p>
<p class="text-muted">
Qui puoi inserire descrizioni più dettagliate del team, del laboratorio e dei progetti in corso.
</p>
<app-button variant="outline" routerLink="/about">Contattaci</app-button>
<app-button variant="outline" routerLink="/contact">Contattaci</app-button>
</div>
<div class="about-media">
<div class="media-grid">
<div class="media-tile">
<div class="media-photo"></div>
<p>Foto laboratorio / stampanti</p>
</div>
<div class="media-tile">
<div class="media-photo"></div>
<p>Dettagli qualità e finiture</p>
</div>
<div class="media-tile">
<div class="media-photo"></div>
<p>Team, prototipi o casi studio</p>
</div>
<div class="about-feature-image">
<!-- Foto founders -->
<span class="text-sm">Foto Founders</span>
</div>
</div>
</div>

View File

@@ -15,10 +15,10 @@
position: absolute;
inset: 0;
@include patterns.pattern-grid(var(--color-neutral-900), 40px, 1px);
opacity: 0.12;
opacity: 0.06;
z-index: 0;
pointer-events: none;
mask-image: linear-gradient(to bottom, black 40%, transparent 100%);
mask-image: linear-gradient(to bottom, black 70%, transparent 100%);
}
}
@@ -43,6 +43,7 @@
position: relative;
z-index: 1;
}
.hero-copy { animation: fadeUp 0.8s ease both; }
.hero-panel { animation: fadeUp 0.8s ease 0.15s both; }
@@ -61,10 +62,18 @@
letter-spacing: -0.02em;
margin-bottom: var(--space-4);
}
.hero-lead {
font-size: 1.35rem;
font-weight: 500;
color: var(--color-neutral-900);
margin-bottom: var(--space-3);
max-width: 600px;
}
.hero-subtitle {
font-size: 1.2rem;
font-size: 1.1rem;
color: var(--color-text-muted);
max-width: 560px;
line-height: 1.6;
}
.hero-actions {
display: flex;
@@ -135,6 +144,9 @@
padding: 0.35rem 0.75rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--color-brand-600);
background: var(--color-brand-50);
border-color: var(--color-brand-200);
}
.quote-steps {
list-style: none;
@@ -177,14 +189,10 @@
.capabilities {
position: relative;
border-bottom: 1px solid var(--color-border);
}
.capabilities-bg {
position: absolute;
inset: 0;
@include patterns.pattern-rectilinear(var(--color-neutral-900), 24px, 1px);
opacity: 0.05;
pointer-events: none;
z-index: 0;
display: none;
}
.section { padding: 5.5rem 0; position: relative; }
@@ -194,24 +202,13 @@
.text-muted { color: var(--color-text-muted); }
.calculator {
background: var(--color-neutral-50);
border-top: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
position: relative;
// Honeycomb Pattern
&::before {
content: '';
position: absolute;
inset: 0;
@include patterns.pattern-honeycomb(var(--color-neutral-900), 24px);
opacity: 0.04;
pointer-events: none;
}
border-bottom: 1px solid var(--color-border);
}
.calculator-grid {
display: grid;
gap: var(--space-10);
align-items: center;
align-items: start;
position: relative;
z-index: 1;
}
@@ -226,6 +223,19 @@
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.card-image-placeholder {
width: 100%;
height: 160px;
background: var(--color-neutral-100);
margin: -1.5rem -1.5rem 1.5rem -1.5rem; /* Negative margins to bleed to edge */
width: calc(100% + 3rem);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-neutral-400);
}
.shop {
background: var(--color-neutral-50);
position: relative;
@@ -282,24 +292,21 @@
align-items: center;
}
.about-media {
display: grid;
gap: var(--space-4);
position: relative;
}
.media-grid {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.media-tile {
display: grid;
gap: var(--space-2);
}
.media-photo {
.about-feature-image {
width: 100%;
aspect-ratio: 4 / 3;
height: 100%;
min-height: 320px;
object-fit: cover;
border-radius: var(--radius-lg);
background: var(--color-neutral-100);
border: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
}
.media-tile p {
margin: 0;
@@ -313,6 +320,7 @@
@media (min-width: 960px) {
.hero-grid { grid-template-columns: 1.1fr 0.9fr; }
.calculator-grid { grid-template-columns: 1.1fr 0.9fr; }
.calculator-grid { grid-template-columns: 1.1fr 0.9fr; }
.split { grid-template-columns: 1.1fr 0.9fr; }
.about-grid { grid-template-columns: 1.1fr 0.9fr; }
}

View File

@@ -0,0 +1,12 @@
import { Routes } from '@angular/router';
export const LEGAL_ROUTES: Routes = [
{
path: 'privacy',
loadComponent: () => import('./privacy/privacy.component').then(m => m.PrivacyComponent)
},
{
path: 'terms',
loadComponent: () => import('./terms/terms.component').then(m => m.TermsComponent)
}
];

View File

@@ -0,0 +1,17 @@
<section class="legal-page">
<div class="container narrow">
<h1>{{ 'LEGAL.PRIVACY_TITLE' | translate }}</h1>
<div class="content">
<p class="intro">{{ 'LEGAL.LAST_UPDATE' | translate }}: February 2026</p>
<h2>{{ 'LEGAL.PRIVACY.SECTION_1' | translate }}</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
<h2>{{ 'LEGAL.PRIVACY.SECTION_2' | translate }}</h2>
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
<h2>{{ 'LEGAL.PRIVACY.SECTION_3' | translate }}</h2>
<p>Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra, est eros bibendum elit, nec luctus magna felis sollicitudin mauris.</p>
</div>
</div>
</section>

View File

@@ -0,0 +1,37 @@
.legal-page {
padding: 6rem 0;
min-height: 70vh;
.narrow {
max-width: 800px;
margin: 0 auto;
}
h1 {
font-size: 3rem;
margin-bottom: 3rem;
color: var(--color-text-main);
}
h2 {
font-size: 1.5rem;
margin-top: 2.5rem;
margin-bottom: 1rem;
color: var(--color-text-main);
}
.intro {
color: var(--color-text-muted);
font-size: 0.9rem;
margin-bottom: 2rem;
}
.content {
line-height: 1.8;
color: var(--color-text-main);
p {
margin-bottom: 1.5rem;
}
}
}

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
@Component({
selector: 'app-privacy',
standalone: true,
imports: [TranslateModule],
templateUrl: './privacy.component.html',
styleUrl: './privacy.component.scss'
})
export class PrivacyComponent {}

View File

@@ -0,0 +1,18 @@
<section class="legal-page">
<div class="container narrow">
<h1>{{ 'LEGAL.TERMS_TITLE' | translate }}</h1>
<div class="content">
<p class="intro">{{ 'LEGAL.LAST_UPDATE' | translate }}: February 2026</p>
<h2>{{ 'LEGAL.TERMS.SECTION_1' | translate }}</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
<h2>{{ 'LEGAL.TERMS.SECTION_2' | translate }}</h2>
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
<h2>{{ 'LEGAL.TERMS.SECTION_3' | translate }}</h2>
<p>I prodotti personalizzati e realizzati su misura tramite stampa 3D non sono soggetti al diritto di recesso, a meno di difetti di fabbricazione evidenti o errori rispetto al file fornito.</p>
<p>In caso di problemi, vi preghiamo di contattarci entro 14 giorni dalla ricezione per valutare una sostituzione o un rimborso parziale.</p>
</div>
</div>
</section>

View File

@@ -0,0 +1,37 @@
.legal-page {
padding: 6rem 0;
min-height: 70vh;
.narrow {
max-width: 800px;
margin: 0 auto;
}
h1 {
font-size: 3rem;
margin-bottom: 3rem;
color: var(--color-text-main);
}
h2 {
font-size: 1.5rem;
margin-top: 2.5rem;
margin-bottom: 1rem;
color: var(--color-text-main);
}
.intro {
color: var(--color-text-muted);
font-size: 0.9rem;
margin-bottom: 2rem;
}
.content {
line-height: 1.8;
color: var(--color-text-main);
p {
margin-bottom: 1.5rem;
}
}
}

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
@Component({
selector: 'app-terms',
standalone: true,
imports: [TranslateModule],
templateUrl: './terms.component.html',
styleUrl: './terms.component.scss'
})
export class TermsComponent {}

View File

@@ -0,0 +1,13 @@
<div class="product-card">
<div class="image-placeholder"></div>
<div class="content">
<span class="category">{{ product().category }}</span>
<h3 class="name">
<a [routerLink]="['/shop', product().id]">{{ product().name }}</a>
</h3>
<div class="footer">
<span class="price">{{ product().price | currency:'EUR' }}</span>
<a [routerLink]="['/shop', product().id]" class="view-btn">Dettagli</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,18 @@
.product-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
transition: box-shadow 0.2s;
&:hover { box-shadow: var(--shadow-md); }
}
.image-placeholder {
height: 200px;
background-color: var(--color-neutral-200);
}
.content { padding: var(--space-4); }
.category { font-size: 0.75rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.name { font-size: 1.125rem; margin: var(--space-2) 0; a { color: var(--color-text); text-decoration: none; &:hover { color: var(--color-brand); } } }
.footer { display: flex; justify-content: space-between; align-items: center; margin-top: var(--space-4); }
.price { font-weight: 700; color: var(--color-brand); }
.view-btn { font-size: 0.875rem; font-weight: 500; }

View File

@@ -7,41 +7,8 @@ import { Product } from '../../services/shop.service';
selector: 'app-product-card',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<div class="product-card">
<div class="image-placeholder"></div>
<div class="content">
<span class="category">{{ product().category }}</span>
<h3 class="name">
<a [routerLink]="['/shop', product().id]">{{ product().name }}</a>
</h3>
<div class="footer">
<span class="price">{{ product().price | currency:'EUR' }}</span>
<a [routerLink]="['/shop', product().id]" class="view-btn">Dettagli</a>
</div>
</div>
</div>
`,
styles: [`
.product-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
transition: box-shadow 0.2s;
&:hover { box-shadow: var(--shadow-md); }
}
.image-placeholder {
height: 200px;
background-color: var(--color-neutral-200);
}
.content { padding: var(--space-4); }
.category { font-size: 0.75rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
.name { font-size: 1.125rem; margin: var(--space-2) 0; a { color: var(--color-text); text-decoration: none; &:hover { color: var(--color-brand); } } }
.footer { display: flex; justify-content: space-between; align-items: center; margin-top: var(--space-4); }
.price { font-weight: 700; color: var(--color-brand); }
.view-btn { font-size: 0.875rem; font-weight: 500; }
`]
templateUrl: './product-card.component.html',
styleUrl: './product-card.component.scss'
})
export class ProductCardComponent {
product = input.required<Product>();

View File

@@ -0,0 +1,25 @@
<div class="container wrapper">
<a routerLink="/shop" class="back-link">← {{ 'SHOP.BACK' | translate }}</a>
@if (product(); as p) {
<div class="detail-grid">
<div class="image-box"></div>
<div class="info">
<span class="category">{{ p.category }}</span>
<h1>{{ p.name }}</h1>
<p class="price">{{ p.price | currency:'EUR' }}</p>
<p class="desc">{{ p.description }}</p>
<div class="actions">
<app-button variant="primary" (click)="addToCart()">
{{ 'SHOP.ADD_CART' | translate }}
</app-button>
</div>
</div>
</div>
} @else {
<p>Prodotto non trovato.</p>
}
</div>

View File

@@ -0,0 +1,20 @@
.wrapper { padding-top: var(--space-8); }
.back-link { display: inline-block; margin-bottom: var(--space-6); color: var(--color-text-muted); }
.detail-grid {
display: grid;
gap: var(--space-8);
@media(min-width: 768px) {
grid-template-columns: 1fr 1fr;
}
}
.image-box {
background-color: var(--color-neutral-200);
border-radius: var(--radius-lg);
aspect-ratio: 1;
}
.category { color: var(--color-brand); font-weight: 600; text-transform: uppercase; font-size: 0.875rem; }
.price { font-size: 1.5rem; font-weight: 700; color: var(--color-text); margin: var(--space-4) 0; }
.desc { color: var(--color-text-muted); line-height: 1.6; margin-bottom: var(--space-8); }

View File

@@ -9,55 +9,8 @@ import { AppButtonComponent } from '../../shared/components/app-button/app-butto
selector: 'app-product-detail',
standalone: true,
imports: [CommonModule, RouterLink, TranslateModule, AppButtonComponent],
template: `
<div class="container wrapper">
<a routerLink="/shop" class="back-link">← {{ 'SHOP.BACK' | translate }}</a>
@if (product(); as p) {
<div class="detail-grid">
<div class="image-box"></div>
<div class="info">
<span class="category">{{ p.category }}</span>
<h1>{{ p.name }}</h1>
<p class="price">{{ p.price | currency:'EUR' }}</p>
<p class="desc">{{ p.description }}</p>
<div class="actions">
<app-button variant="primary" (click)="addToCart()">
{{ 'SHOP.ADD_CART' | translate }}
</app-button>
</div>
</div>
</div>
} @else {
<p>Prodotto non trovato.</p>
}
</div>
`,
styles: [`
.wrapper { padding-top: var(--space-8); }
.back-link { display: inline-block; margin-bottom: var(--space-6); color: var(--color-text-muted); }
.detail-grid {
display: grid;
gap: var(--space-8);
@media(min-width: 768px) {
grid-template-columns: 1fr 1fr;
}
}
.image-box {
background-color: var(--color-neutral-200);
border-radius: var(--radius-lg);
aspect-ratio: 1;
}
.category { color: var(--color-brand); font-weight: 600; text-transform: uppercase; font-size: 0.875rem; }
.price { font-size: 1.5rem; font-weight: 700; color: var(--color-text); margin: var(--space-4) 0; }
.desc { color: var(--color-text-muted); line-height: 1.6; margin-bottom: var(--space-8); }
`]
templateUrl: './product-detail.component.html',
styleUrl: './product-detail.component.scss'
})
export class ProductDetailComponent {
// Input binding from router

View File

@@ -0,0 +1,12 @@
<div class="container hero">
<h1>{{ 'SHOP.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'SHOP.SUBTITLE' | translate }}</p>
</div>
<div class="container">
<div class="grid">
@for (product of products(); track product.id) {
<app-product-card [product]="product"></app-product-card>
}
</div>
</div>

View File

@@ -0,0 +1,7 @@
.hero { padding: var(--space-8) 0; text-align: center; }
.subtitle { color: var(--color-text-muted); margin-bottom: var(--space-8); }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-6);
}

View File

@@ -8,29 +8,8 @@ import { ProductCardComponent } from './components/product-card/product-card.com
selector: 'app-shop-page',
standalone: true,
imports: [CommonModule, TranslateModule, ProductCardComponent],
template: `
<div class="container hero">
<h1>{{ 'SHOP.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'SHOP.SUBTITLE' | translate }}</p>
</div>
<div class="container">
<div class="grid">
@for (product of products(); track product.id) {
<app-product-card [product]="product"></app-product-card>
}
</div>
</div>
`,
styles: [`
.hero { padding: var(--space-8) 0; text-align: center; }
.subtitle { color: var(--color-text-muted); margin-bottom: var(--space-8); }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-6);
}
`]
templateUrl: './shop-page.component.html',
styleUrl: './shop-page.component.scss'
})
export class ShopPageComponent {
products = signal<Product[]>([]);

View File

@@ -0,0 +1,9 @@
<div class="alert" [ngClass]="type()">
<div class="icon">
@if(type() === 'info') { }
@if(type() === 'warning') { ⚠️ }
@if(type() === 'error') { ❌ }
@if(type() === 'success') { ✅ }
</div>
<div class="content"><ng-content></ng-content></div>
</div>

Some files were not shown because too many files have changed in this diff Show More