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 }}
|
← {{ "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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
private materialLabelForVariant(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
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