Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 26s
PR Checks / security-sast (pull_request) Successful in 31s
Build and Deploy / test-frontend (push) Successful in 1m2s
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / test-backend (pull_request) Successful in 30s
Build and Deploy / build-and-push (push) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / deploy (push) Successful in 9s

This commit is contained in:
2026-03-10 15:32:59 +01:00
9 changed files with 81 additions and 47 deletions

View File

@@ -257,9 +257,7 @@
<p class="file-name"> <p class="file-name">
<strong>{{ itemDisplayName(item) }}</strong> <strong>{{ itemDisplayName(item) }}</strong>
</p> </p>
<span <span class="item-kind-badge">
class="item-kind-badge"
>
{{ isShopItem(item) ? "Shop" : "Calcolatore" }} {{ isShopItem(item) ? "Shop" : "Calcolatore" }}
</span> </span>
</div> </div>

View File

@@ -714,9 +714,7 @@
</span> </span>
<textarea <textarea
class="ui-form-control" class="ui-form-control"
[(ngModel)]=" [(ngModel)]="productForm.seoDescriptions[activeContentLanguage]"
productForm.seoDescriptions[activeContentLanguage]
"
[name]="'product-seo-description-' + activeContentLanguage" [name]="'product-seo-description-' + activeContentLanguage"
rows="3" rows="3"
></textarea> ></textarea>

View File

@@ -605,7 +605,9 @@ export class AdminShopComponent implements OnInit, OnDestroy {
} }
availableMaterialChoices(currentMaterialCode: string): string[] { availableMaterialChoices(currentMaterialCode: string): string[] {
const normalizedCurrentMaterialCode = currentMaterialCode.trim().toUpperCase(); const normalizedCurrentMaterialCode = currentMaterialCode
.trim()
.toUpperCase();
const selectedCodes = new Set( const selectedCodes = new Set(
this.productForm.materials this.productForm.materials
.map((material) => material.materialCode.trim().toUpperCase()) .map((material) => material.materialCode.trim().toUpperCase())
@@ -804,10 +806,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
isImageLanguageStarted(language: AdminMediaLanguage): boolean { isImageLanguageStarted(language: AdminMediaLanguage): boolean {
const translation = this.imageUploadState.translations[language]; const translation = this.imageUploadState.translations[language];
return ( return !!translation.title.trim() || !!translation.altText.trim();
!!translation.title.trim() ||
!!translation.altText.trim()
);
} }
isImageLanguageIncomplete(language: AdminMediaLanguage): boolean { isImageLanguageIncomplete(language: AdminMediaLanguage): boolean {
@@ -1291,7 +1290,9 @@ export class AdminShopComponent implements OnInit, OnDestroy {
const groups = new Map<string, AdminShopProductVariant[]>(); const groups = new Map<string, AdminShopProductVariant[]>();
for (const variant of variants) { for (const variant of variants) {
const materialCode = (variant.internalMaterialCode ?? '').trim().toUpperCase(); const materialCode = (variant.internalMaterialCode ?? '')
.trim()
.toUpperCase();
if (!materialCode) { if (!materialCode) {
continue; continue;
} }
@@ -1403,7 +1404,9 @@ export class AdminShopComponent implements OnInit, OnDestroy {
seoTitleEn: this.optionalValue(this.productForm.seoTitles['en']), seoTitleEn: this.optionalValue(this.productForm.seoTitles['en']),
seoTitleDe: this.optionalValue(this.productForm.seoTitles['de']), seoTitleDe: this.optionalValue(this.productForm.seoTitles['de']),
seoTitleFr: this.optionalValue(this.productForm.seoTitles['fr']), seoTitleFr: this.optionalValue(this.productForm.seoTitles['fr']),
seoDescription: this.optionalValue(this.productForm.seoDescriptions['it']), seoDescription: this.optionalValue(
this.productForm.seoDescriptions['it'],
),
seoDescriptionIt: this.optionalValue( seoDescriptionIt: this.optionalValue(
this.productForm.seoDescriptions['it'], this.productForm.seoDescriptions['it'],
), ),
@@ -1461,11 +1464,16 @@ export class AdminShopComponent implements OnInit, OnDestroy {
let defaultVariantKeyForMaterial: string | null = null; let defaultVariantKeyForMaterial: string | null = null;
if (material.isDefault && persistedDefaultKey) { if (material.isDefault && persistedDefaultKey) {
defaultVariantKeyForMaterial = stockVariants defaultVariantKeyForMaterial =
.map((variant) => stockVariants
this.variantKey(materialCode, variant.colorName, variant.colorHex), .map((variant) =>
) this.variantKey(
.find((variantKey) => variantKey === persistedDefaultKey) ?? null; materialCode,
variant.colorName,
variant.colorHex,
),
)
.find((variantKey) => variantKey === persistedDefaultKey) ?? null;
} }
stockVariants.forEach((stockVariant, colorIndex) => { stockVariants.forEach((stockVariant, colorIndex) => {
@@ -1487,7 +1495,9 @@ export class AdminShopComponent implements OnInit, OnDestroy {
sku: this.optionalValue(existingVariant?.sku ?? ''), sku: this.optionalValue(existingVariant?.sku ?? ''),
variantLabel: materialCode, variantLabel: materialCode,
colorName: stockVariant.colorName.trim(), colorName: stockVariant.colorName.trim(),
colorHex: this.optionalValue(stockVariant.colorHex ?? '')?.toUpperCase(), colorHex: this.optionalValue(
stockVariant.colorHex ?? '',
)?.toUpperCase(),
internalMaterialCode: materialCode, internalMaterialCode: materialCode,
priceChf: Number(material.priceChf), priceChf: Number(material.priceChf),
isDefault, isDefault,
@@ -1518,7 +1528,9 @@ export class AdminShopComponent implements OnInit, OnDestroy {
).sort((left, right) => left.localeCompare(right)); ).sort((left, right) => left.localeCompare(right));
} }
private stockVariantsForMaterial(materialCode: string): AdminFilamentVariant[] { private stockVariantsForMaterial(
materialCode: string,
): AdminFilamentVariant[] {
const targetMaterialCode = materialCode.trim().toUpperCase(); const targetMaterialCode = materialCode.trim().toUpperCase();
const seenKeys = new Set<string>(); const seenKeys = new Set<string>();
@@ -1529,7 +1541,8 @@ export class AdminShopComponent implements OnInit, OnDestroy {
) )
.sort((left, right) => { .sort((left, right) => {
const leftName = `${left.colorName} ${left.variantDisplayName}`.trim(); const leftName = `${left.colorName} ${left.variantDisplayName}`.trim();
const rightName = `${right.colorName} ${right.variantDisplayName}`.trim(); const rightName =
`${right.colorName} ${right.variantDisplayName}`.trim();
return leftName.localeCompare(rightName); return leftName.localeCompare(rightName);
}) })
.filter((variant) => { .filter((variant) => {
@@ -1554,8 +1567,9 @@ export class AdminShopComponent implements OnInit, OnDestroy {
); );
return ( return (
this.stockMaterialCodes().find((materialCode) => !selectedCodes.has(materialCode)) ?? this.stockMaterialCodes().find(
null (materialCode) => !selectedCodes.has(materialCode),
) ?? null
); );
} }

View File

@@ -56,18 +56,15 @@
(click)="addToCart()" (click)="addToCart()"
[disabled]="!defaultVariantId() || addingToCart()" [disabled]="!defaultVariantId() || addingToCart()"
> >
{{ {{ (addingToCart() ? "SHOP.ADDING" : "SHOP.ADD_CART") | translate }}
(addingToCart() ? "SHOP.ADDING" : "SHOP.ADD_CART") | translate
}}
</button> </button>
<a <a
[routerLink]="productLink()" [routerLink]="productLink()"
[state]="navigationState()" [state]="navigationState()"
class="view-btn" class="view-btn"
>{{ >{{ "SHOP.DETAILS" | translate }}</a
"SHOP.DETAILS" | translate >
}}</a>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -13,7 +13,7 @@
@if (error()) { @if (error()) {
<div class="state-card">{{ error() | translate }}</div> <div class="state-card">{{ error() | translate }}</div>
} @else { } @else {
@if (product(); as p) { @if (product(); as p) {
<nav class="breadcrumbs"> <nav class="breadcrumbs">
<a [routerLink]="shopRootLink()">{{ <a [routerLink]="shopRootLink()">{{
"SHOP.BREADCRUMB_ROOT" | translate "SHOP.BREADCRUMB_ROOT" | translate
@@ -83,7 +83,9 @@
@if (p.model3d) { @if (p.model3d) {
<div class="model-launch-row"> <div class="model-launch-row">
<div> <div>
<p class="viewer-kicker">{{ "SHOP.MODEL_3D" | translate }}</p> <p class="viewer-kicker">
{{ "SHOP.MODEL_3D" | translate }}
</p>
<div class="dimensions dimensions-inline"> <div class="dimensions dimensions-inline">
<span> <span>
X X
@@ -151,7 +153,9 @@
<button <button
type="button" type="button"
class="material-option" class="material-option"
[class.active]="selectedMaterial()?.key === material.key" [class.active]="
selectedMaterial()?.key === material.key
"
(click)="selectMaterial(material.key)" (click)="selectMaterial(material.key)"
> >
<span class="material-copy"> <span class="material-copy">
@@ -228,7 +232,7 @@
></button> ></button>
<div class="color-popup"> <div class="color-popup">
<div class="color-popup__category"> <div class="color-popup__category">
{{ (selectedMaterial()?.label || "") | uppercase }} {{ selectedMaterial()?.label || "" | uppercase }}
</div> </div>
<div class="color-popup__grid"> <div class="color-popup__grid">

View File

@@ -139,8 +139,8 @@ export class ProductDetailComponent {
); );
}); });
readonly colorOptions = computed<ShopProductVariantOption[]>(() => readonly colorOptions = computed<ShopProductVariantOption[]>(
this.selectedMaterial()?.variants ?? [], () => this.selectedMaterial()?.variants ?? [],
); );
readonly selectedMaterialProperties = computed<ShopMaterialProperty[]>(() => readonly selectedMaterialProperties = computed<ShopMaterialProperty[]>(() =>
@@ -494,11 +494,15 @@ export class ProductDetailComponent {
}); });
} }
private materialLabelForVariant(variant: ShopProductVariantOption | null): string { private materialLabelForVariant(
variant: ShopProductVariantOption | null,
): string {
return String(variant?.variantLabel || '').trim() || 'Standard'; return String(variant?.variantLabel || '').trim() || 'Standard';
} }
private materialKeyForVariant(variant: ShopProductVariantOption | null): string | null { private materialKeyForVariant(
variant: ShopProductVariantOption | null,
): string | null {
if (!variant) { if (!variant) {
return null; return null;
} }
@@ -508,7 +512,9 @@ export class ProductDetailComponent {
private materialPropertiesFor( private materialPropertiesFor(
materialLabel: string | null | undefined, materialLabel: string | null | undefined,
): ShopMaterialProperty[] { ): ShopMaterialProperty[] {
const normalized = String(materialLabel ?? '').trim().toUpperCase(); const normalized = String(materialLabel ?? '')
.trim()
.toUpperCase();
if (normalized.includes('ASA')) { if (normalized.includes('ASA')) {
return [ return [
@@ -600,7 +606,13 @@ export class ProductDetailComponent {
const currentTree = this.router.parseUrl(this.router.url); const currentTree = this.router.parseUrl(this.router.url);
const targetTree = this.router.createUrlTree( const targetTree = this.router.createUrlTree(
['/', this.languageService.selectedLang(), 'shop', 'p', targetProductSlug], [
'/',
this.languageService.selectedLang(),
'shop',
'p',
targetProductSlug,
],
{ {
queryParams: currentTree.queryParams, queryParams: currentTree.queryParams,
fragment: currentTree.fragment ?? undefined, fragment: currentTree.fragment ?? undefined,

View File

@@ -38,8 +38,12 @@ export class ShopRouteService {
return idPrefix ? `${idPrefix}-${tail}` : tail; return idPrefix ? `${idPrefix}-${tail}` : tail;
} }
resolveProductLookup(productPathSegment: string | null | undefined): ShopProductLookup { resolveProductLookup(
const normalized = String(productPathSegment ?? '').trim().toLowerCase(); productPathSegment: string | null | undefined,
): ShopProductLookup {
const normalized = String(productPathSegment ?? '')
.trim()
.toLowerCase();
if (!normalized) { if (!normalized) {
return { return {
idPrefix: null, idPrefix: null,
@@ -89,7 +93,9 @@ export class ShopRouteService {
} }
private productIdPrefix(productId: string | null | undefined): string { private productIdPrefix(productId: string | null | undefined): string {
const normalized = String(productId ?? '').trim().toLowerCase(); const normalized = String(productId ?? '')
.trim()
.toLowerCase();
const canonicalUuidMatch = normalized.match(/^([0-9a-f]{8})-/); const canonicalUuidMatch = normalized.match(/^([0-9a-f]{8})-/);
if (canonicalUuidMatch) { if (canonicalUuidMatch) {
return canonicalUuidMatch[1]; return canonicalUuidMatch[1];

View File

@@ -273,15 +273,16 @@ export class ShopService {
getProductByPublicPath( getProductByPublicPath(
productPathSegment: string, productPathSegment: string,
): Observable<ShopProductDetail> { ): Observable<ShopProductDetail> {
const lookup = this.shopRouteService.resolveProductLookup(productPathSegment); const lookup =
this.shopRouteService.resolveProductLookup(productPathSegment);
if (!lookup.idPrefix && lookup.slugHint) { if (!lookup.idPrefix && lookup.slugHint) {
return this.getProduct(lookup.slugHint); return this.getProduct(lookup.slugHint);
} }
return this.getProductCatalog().pipe( return this.getProductCatalog().pipe(
map((catalog) => map((catalog) =>
catalog.products.find( catalog.products.find((product) =>
(product) => product.id.toLowerCase().startsWith(lookup.idPrefix ?? ''), product.id.toLowerCase().startsWith(lookup.idPrefix ?? ''),
), ),
), ),
switchMap((product) => { switchMap((product) => {

View File

@@ -32,7 +32,9 @@
[class.active]="!currentCategorySlug()" [class.active]="!currentCategorySlug()"
(click)="navigateToCategory()" (click)="navigateToCategory()"
> >
<span class="category-name">{{ "SHOP.ALL_CATEGORIES" | translate }}</span> <span class="category-name">{{
"SHOP.ALL_CATEGORIES" | translate
}}</span>
</button> </button>
<div class="category-list"> <div class="category-list">
@@ -227,7 +229,9 @@
<app-card class="shop-custom-cta-card"> <app-card class="shop-custom-cta-card">
<div class="shop-custom-cta-inner"> <div class="shop-custom-cta-inner">
<div class="shop-custom-cta-copy"> <div class="shop-custom-cta-copy">
<p class="panel-kicker">{{ "SHOP.CUSTOM_PART_FOOTER_TITLE" | translate }}</p> <p class="panel-kicker">
{{ "SHOP.CUSTOM_PART_FOOTER_TITLE" | translate }}
</p>
<h2 class="shop-custom-cta-title"> <h2 class="shop-custom-cta-title">
{{ "SHOP.CUSTOM_PART_FOOTER_TEXT" | translate }} {{ "SHOP.CUSTOM_PART_FOOTER_TEXT" | translate }}
</h2> </h2>