Files
bmp-website-2026/src/spa/pages/Contact.tsx
syntaxbullet b57e08940d Refactor typography styles across multiple pages to use CSS classes for consistency and maintainability
- Updated Participation.tsx to replace inline styles with class names for headings and paragraphs.
- Refactored Preistraeger.tsx to utilize CSS classes for typography instead of inline styles.
- Modified PreistraegerDetail.tsx to implement class-based styling for headings and text elements.
- Adjusted Press.tsx to standardize typography using CSS classes, enhancing readability and maintainability.
2026-07-13 21:46:56 +02:00

297 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from 'react';
import { MapPin, Mail, Phone, Clock, ChevronDown } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import KontaktForm from '@/spa/components/forms/KontaktForm';
import { useIsMobile } from '@/spa/hooks/useIsMobile';
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import { useCmsRoute } from '@/spa/cmsRoute';
import Image from '@/spa/components/ui/UnoptimizedImage'
import type { Page } from '@/payload-types';
import { contactContent } from '@/spa/contactContent';
const NAVY = '#111D55';
const GOLD = '#EFBF04';
const WHITE = '#fff';
const INK = '#3A3A3A';
type ContactCms = NonNullable<Page['contact']>;
type ContactItem = NonNullable<NonNullable<ContactCms['info']>['items']>[number] | {
icon: string;
title: string;
lines: string | string[];
};
type DeadlineItem = NonNullable<NonNullable<ContactCms['deadlines']>['items']>[number];
type FaqItem = NonNullable<NonNullable<ContactCms['faq']>['items']>[number];
const fallbackText = (value: unknown, fallback: string) =>
typeof value === 'string' && value.length > 0 ? value : fallback;
const fallbackArray = <T,>(value: unknown, fallback: T[]) =>
Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback;
function Lines({ text }: { text: string }) {
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
}
const ICONS = {
mapPin: MapPin,
mail: Mail,
phone: Phone,
clock: Clock,
};
const Contact: React.FC = () => {
const [openFaq, setOpenFaq] = useState<number | null>(0);
const isMobile = useIsMobile();
const contact = (useCmsRoute()?.doc?.contact || {}) as ContactCms;
const hero = contact.hero || {};
const info = contact.info || {};
const deadlines = contact.deadlines || {};
const faq = contact.faq || {};
const contactItems = fallbackArray<ContactItem>(info.items, contactContent.info.items);
const deadlineItems = fallbackArray<DeadlineItem>(deadlines.items, contactContent.deadlines.items);
const faqItems = fallbackArray<FaqItem>(faq.items, contactContent.faq.items);
return (
<div style={{ background: WHITE }}>
{/* ── Hero ───────────────────────────────────────────────────────── */}
<section style={{ position: 'relative', height: '68vh', minHeight: 520, overflow: 'hidden', display: 'flex', alignItems: 'flex-end' }}>
<Image unoptimized
src={mediaUrl(hero.backgroundImage, `/images/${contactContent.hero.backgroundImageFilename}`)}
alt={mediaAlt(hero.backgroundImage, fallbackText(hero.imageAlt, contactContent.hero.imageAlt))}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 40%' }}
/>
{/* Diagonal overlay */}
<div style={{ position: 'absolute', inset: 0, background: `linear-gradient(105deg, ${NAVY} 0%, rgba(3,9,58,0.82) 35%, rgba(3,9,58,0.2) 70%, transparent 100%)` }} />
{/* Bottom fade to navy contact section */}
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 160, background: `linear-gradient(to top, ${NAVY}, transparent)` }} />
<div style={{ position: 'relative', zIndex: 2, width: '100%', paddingBottom: isMobile ? '3rem' : '5rem', paddingLeft: isMobile ? 24 : 80, paddingRight: isMobile ? 24 : 80 }}>
<p className="type-eyebrow text-role-brand-dark" style={{ marginBottom: 16 }}>
{fallbackText(hero.eyebrow, contactContent.hero.eyebrow)}
</p>
<h1 className="type-display text-role-inverse" style={{ marginBottom: 24, maxWidth: 640 }}>
<Lines text={fallbackText(hero.heading, contactContent.hero.heading)} />
</h1>
<p className="type-body text-role-inverse-secondary" style={{ maxWidth: 480 }}>
{fallbackText(hero.description, contactContent.hero.description)}
</p>
</div>
</section>
{/* ── Contact + Form ─────────────────────────────────────────────── */}
<section style={{ background: NAVY, position: 'relative', overflow: 'hidden' }}>
{/* Decorative circles */}
<div style={{ position: 'absolute', left: -120, bottom: -120, width: 480, height: 480, borderRadius: '50%', background: 'rgba(5,26,135,0.12)', pointerEvents: 'none' }} />
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '4fr 1px 8fr' }}>
{/* Left – info panel */}
<div style={{ padding: isMobile ? '48px 24px' : '88px 56px 88px 64px', display: 'flex', flexDirection: 'column', justifyContent: 'center', position: 'relative' }}>
<p className="type-eyebrow text-role-inverse-muted" style={{ marginBottom: 20 }}>
{fallbackText(info.eyebrow, contactContent.info.eyebrow)}
</p>
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 20 }} />
<h2 className="type-section-title text-role-inverse" style={{ marginBottom: 52 }}>
<Lines text={fallbackText(info.heading, contactContent.info.heading)} />
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 32 }}>
{contactItems.map(({ icon, title, lines }, i) => {
const Icon = ICONS[icon as keyof typeof ICONS] || MapPin;
const normalizedLines = typeof lines === 'string'
? lines.split('\n').filter(Boolean)
: fallbackArray<{ line?: string | null } | string>(lines, [])
.map((line) => typeof line === 'string' ? line : line.line)
.filter((line): line is string => Boolean(line));
return (
<div key={i} style={{ display: 'flex', gap: 18, alignItems: 'flex-start' }}>
<div style={{ width: 40, height: 40, borderRadius: 8, background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Icon size={17} color="rgba(255,255,255,0.80)" />
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'rgba(255,255,255,0.40)', textTransform: 'uppercase', letterSpacing: '0.14em', marginBottom: 4 }}>{fallbackText(title, '')}</div>
{normalizedLines.map((l: string, j: number) => <div key={j} style={{ fontSize: 17, color: '#fff', fontWeight: 500, lineHeight: 1.5 }}>{l}</div>)}
</div>
</div>
);
})}
</div>
<div style={{ marginTop: 52, paddingTop: 28, borderTop: '1px solid rgba(255,255,255,0.10)' }}>
<p className="type-small text-role-inverse-muted">
{fallbackText(info.nextAwardLabel, contactContent.info.nextAwardLabel)} <strong style={{ color: '#fff' }}>{fallbackText(info.nextAwardDate, contactContent.info.nextAwardDate)}</strong>
</p>
</div>
</div>
{/* Divider */}
{!isMobile && <div style={{ background: 'rgba(255,255,255,0.07)' }} />}
{/* Right – gold form panel */}
<div style={{ display: 'flex', flexDirection: 'column', overflow: isMobile ? 'visible' : 'hidden', height: isMobile ? 'auto' : undefined, minHeight: 0, position: 'relative', background: 'linear-gradient(160deg,#DDB84A 0%,#C9A227 52%,#A87800 100%)' }}>
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 0 }} aria-hidden="true">
<defs>
<pattern id="kontaktCross" width="18" height="18" patternUnits="userSpaceOnUse">
<path d="M0,0 L18,18 M18,0 L0,18" stroke="rgba(17,29,85,0.055)" strokeWidth="0.65" />
</pattern>
<pattern id="kontaktDiamond" width="36" height="36" patternUnits="userSpaceOnUse">
<polygon points="18,2 34,18 18,34 2,18" fill="none" stroke="rgba(17,29,85,0.045)" strokeWidth="0.6" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#kontaktCross)" />
<rect width="100%" height="100%" fill="url(#kontaktDiamond)" />
</svg>
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse at center, transparent 55%, rgba(100,70,0,0.25) 100%)', pointerEvents: 'none', zIndex: 0 }} />
{!isMobile && (
<div style={{ height: 200, position: 'relative', overflow: 'hidden', flexShrink: 0, zIndex: 1 }}>
<Image unoptimized src={mediaUrl(info.formImage, `/images/${contactContent.info.formImageFilename}`)} alt={mediaAlt(info.formImage, fallbackText(info.formImageAlt, contactContent.info.formImageAlt))} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 30%', filter: 'sepia(0.18) brightness(0.92)' }} />
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom, rgba(168,120,0,0.1) 0%, rgba(168,120,0,0.25) 50%, rgba(168,120,0,0.92) 88%, #A87800 100%)' }} />
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 2, background: 'rgba(17,29,85,0.35)' }} />
<div style={{ position: 'absolute', bottom: 14, left: 40, display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 5, height: 5, borderRadius: '50%', background: 'rgba(17,29,85,0.7)' }} />
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 10, fontWeight: 700, color: 'rgba(17,29,85,0.75)', textTransform: 'uppercase', letterSpacing: '0.2em' }}>{fallbackText(info.formImageLabel, contactContent.info.formImageLabel)}</span>
</div>
</div>
)}
<div style={{ padding: isMobile ? '36px 20px' : '32px 48px 32px 40px', flex: isMobile ? 'none' : 1, display: 'flex', flexDirection: 'column', minHeight: isMobile ? 480 : 0, position: 'relative', zIndex: 1 }}>
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 10, color: 'rgba(17,29,85,0.5)', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 8 }}>{fallbackText(info.formEyebrow, contactContent.info.formEyebrow)}</span>
<h3 className="type-card-title text-role-heading" style={{ textTransform: 'uppercase', marginBottom: 24 }}>
{fallbackText(info.formTitle, contactContent.info.formTitle)}
</h3>
<KontaktForm theme="gold" content={info.formCopy || undefined} />
</div>
</div>
</div>
</section>
{/* ── Deadlines ──────────────────────────────────────────────────── */}
<section style={{ background: '#0D1640', padding: isMobile ? '56px 0' : '88px 0', position: 'relative', overflow: 'hidden' }}>
<div style={{ position: 'absolute', right: -20, top: '50%', transform: 'translateY(-50%)', fontSize: 'clamp(160px, 22vw, 280px)', fontWeight: 900, color: '#fff', opacity: 0.025, lineHeight: 1, pointerEvents: 'none', userSelect: 'none', letterSpacing: '-0.05em' }}>
{fallbackText(deadlines.watermark, contactContent.deadlines.watermark)}
</div>
<div className="container mx-auto px-6" style={{ position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 20, marginBottom: isMobile ? 48 : 72 }}>
<span style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.28em', textTransform: 'uppercase', color: '#EFBF04', whiteSpace: 'nowrap' }}>
{fallbackText(deadlines.eyebrow, contactContent.deadlines.eyebrow)}
</span>
<div style={{ flex: 1, height: 1, background: 'rgba(255,255,255,0.10)' }} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, 1fr)', gap: 0 }}>
{deadlineItems.map((item, i) => (
<div key={i} style={isMobile ? {
paddingTop: i > 0 ? 36 : 0,
paddingBottom: i < deadlineItems.length - 1 ? 36 : 0,
borderBottom: i < deadlineItems.length - 1 ? '1px solid rgba(255,255,255,0.10)' : 'none',
} : {
paddingRight: i < deadlineItems.length - 1 ? 48 : 0,
paddingLeft: i > 0 ? 48 : 0,
borderRight: i < deadlineItems.length - 1 ? '1px solid rgba(255,255,255,0.10)' : 'none',
}}>
<div style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.2em', color: `rgba(239,191,4,0.5)`, marginBottom: 18, fontVariantNumeric: 'tabular-nums' }}>
{fallbackText(item.step, '')}
</div>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.14em', color: 'rgba(255,255,255,0.45)', marginBottom: 12 }}>
{fallbackText(item.label, '')}
</div>
<div style={{ fontSize: 'clamp(1.4rem, 2.5vw, 2rem)', fontWeight: 900, color: '#fff', letterSpacing: '-0.03em', lineHeight: 1.1, marginBottom: 16 }}>
{fallbackText(item.date, '')}
</div>
<div style={{ fontSize: 16, color: 'rgba(255,255,255,0.45)', lineHeight: 1.7 }}>
{fallbackText(item.desc, '')}
</div>
</div>
))}
</div>
</div>
</section>
{/* ── FAQ ────────────────────────────────────────────────────────── */}
<section id="faq" style={{ background: WHITE, padding: isMobile ? '56px 0 72px' : '96px 0 112px' }}>
<div className="container mx-auto px-6">
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 2fr', gap: isMobile ? 40 : 80 }}>
{/* Left – sticky label */}
<div style={isMobile ? undefined : { position: 'sticky', top: 96, alignSelf: 'start' }}>
<p className="type-eyebrow text-role-eyebrow" style={{ marginBottom: 20 }}>
{fallbackText(faq.eyebrow, contactContent.faq.eyebrow)}
</p>
<div style={{ width: 28, height: 2, background: GOLD, marginBottom: 20 }} />
<h2 className="type-section-title text-role-heading">
<Lines text={fallbackText(faq.heading, contactContent.faq.heading)} />
</h2>
</div>
{/* Right – accordion */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{faqItems.map((faq, idx) => {
const isOpen = openFaq === idx;
return (
<div
key={idx}
style={{
background: isOpen ? '#fff' : 'transparent',
borderRadius: 12,
overflow: 'hidden',
transition: 'background 0.2s',
border: isOpen ? `1.5px solid rgba(17,29,85,0.12)` : '1.5px solid rgba(17,29,85,0.08)',
}}
>
<button
onClick={() => setOpenFaq(isOpen ? null : idx)}
style={{
width: '100%', padding: '22px 24px',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left',
}}
>
<span style={{ fontSize: 17, fontWeight: 700, color: isOpen ? NAVY : INK, flex: 1, paddingRight: 20, lineHeight: 1.4 }}>
{fallbackText(faq.q, '')}
</span>
<div style={{
width: 30, height: 30, borderRadius: '50%', flexShrink: 0,
background: isOpen ? NAVY : 'rgba(17,29,85,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 0.2s',
}}>
<ChevronDown
size={15}
color={isOpen ? '#fff' : NAVY}
style={{ transform: isOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}
/>
</div>
</button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
key="content"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.22, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
<div style={{ padding: '0 24px 24px', fontSize: 17, color: 'rgba(58,58,58,0.72)', lineHeight: 1.75 }}>
{fallbackText(faq.a, '')}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
</div>
</div>
</section>
</div>
);
};
export default Contact;