feat(web): update color selector
All checks were successful
Build, Test and Deploy / test-backend (push) Successful in 27s
Build, Test and Deploy / build-and-push (push) Successful in 24s
Build, Test and Deploy / deploy (push) Successful in 6s

This commit is contained in:
2026-02-06 13:25:41 +01:00
parent bcdeafe119
commit 13790f2055
10 changed files with 380 additions and 55 deletions

View File

@@ -0,0 +1,41 @@
export interface ColorOption {
label: string;
value: string;
hex: string;
outOfStock?: boolean;
}
export interface ColorCategory {
name: string; // 'Glossy' | 'Matte'
colors: ColorOption[];
}
export const PRODUCT_COLORS: ColorCategory[] = [
{
name: 'Lucidi', // Glossy
colors: [
{ label: 'Black', value: 'Black', hex: '#1a1a1a' }, // Not pure black for visibility
{ label: 'White', value: 'White', hex: '#f5f5f5' },
{ label: 'Red', value: 'Red', hex: '#d32f2f', outOfStock: true },
{ label: 'Blue', value: 'Blue', hex: '#1976d2' },
{ label: 'Green', value: 'Green', hex: '#388e3c' },
{ label: 'Yellow', value: 'Yellow', hex: '#fbc02d' }
]
},
{
name: 'Opachi', // Matte
colors: [
{ label: 'Matte Black', value: 'Matte Black', hex: '#2c2c2c' }, // Lighter charcoal for matte
{ label: 'Matte White', value: 'Matte White', hex: '#e0e0e0' },
{ label: 'Matte Gray', value: 'Matte Gray', hex: '#757575' }
]
}
];
export function getColorHex(value: string): string {
for (const cat of PRODUCT_COLORS) {
const found = cat.colors.find(c => c.value === value);
if (found) return found.hex;
}
return '#facf0a'; // Default Brand Color if not found
}

View File

@@ -72,11 +72,14 @@ export class CalculatorPageComponent {
details += `- File:\n`; details += `- File:\n`;
req.items.forEach(item => { req.items.forEach(item => {
details += ` * ${item.file.name} (Qtà: ${item.quantity})\n`; details += ` * ${item.file.name} (Qtà: ${item.quantity}`;
if (item.color) {
details += `, Colore: ${item.color}`;
}
details += `)\n`;
}); });
if (req.mode === 'advanced') { if (req.mode === 'advanced') {
if (req.color) details += `- Colore: ${req.color}\n`;
if (req.infillDensity) details += `- Infill: ${req.infillDensity}%\n`; if (req.infillDensity) details += `- Infill: ${req.infillDensity}%\n`;
} }

View File

@@ -3,7 +3,10 @@
<div class="section"> <div class="section">
@if (selectedFile()) { @if (selectedFile()) {
<div class="viewer-wrapper"> <div class="viewer-wrapper">
<app-stl-viewer [file]="selectedFile()"></app-stl-viewer> <app-stl-viewer
[file]="selectedFile()"
[color]="getSelectedFileColor()">
</app-stl-viewer>
<!-- Close button removed as requested --> <!-- Close button removed as requested -->
</div> </div>
} }
@@ -29,15 +32,25 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="qty-group"> <div class="card-controls">
<label>Qtà</label> <div class="qty-group">
<input <label>QTÀ</label>
type="number" <input
min="1" type="number"
[value]="item.quantity" min="1"
(change)="updateItemQuantity(i, $event)" [value]="item.quantity"
class="qty-input" (change)="updateItemQuantity(i, $event)"
(click)="$event.stopPropagation()"> class="qty-input"
(click)="$event.stopPropagation()">
</div>
<div class="color-group">
<label>COLORE</label>
<app-color-selector
[selectedColor]="item.color"
(colorSelected)="updateItemColor(i, $event)">
</app-color-selector>
</div>
</div> </div>
<button type="button" class="btn-remove" (click)="removeItem(i); $event.stopPropagation()" title="Remove file"> <button type="button" class="btn-remove" (click)="removeItem(i); $event.stopPropagation()" title="Remove file">
@@ -81,12 +94,6 @@
@if (mode() === 'advanced') { @if (mode() === 'advanced') {
<div class="grid"> <div class="grid">
<app-select
formControlName="color"
[label]="'CALC.COLOR' | translate"
[options]="colors"
></app-select>
<app-select <app-select
formControlName="infillPattern" formControlName="infillPattern"
[label]="'CALC.PATTERN' | translate" [label]="'CALC.PATTERN' | translate"

View File

@@ -16,18 +16,18 @@
/* Grid Layout for Files */ /* Grid Layout for Files */
.items-grid { .items-grid {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr 1fr; /* Force 2 columns on mobile */
gap: var(--space-3); gap: var(--space-2); /* Tighten gap for mobile */
margin-top: var(--space-4); margin-top: var(--space-4);
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
@media(min-width: 640px) { @media(min-width: 640px) {
grid-template-columns: 1fr 1fr; gap: var(--space-3);
} }
} }
.file-card { .file-card {
padding: var(--space-3); padding: var(--space-2); /* Reduced from space-3 */
background: var(--color-neutral-100); background: var(--color-neutral-100);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
@@ -35,7 +35,9 @@
cursor: pointer; cursor: pointer;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-2); gap: 4px; /* Reduced gap */
position: relative; /* For absolute positioning of remove btn */
min-width: 0; /* Allow flex item to shrink below content size if needed */
&:hover { border-color: var(--color-neutral-300); } &:hover { border-color: var(--color-neutral-300); }
&.active { &.active {
@@ -47,11 +49,13 @@
.card-header { .card-header {
overflow: hidden; overflow: hidden;
padding-right: 25px; /* Adjusted */
margin-bottom: 2px;
} }
.file-name { .file-name {
font-weight: 500; font-weight: 500;
font-size: 0.85rem; font-size: 0.8rem; /* Smaller font */
color: var(--color-text); color: var(--color-text);
display: block; display: block;
white-space: nowrap; white-space: nowrap;
@@ -61,34 +65,64 @@
.card-body { .card-body {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
padding-top: 0;
} }
.qty-group { .card-controls {
display: flex; display: flex;
align-items: center; align-items: flex-end; /* Align bottom of input and color circle */
gap: var(--space-2); gap: 16px; /* Space between Qty and Color */
label { font-size: 0.75rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.5px; } width: 100%;
}
.qty-group, .color-group {
display: flex;
flex-direction: column; /* Stack label and input */
align-items: flex-start;
gap: 0px;
label {
font-size: 0.6rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
margin-bottom: 2px;
}
}
.color-group {
align-items: flex-start; /* Align label left */
/* margin-right removed */
/* Override margin in selector for this context */
::ng-deep .color-selector-container {
margin-left: 0;
}
} }
.qty-input { .qty-input {
width: 40px; width: 36px; /* Slightly smaller */
padding: 2px 4px; padding: 1px 2px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
text-align: center; text-align: center;
font-size: 0.9rem; font-size: 0.85rem;
background: white; background: white;
height: 24px; /* Explicit height to match color circle somewhat */
&:focus { outline: none; border-color: var(--color-brand); } &:focus { outline: none; border-color: var(--color-brand); }
} }
.btn-remove { .btn-remove {
width: 24px; position: absolute;
height: 24px; top: 4px;
right: 4px;
width: 18px;
height: 18px;
border-radius: 4px; border-radius: 4px;
border: 1px solid transparent; // var(--color-border); border: none;
background: transparent; // white; background: transparent;
color: var(--color-text-muted); color: var(--color-text-muted);
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
@@ -96,12 +130,11 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.2s; transition: all 0.2s;
font-size: 0.9rem; font-size: 0.8rem;
&:hover { &:hover {
background: var(--color-danger-100); background: var(--color-danger-100);
color: var(--color-danger-500); color: var(--color-danger-500);
border-color: var(--color-danger-200);
} }
} }

View File

@@ -7,17 +7,20 @@ import { AppSelectComponent } from '../../../../shared/components/app-select/app
import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone/app-dropzone.component'; import { AppDropzoneComponent } from '../../../../shared/components/app-dropzone/app-dropzone.component';
import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../../../shared/components/app-button/app-button.component';
import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component'; import { StlViewerComponent } from '../../../../shared/components/stl-viewer/stl-viewer.component';
import { ColorSelectorComponent } from '../../../../shared/components/color-selector/color-selector.component';
import { QuoteRequest } from '../../services/quote-estimator.service'; import { QuoteRequest } from '../../services/quote-estimator.service';
import { getColorHex } from '../../../../core/constants/colors.const';
interface FormItem { interface FormItem {
file: File; file: File;
quantity: number; quantity: number;
color: string;
} }
@Component({ @Component({
selector: 'app-upload-form', selector: 'app-upload-form',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppSelectComponent, AppDropzoneComponent, AppButtonComponent, StlViewerComponent], imports: [CommonModule, ReactiveFormsModule, TranslateModule, AppInputComponent, AppSelectComponent, AppDropzoneComponent, AppButtonComponent, StlViewerComponent, ColorSelectorComponent],
templateUrl: './upload-form.component.html', templateUrl: './upload-form.component.html',
styleUrl: './upload-form.component.scss' styleUrl: './upload-form.component.scss'
}) })
@@ -44,15 +47,6 @@ export class UploadFormComponent {
{ label: 'Alta definizione', value: 'High' } { label: 'Alta definizione', value: 'High' }
]; ];
colors = [
{ label: 'Black', value: 'Black' },
{ label: 'White', value: 'White' },
{ label: 'Gray', value: 'Gray' },
{ label: 'Red', value: 'Red' },
{ label: 'Blue', value: 'Blue' },
{ label: 'Green', value: 'Green' },
{ label: 'Yellow', value: 'Yellow' }
];
infillPatterns = [ infillPatterns = [
{ label: 'Grid', value: 'grid' }, { label: 'Grid', value: 'grid' },
{ label: 'Gyroid', value: 'gyroid' }, { label: 'Gyroid', value: 'gyroid' },
@@ -69,7 +63,7 @@ export class UploadFormComponent {
quality: ['Standard', Validators.required], quality: ['Standard', Validators.required],
notes: [''], notes: [''],
// Advanced fields // Advanced fields
color: ['Black'], // Color removed from global form
infillDensity: [20, [Validators.min(0), Validators.max(100)]], infillDensity: [20, [Validators.min(0), Validators.max(100)]],
infillPattern: ['grid'], infillPattern: ['grid'],
supportEnabled: [false] supportEnabled: [false]
@@ -85,7 +79,8 @@ export class UploadFormComponent {
if (file.size > MAX_SIZE) { if (file.size > MAX_SIZE) {
hasError = true; hasError = true;
} else { } else {
validItems.push({ file, quantity: 1 }); // Default color is Black
validItems.push({ file, quantity: 1, color: 'Black' });
} }
} }
@@ -129,6 +124,18 @@ export class UploadFormComponent {
} }
} }
// Helper to get color of currently selected file
getSelectedFileColor(): string {
const file = this.selectedFile();
if (!file) return '#facf0a'; // Default
const item = this.items().find(i => i.file === file);
if (item) {
return getColorHex(item.color);
}
return '#facf0a';
}
updateItemQuantity(index: number, event: Event) { updateItemQuantity(index: number, event: Event) {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
let val = parseInt(input.value, 10); let val = parseInt(input.value, 10);
@@ -141,6 +148,14 @@ export class UploadFormComponent {
}); });
} }
updateItemColor(index: number, newColor: string) {
this.items.update(current => {
const updated = [...current];
updated[index] = { ...updated[index], color: newColor };
return updated;
});
}
removeItem(index: number) { removeItem(index: number) {
this.items.update(current => { this.items.update(current => {
const updated = [...current]; const updated = [...current];
@@ -155,7 +170,7 @@ export class UploadFormComponent {
onSubmit() { onSubmit() {
if (this.form.valid && this.items().length > 0) { if (this.form.valid && this.items().length > 0) {
this.submitRequest.emit({ this.submitRequest.emit({
items: this.items(), // Pass the items array items: this.items(), // Pass the items array including colors
...this.form.value, ...this.form.value,
mode: this.mode() mode: this.mode()
}); });

View File

@@ -5,11 +5,11 @@ import { map, catchError } from 'rxjs/operators';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
export interface QuoteRequest { export interface QuoteRequest {
items: { file: File, quantity: number }[]; items: { file: File, quantity: number, color?: string }[];
material: string; material: string;
quality: string; quality: string;
notes?: string; notes?: string;
color?: string; // color removed from global scope
infillDensity?: number; infillDensity?: number;
infillPattern?: string; infillPattern?: string;
supportEnabled?: boolean; supportEnabled?: boolean;
@@ -69,8 +69,11 @@ export class QuoteEstimatorService {
formData.append('machine', 'bambu_a1'); formData.append('machine', 'bambu_a1');
formData.append('filament', this.mapMaterial(request.material)); formData.append('filament', this.mapMaterial(request.material));
formData.append('quality', this.mapQuality(request.quality)); formData.append('quality', this.mapQuality(request.quality));
// Send color for both modes if present, defaulting to Black
formData.append('material_color', item.color || 'Black');
if (request.mode === 'advanced') { if (request.mode === 'advanced') {
if (request.color) formData.append('material_color', request.color);
if (request.infillDensity) formData.append('infill_density', request.infillDensity.toString()); if (request.infillDensity) formData.append('infill_density', request.infillDensity.toString());
if (request.infillPattern) formData.append('infill_pattern', request.infillPattern); if (request.infillPattern) formData.append('infill_pattern', request.infillPattern);
if (request.supportEnabled) formData.append('support_enabled', 'true'); if (request.supportEnabled) formData.append('support_enabled', 'true');

View File

@@ -0,0 +1,39 @@
<div class="color-selector-container">
@if (isOpen()) {
<div class="backdrop" (click)="close()"></div>
}
<div
class="color-circle trigger"
[style.background-color]="getCurrentHex()"
[title]="selectedColor()"
(click)="toggleOpen()">
</div>
@if (isOpen()) {
<div class="color-popup">
@for (category of categories; track category.name) {
<div class="category">
<div class="category-name">{{ category.name }}</div>
<div class="colors-grid">
@for (color of category.colors; track color.value) {
<div
class="color-item"
(click)="selectColor(color)"
[class.disabled]="color.outOfStock">
<div class="selection-ring"
[class.active]="selectedColor() === color.value"
[class.out-of-stock]="color.outOfStock">
<div class="color-circle small" [style.background-color]="color.hex"></div>
</div>
<span class="color-name">{{ color.label }}</span>
</div>
}
</div>
</div>
}
</div>
}
</div>

View File

@@ -0,0 +1,136 @@
.color-selector-container {
position: relative;
display: inline-block;
// margin-left: 10px; // Handled by parent now
}
.backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 999;
background: transparent;
cursor: default;
}
.color-circle {
width: 20px; /* Reduced from 24px */
height: 20px;
border-radius: 50%;
border: 2px solid #ddd;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
&.trigger:hover {
transform: scale(1.1);
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
}
&.small {
width: 20px;
height: 20px;
border: 1px solid #eee;
}
}
.color-popup {
position: absolute;
top: calc(100% + 8px); // Explicit gap
left: -2px; // Align left edge with slight offset
background: white;
border: 1px solid #eee;
border-radius: 8px; /* Slightly tighter radius */
padding: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 1000;
width: 230px; /* Increased size */
// Little triangle arrow
&::before {
content: '';
position: absolute;
top: -6px;
left: 8px; // Align arrow to left side near trigger
border-width: 0 6px 6px 6px;
border-style: solid;
border-color: transparent transparent white transparent;
}
}
.category {
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
.category-name {
font-size: 11px;
text-transform: uppercase;
color: #888;
margin-bottom: 6px;
font-weight: 600;
letter-spacing: 0.5px;
}
.colors-grid {
display: grid;
grid-template-columns: repeat(3, 1fr); /* 3 columns for better alignment */
gap: 12px;
}
.color-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
cursor: pointer;
text-align: center;
&:hover .selection-ring {
transform: scale(1.1);
}
&.disabled {
cursor: not-allowed;
opacity: 0.6;
&:hover .selection-ring { transform: none; }
}
}
.selection-ring {
position: relative;
border-radius: 50%;
border: 2px solid transparent;
transition: all 0.2s;
padding: 2px; /* Space for ring */
&.active {
border-color: var(--color-brand, #facf0a);
box-shadow: 0 0 0 1px var(--color-brand, #facf0a);
}
&.out-of-stock::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 2px;
background: #cc0000;
transform: translate(-50%, -50%) rotate(-45deg);
}
}
.color-name {
font-size: 0.65rem;
color: #444;
line-height: 1.1;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,40 @@
import { Component, input, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core';
import { PRODUCT_COLORS, getColorHex, ColorCategory, ColorOption } from '../../../core/constants/colors.const';
@Component({
selector: 'app-color-selector',
standalone: true,
imports: [CommonModule, TranslateModule],
templateUrl: './color-selector.component.html',
styleUrl: './color-selector.component.scss'
})
export class ColorSelectorComponent {
selectedColor = input<string>('Black');
colorSelected = output<string>();
isOpen = signal(false);
categories: ColorCategory[] = PRODUCT_COLORS;
toggleOpen() {
this.isOpen.update(v => !v);
}
selectColor(color: ColorOption) {
if (color.outOfStock) return;
this.colorSelected.emit(color.value);
this.isOpen.set(false);
}
// Helper to find hex for the current selected value
getCurrentHex(): string {
return getColorHex(this.selectedColor());
}
close() {
this.isOpen.set(false);
}
}

View File

@@ -15,6 +15,8 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
}) })
export class StlViewerComponent implements OnInit, OnDestroy, OnChanges { export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
@Input() file: File | null = null; @Input() file: File | null = null;
@Input() color: string = '#facf0a'; // Default Brand Color
@ViewChild('rendererContainer', { static: true }) rendererContainer!: ElementRef; @ViewChild('rendererContainer', { static: true }) rendererContainer!: ElementRef;
private scene!: THREE.Scene; private scene!: THREE.Scene;
@@ -34,6 +36,12 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
if (changes['file'] && this.file) { if (changes['file'] && this.file) {
this.loadFile(this.file); this.loadFile(this.file);
} }
if (changes['color'] && this.currentMesh && !changes['file']) {
// Update existing mesh color if only color changed
const mat = this.currentMesh.material as THREE.MeshPhongMaterial;
mat.color.set(this.color);
}
} }
ngOnDestroy() { ngOnDestroy() {
@@ -99,7 +107,7 @@ export class StlViewerComponent implements OnInit, OnDestroy, OnChanges {
} }
const material = new THREE.MeshPhongMaterial({ const material = new THREE.MeshPhongMaterial({
color: 0xFACF0A, // Brand color color: this.color,
specular: 0x111111, specular: 0x111111,
shininess: 200 shininess: 200
}); });