diff --git a/public/thumbnail/EventTableThumbnail.jpg b/public/thumbnail/EventTableThumbnail.jpg new file mode 100644 index 00000000..dda903a0 Binary files /dev/null and b/public/thumbnail/EventTableThumbnail.jpg differ diff --git a/src/app/(frontend)/[center]/events/[slug]/page.tsx b/src/app/(frontend)/[center]/events/[slug]/page.tsx index da467ac7..d2cc3176 100644 --- a/src/app/(frontend)/[center]/events/[slug]/page.tsx +++ b/src/app/(frontend)/[center]/events/[slug]/page.tsx @@ -2,7 +2,7 @@ import { Redirects } from '@/components/Redirects' import RichText from '@/components/RichText' import configPromise from '@payload-config' import { draftMode } from 'next/headers' -import { getPayload, Where } from 'payload' +import { getPayload } from 'payload' import { EventInfo } from '@/components/EventInfo' import { LivePreviewListener } from '@/components/LivePreviewListener' @@ -166,19 +166,6 @@ const queryEventBySlug = async ({ center, slug }: { center: string; slug: string const payload = await getPayload({ config: configPromise }) - const conditions: Where[] = [ - { - 'tenant.slug': { - equals: center, - }, - }, - { - slug: { - equals: slug, - }, - }, - ] - const result = await payload.find({ collection: 'events', draft, @@ -191,7 +178,20 @@ const queryEventBySlug = async ({ center, slug }: { center: string; slug: string customDomain: true, }, }, - where: { and: conditions }, + where: { + and: [ + { + 'tenant.slug': { + equals: center, + }, + }, + { + slug: { + equals: slug, + }, + }, + ], + }, }) return result.docs?.[0] || null diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index 35e4944e..a9b35ca7 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -1,6 +1,5 @@ import { QueriedPostsComponent as QueriedPostsComponent_a2ad38d8499118f1bebc7752c0fff51e } from '@/blocks/BlogList/fields/QueriedPostsComponent' import { DefaultColumnAdder as DefaultColumnAdder_006f8c6c8800e6fe3753b3785f2c4a01 } from '@/blocks/Content/components/DefaultColumnAdder' -import { QueriedEventsComponent as QueriedEventsComponent_65bd30cc675f775ebce6af07a79e525c } from '@/blocks/EventList/fields/QueriedEventsComponent' import { SponsorsLayoutDescription as SponsorsLayoutDescription_6f00823041b5b0999b9929fb565110de } from '@/blocks/SponsorsBlock/components/SponsorsLayoutDescription' import { CourseTypeField as CourseTypeField_348fff62462d32a00f93a0ac5be86e99 } from '@/collections/Courses/components/CourseTypeField' import { DuplicatePageFor as DuplicatePageFor_8f1d8961a356bec6784e5c591c016925 } from '@/collections/Pages/components/DuplicatePageFor' @@ -104,8 +103,6 @@ export const importMap = { '@/components/ColumnLayoutPicker#default': default_923dc5ccc0b72de4298251644cbfe39e, '@/blocks/Content/components/DefaultColumnAdder#DefaultColumnAdder': DefaultColumnAdder_006f8c6c8800e6fe3753b3785f2c4a01, - '@/blocks/EventList/fields/QueriedEventsComponent#QueriedEventsComponent': - QueriedEventsComponent_65bd30cc675f775ebce6af07a79e525c, '@payloadcms/plugin-seo/client#OverviewComponent': OverviewComponent_a8a977ebc872c5d5ea7ee689724c0860, '@payloadcms/plugin-seo/client#MetaImageComponent': diff --git a/src/blocks/Content/config.ts b/src/blocks/Content/config.ts index c80f34e9..c2ff7bcf 100644 --- a/src/blocks/Content/config.ts +++ b/src/blocks/Content/config.ts @@ -12,6 +12,7 @@ import { ButtonBlock } from '../Button/config' import { CalloutBlock } from '../Callout/config' import { DocumentBlock } from '../DocumentBlock/config' import { EventListBlockLexical } from '../EventList/config' +import { EventTableBlock } from '../EventTable/config' import { GenericEmbedLexical } from '../GenericEmbed/config' import { HeaderBlock } from '../Header/config' import { MediaBlockLexical } from '../MediaBlock/config' @@ -131,6 +132,7 @@ export const Content: Block = { CalloutBlock, DocumentBlock, EventListBlockLexical, + EventTableBlock, SingleEventBlockLexical, GenericEmbedLexical, HeaderBlock, diff --git a/src/blocks/EventList/Component.tsx b/src/blocks/EventList/Component.tsx index ff7679c8..66bfaeb2 100644 --- a/src/blocks/EventList/Component.tsx +++ b/src/blocks/EventList/Component.tsx @@ -1,43 +1,98 @@ +'use client' + import { EventPreviewSmallRow } from '@/components/EventPreviewSmallRow' import RichText from '@/components/RichText' import { Button } from '@/components/ui/button' import type { Event, EventListBlock as EventListBlockProps } from '@/payload-types' +import { useTenant } from '@/providers/TenantProvider' +import { filterValidPublishedRelationships } from '@/utilities/relationships' import { cn } from '@/utilities/ui' +import { format } from 'date-fns' import Link from 'next/link' +import { useEffect, useState } from 'react' type EventListComponentProps = EventListBlockProps & { className?: string wrapInContainer?: boolean } -export const EventListBlockComponent = async (args: EventListComponentProps) => { - const { heading, belowHeadingContent, backgroundColor, className, wrapInContainer = true } = args +export const EventListBlockComponent = (args: EventListComponentProps) => { + const { + heading, + belowHeadingContent, + backgroundColor, + className, + wrapInContainer = true, + eventOptions, + } = args - const { filterByEventTypes, sortBy, queriedEvents } = args.dynamicOptions || {} + const { filterByEventTypes, filterByEventGroups, filterByEventTags, maxEvents } = + args.dynamicOptions || {} const { staticEvents } = args.staticOptions || {} - let events = staticEvents?.filter( - (event): event is Event => typeof event === 'object' && event !== null, - ) + const { tenant } = useTenant() + const [fetchedEvents, setFetchedEvents] = useState([]) + const [eventParams, setEventParams] = useState('') - const hasStaticEvents = staticEvents && staticEvents.length > 0 + useEffect(() => { + if (eventOptions !== 'dynamic') return - if (!staticEvents || (staticEvents.length === 0 && queriedEvents && queriedEvents.length > 0)) { - events = queriedEvents?.filter( - (event): event is Event => typeof event === 'object' && event !== null, - ) - } + const fetchEvents = async () => { + const tenantSlug = typeof tenant === 'object' && tenant?.slug + if (!tenantSlug) return - const eventsLinkQueryParams = new URLSearchParams() - if (sortBy !== undefined) { - eventsLinkQueryParams.set('sort', sortBy) - } + const params = new URLSearchParams({ + center: tenantSlug, + limit: String(maxEvents || 4), + depth: '1', + }) + + if (filterByEventTypes?.length) { + params.append('types', filterByEventTypes.join(',')) + } + + if (filterByEventGroups?.length) { + const groupIds = filterByEventGroups + .map((g) => (typeof g === 'object' ? g.id : g)) + .filter(Boolean) + if (groupIds.length) { + params.append('groups', groupIds.join(',')) + } + } + + if (filterByEventTags?.length) { + const tagIds = filterByEventTags + .map((t) => (typeof t === 'object' ? t.id : t)) + .filter(Boolean) + if (tagIds.length) { + params.append('tags', tagIds.join(',')) + } + } + params.append('startDate', format(new Date(), 'MM-dd-yyyy')) + setEventParams(params.toString()) + + const response = await fetch(`/api/${tenantSlug}/events?${params.toString()}`, { + cache: 'no-store', + }) + + if (!response.ok) { + throw new Error('Failed to fetch events') + } + + const data = await response.json() + setFetchedEvents(data.events || []) + } + + fetchEvents() + }, [eventOptions, filterByEventTypes, filterByEventGroups, filterByEventTags, maxEvents, tenant]) + + let displayEvents: Event[] = filterValidPublishedRelationships(staticEvents) - if (filterByEventTypes && filterByEventTypes.length > 0) { - eventsLinkQueryParams.set('types', filterByEventTypes.join(',')) + if (eventOptions === 'dynamic') { + displayEvents = filterValidPublishedRelationships(fetchedEvents) } - if (!events) { + if (!displayEvents) { return null } @@ -62,20 +117,20 @@ export const EventListBlockComponent = async (args: EventListComponentProps) =>
1 && '@3xl:grid-cols-2 @6xl:grid-cols-3', + displayEvents && displayEvents.length > 1 && '@3xl:grid-cols-2 @6xl:grid-cols-3', )} > - {events && events?.length > 0 ? ( - events?.map((event, index) => ( + {displayEvents && displayEvents?.length > 0 ? ( + displayEvents?.map((event, index) => ( )) ) : (

There are no events matching these results.

)}
- {!hasStaticEvents && ( + {eventOptions === 'static' && ( )} diff --git a/src/blocks/EventList/config.ts b/src/blocks/EventList/config.ts index 58bd0f11..ec2ed275 100644 --- a/src/blocks/EventList/config.ts +++ b/src/blocks/EventList/config.ts @@ -1,160 +1,10 @@ -import { eventTypesData } from '@/constants/eventTypes' import colorPickerField from '@/fields/color' -import { getTenantFilter } from '@/utilities/collectionFilters' import { - BlocksFeature, - HorizontalRuleFeature, - InlineToolbarFeature, - lexicalEditor, -} from '@payloadcms/richtext-lexical' + defaultStylingFields, + dynamicEventRelatedFields, + staticEventRelatedFields, +} from '@/fields/EventQuery/config' import type { Block, Field } from 'payload' -import { ButtonBlock } from '../Button/config' -import { GenericEmbedLexical } from '../GenericEmbed/config' -import { MediaBlockLexical } from '../MediaBlock/config' -import { validateMaxEvents } from './hooks/validateMaxEvents' - -const defaultStylingFields: Field[] = [ - { name: 'heading', type: 'text' }, - { - name: 'belowHeadingContent', - type: 'richText', - editor: lexicalEditor({ - features: ({ rootFeatures }) => { - return [ - ...rootFeatures, - BlocksFeature({ - blocks: [ButtonBlock, MediaBlockLexical, GenericEmbedLexical], - }), - HorizontalRuleFeature(), - InlineToolbarFeature(), - ] - }, - }), - label: 'Content Below Heading', - admin: { - description: 'Optional content to display below the heading and above the event list.', - }, - }, - colorPickerField('Background color'), - { - type: 'radio', - name: 'eventOptions', - label: 'How do you want to choose your events?', - defaultValue: 'dynamic', - required: true, - options: [ - { - label: 'Do it for me', - value: 'dynamic', - }, - { - label: 'Let me choose', - value: 'static', - }, - ], - }, -] - -const dynamicEventRelatedFields: Field[] = [ - { - name: 'dynamicOptions', - type: 'group', - admin: { - condition: (_, siblingData) => siblingData?.eventOptions === 'dynamic', - }, - fields: [ - { - name: 'sortBy', - type: 'select', - defaultValue: 'startDate', - options: [ - { label: 'Start Date (Earliest First)', value: 'startDate' }, - { label: 'Start Date (Latest First)', value: '-startDate' }, - { label: 'Registration Deadline (Earliest First)', value: 'registrationDeadline' }, - { label: 'Registration Deadline (Latest First)', value: '-registrationDeadline' }, - ], - required: true, - admin: { - description: 'Select how the list of events will be sorted.', - }, - }, - { - name: 'filterByEventTypes', - type: 'select', - dbName: 'filterByEventTypes', - options: eventTypesData.map((type) => ({ - label: type.label, - value: type.value, - })), - hasMany: true, - label: 'Filter by Event Type(s)', - admin: { - description: 'Optionally select event types to filter events.', - }, - }, - { - name: 'showUpcomingOnly', - type: 'checkbox', - defaultValue: true, - label: 'Show Upcoming Events Only', - admin: { - description: 'Only display events that have not yet occurred.', - }, - }, - { - name: 'maxEvents', - type: 'number', - label: 'Max Events Displayed', - min: 1, - max: 20, - defaultValue: 4, - admin: { - description: 'Maximum number of events that will be displayed. Must be an integer.', - step: 1, - }, - hooks: { - beforeValidate: [validateMaxEvents], - }, - }, - { - name: 'queriedEvents', - type: 'relationship', - label: 'Preview Events Order', - relationTo: 'events', - hasMany: true, - admin: { - readOnly: true, - components: { - Field: '@/blocks/EventList/fields/QueriedEventsComponent#QueriedEventsComponent', - }, - }, - }, - ], - }, -] - -const staticEventRelatedFields: Field[] = [ - { - name: 'staticOptions', - type: 'group', - admin: { - condition: (_, siblingData) => siblingData?.eventOptions === 'static', - }, - fields: [ - { - name: 'staticEvents', - type: 'relationship', - label: 'Choose events', - relationTo: 'events', - hasMany: true, - admin: { - description: 'Choose new event from dropdown and/or drag and drop to change order', - }, - filterOptions: getTenantFilter, - }, - ], - }, -] const eventListBlockWithFields = (fields: Field[]): Block => ({ slug: 'eventList', @@ -164,13 +14,13 @@ const eventListBlockWithFields = (fields: Field[]): Block => ({ }) export const EventListBlock = eventListBlockWithFields([ - ...defaultStylingFields, - ...dynamicEventRelatedFields, + ...defaultStylingFields([colorPickerField('Background color')]), + ...dynamicEventRelatedFields(), ...staticEventRelatedFields, ]) export const EventListBlockLexical = eventListBlockWithFields([ - ...defaultStylingFields, + ...defaultStylingFields([colorPickerField('Background color')]), { name: 'wrapInContainer', admin: { @@ -180,6 +30,6 @@ export const EventListBlockLexical = eventListBlockWithFields([ type: 'checkbox', defaultValue: false, }, - ...dynamicEventRelatedFields, + ...dynamicEventRelatedFields(), ...staticEventRelatedFields, ]) diff --git a/src/blocks/EventList/fields/QueriedEventsComponent.tsx b/src/blocks/EventList/fields/QueriedEventsComponent.tsx deleted file mode 100644 index 8478b0d9..00000000 --- a/src/blocks/EventList/fields/QueriedEventsComponent.tsx +++ /dev/null @@ -1,146 +0,0 @@ -'use client' - -import { Event } from '@/payload-types' -import { useTenantSelection } from '@/providers/TenantSelectionProvider/index.client' -import { FieldDescription, SelectInput, useField, useForm, useFormFields } from '@payloadcms/ui' -import { OptionObject } from 'payload' -import { useEffect, useState } from 'react' - -type QueriedEventsComponentProps = { - path: string - field?: { - label?: string - } -} - -export const QueriedEventsComponent = ({ path, field }: QueriedEventsComponentProps) => { - const label = field?.label || 'Queried Events' - const parentPathParts = path.split('.').slice(0, -1) - - const { value, setValue } = useField({ path }) - const [isLoading, setIsLoading] = useState(false) - const [fetchedEvents, setFetchedEvents] = useState([]) - const [selectOptions, setSelectOptions] = useState([]) - const { setDisabled } = useForm() - - const filterByEventTypes = useFormFields( - ([fields]) => fields[parentPathParts.concat(['filterByEventTypes']).join('.')]?.value, - ) - const sortBy = useFormFields( - ([fields]) => fields[parentPathParts.concat(['sortBy']).join('.')]?.value, - ) - const maxEvents = useFormFields( - ([fields]) => fields[parentPathParts.concat(['maxEvents']).join('.')]?.value, - ) - const showUpcomingOnly = useFormFields( - ([fields]) => fields[parentPathParts.concat(['showUpcomingOnly']).join('.')]?.value, - ) - const { selectedTenantID: tenant } = useTenantSelection() - - useEffect(() => { - if (!tenant) { - return - } - - const fetchEvents = async () => { - setIsLoading(true) - setDisabled(true) - - try { - const tenantId = typeof tenant === 'number' ? tenant : (tenant as { id?: number })?.id - if (!tenantId) return - - const params = new URLSearchParams({ - limit: String(maxEvents || 4), - depth: '1', - 'where[tenant][equals]': String(tenantId), - }) - - if (sortBy) { - params.append('sort', String(sortBy)) - } - - if (showUpcomingOnly) { - params.append('where[startDate][greater_than]', new Date().toISOString()) - } - - if ( - filterByEventTypes && - Array.isArray(filterByEventTypes) && - filterByEventTypes.length > 0 - ) { - const typeIds = filterByEventTypes.filter(Boolean) - - if (typeIds.length > 0) { - params.append('where[type][in]', typeIds.join(',')) - } - } - - const response = await fetch(`/api/events?${params.toString()}`) - if (!response.ok) { - throw new Error('Failed to fetch events') - } - - const data = await response.json() - const events = data.docs || [] - setFetchedEvents(events) - - const options: OptionObject[] = events.map((event: Event) => ({ - label: event.title, - value: String(event.id), - })) - setSelectOptions(options) - - const currentEventIds = (value || []) - .map((event) => (typeof event === 'number' ? event : event.id)) - .sort() - const newEventIds = events.map((event: Event) => event.id).sort() - - if (JSON.stringify(currentEventIds) !== JSON.stringify(newEventIds)) { - setValue(events) - } - } catch (error) { - console.error('Error fetching events for EventList block:', error) - setFetchedEvents([]) - setSelectOptions([]) - setValue([]) - } finally { - setIsLoading(false) - setDisabled(false) - } - } - - fetchEvents() - }, [ - filterByEventTypes, - sortBy, - maxEvents, - showUpcomingOnly, - tenant, - setValue, - value, - setDisabled, - ]) - - const currentValue = fetchedEvents.map((event) => String(event.id)) - - return ( -
- - -
- ) -} diff --git a/src/blocks/EventTable/Component.tsx b/src/blocks/EventTable/Component.tsx new file mode 100644 index 00000000..d776d7ae --- /dev/null +++ b/src/blocks/EventTable/Component.tsx @@ -0,0 +1,123 @@ +'use client' + +import { EventTable } from '@/components/EventsTable' +import RichText from '@/components/RichText' +import type { Event, EventTableBlock as EventTableBlockProps } from '@/payload-types' +import { useTenant } from '@/providers/TenantProvider' +import { filterValidPublishedRelationships } from '@/utilities/relationships' +import { cn } from '@/utilities/ui' +import { format } from 'date-fns' +import { useEffect, useState } from 'react' + +type EventTableComponentProps = EventTableBlockProps & { + className?: string +} + +export const EventTableBlockComponent = (args: EventTableComponentProps) => { + const { heading, belowHeadingContent, className, eventOptions } = args + const { filterByEventTypes, filterByEventGroups, filterByEventTags, maxEvents } = + args.dynamicOptions || {} + const { staticEvents } = args.staticOptions || {} + + const { tenant } = useTenant() + const [fetchedEvents, setFetchedEvents] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + if (eventOptions !== 'dynamic') return + + const fetchEvents = async () => { + try { + setError(null) + + const tenantSlug = typeof tenant === 'object' && tenant?.slug + if (!tenantSlug) return + + const params = new URLSearchParams({ + center: tenantSlug, + limit: String(maxEvents || 4), + depth: '1', + }) + + if (filterByEventTypes?.length) { + params.append('types', filterByEventTypes.join(',')) + } + + if (filterByEventGroups?.length) { + const groupIds = filterByEventGroups + .map((g) => (typeof g === 'object' ? g.id : g)) + .filter(Boolean) + if (groupIds.length) { + params.append('groups', groupIds.join(',')) + } + } + + if (filterByEventTags?.length) { + const tagIds = filterByEventTags + .map((t) => (typeof t === 'object' ? t.id : t)) + .filter(Boolean) + if (tagIds.length) { + params.append('tags', tagIds.join(',')) + } + } + params.append('startDate', format(new Date(), 'MM-dd-yyyy')) + + const response = await fetch(`/api/${tenantSlug}/events?${params.toString()}`, { + cache: 'no-store', + }) + + if (!response.ok) { + throw new Error('Failed to fetch events') + } + + const data = await response.json() + setFetchedEvents(data.events || []) + } catch (err) { + console.error('[EventTable Error]:', err) + setError(err instanceof Error ? err.message : 'An error occurred while fetching events') + } finally { + setLoading(false) + } + } + + fetchEvents() + }, [eventOptions, filterByEventTypes, filterByEventGroups, filterByEventTags, maxEvents, tenant]) + + let displayEvents: Event[] = filterValidPublishedRelationships(staticEvents) + + if (eventOptions === 'dynamic') { + displayEvents = filterValidPublishedRelationships(fetchedEvents) + } + + if (!displayEvents) { + return null + } + + return ( +
+
+ {heading && ( +
+

{heading}

+
+ )} + {belowHeadingContent && ( +
+ +
+ )} +
+
+ {loading || error ? ( +
+ {loading &&

Loading events...

} + {error &&

Error loading events: {error}

} +
+ ) : ( + + )} +
+
+ ) +} diff --git a/src/blocks/EventTable/config.ts b/src/blocks/EventTable/config.ts new file mode 100644 index 00000000..5c7d7245 --- /dev/null +++ b/src/blocks/EventTable/config.ts @@ -0,0 +1,13 @@ +import { + defaultStylingFields, + dynamicEventRelatedFields, + staticEventRelatedFields, +} from '@/fields/EventQuery/config' +import type { Block } from 'payload' + +export const EventTableBlock: Block = { + slug: 'eventTable', + interfaceName: 'EventTableBlock', + imageURL: '/thumbnail/EventTableThumbnail.jpg', + fields: [...defaultStylingFields(), ...dynamicEventRelatedFields(), ...staticEventRelatedFields], +} diff --git a/src/blocks/RenderBlocks.tsx b/src/blocks/RenderBlocks.tsx index e2fdd1ef..d325a15e 100644 --- a/src/blocks/RenderBlocks.tsx +++ b/src/blocks/RenderBlocks.tsx @@ -8,6 +8,7 @@ import { BlogListBlockComponent } from '@/blocks/BlogList/Component' import { ContentBlock } from '@/blocks/Content/Component' import { DocumentBlock } from '@/blocks/DocumentBlock/Component' import { EventListBlockComponent } from '@/blocks/EventList/Component' +import { EventTableBlockComponent } from '@/blocks/EventTable/Component' import { FormBlock } from '@/blocks/Form/Component' import { GenericEmbedBlock } from '@/blocks/GenericEmbed/Component' import { HeaderBlockComponent } from '@/blocks/Header/Component' @@ -58,6 +59,8 @@ export const RenderBlock = ({ block, payload }: { block: Page['layout'][0]; payl return case 'eventList': return + case 'eventTable': + return case 'formBlock': return case 'genericEmbed': diff --git a/src/collections/Events/index.ts b/src/collections/Events/index.ts index d3690faa..4b96b9cb 100644 --- a/src/collections/Events/index.ts +++ b/src/collections/Events/index.ts @@ -4,6 +4,7 @@ import { Banner } from '@/blocks/Banner/config' import { BlogListBlockLexical } from '@/blocks/BlogList/config' import { DocumentBlock } from '@/blocks/DocumentBlock/config' import { EventListBlockLexical } from '@/blocks/EventList/config' +import { EventTableBlock } from '@/blocks/EventTable/config' import { GenericEmbedLexical } from '@/blocks/GenericEmbed/config' import { HeaderBlock } from '@/blocks/Header/config' import { MediaBlockLexical } from '@/blocks/MediaBlock/config' @@ -176,6 +177,7 @@ export const Events: CollectionConfig = { BlogListBlockLexical, DocumentBlock, EventListBlockLexical, + EventTableBlock, GenericEmbedLexical, HeaderBlock, MediaBlockLexical, @@ -260,7 +262,7 @@ export const Events: CollectionConfig = { ], hooks: { beforeChange: [populatePublishedAt, populateBlocksInContent], - // TODO: need revalidation hooks here + // TODO: need revalidation hooks herehooks: { // TODO: need to update revalidation utilities to look for this blocksInContent field for relationships in addition to Posts and Home Pages }, versions: { diff --git a/src/collections/HomePages/index.tsx b/src/collections/HomePages/index.tsx index 44f37834..3f1df353 100644 --- a/src/collections/HomePages/index.tsx +++ b/src/collections/HomePages/index.tsx @@ -23,6 +23,7 @@ import { TeamBlock } from '@/blocks/Team/config' import { BlogListBlock } from '@/blocks/BlogList/config' import { DocumentBlock } from '@/blocks/DocumentBlock/config' import { EventListBlock } from '@/blocks/EventList/config' +import { EventTableBlock } from '@/blocks/EventTable/config' import { SingleEventBlock } from '@/blocks/SingleEvent/config' import colorPickerField from '@/fields/color' import { quickLinksField } from '@/fields/quickLinksFields' @@ -173,6 +174,7 @@ export const HomePages: CollectionConfig = { DocumentBlock, EventListBlock, SingleEventBlock, + EventTableBlock, FormBlock, GenericEmbed, HeaderBlock, diff --git a/src/collections/Pages/index.ts b/src/collections/Pages/index.ts index 7d55baff..2c51ecea 100644 --- a/src/collections/Pages/index.ts +++ b/src/collections/Pages/index.ts @@ -1,8 +1,18 @@ +import { Tenant } from '@/payload-types' +import { + MetaDescriptionField, + MetaImageField, + OverviewField, + PreviewField, +} from '@payloadcms/plugin-seo/fields' import type { CollectionConfig } from 'payload' import { BiographyBlock } from '@/blocks/Biography/config' import { BlogListBlock } from '@/blocks/BlogList/config' import { Content } from '@/blocks/Content/config' +import { DocumentBlock } from '@/blocks/DocumentBlock/config' +import { EventListBlock } from '@/blocks/EventList/config' +import { EventTableBlock } from '@/blocks/EventTable/config' import { FormBlock } from '@/blocks/Form/config' import { GenericEmbed } from '@/blocks/GenericEmbed/config' import { HeaderBlock } from '@/blocks/Header/config' @@ -13,33 +23,24 @@ import { ImageTextList } from '@/blocks/ImageTextList/config' import { LinkPreviewBlock } from '@/blocks/LinkPreview/config' import { MediaBlock } from '@/blocks/MediaBlock/config' import { SingleBlogPostBlock } from '@/blocks/SingleBlogPost/config' +import { SingleEventBlock } from '@/blocks/SingleEvent/config' +import { SponsorsBlock } from '@/blocks/SponsorsBlock/config' import { TeamBlock } from '@/blocks/Team/config' -import { populatePublishedAt } from '@/hooks/populatePublishedAt' -import { generatePreviewPath } from '@/utilities/generatePreviewPath' -import { revalidatePage, revalidatePageDelete } from './hooks/revalidatePage' - import { accessByTenantRoleOrReadPublished } from '@/access/byTenantRoleOrReadPublished' import { filterByTenant } from '@/access/filterByTenant' import { contentHashField } from '@/fields/contentHashField' import { slugField } from '@/fields/slug' import { tenantField } from '@/fields/tenantField' -import { - MetaDescriptionField, - MetaImageField, - OverviewField, - PreviewField, -} from '@payloadcms/plugin-seo/fields' -import { DocumentBlock } from '@/blocks/DocumentBlock/config' -import { EventListBlock } from '@/blocks/EventList/config' -import { SingleEventBlock } from '@/blocks/SingleEvent/config' -import { SponsorsBlock } from '@/blocks/SponsorsBlock/config' import { duplicatePageToTenant } from '@/collections/Pages/endpoints/duplicatePageToTenant' -import { Tenant } from '@/payload-types' + +import { populatePublishedAt } from '@/hooks/populatePublishedAt' +import { generatePreviewPath } from '@/utilities/generatePreviewPath' import { isTenantValue } from '@/utilities/isTenantValue' import { resolveTenant } from '@/utilities/tenancy/resolveTenant' +import { revalidatePage, revalidatePageDelete } from './hooks/revalidatePage' export const Pages: CollectionConfig<'pages'> = { slug: 'pages', @@ -117,6 +118,7 @@ export const Pages: CollectionConfig<'pages'> = { Content, DocumentBlock, EventListBlock, + EventTableBlock, SingleEventBlock, FormBlock, HeaderBlock, diff --git a/src/collections/Posts/index.ts b/src/collections/Posts/index.ts index 66fd6bf0..6ad56d13 100644 --- a/src/collections/Posts/index.ts +++ b/src/collections/Posts/index.ts @@ -30,6 +30,7 @@ import { revalidatePost, revalidatePostDelete } from './hooks/revalidatePost' import { accessByTenantRoleOrReadPublished } from '@/access/byTenantRoleOrReadPublished' import { filterByTenant } from '@/access/filterByTenant' import { ButtonBlock } from '@/blocks/Button/config' +import { EventTableBlock } from '@/blocks/EventTable/config' import { contentHashField } from '@/fields/contentHashField' import { slugField } from '@/fields/slug' import { tenantField } from '@/fields/tenantField' @@ -123,6 +124,7 @@ export const Posts: CollectionConfig<'posts'> = { BlogListBlockLexical, DocumentBlock, EventListBlockLexical, + EventTableBlock, GenericEmbedLexical, HeaderBlock, MediaBlockLexical, diff --git a/src/components/Breadcrumbs/Breadcrumbs.client.tsx b/src/components/Breadcrumbs/Breadcrumbs.client.tsx index 3fc3cb5a..8b3263d0 100644 --- a/src/components/Breadcrumbs/Breadcrumbs.client.tsx +++ b/src/components/Breadcrumbs/Breadcrumbs.client.tsx @@ -54,6 +54,7 @@ const processNestedSegments = ( export function Breadcrumbs() { const segments = useSelectedLayoutSegments() const decodedSegments = segments.map(decodeURIComponent) + const { isNotFound } = useNotFound() const { captureWithTenant } = useAnalytics() diff --git a/src/components/EventsTable/index.tsx b/src/components/EventsTable/index.tsx new file mode 100644 index 00000000..00912d0f --- /dev/null +++ b/src/components/EventsTable/index.tsx @@ -0,0 +1,316 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import type { Event } from '@/payload-types' +import { format } from 'date-fns' +import { + ChevronDown, + ChevronRight, + ChevronsUpDown, + ChevronUp, + ExternalLink, + MapPin, +} from 'lucide-react' +import { Fragment, useMemo, useState } from 'react' +import { CMSLink } from '../Link' + +export function EventTable({ events = [] }: { events: Event[] }) { + const [sortConfig, setSortConfig] = useState({ key: 'startDate', direction: 'asc' }) + const [expandedRows, setExpandedRows] = useState>(new Set()) + + // Determine status based on event data + const getStatus = (event: Event) => { + const now = new Date() + const isPast = event.startDate ? new Date(event.startDate) < now : false + const isRegistrationClosed = event.registrationDeadline + ? new Date(event.registrationDeadline) < now + : false + + // Determine label and color + let label = 'Open' + let color = 'bg-brand-700' + + if (isRegistrationClosed) { + label = 'Closed' + color = 'bg-brand-400' + } else if (isPast) { + label = 'Past' + color = 'bg-secondary' + } + + return { + label, + color, + isPast, + isRegistrationClosed, + } + } + + // Format date and time + const formatDateTime = (dateString: string) => { + const date = new Date(dateString) + return { + date: format(date, 'MMM d, yy'), + time: format(date, 'h:mm a'), + } + } + + // Get display address + const getAddress = (event: Event) => { + if (event.location?.isVirtual) { + return '' + } + + const eventAddress: string[] = [] + const { address, city, state, zip } = event.location || {} + + if (address) eventAddress.push(address) + if (city && state) eventAddress.push(`${city}, ${state}`) + if (zip) eventAddress.push(zip) + + return eventAddress.length > 0 ? eventAddress.join(', ') : 'TBA' + } + + // Get display location (city/venue) + const getLocation = (event: Event) => { + if (event.location?.isVirtual) { + return ( + + Virtual + + ) + } + const { placeName, city, state } = event.location || {} + if (placeName) return placeName + if (city && state) return `${city}, ${state}` + return 'TBA' + } + + // Sort function + const sortedEvents = useMemo(() => { + const sorted = [...events].sort((a, b) => { + let aValue = a[sortConfig.key as keyof Event] + let bValue = b[sortConfig.key as keyof Event] + + // Handle special cases + if (sortConfig.key === 'type') { + aValue = getStatus(a).label + bValue = getStatus(b).label + } + + if (aValue == null) return 1 + if (bValue == null) return -1 + + if (typeof aValue === 'string' && typeof bValue === 'string') { + aValue = aValue.toLowerCase() + bValue = bValue.toLowerCase() + } + + if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1 + if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1 + return 0 + }) + + return sorted + }, [events, sortConfig]) + + const handleSort = (key: string) => { + if (sortConfig.key === key) { + setSortConfig({ + key, + direction: sortConfig.direction === 'asc' ? 'desc' : 'asc', + }) + } else { + setSortConfig({ key, direction: 'asc' }) + } + } + + const toggleRow = (eventId: string) => { + const newExpanded = new Set(expandedRows) + if (newExpanded.has(eventId)) { + newExpanded.delete(eventId) + } else { + newExpanded.add(eventId) + } + setExpandedRows(newExpanded) + } + + const SortIcon = ({ columnKey }: { columnKey: string }) => { + if (sortConfig.key !== columnKey) { + return + } + return sortConfig.direction === 'asc' ? ( + + ) : ( + + ) + } + + const SortableHeader = ({ label, sortKey }: { label: string; sortKey: string }) => ( + + ) + + if (!events || events.length === 0) { + return
No events found
+ } + + return ( +
+ + + + + + + + + + + Location + + + + + {sortedEvents.map((event) => { + const { date, time } = formatDateTime(event.startDate) + const status = getStatus(event) + const { isPast, isRegistrationClosed } = status + const isExpanded = expandedRows.has(String(event.id)) + const eventUrl = `/events/${event.slug}` + + return ( + + { + if (typeof window !== 'undefined' && window.innerWidth < 1024) { + toggleRow(String(event.id)) + } + }} + > + + + + +
+
{date}
+
{time}
+
+
+ + {/* Name */} + + + {event.title} + + {event.title} + + + {/* Location */} + +
+
{getLocation(event)}
+
{getAddress(event)}
+
+
+ + {/* Register button */} + + {event.registrationUrl && !isPast && !isRegistrationClosed ? ( + <> + + Register + + + + ) : isPast || isRegistrationClosed ? ( + + ) : ( + + )} + +
+ {/* Expanded row for details on smaller screens */} + {isExpanded && ( + + + +
+ {/* Location */} +
+
+ + +

Location

+
+

{getLocation(event)}

+ {getAddress(event) && ( +

{getAddress(event)}

+ )} +
+
+
+ {event.registrationUrl && !isPast && !isRegistrationClosed ? ( + + Register + + + ) : isPast || isRegistrationClosed ? ( + + ) : ( + + )} +
+ + + Learn More + +
+
+
+
+ )} +
+ ) + })} +
+
+
+ ) +} diff --git a/src/components/RichText/index.tsx b/src/components/RichText/index.tsx index de4919bb..7aa5cb0c 100644 --- a/src/components/RichText/index.tsx +++ b/src/components/RichText/index.tsx @@ -17,6 +17,7 @@ import { ButtonBlockComponent } from '@/blocks/Button/Component' import { CalloutBlock } from '@/blocks/Callout/Component' import { DocumentBlock } from '@/blocks/DocumentBlock/Component' import { EventListBlockComponent } from '@/blocks/EventList/Component' +import { EventTableBlockComponent } from '@/blocks/EventTable/Component' import { GenericEmbedBlock } from '@/blocks/GenericEmbed/Component' import { HeaderBlockComponent } from '@/blocks/Header/Component' import { SingleBlogPostBlockComponent } from '@/blocks/SingleBlogPost/Component' @@ -29,6 +30,7 @@ import type { CalloutBlock as CalloutBlockProps, DocumentBlock as DocumentBlockProps, EventListBlock as EventListBlockProps, + EventTableBlock as EventTableBlockProps, GenericEmbedBlock as GenericEmbedBlockProps, HeaderBlock as HeaderBlockProps, MediaBlock as MediaBlockProps, @@ -47,6 +49,7 @@ type NodeTypes = | CalloutBlockProps | DocumentBlockProps | EventListBlockProps + | EventTableBlockProps | GenericEmbedBlockProps | HeaderBlockProps | MediaBlockProps @@ -67,44 +70,35 @@ const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => { const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({ ...defaultConverters, ...LinkJSXConverter({ internalDocToHref }), + // if block has two variants - to make TS happy we fallback to the default for the block variant blocks: { banner: ({ node }) => , blogList: ({ node }) => ( ), buttonBlock: ({ node }) => , calloutBlock: ({ node }) => , documentBlock: ({ node }) => ( - + ), eventList: ({ node }) => ( ), + eventTable: ({ node }) => , singleEvent: ({ node }) => ( ), genericEmbed: ({ node }) => ( - + ), headerBlock: ({ node }) => , mediaBlock: ({ node }) => ( @@ -112,7 +106,6 @@ const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) className="col-start-1 col-span-3" imgClassName="m-0" {...node.fields} - // src/blocks/MediaBlock/config.ts has two variants - to make TS happy we fallback to the default for the MediaBlockLexical variant wrapInContainer={node.fields.wrapInContainer || false} captionClassName="mx-auto max-w-[48rem]" /> @@ -120,7 +113,6 @@ const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) singleBlogPost: ({ node }) => ( ), diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx new file mode 100644 index 00000000..270c7771 --- /dev/null +++ b/src/components/ui/table.tsx @@ -0,0 +1,91 @@ +import * as React from 'react' + +import { cn } from '@/utilities/ui' + +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ + + ), +) +Table.displayName = 'Table' + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = 'TableHeader' + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = 'TableBody' + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0', className)} + {...props} + /> +)) +TableFooter.displayName = 'TableFooter' + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ), +) +TableRow.displayName = 'TableRow' + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableHead.displayName = 'TableHead' + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableCell.displayName = 'TableCell' + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = 'TableCaption' + +export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow } diff --git a/src/endpoints/seed/blocks/event-list.ts b/src/endpoints/seed/blocks/event-list.ts index ddfd6f25..a4e56230 100644 --- a/src/endpoints/seed/blocks/event-list.ts +++ b/src/endpoints/seed/blocks/event-list.ts @@ -35,10 +35,7 @@ export const eventListBlock: EventListBlock = { }, eventOptions: 'dynamic', dynamicOptions: { - sortBy: 'startDate', - showUpcomingOnly: true, maxEvents: 4, - queriedEvents: [], // Will be populated during seeding }, staticOptions: { staticEvents: [], // Will be populated with actual event references during seeding diff --git a/src/endpoints/seed/events.ts b/src/endpoints/seed/events.ts index a8bdf042..b42f0ed6 100644 --- a/src/endpoints/seed/events.ts +++ b/src/endpoints/seed/events.ts @@ -1,55 +1,13 @@ import { Media, Tenant } from '@/payload-types' import { US_TIMEZONES } from '@/utilities/timezones' import { RequiredDataFromCollectionSlug } from 'payload' +import { futureDate, pastDate, simpleContent } from './utilities' export const getEventsData = ( tenant: Tenant, featuredImage?: Media, thumbnailImage?: Media, ): RequiredDataFromCollectionSlug<'events'>[] => { - // Helper to create dates in the future - const futureDate = (monthOffset: number, day: number, hour: number = 18) => { - const date = new Date() - date.setMonth(date.getMonth() + monthOffset) - date.setDate(day) - date.setHours(hour, 0, 0, 0) - return date.toISOString() - } - - // Helper to create properly formatted lexical text nodes - const textNode = (text: string) => ({ - type: 'text', - detail: 0, - format: 0, - mode: 'normal' as const, - style: '', - text, - version: 1, - }) - - // Helper to create properly formatted lexical paragraph nodes - const paragraphNode = (text: string) => ({ - type: 'paragraph', - children: [textNode(text)], - direction: 'ltr' as const, - format: '' as '' | 'left' | 'start' | 'center' | 'right' | 'end' | 'justify', - indent: 0, - textFormat: 0, - version: 1, - }) - - // Helper to create simple content with just paragraphs - const simpleContent = (...paragraphs: string[]) => ({ - root: { - type: 'root', - children: paragraphs.map(paragraphNode), - direction: 'ltr' as const, - format: '' as '' | 'left' | 'start' | 'center' | 'right' | 'end' | 'justify', - indent: 0, - version: 1, - }, - }) - return [ // Awareness event - Free beginner-friendly { @@ -57,8 +15,8 @@ export const getEventsData = ( subtitle: 'Free community presentation', description: 'Join us for a free avalanche awareness presentation covering the basics of avalanche safety, terrain recognition, and rescue fundamentals. Perfect for anyone new to winter backcountry recreation.', - startDate: futureDate(1, 15, 19), - endDate: futureDate(1, 15, 21), + startDate: pastDate(1, 15, 19), + endDate: pastDate(1, 15, 21), timezone: US_TIMEZONES.PACIFIC, location: { isVirtual: false, @@ -129,8 +87,8 @@ export const getEventsData = ( subtitle: 'Hands-on training for sled riders', description: 'A comprehensive field course designed specifically for snowmobile riders. Learn avalanche safety, rescue techniques, and safe riding practices in avalanche terrain.', - startDate: futureDate(1, 22, 8), - endDate: futureDate(1, 22, 17), + startDate: futureDate(1, 5, 8), + endDate: futureDate(1, 5, 17), timezone: US_TIMEZONES.PACIFIC, location: { isVirtual: false, @@ -144,7 +102,7 @@ export const getEventsData = ( featuredImage: featuredImage?.id, thumbnailImage: thumbnailImage?.id, registrationUrl: 'https://example.com/register/snowmobile-safety', - registrationDeadline: futureDate(1, 15, 23), + registrationDeadline: futureDate(1, 1, 23), capacity: 16, cost: 200, skillRating: '1', diff --git a/src/endpoints/seed/home-page.ts b/src/endpoints/seed/home-page.ts index bf581c1c..87656d45 100644 --- a/src/endpoints/seed/home-page.ts +++ b/src/endpoints/seed/home-page.ts @@ -161,10 +161,7 @@ export const homePage: ( backgroundColor: 'transparent', eventOptions: 'dynamic', dynamicOptions: { - sortBy: 'startDate', - showUpcomingOnly: true, maxEvents: 4, - queriedEvents: events.slice(0, 4).map((event) => event.id), // Use first 4 events for preview }, }, ], diff --git a/src/endpoints/seed/index.ts b/src/endpoints/seed/index.ts index 822099af..013d2506 100644 --- a/src/endpoints/seed/index.ts +++ b/src/endpoints/seed/index.ts @@ -308,25 +308,6 @@ export const seed = async ({ }, ]) - // Event groups - await upsert( - 'eventGroups', - payload, - incremental, - tenantsById, - (obj) => obj.slug, - Object.values(tenants) - .map((tenant): RequiredDataFromCollectionSlug<'eventGroups'>[] => [ - { - title: 'Meet Your Forecaster', - description: 'Meet your local avalanche forecasters & learn more about your avy center', - slug: 'meet-your-forecaster', - tenant: tenant.id, - }, - ]) - .flat(), - ) - // Event tags await upsert( 'eventTags', @@ -911,6 +892,24 @@ export const seed = async ({ }) .flat(), ) + // Event groups + await upsert( + 'eventGroups', + payload, + incremental, + tenantsById, + (obj) => obj.slug, + Object.values(tenants) + .map((tenant): RequiredDataFromCollectionSlug<'eventGroups'>[] => [ + { + title: 'Meet Your Forecaster', + description: 'Meet your local avalanche forecasters & learn more about your avy center', + slug: 'meet-your-forecaster', + tenant: tenant.id, + }, + ]) + .flat(), + ) payload.logger.info(`— Seeding contact forms...`) diff --git a/src/endpoints/seed/pages/all-blocks-page.ts b/src/endpoints/seed/pages/all-blocks-page.ts index a32976f8..393bae2f 100644 --- a/src/endpoints/seed/pages/all-blocks-page.ts +++ b/src/endpoints/seed/pages/all-blocks-page.ts @@ -63,10 +63,7 @@ export const allBlocksPage: ( ...eventListBlock, eventOptions: 'dynamic', dynamicOptions: { - sortBy: 'startDate', - showUpcomingOnly: true, maxEvents: 4, - queriedEvents: events.slice(0, 4).map((event) => event.id), // Use first 4 events for preview }, }, { @@ -85,6 +82,18 @@ export const allBlocksPage: ( event: events[1]?.id || 0, // Use second event backgroundColor: 'gray', }, + { + blockName: '', + eventOptions: 'dynamic', + + dynamicOptions: { + maxEvents: 6, + }, + staticOptions: { + staticEvents: [], + }, + blockType: 'eventTable', + }, ], meta: { image: null, diff --git a/src/endpoints/seed/utilities.ts b/src/endpoints/seed/utilities.ts index 2b5a1605..f63dbc74 100644 --- a/src/endpoints/seed/utilities.ts +++ b/src/endpoints/seed/utilities.ts @@ -89,3 +89,54 @@ export async function getSeedImageByFilename(filename: string, logger: Logger) { throw error } } + +export const pastDate = (monthOffset: number, day: number, hour: number = 18) => { + const date = new Date() + date.setMonth(date.getMonth() - monthOffset) + date.setDate(day) + date.setHours(hour, 0, 0, 0) + return date.toISOString() +} + +// Helper to create dates in the future +export const futureDate = (monthOffset: number, day: number, hour: number = 18) => { + const date = new Date() + date.setMonth(date.getMonth() + monthOffset) + date.setDate(day) + date.setHours(hour, 0, 0, 0) + return date.toISOString() +} + +// Helper to create properly formatted lexical text nodes +export const textNode = (text: string) => ({ + type: 'text', + detail: 0, + format: 0, + mode: 'normal' as const, + style: '', + text, + version: 1, +}) + +// Helper to create properly formatted lexical paragraph nodes +export const paragraphNode = (text: string) => ({ + type: 'paragraph', + children: [textNode(text)], + direction: 'ltr' as const, + format: '' as '' | 'left' | 'start' | 'center' | 'right' | 'end' | 'justify', + indent: 0, + textFormat: 0, + version: 1, +}) + +// Helper to create simple content with just paragraphs +export const simpleContent = (...paragraphs: string[]) => ({ + root: { + type: 'root', + children: paragraphs.map(paragraphNode), + direction: 'ltr' as const, + format: '' as '' | 'left' | 'start' | 'center' | 'right' | 'end' | 'justify', + indent: 0, + version: 1, + }, +}) diff --git a/src/fields/EventQuery/config.ts b/src/fields/EventQuery/config.ts new file mode 100644 index 00000000..758ff112 --- /dev/null +++ b/src/fields/EventQuery/config.ts @@ -0,0 +1,146 @@ +import type { Field, FilterOptionsProps } from 'payload' + +import { ButtonBlock } from '@/blocks/Button/config' +import { GenericEmbedLexical } from '@/blocks/GenericEmbed/config' +import { MediaBlockLexical } from '@/blocks/MediaBlock/config' +import { eventTypesData } from '@/constants/eventTypes' +import { getTenantFilter } from '@/utilities/collectionFilters' +import { + BlocksFeature, + HorizontalRuleFeature, + InlineToolbarFeature, + lexicalEditor, +} from '@payloadcms/richtext-lexical' +import { validateMaxEvents } from './hooks/validateMaxEvents' + +export const defaultStylingFields = (additionalFilters?: Field[]): Field[] => [ + ...(additionalFilters ?? []), + { name: 'heading', type: 'text' }, + { + name: 'belowHeadingContent', + type: 'richText', + editor: lexicalEditor({ + features: ({ rootFeatures }) => { + return [ + ...rootFeatures, + BlocksFeature({ + blocks: [ButtonBlock, MediaBlockLexical, GenericEmbedLexical], + }), + HorizontalRuleFeature(), + InlineToolbarFeature(), + ] + }, + }), + label: 'Content Below Heading', + admin: { + description: 'Optional content to display below the heading and above the event content.', + }, + }, + { + type: 'radio', + name: 'eventOptions', + label: 'How do you want to choose your events?', + defaultValue: 'dynamic', + required: true, + options: [ + { + label: 'Do it for me', + value: 'dynamic', + }, + { + label: 'Let me choose', + value: 'static', + }, + ], + }, +] + +export const dynamicEventRelatedFields = (additionalFilters?: Field[]): Field[] => [ + { + name: 'dynamicOptions', + type: 'group', + admin: { + condition: (_, siblingData) => siblingData?.eventOptions === 'dynamic', + description: 'Use Preview ↗ to see how events will appear', + }, + fields: [ + ...(additionalFilters ?? []), + { + name: 'filterByEventTypes', + type: 'select', + dbName: 'filterByEventTypes', + options: eventTypesData.map((type) => ({ + label: type.label, + value: type.value, + })), + hasMany: true, + label: 'Filter by Event Type(s)', + admin: { + description: 'Optionally select event types to filter events.', + }, + }, + { + name: 'filterByEventGroups', + type: 'relationship', + relationTo: 'eventGroups', + hasMany: true, + admin: { + position: 'sidebar', + description: 'Optionally select event group to filter events.', + }, + filterOptions: getTenantFilter, + }, + { + name: 'filterByEventTags', + type: 'relationship', + relationTo: 'eventTags', + hasMany: true, + admin: { + position: 'sidebar', + description: 'Optionally select event tags to filter events.', + }, + filterOptions: getTenantFilter, + }, + { + name: 'maxEvents', + type: 'number', + label: 'Max Events Displayed', + min: 1, + max: 20, + defaultValue: 4, + admin: { + description: 'Maximum number of events that will be displayed. Must be an integer.', + step: 1, + }, + hooks: { + beforeValidate: [validateMaxEvents], + }, + }, + ], + }, +] + +export const staticEventRelatedFields: Field[] = [ + { + name: 'staticOptions', + type: 'group', + admin: { + condition: (_, siblingData) => siblingData?.eventOptions === 'static', + }, + fields: [ + { + name: 'staticEvents', + type: 'relationship', + label: 'Choose events', + relationTo: 'events', + hasMany: true, + admin: { + description: 'Choose new event from dropdown and/or drag and drop to change order', + }, + filterOptions: (props: FilterOptionsProps) => ({ + and: [getTenantFilter(props)], + }), + }, + ], + }, +] diff --git a/src/blocks/EventList/hooks/validateMaxEvents.ts b/src/fields/EventQuery/hooks/validateMaxEvents.ts similarity index 100% rename from src/blocks/EventList/hooks/validateMaxEvents.ts rename to src/fields/EventQuery/hooks/validateMaxEvents.ts diff --git a/src/payload-types.ts b/src/payload-types.ts index 06abd4a8..af8479aa 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -260,6 +260,7 @@ export interface HomePage { | DocumentBlock | EventListBlock | SingleEventBlock + | EventTableBlock | FormBlock | GenericEmbedBlock | HeaderBlock @@ -325,6 +326,7 @@ export interface Page { | ContentBlock | DocumentBlock | EventListBlock + | EventTableBlock | SingleEventBlock | FormBlock | HeaderBlock @@ -685,9 +687,10 @@ export interface Document { * via the `definition` "EventListBlock". */ export interface EventListBlock { + backgroundColor: string; heading?: string | null; /** - * Optional content to display below the heading and above the event list. + * Optional content to display below the heading and above the event content. */ belowHeadingContent?: { root: { @@ -704,17 +707,15 @@ export interface EventListBlock { }; [k: string]: unknown; } | null; - backgroundColor: string; eventOptions: 'dynamic' | 'static'; /** * Checking this will render the block with additional padding around it and using the background color you have selected. */ wrapInContainer?: boolean | null; + /** + * Use Preview ↗ to see how events will appear + */ dynamicOptions?: { - /** - * Select how the list of events will be sorted. - */ - sortBy: 'startDate' | '-startDate' | 'registrationDeadline' | '-registrationDeadline'; /** * Optionally select event types to filter events. */ @@ -722,14 +723,17 @@ export interface EventListBlock { | ('events-by-ac' | 'awareness' | 'workshop' | 'field-class-by-ac' | 'volunteer' | 'events-by-others')[] | null; /** - * Only display events that have not yet occurred. + * Optionally select event group to filter events. + */ + filterByEventGroups?: (number | EventGroup)[] | null; + /** + * Optionally select event tags to filter events. */ - showUpcomingOnly?: boolean | null; + filterByEventTags?: (number | EventTag)[] | null; /** * Maximum number of events that will be displayed. Must be an integer. */ maxEvents?: number | null; - queriedEvents?: (number | Event)[] | null; }; staticOptions?: { /** @@ -741,6 +745,25 @@ export interface EventListBlock { blockName?: string | null; blockType: 'eventList'; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "eventGroups". + */ +export interface EventGroup { + id: number; + tenant: number | Tenant; + title: string; + description?: string | null; + events?: { + docs?: (number | Event)[]; + hasNextPage?: boolean; + totalDocs?: number; + }; + slug: string; + contentHash?: string | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "events". @@ -918,9 +941,9 @@ export interface Event { } /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "eventGroups". + * via the `definition` "eventTags". */ -export interface EventGroup { +export interface EventTag { id: number; tenant: number | Tenant; title: string; @@ -937,22 +960,61 @@ export interface EventGroup { } /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "eventTags". + * via the `definition` "EventTableBlock". */ -export interface EventTag { - id: number; - tenant: number | Tenant; - title: string; - description?: string | null; - events?: { - docs?: (number | Event)[]; - hasNextPage?: boolean; - totalDocs?: number; +export interface EventTableBlock { + heading?: string | null; + /** + * Optional content to display below the heading and above the event content. + */ + belowHeadingContent?: { + 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; + eventOptions: 'dynamic' | 'static'; + /** + * Use Preview ↗ to see how events will appear + */ + dynamicOptions?: { + /** + * Optionally select event types to filter events. + */ + filterByEventTypes?: + | ('events-by-ac' | 'awareness' | 'workshop' | 'field-class-by-ac' | 'volunteer' | 'events-by-others')[] + | null; + /** + * Optionally select event group to filter events. + */ + filterByEventGroups?: (number | EventGroup)[] | null; + /** + * Optionally select event tags to filter events. + */ + filterByEventTags?: (number | EventTag)[] | null; + /** + * Maximum number of events that will be displayed. Must be an integer. + */ + maxEvents?: number | null; }; - slug: string; - contentHash?: string | null; - updatedAt: string; - createdAt: string; + staticOptions?: { + /** + * Choose new event from dropdown and/or drag and drop to change order + */ + staticEvents?: (number | Event)[] | null; + }; + id?: string | null; + blockName?: string | null; + blockType: 'eventTable'; } /** * This interface was referenced by `Config`'s JSON-Schema @@ -2744,6 +2806,7 @@ export interface HomePagesSelect { documentBlock?: T | DocumentBlockSelect; eventList?: T | EventListBlockSelect; singleEvent?: T | SingleEventBlockSelect; + eventTable?: T | EventTableBlockSelect; formBlock?: T | FormBlockSelect; genericEmbed?: T | GenericEmbedBlockSelect; headerBlock?: T | HeaderBlockSelect; @@ -2847,18 +2910,17 @@ export interface DocumentBlockSelect { * via the `definition` "EventListBlock_select". */ export interface EventListBlockSelect { + backgroundColor?: T; heading?: T; belowHeadingContent?: T; - backgroundColor?: T; eventOptions?: T; dynamicOptions?: | T | { - sortBy?: T; filterByEventTypes?: T; - showUpcomingOnly?: T; + filterByEventGroups?: T; + filterByEventTags?: T; maxEvents?: T; - queriedEvents?: T; }; staticOptions?: | T @@ -2878,6 +2940,30 @@ export interface SingleEventBlockSelect { id?: T; blockName?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "EventTableBlock_select". + */ +export interface EventTableBlockSelect { + heading?: T; + belowHeadingContent?: T; + eventOptions?: T; + dynamicOptions?: + | T + | { + filterByEventTypes?: T; + filterByEventGroups?: T; + filterByEventTags?: T; + maxEvents?: T; + }; + staticOptions?: + | T + | { + staticEvents?: T; + }; + id?: T; + blockName?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "FormBlock_select". @@ -3065,6 +3151,7 @@ export interface PagesSelect { content?: T | ContentBlockSelect; documentBlock?: T | DocumentBlockSelect; eventList?: T | EventListBlockSelect; + eventTable?: T | EventTableBlockSelect; singleEvent?: T | SingleEventBlockSelect; formBlock?: T | FormBlockSelect; headerBlock?: T | HeaderBlockSelect;