feat(network): add responsive sponsor hierarchy

This commit is contained in:
syntaxbullet
2026-07-31 16:41:02 +02:00
parent ebecf66e6f
commit 30efb4b36e
11 changed files with 438 additions and 37 deletions

View File

@@ -0,0 +1,183 @@
import { cleanup, render, screen, within } from '@testing-library/react'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { CmsRouteProvider, type CmsRouteData } from '@/spa/cmsRoute'
import { netzwerkContent } from '@/spa/netzwerkContent'
import Netzwerk from '@/spa/pages/Netzwerk'
import {
groupSponsorsByTier,
sponsorCardBasis,
sponsorColumns,
sponsorViewport,
} from '@/spa/sponsorHierarchy'
vi.mock('next/navigation', () => ({
usePathname: () => '/netzwerk',
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))
let viewportWidth = 1440
const partner = (name: string, tier: 1 | 2 | 3, sortOrder: number) => ({
id: name.toLowerCase().replace(/\s/g, '-'),
stableId: name.toLowerCase().replace(/\s/g, '-'),
name,
tier,
sortOrder,
active: true,
desc: `${name} description`,
fullDesc: `${name} details`,
industry: `${name} industry`,
location: `${name} location`,
website: `${name.toLowerCase()}.example`,
})
const partners = [
partner('Tier One B', 1, 2),
partner('Tier Three H', 3, 8),
partner('Tier Two B', 2, 2),
partner('Tier Three A', 3, 1),
partner('Tier One A', 1, 1),
partner('Tier Three C', 3, 3),
partner('Tier Two A', 2, 1),
partner('Tier Three B', 3, 2),
partner('Tier Three D', 3, 4),
partner('Tier Three E', 3, 5),
partner('Tier Three F', 3, 6),
partner('Tier Three G', 3, 7),
]
const sponsorCopy = {
eyebrow: 'CMS sponsor eyebrow',
heading: 'CMS sponsor heading',
description: 'CMS sponsor supporting copy',
detailLabel: 'CMS sponsor action',
tiers: [
{ tier: 1, label: 'CMS feature tier', note: 'CMS feature tier note' },
{ tier: 2, label: 'CMS standard tier', note: 'CMS standard tier note' },
{ tier: 3, label: 'CMS compact tier', note: 'CMS compact tier note' },
],
}
const routeData = (): CmsRouteData => ({
collection: 'pages',
doc: {
id: 'network-page',
slug: 'netzwerk',
spaPath: '/netzwerk',
netzwerk: { partners: sponsorCopy },
},
lists: {
partners,
'jury-mitglieder': [],
},
})
function renderNetwork(width: number) {
viewportWidth = width
return render(
<CmsRouteProvider value={routeData()}>
<Netzwerk />
</CmsRouteProvider>,
)
}
describe('responsive sponsor hierarchy', () => {
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => {
const maximum = Number(query.match(/max-width:\s*(\d+)px/)?.[1] || Number.POSITIVE_INFINITY)
return {
addEventListener: vi.fn(),
matches: viewportWidth <= maximum,
media: query,
removeEventListener: vi.fn(),
}
}),
})
})
afterEach(() => cleanup())
it('groups by Partner tier and sorts each band by Partner sortOrder', () => {
const grouped = groupSponsorsByTier(partners)
expect(grouped[1].map(({ name }) => name)).toEqual(['Tier One A', 'Tier One B'])
expect(grouped[2].map(({ name }) => name)).toEqual(['Tier Two A', 'Tier Two B'])
expect(grouped[3].map(({ name }) => name)).toEqual([
'Tier Three A',
'Tier Three B',
'Tier Three C',
'Tier Three D',
'Tier Three E',
'Tier Three F',
'Tier Three G',
'Tier Three H',
])
})
it('defines the approved columns and conservative narrow-mobile fallback', () => {
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'desktop'))).toEqual([2, 3, 4])
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'tablet'))).toEqual([2, 2, 3])
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'mobile'))).toEqual([1, 1, 2])
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'narrow-mobile'))).toEqual([1, 1, 1])
expect(sponsorViewport({ isMobile: false, isNarrowMobile: false, isTabletOrMobile: true })).toBe('tablet')
expect(sponsorCardBasis(3, 16)).toBe('calc((100% - 32px) / 3)')
})
it('renders every visible sponsor-section label from Netzwerk CMS fields', () => {
renderNetwork(1440)
expect(screen.getByText(sponsorCopy.eyebrow)).toBeTruthy()
expect(screen.getByText(sponsorCopy.heading)).toBeTruthy()
expect(screen.getByText(sponsorCopy.description)).toBeTruthy()
sponsorCopy.tiers.forEach(({ label, note }) => {
expect(screen.getByRole('heading', { name: label })).toBeTruthy()
expect(screen.getByText(note)).toBeTruthy()
})
expect(screen.getAllByText(sponsorCopy.detailLabel)).toHaveLength(partners.length)
expect(screen.queryByText(netzwerkContent.partners.description)).toBeNull()
netzwerkContent.partners.tiers.forEach(({ label }) => expect(screen.queryByText(label)).toBeNull())
})
it.each([
{ width: 1440, viewport: 'desktop', columns: [2, 3, 4] },
{ width: 1024, viewport: 'tablet', columns: [2, 2, 3] },
{ width: 390, viewport: 'mobile', columns: [1, 1, 2] },
{ width: 340, viewport: 'narrow-mobile', columns: [1, 1, 1] },
])('centers independent bands at $width px', ({ width, viewport, columns }) => {
renderNetwork(width)
columns.forEach((columnCount, index) => {
const tier = index + 1
const band = screen.getByTestId(`sponsor-tier-${tier}`)
const cards = screen.getByTestId(`sponsor-tier-${tier}-cards`)
expect(band.getAttribute('data-columns')).toBe(String(columnCount))
expect(band.getAttribute('data-viewport')).toBe(viewport)
expect(cards.style.display).toBe('flex')
expect(cards.style.flexWrap).toBe('wrap')
expect(cards.style.justifyContent).toBe('center')
expect(within(band).getAllByRole('button')).toHaveLength(tier === 3 ? 8 : 2)
})
})
it('keeps sponsor defaults and legacy labels out of the rendered component source', () => {
const source = readFileSync(join(process.cwd(), 'src/spa/pages/Netzwerk.tsx'), 'utf8')
const visibleDefaults = [
netzwerkContent.partners.eyebrow,
netzwerkContent.partners.heading,
netzwerkContent.partners.description,
netzwerkContent.partners.detailLabel,
...netzwerkContent.partners.tiers.map(({ label }) => label),
'Partner in drei Kategorien: Hauptsponsoren, Medienpartner und weitere Sponsoren. Klicken für Details.',
]
visibleDefaults.forEach((copy) => {
expect(source).not.toContain(`'${copy}'`)
expect(source).not.toContain(`"${copy}"`)
})
})
})

View File

@@ -106,7 +106,13 @@ describe('Payload field inventory', () => {
'netzwerk.patronage.greetingVideoTitle',
'netzwerk.patronage.greetingVideoDescription',
'netzwerk.patronage.greetingVideoCloseLabel',
'netzwerk.partners.eyebrow',
'netzwerk.partners.heading',
'netzwerk.partners.description',
'netzwerk.partners.detailLabel',
'netzwerk.partners.tiers',
'netzwerk.partners.tiers.label',
'netzwerk.partners.tiers.note',
'netzwerk.partners.modal.websiteLabel',
'netzwerk.sponsoringForm.steps',
'netzwerk.sponsoringForm.requiredFieldError',
@@ -153,6 +159,15 @@ describe('Payload field inventory', () => {
expect(field).not.toHaveProperty('defaultValue')
})
it('keeps sponsor tier copy separate from collection-owned tier assignment and ordering', () => {
expect(findField(netzwerkFields, 'netzwerk.partners.tiers')).toMatchObject({
type: 'array',
dbName: 'network_tiers',
})
expect(findField(netzwerkFields, 'netzwerk.partners.tiers.label')).toMatchObject({ type: 'text' })
expect(findField(netzwerkFields, 'netzwerk.partners.tiers.note')).toMatchObject({ type: 'text' })
})
it('limits the press contact upload to image media', () => {
const field = findField(pressIndexFields, 'pressIndex.downloads.contactImage')