Merge remote-tracking branch 'origin/feat/shop' into feat/shop
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 26s
PR Checks / test-frontend (pull_request) Successful in 1m1s

# Conflicts:
#	frontend/src/app/features/shop/components/product-card/product-card.component.scss
#	frontend/src/app/features/shop/product-detail.component.scss
#	frontend/src/app/features/shop/shop-page.component.html
#	frontend/src/app/features/shop/shop-page.component.scss
#	frontend/src/app/features/shop/shop-page.component.ts
This commit is contained in:
2026-03-10 10:53:42 +01:00
15 changed files with 200 additions and 49 deletions

View File

@@ -75,7 +75,10 @@
[ngModel]="orderTypeFilter"
(ngModelChange)="onOrderTypeFilterChange($event)"
>
<option *ngFor="let option of orderTypeFilterOptions" [ngValue]="option">
<option
*ngFor="let option of orderTypeFilterOptions"
[ngValue]="option"
>
{{ option }}
</option>
</select>
@@ -133,7 +136,9 @@
<span
class="order-type-badge"
[class.order-type-badge--shop]="orderKind(selectedOrder) === 'SHOP'"
[class.order-type-badge--mixed]="orderKind(selectedOrder) === 'MIXED'"
[class.order-type-badge--mixed]="
orderKind(selectedOrder) === 'MIXED'
"
>
{{ orderKindLabel(selectedOrder) }}
</span>
@@ -162,7 +167,8 @@
<strong>Stato ordine</strong><span>{{ selectedOrder.status }}</span>
</div>
<div class="ui-meta-item">
<strong>Tipo ordine</strong><span>{{ orderKindLabel(selectedOrder) }}</span>
<strong>Tipo ordine</strong
><span>{{ orderKindLabel(selectedOrder) }}</span>
</div>
<div class="ui-meta-item">
<strong>Totale</strong
@@ -279,7 +285,9 @@
></span>
<span>
{{ getItemColorLabel(item) }}
<ng-container *ngIf="getItemColorCodeSuffix(item) as colorCode">
<ng-container
*ngIf="getItemColorCodeSuffix(item) as colorCode"
>
({{ colorCode }})
</ng-container>
</span>
@@ -300,7 +308,12 @@
<button
type="button"
class="ui-button ui-button--ghost"
(click)="downloadItemFile(item.id, item.originalFilename || itemDisplayName(item))"
(click)="
downloadItemFile(
item.id,
item.originalFilename || itemDisplayName(item)
)
"
>
{{ downloadItemLabel(item) }}
</button>
@@ -373,7 +386,10 @@
<h4>Parametri per file</h4>
<div class="file-color-list">
<div class="file-color-row" *ngFor="let item of printItems(selectedOrder)">
<div
class="file-color-row"
*ngFor="let item of printItems(selectedOrder)"
>
<span class="filename">{{ item.originalFilename }}</span>
<span class="file-color">
{{ getItemMaterialLabel(item) }} | Colore:

View File

@@ -131,7 +131,8 @@ export class AdminDashboardComponent implements OnInit {
this.selectedOrder = order;
this.selectedStatus = order.status;
this.selectedPaymentMethod = order.paymentMethod || 'OTHER';
this.showPrintDetails = this.showPrintDetails && this.hasPrintItems(order);
this.showPrintDetails =
this.showPrintDetails && this.hasPrintItems(order);
this.detailLoading = false;
},
error: () => {
@@ -446,7 +447,8 @@ export class AdminDashboardComponent implements OnInit {
this.selectedStatus = updatedOrder.status;
this.selectedPaymentMethod =
updatedOrder.paymentMethod || this.selectedPaymentMethod;
this.showPrintDetails = this.showPrintDetails && this.hasPrintItems(updatedOrder);
this.showPrintDetails =
this.showPrintDetails && this.hasPrintItems(updatedOrder);
}
private applyListFiltersAndSelection(): void {

View File

@@ -393,10 +393,7 @@ export class CheckoutComponent implements OnInit {
}
private loadStlPreviews(session: any): void {
if (
!this.sessionId ||
!Array.isArray(session?.items)
) {
if (!this.sessionId || !Array.isArray(session?.items)) {
return;
}

View File

@@ -161,7 +161,9 @@
<app-button
variant="outline"
(click)="completeOrder()"
[disabled]="!selectedPaymentMethod || o.paymentStatus === 'REPORTED'"
[disabled]="
!selectedPaymentMethod || o.paymentStatus === 'REPORTED'
"
[fullWidth]="true"
>
{{
@@ -201,10 +203,14 @@
</div>
<div class="order-item-meta">
<span>{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span>
<span
>{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span
>
<span *ngIf="showItemMaterial(item)">
{{ "CHECKOUT.MATERIAL" | translate }}:
{{ item.materialCode || ("ORDER.NOT_AVAILABLE" | translate) }}
{{
item.materialCode || ("ORDER.NOT_AVAILABLE" | translate)
}}
</span>
<span *ngIf="itemVariantLabel(item) as variantLabel">
{{ "SHOP.VARIANT" | translate }}: {{ variantLabel }}
@@ -252,7 +258,9 @@
<strong>{{ orderKindLabel(o) }}</strong>
</div>
<div>
<span class="summary-label">{{ "ORDER.ITEM_COUNT" | translate }}</span>
<span class="summary-label">{{
"ORDER.ITEM_COUNT" | translate
}}</span>
<strong>{{ (o.items || []).length }}</strong>
</div>
</div>

View File

@@ -263,7 +263,9 @@ export class OrderComponent implements OnInit {
return shopName;
}
return String(item?.originalFilename ?? this.translate.instant('ORDER.NOT_AVAILABLE'));
return String(
item?.originalFilename ?? this.translate.instant('ORDER.NOT_AVAILABLE'),
);
}
itemVariantLabel(item: PublicOrderItem): string | null {

View File

@@ -41,9 +41,7 @@
<div class="pricing">
<span class="price">{{ priceLabel() | currency: "CHF" }}</span>
@if (hasPriceRange()) {
<small class="price-note">{{
"SHOP.PRICE_FROM" | translate
}}</small>
<small class="price-note">{{ "SHOP.PRICE_FROM" | translate }}</small>
}
</div>

View File

@@ -44,7 +44,9 @@
<button
type="button"
class="thumb"
[class.active]="selectedImage().mediaAssetId === image.mediaAssetId"
[class.active]="
selectedImage().mediaAssetId === image.mediaAssetId
"
(click)="selectImage(image.mediaAssetId)"
>
@if (imageUrl(image); as imageUrl) {
@@ -68,13 +70,16 @@
</div>
<div class="dimensions">
<span>
X {{ p.model3d.boundingBoxXMm || 0 | number: "1.0-1" }} mm
X
{{ p.model3d.boundingBoxXMm || 0 | number: "1.0-1" }} mm
</span>
<span>
Y {{ p.model3d.boundingBoxYMm || 0 | number: "1.0-1" }} mm
Y
{{ p.model3d.boundingBoxYMm || 0 | number: "1.0-1" }} mm
</span>
<span>
Z {{ p.model3d.boundingBoxZMm || 0 | number: "1.0-1" }} mm
Z
{{ p.model3d.boundingBoxZMm || 0 | number: "1.0-1" }} mm
</span>
</div>
</div>
@@ -128,7 +133,8 @@
<span class="cart-pill">
{{
"SHOP.IN_CART_LONG"
| translate: { count: selectedVariantCartQuantity() }
| translate
: { count: selectedVariantCartQuantity() }
}}
</span>
}
@@ -152,7 +158,9 @@
<small>{{ variant.variantLabel }}</small>
}
</span>
<strong>{{ variant.priceChf | currency: "CHF" }}</strong>
<strong>{{
variant.priceChf | currency: "CHF"
}}</strong>
</button>
}
</div>
@@ -160,9 +168,13 @@
<div class="quantity-row">
<span>{{ "SHOP.QUANTITY" | translate }}</span>
<div class="qty-control">
<button type="button" (click)="decreaseQuantity()">-</button>
<button type="button" (click)="decreaseQuantity()">
-
</button>
<span>{{ quantity() }}</span>
<button type="button" (click)="increaseQuantity()">+</button>
<button type="button" (click)="increaseQuantity()">
+
</button>
</div>
</div>
@@ -173,9 +185,8 @@
(click)="addToCart()"
>
{{
(isAddingToCart()
? "SHOP.ADDING"
: "SHOP.ADD_CART") | translate
(isAddingToCart() ? "SHOP.ADDING" : "SHOP.ADD_CART")
| translate
}}
</app-button>

View File

@@ -121,7 +121,9 @@ export class ProductDetailComponent {
combineLatest([
toObservable(this.productSlug, { injector: this.injector }),
toObservable(this.languageService.currentLang, { injector: this.injector }),
toObservable(this.languageService.currentLang, {
injector: this.injector,
}),
])
.pipe(
tap(() => {
@@ -160,13 +162,22 @@ export class ProductDetailComponent {
}
this.product.set(product);
this.selectedVariantId.set(product.defaultVariant?.id ?? product.variants[0]?.id ?? null);
this.selectedImageAssetId.set(product.primaryImage?.mediaAssetId ?? product.images[0]?.mediaAssetId ?? null);
this.selectedVariantId.set(
product.defaultVariant?.id ?? product.variants[0]?.id ?? null,
);
this.selectedImageAssetId.set(
product.primaryImage?.mediaAssetId ??
product.images[0]?.mediaAssetId ??
null,
);
this.quantity.set(1);
this.applySeo(product);
if (product.model3d?.url && product.model3d.originalFilename) {
this.loadModelPreview(product.model3d.url, product.model3d.originalFilename);
this.loadModelPreview(
product.model3d.url,
product.model3d.originalFilename,
);
} else {
this.modelFile.set(null);
this.modelLoading.set(false);
@@ -239,7 +250,9 @@ export class ProductDetailComponent {
}
priceLabel(): number {
return this.selectedVariant()?.priceChf ?? this.product()?.priceFromChf ?? 0;
return (
this.selectedVariant()?.priceChf ?? this.product()?.priceFromChf ?? 0
);
}
colorLabel(variant: ShopProductVariantOption): string {
@@ -282,7 +295,8 @@ export class ProductDetailComponent {
product.seoDescription ||
product.excerpt ||
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
const robots = product.indexable === false ? 'noindex, nofollow' : 'index, follow';
const robots =
product.indexable === false ? 'noindex, nofollow' : 'index, follow';
this.seoService.applyPageSeo({
title,

View File

@@ -129,7 +129,9 @@ describe('ShopService', () => {
it('posts add-to-cart with credentials and replaces local cart state', () => {
service.addToCart('variant-red', 2).subscribe();
const request = httpMock.expectOne('http://localhost:8000/api/shop/cart/items');
const request = httpMock.expectOne(
'http://localhost:8000/api/shop/cart/items',
);
expect(request.request.method).toBe('POST');
expect(request.request.withCredentials).toBeTrue();
expect(request.request.body).toEqual({

View File

@@ -251,9 +251,12 @@ export class ShopService {
params = params.set('featured', String(featured));
}
return this.http.get<ShopProductCatalogResponse>(`${this.apiUrl}/products`, {
params,
});
return this.http.get<ShopProductCatalogResponse>(
`${this.apiUrl}/products`,
{
params,
},
);
}
getProduct(slug: string): Observable<ShopProductDetail> {
@@ -337,10 +340,7 @@ export class ShopService {
.pipe(tap((cart) => this.setCart(cart)));
}
getProductModelFile(
urlOrPath: string,
filename: string,
): Observable<File> {
getProductModelFile(urlOrPath: string, filename: string): Observable<File> {
return this.http
.get(this.resolveApiUrl(urlOrPath), {
responseType: 'blob',