produzione 1 #9
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
const preview: FilePreview = {
|
||||
file,
|
||||
type,
|
||||
url: this.shouldCreatePreview(type) ? URL.createObjectURL(file) : undefined
|
||||
};
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
frontend/src/assets/images/Fav-icon.png
Normal file
BIN
frontend/src/assets/images/Fav-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 952 KiB |
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user