fix(front-end): fix no index product 3 hope the last one
Some checks failed
Build and Deploy / test-backend (push) Successful in 39s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 32s
Build and Deploy / deploy (push) Successful in 19s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / prettier-autofix (pull_request) Failing after 11s
PR Checks / test-backend (pull_request) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m3s

This commit is contained in:
2026-03-24 12:59:09 +01:00
parent 81f6f78c49
commit d27558a3ee
5 changed files with 260 additions and 43 deletions

View File

@@ -4,7 +4,7 @@
← {{ "SHOP.BACK" | translate }} ← {{ "SHOP.BACK" | translate }}
</button> </button>
@if (loading()) { @if (loading() || softFallbackActive()) {
<div class="detail-grid skeleton-grid"> <div class="detail-grid skeleton-grid">
<div class="skeleton-block"></div> <div class="skeleton-block"></div>
<div class="skeleton-block"></div> <div class="skeleton-block"></div>

View File

@@ -35,6 +35,7 @@ import {
ShopService, ShopService,
} from './services/shop.service'; } from './services/shop.service';
import { ShopRouteService } from './services/shop-route.service'; import { ShopRouteService } from './services/shop-route.service';
import { humanizeShopSlug } from './shop-seo-fallback';
interface ShopMaterialOption { interface ShopMaterialOption {
key: string; key: string;
@@ -84,6 +85,7 @@ export class ProductDetailComponent {
); );
readonly loading = signal(true); readonly loading = signal(true);
readonly softFallbackActive = signal(false);
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
readonly product = signal<ShopProductDetail | null>(null); readonly product = signal<ShopProductDetail | null>(null);
readonly selectedVariantId = signal<string | null>(null); readonly selectedVariantId = signal<string | null>(null);
@@ -233,6 +235,7 @@ export class ProductDetailComponent {
.pipe( .pipe(
tap(() => { tap(() => {
this.loading.set(true); this.loading.set(true);
this.softFallbackActive.set(false);
this.error.set(null); this.error.set(null);
this.addSuccess.set(false); this.addSuccess.set(false);
this.modelError.set(false); this.modelError.set(false);
@@ -245,13 +248,14 @@ export class ProductDetailComponent {
this.languageService.clearLocalizedRouteOverrides(); this.languageService.clearLocalizedRouteOverrides();
this.error.set('SHOP.NOT_FOUND'); this.error.set('SHOP.NOT_FOUND');
this.setResponseStatus(404); this.setResponseStatus(404);
this.applyFallbackSeo(); this.applyHardFallbackSeo();
this.loading.set(false); this.loading.set(false);
return of(null); return of(null);
} }
const productSlug = routeParams.productSlug as string;
return this.shopService return this.shopService
.getProductByPublicPath(routeParams.productSlug) .getProductByPublicPath(productSlug)
.pipe( .pipe(
catchError((error) => { catchError((error) => {
this.languageService.clearLocalizedRouteOverrides(); this.languageService.clearLocalizedRouteOverrides();
@@ -260,13 +264,23 @@ export class ProductDetailComponent {
this.setSelectedImageAssetId(null); this.setSelectedImageAssetId(null);
this.modelFile.set(null); this.modelFile.set(null);
const isNotFound = error?.status === 404; const isNotFound = error?.status === 404;
this.error.set( if (isNotFound) {
isNotFound ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR', this.error.set('SHOP.NOT_FOUND');
); this.setResponseStatus(404);
this.setResponseStatus(isNotFound ? 404 : 503); this.applyHardFallbackSeo();
if (this.shouldApplyFallbackSeo(error)) { return of(null);
this.applyFallbackSeo();
} }
if (this.shouldUseSoftSeoFallback(error)) {
this.error.set(null);
this.softFallbackActive.set(true);
this.setResponseStatus(200);
this.applySoftFallbackSeo(productSlug);
return of(null);
}
this.error.set('SHOP.LOAD_ERROR');
this.setResponseStatus(503);
return of(null); return of(null);
}), }),
finalize(() => this.loading.set(false)), finalize(() => this.loading.set(false)),
@@ -280,6 +294,7 @@ export class ProductDetailComponent {
} }
this.product.set(product); this.product.set(product);
this.softFallbackActive.set(false);
this.selectedVariantId.set( this.selectedVariantId.set(
product.defaultVariant?.id ?? product.variants[0]?.id ?? null, product.defaultVariant?.id ?? product.variants[0]?.id ?? null,
); );
@@ -608,7 +623,7 @@ export class ProductDetailComponent {
}); });
} }
private applyFallbackSeo(): void { private applyHardFallbackSeo(): void {
const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`; const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`;
const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
this.seoService.applyResolvedSeo({ this.seoService.applyResolvedSeo({
@@ -623,12 +638,53 @@ export class ProductDetailComponent {
}); });
} }
private shouldApplyFallbackSeo(error: { status?: number } | null): boolean { private applySoftFallbackSeo(productSlug: string): void {
if (error?.status === 404) { const title = this.buildSoftFallbackTitle(productSlug);
return true; const description = this.resolveTranslatedText(
'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION',
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'),
);
this.seoService.applyResolvedSeo({
title,
description,
robots: 'index, follow',
ogTitle: title,
ogDescription: description,
canonicalPath: this.currentPath(),
alternates: null,
xDefault: null,
});
}
private shouldUseSoftSeoFallback(error: { status?: number } | null): boolean {
return !this.isBrowser && error?.status !== 404;
}
private buildSoftFallbackTitle(productSlug: string): string {
const humanized = humanizeShopSlug(productSlug, {
stripProductIdPrefix: true,
});
if (humanized) {
return `${humanized} | 3D fab`;
} }
return !this.isBrowser; return this.resolveTranslatedText(
'SEO.ROUTES.SHOP.PRODUCT_TITLE',
`${this.translate.instant('SHOP.TITLE')} | 3D fab`,
);
}
private resolveTranslatedText(key: string, fallback: string): string {
const translated = this.translate.instant(key);
return typeof translated === 'string' && translated !== key
? translated
: fallback;
}
private currentPath(): string {
const path = String(this.router.url ?? '/').split(/[?#]/, 1)[0] || '/';
return path.startsWith('/') ? path : `/${path}`;
} }
private materialLabelForVariant( private materialLabelForVariant(

View File

@@ -1,15 +1,7 @@
<section class="shop-page"> <section class="shop-page">
<div class="container ui-simple-hero shop-hero"> <div class="container ui-simple-hero shop-hero">
<h1 class="ui-simple-hero__title">{{ "NAV.SHOP" | translate }}</h1> <h1 class="ui-simple-hero__title">{{ "NAV.SHOP" | translate }}</h1>
<p class="ui-simple-hero__subtitle"> <p class="ui-simple-hero__subtitle">{{ heroSubtitle() }}</p>
{{
selectedCategory()
? selectedCategory()?.description ||
("SHOP.CATEGORY_META"
| translate: { count: selectedCategory()?.productCount || 0 })
: ("SHOP.SUBTITLE" | translate)
}}
</p>
</div> </div>
<div class="container shop-layout"> <div class="container shop-layout">
@@ -181,17 +173,9 @@
<div class="section-head catalog-head"> <div class="section-head catalog-head">
<div> <div>
<p class="ui-eyebrow ui-eyebrow--compact"> <p class="ui-eyebrow ui-eyebrow--compact">
{{ {{ catalogEyebrow() }}
selectedCategory()
? ("SHOP.SELECTED_CATEGORY" | translate)
: ("SHOP.CATALOG_LABEL" | translate)
}}
</p> </p>
<h2 class="section-title"> <h2 class="section-title">{{ catalogTitle() }}</h2>
{{
selectedCategory()?.name || ("SHOP.CATALOG_TITLE" | translate)
}}
</h2>
</div> </div>
<span class="catalog-counter"> <span class="catalog-counter">
{{ products().length }} {{ products().length }}
@@ -199,7 +183,7 @@
</span> </span>
</div> </div>
@if (loading()) { @if (loading() || softFallbackActive()) {
<div class="product-grid skeleton-grid"> <div class="product-grid skeleton-grid">
@for (ghost of [1, 2, 3, 4]; track ghost) { @for (ghost of [1, 2, 3, 4]; track ghost) {
<div class="skeleton-card"></div> <div class="skeleton-card"></div>

View File

@@ -42,6 +42,7 @@ import {
ShopService, ShopService,
} from './services/shop.service'; } from './services/shop.service';
import { ShopRouteService } from './services/shop-route.service'; import { ShopRouteService } from './services/shop-route.service';
import { humanizeShopSlug } from './shop-seo-fallback';
@Component({ @Component({
selector: 'app-shop-page', selector: 'app-shop-page',
@@ -75,6 +76,8 @@ export class ShopPageComponent {
); );
readonly loading = signal(true); readonly loading = signal(true);
readonly softFallbackActive = signal(false);
readonly softFallbackCategoryLabel = signal<string | null>(null);
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
readonly categories = signal<ShopCategoryTree[]>([]); readonly categories = signal<ShopCategoryTree[]>([]);
readonly categoryNodes = signal<ShopCategoryNavNode[]>([]); readonly categoryNodes = signal<ShopCategoryNavNode[]>([]);
@@ -96,6 +99,44 @@ export class ShopPageComponent {
), ),
); );
readonly cartHasItems = computed(() => this.cartItems().length > 0); readonly cartHasItems = computed(() => this.cartItems().length > 0);
readonly heroSubtitle = computed(() => {
this.languageService.currentLang();
const category = this.selectedCategory();
if (category) {
return (
category.description ||
this.translate.instant('SHOP.CATEGORY_META', {
count: category.productCount || 0,
})
);
}
if (this.softFallbackActive() && this.routeCategorySlug()) {
return this.resolveTranslatedText(
'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION',
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'),
);
}
return this.translate.instant('SHOP.SUBTITLE');
});
readonly catalogEyebrow = computed(() => {
this.languageService.currentLang();
return this.selectedCategory() || this.softFallbackCategoryLabel()
? this.translate.instant('SHOP.SELECTED_CATEGORY')
: this.translate.instant('SHOP.CATALOG_LABEL');
});
readonly catalogTitle = computed(() => {
this.languageService.currentLang();
return (
this.selectedCategory()?.name ||
this.softFallbackCategoryLabel() ||
this.translate.instant('SHOP.CATALOG_TITLE')
);
});
constructor() { constructor() {
afterNextRender(() => { afterNextRender(() => {
@@ -114,6 +155,8 @@ export class ShopPageComponent {
.pipe( .pipe(
tap(() => { tap(() => {
this.loading.set(true); this.loading.set(true);
this.softFallbackActive.set(false);
this.softFallbackCategoryLabel.set(null);
this.error.set(null); this.error.set(null);
}), }),
switchMap(([categorySlug]) => { switchMap(([categorySlug]) => {
@@ -128,11 +171,26 @@ export class ShopPageComponent {
this.categoryNodes.set([]); this.categoryNodes.set([]);
this.selectedCategory.set(null); this.selectedCategory.set(null);
this.products.set([]); this.products.set([]);
this.error.set(isNotFound ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR'); if (isNotFound) {
this.setResponseStatus(isNotFound ? 404 : 503); this.error.set('SHOP.NOT_FOUND');
if (this.shouldApplyErrorSeo(error)) { this.setResponseStatus(404);
this.applyErrorSeo(); this.applyHardErrorSeo();
return of(null);
} }
if (this.shouldUseSoftSeoFallback(error)) {
this.error.set(null);
this.softFallbackActive.set(true);
this.softFallbackCategoryLabel.set(
categorySlug ? humanizeShopSlug(categorySlug) : null,
);
this.setResponseStatus(200);
this.applySoftFallbackSeo(categorySlug);
return of(null);
}
this.error.set('SHOP.LOAD_ERROR');
this.setResponseStatus(503);
return of(null); return of(null);
}), }),
finalize(() => this.loading.set(false)), finalize(() => this.loading.set(false)),
@@ -154,6 +212,8 @@ export class ShopPageComponent {
); );
this.selectedCategory.set(result.catalog.category ?? null); this.selectedCategory.set(result.catalog.category ?? null);
this.products.set(result.catalog.products); this.products.set(result.catalog.products);
this.softFallbackActive.set(false);
this.softFallbackCategoryLabel.set(null);
this.applySeo(result.catalog.category ?? null); this.applySeo(result.catalog.category ?? null);
this.restoreCatalogScrollIfNeeded(); this.restoreCatalogScrollIfNeeded();
}); });
@@ -369,7 +429,7 @@ export class ShopPageComponent {
}); });
} }
private applyErrorSeo(): void { private applyHardErrorSeo(): void {
const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`; const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`;
const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
@@ -385,12 +445,57 @@ export class ShopPageComponent {
}); });
} }
private shouldApplyErrorSeo(error: { status?: number } | null): boolean { private applySoftFallbackSeo(categorySlug: string | null): void {
if (error?.status === 404) { if (!categorySlug) {
return true; this.applyDefaultSeo();
return;
} }
return !this.isBrowser; const title = this.buildSoftFallbackCategoryTitle(categorySlug);
const description = this.resolveTranslatedText(
'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION',
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'),
);
this.seoService.applyResolvedSeo({
title,
description,
robots: 'index, follow',
ogTitle: title,
ogDescription: description,
canonicalPath: this.currentPath(),
alternates: null,
xDefault: null,
});
}
private shouldUseSoftSeoFallback(error: { status?: number } | null): boolean {
return !this.isBrowser && error?.status !== 404;
}
private buildSoftFallbackCategoryTitle(categorySlug: string): string {
const shopTitle = this.translate.instant('SHOP.TITLE');
const humanized = humanizeShopSlug(categorySlug);
if (humanized) {
return `${humanized} | ${shopTitle} | 3D fab`;
}
return this.resolveTranslatedText(
'SEO.ROUTES.SHOP.CATEGORY_TITLE',
`${shopTitle} | 3D fab`,
);
}
private resolveTranslatedText(key: string, fallback: string): string {
const translated = this.translate.instant(key);
return typeof translated === 'string' && translated !== key
? translated
: fallback;
}
private currentPath(): string {
const path = String(this.router.url ?? '/').split(/[?#]/, 1)[0] || '/';
return path.startsWith('/') ? path : `/${path}`;
} }
private setResponseStatus(status: number): void { private setResponseStatus(status: number): void {

View File

@@ -0,0 +1,72 @@
const PRODUCT_ID_PREFIX_PATTERN = /^[0-9a-f]{8}-(?=[a-z0-9])/i;
const UPPERCASE_TOKENS = new Set([
'3d',
'abs',
'asa',
'cad',
'cf',
'gf',
'pa',
'pc',
'petg',
'pla',
'pp',
'tpu',
'uv',
]);
export function humanizeShopSlug(
value: string | null | undefined,
options?: {
stripProductIdPrefix?: boolean;
},
): string {
const normalized = normalizeShopSlug(value, options?.stripProductIdPrefix);
if (!normalized) {
return '';
}
return normalized
.split('-')
.filter(Boolean)
.map(formatSlugToken)
.join(' ')
.trim();
}
function normalizeShopSlug(
value: string | null | undefined,
stripProductIdPrefix = false,
): string {
const normalized = String(value ?? '')
.trim()
.replace(/^\/+|\/+$/g, '')
.split('/')
.filter(Boolean)
.at(-1)
?.toLowerCase();
if (!normalized) {
return '';
}
return stripProductIdPrefix
? normalized.replace(PRODUCT_ID_PREFIX_PATTERN, '')
: normalized;
}
function formatSlugToken(token: string): string {
if (!token) {
return '';
}
if (/^\d+$/.test(token)) {
return token;
}
if (UPPERCASE_TOKENS.has(token)) {
return token.toUpperCase();
}
return `${token.charAt(0).toUpperCase()}${token.slice(1)}`;
}