diff --git a/src/app/(frontend)/[[...slug]]/page.tsx b/src/app/(frontend)/[[...slug]]/page.tsx index 3fd3e0b..bab3f3e 100644 --- a/src/app/(frontend)/[[...slug]]/page.tsx +++ b/src/app/(frontend)/[[...slug]]/page.tsx @@ -89,6 +89,9 @@ async function findSupportPages(payload: Awaited>, { spaPath: { equals: '/netzwerk' } }, { slug: { equals: 'netzwerk' } }, { slug: { equals: 'network' } }, + { spaPath: { equals: '/teilnahme' } }, + { slug: { equals: 'teilnahme' } }, + { slug: { equals: 'participation' } }, ], }, }) diff --git a/src/collections/Pages/aboutFields.ts b/src/collections/Pages/aboutFields.ts index 83b41b2..0860877 100644 --- a/src/collections/Pages/aboutFields.ts +++ b/src/collections/Pages/aboutFields.ts @@ -47,7 +47,7 @@ export const aboutFields: Field[] = [ type: 'group', admin: { description: - 'Edit the Der BMP page in the same order it appears on the frontend. The generic Hero and Content tabs are hidden for this SPA-managed route.', + 'Edit the Der BMP page in the same order it appears on the frontend. The Preisträger grid reuses the homepage Preisträger card grid and is edited on the Startseite page. The generic Hero and Content tabs are hidden for this SPA-managed route.', }, fields: [ { @@ -127,7 +127,10 @@ export const aboutFields: Field[] = [ name: 'highlights', label: '04 · Preisträger highlights', type: 'group', - admin: sectionAdmin('Header copy and editor-selected winners for the photo grid.'), + admin: { + ...sectionAdmin('Legacy data retained for compatibility. The rendered section now reuses the homepage Preisträger card grid.'), + hidden: true, + }, fields: [ uploadField('fallbackImage', 'Fallback winner image', `Current frontend fallback image: /images/${aboutContent.highlights.fallbackImageFilename}`), text('eyebrow', 'Eyebrow', aboutContent.highlights.eyebrow), diff --git a/src/payload-types.ts b/src/payload-types.ts index 19dd72f..9374195 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -1118,7 +1118,7 @@ export interface Page { }; }; /** - * Edit the Der BMP page in the same order it appears on the frontend. The generic Hero and Content tabs are hidden for this SPA-managed route. + * Edit the Der BMP page in the same order it appears on the frontend. The Preisträger grid reuses the homepage Preisträger card grid and is edited on the Startseite page. The generic Hero and Content tabs are hidden for this SPA-managed route. */ about?: { /** @@ -1195,7 +1195,7 @@ export interface Page { | null; }; /** - * Header copy and editor-selected winners for the photo grid. + * Legacy data retained for compatibility. The rendered section now reuses the homepage Preisträger card grid. */ highlights?: { /** diff --git a/src/spa/components/HomeWinnersSection.tsx b/src/spa/components/HomeWinnersSection.tsx new file mode 100644 index 0000000..793d3f1 --- /dev/null +++ b/src/spa/components/HomeWinnersSection.tsx @@ -0,0 +1,186 @@ +import React, { useRef, useState } from 'react'; +import { ChevronLeft, ChevronRight, Star } from 'lucide-react'; + +import Image from '@/spa/components/ui/UnoptimizedImage'; +import { mediaUrl } from '@/spa/cmsMediaField'; +import { useCmsCollection } from '@/spa/cmsRoute'; +import { WINNERS } from '@/spa/data/winners'; +import { homeContent } from '@/spa/homeContent'; +import { resolveHomeWinners } from '@/spa/homeWinnerSelection'; +import { useIsMobile } from '@/spa/hooks/useIsMobile'; +import { usePreistraegerPlaceholderImage } from '@/spa/preistraegerPlaceholder'; +import { Link } from '@/spa/router'; + +type CmsRecord = Record; + +export type HomeWinnersContent = CmsRecord & { + cardFallbackTitle?: string | null; + cta?: { label?: string | null; url?: string | null } | null; + eyebrow?: string | null; + fallbackImage?: unknown; + featured?: unknown; + heading?: string | null; + hoverLabel?: string | null; +}; + +type WinnerCardData = CmsRecord & { + id?: string | number; + slug?: string; + title?: string; + name?: string; + img?: string; + image?: unknown; + spaPath?: string; + year?: string | number; + awardType?: string; + type?: string; + category?: string; +}; + +const fallbackText = (value: unknown, fallback: string) => + typeof value === 'string' && value.length > 0 ? value : fallback; + +function Lines({ text }: { text: string }) { + return <>{text.split('\n').map((line, i) => {i > 0 &&
}{line}
)}; +} + +function HomeWinnerCard({ + winner, + fallbackTitle, + fallbackImage, + hoverLabel, + horizontalScroll = false, +}: { + winner: WinnerCardData; + fallbackTitle: string; + fallbackImage: string; + hoverLabel: string; + horizontalScroll?: boolean; +}) { + const [hovered, setHovered] = useState(false); + const isMobile = useIsMobile(); + const staticWinner = WINNERS.find( + (item) => + String(item.id) === String(winner?.id) || + item.slug === winner?.slug || + item.name === winner?.title || + item.name === winner?.name, + ); + const title = fallbackText(winner?.title, fallbackText(winner?.name, staticWinner?.name || fallbackTitle)); + const imageSrc = winner?.img || mediaUrl(winner?.image, fallbackImage); + const href = winner?.spaPath || (winner?.slug ? `/preistraeger/${winner.slug}` : staticWinner ? `/preistraeger/${staticWinner.slug}` : '/preistraeger'); + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {title} +
+
+
+
+
+ + {winner?.year || staticWinner?.year} · {winner?.awardType || winner?.type || staticWinner?.type} +
+

{title}

+

{winner?.category || staticWinner?.category}

+
+ {hoverLabel} +
+
+ + ); +} + +export default function HomeWinnersSection({ content }: { content?: HomeWinnersContent }) { + const winnersScrollRef = useRef(null); + const isMobile = useIsMobile(); + const placeholderImage = usePreistraegerPlaceholderImage(); + const cmsPreistraeger = useCmsCollection('preistraeger'); + const winnersSection = content || {}; + const homeWinners = resolveHomeWinners(winnersSection.featured, cmsPreistraeger, WINNERS); + const scrollDesktopWinners = !isMobile && homeWinners.length > 8; + const scrollWinnerGrid = isMobile || scrollDesktopWinners; + + const scrollWinners = (direction: -1 | 1) => { + const container = winnersScrollRef.current; + if (!container) return; + + container.scrollBy({ + left: direction * Math.max(container.clientWidth * 0.8, 320), + behavior: 'smooth', + }); + }; + + return ( +
+
+
+ {fallbackText(winnersSection.eyebrow, homeContent.winners.eyebrow)} +

+ +

+
+
+ {scrollDesktopWinners && ( +
+ + +
+ )} + + {fallbackText(winnersSection.cta?.label, homeContent.winners.cta.label)} + +
+
+ +
+ {homeWinners.map((winner) => ( + + ))} +
+
+ ); +} diff --git a/src/spa/pages/About.tsx b/src/spa/pages/About.tsx index 7ef2a2e..32d89cb 100644 --- a/src/spa/pages/About.tsx +++ b/src/spa/pages/About.tsx @@ -1,40 +1,32 @@ -import React, { useMemo, useState } from 'react' -import { ChevronRight, Star } from 'lucide-react' +import React, { useState } from 'react' +import { ChevronRight } from 'lucide-react' import AwardsGridSection, { type AwardsGridContent } from '@/spa/components/AwardsGridSection' +import HomeWinnersSection, { type HomeWinnersContent } from '@/spa/components/HomeWinnersSection' import TestimonialsSection from '@/spa/components/TestimonialsSection' import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg' import Image from '@/spa/components/ui/UnoptimizedImage' import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField' -import { resolveCmsRelationship, useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute' -import { WINNERS, type Winner } from '@/spa/data/winners' +import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute' import { useIsMobile } from '@/spa/hooks/useIsMobile' import { Link } from '@/spa/router' import { aboutContent } from '@/spa/aboutContent' -import { usePreistraegerPlaceholderImage } from '@/spa/preistraegerPlaceholder' const FF = '"IBM Plex Sans", sans-serif' const FB = '"Inter", sans-serif' const NAVY = '#111D55' const GOLD = '#EFBF04' -const CREAM = '#E4E2E3' +const WHITE = '#fff' const HIDE_MEMBERSHIP_ABOUT_CTA = true type AboutCms = Partial & { hero?: Partial & { backgroundImage?: unknown } prize?: Partial & { image?: unknown } mittelstand?: Partial & { image?: unknown } - highlights?: Partial & { - fallbackImage?: unknown - fallbackWinner?: Partial - selectedWinners?: unknown[] | null - } } type TextRow = { text?: string | null } -const FALLBACK_HIGHLIGHTS = WINNERS.filter((w) => w.year === aboutContent.highlights.year).slice(0, 8) - const fallbackText = (value: unknown, fallback: string) => (typeof value === 'string' && value.length > 0 ? value : fallback) const fallbackArray = (value: unknown, fallback: T[]) => (Array.isArray(value) && value.length ? (value as T[]) : fallback) @@ -69,6 +61,12 @@ const isParticipationPage = (page: CmsRouteDoc) => { return page.spaPath === '/teilnahme' || page.slug === 'teilnahme' || page.slug === 'participation' || title === 'teilnahme' || title === 'participation' } +const isHomePage = (page: CmsRouteDoc) => { + const title = String(page.title || '').toLowerCase() + + return page.spaPath === '/' || page.slug === 'startseite' || page.slug === 'home' || title === 'startseite' || title === 'home' +} + function HighlightedText({ text, highlight }: { text: string; highlight: string }) { const index = text.indexOf(highlight) if (!highlight || index < 0) return <>{text} @@ -82,72 +80,23 @@ function HighlightedText({ text, highlight }: { text: string; highlight: string ) } -const normalizeWinner = ( - doc: CmsRouteDoc | undefined, - placeholderImage: string, - fallbackWinner: typeof aboutContent.highlights.fallbackWinner, -): Winner | undefined => { - if (!doc) return undefined - return { - id: String(doc.id), - slug: String(doc.slug || ''), - name: String(doc.title || fallbackWinner.name), - category: String(doc.category || fallbackWinner.category), - year: Number(doc.year || aboutContent.highlights.year), - type: String(doc.awardType || fallbackWinner.awardType), - img: mediaUrl(doc.image, placeholderImage), - shortDesc: String(doc.shortDesc || doc.description || doc.meta?.description || ''), - longDesc: String(doc.longDesc || doc.description || doc.meta?.description || ''), - quote: String(doc.quote || ''), - quotePerson: String(doc.quotePerson || doc.title || ''), - quoteRole: String(doc.quoteRole || ''), - location: String(doc.location || ''), - industry: String(doc.industry || ''), - website: String(doc.website || ''), - hasMedia: Boolean(doc.hasMedia), - } -} - const About: React.FC = () => { const isMobile = useIsMobile() - const placeholderImage = usePreistraegerPlaceholderImage() const cms = (useCmsRoute()?.doc?.about || {}) as AboutCms - const cmsWinners = useCmsCollection('preistraeger') const cmsPages = useCmsCollection('pages') const hero = { ...aboutContent.hero, ...(cms.hero || {}) } const prize = { ...aboutContent.prize, ...(cms.prize || {}) } const mittelstand = { ...aboutContent.mittelstand, ...(cms.mittelstand || {}) } - const highlights = useMemo(() => ({ ...aboutContent.highlights, ...(cms.highlights || {}) }), [cms.highlights]) const testimonials = { ...aboutContent.testimonials, ...(cms.testimonials || {}) } const history = { ...aboutContent.history, ...(cms.history || {}) } const goals = { ...aboutContent.goals, ...(cms.goals || {}) } const values = { ...aboutContent.values, ...(cms.values || {}) } const cta = { ...aboutContent.cta, ...(cms.cta || {}) } const participationPage = cmsPages.find(isParticipationPage) + const homePage = cmsPages.find(isHomePage) const participationAwardsGrid = (participationPage?.participation as { awardsGrid?: AwardsGridContent } | undefined)?.awardsGrid + const homeWinners = (homePage?.home as { winners?: HomeWinnersContent } | undefined)?.winners const showSecondaryCta = !HIDE_MEMBERSHIP_ABOUT_CTA || !isMembershipCta(cta.secondaryCta) - const highlightFallbackWinner = useMemo( - () => ({ ...aboutContent.highlights.fallbackWinner, ...(highlights.fallbackWinner || {}) }), - [highlights], - ) - - const highlightWinners = useMemo(() => { - const selected = fallbackArray(highlights.selectedWinners, []) - .map((entry) => normalizeWinner(resolveCmsRelationship(entry, cmsWinners), placeholderImage, highlightFallbackWinner)) - .filter((winner): winner is Winner => Boolean(winner)) - - if (selected.length) return selected.slice(0, 8) - - const isWinnerFromYear = (winner: Winner | undefined): winner is Winner => - Boolean(winner) && winner?.year === Number(highlights.year || aboutContent.highlights.year) - - const cmsByYear = cmsWinners - .map((winner) => normalizeWinner(winner, placeholderImage, highlightFallbackWinner)) - .filter(isWinnerFromYear) - .slice(0, 8) - - return cmsByYear.length ? cmsByYear : FALLBACK_HIGHLIGHTS - }, [cmsWinners, highlightFallbackWinner, highlights, placeholderImage]) return (
@@ -213,7 +162,7 @@ const About: React.FC = () => {
-
+
{!isMobile && ( <> {fallbackText(prize.eyebrow, aboutContent.prize.eyebrow)} @@ -247,7 +196,7 @@ const About: React.FC = () => { />
+ : `linear-gradient(to right, ${WHITE} 0%, transparent 25%), linear-gradient(to top, rgba(3,9,58,0.75) 0%, transparent 60%)` }} /> {isMobile && (
{fallbackText(prize.eyebrow, aboutContent.prize.eyebrow)} @@ -312,26 +261,7 @@ const About: React.FC = () => {
-
-
-
- {fallbackText(highlights.eyebrow, aboutContent.highlights.eyebrow)} -

- -

-
- - {fallbackText(highlights.cta?.label, aboutContent.highlights.cta.label)} - -
- -
- {highlightWinners.map((w) => )} -
-
+ @@ -385,7 +315,7 @@ const About: React.FC = () => {
-
+
@@ -404,7 +334,7 @@ const About: React.FC = () => { ))}
- +
@@ -449,37 +379,6 @@ const About: React.FC = () => { ) } -function HighlightCard({ winner, hoverLabel }: { winner: Winner; hoverLabel: string }) { - const [hovered, setHovered] = useState(false) - const isMobile = useIsMobile() - return ( - setHovered(true)} - onMouseLeave={() => setHovered(false)} - > - {winner.name} -
-
-
-
-
- - - {winner.year} · {winner.type} - -
-

{winner.name}

-

{winner.category}

-
-
- {hoverLabel} -
- - ) -} - function ValueRow({ item, last }: { item: { num?: string | null; title?: string | null }; last: boolean }) { const [hovered, setHovered] = useState(false) const isMobileRow = useIsMobile() diff --git a/src/spa/pages/Home.tsx b/src/spa/pages/Home.tsx index d9d6062..c453f23 100644 --- a/src/spa/pages/Home.tsx +++ b/src/spa/pages/Home.tsx @@ -1,10 +1,10 @@ import React, { useEffect, useRef, useState } from 'react'; import { Button } from '@/spa/components/ui/button'; -import { Play, ChevronLeft, ChevronRight, X, Star } from 'lucide-react'; +import { Play, ChevronRight, X } from 'lucide-react'; import { Link } from '@/spa/router'; import { PartnerTicker } from '@/spa/components/ui/partner-ticker'; -import { WINNERS } from '@/spa/data/winners'; import { ApplicationFormSection } from '@/spa/components/forms/ApplicationFormSection'; +import HomeWinnersSection from '@/spa/components/HomeWinnersSection'; import TestimonialsSection from '@/spa/components/TestimonialsSection'; import MunichSkylineBg from '@/spa/components/ui/munich-skyline-bg'; import { useIsMobile } from '@/spa/hooks/useIsMobile'; @@ -13,9 +13,7 @@ import type { SpaApplicationPhaseData, SpaApplicationPhaseItem } from '@/spa/app import { useApplicationPhase, useCmsCollection, useCmsRoute } from '@/spa/cmsRoute' import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField' import { homeContent } from '@/spa/homeContent' -import { resolveHomeWinners } from '@/spa/homeWinnerSelection' import { netzwerkContent } from '@/spa/netzwerkContent' -import { usePreistraegerPlaceholderImage } from '@/spa/preistraegerPlaceholder' import { PartnerLogoMark, type PartnerLogoData } from '@/spa/components/ui/partner-logo' const FF = '"IBM Plex Sans", sans-serif'; @@ -93,20 +91,6 @@ type RenderedStatusPhase = Omit typeof value === 'string' && value.length > 0 ? value : fallback; @@ -168,63 +152,10 @@ function sortedPartners(partners: T[]) { .map(({ partner }) => partner); } -function HomeWinnerCard({ - winner, - fallbackTitle, - fallbackImage, - hoverLabel, - horizontalScroll = false, -}: { - winner: WinnerCardData; - fallbackTitle: string; - fallbackImage: string; - hoverLabel: string; - horizontalScroll?: boolean; -}) { - const [hovered, setHovered] = useState(false); - const isMobile = useIsMobile(); - const staticWinner = WINNERS.find( - (item) => - String(item.id) === String(winner?.id) || - item.slug === winner?.slug || - item.name === winner?.title || - item.name === winner?.name, - ); - const title = fallbackText(winner?.title, fallbackText(winner?.name, staticWinner?.name || fallbackTitle)); - const imageSrc = winner?.img || mediaUrl(winner?.image, fallbackImage); - const href = winner?.spaPath || (winner?.slug ? `/preistraeger/${winner.slug}` : staticWinner ? `/preistraeger/${staticWinner.slug}` : '/preistraeger'); - return ( - setHovered(true)} - onMouseLeave={() => setHovered(false)} - > - {title} -
-
-
-
-
- - {winner?.year || staticWinner?.year} · {winner?.awardType || winner?.type || staticWinner?.type} -
-

{title}

-

{winner?.category || staticWinner?.category}

-
- {hoverLabel} -
-
- - ); -} - const Home: React.FC = () => { const [showVideo, setShowVideo] = useState(false); const videoRef = useRef(null); - const winnersScrollRef = useRef(null); const isMobile = useIsMobile(); - const placeholderImage = usePreistraegerPlaceholderImage(); const home = (useCmsRoute()?.doc?.home || {}) as HomeCms; const cmsPartners = useCmsCollection('partners') as PartnerLogoData[]; const networkPartners = sortedPartners(cmsPartners) @@ -240,19 +171,14 @@ const Home: React.FC = () => { const benefits = home.benefits || {}; const videoModal = home.videoModal || {}; const applicationPhase = useApplicationPhase(); - const cmsPreistraeger = useCmsCollection('preistraeger'); 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 homeWinners = resolveHomeWinners(winnersSection.featured, cmsPreistraeger, WINNERS); - const scrollDesktopWinners = !isMobile && homeWinners.length > 8; - const scrollWinnerGrid = isMobile || scrollDesktopWinners; const quickCriteria = fallbackArray(quickCheck.criteria, homeContent.quickCheck.criteria); const introStats = fallbackArray(intro.stats, homeContent.intro.stats); const benefitItems = fallbackArray(benefits.items, homeContent.benefits.items); - const winnerFallbackImage = placeholderImage; const videoTitle = fallbackText(videoModal.title, homeContent.videoModal.title); const videoSrc = mediaUrl(videoModal.video, ''); @@ -264,16 +190,6 @@ const Home: React.FC = () => { }); }, [showVideo, videoSrc]); - const scrollWinners = (direction: -1 | 1) => { - const container = winnersScrollRef.current; - if (!container) return; - - container.scrollBy({ - left: direction * Math.max(container.clientWidth * 0.8, 320), - behavior: 'smooth', - }); - }; - return (
{/* Hero Section */} @@ -364,72 +280,7 @@ const Home: React.FC = () => { {/* Status Phase Slider */} - {/* Winners Grid – neue Preisträger-Übersicht */} -
-
-
- {fallbackText(winnersSection.eyebrow, homeContent.winners.eyebrow)} -

- -

-
-
- {scrollDesktopWinners && ( -
- - -
- )} - - {fallbackText(winnersSection.cta?.label, homeContent.winners.cta.label)} - -
-
- -
- {homeWinners.map((w) => ( - - ))} -
-
+ {/* Schnell-Check Section */}