dev #3

Merged
JoeKung merged 34 commits from dev into int 2026-02-05 15:30:05 +01:00
9 changed files with 437 additions and 81 deletions
Showing only changes of commit 2f7e8798d2 - Show all commits

View File

@@ -20,6 +20,10 @@ export const routes: Routes = [
{ {
path: 'about', path: 'about',
loadChildren: () => import('./features/about/about.routes').then(m => m.ABOUT_ROUTES) loadChildren: () => import('./features/about/about.routes').then(m => m.ABOUT_ROUTES)
},
{
path: 'contact',
loadChildren: () => import('./features/contact/contact.routes').then(m => m.CONTACT_ROUTES)
} }
] ]
} }

View File

@@ -3,9 +3,11 @@
<a routerLink="/" class="brand">3D <span class="highlight">fab</span></a> <a routerLink="/" class="brand">3D <span class="highlight">fab</span></a>
<nav class="nav-links"> <nav class="nav-links">
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">{{ 'NAV.HOME' | translate }}</a>
<a routerLink="/cal" routerLinkActive="active" [routerLinkActiveOptions]="{exact: false}">{{ 'NAV.CALCULATOR' | translate }}</a> <a routerLink="/cal" routerLinkActive="active" [routerLinkActiveOptions]="{exact: false}">{{ 'NAV.CALCULATOR' | translate }}</a>
<a routerLink="/shop" routerLinkActive="active">{{ 'NAV.SHOP' | translate }}</a> <a routerLink="/shop" routerLinkActive="active">{{ 'NAV.SHOP' | translate }}</a>
<a routerLink="/about" routerLinkActive="active">{{ 'NAV.ABOUT' | translate }}</a> <a routerLink="/about" routerLinkActive="active">{{ 'NAV.ABOUT' | translate }}</a>
<a routerLink="/contact" routerLinkActive="active">{{ 'NAV.CONTACT' | translate }}</a>
</nav> </nav>
<div class="actions"> <div class="actions">

View File

@@ -1,12 +1,11 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { ContactFormComponent } from './components/contact-form/contact-form.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
@Component({ @Component({
selector: 'app-about-page', selector: 'app-about-page',
standalone: true, standalone: true,
imports: [TranslateModule, ContactFormComponent, AppCardComponent], imports: [TranslateModule],
template: ` template: `
<section class="about-hero"> <section class="about-hero">
<div class="container"> <div class="container">
@@ -37,14 +36,25 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
<h3>{{ 'ABOUT.TARGET_TITLE' | translate }}</h3> <h3>{{ 'ABOUT.TARGET_TITLE' | translate }}</h3>
<p class="text-muted">{{ 'ABOUT.TARGET_TEXT' | translate }}</p> <p class="text-muted">{{ 'ABOUT.TARGET_TEXT' | translate }}</p>
<h3>{{ 'ABOUT.TEAM_TITLE' | translate }}</h3>
<div class="team-grid">
<div class="team-member">
<div class="placeholder-img"></div>
<p>Member 1</p>
</div>
<div class="team-member">
<div class="placeholder-img"></div>
<p>Member 2</p>
</div>
<div class="team-member">
<div class="placeholder-img"></div>
<p>Member 3</p>
</div>
</div>
</div> </div>
<div class="contact">
<app-card>
<h2>{{ 'ABOUT.CONTACT_US' | translate }}</h2>
<app-contact-form></app-contact-form>
</app-card>
</div>
</div> </div>
`, `,
styles: [` styles: [`
@@ -91,6 +101,22 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
font-weight: 600; font-weight: 600;
} }
.text-muted { color: var(--color-text-muted); } .text-muted { color: var(--color-text-muted); }
.team-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--space-4);
margin-top: var(--space-4);
}
.team-member {
text-align: center;
}
.placeholder-img {
width: 100%;
aspect-ratio: 1;
background: var(--color-neutral-100);
border-radius: var(--radius-md);
margin-bottom: var(--space-2);
}
`] `]
}) })
export class AboutPageComponent {} export class AboutPageComponent {}

View File

@@ -1,66 +0,0 @@
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
@Component({
selector: 'app-contact-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppButtonComponent],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<app-input formControlName="name" label="Nome" placeholder="Il tuo nome"></app-input>
<app-input formControlName="email" type="email" label="Email" placeholder="tuo@email.com"></app-input>
<div class="form-group">
<label>Messaggio</label>
<textarea formControlName="message" class="form-control" rows="4"></textarea>
</div>
<div class="actions">
<app-button type="submit" [disabled]="form.invalid || sent()">
{{ sent() ? 'Inviato!' : ('ABOUT.SEND' | translate) }}
</app-button>
</div>
</form>
`,
styles: [`
.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); }
.form-control {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
width: 100%;
background: var(--color-bg-card);
color: var(--color-text);
font-family: inherit;
&:focus { outline: none; border-color: var(--color-brand); }
}
`]
})
export class ContactFormComponent {
form: FormGroup;
sent = signal(false);
constructor(private fb: FormBuilder) {
this.form = this.fb.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
message: ['', Validators.required]
});
}
onSubmit() {
if (this.form.valid) {
// Mock submit
this.sent.set(true);
setTimeout(() => {
this.sent.set(false);
this.form.reset();
}, 3000);
}
}
}

View File

@@ -0,0 +1,306 @@
import { Component, signal, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { TranslateModule } from '@ngx-translate/core';
import { AppInputComponent } from '../../../../shared/components/app-input/app-input.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
interface FilePreview {
file: File;
url?: string;
type: 'image' | 'pdf' | '3d' | 'other';
}
@Component({
selector: 'app-contact-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppButtonComponent],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<!-- Request Type -->
<div class="form-group">
<label>{{ 'CONTACT.REQ_TYPE_LABEL' | translate }} *</label>
<select formControlName="requestType" class="form-control">
<option *ngFor="let type of requestTypes" [value]="type.value">
{{ type.label | translate }}
</option>
</select>
</div>
<div class="row">
<!-- Email -->
<app-input formControlName="email" type="email" label="Email *" placeholder="tuo@email.com" class="col"></app-input>
<!-- Phone -->
<app-input formControlName="phone" type="tel" [label]="('CONTACT.PHONE' | translate)" placeholder="+39 000 000 0000" class="col"></app-input>
</div>
<!-- Name (Always Required) -->
<app-input formControlName="name" label="Nome *" placeholder="Il tuo nome"></app-input>
<!-- Company Toggle & Fields -->
<div class="form-group checkbox-group">
<input type="checkbox" formControlName="isCompany" id="isCompany">
<label for="isCompany">{{ 'CONTACT.IS_COMPANY' | translate }}</label>
</div>
<div *ngIf="form.get('isCompany')?.value" class="company-fields">
<app-input formControlName="companyName" [label]="('CONTACT.COMPANY_NAME' | translate) + ' *'" placeholder="Nome Azienda"></app-input>
<app-input formControlName="referencePerson" [label]="('CONTACT.REF_PERSON' | translate) + ' *'" placeholder="Persona di Riferimento"></app-input>
</div>
<div class="form-group">
<label>Messaggio *</label>
<textarea formControlName="message" class="form-control" rows="4"></textarea>
</div>
<!-- File Upload Section -->
<div class="form-group">
<label>{{ 'CONTACT.UPLOAD_LABEL' | translate }}</label>
<p class="hint">{{ 'CONTACT.UPLOAD_HINT' | translate }}</p>
<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,.obj">
<p>{{ 'CONTACT.DROP_FILES' | translate }}</p>
</div>
<div class="file-grid" *ngIf="files().length > 0">
<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">
<span *ngIf="file.type === 'pdf'">PDF</span>
<span *ngIf="file.type === '3d'">3D</span>
</div>
<div class="file-name" [title]="file.file.name">{{ file.file.name }}</div>
</div>
</div>
</div>
<div class="actions">
<app-button type="submit" [disabled]="form.invalid || sent()">
{{ sent() ? 'Inviato!' : ('CONTACT.SEND' | translate) }}
</app-button>
</div>
</form>
`,
styles: [`
.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); }
.hint { font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: var(--space-2); }
.form-control {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
width: 100%;
background: var(--color-bg-card);
color: var(--color-text);
font-family: inherit;
&:focus { outline: none; border-color: var(--color-brand); }
}
select.form-control {
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 1em;
}
.row {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-bottom: var(--space-4);
@media(min-width: 768px) {
flex-direction: row;
.col { flex: 1; margin-bottom: 0; }
}
}
/* Modify direct app-input child of row if possible or target host */
app-input.col {
width: 100%;
}
.checkbox-group {
flex-direction: row;
align-items: center;
gap: var(--space-2);
input[type="checkbox"] { width: auto; margin: 0; }
label { margin: 0; }
}
.company-fields {
padding-left: var(--space-4);
border-left: 2px solid var(--color-border);
margin-bottom: var(--space-4);
}
.drop-zone {
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-6);
text-align: center;
cursor: pointer;
color: var(--color-text-muted);
transition: all 0.2s;
&:hover { border-color: var(--color-brand); color: var(--color-brand); }
}
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: var(--space-3);
margin-top: var(--space-3);
}
.file-item {
position: relative;
background: var(--color-neutral-100);
border-radius: var(--radius-sm);
padding: var(--space-2);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
aspect-ratio: 1;
overflow: hidden;
}
.preview-img {
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;
}
.file-name {
font-size: 0.65rem; color: var(--color-text); white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; width: 100%; text-align: center; position: absolute; bottom: 2px;
padding: 0 4px; z-index: 2; background: rgba(255,255,255,0.8);
}
.remove-btn {
position: absolute; top: 2px; right: 2px; z-index: 10;
background: rgba(0,0,0,0.5); color: white; border: none; border-radius: 50%;
width: 18px; height: 18px; font-size: 12px; cursor: pointer;
display: flex; align-items: center; justify-content: center; line-height: 1;
&:hover { background: red; }
}
`]
})
export class ContactFormComponent {
form: FormGroup;
sent = signal(false);
files = signal<FilePreview[]>([]);
requestTypes = [
{ value: 'custom', label: 'CONTACT.REQ_TYPE_CUSTOM' },
{ value: 'series', label: 'CONTACT.REQ_TYPE_SERIES' },
{ value: 'consult', label: 'CONTACT.REQ_TYPE_CONSULT' },
{ value: 'question', label: 'CONTACT.REQ_TYPE_QUESTION' }
];
constructor(private fb: FormBuilder) {
this.form = this.fb.group({
requestType: ['custom', Validators.required],
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
phone: [''],
message: ['', Validators.required],
isCompany: [false],
companyName: [''],
referencePerson: ['']
});
// Handle conditional validation for Company fields
this.form.get('isCompany')?.valueChanges.subscribe(isCompany => {
const companyNameControl = this.form.get('companyName');
const refPersonControl = this.form.get('referencePerson');
if (isCompany) {
companyNameControl?.setValidators([Validators.required]);
refPersonControl?.setValidators([Validators.required]);
} else {
companyNameControl?.clearValidators();
refPersonControl?.clearValidators();
}
companyNameControl?.updateValueAndValidity();
refPersonControl?.updateValueAndValidity();
});
}
onFileSelected(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files) this.handleFiles(Array.from(input.files));
}
onDragOver(event: DragEvent) {
event.preventDefault(); event.stopPropagation();
}
onDrop(event: DragEvent) {
event.preventDefault(); event.stopPropagation();
if (event.dataTransfer?.files) this.handleFiles(Array.from(event.dataTransfer.files));
}
handleFiles(newFiles: File[]) {
const currentFiles = this.files();
if (currentFiles.length + newFiles.length > 15) {
alert("Max 15 files limit reached.");
return;
}
newFiles.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);
}
this.files.update(files => [...files, preview]);
});
}
removeFile(index: number) {
this.files.update(files => 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';
return 'other';
}
onSubmit() {
if (this.form.valid) {
const formData = {
...this.form.value,
files: this.files().map(f => f.file)
};
console.log('Form Submit:', formData);
this.sent.set(true);
setTimeout(() => {
this.sent.set(false);
this.form.reset({ requestType: 'custom', isCompany: false });
this.files.set([]);
}, 3000);
} else {
this.form.markAllAsTouched();
}
}
}

View File

@@ -0,0 +1,42 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { ContactFormComponent } from './components/contact-form/contact-form.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
@Component({
selector: 'app-contact-page',
standalone: true,
imports: [CommonModule, TranslateModule, ContactFormComponent, AppCardComponent],
template: `
<section class="contact-hero">
<div class="container">
<h1>{{ 'CONTACT.TITLE' | translate }}</h1>
<p class="subtitle">Siamo qui per aiutarti. Compila il modulo sottostante per qualsiasi richiesta.</p>
</div>
</section>
<div class="container content">
<app-card>
<app-contact-form></app-contact-form>
</app-card>
</div>
`,
styles: [`
.contact-hero {
padding: 5rem 0 3.5rem;
background: var(--color-bg);
text-align: center;
}
.subtitle {
color: var(--color-text-muted);
max-width: 640px;
margin: var(--space-3) auto 0;
}
.content {
padding: 3rem 0 5rem;
max-width: 800px;
}
`]
})
export class ContactPageComponent {}

View File

@@ -0,0 +1,8 @@
import { Routes } from '@angular/router';
export const CONTACT_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./contact-page.component').then(m => m.ContactPageComponent)
}
];

View File

@@ -1,8 +1,10 @@
{ {
"NAV": { "NAV": {
"HOME": "Home",
"CALCULATOR": "Calculator", "CALCULATOR": "Calculator",
"SHOP": "Shop", "SHOP": "Shop",
"ABOUT": "About" "ABOUT": "About",
"CONTACT": "Contact Us"
}, },
"FOOTER": { "FOOTER": {
"PRIVACY": "Privacy", "PRIVACY": "Privacy",
@@ -58,7 +60,22 @@
"SERVICE_4": "File verification and optimization for printing", "SERVICE_4": "File verification and optimization for printing",
"TARGET_TITLE": "Who is it for", "TARGET_TITLE": "Who is it for",
"TARGET_TEXT": "Small businesses, freelancers, makers and customers looking for a ready-made product from the shop.", "TARGET_TEXT": "Small businesses, freelancers, makers and customers looking for a ready-made product from the shop.",
"CONTACT_US": "Contact Us", "TEAM_TITLE": "Our Team"
"SEND": "Send Message" },
"CONTACT": {
"TITLE": "Contact Us",
"SEND": "Send Message",
"REQ_TYPE_LABEL": "Type of Request",
"REQ_TYPE_CUSTOM": "Custom Quote",
"REQ_TYPE_SERIES": "Series Production",
"REQ_TYPE_CONSULT": "Consultation",
"REQ_TYPE_QUESTION": "General Questions",
"PHONE": "Phone",
"IS_COMPANY": "Are you a company?",
"COMPANY_NAME": "Company Name",
"REF_PERSON": "Reference Person",
"UPLOAD_LABEL": "Attachments",
"UPLOAD_HINT": "Max 15 files. Supported: Images, PDF, STL, STEP, 3MF, OBJ",
"DROP_FILES": "Drop files here or click to upload"
} }
} }

View File

@@ -1,8 +1,10 @@
{ {
"NAV": { "NAV": {
"HOME": "Home",
"CALCULATOR": "Calcolatore", "CALCULATOR": "Calcolatore",
"SHOP": "Shop", "SHOP": "Shop",
"ABOUT": "Chi Siamo" "ABOUT": "Chi Siamo",
"CONTACT": "Contattaci"
}, },
"FOOTER": { "FOOTER": {
"PRIVACY": "Privacy", "PRIVACY": "Privacy",
@@ -63,7 +65,22 @@
"SERVICE_4": "Verifica file e ottimizzazione per la stampa", "SERVICE_4": "Verifica file e ottimizzazione per la stampa",
"TARGET_TITLE": "Per chi è", "TARGET_TITLE": "Per chi è",
"TARGET_TEXT": "Piccole aziende, freelance, smanettoni e clienti che cercano un prodotto già pronto dallo shop.", "TARGET_TEXT": "Piccole aziende, freelance, smanettoni e clienti che cercano un prodotto già pronto dallo shop.",
"CONTACT_US": "Contattaci", "TEAM_TITLE": "Il Nostro Team"
"SEND": "Invia Messaggio" },
"CONTACT": {
"TITLE": "Contattaci",
"SEND": "Invia Messaggio",
"REQ_TYPE_LABEL": "Tipo di Richiesta",
"REQ_TYPE_CUSTOM": "Preventivo Personalizzato",
"REQ_TYPE_SERIES": "Stampa in Serie",
"REQ_TYPE_CONSULT": "Consulenza",
"REQ_TYPE_QUESTION": "Domande Generali",
"PHONE": "Telefono",
"IS_COMPANY": "Sei un'azienda?",
"COMPANY_NAME": "Ragione Sociale",
"REF_PERSON": "Persona di Riferimento",
"UPLOAD_LABEL": "Allegati",
"UPLOAD_HINT": "Max 15 file. Supportati: Immagini, PDF, STL, STEP, 3MF, OBJ",
"DROP_FILES": "Trascina qui i file o clicca per caricare"
} }
} }