diff --git a/backend/Dockerfile b/backend/Dockerfile index 7d08ed4..32b5ac1 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/build.gradle b/backend/build.gradle index b664e88..35b1154 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -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"] +} diff --git a/backend/src/main/java/com/printcalculator/config/CorsConfig.java b/backend/src/main/java/com/printcalculator/config/CorsConfig.java new file mode 100644 index 0000000..39eaea5 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/config/CorsConfig.java @@ -0,0 +1,20 @@ +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 +@Profile("local") +public class CorsConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins("*") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(false); + } +} diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteController.java b/backend/src/main/java/com/printcalculator/controller/QuoteController.java index 301f01a..b85a6a6 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteController.java @@ -13,16 +13,15 @@ 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; - // 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_MACHINE = "bambu_a1"; + private static final String DEFAULT_FILAMENT = "pla_basic"; + private static final String DEFAULT_PROCESS = "standard"; public QuoteController(SlicerService slicerService, QuoteCalculator quoteCalculator) { this.slicerService = slicerService; @@ -32,12 +31,24 @@ public class QuoteController { @PostMapping("/api/quote") public ResponseEntity 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 = "machine", required = false, defaultValue = DEFAULT_MACHINE) String machine, + @RequestParam(value = "filament", required = false, defaultValue = DEFAULT_FILAMENT) String filament, + @RequestParam(value = "process", required = false) String process, + @RequestParam(value = "quality", required = false) String quality ) throws IOException { - return processRequest(file, machine, filament, process); + // Frontend sends 'quality', backend expects 'process'. + // If process is missing, try quality. If both missing, use default. + String actualProcess = process; + if (actualProcess == null || actualProcess.isEmpty()) { + if (quality != null && !quality.isEmpty()) { + actualProcess = quality; + } else { + actualProcess = DEFAULT_PROCESS; + } + } + + return processRequest(file, machine, filament, actualProcess); } @PostMapping("/calculate/stl") diff --git a/backend/src/main/java/com/printcalculator/service/GCodeParser.java b/backend/src/main/java/com/printcalculator/service/GCodeParser.java index fef2500..f8a1691 100644 --- a/backend/src/main/java/com/printcalculator/service/GCodeParser.java +++ b/backend/src/main/java/com/printcalculator/service/GCodeParser.java @@ -13,9 +13,13 @@ 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 TIME_PATTERN = Pattern.compile(";\\s*estimated printing time\\s*=\\s*(.*)"); + 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,9 +29,9 @@ public class GCodeParser { try (BufferedReader reader = new BufferedReader(new FileReader(gcodeFile))) { String line; - // Scan first 500 lines for efficiency + // Scan first 5000 lines for efficiency (metadata might be further down) int count = 0; - while ((line = reader.readLine()) != null && count < 500) { + while ((line = reader.readLine()) != null && count < 5000) { line = line.trim(); if (!line.startsWith(";")) { count++; diff --git a/backend/src/main/java/com/printcalculator/service/ProfileManager.java b/backend/src/main/java/com/printcalculator/service/ProfileManager.java index 748f5ab..c6bd78a 100644 --- a/backend/src/main/java/com/printcalculator/service/ProfileManager.java +++ b/backend/src/main/java/com/printcalculator/service/ProfileManager.java @@ -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 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 stream = Files.walk(Paths.get(profilesRoot))) { Optional found = stream diff --git a/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java b/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java index b7d8f2e..ada3fe5 100644 --- a/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java +++ b/backend/src/main/java/com/printcalculator/service/QuoteCalculator.java @@ -52,8 +52,8 @@ public class QuoteCalculator { ); List notes = new ArrayList<>(); - notes.add("Generated via Dynamic Slicer (Java Backend)"); + // notes.add("Generated via Dynamic Slicer (Java Backend)"); - return new QuoteResult(totalPrice, "EUR", stats, breakdown, notes); + return new QuoteResult(totalPrice, "CHF", stats, breakdown, notes); } } diff --git a/backend/src/main/java/com/printcalculator/service/SlicerService.java b/backend/src/main/java/com/printcalculator/service/SlicerService.java index 65de876..1594d32 100644 --- a/backend/src/main/java/com/printcalculator/service/SlicerService.java +++ b/backend/src/main/java/com/printcalculator/service/SlicerService.java @@ -55,12 +55,16 @@ public class SlicerService { // 3. Build Command // --load-settings "machine.json;process.json" --load-filaments "filament.json" - String settingsArg = mFile.getAbsolutePath() + ";" + pFile.getAbsolutePath(); - List 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"); diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index f9efc6e..49c9036 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,6 +1,14 @@ 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} diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 14cdd1a..94bdfb2 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -15,7 +15,7 @@ services: - MARKUP_PERCENT=${MARKUP_PERCENT} - TEMP_DIR=/app/temp - PROFILES_DIR=/app/profiles - restart: unless-stopped + restart: always volumes: - backend_profiles_${ENV}:/app/profiles @@ -26,7 +26,7 @@ services: - "${FRONTEND_PORT}:8008" depends_on: - backend - restart: unless-stopped + restart: always volumes: backend_profiles_prod: diff --git a/docker-compose.yml b/docker-compose.yml index b62f814..7009b84 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,4 +25,21 @@ services: - "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: diff --git a/frontend/angular.json b/frontend/angular.json index 03d92e6..bc7951c 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -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" diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 19d0cdf..9d013b7 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -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) } ] } diff --git a/frontend/src/app/core/layout/footer.component.html b/frontend/src/app/core/layout/footer.component.html index 64f9312..d828dcd 100644 --- a/frontend/src/app/core/layout/footer.component.html +++ b/frontend/src/app/core/layout/footer.component.html @@ -4,11 +4,11 @@ 3D fab - + @@ -19,6 +20,7 @@ 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"> @@ -32,6 +34,7 @@ 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"> @@ -41,6 +44,7 @@ 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"> @@ -51,6 +55,7 @@ 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"> @@ -61,6 +66,7 @@ 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"> @@ -69,6 +75,7 @@ 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"> diff --git a/frontend/src/app/features/legal/legal.routes.ts b/frontend/src/app/features/legal/legal.routes.ts new file mode 100644 index 0000000..0f4d4fb --- /dev/null +++ b/frontend/src/app/features/legal/legal.routes.ts @@ -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) + } +]; diff --git a/frontend/src/app/features/legal/privacy/privacy.component.html b/frontend/src/app/features/legal/privacy/privacy.component.html new file mode 100644 index 0000000..1fb7a43 --- /dev/null +++ b/frontend/src/app/features/legal/privacy/privacy.component.html @@ -0,0 +1,17 @@ + diff --git a/frontend/src/app/features/legal/privacy/privacy.component.scss b/frontend/src/app/features/legal/privacy/privacy.component.scss new file mode 100644 index 0000000..22a2780 --- /dev/null +++ b/frontend/src/app/features/legal/privacy/privacy.component.scss @@ -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; + } + } +} diff --git a/frontend/src/app/features/legal/privacy/privacy.component.ts b/frontend/src/app/features/legal/privacy/privacy.component.ts new file mode 100644 index 0000000..eca5688 --- /dev/null +++ b/frontend/src/app/features/legal/privacy/privacy.component.ts @@ -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 {} diff --git a/frontend/src/app/features/legal/terms/terms.component.html b/frontend/src/app/features/legal/terms/terms.component.html new file mode 100644 index 0000000..17025a7 --- /dev/null +++ b/frontend/src/app/features/legal/terms/terms.component.html @@ -0,0 +1,18 @@ + diff --git a/frontend/src/app/features/legal/terms/terms.component.scss b/frontend/src/app/features/legal/terms/terms.component.scss new file mode 100644 index 0000000..22a2780 --- /dev/null +++ b/frontend/src/app/features/legal/terms/terms.component.scss @@ -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; + } + } +} diff --git a/frontend/src/app/features/legal/terms/terms.component.ts b/frontend/src/app/features/legal/terms/terms.component.ts new file mode 100644 index 0000000..3dc5955 --- /dev/null +++ b/frontend/src/app/features/legal/terms/terms.component.ts @@ -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 {} diff --git a/frontend/src/app/shared/components/app-input/app-input.component.html b/frontend/src/app/shared/components/app-input/app-input.component.html index 7e30cf2..6ec048e 100644 --- a/frontend/src/app/shared/components/app-input/app-input.component.html +++ b/frontend/src/app/shared/components/app-input/app-input.component.html @@ -1,5 +1,10 @@
- @if (label()) { } + @if (label()) { + + } ('text'); placeholder = input(''); error = input(null); + required = input(false); value: string = ''; disabled = false; diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index fd65237..e5d3916 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -103,6 +103,21 @@ "ADDRESS_BIENNE": "Bienne Office, Switzerland", "CONTACT_US": "Contact Us" }, + "LEGAL": { + "PRIVACY_TITLE": "Privacy Policy", + "TERMS_TITLE": "Terms and Conditions", + "LAST_UPDATE": "Last update", + "PRIVACY": { + "SECTION_1": "1. Data Collection", + "SECTION_2": "2. Purpose of Processing", + "SECTION_3": "3. Cookies and Tracking" + }, + "TERMS": { + "SECTION_1": "1. Terms of Use", + "SECTION_2": "2. Orders and Payments", + "SECTION_3": "3. Refunds and Returns" + } + }, "CONTACT": { "TITLE": "Contact Us", "SEND": "Send Message", diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index 0f1221b..c9a7be4 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -82,6 +82,21 @@ "ADDRESS_BIENNE": "Sede Bienne, Svizzera", "CONTACT_US": "Contattaci" }, + "LEGAL": { + "PRIVACY_TITLE": "Privacy Policy", + "TERMS_TITLE": "Termini e Condizioni", + "LAST_UPDATE": "Ultimo aggiornamento", + "PRIVACY": { + "SECTION_1": "1. Raccolta dei Dati", + "SECTION_2": "2. Finalità del Trattamento", + "SECTION_3": "3. Cookie e Tracciamento" + }, + "TERMS": { + "SECTION_1": "1. Condizioni d'Uso", + "SECTION_2": "2. Ordini e Pagamenti", + "SECTION_3": "3. Rimborsi e Resi" + } + }, "CONTACT": { "TITLE": "Contattaci", "SEND": "Invia Messaggio",