5 Commits

Author SHA1 Message Date
28c3abdb4a Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
PR Checks / prettier-autofix (pull_request) Successful in 14s
Build and Deploy / test-frontend (push) Successful in 1m41s
PR Checks / security-sast (pull_request) Successful in 52s
PR Checks / test-backend (pull_request) Successful in 30s
Build and Deploy / build-and-push (push) Successful in 32s
Build and Deploy / deploy (push) Successful in 20s
PR Checks / test-frontend (pull_request) Successful in 1m42s
2026-03-25 11:44:01 +01:00
b30bfc9293 fix(front-end): improvements in load products by uuid truncated
Some checks failed
Build and Deploy / test-backend (push) Successful in 38s
Build and Deploy / test-frontend (push) Successful in 1m37s
Build and Deploy / build-and-push (push) Successful in 1m57s
Build and Deploy / deploy (push) Successful in 22s
PR Checks / prettier-autofix (pull_request) Failing after 13s
PR Checks / test-backend (pull_request) Successful in 28s
PR Checks / security-sast (pull_request) Successful in 40s
PR Checks / test-frontend (pull_request) Successful in 1m15s
2026-03-25 11:27:43 +01:00
d70423fcc0 fix(front-end): improvements in ssr
All checks were successful
Build and Deploy / test-backend (push) Successful in 34s
Build and Deploy / test-frontend (push) Successful in 1m6s
Build and Deploy / build-and-push (push) Successful in 29s
Build and Deploy / deploy (push) Successful in 21s
2026-03-24 16:20:40 +01:00
1b7c0c48e7 Merge pull request 'dev' (#54) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 59s
Build and Deploy / build-and-push (push) Successful in 28s
Build and Deploy / deploy (push) Successful in 19s
Reviewed-on: #54
2026-03-24 13:29:50 +01:00
printcalc-ci
cb86137730 style: apply prettier formatting
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m2s
2026-03-24 12:19:19 +00:00
15 changed files with 466 additions and 2057 deletions

View File

@@ -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);

View File

@@ -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
) {

View File

@@ -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();

View File

@@ -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

View File

@@ -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({});
});
});

View File

@@ -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,93 @@ 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) => {

View File

@@ -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>

View File

@@ -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

View File

@@ -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,11 +99,15 @@ 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(),
setLocalizedRouteOverrides: jasmine.createSpy('setLocalizedRouteOverrides'),
selectedLang: () => options?.selectedLang ?? currentLang(),
setLocalizedRouteOverrides: jasmine.createSpy(
'setLocalizedRouteOverrides',
),
clearLocalizedRouteOverrides: jasmine.createSpy(
'clearLocalizedRouteOverrides',
),
@@ -113,7 +123,9 @@ describe('ProductDetailComponent', () => {
.createSpy('quantityForVariant')
.and.returnValue(0),
loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)),
resolveMediaUrl: jasmine.createSpy('resolveMediaUrl').and.returnValue(null),
resolveMediaUrl: jasmine
.createSpy('resolveMediaUrl')
.and.returnValue(null),
};
const router = {
@@ -126,9 +138,13 @@ describe('ProductDetailComponent', () => {
} as unknown as Router;
const activatedRoute = {
paramMap: of(convertToParamMap({ productSlug: '91823f84-bike-wall-hanger' })),
paramMap: of(
convertToParamMap({ productSlug: '91823f84-bike-wall-hanger' }),
),
snapshot: {
paramMap: convertToParamMap({ productSlug: '91823f84-bike-wall-hanger' }),
paramMap: convertToParamMap({
productSlug: '91823f84-bike-wall-hanger',
}),
},
} 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', () => {
const { component, seoService } = createComponent();
@@ -200,7 +231,9 @@ describe('ProductDetailComponent', () => {
it('builds a soft SSR fallback with 200 + index follow', () => {
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).applySoftFallbackSeo('91823f84-bike-wall-hanger');
@@ -221,7 +254,9 @@ describe('ProductDetailComponent', () => {
it('keeps hard fallback noindex for missing products', () => {
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).applyHardFallbackSeo();

View File

@@ -254,37 +254,35 @@ export class ProductDetailComponent {
}
const productSlug = routeParams.productSlug as string;
return this.shopService
.getProductByPublicPath(productSlug)
.pipe(
catchError((error) => {
this.languageService.clearLocalizedRouteOverrides();
this.product.set(null);
this.selectedVariantId.set(null);
this.setSelectedImageAssetId(null);
this.modelFile.set(null);
const isNotFound = error?.status === 404;
if (isNotFound) {
this.error.set('SHOP.NOT_FOUND');
this.setResponseStatus(404);
this.applyHardFallbackSeo();
return of(null);
}
if (this.shouldUseSoftSeoFallback(error)) {
this.error.set(null);
this.softFallbackActive.set(true);
this.setResponseStatus(200);
this.applySoftFallbackSeo(productSlug);
return of(null);
}
this.error.set('SHOP.LOAD_ERROR');
this.setResponseStatus(503);
return this.shopService.getProductByPublicPath(productSlug).pipe(
catchError((error) => {
this.languageService.clearLocalizedRouteOverrides();
this.product.set(null);
this.selectedVariantId.set(null);
this.setSelectedImageAssetId(null);
this.modelFile.set(null);
const isNotFound = error?.status === 404;
if (isNotFound) {
this.error.set('SHOP.NOT_FOUND');
this.setResponseStatus(404);
this.applyHardFallbackSeo();
return of(null);
}),
finalize(() => this.loading.set(false)),
);
}
if (this.shouldUseSoftSeoFallback(error)) {
this.error.set(null);
this.softFallbackActive.set(true);
this.setResponseStatus(200);
this.applySoftFallbackSeo(productSlug);
return of(null);
}
this.error.set('SHOP.LOAD_ERROR');
this.setResponseStatus(503);
return of(null);
}),
finalize(() => this.loading.set(false)),
);
}),
takeUntilDestroyed(this.destroyRef),
)
@@ -607,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;
@@ -859,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)}`;
@@ -904,9 +902,7 @@ export class ProductDetailComponent {
return this.normalizeRouteParam(this.route.snapshot.paramMap.get(name));
}
private normalizeRouteParam(
value: string | null | undefined,
): string | null {
private normalizeRouteParam(value: string | null | undefined): string | null {
const normalized = String(value ?? '').trim();
return normalized || null;
}

View File

@@ -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,74 @@ 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');
});
});

View File

@@ -290,8 +290,13 @@ export class ShopService {
}));
}
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>(
`${this.apiUrl}/products/by-path/${encodeURIComponent(normalizedPath)}`,
endpoint,
{
params: this.buildLangParams(),
},
@@ -304,6 +309,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 +465,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 {

View File

@@ -60,24 +60,26 @@ describe('ShopPageComponent', () => {
'TranslateService',
['instant'],
);
translate.instant.and.callFake((key: string, params?: { count?: number }) => {
const translations: Record<string, string> = {
'SHOP.TITLE': 'Technische Lösungen',
'SHOP.SUBTITLE': 'Fertige Produkte, die praktische Probleme lösen',
'SHOP.CATALOG_TITLE': 'Alle Produkte',
'SHOP.CATALOG_LABEL': 'Katalog',
'SHOP.SELECTED_CATEGORY': 'Ausgewählte Kategorie',
'SHOP.CATALOG_META_DESCRIPTION':
'Entdecken Sie 3D-gedruckte Produkte und technisches Zubehör.',
'SEO.ROUTES.SHOP.CATEGORY_TITLE': 'Shop-Kategorie | 3D fab',
'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION':
'Entdecken Sie Produkte dieser Kategorie und technische Lösungen.',
};
if (key === 'SHOP.CATEGORY_META') {
return `${params?.count ?? 0} Produkte in dieser Kategorie verfügbar`;
}
return translations[key] ?? key;
});
translate.instant.and.callFake(
(key: string, params?: { count?: number }) => {
const translations: Record<string, string> = {
'SHOP.TITLE': 'Technische Lösungen',
'SHOP.SUBTITLE': 'Fertige Produkte, die praktische Probleme lösen',
'SHOP.CATALOG_TITLE': 'Alle Produkte',
'SHOP.CATALOG_LABEL': 'Katalog',
'SHOP.SELECTED_CATEGORY': 'Ausgewählte Kategorie',
'SHOP.CATALOG_META_DESCRIPTION':
'Entdecken Sie 3D-gedruckte Produkte und technisches Zubehör.',
'SEO.ROUTES.SHOP.CATEGORY_TITLE': 'Shop-Kategorie | 3D fab',
'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION':
'Entdecken Sie Produkte dieser Kategorie und technische Lösungen.',
};
if (key === 'SHOP.CATEGORY_META') {
return `${params?.count ?? 0} Produkte in dieser Kategorie verfügbar`;
}
return translations[key] ?? key;
},
);
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de');
const languageService = {
@@ -100,11 +102,17 @@ describe('ShopPageComponent', () => {
flattenCategoryTree: jasmine
.createSpy('flattenCategoryTree')
.and.returnValue([]),
quantityForProduct: jasmine.createSpy('quantityForProduct').and.returnValue(0),
quantityForProduct: jasmine
.createSpy('quantityForProduct')
.and.returnValue(0),
loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)),
clearCart: jasmine.createSpy('clearCart').and.returnValue(of(null)),
removeCartItem: jasmine.createSpy('removeCartItem').and.returnValue(of(null)),
updateCartItem: jasmine.createSpy('updateCartItem').and.returnValue(of(null)),
removeCartItem: jasmine
.createSpy('removeCartItem')
.and.returnValue(of(null)),
updateCartItem: jasmine
.createSpy('updateCartItem')
.and.returnValue(of(null)),
};
const router = {
@@ -164,7 +172,9 @@ describe('ShopPageComponent', () => {
});
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 }));
@@ -180,7 +190,9 @@ describe('ShopPageComponent', () => {
'/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).applySoftFallbackSeo('compatible-with-garmin');
@@ -203,7 +215,9 @@ describe('ShopPageComponent', () => {
'/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).applyHardErrorSeo();

View File

@@ -528,9 +528,7 @@ export class ShopPageComponent {
return this.normalizeRouteParam(this.route.snapshot.paramMap.get(name));
}
private normalizeRouteParam(
value: string | null | undefined,
): string | null {
private normalizeRouteParam(value: string | null | undefined): string | null {
const normalized = String(value ?? '').trim();
return normalized || null;
}