feat: implement contact form submission and database integration

- Added a new collection for contact submissions with validation and error handling.
- Created a migration script to set up the contact submissions table in the database.
- Updated the SponsoringForm component to handle newsletter subscriptions based on application phase.
- Refactored the UI components for improved user experience and error messaging.
- Removed the package selection step from the sponsoring form.
- Updated the styling across various pages to maintain consistency with the new design.
This commit is contained in:
syntaxbullet
2026-07-13 19:56:36 +02:00
parent 308d75c44a
commit b372b23b0c
13 changed files with 451 additions and 99 deletions

View File

@@ -11,7 +11,7 @@ import { contactContent } from '@/spa/contactContent';
const NAVY = '#111D55';
const GOLD = '#EFBF04';
const CREAM = '#EFE5E3';
const WHITE = '#fff';
const INK = '#3A3A3A';
type ContactCms = NonNullable<Page['contact']>;
@@ -53,7 +53,7 @@ const Contact: React.FC = () => {
const faqItems = fallbackArray<FaqItem>(faq.items, contactContent.faq.items);
return (
<div style={{ background: CREAM }}>
<div style={{ background: WHITE }}>
{/* ── Hero ───────────────────────────────────────────────────────── */}
<section style={{ position: 'relative', height: '68vh', minHeight: 520, overflow: 'hidden', display: 'flex', alignItems: 'flex-end' }}>
@@ -210,7 +210,7 @@ const Contact: React.FC = () => {
</section>
{/* ── FAQ ────────────────────────────────────────────────────────── */}
<section id="faq" style={{ background: CREAM, padding: isMobile ? '56px 0 72px' : '96px 0 112px' }}>
<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 }}>

View File

@@ -15,7 +15,6 @@ const FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", sans-serif';
const NAVY = '#111D55';
const GOLD = '#EFBF04';
const CREAM = '#E4E2E3';
type NetzwerkCms = Partial<typeof netzwerkContent> & {
hero?: Partial<typeof netzwerkContent.hero> & { image?: unknown }
@@ -81,7 +80,7 @@ const normalizeJuryMember = (member: CmsRouteDoc): JuryMember => {
return {
name: fallbackText(member.name, ''),
role,
bio: fallbackText(member.bio, fallbackText(member.quote, '')),
bio: fallbackText(member.bio, ''),
image: mediaUrl(member.image, juryImageFallback),
imageAlt: fallbackText(member.imageAlt, fallbackText(member.name, 'Jury-Mitglied')),
imageUpload: member.image,
@@ -91,10 +90,18 @@ const normalizeJuryMember = (member: CmsRouteDoc): JuryMember => {
active: member.active !== false,
}
}
const hasJuryText = (value: unknown) => typeof value === 'string' && value.trim().length > 0
const hasCompleteJuryProfile = (member: JuryMember) => hasJuryText(member.bio) && hasJuryText(member.quote)
const sortedJuryMembers = (members: JuryMember[]) =>
members
.map((member, index) => ({ member, index }))
.sort((a, b) => Number(a.member.sortOrder ?? a.index) - Number(b.member.sortOrder ?? b.index))
.sort((a, b) => {
const completenessOrder = Number(hasCompleteJuryProfile(b.member)) - Number(hasCompleteJuryProfile(a.member))
if (completenessOrder !== 0) return completenessOrder
const sortOrder = Number(a.member.sortOrder ?? a.index) - Number(b.member.sortOrder ?? b.index)
return sortOrder !== 0 ? sortOrder : a.index - b.index
})
.map(({ member }) => member)
function Lines({ text, highlight }: { text: string; highlight?: string }) {
return (
@@ -161,7 +168,8 @@ function PersonCard({ imgSrc, imgAlt, name, titleLine1, titleLine2, institution,
const JuryCard: React.FC<{ member: JuryMember }> = ({ member }) => {
const [hovered, setHovered] = useState(false);
const isMobileCard = useIsMobile();
const quote = fallbackText(member.quote, '');
const bio = hasJuryText(member.bio) ? member.bio : '';
const quote = hasJuryText(member.quote) ? member.quote : '';
return (
<div
onMouseEnter={() => setHovered(true)}
@@ -176,7 +184,9 @@ const JuryCard: React.FC<{ member: JuryMember }> = ({ member }) => {
<div style={{ fontFamily: FF, fontSize: isMobileCard ? 16 : 17, fontWeight: 700, color: '#fff', lineHeight: 1.15, marginBottom: 5, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>{member.name}</div>
<div style={{ fontFamily: FF, fontSize: isMobileCard ? 9 : 10, color: GOLD, textTransform: 'uppercase', letterSpacing: isMobileCard ? '0.1em' : '0.14em', fontWeight: 700, lineHeight: 1.35, marginBottom: 10, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>{member.role}</div>
<div style={{ width: hovered ? '100%' : 20, height: 1, background: GOLD, opacity: 0.4, transition: 'width 0.4s ease', marginBottom: 10 }} />
<div style={{ fontFamily: FB, fontSize: isMobileCard ? 14 : 15, color: 'rgba(255,255,255,0.55)', lineHeight: 1.55, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>{member.bio}</div>
{bio ? (
<div style={{ fontFamily: FB, fontSize: isMobileCard ? 14 : 15, color: 'rgba(255,255,255,0.55)', lineHeight: 1.55, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>{bio}</div>
) : null}
{quote ? (
<div style={{ borderLeft: `2px solid ${GOLD}`, color: 'rgba(255,255,255,0.72)', fontFamily: FB, fontSize: isMobileCard ? 13 : 14, fontStyle: 'italic', lineHeight: 1.5, marginTop: 14, paddingLeft: 10, minWidth: 0, overflowWrap: 'anywhere', wordBreak: 'break-word' }}>
{quote}
@@ -192,7 +202,8 @@ const JuryCard: React.FC<{ member: JuryMember }> = ({ member }) => {
const JuryChairCard: React.FC<{ member: JuryMember; badge: string }> = ({ member, badge }) => {
const [hovered, setHovered] = useState(false);
const isMobileCard = useIsMobile();
const quote = fallbackText(member.quote, '');
const bio = hasJuryText(member.bio) ? member.bio : '';
const quote = hasJuryText(member.quote) ? member.quote : '';
return (
<div
onMouseEnter={() => setHovered(true)}
@@ -218,7 +229,9 @@ const JuryChairCard: React.FC<{ member: JuryMember; badge: string }> = ({ member
<div style={{ fontFamily: FF, fontSize: isMobileCard ? 26 : 34, fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.05, marginBottom: 10 }}>{member.name}</div>
<div style={{ fontFamily: FF, fontSize: 13, color: GOLD, textTransform: 'uppercase', letterSpacing: '0.18em', fontWeight: 700, marginBottom: 24 }}>{member.role}</div>
<div style={{ width: 48, height: 2, background: GOLD, opacity: 0.5, marginBottom: 24 }} />
<div style={{ fontFamily: FB, fontSize: isMobileCard ? 18 : 21, color: 'rgba(255,255,255,0.6)', lineHeight: 1.7, maxWidth: 620 }}>{member.bio}</div>
{bio ? (
<div style={{ fontFamily: FB, fontSize: isMobileCard ? 18 : 21, color: 'rgba(255,255,255,0.6)', lineHeight: 1.7, maxWidth: 620 }}>{bio}</div>
) : null}
{quote ? (
<div style={{ borderLeft: `3px solid ${GOLD}`, color: 'rgba(255,255,255,0.78)', fontFamily: FB, fontSize: isMobileCard ? 17 : 19, fontStyle: 'italic', lineHeight: 1.65, marginTop: 24, maxWidth: 620, paddingLeft: 18 }}>
{quote}
@@ -301,7 +314,7 @@ function PartnerModal({ partner, copy, onClose }: { partner: Partner; copy: Part
}}
>
{/* ── Hero ── */}
<div style={{ position: 'relative', height: 220, flexShrink: 0, overflow: 'hidden' }}>
<div style={{ position: 'relative', height: 220, flexShrink: 0, overflow: 'visible' }}>
{/* Background */}
{partner.heroImg
? <Image unoptimized src={partner.heroImg} alt="" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }} />
@@ -481,10 +494,10 @@ const Netzwerk: React.FC = () => {
const partnerModal = { ...netzwerkContent.partners.modal, ...((cms.partners as typeof netzwerkContent.partners | undefined)?.modal || {}) };
const sponsoring = { ...netzwerkContent.sponsoring, ...(cms.sponsoring || {}) };
const patronPeople = fallbackArray<PatronPerson>(patronage.people, netzwerkContent.patronage.people);
const juryCollectionMembers = sortedJuryMembers(cmsJuryMembers.map(normalizeJuryMember).filter((member) => member.active !== false && member.name));
const juryMembers = juryCollectionMembers.length
const juryCollectionMembers = cmsJuryMembers.map(normalizeJuryMember).filter((member) => member.active !== false && member.name);
const juryMembers = sortedJuryMembers(juryCollectionMembers.length
? juryCollectionMembers
: fallbackArray<JuryMember>(jury.members, netzwerkContent.jury.members);
: fallbackArray<JuryMember>(jury.members, netzwerkContent.jury.members));
const expertiseRows = fallbackArray<ExpertiseRow>(expertise.rows, netzwerkContent.expertise.rows);
const partners = sortedPartners(cmsPartners)
.filter((partner) => partner.active !== false)
@@ -651,8 +664,8 @@ const Netzwerk: React.FC = () => {
</div>
) : (
<>
{/* Left – Cream, stats */}
<div style={{ background: CREAM, padding: '72px 64px', display: 'flex', flexDirection: 'column', justifyContent: 'center', position: 'relative', zIndex: 1 }}>
{/* Left – White, stats */}
<div style={{ background: '#fff', padding: '72px 64px', display: 'flex', flexDirection: 'column', justifyContent: 'center', position: 'relative', zIndex: 1 }}>
<div>
<div style={{ fontFamily: FF, fontSize: 'clamp(5rem, 8.5vw, 7.5rem)', fontWeight: 900, color: '#101828', letterSpacing: '-0.04em', lineHeight: 1 }}>{fallbackText(desktopStats[0]?.value, netzwerkContent.juryIntro.desktopStats[0].value)}</div>
<div style={{ fontFamily: FF, fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.15em', color: 'rgba(16,24,40,0.4)', marginTop: 4 }}>{fallbackText(desktopStats[0]?.label, netzwerkContent.juryIntro.desktopStats[0].label)}</div>
@@ -742,7 +755,7 @@ const Netzwerk: React.FC = () => {
{/* ── 6. PARTNER & SPONSOREN ───────────────────────────────────────────── */}
<section id="partner" style={{ background: CREAM, overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<section id="partner" style={{ background: '#fff', overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<MunichSkylineBg />
<div style={{ padding: isMobile ? '48px 24px 32px' : '80px 80px 56px', display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: isMobile ? 12 : 0, justifyContent: 'space-between', alignItems: isMobile ? 'flex-start' : 'flex-end', borderBottom: '1px solid rgba(3,9,58,0.1)' }}>
<div>

View File

@@ -6,10 +6,10 @@ import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute'
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
import Image from '@/spa/components/ui/UnoptimizedImage'
import { pressIndexContent } from '@/spa/pressIndexContent';
import { Link } from '@/spa/router';
const NAVY = '#111D55';
const GOLD = '#EFBF04';
const CREAM = '#E4E2E3';
const FF = '"IBM Plex Sans", sans-serif';
const FB = '"Inter", sans-serif';
@@ -44,6 +44,14 @@ const normalizeStatus = (status: unknown, labels: Record<string, string>, defaul
return labels[value] || value
}
const detailPath = (spaPath: unknown, slug: unknown, prefix: '/presse/events' | '/presse/blog') => {
const path = fallbackText(spaPath, '')
if (path.startsWith(`${prefix}/`)) return path
const pathSlug = fallbackText(slug, '').replace(/^\/+/, '')
return pathSlug ? `${prefix}/${pathSlug}` : prefix
}
const eventFromCms = (
event: CmsRouteDoc,
fallbackImage: string,
@@ -57,7 +65,7 @@ const eventFromCms = (
status: normalizeStatus(event.status, labels, fallbackText(eventsSection.defaultStatusLabel, pressIndexContent.events.defaultStatusLabel)),
img: docImageUrl(event, fallbackImage),
desc: String(event.description || event.meta?.description || ''),
slug: String(event.spaPath || `/presse/events/${event.slug}`),
slug: detailPath(event.spaPath, event.slug, '/presse/events'),
})
const postFromCms = (post: CmsRouteDoc, fallbackImage: string, newsSection: NonNullable<PressIndexCms['news']>): PressNewsItem => ({
@@ -65,7 +73,7 @@ const postFromCms = (post: CmsRouteDoc, fallbackImage: string, newsSection: NonN
excerpt: String(post.excerpt || post.meta?.description || ''),
cat: String(post.cat || fallbackText(newsSection.missingCategoryLabel, pressIndexContent.news.missingCategoryLabel)),
img: docImageUrl(post, fallbackImage, 'heroImage'),
slug: String(post.spaPath || `/presse/blog/${post.slug}`),
slug: detailPath(post.spaPath, post.slug, '/presse/blog'),
})
function Lines({ text }: { text: string }) {
@@ -77,16 +85,20 @@ const fallbackCardImage = (item: PressEventItem | PressNewsItem) => mediaUrl(ite
function NewsCard({ item, idx, isMobile, total }: { item: PressNewsItem; idx: number; isMobile: boolean; total: number }) {
const [hovered, setHovered] = React.useState(false);
return (
<article
<Link
to={item.slug}
aria-label={item.title}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: 'flex',
flexDirection: 'column',
color: 'inherit',
textDecoration: 'none',
borderLeft: isMobile ? 'none' : (idx > 0 ? '1px solid rgba(255,255,255,0.07)' : 'none'),
borderBottom: isMobile && idx < total - 1 ? '1px solid rgba(255,255,255,0.07)' : 'none',
}}
>
<article style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
{/* image */}
<div style={{ position: 'relative', overflow: 'hidden', height: 220 }}>
<Image unoptimized
@@ -163,7 +175,8 @@ function NewsCard({ item, idx, isMobile, total }: { item: PressNewsItem; idx: nu
{item.excerpt}
</p>
</div>
</article>
</article>
</Link>
);
}
@@ -284,7 +297,7 @@ const Press: React.FC = () => {
</section>
{/* ── 2. EVENTS ───────────────────────────────────────────────────── */}
<section id="events" style={{ background: CREAM, overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<section id="events" style={{ background: '#fff', overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<MunichSkylineBg />
{/* Section header */}
<div
@@ -342,13 +355,17 @@ const Press: React.FC = () => {
{/* Event rows */}
{combinedEvents.map((event, idx) => (
<div
key={idx}
<Link
key={event.slug || idx}
to={event.slug}
aria-label={event.title}
style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : '320px 1fr',
borderBottom: '1px solid rgba(3,9,58,0.08)',
background: 'transparent',
color: 'inherit',
textDecoration: 'none',
}}
>
{/* Left – image cell */}
@@ -374,7 +391,7 @@ const Press: React.FC = () => {
<div style={{ width: 24, height: 1, background: 'rgba(239,191,4,0.4)', marginBottom: 14 }} />
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.5)', lineHeight: 1.7, flex: 1 }}>{event.desc}</p>
</div>
</div>
</Link>
))}
</section>
@@ -437,13 +454,13 @@ const Press: React.FC = () => {
{/* 3-column news grid */}
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, 1fr)' }}>
{combinedNews.map((item, idx) => (
<NewsCard key={idx} item={item} idx={idx} isMobile={isMobile} total={combinedNews.length} />
<NewsCard key={item.slug || idx} item={item} idx={idx} isMobile={isMobile} total={combinedNews.length} />
))}
</div>
</section>
{/* ── 4. PRESSE-MATERIAL & AKKREDITIERUNG ─────────────────────────── */}
<section id="downloads" style={{ background: CREAM, overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<section id="downloads" style={{ background: '#fff', overflow: 'hidden', position: 'relative', isolation: 'isolate' }}>
<MunichSkylineBg />
<div style={{ display: 'grid', gridTemplateColumns: '1fr', minHeight: isMobile ? 'auto' : 600 }}>