dev #8

Closed
JoeKung wants to merge 72 commits from dev into int
10 changed files with 160 additions and 16 deletions
Showing only changes of commit 521009de7c - Show all commits

View File

@@ -0,0 +1,61 @@
package com.printcalculator.service;
import com.printcalculator.model.ModelDimensions;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
class SlicerServiceTest {
@Test
void parseModelDimensionsFromInfoOutput_validOutput_returnsDimensions() {
String output = """
[file.stl]
size_x = 130.860428
size_y = 225.000000
size_z = 140.000000
min_x = 0.000000
""";
Optional<ModelDimensions> dimensions = SlicerService.parseModelDimensionsFromInfoOutput(output);
assertTrue(dimensions.isPresent());
assertEquals(130.860428, dimensions.get().xMm(), 0.000001);
assertEquals(225.0, dimensions.get().yMm(), 0.000001);
assertEquals(140.0, dimensions.get().zMm(), 0.000001);
}
@Test
void parseModelDimensionsFromInfoOutput_withNoise_returnsDimensions() {
String output = """
[2026-02-27 10:26:30.306251] [0x1] [trace] Initializing StaticPrintConfigs
[model.3mf]
size_x = 97.909241
size_y = 97.909241
size_z = 70.000008
[2026-02-27 10:26:30.314575] [0x1] [error] calc_exclude_triangles
""";
Optional<ModelDimensions> dimensions = SlicerService.parseModelDimensionsFromInfoOutput(output);
assertTrue(dimensions.isPresent());
assertEquals(97.909241, dimensions.get().xMm(), 0.000001);
assertEquals(97.909241, dimensions.get().yMm(), 0.000001);
assertEquals(70.000008, dimensions.get().zMm(), 0.000001);
}
@Test
void parseModelDimensionsFromInfoOutput_missingValues_returnsEmpty() {
String output = """
[model.step]
size_x = 10.0
size_y = 20.0
""";
Optional<ModelDimensions> dimensions = SlicerService.parseModelDimensionsFromInfoOutput(output);
assertTrue(dimensions.isEmpty());
}
}

View File

@@ -51,12 +51,21 @@
<input <input
type="number" type="number"
min="1" min="1"
[max]="maxInputQuantity"
[ngModel]="item.quantity" [ngModel]="item.quantity"
(ngModelChange)="updateQuantity(i, $event)" (ngModelChange)="updateQuantity(i, $event)"
class="qty-input"> class="qty-input">
</div> </div>
<div class="item-price"> <div class="item-price">
{{ (item.unitPrice * item.quantity) | currency:result().currency }} <span class="item-total-price">
{{ (item.unitPrice * item.quantity) | currency:result().currency }}
</span>
<small class="item-unit-price" *ngIf="item.quantity > 1; else unitPricePlaceholder">
{{ item.unitPrice | currency:result().currency }} {{ 'CHECKOUT.PER_PIECE' | translate }}
</small>
<ng-template #unitPricePlaceholder>
<small class="item-unit-price item-unit-price--placeholder">&nbsp;</small>
</ng-template>
</div> </div>
</div> </div>
</div> </div>
@@ -67,9 +76,13 @@
<app-button variant="outline" (click)="consult.emit()"> <app-button variant="outline" (click)="consult.emit()">
{{ 'QUOTE.CONSULT' | translate }} {{ 'QUOTE.CONSULT' | translate }}
</app-button> </app-button>
<app-button (click)="proceed.emit()"> @if (!hasQuantityOverLimit()) {
{{ 'QUOTE.PROCEED_ORDER' | translate }} <app-button (click)="proceed.emit()">
</app-button> {{ 'QUOTE.PROCEED_ORDER' | translate }}
</app-button>
} @else {
<small class="limit-note">{{ 'QUOTE.MAX_QTY_NOTICE' | translate:{ max: directOrderLimit } }}</small>
}
</div> </div>
</app-card> </app-card>

View File

@@ -60,6 +60,27 @@
font-weight: 600; font-weight: 600;
min-width: 60px; min-width: 60px;
text-align: right; text-align: right;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
min-height: 2.1rem;
}
.item-total-price {
line-height: 1.1;
}
.item-unit-price {
margin-top: 2px;
font-size: 0.72rem;
font-weight: 400;
color: var(--color-text-muted);
line-height: 1.2;
}
.item-unit-price--placeholder {
visibility: hidden;
} }
.result-grid { .result-grid {
@@ -84,6 +105,13 @@
.actions { display: flex; flex-direction: column; gap: var(--space-3); } .actions { display: flex; flex-direction: column; gap: var(--space-3); }
.limit-note {
font-size: 0.8rem;
color: var(--color-text-muted);
text-align: center;
margin-top: calc(var(--space-2) * -1);
}
.notes-section { .notes-section {
margin-top: var(--space-4); margin-top: var(--space-4);
margin-bottom: var(--space-4); margin-bottom: var(--space-4);

View File

@@ -15,6 +15,9 @@ import { QuoteResult, QuoteItem } from '../../services/quote-estimator.service';
styleUrl: './quote-result.component.scss' styleUrl: './quote-result.component.scss'
}) })
export class QuoteResultComponent { export class QuoteResultComponent {
readonly maxInputQuantity = 500;
readonly directOrderLimit = 100;
result = input.required<QuoteResult>(); result = input.required<QuoteResult>();
consult = output<void>(); consult = output<void>();
proceed = output<void>(); proceed = output<void>();
@@ -34,20 +37,23 @@ export class QuoteResultComponent {
updateQuantity(index: number, newQty: number | string) { updateQuantity(index: number, newQty: number | string) {
const qty = typeof newQty === 'string' ? parseInt(newQty, 10) : newQty; const qty = typeof newQty === 'string' ? parseInt(newQty, 10) : newQty;
if (qty < 1 || isNaN(qty)) return; if (qty < 1 || isNaN(qty)) return;
const normalizedQty = Math.min(qty, this.maxInputQuantity);
this.items.update(current => { this.items.update(current => {
const updated = [...current]; const updated = [...current];
updated[index] = { ...updated[index], quantity: qty }; updated[index] = { ...updated[index], quantity: normalizedQty };
return updated; return updated;
}); });
this.itemChange.emit({ this.itemChange.emit({
id: this.items()[index].id, id: this.items()[index].id,
fileName: this.items()[index].fileName, fileName: this.items()[index].fileName,
quantity: qty quantity: normalizedQty
}); });
} }
hasQuantityOverLimit = computed(() => this.items().some(item => item.quantity > this.directOrderLimit));
totals = computed(() => { totals = computed(() => {
const currentItems = this.items(); const currentItems = this.items();
const setup = this.result().setupCost; const setup = this.result().setupCost;

View File

@@ -122,10 +122,15 @@
<div class="summary-item" *ngFor="let item of quote()!.items"> <div class="summary-item" *ngFor="let item of quote()!.items">
<div class="item-info"> <div class="item-info">
<span class="item-name">{{ item.fileName }}</span> <span class="item-name">{{ item.fileName }}</span>
<span class="item-meta">{{ item.material }} - {{ item.color || 'Default' }}</span> <span class="item-meta">{{ item.material }} - {{ item.color || ('USER_DETAILS.DEFAULT_COLOR' | translate) }}</span>
</div> </div>
<div class="item-qty">x{{ item.quantity }}</div> <div class="item-qty">x{{ item.quantity }}</div>
<div class="item-price">{{ (item.unitPrice * item.quantity) | currency:'CHF' }}</div> <div class="item-price">
<span class="item-total-price">{{ (item.unitPrice * item.quantity) | currency:'CHF' }}</span>
<small class="item-unit-price" *ngIf="item.quantity > 1">
{{ item.unitPrice | currency:'CHF' }} {{ 'CHECKOUT.PER_PIECE' | translate }}
</small>
</div>
</div> </div>
<hr> <hr>

View File

@@ -112,6 +112,17 @@
.item-price { .item-price {
font-weight: 600; font-weight: 600;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.item-unit-price {
margin-top: 2px;
font-size: 0.72rem;
font-weight: 400;
color: var(--color-text-muted);
line-height: 1.2;
} }
.total-row { .total-row {

View File

@@ -10,7 +10,7 @@
<div class="checkout-form-section"> <div class="checkout-form-section">
<!-- Error Message --> <!-- Error Message -->
<div *ngIf="error" class="error-message"> <div *ngIf="error" class="error-message">
{{ error }} {{ error | translate }}
</div> </div>
<form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error"> <form [formGroup]="checkoutForm" (ngSubmit)="onSubmit()" *ngIf="!error">
@@ -142,7 +142,12 @@
</div> </div>
</div> </div>
<div class="item-price"> <div class="item-price">
{{ (item.unitPriceChf * item.quantity) | currency:'CHF' }} <span class="item-total-price">
{{ (item.unitPriceChf * item.quantity) | currency:'CHF' }}
</span>
<small class="item-unit-price" *ngIf="item.quantity > 1">
{{ item.unitPriceChf | currency:'CHF' }} {{ 'CHECKOUT.PER_PIECE' | translate }}
</small>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -240,6 +240,17 @@ app-toggle-selector.user-type-selector-compact {
margin-left: var(--space-3); margin-left: var(--space-3);
white-space: nowrap; white-space: nowrap;
color: var(--color-text); color: var(--color-text);
display: flex;
flex-direction: column;
align-items: flex-end;
.item-unit-price {
margin-top: 2px;
font-size: 0.75rem;
font-weight: 400;
color: var(--color-text-muted);
line-height: 1.2;
}
} }
} }

View File

@@ -7,7 +7,8 @@
"QUOTE": { "QUOTE": {
"CONSULT": "Request Consultation", "CONSULT": "Request Consultation",
"PROCEED_ORDER": "Proceed to Order", "PROCEED_ORDER": "Proceed to Order",
"TOTAL": "Total Estimate" "TOTAL": "Total Estimate",
"MAX_QTY_NOTICE": "For quantities above {{max}} pieces, request consultation."
}, },
"USER_DETAILS": { "USER_DETAILS": {
"TITLE": "Shipping Details", "TITLE": "Shipping Details",
@@ -208,6 +209,7 @@
"SETUP_FEE": "Setup Fee", "SETUP_FEE": "Setup Fee",
"TOTAL": "Total", "TOTAL": "Total",
"QTY": "Qty", "QTY": "Qty",
"PER_PIECE": "per piece",
"SHIPPING": "Shipping" "SHIPPING": "Shipping"
}, },
"PAYMENT": { "PAYMENT": {

View File

@@ -53,7 +53,7 @@
"CARD_SHOP_3_TITLE": "Su richiesta", "CARD_SHOP_3_TITLE": "Su richiesta",
"CARD_SHOP_3_TEXT": "Non trovi quello che serve? Lo progettiamo e lo produciamo per te.", "CARD_SHOP_3_TEXT": "Non trovi quello che serve? Lo progettiamo e lo produciamo per te.",
"SEC_ABOUT_TITLE": "Su di noi", "SEC_ABOUT_TITLE": "Su di noi",
"SEC_ABOUT_TEXT": "Siamo due studenti di ingegneria: la stampa 3D ci ha conquistati per un motivo semplice, vedere un problema e costruire la soluzione. Da questa idea prendono forma prototipi, componenti oggetti pensati per funzionare nella quotidianità. ", "SEC_ABOUT_TEXT": "Siamo due studenti di ingegneria: la stampa 3D ci ha conquistati per un motivo semplice, vedere un problema e costruire la soluzione. Da questa idea prendono forma prototipi, oggetti pensati per funzionare nella quotidianità. ",
"FOUNDERS_PHOTO": "Foto Founders" "FOUNDERS_PHOTO": "Foto Founders"
}, },
"CALC": { "CALC": {
@@ -103,13 +103,14 @@
"PROCESSING": "Elaborazione...", "PROCESSING": "Elaborazione...",
"NOTES_PLACEHOLDER": "Istruzioni specifiche...", "NOTES_PLACEHOLDER": "Istruzioni specifiche...",
"SETUP_NOTE": "* Include {{cost}} Costo di Setup", "SETUP_NOTE": "* Include {{cost}} Costo di Setup",
"SHIPPING_NOTE": "** costi di spedizione esclusi calcolati al prossimo passaggio", "SHIPPING_NOTE": "** costi di spedizione esclusi, calcolati al prossimo passaggio",
"STEP_WARNING": "La visualizzazione 3D non è compatibile con i file step e 3mf" "STEP_WARNING": "La visualizzazione 3D non è compatibile con i file step e 3mf"
}, },
"QUOTE": { "QUOTE": {
"PROCEED_ORDER": "Procedi con l'ordine", "PROCEED_ORDER": "Procedi con l'ordine",
"CONSULT": "Richiedi Consulenza", "CONSULT": "Richiedi Consulenza",
"TOTAL": "Totale" "TOTAL": "Totale",
"MAX_QTY_NOTICE": "Per quantità oltre {{max}} pezzi, richiedi consulenza."
}, },
"USER_DETAILS": { "USER_DETAILS": {
"TITLE": "I tuoi dati", "TITLE": "I tuoi dati",
@@ -153,7 +154,7 @@
"TITLE": "Chi Siamo", "TITLE": "Chi Siamo",
"EYEBROW": "Laboratorio di stampa 3D", "EYEBROW": "Laboratorio di stampa 3D",
"SUBTITLE": "Siamo due studenti con tanta voglia di fare e di imparare.", "SUBTITLE": "Siamo due studenti con tanta voglia di fare e di imparare.",
"HOW_TEXT": "3D Fab nasce dallinteresse iniziale di Matteo per la stampa 3D. Ha comprato una stampante e ha iniziato a sperimentare sul serio. \n A un certo punto sono arrivate le prime richieste: un pezzo rotto da sostituire, un ricambio che non si trova, un adattatore comodo da avere. Le richieste sono aumentate e ci siamo detti: ok, facciamolo bene.\nOggi trasformiamo bisogni concreti e idee in pezzi stampati, con costi trasparenti.\nIn seguito abbiamo creato un calcolatore per capire il costo in anticipo: è stato uno dei primi passi che ci ha fatto passare dal “facciamo qualche pezzo” a un progetto vero, insieme.", "HOW_TEXT": "3D Fab nasce dallinteresse iniziale di Matteo per la stampa 3D. Ha comprato una stampante e ha iniziato a sperimentare sul serio. \n A un certo punto sono arrivate le prime richieste: un pezzo rotto da sostituire, un ricambio che non si trova, un adattatore comodo da avere. Le richieste sono aumentate e ci siamo detti: ok, facciamolo bene.\nIn seguito abbiamo creato un calcolatore per capire il costo in anticipo: è stato uno dei primi passi che ci ha fatto passare dal “facciamo qualche pezzo” a un progetto vero, insieme.",
"PASSIONS_TITLE": "Le nostre passioni", "PASSIONS_TITLE": "Le nostre passioni",
"PASSION_BIKE_TRIAL": "Bike trial", "PASSION_BIKE_TRIAL": "Bike trial",
"PASSION_MOUNTAIN": "Montagna", "PASSION_MOUNTAIN": "Montagna",
@@ -411,6 +412,7 @@
"SETUP_FEE": "Costo di Avvio", "SETUP_FEE": "Costo di Avvio",
"TOTAL": "Totale", "TOTAL": "Totale",
"QTY": "Qtà", "QTY": "Qtà",
"PER_PIECE": "al pezzo",
"SHIPPING": "Spedizione (CH)", "SHIPPING": "Spedizione (CH)",
"INVALID_EMAIL": "Email non valida", "INVALID_EMAIL": "Email non valida",
"COMPANY_OPTIONAL": "Nome Azienda (Opzionale)", "COMPANY_OPTIONAL": "Nome Azienda (Opzionale)",