diff --git a/locksmith/__tests__/operations/eventCollectionOperations.test.ts b/locksmith/__tests__/operations/eventCollectionOperations.test.ts index 5d444bff3ef..c3480a44731 100644 --- a/locksmith/__tests__/operations/eventCollectionOperations.test.ts +++ b/locksmith/__tests__/operations/eventCollectionOperations.test.ts @@ -7,9 +7,13 @@ import { addManagerAddressOperation, createEventCollectionOperation, createEventCollectionSlug, + getEventCollectionOperation, + PUBLIC_EVENTS_COLLECTION_SLUG, removeManagerAddressOperation, updateEventCollectionOperation, } from '../../src/operations/eventCollectionOperations' +import { EventStatus } from '@unlock-protocol/types' +import { Op } from 'sequelize' // interface for link types interface Link { @@ -61,8 +65,29 @@ vi.mock('../../src/utils/createSlug', () => ({ .replace(/(^-|-$)/g, ''), })) +vi.mock('../../src/operations/wedlocksOperations', () => ({ + sendEmail: vi.fn(), +})) + +vi.mock('../../src/operations/privyUserOperations', () => ({ + getPrivyUserByAddress: vi.fn().mockResolvedValue({ success: false }), +})) + +vi.mock('../../src/config/config', () => ({ + default: { + unlockApp: 'https://app.unlock-protocol.com', + }, +})) + +vi.mock('../../src/logger', () => ({ + default: { + error: vi.fn(), + }, +})) + describe('eventCollectionOperations', () => { let mockFindOne: ReturnType + let mockFindAll: ReturnType let scopedEventData: any beforeEach(() => { @@ -70,8 +95,10 @@ describe('eventCollectionOperations', () => { // Mock the scoped EventData mockFindOne = vi.fn() + mockFindAll = vi.fn() scopedEventData = { findOne: mockFindOne, + findAll: mockFindAll, } ;(EventData.scope as any).mockReturnValue(scopedEventData) }) @@ -198,6 +225,62 @@ describe('eventCollectionOperations', () => { 'test-collection-with-special-characters' ) }) + + it('reserves the public events collection slug', async () => { + ;(EventCollection.findByPk as any).mockResolvedValueOnce(null) + + const slug = await createEventCollectionSlug('All Events') + + expect(slug).toBe('all-events-1') + expect(EventCollection.findByPk).toHaveBeenCalledOnce() + expect(EventCollection.findByPk).toHaveBeenCalledWith('all-events-1') + }) + }) + + describe('getEventCollectionOperation', () => { + it('returns a read-only virtual collection of deployed Unlock events', async () => { + const event = { + slug: 'community-meetup', + data: { + name: 'Community meetup', + replyTo: 'private@example.com', + attributes: [], + }, + } + mockFindAll.mockResolvedValue([event]) + + const result = await getEventCollectionOperation( + PUBLIC_EVENTS_COLLECTION_SLUG + ) + + expect(EventCollection.findByPk).not.toHaveBeenCalled() + expect(mockFindAll).toHaveBeenCalledWith({ + where: { + status: EventStatus.DEPLOYED, + eventType: 'unlock', + checkoutConfigId: { + [Op.ne]: null, + }, + }, + order: [['createdAt', 'DESC']], + }) + expect(result).toMatchObject({ + slug: PUBLIC_EVENTS_COLLECTION_SLUG, + title: 'Explore Unlock Events', + managerAddresses: [], + isVirtual: true, + events: [ + { + slug: 'community-meetup', + data: { + name: 'Community meetup', + attributes: [], + }, + }, + ], + }) + expect(result.events[0].data).not.toHaveProperty('replyTo') + }) }) describe('updateEventCollectionOperation', () => { diff --git a/locksmith/src/operations/eventCollectionOperations.ts b/locksmith/src/operations/eventCollectionOperations.ts index 05121c9c545..c76e6bdcc07 100644 --- a/locksmith/src/operations/eventCollectionOperations.ts +++ b/locksmith/src/operations/eventCollectionOperations.ts @@ -8,6 +8,22 @@ import config from '../config/config' import logger from '../logger' import { getPrivyUserByAddress } from './privyUserOperations' import { Op } from 'sequelize' +import { EventStatus } from '@unlock-protocol/types' +import { removeProtectedAttributesFromObject } from '../utils/protectedAttributes' + +export const PUBLIC_EVENTS_COLLECTION_SLUG = 'all-events' + +const PUBLIC_EVENTS_COLLECTION = { + slug: PUBLIC_EVENTS_COLLECTION_SLUG, + title: 'Explore Unlock Events', + description: + 'Discover public events created with Unlock Protocol. New deployed events appear here automatically.', + coverImage: '/images/illustrations/events/farcon-hero.png', + banner: '/images/illustrations/events/party.svg', + links: [], + managerAddresses: [], + isVirtual: true, +} as const // event collection body schema const EventCollectionBody = z.object({ @@ -45,6 +61,10 @@ export async function createEventCollectionSlug( const baseSlug = kebabCase(cleanTitle) const slug = index ? `${baseSlug}-${index}` : baseSlug + if (slug === PUBLIC_EVENTS_COLLECTION_SLUG) { + return createEventCollectionSlug(title, 1) + } + // Check if the slug already exists const existingCollection = await EventCollection.findByPk(slug) if (existingCollection) { @@ -111,6 +131,28 @@ export const createEventCollectionOperation = async ( * @throws An error if the event collection is not found. */ export const getEventCollectionOperation = async (slug: string) => { + if (slug === PUBLIC_EVENTS_COLLECTION_SLUG) { + const events = await EventData.scope('withoutId').findAll({ + where: { + status: EventStatus.DEPLOYED, + eventType: 'unlock', + checkoutConfigId: { + [Op.ne]: null, + }, + }, + order: [['createdAt', 'DESC']], + }) + + events.forEach((event) => { + event.data = removeProtectedAttributesFromObject(event.data) + }) + + return { + ...PUBLIC_EVENTS_COLLECTION, + events, + } + } + const eventCollection = await EventCollection.findByPk(slug, { include: [ { diff --git a/packages/unlock-js/openapi.yml b/packages/unlock-js/openapi.yml index 84ef3e5abff..564ea6e9c06 100644 --- a/packages/unlock-js/openapi.yml +++ b/packages/unlock-js/openapi.yml @@ -596,6 +596,8 @@ components: type: array items: type: string + isVirtual: + type: boolean createdAt: type: string format: date-time diff --git a/unlock-app/src/components/content/event/EventLandingPage.tsx b/unlock-app/src/components/content/event/EventLandingPage.tsx index dcc19e5af04..1a15b5312de 100644 --- a/unlock-app/src/components/content/event/EventLandingPage.tsx +++ b/unlock-app/src/components/content/event/EventLandingPage.tsx @@ -4,6 +4,7 @@ import { Button } from '@unlock-protocol/ui' import Link from 'next/link' import { LockTypeLandingPage } from '~/components/interface/LockTypeLandingPage' import Image from 'next/image' +import { TbCalendarEvent } from 'react-icons/tb' const customers = [ { @@ -136,9 +137,14 @@ export const EventLandingPageCallToAction = ({ handleCreateEvent, }: EventLandingPageCallToActionProps) => { return ( - +
+ + + + +
) } diff --git a/unlock-app/src/components/content/events-collection/EventsCollectionDetailContent.tsx b/unlock-app/src/components/content/events-collection/EventsCollectionDetailContent.tsx index d3b58746321..43d1cae5ee6 100644 --- a/unlock-app/src/components/content/events-collection/EventsCollectionDetailContent.tsx +++ b/unlock-app/src/components/content/events-collection/EventsCollectionDetailContent.tsx @@ -84,6 +84,10 @@ export default function EventsCollectionDetailContent({ const [isEventDetailDrawerOpen, setIsEventDetailDrawerOpen] = useState(false) const [selectedEvent, setSelectedEvent] = useState(null) + const isVirtualCollection = Boolean( + (eventCollection as { isVirtual?: boolean } | undefined)?.isVirtual + ) + const hasValidEvents = useMemo(() => { return ( eventCollection?.events?.some( @@ -284,12 +288,14 @@ export default function EventsCollectionDetailContent({

Events

- + {!isVirtualCollection && ( + + )}
{hasValidEvents ? ( <> @@ -333,15 +339,19 @@ export default function EventsCollectionDetailContent({ - No events have been added yet.{' '} - - {isManager ? 'Add an event' : 'Submit an event'} - -
+ isVirtualCollection ? ( + 'No public events are available yet.' + ) : ( +
+ No events have been added yet.{' '} + + {isManager ? 'Add an event' : 'Submit an event'} + +
+ ) } /> )} @@ -352,13 +362,15 @@ export default function EventsCollectionDetailContent({ {/* Add Event Drawer */} - + {!isVirtualCollection && ( + + )} {/* Event Detail Drawer */} {eventCollection?.slug && (