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) => {
{isRecurring && ( - These are the default prices and quantities across all dates. Sale dates on tiers - apply globally. You can override prices and quantities for individual dates on - the Occurrence Schedule + These prices apply across all dates in your schedule, and tier quantities limit + total sales across all dates combined. Sale dates on tiers apply globally. You can + override prices for individual dates on the Occurrence Schedule page. )} diff --git a/frontend/src/components/layouts/AppLayout/AppLayout.module.scss b/frontend/src/components/layouts/AppLayout/AppLayout.module.scss index a073e0197a..1161f7668e 100644 --- a/frontend/src/components/layouts/AppLayout/AppLayout.module.scss +++ b/frontend/src/components/layouts/AppLayout/AppLayout.module.scss @@ -30,7 +30,7 @@ padding: var(--hi-spacing-lg); padding-bottom: calc(var(--hi-spacing-lg) + 40px); overflow: auto; - background-color: #f9fafc; + background-color: var(--hi-color-gray); @include mixins.scrollbar; @@ -42,7 +42,7 @@ .sidebarOpen { width: 28px; height: 28px; - background: var(--hi-primary); + background: var(--hi-ink); color: white; border-radius: 50%; display: flex; diff --git a/frontend/src/components/layouts/AppLayout/Sidebar/Sidebar.module.scss b/frontend/src/components/layouts/AppLayout/Sidebar/Sidebar.module.scss index 4d947a208c..d8b2b2fb4f 100644 --- a/frontend/src/components/layouts/AppLayout/Sidebar/Sidebar.module.scss +++ b/frontend/src/components/layouts/AppLayout/Sidebar/Sidebar.module.scss @@ -4,10 +4,9 @@ display: flex; flex-direction: column; grid-area: sidebar; - background: linear-gradient(180deg, var(--mantine-color-primary-9), var(--mantine-color-secondary-9)); + background: linear-gradient(180deg, var(--hi-ink) 0%, var(--hi-ink-deep) 100%); color: var(--hi-color-white); position: relative; - box-shadow: 4px 0 16px rgba(0, 0, 0, 0.1); height: 100vh; &.closed { @@ -18,18 +17,23 @@ .logo { display: flex; - justify-content: center; align-items: center; + justify-content: center; position: relative; z-index: 1; - padding: 8px 0; - margin-bottom: 8px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); + padding: 16px 18px 12px; + margin-bottom: 4px; flex-shrink: 0; a { display: flex; - width: 100%; + justify-content: center; + } + + img { + max-height: 36px; + max-width: 160px; + width: auto; } } @@ -65,12 +69,13 @@ } .sectionHeading { - padding: 16px 20px 8px; - font-size: 0.75rem; + padding: 18px 20px 6px; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.625rem; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(255, 255, 255, 0.6); + letter-spacing: 0.16em; + color: color-mix(in srgb, white 45%, transparent); } a, @@ -78,24 +83,23 @@ .link { display: flex; align-items: center; - color: rgba(255, 255, 255, 0.85); - padding: 4px 10px; + color: color-mix(in srgb, white 78%, transparent); + padding: 7px 10px; text-decoration: none; - margin: 3px 10px; - border-radius: var(--hi-radius-md); - font-size: 0.9em; + margin: 2px 12px; + border-radius: var(--hi-radius-lg); + font-size: 0.875rem; font-weight: 500; - transition: all 0.2s ease; + transition: background-color 0.15s ease, color 0.15s ease; position: relative; - overflow: hidden; cursor: pointer; - width: calc(100% - 20px); + width: calc(100% - 24px); background: none; border: none; text-align: left; .navBadge { - background: var(--hi-primary); + background: var(--mantine-color-secondary-5); color: white; padding: 2px 6px; border-radius: 12px; @@ -110,19 +114,6 @@ color: white; } - &::before { - content: ''; - position: absolute; - left: 0; - top: 0; - height: 100%; - width: 0; - background: linear-gradient(90deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0) 100%); - border-radius: 10px; - transition: width 0.25s ease; - z-index: -1; - } - &.loading { background-color: rgba(255, 255, 255, 0.08); animation: pulse 1.5s infinite; @@ -138,31 +129,41 @@ } &.linkActive { - background: rgba(255, 255, 255, 0.12); + background: color-mix(in srgb, white 11%, transparent); color: white; font-weight: 600; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + + &::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 18px; + border-radius: 2px; + background: var(--mantine-color-secondary-5); + } .linkIcon { - color: rgba(255, 255, 255, 1); + color: white; } } &:hover { - background: rgba(255, 255, 255, 0.08); + background: color-mix(in srgb, white 7%, transparent); color: white; - transform: translateY(-1px); - - &::before { - width: 100%; - } } svg { margin-right: 12px; min-width: 20px; - color: rgba(255, 255, 255, 0.7); - transition: color 0.2s ease; + color: color-mix(in srgb, white 55%, transparent); + transition: color 0.15s ease; + } + + &:hover svg { + color: color-mix(in srgb, white 85%, transparent); } } } @@ -193,7 +194,7 @@ .sidebarClose { width: 24px; height: 24px; - color: rgba(255, 255, 255, 0.6); + color: color-mix(in srgb, white 55%, transparent); display: flex; align-items: center; justify-content: center; @@ -201,12 +202,12 @@ right: -2px; top: 50%; transform: translateY(-50%); - transition: all 0.2s ease; + transition: color 0.15s ease, transform 0.15s ease; z-index: 5; flex-shrink: 0; &:hover { - transform: translateY(-50%) scale(1.4); + transform: translateY(-50%) scale(1.2); color: white; } @@ -214,7 +215,7 @@ right: 16px; top: 28px; transform: none; - + &:hover { transform: scale(1.1); } diff --git a/frontend/src/components/layouts/AppLayout/Sidebar/index.tsx b/frontend/src/components/layouts/AppLayout/Sidebar/index.tsx index 0bb5fb72df..527eb69aa1 100644 --- a/frontend/src/components/layouts/AppLayout/Sidebar/index.tsx +++ b/frontend/src/components/layouts/AppLayout/Sidebar/index.tsx @@ -83,7 +83,6 @@ export const Sidebar: React.FC = ({
{t`${getConfig("VITE_APP_NAME", diff --git a/frontend/src/components/layouts/AppLayout/Topbar/Topbar.module.scss b/frontend/src/components/layouts/AppLayout/Topbar/Topbar.module.scss index b71792462e..fb722a5a00 100644 --- a/frontend/src/components/layouts/AppLayout/Topbar/Topbar.module.scss +++ b/frontend/src/components/layouts/AppLayout/Topbar/Topbar.module.scss @@ -2,7 +2,7 @@ .topBar { grid-area: topbar; - background: linear-gradient(90deg, var(--mantine-color-primary-9), var(--mantine-color-secondary-9)); + background: var(--hi-ink); display: flex; flex-direction: column; transition: box-shadow 0.3s ease; @@ -23,15 +23,13 @@ .breadcrumbsRow { padding: 10px 20px; - border-top: 1px solid rgba(0, 0, 0, 0.05); - background-color: #fff; - backdrop-filter: blur(8px); + background-color: var(--hi-color-white); display: flex; align-items: center; justify-content: space-between; gap: 12px; - border-top-left-radius: var(--hi-radius-md); - box-shadow: 3px 0px 8px rgb(0 0 0 / 8%); + border-top-left-radius: var(--hi-radius-lg); + border-bottom: 1px solid var(--hi-color-border); position: relative; z-index: 5; min-height: 44px; @@ -46,7 +44,6 @@ flex-shrink: 0; @include mixins.respond-below(sm) { - // Hide text in buttons, show only icons on very small screens :global(.mantine-Button-label) { display: none; } @@ -61,8 +58,8 @@ font-weight: 500; display: flex; align-items: center; - font-size: 0.85rem; - color: var(--hi-text-light); + font-size: 0.8125rem; + color: var(--hi-color-text-muted); flex: 1; min-width: 0; overflow-x: auto; @@ -74,7 +71,6 @@ display: none; } - // Mantine Breadcrumbs container :global(.mantine-Breadcrumbs-root) { flex-wrap: nowrap; min-width: 0; @@ -92,7 +88,6 @@ max-width: 80px; } - // Don't truncate the last breadcrumb (often an action like "Create Event") &:last-child { max-width: none; flex-shrink: 0; @@ -100,7 +95,7 @@ } a { - color: var(--hi-text-light); + color: var(--hi-color-text-muted); transition: color 0.2s ease; position: relative; @include mixins.ellipsis(); @@ -137,20 +132,8 @@ margin-left: auto; } - .statusToggleContainer { - display: flex; - align-items: center; - position: relative; - margin-left: 16px; - margin-right: auto; - - @include mixins.respond-below(md) { - margin-left: 16px; - } - } - &.withShadow { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); } .burger { @@ -190,49 +173,4 @@ align-items: center; color: var(--hi-color-white); } - - @include mixins.respond-below(md) { - .topBarMain { - background: linear-gradient(90deg, var(--mantine-color-primary-9), var(--mantine-color-secondary-9)); - } - } - - .entityPageButton { - margin-right: 8px; - display: flex; - align-items: center; - font-weight: 500; - transition: transform 0.2s ease; - color: #fff; - - &:hover { - transform: translateY(-1px); - } - - .entityPageButtonText { - // Default: hide both - .mobile, - .desktop { - display: none; - } - - // Show mobile text from sm and up - @include mixins.respond-above(sm) { - .mobile { - display: block; - } - } - - // Switch to desktop text at md and up - @include mixins.respond-above(md) { - .mobile { - display: none; - } - - .desktop { - display: block; - } - } - } - } } diff --git a/frontend/src/components/layouts/AuthLayout/Auth.module.scss b/frontend/src/components/layouts/AuthLayout/Auth.module.scss index 399d9e8581..d4d5df0577 100644 --- a/frontend/src/components/layouts/AuthLayout/Auth.module.scss +++ b/frontend/src/components/layouts/AuthLayout/Auth.module.scss @@ -1,6 +1,5 @@ @use "../../../styles/mixins.scss"; -// Premium Auth Layout - Sophisticated design for events industry .authLayout { min-height: 100vh; display: flex; @@ -14,68 +13,81 @@ min-height: 100vh; } -// Left Panel - Form area with subtle background .leftPanel { flex: 1; display: flex; flex-direction: column; position: relative; - background: linear-gradient(135deg, #fafafa 0%, var(--hi-color-gray) 50%, #faf8fc 100%); + isolation: isolate; + background: + radial-gradient(ellipse 70% 45% at 10% -8%, color-mix(in srgb, var(--mantine-color-primary-2) 40%, transparent), transparent 60%), + radial-gradient(ellipse 55% 35% at 108% 108%, color-mix(in srgb, var(--mantine-color-secondary-2) 22%, transparent), transparent 65%), + var(--hi-color-white); z-index: 2; + &::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + z-index: 0; + opacity: 0.05; + background-image: url("data:image/svg+xml;utf8,"); + background-size: 160px 160px; + } + @include mixins.respond-below(md) { width: 100%; } } .container { + position: relative; + z-index: 1; display: flex; flex-direction: column; - align-items: center; - justify-content: center; flex: 1; - padding: 2rem; min-height: 100vh; + min-height: 100dvh; + padding: 2rem 2.5rem 1.75rem; @include mixins.respond-below(md) { - min-height: auto; - justify-content: flex-start; - padding: 3rem 1.5rem 2rem; + padding: 1.75rem 1.5rem 1.5rem; } @include mixins.respond-below(sm) { - padding: 2rem 1rem 1.5rem; + padding: 1.5rem 1rem 1.25rem; } } .logo { - margin-bottom: 1.5rem; - - @include mixins.respond-below(md) { - margin-bottom: 1.25rem; - } - - @include mixins.respond-below(sm) { - margin-bottom: 1rem; - } + align-self: flex-start; img { - height: 110px; + height: 40px; width: auto; - @include mixins.respond-below(md) { - height: 100px; - } - @include mixins.respond-below(sm) { - height: 90px; + height: 34px; } } } +.formArea { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 2.5rem 0; + + @include mixins.respond-below(sm) { + padding: 1.75rem 0; + } +} + .wrapper { width: 100%; - max-width: 480px; + max-width: 420px; @include mixins.respond-below(sm) { max-width: 100%; @@ -96,7 +108,6 @@ footer { font-size: 0.875rem; margin-top: 2rem; - text-align: center; color: var(--hi-color-gray-dark); line-height: 1.6; @@ -104,16 +115,24 @@ font-weight: 500; } } +} + +.panelFooter { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; - .languageSwitcher { - max-width: 100px; - margin: 1.5rem auto 0; + > div { + margin: 0; } } -// ========================================================= -// RIGHT PANEL — product showcase, matches app's light lavender vibe -// ========================================================= +.languageSwitcher { + width: 110px; +} + .rightPanel { width: 55%; max-width: 760px; @@ -125,9 +144,9 @@ overflow: hidden; isolation: isolate; background: - radial-gradient(ellipse 80% 60% at 80% 10%, color-mix(in srgb, var(--mantine-color-primary-4) 55%, transparent), transparent 70%), - radial-gradient(ellipse 70% 50% at 15% 90%, color-mix(in srgb, var(--mantine-color-secondary-4) 45%, transparent), transparent 70%), - linear-gradient(180deg, color-mix(in srgb, var(--mantine-color-primary-2) 70%, white) 0%, var(--mantine-color-primary-3) 100%); + radial-gradient(ellipse 85% 65% at 90% -12%, color-mix(in srgb, var(--mantine-color-secondary-5) 24%, transparent), transparent 60%), + radial-gradient(ellipse 75% 55% at -10% 110%, color-mix(in srgb, var(--mantine-color-primary-5) 20%, transparent), transparent 62%), + linear-gradient(180deg, color-mix(in srgb, var(--mantine-color-primary-9) 90%, black) 0%, color-mix(in srgb, var(--mantine-color-primary-9) 70%, black) 100%); @include mixins.respond-below(lg) { width: 48%; @@ -138,73 +157,77 @@ } } -// Film-grain noise — adds organic texture to the gradient .noise { position: absolute; inset: 0; pointer-events: none; z-index: 1; - opacity: 0.22; - mix-blend-mode: multiply; + opacity: 0.3; + mix-blend-mode: soft-light; background-image: url("data:image/svg+xml;utf8,"); background-size: 160px 160px; } -// Subtle dot grid for texture -.dotGrid { +.rings { position: absolute; inset: 0; - background-image: radial-gradient(circle, color-mix(in srgb, var(--mantine-color-primary-9) 18%, transparent) 1px, transparent 1px); - background-size: 24px 24px; - opacity: 0.35; - mask-image: radial-gradient(ellipse 80% 70% at 50% 50%, black 30%, transparent 75%); - -webkit-mask-image: radial-gradient(ellipse 80% 70% at 50% 50%, black 30%, transparent 75%); pointer-events: none; z-index: 0; + + &::before, + &::after { + content: ""; + position: absolute; + left: 50%; + top: 58%; + transform: translate(-50%, -50%); + border-radius: 50%; + } + + &::before { + width: 620px; + height: 620px; + border: 1px solid color-mix(in srgb, white 8%, transparent); + } + + &::after { + width: 940px; + height: 940px; + border: 1px solid color-mix(in srgb, white 4%, transparent); + } } -// Inner flex column — CENTERED like the form .panelInner { position: relative; z-index: 2; height: 100%; display: flex; flex-direction: column; - align-items: center; justify-content: center; - padding: 3rem 3rem 5rem; - gap: 2.75rem; + padding: 3rem 4rem 6.5rem; + gap: 3.25rem; @include mixins.respond-below(lg) { - padding: 2rem 2rem 4.5rem; - gap: 3rem; + padding: 2.5rem 2.5rem 5.5rem; + gap: 2.5rem; } } -// ------- HEADING BLOCK ------- -.headingBlock { - text-align: center; - max-width: 520px; +.heroTitle { + margin: 0; + align-self: flex-start; + font-size: clamp(2.25rem, 2.6vw + 1.1rem, 3.25rem); + line-height: 1.04; + letter-spacing: -0.035em; + color: var(--hi-color-white); animation: rise 0.9s cubic-bezier(0.2, 0.8, 0.2, 1) both; } @keyframes rise { - from { opacity: 0; transform: translateY(12px); } + from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } } -.heroTitle { - margin: 0; - font-size: 2.75rem; - line-height: 1.02; - letter-spacing: -0.03em; - color: var(--mantine-color-primary-9); - - @include mixins.respond-below(lg) { - font-size: 2.125rem; - } -} - .heroBold { font-weight: 800; display: block; @@ -213,385 +236,179 @@ .heroLight { font-weight: 300; font-style: italic; - color: color-mix(in srgb, var(--mantine-color-primary-9) 70%, white); display: block; + color: color-mix(in srgb, var(--mantine-color-primary-2) 85%, transparent); } -// ------- DASHBOARD STAGE — the centerpiece ------- -.dashStage { +.ticketScene { position: relative; - width: 100%; - max-width: 460px; - aspect-ratio: 1 / 0.82; - animation: rise 1.1s cubic-bezier(0.2, 0.8, 0.2, 1) 0.1s both; - - @include mixins.respond-below(lg) { - max-width: 380px; - } + align-self: center; + width: min(100%, 480px); + animation: rise 1.1s cubic-bezier(0.2, 0.8, 0.2, 1) 0.15s both; } -// Main event dashboard card -.dashCard { +.ticketGhost { position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%) rotate(-1.5deg); - width: 100%; - background: white; + inset: 0; border-radius: 18px; - padding: 1.25rem 1.375rem 1.125rem; - box-shadow: - 0 1px 0 rgba(255, 255, 255, 0.9) inset, - 0 24px 48px -12px color-mix(in srgb, var(--mantine-color-primary-9) 25%, transparent), - 0 2px 8px -2px color-mix(in srgb, var(--mantine-color-primary-9) 15%, transparent); - border: 1px solid color-mix(in srgb, var(--mantine-color-primary-3) 30%, white); - z-index: 2; - animation: floatMain 8s ease-in-out infinite; -} - -@keyframes floatMain { - 0%, 100% { transform: translate(-50%, -50%) rotate(-1.5deg); } - 50% { transform: translate(-50%, calc(-50% - 4px)) rotate(-1.5deg); } -} - -.dashHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - margin-bottom: 0.875rem; -} - -.dashHeaderLeft { - display: flex; - align-items: center; - gap: 0.625rem; - min-width: 0; + background: color-mix(in srgb, white 6%, transparent); + border: 1px solid color-mix(in srgb, white 13%, transparent); + transform: rotate(-4.5deg) translate(-16px, 20px); } -.dashCover { - width: 32px; - height: 32px; - border-radius: 8px; - background: linear-gradient(135deg, var(--mantine-color-primary-5), var(--mantine-color-secondary-5)); - flex-shrink: 0; +.ticket { position: relative; - overflow: hidden; - - &::after { - content: ""; - position: absolute; - inset: 0; - background: radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.4), transparent 60%); - } -} - -.dashTitle { - font-size: 0.8125rem; - font-weight: 600; - color: var(--mantine-color-primary-9); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 1.2; + z-index: 1; + transform: rotate(2.5deg); + animation: floatTicket 8s ease-in-out infinite; + filter: + drop-shadow(0 30px 45px rgba(0, 0, 0, 0.45)) + drop-shadow(0 4px 10px rgba(0, 0, 0, 0.3)); } -.dashTitleSub { - font-size: 0.6875rem; - color: color-mix(in srgb, var(--mantine-color-primary-9) 50%, white); - margin-top: 1px; +@keyframes floatTicket { + 0%, 100% { transform: rotate(2.5deg) translateY(0); } + 50% { transform: rotate(2.5deg) translateY(-8px); } } -.dashBadge { - display: inline-flex; - align-items: center; - gap: 0.3125rem; - padding: 0.25rem 0.5rem 0.25rem 0.4375rem; - background: color-mix(in srgb, #16a34a 12%, white); - border: 1px solid color-mix(in srgb, #16a34a 25%, white); - border-radius: 9999px; - font-size: 0.625rem; - font-weight: 600; - color: #15803d; - text-transform: uppercase; - letter-spacing: 0.05em; - flex-shrink: 0; -} - -.dashBadgeDot { - width: 5px; - height: 5px; - border-radius: 50%; - background: #16a34a; - box-shadow: 0 0 6px #16a34a; - animation: pulse 2s ease-in-out infinite; +.ticketInner { + display: grid; + grid-template-columns: 1fr 118px; + background: linear-gradient(180deg, #ffffff 0%, #f6f4f9 100%); + border-radius: 18px; + -webkit-mask-image: + radial-gradient(circle 9px at calc(100% - 118px) 0, transparent 8px, #000 9px), + radial-gradient(circle 9px at calc(100% - 118px) 100%, transparent 8px, #000 9px); + -webkit-mask-composite: source-in; + mask-image: + radial-gradient(circle 9px at calc(100% - 118px) 0, transparent 8px, #000 9px), + radial-gradient(circle 9px at calc(100% - 118px) 100%, transparent 8px, #000 9px); + mask-composite: intersect; } -@keyframes pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.5; transform: scale(0.7); } +.ticketMain { + min-width: 0; + padding: 1.375rem 1.5rem 1.25rem; } -.dashStatRow { +.ticketTop { display: flex; - align-items: baseline; - gap: 0.5rem; - margin-bottom: 0.125rem; -} - -.dashStatBig { - font-size: 1.875rem; - font-weight: 800; - color: var(--mantine-color-primary-9); - letter-spacing: -0.02em; - line-height: 1; - font-feature-settings: "tnum"; -} - -.dashStatTrend { - display: inline-flex; align-items: center; - gap: 0.125rem; - font-size: 0.75rem; - font-weight: 600; - color: #15803d; -} - -.dashStatLabel { + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.875rem; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; - font-size: 0.625rem; + font-size: 0.5625rem; text-transform: uppercase; - letter-spacing: 0.1em; - color: color-mix(in srgb, var(--mantine-color-primary-9) 45%, white); - margin-bottom: 0.75rem; -} - -.dashChart { - width: 100%; - height: 48px; - margin-bottom: 0.875rem; - overflow: visible; -} - -.dashChartLine { - fill: none; - stroke: var(--mantine-color-primary-6); - stroke-width: 2; - stroke-linecap: round; - stroke-linejoin: round; - stroke-dasharray: 500; - stroke-dashoffset: 500; - animation: drawLine 2s cubic-bezier(0.4, 0, 0.2, 1) 0.4s forwards; -} - -@keyframes drawLine { - to { stroke-dashoffset: 0; } -} - -.dashChartFill { - fill: url(#chartGradient); - opacity: 0; - animation: fadeIn 0.8s ease-out 1.2s forwards; -} - -@keyframes fadeIn { - to { opacity: 1; } -} - -.dashChartDot { - fill: var(--mantine-color-primary-6); - stroke: white; - stroke-width: 2; - opacity: 0; - animation: fadeIn 0.4s ease-out 2s forwards; + letter-spacing: 0.22em; + color: var(--hi-color-text-tertiary); } -.dashTiers { - display: flex; - flex-direction: column; - gap: 0.4375rem; - margin-bottom: 0.875rem; -} - -.dashTier { - display: grid; - grid-template-columns: 64px 1fr 42px; - align-items: center; - gap: 0.625rem; - font-size: 0.6875rem; -} - -.dashTierName { +.ticketTitle { + font-size: 1.3125rem; + font-weight: 800; + letter-spacing: -0.02em; + line-height: 1.1; color: var(--mantine-color-primary-9); - font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.dashTierBar { - height: 5px; - background: color-mix(in srgb, var(--mantine-color-primary-2) 60%, white); - border-radius: 999px; - overflow: hidden; - position: relative; +.ticketMeta { + margin-top: 0.375rem; + font-size: 0.8125rem; + color: var(--hi-color-text-muted); } -.dashTierBarFill { - position: absolute; - inset: 0; - background: linear-gradient(90deg, var(--mantine-color-primary-5), var(--mantine-color-primary-7)); - border-radius: 999px; - transform-origin: left; - transform: scaleX(0); - animation: fillBar 1.2s cubic-bezier(0.4, 0, 0.2, 1) forwards; -} +.ticketFields { + display: flex; + gap: 2rem; + margin-top: 1.125rem; +} + +.ticketField { + span { + display: block; + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.5625rem; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--hi-color-text-tertiary); + margin-bottom: 2px; + } -@keyframes fillBar { - to { transform: scaleX(var(--fill, 0.5)); } + strong { + font-size: 0.9375rem; + font-weight: 700; + color: var(--mantine-color-primary-9); + font-feature-settings: "tnum"; + } } -.dashTierCount { - font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; - font-size: 0.625rem; - color: color-mix(in srgb, var(--mantine-color-primary-9) 55%, white); - text-align: right; - font-feature-settings: "tnum"; +.barcode { + margin-top: 1.125rem; + height: 26px; + width: 78%; + opacity: 0.85; + background: + repeating-linear-gradient(90deg, var(--mantine-color-primary-9) 0 2px, transparent 2px 5px), + repeating-linear-gradient(90deg, var(--mantine-color-primary-9) 0 1px, transparent 1px 8px); } -.dashFooter { +.ticketStub { + border-left: 1.5px dashed rgba(0, 0, 0, 0.16); display: flex; + flex-direction: column; align-items: center; justify-content: space-between; - padding-top: 0.75rem; - border-top: 1px solid color-mix(in srgb, var(--mantine-color-primary-2) 60%, white); -} - -.dashAvatars { - display: flex; - align-items: center; + gap: 0.625rem; + padding: 1.125rem 0.75rem; } -.dashAvatar { - width: 22px; - height: 22px; - border-radius: 50%; - border: 2px solid white; - display: flex; - align-items: center; - justify-content: center; +.stubLabel, +.stubSeat { + font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 0.5625rem; - font-weight: 700; - color: white; - - &:not(:first-child) { - margin-left: -7px; - } + text-transform: uppercase; + letter-spacing: 0.2em; + color: var(--hi-color-text-tertiary); + white-space: nowrap; } -.dashAvatar1 { background: linear-gradient(135deg, #f97316, #dc2626); } -.dashAvatar2 { background: linear-gradient(135deg, #8b5cf6, #6366f1); } -.dashAvatar3 { background: linear-gradient(135deg, #06b6d4, #0ea5e9); } -.dashAvatar4 { background: linear-gradient(135deg, #10b981, #059669); } - -.dashFooterText { - font-size: 0.6875rem; - color: color-mix(in srgb, var(--mantine-color-primary-9) 55%, white); - font-weight: 500; +.stubQr { + width: 58px; + height: 58px; + fill: var(--mantine-color-primary-9); + opacity: 0.9; } -// Floating secondary notification cards -.floatCard { +.stamp { position: absolute; - background: white; - border-radius: 14px; - padding: 0.75rem 0.875rem; - box-shadow: - 0 18px 36px -12px color-mix(in srgb, var(--mantine-color-primary-9) 22%, transparent), - 0 2px 6px -1px color-mix(in srgb, var(--mantine-color-primary-9) 10%, transparent); - border: 1px solid color-mix(in srgb, var(--mantine-color-primary-3) 25%, white); - display: flex; - align-items: center; - gap: 0.625rem; - min-width: 0; z-index: 3; -} - -.floatCardTop { - top: 4%; - right: -6%; - width: 200px; - transform: rotate(3deg); - animation: floatA 7s ease-in-out infinite; - - @include mixins.respond-below(lg) { - width: 180px; - top: 6%; - right: -4%; - } -} - -.floatCardBottom { - bottom: -9%; - left: -8%; - width: 210px; - transform: rotate(-4deg); - animation: floatB 9s ease-in-out infinite; - - @include mixins.respond-below(lg) { - width: 190px; - bottom: -11%; - left: -6%; - } -} - -@keyframes floatA { - 0%, 100% { transform: rotate(3deg) translateY(0); } - 50% { transform: rotate(3deg) translateY(-6px); } -} - -@keyframes floatB { - 0%, 100% { transform: rotate(-4deg) translateY(0); } - 50% { transform: rotate(-4deg) translateY(-6px); } -} - -.floatIcon { - width: 32px; - height: 32px; - border-radius: 9px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - background: color-mix(in srgb, var(--mantine-color-primary-3) 25%, white); - color: var(--mantine-color-primary-7); -} - -.floatBody { - min-width: 0; - flex: 1; -} - -.floatTitle { - font-size: 0.6875rem; - font-weight: 600; - color: var(--mantine-color-primary-9); - line-height: 1.2; + top: 30%; + right: 14%; + padding: 0.3125rem 0.875rem; + border: 5px double color-mix(in srgb, var(--mantine-color-secondary-5) 90%, white); + border-radius: 8px; + color: color-mix(in srgb, var(--mantine-color-secondary-5) 90%, white); + background: color-mix(in srgb, var(--mantine-color-secondary-5) 6%, transparent); + font-weight: 800; + font-size: 0.9375rem; + text-transform: uppercase; + letter-spacing: 0.22em; white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + mix-blend-mode: multiply; + transform: rotate(-10deg); + animation: stampIn 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) 1.2s both; } -.floatSub { - font-size: 0.625rem; - color: color-mix(in srgb, var(--mantine-color-primary-9) 55%, white); - margin-top: 2px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +@keyframes stampIn { + 0% { opacity: 0; transform: rotate(-10deg) scale(1.9); } + 60% { opacity: 1; transform: rotate(-10deg) scale(0.94); } + 100% { opacity: 1; transform: rotate(-10deg) scale(1); } } -// ------- FEATURE TICKER — pinned at bottom, subtle scroll ------- .ticker { position: absolute; bottom: 0; @@ -602,10 +419,10 @@ align-items: center; overflow: hidden; z-index: 4; - border-top: 1px solid color-mix(in srgb, var(--mantine-color-primary-9) 8%, transparent); - background: color-mix(in srgb, var(--mantine-color-primary-0) 55%, transparent); - backdrop-filter: blur(10px) saturate(140%); - -webkit-backdrop-filter: blur(10px) saturate(140%); + border-top: 1px solid color-mix(in srgb, white 9%, transparent); + background: color-mix(in srgb, white 4%, transparent); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent); -webkit-mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent); @@ -624,7 +441,7 @@ font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.14em; - color: color-mix(in srgb, var(--mantine-color-primary-9) 60%, white); + color: color-mix(in srgb, white 55%, transparent); white-space: nowrap; will-change: transform; @@ -653,28 +470,17 @@ width: 4px; height: 4px; border-radius: 50%; - background: var(--mantine-color-primary-5); - opacity: 0.55; + background: var(--mantine-color-secondary-5); + opacity: 0.7; flex-shrink: 0; } @media (prefers-reduced-motion: reduce) { - .headingBlock, - .dashStage, - .dashCard, - .dashBadgeDot, - .dashChartLine, - .dashChartFill, - .dashChartDot, - .dashTierBarFill, - .floatCardTop, - .floatCardBottom, + .heroTitle, + .ticketScene, + .ticket, + .stamp, .tickerTrack { animation: none; } - - .dashChartLine { stroke-dashoffset: 0; } - .dashChartFill, - .dashChartDot { opacity: 1; } - .dashTierBarFill { transform: scaleX(var(--fill, 0.5)); } } diff --git a/frontend/src/components/layouts/AuthLayout/index.tsx b/frontend/src/components/layouts/AuthLayout/index.tsx index 9497f6d619..418ef7b829 100644 --- a/frontend/src/components/layouts/AuthLayout/index.tsx +++ b/frontend/src/components/layouts/AuthLayout/index.tsx @@ -4,18 +4,11 @@ import {t} from "@lingui/macro"; import {useGetMe} from "../../../queries/useGetMe.ts"; import {PoweredByFooter} from "../../common/PoweredByFooter"; import {LanguageSwitcher} from "../../common/LanguageSwitcher"; -import {IconBellRinging, IconUsersGroup} from "@tabler/icons-react"; import {useCallback, useRef} from "react"; import {getConfig} from "../../../utilites/config.ts"; import {isHiEvents} from "../../../utilites/helpers.ts"; import {showInfo} from "../../../utilites/notifications.tsx"; -const tiers = [ - {name: "VIP Pass", count: "87/100", fill: 0.87}, - {name: "Early Bird", count: "240/240", fill: 1.0}, - {name: "General", count: "512/750", fill: 0.68}, -]; - const tickerFeatures = [ t`Recurring events`, t`Instant Stripe payouts`, @@ -40,105 +33,78 @@ const FeaturePanel = () => { return (
-
-
+
+
-
-

- {t`Sell out your event.`} - {t`Keep the profit.`} -

-
- -