produzione 1 #9

Merged
JoeKung merged 135 commits from dev into main 2026-03-03 09:58:04 +01:00
11 changed files with 183 additions and 48 deletions
Showing only changes of commit e82862821e - Show all commits

View File

@@ -4,10 +4,12 @@ import com.printcalculator.entity.CustomQuoteRequest;
import com.printcalculator.entity.CustomQuoteRequestAttachment;
import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
import com.printcalculator.repository.CustomQuoteRequestRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@@ -15,8 +17,8 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@RestController
@@ -29,6 +31,23 @@ public class CustomQuoteRequestController {
// TODO: Inject Storage Service
private static final String STORAGE_ROOT = "storage_requests";
private static final Set<String> FORBIDDEN_COMPRESSED_EXTENSIONS = Set.of(
"zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "tbz2", "xz", "txz", "zst"
);
private static final Set<String> FORBIDDEN_COMPRESSED_MIME_TYPES = Set.of(
"application/zip",
"application/x-zip-compressed",
"application/x-rar-compressed",
"application/vnd.rar",
"application/x-7z-compressed",
"application/gzip",
"application/x-gzip",
"application/x-tar",
"application/x-bzip2",
"application/x-xz",
"application/zstd",
"application/x-zstd"
);
public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
CustomQuoteRequestAttachmentRepository attachmentRepo,
@@ -71,6 +90,13 @@ public class CustomQuoteRequestController {
for (MultipartFile file : files) {
if (file.isEmpty()) continue;
if (isCompressedFile(file)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Compressed files are not allowed."
);
}
// Scan for virus
clamAVService.scan(file.getInputStream());
@@ -120,8 +146,17 @@ public class CustomQuoteRequestController {
if (filename == null) return "dat";
int i = filename.lastIndexOf('.');
if (i > 0) {
return filename.substring(i + 1);
return filename.substring(i + 1).toLowerCase();
}
return "dat";
}
private boolean isCompressedFile(MultipartFile file) {
String ext = getExtension(file.getOriginalFilename());
if (FORBIDDEN_COMPRESSED_EXTENSIONS.contains(ext)) {
return true;
}
String mime = file.getContentType();
return mime != null && FORBIDDEN_COMPRESSED_MIME_TYPES.contains(mime.toLowerCase());
}
}

View File

@@ -1,5 +1,5 @@
import { ApplicationConfig, provideZoneChangeDetection, importProvidersFrom } from '@angular/core';
import { provideRouter, withComponentInputBinding, withViewTransitions } from '@angular/router';
import { provideRouter, withComponentInputBinding, withInMemoryScrolling, withViewTransitions } from '@angular/router';
import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
@@ -8,7 +8,14 @@ import { provideTranslateHttpLoader, TranslateHttpLoader } from '@ngx-translate/
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withComponentInputBinding(), withViewTransitions()),
provideRouter(
routes,
withComponentInputBinding(),
withViewTransitions(),
withInMemoryScrolling({
scrollPositionRestoration: 'top'
})
),
provideHttpClient(),
provideTranslateHttpLoader({
prefix: './assets/i18n/',

View File

@@ -25,15 +25,15 @@
<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>
<span class="member-name">Joe Küng</span>
<span class="member-role">Studente Ingegneria Informatica</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>
<span class="member-name">Matteo Caletti</span>
<span class="member-role">Studente Ingegneria Elettronica</span>
</div>
</div>
</div>

View File

@@ -51,7 +51,7 @@
<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">
[accept]="acceptedFormats">
<p>{{ 'CONTACT.DROP_FILES' | translate }}</p>
</div>
@@ -59,9 +59,12 @@
<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">
<video *ngIf="file.type === 'video'" [src]="file.url" class="preview-video" muted playsinline preload="metadata"></video>
<div *ngIf="file.type !== 'image' && file.type !== 'video'" class="file-icon">
<span *ngIf="file.type === 'pdf'">{{ 'CONTACT.FILE_TYPE_PDF' | translate }}</span>
<span *ngIf="file.type === '3d'">{{ 'CONTACT.FILE_TYPE_3D' | translate }}</span>
<span *ngIf="file.type === 'document'">{{ 'CONTACT.FILE_TYPE_DOC' | translate }}</span>
<span *ngIf="file.type === 'other'">{{ 'CONTACT.FILE_TYPE_FILE' | translate }}</span>
</div>
<div class="file-name" [title]="file.file.name">{{ file.file.name }}</div>
</div>

View File

@@ -114,6 +114,16 @@ app-input.col { width: 100%; }
border-radius: var(--radius-sm);
}
.preview-video {
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;
}

View File

@@ -1,4 +1,4 @@
import { Component, signal, effect, inject } from '@angular/core';
import { Component, signal, effect, inject, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
@@ -10,7 +10,7 @@ import { QuoteRequestService } from '../../../../core/services/quote-request.ser
interface FilePreview {
file: File;
url?: string;
type: 'image' | 'pdf' | '3d' | 'other';
type: 'image' | 'video' | 'pdf' | '3d' | 'document' | 'other';
}
import { SuccessStateComponent } from '../../../../shared/components/success-state/success-state.component';
@@ -22,10 +22,11 @@ import { SuccessStateComponent } from '../../../../shared/components/success-sta
templateUrl: './contact-form.component.html',
styleUrl: './contact-form.component.scss'
})
export class ContactFormComponent {
export class ContactFormComponent implements OnDestroy {
form: FormGroup;
sent = signal(false);
files = signal<FilePreview[]>([]);
readonly acceptedFormats = '.jpg,.jpeg,.png,.webp,.gif,.bmp,.svg,.heic,.heif,.pdf,.stl,.step,.stp,.3mf,.obj,.iges,.igs,.dwg,.dxf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.rtf,.csv,.mp4,.mov,.avi,.mkv,.webm,.m4v,.wmv';
get isCompany(): boolean {
return this.form.get('isCompany')?.value;
@@ -96,14 +97,22 @@ export class ContactFormComponent {
});
// Process files
const filePreviews: FilePreview[] = [];
pending.files.forEach(f => {
filePreviews.push({ file: f, type: this.getFileType(f) });
const filePreviews: FilePreview[] = pending.files.map(f => {
const type = this.getFileType(f);
return {
file: f,
type,
url: this.shouldCreatePreview(type) ? URL.createObjectURL(f) : undefined
};
});
this.files.set(filePreviews);
}
}
ngOnDestroy(): void {
this.revokeAllPreviewUrls();
}
setCompanyMode(isCompany: boolean) {
this.form.patchValue({ isCompany });
}
@@ -124,36 +133,52 @@ export class ContactFormComponent {
handleFiles(newFiles: File[]) {
const currentFiles = this.files();
if (currentFiles.length + newFiles.length > 15) {
const blockedCompressed = newFiles.filter(file => this.isCompressedFile(file));
if (blockedCompressed.length > 0) {
alert(this.translate.instant('CONTACT.ERR_COMPRESSED_FILES'));
}
const allowedFiles = newFiles.filter(file => !this.isCompressedFile(file));
if (allowedFiles.length === 0) return;
if (currentFiles.length + allowedFiles.length > 15) {
alert(this.translate.instant('CONTACT.ERR_MAX_FILES'));
return;
}
newFiles.forEach(file => {
allowedFiles.forEach(file => {
const type = this.getFileType(file);
const preview: FilePreview = { file, type };
if (type === 'image') {
const reader = new FileReader();
reader.onload = (e) => {
preview.url = e.target?.result as string;
this.files.update(files => [...files]);
const preview: FilePreview = {
file,
type,
url: this.shouldCreatePreview(type) ? URL.createObjectURL(file) : undefined
};
reader.readAsDataURL(file);
}
this.files.update(files => [...files, preview]);
});
}
removeFile(index: number) {
this.files.update(files => files.filter((_, i) => i !== index));
this.files.update(files => {
const fileToRemove = files[index];
if (fileToRemove) this.revokePreviewUrl(fileToRemove);
return files.filter((_, i) => i !== index);
});
}
getFileType(file: File): 'image' | 'pdf' | '3d' | 'other' {
if (file.type.startsWith('image/')) return 'image';
if (file.type === 'application/pdf') return 'pdf';
const ext = file.name.split('.').pop()?.toLowerCase();
if (['stl', 'step', 'stp', '3mf', 'obj'].includes(ext || '')) return '3d';
getFileType(file: File): 'image' | 'video' | 'pdf' | '3d' | 'document' | 'other' {
const ext = this.getExtension(file.name);
if (file.type.startsWith('image/') || ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'svg', 'heic', 'heif'].includes(ext)) {
return 'image';
}
if (file.type.startsWith('video/') || ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v', 'wmv'].includes(ext)) {
return 'video';
}
if (file.type === 'application/pdf' || ext === 'pdf') return 'pdf';
if (['stl', 'step', 'stp', '3mf', 'obj', 'iges', 'igs', 'dwg', 'dxf'].includes(ext)) return '3d';
if ([
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf', 'csv',
].includes(ext)) return 'document';
return 'other';
}
@@ -195,6 +220,48 @@ export class ContactFormComponent {
resetForm() {
this.sent.set(false);
this.form.reset({ requestType: 'custom', isCompany: false });
this.revokeAllPreviewUrls();
this.files.set([]);
}
private getExtension(fileName: string): string {
const index = fileName.lastIndexOf('.');
return index > -1 ? fileName.substring(index + 1).toLowerCase() : '';
}
private shouldCreatePreview(type: FilePreview['type']): boolean {
return type === 'image' || type === 'video';
}
private isCompressedFile(file: File): boolean {
const ext = this.getExtension(file.name);
const compressedExtensions = [
'zip', 'rar', '7z', 'tar', 'gz', 'tgz', 'bz2', 'tbz2', 'xz', 'txz', 'zst'
];
const compressedMimeTypes = [
'application/zip',
'application/x-zip-compressed',
'application/x-rar-compressed',
'application/vnd.rar',
'application/x-7z-compressed',
'application/gzip',
'application/x-gzip',
'application/x-tar',
'application/x-bzip2',
'application/x-xz',
'application/zstd',
'application/x-zstd'
];
return compressedExtensions.includes(ext) || compressedMimeTypes.includes((file.type || '').toLowerCase());
}
private revokePreviewUrl(file: FilePreview): void {
if (file.url?.startsWith('blob:')) {
URL.revokeObjectURL(file.url);
}
}
private revokeAllPreviewUrls(): void {
this.files().forEach(file => this.revokePreviewUrl(file));
}
}

View File

@@ -231,6 +231,8 @@
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-top-left-radius: var(--radius-lg);
border-top-right-radius: var(--radius-lg);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
@@ -417,8 +419,9 @@
.hero-actions { flex-direction: column; align-items: stretch; }
.quote-meta { grid-template-columns: 1fr; }
.shop-gallery {
width: min(100%, 320px);
justify-self: start;
width: 100%;
max-width: none;
justify-self: stretch;
}
.shop-gallery-item {
aspect-ratio: 16 / 11;

View File

@@ -142,7 +142,7 @@
"COMPANY_NAME": "Company Name",
"REF_PERSON": "Reference Person",
"UPLOAD_LABEL": "Attachments",
"UPLOAD_HINT": "Max 15 files. Supported: Images, PDF, STL, STEP, 3MF",
"UPLOAD_HINT": "Max 15 files. Supported: images, videos, PDF, Office documents, STL/STEP/3MF/OBJ/IGES, DWG/DXF. Compressed files are not allowed.",
"DROP_FILES": "Drop files here or click to upload",
"PLACEHOLDER_NAME": "Your Name",
"PLACEHOLDER_EMAIL": "your@email.com",
@@ -154,9 +154,15 @@
"LABEL_NAME": "Name *",
"MSG_SENT": "Sent!",
"ERR_MAX_FILES": "Max 15 files limit reached.",
"ERR_COMPRESSED_FILES": "Compressed files are not allowed (ZIP/RAR/7z/TAR/GZ).",
"SUCCESS_TITLE": "Message Sent Successfully",
"SUCCESS_DESC": "Thank you for contacting us. We have received your message and will send you a recap email shortly.",
"SEND_ANOTHER": "Send Another Message"
"SEND_ANOTHER": "Send Another Message",
"FILE_TYPE_PDF": "PDF",
"FILE_TYPE_3D": "3D",
"FILE_TYPE_VIDEO": "Video",
"FILE_TYPE_DOC": "DOC",
"FILE_TYPE_FILE": "FILE"
},
"CHECKOUT": {
"TITLE": "Checkout",

View File

@@ -36,11 +36,11 @@
"CAP_2_TITLE": "Pezzi personalizzati",
"CAP_2_TEXT": "Componenti unici impossibili da trovare in commercio. Riproduciamo parti rotte o creiamo adattamenti su misura.",
"CAP_3_TITLE": "Piccole serie",
"CAP_3_TEXT": "Produzione ponte o serie limitate (10-500 pezzi) con qualità ripetibile. Ideale per validazione di mercato.",
"CAP_3_TEXT": "Produzione ponte o serie limitate (10-500 pezzi) con qualità ripetibile.",
"CAP_4_TITLE": "Consulenza e CAD",
"CAP_4_TEXT": "Non hai il file 3D? Ti aiutiamo a progettarlo, ottimizzarlo per la stampa e scegliere il materiale giusto.",
"SEC_SHOP_TITLE": "Shop di soluzioni tecniche pronte",
"SEC_SHOP_TEXT": "Prodotti selezionati, testati in laboratorio e pronti all'uso. Risolvono problemi reali con funzionalità concrete.",
"CAP_4_TEXT": "Non hai il file 3D? Ti aiutiamo a progettarlo, ottimizzarlo per la stampa e scegliere il materiale giusto insieme.",
"SEC_SHOP_TITLE": "Shop",
"SEC_SHOP_TEXT": "Prodotti selezionati, e pronti all'uso.",
"SEC_SHOP_LIST_1": "Accessori funzionali per officine e laboratori",
"SEC_SHOP_LIST_2": "Ricambi e componenti difficili da reperire",
"SEC_SHOP_LIST_3": "Supporti e organizzatori per migliorare i flussi di lavoro",
@@ -206,7 +206,7 @@
"COMPANY_NAME": "Ragione Sociale",
"REF_PERSON": "Persona di Riferimento",
"UPLOAD_LABEL": "Allegati",
"UPLOAD_HINT": "Max 15 file. Supportati: Immagini, PDF, STL, STEP, 3MF",
"UPLOAD_HINT": "Max 15 file. Supportati: immagini, video, PDF, documenti Office, STL/STEP/3MF/OBJ/IGES, DWG/DXF. File compressi non consentiti.",
"DROP_FILES": "Trascina qui i file o clicca per caricare",
"PLACEHOLDER_NAME": "Il tuo nome",
"PLACEHOLDER_EMAIL": "tuo@email.com",
@@ -218,12 +218,16 @@
"LABEL_NAME": "Nome *",
"MSG_SENT": "Inviato!",
"ERR_MAX_FILES": "Limite massimo di 15 file raggiunto.",
"ERR_COMPRESSED_FILES": "I file compressi non sono consentiti (ZIP/RAR/7z/TAR/GZ).",
"SUCCESS_TITLE": "Messaggio Inviato con Successo",
"SUCCESS_DESC": "Grazie per averci contattato. Abbiamo ricevuto il tuo messaggio e ti invieremo una email di riepilogo a breve.",
"SEND_ANOTHER": "Invia un altro messaggio",
"HERO_SUBTITLE": "Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.",
"FILE_TYPE_PDF": "PDF",
"FILE_TYPE_3D": "3D"
"FILE_TYPE_3D": "3D",
"FILE_TYPE_VIDEO": "Video",
"FILE_TYPE_DOC": "DOC",
"FILE_TYPE_FILE": "FILE"
},
"CHECKOUT": {
"TITLE": "Checkout",

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 KiB

View File

@@ -6,7 +6,7 @@
<title>3D fab</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="icon" type="image/png" href="assets/images/Fav-icon.png">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>