fix(home): honor phase headline line breaks

This commit is contained in:
syntaxbullet
2026-07-31 17:24:36 +02:00
parent adda9ddbd0
commit f465839af0
6 changed files with 112 additions and 9 deletions

View File

@@ -26,7 +26,7 @@ const phaseFields: Field[] = [
text('tag', 'Badge'), text('tag', 'Badge'),
{ name: 'pulse', label: 'Show pulse dot', type: 'checkbox', defaultValue: true }, { name: 'pulse', label: 'Show pulse dot', type: 'checkbox', defaultValue: true },
text('phase', 'Phase label'), text('phase', 'Phase label'),
text('headline', 'Headline'), textarea('headline', 'Headline'),
textarea('body', 'Body copy'), textarea('body', 'Body copy'),
{ name: 'cta', label: 'Primary CTA', type: 'group', fields: linkGroup() }, { name: 'cta', label: 'Primary CTA', type: 'group', fields: linkGroup() },
text('accent', 'Accent color'), text('accent', 'Accent color'),

View File

@@ -0,0 +1,23 @@
import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
const previousHeadline = 'DER BMP 2026 WAR EIN ERFOLG.'
const unbrokenHeadline = 'Ein weiteres erfolgreiches Jahr'
const splitHeadline = 'Ein weiteres\nerfolgreiches Jahr'
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
await db.run(
sql.raw(
`UPDATE application_phase_phases SET headline = ${literal(splitHeadline)} WHERE num = '03' AND headline IN (${literal(previousHeadline)}, ${literal(unbrokenHeadline)});`,
),
)
}
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
await db.run(
sql.raw(
`UPDATE application_phase_phases SET headline = ${literal(previousHeadline)} WHERE num = '03' AND headline = ${literal(splitHeadline)};`,
),
)
}

View File

@@ -20,6 +20,7 @@ import * as migration_20260731_160000_focused_payload_cleanup from './20260731_1
import * as migration_20260731_170000_netzwerk_greeting_video from './20260731_170000_netzwerk_greeting_video'; import * as migration_20260731_170000_netzwerk_greeting_video from './20260731_170000_netzwerk_greeting_video';
import * as migration_20260731_180000_press_contact_image from './20260731_180000_press_contact_image'; import * as migration_20260731_180000_press_contact_image from './20260731_180000_press_contact_image';
import * as migration_20260731_190000_responsive_sponsor_hierarchy from './20260731_190000_responsive_sponsor_hierarchy'; import * as migration_20260731_190000_responsive_sponsor_hierarchy from './20260731_190000_responsive_sponsor_hierarchy';
import * as migration_20260731_200000_home_phase_headline_line_break from './20260731_200000_home_phase_headline_line_break';
export const migrations = [ export const migrations = [
{ {
@@ -132,4 +133,9 @@ export const migrations = [
down: migration_20260731_190000_responsive_sponsor_hierarchy.down, down: migration_20260731_190000_responsive_sponsor_hierarchy.down,
name: '20260731_190000_responsive_sponsor_hierarchy', name: '20260731_190000_responsive_sponsor_hierarchy',
}, },
{
up: migration_20260731_200000_home_phase_headline_line_break.up,
down: migration_20260731_200000_home_phase_headline_line_break.down,
name: '20260731_200000_home_phase_headline_line_break',
},
]; ];

View File

@@ -70,7 +70,7 @@ export const defaultApplicationPhaseData: SpaApplicationPhaseData = {
tag: 'ABGESCHLOSSEN', tag: 'ABGESCHLOSSEN',
pulse: false, pulse: false,
phase: 'Preisverleihung · Oktober 2026', phase: 'Preisverleihung · Oktober 2026',
headline: 'DER BMP 2026 WAR EIN ERFOLG.', headline: 'Ein weiteres\nerfolgreiches Jahr',
body: body:
'Wir danken allen 247 Bewerberinnen und Bewerbern für Mut und Exzellenz. Drei außergewöhnliche Unternehmen wurden in München ausgezeichnet ein unvergesslicher Abend.', 'Wir danken allen 247 Bewerberinnen und Bewerbern für Mut und Exzellenz. Drei außergewöhnliche Unternehmen wurden in München ausgezeichnet ein unvergesslicher Abend.',
cta: { label: 'Alle Preisträger ansehen', url: '/preistraeger' }, cta: { label: 'Alle Preisträger ansehen', url: '/preistraeger' },

View File

@@ -116,7 +116,7 @@ const isMembershipCta = (cta?: { label?: string; url?: string; to?: string } | n
cta?.label?.trim().toLowerCase() === 'mitglied werden'; cta?.label?.trim().toLowerCase() === 'mitglied werden';
function Lines({ text }: { text: string }) { function Lines({ text }: { text: string }) {
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>; return <>{text.split(/\r?\n/).map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
} }
function partnerLogos(partners: PartnerLogoData[]) { function partnerLogos(partners: PartnerLogoData[]) {
@@ -542,7 +542,7 @@ const Home: React.FC = () => {
// ─── StatusSlider ───────────────────────────────────────────────────────────── // ─── StatusSlider ─────────────────────────────────────────────────────────────
function StatusSlider({ data }: { data?: SpaApplicationPhaseData }) { export function StatusSlider({ data }: { data?: SpaApplicationPhaseData }) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
if (!data?.phases?.length) return null; if (!data?.phases?.length) return null;
@@ -568,11 +568,12 @@ function StatusSlider({ data }: { data?: SpaApplicationPhaseData }) {
return ( return (
<section <section
data-testid="phase-section"
style={{ style={{
position: 'relative', position: 'relative',
overflow: 'hidden', overflow: 'hidden',
height: isMobile ? 'auto' : 480, height: 'auto',
minHeight: isMobile ? 360 : 'auto', minHeight: isMobile ? 360 : 480,
}} }}
> >
<StatusSlideCard <StatusSlideCard
@@ -605,7 +606,7 @@ function StatusSlideCard({
<div <div
style={{ style={{
width: '100vw', width: '100vw',
height: '100%', minHeight: isMobile ? 360 : 480,
flexShrink: 0, flexShrink: 0,
position: 'relative', position: 'relative',
background: phase.bg, background: phase.bg,
@@ -629,8 +630,8 @@ function StatusSlideCard({
{/* Headline + body + CTA */} {/* Headline + body + CTA */}
<div> <div>
<h2 className="type-page-title text-role-inverse" style={{ textTransform: 'uppercase', margin: '0 0 24px' }}> <h2 className="type-page-title text-role-inverse" data-testid="phase-headline" style={{ textTransform: 'uppercase', margin: '0 0 24px' }}>
{phase.headline} <Lines text={fallbackText(phase.headline, '')} />
</h2> </h2>
<p className="type-body text-role-inverse-secondary" style={{ marginBottom: 32, maxWidth: isMobile ? 520 : 760 }}> <p className="type-body text-role-inverse-secondary" style={{ marginBottom: 32, maxWidth: isMobile ? 520 : 760 }}>
{phase.body} {phase.body}

View File

@@ -0,0 +1,73 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SpaApplicationPhaseData } from '@/spa/applicationPhase'
import { StatusSlider } from '@/spa/pages/Home'
vi.mock('@/spa/hooks/useIsMobile', () => ({
useIsMobile: () => false,
}))
vi.mock('next/navigation', () => ({
usePathname: () => '/',
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}))
const applicationPhase = (headline: string): SpaApplicationPhaseData => ({
activePhase: '0',
noActionLabel: 'No action',
successApplicationsLabel: 'Applications',
successWinnersLabel: 'Winners',
phases: [
{
num: '03',
tag: 'Complete',
phase: 'Award ceremony',
headline,
body: 'CMS body copy',
accent: '#EFBF04',
bg: '#111D55',
meta: 'CMS meta copy',
successApplications: '247',
successWinners: '3',
},
],
})
describe('homepage application-phase headline', () => {
afterEach(() => cleanup())
it('renders each explicit CMS newline as an intentional line break', () => {
render(<StatusSlider data={applicationPhase('Ein weiteres\nerfolgreiches Jahr')} />)
const headline = screen.getByTestId('phase-headline')
expect(headline.textContent).toBe('Ein weitereserfolgreiches Jahr')
expect(headline.querySelectorAll('br')).toHaveLength(1)
expect(headline.childNodes[0]?.textContent).toBe('Ein weiteres')
expect(headline.childNodes[2]?.textContent).toBe('erfolgreiches Jahr')
})
it('keeps arbitrary CMS-edited copy authoritative and naturally wrappable', () => {
const editedHeadline =
'Eine redaktionell geänderte Überschrift mit zusätzlichem Inhalt für schmale Ansichten'
render(<StatusSlider data={applicationPhase(editedHeadline)} />)
const headline = screen.getByTestId('phase-headline')
expect(headline.textContent).toBe(editedHeadline)
expect(headline.querySelector('br')).toBeNull()
expect(headline.classList.contains('type-page-title')).toBe(true)
expect(screen.getByTestId('phase-section').style.height).toBe('auto')
expect(screen.getByTestId('phase-section').style.minHeight).toBe('480px')
})
it('keeps the requested copy out of the homepage component', () => {
const source = readFileSync(join(process.cwd(), 'src/spa/pages/Home.tsx'), 'utf8')
expect(source).not.toContain('Ein weiteres')
expect(source).not.toContain('erfolgreiches Jahr')
})
})