fix(about): rebalance relevance section

This commit is contained in:
syntaxbullet
2026-07-31 18:25:33 +02:00
parent 4bb265dac1
commit 3c28f6ff9c
3 changed files with 178 additions and 20 deletions

View File

@@ -4,6 +4,8 @@ type MediaLike = {
url?: string | null url?: string | null
filename?: string | null filename?: string | null
alt?: string | null alt?: string | null
focalX?: number | null
focalY?: number | null
} }
export function normalizeMediaUrl(url: string | null | undefined) { export function normalizeMediaUrl(url: string | null | undefined) {
@@ -22,6 +24,19 @@ export function mediaAlt(media: unknown, fallback: string) {
return doc.alt || fallback return doc.alt || fallback
} }
const focalCoordinate = (value: unknown) => {
if (typeof value !== 'number' || !Number.isFinite(value)) return 50
return Math.min(100, Math.max(0, value))
}
export function mediaObjectPosition(media: unknown, fallback = 'center') {
if (!media || typeof media !== 'object') return fallback
const doc = media as MediaLike
if (typeof doc.focalX !== 'number' && typeof doc.focalY !== 'number') return fallback
return `${focalCoordinate(doc.focalX)}% ${focalCoordinate(doc.focalY)}%`
}
export function docImageUrl(doc: CmsRouteDoc | undefined, fallback: string, field = 'image') { export function docImageUrl(doc: CmsRouteDoc | undefined, fallback: string, field = 'image') {
return mediaUrl(doc?.[field], fallback) return mediaUrl(doc?.[field], fallback)
} }

View File

@@ -6,7 +6,7 @@ import HomeWinnersSection, { type HomeWinnersContent } from '@/spa/components/Ho
import TestimonialsSection from '@/spa/components/TestimonialsSection' import TestimonialsSection from '@/spa/components/TestimonialsSection'
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg' import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg'
import Image from '@/spa/components/ui/UnoptimizedImage' import Image from '@/spa/components/ui/UnoptimizedImage'
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField' import { mediaAlt, mediaObjectPosition, mediaUrl } from '@/spa/cmsMediaField'
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute' import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute'
import { useIsMobile } from '@/spa/hooks/useIsMobile' import { useIsMobile } from '@/spa/hooks/useIsMobile'
import { Link } from '@/spa/router' import { Link } from '@/spa/router'
@@ -82,6 +82,7 @@ function HighlightedText({ text, highlight }: { text: string; highlight: string
const About: React.FC = () => { const About: React.FC = () => {
const isMobile = useIsMobile() const isMobile = useIsMobile()
const isRelevanceStacked = useIsMobile(1024)
const cms = (useCmsRoute()?.doc?.about || {}) as AboutCms const cms = (useCmsRoute()?.doc?.about || {}) as AboutCms
const cmsPages = useCmsCollection('pages') const cmsPages = useCmsCollection('pages')
const hero = { ...aboutContent.hero, ...(cms.hero || {}) } const hero = { ...aboutContent.hero, ...(cms.hero || {}) }
@@ -215,14 +216,22 @@ const About: React.FC = () => {
</div> </div>
</section> </section>
<section id="mittelstand" style={{ overflow: 'hidden' }}> <section id="mittelstand" data-testid="relevance-section" style={{ overflow: 'hidden' }}>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '45% 55%', minHeight: isMobile ? 'auto' : 580 }}> <div
<div style={{ position: 'relative', overflow: 'hidden', minHeight: isMobile ? 280 : undefined }}> data-testid="relevance-layout"
data-layout={isRelevanceStacked ? 'stacked' : 'split'}
style={{ display: 'grid', gridTemplateColumns: isRelevanceStacked ? '1fr' : '55% 45%', minHeight: isRelevanceStacked ? 'auto' : 580 }}
>
<div
data-testid="relevance-image-panel"
style={{ position: 'relative', overflow: 'hidden', minHeight: isRelevanceStacked ? (isMobile ? 280 : 420) : undefined }}
>
<Image <Image
unoptimized unoptimized
data-testid="relevance-image"
src={mediaUrl(mittelstand.image, `/images/${aboutContent.mittelstand.imageFilename}`)} src={mediaUrl(mittelstand.image, `/images/${aboutContent.mittelstand.imageFilename}`)}
alt={mediaAlt(mittelstand.image, fallbackText(mittelstand.imageAlt, aboutContent.mittelstand.imageAlt))} alt={mediaAlt(mittelstand.image, fallbackText(mittelstand.imageAlt, aboutContent.mittelstand.imageAlt))}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center', display: 'block' }} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', objectPosition: mediaObjectPosition(mittelstand.image), display: 'block' }}
/> />
<div style={{ position: 'absolute', inset: 0, background: isMobile <div style={{ position: 'absolute', inset: 0, background: isMobile
? 'linear-gradient(to top, rgba(3,9,58,0.94) 0%, rgba(3,9,58,0.55) 38%, rgba(3,9,58,0.12) 72%, transparent 100%), linear-gradient(to right, rgba(3,9,58,0.5) 0%, transparent 55%)' ? 'linear-gradient(to top, rgba(3,9,58,0.94) 0%, rgba(3,9,58,0.55) 38%, rgba(3,9,58,0.12) 72%, transparent 100%), linear-gradient(to right, rgba(3,9,58,0.5) 0%, transparent 55%)'
@@ -242,7 +251,11 @@ const About: React.FC = () => {
)} )}
</div> </div>
<div style={{ background: NAVY, padding: isMobile ? '32px 24px 40px' : '88px 80px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}> <div
data-testid="relevance-copy-panel"
style={{ background: NAVY, padding: isMobile ? '32px 24px 40px' : isRelevanceStacked ? '56px 48px' : '88px 64px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}
>
<div data-testid="relevance-copy" style={{ width: '100%', maxWidth: 520, margin: '0 auto' }}>
{!isMobile && ( {!isMobile && (
<> <>
<span style={{ fontFamily: FF, fontSize: 10, color: '#EFBF04', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 20 }}>{fallbackText(mittelstand.eyebrow, aboutContent.mittelstand.eyebrow)}</span> <span style={{ fontFamily: FF, fontSize: 10, color: '#EFBF04', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 20 }}>{fallbackText(mittelstand.eyebrow, aboutContent.mittelstand.eyebrow)}</span>
@@ -253,12 +266,13 @@ const About: React.FC = () => {
)} )}
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 28 }} /> <div style={{ width: 36, height: 2, background: GOLD, marginBottom: 28 }} />
{paragraphs(mittelstand.body, aboutContent.mittelstand.body).map((text, index, arr) => ( {paragraphs(mittelstand.body, aboutContent.mittelstand.body).map((text, index, arr) => (
<p key={index} className="type-body text-role-inverse-secondary" style={{ maxWidth: 440, marginBottom: index === arr.length - 1 ? 40 : 16 }}> <p key={index} className="type-body text-role-inverse-secondary" style={{ marginBottom: index === arr.length - 1 ? (isMobile ? 0 : 40) : 16 }}>
{text} {text}
</p> </p>
))} ))}
</div> </div>
</div> </div>
</div>
</section> </section>
<HomeWinnersSection content={homeWinners} /> <HomeWinnersSection content={homeWinners} />

View File

@@ -0,0 +1,129 @@
import { cleanup, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
import About from '@/spa/pages/About'
const viewport = vi.hoisted(() => ({ mobile: false, relevanceStacked: false }))
vi.mock('@/spa/hooks/useIsMobile', () => ({
useIsMobile: (breakpoint = 768) =>
breakpoint === 1024 ? viewport.relevanceStacked : viewport.mobile,
}))
vi.mock('next/navigation', () => ({
usePathname: () => '/der-bmp',
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))
vi.mock('@/spa/components/AwardsGridSection', () => ({
default: () => <section data-testid="awards-stub" />,
}))
vi.mock('@/spa/components/HomeWinnersSection', () => ({
default: () => <section data-testid="winners-stub" />,
}))
vi.mock('@/spa/components/TestimonialsSection', () => ({
default: () => <section data-testid="testimonials-stub" />,
}))
vi.mock('@/spa/components/ui/munich-skyline-bg', () => ({
default: () => null,
}))
const aboutRoute: CmsRouteData = {
collection: 'pages',
doc: {
id: 'about-page',
slug: 'der-bmp',
spaPath: '/der-bmp',
about: {
mittelstand: {
image: {
url: '/api/media/file/cms-relevance.jpg',
alt: 'CMS relevance event',
focalX: 38,
focalY: 42,
},
imageAlt: 'Page-owned fallback alt',
eyebrow: 'CMS relevance eyebrow',
heading: 'CMS RELEVANCE\nHEADING',
body: [
{ text: 'First CMS relevance paragraph.' },
{ text: 'Second CMS relevance paragraph.' },
],
},
},
},
}
const renderAbout = () =>
render(
<CmsRouteProvider value={aboutRoute}>
<About />
</CmsRouteProvider>,
)
describe('/der-bmp relevance section', () => {
beforeEach(() => {
viewport.mobile = false
viewport.relevanceStacked = false
})
afterEach(() => cleanup())
it('gives the image the larger desktop share and centers a readable copy measure', () => {
renderAbout()
const layout = screen.getByTestId('relevance-layout')
const copyPanel = screen.getByTestId('relevance-copy-panel')
const copy = screen.getByTestId('relevance-copy')
expect(layout.getAttribute('data-layout')).toBe('split')
expect(layout.style.gridTemplateColumns).toBe('55% 45%')
expect(copyPanel.style.padding).toBe('88px 64px')
expect(copy.style.width).toBe('100%')
expect(copy.style.maxWidth).toBe('520px')
expect(copy.style.margin).toBe('0px auto')
})
it('preserves CMS-owned copy and media while honoring the media focal point', () => {
renderAbout()
const section = screen.getByTestId('relevance-section')
const image = within(section).getByRole('img', { name: 'CMS relevance event' })
expect(image.getAttribute('src')).toBe('/api/media/file/cms-relevance.jpg')
expect(image.style.objectPosition).toBe('38% 42%')
expect(within(section).getByText('CMS relevance eyebrow')).toBeTruthy()
expect(within(section).getByRole('heading', { level: 2 }).textContent).toBe(
'CMS RELEVANCEHEADING',
)
expect(within(section).getByText('First CMS relevance paragraph.')).toBeTruthy()
expect(within(section).getByText('Second CMS relevance paragraph.')).toBeTruthy()
expect(
within(section).queryByText(
'Der Bayerische Mittelstandspreis ist weit mehr als eine Trophäe.',
),
).toBeNull()
})
it('keeps the image first with readable padding in the stacked mobile layout', () => {
viewport.mobile = true
viewport.relevanceStacked = true
renderAbout()
const layout = screen.getByTestId('relevance-layout')
const imagePanel = screen.getByTestId('relevance-image-panel')
const copyPanel = screen.getByTestId('relevance-copy-panel')
expect(layout.getAttribute('data-layout')).toBe('stacked')
expect(layout.style.gridTemplateColumns).toBe('1fr')
expect(imagePanel.style.minHeight).toBe('280px')
expect(copyPanel.style.padding).toBe('32px 24px 40px')
expect(
imagePanel.compareDocumentPosition(copyPanel) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
})
})