From bd6be1ef6c748c70499afa63b0b0e88972ff044c Mon Sep 17 00:00:00 2001
From: Dave Earley
Date: Sun, 26 Jul 2026 09:12:27 +0100
Subject: [PATCH 1/2] UI fixes
---
CLAUDE.md | 5 +
.../app/DomainObjects/EventDomainObject.php | 97 +--
.../GetEventOccurrencesPublicAction.php | 54 ++
.../Actions/Events/BasePublicEventAction.php | 36 +
.../Actions/Events/GetEventPublicAction.php | 28 +-
.../Resources/Event/EventResourcePublic.php | 2 +
.../DTO/PublicOccurrenceFetchResultDTO.php | 14 +
.../Handlers/Event/GetPublicEventHandler.php | 225 +++---
.../DTO/GetPublicEventOccurrencesDTO.php | 14 +
.../GetPublicEventOccurrencesResultDTO.php | 15 +
.../GetPublicEventOccurrencesHandler.php | 108 +++
.../PublicOccurrenceVisibilityService.php | 76 ++
backend/routes/api.php | 3 +
.../DomainObjects/EventDomainObjectTest.php | 138 ++--
.../Event/GetPublicEventHandlerTest.php | 189 ++++-
.../GetPublicEventOccurrencesHandlerTest.php | 172 ++++
.../PublicOccurrenceVisibilityServiceTest.php | 99 +++
e2e/README.md | 25 +-
e2e/pages/occurrence.page.ts | 35 +
.../checkout/kitchen-sink-recurring.spec.ts | 19 +-
.../events/recurring-event-checkout.spec.ts | 51 ++
frontend/public/widget-test.html | 2 +-
frontend/src/api/event-occurrence.client.ts | 10 +-
.../common/EventDateRange/index.tsx | 20 +-
.../common/EventDocumentHead/index.tsx | 6 +-
.../components/forms/ProductForm/index.tsx | 40 +-
.../layouts/AppLayout/AppLayout.module.scss | 4 +-
.../AppLayout/Sidebar/Sidebar.module.scss | 97 +--
.../layouts/AppLayout/Sidebar/index.tsx | 1 -
.../AppLayout/Topbar/Topbar.module.scss | 78 +-
.../layouts/AuthLayout/Auth.module.scss | 636 +++++----------
.../components/layouts/AuthLayout/index.tsx | 172 ++--
.../layouts/EventHomepage/index.tsx | 2 +-
.../AcceptInvitation.module.scss | 27 +-
.../ForgotPassword/ForgotPassword.module.scss | 27 +-
.../routes/auth/Login/Login.module.scss | 92 ++-
.../components/routes/auth/Login/index.tsx | 16 +-
.../routes/auth/Register/Register.module.scss | 7 +-
.../ResetPassword/ResetPassword.module.scss | 2 -
.../components/routes/auth/_auth-common.scss | 123 +--
.../OccurrenceSelector.scss | 12 +
.../OccurrenceSelector/index.tsx | 82 +-
.../product-widget/SelectProducts/index.tsx | 12 +-
frontend/src/locales/de.js | 2 +-
frontend/src/locales/de.po | 757 +++++++++---------
frontend/src/locales/el.js | 2 +-
frontend/src/locales/el.po | 757 +++++++++---------
frontend/src/locales/en.js | 2 +-
frontend/src/locales/en.po | 757 +++++++++---------
frontend/src/locales/es.js | 2 +-
frontend/src/locales/es.po | 757 +++++++++---------
frontend/src/locales/fr.js | 2 +-
frontend/src/locales/fr.po | 757 +++++++++---------
frontend/src/locales/hu.js | 2 +-
frontend/src/locales/hu.po | 757 +++++++++---------
frontend/src/locales/it.js | 2 +-
frontend/src/locales/it.po | 757 +++++++++---------
frontend/src/locales/nl.js | 2 +-
frontend/src/locales/nl.po | 757 +++++++++---------
frontend/src/locales/pl.js | 2 +-
frontend/src/locales/pl.po | 757 +++++++++---------
frontend/src/locales/pt-br.js | 2 +-
frontend/src/locales/pt-br.po | 757 +++++++++---------
frontend/src/locales/pt.js | 2 +-
frontend/src/locales/pt.po | 757 +++++++++---------
frontend/src/locales/ru.js | 2 +-
frontend/src/locales/ru.po | 757 +++++++++---------
frontend/src/locales/se.js | 2 +-
frontend/src/locales/se.po | 757 +++++++++---------
frontend/src/locales/sk.js | 2 +-
frontend/src/locales/sk.po | 757 +++++++++---------
frontend/src/locales/tr.js | 2 +-
frontend/src/locales/tr.po | 757 +++++++++---------
frontend/src/locales/vi.js | 2 +-
frontend/src/locales/vi.po | 757 +++++++++---------
frontend/src/locales/zh-cn.js | 2 +-
frontend/src/locales/zh-cn.po | 757 +++++++++---------
frontend/src/locales/zh-hk.js | 2 +-
frontend/src/locales/zh-hk.po | 757 +++++++++---------
.../queries/useGetEventOccurrencesPublic.ts | 35 +
frontend/src/styles/global.scss | 2 +
frontend/src/types.ts | 2 +
frontend/src/utilites/calendar.ts | 18 +-
83 files changed, 8615 insertions(+), 7977 deletions(-)
create mode 100644 backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesPublicAction.php
create mode 100644 backend/app/Http/Actions/Events/BasePublicEventAction.php
create mode 100644 backend/app/Services/Application/Handlers/Event/DTO/PublicOccurrenceFetchResultDTO.php
create mode 100644 backend/app/Services/Application/Handlers/EventOccurrence/DTO/GetPublicEventOccurrencesDTO.php
create mode 100644 backend/app/Services/Application/Handlers/EventOccurrence/DTO/GetPublicEventOccurrencesResultDTO.php
create mode 100644 backend/app/Services/Application/Handlers/EventOccurrence/GetPublicEventOccurrencesHandler.php
create mode 100644 backend/app/Services/Domain/EventOccurrence/PublicOccurrenceVisibilityService.php
create mode 100644 backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetPublicEventOccurrencesHandlerTest.php
create mode 100644 backend/tests/Unit/Services/Domain/EventOccurrence/PublicOccurrenceVisibilityServiceTest.php
create mode 100644 frontend/src/queries/useGetEventOccurrencesPublic.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index a3ef65116c..7141894ff6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -151,6 +151,11 @@ Gotchas:
#### E2E Tests
- There is a Playwright E2E suite in `e2e/` (see `e2e/README.md`). It runs the real stack (Laravel + SSR frontend + Postgres + Redis + Mailpit) in Docker.
+- **To test uncommitted changes, run specs against the dev stack** — the hermetic e2e stack bakes source into images and `docker compose up` never rebuilds them. From `e2e/`:
+ ```bash
+ E2E_BASE_URL=https://localhost:8443 MAILPIT_URL=http://localhost:8025 E2E_SAAS_MODE=true npx playwright test
+ ```
+ `E2E_SAAS_MODE=true` is required (the dev stack requires email verification; the fixture only confirms via Mailpit in SaaS mode), a queue worker must be running to deliver the verification emails, and superadmin-dependent specs need a one-time `php artisan dev:bootstrap --email=superadmin@e2e.test --password='SuperAdminPass123!'`. See "Against the running dev stack" in `e2e/README.md`.
- **When you add or meaningfully change a user-facing flow, add or update an E2E spec for it where practical.** Follow the existing pattern: arrange data via the API/`factory`, drive only the flow under test through the UI with a thin page object, and assert on real page content (the created/edited item appears), not just a URL change. Tag fast, load-bearing checks with `@smoke`.
- Not everything needs E2E — reserve it for real user journeys (create/edit/complete flows). Pure logic belongs in backend unit/feature tests instead.
diff --git a/backend/app/DomainObjects/EventDomainObject.php b/backend/app/DomainObjects/EventDomainObject.php
index 3c28a29d56..10bc05188a 100644
--- a/backend/app/DomainObjects/EventDomainObject.php
+++ b/backend/app/DomainObjects/EventDomainObject.php
@@ -2,7 +2,6 @@
namespace HiEvents\DomainObjects;
-use Carbon\Carbon;
use HiEvents\DomainObjects\Enums\EventType;
use HiEvents\DomainObjects\Interfaces\IsFilterable;
use HiEvents\DomainObjects\Interfaces\IsSortable;
@@ -48,6 +47,12 @@ class EventDomainObject extends Generated\EventDomainObjectAbstract implements I
private bool $upcomingOccurrencesSoldOut = false;
+ private ?string $nextOccurrenceStartDate = null;
+
+ private ?string $lastOccurrenceStartDate = null;
+
+ private ?string $occurrencesMonth = null;
+
public static function getAllowedFilterFields(): array
{
return [
@@ -236,49 +241,54 @@ public function getEndDate(): ?string
);
}
- public function getNextOccurrenceStartDate(): ?string
+ public function setNextOccurrenceStartDate(?string $nextOccurrenceStartDate): self
{
- if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) {
- return null;
- }
+ $this->nextOccurrenceStartDate = $nextOccurrenceStartDate;
- $nextOccurrence = $this->eventOccurrences
- ->filter(fn (EventOccurrenceDomainObject $o) => $o->getStatus() === EventOccurrenceStatus::ACTIVE->name)
- ->filter(fn (EventOccurrenceDomainObject $o) => ! $o->isPast())
- ->sortBy(fn (EventOccurrenceDomainObject $o) => $o->getStartDate())
- ->first();
+ return $this;
+ }
- return $nextOccurrence?->getStartDate();
+ public function setLastOccurrenceStartDate(?string $lastOccurrenceStartDate): self
+ {
+ $this->lastOccurrenceStartDate = $lastOccurrenceStartDate;
+
+ return $this;
}
- public function isEventInPast(): bool
+ public function getLastOccurrenceStartDate(): ?string
{
- $endDate = $this->getEndDate();
- if ($endDate === null) {
- return false;
- }
+ return $this->lastOccurrenceStartDate;
+ }
- $parsed = Carbon::parse($endDate);
- if ($this->getTimezone()) {
- $parsed->setTimezone($this->getTimezone());
- }
+ public function setOccurrencesMonth(?string $occurrencesMonth): self
+ {
+ $this->occurrencesMonth = $occurrencesMonth;
- return $parsed->isPast();
+ return $this;
}
- public function isEventInFuture(): bool
+ public function getOccurrencesMonth(): ?string
{
- $startDate = $this->getStartDate();
- if ($startDate === null) {
- return false;
+ return $this->occurrencesMonth;
+ }
+
+ public function getNextOccurrenceStartDate(): ?string
+ {
+ if ($this->nextOccurrenceStartDate !== null) {
+ return $this->nextOccurrenceStartDate;
}
- $parsed = Carbon::parse($startDate);
- if ($this->getTimezone()) {
- $parsed->setTimezone($this->getTimezone());
+ if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) {
+ return null;
}
- return $parsed->isFuture();
+ $nextOccurrence = $this->eventOccurrences
+ ->filter(fn (EventOccurrenceDomainObject $o) => $o->getStatus() === EventOccurrenceStatus::ACTIVE->name)
+ ->filter(fn (EventOccurrenceDomainObject $o) => ! $o->isPast())
+ ->sortBy(fn (EventOccurrenceDomainObject $o) => $o->getStartDate())
+ ->first();
+
+ return $nextOccurrence?->getStartDate();
}
public function isEventOngoing(): bool
@@ -287,20 +297,11 @@ public function isEventOngoing(): bool
return false;
}
- foreach ($this->eventOccurrences as $occurrence) {
- if ($occurrence->getStatus() !== EventOccurrenceStatus::ACTIVE->name) {
- continue;
- }
-
- $start = Carbon::parse($occurrence->getStartDate(), 'UTC');
- $end = $occurrence->getEndDate() ? Carbon::parse($occurrence->getEndDate(), 'UTC') : null;
-
- if ($start->isPast() && ($end === null || $end->isFuture())) {
- return true;
- }
- }
-
- return false;
+ return $this->eventOccurrences->contains(
+ fn (EventOccurrenceDomainObject $o) => $o->getStatus() === EventOccurrenceStatus::ACTIVE->name
+ && ! $o->isFuture()
+ && ! $o->isPast()
+ );
}
public function getLifecycleStatus(): string
@@ -309,11 +310,17 @@ public function getLifecycleStatus(): string
return EventLifecycleStatus::ONGOING->name;
}
- if ($this->isEventInFuture() || $this->getStartDate() === null) {
+ if ($this->eventOccurrences === null || $this->eventOccurrences->isEmpty()) {
return EventLifecycleStatus::UPCOMING->name;
}
- return EventLifecycleStatus::ENDED->name;
+ $hasOccurrenceStillToCome = $this->eventOccurrences->contains(
+ fn (EventOccurrenceDomainObject $o) => ! $o->isPast()
+ );
+
+ return $hasOccurrenceStillToCome
+ ? EventLifecycleStatus::UPCOMING->name
+ : EventLifecycleStatus::ENDED->name;
}
public function isRecurring(): bool
diff --git a/backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesPublicAction.php b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesPublicAction.php
new file mode 100644
index 0000000000..1598706766
--- /dev/null
+++ b/backend/app/Http/Actions/EventOccurrences/GetEventOccurrencesPublicAction.php
@@ -0,0 +1,54 @@
+query('start_date_from');
+ $startDateTo = $request->query('start_date_to');
+
+ try {
+ $result = $this->handler->handle(new GetPublicEventOccurrencesDTO(
+ eventId: $eventId,
+ startDateFrom: is_string($startDateFrom) ? $startDateFrom : null,
+ startDateTo: is_string($startDateTo) ? $startDateTo : null,
+ ));
+ } catch (InvalidOccurrenceDatesException $exception) {
+ throw ValidationException::withMessages([
+ 'start_date_from' => $exception->getMessage(),
+ ]);
+ }
+
+ if (! $this->canUserViewEvent($result->event)) {
+ return $this->notFoundResponse();
+ }
+
+ $showCapacity = $result->event->getEventSettings()?->getShowAvailableOccurrenceCapacity() ?? false;
+
+ return $this->jsonResponse([
+ 'data' => $result->occurrences->map(
+ fn (EventOccurrenceDomainObject $occurrence) => new EventOccurrenceResourcePublic($occurrence, $showCapacity)
+ )->values(),
+ ]);
+ }
+}
diff --git a/backend/app/Http/Actions/Events/BasePublicEventAction.php b/backend/app/Http/Actions/Events/BasePublicEventAction.php
new file mode 100644
index 0000000000..6c24722eeb
--- /dev/null
+++ b/backend/app/Http/Actions/Events/BasePublicEventAction.php
@@ -0,0 +1,36 @@
+getStatus() === EventStatus::LIVE->name) {
+ return true;
+ }
+
+ if ($this->isUserAuthenticated() && $event->getAccountId() === $this->getAuthenticatedAccountId()) {
+ return true;
+ }
+
+ if ($this->isUserAuthenticated() && $this->getAuthenticatedUserRole() === Role::SUPERADMIN) {
+ Log::debug(__('Superadmin user is viewing non-live event with ID :eventId', [
+ 'eventId' => $event->getId(),
+ 'accountId' => $this->getAuthenticatedAccountId(),
+ ]));
+
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/backend/app/Http/Actions/Events/GetEventPublicAction.php b/backend/app/Http/Actions/Events/GetEventPublicAction.php
index f2404371fd..e5e8e4c7c9 100644
--- a/backend/app/Http/Actions/Events/GetEventPublicAction.php
+++ b/backend/app/Http/Actions/Events/GetEventPublicAction.php
@@ -2,10 +2,6 @@
namespace HiEvents\Http\Actions\Events;
-use HiEvents\DomainObjects\Enums\Role;
-use HiEvents\DomainObjects\EventDomainObject;
-use HiEvents\DomainObjects\Status\EventStatus;
-use HiEvents\Http\Actions\BaseAction;
use HiEvents\Resources\Event\EventResourcePublic;
use HiEvents\Services\Application\Handlers\Event\DTO\GetPublicEventDTO;
use HiEvents\Services\Application\Handlers\Event\GetPublicEventHandler;
@@ -14,7 +10,7 @@
use Illuminate\Http\Response;
use Psr\Log\LoggerInterface;
-class GetEventPublicAction extends BaseAction
+class GetEventPublicAction extends BasePublicEventAction
{
public function __construct(
private readonly GetPublicEventHandler $getPublicEventHandler,
@@ -41,26 +37,4 @@ public function __invoke(int $eventId, Request $request): Response|JsonResponse
return $this->resourceResponse(EventResourcePublic::class, $event);
}
-
- private function canUserViewEvent(EventDomainObject $event): bool
- {
- if ($event->getStatus() === EventStatus::LIVE->name) {
- return true;
- }
-
- if ($this->isUserAuthenticated() && $event->getAccountId() === $this->getAuthenticatedAccountId()) {
- return true;
- }
-
- if ($this->isUserAuthenticated() && $this->getAuthenticatedUserRole() === Role::SUPERADMIN) {
- $this->logger->debug(__('Superadmin user is viewing non-live event with ID :eventId', [
- 'eventId' => $event->getId(),
- 'accountId' => $this->getAuthenticatedAccountId(),
- ]));
-
- return true;
- }
-
- return false;
- }
}
diff --git a/backend/app/Resources/Event/EventResourcePublic.php b/backend/app/Resources/Event/EventResourcePublic.php
index b4c573dfc9..af10e71787 100644
--- a/backend/app/Resources/Event/EventResourcePublic.php
+++ b/backend/app/Resources/Event/EventResourcePublic.php
@@ -46,6 +46,8 @@ public function toArray(Request $request): array
'end_date' => $this->getEndDate(),
'next_occurrence_start_date' => $this->getNextOccurrenceStartDate(),
'upcoming_occurrences_sold_out' => $this->getUpcomingOccurrencesSoldOut(),
+ 'last_occurrence_date' => $this->when($isRecurring, fn () => $this->getLastOccurrenceStartDate()),
+ 'occurrences_month' => $this->when($isRecurring, fn () => $this->getOccurrencesMonth()),
'type' => $this->getType(),
'currency' => $this->getCurrency(),
'slug' => $this->getSlug(),
diff --git a/backend/app/Services/Application/Handlers/Event/DTO/PublicOccurrenceFetchResultDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/PublicOccurrenceFetchResultDTO.php
new file mode 100644
index 0000000000..a68eb3e2e2
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/Event/DTO/PublicOccurrenceFetchResultDTO.php
@@ -0,0 +1,14 @@
+findById($data->eventId);
- $hideSoldOutOccurrences = $event->getType() === EventType::RECURRING->name
- && ($event->getEventSettings()?->getHideSoldOutOccurrences() ?? false)
- && ! $this->eventHasWaitlistEnabledProducts($event);
+ $isRecurring = $event->getType() === EventType::RECURRING->name;
+ $hideSoldOutOccurrences = $this->occurrenceVisibilityService->shouldHideSoldOutOccurrences($event);
+ $occurrenceWhere = $this->occurrenceVisibilityService->buildWhereConditions(
+ eventId: $data->eventId,
+ isRecurring: $isRecurring,
+ hideSoldOutOccurrences: $hideSoldOutOccurrences,
+ );
+
+ $verifiedOccurrence = $this->resolveVerifiedOccurrence($data, $hideSoldOutOccurrences);
+
+ if ($isRecurring) {
+ $this->setRecurringEventOccurrences($event, $data->eventId, $occurrenceWhere, $hideSoldOutOccurrences, $verifiedOccurrence);
+ } else {
+ $event->setEventOccurrences(
+ $this->fetchOccurrences($occurrenceWhere, $verifiedOccurrence)->occurrences
+ );
+ }
- $occurrenceWhere = [
- EventOccurrenceDomainObjectAbstract::EVENT_ID => $data->eventId,
- [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name],
- ];
+ $promoCodeDomainObject = $this->promoCodeRepository->findFirstWhere([
+ PromoCodeDomainObjectAbstract::EVENT_ID => $data->eventId,
+ PromoCodeDomainObjectAbstract::CODE => $data->promoCode,
+ ]);
- if ($event->getType() === EventType::RECURRING->name) {
- $occurrenceWhere[] = self::isNotEnded();
+ if (! $promoCodeDomainObject?->isValid()) {
+ $promoCodeDomainObject = null;
}
- if ($hideSoldOutOccurrences) {
- $occurrenceWhere[] = self::hasRemainingCapacity();
+ if (! $data->isAuthenticated) {
+ $this->eventPageViewIncrementService->increment($data->eventId, $data->ipAddress);
}
- $occurrences = $this->occurrenceRepository
- ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
- new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
- ]))
- ->findWhere(
- where: $occurrenceWhere,
- orderAndDirections: [
- new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, 'asc'),
- ],
- limit: self::MAX_PUBLIC_OCCURRENCES + 1,
- );
+ return $event->setProductCategories($this->productFilterService->filter(
+ productsCategories: $event->getProductCategories(),
+ promoCode: $promoCodeDomainObject,
+ eventOccurrenceId: $verifiedOccurrence?->getId(),
+ ));
+ }
- $verifiedOccurrence = null;
- if ($data->eventOccurrenceId !== null) {
- $verifiedOccurrence = $occurrences->first(
- fn (EventOccurrenceDomainObject $o) => $o->getId() === $data->eventOccurrenceId
- );
- if ($verifiedOccurrence === null) {
- $fallbackWhere = [
- EventOccurrenceDomainObjectAbstract::ID => $data->eventOccurrenceId,
- EventOccurrenceDomainObjectAbstract::EVENT_ID => $data->eventId,
- [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name],
- ];
-
- if ($hideSoldOutOccurrences) {
- $fallbackWhere[] = self::hasRemainingCapacity();
- }
-
- $verifiedOccurrence = $this->occurrenceRepository
- ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
- new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
- ]))
- ->findFirstWhere($fallbackWhere);
- }
- if ($verifiedOccurrence !== null && $verifiedOccurrence->isPast()) {
- $verifiedOccurrence = null;
- }
+ private function setRecurringEventOccurrences(
+ EventDomainObject $event,
+ int $eventId,
+ array $occurrenceWhere,
+ bool $hideSoldOutOccurrences,
+ ?EventOccurrenceDomainObject $verifiedOccurrence,
+ ): void {
+ $nextBookableWhere = $hideSoldOutOccurrences
+ ? $occurrenceWhere
+ : [...$occurrenceWhere, PublicOccurrenceVisibilityService::hasRemainingCapacity()];
+
+ $nextBookable = $this->findEdgeOccurrence($nextBookableWhere, 'asc');
+ $event->setNextOccurrenceStartDate($nextBookable?->getStartDate());
+ $event->setLastOccurrenceStartDate($this->findEdgeOccurrence($occurrenceWhere, 'desc')?->getStartDate());
+
+ $anchorOccurrence = $verifiedOccurrence ?? $nextBookable;
+ if ($anchorOccurrence === null && ! $hideSoldOutOccurrences) {
+ $anchorOccurrence = $this->findEdgeOccurrence($occurrenceWhere, 'asc');
}
- $verifiedOccurrenceId = $verifiedOccurrence?->getId();
+ $timezone = $event->getTimezone() ?: 'UTC';
+ $anchorMonthStart = Carbon::parse($anchorOccurrence?->getStartDate() ?? now(), 'UTC')
+ ->setTimezone($timezone)
+ ->startOfMonth();
- if ($occurrences->count() > self::MAX_PUBLIC_OCCURRENCES) {
- $occurrences = $occurrences->take(self::MAX_PUBLIC_OCCURRENCES)->values();
- }
+ $monthWhere = [
+ ...$occurrenceWhere,
+ [EventOccurrenceDomainObjectAbstract::START_DATE, '>=', $anchorMonthStart->copy()->utc()->toDateTimeString()],
+ [EventOccurrenceDomainObjectAbstract::START_DATE, '<=', $anchorMonthStart->copy()->endOfMonth()->utc()->toDateTimeString()],
+ ];
- if ($verifiedOccurrence !== null
- && ! $occurrences->contains(fn (EventOccurrenceDomainObject $o) => $o->getId() === $verifiedOccurrenceId)) {
- $occurrences->push($verifiedOccurrence);
- }
+ $result = $this->fetchOccurrences($monthWhere, $verifiedOccurrence);
- $event->setEventOccurrences($occurrences);
+ $event->setEventOccurrences($result->occurrences);
+ $event->setOccurrencesMonth($result->truncated ? null : $anchorMonthStart->format('Y-m'));
- if ($hideSoldOutOccurrences && $occurrences->isEmpty()) {
+ if ($hideSoldOutOccurrences && $nextBookable === null) {
$event->setUpcomingOccurrencesSoldOut(
$this->occurrenceRepository->findFirstWhere([
- EventOccurrenceDomainObjectAbstract::EVENT_ID => $data->eventId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
[EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name],
- self::isNotEnded(),
+ PublicOccurrenceVisibilityService::isNotEnded(),
static function ($query): void {
$query->whereColumn(
EventOccurrenceDomainObjectAbstract::USED_CAPACITY,
@@ -153,59 +161,74 @@ static function ($query): void {
]) !== null
);
}
+ }
- $promoCodeDomainObject = $this->promoCodeRepository->findFirstWhere([
- PromoCodeDomainObjectAbstract::EVENT_ID => $data->eventId,
- PromoCodeDomainObjectAbstract::CODE => $data->promoCode,
- ]);
+ private function fetchOccurrences(array $where, ?EventOccurrenceDomainObject $verifiedOccurrence): PublicOccurrenceFetchResultDTO
+ {
+ $occurrences = $this->occurrenceRepository
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
+ ->findWhere(
+ where: $where,
+ orderAndDirections: [
+ new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, 'asc'),
+ ],
+ limit: self::MAX_PUBLIC_OCCURRENCES + 1,
+ );
- if (! $promoCodeDomainObject?->isValid()) {
- $promoCodeDomainObject = null;
+ $truncated = $occurrences->count() > self::MAX_PUBLIC_OCCURRENCES;
+ if ($truncated) {
+ $occurrences = $occurrences->take(self::MAX_PUBLIC_OCCURRENCES)->values();
}
- if (! $data->isAuthenticated) {
- $this->eventPageViewIncrementService->increment($data->eventId, $data->ipAddress);
+ if ($verifiedOccurrence !== null
+ && ! $occurrences->contains(fn (EventOccurrenceDomainObject $o) => $o->getId() === $verifiedOccurrence->getId())) {
+ $occurrences->push($verifiedOccurrence);
}
- return $event->setProductCategories($this->productFilterService->filter(
- productsCategories: $event->getProductCategories(),
- promoCode: $promoCodeDomainObject,
- eventOccurrenceId: $verifiedOccurrenceId,
- ));
+ return new PublicOccurrenceFetchResultDTO($occurrences, $truncated);
}
- private function eventHasWaitlistEnabledProducts(EventDomainObject $event): bool
+ private function findEdgeOccurrence(array $where, string $direction): ?EventOccurrenceDomainObject
{
- return $event->getProductCategories()
- ?->contains(
- fn (ProductCategoryDomainObject $category) => $category->getProducts()
- ?->contains(fn (ProductDomainObject $product) => $product->getWaitlistEnabled() === true) ?? false
- ) ?? false;
+ return $this->occurrenceRepository
+ ->findWhere(
+ where: $where,
+ orderAndDirections: [
+ new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, $direction),
+ ],
+ limit: 1,
+ )
+ ->first();
}
- private static function hasRemainingCapacity(): Closure
+ private function resolveVerifiedOccurrence(GetPublicEventDTO $data, bool $hideSoldOutOccurrences): ?EventOccurrenceDomainObject
{
- return static function ($query): void {
- $query->whereNull(EventOccurrenceDomainObjectAbstract::CAPACITY)
- ->orWhereColumn(
- EventOccurrenceDomainObjectAbstract::USED_CAPACITY,
- '<',
- EventOccurrenceDomainObjectAbstract::CAPACITY,
- );
- };
- }
+ if ($data->eventOccurrenceId === null) {
+ return null;
+ }
- private static function isNotEnded(): Closure
- {
- return static function ($query): void {
- $query->whereRaw(
- sprintf(
- 'COALESCE(%s, %s) >= ?',
- EventOccurrenceDomainObjectAbstract::END_DATE,
- EventOccurrenceDomainObjectAbstract::START_DATE,
- ),
- [now()->toDateTimeString()],
- );
- };
+ $where = [
+ EventOccurrenceDomainObjectAbstract::ID => $data->eventOccurrenceId,
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $data->eventId,
+ [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name],
+ ];
+
+ if ($hideSoldOutOccurrences) {
+ $where[] = PublicOccurrenceVisibilityService::hasRemainingCapacity();
+ }
+
+ $verifiedOccurrence = $this->occurrenceRepository
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
+ ->findFirstWhere($where);
+
+ if ($verifiedOccurrence !== null && $verifiedOccurrence->isPast()) {
+ return null;
+ }
+
+ return $verifiedOccurrence;
}
}
diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/DTO/GetPublicEventOccurrencesDTO.php b/backend/app/Services/Application/Handlers/EventOccurrence/DTO/GetPublicEventOccurrencesDTO.php
new file mode 100644
index 0000000000..a335805455
--- /dev/null
+++ b/backend/app/Services/Application/Handlers/EventOccurrence/DTO/GetPublicEventOccurrencesDTO.php
@@ -0,0 +1,14 @@
+validateRange($dto);
+
+ $event = $this->eventRepository
+ ->loadRelation(new Relationship(ProductCategoryDomainObject::class, [
+ new Relationship(ProductDomainObject::class),
+ ]))
+ ->loadRelation(new Relationship(EventSettingDomainObject::class))
+ ->findById($dto->eventId);
+
+ $where = $this->occurrenceVisibilityService->buildWhereConditions(
+ eventId: $dto->eventId,
+ isRecurring: $event->getType() === EventType::RECURRING->name,
+ hideSoldOutOccurrences: $this->occurrenceVisibilityService->shouldHideSoldOutOccurrences($event),
+ );
+
+ $where[] = [EventOccurrenceDomainObjectAbstract::START_DATE, '>=', $startDateFrom->toDateTimeString()];
+ $where[] = [EventOccurrenceDomainObjectAbstract::START_DATE, '<=', $startDateTo->toDateTimeString()];
+
+ $occurrences = $this->occurrenceRepository
+ ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, name: 'event_location', nested: [
+ new Relationship(domainObject: LocationDomainObject::class, name: 'location'),
+ ]))
+ ->findWhere(
+ where: $where,
+ orderAndDirections: [
+ new OrderAndDirection(EventOccurrenceDomainObjectAbstract::START_DATE, 'asc'),
+ ],
+ limit: GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES,
+ );
+
+ return new GetPublicEventOccurrencesResultDTO(
+ event: $event,
+ occurrences: $occurrences,
+ );
+ }
+
+ /**
+ * @return array{0: Carbon, 1: Carbon}
+ *
+ * @throws InvalidOccurrenceDatesException
+ */
+ private function validateRange(GetPublicEventOccurrencesDTO $dto): array
+ {
+ if ($dto->startDateFrom === null || $dto->startDateTo === null) {
+ throw new InvalidOccurrenceDatesException(
+ __('Both start_date_from and start_date_to are required.')
+ );
+ }
+
+ try {
+ $startDateFrom = Carbon::parse($dto->startDateFrom, 'UTC');
+ $startDateTo = Carbon::parse($dto->startDateTo, 'UTC');
+ } catch (InvalidFormatException) {
+ throw new InvalidOccurrenceDatesException(
+ __('The date range is invalid.')
+ );
+ }
+
+ if ($startDateFrom->greaterThan($startDateTo)
+ || $startDateFrom->diffInDays($startDateTo) > self::MAX_RANGE_DAYS) {
+ throw new InvalidOccurrenceDatesException(
+ __('The date range must be valid and span at most :days days.', ['days' => self::MAX_RANGE_DAYS])
+ );
+ }
+
+ return [$startDateFrom, $startDateTo];
+ }
+}
diff --git a/backend/app/Services/Domain/EventOccurrence/PublicOccurrenceVisibilityService.php b/backend/app/Services/Domain/EventOccurrence/PublicOccurrenceVisibilityService.php
new file mode 100644
index 0000000000..bdbfce0b68
--- /dev/null
+++ b/backend/app/Services/Domain/EventOccurrence/PublicOccurrenceVisibilityService.php
@@ -0,0 +1,76 @@
+getType() === EventType::RECURRING->name
+ && ($event->getEventSettings()?->getHideSoldOutOccurrences() ?? false)
+ && ! $this->eventHasWaitlistEnabledProducts($event);
+ }
+
+ public function buildWhereConditions(int $eventId, bool $isRecurring, bool $hideSoldOutOccurrences): array
+ {
+ $where = [
+ EventOccurrenceDomainObjectAbstract::EVENT_ID => $eventId,
+ [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name],
+ ];
+
+ if ($isRecurring) {
+ $where[] = self::isNotEnded();
+ }
+
+ if ($hideSoldOutOccurrences) {
+ $where[] = self::hasRemainingCapacity();
+ }
+
+ return $where;
+ }
+
+ public static function hasRemainingCapacity(): Closure
+ {
+ return static function ($query): void {
+ $query->whereNull(EventOccurrenceDomainObjectAbstract::CAPACITY)
+ ->orWhereColumn(
+ EventOccurrenceDomainObjectAbstract::USED_CAPACITY,
+ '<',
+ EventOccurrenceDomainObjectAbstract::CAPACITY,
+ );
+ };
+ }
+
+ public static function isNotEnded(): Closure
+ {
+ return static function ($query): void {
+ $query->whereRaw(
+ sprintf(
+ 'COALESCE(%s, %s) >= ?',
+ EventOccurrenceDomainObjectAbstract::END_DATE,
+ EventOccurrenceDomainObjectAbstract::START_DATE,
+ ),
+ [now()->toDateTimeString()],
+ );
+ };
+ }
+
+ private function eventHasWaitlistEnabledProducts(EventDomainObject $event): bool
+ {
+ return $event->getProductCategories()
+ ?->contains(
+ fn (ProductCategoryDomainObject $category) => $category->getProducts()
+ ?->contains(fn (ProductDomainObject $product) => $product->getWaitlistEnabled() === true) ?? false
+ ) ?? false;
+ }
+}
diff --git a/backend/routes/api.php b/backend/routes/api.php
index 4e4a8b0260..660d1a015d 100644
--- a/backend/routes/api.php
+++ b/backend/routes/api.php
@@ -93,6 +93,7 @@
use HiEvents\Http\Actions\EventOccurrences\GenerateOccurrencesAction;
use HiEvents\Http\Actions\EventOccurrences\GetEventOccurrenceAction;
use HiEvents\Http\Actions\EventOccurrences\GetEventOccurrencesAction;
+use HiEvents\Http\Actions\EventOccurrences\GetEventOccurrencesPublicAction;
use HiEvents\Http\Actions\EventOccurrences\GetPriceOverridesAction;
use HiEvents\Http\Actions\EventOccurrences\GetProductVisibilityAction;
use HiEvents\Http\Actions\EventOccurrences\ReactivateOccurrenceAction;
@@ -555,6 +556,8 @@ function (Router $router): void {
function (Router $router): void {
// Events
$router->get('/events/{event_id}', GetEventPublicAction::class);
+ $router->get('/events/{event_id}/occurrences', GetEventOccurrencesPublicAction::class)
+ ->middleware('throttle:60,1');
// Organizers
$router->get('/organizers/{organizer_id}', GetPublicOrganizerAction::class);
diff --git a/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php b/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php
index 229a1d3f10..55229570b5 100644
--- a/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php
+++ b/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php
@@ -110,79 +110,61 @@ public function test_get_end_date_returns_null_when_no_occurrences(): void
$this->assertNull($eventWithEmpty->getEndDate());
}
- public function test_is_event_in_past_returns_true_when_all_occurrences_are_past(): void
+ public function test_is_event_ongoing_returns_true_when_active_occurrence_has_started_but_not_ended(): void
{
$occurrences = collect([
$this->createOccurrence(
- Carbon::now()->subDays(3)->toDateTimeString(),
- Carbon::now()->subDays(2)->toDateTimeString(),
- ),
- $this->createOccurrence(
- Carbon::now()->subDays(2)->toDateTimeString(),
- Carbon::now()->subDay()->toDateTimeString(),
+ Carbon::now()->subHour()->toDateTimeString(),
+ Carbon::now()->addHour()->toDateTimeString(),
+ EventOccurrenceStatus::ACTIVE->name,
),
]);
$event = $this->createEvent($occurrences);
- $this->assertTrue($event->isEventInPast());
+ $this->assertTrue($event->isEventOngoing());
}
- public function test_is_event_in_past_returns_false_when_some_occurrences_are_future(): void
+ public function test_is_event_ongoing_returns_false_for_cancelled_occurrences(): void
{
$occurrences = collect([
$this->createOccurrence(
- Carbon::now()->subDays(2)->toDateTimeString(),
- Carbon::now()->subDay()->toDateTimeString(),
- ),
- $this->createOccurrence(
- Carbon::now()->addDay()->toDateTimeString(),
- Carbon::now()->addDays(2)->toDateTimeString(),
+ Carbon::now()->subHour()->toDateTimeString(),
+ Carbon::now()->addHour()->toDateTimeString(),
+ EventOccurrenceStatus::CANCELLED->name,
),
]);
$event = $this->createEvent($occurrences);
- $this->assertFalse($event->isEventInPast());
+ $this->assertFalse($event->isEventOngoing());
}
- public function test_is_event_in_future_returns_true_when_earliest_start_is_future(): void
+ public function test_is_event_ongoing_returns_false_when_started_occurrence_has_no_end_date(): void
{
$occurrences = collect([
$this->createOccurrence(
- Carbon::now()->addDay()->toDateTimeString(),
- Carbon::now()->addDays(2)->toDateTimeString(),
- ),
- $this->createOccurrence(
- Carbon::now()->addDays(3)->toDateTimeString(),
- Carbon::now()->addDays(4)->toDateTimeString(),
+ Carbon::now()->subHour()->toDateTimeString(),
+ null,
+ EventOccurrenceStatus::ACTIVE->name,
),
]);
$event = $this->createEvent($occurrences);
- $this->assertTrue($event->isEventInFuture());
+ $this->assertFalse($event->isEventOngoing());
}
- public function test_is_event_in_future_returns_false_when_earliest_start_is_past(): void
+ public function test_is_event_ongoing_returns_false_when_no_occurrences(): void
{
- $occurrences = collect([
- $this->createOccurrence(
- Carbon::now()->subDay()->toDateTimeString(),
- Carbon::now()->addDay()->toDateTimeString(),
- ),
- $this->createOccurrence(
- Carbon::now()->addDays(2)->toDateTimeString(),
- Carbon::now()->addDays(3)->toDateTimeString(),
- ),
- ]);
-
- $event = $this->createEvent($occurrences);
+ $event = $this->createEvent();
+ $this->assertFalse($event->isEventOngoing());
- $this->assertFalse($event->isEventInFuture());
+ $eventWithEmpty = $this->createEvent(collect([]));
+ $this->assertFalse($eventWithEmpty->isEventOngoing());
}
- public function test_is_event_ongoing_returns_true_when_active_occurrence_has_started_but_not_ended(): void
+ public function test_get_lifecycle_status_returns_ongoing_when_ongoing(): void
{
$occurrences = collect([
$this->createOccurrence(
@@ -194,96 +176,112 @@ public function test_is_event_ongoing_returns_true_when_active_occurrence_has_st
$event = $this->createEvent($occurrences);
- $this->assertTrue($event->isEventOngoing());
+ $this->assertEquals(EventLifecycleStatus::ONGOING->name, $event->getLifecycleStatus());
}
- public function test_is_event_ongoing_returns_false_for_cancelled_occurrences(): void
+ public function test_get_lifecycle_status_returns_upcoming_when_all_future(): void
{
$occurrences = collect([
$this->createOccurrence(
- Carbon::now()->subHour()->toDateTimeString(),
- Carbon::now()->addHour()->toDateTimeString(),
- EventOccurrenceStatus::CANCELLED->name,
+ Carbon::now()->addDay()->toDateTimeString(),
+ Carbon::now()->addDays(2)->toDateTimeString(),
),
]);
$event = $this->createEvent($occurrences);
- $this->assertFalse($event->isEventOngoing());
+ $this->assertEquals(EventLifecycleStatus::UPCOMING->name, $event->getLifecycleStatus());
}
- public function test_is_event_ongoing_returns_true_when_active_occurrence_has_no_end_date(): void
+ public function test_get_lifecycle_status_returns_ended_when_all_past(): void
{
$occurrences = collect([
$this->createOccurrence(
- Carbon::now()->subHour()->toDateTimeString(),
- null,
- EventOccurrenceStatus::ACTIVE->name,
+ Carbon::now()->subDays(3)->toDateTimeString(),
+ Carbon::now()->subDay()->toDateTimeString(),
),
]);
$event = $this->createEvent($occurrences);
- $this->assertTrue($event->isEventOngoing());
+ $this->assertEquals(EventLifecycleStatus::ENDED->name, $event->getLifecycleStatus());
}
- public function test_is_event_ongoing_returns_false_when_no_occurrences(): void
+ public function test_get_lifecycle_status_returns_upcoming_when_no_occurrences(): void
{
- $event = $this->createEvent();
- $this->assertFalse($event->isEventOngoing());
+ $event = $this->createEvent(collect());
- $eventWithEmpty = $this->createEvent(collect([]));
- $this->assertFalse($eventWithEmpty->isEventOngoing());
+ $this->assertEquals(EventLifecycleStatus::UPCOMING->name, $event->getLifecycleStatus());
}
- public function test_get_lifecycle_status_returns_ongoing_when_ongoing(): void
+ public function test_get_lifecycle_status_returns_upcoming_for_recurring_event_mid_series(): void
{
$occurrences = collect([
$this->createOccurrence(
- Carbon::now()->subHour()->toDateTimeString(),
- Carbon::now()->addHour()->toDateTimeString(),
- EventOccurrenceStatus::ACTIVE->name,
+ Carbon::now()->subDays(3)->toDateTimeString(),
+ Carbon::now()->subDays(3)->addHours(2)->toDateTimeString(),
+ ),
+ $this->createOccurrence(
+ Carbon::now()->addDays(3)->toDateTimeString(),
+ Carbon::now()->addDays(3)->addHours(2)->toDateTimeString(),
),
]);
$event = $this->createEvent($occurrences);
- $this->assertEquals(EventLifecycleStatus::ONGOING->name, $event->getLifecycleStatus());
+ $this->assertEquals(EventLifecycleStatus::UPCOMING->name, $event->getLifecycleStatus());
}
- public function test_get_lifecycle_status_returns_upcoming_when_all_future(): void
+ public function test_get_lifecycle_status_returns_ongoing_when_one_occurrence_in_series_is_live(): void
{
$occurrences = collect([
$this->createOccurrence(
- Carbon::now()->addDay()->toDateTimeString(),
- Carbon::now()->addDays(2)->toDateTimeString(),
+ Carbon::now()->subDays(3)->toDateTimeString(),
+ Carbon::now()->subDays(3)->addHours(2)->toDateTimeString(),
+ ),
+ $this->createOccurrence(
+ Carbon::now()->subHour()->toDateTimeString(),
+ Carbon::now()->addHour()->toDateTimeString(),
+ ),
+ $this->createOccurrence(
+ Carbon::now()->addDays(3)->toDateTimeString(),
+ Carbon::now()->addDays(3)->addHours(2)->toDateTimeString(),
),
]);
$event = $this->createEvent($occurrences);
- $this->assertEquals(EventLifecycleStatus::UPCOMING->name, $event->getLifecycleStatus());
+ $this->assertEquals(EventLifecycleStatus::ONGOING->name, $event->getLifecycleStatus());
}
- public function test_get_lifecycle_status_returns_ended_when_all_past(): void
+ public function test_get_lifecycle_status_returns_upcoming_when_only_remaining_occurrence_is_cancelled(): void
{
$occurrences = collect([
$this->createOccurrence(
Carbon::now()->subDays(3)->toDateTimeString(),
- Carbon::now()->subDay()->toDateTimeString(),
+ Carbon::now()->subDays(3)->addHours(2)->toDateTimeString(),
+ ),
+ $this->createOccurrence(
+ Carbon::now()->addDay()->toDateTimeString(),
+ Carbon::now()->addDay()->addHours(2)->toDateTimeString(),
+ EventOccurrenceStatus::CANCELLED->name,
),
]);
$event = $this->createEvent($occurrences);
- $this->assertEquals(EventLifecycleStatus::ENDED->name, $event->getLifecycleStatus());
+ $this->assertEquals(EventLifecycleStatus::UPCOMING->name, $event->getLifecycleStatus());
}
- public function test_get_lifecycle_status_returns_upcoming_when_no_occurrences(): void
+ public function test_get_lifecycle_status_returns_ended_when_started_occurrence_has_no_end_date(): void
{
- $event = $this->createEvent(collect());
+ $occurrences = collect([
+ $this->createOccurrence(Carbon::now()->subHour()->toDateTimeString()),
+ ]);
- $this->assertEquals(EventLifecycleStatus::UPCOMING->name, $event->getLifecycleStatus());
+ $event = $this->createEvent($occurrences);
+
+ $this->assertEquals(EventLifecycleStatus::ENDED->name, $event->getLifecycleStatus());
}
public function test_is_recurring_returns_true_for_recurring_type(): void
diff --git a/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php
index ed0c074535..9dd40136ae 100644
--- a/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php
+++ b/backend/tests/Unit/Services/Application/Handlers/Event/GetPublicEventHandlerTest.php
@@ -12,12 +12,14 @@
use HiEvents\DomainObjects\ProductDomainObject;
use HiEvents\DomainObjects\PromoCodeDomainObject;
use HiEvents\DomainObjects\Status\EventOccurrenceStatus;
+use HiEvents\Repository\Eloquent\Value\OrderAndDirection;
use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface;
use HiEvents\Repository\Interfaces\EventRepositoryInterface;
use HiEvents\Repository\Interfaces\PromoCodeRepositoryInterface;
use HiEvents\Services\Application\Handlers\Event\DTO\GetPublicEventDTO;
use HiEvents\Services\Application\Handlers\Event\GetPublicEventHandler;
use HiEvents\Services\Domain\Event\EventPageViewIncrementService;
+use HiEvents\Services\Domain\EventOccurrence\PublicOccurrenceVisibilityService;
use HiEvents\Services\Domain\Product\ProductFilterService;
use Illuminate\Support\Collection;
use Mockery as m;
@@ -52,7 +54,8 @@ protected function setUp(): void
$this->occurrenceRepository,
$this->promoCodeRepository,
$this->ticketFilterService,
- $this->eventPageViewIncrementService
+ $this->eventPageViewIncrementService,
+ new PublicOccurrenceVisibilityService,
);
}
@@ -257,6 +260,7 @@ public function test_handle_excludes_sold_out_occurrences_when_recurring_event_h
$this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
+ $this->expectEdgeOccurrenceQueries();
$this->occurrenceRepository
->shouldReceive('findWhere')
->once()
@@ -266,7 +270,7 @@ public function test_handle_excludes_sold_out_occurrences_when_recurring_event_h
)->count() === 2),
m::any(),
m::any(),
- m::any(),
+ GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1,
)
->andReturn(collect());
$this->occurrenceRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
@@ -296,6 +300,7 @@ public function test_handle_keeps_sold_out_occurrences_when_event_has_waitlist_e
$this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
+ $this->expectEdgeOccurrenceQueries();
$this->occurrenceRepository
->shouldReceive('findWhere')
->once()
@@ -305,7 +310,7 @@ public function test_handle_keeps_sold_out_occurrences_when_event_has_waitlist_e
)->count() === 1),
m::any(),
m::any(),
- m::any(),
+ GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1,
)
->andReturn(collect());
$this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
@@ -328,6 +333,7 @@ public function test_handle_keeps_sold_out_occurrences_when_hiding_disabled(): v
$this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
+ $this->expectEdgeOccurrenceQueries();
$this->occurrenceRepository
->shouldReceive('findWhere')
->once()
@@ -335,13 +341,13 @@ public function test_handle_keeps_sold_out_occurrences_when_hiding_disabled(): v
m::on(static fn (array $where): bool => collect($where)->filter(
static fn ($condition): bool => $condition instanceof Closure
)->count() === 1
- && ! collect($where)->contains(
+ && collect($where)->contains(
static fn ($condition): bool => is_array($condition)
&& ($condition[0] ?? null) === EventOccurrenceDomainObjectAbstract::START_DATE
)),
m::any(),
m::any(),
- m::any(),
+ GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1,
)
->andReturn(collect());
$this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
@@ -370,7 +376,7 @@ public function test_handle_ignores_requested_sold_out_occurrence_when_hidden():
$this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
- $this->occurrenceRepository->shouldReceive('findWhere')->once()->andReturn(collect());
+ $this->occurrenceRepository->shouldReceive('findWhere')->andReturn(collect());
$this->occurrenceRepository
->shouldReceive('findFirstWhere')
->once()
@@ -421,7 +427,7 @@ public function test_handle_flags_upcoming_occurrences_sold_out_when_all_hidden(
$this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
$this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
- $this->occurrenceRepository->shouldReceive('findWhere')->once()->andReturn(collect());
+ $this->occurrenceRepository->shouldReceive('findWhere')->andReturn(collect());
$this->occurrenceRepository
->shouldReceive('findFirstWhere')
->once()
@@ -444,6 +450,175 @@ public function test_handle_flags_upcoming_occurrences_sold_out_when_all_hidden(
$this->assertTrue($result->getUpcomingOccurrencesSoldOut());
}
+ public function test_handle_windows_recurring_occurrences_to_anchor_month_in_event_timezone(): void
+ {
+ $data = new GetPublicEventDTO(eventId: 1, isAuthenticated: true, ipAddress: '127.0.0.1', promoCode: null);
+ $event = (new EventDomainObject)
+ ->setType(EventType::RECURRING->name)
+ ->setTimezone('America/New_York')
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(false))
+ ->setProductCategories(collect());
+
+ $nextBookable = $this->makeOccurrence(1, '2026-08-01 02:00:00');
+ $lastOccurrence = $this->makeOccurrence(99, '2027-06-30 22:00:00');
+ $windowOccurrence = $this->makeOccurrence(2, '2026-08-01 03:00:00');
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
+ $this->expectEdgeOccurrenceQueries($nextBookable, $lastOccurrence);
+
+ $capturedWindowWhere = null;
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with(m::any(), m::any(), m::any(), GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1)
+ ->andReturnUsing(static function (array $where) use (&$capturedWindowWhere, $windowOccurrence) {
+ $capturedWindowWhere = $where;
+
+ return collect([$windowOccurrence]);
+ });
+
+ $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
+ $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect());
+ $this->eventPageViewIncrementService->shouldNotReceive('increment');
+
+ $result = $this->handler->handle($data);
+
+ $bounds = collect($capturedWindowWhere)
+ ->filter(static fn ($condition): bool => is_array($condition)
+ && ($condition[0] ?? null) === EventOccurrenceDomainObjectAbstract::START_DATE)
+ ->values();
+
+ $this->assertSame('>=', $bounds[0][1]);
+ $this->assertSame('2026-07-01 04:00:00', $bounds[0][2]);
+ $this->assertSame('<=', $bounds[1][1]);
+ $this->assertSame('2026-08-01 03:59:59', $bounds[1][2]);
+ $this->assertSame('2026-07', $result->getOccurrencesMonth());
+ $this->assertSame('2026-08-01 02:00:00', $result->getNextOccurrenceStartDate());
+ $this->assertSame('2027-06-30 22:00:00', $result->getLastOccurrenceStartDate());
+ }
+
+ public function test_handle_anchors_window_on_deep_linked_occurrence_month(): void
+ {
+ $linkedOccurrenceId = 42;
+ $data = new GetPublicEventDTO(
+ eventId: 1,
+ isAuthenticated: true,
+ ipAddress: '127.0.0.1',
+ promoCode: null,
+ eventOccurrenceId: $linkedOccurrenceId,
+ );
+ $event = (new EventDomainObject)
+ ->setType(EventType::RECURRING->name)
+ ->setTimezone('UTC')
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(false))
+ ->setProductCategories(collect());
+
+ $linkedOccurrence = $this->makeOccurrence($linkedOccurrenceId, '2027-03-15 10:00:00');
+ $nextBookable = $this->makeOccurrence(1, '2026-08-02 10:00:00');
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
+ $this->occurrenceRepository
+ ->shouldReceive('findFirstWhere')
+ ->once()
+ ->with(m::on(static fn (array $where): bool => ($where[EventOccurrenceDomainObjectAbstract::ID] ?? null) === $linkedOccurrenceId))
+ ->andReturn($linkedOccurrence);
+ $this->expectEdgeOccurrenceQueries($nextBookable, $linkedOccurrence);
+
+ $capturedWindowWhere = null;
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with(m::any(), m::any(), m::any(), GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1)
+ ->andReturnUsing(static function (array $where) use (&$capturedWindowWhere, $linkedOccurrence) {
+ $capturedWindowWhere = $where;
+
+ return collect([$linkedOccurrence]);
+ });
+
+ $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
+ $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect());
+ $this->eventPageViewIncrementService->shouldNotReceive('increment');
+
+ $result = $this->handler->handle($data);
+
+ $bounds = collect($capturedWindowWhere)
+ ->filter(static fn ($condition): bool => is_array($condition)
+ && ($condition[0] ?? null) === EventOccurrenceDomainObjectAbstract::START_DATE)
+ ->values();
+
+ $this->assertSame('2027-03-01 00:00:00', $bounds[0][2]);
+ $this->assertSame('2027-03-31 23:59:59', $bounds[1][2]);
+ $this->assertSame('2027-03', $result->getOccurrencesMonth());
+ $this->assertSame('2026-08-02 10:00:00', $result->getNextOccurrenceStartDate());
+ $this->assertTrue($result->getEventOccurrences()->contains(
+ fn (EventOccurrenceDomainObject $occurrence) => $occurrence->getId() === $linkedOccurrenceId
+ ));
+ }
+
+ public function test_handle_sets_null_occurrences_month_when_anchor_month_truncated(): 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());
+
+ $nextBookable = $this->makeOccurrence(1, '2026-08-01 10:00:00');
+ $windowOccurrences = collect(range(1, GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1))
+ ->map(fn (int $id) => $this->makeOccurrence($id, '2026-08-01 10:00:00'));
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->with($data->eventId)->andReturn($event);
+ $this->expectEdgeOccurrenceQueries($nextBookable, $nextBookable);
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with(m::any(), m::any(), m::any(), GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES + 1)
+ ->andReturn($windowOccurrences);
+
+ $this->promoCodeRepository->shouldReceive('findFirstWhere')->once()->andReturnNull();
+ $this->ticketFilterService->shouldReceive('filter')->once()->withAnyArgs()->andReturn(collect());
+ $this->eventPageViewIncrementService->shouldNotReceive('increment');
+
+ $result = $this->handler->handle($data);
+
+ $this->assertNull($result->getOccurrencesMonth());
+ $this->assertCount(GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES, $result->getEventOccurrences());
+ }
+
+ private function expectEdgeOccurrenceQueries(
+ ?EventOccurrenceDomainObject $nextBookable = null,
+ ?EventOccurrenceDomainObject $lastOccurrence = null,
+ ): void {
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->with(
+ m::any(),
+ m::any(),
+ m::on(static fn (array $orders): bool => ($orders[0] ?? null) instanceof OrderAndDirection
+ && $orders[0]->getDirection() === OrderAndDirection::DIRECTION_ASC),
+ 1,
+ )
+ ->andReturn(collect(array_filter([$nextBookable])));
+
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->with(
+ m::any(),
+ m::any(),
+ m::on(static fn (array $orders): bool => ($orders[0] ?? null) instanceof OrderAndDirection
+ && $orders[0]->getDirection() === OrderAndDirection::DIRECTION_DESC),
+ 1,
+ )
+ ->andReturn(collect(array_filter([$lastOccurrence])));
+ }
+
private function setupEventRepositoryMock($event, $eventId): void
{
$this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetPublicEventOccurrencesHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetPublicEventOccurrencesHandlerTest.php
new file mode 100644
index 0000000000..b0d431cb76
--- /dev/null
+++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/GetPublicEventOccurrencesHandlerTest.php
@@ -0,0 +1,172 @@
+eventRepository = m::mock(EventRepositoryInterface::class);
+ $this->occurrenceRepository = m::mock(EventOccurrenceRepositoryInterface::class);
+
+ $this->handler = new GetPublicEventOccurrencesHandler(
+ $this->eventRepository,
+ $this->occurrenceRepository,
+ new PublicOccurrenceVisibilityService,
+ );
+ }
+
+ public function test_handle_throws_when_range_is_missing(): void
+ {
+ $this->expectException(InvalidOccurrenceDatesException::class);
+
+ $this->handler->handle(new GetPublicEventOccurrencesDTO(
+ eventId: 1,
+ startDateFrom: '2026-08-01 00:00:00',
+ startDateTo: null,
+ ));
+ }
+
+ public function test_handle_throws_when_range_is_unparseable(): void
+ {
+ $this->expectException(InvalidOccurrenceDatesException::class);
+
+ $this->handler->handle(new GetPublicEventOccurrencesDTO(
+ eventId: 1,
+ startDateFrom: 'not-a-date',
+ startDateTo: '2026-08-31 23:59:59',
+ ));
+ }
+
+ public function test_handle_throws_when_range_is_inverted(): void
+ {
+ $this->expectException(InvalidOccurrenceDatesException::class);
+
+ $this->handler->handle(new GetPublicEventOccurrencesDTO(
+ eventId: 1,
+ startDateFrom: '2026-09-01 00:00:00',
+ startDateTo: '2026-08-01 00:00:00',
+ ));
+ }
+
+ public function test_handle_throws_when_range_exceeds_maximum_span(): void
+ {
+ $this->expectException(InvalidOccurrenceDatesException::class);
+
+ $this->handler->handle(new GetPublicEventOccurrencesDTO(
+ eventId: 1,
+ startDateFrom: '2026-08-01 00:00:00',
+ startDateTo: '2026-10-01 00:00:00',
+ ));
+ }
+
+ public function test_handle_returns_occurrences_within_range(): void
+ {
+ $event = (new EventDomainObject)
+ ->setId(1)
+ ->setType(EventType::RECURRING->name)
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(false))
+ ->setProductCategories(collect());
+
+ $occurrence = (new EventOccurrenceDomainObject)
+ ->setId(10)
+ ->setEventId(1)
+ ->setStartDate('2026-08-10 10:00:00')
+ ->setStatus(EventOccurrenceStatus::ACTIVE->name);
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->with(1)->andReturn($event);
+
+ $capturedWhere = null;
+ $capturedLimit = null;
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->andReturnUsing(static function (array $where, $columns = null, $orders = null, $limit = null) use (&$capturedWhere, &$capturedLimit, $occurrence) {
+ $capturedWhere = $where;
+ $capturedLimit = $limit;
+
+ return collect([$occurrence]);
+ });
+
+ $result = $this->handler->handle(new GetPublicEventOccurrencesDTO(
+ eventId: 1,
+ startDateFrom: '2026-08-01 00:00:00',
+ startDateTo: '2026-08-31 23:59:59',
+ ));
+
+ $this->assertSame($event, $result->event);
+ $this->assertTrue($result->occurrences->contains(
+ fn (EventOccurrenceDomainObject $o) => $o->getId() === 10
+ ));
+ $this->assertSame(GetPublicEventHandler::MAX_PUBLIC_OCCURRENCES, $capturedLimit);
+
+ $bounds = collect($capturedWhere)
+ ->filter(static fn ($condition): bool => is_array($condition)
+ && ($condition[0] ?? null) === EventOccurrenceDomainObjectAbstract::START_DATE)
+ ->values();
+
+ $this->assertSame(['>=', '2026-08-01 00:00:00'], [$bounds[0][1], $bounds[0][2]]);
+ $this->assertSame(['<=', '2026-08-31 23:59:59'], [$bounds[1][1], $bounds[1][2]]);
+ }
+
+ public function test_handle_applies_sold_out_filter_when_event_hides_sold_out_occurrences(): void
+ {
+ $event = (new EventDomainObject)
+ ->setId(1)
+ ->setType(EventType::RECURRING->name)
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(true))
+ ->setProductCategories(collect());
+
+ $this->eventRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->eventRepository->shouldReceive('findById')->with(1)->andReturn($event);
+
+ $this->occurrenceRepository->shouldReceive('loadRelation')->andReturnSelf();
+ $this->occurrenceRepository
+ ->shouldReceive('findWhere')
+ ->once()
+ ->with(
+ m::on(static fn (array $where): bool => collect($where)->filter(
+ static fn ($condition): bool => $condition instanceof Closure
+ )->count() === 2),
+ m::any(),
+ m::any(),
+ m::any(),
+ )
+ ->andReturn(collect());
+
+ $result = $this->handler->handle(new GetPublicEventOccurrencesDTO(
+ eventId: 1,
+ startDateFrom: '2026-08-01 00:00:00',
+ startDateTo: '2026-08-31 23:59:59',
+ ));
+
+ $this->assertCount(0, $result->occurrences);
+ }
+}
diff --git a/backend/tests/Unit/Services/Domain/EventOccurrence/PublicOccurrenceVisibilityServiceTest.php b/backend/tests/Unit/Services/Domain/EventOccurrence/PublicOccurrenceVisibilityServiceTest.php
new file mode 100644
index 0000000000..2149a995c7
--- /dev/null
+++ b/backend/tests/Unit/Services/Domain/EventOccurrence/PublicOccurrenceVisibilityServiceTest.php
@@ -0,0 +1,99 @@
+service = new PublicOccurrenceVisibilityService;
+ }
+
+ public function test_should_hide_sold_out_occurrences_for_recurring_event_with_setting_enabled(): void
+ {
+ $event = (new EventDomainObject)
+ ->setType(EventType::RECURRING->name)
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(true))
+ ->setProductCategories(collect());
+
+ $this->assertTrue($this->service->shouldHideSoldOutOccurrences($event));
+ }
+
+ public function test_should_not_hide_sold_out_occurrences_for_single_event(): void
+ {
+ $event = (new EventDomainObject)
+ ->setType(EventType::SINGLE->name)
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(true))
+ ->setProductCategories(collect());
+
+ $this->assertFalse($this->service->shouldHideSoldOutOccurrences($event));
+ }
+
+ public function test_should_not_hide_sold_out_occurrences_when_setting_disabled(): void
+ {
+ $event = (new EventDomainObject)
+ ->setType(EventType::RECURRING->name)
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(false))
+ ->setProductCategories(collect());
+
+ $this->assertFalse($this->service->shouldHideSoldOutOccurrences($event));
+ }
+
+ public function test_should_not_hide_sold_out_occurrences_when_waitlist_product_exists(): void
+ {
+ $category = new ProductCategoryDomainObject;
+ $category->setProducts(collect([
+ (new ProductDomainObject)->setWaitlistEnabled(true),
+ ]));
+
+ $event = (new EventDomainObject)
+ ->setType(EventType::RECURRING->name)
+ ->setEventSettings((new EventSettingDomainObject)->setHideSoldOutOccurrences(true))
+ ->setProductCategories(collect([$category]));
+
+ $this->assertFalse($this->service->shouldHideSoldOutOccurrences($event));
+ }
+
+ public function test_build_where_conditions_for_recurring_event_hiding_sold_out(): void
+ {
+ $where = $this->service->buildWhereConditions(
+ eventId: 5,
+ isRecurring: true,
+ hideSoldOutOccurrences: true,
+ );
+
+ $this->assertSame(5, $where[EventOccurrenceDomainObjectAbstract::EVENT_ID]);
+ $this->assertContains(
+ [EventOccurrenceDomainObjectAbstract::STATUS, '!=', EventOccurrenceStatus::CANCELLED->name],
+ $where,
+ );
+ $this->assertCount(2, array_filter($where, static fn ($condition) => $condition instanceof Closure));
+ }
+
+ public function test_build_where_conditions_for_single_event_without_hiding(): void
+ {
+ $where = $this->service->buildWhereConditions(
+ eventId: 5,
+ isRecurring: false,
+ hideSoldOutOccurrences: false,
+ );
+
+ $this->assertSame(5, $where[EventOccurrenceDomainObjectAbstract::EVENT_ID]);
+ $this->assertCount(0, array_filter($where, static fn ($condition) => $condition instanceof Closure));
+ }
+}
diff --git a/e2e/README.md b/e2e/README.md
index 77d96945e5..84688c7498 100644
--- a/e2e/README.md
+++ b/e2e/README.md
@@ -82,12 +82,29 @@ docker compose -f docker/e2e/docker-compose.e2e.yml down -v
### Against the running dev stack
-The suite is data-isolated (unique emails per run), so it can target the dev stack directly.
-Use `--skip-stack` so the script doesn't manage the e2e stack, and point it at the dev
-stack's URLs. Note it leaves test data behind in the dev database.
+The suite is data-isolated (unique emails per run), so it can target the dev stack directly —
+useful for testing uncommitted changes without rebuilding the hermetic images (the dev stack
+mounts source live). Note it leaves test data behind in the dev database.
+
+`E2E_SAAS_MODE=true` is required: the dev stack requires email verification, and the account
+fixture only confirms the code from Mailpit in SaaS mode. That also means a queue worker must
+be running to deliver the verification emails, and superadmin-dependent specs need the e2e
+superadmin provisioned once:
+
+```bash
+cd docker/development
+docker compose -f docker-compose.dev.yml exec -d backend php artisan queue:work
+docker compose -f docker-compose.dev.yml exec backend php artisan dev:bootstrap \
+ --email=superadmin@e2e.test --password='SuperAdminPass123!'
+```
+
+Then run specs directly (from `e2e/`), or the whole suite via the script (from the repo root):
```bash
-E2E_BASE_URL=https://localhost:8443 MAILPIT_URL=http://localhost:8025 \
+E2E_BASE_URL=https://localhost:8443 MAILPIT_URL=http://localhost:8025 E2E_SAAS_MODE=true \
+ npx playwright test tests/events/recurring-event-checkout.spec.ts
+
+E2E_BASE_URL=https://localhost:8443 MAILPIT_URL=http://localhost:8025 E2E_SAAS_MODE=true \
./e2e/run-e2e.sh --skip-stack --skip-deps
```
diff --git a/e2e/pages/occurrence.page.ts b/e2e/pages/occurrence.page.ts
index 36bdb6d39e..35c868323d 100644
--- a/e2e/pages/occurrence.page.ts
+++ b/e2e/pages/occurrence.page.ts
@@ -98,14 +98,49 @@ export class PublicOccurrenceSelector {
return this.page.locator('.hi-slot-header-day');
}
+ monthHeader(): Locator {
+ return this.page.locator('.hi-dp-level');
+ }
+
productsLoadingOverlay(): Locator {
return this.page.locator('.hi-occurrence-loading-overlay');
}
+ monthLoadingOverlay(): Locator {
+ return this.page.locator('.hi-calendar-month-loading');
+ }
+
+ async waitForMonthLoaded(): Promise {
+ await this.monthLoadingOverlay().waitFor({ state: 'visible', timeout: 300 }).catch(() => {});
+ await this.monthLoadingOverlay().waitFor({ state: 'detached' });
+ }
+
+ async navigateToMonthOf(isoDate: string): Promise {
+ const target = new Date(isoDate);
+ const targetIndex = target.getUTCFullYear() * 12 + target.getUTCMonth();
+ await this.calendar().waitFor();
+ await this.waitForMonthLoaded();
+ for (let attempt = 0; attempt < 24; attempt++) {
+ const header = (await this.monthHeader().innerText()).trim();
+ const displayed = new Date(header.replace(' ', ' 1, '));
+ const displayedIndex = displayed.getFullYear() * 12 + displayed.getMonth();
+ if (displayedIndex === targetIndex) return;
+ if (displayedIndex < targetIndex) {
+ await this.nextMonthButton().click();
+ } else {
+ await this.previousMonthButton().click();
+ }
+ await this.waitForMonthLoaded();
+ }
+ throw new Error(`Could not navigate the occurrence calendar to the month of ${isoDate}`);
+ }
+
async selectDay(label: RegExp): Promise {
await this.calendar().waitFor();
+ await this.waitForMonthLoaded();
for (let attempt = 0; attempt < 2 && (await this.dayButton(label).count()) === 0; attempt++) {
await this.nextMonthButton().click();
+ await this.waitForMonthLoaded();
}
await this.dayButton(label).click();
}
diff --git a/e2e/tests/checkout/kitchen-sink-recurring.spec.ts b/e2e/tests/checkout/kitchen-sink-recurring.spec.ts
index 7e3a32da54..438d1165a4 100644
--- a/e2e/tests/checkout/kitchen-sink-recurring.spec.ts
+++ b/e2e/tests/checkout/kitchen-sink-recurring.spec.ts
@@ -46,16 +46,6 @@ const paneHeaderDay = (isoDate: string): string => {
return `${weekday}, ${month} ${day}`;
};
-async function revealDay(selector: PublicOccurrenceSelector, label: RegExp): Promise {
- await selector.calendar().waitFor();
- for (let attempt = 0; attempt < 2 && (await selector.dayButton(label).count()) === 0; attempt++) {
- await selector.nextMonthButton().click();
- }
- for (let attempt = 0; attempt < 4 && (await selector.dayButton(label).count()) === 0; attempt++) {
- await selector.previousMonthButton().click();
- }
-}
-
async function arrangeRecurringKitchenSink(
api: ApiClient,
organizerId: number,
@@ -104,12 +94,11 @@ function buildCheckoutOptions(
await expect(page.getByRole('heading', { name: 'Select a Date & Time' })).toBeVisible();
for (const occurrence of occurrences) {
- const label = dayButtonLabel(occurrence.start_date);
- await revealDay(selector, label);
- await expect(selector.dayButton(label)).toBeVisible();
+ await selector.navigateToMonthOf(occurrence.start_date);
+ await expect(selector.dayButton(dayButtonLabel(occurrence.start_date))).toBeVisible();
}
- await revealDay(selector, dayButtonLabel(first.start_date));
+ await selector.navigateToMonthOf(first.start_date);
await expect(selector.slotHeaderDay()).toHaveText(paneHeaderDay(first.start_date));
await expect(paneTime).toContainText(/7:00\s?PM/i);
await expect(paneLocation).toHaveCount(0);
@@ -117,7 +106,7 @@ function buildCheckoutOptions(
await expect(standardRow.getByText(BASE_STANDARD_INCLUSIVE)).toBeVisible();
const secondLabel = dayButtonLabel(second.start_date);
- await revealDay(selector, secondLabel);
+ await selector.navigateToMonthOf(second.start_date);
await selector.dayButton(secondLabel).click();
await expect(selector.slotHeaderDay()).toHaveText(paneHeaderDay(second.start_date));
await expect(paneTime).toContainText(/7:00\s?PM/i);
diff --git a/e2e/tests/events/recurring-event-checkout.spec.ts b/e2e/tests/events/recurring-event-checkout.spec.ts
index f2933a6534..1b3b6f7852 100644
--- a/e2e/tests/events/recurring-event-checkout.spec.ts
+++ b/e2e/tests/events/recurring-event-checkout.spec.ts
@@ -10,9 +10,16 @@ const utcParts = (isoDate: string) => {
weekday: date.toLocaleString('en-US', { weekday: 'long', timeZone: 'UTC' }),
month: date.toLocaleString('en-US', { month: 'long', timeZone: 'UTC' }),
day: date.getUTCDate(),
+ year: date.getUTCFullYear(),
};
};
+const utcMonthsBetween = (fromIsoDate: string, toIsoDate: string) => {
+ const from = new Date(fromIsoDate);
+ const to = new Date(toIsoDate);
+ return (to.getUTCFullYear() - from.getUTCFullYear()) * 12 + (to.getUTCMonth() - from.getUTCMonth());
+};
+
test.describe('recurring event checkout', () => {
test('a buyer picks a specific occurrence and completes a free order', async ({ page, api, account }) => {
const event = await createRecurringLiveEvent(api, account.organizerId, { count: 3 });
@@ -36,4 +43,48 @@ test.describe('recurring event checkout', () => {
await expect(page.getByText(`You're going to ${event.title}`)).toBeVisible();
await expect(page.getByText(new RegExp(`${month} ${day}\\b`)).first()).toBeVisible();
});
+
+ test('a buyer navigates months beyond the embedded window and completes an order', async ({ page, api, account }) => {
+ const event = await createRecurringLiveEvent(api, account.organizerId, { count: 20 });
+ const sorted = [...event.occurrences].sort((a, b) => a.start_date.localeCompare(b.start_date));
+ const target = sorted[sorted.length - 2];
+ const monthsAhead = utcMonthsBetween(sorted[0].start_date, target.start_date);
+ expect(monthsAhead).toBeGreaterThanOrEqual(3);
+ const { weekday, month, day } = utcParts(target.start_date);
+ const buyer = { firstName: 'FarMonth', lastName: 'Buyer', email: uniqueEmail('buyer') };
+
+ const checkout = new CheckoutPage(page);
+ const selector = new PublicOccurrenceSelector(page);
+ await checkout.gotoPublicEvent(event.eventId, event.slug);
+ await selector.calendar().waitFor();
+ for (let i = 0; i < monthsAhead; i++) {
+ await selector.nextMonthButton().click();
+ }
+ await selector.dayButton(new RegExp(`^${weekday}, ${month} ${day},`)).click();
+
+ await expect(selector.slotHeaderDay()).toHaveText(`${weekday}, ${month} ${day}`);
+ await expect(selector.productsLoadingOverlay()).toHaveCount(0);
+
+ await checkout.setFirstProductQuantity(1);
+ await checkout.continueToCheckout();
+ await checkout.fillOrderDetails(buyer);
+ await checkout.completeFreeOrder();
+
+ await expect(page.getByText(`You're going to ${event.title}`)).toBeVisible();
+ await expect(page.getByText(new RegExp(`${month} ${day}\\b`)).first()).toBeVisible();
+ });
+
+ test('a deep link to a far-out occurrence anchors the calendar on its month', async ({ page, api, account }) => {
+ const event = await createRecurringLiveEvent(api, account.organizerId, { count: 20 });
+ const sorted = [...event.occurrences].sort((a, b) => a.start_date.localeCompare(b.start_date));
+ const target = sorted[sorted.length - 1];
+ expect(utcMonthsBetween(sorted[0].start_date, target.start_date)).toBeGreaterThanOrEqual(3);
+ const { weekday, month, day, year } = utcParts(target.start_date);
+
+ const selector = new PublicOccurrenceSelector(page);
+ await page.goto(`/event/${event.eventId}/${event.slug}?occurrence_id=${target.id}`);
+
+ await expect(selector.monthHeader()).toHaveText(`${month} ${year}`);
+ await expect(selector.slotHeaderDay()).toHaveText(`${weekday}, ${month} ${day}`);
+ });
});
diff --git a/frontend/public/widget-test.html b/frontend/public/widget-test.html
index 7c2ae9b718..427e081666 100644
--- a/frontend/public/widget-test.html
+++ b/frontend/public/widget-test.html
@@ -19,7 +19,7 @@
-
{
- const response = await publicApi.get
>(
- `events/${eventId}/occurrences` + queryParamsHelper.buildQueryString(pagination)
+ all: async (eventId: IdParam, startDateFrom: string, startDateTo: string) => {
+ const params = new URLSearchParams({
+ start_date_from: startDateFrom,
+ start_date_to: startDateTo,
+ });
+ const response = await publicApi.get>(
+ `events/${eventId}/occurrences?${params.toString()}`
);
return response.data;
},
diff --git a/frontend/src/components/common/EventDateRange/index.tsx b/frontend/src/components/common/EventDateRange/index.tsx
index 8b7e2550e3..aa2d799ae2 100644
--- a/frontend/src/components/common/EventDateRange/index.tsx
+++ b/frontend/src/components/common/EventDateRange/index.tsx
@@ -46,17 +46,19 @@ export const EventDateRange = ({event, occurrence}: EventDateRangeProps) => {
.filter(o => o.status !== EventOccurrenceStatus.CANCELLED && !o.is_past)
.sort((a, b) => a.start_date.localeCompare(b.start_date));
- if (upcomingOccurrences.length > 0) {
- const next = upcomingOccurrences[0];
- if (upcomingOccurrences.length === 1) {
+ const nextStartDate = event.next_occurrence_start_date || upcomingOccurrences[0]?.start_date;
+
+ if (nextStartDate) {
+ const isSingleRemaining = upcomingOccurrences.length === 1
+ && (!event.last_occurrence_date || event.last_occurrence_date === nextStartDate);
+
+ if (isSingleRemaining) {
+ const next = upcomingOccurrences[0];
return formatRange(next.start_date, next.end_date, event.timezone);
}
- const nextFormatted = formatDateWithLocale(next.start_date, "shortDateTime", event.timezone);
- return (
-
- {t`Next: ${nextFormatted}`} · {t`${upcomingOccurrences.length} upcoming dates`}
-
- );
+
+ const nextFormatted = formatDateWithLocale(nextStartDate, "shortDateTime", event.timezone);
+ return {t`Next: ${nextFormatted}`};
}
if (event.upcoming_occurrences_sold_out) {
diff --git a/frontend/src/components/common/EventDocumentHead/index.tsx b/frontend/src/components/common/EventDocumentHead/index.tsx
index db85d16516..75694c773b 100644
--- a/frontend/src/components/common/EventDocumentHead/index.tsx
+++ b/frontend/src/components/common/EventDocumentHead/index.tsx
@@ -19,8 +19,10 @@ export const EventDocumentHead = ({event}: EventDocumentHeadProps) => {
const keywords = eventSettings?.seo_keywords;
const image = eventCoverImageUrl(event);
const url = eventHomepageUrl(event);
- const startDate = utcToTz(new Date(event.start_date), event.timezone);
- const endDate = event.end_date ? utcToTz(new Date(event.end_date), event.timezone) : undefined;
+ const seriesStartDate = event.next_occurrence_start_date || event.start_date;
+ const seriesEndDate = event.last_occurrence_date || event.end_date;
+ const startDate = utcToTz(new Date(seriesStartDate), event.timezone);
+ const endDate = seriesEndDate ? utcToTz(new Date(seriesEndDate), event.timezone) : undefined;
const locationSummary = summariseEventLocations(event);
const effective = locationSummary.kind === 'single' ? locationSummary.eventLocation : null;
diff --git a/frontend/src/components/forms/ProductForm/index.tsx b/frontend/src/components/forms/ProductForm/index.tsx
index 5ed39fdf65..cd0c74956a 100644
--- a/frontend/src/components/forms/ProductForm/index.tsx
+++ b/frontend/src/components/forms/ProductForm/index.tsx
@@ -63,7 +63,22 @@ interface ProductFormProps {
event?: Event,
}
+const hasQuantityValue = (value: unknown): boolean =>
+ value !== undefined && value !== null && value !== '';
+
+const SeriesQuantityWarning = ({eventId}: { eventId?: string | number }) => (
+
+
+ This limits total sales across every date in your schedule combined — it is not a
+ per-date limit. To limit attendance for each date, set a capacity on the Occurrence Schedule page.
+
+
+);
+
const ProductPriceTierForm = ({form, product, event}: ProductFormProps) => {
+ const isRecurringTicket = event?.type === EventType.RECURRING && form.values.product_type === 'TICKET';
+
return form?.values?.prices?.map((price, index) => {
const existingPrice = product?.prices?.find((p) => Number(p.id) === Number(price.id));
const deleteDisabled = form?.values?.prices?.length === 1 || (existingPrice && Number(existingPrice?.quantity_sold) > 0);
@@ -98,8 +113,11 @@ const ProductPriceTierForm = ({form, product, event}: ProductFormProps) => {
+ {!product && isRecurringTicket && hasQuantityValue(price.initial_quantity_available) && (
+
+ )}
{
const {data: event} = useGetEvent(eventId);
const {data: taxesAndFees} = useGetTaxesAndFees();
const isRecurring = event?.type === EventType.RECURRING;
+ const isRecurringTicket = isRecurring && form.values.product_type === 'TICKET';
const handleTaxOrFeeCreated = (taxOrFee: TaxAndFee) => {
const currentIds = form.values.tax_and_fee_ids || [];
@@ -331,12 +350,13 @@ export const ProductForm = ({form, product}: ProductFormProps) => {
placeholder={t`Unlimited`}
{...form.getInputProps('prices.0.initial_quantity_available')}
label={
- This is the default quantity across all dates. Each date's capacity
- can further limit availability on the Occurrence Schedule
page.
@@ -356,6 +376,9 @@ export const ProductForm = ({form, product}: ProductFormProps) => {
/>}
/>
+ {!product && isRecurringTicket && hasQuantityValue(form.values.prices?.[0]?.initial_quantity_available) && (
+
+ )}
>
)}
@@ -363,9 +386,10 @@ export const ProductForm = ({form, product}: ProductFormProps) => {