feature: readability and ux
This commit is contained in:
parent
20b0c0a768
commit
f489ac9d9b
13 changed files with 379 additions and 126 deletions
|
|
@ -0,0 +1,166 @@
|
|||
'use client'
|
||||
|
||||
import { CheckboxInput, useDocumentInfo } from '@payloadcms/ui'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
type Occurrence = {
|
||||
id: string
|
||||
date: string
|
||||
cancelled: boolean
|
||||
}
|
||||
|
||||
const LIMIT = 10
|
||||
|
||||
const formatDate = (iso: string): string =>
|
||||
new Date(iso).toLocaleString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
export const NextOccurrencesTable = () => {
|
||||
const { id } = useDocumentInfo()
|
||||
const [occurrences, setOccurrences] = useState<Occurrence[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [pendingIds, setPendingIds] = useState<Set<string>>(new Set())
|
||||
const [errorIds, setErrorIds] = useState<Set<string>>(new Set())
|
||||
const [fetchError, setFetchError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setFetchError(null)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
'where[event][equals]': String(id),
|
||||
'where[date][greater_than_equal]': new Date().toISOString(),
|
||||
sort: 'date',
|
||||
limit: String(LIMIT),
|
||||
depth: '0',
|
||||
})
|
||||
|
||||
fetch(`/api/eventOccurrence?${params.toString()}`, { credentials: 'include' })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
})
|
||||
.then((json: { docs: Occurrence[] }) => {
|
||||
if (cancelled) return
|
||||
setOccurrences(json.docs)
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (cancelled) return
|
||||
setFetchError(err.message)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id])
|
||||
|
||||
const toggle = useCallback(async (occ: Occurrence) => {
|
||||
const next = !occ.cancelled
|
||||
setOccurrences((prev) =>
|
||||
prev.map((o) => (o.id === occ.id ? { ...o, cancelled: next } : o)),
|
||||
)
|
||||
setPendingIds((prev) => new Set(prev).add(occ.id))
|
||||
setErrorIds((prev) => {
|
||||
const s = new Set(prev)
|
||||
s.delete(occ.id)
|
||||
return s
|
||||
})
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/eventOccurrence/${occ.id}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cancelled: next }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
} catch {
|
||||
setOccurrences((prev) =>
|
||||
prev.map((o) => (o.id === occ.id ? { ...o, cancelled: occ.cancelled } : o)),
|
||||
)
|
||||
setErrorIds((prev) => new Set(prev).add(occ.id))
|
||||
} finally {
|
||||
setPendingIds((prev) => {
|
||||
const s = new Set(prev)
|
||||
s.delete(occ.id)
|
||||
return s
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="field-description">
|
||||
Veranstaltung erst speichern, um Termine zu verwalten.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="field-description">Wird geladen…</div>
|
||||
}
|
||||
|
||||
if (fetchError) {
|
||||
return (
|
||||
<div className="field-description" style={{ color: 'var(--theme-error-500)' }}>
|
||||
Fehler beim Laden: {fetchError}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (occurrences.length === 0) {
|
||||
return (
|
||||
<div className="field-description">
|
||||
Keine zukünftigen Termine vorhanden.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Abgesagt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{occurrences.map((occ) => (
|
||||
<tr key={occ.id}>
|
||||
<td>{formatDate(occ.date)}</td>
|
||||
<td>
|
||||
<CheckboxInput
|
||||
checked={occ.cancelled}
|
||||
onToggle={() => toggle(occ)}
|
||||
readOnly={pendingIds.has(occ.id)}
|
||||
/>
|
||||
{errorIds.has(occ.id) && (
|
||||
<div
|
||||
className="field-description"
|
||||
style={{ color: 'var(--theme-error-500)' }}
|
||||
>
|
||||
Speichern fehlgeschlagen.
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NextOccurrencesTable
|
||||
|
|
@ -4,9 +4,8 @@ import { Blocks } from '@/compositions/Blocks/Blocks'
|
|||
import { Metadata } from 'next'
|
||||
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
||||
import { Section } from '@/components/Section/Section'
|
||||
import { isAuthenticated } from '@/utils/auth'
|
||||
import { getRequestAuth } from '@/utils/auth'
|
||||
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
||||
import { draftMode } from 'next/headers'
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ slug?: string[] }>
|
||||
|
|
@ -26,9 +25,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||
|
||||
export default async function DynamicPage({ params }: Props) {
|
||||
const slug = (await params).slug
|
||||
const { isEnabled: isDraft } = await draftMode()
|
||||
const { authenticated, isDraft } = await getRequestAuth()
|
||||
const page = await fetchPageBySlug(slug?.join('/') || "", isDraft)
|
||||
const authenticated = await isAuthenticated()
|
||||
|
||||
if (!page) {
|
||||
notFound()
|
||||
|
|
|
|||
|
|
@ -9,27 +9,19 @@ import { HR } from '@/components/HorizontalRule/HorizontalRule'
|
|||
import Image from 'next/image'
|
||||
import styles from './styles.module.scss'
|
||||
import { Blocks } from '@/compositions/Blocks/Blocks'
|
||||
import { isAuthenticated } from '@/utils/auth'
|
||||
import { canView, getRequestAuth } from '@/utils/auth'
|
||||
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
||||
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
||||
import { draftMode } from 'next/headers'
|
||||
import { fetchBlog } from '@/fetch/blog'
|
||||
|
||||
export default async function BlogPage({ params }: { params: Promise<{id: string}>}){
|
||||
|
||||
const id = (await params).id;
|
||||
const { isEnabled: isDraft } = await draftMode()
|
||||
const { authenticated, isDraft } = await getRequestAuth()
|
||||
const data = await fetchBlog(id, isDraft) as Blog;
|
||||
const url = typeof data.photo === 'object' && data.photo?.sizes?.banner?.url;
|
||||
const authenticated = await isAuthenticated();
|
||||
|
||||
if(!data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if(!authenticated && data._status !== "published") {
|
||||
notFound();
|
||||
}
|
||||
if (!canView(data, authenticated)) notFound()
|
||||
|
||||
// determine if some margin at the bottom should be added
|
||||
const length = data.content.content.length;
|
||||
|
|
|
|||
|
|
@ -6,25 +6,17 @@ import { fetchParish } from '@/fetch/parish'
|
|||
import { fetchLastAnnouncement } from '@/fetch/announcement'
|
||||
import { getPhoto, transformGallery } from '@/utils/dto/gallery'
|
||||
import { fetchLastCalendar } from '@/fetch/calendar'
|
||||
import { isAuthenticated } from '@/utils/auth'
|
||||
import { canView, getRequestAuth } from '@/utils/auth'
|
||||
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
||||
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
||||
import { draftMode } from 'next/headers'
|
||||
|
||||
export default async function ParishPage ({ params }: { params: Promise<{slug: string}>}) {
|
||||
|
||||
const slug = (await params).slug;
|
||||
const { isEnabled: isDraft } = await draftMode()
|
||||
const { authenticated, isDraft } = await getRequestAuth()
|
||||
const parish = await fetchParish(slug, isDraft);
|
||||
const authenticated = await isAuthenticated();
|
||||
|
||||
if(!parish) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if(!authenticated && parish._status !== "published") {
|
||||
notFound();
|
||||
}
|
||||
if (!canView(parish, authenticated)) notFound()
|
||||
|
||||
const {
|
||||
id,
|
||||
|
|
|
|||
|
|
@ -11,28 +11,19 @@ import { Col } from '@/components/Flex/Col'
|
|||
import { Row } from '@/components/Flex/Row'
|
||||
import { Blocks } from '@/compositions/Blocks/Blocks'
|
||||
import { getPhoto } from '@/utils/dto/gallery'
|
||||
import { isAuthenticated } from '@/utils/auth'
|
||||
import { canView, getRequestAuth } from '@/utils/auth'
|
||||
import { AdminMenu } from '@/components/AdminMenu/AdminMenu'
|
||||
import { GroupEvents } from '@/compositions/GroupEvents/GroupEvents'
|
||||
import { RichText } from '@/components/Text/RichText'
|
||||
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
||||
import { draftMode } from 'next/headers'
|
||||
|
||||
export default async function GroupPage({ params }: { params: Promise<{slug: string}>}) {
|
||||
|
||||
const slug = (await params).slug
|
||||
const { isEnabled: isDraft } = await draftMode()
|
||||
const { authenticated, isDraft } = await getRequestAuth()
|
||||
const group = await fetchGroup(slug, isDraft)
|
||||
|
||||
if(!group) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const authenticated = await isAuthenticated();
|
||||
|
||||
if(!authenticated && group._status !== "published") {
|
||||
notFound();
|
||||
}
|
||||
if (!canView(group, authenticated)) notFound()
|
||||
|
||||
const {id, shortDescription, photo, name, content, text } = group
|
||||
const media = getPhoto("tablet", photo)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import { notFound } from 'next/navigation'
|
||||
import { draftMode } from 'next/headers'
|
||||
import { EventPage } from '@/pageComponents/Event/Event'
|
||||
import { getPhoto } from '@/utils/dto/gallery'
|
||||
import { isAuthenticated } from '@/utils/auth'
|
||||
import { canView, getRequestAuth } from '@/utils/auth'
|
||||
import {
|
||||
fetchOccurrenceById,
|
||||
fetchUpcomingOccurrences,
|
||||
} from '@/fetch/eventOccurrences'
|
||||
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
||||
import { transformOccurrences } from '@/utils/dto/events'
|
||||
import { eventToPageProps, transformOccurrences } from '@/utils/dto/events'
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
|
|
@ -16,22 +15,15 @@ export default async function Page({
|
|||
params: Promise<{ eventId: string; occurrenceId: string }>
|
||||
}) {
|
||||
const { eventId, occurrenceId } = await params
|
||||
const { isEnabled: isDraft } = await draftMode()
|
||||
const { authenticated, isDraft } = await getRequestAuth()
|
||||
|
||||
const occurrence = await fetchOccurrenceById(occurrenceId, isDraft)
|
||||
if (!occurrence) notFound()
|
||||
|
||||
const event = typeof occurrence.event === 'object' ? occurrence.event : undefined
|
||||
if (!event) notFound()
|
||||
if (!canView(event, authenticated)) notFound()
|
||||
if (event.id !== eventId) notFound()
|
||||
|
||||
const authenticated = await isAuthenticated()
|
||||
if (!authenticated && event._status !== 'published') notFound()
|
||||
|
||||
const group =
|
||||
Array.isArray(event.group) && event.group.length > 0 && typeof event.group[0] === 'object'
|
||||
? event.group[0].slug
|
||||
: undefined
|
||||
const photo = getPhoto('tablet', event.photo)
|
||||
|
||||
const upcomingRaw = await fetchUpcomingOccurrences({
|
||||
|
|
@ -45,19 +37,7 @@ export default async function Page({
|
|||
<>
|
||||
{isDraft && <RefreshRouteOnSave />}
|
||||
<EventPage
|
||||
id={event.id}
|
||||
title={event.title}
|
||||
date={occurrence.date}
|
||||
createdAt={event.createdAt}
|
||||
cancelled={event.cancelled || occurrence.cancelled}
|
||||
recurrenceType={event.recurrenceType}
|
||||
location={event.location}
|
||||
description={event.description}
|
||||
shortDescription={event.shortDescription}
|
||||
group={group}
|
||||
contact={event.contact || undefined}
|
||||
rsvpLink={event.rsvpLink || undefined}
|
||||
flyer={typeof event.flyer === 'object' ? event.flyer || undefined : undefined}
|
||||
{...eventToPageProps(event, occurrence)}
|
||||
photo={photo}
|
||||
isAuthenticated={authenticated}
|
||||
upcomingOccurrences={upcomingOccurrences}
|
||||
|
|
|
|||
|
|
@ -1,79 +1,31 @@
|
|||
import { notFound, redirect } from 'next/navigation'
|
||||
import { draftMode } from 'next/headers'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { EventPage } from '@/pageComponents/Event/Event'
|
||||
import { getPhoto } from '@/utils/dto/gallery'
|
||||
import { isAuthenticated } from '@/utils/auth'
|
||||
import { canView, getRequestAuth } from '@/utils/auth'
|
||||
import { fetchEventById } from '@/fetch/events'
|
||||
import { fetchUpcomingOccurrences } from '@/fetch/eventOccurrences'
|
||||
import { fetchUpcomingOrPastOccurrences } from '@/fetch/eventOccurrences'
|
||||
import { RefreshRouteOnSave } from '@/components/RefreshRouteOnSave/RefreshRouteOnSave'
|
||||
import { getPayload } from 'payload'
|
||||
import config from '@/payload.config'
|
||||
import { eventToPageProps, transformOccurrences } from '@/utils/dto/events'
|
||||
|
||||
/**
|
||||
* Entry route per event. Redirects to the current next-upcoming occurrence
|
||||
* when one exists so shared/search URLs land on the canonical detail page.
|
||||
* Falls back to an inline render when there are no occurrences yet (fresh
|
||||
* event, draft preview) so live preview stays responsive.
|
||||
*/
|
||||
export default async function Page({ params }: { params: Promise<{ eventId: string }> }) {
|
||||
const { eventId } = await params
|
||||
const { isEnabled: isDraft } = await draftMode()
|
||||
const authenticated = await isAuthenticated()
|
||||
const { authenticated, isDraft } = await getRequestAuth()
|
||||
|
||||
const event = await fetchEventById(eventId, isDraft)
|
||||
if (!event) notFound()
|
||||
if (!authenticated && event._status !== 'published') notFound()
|
||||
if (!canView(event, authenticated)) notFound()
|
||||
|
||||
// Try next upcoming first, then most recent past as a fallback.
|
||||
const upcoming = await fetchUpcomingOccurrences({ eventId, limit: 1 })
|
||||
if (upcoming.docs.length > 0) {
|
||||
redirect(`/veranstaltungen/${eventId}/${upcoming.docs[0].id}`)
|
||||
}
|
||||
|
||||
const payload = await getPayload({ config })
|
||||
const past = await payload.find({
|
||||
collection: 'eventOccurrence',
|
||||
where: {
|
||||
and: [
|
||||
{ event: { equals: eventId } },
|
||||
{ date: { less_than: new Date().toISOString() } },
|
||||
],
|
||||
},
|
||||
sort: '-date',
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
})
|
||||
if (past.docs.length > 0) {
|
||||
redirect(`/veranstaltungen/${eventId}/${past.docs[0].id}`)
|
||||
}
|
||||
|
||||
// No occurrences yet (e.g. draft preview right after save). Render from
|
||||
// the event's own date so editors still see their content.
|
||||
const group =
|
||||
Array.isArray(event.group) && event.group.length > 0 && typeof event.group[0] === 'object'
|
||||
? event.group[0].slug
|
||||
: undefined
|
||||
const occurrences = await fetchUpcomingOrPastOccurrences({ eventId, limit: 5 })
|
||||
const upcomingOccurrences = transformOccurrences(occurrences.docs)
|
||||
const photo = getPhoto('tablet', event.photo)
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDraft && <RefreshRouteOnSave />}
|
||||
<EventPage
|
||||
id={event.id}
|
||||
title={event.title}
|
||||
date={event.date}
|
||||
createdAt={event.createdAt}
|
||||
cancelled={event.cancelled}
|
||||
recurrenceType={event.recurrenceType}
|
||||
location={event.location}
|
||||
description={event.description}
|
||||
shortDescription={event.shortDescription}
|
||||
group={group}
|
||||
contact={event.contact || undefined}
|
||||
rsvpLink={event.rsvpLink || undefined}
|
||||
flyer={typeof event.flyer === 'object' ? event.flyer || undefined : undefined}
|
||||
{...eventToPageProps(event)}
|
||||
photo={photo}
|
||||
isAuthenticated={authenticated}
|
||||
upcomingOccurrences={upcomingOccurrences}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1e
|
|||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { NextOccurrencesTable as NextOccurrencesTable_5b561f1308e1f6674a6813c6174350fc } from '@/admin/components/NextOccurrencesTable/NextOccurrencesTable'
|
||||
import { LinkToDoc as LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
|
||||
import { ReindexButton as ReindexButton_aead06e4cbf6b2620c5c51c9ab283634 } from '@payloadcms/plugin-search/client'
|
||||
import { default as default_9bcae99938dc292be0063ce32055e14c } from '../../../components/Logo/Logo'
|
||||
|
|
@ -33,6 +34,7 @@ export const importMap = {
|
|||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@/admin/components/NextOccurrencesTable/NextOccurrencesTable#NextOccurrencesTable": NextOccurrencesTable_5b561f1308e1f6674a6813c6174350fc,
|
||||
"@payloadcms/plugin-search/client#LinkToDoc": LinkToDoc_aead06e4cbf6b2620c5c51c9ab283634,
|
||||
"@payloadcms/plugin-search/client#ReindexButton": ReindexButton_aead06e4cbf6b2620c5c51c9ab283634,
|
||||
"/components/Logo/Logo#default": default_9bcae99938dc292be0063ce32055e14c,
|
||||
|
|
|
|||
|
|
@ -226,6 +226,22 @@ export const Events: CollectionConfig = {
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: { de: 'Nächste Termine' },
|
||||
fields: [
|
||||
{
|
||||
name: 'nextOccurrences',
|
||||
type: 'ui',
|
||||
admin: {
|
||||
components: {
|
||||
Field: {
|
||||
path: '@/admin/components/NextOccurrencesTable/NextOccurrencesTable#NextOccurrencesTable',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
@ -265,13 +281,31 @@ export const Events: CollectionConfig = {
|
|||
},
|
||||
hooks: {
|
||||
afterChange: [
|
||||
async ({ doc, req }) => {
|
||||
async ({ doc, previousDoc, req }) => {
|
||||
try {
|
||||
await regenerateOccurrencesForEvent({
|
||||
event: doc,
|
||||
payload: req.payload,
|
||||
req,
|
||||
})
|
||||
|
||||
// Propagate event.cancelled to all future occurrences when it flips,
|
||||
// so toggling the event-level flag acts as a master switch. Skipped
|
||||
// when unchanged so unrelated saves don't overwrite per-occurrence
|
||||
// cancellations set from the "Nächste Termine" tab.
|
||||
if (doc.cancelled !== previousDoc?.cancelled) {
|
||||
await req.payload.update({
|
||||
collection: 'eventOccurrence',
|
||||
where: {
|
||||
and: [
|
||||
{ event: { equals: doc.id } },
|
||||
{ date: { greater_than_equal: new Date().toISOString() } },
|
||||
],
|
||||
},
|
||||
data: { cancelled: doc.cancelled },
|
||||
req,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
req.payload.logger.error(
|
||||
{ err, eventId: doc.id },
|
||||
|
|
|
|||
|
|
@ -60,6 +60,66 @@ export async function fetchUpcomingOccurrences(
|
|||
}) as Promise<PaginatedDocs<EventOccurrence>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch past event occurrences (most recent first), joined to their parent
|
||||
* event. Always filters to occurrences whose parent event is published.
|
||||
*/
|
||||
export async function fetchPastOccurrences(
|
||||
args?: ListArgs,
|
||||
): Promise<PaginatedDocs<EventOccurrence>> {
|
||||
const {
|
||||
parishId,
|
||||
groupId,
|
||||
eventId,
|
||||
limit = 30,
|
||||
page = 0,
|
||||
fromDate,
|
||||
toDate = new Date(),
|
||||
} = args || {}
|
||||
|
||||
const query: any = {
|
||||
and: [
|
||||
{ date: { less_than: toDate.toISOString() } },
|
||||
{ 'event._status': { equals: 'published' } },
|
||||
],
|
||||
}
|
||||
|
||||
if (fromDate) {
|
||||
query.and.push({ date: { greater_than_equal: fromDate.toISOString() } })
|
||||
}
|
||||
if (eventId) {
|
||||
query.and.push({ event: { equals: eventId } })
|
||||
}
|
||||
if (parishId) {
|
||||
query.and.push({ 'event.parish': { equals: parishId } })
|
||||
}
|
||||
if (groupId) {
|
||||
query.and.push({ 'event.group': { equals: groupId } })
|
||||
}
|
||||
|
||||
const payload = await getPayload({ config })
|
||||
return payload.find({
|
||||
collection: 'eventOccurrence',
|
||||
sort: '-date',
|
||||
where: query,
|
||||
depth: 2,
|
||||
limit,
|
||||
page,
|
||||
}) as Promise<PaginatedDocs<EventOccurrence>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch upcoming occurrences, falling back to past occurrences if there
|
||||
* are no upcoming ones.
|
||||
*/
|
||||
export async function fetchUpcomingOrPastOccurrences(
|
||||
args?: ListArgs,
|
||||
): Promise<PaginatedDocs<EventOccurrence>> {
|
||||
const upcoming = await fetchUpcomingOccurrences(args)
|
||||
if (upcoming.docs.length > 0) return upcoming
|
||||
return fetchPastOccurrences(args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single occurrence by id with its parent event populated.
|
||||
* Returns undefined if not found or if the id is malformed.
|
||||
|
|
|
|||
|
|
@ -67,6 +67,29 @@ export const regenerateOccurrencesForEvent = async ({
|
|||
}): Promise<GenerateEventOccurrencesOutput> => {
|
||||
const horizon = new Date(now.getTime() + weeksAhead * MS_PER_WEEK)
|
||||
|
||||
// Snapshot cancelled dates so manual cancellations survive the wipe/regen.
|
||||
// Matching by ISO date works because regen derives timestamps from the same
|
||||
// event.date stepped by whole calendar days — shifts in event.date
|
||||
// intentionally drop stale cancellations.
|
||||
const existing = await payload.find({
|
||||
collection: 'eventOccurrence',
|
||||
where: {
|
||||
and: [
|
||||
{ event: { equals: event.id } },
|
||||
{ date: { greater_than_equal: now.toISOString() } },
|
||||
],
|
||||
},
|
||||
depth: 0,
|
||||
limit: 1000,
|
||||
pagination: false,
|
||||
req,
|
||||
})
|
||||
const cancelledDates = new Set(
|
||||
existing.docs
|
||||
.filter((occ) => occ.cancelled === true && typeof occ.date === 'string')
|
||||
.map((occ) => new Date(occ.date as string).toISOString()),
|
||||
)
|
||||
|
||||
const wipe = await payload.delete({
|
||||
collection: 'eventOccurrence',
|
||||
where: {
|
||||
|
|
@ -100,7 +123,7 @@ export const regenerateOccurrencesForEvent = async ({
|
|||
data: {
|
||||
event: event.id,
|
||||
date: eventDate.toISOString(),
|
||||
cancelled: false,
|
||||
cancelled: cancelledDates.has(eventDate.toISOString()),
|
||||
generated: true,
|
||||
},
|
||||
req,
|
||||
|
|
@ -120,12 +143,13 @@ export const regenerateOccurrencesForEvent = async ({
|
|||
const cursor = new Date(eventDate)
|
||||
while (cursor.getTime() <= effectiveEnd.getTime()) {
|
||||
if (cursor.getTime() >= now.getTime()) {
|
||||
const iso = cursor.toISOString()
|
||||
await payload.create({
|
||||
collection: 'eventOccurrence',
|
||||
data: {
|
||||
event: event.id,
|
||||
date: cursor.toISOString(),
|
||||
cancelled: false,
|
||||
date: iso,
|
||||
cancelled: cancelledDates.has(iso),
|
||||
generated: true,
|
||||
},
|
||||
req,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { cookies } from 'next/headers'
|
||||
import { cookies, draftMode } from 'next/headers'
|
||||
|
||||
/**
|
||||
* Check if the current user is (trying to be) authenticated by
|
||||
|
|
@ -15,4 +15,33 @@ export const isAuthenticated = async (): Promise<boolean> => {
|
|||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the per-request auth context used by server pages that need both
|
||||
* authentication state and Next's draft-mode flag.
|
||||
*/
|
||||
export const getRequestAuth = async (): Promise<{ authenticated: boolean; isDraft: boolean }> => {
|
||||
const [authenticated, { isEnabled: isDraft }] = await Promise.all([
|
||||
isAuthenticated(),
|
||||
draftMode(),
|
||||
])
|
||||
return { authenticated, isDraft }
|
||||
}
|
||||
|
||||
type Publishable = { _status?: ('draft' | 'published') | null }
|
||||
|
||||
/**
|
||||
* Determines if the given entity can be viewed based on its status and authentication state.
|
||||
*
|
||||
* @param {T | null | undefined} entity - The entity to check, which may be null or undefined.
|
||||
* @param {boolean} authenticated - Indicates whether the user is authenticated.
|
||||
* @return {entity is T} Returns true if the entity can be viewed, otherwise false.
|
||||
*/
|
||||
export function canView<T extends Publishable>(
|
||||
entity: T | null | undefined,
|
||||
authenticated: boolean,
|
||||
): entity is T {
|
||||
if (!entity) return false
|
||||
return authenticated || entity._status === 'published'
|
||||
}
|
||||
|
|
@ -1,4 +1,37 @@
|
|||
import type { ComponentProps } from 'react'
|
||||
import { Event, EventOccurrence } from '@/payload-types'
|
||||
import type { EventPage } from '@/pageComponents/Event/Event'
|
||||
|
||||
export const getEventGroupSlug = (event: Event): string | undefined => {
|
||||
const firstGroup = event.group?.[0]
|
||||
if (typeof firstGroup !== 'object' || firstGroup === null) return undefined
|
||||
return firstGroup.slug ?? undefined
|
||||
}
|
||||
|
||||
type EventPageProps = ComponentProps<typeof EventPage>
|
||||
type EventPagePropsAdapted = Omit<
|
||||
EventPageProps,
|
||||
'isAuthenticated' | 'upcomingOccurrences' | 'photo'
|
||||
>
|
||||
|
||||
export const eventToPageProps = (
|
||||
event: Event,
|
||||
occurrence?: EventOccurrence,
|
||||
): EventPagePropsAdapted => ({
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
date: occurrence?.date ?? event.date,
|
||||
createdAt: event.createdAt,
|
||||
cancelled: Boolean(event.cancelled || occurrence?.cancelled),
|
||||
recurrenceType: event.recurrenceType,
|
||||
location: event.location,
|
||||
description: event.description,
|
||||
shortDescription: event.shortDescription,
|
||||
group: getEventGroupSlug(event),
|
||||
contact: event.contact || undefined,
|
||||
rsvpLink: event.rsvpLink || undefined,
|
||||
flyer: typeof event.flyer === 'object' ? event.flyer || undefined : undefined,
|
||||
})
|
||||
|
||||
type EventRowItem = {
|
||||
id: string
|
||||
|
|
|
|||
Loading…
Reference in a new issue