diff --git a/src/app/(home)/veranstaltungen/page.tsx b/src/app/(home)/veranstaltungen/page.tsx index c5c1fd3..d1faf2b 100644 --- a/src/app/(home)/veranstaltungen/page.tsx +++ b/src/app/(home)/veranstaltungen/page.tsx @@ -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 }) { + 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 } - const fromDate = moment(week, true); + const events = transformOccurrences(paginated.docs) - if (!fromDate.isValid()) { - return + 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 ; - } - - const events = transformOccurrences(paginatedOccurrences.docs) - return ( <> - - -
+
+ + <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> - } + )} </> ) -} \ No newline at end of file +} diff --git a/src/app/(home)/veranstaltungen/woche/page.tsx b/src/app/(home)/veranstaltungen/woche/page.tsx new file mode 100644 index 0000000..c5c1fd3 --- /dev/null +++ b/src/app/(home)/veranstaltungen/woche/page.tsx @@ -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> + } + </> + ) +} \ No newline at end of file diff --git a/src/compositions/EventFilterBar/EventFilterBar.tsx b/src/compositions/EventFilterBar/EventFilterBar.tsx new file mode 100644 index 0000000..975e58f --- /dev/null +++ b/src/compositions/EventFilterBar/EventFilterBar.tsx @@ -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> + ) +} diff --git a/src/compositions/EventFilterBar/styles.module.scss b/src/compositions/EventFilterBar/styles.module.scss new file mode 100644 index 0000000..100c0e5 --- /dev/null +++ b/src/compositions/EventFilterBar/styles.module.scss @@ -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; + } +} diff --git a/src/fetch/group.ts b/src/fetch/group.ts index 75927c6..4054f4b 100644 --- a/src/fetch/group.ts +++ b/src/fetch/group.ts @@ -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 }, +) diff --git a/src/fetch/parish.ts b/src/fetch/parish.ts index bad9881..945518e 100644 --- a/src/fetch/parish.ts +++ b/src/fetch/parish.ts @@ -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 }, +)