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:
@@ -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 }}>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user