feat: manage preistraeger index via payload
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Link } from '@/spa/router';
|
||||
import { Trophy, Search, ChevronDown, ChevronRight, X, ArrowRight } from 'lucide-react';
|
||||
import { WINNERS, CATEGORIES, YEARS } from '@/spa/data/winners';
|
||||
import { useCmsCollection } from '@/spa/cmsRoute';
|
||||
import { docImageUrl } from '@/spa/cmsMediaField';
|
||||
import { WINNERS, type Winner } from '@/spa/data/winners';
|
||||
import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute';
|
||||
import { docImageUrl, mediaAlt, mediaUrl } from '@/spa/cmsMediaField';
|
||||
import { useIsMobile } from '@/spa/hooks/useIsMobile';
|
||||
import Image from '@/spa/components/ui/UnoptimizedImage'
|
||||
import { preistraegerIndexContent } from '@/spa/preistraegerIndexContent';
|
||||
|
||||
const FF = '"IBM Plex Sans", sans-serif';
|
||||
const NAVY = '#111D55';
|
||||
@@ -14,47 +15,61 @@ const BORDER = '#D0D5DD';
|
||||
const GRAY = '#666666';
|
||||
const BG_ALT = '#E4E2E3';
|
||||
|
||||
type PreistraegerIndexCms = Partial<typeof preistraegerIndexContent> & {
|
||||
hero?: Partial<typeof preistraegerIndexContent.hero> & { image?: unknown }
|
||||
}
|
||||
|
||||
const fallbackText = (value: unknown, fallback: string) => typeof value === 'string' && value.length > 0 ? value : fallback;
|
||||
|
||||
const normalizeWinner = (doc: CmsRouteDoc): Winner => ({
|
||||
id: String(doc.id),
|
||||
slug: String(doc.slug || ''),
|
||||
name: String(doc.title || 'Preisträger'),
|
||||
category: String(doc.category || 'Preisträger'),
|
||||
year: Number(doc.year || new Date().getFullYear()),
|
||||
type: String(doc.awardType || 'Auszeichnung'),
|
||||
img: docImageUrl(doc, '/images/gala-saal-overview.jpg'),
|
||||
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 || ''),
|
||||
quoteRole: String(doc.quoteRole || ''),
|
||||
location: String(doc.location || 'Bayern'),
|
||||
industry: String(doc.industry || 'Mittelstand'),
|
||||
website: String(doc.website || '#'),
|
||||
hasMedia: Boolean(doc.hasMedia),
|
||||
});
|
||||
|
||||
export default function Preistraeger() {
|
||||
const isMobile = useIsMobile();
|
||||
const [activeYear, setActiveYear] = useState(YEARS[0]); // default: most recent
|
||||
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 = { ...preistraegerIndexContent.card, ...(cms.card || {}) };
|
||||
const cta = { ...preistraegerIndexContent.cta, ...(cms.cta || {}) };
|
||||
const winners = useMemo(() => {
|
||||
const existingSlugs = new Set(WINNERS.map((winner) => winner.slug));
|
||||
return [
|
||||
...WINNERS,
|
||||
...cmsPreistraeger
|
||||
.filter((doc) => doc.slug && !existingSlugs.has(String(doc.slug)))
|
||||
.map((doc) => ({
|
||||
id: String(doc.id),
|
||||
slug: String(doc.slug),
|
||||
name: String(doc.title || 'Preisträger'),
|
||||
category: String(doc.category || 'Preisträger'),
|
||||
year: Number(doc.year || new Date().getFullYear()),
|
||||
type: String(doc.awardType || 'Auszeichnung'),
|
||||
img: docImageUrl(doc, '/images/gala-saal-overview.jpg'),
|
||||
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 || ''),
|
||||
quoteRole: String(doc.quoteRole || ''),
|
||||
location: String(doc.location || 'Bayern'),
|
||||
industry: String(doc.industry || 'Mittelstand'),
|
||||
website: String(doc.website || '#'),
|
||||
hasMedia: Boolean(doc.hasMedia),
|
||||
})),
|
||||
];
|
||||
if (cmsPreistraeger.length) return cmsPreistraeger.map(normalizeWinner).filter((winner) => winner.slug);
|
||||
return WINNERS;
|
||||
}, [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 !== activeYear) return false;
|
||||
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;
|
||||
}), [activeYear, filterCategory, search, mediaOnly, winners]);
|
||||
}), [selectedYear, filterCategory, search, mediaOnly, winners]);
|
||||
|
||||
const reset = () => { setFilterCategory(''); setSearch(''); setMediaOnly(false); };
|
||||
const hasFilter = !!(filterCategory || search || mediaOnly);
|
||||
@@ -64,8 +79,8 @@ export default function Preistraeger() {
|
||||
{/* Hero */}
|
||||
<div style={{ width: '100%', height: '50vh', overflow: 'hidden', position: 'relative' }}>
|
||||
<Image unoptimized
|
||||
src="https://images.unsplash.com/photo-1492684223066-81342ee5ff30?auto=format&fit=crop&q=80&w=2000"
|
||||
alt="Preisverleihung"
|
||||
src={mediaUrl(hero.image, fallbackText(hero.imageUrl, preistraegerIndexContent.hero.imageUrl))}
|
||||
alt={mediaAlt(hero.image, fallbackText(hero.imageAlt, preistraegerIndexContent.hero.imageAlt))}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to bottom, transparent 40%, rgba(3,9,58,0.82) 100%)' }} />
|
||||
@@ -74,7 +89,7 @@ export default function Preistraeger() {
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div style={{ background: '#fff', borderBottom: `1px solid ${BORDER}`, padding: isMobile ? '18px 24px' : '18px 80px' }}>
|
||||
<span style={{ fontFamily: FF, fontSize: 16, color: GOLD, fontWeight: 600, letterSpacing: '0.04em' }}>Preisträger:innen</span>
|
||||
<span style={{ fontFamily: FF, fontSize: 16, color: GOLD, fontWeight: 600, letterSpacing: '0.04em' }}>{fallbackText(breadcrumb.label, preistraegerIndexContent.breadcrumb.label)}</span>
|
||||
</div>
|
||||
|
||||
{/* Filter Section */}
|
||||
@@ -83,7 +98,7 @@ export default function Preistraeger() {
|
||||
|
||||
{/* 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%' }}>
|
||||
{YEARS.map(year => (
|
||||
{years.map(year => (
|
||||
<button
|
||||
key={year}
|
||||
onClick={() => { setActiveYear(year); reset(); }}
|
||||
@@ -92,8 +107,8 @@ export default function Preistraeger() {
|
||||
padding: isMobile ? '12px 20px' : '12px 32px',
|
||||
flexShrink: 0,
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: activeYear === year ? NAVY : GRAY,
|
||||
borderBottom: activeYear === year ? `2px solid ${GOLD}` : '2px solid transparent',
|
||||
color: selectedYear === year ? NAVY : GRAY,
|
||||
borderBottom: selectedYear === year ? `2px solid ${GOLD}` : '2px solid transparent',
|
||||
marginBottom: -2,
|
||||
letterSpacing: '0.04em',
|
||||
transition: 'color 0.15s, border-color 0.15s',
|
||||
@@ -109,22 +124,22 @@ export default function Preistraeger() {
|
||||
<SelectControl
|
||||
value={filterCategory}
|
||||
onChange={setFilterCategory}
|
||||
placeholder="Preiskategorie …"
|
||||
options={CATEGORIES.map(c => ({ label: c, value: c }))}
|
||||
placeholder={fallbackText(filters.categoryPlaceholder, preistraegerIndexContent.filters.categoryPlaceholder)}
|
||||
options={categories.map(c => ({ label: c, value: c }))}
|
||||
width={isMobile ? undefined : 280}
|
||||
/>
|
||||
<SearchControl value={search} onChange={setSearch} />
|
||||
<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' }}>
|
||||
Diese Erfolgsgeschichten müssen erzählt werden.
|
||||
{fallbackText(filters.storyHeading, preistraegerIndexContent.filters.storyHeading)}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 15 }}>💬</span>
|
||||
<span style={{ fontFamily: FF, fontSize: 15, color: GRAY }}>Mit Medienbeiträgen (Videos & Storys)</span>
|
||||
<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={{
|
||||
@@ -147,7 +162,7 @@ export default function Preistraeger() {
|
||||
onMouseEnter={e => (e.currentTarget.style.color = GOLD)}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = GRAY)}
|
||||
>
|
||||
<X size={14} /> Filter zurücksetzen
|
||||
<X size={14} /> {fallbackText(filters.resetLabel, preistraegerIndexContent.filters.resetLabel)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -157,14 +172,14 @@ export default function Preistraeger() {
|
||||
<div style={{ padding: isMobile ? '12px 24px' : '12px 80px', background: BG_ALT, borderTop: `1px solid ${BORDER}`, borderBottom: `1px solid ${BORDER}`, display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Trophy size={13} style={{ color: GOLD }} fill={GOLD} />
|
||||
<span style={{ fontFamily: FF, fontSize: 15, color: GRAY }}>
|
||||
<strong style={{ color: '#101828' }}>{activeYear}</strong> – {filtered.length} Preisträger:innen
|
||||
<strong style={{ color: '#101828' }}>{selectedYear}</strong> – {filtered.length} {fallbackText(count.label, preistraegerIndexContent.count.label)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: isMobile ? '56px 24px' : '80px', textAlign: 'center', color: GRAY, fontFamily: FF }}>
|
||||
Keine Preisträger für diese Auswahl.
|
||||
{fallbackText(empty.message, preistraegerIndexContent.empty.message)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
@@ -181,16 +196,16 @@ export default function Preistraeger() {
|
||||
scrollbarWidth: 'none',
|
||||
}}>
|
||||
{filtered.map(w => (
|
||||
<WinnerCard key={w.id} winner={w} />
|
||||
<WinnerCard key={w.id} winner={w} hoverLabel={fallbackText(card.hoverLabel, preistraegerIndexContent.card.hoverLabel)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom CTA */}
|
||||
<div style={{ padding: isMobile ? '48px 24px' : '64px 80px', borderTop: `1px solid ${BORDER}`, textAlign: 'center' }}>
|
||||
<p style={{ fontFamily: FF, fontSize: 17, color: GRAY, marginBottom: 20 }}>Werden Sie der nächste Preisträger.</p>
|
||||
<p style={{ fontFamily: FF, fontSize: 17, color: GRAY, marginBottom: 20 }}>{fallbackText(cta.text, preistraegerIndexContent.cta.text)}</p>
|
||||
<Link
|
||||
to="/teilnahme"
|
||||
to={fallbackText(cta.primaryCta?.url, preistraegerIndexContent.cta.primaryCta.url)}
|
||||
style={{
|
||||
fontFamily: FF, fontSize: 16, fontWeight: 700, textTransform: 'uppercase',
|
||||
letterSpacing: '0.12em', color: '#101828', background: GOLD,
|
||||
@@ -200,13 +215,13 @@ export default function Preistraeger() {
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = '#FFD130'; (e.currentTarget as HTMLElement).style.boxShadow = '0 0 18px rgba(239,191,4,0.65), 0 0 40px rgba(239,191,4,0.3)'; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = GOLD; (e.currentTarget as HTMLElement).style.boxShadow = 'none'; }}
|
||||
>
|
||||
Jetzt kostenlos bewerben <ArrowRight size={14} />
|
||||
{fallbackText(cta.primaryCta?.label, preistraegerIndexContent.cta.primaryCta.label)} <ArrowRight size={14} />
|
||||
</Link>
|
||||
<div style={{ marginTop: 20, display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : undefined, flexWrap: isMobile ? 'wrap' : 'nowrap', gap: 8 }}>
|
||||
<span style={{ width: 20, height: 1, background: 'rgba(239,191,4,0.3)', display: 'inline-block' }} />
|
||||
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 11, color: 'rgba(16,24,40,0.4)' }}>Diese Arbeit unterstützen:</span>
|
||||
<span style={{ fontFamily: '"IBM Plex Sans", sans-serif', fontSize: 11, color: 'rgba(16,24,40,0.4)' }}>{fallbackText(cta.secondaryPrefix, preistraegerIndexContent.cta.secondaryPrefix)}</span>
|
||||
<Link
|
||||
to="/mitglied-werden"
|
||||
to={fallbackText(cta.secondaryCta?.url, preistraegerIndexContent.cta.secondaryCta.url)}
|
||||
style={{
|
||||
fontFamily: '"IBM Plex Sans", sans-serif',
|
||||
fontSize: 11,
|
||||
@@ -231,7 +246,7 @@ export default function Preistraeger() {
|
||||
(e.currentTarget as HTMLElement).style.borderBottomColor = 'rgba(239,191,4,0.3)';
|
||||
}}
|
||||
>
|
||||
Vereinsmitglied werden <ChevronRight size={10} />
|
||||
{fallbackText(cta.secondaryCta?.label, preistraegerIndexContent.cta.secondaryCta.label)} <ChevronRight size={10} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,7 +256,7 @@ export default function Preistraeger() {
|
||||
|
||||
// ── WinnerCard ───────────────────────────────────────────────────────────────
|
||||
|
||||
function WinnerCard({ winner }: { winner: import('@/spa/data/winners').Winner }) {
|
||||
function WinnerCard({ winner, hoverLabel }: { winner: Winner; hoverLabel: string }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
return (
|
||||
@@ -288,7 +303,7 @@ function WinnerCard({ winner }: { winner: import('@/spa/data/winners').Winner })
|
||||
transform: hovered ? 'translateY(0)' : 'translateY(6px)',
|
||||
transition: 'opacity 0.25s, transform 0.25s',
|
||||
}}>
|
||||
<Trophy size={14} /> Mehr erfahren
|
||||
<Trophy size={14} /> {hoverLabel}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -330,14 +345,14 @@ function SelectControl({ value, onChange, placeholder, options, width }: {
|
||||
|
||||
// ── SearchControl ────────────────────────────────────────────────────────────
|
||||
|
||||
function SearchControl({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
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="Stichwortsuche …"
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
width: '100%', height: 44, padding: '0 40px 0 14px',
|
||||
fontFamily: FF, fontSize: 16,
|
||||
|
||||
Reference in New Issue
Block a user