feature: search events
Some checks are pending
Deploy / deploy (push) Waiting to run

This commit is contained in:
Benno Tielen 2026-04-30 11:37:13 +02:00
parent 536163f0e2
commit 79dbf005bf
6 changed files with 453 additions and 115 deletions

View file

@ -1,144 +1,148 @@
import moment from 'moment'
import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences'
import { fetchGroup, fetchAllGroups } from '@/fetch/group'
import { fetchParish, fetchAllParishes } from '@/fetch/parish'
import { transformOccurrences } from '@/utils/dto/events'
import { PageHeader } from '@/compositions/PageHeader/PageHeader'
import { Section } from '@/components/Section/Section'
import { Container } from '@/components/Container/Container'
import { Title } from '@/components/Title/Title'
import { NextPrevButtons } from '@/components/NextPrevButtons/NextPrevButtons'
import moment from 'moment'
import { transformOccurrences } from '@/utils/dto/events'
import { weekNumber } from '@/utils/week'
import { EventRow } from '@/components/EventRow/EventRow'
import { fetchHighlightsBetweenDates } from '@/fetch/highlights'
import { Row } from '@/components/Flex/Row'
import { Col } from '@/components/Flex/Col'
import { highlightLink } from '@/utils/dto/highlight'
import Error from '@/components/Error/Error'
import { NextPrevButtons } from '@/components/NextPrevButtons/NextPrevButtons'
import { EventFilterBar } from '@/compositions/EventFilterBar/EventFilterBar'
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
import Error from '@/components/Error/Error'
import { isAuthenticated } from '@/utils/auth'
import { Title } from '@/components/Title/Title'
import { HR } from '@/components/HorizontalRule/HorizontalRule'
import { P } from '@/components/Text/Paragraph'
const DATE_FMT = 'YYYY-MM-DD'
const PAGE_SIZE = 15
export default async function EventsPage({searchParams}: {
searchParams: Promise<{ week: string | undefined }>
type Query = {
group?: string
parish?: string
from?: string
to?: string
page?: string
}
const parseDate = (value: string | undefined): Date | undefined => {
if (!value) return undefined
const m = moment(value, DATE_FMT, true)
return m.isValid() ? m.toDate() : undefined
}
const buildHref = (q: Query, page: number): string => {
const params = new URLSearchParams()
if (q.group) params.set('group', q.group)
if (q.parish) params.set('parish', q.parish)
if (q.from) params.set('from', q.from)
if (q.to) params.set('to', q.to)
if (page > 1) params.set('page', String(page))
const qs = params.toString()
return qs ? `/veranstaltungen/ueberblick?${qs}` : '/veranstaltungen/ueberblick'
}
export default async function EventsOverviewPage({
searchParams,
}: {
searchParams: Promise<Query>
}) {
const authenticated = await isAuthenticated()
const query = await searchParams
const authenticated = await isAuthenticated();
const query = await searchParams;
const limit = 100;
let week = query.week;
if (!week) {
week = weekNumber(moment());
const fromDate = parseDate(query.from) ?? moment().startOf('day').toDate()
const toDate = parseDate(query.to)
const parsedPage = parseInt(query.page ?? '1', 10)
const page = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1
const [allGroups, allParishes, groupDoc, parishDoc] = await Promise.all([
fetchAllGroups(),
fetchAllParishes(),
query.group ? fetchGroup(query.group) : Promise.resolve(null),
query.parish ? fetchParish(query.parish) : Promise.resolve(null),
])
const paginated = await fetchUpcomingOccurrences({
limit: PAGE_SIZE,
page,
fromDate,
toDate,
groupId: groupDoc?.id,
parishId: parishDoc?.id,
})
if (!paginated) {
return <Error statusCode={503} message={'Veranstaltungen konnten nicht geladen werden.'} />
}
const fromDate = moment(week, true);
const events = transformOccurrences(paginated.docs)
if (!fromDate.isValid()) {
return <Error statusCode={422} message={"Woche fehlerhaft formatiert."}/>
const initial = {
group: groupDoc?.slug ?? undefined,
parish: parishDoc?.slug ?? undefined,
from: query.from && parseDate(query.from) ? query.from : undefined,
to: query.to && parseDate(query.to) ? query.to : undefined,
}
const toDate = moment(week).add(1, 'week');
const lastWeek = moment(week).subtract(1, 'week');
const paginatedOccurrences = await fetchUpcomingOccurrences(
{
limit: limit,
fromDate: fromDate.toDate(),
toDate: toDate.toDate()
}
);
const paginatedHighlights = ((await fetchHighlightsBetweenDates(
fromDate.toDate(),
toDate.toDate(),
))?.docs) || [];
if (!paginatedOccurrences) {
return <Error statusCode={503} message={"Veranstaltungen konnten nicht geladen werden."}/>;
}
const events = transformOccurrences(paginatedOccurrences.docs)
return (
<>
<PageHeader
title={"Veranstaltungen"}
description={"Entdecken Sie unsere kommenden Veranstaltungen und seien Sie dabei! Hier finden Sie alle Termine, Informationen und Highlights auf einen Blick."}
/>
<Section padding={"small"} paddingBottom={"large"}>
<Section paddingBottom={"small"}>
<Container>
<Title
title={`Veranstaltungen`}
size={"lg"}
color={"contrast"}
/>
<P width={"3/4"}>
Entdecken Sie unsere kommenden Veranstaltungen und seien Sie dabei! Hier finden Sie alle Termine, Informationen und Highlights auf einen Blick.
</P>
<EventFilterBar
groups={allGroups.docs.map((g) => ({ slug: g.slug, name: g.name }))}
parishes={allParishes.docs.map((p) => ({ slug: p.slug, name: p.name }))}
initial={initial}
/>
</Container>
<HR />
</Section>
<Row>
<Col>
<Title
color={"contrast"}
title={`Woche ${week.substring(5)} - ${week.substring(0, 4)}`}
size={"md"}
/>
<Section padding={'small'} paddingBottom={'large'}>
<Container>
{events.map((e) => (
<EventRow
key={e.id}
date={e.date}
title={e.title}
href={e.href}
location={e.location}
cancelled={e.cancelled}
/>
))}
{events.map(e =>
<EventRow
key={e.id}
date={e.date}
title={e.title}
href={e.href}
location={e.location}
cancelled={e.cancelled}
/>
)}
{events.length == 0 &&
<p>
Keine Veranstaltungen gefunden
</p>
}
</Col>
<Col>
{ paginatedHighlights.length > 0 &&
<>
<Title
color={"base"}
title={`Highlights`}
size={"md"}
/>
{ paginatedHighlights.map(h =>
<EventRow
key={h.id}
date={h.date}
title={h.text}
href={highlightLink(h)}
cancelled={false}
showDate={false}
/>
)}
</>
}
</Col>
</Row>
{events.length === 0 && <p>Keine Veranstaltungen gefunden</p>}
</Container>
</Section>
<AdminMenu
collection={"event"}
isAuthenticated={authenticated}
/>
{/*prevents bots indexing till infinity*/}
{ events.length > 0 &&
<Section padding={"small"}>
<AdminMenu collection={'event'} isAuthenticated={authenticated} />
{(paginated.hasPrevPage || paginated.hasNextPage) && (
<Section padding={'small'}>
<NextPrevButtons
prev={{
href: `/veranstaltungen?week=${weekNumber(lastWeek)}`,
text: "Vorige Woche"
}}
next={{
href: `/veranstaltungen?week=${weekNumber(toDate)}`,
text: "Nächste Woche"
}}
prev={
paginated.hasPrevPage && paginated.prevPage
? { href: buildHref(query, paginated.prevPage), text: 'Vorige Seite' }
: undefined
}
next={
paginated.hasNextPage && paginated.nextPage
? { href: buildHref(query, paginated.nextPage), text: 'Nächste Seite' }
: undefined
}
/>
</Section>
}
)}
</>
)
}
}

View file

@ -0,0 +1,144 @@
import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences'
import { PageHeader } from '@/compositions/PageHeader/PageHeader'
import { Section } from '@/components/Section/Section'
import { Container } from '@/components/Container/Container'
import { Title } from '@/components/Title/Title'
import { NextPrevButtons } from '@/components/NextPrevButtons/NextPrevButtons'
import moment from 'moment'
import { transformOccurrences } from '@/utils/dto/events'
import { weekNumber } from '@/utils/week'
import { EventRow } from '@/components/EventRow/EventRow'
import { fetchHighlightsBetweenDates } from '@/fetch/highlights'
import { Row } from '@/components/Flex/Row'
import { Col } from '@/components/Flex/Col'
import { highlightLink } from '@/utils/dto/highlight'
import Error from '@/components/Error/Error'
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
import { isAuthenticated } from '@/utils/auth'
export default async function EventsPage({searchParams}: {
searchParams: Promise<{ week: string | undefined }>
}) {
const authenticated = await isAuthenticated();
const query = await searchParams;
const limit = 100;
let week = query.week;
if (!week) {
week = weekNumber(moment());
}
const fromDate = moment(week, true);
if (!fromDate.isValid()) {
return <Error statusCode={422} message={"Woche fehlerhaft formatiert."}/>
}
const toDate = moment(week).add(1, 'week');
const lastWeek = moment(week).subtract(1, 'week');
const paginatedOccurrences = await fetchUpcomingOccurrences(
{
limit: limit,
fromDate: fromDate.toDate(),
toDate: toDate.toDate()
}
);
const paginatedHighlights = ((await fetchHighlightsBetweenDates(
fromDate.toDate(),
toDate.toDate(),
))?.docs) || [];
if (!paginatedOccurrences) {
return <Error statusCode={503} message={"Veranstaltungen konnten nicht geladen werden."}/>;
}
const events = transformOccurrences(paginatedOccurrences.docs)
return (
<>
<PageHeader
title={"Veranstaltungen"}
description={"Entdecken Sie unsere kommenden Veranstaltungen und seien Sie dabei! Hier finden Sie alle Termine, Informationen und Highlights auf einen Blick."}
/>
<Section padding={"small"} paddingBottom={"large"}>
<Container>
<Row>
<Col>
<Title
color={"contrast"}
title={`Woche ${week.substring(5)} - ${week.substring(0, 4)}`}
size={"md"}
/>
{events.map(e =>
<EventRow
key={e.id}
date={e.date}
title={e.title}
href={e.href}
location={e.location}
cancelled={e.cancelled}
/>
)}
{events.length == 0 &&
<p>
Keine Veranstaltungen gefunden
</p>
}
</Col>
<Col>
{ paginatedHighlights.length > 0 &&
<>
<Title
color={"base"}
title={`Highlights`}
size={"md"}
/>
{ paginatedHighlights.map(h =>
<EventRow
key={h.id}
date={h.date}
title={h.text}
href={highlightLink(h)}
cancelled={false}
showDate={false}
/>
)}
</>
}
</Col>
</Row>
</Container>
</Section>
<AdminMenu
collection={"event"}
isAuthenticated={authenticated}
/>
{/*prevents bots indexing till infinity*/}
{ events.length > 0 &&
<Section padding={"small"}>
<NextPrevButtons
prev={{
href: `/veranstaltungen?week=${weekNumber(lastWeek)}`,
text: "Vorige Woche"
}}
next={{
href: `/veranstaltungen?week=${weekNumber(toDate)}`,
text: "Nächste Woche"
}}
/>
</Section>
}
</>
)
}

View file

@ -0,0 +1,107 @@
'use client'
import { FormEvent, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/Button/Button'
import styles from './styles.module.scss'
type Option = {
slug: string
name: string
}
type EventFilterBarProps = {
groups: Option[]
parishes: Option[]
initial: {
group?: string
parish?: string
from?: string
to?: string
}
}
export const EventFilterBar = ({ groups, parishes, initial }: EventFilterBarProps) => {
const router = useRouter()
const [group, setGroup] = useState(initial.group ?? '')
const [parish, setParish] = useState(initial.parish ?? '')
const [from, setFrom] = useState(initial.from ?? '')
const [to, setTo] = useState(initial.to ?? '')
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
const params = new URLSearchParams()
if (group) params.set('group', group)
if (parish) params.set('parish', parish)
if (from) params.set('from', from)
if (to) params.set('to', to)
const qs = params.toString()
router.push(qs ? `/veranstaltungen/ueberblick?${qs}` : '/veranstaltungen/ueberblick')
}
return (
<form className={styles.bar} onSubmit={handleSubmit}>
<label className={styles.field}>
<span className={styles.label}>Gemeinde</span>
<select
name="parish"
value={parish}
onChange={(e) => setParish(e.target.value)}
className={styles.input}
>
<option value="">Alle Gemeinden</option>
{parishes.map((p) => (
<option key={p.slug} value={p.slug}>
{p.name}
</option>
))}
</select>
</label>
<label className={styles.field}>
<span className={styles.label}>Gruppe</span>
<select
name="group"
value={group}
onChange={(e) => setGroup(e.target.value)}
className={styles.input}
>
<option value="">Alle Gruppen</option>
{groups.map((g) => (
<option key={g.slug} value={g.slug}>
{g.name}
</option>
))}
</select>
</label>
<label className={styles.field}>
<span className={styles.label}>Von</span>
<input
type="date"
name="from"
value={from}
onChange={(e) => setFrom(e.target.value)}
className={styles.input}
/>
</label>
<label className={styles.field}>
<span className={styles.label}>Bis</span>
<input
type="date"
name="to"
value={to}
onChange={(e) => setTo(e.target.value)}
className={styles.input}
/>
</label>
<div className={styles.actions}>
<Button type="submit" size="md">
Filtern
</Button>
</div>
</form>
)
}

View file

@ -0,0 +1,53 @@
@import "template.scss";
.bar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: flex-end;
margin-bottom: 40px;
}
.field {
display: flex;
flex-direction: column;
gap: 0;
flex: 1 1 180px;
min-width: 180px;
}
.label {
font-size: 11px;
color: $shade1;
}
.input {
background-color: $shade3;
padding: 10px 20px;
font-size: 16px;
border: none;
font-family: inherit;
border-radius: $border-radius;
width: 100%;
box-sizing: border-box;
color: #2c2c2c;
}
.actions {
flex: 0 0 auto;
}
@media screen and (max-width: 576px) {
.field {
flex: 1 1 100%;
}
.actions {
flex: 1 1 100%;
}
.input {
padding: 10px 15px;
font-size: 16px;
}
}

View file

@ -1,3 +1,4 @@
import { unstable_cache } from 'next/cache'
import { getPayload } from 'payload'
import config from '@/payload.config'
@ -14,3 +15,17 @@ export async function fetchGroup(slug: string, draft: boolean = false) {
})
return result.docs[0] ?? null
}
export const fetchAllGroups = unstable_cache(
async () => {
const payload = await getPayload({ config })
return payload.find({
collection: 'group',
limit: 200,
sort: 'name',
select: { id: true, name: true, slug: true },
})
},
['groups-all'],
{ tags: ['groups'], revalidate: 3600 },
)

View file

@ -1,3 +1,4 @@
import { unstable_cache } from 'next/cache'
import { getPayload } from 'payload'
import config from '@/payload.config'
@ -14,3 +15,17 @@ export async function fetchParish(slug: string, draft: boolean = false) {
})
return result.docs[0] ?? null
}
export const fetchAllParishes = unstable_cache(
async () => {
const payload = await getPayload({ config })
return payload.find({
collection: 'parish',
limit: 200,
sort: 'name',
select: { id: true, name: true, slug: true },
})
},
['parishes-all'],
{ tags: ['parishes'], revalidate: 3600 },
)