Compare commits
9 Commits
feat/mater
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fa2e249e94 | |||
| 132f0f3646 | |||
| 3cbcec5f53 | |||
|
|
8835175fb3 | ||
| 28c3abdb4a | |||
| b30bfc9293 | |||
| d70423fcc0 | |||
| 1b7c0c48e7 | |||
|
|
cb86137730 |
@@ -62,6 +62,12 @@ public class PublicShopController {
|
|||||||
return ResponseEntity.ok(publicShopCatalogService.getProductByPublicPath(publicPath, lang));
|
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")
|
@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);
|
||||||
|
|||||||
@@ -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) {
|
public ProductModelDownload getProductModelDownload(String slug) {
|
||||||
CategoryContext categoryContext = loadCategoryContext(null);
|
CategoryContext categoryContext = loadCategoryContext(null);
|
||||||
PublicProductContext productContext = loadPublicProductContext(categoryContext, null);
|
PublicProductContext productContext = loadPublicProductContext(categoryContext, null);
|
||||||
@@ -231,11 +253,19 @@ public class PublicShopCatalogService {
|
|||||||
(left, right) -> left,
|
(left, right) -> left,
|
||||||
LinkedHashMap::new
|
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(
|
return new PublicProductContext(
|
||||||
entries,
|
entries,
|
||||||
entriesBySlug,
|
entriesBySlug,
|
||||||
entriesByPublicPath,
|
entriesByPublicPath,
|
||||||
|
entriesByIdPrefix,
|
||||||
productMediaBySlug,
|
productMediaBySlug,
|
||||||
variantColorHexByMaterialAndColor
|
variantColorHexByMaterialAndColor
|
||||||
);
|
);
|
||||||
@@ -566,6 +596,15 @@ public class PublicShopCatalogService {
|
|||||||
return normalized.toLowerCase(Locale.ROOT);
|
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) {
|
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()) {
|
||||||
@@ -662,6 +701,7 @@ public class PublicShopCatalogService {
|
|||||||
List<ProductEntry> entries,
|
List<ProductEntry> entries,
|
||||||
Map<String, ProductEntry> entriesBySlug,
|
Map<String, ProductEntry> entriesBySlug,
|
||||||
Map<String, ProductEntry> entriesByPublicPath,
|
Map<String, ProductEntry> entriesByPublicPath,
|
||||||
|
Map<String, ProductEntry> entriesByIdPrefix,
|
||||||
Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
|
Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
|
||||||
Map<String, String> variantColorHexByMaterialAndColor
|
Map<String, String> variantColorHexByMaterialAndColor
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -108,6 +108,21 @@ class PublicShopCatalogServiceTest {
|
|||||||
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("en"));
|
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
|
@Test
|
||||||
void getProductByPublicPath_shouldRejectNonCanonicalSegment() {
|
void getProductByPublicPath_shouldRejectNonCanonicalSegment() {
|
||||||
ShopCategory category = buildCategory();
|
ShopCategory category = buildCategory();
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ services:
|
|||||||
container_name: print-calculator-frontend-${ENV}
|
container_name: print-calculator-frontend-${ENV}
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT}:80"
|
- "${FRONTEND_PORT}:80"
|
||||||
|
environment:
|
||||||
|
- SSR_INTERNAL_API_ORIGIN=http://backend:8000
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -8,11 +8,19 @@ import { REQUEST } from '@angular/core';
|
|||||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import { serverOriginInterceptor } from './server-origin.interceptor';
|
import { serverOriginInterceptor } from './server-origin.interceptor';
|
||||||
|
|
||||||
|
type TestGlobal = typeof globalThis & {
|
||||||
|
__SSR_INTERNAL_API_ORIGIN__?: string;
|
||||||
|
};
|
||||||
|
|
||||||
describe('serverOriginInterceptor', () => {
|
describe('serverOriginInterceptor', () => {
|
||||||
let http: HttpClient;
|
let http: HttpClient;
|
||||||
let httpMock: HttpTestingController;
|
let httpMock: HttpTestingController;
|
||||||
|
const testGlobal = globalThis as TestGlobal;
|
||||||
|
const originalInternalApiOrigin = testGlobal.__SSR_INTERNAL_API_ORIGIN__;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
delete testGlobal.__SSR_INTERNAL_API_ORIGIN__;
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(withInterceptors([serverOriginInterceptor])),
|
provideHttpClient(withInterceptors([serverOriginInterceptor])),
|
||||||
@@ -21,6 +29,7 @@ describe('serverOriginInterceptor', () => {
|
|||||||
provide: REQUEST,
|
provide: REQUEST,
|
||||||
useValue: {
|
useValue: {
|
||||||
protocol: 'https',
|
protocol: 'https',
|
||||||
|
url: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||||
headers: {
|
headers: {
|
||||||
host: 'dev.3d-fab.ch',
|
host: 'dev.3d-fab.ch',
|
||||||
authorization: 'Basic dGVzdDp0ZXN0',
|
authorization: 'Basic dGVzdDp0ZXN0',
|
||||||
@@ -38,6 +47,11 @@ describe('serverOriginInterceptor', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
httpMock.verify();
|
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', () => {
|
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');
|
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
||||||
request.flush({});
|
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,
|
resolveRequestOrigin,
|
||||||
} from '../../../core/request-origin';
|
} from '../../../core/request-origin';
|
||||||
|
|
||||||
|
type ServerRequestLike = RequestLike & {
|
||||||
|
originalUrl?: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const FORWARDED_REQUEST_HEADERS = [
|
const FORWARDED_REQUEST_HEADERS = [
|
||||||
'authorization',
|
'authorization',
|
||||||
'cookie',
|
'cookie',
|
||||||
'accept-language',
|
'accept-language',
|
||||||
] as const;
|
] 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 {
|
function isAbsoluteUrl(url: string): boolean {
|
||||||
return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//');
|
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}`;
|
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(
|
function readRequestHeader(
|
||||||
request: RequestLike | null,
|
request: RequestLike | null,
|
||||||
name: (typeof FORWARDED_REQUEST_HEADERS)[number],
|
name: (typeof FORWARDED_REQUEST_HEADERS)[number],
|
||||||
@@ -34,18 +57,95 @@ function readRequestHeader(
|
|||||||
return typeof headerValue === 'string' ? headerValue : null;
|
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) => {
|
export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
if (isAbsoluteUrl(req.url)) {
|
if (isAbsoluteUrl(req.url)) {
|
||||||
return next(req);
|
return next(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = inject(REQUEST, { optional: true }) as RequestLike | null;
|
const request = inject(REQUEST, {
|
||||||
const origin = resolveRequestOrigin(request);
|
optional: true,
|
||||||
|
}) as ServerRequestLike | null;
|
||||||
|
const origin = resolveApiOrigin(request, req.url);
|
||||||
if (!origin) {
|
if (!origin) {
|
||||||
return next(req);
|
return next(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
const absoluteUrl = `${origin}${normalizeRelativePath(req.url)}`;
|
const absoluteUrl = `${normalizeOrigin(origin)}${normalizeRelativePath(req.url)}`;
|
||||||
const forwardedHeaders = FORWARDED_REQUEST_HEADERS.reduce<
|
const forwardedHeaders = FORWARDED_REQUEST_HEADERS.reduce<
|
||||||
Record<string, string>
|
Record<string, string>
|
||||||
>((headers, name) => {
|
>((headers, name) => {
|
||||||
|
|||||||
@@ -22,10 +22,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col social">
|
<div class="col social">
|
||||||
<!-- Social Placeholders -->
|
<div class="social-link-row">
|
||||||
<div class="social-icon"></div>
|
<span class="social-name">Joe Küng:</span>
|
||||||
<div class="social-icon"></div>
|
<a
|
||||||
<div class="social-icon"></div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -66,11 +66,69 @@
|
|||||||
|
|
||||||
.social {
|
.social {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
.social-icon {
|
|
||||||
width: 24px;
|
.social-link-row {
|
||||||
height: 24px;
|
display: flex;
|
||||||
background-color: var(--color-neutral-800);
|
align-items: center;
|
||||||
border-radius: 50%;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,397 +0,0 @@
|
|||||||
<main class="materials-page">
|
|
||||||
<section class="hero">
|
|
||||||
<div class="container hero-inner">
|
|
||||||
<p class="ui-eyebrow">Guida materiali</p>
|
|
||||||
<h1>Qualita e Materiali</h1>
|
|
||||||
<p class="hero-lead">
|
|
||||||
Confronta materiali in modo interattivo con radar chart, metriche tecniche,
|
|
||||||
vantaggi, limiti e fonti verificabili.
|
|
||||||
</p>
|
|
||||||
<p class="hero-note">
|
|
||||||
Seleziona fino a {{ maxCompareCount }} materiali: il grafico aggiorna i
|
|
||||||
punteggi in tempo reale.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="selector-section">
|
|
||||||
<div class="container">
|
|
||||||
<h2>Selezione confronto</h2>
|
|
||||||
<div class="selector-grid" role="group" aria-label="Selezione materiali">
|
|
||||||
@for (material of materials; track trackMaterial($index, material)) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="selector-chip"
|
|
||||||
[class.is-selected]="isSelected(material.id)"
|
|
||||||
[disabled]="!canSelect(material.id)"
|
|
||||||
(click)="toggleMaterial(material.id)"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="selector-dot"
|
|
||||||
[style.background-color]="legendDotColor(material.id)"
|
|
||||||
></span>
|
|
||||||
<span>{{ material.name }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<p class="selector-help">
|
|
||||||
Nota: per l asse Economicita, un valore alto significa costo al kg piu
|
|
||||||
conveniente.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="chart-section">
|
|
||||||
<div class="container chart-layout">
|
|
||||||
<article class="chart-card">
|
|
||||||
<header class="chart-header">
|
|
||||||
<h2>Radar chart comparativo</h2>
|
|
||||||
<p>
|
|
||||||
Punteggi normalizzati 0-100 su tutto il set materiali (min-max scaling).
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<svg
|
|
||||||
class="radar-chart"
|
|
||||||
[attr.viewBox]="'0 0 ' + chartSize + ' ' + chartSize"
|
|
||||||
role="img"
|
|
||||||
aria-label="Radar chart materiali"
|
|
||||||
>
|
|
||||||
<g class="chart-rings">
|
|
||||||
@for (ring of ringPolygons(); track $index) {
|
|
||||||
<polygon [attr.points]="ring"></polygon>
|
|
||||||
}
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g class="chart-axes">
|
|
||||||
@for (axis of axisGuides(); track axis.id) {
|
|
||||||
<line
|
|
||||||
[attr.x1]="axis.fromX"
|
|
||||||
[attr.y1]="axis.fromY"
|
|
||||||
[attr.x2]="axis.x"
|
|
||||||
[attr.y2]="axis.y"
|
|
||||||
></line>
|
|
||||||
<text
|
|
||||||
[attr.x]="axis.labelX"
|
|
||||||
[attr.y]="axis.labelY"
|
|
||||||
[attr.text-anchor]="axis.labelAnchor"
|
|
||||||
>
|
|
||||||
{{ radarAxes[$index].label }}
|
|
||||||
</text>
|
|
||||||
}
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g class="chart-series">
|
|
||||||
@for (series of radarSeries(); track series.material.id) {
|
|
||||||
<polygon
|
|
||||||
class="series-shape"
|
|
||||||
[attr.points]="series.points"
|
|
||||||
[style.stroke]="series.color"
|
|
||||||
[style.fill]="series.fill"
|
|
||||||
[class.is-hovered]="hoveredMaterialId() === series.material.id"
|
|
||||||
(mouseenter)="setHoveredMaterial(series.material.id)"
|
|
||||||
(mouseleave)="setHoveredMaterial(null)"
|
|
||||||
></polygon>
|
|
||||||
|
|
||||||
@for (point of series.values; track point.axis.id) {
|
|
||||||
<circle
|
|
||||||
class="series-node"
|
|
||||||
[attr.cx]="point.x"
|
|
||||||
[attr.cy]="point.y"
|
|
||||||
r="4"
|
|
||||||
[style.fill]="series.color"
|
|
||||||
></circle>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div class="chart-legend">
|
|
||||||
@for (series of radarSeries(); track series.material.id) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="legend-item"
|
|
||||||
(mouseenter)="setHoveredMaterial(series.material.id)"
|
|
||||||
(mouseleave)="setHoveredMaterial(null)"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="legend-dot"
|
|
||||||
[style.background-color]="series.color"
|
|
||||||
></span>
|
|
||||||
<span>{{ series.material.name }}</span>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="explain-card">
|
|
||||||
<h3>Spiegazione completa del radar</h3>
|
|
||||||
<p>
|
|
||||||
Ogni asse mostra una proprieta tecnica. Il valore 100 rappresenta la
|
|
||||||
miglior performance relativa nel dataset attuale; 0 la meno favorevole.
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
@for (axis of radarAxes; track axis.id) {
|
|
||||||
<li>
|
|
||||||
<strong>{{ axis.label }}:</strong>
|
|
||||||
{{ axis.description }}
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
<p>
|
|
||||||
La normalizzazione e calcolata su tutti i materiali mostrati in pagina.
|
|
||||||
Per leggibilita il radar usa un raggio minimo visivo: i valori minimi
|
|
||||||
restano i meno favorevoli, ma non collassano tutti nello stesso punto.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="table-section">
|
|
||||||
<div class="container">
|
|
||||||
<h2>Tabella tecnica di confronto</h2>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Parametro</th>
|
|
||||||
@for (material of selectedMaterials(); track trackMaterial($index, material)) {
|
|
||||||
<th>{{ material.name }}</th>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (row of comparisonRows(); track row.label) {
|
|
||||||
<tr>
|
|
||||||
<th>{{ row.label }}</th>
|
|
||||||
@for (value of row.values; track $index) {
|
|
||||||
<td>{{ value }}</td>
|
|
||||||
}
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="quality-section">
|
|
||||||
<div class="container">
|
|
||||||
<h2>Layer, ugello e infill: esempi pratici</h2>
|
|
||||||
<p class="quality-intro">
|
|
||||||
Questa sezione non e un calcolatore interattivo: spiega visivamente cosa
|
|
||||||
cambia su oggetti reali e come leggere i risultati del vostro calcolatore.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="visual-guide-grid">
|
|
||||||
@for (guide of qualityVisualCards(); track trackVisualGuide($index, guide)) {
|
|
||||||
<article class="visual-guide-card">
|
|
||||||
<p class="visual-guide-category">{{ guide.category }}</p>
|
|
||||||
<h3>{{ guide.title }}</h3>
|
|
||||||
|
|
||||||
<div class="visual-guide-media">
|
|
||||||
@if (guide.image; as image) {
|
|
||||||
<picture>
|
|
||||||
@if (image.source.avifUrl) {
|
|
||||||
<source [srcset]="image.source.avifUrl" type="image/avif" />
|
|
||||||
}
|
|
||||||
@if (image.source.webpUrl) {
|
|
||||||
<source [srcset]="image.source.webpUrl" type="image/webp" />
|
|
||||||
}
|
|
||||||
<img
|
|
||||||
[src]="image.source.fallbackUrl"
|
|
||||||
[attr.alt]="image.altText || guide.title"
|
|
||||||
width="640"
|
|
||||||
height="420"
|
|
||||||
/>
|
|
||||||
</picture>
|
|
||||||
} @else {
|
|
||||||
<div class="media-fallback">
|
|
||||||
<span>
|
|
||||||
Nessuna immagine assegnata.
|
|
||||||
Carica asset backend con usageType
|
|
||||||
<code>MATERIALS_PAGE</code> e usageKey
|
|
||||||
<code>{{ guide.usageKey }}</code>.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p><strong>Oggetto esempio:</strong> {{ guide.objectExample }}</p>
|
|
||||||
<p><strong>Meglio per:</strong> {{ guide.bestFor }}</p>
|
|
||||||
<p><strong>Limite:</strong> {{ guide.tradeoff }}</p>
|
|
||||||
<p class="visual-guide-calc">
|
|
||||||
<strong>Lettura nel calcolatore:</strong> {{ guide.calculatorRead }}
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<article class="calculator-logic-card">
|
|
||||||
<h3>Come leggere il nostro calcolatore</h3>
|
|
||||||
<p>
|
|
||||||
Il calcolatore non sostituisce i profili slicer: serve a spiegare il
|
|
||||||
compromesso tra estetica, robustezza e tempi in modo coerente.
|
|
||||||
</p>
|
|
||||||
<div class="logic-table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Metrica</th>
|
|
||||||
<th>Cosa significa</th>
|
|
||||||
<th>Valore alto</th>
|
|
||||||
<th>Valore basso</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (rule of calculatorRules; track rule.metric) {
|
|
||||||
<tr>
|
|
||||||
<th>{{ rule.metric }}</th>
|
|
||||||
<td>{{ rule.whatItMeans }}</td>
|
|
||||||
<td>{{ rule.whenHigh }}</td>
|
|
||||||
<td>{{ rule.whenLow }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<div class="quality-layout">
|
|
||||||
<article class="quality-card">
|
|
||||||
<h3>Regole rapide per l utente</h3>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
Layer basso e ugello piccolo migliorano i dettagli, ma aumentano i
|
|
||||||
tempi.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Infill e perimetri alti migliorano resistenza, ma aumentano tempo e
|
|
||||||
materiale.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Per pezzi estetici usa profili fini; per pezzi funzionali scegli setup
|
|
||||||
bilanciati o robusti.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="guides-grid">
|
|
||||||
@for (guide of qualityGuides; track trackGuide($index, guide)) {
|
|
||||||
<article class="guide-card">
|
|
||||||
<h3>{{ guide.title }}</h3>
|
|
||||||
<p><strong>Range consigliato:</strong> {{ guide.recommendation }}</p>
|
|
||||||
<p>{{ guide.explanation }}</p>
|
|
||||||
<p class="guide-effect">{{ guide.practicalEffect }}</p>
|
|
||||||
</article>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="materials-section">
|
|
||||||
<div class="container">
|
|
||||||
<h2>Schede materiali: spiegazioni, pro/contro, fonti</h2>
|
|
||||||
<div class="materials-grid">
|
|
||||||
@for (card of selectedCards(); track card.material.id) {
|
|
||||||
<article class="material-card">
|
|
||||||
<header>
|
|
||||||
<h3>{{ card.material.name }}</h3>
|
|
||||||
<p>{{ card.material.summary }}</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="material-media">
|
|
||||||
@if (card.image; as image) {
|
|
||||||
<picture>
|
|
||||||
@if (image.source.avifUrl) {
|
|
||||||
<source [srcset]="image.source.avifUrl" type="image/avif" />
|
|
||||||
}
|
|
||||||
@if (image.source.webpUrl) {
|
|
||||||
<source [srcset]="image.source.webpUrl" type="image/webp" />
|
|
||||||
}
|
|
||||||
<img
|
|
||||||
[src]="image.source.fallbackUrl"
|
|
||||||
[attr.alt]="image.altText || card.material.name"
|
|
||||||
width="640"
|
|
||||||
height="400"
|
|
||||||
/>
|
|
||||||
</picture>
|
|
||||||
} @else {
|
|
||||||
<div class="media-fallback">
|
|
||||||
<span>
|
|
||||||
Nessuna immagine assegnata.
|
|
||||||
Carica asset backend con usageType
|
|
||||||
<code>MATERIALS_PAGE</code> e usageKey
|
|
||||||
<code>material-{{ card.material.id }}</code>.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="material-columns">
|
|
||||||
<div>
|
|
||||||
<h4>Vantaggi</h4>
|
|
||||||
<ul>
|
|
||||||
@for (item of card.material.pros; track $index) {
|
|
||||||
<li>{{ item }}</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4>Limiti</h4>
|
|
||||||
<ul>
|
|
||||||
@for (item of card.material.cons; track $index) {
|
|
||||||
<li>{{ item }}</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4>Ideale per</h4>
|
|
||||||
<ul>
|
|
||||||
@for (item of card.material.idealFor; track $index) {
|
|
||||||
<li>{{ item }}</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="source-list">
|
|
||||||
<h4>Fonti citate</h4>
|
|
||||||
<ul>
|
|
||||||
@for (source of card.material.sources; track trackSource($index, source)) {
|
|
||||||
<li>
|
|
||||||
<a [href]="source.url" target="_blank" rel="noopener noreferrer">
|
|
||||||
{{ source.label }}
|
|
||||||
</a>
|
|
||||||
<span class="source-kind">{{ source.kind }}</span>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="global-sources">
|
|
||||||
<div class="container">
|
|
||||||
<h2>Indice completo fonti</h2>
|
|
||||||
<p>
|
|
||||||
Tutti i link usati per metriche e descrizioni sono riportati qui in forma
|
|
||||||
centralizzata.
|
|
||||||
</p>
|
|
||||||
<ul class="global-source-list">
|
|
||||||
@for (source of allSources(); track trackSource($index, source)) {
|
|
||||||
<li>
|
|
||||||
<a [href]="source.url" target="_blank" rel="noopener noreferrer">
|
|
||||||
{{ source.label }}
|
|
||||||
</a>
|
|
||||||
<span>{{ source.kind }}</span>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
@@ -1,546 +0,0 @@
|
|||||||
.materials-page {
|
|
||||||
--materials-bg: #ffffff;
|
|
||||||
--materials-accent: #c23b22;
|
|
||||||
--materials-muted: #5f6771;
|
|
||||||
--materials-card: #ffffff;
|
|
||||||
background: var(--materials-bg);
|
|
||||||
color: var(--color-text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
padding: 5rem 0 2.25rem;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: -40% -15% auto auto;
|
|
||||||
width: 420px;
|
|
||||||
height: 420px;
|
|
||||||
background: radial-gradient(circle, rgba(194, 59, 34, 0.08), transparent 70%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-inner {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero h1 {
|
|
||||||
margin: 0.4rem 0 1rem;
|
|
||||||
font-size: clamp(2rem, 4vw, 3rem);
|
|
||||||
line-height: 1.05;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-lead {
|
|
||||||
margin: 0;
|
|
||||||
max-width: 68ch;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
color: var(--color-text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-note {
|
|
||||||
margin: 0.9rem 0 0;
|
|
||||||
color: var(--materials-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-section,
|
|
||||||
.chart-section,
|
|
||||||
.table-section,
|
|
||||||
.quality-section,
|
|
||||||
.materials-section,
|
|
||||||
.global-sources {
|
|
||||||
padding: 1.8rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-grid {
|
|
||||||
margin-top: 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-chip {
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
background: #fff;
|
|
||||||
color: var(--color-text-main);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.5rem 0.9rem;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
transform 0.2s ease,
|
|
||||||
border-color 0.2s ease,
|
|
||||||
box-shadow 0.2s ease,
|
|
||||||
background-color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-chip:hover:enabled {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
border-color: var(--materials-accent);
|
|
||||||
box-shadow: 0 4px 12px rgb(16 24 32 / 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-chip:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-chip.is-selected {
|
|
||||||
border-color: var(--materials-accent);
|
|
||||||
background: #fff3ee;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-dot {
|
|
||||||
width: 0.7rem;
|
|
||||||
height: 0.7rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1px solid rgb(0 0 0 / 0.15);
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selector-help {
|
|
||||||
margin-top: 0.8rem;
|
|
||||||
color: var(--materials-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-layout {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-card,
|
|
||||||
.explain-card,
|
|
||||||
.material-card,
|
|
||||||
.table-wrap,
|
|
||||||
.quality-card,
|
|
||||||
.guide-card {
|
|
||||||
background: var(--materials-card);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 1rem;
|
|
||||||
box-shadow: 0 10px 28px rgb(16 24 32 / 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-card {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-header h2 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-header p {
|
|
||||||
margin: 0.5rem 0 0;
|
|
||||||
color: var(--materials-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.radar-chart {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 520px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-rings polygon {
|
|
||||||
fill: none;
|
|
||||||
stroke: #d7d9de;
|
|
||||||
stroke-width: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-axes line {
|
|
||||||
stroke: #c3c8cf;
|
|
||||||
stroke-width: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-axes text {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
fill: #4f5a66;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-shape {
|
|
||||||
stroke-width: 2.2;
|
|
||||||
transition: filter 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-shape.is-hovered {
|
|
||||||
filter: drop-shadow(0 3px 8px rgb(16 24 32 / 0.26));
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-node {
|
|
||||||
stroke: #ffffff;
|
|
||||||
stroke-width: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-legend {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-item {
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 0.35rem 0.7rem;
|
|
||||||
background: #fff;
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 0.45rem;
|
|
||||||
align-items: center;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-dot {
|
|
||||||
width: 0.65rem;
|
|
||||||
height: 0.65rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.explain-card {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.explain-card h3 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.explain-card p {
|
|
||||||
margin: 0.75rem 0;
|
|
||||||
color: var(--materials-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.explain-card ul {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 1.1rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-wrap {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-wrap table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
min-width: 760px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-wrap th,
|
|
||||||
.table-wrap td {
|
|
||||||
padding: 0.6rem 0.75rem;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-wrap thead th {
|
|
||||||
background: #f8fafd;
|
|
||||||
font-size: 0.84rem;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-wrap tbody tr:hover {
|
|
||||||
background: #f8fbff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-intro {
|
|
||||||
margin: 0.4rem 0 0;
|
|
||||||
color: var(--materials-muted);
|
|
||||||
max-width: 72ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visual-guide-grid {
|
|
||||||
margin-top: 1rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.visual-guide-card {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 1rem;
|
|
||||||
box-shadow: 0 10px 28px rgb(16 24 32 / 0.05);
|
|
||||||
padding: 0.85rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.55rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visual-guide-category {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #2563b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visual-guide-card h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.02rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visual-guide-media {
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #f7f8fb;
|
|
||||||
min-height: 170px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visual-guide-media img {
|
|
||||||
width: 100%;
|
|
||||||
height: 185px;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visual-guide-card p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--materials-muted);
|
|
||||||
font-size: 0.92rem;
|
|
||||||
line-height: 1.42;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visual-guide-calc {
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
color: var(--color-text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
.calculator-logic-card {
|
|
||||||
margin-top: 1rem;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 1rem;
|
|
||||||
box-shadow: 0 10px 28px rgb(16 24 32 / 0.05);
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calculator-logic-card h3 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.calculator-logic-card p {
|
|
||||||
margin: 0.6rem 0 0;
|
|
||||||
color: var(--materials-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logic-table-wrap {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logic-table-wrap table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
min-width: 720px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logic-table-wrap th,
|
|
||||||
.logic-table-wrap td {
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
text-align: left;
|
|
||||||
padding: 0.62rem 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logic-table-wrap thead th {
|
|
||||||
background: #f8fafd;
|
|
||||||
font-size: 0.84rem;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-layout {
|
|
||||||
margin-top: 1rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-card {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-card h3 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-card ul {
|
|
||||||
margin: 0.7rem 0 0;
|
|
||||||
padding-left: 1rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.45rem;
|
|
||||||
color: var(--materials-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.guides-grid {
|
|
||||||
margin-top: 1rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.8rem;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-card {
|
|
||||||
padding: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-card h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-card p {
|
|
||||||
margin: 0.55rem 0 0;
|
|
||||||
color: var(--materials-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-effect {
|
|
||||||
color: var(--color-text-main);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.materials-grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-card {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-card h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-card header p {
|
|
||||||
margin: 0.55rem 0 0;
|
|
||||||
color: var(--materials-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-media {
|
|
||||||
margin-top: 0.85rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
background: #f7f8fb;
|
|
||||||
min-height: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-media img {
|
|
||||||
width: 100%;
|
|
||||||
height: 220px;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-fallback {
|
|
||||||
min-height: 180px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 1rem;
|
|
||||||
color: var(--materials-muted);
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-columns {
|
|
||||||
margin-top: 0.9rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-columns h4,
|
|
||||||
.source-list h4 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.material-columns ul,
|
|
||||||
.source-list ul,
|
|
||||||
.global-source-list {
|
|
||||||
margin: 0.45rem 0 0;
|
|
||||||
padding-left: 1rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-list {
|
|
||||||
margin-top: 0.9rem;
|
|
||||||
padding-top: 0.9rem;
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-list li,
|
|
||||||
.global-source-list li {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-list a,
|
|
||||||
.global-source-list a {
|
|
||||||
color: #14409b;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-kind {
|
|
||||||
color: var(--materials-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.global-sources p {
|
|
||||||
color: var(--materials-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.global-source-list {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
padding: 1rem 1rem 1rem 1.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.chart-layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-layout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.hero {
|
|
||||||
padding-top: 4.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-card,
|
|
||||||
.explain-card,
|
|
||||||
.material-card {
|
|
||||||
padding: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-wrap table {
|
|
||||||
min-width: 640px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-list li,
|
|
||||||
.global-source-list li {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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 responseInit: { status?: number } = {};
|
||||||
const seoService = jasmine.createSpyObj<SeoService>('SeoService', [
|
const seoService = jasmine.createSpyObj<SeoService>('SeoService', [
|
||||||
'applyResolvedSeo',
|
'applyResolvedSeo',
|
||||||
@@ -93,11 +99,15 @@ describe('ProductDetailComponent', () => {
|
|||||||
return translations[key] ?? key;
|
return translations[key] ?? key;
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de');
|
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>(
|
||||||
|
options?.currentLang ?? 'de',
|
||||||
|
);
|
||||||
const languageService = {
|
const languageService = {
|
||||||
currentLang,
|
currentLang,
|
||||||
selectedLang: () => currentLang(),
|
selectedLang: () => options?.selectedLang ?? currentLang(),
|
||||||
setLocalizedRouteOverrides: jasmine.createSpy('setLocalizedRouteOverrides'),
|
setLocalizedRouteOverrides: jasmine.createSpy(
|
||||||
|
'setLocalizedRouteOverrides',
|
||||||
|
),
|
||||||
clearLocalizedRouteOverrides: jasmine.createSpy(
|
clearLocalizedRouteOverrides: jasmine.createSpy(
|
||||||
'clearLocalizedRouteOverrides',
|
'clearLocalizedRouteOverrides',
|
||||||
),
|
),
|
||||||
@@ -113,7 +123,9 @@ describe('ProductDetailComponent', () => {
|
|||||||
.createSpy('quantityForVariant')
|
.createSpy('quantityForVariant')
|
||||||
.and.returnValue(0),
|
.and.returnValue(0),
|
||||||
loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)),
|
loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)),
|
||||||
resolveMediaUrl: jasmine.createSpy('resolveMediaUrl').and.returnValue(null),
|
resolveMediaUrl: jasmine
|
||||||
|
.createSpy('resolveMediaUrl')
|
||||||
|
.and.returnValue(null),
|
||||||
};
|
};
|
||||||
|
|
||||||
const router = {
|
const router = {
|
||||||
@@ -126,9 +138,13 @@ describe('ProductDetailComponent', () => {
|
|||||||
} as unknown as Router;
|
} as unknown as Router;
|
||||||
|
|
||||||
const activatedRoute = {
|
const activatedRoute = {
|
||||||
paramMap: of(convertToParamMap({ productSlug: '91823f84-bike-wall-hanger' })),
|
paramMap: of(
|
||||||
|
convertToParamMap({ productSlug: '91823f84-bike-wall-hanger' }),
|
||||||
|
),
|
||||||
snapshot: {
|
snapshot: {
|
||||||
paramMap: convertToParamMap({ productSlug: '91823f84-bike-wall-hanger' }),
|
paramMap: convertToParamMap({
|
||||||
|
productSlug: '91823f84-bike-wall-hanger',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
} as unknown as ActivatedRoute;
|
} as unknown as ActivatedRoute;
|
||||||
|
|
||||||
@@ -185,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', () => {
|
it('applies noindex for products explicitly marked as non-indexable', () => {
|
||||||
const { component, seoService } = createComponent();
|
const { component, seoService } = createComponent();
|
||||||
|
|
||||||
@@ -200,7 +231,9 @@ describe('ProductDetailComponent', () => {
|
|||||||
it('builds a soft SSR fallback with 200 + index follow', () => {
|
it('builds a soft SSR fallback with 200 + index follow', () => {
|
||||||
const { component, seoService, responseInit } = createComponent();
|
const { component, seoService, responseInit } = createComponent();
|
||||||
|
|
||||||
expect((component as any).shouldUseSoftSeoFallback({ status: 500 })).toBeTrue();
|
expect(
|
||||||
|
(component as any).shouldUseSoftSeoFallback({ status: 500 }),
|
||||||
|
).toBeTrue();
|
||||||
(component as any).setResponseStatus(200);
|
(component as any).setResponseStatus(200);
|
||||||
(component as any).applySoftFallbackSeo('91823f84-bike-wall-hanger');
|
(component as any).applySoftFallbackSeo('91823f84-bike-wall-hanger');
|
||||||
|
|
||||||
@@ -221,7 +254,9 @@ describe('ProductDetailComponent', () => {
|
|||||||
it('keeps hard fallback noindex for missing products', () => {
|
it('keeps hard fallback noindex for missing products', () => {
|
||||||
const { component, seoService, responseInit } = createComponent();
|
const { component, seoService, responseInit } = createComponent();
|
||||||
|
|
||||||
expect((component as any).shouldUseSoftSeoFallback({ status: 404 })).toBeFalse();
|
expect(
|
||||||
|
(component as any).shouldUseSoftSeoFallback({ status: 404 }),
|
||||||
|
).toBeFalse();
|
||||||
(component as any).setResponseStatus(404);
|
(component as any).setResponseStatus(404);
|
||||||
(component as any).applyHardFallbackSeo();
|
(component as any).applyHardFallbackSeo();
|
||||||
|
|
||||||
|
|||||||
@@ -254,9 +254,7 @@ export class ProductDetailComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const productSlug = routeParams.productSlug as string;
|
const productSlug = routeParams.productSlug as string;
|
||||||
return this.shopService
|
return this.shopService.getProductByPublicPath(productSlug).pipe(
|
||||||
.getProductByPublicPath(productSlug)
|
|
||||||
.pipe(
|
|
||||||
catchError((error) => {
|
catchError((error) => {
|
||||||
this.languageService.clearLocalizedRouteOverrides();
|
this.languageService.clearLocalizedRouteOverrides();
|
||||||
this.product.set(null);
|
this.product.set(null);
|
||||||
@@ -607,7 +605,7 @@ export class ProductDetailComponent {
|
|||||||
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
||||||
const robots =
|
const robots =
|
||||||
product.indexable === false ? 'noindex, nofollow' : 'index, follow';
|
product.indexable === false ? 'noindex, nofollow' : 'index, follow';
|
||||||
const lang = this.languageService.selectedLang();
|
const lang = this.languageService.currentLang();
|
||||||
const canonicalPath =
|
const canonicalPath =
|
||||||
product.localizedPaths?.[lang] ?? product.localizedPaths?.it ?? null;
|
product.localizedPaths?.[lang] ?? product.localizedPaths?.it ?? null;
|
||||||
|
|
||||||
@@ -859,7 +857,7 @@ export class ProductDetailComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentTree = this.router.parseUrl(this.router.url);
|
const currentTree = this.router.parseUrl(this.router.url);
|
||||||
const lang = this.languageService.selectedLang();
|
const lang = this.languageService.currentLang();
|
||||||
const targetPath =
|
const targetPath =
|
||||||
product.localizedPaths?.[lang] ??
|
product.localizedPaths?.[lang] ??
|
||||||
`/${lang}/shop/p/${this.shopRouteService.productPathSegment(product)}`;
|
`/${lang}/shop/p/${this.shopRouteService.productPathSegment(product)}`;
|
||||||
@@ -904,9 +902,7 @@ export class ProductDetailComponent {
|
|||||||
return this.normalizeRouteParam(this.route.snapshot.paramMap.get(name));
|
return this.normalizeRouteParam(this.route.snapshot.paramMap.get(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeRouteParam(
|
private normalizeRouteParam(value: string | null | undefined): string | null {
|
||||||
value: string | null | undefined,
|
|
||||||
): string | null {
|
|
||||||
const normalized = String(value ?? '').trim();
|
const normalized = String(value ?? '').trim();
|
||||||
return normalized || null;
|
return normalized || null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import {
|
import {
|
||||||
HttpClientTestingModule,
|
HttpClientTestingModule,
|
||||||
@@ -13,6 +14,11 @@ import { LanguageService } from '../../../core/services/language.service';
|
|||||||
describe('ShopService', () => {
|
describe('ShopService', () => {
|
||||||
let service: ShopService;
|
let service: ShopService;
|
||||||
let httpMock: HttpTestingController;
|
let httpMock: HttpTestingController;
|
||||||
|
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('it');
|
||||||
|
const languageService = {
|
||||||
|
currentLang,
|
||||||
|
selectedLang: jasmine.createSpy('selectedLang').and.returnValue('it'),
|
||||||
|
};
|
||||||
|
|
||||||
const buildCart = (): ShopCartResponse => ({
|
const buildCart = (): ShopCartResponse => ({
|
||||||
session: {
|
session: {
|
||||||
@@ -131,13 +137,14 @@ describe('ShopService', () => {
|
|||||||
ShopService,
|
ShopService,
|
||||||
{
|
{
|
||||||
provide: LanguageService,
|
provide: LanguageService,
|
||||||
useValue: {
|
useValue: languageService,
|
||||||
selectedLang: () => 'it',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
currentLang.set('it');
|
||||||
|
languageService.selectedLang.and.returnValue('it');
|
||||||
|
|
||||||
service = TestBed.inject(ShopService);
|
service = TestBed.inject(ShopService);
|
||||||
httpMock = TestBed.inject(HttpTestingController);
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
});
|
});
|
||||||
@@ -196,7 +203,7 @@ describe('ShopService', () => {
|
|||||||
return (
|
return (
|
||||||
request.method === 'GET' &&
|
request.method === 'GET' &&
|
||||||
request.url ===
|
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'
|
request.params.get('lang') === 'it'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -206,47 +213,76 @@ describe('ShopService', () => {
|
|||||||
expect(response?.name).toBe('Supporto cavo scrivania');
|
expect(response?.name).toBe('Supporto cavo scrivania');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects product paths whose slug tail does not match the canonical path', () => {
|
it('resolves products from the stable uuid prefix even if the slug tail is stale', () => {
|
||||||
let errorResponse: { status?: number } | undefined;
|
let response: ShopProductDetail | undefined;
|
||||||
|
|
||||||
service.getProductByPublicPath('12345678-qualunque-nome').subscribe({
|
service.getProductByPublicPath('12345678-qualunque-nome').subscribe({
|
||||||
next: () => fail('Expected canonical path mismatch to return 404'),
|
next: (product) => {
|
||||||
error: (error) => {
|
response = product;
|
||||||
errorResponse = error;
|
|
||||||
},
|
},
|
||||||
|
error: () =>
|
||||||
|
fail('Expected stale slug tails to resolve from the uuid prefix'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const request = httpMock.expectOne((request) => {
|
const request = httpMock.expectOne((request) => {
|
||||||
return (
|
return (
|
||||||
request.method === 'GET' &&
|
request.method === 'GET' &&
|
||||||
request.url ===
|
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.params.get('lang') === 'it'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
request.flush('Not found', { status: 404, statusText: 'Not Found' });
|
request.flush(buildProduct());
|
||||||
expect(errorResponse?.status).toBe(404);
|
|
||||||
|
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects bare uuid product paths without the localized slug tail', () => {
|
it('resolves bare uuid product paths through the stable uuid prefix endpoint', () => {
|
||||||
let errorResponse: { status?: number } | undefined;
|
let response: ShopProductDetail | undefined;
|
||||||
|
|
||||||
service.getProductByPublicPath('12345678').subscribe({
|
service.getProductByPublicPath('12345678').subscribe({
|
||||||
next: () => fail('Expected bare uuid path to return 404'),
|
next: (product) => {
|
||||||
error: (error) => {
|
response = product;
|
||||||
errorResponse = error;
|
|
||||||
},
|
},
|
||||||
|
error: () =>
|
||||||
|
fail('Expected bare uuid path to resolve from the uuid prefix'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const request = httpMock.expectOne((request) => {
|
const request = httpMock.expectOne((request) => {
|
||||||
return (
|
return (
|
||||||
request.method === 'GET' &&
|
request.method === 'GET' &&
|
||||||
request.url ===
|
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.params.get('lang') === 'it'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
request.flush('Not found', { status: 404, statusText: 'Not Found' });
|
request.flush(buildProduct());
|
||||||
expect(errorResponse?.status).toBe(404);
|
|
||||||
|
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>(
|
const productIdPrefix = this.extractProductIdPrefix(normalizedPath);
|
||||||
`${this.apiUrl}/products/by-path/${encodeURIComponent(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(),
|
params: this.buildLangParams(),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizePublicPath(value: string | null | undefined): string {
|
private normalizePublicPath(value: string | null | undefined): string {
|
||||||
@@ -304,6 +306,11 @@ export class ShopService {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractProductIdPrefix(value: string): string | null {
|
||||||
|
const match = value.match(/^([0-9a-f]{8})(?:-|$)/);
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
loadCart(): Observable<ShopCartResponse> {
|
loadCart(): Observable<ShopCartResponse> {
|
||||||
this.cartLoading.set(true);
|
this.cartLoading.set(true);
|
||||||
return this.http
|
return this.http
|
||||||
@@ -455,7 +462,10 @@ export class ShopService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildLangParams(): HttpParams {
|
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 {
|
private setCart(cart: ShopCartResponse): void {
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ describe('ShopPageComponent', () => {
|
|||||||
'TranslateService',
|
'TranslateService',
|
||||||
['instant'],
|
['instant'],
|
||||||
);
|
);
|
||||||
translate.instant.and.callFake((key: string, params?: { count?: number }) => {
|
translate.instant.and.callFake(
|
||||||
|
(key: string, params?: { count?: number }) => {
|
||||||
const translations: Record<string, string> = {
|
const translations: Record<string, string> = {
|
||||||
'SHOP.TITLE': 'Technische Lösungen',
|
'SHOP.TITLE': 'Technische Lösungen',
|
||||||
'SHOP.SUBTITLE': 'Fertige Produkte, die praktische Probleme lösen',
|
'SHOP.SUBTITLE': 'Fertige Produkte, die praktische Probleme lösen',
|
||||||
@@ -77,7 +78,8 @@ describe('ShopPageComponent', () => {
|
|||||||
return `${params?.count ?? 0} Produkte in dieser Kategorie verfügbar`;
|
return `${params?.count ?? 0} Produkte in dieser Kategorie verfügbar`;
|
||||||
}
|
}
|
||||||
return translations[key] ?? key;
|
return translations[key] ?? key;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de');
|
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de');
|
||||||
const languageService = {
|
const languageService = {
|
||||||
@@ -100,11 +102,17 @@ describe('ShopPageComponent', () => {
|
|||||||
flattenCategoryTree: jasmine
|
flattenCategoryTree: jasmine
|
||||||
.createSpy('flattenCategoryTree')
|
.createSpy('flattenCategoryTree')
|
||||||
.and.returnValue([]),
|
.and.returnValue([]),
|
||||||
quantityForProduct: jasmine.createSpy('quantityForProduct').and.returnValue(0),
|
quantityForProduct: jasmine
|
||||||
|
.createSpy('quantityForProduct')
|
||||||
|
.and.returnValue(0),
|
||||||
loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)),
|
loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)),
|
||||||
clearCart: jasmine.createSpy('clearCart').and.returnValue(of(null)),
|
clearCart: jasmine.createSpy('clearCart').and.returnValue(of(null)),
|
||||||
removeCartItem: jasmine.createSpy('removeCartItem').and.returnValue(of(null)),
|
removeCartItem: jasmine
|
||||||
updateCartItem: jasmine.createSpy('updateCartItem').and.returnValue(of(null)),
|
.createSpy('removeCartItem')
|
||||||
|
.and.returnValue(of(null)),
|
||||||
|
updateCartItem: jasmine
|
||||||
|
.createSpy('updateCartItem')
|
||||||
|
.and.returnValue(of(null)),
|
||||||
};
|
};
|
||||||
|
|
||||||
const router = {
|
const router = {
|
||||||
@@ -164,7 +172,9 @@ describe('ShopPageComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps noindex for categories explicitly marked as non-indexable', () => {
|
it('keeps noindex for categories explicitly marked as non-indexable', () => {
|
||||||
const { component, seoService } = createComponent('/de/shop/compatible-with-garmin');
|
const { component, seoService } = createComponent(
|
||||||
|
'/de/shop/compatible-with-garmin',
|
||||||
|
);
|
||||||
|
|
||||||
(component as any).applySeo(buildCategory({ indexable: false }));
|
(component as any).applySeo(buildCategory({ indexable: false }));
|
||||||
|
|
||||||
@@ -180,7 +190,9 @@ describe('ShopPageComponent', () => {
|
|||||||
'/de/shop/compatible-with-garmin',
|
'/de/shop/compatible-with-garmin',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect((component as any).shouldUseSoftSeoFallback({ status: 500 })).toBeTrue();
|
expect(
|
||||||
|
(component as any).shouldUseSoftSeoFallback({ status: 500 }),
|
||||||
|
).toBeTrue();
|
||||||
(component as any).setResponseStatus(200);
|
(component as any).setResponseStatus(200);
|
||||||
(component as any).applySoftFallbackSeo('compatible-with-garmin');
|
(component as any).applySoftFallbackSeo('compatible-with-garmin');
|
||||||
|
|
||||||
@@ -203,7 +215,9 @@ describe('ShopPageComponent', () => {
|
|||||||
'/de/shop/compatible-with-garmin',
|
'/de/shop/compatible-with-garmin',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect((component as any).shouldUseSoftSeoFallback({ status: 404 })).toBeFalse();
|
expect(
|
||||||
|
(component as any).shouldUseSoftSeoFallback({ status: 404 }),
|
||||||
|
).toBeFalse();
|
||||||
(component as any).setResponseStatus(404);
|
(component as any).setResponseStatus(404);
|
||||||
(component as any).applyHardErrorSeo();
|
(component as any).applyHardErrorSeo();
|
||||||
|
|
||||||
|
|||||||
@@ -528,9 +528,7 @@ export class ShopPageComponent {
|
|||||||
return this.normalizeRouteParam(this.route.snapshot.paramMap.get(name));
|
return this.normalizeRouteParam(this.route.snapshot.paramMap.get(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeRouteParam(
|
private normalizeRouteParam(value: string | null | undefined): string | null {
|
||||||
value: string | null | undefined,
|
|
||||||
): string | null {
|
|
||||||
const normalized = String(value ?? '').trim();
|
const normalized = String(value ?? '').trim();
|
||||||
return normalized || null;
|
return normalized || null;
|
||||||
}
|
}
|
||||||
|
|||||||
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