Compare commits
5 Commits
1b7c0c48e7
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 132f0f3646 | |||
|
|
8835175fb3 | ||
| 28c3abdb4a | |||
| b30bfc9293 | |||
| d70423fcc0 |
@@ -62,6 +62,12 @@ public class PublicShopController {
|
||||
return ResponseEntity.ok(publicShopCatalogService.getProductByPublicPath(publicPath, lang));
|
||||
}
|
||||
|
||||
@GetMapping("/products/by-id-prefix/{idPrefix}")
|
||||
public ResponseEntity<ShopProductDetailDto> getProductByIdPrefix(@PathVariable String idPrefix,
|
||||
@RequestParam(required = false) String lang) {
|
||||
return ResponseEntity.ok(publicShopCatalogService.getProductByIdPrefix(idPrefix, lang));
|
||||
}
|
||||
|
||||
@GetMapping("/products/{slug}/model")
|
||||
public ResponseEntity<Resource> getProductModel(@PathVariable String slug) throws IOException {
|
||||
PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug);
|
||||
|
||||
@@ -163,6 +163,28 @@ public class PublicShopCatalogService {
|
||||
);
|
||||
}
|
||||
|
||||
public ShopProductDetailDto getProductByIdPrefix(String idPrefix, String language) {
|
||||
String normalizedLanguage = normalizeLanguage(language);
|
||||
String normalizedIdPrefix = normalizeProductIdPrefix(idPrefix);
|
||||
if (normalizedIdPrefix == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
||||
}
|
||||
|
||||
CategoryContext categoryContext = loadCategoryContext(normalizedLanguage);
|
||||
PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage);
|
||||
ProductEntry entry = requirePublicProductEntry(
|
||||
productContext.entriesByIdPrefix().get(normalizedIdPrefix),
|
||||
categoryContext
|
||||
);
|
||||
|
||||
return toProductDetailDto(
|
||||
entry,
|
||||
productContext.productMediaBySlug(),
|
||||
productContext.variantColorHexByMaterialAndColor(),
|
||||
normalizedLanguage
|
||||
);
|
||||
}
|
||||
|
||||
public ProductModelDownload getProductModelDownload(String slug) {
|
||||
CategoryContext categoryContext = loadCategoryContext(null);
|
||||
PublicProductContext productContext = loadPublicProductContext(categoryContext, null);
|
||||
@@ -231,11 +253,19 @@ public class PublicShopCatalogService {
|
||||
(left, right) -> left,
|
||||
LinkedHashMap::new
|
||||
));
|
||||
Map<String, ProductEntry> entriesByIdPrefix = entries.stream()
|
||||
.collect(Collectors.toMap(
|
||||
entry -> normalizeProductIdPrefix(ShopPublicPathSupport.productIdPrefix(entry.product().getId())),
|
||||
entry -> entry,
|
||||
(left, right) -> left,
|
||||
LinkedHashMap::new
|
||||
));
|
||||
|
||||
return new PublicProductContext(
|
||||
entries,
|
||||
entriesBySlug,
|
||||
entriesByPublicPath,
|
||||
entriesByIdPrefix,
|
||||
productMediaBySlug,
|
||||
variantColorHexByMaterialAndColor
|
||||
);
|
||||
@@ -566,6 +596,15 @@ public class PublicShopCatalogService {
|
||||
return normalized.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String normalizeProductIdPrefix(String idPrefix) {
|
||||
String normalized = trimToNull(idPrefix);
|
||||
if (normalized == null) {
|
||||
return null;
|
||||
}
|
||||
normalized = normalized.toLowerCase(Locale.ROOT);
|
||||
return normalized.matches("^[0-9a-f]{8}$") ? normalized : null;
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
String raw = String.valueOf(value == null ? "" : value).trim();
|
||||
if (raw.isEmpty()) {
|
||||
@@ -662,6 +701,7 @@ public class PublicShopCatalogService {
|
||||
List<ProductEntry> entries,
|
||||
Map<String, ProductEntry> entriesBySlug,
|
||||
Map<String, ProductEntry> entriesByPublicPath,
|
||||
Map<String, ProductEntry> entriesByIdPrefix,
|
||||
Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
|
||||
Map<String, String> variantColorHexByMaterialAndColor
|
||||
) {
|
||||
|
||||
@@ -108,6 +108,21 @@ class PublicShopCatalogServiceTest {
|
||||
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("en"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProductByIdPrefix_shouldResolveLocalizedProduct() {
|
||||
ShopCategory category = buildCategory();
|
||||
ShopProduct product = buildProduct(category);
|
||||
ShopProductVariant variant = buildVariant(product);
|
||||
|
||||
stubPublicCatalog(category, product, variant);
|
||||
|
||||
ShopProductDetailDto response = service.getProductByIdPrefix("12345678", "de");
|
||||
|
||||
assertEquals("bike-wall-hanger", response.slug());
|
||||
assertEquals("12345678-bike-wall-hanger", response.publicPath());
|
||||
assertEquals("/de/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("de"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getProductByPublicPath_shouldRejectNonCanonicalSegment() {
|
||||
ShopCategory category = buildCategory();
|
||||
|
||||
@@ -62,6 +62,8 @@ services:
|
||||
container_name: print-calculator-frontend-${ENV}
|
||||
ports:
|
||||
- "${FRONTEND_PORT}:80"
|
||||
environment:
|
||||
- SSR_INTERNAL_API_ORIGIN=http://backend:8000
|
||||
depends_on:
|
||||
- backend
|
||||
restart: always
|
||||
|
||||
@@ -8,11 +8,19 @@ import { REQUEST } from '@angular/core';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { serverOriginInterceptor } from './server-origin.interceptor';
|
||||
|
||||
type TestGlobal = typeof globalThis & {
|
||||
__SSR_INTERNAL_API_ORIGIN__?: string;
|
||||
};
|
||||
|
||||
describe('serverOriginInterceptor', () => {
|
||||
let http: HttpClient;
|
||||
let httpMock: HttpTestingController;
|
||||
const testGlobal = globalThis as TestGlobal;
|
||||
const originalInternalApiOrigin = testGlobal.__SSR_INTERNAL_API_ORIGIN__;
|
||||
|
||||
beforeEach(() => {
|
||||
delete testGlobal.__SSR_INTERNAL_API_ORIGIN__;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([serverOriginInterceptor])),
|
||||
@@ -21,6 +29,7 @@ describe('serverOriginInterceptor', () => {
|
||||
provide: REQUEST,
|
||||
useValue: {
|
||||
protocol: 'https',
|
||||
url: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||
headers: {
|
||||
host: 'dev.3d-fab.ch',
|
||||
authorization: 'Basic dGVzdDp0ZXN0',
|
||||
@@ -38,6 +47,11 @@ describe('serverOriginInterceptor', () => {
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
if (originalInternalApiOrigin) {
|
||||
testGlobal.__SSR_INTERNAL_API_ORIGIN__ = originalInternalApiOrigin;
|
||||
return;
|
||||
}
|
||||
delete testGlobal.__SSR_INTERNAL_API_ORIGIN__;
|
||||
});
|
||||
|
||||
it('rewrites relative SSR URLs to the incoming origin and forwards auth headers', () => {
|
||||
@@ -74,4 +88,109 @@ describe('serverOriginInterceptor', () => {
|
||||
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
||||
request.flush({});
|
||||
});
|
||||
|
||||
it('uses the internal SSR API origin for public shop discovery calls', () => {
|
||||
testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/';
|
||||
|
||||
http.get('/api/shop/products/by-id-prefix/91823f84?lang=de').subscribe();
|
||||
|
||||
const request = httpMock.expectOne(
|
||||
'http://backend:8000/api/shop/products/by-id-prefix/91823f84?lang=de',
|
||||
);
|
||||
expect(request.request.headers.get('authorization')).toBe(
|
||||
'Basic dGVzdDp0ZXN0',
|
||||
);
|
||||
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
||||
expect(request.request.headers.get('accept-language')).toBe(
|
||||
'de-CH,de;q=0.9,en;q=0.8',
|
||||
);
|
||||
request.flush({});
|
||||
});
|
||||
|
||||
it('bypasses the public origin even when the proxy strips authorization on shop SSR requests', () => {
|
||||
testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/';
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([serverOriginInterceptor])),
|
||||
provideHttpClientTesting(),
|
||||
{
|
||||
provide: REQUEST,
|
||||
useValue: {
|
||||
protocol: 'https',
|
||||
url: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||
headers: {
|
||||
host: 'dev.3d-fab.ch',
|
||||
cookie: 'session=abc123',
|
||||
'accept-language': 'de-CH,de;q=0.9,en;q=0.8',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
http = TestBed.inject(HttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
|
||||
http.get('/api/shop/products/by-id-prefix/91823f84?lang=de').subscribe();
|
||||
|
||||
const request = httpMock.expectOne(
|
||||
'http://backend:8000/api/shop/products/by-id-prefix/91823f84?lang=de',
|
||||
);
|
||||
expect(request.request.headers.get('authorization')).toBeNull();
|
||||
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
||||
expect(request.request.headers.get('accept-language')).toBe(
|
||||
'de-CH,de;q=0.9,en;q=0.8',
|
||||
);
|
||||
request.flush({});
|
||||
});
|
||||
|
||||
it('keeps transactional shop API calls on the public origin', () => {
|
||||
testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/';
|
||||
|
||||
http.get('/api/shop/cart').subscribe();
|
||||
|
||||
const request = httpMock.expectOne('https://dev.3d-fab.ch/api/shop/cart');
|
||||
expect(request.request.headers.get('authorization')).toBe(
|
||||
'Basic dGVzdDp0ZXN0',
|
||||
);
|
||||
request.flush({});
|
||||
});
|
||||
|
||||
it('keeps non-shop pages on the public origin even for public shop APIs', () => {
|
||||
testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/';
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([serverOriginInterceptor])),
|
||||
provideHttpClientTesting(),
|
||||
{
|
||||
provide: REQUEST,
|
||||
useValue: {
|
||||
protocol: 'https',
|
||||
url: '/de/checkout?session=abc',
|
||||
headers: {
|
||||
host: 'dev.3d-fab.ch',
|
||||
cookie: 'session=abc123',
|
||||
'accept-language': 'de-CH,de;q=0.9,en;q=0.8',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
http = TestBed.inject(HttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
|
||||
http.get('/api/shop/products/by-id-prefix/91823f84?lang=de').subscribe();
|
||||
|
||||
const request = httpMock.expectOne(
|
||||
'https://dev.3d-fab.ch/api/shop/products/by-id-prefix/91823f84?lang=de',
|
||||
);
|
||||
expect(request.request.headers.get('authorization')).toBeNull();
|
||||
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
||||
request.flush({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,12 +5,27 @@ import {
|
||||
resolveRequestOrigin,
|
||||
} from '../../../core/request-origin';
|
||||
|
||||
type ServerRequestLike = RequestLike & {
|
||||
originalUrl?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
const FORWARDED_REQUEST_HEADERS = [
|
||||
'authorization',
|
||||
'cookie',
|
||||
'accept-language',
|
||||
] as const;
|
||||
|
||||
const SHOP_DISCOVERY_API_PATTERNS = [
|
||||
/^\/api\/shop\/categories(?:\/[^/?#]+)?$/i,
|
||||
/^\/api\/shop\/products$/i,
|
||||
/^\/api\/shop\/products\/by-id-prefix\/[^/?#]+$/i,
|
||||
/^\/api\/shop\/products\/by-path\/[^/?#]+$/i,
|
||||
/^\/api\/shop\/products\/[^/?#]+$/i,
|
||||
] as const;
|
||||
|
||||
const SHOP_PAGE_PATH_PATTERN = /^\/(?:it|en|de|fr)\/shop(?:\/.*)?$/i;
|
||||
|
||||
function isAbsoluteUrl(url: string): boolean {
|
||||
return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//');
|
||||
}
|
||||
@@ -20,6 +35,14 @@ function normalizeRelativePath(url: string): string {
|
||||
return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`;
|
||||
}
|
||||
|
||||
function stripQueryAndHash(url: string): string {
|
||||
return String(url ?? '').split(/[?#]/, 1)[0] || '/';
|
||||
}
|
||||
|
||||
function normalizeOrigin(origin: string): string {
|
||||
return origin.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function readRequestHeader(
|
||||
request: RequestLike | null,
|
||||
name: (typeof FORWARDED_REQUEST_HEADERS)[number],
|
||||
@@ -34,18 +57,95 @@ function readRequestHeader(
|
||||
return typeof headerValue === 'string' ? headerValue : null;
|
||||
}
|
||||
|
||||
function readRequestPath(request: ServerRequestLike | null): string | null {
|
||||
const rawPath =
|
||||
(typeof request?.originalUrl === 'string' && request.originalUrl) ||
|
||||
(typeof request?.url === 'string' && request.url) ||
|
||||
null;
|
||||
if (!rawPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAbsoluteUrl(rawPath)) {
|
||||
try {
|
||||
return stripQueryAndHash(new URL(rawPath).pathname || '/');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return stripQueryAndHash(rawPath.startsWith('/') ? rawPath : `/${rawPath}`);
|
||||
}
|
||||
|
||||
function isPublicShopPageRequest(request: ServerRequestLike | null): boolean {
|
||||
const requestPath = readRequestPath(request);
|
||||
return !!requestPath && SHOP_PAGE_PATH_PATTERN.test(requestPath);
|
||||
}
|
||||
|
||||
function isPublicShopDiscoveryApi(url: string): boolean {
|
||||
const normalizedPath = stripQueryAndHash(normalizeRelativePath(url));
|
||||
return SHOP_DISCOVERY_API_PATTERNS.some((pattern) =>
|
||||
pattern.test(normalizedPath),
|
||||
);
|
||||
}
|
||||
|
||||
function readInternalApiOrigin(): string | null {
|
||||
const globalObject = globalThis as {
|
||||
__SSR_INTERNAL_API_ORIGIN__?: string;
|
||||
process?: {
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
};
|
||||
const explicitOverride =
|
||||
typeof globalObject.__SSR_INTERNAL_API_ORIGIN__ === 'string'
|
||||
? globalObject.__SSR_INTERNAL_API_ORIGIN__
|
||||
: null;
|
||||
const env = (
|
||||
globalObject as {
|
||||
process?: {
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
}
|
||||
).process?.env;
|
||||
const rawValue = explicitOverride ?? env?.['SSR_INTERNAL_API_ORIGIN'];
|
||||
if (typeof rawValue !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = rawValue.trim();
|
||||
return normalized ? normalizeOrigin(normalized) : null;
|
||||
}
|
||||
|
||||
function resolveApiOrigin(
|
||||
request: ServerRequestLike | null,
|
||||
relativeUrl: string,
|
||||
): string | null {
|
||||
const internalOrigin = readInternalApiOrigin();
|
||||
if (
|
||||
internalOrigin &&
|
||||
isPublicShopPageRequest(request) &&
|
||||
isPublicShopDiscoveryApi(relativeUrl)
|
||||
) {
|
||||
return internalOrigin;
|
||||
}
|
||||
|
||||
return resolveRequestOrigin(request);
|
||||
}
|
||||
|
||||
export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (isAbsoluteUrl(req.url)) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
const request = inject(REQUEST, { optional: true }) as RequestLike | null;
|
||||
const origin = resolveRequestOrigin(request);
|
||||
const request = inject(REQUEST, {
|
||||
optional: true,
|
||||
}) as ServerRequestLike | null;
|
||||
const origin = resolveApiOrigin(request, req.url);
|
||||
if (!origin) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
const absoluteUrl = `${origin}${normalizeRelativePath(req.url)}`;
|
||||
const absoluteUrl = `${normalizeOrigin(origin)}${normalizeRelativePath(req.url)}`;
|
||||
const forwardedHeaders = FORWARDED_REQUEST_HEADERS.reduce<
|
||||
Record<string, string>
|
||||
>((headers, name) => {
|
||||
|
||||
@@ -22,10 +22,30 @@
|
||||
</div>
|
||||
|
||||
<div class="col social">
|
||||
<!-- Social Placeholders -->
|
||||
<div class="social-icon"></div>
|
||||
<div class="social-icon"></div>
|
||||
<div class="social-icon"></div>
|
||||
<div class="social-link-row">
|
||||
<span class="social-name">Joe Küng:</span>
|
||||
<a
|
||||
class="social-icon-link"
|
||||
href="https://www.linkedin.com/in/joe-k%C3%BCng-31831828b/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Joe Küng LinkedIn"
|
||||
>
|
||||
<span class="social-icon-linkedin" aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="social-link-row">
|
||||
<span class="social-name">Matteo Caletti:</span>
|
||||
<a
|
||||
class="social-icon-link"
|
||||
href="https://www.linkedin.com/in/matteo-caletti-94291a3b6/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Matteo Caletti LinkedIn"
|
||||
>
|
||||
<span class="social-icon-linkedin" aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -66,11 +66,69 @@
|
||||
|
||||
.social {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.social-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: var(--color-neutral-800);
|
||||
border-radius: 50%;
|
||||
|
||||
.social-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.social-name {
|
||||
color: var(--color-neutral-200);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.social-icon-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-neutral-50);
|
||||
color: #0a66c2;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #0a66c2;
|
||||
color: var(--color-neutral-50);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-secondary-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.social-icon-linkedin {
|
||||
display: block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-color: currentColor;
|
||||
mask-image: url("/assets/images/SVG/linkedin-svgrepo-com.svg");
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
-webkit-mask-image: url("/assets/images/SVG/linkedin-svgrepo-com.svg");
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
-webkit-mask-size: contain;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.social {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.social-link-row {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,13 @@ describe('ProductDetailComponent', () => {
|
||||
};
|
||||
}
|
||||
|
||||
function createComponent(routerUrl = '/de/shop/p/91823f84-bike-wall-hanger') {
|
||||
function createComponent(
|
||||
routerUrl = '/de/shop/p/91823f84-bike-wall-hanger',
|
||||
options?: {
|
||||
currentLang?: 'it' | 'en' | 'de' | 'fr';
|
||||
selectedLang?: 'it' | 'en' | 'de' | 'fr';
|
||||
},
|
||||
) {
|
||||
const responseInit: { status?: number } = {};
|
||||
const seoService = jasmine.createSpyObj<SeoService>('SeoService', [
|
||||
'applyResolvedSeo',
|
||||
@@ -93,10 +99,12 @@ describe('ProductDetailComponent', () => {
|
||||
return translations[key] ?? key;
|
||||
});
|
||||
|
||||
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de');
|
||||
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>(
|
||||
options?.currentLang ?? 'de',
|
||||
);
|
||||
const languageService = {
|
||||
currentLang,
|
||||
selectedLang: () => currentLang(),
|
||||
selectedLang: () => options?.selectedLang ?? currentLang(),
|
||||
setLocalizedRouteOverrides: jasmine.createSpy(
|
||||
'setLocalizedRouteOverrides',
|
||||
),
|
||||
@@ -193,6 +201,21 @@ describe('ProductDetailComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the route language for canonical SEO even if the selected translation language lags', () => {
|
||||
const { component, seoService } = createComponent(undefined, {
|
||||
currentLang: 'de',
|
||||
selectedLang: 'en',
|
||||
});
|
||||
|
||||
(component as any).applySeo(buildProduct());
|
||||
|
||||
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
canonicalPath: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies noindex for products explicitly marked as non-indexable', () => {
|
||||
const { component, seoService } = createComponent();
|
||||
|
||||
|
||||
@@ -605,7 +605,7 @@ export class ProductDetailComponent {
|
||||
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
||||
const robots =
|
||||
product.indexable === false ? 'noindex, nofollow' : 'index, follow';
|
||||
const lang = this.languageService.selectedLang();
|
||||
const lang = this.languageService.currentLang();
|
||||
const canonicalPath =
|
||||
product.localizedPaths?.[lang] ?? product.localizedPaths?.it ?? null;
|
||||
|
||||
@@ -857,7 +857,7 @@ export class ProductDetailComponent {
|
||||
}
|
||||
|
||||
const currentTree = this.router.parseUrl(this.router.url);
|
||||
const lang = this.languageService.selectedLang();
|
||||
const lang = this.languageService.currentLang();
|
||||
const targetPath =
|
||||
product.localizedPaths?.[lang] ??
|
||||
`/${lang}/shop/p/${this.shopRouteService.productPathSegment(product)}`;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
HttpClientTestingModule,
|
||||
@@ -13,6 +14,11 @@ import { LanguageService } from '../../../core/services/language.service';
|
||||
describe('ShopService', () => {
|
||||
let service: ShopService;
|
||||
let httpMock: HttpTestingController;
|
||||
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('it');
|
||||
const languageService = {
|
||||
currentLang,
|
||||
selectedLang: jasmine.createSpy('selectedLang').and.returnValue('it'),
|
||||
};
|
||||
|
||||
const buildCart = (): ShopCartResponse => ({
|
||||
session: {
|
||||
@@ -131,13 +137,14 @@ describe('ShopService', () => {
|
||||
ShopService,
|
||||
{
|
||||
provide: LanguageService,
|
||||
useValue: {
|
||||
selectedLang: () => 'it',
|
||||
},
|
||||
useValue: languageService,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
currentLang.set('it');
|
||||
languageService.selectedLang.and.returnValue('it');
|
||||
|
||||
service = TestBed.inject(ShopService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
@@ -196,7 +203,7 @@ describe('ShopService', () => {
|
||||
return (
|
||||
request.method === 'GET' &&
|
||||
request.url ===
|
||||
'http://localhost:8000/api/shop/products/by-path/12345678-supporto-cavo-scrivania' &&
|
||||
'http://localhost:8000/api/shop/products/by-id-prefix/12345678' &&
|
||||
request.params.get('lang') === 'it'
|
||||
);
|
||||
});
|
||||
@@ -206,47 +213,76 @@ describe('ShopService', () => {
|
||||
expect(response?.name).toBe('Supporto cavo scrivania');
|
||||
});
|
||||
|
||||
it('rejects product paths whose slug tail does not match the canonical path', () => {
|
||||
let errorResponse: { status?: number } | undefined;
|
||||
it('resolves products from the stable uuid prefix even if the slug tail is stale', () => {
|
||||
let response: ShopProductDetail | undefined;
|
||||
|
||||
service.getProductByPublicPath('12345678-qualunque-nome').subscribe({
|
||||
next: () => fail('Expected canonical path mismatch to return 404'),
|
||||
error: (error) => {
|
||||
errorResponse = error;
|
||||
next: (product) => {
|
||||
response = product;
|
||||
},
|
||||
error: () =>
|
||||
fail('Expected stale slug tails to resolve from the uuid prefix'),
|
||||
});
|
||||
|
||||
const request = httpMock.expectOne((request) => {
|
||||
return (
|
||||
request.method === 'GET' &&
|
||||
request.url ===
|
||||
'http://localhost:8000/api/shop/products/by-path/12345678-qualunque-nome' &&
|
||||
'http://localhost:8000/api/shop/products/by-id-prefix/12345678' &&
|
||||
request.params.get('lang') === 'it'
|
||||
);
|
||||
});
|
||||
request.flush('Not found', { status: 404, statusText: 'Not Found' });
|
||||
expect(errorResponse?.status).toBe(404);
|
||||
request.flush(buildProduct());
|
||||
|
||||
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
||||
});
|
||||
|
||||
it('rejects bare uuid product paths without the localized slug tail', () => {
|
||||
let errorResponse: { status?: number } | undefined;
|
||||
it('resolves bare uuid product paths through the stable uuid prefix endpoint', () => {
|
||||
let response: ShopProductDetail | undefined;
|
||||
|
||||
service.getProductByPublicPath('12345678').subscribe({
|
||||
next: () => fail('Expected bare uuid path to return 404'),
|
||||
error: (error) => {
|
||||
errorResponse = error;
|
||||
next: (product) => {
|
||||
response = product;
|
||||
},
|
||||
error: () =>
|
||||
fail('Expected bare uuid path to resolve from the uuid prefix'),
|
||||
});
|
||||
|
||||
const request = httpMock.expectOne((request) => {
|
||||
return (
|
||||
request.method === 'GET' &&
|
||||
request.url ===
|
||||
'http://localhost:8000/api/shop/products/by-path/12345678' &&
|
||||
'http://localhost:8000/api/shop/products/by-id-prefix/12345678' &&
|
||||
request.params.get('lang') === 'it'
|
||||
);
|
||||
});
|
||||
request.flush('Not found', { status: 404, statusText: 'Not Found' });
|
||||
expect(errorResponse?.status).toBe(404);
|
||||
request.flush(buildProduct());
|
||||
|
||||
expect(response?.publicPath).toBe('12345678-supporto-cavo-scrivania');
|
||||
});
|
||||
|
||||
it('uses the route language for public shop lookups when translate.currentLang lags behind', () => {
|
||||
let response: ShopProductDetail | undefined;
|
||||
|
||||
currentLang.set('de');
|
||||
languageService.selectedLang.and.returnValue('en');
|
||||
|
||||
service
|
||||
.getProductByPublicPath('12345678-schreibtisch-kabelhalter')
|
||||
.subscribe((product) => {
|
||||
response = product;
|
||||
});
|
||||
|
||||
const request = httpMock.expectOne((request) => {
|
||||
return (
|
||||
request.method === 'GET' &&
|
||||
request.url ===
|
||||
'http://localhost:8000/api/shop/products/by-id-prefix/12345678' &&
|
||||
request.params.get('lang') === 'de'
|
||||
);
|
||||
});
|
||||
request.flush(buildProduct());
|
||||
|
||||
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,12 +290,14 @@ export class ShopService {
|
||||
}));
|
||||
}
|
||||
|
||||
return this.http.get<ShopProductDetail>(
|
||||
`${this.apiUrl}/products/by-path/${encodeURIComponent(normalizedPath)}`,
|
||||
{
|
||||
params: this.buildLangParams(),
|
||||
},
|
||||
);
|
||||
const productIdPrefix = this.extractProductIdPrefix(normalizedPath);
|
||||
const endpoint = productIdPrefix
|
||||
? `${this.apiUrl}/products/by-id-prefix/${encodeURIComponent(productIdPrefix)}`
|
||||
: `${this.apiUrl}/products/by-path/${encodeURIComponent(normalizedPath)}`;
|
||||
|
||||
return this.http.get<ShopProductDetail>(endpoint, {
|
||||
params: this.buildLangParams(),
|
||||
});
|
||||
}
|
||||
|
||||
private normalizePublicPath(value: string | null | undefined): string {
|
||||
@@ -304,6 +306,11 @@ export class ShopService {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
private extractProductIdPrefix(value: string): string | null {
|
||||
const match = value.match(/^([0-9a-f]{8})(?:-|$)/);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
loadCart(): Observable<ShopCartResponse> {
|
||||
this.cartLoading.set(true);
|
||||
return this.http
|
||||
@@ -455,7 +462,10 @@ export class ShopService {
|
||||
}
|
||||
|
||||
private buildLangParams(): HttpParams {
|
||||
return new HttpParams().set('lang', this.languageService.selectedLang());
|
||||
// Public shop URLs are localized. During direct loads the translation
|
||||
// service can still momentarily reflect the browser language, while the
|
||||
// route language has already been resolved from the URL.
|
||||
return new HttpParams().set('lang', this.languageService.currentLang());
|
||||
}
|
||||
|
||||
private setCart(cart: ShopCartResponse): void {
|
||||
|
||||
14
frontend/src/assets/images/SVG/linkedin-svgrepo-com.svg
Normal file
14
frontend/src/assets/images/SVG/linkedin-svgrepo-com.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 382 382" xml:space="preserve">
|
||||
<path style="fill:#0077B7;" d="M347.445,0H34.555C15.471,0,0,15.471,0,34.555v312.889C0,366.529,15.471,382,34.555,382h312.889
|
||||
C366.529,382,382,366.529,382,347.444V34.555C382,15.471,366.529,0,347.445,0z M118.207,329.844c0,5.554-4.502,10.056-10.056,10.056
|
||||
H65.345c-5.554,0-10.056-4.502-10.056-10.056V150.403c0-5.554,4.502-10.056,10.056-10.056h42.806
|
||||
c5.554,0,10.056,4.502,10.056,10.056V329.844z M86.748,123.432c-22.459,0-40.666-18.207-40.666-40.666S64.289,42.1,86.748,42.1
|
||||
s40.666,18.207,40.666,40.666S109.208,123.432,86.748,123.432z M341.91,330.654c0,5.106-4.14,9.246-9.246,9.246H286.73
|
||||
c-5.106,0-9.246-4.14-9.246-9.246v-84.168c0-12.556,3.683-55.021-32.813-55.021c-28.309,0-34.051,29.066-35.204,42.11v97.079
|
||||
c0,5.106-4.139,9.246-9.246,9.246h-44.426c-5.106,0-9.246-4.14-9.246-9.246V149.593c0-5.106,4.14-9.246,9.246-9.246h44.426
|
||||
c5.106,0,9.246,4.14,9.246,9.246v15.655c10.497-15.753,26.097-27.912,59.312-27.912c73.552,0,73.131,68.716,73.131,106.472
|
||||
L341.91,330.654L341.91,330.654z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
Reference in New Issue
Block a user