From cbae6858d082b59d26588221753878d245dd6c5d Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Fri, 4 Sep 2026 20:36:26 +0100 Subject: [PATCH 1/4] Fix: Show sales ended state on past event pages (#1334) --- .../app/DomainObjects/EventDomainObject.php | 13 ++++ .../Handlers/Event/GetPublicEventHandler.php | 20 ++++- .../Event/GetPublicEventHandlerTest.php | 73 ++++++++++++++++++ e2e/api/api-client.ts | 10 +++ e2e/api/factory.ts | 39 +++++++++- e2e/api/types.ts | 8 ++ e2e/fixtures/assets/event-cover.png | Bin 0 -> 689 bytes e2e/tests/events/past-event-page.spec.ts | 35 +++++++++ .../layouts/EventHomepage/index.tsx | 10 ++- frontend/src/locales/de.po | 43 ++++++----- frontend/src/locales/el.po | 43 ++++++----- frontend/src/locales/en.po | 43 ++++++----- frontend/src/locales/es.po | 43 ++++++----- frontend/src/locales/fr.po | 43 ++++++----- frontend/src/locales/hu.po | 43 ++++++----- frontend/src/locales/it.po | 43 ++++++----- frontend/src/locales/nl.po | 43 ++++++----- frontend/src/locales/pl.po | 43 ++++++----- frontend/src/locales/pt-br.po | 43 ++++++----- frontend/src/locales/pt.po | 43 ++++++----- frontend/src/locales/ru.po | 43 ++++++----- frontend/src/locales/se.po | 43 ++++++----- frontend/src/locales/sk.po | 43 ++++++----- frontend/src/locales/tr.po | 43 ++++++----- frontend/src/locales/vi.po | 43 ++++++----- frontend/src/locales/zh-cn.po | 43 ++++++----- frontend/src/locales/zh-hk.po | 43 ++++++----- 27 files changed, 600 insertions(+), 382 deletions(-) create mode 100644 e2e/fixtures/assets/event-cover.png create mode 100644 e2e/tests/events/past-event-page.spec.ts 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 0000000000000000000000000000000000000000..1bb923f82757352527596c0a1a2d36b4f5f106f3 GIT binary patch literal 689 zcmeAS@N?(olHy`uVBq!ia0y~yV2S{;PjD~+$=!cB9s&g_JY5_^DsH{KYRJpLz`?Td zd$R { + 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 && (