fix(front-end): fix no index product
This commit is contained in:
@@ -56,6 +56,12 @@ public class PublicShopController {
|
|||||||
return ResponseEntity.ok(publicShopCatalogService.getProduct(slug, lang));
|
return ResponseEntity.ok(publicShopCatalogService.getProduct(slug, lang));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/products/by-path/{publicPath}")
|
||||||
|
public ResponseEntity<ShopProductDetailDto> getProductByPublicPath(@PathVariable String publicPath,
|
||||||
|
@RequestParam(required = false) String lang) {
|
||||||
|
return ResponseEntity.ok(publicShopCatalogService.getProductByPublicPath(publicPath, lang));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/products/{slug}/model")
|
@GetMapping("/products/{slug}/model")
|
||||||
public ResponseEntity<Resource> getProductModel(@PathVariable String slug) throws IOException {
|
public ResponseEntity<Resource> getProductModel(@PathVariable String slug) throws IOException {
|
||||||
PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug);
|
PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug);
|
||||||
|
|||||||
@@ -126,24 +126,40 @@ public class PublicShopCatalogService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ShopProductDetailDto getProduct(String slug, String language) {
|
public ShopProductDetailDto getProduct(String slug, String language) {
|
||||||
CategoryContext categoryContext = loadCategoryContext(language);
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
PublicProductContext productContext = loadPublicProductContext(categoryContext, language);
|
CategoryContext categoryContext = loadCategoryContext(normalizedLanguage);
|
||||||
|
PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage);
|
||||||
|
ProductEntry entry = requirePublicProductEntry(
|
||||||
|
productContext.entriesBySlug().get(slug),
|
||||||
|
categoryContext
|
||||||
|
);
|
||||||
|
return toProductDetailDto(
|
||||||
|
entry,
|
||||||
|
productContext.productMediaBySlug(),
|
||||||
|
productContext.variantColorHexByMaterialAndColor(),
|
||||||
|
normalizedLanguage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ProductEntry entry = productContext.entriesBySlug().get(slug);
|
public ShopProductDetailDto getProductByPublicPath(String publicPathSegment, String language) {
|
||||||
if (entry == null) {
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
|
String normalizedPublicPath = normalizePublicPathSegment(publicPathSegment);
|
||||||
|
if (normalizedPublicPath == null) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
ShopCategory category = entry.product().getCategory();
|
CategoryContext categoryContext = loadCategoryContext(normalizedLanguage);
|
||||||
if (category == null || !categoryContext.categoriesById().containsKey(category.getId())) {
|
PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage);
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
ProductEntry entry = requirePublicProductEntry(
|
||||||
}
|
productContext.entriesByPublicPath().get(normalizedPublicPath),
|
||||||
|
categoryContext
|
||||||
|
);
|
||||||
|
|
||||||
return toProductDetailDto(
|
return toProductDetailDto(
|
||||||
entry,
|
entry,
|
||||||
productContext.productMediaBySlug(),
|
productContext.productMediaBySlug(),
|
||||||
productContext.variantColorHexByMaterialAndColor(),
|
productContext.variantColorHexByMaterialAndColor(),
|
||||||
language
|
normalizedLanguage
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +213,7 @@ public class PublicShopCatalogService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private PublicProductContext loadPublicProductContext(CategoryContext categoryContext, String language) {
|
private PublicProductContext loadPublicProductContext(CategoryContext categoryContext, String language) {
|
||||||
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
List<ProductEntry> entries = loadPublicProducts(categoryContext.categoriesById().keySet());
|
List<ProductEntry> entries = loadPublicProducts(categoryContext.categoriesById().keySet());
|
||||||
Map<String, List<PublicMediaUsageDto>> productMediaBySlug = publicMediaQueryService.getUsageMediaMap(
|
Map<String, List<PublicMediaUsageDto>> productMediaBySlug = publicMediaQueryService.getUsageMediaMap(
|
||||||
SHOP_PRODUCT_MEDIA_USAGE_TYPE,
|
SHOP_PRODUCT_MEDIA_USAGE_TYPE,
|
||||||
@@ -207,8 +224,21 @@ public class PublicShopCatalogService {
|
|||||||
|
|
||||||
Map<String, ProductEntry> entriesBySlug = entries.stream()
|
Map<String, ProductEntry> entriesBySlug = entries.stream()
|
||||||
.collect(Collectors.toMap(entry -> entry.product().getSlug(), entry -> entry, (left, right) -> left, LinkedHashMap::new));
|
.collect(Collectors.toMap(entry -> entry.product().getSlug(), entry -> entry, (left, right) -> left, LinkedHashMap::new));
|
||||||
|
Map<String, ProductEntry> entriesByPublicPath = entries.stream()
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
entry -> normalizePublicPathSegment(ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage)),
|
||||||
|
entry -> entry,
|
||||||
|
(left, right) -> left,
|
||||||
|
LinkedHashMap::new
|
||||||
|
));
|
||||||
|
|
||||||
return new PublicProductContext(entries, entriesBySlug, productMediaBySlug, variantColorHexByMaterialAndColor);
|
return new PublicProductContext(
|
||||||
|
entries,
|
||||||
|
entriesBySlug,
|
||||||
|
entriesByPublicPath,
|
||||||
|
productMediaBySlug,
|
||||||
|
variantColorHexByMaterialAndColor
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, String> buildFilamentVariantColorHexMap() {
|
private Map<String, String> buildFilamentVariantColorHexMap() {
|
||||||
@@ -515,6 +545,27 @@ public class PublicShopCatalogService {
|
|||||||
return raw.toLowerCase(Locale.ROOT);
|
return raw.toLowerCase(Locale.ROOT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ProductEntry requirePublicProductEntry(ProductEntry entry, CategoryContext categoryContext) {
|
||||||
|
if (entry == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
ShopCategory category = entry.product().getCategory();
|
||||||
|
if (category == null || !categoryContext.categoriesById().containsKey(category.getId())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizePublicPathSegment(String publicPathSegment) {
|
||||||
|
String normalized = trimToNull(publicPathSegment);
|
||||||
|
if (normalized == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalized.toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
private String trimToNull(String value) {
|
private String trimToNull(String value) {
|
||||||
String raw = String.valueOf(value == null ? "" : value).trim();
|
String raw = String.valueOf(value == null ? "" : value).trim();
|
||||||
if (raw.isEmpty()) {
|
if (raw.isEmpty()) {
|
||||||
@@ -610,6 +661,7 @@ public class PublicShopCatalogService {
|
|||||||
private record PublicProductContext(
|
private record PublicProductContext(
|
||||||
List<ProductEntry> entries,
|
List<ProductEntry> entries,
|
||||||
Map<String, ProductEntry> entriesBySlug,
|
Map<String, ProductEntry> entriesBySlug,
|
||||||
|
Map<String, ProductEntry> entriesByPublicPath,
|
||||||
Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
|
Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
|
||||||
Map<String, String> variantColorHexByMaterialAndColor
|
Map<String, String> variantColorHexByMaterialAndColor
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -23,6 +24,7 @@ import java.util.Map;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.mockito.ArgumentMatchers.anyList;
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@@ -91,6 +93,37 @@ class PublicShopCatalogServiceTest {
|
|||||||
assertEquals("/it/shop/p/12345678-supporto-bici", response.localizedPaths().get("it"));
|
assertEquals("/it/shop/p/12345678-supporto-bici", response.localizedPaths().get("it"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProductByPublicPath_shouldResolveLocalizedSegment() {
|
||||||
|
ShopCategory category = buildCategory();
|
||||||
|
ShopProduct product = buildProduct(category);
|
||||||
|
ShopProductVariant variant = buildVariant(product);
|
||||||
|
|
||||||
|
stubPublicCatalog(category, product, variant);
|
||||||
|
|
||||||
|
ShopProductDetailDto response = service.getProductByPublicPath("12345678-bike-wall-hanger", "en");
|
||||||
|
|
||||||
|
assertEquals("bike-wall-hanger", response.slug());
|
||||||
|
assertEquals("12345678-bike-wall-hanger", response.publicPath());
|
||||||
|
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("en"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProductByPublicPath_shouldRejectNonCanonicalSegment() {
|
||||||
|
ShopCategory category = buildCategory();
|
||||||
|
ShopProduct product = buildProduct(category);
|
||||||
|
ShopProductVariant variant = buildVariant(product);
|
||||||
|
|
||||||
|
stubPublicCatalog(category, product, variant);
|
||||||
|
|
||||||
|
ResponseStatusException exception = assertThrows(
|
||||||
|
ResponseStatusException.class,
|
||||||
|
() -> service.getProductByPublicPath("12345678-wrong-tail", "en")
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(404, exception.getStatusCode().value());
|
||||||
|
}
|
||||||
|
|
||||||
private void stubPublicCatalog(ShopCategory category, ShopProduct product, ShopProductVariant variant) {
|
private void stubPublicCatalog(ShopCategory category, ShopProduct product, ShopProductVariant variant) {
|
||||||
when(shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc()).thenReturn(List.of(category));
|
when(shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc()).thenReturn(List.of(category));
|
||||||
when(shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc()).thenReturn(List.of(product));
|
when(shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc()).thenReturn(List.of(product));
|
||||||
|
|||||||
@@ -8,24 +8,24 @@ import {
|
|||||||
PLATFORM_ID,
|
PLATFORM_ID,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
input,
|
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||||
import {
|
import {
|
||||||
EMPTY,
|
|
||||||
catchError,
|
catchError,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
|
distinctUntilChanged,
|
||||||
finalize,
|
finalize,
|
||||||
|
map,
|
||||||
of,
|
of,
|
||||||
switchMap,
|
switchMap,
|
||||||
tap,
|
tap,
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import { SeoService } from '../../core/services/seo.service';
|
import { SeoService } from '../../core/services/seo.service';
|
||||||
import { LanguageService } from '../../core/services/language.service';
|
import { LanguageService } from '../../core/services/language.service';
|
||||||
import { findColorHex, getColorHex } from '../../core/constants/colors.const';
|
import { findColorHex } from '../../core/constants/colors.const';
|
||||||
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
||||||
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||||
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
|
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
|
||||||
@@ -69,6 +69,7 @@ export class ProductDetailComponent {
|
|||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly injector = inject(Injector);
|
private readonly injector = inject(Injector);
|
||||||
private readonly location = inject(Location);
|
private readonly location = inject(Location);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
private readonly seoService = inject(SeoService);
|
private readonly seoService = inject(SeoService);
|
||||||
@@ -78,8 +79,9 @@ export class ProductDetailComponent {
|
|||||||
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
|
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
|
||||||
readonly shopService = inject(ShopService);
|
readonly shopService = inject(ShopService);
|
||||||
|
|
||||||
readonly categorySlug = input<string | undefined>();
|
readonly routeCategorySlug = signal<string | null>(
|
||||||
readonly productSlug = input<string | undefined>();
|
this.readRouteParam('categorySlug'),
|
||||||
|
);
|
||||||
|
|
||||||
readonly loading = signal(true);
|
readonly loading = signal(true);
|
||||||
readonly error = signal<string | null>(null);
|
readonly error = signal<string | null>(null);
|
||||||
@@ -213,10 +215,20 @@ export class ProductDetailComponent {
|
|||||||
});
|
});
|
||||||
|
|
||||||
combineLatest([
|
combineLatest([
|
||||||
toObservable(this.productSlug, { injector: this.injector }),
|
this.route.paramMap.pipe(
|
||||||
|
map((params) => ({
|
||||||
|
categorySlug: this.normalizeRouteParam(params.get('categorySlug')),
|
||||||
|
productSlug: this.normalizeRouteParam(params.get('productSlug')),
|
||||||
|
})),
|
||||||
|
distinctUntilChanged(
|
||||||
|
(previous, current) =>
|
||||||
|
previous.categorySlug === current.categorySlug &&
|
||||||
|
previous.productSlug === current.productSlug,
|
||||||
|
),
|
||||||
|
),
|
||||||
toObservable(this.languageService.currentLang, {
|
toObservable(this.languageService.currentLang, {
|
||||||
injector: this.injector,
|
injector: this.injector,
|
||||||
}),
|
}).pipe(distinctUntilChanged()),
|
||||||
])
|
])
|
||||||
.pipe(
|
.pipe(
|
||||||
tap(() => {
|
tap(() => {
|
||||||
@@ -227,13 +239,9 @@ export class ProductDetailComponent {
|
|||||||
this.colorPopupOpen.set(false);
|
this.colorPopupOpen.set(false);
|
||||||
this.modelModalOpen.set(false);
|
this.modelModalOpen.set(false);
|
||||||
}),
|
}),
|
||||||
switchMap(([productSlug]) => {
|
switchMap(([routeParams]) => {
|
||||||
if (productSlug === undefined) {
|
this.routeCategorySlug.set(routeParams.categorySlug);
|
||||||
return EMPTY;
|
if (!routeParams.productSlug) {
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedProductSlug = productSlug.trim();
|
|
||||||
if (!normalizedProductSlug) {
|
|
||||||
this.languageService.clearLocalizedRouteOverrides();
|
this.languageService.clearLocalizedRouteOverrides();
|
||||||
this.error.set('SHOP.NOT_FOUND');
|
this.error.set('SHOP.NOT_FOUND');
|
||||||
this.setResponseStatus(404);
|
this.setResponseStatus(404);
|
||||||
@@ -243,7 +251,7 @@ export class ProductDetailComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.shopService
|
return this.shopService
|
||||||
.getProductByPublicPath(normalizedProductSlug)
|
.getProductByPublicPath(routeParams.productSlug)
|
||||||
.pipe(
|
.pipe(
|
||||||
catchError((error) => {
|
catchError((error) => {
|
||||||
this.languageService.clearLocalizedRouteOverrides();
|
this.languageService.clearLocalizedRouteOverrides();
|
||||||
@@ -508,7 +516,8 @@ export class ProductDetailComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
productLinkRoot(): string[] {
|
productLinkRoot(): string[] {
|
||||||
const categorySlug = this.product()?.category.slug || this.categorySlug();
|
const categorySlug =
|
||||||
|
this.product()?.category.slug || this.routeCategorySlug();
|
||||||
return this.shopRouteService.shopRootCommands(categorySlug);
|
return this.shopRouteService.shopRootCommands(categorySlug);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -834,4 +843,15 @@ export class ProductDetailComponent {
|
|||||||
this.responseInit.status = status;
|
this.responseInit.status = status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readRouteParam(name: string): string | null {
|
||||||
|
return this.normalizeRouteParam(this.route.snapshot.paramMap.get(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRouteParam(
|
||||||
|
value: string | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
const normalized = String(value ?? '').trim();
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
} from '@angular/common/http/testing';
|
} from '@angular/common/http/testing';
|
||||||
import {
|
import {
|
||||||
ShopCartResponse,
|
ShopCartResponse,
|
||||||
ShopProductCatalogResponse,
|
|
||||||
ShopProductDetail,
|
ShopProductDetail,
|
||||||
ShopService,
|
ShopService,
|
||||||
} from './shop.service';
|
} from './shop.service';
|
||||||
@@ -90,39 +89,6 @@ describe('ShopService', () => {
|
|||||||
grandTotalChf: 36.8,
|
grandTotalChf: 36.8,
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildCatalog = (): ShopProductCatalogResponse => ({
|
|
||||||
categorySlug: null,
|
|
||||||
featuredOnly: false,
|
|
||||||
category: null,
|
|
||||||
products: [
|
|
||||||
{
|
|
||||||
id: '12345678-abcd-4abc-9abc-1234567890ab',
|
|
||||||
slug: 'desk-cable-clip',
|
|
||||||
name: 'Supporto cavo scrivania',
|
|
||||||
excerpt: 'Accessorio tecnico',
|
|
||||||
isFeatured: true,
|
|
||||||
sortOrder: 0,
|
|
||||||
category: {
|
|
||||||
id: 'category-1',
|
|
||||||
slug: 'accessori',
|
|
||||||
name: 'Accessori',
|
|
||||||
},
|
|
||||||
priceFromChf: 9.9,
|
|
||||||
priceToChf: 12.5,
|
|
||||||
defaultVariant: null,
|
|
||||||
primaryImage: null,
|
|
||||||
model3d: null,
|
|
||||||
publicPath: '12345678-supporto-cavo-scrivania',
|
|
||||||
localizedPaths: {
|
|
||||||
it: '/it/shop/p/12345678-supporto-cavo-scrivania',
|
|
||||||
en: '/en/shop/p/12345678-desk-cable-clip',
|
|
||||||
de: '/de/shop/p/12345678-schreibtisch-kabelhalter',
|
|
||||||
fr: '/fr/shop/p/12345678-support-cable-bureau',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const buildProduct = (): ShopProductDetail => ({
|
const buildProduct = (): ShopProductDetail => ({
|
||||||
id: '12345678-abcd-4abc-9abc-1234567890ab',
|
id: '12345678-abcd-4abc-9abc-1234567890ab',
|
||||||
slug: 'desk-cable-clip',
|
slug: 'desk-cable-clip',
|
||||||
@@ -226,24 +192,15 @@ describe('ShopService', () => {
|
|||||||
response = product;
|
response = product;
|
||||||
});
|
});
|
||||||
|
|
||||||
const catalogRequest = httpMock.expectOne((request) => {
|
const request = httpMock.expectOne((request) => {
|
||||||
return (
|
|
||||||
request.method === 'GET' &&
|
|
||||||
request.url === 'http://localhost:8000/api/shop/products' &&
|
|
||||||
request.params.get('lang') === 'it'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
catalogRequest.flush(buildCatalog());
|
|
||||||
|
|
||||||
const detailRequest = httpMock.expectOne((request) => {
|
|
||||||
return (
|
return (
|
||||||
request.method === 'GET' &&
|
request.method === 'GET' &&
|
||||||
request.url ===
|
request.url ===
|
||||||
'http://localhost:8000/api/shop/products/desk-cable-clip' &&
|
'http://localhost:8000/api/shop/products/by-path/12345678-supporto-cavo-scrivania' &&
|
||||||
request.params.get('lang') === 'it'
|
request.params.get('lang') === 'it'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
detailRequest.flush(buildProduct());
|
request.flush(buildProduct());
|
||||||
|
|
||||||
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
||||||
expect(response?.name).toBe('Supporto cavo scrivania');
|
expect(response?.name).toBe('Supporto cavo scrivania');
|
||||||
@@ -259,18 +216,15 @@ describe('ShopService', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const catalogRequest = httpMock.expectOne((request) => {
|
const request = httpMock.expectOne((request) => {
|
||||||
return (
|
return (
|
||||||
request.method === 'GET' &&
|
request.method === 'GET' &&
|
||||||
request.url === 'http://localhost:8000/api/shop/products' &&
|
request.url ===
|
||||||
|
'http://localhost:8000/api/shop/products/by-path/12345678-qualunque-nome' &&
|
||||||
request.params.get('lang') === 'it'
|
request.params.get('lang') === 'it'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
catalogRequest.flush(buildCatalog());
|
request.flush('Not found', { status: 404, statusText: 'Not Found' });
|
||||||
|
|
||||||
httpMock.expectNone(
|
|
||||||
'http://localhost:8000/api/shop/products/desk-cable-clip',
|
|
||||||
);
|
|
||||||
expect(errorResponse?.status).toBe(404);
|
expect(errorResponse?.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -284,18 +238,15 @@ describe('ShopService', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const catalogRequest = httpMock.expectOne((request) => {
|
const request = httpMock.expectOne((request) => {
|
||||||
return (
|
return (
|
||||||
request.method === 'GET' &&
|
request.method === 'GET' &&
|
||||||
request.url === 'http://localhost:8000/api/shop/products' &&
|
request.url ===
|
||||||
|
'http://localhost:8000/api/shop/products/by-path/12345678' &&
|
||||||
request.params.get('lang') === 'it'
|
request.params.get('lang') === 'it'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
catalogRequest.flush(buildCatalog());
|
request.flush('Not found', { status: 404, statusText: 'Not Found' });
|
||||||
|
|
||||||
httpMock.expectNone(
|
|
||||||
'http://localhost:8000/api/shop/products/desk-cable-clip',
|
|
||||||
);
|
|
||||||
expect(errorResponse?.status).toBe(404);
|
expect(errorResponse?.status).toBe(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { computed, inject, Injectable, signal } from '@angular/core';
|
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { map, Observable, switchMap, tap, throwError } from 'rxjs';
|
import { map, Observable, tap, throwError } from 'rxjs';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
import {
|
import {
|
||||||
PublicMediaUsageDto,
|
PublicMediaUsageDto,
|
||||||
@@ -290,21 +290,11 @@ export class ShopService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.getProductCatalog().pipe(
|
return this.http.get<ShopProductDetail>(
|
||||||
map((catalog) =>
|
`${this.apiUrl}/products/by-path/${encodeURIComponent(normalizedPath)}`,
|
||||||
catalog.products.find(
|
{
|
||||||
(product) =>
|
params: this.buildLangParams(),
|
||||||
this.normalizePublicPath(product.publicPath) === normalizedPath,
|
},
|
||||||
),
|
|
||||||
),
|
|
||||||
switchMap((product) => {
|
|
||||||
if (!product) {
|
|
||||||
return throwError(() => ({
|
|
||||||
status: 404,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return this.getProduct(product.slug);
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,17 +8,18 @@ import {
|
|||||||
Injector,
|
Injector,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
input,
|
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||||
import {
|
import {
|
||||||
catchError,
|
catchError,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
|
distinctUntilChanged,
|
||||||
finalize,
|
finalize,
|
||||||
forkJoin,
|
forkJoin,
|
||||||
|
map,
|
||||||
of,
|
of,
|
||||||
switchMap,
|
switchMap,
|
||||||
tap,
|
tap,
|
||||||
@@ -59,6 +60,7 @@ import { ShopRouteService } from './services/shop-route.service';
|
|||||||
export class ShopPageComponent {
|
export class ShopPageComponent {
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly injector = inject(Injector);
|
private readonly injector = inject(Injector);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
private readonly seoService = inject(SeoService);
|
private readonly seoService = inject(SeoService);
|
||||||
@@ -68,7 +70,9 @@ export class ShopPageComponent {
|
|||||||
private readonly shopRouteService = inject(ShopRouteService);
|
private readonly shopRouteService = inject(ShopRouteService);
|
||||||
readonly shopService = inject(ShopService);
|
readonly shopService = inject(ShopService);
|
||||||
|
|
||||||
readonly categorySlug = input<string | undefined>();
|
readonly routeCategorySlug = signal<string | null>(
|
||||||
|
this.readRouteParam('categorySlug'),
|
||||||
|
);
|
||||||
|
|
||||||
readonly loading = signal(true);
|
readonly loading = signal(true);
|
||||||
readonly error = signal<string | null>(null);
|
readonly error = signal<string | null>(null);
|
||||||
@@ -84,7 +88,7 @@ export class ShopPageComponent {
|
|||||||
readonly cartLoading = this.shopService.cartLoading;
|
readonly cartLoading = this.shopService.cartLoading;
|
||||||
readonly cartItemCount = this.shopService.cartItemCount;
|
readonly cartItemCount = this.shopService.cartItemCount;
|
||||||
readonly currentCategorySlug = computed(
|
readonly currentCategorySlug = computed(
|
||||||
() => this.selectedCategory()?.slug ?? this.categorySlug() ?? null,
|
() => this.selectedCategory()?.slug ?? this.routeCategorySlug() ?? null,
|
||||||
);
|
);
|
||||||
readonly cartItems = computed(() =>
|
readonly cartItems = computed(() =>
|
||||||
(this.cart()?.items ?? []).filter(
|
(this.cart()?.items ?? []).filter(
|
||||||
@@ -99,18 +103,22 @@ export class ShopPageComponent {
|
|||||||
});
|
});
|
||||||
|
|
||||||
combineLatest([
|
combineLatest([
|
||||||
toObservable(this.categorySlug, { injector: this.injector }),
|
this.route.paramMap.pipe(
|
||||||
|
map((params) => this.normalizeRouteParam(params.get('categorySlug'))),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
),
|
||||||
toObservable(this.languageService.currentLang, {
|
toObservable(this.languageService.currentLang, {
|
||||||
injector: this.injector,
|
injector: this.injector,
|
||||||
}),
|
}).pipe(distinctUntilChanged()),
|
||||||
])
|
])
|
||||||
.pipe(
|
.pipe(
|
||||||
tap(() => {
|
tap(() => {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
}),
|
}),
|
||||||
switchMap(([categorySlug]) =>
|
switchMap(([categorySlug]) => {
|
||||||
forkJoin({
|
this.routeCategorySlug.set(categorySlug);
|
||||||
|
return forkJoin({
|
||||||
categories: this.shopService.getCategories(),
|
categories: this.shopService.getCategories(),
|
||||||
catalog: this.shopService.getProductCatalog(categorySlug ?? null),
|
catalog: this.shopService.getProductCatalog(categorySlug ?? null),
|
||||||
}).pipe(
|
}).pipe(
|
||||||
@@ -128,8 +136,8 @@ export class ShopPageComponent {
|
|||||||
return of(null);
|
return of(null);
|
||||||
}),
|
}),
|
||||||
finalize(() => this.loading.set(false)),
|
finalize(() => this.loading.set(false)),
|
||||||
),
|
);
|
||||||
),
|
}),
|
||||||
takeUntilDestroyed(this.destroyRef),
|
takeUntilDestroyed(this.destroyRef),
|
||||||
)
|
)
|
||||||
.subscribe((result) => {
|
.subscribe((result) => {
|
||||||
@@ -141,7 +149,7 @@ export class ShopPageComponent {
|
|||||||
this.categoryNodes.set(
|
this.categoryNodes.set(
|
||||||
this.shopService.flattenCategoryTree(
|
this.shopService.flattenCategoryTree(
|
||||||
result.categories,
|
result.categories,
|
||||||
result.catalog.category?.slug ?? this.categorySlug() ?? null,
|
result.catalog.category?.slug ?? this.routeCategorySlug() ?? null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
this.selectedCategory.set(result.catalog.category ?? null);
|
this.selectedCategory.set(result.catalog.category ?? null);
|
||||||
@@ -410,4 +418,15 @@ export class ShopPageComponent {
|
|||||||
window.setTimeout(restore, 60);
|
window.setTimeout(restore, 60);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readRouteParam(name: string): string | null {
|
||||||
|
return this.normalizeRouteParam(this.route.snapshot.paramMap.get(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRouteParam(
|
||||||
|
value: string | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
const normalized = String(value ?? '').trim();
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user