Compare commits
4 Commits
150563a8f5
...
a219825b28
| Author | SHA1 | Date | |
|---|---|---|---|
| a219825b28 | |||
| 3b4ef37e58 | |||
| eb4ad8b637 | |||
| f0e0f57e7c |
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<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 = "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")
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -52,8 +52,8 @@ public class QuoteCalculator {
|
||||
);
|
||||
|
||||
List<String> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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");
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
<span class="brand">3D fab</span>
|
||||
<p class="copyright">© 2026 3D fab.</p>
|
||||
</div>
|
||||
|
||||
|
||||
<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">
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
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>
|
||||
@@ -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">
|
||||
</app-input>
|
||||
</div>
|
||||
@@ -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">
|
||||
</app-input>
|
||||
</div>
|
||||
@@ -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">
|
||||
</app-input>
|
||||
</div>
|
||||
@@ -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">
|
||||
</app-input>
|
||||
|
||||
@@ -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">
|
||||
</app-input>
|
||||
</div>
|
||||
@@ -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">
|
||||
</app-input>
|
||||
</div>
|
||||
|
||||
12
frontend/src/app/features/legal/legal.routes.ts
Normal file
12
frontend/src/app/features/legal/legal.routes.ts
Normal 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)
|
||||
}
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
frontend/src/app/features/legal/privacy/privacy.component.ts
Normal file
11
frontend/src/app/features/legal/privacy/privacy.component.ts
Normal 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 {}
|
||||
18
frontend/src/app/features/legal/terms/terms.component.html
Normal file
18
frontend/src/app/features/legal/terms/terms.component.html
Normal 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>
|
||||
37
frontend/src/app/features/legal/terms/terms.component.scss
Normal file
37
frontend/src/app/features/legal/terms/terms.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
frontend/src/app/features/legal/terms/terms.component.ts
Normal file
11
frontend/src/app/features/legal/terms/terms.component.ts
Normal 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 {}
|
||||
@@ -1,5 +1,10 @@
|
||||
<div class="form-group">
|
||||
@if (label()) { <label [for]="id()">{{ label() }}</label> }
|
||||
@if (label()) {
|
||||
<label [for]="id()">
|
||||
{{ label() }}
|
||||
@if (required()) { <span class="required-mark">*</span> }
|
||||
</label>
|
||||
}
|
||||
<input
|
||||
[id]="id()"
|
||||
[type]="type()"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.form-group { display: flex; flex-direction: column; margin-bottom: var(--space-4); }
|
||||
label { font-size: 0.875rem; font-weight: 500; margin-bottom: var(--space-2); color: var(--color-text); }
|
||||
.required-mark { color: var(--color-danger-500); margin-left: 2px; }
|
||||
.form-control {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
@@ -22,6 +22,7 @@ export class AppInputComponent implements ControlValueAccessor {
|
||||
type = input<string>('text');
|
||||
placeholder = input<string>('');
|
||||
error = input<string | null>(null);
|
||||
required = input<boolean>(false);
|
||||
|
||||
value: string = '';
|
||||
disabled = false;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user