From 13a800efc0ea073b218a34dadd791cb3ab6b946b Mon Sep 17 00:00:00 2001 From: InnerDesires Date: Sat, 15 Aug 2026 01:27:52 +0300 Subject: [PATCH 01/11] feat: events with enrollment, calendar links and embeddable blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - events collection (drafts, uk/en locales, local/virtual location, cover, capacity) with published-only public reads; meeting link field-gated to authenticated reads and served to registered users via a server action - event-enrollments collection: unique user×event, rate-limited creates, user binding on REST, published/not-finished/capacity rules in hooks, owner-cancellable, cascaded from events and users deletes - frontend: /events with upcoming/past tabs, featured next-event card with live countdown; event page with enroll/unenroll, seats left, address/map for offline and a platform-aware join card for online events; add to calendar via Google link and public .ics endpoint (meeting link never leaks into static HTML or calendar files) - eventsBlock embeddable in posts (Lexical) and pages (layout), upcoming or hand-picked selection - profile shows upcoming registered events; events indexed in search - idempotent migration rehearsed on a ci-base fork (up, down, re-up) - 18 integration tests + e2e smoke specs --- .../[locale]/events/[slug]/page.tsx | 294 ++++++++++++++++++ src/app/(frontend)/[locale]/events/actions.ts | 183 +++++++++++ src/app/(frontend)/[locale]/events/page.tsx | 69 ++++ src/app/(frontend)/[locale]/profile/page.tsx | 60 +++- src/app/api/events/[id]/calendar.ics/route.ts | 76 +++++ src/blocks/EventsBlock/Component.tsx | 131 ++++++++ src/blocks/EventsBlock/config.ts | 77 +++++ src/blocks/RenderBlocks.tsx | 2 + src/collections/EventEnrollments.ts | 149 +++++++++ src/collections/Events.ts | 203 ++++++++++++ src/collections/Pages/index.ts | 3 +- src/collections/Posts/index.ts | 3 +- src/collections/Users/index.ts | 5 + src/components/Events/AddToCalendar.tsx | 49 +++ src/components/Events/EventActionBar.tsx | 127 ++++++++ src/components/Events/EventCard.tsx | 125 ++++++++ src/components/Events/EventJoinCard.tsx | 87 ++++++ src/components/Events/EventUserState.tsx | 82 +++++ src/components/Events/EventsExplorer.tsx | 222 +++++++++++++ .../Events/useMyEventEnrollments.ts | 38 +++ src/components/RichText/WithArchive.tsx | 21 +- src/hooks/revalidateEvent.ts | 52 ++++ src/migrations/20260815_120000_events.ts | 293 +++++++++++++++++ src/migrations/index.ts | 6 + src/payload-types.ts | 139 ++++++++- src/payload.config.ts | 4 + src/search/beforeSync.ts | 16 + src/search/localeSync.ts | 1 + src/utilities/eventTime.ts | 107 +++++++ src/utilities/i18n.ts | 129 ++++++++ tests/e2e/events.e2e.spec.ts | 64 ++++ tests/helpers/factories.ts | 22 ++ tests/int/event-enrollments.int.spec.ts | 257 +++++++++++++++ tests/int/events.int.spec.ts | 165 ++++++++++ 34 files changed, 3251 insertions(+), 10 deletions(-) create mode 100644 src/app/(frontend)/[locale]/events/[slug]/page.tsx create mode 100644 src/app/(frontend)/[locale]/events/actions.ts create mode 100644 src/app/(frontend)/[locale]/events/page.tsx create mode 100644 src/app/api/events/[id]/calendar.ics/route.ts create mode 100644 src/blocks/EventsBlock/Component.tsx create mode 100644 src/blocks/EventsBlock/config.ts create mode 100644 src/collections/EventEnrollments.ts create mode 100644 src/collections/Events.ts create mode 100644 src/components/Events/AddToCalendar.tsx create mode 100644 src/components/Events/EventActionBar.tsx create mode 100644 src/components/Events/EventCard.tsx create mode 100644 src/components/Events/EventJoinCard.tsx create mode 100644 src/components/Events/EventUserState.tsx create mode 100644 src/components/Events/EventsExplorer.tsx create mode 100644 src/components/Events/useMyEventEnrollments.ts create mode 100644 src/hooks/revalidateEvent.ts create mode 100644 src/migrations/20260815_120000_events.ts create mode 100644 src/utilities/eventTime.ts create mode 100644 tests/e2e/events.e2e.spec.ts create mode 100644 tests/int/event-enrollments.int.spec.ts create mode 100644 tests/int/events.int.spec.ts diff --git a/src/app/(frontend)/[locale]/events/[slug]/page.tsx b/src/app/(frontend)/[locale]/events/[slug]/page.tsx new file mode 100644 index 0000000..6556e39 --- /dev/null +++ b/src/app/(frontend)/[locale]/events/[slug]/page.tsx @@ -0,0 +1,294 @@ +import type { Metadata } from 'next/types' +import { getPayload } from 'payload' +import configPromise from '@payload-config' +import React from 'react' +import { notFound } from 'next/navigation' +import Link from 'next/link' +import Image from 'next/image' +import { ArrowLeft, CalendarDays, Clock, MapPin, Users, Video } from 'lucide-react' + +import { locales, type SiteLocale } from '@/utilities/locales' +import { getFrontendMessages } from '@/utilities/i18n' +import { plural } from '@/utilities/plural' +import type { Event, Media as MediaType } from '@/payload-types' +import { getServerSideURL } from '@/utilities/getURL' +import { + formatEventDate, + formatEventRange, + formatEventTime, + getEventTimes, + isEventPast, +} from '@/utilities/eventTime' +import { EventUserStateProvider } from '@/components/Events/EventUserState' +import { EventActionBar } from '@/components/Events/EventActionBar' +import { EventJoinCard } from '@/components/Events/EventJoinCard' +import { AddToCalendar } from '@/components/Events/AddToCalendar' + +export const revalidate = 300 + +export async function generateStaticParams() { + const payload = await getPayload({ config: configPromise }) + const events = await payload.find({ + collection: 'events', + draft: false, + limit: 1000, + overrideAccess: false, + pagination: false, + where: { _status: { equals: 'published' } }, + select: { slug: true }, + }) + + return events.docs.flatMap(({ slug }) => locales.map((locale) => ({ locale, slug }))) +} + +type Args = { + params: Promise<{ locale: SiteLocale; slug: string }> +} + +async function queryEventBySlug(locale: SiteLocale, slug: string): Promise { + const payload = await getPayload({ config: configPromise }) + // overrideAccess: false — published only, meeting link stripped from the + // static render (registered users fetch it client-side). + const result = await payload.find({ + collection: 'events', + locale, + depth: 1, + draft: false, + overrideAccess: false, + where: { + slug: { equals: slug }, + _status: { equals: 'published' }, + }, + limit: 1, + }) + return result.docs[0] as Event | undefined +} + +export default async function EventPage({ params: paramsPromise }: Args) { + const { locale, slug } = await paramsPromise + const t = getFrontendMessages(locale) + const payload = await getPayload({ config: configPromise }) + + const event = await queryEventBySlug(locale, slug) + if (!event) notFound() + + const { totalDocs: enrolledCount } = await payload.count({ + collection: 'event-enrollments', + where: { event: { equals: event.id } }, + }) + + const cover = event.cover && typeof event.cover === 'object' ? (event.cover as MediaType) : null + const coverUrl = cover?.sizes?.large?.url || cover?.sizes?.xlarge?.url || cover?.url + const prefix = locale === 'en' ? '/en' : '' + const past = isEventPast(event) + const isFull = typeof event.capacity === 'number' && enrolledCount >= event.capacity + const seatsLeft = + typeof event.capacity === 'number' ? Math.max(event.capacity - enrolledCount, 0) : null + const { startsAt, endsAt } = getEventTimes(event) + const multiDay = startsAt.toDateString() !== endsAt.toDateString() + const eventUrl = `${getServerSideURL()}${prefix}/events/${event.slug}` + + return ( + +
+
+ {coverUrl && ( + + )} +
+
+ + + {t.eventBackToEvents} + + +
+ + {event.locationType === 'virtual' ? t.eventOnline : t.eventOffline} + + {past && ( + + {t.eventFinished} + + )} + {!past && seatsLeft !== null && !isFull && ( + + {t.eventSeatsLeft} {seatsLeft} + + )} +
+ +

+ {event.title} +

+ + {event.description && ( +

+ {event.description} +

+ )} + +
+ + + {formatEventRange(event, locale)} + + {multiDay && ( + + + {formatEventTime(startsAt, locale)} + + )} + {enrolledCount > 0 && ( + + + {enrolledCount} {plural(locale, enrolledCount, t.eventParticipantsPlural)} + + )} +
+ +
+ +
+
+
+ +
+
+
+ {event.locationType === 'local' ? ( +
+

+ + + + {t.eventLocationTitle} +

+ {event.address && ( +

{event.address}

+ )} + {event.mapLink && ( + + + {t.eventOpenMap} + + )} +
+ ) : ( + + )} + + {!past && ( +
+ +
+ )} +
+ +
+

+ {event.locationType === 'virtual' ? ( + + + + {t.eventOnline} + + ) : ( + + + + + {formatEventDate(startsAt, locale)} + + )} +

+
+
+
{t.eventStartLabel}
+
+ {formatEventDate(startsAt, locale)}, {formatEventTime(startsAt, locale)} +
+
+ {event.endDate && ( +
+
{t.eventEndLabel}
+
+ {formatEventDate(endsAt, locale)}, {formatEventTime(endsAt, locale)} +
+
+ )} + {event.capacity != null && ( +
+
{t.eventSeatsLeft}
+
+ {seatsLeft} / {event.capacity} +
+
+ )} +
+
+
+
+
+ + ) +} + +export async function generateMetadata({ params: paramsPromise }: Args): Promise { + const { locale, slug } = await paramsPromise + const payload = await getPayload({ config: configPromise }) + const result = await payload.find({ + collection: 'events', + locale, + draft: false, + overrideAccess: false, + where: { slug: { equals: slug }, _status: { equals: 'published' } }, + limit: 1, + depth: 0, + select: { title: true }, + }) + const event = result.docs[0] + if (!event) return {} + return { title: `${event.title} | Залізна Зміна` } +} diff --git a/src/app/(frontend)/[locale]/events/actions.ts b/src/app/(frontend)/[locale]/events/actions.ts new file mode 100644 index 0000000..3b724a7 --- /dev/null +++ b/src/app/(frontend)/[locale]/events/actions.ts @@ -0,0 +1,183 @@ +'use server' + +import { APIError, getPayload } from 'payload' +import configPromise from '@payload-config' +import { revalidatePath } from 'next/cache' +import { getSession } from '@/lib/auth/getSession' +import type { Event, EventEnrollment } from '@/payload-types' + +function revalidateEventPages(slug: string) { + for (const prefix of ['', '/en']) { + revalidatePath(`${prefix}/events/${slug}`) + revalidatePath(`${prefix}/events`) + } +} + +export async function getMyEventEnrollments(): Promise { + const session = await getSession().catch(() => null) + if (!session?.user) return [] + + const payload = await getPayload({ config: configPromise }) + const { docs } = await payload.find({ + collection: 'event-enrollments', + where: { user: { equals: Number(session.user.id) } }, + limit: 1000, + depth: 0, + select: { event: true }, + }) + + return docs.map((enrollment) => + typeof enrollment.event === 'object' ? enrollment.event.id : enrollment.event, + ) +} + +export async function getEventEnrollment(eventId: number): Promise { + const session = await getSession() + if (!session?.user) return null + + const payload = await getPayload({ config: configPromise }) + + const result = await payload.find({ + collection: 'event-enrollments', + where: { + and: [{ user: { equals: session.user.id } }, { event: { equals: eventId } }], + }, + limit: 1, + depth: 0, + }) + + return result.docs[0] ?? null +} + +export async function enrollInEvent( + eventId: number, +): Promise<{ success: boolean; enrollment?: EventEnrollment; error?: string }> { + const session = await getSession() + if (!session?.user) { + return { success: false, error: 'Необхідно увійти в акаунт' } + } + + const payload = await getPayload({ config: configPromise }) + + const existing = await payload.find({ + collection: 'event-enrollments', + where: { + and: [{ user: { equals: session.user.id } }, { event: { equals: eventId } }], + }, + limit: 1, + }) + + if (existing.totalDocs > 0) { + return { success: true, enrollment: existing.docs[0] } + } + + let enrollment: EventEnrollment + try { + // The collection's beforeValidate hook enforces the business rules + // (published event, not finished, free seats, no duplicates). + enrollment = await payload.create({ + collection: 'event-enrollments', + data: { + user: Number(session.user.id), + event: eventId, + }, + }) + } catch (err) { + if (err instanceof APIError && err.status === 429) { + return { success: false, error: 'Забагато запитів. Спробуйте пізніше.' } + } + if (err instanceof APIError && err.status < 500) { + return { success: false, error: err.message } + } + throw err + } + + const event = (await payload.findByID({ + collection: 'events', + id: eventId, + depth: 0, + })) as Event + revalidateEventPages(event.slug) + + return { success: true, enrollment } +} + +export async function unenrollFromEvent( + eventId: number, +): Promise<{ success: boolean; error?: string }> { + const session = await getSession() + if (!session?.user) { + return { success: false, error: 'Необхідно увійти в акаунт' } + } + + const payload = await getPayload({ config: configPromise }) + + const existing = await payload.find({ + collection: 'event-enrollments', + where: { + and: [{ user: { equals: session.user.id } }, { event: { equals: eventId } }], + }, + limit: 1, + depth: 0, + }) + + // Already not registered — nothing to undo. + if (existing.totalDocs === 0) { + return { success: true } + } + + await payload.delete({ + collection: 'event-enrollments', + id: existing.docs[0].id, + }) + + const event = (await payload.findByID({ + collection: 'events', + id: eventId, + depth: 0, + disableErrors: true, + })) as Event | null + if (event) revalidateEventPages(event.slug) + + return { success: true } +} + +export type EventJoinInfo = { + meetingLink: string | null + enrolled: boolean +} + +/** + * The meeting link is stripped from public reads (field access) and never + * rendered into the shared ISR page — enrolled users fetch it here after + * hydration. + */ +export async function getEventJoinInfo(eventId: number): Promise { + const session = await getSession() + if (!session?.user) return { meetingLink: null, enrolled: false } + + const payload = await getPayload({ config: configPromise }) + + const enrollment = await payload.find({ + collection: 'event-enrollments', + where: { + and: [{ user: { equals: session.user.id } }, { event: { equals: eventId } }], + }, + limit: 1, + depth: 0, + }) + if (enrollment.totalDocs === 0) return { meetingLink: null, enrolled: false } + + const event = (await payload.findByID({ + collection: 'events', + id: eventId, + depth: 0, + disableErrors: true, + })) as Event | null + + if (!event || event._status !== 'published' || event.locationType !== 'virtual') { + return { meetingLink: null, enrolled: true } + } + + return { meetingLink: event.meetingLink ?? null, enrolled: true } +} diff --git a/src/app/(frontend)/[locale]/events/page.tsx b/src/app/(frontend)/[locale]/events/page.tsx new file mode 100644 index 0000000..30e9c97 --- /dev/null +++ b/src/app/(frontend)/[locale]/events/page.tsx @@ -0,0 +1,69 @@ +import type { Metadata } from 'next/types' +import { getPayload } from 'payload' +import configPromise from '@payload-config' +import React from 'react' + +import { locales, type SiteLocale } from '@/utilities/locales' +import { getFrontendMessages } from '@/utilities/i18n' +import { PageHead } from '@/components/brand' +import { EventsExplorer } from '@/components/Events/EventsExplorer' +import type { EventCardData } from '@/components/Events/EventCard' + +export const revalidate = 300 + +export function generateStaticParams() { + return locales.map((locale) => ({ locale })) +} + +type Args = { + params: Promise<{ locale: SiteLocale }> +} + +export default async function EventsPage({ params: paramsPromise }: Args) { + const { locale } = await paramsPromise + const t = getFrontendMessages(locale) + const payload = await getPayload({ config: configPromise }) + + // overrideAccess: false keeps this to published events and strips the + // registration-only meeting link out of the shared ISR cache. + const { docs } = await payload.find({ + collection: 'events', + locale, + draft: false, + overrideAccess: false, + where: { _status: { equals: 'published' } }, + sort: '-startDate', + limit: 200, + depth: 1, + select: { + slug: true, + title: true, + description: true, + cover: true, + startDate: true, + endDate: true, + locationType: true, + address: true, + capacity: true, + }, + }) + + return ( +
+ {t.eventsTitle}} sub={t.eventsSub} /> +
+ +
+
+ ) +} + +export async function generateMetadata({ params: paramsPromise }: Args): Promise { + const { locale } = await paramsPromise + const t = getFrontendMessages(locale) + return { title: t.eventsMetaTitle } +} diff --git a/src/app/(frontend)/[locale]/profile/page.tsx b/src/app/(frontend)/[locale]/profile/page.tsx index ec6af19..faf84dc 100644 --- a/src/app/(frontend)/[locale]/profile/page.tsx +++ b/src/app/(frontend)/[locale]/profile/page.tsx @@ -29,7 +29,8 @@ import { defaultLocale, type SiteLocale } from '@/utilities/locales' import { STEP_XP, QUIZ_XP, levelForXp } from '@/utilities/xp' import { formatDateTime } from '@/utilities/formatDateTime' import { plural } from '@/utilities/plural' -import type { Course } from '@/payload-types' +import { formatEventRange, isEventPast } from '@/utilities/eventTime' +import type { Course, Event } from '@/payload-types' import { SignOutButton } from './SignOutButton' type Args = { @@ -56,7 +57,7 @@ export default async function ProfilePage({ params }: Args) { const prefix = locale === defaultLocale ? '' : `/${locale}` const payload = await getPayload() - const [userDoc, enrollments, recentAttempts] = await Promise.all([ + const [userDoc, enrollments, recentAttempts, eventEnrollments] = await Promise.all([ payload.findByID({ collection: 'users', id: Number(user.id), @@ -75,8 +76,23 @@ export default async function ProfilePage({ params }: Args) { limit: 5, depth: 1, }), + payload.find({ + collection: 'event-enrollments', + where: { user: { equals: Number(user.id) } }, + sort: '-enrolledAt', + limit: 100, + depth: 1, + locale, + }), ]) + const upcomingEvents = eventEnrollments.docs + .flatMap((enrollment) => + typeof enrollment.event === 'object' && enrollment.event ? [enrollment.event as Event] : [], + ) + .filter((event) => event._status === 'published' && !isEventPast(event)) + .sort((a, b) => +new Date(a.startDate) - +new Date(b.startDate)) + let stepsDone = 0 let quizzesPassed = 0 for (const enrollment of enrollments.docs) { @@ -273,6 +289,46 @@ export default async function ProfilePage({ params }: Args) {
)} + {upcomingEvents.length > 0 && ( + <> +

{t.profileUpcomingEvents}

+
+ {upcomingEvents.map((event) => ( + + + + {new Date(event.startDate).getDate()} + + + {new Intl.DateTimeFormat(locale === 'uk' ? 'uk-UA' : 'en-GB', { + month: 'short', + }) + .format(new Date(event.startDate)) + .replace('.', '')} + + +
+ + {event.title} + +

+ {formatEventRange(event, locale)} + + {event.locationType === 'virtual' ? t.eventOnline : event.address || t.eventOffline} + +

+
+ + + ))} +
+ + )} + {completed.length > 0 && ( <>

{t.profileCertificates}

diff --git a/src/app/api/events/[id]/calendar.ics/route.ts b/src/app/api/events/[id]/calendar.ics/route.ts new file mode 100644 index 0000000..1282c12 --- /dev/null +++ b/src/app/api/events/[id]/calendar.ics/route.ts @@ -0,0 +1,76 @@ +import { getPayload } from 'payload' +import configPromise from '@payload-config' + +import type { Event } from '@/payload-types' +import { getServerSideURL } from '@/utilities/getURL' +import { getEventTimes } from '@/utilities/eventTime' + +const icsEscape = (value: string): string => + value.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n') + +const icsDate = (date: Date): string => + date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '') + +/** + * Public .ics download for a published event. Virtual events point at the event + * page rather than the meeting link — the link is reserved for registered + * users (and is stripped by field access on this anonymous read anyway). + */ +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const eventId = Number(id) + if (!Number.isInteger(eventId)) { + return new Response('Not found', { status: 404 }) + } + + const payload = await getPayload({ config: configPromise }) + + const result = await payload.find({ + collection: 'events', + where: { and: [{ id: { equals: eventId } }, { _status: { equals: 'published' } }] }, + limit: 1, + depth: 0, + overrideAccess: false, + }) + + const event = result.docs[0] as Event | undefined + if (!event) { + return new Response('Not found', { status: 404 }) + } + + const { startsAt, endsAt } = getEventTimes(event) + const end = endsAt.getTime() === startsAt.getTime() + ? new Date(startsAt.getTime() + 60 * 60 * 1000) + : endsAt + + const eventUrl = `${getServerSideURL()}/events/${event.slug}` + const location = event.locationType === 'local' ? event.address || '' : eventUrl + const description = [event.description, eventUrl].filter(Boolean).join('\n\n') + + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Zalizna Zmina//Events//UK', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'BEGIN:VEVENT', + `UID:event-${event.id}@zalizna-zmina`, + `DTSTAMP:${icsDate(new Date())}`, + `DTSTART:${icsDate(startsAt)}`, + `DTEND:${icsDate(end)}`, + `SUMMARY:${icsEscape(event.title)}`, + description ? `DESCRIPTION:${icsEscape(description)}` : null, + location ? `LOCATION:${icsEscape(location)}` : null, + `URL:${icsEscape(eventUrl)}`, + 'END:VEVENT', + 'END:VCALENDAR', + ].filter((line): line is string => line !== null) + + return new Response(lines.join('\r\n') + '\r\n', { + headers: { + 'Content-Type': 'text/calendar; charset=utf-8', + 'Content-Disposition': `attachment; filename="event-${event.id}.ics"`, + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600', + }, + }) +} diff --git a/src/blocks/EventsBlock/Component.tsx b/src/blocks/EventsBlock/Component.tsx new file mode 100644 index 0000000..34bec77 --- /dev/null +++ b/src/blocks/EventsBlock/Component.tsx @@ -0,0 +1,131 @@ +import type { EventsBlock as EventsBlockProps } from '@/payload-types' +import type { SiteLocale } from '@/utilities/locales' + +import configPromise from '@payload-config' +import { getPayload } from 'payload' +import React from 'react' +import Link from 'next/link' +import { ArrowRight } from 'lucide-react' + +import RichText from '@/components/RichText' +import { EventCard, type EventCardData } from '@/components/Events/EventCard' +import { getFrontendMessages } from '@/utilities/i18n' +import { isEventPast } from '@/utilities/eventTime' + +const CARD_SELECT = { + slug: true, + title: true, + description: true, + cover: true, + startDate: true, + endDate: true, + locationType: true, + address: true, + capacity: true, +} as const + +export const EventsBlockComponent: React.FC< + EventsBlockProps & { + id?: string + locale?: SiteLocale + } +> = async (props) => { + const { id, introContent, limit: limitFromProps, locale, populateBy, selectedEvents, showAllLink } = props + + const siteLocale: SiteLocale = locale ?? 'uk' + const t = getFrontendMessages(siteLocale) + const prefix = siteLocale === 'en' ? '/en' : '' + const limit = limitFromProps || 3 + + let events: EventCardData[] = [] + + const payload = await getPayload({ config: configPromise }) + + if (populateBy === 'selection' && selectedEvents?.length) { + const ids = selectedEvents.map((event) => (typeof event === 'object' ? event.id : event)) + // Re-fetch instead of trusting the populated docs: access control filters + // out drafts and strips the registration-only meeting link. + const fetched = await payload.find({ + collection: 'events', + depth: 1, + limit: ids.length, + locale: siteLocale, + draft: false, + overrideAccess: false, + select: CARD_SELECT, + where: { + and: [{ id: { in: ids } }, { _status: { equals: 'published' } }], + }, + }) + // Preserve the hand-picked order. + const byId = new Map(fetched.docs.map((doc) => [doc.id, doc])) + events = ids.flatMap((eventId) => { + const doc = byId.get(eventId) + return doc ? [doc as EventCardData] : [] + }) + } else { + const fetched = await payload.find({ + collection: 'events', + depth: 1, + limit, + locale: siteLocale, + draft: false, + overrideAccess: false, + sort: 'startDate', + select: CARD_SELECT, + where: { + and: [ + { _status: { equals: 'published' } }, + { + or: [ + { endDate: { greater_than_equal: new Date().toISOString() } }, + { + and: [ + { endDate: { exists: false } }, + { startDate: { greater_than_equal: new Date().toISOString() } }, + ], + }, + ], + }, + ], + }, + }) + events = fetched.docs as EventCardData[] + } + + if (events.length === 0 && !introContent) return null + + return ( +
+ {introContent && ( +
+ +
+ )} +
+
+ {events.map((event) => ( + + ))} +
+ {showAllLink !== false && ( +
+ + {t.eventsBlockSeeAll} + + +
+ )} +
+
+ ) +} diff --git a/src/blocks/EventsBlock/config.ts b/src/blocks/EventsBlock/config.ts new file mode 100644 index 0000000..379e1a1 --- /dev/null +++ b/src/blocks/EventsBlock/config.ts @@ -0,0 +1,77 @@ +import type { Block } from 'payload' + +import { + FixedToolbarFeature, + HeadingFeature, + InlineToolbarFeature, + lexicalEditor, +} from '@payloadcms/richtext-lexical' + +export const EventsBlock: Block = { + slug: 'eventsBlock', + interfaceName: 'EventsBlock', + imageAltText: 'Сітка карток подій', + fields: [ + { + name: 'introContent', + type: 'richText', + editor: lexicalEditor({ + features: ({ rootFeatures }) => { + return [ + ...rootFeatures, + HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }), + FixedToolbarFeature(), + InlineToolbarFeature(), + ] + }, + }), + label: 'Вступний текст', + }, + { + name: 'populateBy', + type: 'select', + defaultValue: 'upcoming', + label: 'Джерело наповнення', + options: [ + { + label: 'Найближчі події', + value: 'upcoming', + }, + { + label: 'Вибрані вручну', + value: 'selection', + }, + ], + }, + { + name: 'limit', + type: 'number', + admin: { + condition: (_, siblingData) => siblingData.populateBy === 'upcoming', + step: 1, + }, + defaultValue: 3, + label: 'Кількість', + }, + { + name: 'selectedEvents', + type: 'relationship', + admin: { + condition: (_, siblingData) => siblingData.populateBy === 'selection', + }, + hasMany: true, + label: 'Вибрані події', + relationTo: 'events', + }, + { + name: 'showAllLink', + type: 'checkbox', + defaultValue: true, + label: 'Показувати посилання «Всі події»', + }, + ], + labels: { + plural: 'Події', + singular: 'Події', + }, +} diff --git a/src/blocks/RenderBlocks.tsx b/src/blocks/RenderBlocks.tsx index 8c5c338..5aeb290 100644 --- a/src/blocks/RenderBlocks.tsx +++ b/src/blocks/RenderBlocks.tsx @@ -6,6 +6,7 @@ import type { SiteLocale } from '@/utilities/locales' import { ArchiveBlock } from '@/blocks/ArchiveBlock/Component' import { CallToActionBlock } from '@/blocks/CallToAction/Component' import { ContentBlock } from '@/blocks/Content/Component' +import { EventsBlockComponent } from '@/blocks/EventsBlock/Component' import { FormBlock } from '@/blocks/Form/Component' import { MediaBlock } from '@/blocks/MediaBlock/Component' @@ -13,6 +14,7 @@ const blockComponents = { archive: ArchiveBlock, content: ContentBlock, cta: CallToActionBlock, + eventsBlock: EventsBlockComponent, formBlock: FormBlock, mediaBlock: MediaBlock, } diff --git a/src/collections/EventEnrollments.ts b/src/collections/EventEnrollments.ts new file mode 100644 index 0000000..f86ce29 --- /dev/null +++ b/src/collections/EventEnrollments.ts @@ -0,0 +1,149 @@ +import type { CollectionConfig, Access } from 'payload' +import { APIError } from 'payload' + +import { admin } from '../access/admin' +import { authenticated } from '../access/authenticated' +import { rateLimitCreate } from '../hooks/rateLimitCreate' +import type { Event } from '@/payload-types' + +const adminOrOwn: Access = ({ req: { user } }) => { + if (!user) return false + if ('role' in user && user.role?.includes('admin')) return true + return { + user: { equals: user.id }, + } +} + +export const EventEnrollments: CollectionConfig = { + slug: 'event-enrollments', + labels: { singular: 'Реєстрація на подію', plural: 'Реєстрації на події' }, + lockDocuments: false, + admin: { + useAsTitle: 'id', + defaultColumns: ['user', 'event', 'enrolledAt'], + group: 'Події', + }, + access: { + create: authenticated, + // Unlike course enrollments there is no forgeable progress here, so owners + // may cancel their own registration directly (mirrors Likes). + delete: adminOrOwn, + read: adminOrOwn, + update: admin, + }, + indexes: [ + { + fields: ['user', 'event'], + unique: true, + }, + ], + fields: [ + { + name: 'user', + type: 'relationship', + relationTo: 'users', + required: true, + label: 'Користувач', + index: true, + admin: { + readOnly: true, + }, + }, + { + name: 'event', + type: 'relationship', + relationTo: 'events', + required: true, + label: 'Подія', + index: true, + admin: { + readOnly: true, + }, + }, + { + name: 'enrolledAt', + type: 'date', + label: 'Дата реєстрації', + admin: { + readOnly: true, + position: 'sidebar', + }, + }, + ], + hooks: { + beforeValidate: [ + // One row per user×event is enforced below, but each attempt still does + // lookups and page revalidation — cap scripted registration loops. + rateLimitCreate({ prefix: 'event-enroll-create', windowSeconds: 600, max: 30 }), + async ({ data, operation, req }) => { + if (operation !== 'create' || !data) return data + + const isAdminReq = Boolean( + req.user && 'role' in req.user && req.user.role?.includes('admin'), + ) + + // Non-admin API requests can only register themselves; bind before the + // duplicate check so a spoofed user id can't dodge it. Local API calls + // without a user (server actions) pass the id explicitly. + if (req.user && !isAdminReq) { + data.user = req.user.id + } + + if (data.user && data.event) { + const existing = await req.payload.find({ + collection: 'event-enrollments', + where: { + and: [{ user: { equals: data.user } }, { event: { equals: data.event } }], + }, + limit: 1, + req, + }) + if (existing.totalDocs > 0) { + throw new APIError('Ви вже зареєстровані на цю подію', 409) + } + } + + // Business rules apply to users and server actions alike; admins may + // register anyone manually from the panel (e.g. offline sign-ups). + if (data.event && !isAdminReq) { + const event = (await req.payload.findByID({ + collection: 'events', + id: data.event, + depth: 0, + req, + })) as Event | null + + if (!event || event._status !== 'published') { + throw new APIError('Подію не знайдено', 404) + } + + const ends = event.endDate ?? event.startDate + if (ends && new Date(ends) < new Date()) { + throw new APIError('Подія вже завершилась', 400) + } + + if (typeof event.capacity === 'number') { + const { totalDocs } = await req.payload.count({ + collection: 'event-enrollments', + where: { event: { equals: data.event } }, + req, + }) + if (totalDocs >= event.capacity) { + throw new APIError('Вільних місць більше немає', 409) + } + } + } + + return data + }, + ], + beforeChange: [ + ({ data, operation }) => { + if (operation === 'create') { + data.enrolledAt = new Date().toISOString() + } + return data + }, + ], + }, +} diff --git a/src/collections/Events.ts b/src/collections/Events.ts new file mode 100644 index 0000000..33b7111 --- /dev/null +++ b/src/collections/Events.ts @@ -0,0 +1,203 @@ +import type { CollectionConfig, TextFieldValidation } from 'payload' + +import { admin } from '../access/admin' +import { authenticatedOrPublished } from '../access/authenticatedOrPublished' +import { slugField } from 'payload' +import { cyrillicSlugify } from '../utilities/cyrillicSlugify' +import { revalidateEvent, revalidateEventDelete } from '../hooks/revalidateEvent' + +const HTTP_URL_REGEX = /^https?:\/\/\S+$/ + +const validateOptionalUrl = (value: string | null | undefined) => { + if (!value) return true + return HTTP_URL_REGEX.test(value) || 'Введіть коректне посилання (https://…)' +} + +export const Events: CollectionConfig = { + slug: 'events', + labels: { singular: 'Подія', plural: 'Події' }, + lockDocuments: false, + admin: { + useAsTitle: 'title', + defaultColumns: ['title', 'startDate', 'locationType', '_status'], + group: 'Події', + }, + access: { + create: admin, + delete: admin, + read: authenticatedOrPublished, + update: admin, + }, + hooks: { + afterChange: [revalidateEvent], + afterDelete: [revalidateEventDelete], + beforeDelete: [ + async ({ id, req }) => { + // event_enrollments.event_id is NOT NULL with an ON DELETE SET NULL FK — + // the rows must be removed first or the whole delete fails at the DB level. + await req.payload.delete({ + collection: 'event-enrollments', + where: { event: { equals: id } }, + req, + }) + }, + ], + }, + versions: { + drafts: { + autosave: { + interval: 10000, + }, + schedulePublish: true, + }, + maxPerDoc: 50, + }, + fields: [ + { + name: 'title', + type: 'text', + required: true, + localized: true, + label: 'Назва', + }, + slugField({ + slugify: cyrillicSlugify, + position: undefined, + }), + { + name: 'description', + type: 'textarea', + localized: true, + label: 'Опис', + }, + { + name: 'cover', + type: 'upload', + relationTo: 'media', + label: 'Обкладинка', + }, + { + type: 'row', + fields: [ + { + name: 'startDate', + type: 'date', + required: true, + label: 'Початок', + index: true, + admin: { + date: { pickerAppearance: 'dayAndTime', displayFormat: 'dd.MM.yyyy HH:mm' }, + width: '50%', + }, + }, + { + name: 'endDate', + type: 'date', + label: 'Завершення', + admin: { + date: { pickerAppearance: 'dayAndTime', displayFormat: 'dd.MM.yyyy HH:mm' }, + width: '50%', + }, + validate: (value, { siblingData }) => { + const start = (siblingData as { startDate?: string })?.startDate + if (value && start && new Date(value) <= new Date(start)) { + return 'Завершення має бути пізніше за початок' + } + return true + }, + }, + ], + }, + { + name: 'locationType', + type: 'select', + required: true, + defaultValue: 'local', + label: 'Формат', + options: [ + { label: 'Офлайн (за адресою)', value: 'local' }, + { label: 'Онлайн (за посиланням)', value: 'virtual' }, + ], + }, + { + name: 'address', + type: 'text', + localized: true, + label: 'Адреса', + admin: { + condition: (data) => data?.locationType === 'local', + description: 'Місце проведення, наприклад: Київ, вул. Хрещатик, 1', + }, + // Drafts save without validation, so this only blocks publishing an + // offline event with no address. + validate: ((value, { data }) => { + const shape = data as { locationType?: string } | undefined + if (shape?.locationType === 'local' && !value) { + return 'Вкажіть адресу для офлайн-події' + } + return true + }) as TextFieldValidation, + }, + { + name: 'mapLink', + type: 'text', + label: 'Посилання на мапу', + admin: { + condition: (data) => data?.locationType === 'local', + description: 'Посилання на Google Maps або інший сервіс мап', + }, + validate: validateOptionalUrl, + }, + { + name: 'meetingLink', + type: 'text', + label: 'Посилання на зустріч', + access: { + // The join link is for registered participants; keep it out of + // anonymous REST/API responses. ISR pages fetch with + // overrideAccess: false and no user, so it never lands in the shared + // static cache either — enrolled users get it via a server action. + read: ({ req }) => Boolean(req.user), + }, + admin: { + condition: (data) => data?.locationType === 'virtual', + description: 'Zoom, Google Meet або інша платформа. Бачать лише зареєстровані учасники.', + }, + validate: ((value, { data }) => { + const shape = data as { locationType?: string } | undefined + if (shape?.locationType === 'virtual' && !value) { + return 'Додайте посилання на онлайн-зустріч' + } + return validateOptionalUrl(value) + }) as TextFieldValidation, + }, + { + name: 'capacity', + type: 'number', + min: 1, + label: 'Кількість місць', + admin: { + position: 'sidebar', + description: 'Залиште порожнім, якщо кількість учасників не обмежена', + }, + }, + { + name: 'publishedAt', + type: 'date', + label: 'Дата публікації', + admin: { + position: 'sidebar', + }, + hooks: { + beforeChange: [ + ({ siblingData, value }) => { + if (siblingData._status === 'published' && !value) { + return new Date() + } + return value + }, + ], + }, + }, + ], +} diff --git a/src/collections/Pages/index.ts b/src/collections/Pages/index.ts index 36d6657..3dd98ba 100644 --- a/src/collections/Pages/index.ts +++ b/src/collections/Pages/index.ts @@ -3,6 +3,7 @@ import type { CollectionConfig } from 'payload' import { admin } from '../../access/admin' import { authenticatedOrPublished } from '../../access/authenticatedOrPublished' import { Archive } from '../../blocks/ArchiveBlock/config' +import { EventsBlock } from '../../blocks/EventsBlock/config' import { CallToAction } from '../../blocks/CallToAction/config' import { Content } from '../../blocks/Content/config' import { FormBlock } from '../../blocks/Form/config' @@ -73,7 +74,7 @@ export const Pages: CollectionConfig<'pages'> = { { name: 'layout', type: 'blocks', - blocks: [CallToAction, Content, MediaBlock, Archive, FormBlock], + blocks: [CallToAction, Content, MediaBlock, Archive, EventsBlock, FormBlock], required: true, localized: true, label: 'Макет', diff --git a/src/collections/Posts/index.ts b/src/collections/Posts/index.ts index a41644b..66d5301 100644 --- a/src/collections/Posts/index.ts +++ b/src/collections/Posts/index.ts @@ -12,6 +12,7 @@ import { import { admin } from '../../access/admin' import { authenticatedOrPublished } from '../../access/authenticatedOrPublished' import { Archive } from '../../blocks/ArchiveBlock/config' +import { EventsBlock } from '../../blocks/EventsBlock/config' import { Banner } from '../../blocks/Banner/config' import { Code } from '../../blocks/Code/config' import { MediaBlock } from '../../blocks/MediaBlock/config' @@ -95,7 +96,7 @@ export const Posts: CollectionConfig<'posts'> = { return [ ...rootFeatures, HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }), - BlocksFeature({ blocks: [Banner, Code, MediaBlock, Archive] }), + BlocksFeature({ blocks: [Banner, Code, MediaBlock, Archive, EventsBlock] }), FixedToolbarFeature(), InlineToolbarFeature(), HorizontalRuleFeature(), diff --git a/src/collections/Users/index.ts b/src/collections/Users/index.ts index d97c55e..6104ba3 100644 --- a/src/collections/Users/index.ts +++ b/src/collections/Users/index.ts @@ -36,6 +36,11 @@ export const Users: CollectionConfig = { where: { user: { equals: id } }, req, }) + await req.payload.delete({ + collection: 'event-enrollments', + where: { user: { equals: id } }, + req, + }) await req.payload.delete({ collection: 'likes', where: { user: { equals: id } }, diff --git a/src/components/Events/AddToCalendar.tsx b/src/components/Events/AddToCalendar.tsx new file mode 100644 index 0000000..5376903 --- /dev/null +++ b/src/components/Events/AddToCalendar.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { CalendarPlus, Download } from 'lucide-react' + +import type { SiteLocale } from '@/utilities/locales' +import { getFrontendMessages } from '@/utilities/i18n' +import { googleCalendarUrl } from '@/utilities/eventTime' +import type { EventCardData } from './EventCard' + +type Props = { + event: EventCardData & { description?: string | null } + locale: SiteLocale + /** Absolute or root-relative URL of the event page — used as the calendar "location" for virtual events. */ + eventUrl: string +} + +const linkClass = + 'inline-flex items-center gap-2 rounded-full border border-line-2 px-4 py-2 font-display text-[11px] font-semibold uppercase tracking-[0.1em] text-fog transition-colors hover:border-orange hover:text-orange' + +export const AddToCalendar: React.FC = ({ event, locale, eventUrl }) => { + const t = getFrontendMessages(locale) + + // Virtual events point at the event page (where the registered user finds the + // join link) so the meeting link itself never leaks through calendar files. + const location = event.locationType === 'local' ? event.address || undefined : eventUrl + + const googleUrl = googleCalendarUrl({ + title: event.title, + description: event.description ? `${event.description}\n\n${eventUrl}` : eventUrl, + location, + startDate: event.startDate, + endDate: event.endDate, + }) + + return ( +
+ + {t.eventAddToCalendar} + + + + {t.eventGoogleCalendar} + + + + {t.eventDownloadIcs} + +
+ ) +} diff --git a/src/components/Events/EventActionBar.tsx b/src/components/Events/EventActionBar.tsx new file mode 100644 index 0000000..7f1d768 --- /dev/null +++ b/src/components/Events/EventActionBar.tsx @@ -0,0 +1,127 @@ +'use client' + +import React, { useState, useTransition } from 'react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { Check, LoaderCircle } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { ActionButtonSkeleton } from '@/components/Courses/ActionButtonSkeleton' +import { enrollInEvent, unenrollFromEvent } from '@/app/(frontend)/[locale]/events/actions' +import { useEventUserState } from './EventUserState' + +type Props = { + eventId: number + eventSlug: string + isPast: boolean + isFull: boolean + localePrefix?: string + labels: { + signIn: string + loginToEnroll: string + enroll: string + unenroll: string + unenrollConfirm: string + enrolledBadge: string + full: string + finished: string + } +} + +export function EventActionBar({ + eventId, + eventSlug, + isPast, + isFull, + localePrefix = '', + labels, +}: Props) { + const { loading, isLoggedIn, enrolled, refresh } = useEventUserState() + const [isPending, startTransition] = useTransition() + const [error, setError] = useState(null) + const router = useRouter() + + if (loading) return + + if (isPast) { + return ( + + {labels.finished} + + ) + } + + const handleEnroll = () => { + setError(null) + startTransition(async () => { + const result = await enrollInEvent(eventId) + if (result.success) { + await refresh() + router.refresh() + } else { + setError(result.error ?? 'Error') + } + }) + } + + const handleUnenroll = () => { + if (!window.confirm(labels.unenrollConfirm)) return + setError(null) + startTransition(async () => { + const result = await unenrollFromEvent(eventId) + if (result.success) { + await refresh() + router.refresh() + } else { + setError(result.error ?? 'Error') + } + }) + } + + return ( +
+
+ {!isLoggedIn && ( + <> + + {labels.loginToEnroll} + + )} + + {isLoggedIn && !enrolled && isFull && ( + + {labels.full} + + )} + + {isLoggedIn && !enrolled && !isFull && ( + + )} + + {isLoggedIn && enrolled && ( + <> + + + {labels.enrolledBadge} + + + + )} +
+ {error &&

{error}

} +
+ ) +} diff --git a/src/components/Events/EventCard.tsx b/src/components/Events/EventCard.tsx new file mode 100644 index 0000000..e712067 --- /dev/null +++ b/src/components/Events/EventCard.tsx @@ -0,0 +1,125 @@ +import Link from 'next/link' +import React from 'react' +import { Check, MapPin, Users, Video } from 'lucide-react' + +import type { Event } from '@/payload-types' +import { Media } from '@/components/Media' +import { cn } from '@/utilities/ui' +import type { SiteLocale } from '@/utilities/locales' +import { getFrontendMessages } from '@/utilities/i18n' +import { + formatEventMonthShort, + formatEventRange, + getEventTimes, + isEventPast, +} from '@/utilities/eventTime' + +export type EventCardData = Pick< + Event, + | 'id' + | 'slug' + | 'title' + | 'description' + | 'cover' + | 'startDate' + | 'endDate' + | 'locationType' + | 'address' + | 'capacity' +> + +type Props = { + event: EventCardData + locale: SiteLocale + isEnrolled?: boolean + /** Precomputed on the server for static renders; client renders pass live "now". */ + isPast?: boolean + className?: string +} + +export const EventCard: React.FC = ({ event, locale, isEnrolled, isPast, className }) => { + const t = getFrontendMessages(locale) + const { slug, title, description, cover, locationType, address } = event + const prefix = locale === 'en' ? '/en' : '' + const href = `${prefix}/events/${slug}` + const { startsAt } = getEventTimes(event) + const past = isPast ?? isEventPast(event) + + return ( +
+ +
+ {cover && typeof cover === 'object' ? ( + + ) : ( +
+ )} + {/* Date tile — echoes the homepage calendar motif. */} +
+ {startsAt.getDate()} + + {formatEventMonthShort(startsAt, locale)} + +
+ {isEnrolled && !past && ( + + + {t.eventEnrolledBadge} + + )} + {past && ( + + {t.eventFinished} + + )} +
+
+
+ {formatEventRange(event, locale)} +
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+ {locationType === 'virtual' ? ( + + + ) : ( + + + {address || t.eventOffline} + + )} + {typeof event.capacity === 'number' && !past && ( + + + {event.capacity} + + )} +
+
+ +
+ ) +} diff --git a/src/components/Events/EventJoinCard.tsx b/src/components/Events/EventJoinCard.tsx new file mode 100644 index 0000000..47f5d23 --- /dev/null +++ b/src/components/Events/EventJoinCard.tsx @@ -0,0 +1,87 @@ +'use client' + +import React from 'react' +import { ExternalLink, Lock, Video } from 'lucide-react' + +import type { SiteLocale } from '@/utilities/locales' +import { getFrontendMessages } from '@/utilities/i18n' +import { detectMeetingPlatform, MEETING_PLATFORM_LABELS } from '@/utilities/eventTime' +import { useEventUserState } from './EventUserState' + +const PLATFORM_ACCENTS: Record = { + zoom: '#2D8CFF', + 'google-meet': '#00897B', + youtube: '#FF0033', + other: '#5b9bff', +} + +type Props = { + locale: SiteLocale + isPast: boolean +} + +/** + * The virtual-event join card. The meeting link never ships in the static + * page — it arrives via EventUserState only for registered users. + */ +export function EventJoinCard({ locale, isPast }: Props) { + const t = getFrontendMessages(locale) + const { loading, enrolled, meetingLink } = useEventUserState() + + if (isPast) return null + + const platform = detectMeetingPlatform(meetingLink) + const accent = PLATFORM_ACCENTS[platform] + const platformLabel = MEETING_PLATFORM_LABELS[platform] + + return ( +
+
+ + +
+

+ {t.eventJoinTitle} +

+ {meetingLink && ( + + {platformLabel} + + )} +
+
+ + {loading ? ( +
+ ) : enrolled && meetingLink ? ( +
+ + {t.eventJoin} + + +

{meetingLink}

+
+ ) : ( +

+ + {enrolled ? t.eventJoinHint : t.eventJoinLocked} +

+ )} +
+ ) +} diff --git a/src/components/Events/EventUserState.tsx b/src/components/Events/EventUserState.tsx new file mode 100644 index 0000000..0c3ec3d --- /dev/null +++ b/src/components/Events/EventUserState.tsx @@ -0,0 +1,82 @@ +'use client' + +import React, { createContext, useCallback, useContext, useEffect, useState } from 'react' +import { useSession } from '@/lib/auth/client' +import { getEventJoinInfo } from '@/app/(frontend)/[locale]/events/actions' + +type EventUserState = { + loading: boolean + isLoggedIn: boolean + enrolled: boolean + meetingLink: string | null + refresh: () => Promise +} + +const Context = createContext({ + loading: true, + isLoggedIn: false, + enrolled: false, + meetingLink: null, + refresh: async () => {}, +}) + +export function EventUserStateProvider({ + eventId, + children, +}: { + eventId: number + children: React.ReactNode +}) { + const { data: session, isPending } = useSession() + const [state, setState] = useState<{ + loading: boolean + enrolled: boolean + meetingLink: string | null + }>({ loading: true, enrolled: false, meetingLink: null }) + + const fetchState = useCallback(async () => { + try { + const info = await getEventJoinInfo(eventId) + setState({ loading: false, enrolled: info.enrolled, meetingLink: info.meetingLink }) + } catch { + setState({ loading: false, enrolled: false, meetingLink: null }) + } + }, [eventId]) + + useEffect(() => { + if (isPending) return + if (!session?.user) { + setState({ loading: false, enrolled: false, meetingLink: null }) + return + } + let cancelled = false + getEventJoinInfo(eventId) + .then((info) => { + if (!cancelled) { + setState({ loading: false, enrolled: info.enrolled, meetingLink: info.meetingLink }) + } + }) + .catch(() => { + if (!cancelled) setState({ loading: false, enrolled: false, meetingLink: null }) + }) + return () => { + cancelled = true + } + }, [eventId, isPending, session?.user]) + + return ( + + {children} + + ) +} + +export const useEventUserState = () => useContext(Context) diff --git a/src/components/Events/EventsExplorer.tsx b/src/components/Events/EventsExplorer.tsx new file mode 100644 index 0000000..6fa5616 --- /dev/null +++ b/src/components/Events/EventsExplorer.tsx @@ -0,0 +1,222 @@ +'use client' + +import React, { useEffect, useMemo, useState } from 'react' +import Link from 'next/link' +import { ArrowRight, CalendarDays, MapPin, Video } from 'lucide-react' + +import type { SiteLocale } from '@/utilities/locales' +import { getFrontendMessages } from '@/utilities/i18n' +import { filterPill } from '@/components/Courses/CategoryFilter' +import { plural } from '@/utilities/plural' +import { FadeIn } from '@/components/Home/FadeIn' +import { Media } from '@/components/Media' +import { formatEventRange, getEventTimes } from '@/utilities/eventTime' +import { EventCard, type EventCardData } from './EventCard' +import { useMyEventEnrollments } from './useMyEventEnrollments' + +type Props = { + events: EventCardData[] + locale: SiteLocale + /** Time the page was rendered — the first client render must match the SSR HTML. */ + serverNow: string +} + +type Tab = 'upcoming' | 'past' + +function Countdown({ startsAt, now, locale }: { startsAt: Date; now: Date; locale: SiteLocale }) { + const t = getFrontendMessages(locale) + const diffMs = startsAt.getTime() - now.getTime() + if (diffMs <= 0) { + return {t.eventStartsNow} + } + const minutes = Math.floor(diffMs / 60000) + const days = Math.floor(minutes / (60 * 24)) + const hours = Math.floor((minutes % (60 * 24)) / 60) + + const [value, pluralForms] = + days > 0 + ? [days, t.eventStartsInDaysPlural] + : hours > 0 + ? [hours, t.eventStartsInHoursPlural] + : [Math.max(minutes, 1), t.eventStartsInMinutesPlural] + + return ( + + {value} {plural(locale, value, pluralForms)}{' '} + {t.eventStartsInSuffix} + + ) +} + +export function EventsExplorer({ events, locale, serverNow }: Props) { + const t = getFrontendMessages(locale) + const prefix = locale === 'en' ? '/en' : '' + const [tab, setTab] = useState('upcoming') + const [now, setNow] = useState(() => new Date(serverNow)) + const { enrolledEventIds } = useMyEventEnrollments() + + // Swap the prerendered clock for the live one after hydration, then keep the + // countdown fresh. + useEffect(() => { + setNow(new Date()) + const interval = setInterval(() => setNow(new Date()), 60_000) + return () => clearInterval(interval) + }, []) + + const { upcoming, past } = useMemo(() => { + const upcomingList: EventCardData[] = [] + const pastList: EventCardData[] = [] + for (const event of events) { + if (getEventTimes(event).endsAt < now) pastList.push(event) + else upcomingList.push(event) + } + upcomingList.sort((a, b) => +new Date(a.startDate) - +new Date(b.startDate)) + pastList.sort((a, b) => +new Date(b.startDate) - +new Date(a.startDate)) + return { upcoming: upcomingList, past: pastList } + }, [events, now]) + + const [featured, ...restUpcoming] = upcoming + const featuredCover = + featured?.cover && typeof featured.cover === 'object' ? featured.cover : null + + return ( +
+
+ + +
+ + {tab === 'upcoming' && ( +
+ {upcoming.length === 0 && ( +

+ {t.eventsEmptyUpcoming} +

+ )} + + {featured && ( + + +
+ {featuredCover ? ( + + ) : ( +
+ )} +
+
+
+ {t.eventsNextEvent} +

+ {featured.title} +

+
+ + + {formatEventRange(featured, locale)} + + {featured.locationType === 'virtual' ? ( + + + ) : ( + featured.address && ( + + + {featured.address} + + ) + )} + + + +
+ + {enrolledEventIds.includes(featured.id) ? t.eventEnrolledBadge : t.eventEnroll} + + +
+ + + )} + + {restUpcoming.length > 0 && ( +
+ {restUpcoming.map((event, i) => ( + + + + ))} +
+ )} +
+ )} + + {tab === 'past' && ( +
+ {past.length === 0 ? ( +

+ {t.eventsEmptyPast} +

+ ) : ( +
+ {past.map((event, i) => ( + + + + ))} +
+ )} +
+ )} +
+ ) +} diff --git a/src/components/Events/useMyEventEnrollments.ts b/src/components/Events/useMyEventEnrollments.ts new file mode 100644 index 0000000..30779de --- /dev/null +++ b/src/components/Events/useMyEventEnrollments.ts @@ -0,0 +1,38 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useSession } from '@/lib/auth/client' +import { getMyEventEnrollments } from '@/app/(frontend)/[locale]/events/actions' + +/** + * Event ids the current user is registered for. Fetched client-side after + * hydration so listing pages stay in the shared ISR cache. + */ +export function useMyEventEnrollments(): { loading: boolean; enrolledEventIds: number[] } { + const { data: session, isPending } = useSession() + const [state, setState] = useState<{ loading: boolean; ids: number[] }>({ + loading: true, + ids: [], + }) + + useEffect(() => { + if (isPending) return + if (!session?.user) { + setState({ loading: false, ids: [] }) + return + } + let cancelled = false + getMyEventEnrollments() + .then((ids) => { + if (!cancelled) setState({ loading: false, ids }) + }) + .catch(() => { + if (!cancelled) setState({ loading: false, ids: [] }) + }) + return () => { + cancelled = true + } + }, [isPending, session?.user]) + + return { loading: isPending || state.loading, enrolledEventIds: state.ids } +} diff --git a/src/components/RichText/WithArchive.tsx b/src/components/RichText/WithArchive.tsx index 71e873a..9104f14 100644 --- a/src/components/RichText/WithArchive.tsx +++ b/src/components/RichText/WithArchive.tsx @@ -2,28 +2,39 @@ import React from 'react' import type { SerializedBlockNode } from '@payloadcms/richtext-lexical' import type { JSXConvertersFunction } from '@payloadcms/richtext-lexical/react' -import type { ArchiveBlock as ArchiveBlockProps } from '@/payload-types' +import type { + ArchiveBlock as ArchiveBlockProps, + EventsBlock as EventsBlockProps, +} from '@/payload-types' import type { SiteLocale } from '@/utilities/locales' import { ArchiveBlock } from '@/blocks/ArchiveBlock/Component' +import { EventsBlockComponent } from '@/blocks/EventsBlock/Component' import RichText, { jsxConverters, type NodeTypes } from '@/components/RichText' type Props = { locale?: SiteLocale } & Omit, 'converters'> -// Server-only: the Archive renderer pulls in the Payload Local API, which must never end -// up in a client bundle, so it stays out of the shared RichText component. +// Server-only: the Archive and Events renderers pull in the Payload Local API, which must +// never end up in a client bundle, so they stay out of the shared RichText component. export default function RichTextWithArchive({ locale, ...rest }: Props) { const converters: JSXConvertersFunction< - NodeTypes | SerializedBlockNode + NodeTypes | SerializedBlockNode > = (args) => { const base = jsxConverters(args) return { ...base, blocks: { ...base.blocks, - archive: ({ node }) => , + archive: ({ node }) => { + const { id, ...fields } = node.fields as ArchiveBlockProps + return + }, + eventsBlock: ({ node }) => { + const { id, ...fields } = node.fields as EventsBlockProps + return + }, }, } } diff --git a/src/hooks/revalidateEvent.ts b/src/hooks/revalidateEvent.ts new file mode 100644 index 0000000..05a48f2 --- /dev/null +++ b/src/hooks/revalidateEvent.ts @@ -0,0 +1,52 @@ +import type { CollectionAfterChangeHook, CollectionAfterDeleteHook, Payload } from 'payload' + +import { revalidatePath } from 'next/cache' + +import type { Event } from '@/payload-types' + +const LOCALE_PREFIXES = ['', '/en'] + +// Busts every ISR-cached surface an event appears on. Scheduled publishes run +// outside a request, where revalidatePath throws; a missed bust falls back to +// the time window, so never fail the save. +function revalidateEventPaths(payload: Payload, slug: string | null | undefined) { + try { + for (const prefix of LOCALE_PREFIXES) { + if (slug) revalidatePath(`${prefix}/events/${slug}`) + revalidatePath(`${prefix}/events`) + } + } catch (err) { + payload.logger.warn({ err, slug }, 'events: revalidation skipped') + } +} + +export const revalidateEvent: CollectionAfterChangeHook = ({ + doc, + previousDoc, + req: { context, payload }, +}) => { + if (context.disableRevalidate) return doc + + if (doc._status === 'published') { + revalidateEventPaths(payload, doc.slug) + } + + // A rename or an unpublish strands the old URL with stale content. + const wasPublished = previousDoc?._status === 'published' + const slugChanged = previousDoc?.slug && previousDoc.slug !== doc.slug + if (wasPublished && (slugChanged || doc._status !== 'published')) { + revalidateEventPaths(payload, previousDoc.slug) + } + + return doc +} + +export const revalidateEventDelete: CollectionAfterDeleteHook = ({ + doc, + req: { context, payload }, +}) => { + if (!context.disableRevalidate && doc?.slug) { + revalidateEventPaths(payload, doc.slug) + } + return doc +} diff --git a/src/migrations/20260815_120000_events.ts b/src/migrations/20260815_120000_events.ts new file mode 100644 index 0000000..e3b55a6 --- /dev/null +++ b/src/migrations/20260815_120000_events.ts @@ -0,0 +1,293 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +/** + * Events feature: the `events` collection (drafts + uk/en locales), the + * `event_enrollments` join collection (unique user×event), the events block + * on pages, and the search-plugin relation for indexed events. DDL mirrors + * what drizzle push generates in dev, including index names (compound index + * names carry no table prefix — adapter quirk). + * + * Posts embed the events block inside Lexical JSON, so only pages need block + * tables. lockDocuments is false on both collections, so + * payload_locked_documents_rels needs no new columns. + */ +export async function up({ db }: MigrateUpArgs): Promise { + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_events_location_type" AS ENUM('local', 'virtual'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + CREATE TYPE "public"."enum_events_status" AS ENUM('draft', 'published'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + CREATE TYPE "public"."enum__events_v_version_location_type" AS ENUM('local', 'virtual'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + CREATE TYPE "public"."enum__events_v_version_status" AS ENUM('draft', 'published'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + CREATE TYPE "public"."enum__events_v_published_locale" AS ENUM('uk', 'en'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + CREATE TYPE "public"."enum_pages_blocks_events_block_populate_by" AS ENUM('upcoming', 'selection'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + CREATE TYPE "public"."enum__pages_v_blocks_events_block_populate_by" AS ENUM('upcoming', 'selection'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + CREATE TABLE IF NOT EXISTS "events" ( + "id" serial PRIMARY KEY NOT NULL, + "generate_slug" boolean DEFAULT true, + "slug" varchar, + "cover_id" integer, + "start_date" timestamp(3) with time zone, + "end_date" timestamp(3) with time zone, + "location_type" "enum_events_location_type" DEFAULT 'local', + "map_link" varchar, + "meeting_link" varchar, + "capacity" numeric, + "published_at" timestamp(3) with time zone, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "_status" "enum_events_status" DEFAULT 'draft' + ); + + CREATE TABLE IF NOT EXISTS "events_locales" ( + "title" varchar, + "description" varchar, + "address" varchar, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" integer NOT NULL + ); + + CREATE TABLE IF NOT EXISTS "_events_v" ( + "id" serial PRIMARY KEY NOT NULL, + "parent_id" integer, + "version_generate_slug" boolean DEFAULT true, + "version_slug" varchar, + "version_cover_id" integer, + "version_start_date" timestamp(3) with time zone, + "version_end_date" timestamp(3) with time zone, + "version_location_type" "enum__events_v_version_location_type" DEFAULT 'local', + "version_map_link" varchar, + "version_meeting_link" varchar, + "version_capacity" numeric, + "version_published_at" timestamp(3) with time zone, + "version_updated_at" timestamp(3) with time zone, + "version_created_at" timestamp(3) with time zone, + "version__status" "enum__events_v_version_status" DEFAULT 'draft', + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "snapshot" boolean, + "published_locale" "enum__events_v_published_locale", + "latest" boolean, + "autosave" boolean + ); + + CREATE TABLE IF NOT EXISTS "_events_v_locales" ( + "version_title" varchar, + "version_description" varchar, + "version_address" varchar, + "id" serial PRIMARY KEY NOT NULL, + "_locale" "_locales" NOT NULL, + "_parent_id" integer NOT NULL + ); + + CREATE TABLE IF NOT EXISTS "event_enrollments" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "event_id" integer NOT NULL, + "enrolled_at" timestamp(3) with time zone, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + + CREATE TABLE IF NOT EXISTS "pages_blocks_events_block" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL, + "_path" text NOT NULL, + "_locale" "_locales" NOT NULL, + "id" varchar PRIMARY KEY NOT NULL, + "intro_content" jsonb, + "populate_by" "enum_pages_blocks_events_block_populate_by" DEFAULT 'upcoming', + "limit" numeric DEFAULT 3, + "show_all_link" boolean DEFAULT true, + "block_name" varchar + ); + + CREATE TABLE IF NOT EXISTS "_pages_v_blocks_events_block" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL, + "_path" text NOT NULL, + "_locale" "_locales" NOT NULL, + "id" serial PRIMARY KEY NOT NULL, + "intro_content" jsonb, + "populate_by" "enum__pages_v_blocks_events_block_populate_by" DEFAULT 'upcoming', + "limit" numeric DEFAULT 3, + "show_all_link" boolean DEFAULT true, + "_uuid" varchar, + "block_name" varchar + ); + + DO $$ BEGIN + ALTER TABLE "events" ADD CONSTRAINT "events_cover_id_media_id_fk" + FOREIGN KEY ("cover_id") REFERENCES "public"."media"("id") ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "events_locales" ADD CONSTRAINT "events_locales_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."events"("id") ON DELETE CASCADE; + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "_events_v" ADD CONSTRAINT "_events_v_parent_id_events_id_fk" + FOREIGN KEY ("parent_id") REFERENCES "public"."events"("id") ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "_events_v" ADD CONSTRAINT "_events_v_version_cover_id_media_id_fk" + FOREIGN KEY ("version_cover_id") REFERENCES "public"."media"("id") ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "_events_v_locales" ADD CONSTRAINT "_events_v_locales_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."_events_v"("id") ON DELETE CASCADE; + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "event_enrollments" ADD CONSTRAINT "event_enrollments_user_id_users_id_fk" + FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "event_enrollments" ADD CONSTRAINT "event_enrollments_event_id_events_id_fk" + FOREIGN KEY ("event_id") REFERENCES "public"."events"("id") ON DELETE SET NULL; + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "pages_blocks_events_block" ADD CONSTRAINT "pages_blocks_events_block_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."pages"("id") ON DELETE CASCADE; + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "_pages_v_blocks_events_block" ADD CONSTRAINT "_pages_v_blocks_events_block_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."_pages_v"("id") ON DELETE CASCADE; + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + CREATE UNIQUE INDEX IF NOT EXISTS "events_slug_idx" ON "events" USING btree ("slug"); + CREATE INDEX IF NOT EXISTS "events_cover_idx" ON "events" USING btree ("cover_id"); + CREATE INDEX IF NOT EXISTS "events_start_date_idx" ON "events" USING btree ("start_date"); + CREATE INDEX IF NOT EXISTS "events_updated_at_idx" ON "events" USING btree ("updated_at"); + CREATE INDEX IF NOT EXISTS "events_created_at_idx" ON "events" USING btree ("created_at"); + CREATE INDEX IF NOT EXISTS "events__status_idx" ON "events" USING btree ("_status"); + + CREATE UNIQUE INDEX IF NOT EXISTS "events_locales_locale_parent_id_unique" + ON "events_locales" USING btree ("_locale", "_parent_id"); + + CREATE INDEX IF NOT EXISTS "_events_v_parent_idx" ON "_events_v" USING btree ("parent_id"); + CREATE INDEX IF NOT EXISTS "_events_v_version_version_slug_idx" ON "_events_v" USING btree ("version_slug"); + CREATE INDEX IF NOT EXISTS "_events_v_version_version_cover_idx" ON "_events_v" USING btree ("version_cover_id"); + CREATE INDEX IF NOT EXISTS "_events_v_version_version_start_date_idx" ON "_events_v" USING btree ("version_start_date"); + CREATE INDEX IF NOT EXISTS "_events_v_version_version_updated_at_idx" ON "_events_v" USING btree ("version_updated_at"); + CREATE INDEX IF NOT EXISTS "_events_v_version_version_created_at_idx" ON "_events_v" USING btree ("version_created_at"); + CREATE INDEX IF NOT EXISTS "_events_v_version_version__status_idx" ON "_events_v" USING btree ("version__status"); + CREATE INDEX IF NOT EXISTS "_events_v_created_at_idx" ON "_events_v" USING btree ("created_at"); + CREATE INDEX IF NOT EXISTS "_events_v_updated_at_idx" ON "_events_v" USING btree ("updated_at"); + CREATE INDEX IF NOT EXISTS "_events_v_snapshot_idx" ON "_events_v" USING btree ("snapshot"); + CREATE INDEX IF NOT EXISTS "_events_v_published_locale_idx" ON "_events_v" USING btree ("published_locale"); + CREATE INDEX IF NOT EXISTS "_events_v_latest_idx" ON "_events_v" USING btree ("latest"); + CREATE INDEX IF NOT EXISTS "_events_v_autosave_idx" ON "_events_v" USING btree ("autosave"); + + CREATE UNIQUE INDEX IF NOT EXISTS "_events_v_locales_locale_parent_id_unique" + ON "_events_v_locales" USING btree ("_locale", "_parent_id"); + + CREATE INDEX IF NOT EXISTS "event_enrollments_user_idx" ON "event_enrollments" USING btree ("user_id"); + CREATE INDEX IF NOT EXISTS "event_enrollments_event_idx" ON "event_enrollments" USING btree ("event_id"); + CREATE INDEX IF NOT EXISTS "event_enrollments_updated_at_idx" ON "event_enrollments" USING btree ("updated_at"); + CREATE INDEX IF NOT EXISTS "event_enrollments_created_at_idx" ON "event_enrollments" USING btree ("created_at"); + CREATE UNIQUE INDEX IF NOT EXISTS "user_event_idx" ON "event_enrollments" USING btree ("user_id", "event_id"); + + CREATE INDEX IF NOT EXISTS "pages_blocks_events_block_order_idx" ON "pages_blocks_events_block" USING btree ("_order"); + CREATE INDEX IF NOT EXISTS "pages_blocks_events_block_parent_id_idx" ON "pages_blocks_events_block" USING btree ("_parent_id"); + CREATE INDEX IF NOT EXISTS "pages_blocks_events_block_path_idx" ON "pages_blocks_events_block" USING btree ("_path"); + CREATE INDEX IF NOT EXISTS "pages_blocks_events_block_locale_idx" ON "pages_blocks_events_block" USING btree ("_locale"); + + CREATE INDEX IF NOT EXISTS "_pages_v_blocks_events_block_order_idx" ON "_pages_v_blocks_events_block" USING btree ("_order"); + CREATE INDEX IF NOT EXISTS "_pages_v_blocks_events_block_parent_id_idx" ON "_pages_v_blocks_events_block" USING btree ("_parent_id"); + CREATE INDEX IF NOT EXISTS "_pages_v_blocks_events_block_path_idx" ON "_pages_v_blocks_events_block" USING btree ("_path"); + CREATE INDEX IF NOT EXISTS "_pages_v_blocks_events_block_locale_idx" ON "_pages_v_blocks_events_block" USING btree ("_locale"); + + ALTER TABLE "pages_rels" ADD COLUMN IF NOT EXISTS "events_id" integer; + ALTER TABLE "_pages_v_rels" ADD COLUMN IF NOT EXISTS "events_id" integer; + ALTER TABLE "search_rels" ADD COLUMN IF NOT EXISTS "events_id" integer; + + DO $$ BEGIN + ALTER TABLE "pages_rels" ADD CONSTRAINT "pages_rels_events_fk" + FOREIGN KEY ("events_id") REFERENCES "public"."events"("id") ON DELETE CASCADE; + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "_pages_v_rels" ADD CONSTRAINT "_pages_v_rels_events_fk" + FOREIGN KEY ("events_id") REFERENCES "public"."events"("id") ON DELETE CASCADE; + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + DO $$ BEGIN + ALTER TABLE "search_rels" ADD CONSTRAINT "search_rels_events_fk" + FOREIGN KEY ("events_id") REFERENCES "public"."events"("id") ON DELETE CASCADE; + EXCEPTION WHEN duplicate_object THEN null; + END $$; + + CREATE INDEX IF NOT EXISTS "pages_rels_events_id_idx" ON "pages_rels" USING btree ("events_id", "locale"); + CREATE INDEX IF NOT EXISTS "_pages_v_rels_events_id_idx" ON "_pages_v_rels" USING btree ("events_id", "locale"); + CREATE INDEX IF NOT EXISTS "search_rels_events_id_idx" ON "search_rels" USING btree ("events_id"); + `) +} + +export async function down({ db }: MigrateDownArgs): Promise { + await db.execute(sql` + ALTER TABLE "pages_rels" DROP COLUMN IF EXISTS "events_id"; + ALTER TABLE "_pages_v_rels" DROP COLUMN IF EXISTS "events_id"; + ALTER TABLE "search_rels" DROP COLUMN IF EXISTS "events_id"; + + DROP TABLE IF EXISTS "pages_blocks_events_block" CASCADE; + DROP TABLE IF EXISTS "_pages_v_blocks_events_block" CASCADE; + DROP TABLE IF EXISTS "event_enrollments" CASCADE; + DROP TABLE IF EXISTS "_events_v_locales" CASCADE; + DROP TABLE IF EXISTS "_events_v" CASCADE; + DROP TABLE IF EXISTS "events_locales" CASCADE; + DROP TABLE IF EXISTS "events" CASCADE; + + DROP TYPE IF EXISTS "enum_pages_blocks_events_block_populate_by"; + DROP TYPE IF EXISTS "enum__pages_v_blocks_events_block_populate_by"; + DROP TYPE IF EXISTS "enum__events_v_published_locale"; + DROP TYPE IF EXISTS "enum__events_v_version_status"; + DROP TYPE IF EXISTS "enum__events_v_version_location_type"; + DROP TYPE IF EXISTS "enum_events_status"; + DROP TYPE IF EXISTS "enum_events_location_type"; + `) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index e68e02c..70f2196 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -21,6 +21,7 @@ import * as migration_20260724_170000_audit_access_and_indexes from './20260724_ import * as migration_20260724_190000_rate_limit from './20260724_190000_rate_limit'; import * as migration_20260724_200000_backfill_blob_urls from './20260724_200000_backfill_blob_urls'; import * as migration_20260729_100000_backfill_user_roles from './20260729_100000_backfill_user_roles'; +import * as migration_20260815_120000_events from './20260815_120000_events'; export const migrations = [ { @@ -138,4 +139,9 @@ export const migrations = [ down: migration_20260729_100000_backfill_user_roles.down, name: '20260729_100000_backfill_user_roles', }, + { + up: migration_20260815_120000_events.up, + down: migration_20260815_120000_events.down, + name: '20260815_120000_events', + }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index 7d3c3ea..ff8972a 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -82,6 +82,8 @@ export interface Config { 'course-files': CourseFile; courses: Course; enrollments: Enrollment; + events: Event; + 'event-enrollments': EventEnrollment; comments: Comment; likes: Like; 'quiz-attempts': QuizAttempt; @@ -122,6 +124,8 @@ export interface Config { 'course-files': CourseFilesSelect | CourseFilesSelect; courses: CoursesSelect | CoursesSelect; enrollments: EnrollmentsSelect | EnrollmentsSelect; + events: EventsSelect | EventsSelect; + 'event-enrollments': EventEnrollmentsSelect | EventEnrollmentsSelect; comments: CommentsSelect | CommentsSelect; likes: LikesSelect | LikesSelect; 'quiz-attempts': QuizAttemptsSelect | QuizAttemptsSelect; @@ -437,7 +441,7 @@ export interface Page { | null; media?: (number | null) | Media; }; - layout: (CallToActionBlock | ContentBlock | MediaBlock | ArchiveBlock | FormBlock)[]; + layout: (CallToActionBlock | ContentBlock | MediaBlock | ArchiveBlock | EventsBlock | FormBlock)[]; meta?: { title?: string | null; /** @@ -928,6 +932,72 @@ export interface CourseFile { focalX?: number | null; focalY?: number | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "EventsBlock". + */ +export interface EventsBlock { + introContent?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + populateBy?: ('upcoming' | 'selection') | null; + limit?: number | null; + selectedEvents?: (number | Event)[] | null; + showAllLink?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'eventsBlock'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "events". + */ +export interface Event { + id: number; + title: string; + /** + * When enabled, the slug will auto-generate from the title field on save and autosave. + */ + generateSlug?: boolean | null; + slug: string; + description?: string | null; + cover?: (number | null) | Media; + startDate: string; + endDate?: string | null; + locationType: 'local' | 'virtual'; + /** + * Місце проведення, наприклад: Київ, вул. Хрещатик, 1 + */ + address?: string | null; + /** + * Посилання на Google Maps або інший сервіс мап + */ + mapLink?: string | null; + /** + * Zoom, Google Meet або інша платформа. Бачать лише зареєстровані учасники. + */ + meetingLink?: string | null; + /** + * Залиште порожнім, якщо кількість учасників не обмежена + */ + capacity?: number | null; + publishedAt?: string | null; + updatedAt: string; + createdAt: string; + _status?: ('draft' | 'published') | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "FormBlock". @@ -1154,6 +1224,18 @@ export interface Enrollment { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "event-enrollments". + */ +export interface EventEnrollment { + id: number; + user: number | User; + event: number | Event; + enrolledAt?: string | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "comments". @@ -1287,6 +1369,10 @@ export interface Search { | { relationTo: 'pages'; value: number | Page; + } + | { + relationTo: 'events'; + value: number | Event; }; slug?: string | null; collectionType?: string | null; @@ -1820,6 +1906,7 @@ export interface PagesSelect { content?: T | ContentBlockSelect; mediaBlock?: T | MediaBlockSelect; archive?: T | ArchiveBlockSelect; + eventsBlock?: T | EventsBlockSelect; formBlock?: T | FormBlockSelect; }; meta?: @@ -1912,6 +1999,19 @@ export interface ArchiveBlockSelect { id?: T; blockName?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "EventsBlock_select". + */ +export interface EventsBlockSelect { + introContent?: T; + populateBy?: T; + limit?: T; + selectedEvents?: T; + showAllLink?: T; + id?: T; + blockName?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "FormBlock_select". @@ -2186,6 +2286,39 @@ export interface EnrollmentsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "events_select". + */ +export interface EventsSelect { + title?: T; + generateSlug?: T; + slug?: T; + description?: T; + cover?: T; + startDate?: T; + endDate?: T; + locationType?: T; + address?: T; + mapLink?: T; + meetingLink?: T; + capacity?: T; + publishedAt?: T; + updatedAt?: T; + createdAt?: T; + _status?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "event-enrollments_select". + */ +export interface EventEnrollmentsSelect { + user?: T; + event?: T; + enrolledAt?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "comments_select". @@ -2794,6 +2927,10 @@ export interface TaskSchedulePublish { | ({ relationTo: 'courses'; value: number | Course; + } | null) + | ({ + relationTo: 'events'; + value: number | Event; } | null); global?: string | null; user?: (number | null) | User; diff --git a/src/payload.config.ts b/src/payload.config.ts index 7c4e4f6..a564dd3 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -12,6 +12,8 @@ import { CourseCategories } from './collections/CourseCategories' import { CourseFiles } from './collections/CourseFiles' import { Courses } from './collections/Courses' import { Enrollments } from './collections/Enrollments' +import { EventEnrollments } from './collections/EventEnrollments' +import { Events } from './collections/Events' import { Likes } from './collections/Likes' import { QuizAttempts } from './collections/QuizAttempts' import { XpEvents } from './collections/XpEvents' @@ -111,6 +113,8 @@ export default buildConfig({ CourseFiles, Courses, Enrollments, + Events, + EventEnrollments, Comments, Likes, QuizAttempts, diff --git a/src/search/beforeSync.ts b/src/search/beforeSync.ts index 7515f11..e5b5afc 100644 --- a/src/search/beforeSync.ts +++ b/src/search/beforeSync.ts @@ -73,6 +73,22 @@ export const beforeSyncWithSearch: BeforeSync = async ({ req, originalDoc, searc } } + if (collection === 'events') { + const { slug, title, description, cover } = originalDoc + + return { + ...searchDoc, + slug, + collectionType: 'events', + meta: { + title, + description: description || null, + image: typeof cover === 'object' ? cover?.id : cover, + }, + categories: [], + } + } + if (collection === 'pages') { const { slug, title, meta } = originalDoc diff --git a/src/search/localeSync.ts b/src/search/localeSync.ts index dc0423e..e710c3c 100644 --- a/src/search/localeSync.ts +++ b/src/search/localeSync.ts @@ -5,6 +5,7 @@ export const searchIndexedCollections: CollectionSlug[] = [ 'courses', 'course-categories', 'pages', + 'events', ] // @payloadcms/plugin-search writes localized search fields only for the locale of diff --git a/src/utilities/eventTime.ts b/src/utilities/eventTime.ts new file mode 100644 index 0000000..7a5ac9d --- /dev/null +++ b/src/utilities/eventTime.ts @@ -0,0 +1,107 @@ +import type { SiteLocale } from './locales' + +export type EventTimeInfo = { + /** The moment the event stops being "upcoming" — endDate when set, else startDate. */ + endsAt: Date + startsAt: Date +} + +export const getEventTimes = (event: { + startDate: string + endDate?: string | null +}): EventTimeInfo => { + const startsAt = new Date(event.startDate) + const endsAt = event.endDate ? new Date(event.endDate) : startsAt + return { startsAt, endsAt } +} + +export const isEventPast = ( + event: { startDate: string; endDate?: string | null }, + now: Date = new Date(), +): boolean => getEventTimes(event).endsAt < now + +const INTL_LOCALES: Record = { uk: 'uk-UA', en: 'en-GB' } + +export const formatEventDate = (date: Date, locale: SiteLocale): string => + new Intl.DateTimeFormat(INTL_LOCALES[locale], { + day: 'numeric', + month: 'long', + year: 'numeric', + }).format(date) + +export const formatEventTime = (date: Date, locale: SiteLocale): string => + new Intl.DateTimeFormat(INTL_LOCALES[locale], { + hour: '2-digit', + minute: '2-digit', + }).format(date) + +export const formatEventMonthShort = (date: Date, locale: SiteLocale): string => + new Intl.DateTimeFormat(INTL_LOCALES[locale], { month: 'short' }).format(date).replace('.', '') + +/** "12 січня 2026, 18:00 – 20:00" or "12 – 14 січня 2026" for multi-day events. */ +export const formatEventRange = ( + event: { startDate: string; endDate?: string | null }, + locale: SiteLocale, +): string => { + const { startsAt, endsAt } = getEventTimes(event) + const sameDay = startsAt.toDateString() === endsAt.toDateString() + + if (!event.endDate || (sameDay && startsAt.getTime() === endsAt.getTime())) { + return `${formatEventDate(startsAt, locale)}, ${formatEventTime(startsAt, locale)}` + } + if (sameDay) { + return `${formatEventDate(startsAt, locale)}, ${formatEventTime(startsAt, locale)} – ${formatEventTime(endsAt, locale)}` + } + return `${formatEventDate(startsAt, locale)} – ${formatEventDate(endsAt, locale)}` +} + +const toGoogleDate = (date: Date): string => + date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '') + +/** Prefilled Google Calendar "create event" URL. */ +export const googleCalendarUrl = (args: { + title: string + description?: string | null + location?: string | null + startDate: string + endDate?: string | null +}): string => { + const { startsAt, endsAt } = getEventTimes(args) + // Google requires an end; default to one hour for open-ended events. + const end = endsAt.getTime() === startsAt.getTime() + ? new Date(startsAt.getTime() + 60 * 60 * 1000) + : endsAt + + const params = new URLSearchParams({ + action: 'TEMPLATE', + text: args.title, + dates: `${toGoogleDate(startsAt)}/${toGoogleDate(end)}`, + }) + if (args.description) params.set('details', args.description) + if (args.location) params.set('location', args.location) + return `https://calendar.google.com/calendar/render?${params.toString()}` +} + +export type MeetingPlatform = 'zoom' | 'google-meet' | 'youtube' | 'other' + +export const detectMeetingPlatform = (url: string | null | undefined): MeetingPlatform => { + if (!url) return 'other' + try { + const host = new URL(url).hostname + if (host === 'zoom.us' || host.endsWith('.zoom.us')) return 'zoom' + if (host === 'meet.google.com') return 'google-meet' + if (host === 'youtube.com' || host.endsWith('.youtube.com') || host === 'youtu.be') { + return 'youtube' + } + } catch { + // not a parseable URL — fall through + } + return 'other' +} + +export const MEETING_PLATFORM_LABELS: Record = { + zoom: 'Zoom', + 'google-meet': 'Google Meet', + youtube: 'YouTube', + other: 'Online', +} diff --git a/src/utilities/i18n.ts b/src/utilities/i18n.ts index 52580d9..a44c80a 100644 --- a/src/utilities/i18n.ts +++ b/src/utilities/i18n.ts @@ -305,6 +305,49 @@ type FrontendMessages = { forgotResetting: string forgotSuccess: string forgotErrorGeneric: string + eventsTitle: string + eventsMetaTitle: string + eventsEyebrow: string + eventsSub: string + eventsUpcoming: string + eventsPast: string + eventsEmptyUpcoming: string + eventsEmptyPast: string + eventsNextEvent: string + eventsAll: string + eventOnline: string + eventOffline: string + eventToday: string + eventFinished: string + eventEnroll: string + eventUnenroll: string + eventUnenrollConfirm: string + eventEnrolledBadge: string + eventFull: string + eventSeatsLeft: string + eventParticipantsPlural: string + eventLoginToEnroll: string + eventSignIn: string + eventBackToEvents: string + eventLocationTitle: string + eventOpenMap: string + eventJoinTitle: string + eventJoin: string + eventJoinHint: string + eventJoinLocked: string + eventAddToCalendar: string + eventGoogleCalendar: string + eventDownloadIcs: string + eventStartLabel: string + eventEndLabel: string + eventStartsInDaysPlural: string + eventStartsInHoursPlural: string + eventStartsInMinutesPlural: string + eventStartsInSuffix: string + eventStartsNow: string + profileUpcomingEvents: string + profileNoUpcomingEvents: string + eventsBlockSeeAll: string } const frontendMessages: Record = { @@ -621,6 +664,49 @@ const frontendMessages: Record = { forgotResetting: 'Зберігаємо…', forgotSuccess: 'Пароль змінено. Тепер ви можете увійти.', forgotErrorGeneric: 'Не вдалося скинути пароль. Перевірте код і спробуйте ще раз.', + eventsTitle: 'Події', + eventsMetaTitle: 'Події | Залізна Зміна', + eventsEyebrow: 'Календар', + eventsSub: 'Зустрічі, табори та онлайн-зміни спільноти — реєструйся та приєднуйся.', + eventsUpcoming: 'Майбутні', + eventsPast: 'Минулі', + eventsEmptyUpcoming: 'Найближчих подій поки немає — зазирни згодом.', + eventsEmptyPast: 'Минулих подій ще немає.', + eventsNextEvent: 'Найближча подія', + eventsAll: 'Всі події', + eventOnline: 'Онлайн', + eventOffline: 'Офлайн', + eventToday: 'Сьогодні', + eventFinished: 'Завершилась', + eventEnroll: 'Зареєструватися', + eventUnenroll: 'Скасувати реєстрацію', + eventUnenrollConfirm: 'Скасувати вашу реєстрацію на цю подію?', + eventEnrolledBadge: 'Ви зареєстровані', + eventFull: 'Вільних місць немає', + eventSeatsLeft: 'Залишилось місць:', + eventParticipantsPlural: 'учасник|учасники|учасників', + eventLoginToEnroll: 'Увійдіть, щоб зареєструватися на подію', + eventSignIn: 'Увійти', + eventBackToEvents: 'До всіх подій', + eventLocationTitle: 'Місце проведення', + eventOpenMap: 'Відкрити мапу', + eventJoinTitle: 'Онлайн-зустріч', + eventJoin: 'Приєднатися', + eventJoinHint: 'Посилання на зустріч доступне після реєстрації.', + eventJoinLocked: 'Зареєструйтесь, щоб отримати посилання на зустріч', + eventAddToCalendar: 'Додати в календар', + eventGoogleCalendar: 'Google Календар', + eventDownloadIcs: 'Файл .ics', + eventStartLabel: 'Початок', + eventEndLabel: 'Завершення', + eventStartsInDaysPlural: 'день|дні|днів', + eventStartsInHoursPlural: 'година|години|годин', + eventStartsInMinutesPlural: 'хвилина|хвилини|хвилин', + eventStartsInSuffix: 'до початку', + eventStartsNow: 'Вже триває', + profileUpcomingEvents: 'Мої події', + profileNoUpcomingEvents: 'Ви поки не зареєстровані на жодну подію.', + eventsBlockSeeAll: 'Всі події', }, en: { searchTitle: 'Search', @@ -934,6 +1020,49 @@ const frontendMessages: Record = { forgotResetting: 'Saving…', forgotSuccess: 'Password changed. You can now log in.', forgotErrorGeneric: 'Could not reset password. Check the code and try again.', + eventsTitle: 'Events', + eventsMetaTitle: 'Events | Iron Squad', + eventsEyebrow: 'Calendar', + eventsSub: 'Community meetups, camps and online sessions — register and join in.', + eventsUpcoming: 'Upcoming', + eventsPast: 'Past', + eventsEmptyUpcoming: 'No upcoming events yet — check back soon.', + eventsEmptyPast: 'No past events yet.', + eventsNextEvent: 'Next event', + eventsAll: 'All events', + eventOnline: 'Online', + eventOffline: 'In person', + eventToday: 'Today', + eventFinished: 'Finished', + eventEnroll: 'Register', + eventUnenroll: 'Cancel registration', + eventUnenrollConfirm: 'Cancel your registration for this event?', + eventEnrolledBadge: 'You are registered', + eventFull: 'No seats left', + eventSeatsLeft: 'Seats left:', + eventParticipantsPlural: 'participant|participants|participants', + eventLoginToEnroll: 'Sign in to register for this event', + eventSignIn: 'Sign in', + eventBackToEvents: 'All events', + eventLocationTitle: 'Location', + eventOpenMap: 'Open map', + eventJoinTitle: 'Online meeting', + eventJoin: 'Join', + eventJoinHint: 'The meeting link becomes available after you register.', + eventJoinLocked: 'Register to get the meeting link', + eventAddToCalendar: 'Add to calendar', + eventGoogleCalendar: 'Google Calendar', + eventDownloadIcs: '.ics file', + eventStartLabel: 'Start', + eventEndLabel: 'End', + eventStartsInDaysPlural: 'day|days|days', + eventStartsInHoursPlural: 'hour|hours|hours', + eventStartsInMinutesPlural: 'minute|minutes|minutes', + eventStartsInSuffix: 'to start', + eventStartsNow: 'Happening now', + profileUpcomingEvents: 'My events', + profileNoUpcomingEvents: 'You are not registered for any events yet.', + eventsBlockSeeAll: 'All events', }, } diff --git a/tests/e2e/events.e2e.spec.ts b/tests/e2e/events.e2e.spec.ts new file mode 100644 index 0000000..b9d2afd --- /dev/null +++ b/tests/e2e/events.e2e.spec.ts @@ -0,0 +1,64 @@ +import { expect, test, type Page } from '@playwright/test' +import { getPayload } from 'payload' + +import config from '../../src/payload.config.js' +import { minimalEventData } from '../helpers/factories' + +const waitForPageReady = async (page: Page) => { + await page.getByTestId('header-logo-link').waitFor({ state: 'visible' }) + await expect(page.getByTestId('lang-switch-en')).toBeVisible() + await expect(page.getByTestId('lang-switch-uk')).toBeVisible() +} + +let eventId: number +let eventSlug: string + +test.beforeAll(async () => { + const payload = await getPayload({ config: await config }) + const event = await payload.create({ + collection: 'events', + data: minimalEventData('E2E Public Event') as never, + }) + eventId = event.id + eventSlug = event.slug +}) + +test.afterAll(async () => { + const payload = await getPayload({ config: await config }) + await payload + .delete({ collection: 'events', id: eventId, context: { disableRevalidate: true } }) + .catch(() => {}) +}) + +test.describe('Events pages', () => { + test('events page loads in ukrainian and english', async ({ page }) => { + await page.goto('/events') + await waitForPageReady(page) + await expect(page.getByTestId('events-page-title')).toHaveText('Події') + + await page.goto('/en/events') + await waitForPageReady(page) + await expect(page.getByTestId('events-page-title')).toHaveText('Events') + }) + + test('event detail page shows the seeded event', async ({ page }) => { + await page.goto(`/events/${eventSlug}`) + await waitForPageReady(page) + await expect(page.getByTestId('event-page-title')).toHaveText('E2E Public Event') + }) + + test('anonymous visitors are asked to sign in before registering', async ({ page }) => { + await page.goto(`/events/${eventSlug}`) + await waitForPageReady(page) + await expect(page.getByTestId('event-signin-link')).toBeVisible() + }) + + test('ics download responds with a calendar file', async ({ request }) => { + const response = await request.get(`/api/events/${eventId}/calendar.ics`) + expect(response.status()).toBe(200) + expect(response.headers()['content-type']).toContain('text/calendar') + const body = await response.text() + expect(body).toContain('BEGIN:VEVENT') + expect(body).toContain('E2E Public Event') + }) +}) diff --git a/tests/helpers/factories.ts b/tests/helpers/factories.ts index 6107587..afb4902 100644 --- a/tests/helpers/factories.ts +++ b/tests/helpers/factories.ts @@ -44,3 +44,25 @@ export function minimalCourseData(titlePrefix: string, stepCount = 1) { steps: Array.from({ length: stepCount }, (_, i) => minimalRichTextStep(`Step ${i + 1}`)), } } + +/** + * Minimal published event data with a unique slug. Defaults to an offline + * event starting tomorrow; override any field (e.g. `_status: 'draft'`, + * `locationType: 'virtual'`, a past `startDate`). + */ +export function minimalEventData( + titlePrefix: string, + overrides: Record = {}, +) { + const uid = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}` + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000) + return { + title: titlePrefix, + slug: `${titlePrefix.toLowerCase().replace(/\s+/g, '-')}-${uid}`, + startDate: tomorrow.toISOString(), + locationType: 'local' as const, + address: 'Київ, вул. Тестова, 1', + _status: 'published' as const, + ...overrides, + } +} diff --git a/tests/int/event-enrollments.int.spec.ts b/tests/int/event-enrollments.int.spec.ts new file mode 100644 index 0000000..5b64a94 --- /dev/null +++ b/tests/int/event-enrollments.int.spec.ts @@ -0,0 +1,257 @@ +import { getPayload, Payload } from 'payload' +import config from '@/payload.config' +import { describe, it, beforeAll, afterAll, afterEach, expect } from 'vitest' +import { minimalEventData } from '../helpers/factories' +import type { Event, User } from '@/payload-types' + +let payload: Payload +let user: User +let otherUser: User +let adminUser: User +let event: Event +const createdEventIds: number[] = [] + +async function createEvent(data: Record) { + const doc = await payload.create({ collection: 'events', data: data as never }) + createdEventIds.push(doc.id) + return doc +} + +describe('EventEnrollments', () => { + beforeAll(async () => { + const payloadConfig = await config + payload = await getPayload({ config: payloadConfig }) + + const uid = Date.now() + user = (await payload.create({ + collection: 'users', + data: { + name: 'Event Enroll User', + email: `event-enroll-${uid}@test.local`, + emailVerified: true, + role: ['learner'], + }, + })) as User + otherUser = (await payload.create({ + collection: 'users', + data: { + name: 'Event Enroll Other', + email: `event-enroll-other-${uid}@test.local`, + emailVerified: true, + role: ['learner'], + }, + })) as User + adminUser = (await payload.create({ + collection: 'users', + data: { + name: 'Event Enroll Admin', + email: `event-enroll-admin-${uid}@test.local`, + emailVerified: true, + role: ['admin'], + }, + })) as User + + event = (await createEvent(minimalEventData('Enrollable Event'))) as Event + }) + + afterEach(async () => { + await payload.delete({ + collection: 'event-enrollments', + where: { user: { in: [user.id, otherUser.id] } }, + }) + }) + + afterAll(async () => { + for (const id of createdEventIds) { + await payload + .delete({ collection: 'events', id, context: { disableRevalidate: true } }) + .catch(() => {}) + } + for (const u of [user, otherUser, adminUser]) { + await payload.delete({ collection: 'users', id: u.id }).catch(() => {}) + } + }) + + it('stamps enrolledAt on create', async () => { + const enrollment = await payload.create({ + collection: 'event-enrollments', + data: { user: user.id, event: event.id }, + }) + expect(enrollment.enrolledAt).toBeTruthy() + }) + + it('rejects duplicate registration for the same user+event', async () => { + await payload.create({ + collection: 'event-enrollments', + data: { user: user.id, event: event.id }, + }) + await expect( + payload.create({ + collection: 'event-enrollments', + data: { user: user.id, event: event.id }, + }), + ).rejects.toMatchObject({ status: 409 }) + }) + + it('rejects registration for a draft event', async () => { + const draft = await createEvent(minimalEventData('Draft Only Event', { _status: 'draft' })) + await expect( + payload.create({ + collection: 'event-enrollments', + data: { user: user.id, event: draft.id }, + }), + ).rejects.toMatchObject({ status: 404 }) + }) + + it('rejects registration for a finished event', async () => { + const past = await createEvent( + minimalEventData('Past Event', { + startDate: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(), + endDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + }), + ) + await expect( + payload.create({ + collection: 'event-enrollments', + data: { user: user.id, event: past.id }, + }), + ).rejects.toMatchObject({ status: 400 }) + }) + + it('allows registration while an event is in progress', async () => { + const ongoing = await createEvent( + minimalEventData('Ongoing Event', { + startDate: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + endDate: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }), + ) + const enrollment = await payload.create({ + collection: 'event-enrollments', + data: { user: user.id, event: ongoing.id }, + }) + expect(enrollment.id).toBeDefined() + }) + + it('rejects registration once capacity is reached, but admins may override', async () => { + const small = await createEvent(minimalEventData('Tiny Event', { capacity: 1 })) + + await payload.create({ + collection: 'event-enrollments', + data: { user: user.id, event: small.id }, + }) + await expect( + payload.create({ + collection: 'event-enrollments', + data: { user: otherUser.id, event: small.id }, + }), + ).rejects.toMatchObject({ status: 409 }) + + // Admin panel sign-ups (e.g. offline registrations) bypass the capacity rule. + const adminCreated = await payload.create({ + collection: 'event-enrollments', + data: { user: otherUser.id, event: small.id }, + user: adminUser, + overrideAccess: false, + }) + expect(adminCreated.id).toBeDefined() + }) + + it('binds non-admin API registrations to the requesting user', async () => { + const enrollment = await payload.create({ + collection: 'event-enrollments', + // A forged user id must not let one user register another. + data: { user: otherUser.id, event: event.id }, + user, + overrideAccess: false, + }) + const boundUserId = + typeof enrollment.user === 'object' ? enrollment.user.id : enrollment.user + expect(boundUserId).toBe(user.id) + }) + + it('read access: own rows only for non-admins, everything for admins, nothing for anon', async () => { + await payload.create({ + collection: 'event-enrollments', + data: { user: user.id, event: event.id }, + }) + + const own = await payload.find({ + collection: 'event-enrollments', + where: { event: { equals: event.id } }, + user, + overrideAccess: false, + }) + expect(own.totalDocs).toBe(1) + + const foreign = await payload.find({ + collection: 'event-enrollments', + where: { event: { equals: event.id } }, + user: otherUser, + overrideAccess: false, + }) + expect(foreign.totalDocs).toBe(0) + + const admin = await payload.find({ + collection: 'event-enrollments', + where: { event: { equals: event.id } }, + user: adminUser, + overrideAccess: false, + }) + expect(admin.totalDocs).toBe(1) + + await expect( + payload.find({ + collection: 'event-enrollments', + where: { event: { equals: event.id } }, + overrideAccess: false, + }), + ).rejects.toThrow() + }) + + it('owners can cancel their own registration; others cannot', async () => { + const enrollment = await payload.create({ + collection: 'event-enrollments', + data: { user: user.id, event: event.id }, + }) + + await expect( + payload.delete({ + collection: 'event-enrollments', + id: enrollment.id, + user: otherUser, + overrideAccess: false, + }), + ).rejects.toThrow() + + const deleted = await payload.delete({ + collection: 'event-enrollments', + id: enrollment.id, + user, + overrideAccess: false, + }) + expect(deleted.id).toBe(enrollment.id) + + const remaining = await payload.find({ + collection: 'event-enrollments', + where: { id: { equals: enrollment.id } }, + }) + expect(remaining.totalDocs).toBe(0) + }) + + it('progress-free rows are not owner-updatable', async () => { + const enrollment = await payload.create({ + collection: 'event-enrollments', + data: { user: user.id, event: event.id }, + }) + + await expect( + payload.update({ + collection: 'event-enrollments', + id: enrollment.id, + data: { enrolledAt: new Date(0).toISOString() }, + user, + overrideAccess: false, + }), + ).rejects.toThrow() + }) +}) diff --git a/tests/int/events.int.spec.ts b/tests/int/events.int.spec.ts new file mode 100644 index 0000000..23b65f0 --- /dev/null +++ b/tests/int/events.int.spec.ts @@ -0,0 +1,165 @@ +import { getPayload, Payload } from 'payload' +import config from '@/payload.config' +import { describe, it, beforeAll, afterAll, expect } from 'vitest' +import { minimalEventData } from '../helpers/factories' +import type { User } from '@/payload-types' + +let payload: Payload +let regularUser: User +const createdEventIds: number[] = [] + +async function createEvent(data: Record) { + const event = await payload.create({ + collection: 'events', + data: data as never, + }) + createdEventIds.push(event.id) + return event +} + +describe('Events', () => { + beforeAll(async () => { + const payloadConfig = await config + payload = await getPayload({ config: payloadConfig }) + + regularUser = (await payload.create({ + collection: 'users', + data: { + name: 'Events Test User', + email: `events-test-${Date.now()}@test.local`, + emailVerified: true, + role: ['learner'], + }, + })) as User + }) + + afterAll(async () => { + for (const id of createdEventIds) { + await payload + .delete({ collection: 'events', id, context: { disableRevalidate: true } }) + .catch(() => {}) + } + await payload.delete({ collection: 'users', id: regularUser.id }).catch(() => {}) + }) + + it('creates a published local event', async () => { + const event = await createEvent(minimalEventData('Local Event')) + expect(event._status).toBe('published') + expect(event.locationType).toBe('local') + expect(event.address).toBeTruthy() + }) + + it('rejects publishing a virtual event without a meeting link', async () => { + await expect( + payload.create({ + collection: 'events', + data: minimalEventData('Virtual No Link', { + locationType: 'virtual', + address: undefined, + }) as never, + }), + ).rejects.toThrow() + }) + + it('rejects publishing a local event without an address', async () => { + await expect( + payload.create({ + collection: 'events', + data: minimalEventData('Local No Address', { address: undefined }) as never, + }), + ).rejects.toThrow() + }) + + it('rejects an endDate that is not after startDate', async () => { + const start = new Date(Date.now() + 24 * 60 * 60 * 1000) + await expect( + payload.create({ + collection: 'events', + data: minimalEventData('Bad Dates', { + startDate: start.toISOString(), + endDate: new Date(start.getTime() - 60 * 60 * 1000).toISOString(), + }) as never, + }), + ).rejects.toThrow() + }) + + it('hides draft events from anonymous reads', async () => { + const draft = await createEvent(minimalEventData('Draft Event', { _status: 'draft' })) + + const anonResult = await payload.find({ + collection: 'events', + where: { id: { equals: draft.id } }, + overrideAccess: false, + }) + expect(anonResult.totalDocs).toBe(0) + }) + + it('strips meetingLink from anonymous reads but keeps it for authenticated users', async () => { + const event = await createEvent( + minimalEventData('Virtual Event', { + locationType: 'virtual', + address: undefined, + meetingLink: 'https://us02web.zoom.us/j/1234567890', + }), + ) + + const anonResult = await payload.find({ + collection: 'events', + where: { id: { equals: event.id } }, + overrideAccess: false, + }) + expect(anonResult.totalDocs).toBe(1) + expect(anonResult.docs[0].meetingLink ?? null).toBeNull() + + const authedResult = await payload.find({ + collection: 'events', + where: { id: { equals: event.id } }, + user: regularUser, + overrideAccess: false, + }) + expect(authedResult.docs[0].meetingLink).toBe('https://us02web.zoom.us/j/1234567890') + }) + + it('rejects create/update from non-admin users', async () => { + await expect( + payload.create({ + collection: 'events', + data: minimalEventData('Forged Event') as never, + user: regularUser, + overrideAccess: false, + }), + ).rejects.toThrow() + + const event = await createEvent(minimalEventData('Untouchable Event')) + await expect( + payload.update({ + collection: 'events', + id: event.id, + data: { title: 'Hacked' }, + user: regularUser, + overrideAccess: false, + }), + ).rejects.toThrow() + }) + + it('deleting an event cascades its enrollments', async () => { + const event = await createEvent(minimalEventData('Cascade Event')) + await payload.create({ + collection: 'event-enrollments', + data: { user: regularUser.id, event: event.id }, + }) + + await payload.delete({ + collection: 'events', + id: event.id, + context: { disableRevalidate: true }, + }) + createdEventIds.splice(createdEventIds.indexOf(event.id), 1) + + const orphans = await payload.find({ + collection: 'event-enrollments', + where: { user: { equals: regularUser.id } }, + }) + expect(orphans.totalDocs).toBe(0) + }) +}) From eca13817dd460194994c511261cbca2788d5e53b Mon Sep 17 00:00:00 2001 From: InnerDesires Date: Sat, 15 Aug 2026 07:18:02 +0300 Subject: [PATCH 02/11] chore: trigger preview deploy [preview] From 85554662fb061a2ff40b7de91f598b0cfda9237f Mon Sep 17 00:00:00 2001 From: InnerDesires Date: Sat, 15 Aug 2026 07:25:25 +0300 Subject: [PATCH 03/11] chore: retrigger preview after preview-branch schema sync [preview] From cc63a72d73fe016043ba89e12f6b2c9dfeba43ca Mon Sep 17 00:00:00 2001 From: InnerDesires Date: Sat, 15 Aug 2026 07:49:17 +0300 Subject: [PATCH 04/11] chore: rebuild preview on re-forked preview branch [preview] From 2d00f0a550b298eb600ee7389c98b86ec375cc64 Mon Sep 17 00:00:00 2001 From: InnerDesires Date: Mon, 28 Sep 2026 23:12:48 +0300 Subject: [PATCH 05/11] fix: allow preview branch origin for admin login [preview] --- docs/auth.md | 16 ++++++++-------- src/lib/auth/client.ts | 1 - src/lib/auth/options.ts | 13 ++++++++++--- src/payload.config.ts | 3 +++ 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/docs/auth.md b/docs/auth.md index ce70966..7bc5208 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -75,20 +75,20 @@ Peer dependencies: `payload >= 3.69.0`, `@payloadcms/next >= 3.69.0` (satisfied | Variable | Example | Description | |----------|---------|-------------| | `BETTER_AUTH_SECRET` | `openssl rand -hex 32` | Signs session cookies | -| `NEXT_PUBLIC_BETTER_AUTH_URL` | `http://localhost:3000` | Base URL for auth API calls. **Optional on Vercel** — auto-derived from `VERCEL_PROJECT_PRODUCTION_URL`. Only needed for local dev or custom domain overrides. | +| `NEXT_PUBLIC_BETTER_AUTH_URL` | `http://localhost:3000` | Server-side Better Auth base URL override for local development or a production custom domain. The browser client calls its own origin. Previews use their Vercel URL even if this variable is set. | | `GOOGLE_CLIENT_ID` | `xxx.apps.googleusercontent.com` | Google OAuth client ID | | `GOOGLE_CLIENT_SECRET` | `GOCSPX-xxx` | Google OAuth client secret | ### Base URL resolution order -Both the server config (`options.ts`) and the client (`client.ts`) resolve the base URL in the same priority: +The server config (`options.ts`) resolves its base URL in this order: -1. `NEXT_PUBLIC_BETTER_AUTH_URL` — explicit override (local dev, custom domain) -2. `VERCEL_PROJECT_PRODUCTION_URL` / `NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL` — Vercel's stable production domain, available on **all** deployments (production + preview) -3. `VERCEL_URL` / `NEXT_PUBLIC_VERCEL_URL` — per-deployment URL (fallback) -4. `http://localhost:3000` — local development fallback +1. On previews, `VERCEL_BRANCH_URL`, then `VERCEL_URL` +2. Otherwise, `NEXT_PUBLIC_BETTER_AUTH_URL` — explicit override (local dev, custom domain) +3. On production, `VERCEL_PROJECT_PRODUCTION_URL` +4. `VERCEL_URL`, then `VERCEL_PROJECT_PRODUCTION_URL`, then `http://localhost:3000` -On Vercel, `VERCEL_PROJECT_PRODUCTION_URL` is the production domain (e.g. `learning-platform-six-cyan.vercel.app`) and is always set, even on preview deployments. This means auth API calls from preview deployments route to the production domain, which is correct because Better Auth sessions are tied to a single origin and Google OAuth redirect URIs are registered against the production domain. +The browser client calls `/api/auth` on the page's own origin. Better Auth and Payload both allow the Vercel branch URL, so sign-in works from the stable PR preview link as well as the generated deployment URL. Google OAuth redirect URI: `https://{production-domain}/api/auth/callback/google` @@ -367,7 +367,7 @@ If `/admin` redirects to the first-admin setup screen but rejects your email as ### Session cookie not set after sign in -Check that the resolved base URL matches the origin you're accessing the app from. On Vercel, this is `VERCEL_PROJECT_PRODUCTION_URL`. Locally, set `NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000`. The `trustedOrigins` config must include the URL. +Check that the resolved base URL matches the origin you're accessing the app from. On Vercel previews, check `VERCEL_BRANCH_URL` and `VERCEL_URL`; on production, check the configured custom domain. Locally, set `NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000`. The `trustedOrigins` config must include the URL. ### Google OAuth callback fails diff --git a/src/lib/auth/client.ts b/src/lib/auth/client.ts index 5535e08..d9d92b2 100644 --- a/src/lib/auth/client.ts +++ b/src/lib/auth/client.ts @@ -4,7 +4,6 @@ import { createAuthClient } from 'better-auth/react' import { emailOTPClient } from 'better-auth/client/plugins' export const authClient = createAuthClient({ - baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL, plugins: [emailOTPClient()], }) diff --git a/src/lib/auth/options.ts b/src/lib/auth/options.ts index 42ce40a..b3c51c4 100644 --- a/src/lib/auth/options.ts +++ b/src/lib/auth/options.ts @@ -8,9 +8,14 @@ import { consumePreVerified } from '@/lib/auth/pre-verified' import { isRateLimitEnabled } from '@/lib/rate-limit' function resolveBaseURL(): string { - // Explicit override wins: VERCEL_PROJECT_PRODUCTION_URL is the *shortest* - // production custom domain, which during a domain migration is the old one. - // Set this in Vercel for the production environment only, or previews break. + // Preview requests use the branch alias. A production URL override may be + // present in Vercel's Preview environment, so select the preview host first. + if (process.env.VERCEL_ENV === 'preview') { + if (process.env.VERCEL_BRANCH_URL) return `https://${process.env.VERCEL_BRANCH_URL}` + if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}` + } + // Outside previews, the explicit override wins: Vercel may report an old + // production domain during a custom domain migration. if (process.env.NEXT_PUBLIC_BETTER_AUTH_URL) return process.env.NEXT_PUBLIC_BETTER_AUTH_URL if (process.env.VERCEL_PROJECT_PRODUCTION_URL && process.env.VERCEL_ENV === 'production') return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` @@ -24,6 +29,8 @@ const baseURL = resolveBaseURL() const trustedOrigins = new Set([baseURL]) if (process.env.VERCEL_URL) trustedOrigins.add(`https://${process.env.VERCEL_URL}`) +if (process.env.VERCEL_ENV === 'preview' && process.env.VERCEL_BRANCH_URL) + trustedOrigins.add(`https://${process.env.VERCEL_BRANCH_URL}`) if (process.env.VERCEL_PROJECT_PRODUCTION_URL) trustedOrigins.add(`https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`) if (process.env.NEXT_PUBLIC_BETTER_AUTH_URL) diff --git a/src/payload.config.ts b/src/payload.config.ts index a564dd3..a8bf787 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -123,6 +123,9 @@ export default buildConfig({ cors: [ getServerSideURL(), process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : '', + process.env.VERCEL_ENV === 'preview' && process.env.VERCEL_BRANCH_URL + ? `https://${process.env.VERCEL_BRANCH_URL}` + : '', ].filter(Boolean), globals: [Header, Footer, HomeCalendar], i18n: { From d2747621d170a163254fba0fb79ee3abf95dc34c Mon Sep 17 00:00:00 2001 From: InnerDesires Date: Mon, 28 Sep 2026 23:40:27 +0300 Subject: [PATCH 06/11] feat: improve event experience and interactions [preview] --- src/actions/commentsAndLikes.ts | 6 +- .../[locale]/events/[slug]/page.tsx | 421 ++++++++++-------- src/app/(frontend)/[locale]/events/page.tsx | 14 +- src/app/api/events/[id]/calendar.ics/route.ts | 4 +- src/collections/Comments.ts | 1 + src/collections/Events.ts | 31 ++ src/collections/Likes.ts | 1 + .../CommentsAndLikes/CommentsSection.tsx | 2 +- .../CommentsAndLikes/InteractionClient.tsx | 6 +- .../CommentsAndLikes/InteractionSection.tsx | 4 +- .../CommentsAndLikes/LikeButton.tsx | 9 +- src/components/Events/EventActionBar.tsx | 2 +- src/components/Events/EventCard.tsx | 3 +- src/components/Events/EventJoinCard.tsx | 3 +- .../Profile/ProfileLatestComments.tsx | 21 +- .../20260928_120000_event_interactions.ts | 15 + src/migrations/index.ts | 6 + src/payload-types.ts | 4 +- src/plugins/index.ts | 4 +- src/utilities/eventTime.ts | 22 +- src/utilities/getURL.ts | 9 + src/utilities/i18n.ts | 6 + tests/e2e/events.e2e.spec.ts | 6 + tests/int/events.int.spec.ts | 49 ++ 24 files changed, 433 insertions(+), 216 deletions(-) create mode 100644 src/migrations/20260928_120000_event_interactions.ts diff --git a/src/actions/commentsAndLikes.ts b/src/actions/commentsAndLikes.ts index 15f56b2..6ef8c3c 100644 --- a/src/actions/commentsAndLikes.ts +++ b/src/actions/commentsAndLikes.ts @@ -7,8 +7,8 @@ import { getSession } from '@/lib/auth/getSession' const isRateLimited = (err: unknown): boolean => err instanceof APIError && err.status === 429 -type TargetCollection = 'posts' | 'courses' -type LikeTargetCollection = 'posts' | 'courses' | 'comments' +type TargetCollection = 'posts' | 'courses' | 'events' +type LikeTargetCollection = TargetCollection | 'comments' export type CommentWithMeta = { id: number @@ -249,7 +249,7 @@ export type LikeInfo = { } function revalidateCounts(kind: 'likes' | 'comments', targetCollection: LikeTargetCollection) { - if (targetCollection === 'posts' || targetCollection === 'courses') { + if (targetCollection !== 'comments') { revalidateTag(`${kind}-counts-${targetCollection}`) } } diff --git a/src/app/(frontend)/[locale]/events/[slug]/page.tsx b/src/app/(frontend)/[locale]/events/[slug]/page.tsx index 6556e39..de1ac13 100644 --- a/src/app/(frontend)/[locale]/events/[slug]/page.tsx +++ b/src/app/(frontend)/[locale]/events/[slug]/page.tsx @@ -5,15 +5,17 @@ import React from 'react' import { notFound } from 'next/navigation' import Link from 'next/link' import Image from 'next/image' -import { ArrowLeft, CalendarDays, Clock, MapPin, Users, Video } from 'lucide-react' +import { ArrowLeft, CalendarDays, Clock3, MapPin, Users, Video } from 'lucide-react' import { locales, type SiteLocale } from '@/utilities/locales' import { getFrontendMessages } from '@/utilities/i18n' import { plural } from '@/utilities/plural' import type { Event, Media as MediaType } from '@/payload-types' -import { getServerSideURL } from '@/utilities/getURL' +import { getPreviewAwareServerURL } from '@/utilities/getURL' import { formatEventDate, + formatEventDayNumber, + formatEventMonthShort, formatEventRange, formatEventTime, getEventTimes, @@ -23,55 +25,56 @@ import { EventUserStateProvider } from '@/components/Events/EventUserState' import { EventActionBar } from '@/components/Events/EventActionBar' import { EventJoinCard } from '@/components/Events/EventJoinCard' import { AddToCalendar } from '@/components/Events/AddToCalendar' +import { InteractionSection } from '@/components/CommentsAndLikes/InteractionSection' +import { ShareButtons } from '@/components/ShareButtons' export const revalidate = 300 -export async function generateStaticParams() { - const payload = await getPayload({ config: configPromise }) - const events = await payload.find({ - collection: 'events', - draft: false, - limit: 1000, - overrideAccess: false, - pagination: false, - where: { _status: { equals: 'published' } }, - select: { slug: true }, - }) - - return events.docs.flatMap(({ slug }) => locales.map((locale) => ({ locale, slug }))) -} - type Args = { params: Promise<{ locale: SiteLocale; slug: string }> } +function eventPageUrl(locale: SiteLocale, slug: string): string { + const base = getPreviewAwareServerURL() + return `${base}${locale === 'en' ? '/en' : ''}/events/${encodeURIComponent(slug)}` +} + async function queryEventBySlug(locale: SiteLocale, slug: string): Promise { const payload = await getPayload({ config: configPromise }) - // overrideAccess: false — published only, meeting link stripped from the - // static render (registered users fetch it client-side). + // The meeting link is excluded from the shared ISR page by field access. const result = await payload.find({ collection: 'events', locale, depth: 1, draft: false, overrideAccess: false, - where: { - slug: { equals: slug }, - _status: { equals: 'published' }, - }, + where: { slug: { equals: slug }, _status: { equals: 'published' } }, limit: 1, }) return result.docs[0] as Event | undefined } +export async function generateStaticParams() { + const payload = await getPayload({ config: configPromise }) + const events = await payload.find({ + collection: 'events', + draft: false, + limit: 1000, + overrideAccess: false, + pagination: false, + where: { _status: { equals: 'published' } }, + select: { slug: true }, + }) + return events.docs.flatMap(({ slug }) => locales.map((locale) => ({ locale, slug }))) +} + export default async function EventPage({ params: paramsPromise }: Args) { const { locale, slug } = await paramsPromise const t = getFrontendMessages(locale) - const payload = await getPayload({ config: configPromise }) - const event = await queryEventBySlug(locale, slug) if (!event) notFound() + const payload = await getPayload({ config: configPromise }) const { totalDocs: enrolledCount } = await payload.count({ collection: 'event-enrollments', where: { event: { equals: event.id } }, @@ -82,213 +85,243 @@ export default async function EventPage({ params: paramsPromise }: Args) { const prefix = locale === 'en' ? '/en' : '' const past = isEventPast(event) const isFull = typeof event.capacity === 'number' && enrolledCount >= event.capacity - const seatsLeft = - typeof event.capacity === 'number' ? Math.max(event.capacity - enrolledCount, 0) : null + const seatsLeft = typeof event.capacity === 'number' ? Math.max(event.capacity - enrolledCount, 0) : null const { startsAt, endsAt } = getEventTimes(event) - const multiDay = startsAt.toDateString() !== endsAt.toDateString() - const eventUrl = `${getServerSideURL()}${prefix}/events/${event.slug}` + const eventUrl = eventPageUrl(locale, event.slug) + const locationLabel = event.locationType === 'virtual' ? t.eventOnline : t.eventOffline + const schema = { + '@context': 'https://schema.org', + '@type': 'Event', + name: event.title, + description: event.description || undefined, + startDate: startsAt.toISOString(), + endDate: event.endDate ? endsAt.toISOString() : undefined, + eventStatus: 'https://schema.org/EventScheduled', + eventAttendanceMode: event.locationType === 'virtual' + ? 'https://schema.org/OnlineEventAttendanceMode' + : 'https://schema.org/OfflineEventAttendanceMode', + location: event.locationType === 'virtual' + ? { '@type': 'VirtualLocation', url: eventUrl } + : { '@type': 'Place', name: event.address || t.eventOffline, address: event.address }, + image: coverUrl ? new URL(coverUrl, new URL(eventUrl).origin).href : undefined, + url: eventUrl, + } return ( -
-
- {coverUrl && ( - - )} -
-
+
+