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
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:
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
72
frontend/src/app/features/shop/shop-seo-fallback.ts
Normal file
72
frontend/src/app/features/shop/shop-seo-fallback.ts
Normal 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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user