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.entity.CustomQuoteRequestAttachment;
import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository; import com.printcalculator.repository.CustomQuoteRequestAttachmentRepository;
import com.printcalculator.repository.CustomQuoteRequestRepository; import com.printcalculator.repository.CustomQuoteRequestRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
@@ -15,8 +17,8 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
@RestController @RestController
@@ -29,6 +31,23 @@ public class CustomQuoteRequestController {
// TODO: Inject Storage Service // TODO: Inject Storage Service
private static final String STORAGE_ROOT = "storage_requests"; 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, public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
CustomQuoteRequestAttachmentRepository attachmentRepo, CustomQuoteRequestAttachmentRepository attachmentRepo,
@@ -71,6 +90,13 @@ public class CustomQuoteRequestController {
for (MultipartFile file : files) { for (MultipartFile file : files) {
if (file.isEmpty()) continue; if (file.isEmpty()) continue;
if (isCompressedFile(file)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Compressed files are not allowed."
);
}
// Scan for virus // Scan for virus
clamAVService.scan(file.getInputStream()); clamAVService.scan(file.getInputStream());
@@ -120,8 +146,17 @@ public class CustomQuoteRequestController {
if (filename == null) return "dat"; if (filename == null) return "dat";
int i = filename.lastIndexOf('.'); int i = filename.lastIndexOf('.');
if (i > 0) { if (i > 0) {
return filename.substring(i + 1); return filename.substring(i + 1).toLowerCase();
} }
return "dat"; 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 { 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 { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
@@ -8,7 +8,14 @@ import { provideTranslateHttpLoader, TranslateHttpLoader } from '@ngx-translate/
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideZoneChangeDetection({ eventCoalescing: true }), provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withComponentInputBinding(), withViewTransitions()), provideRouter(
routes,
withComponentInputBinding(),
withViewTransitions(),
withInMemoryScrolling({
scrollPositionRestoration: 'top'
})
),
provideHttpClient(), provideHttpClient(),
provideTranslateHttpLoader({ provideTranslateHttpLoader({
prefix: './assets/i18n/', prefix: './assets/i18n/',

View File

@@ -25,15 +25,15 @@
<div class="photo-card card-1"> <div class="photo-card card-1">
<div class="placeholder-img"></div> <div class="placeholder-img"></div>
<div class="member-info"> <div class="member-info">
<span class="member-name">Member 1</span> <span class="member-name">Joe Küng</span>
<span class="member-role">Founder</span> <span class="member-role">Studente Ingegneria Informatica</span>
</div> </div>
</div> </div>
<div class="photo-card card-2"> <div class="photo-card card-2">
<div class="placeholder-img"></div> <div class="placeholder-img"></div>
<div class="member-info"> <div class="member-info">
<span class="member-name">Member 2</span> <span class="member-name">Matteo Caletti</span>
<span class="member-role">Co-Founder</span> <span class="member-role">Studente Ingegneria Elettronica</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -51,7 +51,7 @@
<div class="drop-zone" (click)="fileInput.click()" <div class="drop-zone" (click)="fileInput.click()"
(dragover)="onDragOver($event)" (drop)="onDrop($event)"> (dragover)="onDragOver($event)" (drop)="onDrop($event)">
<input #fileInput type="file" multiple (change)="onFileSelected($event)" hidden <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> <p>{{ 'CONTACT.DROP_FILES' | translate }}</p>
</div> </div>
@@ -59,9 +59,12 @@
<div class="file-item" *ngFor="let file of files(); let i = index"> <div class="file-item" *ngFor="let file of files(); let i = index">
<button type="button" class="remove-btn" (click)="removeFile(i)">×</button> <button type="button" class="remove-btn" (click)="removeFile(i)">×</button>
<img *ngIf="file.type === 'image'" [src]="file.url" class="preview-img"> <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 === 'pdf'">{{ 'CONTACT.FILE_TYPE_PDF' | translate }}</span>
<span *ngIf="file.type === '3d'">{{ 'CONTACT.FILE_TYPE_3D' | 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>
<div class="file-name" [title]="file.file.name">{{ file.file.name }}</div> <div class="file-name" [title]="file.file.name">{{ file.file.name }}</div>
</div> </div>

View File

@@ -114,6 +114,16 @@ app-input.col { width: 100%; }
border-radius: var(--radius-sm); 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 { .file-icon {
font-weight: 700; color: var(--color-text-muted); font-size: 0.8rem; 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 { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core';
@@ -10,7 +10,7 @@ import { QuoteRequestService } from '../../../../core/services/quote-request.ser
interface FilePreview { interface FilePreview {
file: File; file: File;
url?: string; url?: string;
type: 'image' | 'pdf' | '3d' | 'other'; type: 'image' | 'video' | 'pdf' | '3d' | 'document' | 'other';
} }
import { SuccessStateComponent } from '../../../../shared/components/success-state/success-state.component'; 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', templateUrl: './contact-form.component.html',
styleUrl: './contact-form.component.scss' styleUrl: './contact-form.component.scss'
}) })
export class ContactFormComponent { export class ContactFormComponent implements OnDestroy {
form: FormGroup; form: FormGroup;
sent = signal(false); sent = signal(false);
files = signal<FilePreview[]>([]); 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 { get isCompany(): boolean {
return this.form.get('isCompany')?.value; return this.form.get('isCompany')?.value;
@@ -96,14 +97,22 @@ export class ContactFormComponent {
}); });
// Process files // Process files
const filePreviews: FilePreview[] = []; const filePreviews: FilePreview[] = pending.files.map(f => {
pending.files.forEach(f => { const type = this.getFileType(f);
filePreviews.push({ file: f, type: this.getFileType(f) }); return {
file: f,
type,
url: this.shouldCreatePreview(type) ? URL.createObjectURL(f) : undefined
};
}); });
this.files.set(filePreviews); this.files.set(filePreviews);
} }
} }
ngOnDestroy(): void {
this.revokeAllPreviewUrls();
}
setCompanyMode(isCompany: boolean) { setCompanyMode(isCompany: boolean) {
this.form.patchValue({ isCompany }); this.form.patchValue({ isCompany });
} }
@@ -124,36 +133,52 @@ export class ContactFormComponent {
handleFiles(newFiles: File[]) { handleFiles(newFiles: File[]) {
const currentFiles = this.files(); 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')); alert(this.translate.instant('CONTACT.ERR_MAX_FILES'));
return; return;
} }
newFiles.forEach(file => { allowedFiles.forEach(file => {
const type = this.getFileType(file); const type = this.getFileType(file);
const preview: FilePreview = { file, type }; const preview: FilePreview = {
file,
if (type === 'image') { type,
const reader = new FileReader(); url: this.shouldCreatePreview(type) ? URL.createObjectURL(file) : undefined
reader.onload = (e) => { };
preview.url = e.target?.result as string;
this.files.update(files => [...files]);
};
reader.readAsDataURL(file);
}
this.files.update(files => [...files, preview]); this.files.update(files => [...files, preview]);
}); });
} }
removeFile(index: number) { 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' { getFileType(file: File): 'image' | 'video' | 'pdf' | '3d' | 'document' | 'other' {
if (file.type.startsWith('image/')) return 'image'; const ext = this.getExtension(file.name);
if (file.type === 'application/pdf') return 'pdf';
const ext = file.name.split('.').pop()?.toLowerCase(); if (file.type.startsWith('image/') || ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'svg', 'heic', 'heif'].includes(ext)) {
if (['stl', 'step', 'stp', '3mf', 'obj'].includes(ext || '')) return '3d'; 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'; return 'other';
} }
@@ -195,6 +220,48 @@ export class ContactFormComponent {
resetForm() { resetForm() {
this.sent.set(false); this.sent.set(false);
this.form.reset({ requestType: 'custom', isCompany: false }); this.form.reset({ requestType: 'custom', isCompany: false });
this.revokeAllPreviewUrls();
this.files.set([]); 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); background: var(--color-neutral-100);
margin: -1.5rem -1.5rem 1.5rem -1.5rem; /* Negative margins to bleed to edge */ margin: -1.5rem -1.5rem 1.5rem -1.5rem; /* Negative margins to bleed to edge */
width: calc(100% + 3rem); 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); border-bottom: 1px solid var(--color-border);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -417,8 +419,9 @@
.hero-actions { flex-direction: column; align-items: stretch; } .hero-actions { flex-direction: column; align-items: stretch; }
.quote-meta { grid-template-columns: 1fr; } .quote-meta { grid-template-columns: 1fr; }
.shop-gallery { .shop-gallery {
width: min(100%, 320px); width: 100%;
justify-self: start; max-width: none;
justify-self: stretch;
} }
.shop-gallery-item { .shop-gallery-item {
aspect-ratio: 16 / 11; aspect-ratio: 16 / 11;

View File

@@ -142,7 +142,7 @@
"COMPANY_NAME": "Company Name", "COMPANY_NAME": "Company Name",
"REF_PERSON": "Reference Person", "REF_PERSON": "Reference Person",
"UPLOAD_LABEL": "Attachments", "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", "DROP_FILES": "Drop files here or click to upload",
"PLACEHOLDER_NAME": "Your Name", "PLACEHOLDER_NAME": "Your Name",
"PLACEHOLDER_EMAIL": "your@email.com", "PLACEHOLDER_EMAIL": "your@email.com",
@@ -154,9 +154,15 @@
"LABEL_NAME": "Name *", "LABEL_NAME": "Name *",
"MSG_SENT": "Sent!", "MSG_SENT": "Sent!",
"ERR_MAX_FILES": "Max 15 files limit reached.", "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_TITLE": "Message Sent Successfully",
"SUCCESS_DESC": "Thank you for contacting us. We have received your message and will send you a recap email shortly.", "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": { "CHECKOUT": {
"TITLE": "Checkout", "TITLE": "Checkout",

View File

@@ -36,11 +36,11 @@
"CAP_2_TITLE": "Pezzi personalizzati", "CAP_2_TITLE": "Pezzi personalizzati",
"CAP_2_TEXT": "Componenti unici impossibili da trovare in commercio. Riproduciamo parti rotte o creiamo adattamenti su misura.", "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_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_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.", "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 di soluzioni tecniche pronte", "SEC_SHOP_TITLE": "Shop",
"SEC_SHOP_TEXT": "Prodotti selezionati, testati in laboratorio e pronti all'uso. Risolvono problemi reali con funzionalità concrete.", "SEC_SHOP_TEXT": "Prodotti selezionati, e pronti all'uso.",
"SEC_SHOP_LIST_1": "Accessori funzionali per officine e laboratori", "SEC_SHOP_LIST_1": "Accessori funzionali per officine e laboratori",
"SEC_SHOP_LIST_2": "Ricambi e componenti difficili da reperire", "SEC_SHOP_LIST_2": "Ricambi e componenti difficili da reperire",
"SEC_SHOP_LIST_3": "Supporti e organizzatori per migliorare i flussi di lavoro", "SEC_SHOP_LIST_3": "Supporti e organizzatori per migliorare i flussi di lavoro",
@@ -206,7 +206,7 @@
"COMPANY_NAME": "Ragione Sociale", "COMPANY_NAME": "Ragione Sociale",
"REF_PERSON": "Persona di Riferimento", "REF_PERSON": "Persona di Riferimento",
"UPLOAD_LABEL": "Allegati", "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", "DROP_FILES": "Trascina qui i file o clicca per caricare",
"PLACEHOLDER_NAME": "Il tuo nome", "PLACEHOLDER_NAME": "Il tuo nome",
"PLACEHOLDER_EMAIL": "tuo@email.com", "PLACEHOLDER_EMAIL": "tuo@email.com",
@@ -218,12 +218,16 @@
"LABEL_NAME": "Nome *", "LABEL_NAME": "Nome *",
"MSG_SENT": "Inviato!", "MSG_SENT": "Inviato!",
"ERR_MAX_FILES": "Limite massimo di 15 file raggiunto.", "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_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.", "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", "SEND_ANOTHER": "Invia un altro messaggio",
"HERO_SUBTITLE": "Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.", "HERO_SUBTITLE": "Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.",
"FILE_TYPE_PDF": "PDF", "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": { "CHECKOUT": {
"TITLE": "Checkout", "TITLE": "Checkout",

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 KiB

View File

@@ -6,7 +6,7 @@
<title>3D fab</title> <title>3D fab</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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/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"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head> </head>