From 4bb265dac1b7862db1e468d63126a07e9eaa8aec Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 31 Jul 2026 18:07:05 +0200 Subject: [PATCH] fix(participation): clarify eligibility hierarchy --- src/spa/pages/Participation.tsx | 92 +++++----- .../participation-eligibility.int.spec.tsx | 157 ++++++++++++++++++ 2 files changed, 206 insertions(+), 43 deletions(-) create mode 100644 tests/int/participation-eligibility.int.spec.tsx diff --git a/src/spa/pages/Participation.tsx b/src/spa/pages/Participation.tsx index 24c445d..e5f10b3 100644 --- a/src/spa/pages/Participation.tsx +++ b/src/spa/pages/Participation.tsx @@ -49,6 +49,7 @@ function Lines({ text }: { text: string }) { const Participation: React.FC = () => { const isMobile = useIsMobile(); + const isEligibilityCompact = useIsMobile(900); const qualSectionRef = useRef(null); const cms = (useCmsRoute()?.doc?.participation || {}) as ParticipationCms; const hero = { ...participationContent.hero, ...(cms.hero || {}) }; @@ -94,10 +95,10 @@ const Participation: React.FC = () => { {/* ── QUALIFICATIONS ───────────────────────────────────────────────────── */} -
+
{/* Header */} -
+
{fallbackText(eligibility.eyebrow, participationContent.eligibility.eyebrow)}

@@ -122,56 +123,60 @@ const Participation: React.FC = () => {

{/* 4 criteria as editorial rows */} -
- {eligibilityCriteria.map((item, i, arr) => { - const Icon = ICONS[item.icon as keyof typeof ICONS] || MapPin; - return ( - - ); - })} +
+
    + {eligibilityCriteria.map((item, i, arr) => { + const Icon = ICONS[item.icon as keyof typeof ICONS] || MapPin; + return ( + + ); + })} +
{/* Zusätzliche Hinweise: Verbund + Wiederbewerber */} -
- {eligibilityNotes.map((note, i) => { - const Icon = ICONS[note.icon as keyof typeof ICONS] || Building2; - return ( -
- -
-
{fallbackText(note.title, '')}
-

- {fallbackText(note.body, '')} -

-
+
+
+ {eligibilityNotes.map((note, i) => { + const Icon = ICONS[note.icon as keyof typeof ICONS] || Building2; + return ( +
+
+ ); + })}
- ); - })}
{/* CTA nudge */} -
-
- - {fallbackText(eligibility.nudgeText, participationContent.eligibility.nudgeText)} +
+
{/* ── BEWERBUNGSWEGE ───────────────────────────────────────────────────── */} {/* ── DATES BANNER ─────────────────────────────────────────────────────── */} -
+
{isMobile ? ( /* Mobile: slim horizontal bar */
{fallbackText(datesBanner.heading, participationContent.datesBanner.heading)}
-
+
{mobileDates.map((d, i) => ( -
0 ? 14 : 0, borderLeft: i > 0 ? '1px solid rgba(3,9,58,0.2)' : 'none' }}> -
{fallbackText(d.date, '')}
-
{fallbackText(d.label, '')}
+
+
{fallbackText(d.date, '')}
+
{fallbackText(d.label, '')}
))}
@@ -188,7 +193,7 @@ const Participation: React.FC = () => {
{fallbackText(d.date, '')}
-
{fallbackText(d.label, '')}
+
{fallbackText(d.label, '')}
{i < desktopDates.length - 1 &&
} @@ -207,31 +212,32 @@ const Participation: React.FC = () => { // ── QualRow ─────────────────────────────────────────────────────────────────── -function QualRow({ item, Icon, last }: { item: { num: string; title: string; body: string }; Icon: React.ElementType; last: boolean }) { +function QualRow({ item, Icon, last, index, compact }: { item: { num: string; title: string; body: string }; Icon: React.ElementType; last: boolean; index: number; compact: boolean }) { const [hovered, setHovered] = React.useState(false); - const isMobileRow = useIsMobile(); return ( -
setHovered(true)} onMouseLeave={() => setHovered(false)} > - {item.num} - {isMobileRow ? ( + {item.num} + {compact ? (
{item.title}
-
{item.body}
+
{item.body}
) : ( <>
{item.title}
-
{item.body}
+
{item.body}
)} -
+ ); } diff --git a/tests/int/participation-eligibility.int.spec.tsx b/tests/int/participation-eligibility.int.spec.tsx new file mode 100644 index 0000000..529323b --- /dev/null +++ b/tests/int/participation-eligibility.int.spec.tsx @@ -0,0 +1,157 @@ +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 Participation from '@/spa/pages/Participation' + +const viewport = vi.hoisted(() => ({ mobile: false })) + +vi.mock('@/spa/hooks/useIsMobile', () => ({ + useIsMobile: () => viewport.mobile, +})) + +vi.mock('next/navigation', () => ({ + usePathname: () => '/teilnahme', + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})) + +vi.mock('@/spa/components/WegZurAuszeichnungSection', () => ({ + default: () =>
, +})) + +vi.mock('@/spa/components/AwardsGridSection', () => ({ + default: () =>
, +})) + +vi.mock('@/spa/components/forms/ApplicationFormSection', () => ({ + ApplicationFormSection: () =>
, +})) + +vi.mock('@/spa/components/ui/UnoptimizedImage', () => ({ + default: ({ alt }: { alt: string }) =>
, +})) + +const participationRoute: CmsRouteData = { + collection: 'pages', + doc: { + id: 'participation-page', + slug: 'teilnahme', + spaPath: '/teilnahme', + participation: { + eligibility: { + eyebrow: 'CMS eligibility eyebrow', + heading: 'CMS ELIGIBILITY\nHEADING', + description: 'CMS eligibility introduction.', + primaryCta: { label: 'CMS eligibility CTA', url: '/kontakt' }, + criteria: [ + { + num: 'A', + title: 'First CMS criterion', + body: 'First CMS criterion body.', + icon: 'mapPin', + }, + { + num: 'B', + title: 'Second CMS criterion', + body: 'Second CMS criterion body.', + icon: 'users', + }, + ], + notes: [ + { title: 'First CMS note', body: 'First CMS note body.', icon: 'building2' }, + { title: 'Second CMS note', body: 'Second CMS note body.', icon: 'userCheck' }, + ], + nudgeText: 'CMS eligibility conclusion.', + }, + datesBanner: { + heading: 'CMS important dates', + description: 'CMS dates description.', + mobileItems: [{ date: 'Mobile date', label: 'Mobile label' }], + desktopItems: [{ date: 'Desktop date', label: 'Desktop label' }], + }, + }, + }, +} + +const renderParticipation = () => + render( + + + , + ) + +describe('participation eligibility hierarchy', () => { + beforeEach(() => { + viewport.mobile = false + }) + + afterEach(() => cleanup()) + + it('keeps CMS eligibility content and its CTA authoritative inside semantic groups', () => { + renderParticipation() + + const section = screen.getByTestId('eligibility-section') + const criteria = within(section).getAllByTestId('eligibility-criterion') + const notes = within(section).getAllByTestId('eligibility-note') + const cta = within(section).getByRole('link', { name: 'CMS eligibility CTA' }) + + expect(within(section).getByRole('heading', { level: 2 }).textContent).toBe( + 'CMS ELIGIBILITYHEADING', + ) + expect(within(section).getByText('CMS eligibility introduction.')).toBeTruthy() + expect(criteria.map((row) => row.textContent)).toEqual([ + 'AFirst CMS criterionFirst CMS criterion body.', + 'BSecond CMS criterionSecond CMS criterion body.', + ]) + expect(criteria.every((row) => row.tagName === 'LI')).toBe(true) + expect(criteria[0]?.parentElement?.tagName).toBe('OL') + expect(notes.map((note) => note.textContent)).toEqual([ + 'First CMS noteFirst CMS note body.', + 'Second CMS noteSecond CMS note body.', + ]) + expect(within(section).getByText('CMS eligibility conclusion.')).toBeTruthy() + expect(cta.getAttribute('href')).toBe('/kontakt') + expect(within(section).getAllByRole('link')).toHaveLength(1) + }) + + it('provides restrained group and dates-transition hooks on desktop', () => { + renderParticipation() + + expect(screen.getByTestId('eligibility-header').style.background).toBe('rgb(255, 255, 255)') + expect(screen.getByTestId('eligibility-criteria-group').style.background).toBe( + 'rgb(244, 246, 248)', + ) + expect(screen.getByTestId('eligibility-summary').style.background).toBe('rgb(17, 29, 85)') + expect(screen.getByTestId('important-dates-transition').style.borderTopWidth).toBe('8px') + expect(screen.getByRole('heading', { name: 'CMS important dates', level: 3 })).toBeTruthy() + expect(screen.getByText('Desktop date')).toBeTruthy() + expect(screen.queryByText('Mobile date')).toBeNull() + }) + + it('keeps criteria, notes, and conclusion in a single readable mobile order', () => { + viewport.mobile = true + renderParticipation() + + const section = screen.getByTestId('eligibility-section') + const criteriaGroup = within(section).getByTestId('eligibility-criteria-group') + const notesGroup = within(section).getByTestId('eligibility-notes-group') + const summary = within(section).getByTestId('eligibility-summary') + const rows = within(criteriaGroup).getAllByTestId('eligibility-criterion') + + expect(rows.map((row) => row.getAttribute('data-order'))).toEqual(['1', '2']) + expect(rows.every((row) => row.style.gridTemplateColumns === '40px 1fr')).toBe(true) + expect( + criteriaGroup.compareDocumentPosition(notesGroup) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + expect( + notesGroup.compareDocumentPosition(summary) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy() + expect(screen.getByTestId('important-dates-transition').style.borderTopWidth).toBe('5px') + expect(screen.getByTestId('important-date-item').getAttribute('data-order')).toBe('1') + expect(screen.getByTestId('important-date-item').style.gridTemplateColumns).toBe( + 'minmax(112px, 0.9fr) minmax(0, 1.1fr)', + ) + expect(screen.getByText('Mobile date')).toBeTruthy() + expect(screen.queryByText('Desktop date')).toBeNull() + }) +})