Add migration scripts and preload participation awards script
- Created migration index file to manage database migrations. - Added script to preload participation awards and related blog posts into the CMS. - Introduced new types and default data for application phases. - Added award articles data structure for the upcoming awards.
This commit is contained in:
@@ -16,6 +16,7 @@ const FB = '"Inter", sans-serif'
|
||||
const NAVY = '#111D55'
|
||||
const GOLD = '#EFBF04'
|
||||
const CREAM = '#E4E2E3'
|
||||
const HIDE_MEMBERSHIP_ABOUT_CTA = true
|
||||
|
||||
type AboutCms = Partial<typeof aboutContent> & {
|
||||
hero?: Partial<typeof aboutContent.hero> & { backgroundImage?: unknown }
|
||||
@@ -36,6 +37,12 @@ const fallbackText = (value: unknown, fallback: string) => (typeof value === 'st
|
||||
|
||||
const fallbackArray = <T,>(value: unknown, fallback: T[]) => (Array.isArray(value) && value.length ? (value as T[]) : fallback)
|
||||
|
||||
const isMembershipCta = (cta?: { label?: string | null; url?: string | null } | null) => {
|
||||
const normalizedLabel = cta?.label?.trim().toLowerCase()
|
||||
|
||||
return cta?.url === '/mitglied-werden' || normalizedLabel === 'mitglied werden' || normalizedLabel === 'vereinsmitglied werden'
|
||||
}
|
||||
|
||||
function Lines({ text }: { text: string }) {
|
||||
return (
|
||||
<>
|
||||
@@ -117,6 +124,7 @@ const About: React.FC = () => {
|
||||
const goals = { ...aboutContent.goals, ...(cms.goals || {}) }
|
||||
const values = { ...aboutContent.values, ...(cms.values || {}) }
|
||||
const cta = { ...aboutContent.cta, ...(cms.cta || {}) }
|
||||
const showSecondaryCta = !HIDE_MEMBERSHIP_ABOUT_CTA || !isMembershipCta(cta.secondaryCta)
|
||||
const highlightFallbackImage = mediaUrl(
|
||||
highlights.fallbackImage,
|
||||
`/images/${aboutContent.highlights.fallbackImageFilename}`,
|
||||
@@ -359,7 +367,7 @@ const About: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: '#EFE5E3', padding: isMobile ? '56px 24px' : '88px 80px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<div style={{ background: '#fff', padding: isMobile ? '56px 24px' : '88px 80px', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(goals.eyebrow, aboutContent.goals.eyebrow)}</span>
|
||||
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 3vw, 2.8rem)', fontWeight: 900, color: NAVY, textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 1.0, margin: '0 0 40px' }}>
|
||||
<Lines text={fallbackText(goals.heading, aboutContent.goals.heading)} />
|
||||
@@ -423,17 +431,19 @@ const About: React.FC = () => {
|
||||
>
|
||||
{fallbackText(cta.primaryCta?.label, aboutContent.cta.primaryCta.label)} <ChevronRight size={16} />
|
||||
</Link>
|
||||
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}>
|
||||
<span style={{ fontFamily: FF, fontSize: 11, color: 'rgba(255,255,255,0.3)', marginRight: 8 }}>{fallbackText(cta.secondaryPrefix, aboutContent.cta.secondaryPrefix)}</span>
|
||||
<Link
|
||||
to={fallbackText(cta.secondaryCta?.url, aboutContent.cta.secondaryCta.url)}
|
||||
style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(239,191,4,0.7)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 6, borderBottom: '1px solid rgba(239,191,4,0.3)', paddingBottom: 1, transition: 'color 0.15s, borderColor 0.15s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#EFBF04'; e.currentTarget.style.borderBottomColor = '#EFBF04' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(239,191,4,0.7)'; e.currentTarget.style.borderBottomColor = 'rgba(239,191,4,0.3)' }}
|
||||
>
|
||||
{fallbackText(cta.secondaryCta?.label, aboutContent.cta.secondaryCta.label)} <ChevronRight size={10} />
|
||||
</Link>
|
||||
</div>
|
||||
{showSecondaryCta && (
|
||||
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}>
|
||||
<span style={{ fontFamily: FF, fontSize: 11, color: 'rgba(255,255,255,0.3)', marginRight: 8 }}>{fallbackText(cta.secondaryPrefix, aboutContent.cta.secondaryPrefix)}</span>
|
||||
<Link
|
||||
to={fallbackText(cta.secondaryCta?.url, aboutContent.cta.secondaryCta.url)}
|
||||
style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(239,191,4,0.7)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 6, borderBottom: '1px solid rgba(239,191,4,0.3)', paddingBottom: 1, transition: 'color 0.15s, borderColor 0.15s' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#EFBF04'; e.currentTarget.style.borderBottomColor = '#EFBF04' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(239,191,4,0.7)'; e.currentTarget.style.borderBottomColor = 'rgba(239,191,4,0.3)' }}
|
||||
>
|
||||
{fallbackText(cta.secondaryCta?.label, aboutContent.cta.secondaryCta.label)} <ChevronRight size={10} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import Image from '@/spa/components/ui/UnoptimizedImage'
|
||||
|
||||
const NAVY = '#111D55';
|
||||
const GOLD = '#EFBF04';
|
||||
const CREAM = '#EFE5E3';
|
||||
const PAGE_BG = '#fff';
|
||||
const INK = '#3A3A3A';
|
||||
const FF = '"IBM Plex Sans", sans-serif';
|
||||
const FB = '"Inter", sans-serif';
|
||||
@@ -81,10 +81,21 @@ export default function BlogDetail() {
|
||||
const metaCopy = detail.metaCopy || blogDetailContent.meta;
|
||||
const sidebarCta = detail.sidebarCta || blogDetailContent.sidebarCta;
|
||||
const relatedCopy = detail.related || blogDetailContent.related;
|
||||
const sidebarCtaUrl = fallbackText(sidebarCta.url, blogDetailContent.sidebarCta.url);
|
||||
const sidebarCtaIsExternal = /^(mailto:|tel:|https?:)/.test(sidebarCtaUrl);
|
||||
const sidebarCtaStyle: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
padding: '13px 20px',
|
||||
background: NAVY, color: '#fff',
|
||||
fontFamily: FF, fontSize: 14, fontWeight: 700,
|
||||
textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
textDecoration: 'none',
|
||||
transition: 'background 0.15s',
|
||||
};
|
||||
|
||||
if (!article) {
|
||||
return (
|
||||
<div style={{ minHeight: '60vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 24, padding: '80px 24px', background: CREAM }}>
|
||||
<div style={{ minHeight: '60vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 24, padding: '80px 24px', background: PAGE_BG }}>
|
||||
<h2 style={{ fontFamily: FF, fontSize: '2rem', fontWeight: 900, color: NAVY }}>{fallbackText(notFound.title, blogDetailContent.notFound.title)}</h2>
|
||||
<Link to={fallbackText(notFound.backUrl, blogDetailContent.notFound.backUrl)} style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: NAVY, textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<ArrowLeft size={14} /> {fallbackText(notFound.backLabel, blogDetailContent.notFound.backLabel)}
|
||||
@@ -177,7 +188,7 @@ export default function BlogDetail() {
|
||||
</section>
|
||||
|
||||
{/* ── ARTICLE BODY ─────────────────────────────────────────────────── */}
|
||||
<section style={{ background: CREAM, padding: isMobile ? '48px 0 0' : '80px 0 0' }}>
|
||||
<section style={{ background: PAGE_BG, padding: isMobile ? '48px 0 0' : '80px 0 0' }}>
|
||||
<div style={{ maxWidth: 1100, margin: '0 auto', padding: isMobile ? '0 24px' : '0 80px', display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 320px', gap: isMobile ? 40 : 80 }}>
|
||||
|
||||
{/* LEFT – article text */}
|
||||
@@ -263,22 +274,25 @@ export default function BlogDetail() {
|
||||
<p style={{ fontFamily: FB, fontSize: 16, color: 'rgba(58,58,58,0.55)', lineHeight: 1.7, marginBottom: 20 }}>
|
||||
{fallbackText(sidebarCta.text, blogDetailContent.sidebarCta.text)}
|
||||
</p>
|
||||
<Link
|
||||
to={fallbackText(sidebarCta.url, blogDetailContent.sidebarCta.url)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
padding: '13px 20px',
|
||||
background: NAVY, color: '#fff',
|
||||
fontFamily: FF, fontSize: 14, fontWeight: 700,
|
||||
textTransform: 'uppercase', letterSpacing: '0.1em',
|
||||
textDecoration: 'none',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = GOLD)}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = NAVY)}
|
||||
>
|
||||
{fallbackText(sidebarCta.label, blogDetailContent.sidebarCta.label)} <ArrowRight size={12} />
|
||||
</Link>
|
||||
{sidebarCtaIsExternal ? (
|
||||
<a
|
||||
href={sidebarCtaUrl}
|
||||
style={sidebarCtaStyle}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = GOLD)}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = NAVY)}
|
||||
>
|
||||
{fallbackText(sidebarCta.label, blogDetailContent.sidebarCta.label)} <ArrowRight size={12} />
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
to={sidebarCtaUrl}
|
||||
style={sidebarCtaStyle}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = GOLD)}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = NAVY)}
|
||||
>
|
||||
{fallbackText(sidebarCta.label, blogDetailContent.sidebarCta.label)} <ArrowRight size={12} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/spa/components/ui/button';
|
||||
import { Play, ChevronRight, X, Star } from 'lucide-react';
|
||||
import { Link } from '@/spa/router';
|
||||
@@ -9,17 +9,86 @@ import TestimonialsSection from '@/spa/components/TestimonialsSection';
|
||||
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
|
||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||
import Image from '@/spa/components/ui/UnoptimizedImage'
|
||||
import { useCmsCollection, useCmsRoute } from '@/spa/cmsRoute'
|
||||
import type { SpaApplicationPhaseData, SpaApplicationPhaseItem } from '@/spa/applicationPhase'
|
||||
import { useApplicationPhase, useCmsCollection, useCmsRoute } from '@/spa/cmsRoute'
|
||||
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'
|
||||
import { homeContent } from '@/spa/homeContent'
|
||||
|
||||
const FF = '"IBM Plex Sans", sans-serif';
|
||||
|
||||
type CmsRecord = Record<string, unknown>;
|
||||
type HomeCms = Record<string, any>;
|
||||
type StatusPhase = HomeCms & {
|
||||
cta?: { label?: string; to?: string; url?: string } | null;
|
||||
membershipCta?: { label?: string; to?: string; url?: string } | null;
|
||||
type CmsLink = { label?: string | null; to?: string | null; url?: string | null };
|
||||
type HomeHeroCms = {
|
||||
backgroundImage?: unknown;
|
||||
badge?: string | null;
|
||||
description?: string | null;
|
||||
headingAccent?: string | null;
|
||||
headingLine1?: string | null;
|
||||
imageAlt?: string | null;
|
||||
partners?: unknown;
|
||||
partnersLabel?: string | null;
|
||||
primaryCta?: CmsLink | null;
|
||||
secondaryCta?: CmsLink | null;
|
||||
videoLabel?: string | null;
|
||||
};
|
||||
type HomeSectionCms = CmsRecord & {
|
||||
awardCaption?: string | null;
|
||||
awardImage?: unknown;
|
||||
awardImageAlt?: string | null;
|
||||
benefits?: unknown;
|
||||
body?: string | null;
|
||||
cardFallbackTitle?: string | null;
|
||||
criteria?: unknown;
|
||||
cta?: CmsLink | null;
|
||||
eyebrow?: string | null;
|
||||
fallbackImage?: unknown;
|
||||
featured?: unknown;
|
||||
footnote?: string | null;
|
||||
footnoteStrong?: string | null;
|
||||
formEyebrow?: string | null;
|
||||
formTitle?: string | null;
|
||||
heading?: string | null;
|
||||
hoverLabel?: string | null;
|
||||
image?: unknown;
|
||||
imageAlt?: string | null;
|
||||
imageLabel?: string | null;
|
||||
items?: unknown;
|
||||
note?: string | null;
|
||||
quote?: string | null;
|
||||
stats?: unknown;
|
||||
};
|
||||
type HomeVideoModalCms = {
|
||||
closeLabel?: string | null;
|
||||
description?: string | null;
|
||||
title?: string | null;
|
||||
video?: unknown;
|
||||
};
|
||||
type HomeTestimonialsCms = {
|
||||
desktopHeading?: string | null;
|
||||
eyebrow?: string | null;
|
||||
heading?: string | null;
|
||||
items?: Array<Record<string, unknown>> | null;
|
||||
nextLabel?: string | null;
|
||||
previousLabel?: string | null;
|
||||
slideLabelPrefix?: string | null;
|
||||
yearPrefix?: string | null;
|
||||
};
|
||||
type HomeCms = {
|
||||
application?: HomeSectionCms;
|
||||
benefits?: HomeSectionCms;
|
||||
form?: Partial<typeof homeContent.form>;
|
||||
hero?: HomeHeroCms;
|
||||
intro?: HomeSectionCms;
|
||||
quickCheck?: HomeSectionCms;
|
||||
testimonials?: HomeTestimonialsCms;
|
||||
videoModal?: HomeVideoModalCms;
|
||||
winners?: HomeSectionCms;
|
||||
};
|
||||
type RenderedStatusPhase = Omit<SpaApplicationPhaseItem, 'accent' | 'bg' | 'cta' | 'membershipCta'> & {
|
||||
accent: string;
|
||||
bg: string;
|
||||
cta?: { label: string; to: string } | null;
|
||||
membershipCta?: { label: string; to: string } | null;
|
||||
};
|
||||
type WinnerCardData = CmsRecord & {
|
||||
id?: string | number;
|
||||
@@ -41,6 +110,13 @@ const fallbackText = (value: unknown, fallback: string) =>
|
||||
const fallbackArray = <T,>(value: unknown, fallback: readonly T[]) =>
|
||||
Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback;
|
||||
|
||||
const HIDE_MEMBERSHIP_HOME_HERO_CTA = true;
|
||||
|
||||
const isMembershipCta = (cta?: { label?: string; url?: string; to?: string } | null) =>
|
||||
cta?.url === '/mitglied-werden' ||
|
||||
cta?.to === '/mitglied-werden' ||
|
||||
cta?.label?.trim().toLowerCase() === 'mitglied werden';
|
||||
|
||||
function Lines({ text }: { text: string }) {
|
||||
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
|
||||
}
|
||||
@@ -108,6 +184,7 @@ function HomeWinnerCard({
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const [showVideo, setShowVideo] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const isMobile = useIsMobile();
|
||||
const home = (useCmsRoute()?.doc?.home || {}) as HomeCms;
|
||||
const hero = home.hero || {};
|
||||
@@ -118,8 +195,14 @@ const Home: React.FC = () => {
|
||||
const application = home.application || {};
|
||||
const form = home.form || {};
|
||||
const videoModal = home.videoModal || {};
|
||||
const applicationPhase = useApplicationPhase();
|
||||
const cmsPreistraeger = useCmsCollection('preistraeger');
|
||||
const selectedWinners = fallbackArray(winnersSection.featured, []);
|
||||
const heroSecondaryCta = {
|
||||
label: fallbackText(hero.secondaryCta?.label, homeContent.hero.secondaryCta.label),
|
||||
url: fallbackText(hero.secondaryCta?.url, homeContent.hero.secondaryCta.url),
|
||||
};
|
||||
const showHeroSecondaryCta = !HIDE_MEMBERSHIP_HOME_HERO_CTA || !isMembershipCta(heroSecondaryCta);
|
||||
const selectedWinnerDocs = selectedWinners
|
||||
.map((selected) => {
|
||||
const selectedRecord = selected as { id?: string | number }
|
||||
@@ -136,6 +219,16 @@ const Home: React.FC = () => {
|
||||
const benefitItems = fallbackArray(benefits.items, homeContent.benefits.items);
|
||||
const applicationBenefits = fallbackArray(application.benefits, homeContent.application.benefits);
|
||||
const winnerFallbackImage = mediaUrl(winnersSection.fallbackImage, `/images/${homeContent.winners.fallbackImageFilename}`);
|
||||
const videoTitle = fallbackText(videoModal.title, homeContent.videoModal.title);
|
||||
const videoSrc = mediaUrl(videoModal.video, '');
|
||||
|
||||
useEffect(() => {
|
||||
if (!showVideo || !videoSrc) return;
|
||||
|
||||
videoRef.current?.play().catch(() => {
|
||||
// Browser autoplay rules can still require the native control click.
|
||||
});
|
||||
}, [showVideo, videoSrc]);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
@@ -177,24 +270,27 @@ const Home: React.FC = () => {
|
||||
{fallbackText(hero.primaryCta?.label, homeContent.hero.primaryCta.label)} <ChevronRight size={16} />
|
||||
</Link>
|
||||
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 16, alignItems: isMobile ? 'stretch' : 'center' }}>
|
||||
<Link
|
||||
to={fallbackText(hero.secondaryCta?.url, homeContent.hero.secondaryCta.url)}
|
||||
style={{ fontFamily:'"IBM Plex Sans", sans-serif', fontSize: 16, fontWeight:700, textTransform:'uppercase', letterSpacing:'0.12em', color:'rgba(255,255,255,0.85)', border:'1.5px solid rgba(255,255,255,0.35)', padding:'14px 32px', textDecoration:'none', display:'inline-flex', alignItems:'center', justifyContent: isMobile ? 'center' : 'flex-start', gap:10, transition:'border-color 0.15s, color 0.15s', background:'transparent', width: isMobile ? '100%' : 'auto' }}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = '#EFBF04'; (e.currentTarget as HTMLElement).style.color = '#EFBF04'; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.35)'; (e.currentTarget as HTMLElement).style.color = 'rgba(255,255,255,0.85)'; }}
|
||||
>
|
||||
{fallbackText(hero.secondaryCta?.label, homeContent.hero.secondaryCta.label)}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setShowVideo(true)}
|
||||
className="flex items-center gap-4 text-white hover:text-accent transition-colors group"
|
||||
style={{ justifyContent: isMobile ? 'center' : 'flex-start' }}
|
||||
>
|
||||
<div className="w-12 h-12 rounded-full border border-white/30 flex items-center justify-center group-hover:border-accent group-hover:bg-accent/10 transition-all">
|
||||
<Play size={20} fill="currentColor" />
|
||||
</div>
|
||||
<span className="uppercase text-xs tracking-widest font-bold">{fallbackText(hero.videoLabel, homeContent.hero.videoLabel)}</span>
|
||||
</button>
|
||||
{showHeroSecondaryCta && (
|
||||
<Link
|
||||
to={heroSecondaryCta.url}
|
||||
style={{ fontFamily:'"IBM Plex Sans", sans-serif', fontSize: 16, fontWeight:700, textTransform:'uppercase', letterSpacing:'0.12em', color:'rgba(255,255,255,0.85)', border:'1.5px solid rgba(255,255,255,0.35)', padding:'14px 32px', textDecoration:'none', display:'inline-flex', alignItems:'center', justifyContent: isMobile ? 'center' : 'flex-start', gap:10, transition:'border-color 0.15s, color 0.15s', background:'transparent', width: isMobile ? '100%' : 'auto' }}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = '#EFBF04'; (e.currentTarget as HTMLElement).style.color = '#EFBF04'; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.35)'; (e.currentTarget as HTMLElement).style.color = 'rgba(255,255,255,0.85)'; }}
|
||||
>
|
||||
{heroSecondaryCta.label}
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowVideo(true)}
|
||||
className="flex items-center gap-4 text-white hover:text-accent transition-colors group"
|
||||
style={{ justifyContent: isMobile ? 'center' : 'flex-start' }}
|
||||
>
|
||||
<div className="w-12 h-12 rounded-full border border-white/30 flex items-center justify-center group-hover:border-accent group-hover:bg-accent/10 transition-all">
|
||||
<Play size={20} fill="currentColor" />
|
||||
</div>
|
||||
<span className="uppercase text-xs tracking-widest font-bold">{fallbackText(hero.videoLabel, homeContent.hero.videoLabel)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -221,7 +317,7 @@ const Home: React.FC = () => {
|
||||
<MunichSkylineBg />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '55% 45%', position: 'relative' }}>
|
||||
{/* Left (desktop) / Bottom (mobile) – text block (light bg, dark text) */}
|
||||
<div style={{ background: '#E4E2E3', padding: isMobile ? '32px 24px 44px' : '80px 72px', display: 'flex', flexDirection: 'column', justifyContent: 'center', order: isMobile ? 2 : 0 }}>
|
||||
<div style={{ background: '#fff', padding: isMobile ? '32px 24px 44px' : '80px 72px', display: 'flex', flexDirection: 'column', justifyContent: 'center', order: isMobile ? 2 : 0 }}>
|
||||
{/* Eyebrow + heading – desktop only (on mobile these are overlaid on the image) */}
|
||||
{!isMobile && (
|
||||
<>
|
||||
@@ -272,7 +368,7 @@ const Home: React.FC = () => {
|
||||
{/* Overlay: mobile = navy shadow bottom-left (for the heading); desktop = leftward fade into text bg */}
|
||||
<div style={{ position: 'absolute', inset: 0, background: isMobile
|
||||
? 'linear-gradient(to top, rgba(3,9,58,0.92) 0%, rgba(3,9,58,0.55) 34%, rgba(3,9,58,0.12) 68%, transparent 100%), linear-gradient(to right, rgba(3,9,58,0.55) 0%, transparent 55%)'
|
||||
: 'linear-gradient(to left, transparent 40%, #E4E2E3 100%)' }} />
|
||||
: 'linear-gradient(to left, transparent 40%, #fff 100%)' }} />
|
||||
{/* Heading overlay – mobile only */}
|
||||
{isMobile && (
|
||||
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 24px 26px', zIndex: 2 }}>
|
||||
@@ -366,7 +462,7 @@ const Home: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* Status Phase Slider */}
|
||||
<StatusSlider data={home.status} />
|
||||
<StatusSlider data={applicationPhase} />
|
||||
|
||||
{/* Winners Grid – neue Preisträger-Übersicht */}
|
||||
<section style={{ background: '#111D55' }}>
|
||||
@@ -419,8 +515,8 @@ const Home: React.FC = () => {
|
||||
<MunichSkylineBg />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '55% 45%', minHeight: isMobile ? 'auto' : 560 }}>
|
||||
|
||||
{/* Left – text on cream */}
|
||||
<div style={{ background: '#E4E2E3', padding: isMobile ? '32px 24px 44px' : '80px 72px', display: 'flex', flexDirection: 'column', justifyContent: 'center', order: isMobile ? 2 : 0 }}>
|
||||
{/* Left – text on white */}
|
||||
<div style={{ background: '#fff', padding: isMobile ? '32px 24px 44px' : '80px 72px', display: 'flex', flexDirection: 'column', justifyContent: 'center', order: isMobile ? 2 : 0 }}>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.3em', fontWeight: 700, display: 'block', marginBottom: 16 }}>{fallbackText(benefits.eyebrow, homeContent.benefits.eyebrow)}</span>
|
||||
@@ -436,7 +532,7 @@ const Home: React.FC = () => {
|
||||
<div
|
||||
key={i}
|
||||
style={{ display: 'grid', gridTemplateColumns: '48px 1fr', gap: '0 20px', padding: '24px 0', borderBottom: '1px solid #D0D5DD', cursor: 'default', transition: 'background 0.15s' }}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = '#EAE7E6')}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(17,29,85,0.04)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<span style={{ fontFamily: FF, fontSize: 11, fontWeight: 700, color: '#EFBF04', letterSpacing: '0.1em', paddingTop: 2 }}>{item.num}</span>
|
||||
@@ -458,7 +554,7 @@ const Home: React.FC = () => {
|
||||
/>
|
||||
<div style={{ position: 'absolute', inset: 0, background: isMobile
|
||||
? 'linear-gradient(to top, rgba(3,9,58,0.92) 0%, rgba(3,9,58,0.5) 36%, rgba(3,9,58,0.1) 70%, transparent 100%), linear-gradient(to right, rgba(3,9,58,0.5) 0%, transparent 55%)'
|
||||
: 'linear-gradient(to right, #E4E2E3 0%, transparent 30%), linear-gradient(to top, rgba(3,9,58,0.82) 0%, transparent 50%)' }} />
|
||||
: 'linear-gradient(to right, #fff 0%, transparent 30%), linear-gradient(to top, rgba(3,9,58,0.82) 0%, transparent 50%)' }} />
|
||||
{/* Heading overlay – mobile only (bottom-left) */}
|
||||
{isMobile && (
|
||||
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 24px 26px', zIndex: 2 }}>
|
||||
@@ -480,9 +576,9 @@ const Home: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* CTA Section – with embedded form */}
|
||||
<section style={{ background: '#111D55', position: 'relative', overflow: 'hidden', height: isMobile ? 'auto' : 'calc(100vh - 60px)', display: 'flex', flexDirection: 'column' }} id="bewerben">
|
||||
<section style={{ background: '#24366A', position: 'relative', overflow: 'hidden', height: isMobile ? 'auto' : 'calc(100vh - 60px)', display: 'flex', flexDirection: 'column' }} id="bewerben">
|
||||
{/* Subtle radial glow behind left copy */}
|
||||
<div style={{ position: 'absolute', left: -120, top: '50%', transform: 'translateY(-50%)', width: 600, height: 600, borderRadius: '50%', background: 'radial-gradient(circle, rgba(5,26,135,0.35) 0%, transparent 70%)', pointerEvents: 'none' }} />
|
||||
<div style={{ position: 'absolute', left: -120, top: '50%', transform: 'translateY(-50%)', width: 600, height: 600, borderRadius: '50%', background: 'radial-gradient(circle, rgba(255,255,255,0.08) 0%, transparent 70%)', pointerEvents: 'none' }} />
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '4fr 1px 8fr', position: 'relative', flex: isMobile ? 'none' : 1, minHeight: 0, overflow: 'hidden' }}>
|
||||
|
||||
@@ -570,18 +666,32 @@ const Home: React.FC = () => {
|
||||
{showVideo && (
|
||||
<div className="fixed inset-0 z-[200] bg-black flex items-center justify-center p-6 bg-opacity-95">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowVideo(false)}
|
||||
className="absolute top-8 right-8 text-white/60 hover:text-white transition-colors"
|
||||
aria-label={fallbackText(videoModal.closeLabel, homeContent.videoModal.closeLabel)}
|
||||
>
|
||||
<X size={40} />
|
||||
</button>
|
||||
<div className="w-full max-w-6xl aspect-video bg-white/5 rounded-3xl overflow-hidden flex items-center justify-center relative">
|
||||
<div className="text-center">
|
||||
<Play size={80} className="text-accent mx-auto mb-6 opacity-40" />
|
||||
<p className="text-white/40 uppercase tracking-widest text-sm">{fallbackText(videoModal.title, homeContent.videoModal.title)}</p>
|
||||
<p className="text-white/60 mt-4 text-xs italic font-body">{fallbackText(videoModal.description, homeContent.videoModal.description)}</p>
|
||||
<Button onClick={() => setShowVideo(false)} className="mt-8" variant="outline">{fallbackText(videoModal.closeLabel, homeContent.videoModal.closeLabel)}</Button>
|
||||
</div>
|
||||
{videoSrc ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoSrc}
|
||||
className="h-full w-full bg-black object-contain"
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
aria-label={videoTitle}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<Play size={80} className="text-accent mx-auto mb-6 opacity-40" />
|
||||
<p className="text-white/40 uppercase tracking-widest text-sm">{videoTitle}</p>
|
||||
<p className="text-white/60 mt-4 text-xs italic font-body">{fallbackText(videoModal.description, homeContent.videoModal.description)}</p>
|
||||
<Button type="button" onClick={() => setShowVideo(false)} className="mt-8" variant="outline">{fallbackText(videoModal.closeLabel, homeContent.videoModal.closeLabel)}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -591,26 +701,28 @@ const Home: React.FC = () => {
|
||||
|
||||
// ─── StatusSlider ─────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusSlider({ data }: { data?: HomeCms }) {
|
||||
function StatusSlider({ data }: { data?: SpaApplicationPhaseData }) {
|
||||
const isMobile = useIsMobile();
|
||||
const phases = fallbackArray(data?.phases, homeContent.status.phases).map((phase, i) => {
|
||||
const phaseRecord = phase as StatusPhase
|
||||
const fallbackPhase = homeContent.status.phases[i]
|
||||
const fallbackCta = fallbackPhase?.cta?.label
|
||||
? { label: fallbackPhase.cta.label, to: fallbackPhase.cta.url }
|
||||
if (!data?.phases?.length) return null;
|
||||
|
||||
const phases = data.phases.map((phase): RenderedStatusPhase => {
|
||||
const cta = phase.cta?.label
|
||||
? { label: phase.cta.label, to: phase.cta.to || phase.cta.url || '/' }
|
||||
: null
|
||||
const cta = phaseRecord?.cta?.label
|
||||
? { label: phaseRecord.cta.label, to: phaseRecord.cta.to || phaseRecord.cta.url || fallbackPhase?.cta?.url || '/' }
|
||||
: fallbackCta
|
||||
const membershipCta = phaseRecord?.membershipCta?.label
|
||||
? { label: phaseRecord.membershipCta.label, to: phaseRecord.membershipCta.to || phaseRecord.membershipCta.url || homeContent.hero.secondaryCta.url }
|
||||
const membershipCta = phase.membershipCta?.label
|
||||
? { label: phase.membershipCta.label, to: phase.membershipCta.to || phase.membershipCta.url || '/mitglied-werden' }
|
||||
: undefined
|
||||
|
||||
return { ...fallbackPhase, ...phaseRecord, cta, membershipCta }
|
||||
return {
|
||||
...phase,
|
||||
accent: phase.accent || '#EFBF04',
|
||||
bg: phase.bg || '#020A1E',
|
||||
cta,
|
||||
membershipCta,
|
||||
}
|
||||
});
|
||||
const fallbackActivePhase = Number(homeContent.status.activePhase);
|
||||
const configuredActive = data?.activePhase !== undefined && data?.activePhase !== null ? Number(data.activePhase) : fallbackActivePhase;
|
||||
const active = Math.max(0, Math.min(phases.length - 1, Number.isFinite(configuredActive) ? configuredActive : fallbackActivePhase));
|
||||
const configuredActive = Number(data.activePhase);
|
||||
const active = Math.max(0, Math.min(phases.length - 1, Number.isFinite(configuredActive) ? configuredActive : 0));
|
||||
const phase = phases[active];
|
||||
|
||||
return (
|
||||
@@ -625,9 +737,9 @@ function StatusSlider({ data }: { data?: HomeCms }) {
|
||||
<StatusSlideCard
|
||||
phase={phase}
|
||||
isMobile={isMobile}
|
||||
noActionLabel={fallbackText(data?.noActionLabel, homeContent.status.noActionLabel)}
|
||||
successApplicationsLabel={fallbackText(data?.successApplicationsLabel, homeContent.status.successApplicationsLabel)}
|
||||
successWinnersLabel={fallbackText(data?.successWinnersLabel, homeContent.status.successWinnersLabel)}
|
||||
noActionLabel={data.noActionLabel}
|
||||
successApplicationsLabel={data.successApplicationsLabel}
|
||||
successWinnersLabel={data.successWinnersLabel}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
@@ -642,7 +754,7 @@ function StatusSlideCard({
|
||||
successApplicationsLabel,
|
||||
successWinnersLabel,
|
||||
}: {
|
||||
phase: StatusPhase & HomeCms;
|
||||
phase: RenderedStatusPhase;
|
||||
isMobile: boolean;
|
||||
noActionLabel: string;
|
||||
successApplicationsLabel: string;
|
||||
@@ -722,10 +834,10 @@ function StatusSlideCard({
|
||||
) : (
|
||||
/* Success stats */
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontFamily: FF, fontSize: 68, fontWeight: 900, color: phase.accent, lineHeight: 1, opacity: 0.9 }}>{fallbackText(phase.successApplications, homeContent.status.phases[2]?.successApplications || '')}</div>
|
||||
<div style={{ fontFamily: FF, fontSize: 68, fontWeight: 900, color: phase.accent, lineHeight: 1, opacity: 0.9 }}>{fallbackText(phase.successApplications, '')}</div>
|
||||
<div style={{ fontFamily: FF, fontSize: 8, fontWeight: 700, letterSpacing: '0.35em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.50)', marginTop: 4, marginBottom: 20 }}>{successApplicationsLabel}</div>
|
||||
<div style={{ width: 1, height: 28, background: 'rgba(255,255,255,0.08)', margin: '0 auto 20px' }} />
|
||||
<div style={{ fontFamily: FF, fontSize: 44, fontWeight: 900, color: '#fff', lineHeight: 1 }}>{fallbackText(phase.successWinners, homeContent.status.phases[2]?.successWinners || '')}</div>
|
||||
<div style={{ fontFamily: FF, fontSize: 44, fontWeight: 900, color: '#fff', lineHeight: 1 }}>{fallbackText(phase.successWinners, '')}</div>
|
||||
<div style={{ fontFamily: FF, fontSize: 8, fontWeight: 700, letterSpacing: '0.35em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.50)', marginTop: 4 }}>{successWinnersLabel}</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -735,7 +847,7 @@ function StatusSlideCard({
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ width: 1, height: 20, background: 'rgba(255,255,255,0.08)', margin: '0 auto 20px' }} />
|
||||
<Link
|
||||
to={fallbackText(phase.membershipCta?.to || phase.membershipCta?.url, homeContent.hero.secondaryCta.url)}
|
||||
to={phase.membershipCta?.to || '/mitglied-werden'}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
style={{
|
||||
fontFamily: FF,
|
||||
@@ -760,7 +872,7 @@ function StatusSlideCard({
|
||||
(e.currentTarget as HTMLElement).style.borderColor = `${phase.accent}55`;
|
||||
}}
|
||||
>
|
||||
{fallbackText(phase.membershipCta?.label, homeContent.hero.secondaryCta.label)} <ChevronRight size={13} />
|
||||
{phase.membershipCta?.label} <ChevronRight size={13} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Link } from '@/spa/router';
|
||||
import { ChevronRight, X, ArrowRight, Globe, Linkedin, Building2, MapPin, ExternalLink } from 'lucide-react';
|
||||
import { ChevronRight, X, ArrowRight, Globe, Linkedin, Building2, MapPin, ExternalLink, Play } from 'lucide-react';
|
||||
import SponsoringForm from '@/spa/components/forms/SponsoringForm';
|
||||
import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg';
|
||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||
import { useCmsRoute } from '@/spa/cmsRoute';
|
||||
import { useCmsCollection, useCmsRoute } from '@/spa/cmsRoute';
|
||||
import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
|
||||
import { homeContent } from '@/spa/homeContent';
|
||||
import { netzwerkContent } from '@/spa/netzwerkContent';
|
||||
import Image from '@/spa/components/ui/UnoptimizedImage'
|
||||
|
||||
@@ -14,6 +15,7 @@ const FB = '"Inter", sans-serif';
|
||||
const NAVY = '#111D55';
|
||||
const GOLD = '#EFBF04';
|
||||
const CREAM = '#E4E2E3';
|
||||
const HIDE_MEMBERSHIP_NETWORK_CTA = true;
|
||||
|
||||
const ROLE_COLOR: Record<string, string> = {
|
||||
'Hauptsponsor': '#EFBF04',
|
||||
@@ -28,6 +30,18 @@ type NetzwerkCms = Partial<typeof netzwerkContent> & {
|
||||
sponsoring?: Partial<typeof netzwerkContent.sponsoring> & { image?: unknown }
|
||||
}
|
||||
|
||||
type HomeVideoCms = {
|
||||
hero?: {
|
||||
videoLabel?: string | null
|
||||
}
|
||||
videoModal?: {
|
||||
video?: unknown
|
||||
title?: string | null
|
||||
description?: string | null
|
||||
closeLabel?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
type PatronPerson = (typeof netzwerkContent.patronage.people)[number] & { imageUpload?: unknown }
|
||||
type JuryMember = (typeof netzwerkContent.jury.members)[number] & { imageUpload?: unknown }
|
||||
type Partner = (typeof netzwerkContent.partners.items)[number] & { stableId?: string; tier: 1 | 2 | 3; heroImg?: string; linkedin?: string; logoSubtext?: string }
|
||||
@@ -40,6 +54,11 @@ type PartnerModalCopy = typeof netzwerkContent.partners.modal
|
||||
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 ? value as T[] : fallback;
|
||||
const managedImage = (upload: unknown, fallback: unknown) => mediaUrl(upload, fallbackText(fallback, ''))
|
||||
const isMembershipCta = (cta?: { label?: string | null; url?: string | null } | null) => {
|
||||
const normalizedLabel = cta?.label?.trim().toLowerCase();
|
||||
|
||||
return cta?.url === '/mitglied-werden' || normalizedLabel === 'mitglied werden' || normalizedLabel === 'vereinsmitglied werden';
|
||||
}
|
||||
|
||||
function Lines({ text, highlight }: { text: string; highlight?: string }) {
|
||||
return (
|
||||
@@ -443,7 +462,12 @@ function PartnerModal({ partner, copy, onClose }: { partner: Partner; copy: Part
|
||||
const Netzwerk: React.FC = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const [selectedPartner, setSelectedPartner] = useState<Partner | null>(null);
|
||||
const [showGreetingVideo, setShowGreetingVideo] = useState(false);
|
||||
const greetingVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const cms = (useCmsRoute()?.doc?.netzwerk || {}) as NetzwerkCms;
|
||||
const homePage = useCmsCollection('pages').find((page) => page.spaPath === '/' || page.slug === 'startseite' || page.slug === 'home');
|
||||
const home = (homePage?.home || {}) as HomeVideoCms;
|
||||
const homeVideoModal = home.videoModal || {};
|
||||
const hero = { ...netzwerkContent.hero, ...(cms.hero || {}) };
|
||||
const patronage = { ...netzwerkContent.patronage, ...(cms.patronage || {}) };
|
||||
const juryIntro = { ...netzwerkContent.juryIntro, ...(cms.juryIntro || {}) };
|
||||
@@ -465,6 +489,21 @@ const Netzwerk: React.FC = () => {
|
||||
const mobileStats = fallbackArray<StatItem>(juryIntro.stats, netzwerkContent.juryIntro.stats);
|
||||
const desktopStats = fallbackArray<StatItem>(juryIntro.desktopStats, netzwerkContent.juryIntro.desktopStats);
|
||||
const sponsorBenefits = fallbackArray<BenefitItem>(sponsoring.benefits, netzwerkContent.sponsoring.benefits);
|
||||
const greetingVideoSrc = mediaUrl(homeVideoModal.video, '');
|
||||
const greetingVideoTitle = fallbackText(homeVideoModal.title, homeContent.videoModal.title);
|
||||
const greetingVideoLabel = fallbackText(home.hero?.videoLabel, homeContent.hero.videoLabel);
|
||||
const greetingVideoCloseLabel = fallbackText(homeVideoModal.closeLabel, homeContent.videoModal.closeLabel);
|
||||
const showMembershipFootnote =
|
||||
!HIDE_MEMBERSHIP_NETWORK_CTA ||
|
||||
!isMembershipCta({ label: sponsoring.footnoteLabel, url: sponsoring.footnoteUrl });
|
||||
|
||||
useEffect(() => {
|
||||
if (!showGreetingVideo || !greetingVideoSrc) return;
|
||||
|
||||
greetingVideoRef.current?.play().catch(() => {
|
||||
// Browser autoplay rules can still require the native control click.
|
||||
});
|
||||
}, [showGreetingVideo, greetingVideoSrc]);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
@@ -532,6 +571,47 @@ const Netzwerk: React.FC = () => {
|
||||
<div style={{ fontFamily: FF, fontSize: 11, color: 'rgba(255,255,255,0.35)', marginTop: 20, paddingLeft: 24 }}>
|
||||
{fallbackText(patronage.greetingAttribution, netzwerkContent.patronage.greetingAttribution)}
|
||||
</div>
|
||||
{greetingVideoSrc && (
|
||||
<div style={{ paddingLeft: 24, marginTop: 28 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGreetingVideo(true)}
|
||||
aria-label={greetingVideoLabel}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
border: `1px solid ${GOLD}73`,
|
||||
background: 'rgba(239,191,4,0.1)',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontFamily: FF,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.12em',
|
||||
lineHeight: 1,
|
||||
padding: '13px 18px',
|
||||
textTransform: 'uppercase',
|
||||
transition: 'background 0.15s, border-color 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.background = GOLD;
|
||||
el.style.borderColor = GOLD;
|
||||
el.style.color = NAVY;
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.background = 'rgba(239,191,4,0.1)';
|
||||
el.style.borderColor = `${GOLD}73`;
|
||||
el.style.color = '#fff';
|
||||
}}
|
||||
>
|
||||
<Play size={15} fill="currentColor" />
|
||||
{greetingVideoLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -757,37 +837,39 @@ const Netzwerk: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
{/* Footnote */}
|
||||
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '16px 20px 20px' : '14px 56px 18px', display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'center', borderTop: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}>
|
||||
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 15, color: 'rgba(255,255,255,0.45)', marginRight: 10 }}>{fallbackText(sponsoring.footnotePrefix, netzwerkContent.sponsoring.footnotePrefix)}</span>
|
||||
<Link
|
||||
to={fallbackText(sponsoring.footnoteUrl, netzwerkContent.sponsoring.footnoteUrl)}
|
||||
style={{
|
||||
fontFamily: '"IBM Plex Sans", sans-serif',
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'rgba(239,191,4,0.7)',
|
||||
textDecoration: 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderBottom: '1px solid rgba(239,191,4,0.3)',
|
||||
paddingBottom: 1,
|
||||
transition: 'color 0.15s, borderColor 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
(e.currentTarget as HTMLElement).style.color = '#EFBF04';
|
||||
(e.currentTarget as HTMLElement).style.borderBottomColor = '#EFBF04';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
(e.currentTarget as HTMLElement).style.color = 'rgba(239,191,4,0.7)';
|
||||
(e.currentTarget as HTMLElement).style.borderBottomColor = 'rgba(239,191,4,0.3)';
|
||||
}}
|
||||
>
|
||||
{fallbackText(sponsoring.footnoteLabel, netzwerkContent.sponsoring.footnoteLabel)} <ChevronRight size={11} />
|
||||
</Link>
|
||||
</div>
|
||||
{showMembershipFootnote && (
|
||||
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '16px 20px 20px' : '14px 56px 18px', display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'center', borderTop: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}>
|
||||
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 15, color: 'rgba(255,255,255,0.45)', marginRight: 10 }}>{fallbackText(sponsoring.footnotePrefix, netzwerkContent.sponsoring.footnotePrefix)}</span>
|
||||
<Link
|
||||
to={fallbackText(sponsoring.footnoteUrl, netzwerkContent.sponsoring.footnoteUrl)}
|
||||
style={{
|
||||
fontFamily: '"IBM Plex Sans", sans-serif',
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'rgba(239,191,4,0.7)',
|
||||
textDecoration: 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderBottom: '1px solid rgba(239,191,4,0.3)',
|
||||
paddingBottom: 1,
|
||||
transition: 'color 0.15s, borderColor 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
(e.currentTarget as HTMLElement).style.color = '#EFBF04';
|
||||
(e.currentTarget as HTMLElement).style.borderBottomColor = '#EFBF04';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
(e.currentTarget as HTMLElement).style.color = 'rgba(239,191,4,0.7)';
|
||||
(e.currentTarget as HTMLElement).style.borderBottomColor = 'rgba(239,191,4,0.3)';
|
||||
}}
|
||||
>
|
||||
{fallbackText(sponsoring.footnoteLabel, netzwerkContent.sponsoring.footnoteLabel)} <ChevronRight size={11} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative', zIndex: 1, height: 2, background: `linear-gradient(to right, ${GOLD}, rgba(239,191,4,0.3), transparent)`, flexShrink: 0 }} />
|
||||
</section>
|
||||
|
||||
@@ -797,6 +879,68 @@ const Netzwerk: React.FC = () => {
|
||||
<PartnerModal partner={selectedPartner} copy={partnerModal} onClose={() => setSelectedPartner(null)} />
|
||||
)}
|
||||
|
||||
{/* ── 9. GRUSSWORT VIDEO MODAL ────────────────────────────────────────── */}
|
||||
{showGreetingVideo && greetingVideoSrc && (
|
||||
<div
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setShowGreetingVideo(false); }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 520,
|
||||
background: 'rgba(0,0,0,0.95)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: isMobile ? 16 : 24,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGreetingVideo(false)}
|
||||
aria-label={greetingVideoCloseLabel}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: isMobile ? 18 : 32,
|
||||
right: isMobile ? 18 : 32,
|
||||
width: isMobile ? 42 : 52,
|
||||
height: isMobile ? 42 : 52,
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
color: 'rgba(255,255,255,0.75)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.background = 'rgba(255,255,255,0.18)';
|
||||
el.style.color = '#fff';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
el.style.background = 'rgba(255,255,255,0.1)';
|
||||
el.style.color = 'rgba(255,255,255,0.75)';
|
||||
}}
|
||||
>
|
||||
<X size={isMobile ? 24 : 30} />
|
||||
</button>
|
||||
<div style={{ width: '100%', maxWidth: 1120, aspectRatio: '16/9', background: '#000' }}>
|
||||
<video
|
||||
ref={greetingVideoRef}
|
||||
src={greetingVideoSrc}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
aria-label={greetingVideoTitle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { MapPin, Users, TrendingUp, Building2, ArrowRight, CheckCircle2, UserCheck, Send, Star } from 'lucide-react';
|
||||
import { MapPin, Users, TrendingUp, Building2, ArrowRight, CheckCircle2, UserCheck, Send, Star, Mail } from 'lucide-react';
|
||||
import { Link } from '@/spa/router';
|
||||
import BewerbungsForm from '@/spa/components/forms/BewerbungsForm';
|
||||
import WegZurAuszeichnungSection from '@/spa/components/WegZurAuszeichnungSection';
|
||||
@@ -18,10 +18,13 @@ const CREAM = '#E4E2E3';
|
||||
|
||||
type ParticipationCms = Partial<typeof participationContent> & {
|
||||
hero?: Partial<typeof participationContent.hero> & { backgroundImage?: unknown };
|
||||
evaluation?: Partial<typeof participationContent.evaluation> & { image?: unknown };
|
||||
awardsGrid?: Partial<typeof participationContent.awardsGrid> & { cards?: AwardCardCms[] };
|
||||
applicationForm?: Partial<typeof participationContent.applicationForm> & { image?: unknown };
|
||||
};
|
||||
|
||||
type AwardCard = (typeof participationContent.awardsGrid.cards)[number];
|
||||
type AwardCardCms = Partial<AwardCard> & { image?: unknown };
|
||||
|
||||
const ICONS = {
|
||||
building2: Building2,
|
||||
mapPin: MapPin,
|
||||
@@ -38,6 +41,11 @@ const fallbackText = (value: unknown, fallback: string) =>
|
||||
const fallbackArray = <T,>(value: unknown, fallback: T[]) =>
|
||||
Array.isArray(value) && value.length > 0 ? (value as T[]) : fallback;
|
||||
|
||||
const mergeAwardCards = (value: unknown, fallback: AwardCard[]): (AwardCard & { image?: unknown })[] => {
|
||||
const cards = Array.isArray(value) && value.length > 0 ? (value as AwardCardCms[]) : fallback;
|
||||
return cards.map((card, index) => ({ ...(fallback[index] || fallback[0]), ...card }));
|
||||
};
|
||||
|
||||
function Lines({ text }: { text: string }) {
|
||||
return <>{text.split('\n').map((line, i) => <React.Fragment key={`${line}-${i}`}>{i > 0 && <br />}{line}</React.Fragment>)}</>;
|
||||
}
|
||||
@@ -49,14 +57,13 @@ const Participation: React.FC = () => {
|
||||
const hero = { ...participationContent.hero, ...(cms.hero || {}) };
|
||||
const process = { ...participationContent.process, ...(cms.process || {}) };
|
||||
const eligibility = { ...participationContent.eligibility, ...(cms.eligibility || {}) };
|
||||
const evaluation = { ...participationContent.evaluation, ...(cms.evaluation || {}) };
|
||||
const applicationWays = { ...participationContent.applicationWays, ...(cms.applicationWays || {}) };
|
||||
const datesBanner = { ...participationContent.datesBanner, ...(cms.datesBanner || {}) };
|
||||
const awardsGrid = { ...participationContent.awardsGrid, ...(cms.awardsGrid || {}) };
|
||||
const applicationForm = { ...participationContent.applicationForm, ...(cms.applicationForm || {}) };
|
||||
const form = { ...participationContent.form, ...(cms.form || {}) };
|
||||
const eligibilityCriteria = fallbackArray(eligibility.criteria, participationContent.eligibility.criteria);
|
||||
const eligibilityNotes = fallbackArray(eligibility.notes, participationContent.eligibility.notes);
|
||||
const evaluationCriteria = fallbackArray(evaluation.criteria, participationContent.evaluation.criteria);
|
||||
const mobileDates = fallbackArray(datesBanner.mobileItems, participationContent.datesBanner.mobileItems);
|
||||
const desktopDates = fallbackArray(datesBanner.desktopItems, participationContent.datesBanner.desktopItems);
|
||||
const applicationFacts = fallbackArray(applicationForm.facts, participationContent.applicationForm.facts);
|
||||
@@ -174,75 +181,6 @@ const Participation: React.FC = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── CRITERIA / BEWERTUNG ─────────────────────────────────────────────── */}
|
||||
<section id="kriterien" style={{ background: NAVY, overflow: 'hidden', position: 'relative' }}>
|
||||
|
||||
{/* TOP: editorial split – image left (45%) / header right (55%) */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '45% 55%', minHeight: isMobile ? 'auto' : 480 }}>
|
||||
|
||||
{/* Left – full-bleed image with gradient blend into navy on the right (+ heading on mobile) */}
|
||||
<div style={{ position: 'relative', overflow: 'hidden', minHeight: isMobile ? 280 : undefined }}>
|
||||
<Image unoptimized
|
||||
src={mediaUrl(evaluation.image, `/images/${participationContent.evaluation.imageFilename}`)}
|
||||
alt={mediaAlt(evaluation.image, fallbackText(evaluation.imageAlt, participationContent.evaluation.imageAlt))}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center', display: 'block' }}
|
||||
/>
|
||||
{/* Dark overlay for depth */}
|
||||
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom, rgba(3,9,58,0.35) 0%, rgba(3,9,58,0.15) 50%, rgba(3,9,58,0.35) 100%)' }} />
|
||||
{/* Right-side blend into navy (desktop) */}
|
||||
{!isMobile && <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to right, transparent 50%, rgba(3,9,58,0.7) 80%, #111D55 100%)' }} />}
|
||||
{/* Mobile heading shadow (bottom-left) */}
|
||||
{isMobile && <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(3,9,58,0.94) 0%, rgba(3,9,58,0.55) 38%, rgba(3,9,58,0.12) 72%, transparent 100%), linear-gradient(to right, rgba(3,9,58,0.5) 0%, transparent 55%)' }} />}
|
||||
{/* Bottom blend for continuity with grid below */}
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 120, background: 'linear-gradient(to bottom, transparent, #111D55)' }} />
|
||||
{/* Heading overlay – mobile only */}
|
||||
{isMobile && (
|
||||
<div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, padding: '0 24px 24px', zIndex: 2 }}>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: '#EFBF04', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 12 }}>{fallbackText(evaluation.eyebrow, participationContent.evaluation.eyebrow)}</span>
|
||||
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 9vw, 2.8rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 0.97, margin: 0, textShadow: '0 2px 18px rgba(3,9,58,0.6)' }}>
|
||||
<Lines text={fallbackText(evaluation.heading, participationContent.evaluation.heading)} />
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right – overline + headline + intro */}
|
||||
<div style={{ padding: isMobile ? '32px 24px 40px' : '80px 80px 72px 64px', display: 'flex', flexDirection: 'column', justifyContent: 'center', borderBottom: '1px solid rgba(255,255,255,0.07)' }}>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: '#EFBF04', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 20 }}>{fallbackText(evaluation.eyebrow, participationContent.evaluation.eyebrow)}</span>
|
||||
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2.4rem, 4vw, 3.6rem)', fontWeight: 900, color: '#fff', textTransform: 'uppercase', letterSpacing: '-0.03em', lineHeight: 0.97, margin: '0 0 28px' }}>
|
||||
<Lines text={fallbackText(evaluation.heading, participationContent.evaluation.heading)} />
|
||||
</h2>
|
||||
</>
|
||||
)}
|
||||
<div style={{ width: 40, height: 2, background: GOLD, marginBottom: 28 }} />
|
||||
<p style={{ fontFamily: FB, fontSize: 19, color: 'rgba(255,255,255,0.68)', lineHeight: 1.8, margin: 0, maxWidth: 420 }}>
|
||||
<Lines text={fallbackText(evaluation.body, participationContent.evaluation.body)} />
|
||||
</p>
|
||||
<a
|
||||
href={fallbackText(evaluation.link?.url, participationContent.evaluation.link.url)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontFamily: FF, fontSize: 14, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: GOLD, textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 8, marginTop: 28, borderBottom: `1px solid rgba(239,191,4,0.4)`, paddingBottom: 2, alignSelf: 'flex-start' }}
|
||||
onMouseEnter={e => ((e.currentTarget as HTMLElement).style.opacity = '0.8')}
|
||||
onMouseLeave={e => ((e.currentTarget as HTMLElement).style.opacity = '1')}
|
||||
>
|
||||
{fallbackText(evaluation.link?.label, participationContent.evaluation.link.label)} <ArrowRight size={13} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOTTOM: full-width 4-column criteria grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(4, 1fr)', borderTop: '1px solid rgba(255,255,255,0.07)', position: 'relative' }}>
|
||||
{evaluationCriteria.map((crit, i) => (
|
||||
<CriteriaCell key={i} crit={crit} idx={i} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ height: 2, background: `linear-gradient(to right, ${GOLD}, rgba(239,191,4,0.2), transparent)` }} />
|
||||
</section>
|
||||
|
||||
{/* ── BEWERBUNGSWEGE ───────────────────────────────────────────────────── */}
|
||||
<BewerbungswegeSection content={applicationWays} />
|
||||
|
||||
@@ -282,6 +220,9 @@ const Participation: React.FC = () => {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── AWARDS GRID ─────────────────────────────────────────────────────── */}
|
||||
<AwardsGridSection content={awardsGrid} />
|
||||
|
||||
{/* ── FORM SECTION ─────────────────────────────────────────────────────── */}
|
||||
<section style={{ background: NAVY, position: 'relative', overflow: 'hidden', height: isMobile ? 'auto' : 'calc(100vh - 60px)', display: 'flex', flexDirection: 'column' }} id="bewerben">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '4fr 1px 8fr', flex: 1, minHeight: 0, overflow: isMobile ? 'visible' : 'hidden' }}>
|
||||
@@ -400,27 +341,6 @@ function QualRow({ item, Icon, last }: { item: { num: string; title: string; bod
|
||||
);
|
||||
}
|
||||
|
||||
// ── CriteriaCell ──────────────────────────────────────────────────────────────
|
||||
|
||||
function CriteriaCell({ crit, idx }: { crit: { title: string; desc: string }; idx: number }) {
|
||||
const [hovered, setHovered] = React.useState(false);
|
||||
const isMobileCell = useIsMobile();
|
||||
return (
|
||||
<div
|
||||
style={{ padding: isMobileCell ? '32px 24px' : '56px 40px', borderLeft: !isMobileCell && idx > 0 ? '1px solid rgba(255,255,255,0.07)' : 'none', borderTop: isMobileCell && idx > 0 ? '1px solid rgba(255,255,255,0.07)' : 'none', display: 'flex', flexDirection: 'column', gap: 0, cursor: 'default', transition: 'background 0.2s', background: hovered ? 'rgba(255,255,255,0.03)' : 'transparent' }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 24 }}>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, fontWeight: 700, color: 'rgba(255,255,255,0.50)', letterSpacing: '0.15em', textTransform: 'uppercase' }}>0{idx + 1}</span>
|
||||
</div>
|
||||
<div style={{ width: hovered ? '100%' : 28, height: 1, background: hovered ? 'rgba(239,191,4,0.6)' : 'rgba(239,191,4,0.3)', marginBottom: 20, transition: 'width 0.5s ease' }} />
|
||||
<h4 style={{ fontFamily: FF, fontSize: 16, fontWeight: 700, color: '#fff', textTransform: 'uppercase', letterSpacing: '0.08em', lineHeight: 1.3, marginBottom: 14 }}>{crit.title}</h4>
|
||||
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(255,255,255,0.65)', lineHeight: 1.7, flex: 1 }}>{crit.desc}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── BewerbungswegeSection ─────────────────────────────────────────────────────
|
||||
|
||||
type ApplicationWaysContent = Partial<typeof participationContent.applicationWays>;
|
||||
@@ -551,4 +471,131 @@ function BewerbungswegeSection({ content }: { content?: ApplicationWaysContent }
|
||||
);
|
||||
}
|
||||
|
||||
type AwardsGridContent = Partial<typeof participationContent.awardsGrid>;
|
||||
|
||||
function AwardsGridSection({ content }: { content?: AwardsGridContent }) {
|
||||
const isMobile = useIsMobile();
|
||||
const section = { ...participationContent.awardsGrid, ...(content || {}) };
|
||||
const cards = mergeAwardCards(section.cards, participationContent.awardsGrid.cards);
|
||||
|
||||
return (
|
||||
<section id="auszeichnungen" style={{ background: '#F7F7F5', position: 'relative', overflow: 'hidden', isolation: 'isolate' }}>
|
||||
<MunichSkylineBg />
|
||||
<div style={{ position: 'relative', zIndex: 1, padding: isMobile ? '48px 24px 32px' : '80px 80px 56px', borderBottom: '1px solid rgba(3,9,58,0.1)' }}>
|
||||
<span style={{ fontFamily: FF, fontSize: 10, color: '#4A8FC9', textTransform: 'uppercase', letterSpacing: '0.32em', fontWeight: 700, display: 'block', marginBottom: 16 }}>
|
||||
{fallbackText(section.eyebrow, participationContent.awardsGrid.eyebrow)}
|
||||
</span>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'minmax(0, 0.9fr) minmax(0, 1fr)', gap: isMobile ? 16 : 56, alignItems: 'end' }}>
|
||||
<h2 style={{ fontFamily: FF, fontSize: 'clamp(2rem, 3.5vw, 3rem)', fontWeight: 900, color: '#101828', textTransform: 'uppercase', letterSpacing: '-0.025em', lineHeight: 1.03, margin: 0, overflowWrap: 'anywhere' }}>
|
||||
<Lines text={fallbackText(section.heading, participationContent.awardsGrid.heading)} />
|
||||
</h2>
|
||||
<p style={{ fontFamily: FB, fontSize: 18, color: 'rgba(16,24,40,0.55)', lineHeight: 1.8, margin: 0 }}>
|
||||
{fallbackText(section.description, participationContent.awardsGrid.description)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, display: 'grid', gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))', borderBottom: '1px solid rgba(3,9,58,0.08)' }}>
|
||||
{cards.map((card, index) => {
|
||||
const imageFilename = fallbackText(card.imageFilename, participationContent.awardsGrid.cards[index]?.imageFilename || participationContent.awardsGrid.cards[0].imageFilename);
|
||||
const isContainedImage = imageFilename.endsWith('.png') || imageFilename.includes('roland-berger');
|
||||
const isLast = index === cards.length - 1;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={`${fallbackText(card.title, 'award')}-${index}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: isMobile ? 'auto' : 620,
|
||||
borderRight: !isMobile && !isLast ? '1px solid rgba(3,9,58,0.1)' : 'none',
|
||||
borderBottom: isMobile && !isLast ? '1px solid rgba(3,9,58,0.1)' : 'none',
|
||||
background: index === 1 ? NAVY : 'rgba(255,255,255,0.72)',
|
||||
}}
|
||||
>
|
||||
<div style={{ height: isMobile ? 220 : 260, background: imageFilename.includes('roland-berger') ? '#DDE7E2' : NAVY, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}>
|
||||
<Image unoptimized
|
||||
src={mediaUrl(card.image, `/images/${imageFilename}`)}
|
||||
alt={mediaAlt(card.image, fallbackText(card.imageAlt, fallbackText(card.title, 'Auszeichnung')))}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: isContainedImage ? 'contain' : 'cover',
|
||||
objectPosition: index === 1 ? 'center top' : 'center',
|
||||
padding: imageFilename.endsWith('.png') ? 22 : 0,
|
||||
boxSizing: 'border-box',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: isMobile ? '28px 24px 32px' : '36px 36px 40px', display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
<span style={{ fontFamily: FF, fontSize: 9, fontWeight: 700, letterSpacing: '0.26em', textTransform: 'uppercase', color: index === 1 ? 'rgba(239,191,4,0.8)' : '#4A8FC9', marginBottom: 12 }}>
|
||||
{fallbackText(card.label, '')}
|
||||
</span>
|
||||
<h3 style={{ fontFamily: FF, fontSize: 'clamp(1.25rem, 2vw, 1.7rem)', fontWeight: 900, color: index === 1 ? '#fff' : '#101828', textTransform: 'uppercase', letterSpacing: '-0.02em', lineHeight: 1.08, margin: '0 0 16px', overflowWrap: 'anywhere' }}>
|
||||
<Lines text={fallbackText(card.title, '')} />
|
||||
</h3>
|
||||
<div style={{ width: 36, height: 2, background: GOLD, marginBottom: 22 }} />
|
||||
<p style={{ fontFamily: FB, fontSize: 18, color: index === 1 ? 'rgba(255,255,255,0.62)' : 'rgba(16,24,40,0.55)', lineHeight: 1.7, margin: 0, flex: 1 }}>
|
||||
{fallbackText(card.description, '')}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 10, marginTop: 32 }}>
|
||||
<Link
|
||||
to={fallbackText(card.articleCta?.url, '#bewerben')}
|
||||
style={{
|
||||
minHeight: 46,
|
||||
padding: '0 18px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
background: GOLD,
|
||||
color: '#101828',
|
||||
fontFamily: FF,
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
textDecoration: 'none',
|
||||
whiteSpace: 'normal',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{fallbackText(card.articleCta?.label, 'Artikel lesen')} <ArrowRight size={13} />
|
||||
</Link>
|
||||
<a
|
||||
href={fallbackText(card.mailtoCta?.url, `mailto:${participationContent.form.eligibility.ineligibleContactEmail}`)}
|
||||
style={{
|
||||
minHeight: 46,
|
||||
padding: '0 18px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
color: index === 1 ? '#fff' : NAVY,
|
||||
border: `1px solid ${index === 1 ? 'rgba(255,255,255,0.22)' : 'rgba(3,9,58,0.18)'}`,
|
||||
fontFamily: FF,
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
textDecoration: 'none',
|
||||
whiteSpace: 'normal',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Mail size={13} /> {fallbackText(card.mailtoCta?.label, 'Kontakt aufnehmen')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default Participation;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Link } from '@/spa/router';
|
||||
import { Trophy, Search, ChevronDown, ChevronRight, X, ArrowRight } from 'lucide-react';
|
||||
import { Trophy, ChevronRight, ArrowRight } from 'lucide-react';
|
||||
import { WINNERS, type Winner } from '@/spa/data/winners';
|
||||
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
|
||||
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
|
||||
@@ -47,14 +47,10 @@ const normalizeWinner = (
|
||||
|
||||
export default function Preistraeger() {
|
||||
const isMobile = useIsMobile();
|
||||
const [filterCategory, setFilterCategory] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [mediaOnly, setMediaOnly] = useState(false);
|
||||
const cms = (useCmsRoute()?.doc?.preistraegerIndex || {}) as PreistraegerIndexCms;
|
||||
const cmsPreistraeger = useCmsCollection('preistraeger');
|
||||
const hero = { ...preistraegerIndexContent.hero, ...(cms.hero || {}) };
|
||||
const breadcrumb = { ...preistraegerIndexContent.breadcrumb, ...(cms.breadcrumb || {}) };
|
||||
const filters = { ...preistraegerIndexContent.filters, ...(cms.filters || {}) };
|
||||
const count = { ...preistraegerIndexContent.count, ...(cms.count || {}) };
|
||||
const empty = { ...preistraegerIndexContent.empty, ...(cms.empty || {}) };
|
||||
const card = useMemo(() => ({ ...preistraegerIndexContent.card, ...(cms.card || {}) }), [cms.card]);
|
||||
@@ -69,20 +65,10 @@ export default function Preistraeger() {
|
||||
return WINNERS;
|
||||
}, [card, cardFallbackImage, cmsPreistraeger]);
|
||||
const years = useMemo(() => [...new Set(winners.map((winner) => winner.year))].sort((a, b) => b - a), [winners]);
|
||||
const categories = useMemo(() => [...new Set(winners.map((winner) => winner.category))].sort((a, b) => a.localeCompare(b, 'de')), [winners]);
|
||||
const [activeYear, setActiveYear] = useState<number | undefined>(undefined);
|
||||
const selectedYear = activeYear ?? years[0] ?? new Date().getFullYear();
|
||||
|
||||
const filtered = useMemo(() => winners.filter(w => {
|
||||
if (w.year !== selectedYear) return false;
|
||||
if (filterCategory && w.category !== filterCategory) return false;
|
||||
if (search && !w.name.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
if (mediaOnly && !w.hasMedia) return false;
|
||||
return true;
|
||||
}), [selectedYear, filterCategory, search, mediaOnly, winners]);
|
||||
|
||||
const reset = () => { setFilterCategory(''); setSearch(''); setMediaOnly(false); };
|
||||
const hasFilter = !!(filterCategory || search || mediaOnly);
|
||||
const filtered = useMemo(() => winners.filter(w => w.year === selectedYear), [selectedYear, winners]);
|
||||
|
||||
return (
|
||||
<div style={{ background: '#fff', minHeight: '100vh' }}>
|
||||
@@ -102,16 +88,16 @@ export default function Preistraeger() {
|
||||
<span style={{ fontFamily: FF, fontSize: 16, color: GOLD, fontWeight: 600, letterSpacing: '0.04em' }}>{fallbackText(breadcrumb.label, preistraegerIndexContent.breadcrumb.label)}</span>
|
||||
</div>
|
||||
|
||||
{/* Filter Section */}
|
||||
<div style={{ background: '#fff', padding: isMobile ? '32px 24px 24px' : '48px 80px 32px' }}>
|
||||
{/* Year Filter Section */}
|
||||
<div style={{ background: '#fff', padding: isMobile ? '32px 24px 20px' : '48px 80px 24px' }}>
|
||||
{/* #93: Headline entfernt – Seitenname steht bereits oben im Breadcrumb. */}
|
||||
|
||||
{/* Year Tabs */}
|
||||
<div style={{ display: 'flex', flexWrap: isMobile ? 'nowrap' : 'wrap', gap: 0, marginBottom: 32, borderBottom: `2px solid ${BORDER}`, overflowX: isMobile ? 'auto' : 'visible', WebkitOverflowScrolling: 'touch', maxWidth: '100%' }}>
|
||||
<div style={{ display: 'flex', flexWrap: isMobile ? 'nowrap' : 'wrap', gap: 0, borderBottom: `2px solid ${BORDER}`, overflowX: isMobile ? 'auto' : 'visible', WebkitOverflowScrolling: 'touch', maxWidth: '100%' }}>
|
||||
{years.map(year => (
|
||||
<button
|
||||
key={year}
|
||||
onClick={() => { setActiveYear(year); reset(); }}
|
||||
onClick={() => setActiveYear(year)}
|
||||
style={{
|
||||
fontFamily: FF, fontSize: 16, fontWeight: 700,
|
||||
padding: isMobile ? '12px 20px' : '12px 32px',
|
||||
@@ -128,54 +114,6 @@ export default function Preistraeger() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filter Row */}
|
||||
<div style={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row', gap: 16, alignItems: 'stretch', marginBottom: 20, flexWrap: 'wrap' }}>
|
||||
<SelectControl
|
||||
value={filterCategory}
|
||||
onChange={setFilterCategory}
|
||||
placeholder={fallbackText(filters.categoryPlaceholder, preistraegerIndexContent.filters.categoryPlaceholder)}
|
||||
options={categories.map(c => ({ label: c, value: c }))}
|
||||
width={isMobile ? undefined : 280}
|
||||
/>
|
||||
<SearchControl value={search} onChange={setSearch} placeholder={fallbackText(filters.searchPlaceholder, preistraegerIndexContent.filters.searchPlaceholder)} />
|
||||
</div>
|
||||
|
||||
{/* Toggle row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<span style={{ fontFamily: FF, fontSize: 15, fontWeight: 700, color: '#101828' }}>
|
||||
{fallbackText(filters.storyHeading, preistraegerIndexContent.filters.storyHeading)}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 15 }}>{fallbackText(filters.mediaIcon, preistraegerIndexContent.filters.mediaIcon)}</span>
|
||||
<span style={{ fontFamily: FF, fontSize: 15, color: GRAY }}>{fallbackText(filters.mediaLabel, preistraegerIndexContent.filters.mediaLabel)}</span>
|
||||
<button
|
||||
onClick={() => setMediaOnly(v => !v)}
|
||||
style={{
|
||||
width: 40, height: 22, borderRadius: 11, border: 'none', cursor: 'pointer',
|
||||
background: mediaOnly ? NAVY : BORDER,
|
||||
position: 'relative', transition: 'background 0.2s',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute', top: 3, left: mediaOnly ? 20 : 3, width: 16, height: 16,
|
||||
borderRadius: '50%', background: '#fff', transition: 'left 0.2s',
|
||||
}} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{hasFilter && (
|
||||
<button
|
||||
onClick={reset}
|
||||
style={{ fontFamily: FF, fontSize: 16, color: GRAY, background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6 }}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = GOLD)}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = GRAY)}
|
||||
>
|
||||
<X size={14} /> {fallbackText(filters.resetLabel, preistraegerIndexContent.filters.resetLabel)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Count bar */}
|
||||
@@ -319,61 +257,3 @@ function WinnerCard({ winner, hoverLabel }: { winner: Winner; hoverLabel: string
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// ── SelectControl ────────────────────────────────────────────────────────────
|
||||
|
||||
function SelectControl({ value, onChange, placeholder, options, width }: {
|
||||
value: string; onChange: (v: string) => void;
|
||||
placeholder: string; options: { label: string; value: string }[];
|
||||
width?: number;
|
||||
}) {
|
||||
const active = !!value;
|
||||
return (
|
||||
<div style={{ position: 'relative', width: width ?? '100%', flexShrink: width ? 0 : 1 }}>
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
style={{
|
||||
width: '100%', height: 44, padding: '0 36px 0 14px',
|
||||
fontFamily: FF, fontSize: 16,
|
||||
border: `1px solid ${active ? NAVY : BORDER}`,
|
||||
color: active ? NAVY : GRAY,
|
||||
background: '#fff', borderRadius: 0,
|
||||
appearance: 'none', cursor: 'pointer', outline: 'none',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onFocus={e => (e.currentTarget.style.borderColor = GOLD)}
|
||||
onBlur={e => (e.currentTarget.style.borderColor = active ? NAVY : BORDER)}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<ChevronDown size={16} style={{ position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none', color: '#101828' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── SearchControl ────────────────────────────────────────────────────────────
|
||||
|
||||
function SearchControl({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder: string }) {
|
||||
return (
|
||||
<div style={{ position: 'relative', flex: 1, minWidth: 200 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
width: '100%', height: 44, padding: '0 40px 0 14px',
|
||||
fontFamily: FF, fontSize: 16,
|
||||
border: `1px solid ${BORDER}`, borderRadius: 0,
|
||||
background: '#fff', outline: 'none', boxSizing: 'border-box',
|
||||
transition: 'border-color 0.15s', color: '#101828',
|
||||
}}
|
||||
onFocus={e => (e.currentTarget.style.borderColor = GOLD)}
|
||||
onBlur={e => (e.currentTarget.style.borderColor = BORDER)}
|
||||
/>
|
||||
<Search size={16} style={{ position: 'absolute', right: 14, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none', color: GRAY }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -524,6 +524,7 @@ const Press: React.FC = () => {
|
||||
{/* Left col */}
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
padding: isMobile ? '48px 24px' : '88px 80px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
Reference in New Issue
Block a user