From d27558a3ee660dd78323131e7053d0d589923994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Tue, 24 Mar 2026 12:59:09 +0100 Subject: [PATCH] fix(front-end): fix no index product 3 hope the last one --- .../shop/product-detail.component.html | 2 +- .../features/shop/product-detail.component.ts | 82 ++++++++++-- .../features/shop/shop-page.component.html | 24 +--- .../app/features/shop/shop-page.component.ts | 123 ++++++++++++++++-- .../app/features/shop/shop-seo-fallback.ts | 72 ++++++++++ 5 files changed, 260 insertions(+), 43 deletions(-) create mode 100644 frontend/src/app/features/shop/shop-seo-fallback.ts diff --git a/frontend/src/app/features/shop/product-detail.component.html b/frontend/src/app/features/shop/product-detail.component.html index 76badd8..9a5981a 100644 --- a/frontend/src/app/features/shop/product-detail.component.html +++ b/frontend/src/app/features/shop/product-detail.component.html @@ -4,7 +4,7 @@ ← {{ "SHOP.BACK" | translate }} - @if (loading()) { + @if (loading() || softFallbackActive()) {
diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts index 5345066..47f7257 100644 --- a/frontend/src/app/features/shop/product-detail.component.ts +++ b/frontend/src/app/features/shop/product-detail.component.ts @@ -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(null); readonly product = signal(null); readonly selectedVariantId = signal(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, + }); + } + + 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( diff --git a/frontend/src/app/features/shop/shop-page.component.html b/frontend/src/app/features/shop/shop-page.component.html index c276508..8675519 100644 --- a/frontend/src/app/features/shop/shop-page.component.html +++ b/frontend/src/app/features/shop/shop-page.component.html @@ -1,15 +1,7 @@

{{ "NAV.SHOP" | translate }}

-

- {{ - selectedCategory() - ? selectedCategory()?.description || - ("SHOP.CATEGORY_META" - | translate: { count: selectedCategory()?.productCount || 0 }) - : ("SHOP.SUBTITLE" | translate) - }} -

+

{{ heroSubtitle() }}

@@ -181,17 +173,9 @@

- {{ - selectedCategory() - ? ("SHOP.SELECTED_CATEGORY" | translate) - : ("SHOP.CATALOG_LABEL" | translate) - }} + {{ catalogEyebrow() }}

-

- {{ - selectedCategory()?.name || ("SHOP.CATALOG_TITLE" | translate) - }} -

+

{{ catalogTitle() }}

{{ products().length }} @@ -199,7 +183,7 @@
- @if (loading()) { + @if (loading() || softFallbackActive()) {
@for (ghost of [1, 2, 3, 4]; track ghost) {
diff --git a/frontend/src/app/features/shop/shop-page.component.ts b/frontend/src/app/features/shop/shop-page.component.ts index 13264f3..8287ddd 100644 --- a/frontend/src/app/features/shop/shop-page.component.ts +++ b/frontend/src/app/features/shop/shop-page.component.ts @@ -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(null); readonly error = signal(null); readonly categories = signal([]); readonly categoryNodes = signal([]); @@ -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 { diff --git a/frontend/src/app/features/shop/shop-seo-fallback.ts b/frontend/src/app/features/shop/shop-seo-fallback.ts new file mode 100644 index 0000000..cda6389 --- /dev/null +++ b/frontend/src/app/features/shop/shop-seo-fallback.ts @@ -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)}`; +}