diff --git a/backend/app/DomainObjects/EventDomainObject.php b/backend/app/DomainObjects/EventDomainObject.php index c934b0b5a..9f0b318d3 100644 --- a/backend/app/DomainObjects/EventDomainObject.php +++ b/backend/app/DomainObjects/EventDomainObject.php @@ -53,6 +53,8 @@ class EventDomainObject extends Generated\EventDomainObjectAbstract implements I private ?string $occurrencesMonth = null; + private ?string $lifecycleStatus = null; + public static function getAllowedFilterFields(): array { return [ @@ -308,8 +310,19 @@ public function isEventOngoing(): bool ); } + public function setLifecycleStatus(string $lifecycleStatus): self + { + $this->lifecycleStatus = $lifecycleStatus; + + return $this; + } + public function getLifecycleStatus(): string { + if ($this->lifecycleStatus !== null) { + return $this->lifecycleStatus; + } + if ($this->isEventOngoing()) { return EventLifecycleStatus::ONGOING->name; } diff --git a/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php b/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php index e7bd02954..a63c36535 100644 --- a/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php +++ b/backend/app/Services/Application/Handlers/Event/GetPublicEventHandler.php @@ -17,6 +17,7 @@ use HiEvents\DomainObjects\ProductCategoryDomainObject; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductPriceDomainObject; +use HiEvents\DomainObjects\Status\EventLifecycleStatus; use HiEvents\DomainObjects\Status\EventOccurrenceStatus; use HiEvents\DomainObjects\TaxAndFeesDomainObject; use HiEvents\Repository\Eloquent\Value\OrderAndDirection; @@ -122,8 +123,13 @@ private function setRecurringEventOccurrences( : [...$occurrenceWhere, PublicOccurrenceVisibilityService::hasRemainingCapacity()]; $nextBookable = $this->findEdgeOccurrence($nextBookableWhere, 'asc'); + $lastUpcoming = $this->findEdgeOccurrence($occurrenceWhere, 'desc'); $event->setNextOccurrenceStartDate($nextBookable?->getStartDate()); - $event->setLastOccurrenceStartDate($this->findEdgeOccurrence($occurrenceWhere, 'desc')?->getStartDate()); + $event->setLastOccurrenceStartDate($lastUpcoming?->getStartDate()); + + if ($lastUpcoming === null) { + $this->setLifecycleStatusForEndedEvent($event, $eventId); + } $anchorOccurrence = $verifiedOccurrence ?? $nextBookable; if ($anchorOccurrence === null && ! $hideSoldOutOccurrences) { @@ -164,6 +170,18 @@ static function ($query): void { } } + private function setLifecycleStatusForEndedEvent(EventDomainObject $event, int $eventId): void + { + $latestOccurrence = $this->findEdgeOccurrence([ + EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId, + [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name], + ], 'desc'); + + if ($latestOccurrence?->isPast()) { + $event->setLifecycleStatus(EventLifecycleStatus::ENDED->name); + } + } + private function fetchOccurrences(array $where, ?EventOccurrenceDomainObject $verifiedOccurrence): PublicOccurrenceFetchResultDTO { $occurrences = $this->occurrenceRepository diff --git a/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php index 9dd40136a..2d5d205a8 100644 --- a/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php @@ -11,6 +11,7 @@ use HiEvents\DomainObjects\ProductCategoryDomainObject; use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\PromoCodeDomainObject; +use HiEvents\DomainObjects\Status\EventLifecycleStatus; use HiEvents\DomainObjects\Status\EventOccurrenceStatus; use HiEvents\Repository\Eloquent\Value\OrderAndDirection; use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; @@ -592,6 +593,78 @@ public function test_handle_sets_null_occurrences_month_when_anchor_month_trunca $this->assertCount(GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES, $result->getEventOccurrences()); } + public function test_handle_marks_recurring_event_ended_when_all_occurrences_are_past(): void + { + $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: true, ipAddress: '127.0.0.1', promoCode: null); + $event = (new EventDomainObject) + ->setType(EventType::RECURRING->name) + ->setTimezone('UTC') + ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(false)) + ->setProductCategories(collect()); + + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event); + $this->expectLatestOccurrenceQuery($this->makeOccurrence(3, '2024-01-01 10:00:00')); + $this->expectEdgeOccurrenceQueries(); + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->with(m::any(), m::any(), m::any(), GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1) + ->andReturn(collect()); + $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull(); + $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect()); + $this->eventPageViewIncrementService->shouldNotReceive('increment'); + + $result = $this->handler->handle($data); + + $this->assertSame(EventLifecycleStatus::ENDED->name, $result->getLifecycleStatus()); + } + + public function test_handle_does_not_mark_recurring_event_ended_when_upcoming_occurrences_are_hidden(): void + { + $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: true, ipAddress: '127.0.0.1', promoCode: null); + $event = (new EventDomainObject) + ->setType(EventType::RECURRING->name) + ->setTimezone('UTC') + ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(true)) + ->setProductCategories(collect()); + + $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf(); + $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event); + $this->expectLatestOccurrenceQuery($this->makeOccurrence(3, '2099-01-01 10:00:00')); + $this->expectEdgeOccurrenceQueries(); + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->with(m::any(), m::any(), m::any(), GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1) + ->andReturn(collect()); + $this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturnNull(); + $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull(); + $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect()); + $this->eventPageViewIncrementService->shouldNotReceive('increment'); + + $result = $this->handler->handle($data); + + $this->assertSame(EventLifecycleStatus::UPCOMING->name, $result->getLifecycleStatus()); + } + + private function expectLatestOccurrenceQuery(EventOccurrenceDomainObject $latestOccurrence): void + { + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->once() + ->with( + m::on(static fn (array $where): bool => ! collect($where)->contains( + static fn ($condition): bool => $condition instanceof Closure + )), + m::any(), + m::on(static fn (array $orders): bool => ($orders[0] ?? null) instanceof OrderAndDirection + && $orders[0]->getDirection() === OrderAndDirection::DIRECTION_DESC), + 1, + ) + ->andReturn(collect([$latestOccurrence])); + } + private function expectEdgeOccurrenceQueries( ?EventOccurrenceDomainObject $nextBookable = null, ?EventOccurrenceDomainObject $lastOccurrence = null, diff --git a/e2e/api/api-client.ts b/e2e/api/api-client.ts index 1387128b5..f54410a3d 100644 --- a/e2e/api/api-client.ts +++ b/e2e/api/api-client.ts @@ -18,9 +18,11 @@ import type { CreateTaxOrFeePayload, CreateWebhookPayload, EmailTemplate, + EventImageType, EventRecord, EventSettings, EventStatus, + ImageRecord, InviteUserPayload, Me, Occurrence, @@ -130,6 +132,14 @@ export class ApiClient { return unwrap(this.request.post('events', { headers: jsonHeaders, data: payload })); } + uploadEventImage( + eventId: number, + image: { name: string; mimeType: string; buffer: Buffer }, + type: EventImageType = 'EVENT_COVER', + ): Promise { + return unwrap(this.request.post(`events/${eventId}/images`, { multipart: { image, type } })); + } + listProductCategories(eventId: number): Promise { return unwrap(this.request.get(`events/${eventId}/product-categories`, { headers: jsonHeaders })); } diff --git a/e2e/api/factory.ts b/e2e/api/factory.ts index 757d7b831..c2c8b25e4 100644 --- a/e2e/api/factory.ts +++ b/e2e/api/factory.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import type { APIRequestContext } from '@playwright/test'; import type { ApiClient } from './api-client'; import type { @@ -29,6 +31,7 @@ export interface SeededEvent { interface SeedOptions { organizerId: number; + startDate?: string; price?: number; productType?: ProductPriceType; eventType?: EventType; @@ -55,6 +58,19 @@ const futureStartDate = (): string => { return date.toISOString(); }; +const pastStartDate = (): string => { + const date = new Date(); + date.setDate(date.getDate() - 30); + date.setHours(21, 0, 0, 0); + return date.toISOString(); +}; + +const coverImage = (): { name: string; mimeType: string; buffer: Buffer } => ({ + name: 'event-cover.png', + mimeType: 'image/png', + buffer: readFileSync(fileURLToPath(new URL('../fixtures/assets/event-cover.png', import.meta.url))), +}); + export async function createLiveEventWithProduct(api: ApiClient, opts: SeedOptions): Promise { const { organizerId, @@ -70,7 +86,7 @@ export async function createLiveEventWithProduct(api: ApiClient, opts: SeedOptio title, type: eventType, organizer_id: organizerId, - start_date: futureStartDate(), + start_date: opts.startDate ?? futureStartDate(), category, currency: 'USD', timezone: 'UTC', @@ -306,6 +322,27 @@ export async function createSoldOutEvent( return { ...event, consumedOrder }; } +export async function createPastEventWithCoverImage( + api: ApiClient, + organizerId: number, + opts: { title?: string; eventType?: EventType } = {}, +): Promise { + const event = await createLiveEventWithProduct(api, { + organizerId, + startDate: pastStartDate(), + title: opts.title, + eventType: opts.eventType, + }); + + if (opts.eventType === 'RECURRING') { + await api.createOccurrence(event.eventId, { start_date: pastStartDate() }); + } + + await api.uploadEventImage(event.eventId, coverImage()); + + return event; +} + export async function createRecurringLiveEvent( api: ApiClient, organizerId: number, diff --git a/e2e/api/types.ts b/e2e/api/types.ts index 8a006ce56..69ae77fce 100644 --- a/e2e/api/types.ts +++ b/e2e/api/types.ts @@ -44,6 +44,14 @@ export interface EventRecord { status: EventStatus; } +export type EventImageType = 'EVENT_COVER' | 'TICKET_LOGO'; + +export interface ImageRecord { + id: number; + url: string; + type: EventImageType; +} + export interface ProductCategory { id: number; name: string; diff --git a/e2e/fixtures/assets/event-cover.png b/e2e/fixtures/assets/event-cover.png new file mode 100644 index 000000000..1bb923f82 Binary files /dev/null and b/e2e/fixtures/assets/event-cover.png differ diff --git a/e2e/tests/events/past-event-page.spec.ts b/e2e/tests/events/past-event-page.spec.ts new file mode 100644 index 000000000..1e1df9fbf --- /dev/null +++ b/e2e/tests/events/past-event-page.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from '../../fixtures'; +import { PublicEventPage } from '../../pages/public-event.page'; +import { createFreshOrganizer, createPastEventWithCoverImage } from '../../api/factory'; +import { uniqueName } from '../../utils/unique'; + +test.describe('past event page', () => { + test('a visitor sees a sales ended badge and no way to buy', async ({ page, api }) => { + const organizer = await createFreshOrganizer(api, uniqueName('E2E Past Org')); + await api.updateOrganizerStatus(organizer.id, 'LIVE'); + const event = await createPastEventWithCoverImage(api, organizer.id, { title: uniqueName('E2E Past Event') }); + + const publicPage = new PublicEventPage(page); + await publicPage.goto(event.eventId, event.slug); + + await expect(page.getByRole('heading', { name: event.title })).toBeVisible(); + await expect(page.getByText('Sales ended', { exact: true })).toBeVisible(); + await expect(page.getByText('Ticket sales have ended for this event')).toBeVisible(); + await expect(page.getByText(event.productTitle)).toHaveCount(0); + }); + + test('a visitor sees a sales ended badge on a recurring event whose dates have all passed', async ({ page, api }) => { + const organizer = await createFreshOrganizer(api, uniqueName('E2E Past Recurring Org')); + await api.updateOrganizerStatus(organizer.id, 'LIVE'); + const event = await createPastEventWithCoverImage(api, organizer.id, { + title: uniqueName('E2E Past Recurring Event'), + eventType: 'RECURRING', + }); + + const publicPage = new PublicEventPage(page); + await publicPage.goto(event.eventId, event.slug); + + await expect(page.getByRole('heading', { name: event.title })).toBeVisible(); + await expect(page.getByText('Sales ended', { exact: true })).toBeVisible(); + }); +}); diff --git a/frontend/src/components/layouts/EventHomepage/index.tsx b/frontend/src/components/layouts/EventHomepage/index.tsx index 451e9f280..3cf6b4276 100644 --- a/frontend/src/components/layouts/EventHomepage/index.tsx +++ b/frontend/src/components/layouts/EventHomepage/index.tsx @@ -4,7 +4,7 @@ import "../../../styles/widget/default.scss"; import React, {useCallback, useEffect, useRef, useState} from "react"; import {EventDocumentHead} from "../../common/EventDocumentHead"; import {eventCoverImage, eventHomepageUrl, imageUrl, organizerHomepageUrl} from "../../../utilites/urlHelper.ts"; -import {Event, EventOccurrence, EventType, OrganizerStatus} from "../../../types.ts"; +import {Event, EventLifecycleStatus, EventOccurrence, EventType, OrganizerStatus} from "../../../types.ts"; import {EventNotAvailable} from "./EventNotAvailable"; import { IconArrowUpRight, @@ -188,7 +188,13 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => { config: socialMediaConfig[platform as keyof typeof socialMediaConfig] })) : []; + const eventHasEnded = event.lifecycle_status === EventLifecycleStatus.ENDED; + const getStatusBadge = () => { + if (eventHasEnded) { + return {text: t`Sales ended`}; + } + const products = event.products || event.product_categories?.flatMap(c => c.products || []) || []; if (products.length > 0 && products.every(p => p.is_sold_out)) { @@ -714,7 +720,7 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => { : continueButtonText} )} - {!showFloatingCheckoutButton && showScrollButton && ( + {!showFloatingCheckoutButton && showScrollButton && !eventHasEnded && (