- 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.
297 lines
18 KiB
TypeScript
297 lines
18 KiB
TypeScript
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;
|