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 }}
</button>
@if (loading()) {
@if (loading() || softFallbackActive()) {
<div class="detail-grid skeleton-grid">
<div class="skeleton-block"></div>
<div class="skeleton-block"></div>

View File

@@ -35,6 +35,7 @@ import {
ShopService,
} from './services/shop.service';
import { ShopRouteService } from './services/shop-route.service';
import { humanizeShopSlug } from './shop-seo-fallback';
interface ShopMaterialOption {
key: string;
@@ -84,6 +85,7 @@ export class ProductDetailComponent {
);
readonly loading = signal(true);
readonly softFallbackActive = signal(false);
readonly error = signal<string | null>(null);
readonly product = signal<ShopProductDetail | null>(null);
readonly selectedVariantId = signal<string | null>(null);
@@ -233,6 +235,7 @@ export class ProductDetailComponent {
.pipe(
tap(() => {
this.loading.set(true);
this.softFallbackActive.set(false);
this.error.set(null);
this.addSuccess.set(false);
this.modelError.set(false);
@@ -245,13 +248,14 @@ export class ProductDetailComponent {
this.languageService.clearLocalizedRouteOverrides();
this.error.set('SHOP.NOT_FOUND');
this.setResponseStatus(404);
this.applyFallbackSeo();
this.applyHardFallbackSeo();
this.loading.set(false);
return of(null);
}
const productSlug = routeParams.productSlug as string;
return this.shopService
.getProductByPublicPath(routeParams.productSlug)
.getProductByPublicPath(productSlug)
.pipe(
catchError((error) => {
this.languageService.clearLocalizedRouteOverrides();
@@ -260,13 +264,23 @@ export class ProductDetailComponent {
this.setSelectedImageAssetId(null);
this.modelFile.set(null);
const isNotFound = error?.status === 404;
this.error.set(
isNotFound ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
);
this.setResponseStatus(isNotFound ? 404 : 503);
if (this.shouldApplyFallbackSeo(error)) {
this.applyFallbackSeo();
if (isNotFound) {
this.error.set('SHOP.NOT_FOUND');
this.setResponseStatus(404);
this.applyHardFallbackSeo();
return of(null);
}
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);
}),
finalize(() => this.loading.set(false)),
@@ -280,6 +294,7 @@ export class ProductDetailComponent {
}
this.product.set(product);
this.softFallbackActive.set(false);
this.selectedVariantId.set(
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 description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
this.seoService.applyResolvedSeo({
@@ -623,12 +638,53 @@ export class ProductDetailComponent {
});
}
private shouldApplyFallbackSeo(error: { status?: number } | null): boolean {
if (error?.status === 404) {
return true;
private applySoftFallbackSeo(productSlug: string): void {
const title = this.buildSoftFallbackTitle(productSlug);
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,
});
}
return !this.isBrowser;
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.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(

View File

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

View File

@@ -42,6 +42,7 @@ import {
ShopService,
} from './services/shop.service';
import { ShopRouteService } from './services/shop-route.service';
import { humanizeShopSlug } from './shop-seo-fallback';
@Component({
selector: 'app-shop-page',
@@ -75,6 +76,8 @@ export class ShopPageComponent {
);
readonly loading = signal(true);
readonly softFallbackActive = signal(false);
readonly softFallbackCategoryLabel = signal<string | null>(null);
readonly error = signal<string | null>(null);
readonly categories = signal<ShopCategoryTree[]>([]);
readonly categoryNodes = signal<ShopCategoryNavNode[]>([]);
@@ -96,6 +99,44 @@ export class ShopPageComponent {
),
);
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() {
afterNextRender(() => {
@@ -114,6 +155,8 @@ export class ShopPageComponent {
.pipe(
tap(() => {
this.loading.set(true);
this.softFallbackActive.set(false);
this.softFallbackCategoryLabel.set(null);
this.error.set(null);
}),
switchMap(([categorySlug]) => {
@@ -128,11 +171,26 @@ export class ShopPageComponent {
this.categoryNodes.set([]);
this.selectedCategory.set(null);
this.products.set([]);
this.error.set(isNotFound ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR');
this.setResponseStatus(isNotFound ? 404 : 503);
if (this.shouldApplyErrorSeo(error)) {
this.applyErrorSeo();
if (isNotFound) {
this.error.set('SHOP.NOT_FOUND');
this.setResponseStatus(404);
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);
}),
finalize(() => this.loading.set(false)),
@@ -154,6 +212,8 @@ export class ShopPageComponent {
);
this.selectedCategory.set(result.catalog.category ?? null);
this.products.set(result.catalog.products);
this.softFallbackActive.set(false);
this.softFallbackCategoryLabel.set(null);
this.applySeo(result.catalog.category ?? null);
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 description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
@@ -385,12 +445,57 @@ export class ShopPageComponent {
});
}
private shouldApplyErrorSeo(error: { status?: number } | null): boolean {
if (error?.status === 404) {
return true;
private applySoftFallbackSeo(categorySlug: string | null): void {
if (!categorySlug) {
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 {

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)}`;
}