Compare commits
5 Commits
bad5947fb5
...
feat/mater
| Author | SHA1 | Date | |
|---|---|---|---|
| c95096ddf0 | |||
| c8913af660 | |||
| 9611049e01 | |||
| 9fc1fc97fa | |||
| 7010a81596 |
@@ -0,0 +1,77 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {
|
||||
HttpTestingController,
|
||||
provideHttpClientTesting,
|
||||
} from '@angular/common/http/testing';
|
||||
import { REQUEST } from '@angular/core';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { serverOriginInterceptor } from './server-origin.interceptor';
|
||||
|
||||
describe('serverOriginInterceptor', () => {
|
||||
let http: HttpClient;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([serverOriginInterceptor])),
|
||||
provideHttpClientTesting(),
|
||||
{
|
||||
provide: REQUEST,
|
||||
useValue: {
|
||||
protocol: 'https',
|
||||
headers: {
|
||||
host: 'dev.3d-fab.ch',
|
||||
authorization: 'Basic dGVzdDp0ZXN0',
|
||||
cookie: 'session=abc123',
|
||||
'accept-language': 'de-CH,de;q=0.9,en;q=0.8',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
http = TestBed.inject(HttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('rewrites relative SSR URLs to the incoming origin and forwards auth headers', () => {
|
||||
http.get('/api/shop/products/by-path/example?lang=de').subscribe();
|
||||
|
||||
const request = httpMock.expectOne(
|
||||
'https://dev.3d-fab.ch/api/shop/products/by-path/example?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('does not overwrite explicit request headers', () => {
|
||||
http
|
||||
.get('/api/shop/products', {
|
||||
headers: {
|
||||
authorization: 'Bearer explicit-token',
|
||||
},
|
||||
})
|
||||
.subscribe();
|
||||
|
||||
const request = httpMock.expectOne(
|
||||
'https://dev.3d-fab.ch/api/shop/products',
|
||||
);
|
||||
expect(request.request.headers.get('authorization')).toBe(
|
||||
'Bearer explicit-token',
|
||||
);
|
||||
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
||||
request.flush({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,397 @@
|
||||
<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>
|
||||
@@ -0,0 +1,546 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
1018
frontend/src/app/features/materials/materials-page.component.ts
Normal file
1018
frontend/src/app/features/materials/materials-page.component.ts
Normal file
File diff suppressed because it is too large
Load Diff
237
frontend/src/app/features/shop/product-detail.component.spec.ts
Normal file
237
frontend/src/app/features/shop/product-detail.component.spec.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { Location } from '@angular/common';
|
||||
import { PLATFORM_ID, RESPONSE_INIT, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { SeoService } from '../../core/services/seo.service';
|
||||
import { LanguageService } from '../../core/services/language.service';
|
||||
import { ShopRouteService } from './services/shop-route.service';
|
||||
import { ShopProductDetail, ShopService } from './services/shop.service';
|
||||
import { ProductDetailComponent } from './product-detail.component';
|
||||
|
||||
describe('ProductDetailComponent', () => {
|
||||
function buildProduct(
|
||||
overrides: Partial<ShopProductDetail> = {},
|
||||
): ShopProductDetail {
|
||||
return {
|
||||
id: '91823f84-1111-2222-3333-444444444444',
|
||||
slug: 'bike-wall-hanger',
|
||||
name: 'Bike Wall-Hanger',
|
||||
excerpt: 'Wall mount for bicycles',
|
||||
description: '<p>Wall mount for bicycles</p>',
|
||||
seoTitle: null,
|
||||
seoDescription: null,
|
||||
ogTitle: null,
|
||||
ogDescription: null,
|
||||
indexable: true,
|
||||
isFeatured: false,
|
||||
sortOrder: 0,
|
||||
category: {
|
||||
id: 'category-1',
|
||||
slug: 'bike-accessories',
|
||||
name: 'Bike Accessories',
|
||||
},
|
||||
breadcrumbs: [],
|
||||
priceFromChf: 29.9,
|
||||
priceToChf: 29.9,
|
||||
defaultVariant: {
|
||||
id: 'variant-1',
|
||||
sku: 'BW-1',
|
||||
variantLabel: 'PLA',
|
||||
colorName: 'Black',
|
||||
colorLabel: 'Black',
|
||||
colorHex: '#111111',
|
||||
priceChf: 29.9,
|
||||
isDefault: true,
|
||||
},
|
||||
variants: [
|
||||
{
|
||||
id: 'variant-1',
|
||||
sku: 'BW-1',
|
||||
variantLabel: 'PLA',
|
||||
colorName: 'Black',
|
||||
colorLabel: 'Black',
|
||||
colorHex: '#111111',
|
||||
priceChf: 29.9,
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
primaryImage: null,
|
||||
images: [],
|
||||
model3d: null,
|
||||
publicPath: '91823f84-bike-wall-hanger',
|
||||
localizedPaths: {
|
||||
it: '/it/shop/p/91823f84-supporto-bici-muro',
|
||||
en: '/en/shop/p/91823f84-bike-wall-hanger',
|
||||
de: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||
fr: '/fr/shop/p/91823f84-support-mural-velo',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createComponent(routerUrl = '/de/shop/p/91823f84-bike-wall-hanger') {
|
||||
const responseInit: { status?: number } = {};
|
||||
const seoService = jasmine.createSpyObj<SeoService>('SeoService', [
|
||||
'applyResolvedSeo',
|
||||
'applyPageSeo',
|
||||
]);
|
||||
const translate = jasmine.createSpyObj<TranslateService>(
|
||||
'TranslateService',
|
||||
['instant'],
|
||||
);
|
||||
translate.instant.and.callFake((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'SHOP.TITLE': 'Technische Lösungen',
|
||||
'SHOP.CATALOG_META_DESCRIPTION':
|
||||
'Entdecken Sie technische 3D-Druck-Lösungen.',
|
||||
'SEO.ROUTES.SHOP.PRODUCT_TITLE': 'Produkt | 3D fab',
|
||||
'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION':
|
||||
'Entdecken Sie Details, Materialien, Varianten und Verfügbarkeit.',
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
});
|
||||
|
||||
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de');
|
||||
const languageService = {
|
||||
currentLang,
|
||||
selectedLang: () => currentLang(),
|
||||
setLocalizedRouteOverrides: jasmine.createSpy('setLocalizedRouteOverrides'),
|
||||
clearLocalizedRouteOverrides: jasmine.createSpy(
|
||||
'clearLocalizedRouteOverrides',
|
||||
),
|
||||
};
|
||||
|
||||
const shopService = {
|
||||
cartLoaded: signal(false),
|
||||
cartLoading: signal(false),
|
||||
getProductByPublicPath: jasmine
|
||||
.createSpy('getProductByPublicPath')
|
||||
.and.returnValue(of(buildProduct())),
|
||||
quantityForVariant: jasmine
|
||||
.createSpy('quantityForVariant')
|
||||
.and.returnValue(0),
|
||||
loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)),
|
||||
resolveMediaUrl: jasmine.createSpy('resolveMediaUrl').and.returnValue(null),
|
||||
};
|
||||
|
||||
const router = {
|
||||
url: routerUrl,
|
||||
navigate: jasmine.createSpy('navigate'),
|
||||
navigateByUrl: jasmine.createSpy('navigateByUrl'),
|
||||
parseUrl: jasmine.createSpy('parseUrl'),
|
||||
createUrlTree: jasmine.createSpy('createUrlTree'),
|
||||
serializeUrl: jasmine.createSpy('serializeUrl'),
|
||||
} as unknown as Router;
|
||||
|
||||
const activatedRoute = {
|
||||
paramMap: of(convertToParamMap({ productSlug: '91823f84-bike-wall-hanger' })),
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ productSlug: '91823f84-bike-wall-hanger' }),
|
||||
},
|
||||
} as unknown as ActivatedRoute;
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ProductDetailComponent],
|
||||
providers: [
|
||||
{ provide: SeoService, useValue: seoService },
|
||||
{ provide: TranslateService, useValue: translate },
|
||||
{ provide: LanguageService, useValue: languageService },
|
||||
{ provide: ShopService, useValue: shopService },
|
||||
{
|
||||
provide: ShopRouteService,
|
||||
useValue: jasmine.createSpyObj<ShopRouteService>('ShopRouteService', [
|
||||
'shopRootCommands',
|
||||
'productPathSegment',
|
||||
'isCatalogUrl',
|
||||
]),
|
||||
},
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: ActivatedRoute, useValue: activatedRoute },
|
||||
{
|
||||
provide: Location,
|
||||
useValue: jasmine.createSpyObj<Location>('Location', ['back']),
|
||||
},
|
||||
{ provide: RESPONSE_INIT, useValue: responseInit },
|
||||
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||
],
|
||||
});
|
||||
|
||||
const fixture: ComponentFixture<ProductDetailComponent> =
|
||||
TestBed.createComponent(ProductDetailComponent);
|
||||
|
||||
return {
|
||||
component: fixture.componentInstance,
|
||||
seoService,
|
||||
responseInit,
|
||||
};
|
||||
}
|
||||
|
||||
it('applies index follow SEO for indexable products', () => {
|
||||
const { component, seoService } = createComponent();
|
||||
|
||||
(component as any).applySeo(buildProduct());
|
||||
|
||||
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
title: 'Bike Wall-Hanger | 3D fab',
|
||||
robots: 'index, follow',
|
||||
canonicalPath: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||
alternates: buildProduct().localizedPaths,
|
||||
xDefault: '/it/shop/p/91823f84-supporto-bici-muro',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies noindex for products explicitly marked as non-indexable', () => {
|
||||
const { component, seoService } = createComponent();
|
||||
|
||||
(component as any).applySeo(buildProduct({ indexable: false }));
|
||||
|
||||
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
robots: 'noindex, nofollow',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('builds a soft SSR fallback with 200 + index follow', () => {
|
||||
const { component, seoService, responseInit } = createComponent();
|
||||
|
||||
expect((component as any).shouldUseSoftSeoFallback({ status: 500 })).toBeTrue();
|
||||
(component as any).setResponseStatus(200);
|
||||
(component as any).applySoftFallbackSeo('91823f84-bike-wall-hanger');
|
||||
|
||||
expect(responseInit.status).toBe(200);
|
||||
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
title: 'Bike Wall Hanger | 3D fab',
|
||||
description:
|
||||
'Entdecken Sie Details, Materialien, Varianten und Verfügbarkeit.',
|
||||
robots: 'index, follow',
|
||||
canonicalPath: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||
alternates: null,
|
||||
xDefault: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps hard fallback noindex for missing products', () => {
|
||||
const { component, seoService, responseInit } = createComponent();
|
||||
|
||||
expect((component as any).shouldUseSoftSeoFallback({ status: 404 })).toBeFalse();
|
||||
(component as any).setResponseStatus(404);
|
||||
(component as any).applyHardFallbackSeo();
|
||||
|
||||
expect(responseInit.status).toBe(404);
|
||||
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
robots: 'noindex, nofollow',
|
||||
alternates: null,
|
||||
xDefault: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
219
frontend/src/app/features/shop/shop-page.component.spec.ts
Normal file
219
frontend/src/app/features/shop/shop-page.component.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { PLATFORM_ID, RESPONSE_INIT, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { SeoService } from '../../core/services/seo.service';
|
||||
import { LanguageService } from '../../core/services/language.service';
|
||||
import {
|
||||
ShopCategoryDetail,
|
||||
ShopCategoryTree,
|
||||
ShopProductCatalogResponse,
|
||||
ShopService,
|
||||
} from './services/shop.service';
|
||||
import { ShopRouteService } from './services/shop-route.service';
|
||||
import { ShopPageComponent } from './shop-page.component';
|
||||
|
||||
describe('ShopPageComponent', () => {
|
||||
function buildCategory(
|
||||
overrides: Partial<ShopCategoryDetail> = {},
|
||||
): ShopCategoryDetail {
|
||||
return {
|
||||
id: 'cat-1',
|
||||
slug: 'compatible-with-garmin',
|
||||
name: 'Compatible with Garmin',
|
||||
description: 'Accessories compatible with Garmin devices.',
|
||||
seoTitle: null,
|
||||
seoDescription: null,
|
||||
ogTitle: null,
|
||||
ogDescription: null,
|
||||
indexable: true,
|
||||
sortOrder: 0,
|
||||
productCount: 3,
|
||||
breadcrumbs: [],
|
||||
primaryImage: null,
|
||||
images: [],
|
||||
children: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCatalog(
|
||||
overrides: Partial<ShopProductCatalogResponse> = {},
|
||||
): ShopProductCatalogResponse {
|
||||
return {
|
||||
categorySlug: null,
|
||||
featuredOnly: null,
|
||||
category: null,
|
||||
products: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createComponent(routerUrl = '/de/shop') {
|
||||
const responseInit: { status?: number } = {};
|
||||
const seoService = jasmine.createSpyObj<SeoService>('SeoService', [
|
||||
'applyResolvedSeo',
|
||||
'applyPageSeo',
|
||||
]);
|
||||
const translate = jasmine.createSpyObj<TranslateService>(
|
||||
'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;
|
||||
});
|
||||
|
||||
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de');
|
||||
const languageService = {
|
||||
currentLang,
|
||||
selectedLang: () => currentLang(),
|
||||
};
|
||||
|
||||
const shopService = {
|
||||
cart: signal(null),
|
||||
cartLoading: signal(false),
|
||||
cartLoaded: signal(false),
|
||||
cartItemCount: signal(0),
|
||||
cartSessionId: signal<string | null>(null),
|
||||
getCategories: jasmine
|
||||
.createSpy('getCategories')
|
||||
.and.returnValue(of([] as ShopCategoryTree[])),
|
||||
getProductCatalog: jasmine
|
||||
.createSpy('getProductCatalog')
|
||||
.and.returnValue(of(buildCatalog())),
|
||||
flattenCategoryTree: jasmine
|
||||
.createSpy('flattenCategoryTree')
|
||||
.and.returnValue([]),
|
||||
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)),
|
||||
};
|
||||
|
||||
const router = {
|
||||
url: routerUrl,
|
||||
navigate: jasmine.createSpy('navigate'),
|
||||
} as unknown as Router;
|
||||
|
||||
const activatedRoute = {
|
||||
paramMap: of(convertToParamMap({})),
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({}),
|
||||
},
|
||||
} as unknown as ActivatedRoute;
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ShopPageComponent],
|
||||
providers: [
|
||||
{ provide: SeoService, useValue: seoService },
|
||||
{ provide: TranslateService, useValue: translate },
|
||||
{ provide: LanguageService, useValue: languageService },
|
||||
{ provide: ShopService, useValue: shopService },
|
||||
{
|
||||
provide: ShopRouteService,
|
||||
useValue: jasmine.createSpyObj<ShopRouteService>('ShopRouteService', [
|
||||
'shopRootCommands',
|
||||
]),
|
||||
},
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: ActivatedRoute, useValue: activatedRoute },
|
||||
{ provide: RESPONSE_INIT, useValue: responseInit },
|
||||
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||
],
|
||||
});
|
||||
|
||||
const fixture: ComponentFixture<ShopPageComponent> =
|
||||
TestBed.createComponent(ShopPageComponent);
|
||||
|
||||
return {
|
||||
component: fixture.componentInstance,
|
||||
seoService,
|
||||
responseInit,
|
||||
};
|
||||
}
|
||||
|
||||
it('keeps index follow on the public shop root', () => {
|
||||
const { component, seoService } = createComponent();
|
||||
|
||||
(component as any).applyDefaultSeo();
|
||||
|
||||
expect(seoService.applyPageSeo).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
title: 'Technische Lösungen | 3D fab',
|
||||
robots: 'index, follow',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps noindex for categories explicitly marked as non-indexable', () => {
|
||||
const { component, seoService } = createComponent('/de/shop/compatible-with-garmin');
|
||||
|
||||
(component as any).applySeo(buildCategory({ indexable: false }));
|
||||
|
||||
expect(seoService.applyPageSeo).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
robots: 'noindex, nofollow',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a soft SSR fallback for non-404 category load errors', () => {
|
||||
const { component, seoService, responseInit } = createComponent(
|
||||
'/de/shop/compatible-with-garmin',
|
||||
);
|
||||
|
||||
expect((component as any).shouldUseSoftSeoFallback({ status: 500 })).toBeTrue();
|
||||
(component as any).setResponseStatus(200);
|
||||
(component as any).applySoftFallbackSeo('compatible-with-garmin');
|
||||
|
||||
expect(responseInit.status).toBe(200);
|
||||
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
title: 'Compatible With Garmin | Technische Lösungen | 3D fab',
|
||||
description:
|
||||
'Entdecken Sie Produkte dieser Kategorie und technische Lösungen.',
|
||||
robots: 'index, follow',
|
||||
canonicalPath: '/de/shop/compatible-with-garmin',
|
||||
alternates: null,
|
||||
xDefault: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps hard 404 noindex behavior for missing categories', () => {
|
||||
const { component, seoService, responseInit } = createComponent(
|
||||
'/de/shop/compatible-with-garmin',
|
||||
);
|
||||
|
||||
expect((component as any).shouldUseSoftSeoFallback({ status: 404 })).toBeFalse();
|
||||
(component as any).setResponseStatus(404);
|
||||
(component as any).applyHardErrorSeo();
|
||||
|
||||
expect(responseInit.status).toBe(404);
|
||||
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
robots: 'noindex, nofollow',
|
||||
alternates: null,
|
||||
xDefault: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user