From 56b03579f7b2393cb077593b4e5d4b922d122189 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Tue, 2 Jun 2026 19:15:57 +0100 Subject: [PATCH] Feature: Occurrence improvements --- .../app/DomainObjects/EventDomainObject.php | 3 +- .../Repository/Eloquent/EventRepository.php | 69 +- .../UpdateProductVisibilityHandler.php | 2 +- .../OccurrencePurchaseEligibilityService.php | 70 +- .../Eloquent/EventRepositoryTest.php | 133 ++++ .../DomainObjects/EventDomainObjectTest.php | 7 + .../UpdateProductVisibilityHandlerTest.php | 21 +- ...currencePurchaseEligibilityServiceTest.php | 35 + ...rderCreateRequestValidationServiceTest.php | 11 +- .../src/components/common/EventCard/index.tsx | 37 +- .../StatusToggle/StatusToggle.module.scss | 2 +- .../layouts/EventHomepage/index.tsx | 13 +- .../ManageOccurrenceModal.module.scss | 30 + .../modals/ManageOccurrenceModal/index.tsx | 57 +- .../SelectCheckInListModal.module.scss | 159 ++++ .../modals/SelectCheckInListModal/index.tsx | 93 +++ .../NextOccurrenceHero/index.tsx | 21 +- .../event/EventDashboard/SetupChecklist.tsx | 50 +- .../routes/event/EventDashboard/index.tsx | 13 +- .../routes/event/OccurrenceDetail/index.tsx | 29 +- .../event/OccurrencesTab/OccurrenceMenu.tsx | 6 +- .../OccurrencesTab/OccurrencesTab.module.scss | 10 + .../PriceOverrideForm/index.tsx | 18 +- .../event/OccurrencesTab/checkInLaunch.tsx | 51 +- .../routes/event/OccurrencesTab/index.tsx | 38 +- .../Sections/EventDetailsForm/index.tsx | 30 +- .../product-widget/SelectProducts/index.tsx | 43 +- .../routes/welcome/Welcome.module.scss | 62 ++ .../src/components/routes/welcome/index.tsx | 165 +++-- frontend/src/hooks/useOccurrenceCheckIn.tsx | 73 ++ frontend/src/locales/de.js | 2 +- frontend/src/locales/de.po | 686 +++++++++--------- frontend/src/locales/en.js | 2 +- frontend/src/locales/en.po | 684 ++++++++--------- frontend/src/locales/es.js | 2 +- frontend/src/locales/es.po | 686 +++++++++--------- frontend/src/locales/fr.js | 2 +- frontend/src/locales/fr.po | 686 +++++++++--------- frontend/src/locales/hu.js | 2 +- frontend/src/locales/hu.po | 686 +++++++++--------- frontend/src/locales/it.js | 2 +- frontend/src/locales/it.po | 686 +++++++++--------- frontend/src/locales/nl.js | 2 +- frontend/src/locales/nl.po | 686 +++++++++--------- frontend/src/locales/pl.js | 2 +- frontend/src/locales/pl.po | 686 +++++++++--------- frontend/src/locales/pt-br.js | 2 +- frontend/src/locales/pt-br.po | 686 +++++++++--------- frontend/src/locales/pt.js | 2 +- frontend/src/locales/pt.po | 686 +++++++++--------- frontend/src/locales/ru.js | 2 +- frontend/src/locales/ru.po | 680 ++++++++--------- frontend/src/locales/se.js | 2 +- frontend/src/locales/se.po | 686 +++++++++--------- frontend/src/locales/tr.js | 2 +- frontend/src/locales/tr.po | 686 +++++++++--------- frontend/src/locales/vi.js | 2 +- frontend/src/locales/vi.po | 686 +++++++++--------- frontend/src/locales/zh-cn.js | 2 +- frontend/src/locales/zh-cn.po | 686 +++++++++--------- frontend/src/locales/zh-hk.js | 2 +- frontend/src/locales/zh-hk.po | 686 +++++++++--------- frontend/src/router.tsx | 4 + frontend/src/utilites/dates.ts | 4 + frontend/src/utilites/effectiveLocation.ts | 42 +- .../src/utilites/eventsPageFiltersHelper.ts | 8 +- 66 files changed, 6811 insertions(+), 5598 deletions(-) create mode 100644 backend/tests/Feature/Repository/Eloquent/EventRepositoryTest.php create mode 100644 frontend/src/components/modals/SelectCheckInListModal/SelectCheckInListModal.module.scss create mode 100644 frontend/src/components/modals/SelectCheckInListModal/index.tsx create mode 100644 frontend/src/hooks/useOccurrenceCheckIn.tsx diff --git a/backend/app/DomainObjects/EventDomainObject.php b/backend/app/DomainObjects/EventDomainObject.php index dec2fa5352..f59d9492b1 100644 --- a/backend/app/DomainObjects/EventDomainObject.php +++ b/backend/app/DomainObjects/EventDomainObject.php @@ -297,12 +297,11 @@ public function getLifecycleStatus(): string return EventLifecycleStatus::ONGOING->name; } - if ($this->isEventInFuture()) { + if ($this->isEventInFuture() || $this->getStartDate() === null) { return EventLifecycleStatus::UPCOMING->name; } return EventLifecycleStatus::ENDED->name; - } public function isRecurring(): bool diff --git a/backend/app/Repository/Eloquent/EventRepository.php b/backend/app/Repository/Eloquent/EventRepository.php index 9dbbaacbc3..3d54cb535d 100644 --- a/backend/app/Repository/Eloquent/EventRepository.php +++ b/backend/app/Repository/Eloquent/EventRepository.php @@ -51,25 +51,57 @@ public function findEventsForOrganizer(int $organizerId, int $accountId, QueryPa public function findEvents(array $where, QueryParamsDTO $params): LengthAwarePaginator { - if (!empty($params->query)) { + if (! empty($params->query)) { $where[] = static function (Builder $builder) use ($params) { $builder - ->where(EventDomainObjectAbstract::TITLE, 'ilike', '%' . $params->query . '%'); + ->where(EventDomainObjectAbstract::TITLE, 'ilike', '%'.$params->query.'%'); }; } $upcomingEventsFilter = $params->query_params->get('eventsStatus') === 'upcoming'; + $endedEventsFilter = $params->query_params->get('eventsStatus') === 'ended'; - if (!empty($params->filter_fields) && !$upcomingEventsFilter) { + if (! empty($params->filter_fields)) { $this->applyFilterFields($params, EventDomainObject::getAllowedFilterFields()); } - // Apply custom filter for upcoming events, as it keeps things less complex on the front-end if ($upcomingEventsFilter) { + $where[] = static function (Builder $builder) { + $builder + ->where(EventDomainObjectAbstract::STATUS, '!=', EventStatus::ARCHIVED->getName()) + ->where(function (Builder $eventQuery) { + $eventQuery + ->whereNotExists(function ($query) { + $query->select(DB::raw(1)) + ->from('event_occurrences') + ->whereColumn('event_occurrences.event_id', 'events.id') + ->whereNull('event_occurrences.deleted_at'); + }) + ->orWhereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('event_occurrences') + ->whereColumn('event_occurrences.event_id', 'events.id') + ->whereNull('event_occurrences.deleted_at') + ->where(function ($q) { + $q->whereNull('event_occurrences.end_date') + ->orWhere('event_occurrences.end_date', '>=', now()); + }); + }); + }); + }; + } + + if ($endedEventsFilter) { $where[] = static function (Builder $builder) { $builder ->where(EventDomainObjectAbstract::STATUS, '!=', EventStatus::ARCHIVED->getName()) ->whereExists(function ($query) { + $query->select(DB::raw(1)) + ->from('event_occurrences') + ->whereColumn('event_occurrences.event_id', 'events.id') + ->whereNull('event_occurrences.deleted_at'); + }) + ->whereNotExists(function ($query) { $query->select(DB::raw(1)) ->from('event_occurrences') ->whereColumn('event_occurrences.event_id', 'events.id') @@ -80,11 +112,6 @@ public function findEvents(array $where, QueryParamsDTO $params): LengthAwarePag }); }); }; - - $organizerId = $params->filter_fields->first(fn($filter) => $filter->field === EventDomainObjectAbstract::ORGANIZER_ID)?->value; - if ($organizerId) { - $this->model = $this->model->where(EventDomainObjectAbstract::ORGANIZER_ID, $organizerId); - } } $this->model = $this->model->orderBy( @@ -135,9 +162,9 @@ public function getAllEventsForAdmin( if ($search) { $this->model = $this->model->where(function ($q) use ($search) { - $q->where(EventDomainObjectAbstract::TITLE, 'ilike', '%' . $search . '%') + $q->where(EventDomainObjectAbstract::TITLE, 'ilike', '%'.$search.'%') ->orWhereHas('organizer', function ($orgQuery) use ($search) { - $orgQuery->where('name', 'ilike', '%' . $search . '%'); + $orgQuery->where('name', 'ilike', '%'.$search.'%'); }); }); } @@ -159,15 +186,15 @@ public function getSitemapEvents(int $page, int $perPage): LengthAwarePaginator { return $this->handleResults($this->model ->select([ - 'events.' . EventDomainObjectAbstract::ID, - 'events.' . EventDomainObjectAbstract::TITLE, - 'events.' . EventDomainObjectAbstract::UPDATED_AT, + 'events.'.EventDomainObjectAbstract::ID, + 'events.'.EventDomainObjectAbstract::TITLE, + 'events.'.EventDomainObjectAbstract::UPDATED_AT, ]) ->join('event_settings', 'events.id', '=', 'event_settings.event_id') - ->where('events.' . EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name) - ->where('event_settings.' . EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true) - ->whereNull('events.' . EventDomainObjectAbstract::DELETED_AT) - ->orderBy('events.' . EventDomainObjectAbstract::ID) + ->where('events.'.EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name) + ->where('event_settings.'.EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true) + ->whereNull('events.'.EventDomainObjectAbstract::DELETED_AT) + ->orderBy('events.'.EventDomainObjectAbstract::ID) ->paginate($perPage, ['*'], 'page', $page)); } @@ -186,9 +213,9 @@ public function getSitemapEventCount(): int return $this->model ->newQuery() ->join('event_settings', 'events.id', '=', 'event_settings.event_id') - ->where('events.' . EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name) - ->where('event_settings.' . EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true) - ->whereNull('events.' . EventDomainObjectAbstract::DELETED_AT) + ->where('events.'.EventDomainObjectAbstract::STATUS, EventStatus::LIVE->name) + ->where('event_settings.'.EventSettingDomainObjectAbstract::ALLOW_SEARCH_ENGINE_INDEXING, true) + ->whereNull('events.'.EventDomainObjectAbstract::DELETED_AT) ->count(); } } diff --git a/backend/app/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandler.php b/backend/app/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandler.php index 965aebe735..a515765867 100644 --- a/backend/app/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandler.php +++ b/backend/app/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandler.php @@ -50,7 +50,7 @@ public function handle(UpdateProductVisibilityDTO $dto): Collection ProductDomainObjectAbstract::EVENT_ID => $dto->event_id, ]); - $allProductIds = $allProducts->pluck('id')->sort()->values()->toArray(); + $allProductIds = $allProducts->map(fn ($product) => $product->getId())->sort()->values()->toArray(); $selectedProductIds = collect($dto->product_ids)->sort()->values()->toArray(); $invalidIds = array_diff($selectedProductIds, $allProductIds); diff --git a/backend/app/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityService.php b/backend/app/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityService.php index 03c37c28d0..308726af6a 100644 --- a/backend/app/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityService.php +++ b/backend/app/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityService.php @@ -10,20 +10,6 @@ use HiEvents\Repository\Interfaces\ProductOccurrenceVisibilityRepositoryInterface; use Illuminate\Validation\ValidationException; -/** - * Single source of truth for "can this occurrence currently receive a purchase?". - * - * Used by both the public checkout validator and manual-attendee creation so the - * two paths cannot drift apart. Intentionally narrow: - * - * - status / visibility / capacity checks live here - * - per-product per-price availability and quantity math live in the existing - * `AvailableProductQuantitiesFetchService`; callers compose the two - * - * Consolidating here matters because the manual-attendee path previously skipped - * all of these checks, letting organisers issue tickets against cancelled or - * sold-out occurrences and bypass occurrence-scoped product visibility rules. - */ class OccurrencePurchaseEligibilityService { public function __construct( @@ -33,14 +19,6 @@ public function __construct( ) {} /** - * Loads and validates the occurrence is eligible to receive an additional - * purchase of the given quantity. Returns the loaded domain object on - * success — saves the caller a duplicate fetch. - * - * `$overrideCapacity` is for organiser-initiated manual creation only; it - * skips the capacity ceiling but still enforces status (a cancelled - * occurrence has no seats to override into). - * * @throws ValidationException */ public function assertOccurrencePurchasable( @@ -62,28 +40,31 @@ public function assertOccurrencePurchasable( if ($occurrence->isCancelled()) { throw ValidationException::withMessages([ - 'event_occurrence_id' => __('This event occurrence has been cancelled'), + 'event_occurrence_id' => $this->purchasabilityMessage( + $eventId, + __('This event occurrence has been cancelled'), + __('This event has been cancelled'), + ), ]); } - // Past dates are blocked even when capacity is overridden — selling or - // manually issuing tickets for a session that has already ended is - // never the intended behaviour, and the public payload already filters - // these out so any request reaching here is stale or hand-crafted. if ($occurrence->isPast()) { throw ValidationException::withMessages([ - 'event_occurrence_id' => __('This event occurrence has already ended'), + 'event_occurrence_id' => $this->purchasabilityMessage( + $eventId, + __('This event occurrence has already ended'), + __('This event has already ended'), + ), ]); } - // SOLD_OUT is a capacity-derived status (ProductQuantityUpdateService - // flips it whenever used_capacity >= capacity), so blocking it here - // before the capacity-override branch would defeat the override flag in - // the most common case it was added for. Treat SOLD_OUT as a normal - // capacity gate that the override can bypass. if (! $overrideCapacity && $occurrence->isSoldOut()) { throw ValidationException::withMessages([ - 'event_occurrence_id' => __('This event occurrence is sold out'), + 'event_occurrence_id' => $this->purchasabilityMessage( + $eventId, + __('This event occurrence is sold out'), + __('This event is sold out'), + ), ]); } @@ -94,7 +75,11 @@ public function assertOccurrencePurchasable( $available = $occurrence->getCapacity() - $occurrence->getUsedCapacity() - $reservedForOccurrence; if ($additionalQuantity > $available) { throw ValidationException::withMessages([ - 'event_occurrence_id' => __('Not enough capacity available for this occurrence'), + 'event_occurrence_id' => $this->purchasabilityMessage( + $eventId, + __('Not enough capacity available for this occurrence'), + __('Not enough capacity available for this event'), + ), ]); } } @@ -103,11 +88,6 @@ public function assertOccurrencePurchasable( } /** - * Verifies the product is visible on the occurrence. Visibility rules are an - * allow-list — an occurrence with no rules is treated as "all products - * visible" (the default), matching `ProductFilterService::filterByOccurrenceVisibility` - * so the validator and the storefront filter agree. - * * @param int[] $productIds * * @throws ValidationException @@ -137,4 +117,14 @@ public function assertProductsVisibleOnOccurrence(int $occurrenceId, array $prod } } } + + private function purchasabilityMessage(int $eventId, string $multiOccurrence, string $singleOccurrence): string + { + return $this->eventHasMultipleOccurrences($eventId) ? $multiOccurrence : $singleOccurrence; + } + + private function eventHasMultipleOccurrences(int $eventId): bool + { + return $this->occurrenceRepository->countWhere(['event_id' => $eventId]) > 1; + } } diff --git a/backend/tests/Feature/Repository/Eloquent/EventRepositoryTest.php b/backend/tests/Feature/Repository/Eloquent/EventRepositoryTest.php new file mode 100644 index 0000000000..7e0ff0b465 --- /dev/null +++ b/backend/tests/Feature/Repository/Eloquent/EventRepositoryTest.php @@ -0,0 +1,133 @@ +withAccount()->create(); + $this->userId = $user->id; + $this->accountId = $user->accounts()->first()->id; + + $now = now()->toDateTimeString(); + + $this->organizerId = DB::table('organizers')->insertGetId([ + 'account_id' => $this->accountId, + 'name' => 'Events Organizer', + 'email' => 'events-organizer@example.test', + 'currency' => 'USD', + 'timezone' => 'UTC', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->eventWithoutOccurrencesId = $this->createEvent('Event without occurrences'); + $this->eventWithFutureOccurrenceId = $this->createEvent('Event with future occurrence'); + $this->eventWithPastOccurrenceId = $this->createEvent('Event with past occurrence'); + + $this->createOccurrence($this->eventWithFutureOccurrenceId, now()->addDay(), now()->addDay()->addHours(2)); + $this->createOccurrence($this->eventWithPastOccurrenceId, now()->subDays(2), now()->subDays(2)->addHours(2)); + } + + public function test_upcoming_filter_includes_events_with_no_occurrences(): void + { + $ids = $this->findEventIds('upcoming'); + + $this->assertContains($this->eventWithoutOccurrencesId, $ids); + $this->assertContains($this->eventWithFutureOccurrenceId, $ids); + $this->assertNotContains($this->eventWithPastOccurrenceId, $ids); + } + + public function test_ended_filter_only_includes_events_whose_occurrences_have_all_passed(): void + { + $ids = $this->findEventIds('ended'); + + $this->assertContains($this->eventWithPastOccurrenceId, $ids); + $this->assertNotContains($this->eventWithoutOccurrencesId, $ids); + $this->assertNotContains($this->eventWithFutureOccurrenceId, $ids); + } + + private function findEventIds(string $eventsStatus): array + { + $params = QueryParamsDTO::fromArray([ + 'eventsStatus' => $eventsStatus, + 'sort_by' => 'created_at', + 'sort_direction' => 'desc', + 'per_page' => 100, + ]); + + $result = $this->app->make(EventRepository::class)->findEvents( + where: [ + 'account_id' => $this->accountId, + 'organizer_id' => $this->organizerId, + ], + params: $params, + ); + + return collect($result->items()) + ->map(fn (EventDomainObject $event) => $event->getId()) + ->all(); + } + + private function createEvent(string $title): int + { + $now = now()->toDateTimeString(); + + return DB::table('events')->insertGetId([ + 'title' => $title, + 'status' => 'DRAFT', + 'account_id' => $this->accountId, + 'user_id' => $this->userId, + 'organizer_id' => $this->organizerId, + 'currency' => 'USD', + 'timezone' => 'UTC', + 'short_id' => 'evt_'.uniqid(), + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + private function createOccurrence(int $eventId, $startDate, $endDate): void + { + $now = now()->toDateTimeString(); + + DB::table('event_occurrences')->insert([ + 'short_id' => 'occ_'.uniqid(), + 'event_id' => $eventId, + 'start_date' => $startDate->toDateTimeString(), + 'end_date' => $endDate->toDateTimeString(), + 'status' => 'ACTIVE', + 'used_capacity' => 0, + 'is_overridden' => false, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } +} diff --git a/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php b/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php index 81fab8f06b..3cb8e5ae6c 100644 --- a/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php +++ b/backend/tests/Unit/DomainObjects/EventDomainObjectTest.php @@ -279,6 +279,13 @@ public function testGetLifecycleStatusReturnsEndedWhenAllPast(): void $this->assertEquals(EventLifecycleStatus::ENDED->name, $event->getLifecycleStatus()); } + public function testGetLifecycleStatusReturnsUpcomingWhenNoOccurrences(): void + { + $event = $this->createEvent(collect()); + + $this->assertEquals(EventLifecycleStatus::UPCOMING->name, $event->getLifecycleStatus()); + } + public function testIsRecurringReturnsTrueForRecurringType(): void { $event = new EventDomainObject(); diff --git a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandlerTest.php index 965028db00..954d71080c 100644 --- a/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/EventOccurrence/UpdateProductVisibilityHandlerTest.php @@ -6,6 +6,7 @@ use HiEvents\DomainObjects\Generated\EventOccurrenceDomainObjectAbstract; use HiEvents\DomainObjects\Generated\ProductDomainObjectAbstract; use HiEvents\DomainObjects\Generated\ProductOccurrenceVisibilityDomainObjectAbstract; +use HiEvents\DomainObjects\ProductDomainObject; use HiEvents\DomainObjects\ProductOccurrenceVisibilityDomainObject; use HiEvents\Exceptions\ResourceNotFoundException; use HiEvents\Repository\Interfaces\EventOccurrenceRepositoryInterface; @@ -52,22 +53,10 @@ protected function setUp(): void private function makeProductCollection(array $ids): Collection { - return collect(array_map(function ($id) { - return new class($id) - { - public function __construct(public readonly int $id) {} - - public function offsetGet($key) - { - return $this->$key; - } - - public function offsetExists($key): bool - { - return isset($this->$key); - } - }; - }, $ids)); + return collect(array_map( + fn ($id) => (new ProductDomainObject)->setId($id), + $ids, + )); } public function test_handle_creates_visibility_records_for_selected_products(): void diff --git a/backend/tests/Unit/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityServiceTest.php b/backend/tests/Unit/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityServiceTest.php index be5778d7c8..8b831b72c8 100644 --- a/backend/tests/Unit/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityServiceTest.php +++ b/backend/tests/Unit/Services/Domain/EventOccurrence/OccurrencePurchaseEligibilityServiceTest.php @@ -37,6 +37,11 @@ protected function setUp(): void ->byDefault() ->andReturn(0); + $this->occurrenceRepository + ->shouldReceive('countWhere') + ->byDefault() + ->andReturn(1); + $this->service = new OccurrencePurchaseEligibilityService( $this->occurrenceRepository, $this->orderItemRepository, @@ -213,6 +218,36 @@ public function test_override_capacity_allows_exceeding_capacity_for_active_occu $this->assertSame($occurrence, $result); } + public function test_uses_single_occurrence_wording_for_single_occurrence_event(): void + { + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->andReturn($this->occurrence(EventOccurrenceStatus::CANCELLED->name)); + $this->occurrenceRepository + ->shouldReceive('countWhere') + ->andReturn(1); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('This event has been cancelled'); + + $this->service->assertOccurrencePurchasable(eventId: 1, occurrenceId: 10); + } + + public function test_uses_occurrence_wording_for_multi_occurrence_event(): void + { + $this->occurrenceRepository + ->shouldReceive('findFirstWhere') + ->andReturn($this->occurrence(EventOccurrenceStatus::CANCELLED->name)); + $this->occurrenceRepository + ->shouldReceive('countWhere') + ->andReturn(2); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('This event occurrence has been cancelled'); + + $this->service->assertOccurrencePurchasable(eventId: 1, occurrenceId: 10); + } + public function test_product_visibility_allows_all_when_no_rules_exist(): void { $this->visibilityRepository diff --git a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php index 91af0cac10..740d290909 100644 --- a/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php +++ b/backend/tests/Unit/Services/Domain/Order/OrderCreateRequestValidationServiceTest.php @@ -69,6 +69,16 @@ protected function setUp(): void ->byDefault() ->andReturn(0); + $this->occurrenceRepository + ->shouldReceive('findWhere') + ->byDefault() + ->andReturn(collect([1])); + + $this->occurrenceRepository + ->shouldReceive('countWhere') + ->byDefault() + ->andReturn(1); + // Build the real eligibility service from the same mocked repositories // — keeps the existing tests as integration-style verification that the // validator + eligibility service compose correctly without doubling up @@ -168,7 +178,6 @@ public function test_normalizes_missing_occurrence_id_for_single_event_checkout( $this->setupEventLookup(1, isRecurring: false); $this->occurrenceRepository ->shouldReceive('findWhere') - ->once() ->andReturn(collect([$occurrence])); $this->setupOccurrenceLookup(1, 10, $occurrence); $this->setupAvailability(1); diff --git a/frontend/src/components/common/EventCard/index.tsx b/frontend/src/components/common/EventCard/index.tsx index f3ff284534..f62fc96084 100644 --- a/frontend/src/components/common/EventCard/index.tsx +++ b/frontend/src/components/common/EventCard/index.tsx @@ -21,7 +21,7 @@ import {showError, showSuccess} from "../../../utilites/notifications.tsx"; import {useUpdateEventStatus} from "../../../mutations/useUpdateEventStatus.ts"; import {formatCurrency} from "../../../utilites/currency.ts"; import {formatNumber} from "../../../utilites/helpers.ts"; -import {formatDateWithLocale, relativeDate} from "../../../utilites/dates.ts"; +import {formatDateWithLocale, isValidDate, relativeDate} from "../../../utilites/dates.ts"; import {Card} from "../Card"; import {resolveEventLocation} from "../../../utilites/effectiveLocation.ts"; import {formatAddress} from "../../../utilites/addressUtilities.ts"; @@ -187,6 +187,7 @@ export function EventCard({event, compact = false}: EventCardProps) { const isRecurring = event.type === EventType.RECURRING; const displayDate = (isRecurring && event.next_occurrence_start_date) || event.start_date; + const hasDate = isValidDate(displayDate); const monthShort = formatDateWithLocale(displayDate, 'monthShort', event.timezone); const dayOfMonth = formatDateWithLocale(displayDate, 'dayOfMonth', event.timezone); const shortDateTime = formatDateWithLocale(displayDate, 'shortDateTime', event.timezone); @@ -205,10 +206,12 @@ export function EventCard({event, compact = false}: EventCardProps) { : {background: placeholderGradient} } /> -
- {dayOfMonth} - {monthShort} -
+ {hasDate && ( +
+ {dayOfMonth} + {monthShort} +
+ )}
@@ -227,10 +230,14 @@ export function EventCard({event, compact = false}: EventCardProps) { · - {shortDateTime} + {hasDate ? shortDateTime : t`No date added`} - · - {relativeDateStr} + {hasDate && ( + <> + · + {relativeDateStr} + + )} {locationText && ( <> · @@ -301,10 +308,12 @@ export function EventCard({event, compact = false}: EventCardProps) { {statusConfig.label}
-
- {dayOfMonth} - {monthShort} -
+ {hasDate && ( +
+ {dayOfMonth} + {monthShort} +
+ )} {isRecurring && (
@@ -318,7 +327,7 @@ export function EventCard({event, compact = false}: EventCardProps) {

{event.title}

- {shortDateTime} + {hasDate ? shortDateTime : t`No date added`} {isRecurring && ( @@ -326,7 +335,7 @@ export function EventCard({event, compact = false}: EventCardProps) { )} - ({relativeDateStr}) + {hasDate && ({relativeDateStr})} {locationText && ( <> · diff --git a/frontend/src/components/common/StatusToggle/StatusToggle.module.scss b/frontend/src/components/common/StatusToggle/StatusToggle.module.scss index 092ec4a8e0..b4a7d15493 100644 --- a/frontend/src/components/common/StatusToggle/StatusToggle.module.scss +++ b/frontend/src/components/common/StatusToggle/StatusToggle.module.scss @@ -5,7 +5,7 @@ top: 0; left: 0; right: 0; - z-index: 1; + z-index: 100; background: rgba(0, 0, 0, 0.44); backdrop-filter: blur(8px); padding: 0.75rem 1rem; diff --git a/frontend/src/components/layouts/EventHomepage/index.tsx b/frontend/src/components/layouts/EventHomepage/index.tsx index 1a9a8d328b..bf939dbee1 100644 --- a/frontend/src/components/layouts/EventHomepage/index.tsx +++ b/frontend/src/components/layouts/EventHomepage/index.tsx @@ -4,7 +4,7 @@ import "../../../styles/widget/default.scss"; import React, {useEffect, useRef, useState} from "react"; import {EventDocumentHead} from "../../common/EventDocumentHead"; import {eventCoverImage, eventHomepageUrl, imageUrl, organizerHomepageUrl} from "../../../utilites/urlHelper.ts"; -import {Event, EventOccurrenceStatus, EventType, LocationType, OrganizerStatus} from "../../../types.ts"; +import {Event, EventOccurrence, EventOccurrenceStatus, EventType, LocationType, OrganizerStatus} from "../../../types.ts"; import {EventNotAvailable} from "./EventNotAvailable"; import { IconArrowUpRight, @@ -54,6 +54,7 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => { const {event, promoCodeValid, promoCode, initialOccurrenceId} = loaderData; const [showScrollButton, setShowScrollButton] = useState(false); const [contactModalOpen, setContactModalOpen] = useState(false); + const [selectedOccurrence, setSelectedOccurrence] = useState(); const ticketsSectionRef = useRef(null); const {consentPending, consentGranted, onConsent} = useOrganizerTrackingPixels( @@ -378,14 +379,9 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => { )}
{(() => { - const nextOccurrence = event.type === EventType.RECURRING - ? (event.occurrences || []) - .filter(o => o.status === EventOccurrenceStatus.ACTIVE && !o.is_past) - .sort((a, b) => a.start_date.localeCompare(b.start_date))[0] - : undefined; - if (event.type === EventType.RECURRING && !nextOccurrence) return null; + if (event.type === EventType.RECURRING && !selectedOccurrence) return null; return ( - +
diff --git a/frontend/src/components/modals/ManageOccurrenceModal/ManageOccurrenceModal.module.scss b/frontend/src/components/modals/ManageOccurrenceModal/ManageOccurrenceModal.module.scss index ebb26ba1c8..e45e1c89e7 100644 --- a/frontend/src/components/modals/ManageOccurrenceModal/ManageOccurrenceModal.module.scss +++ b/frontend/src/components/modals/ManageOccurrenceModal/ManageOccurrenceModal.module.scss @@ -27,6 +27,36 @@ margin-top: 2px; } +.location { + display: flex; + align-items: center; + gap: 5px; + margin-top: 4px; + font-size: 0.75rem; + color: var(--mantine-color-dimmed); +} + +.locationIcon { + flex-shrink: 0; + color: var(--mantine-color-gray-5); +} + +.locationText { + line-height: 1.3; +} + +.locationVenue { + font-weight: 500; + color: var(--mantine-color-text); + + &::after { + content: '·'; + margin: 0 5px; + color: var(--mantine-color-dimmed); + font-weight: 400; + } +} + .statusBadge { display: inline-flex; align-items: center; diff --git a/frontend/src/components/modals/ManageOccurrenceModal/index.tsx b/frontend/src/components/modals/ManageOccurrenceModal/index.tsx index c9c051e16d..fafd952060 100644 --- a/frontend/src/components/modals/ManageOccurrenceModal/index.tsx +++ b/frontend/src/components/modals/ManageOccurrenceModal/index.tsx @@ -2,16 +2,16 @@ import {EventOccurrence, GenericModalProps, IdParam, MessageType} from "../../.. import {useNavigate, useParams} from "react-router"; import {useGetEvent} from "../../../queries/useGetEvent.ts"; import {useGetEventOccurrence} from "../../../queries/useGetEventOccurrence.ts"; -import {useGetEventCheckInLists} from "../../../queries/useGetCheckInLists.ts"; import {t} from "@lingui/macro"; import {useCallback, useMemo, useState} from "react"; -import {Progress, Skeleton, Stack, Text} from "@mantine/core"; +import {Anchor, Progress, Skeleton, Stack, Text} from "@mantine/core"; +import {IconMapPin, IconWorld} from "@tabler/icons-react"; +import {getEventLocationDisplay} from "../../../utilites/effectiveLocation.ts"; import {OccurrenceAttendeesAndOrders} from "../../common/OccurrenceAttendeesAndOrders"; import {SideDrawer} from "../../common/SideDrawer"; import {SendMessageModal} from "../SendMessageModal"; import {ShareModal} from "../ShareModal"; import {OccurrenceEditModal} from "../../routes/event/OccurrencesTab/OccurrenceEditModal"; -import {CreateCheckInListModal} from "../CreateCheckInListModal"; import {OccurrenceActionBar, OccurrenceMenuActions} from "../../routes/event/OccurrencesTab/OccurrenceMenu"; import {statusLabel} from "../../routes/event/OccurrencesTab/OccurrenceMenu"; import {formatDateWithLocale} from "../../../utilites/dates.ts"; @@ -23,7 +23,7 @@ import {useDeleteEventOccurrence} from "../../../mutations/useDeleteEventOccurre import {useReactivateOccurrence} from "../../../mutations/useReactivateOccurrence.ts"; import {eventHomepageUrl} from "../../../utilites/urlHelper.ts"; import {openCancelOccurrenceDialog} from "../../routes/event/OccurrencesTab/cancelOccurrenceDialog"; -import {launchCheckInForOccurrence} from "../../routes/event/OccurrencesTab/checkInLaunch"; +import {useOccurrenceCheckIn} from "../../../hooks/useOccurrenceCheckIn.tsx"; import classes from './ManageOccurrenceModal.module.scss'; interface ManageOccurrenceModalProps { @@ -35,26 +35,16 @@ export const ManageOccurrenceModal = ({onClose, occurrenceId}: GenericModalProps const navigate = useNavigate(); const {data: occurrence} = useGetEventOccurrence(eventId, occurrenceId); const {data: event} = useGetEvent(eventId); - const checkInListsQuery = useGetEventCheckInLists(eventId); - const checkInLists = checkInListsQuery?.data?.data; + const {launchCheckIn, checkInModals} = useOccurrenceCheckIn(eventId); const [showMessageModal, setShowMessageModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showShareOccurrence, setShowShareOccurrence] = useState(); - const [createCheckInForOccurrenceId, setCreateCheckInForOccurrenceId] = useState(); const cancelMutation = useCancelOccurrence(); const deleteMutation = useDeleteEventOccurrence(); const reactivateMutation = useReactivateOccurrence(); - const handleCheckIn = useCallback((occurrenceId: number) => { - launchCheckInForOccurrence({ - occurrenceId, - checkInLists, - onCreateForOccurrence: setCreateCheckInForOccurrenceId, - }); - }, [checkInLists]); - const handleCancel = useCallback((occId: number) => { openCancelOccurrenceDialog({ eventId, @@ -98,10 +88,10 @@ export const ManageOccurrenceModal = ({onClose, occurrenceId}: GenericModalProps navigate(path); }, onMessage: () => setShowMessageModal(true), - onCheckIn: handleCheckIn, + onCheckIn: launchCheckIn, onReactivate: handleReactivate, onShare: (occ: EventOccurrence) => setShowShareOccurrence(occ), - }), [eventId, handleCheckIn, handleCancel, handleDelete, handleReactivate, onClose, navigate]); + }), [eventId, launchCheckIn, handleCancel, handleDelete, handleReactivate, onClose, navigate]); if (!occurrence || !event) { return ( @@ -125,6 +115,7 @@ export const ManageOccurrenceModal = ({onClose, occurrenceId}: GenericModalProps const endFormatted = occurrence.end_date ? formatDateWithLocale(occurrence.end_date, 'timeOnly', event.timezone) : null; + const locationDisplay = getEventLocationDisplay(event, occurrence); const usedCapacity = occurrence.used_capacity ?? 0; const hasCapacityLimit = occurrence.capacity != null; @@ -146,6 +137,31 @@ export const ManageOccurrenceModal = ({onClose, occurrenceId}: GenericModalProps {occurrence.label && ( {occurrence.label} )} + {locationDisplay && ( +
+ {locationDisplay.isOnline + ? + : } + {locationDisplay.isOnline ? ( + {t`Online event`} + ) : locationDisplay.mapsUrl ? ( + e.stopPropagation()} + > + {locationDisplay.venueName && ( + {locationDisplay.venueName} + )} + {locationDisplay.full} + + ) : ( + {locationDisplay.short} + )} +
+ )}
{statusLabel(occurrence.status)} @@ -199,12 +215,7 @@ export const ManageOccurrenceModal = ({onClose, occurrenceId}: GenericModalProps /> )} - {createCheckInForOccurrenceId && ( - setCreateCheckInForOccurrenceId(undefined)} - initialOccurrenceId={createCheckInForOccurrenceId} - /> - )} + {checkInModals} {showShareOccurrence && ( void; +} + +export const SelectCheckInListModal = ({ + onClose, + eventId, + occurrenceId, + lists, + onCreateForOccurrence, + }: SelectCheckInListModalProps) => { + const navigate = useNavigate(); + + const defaultList = lists.find(list => list.is_system_default) ?? lists.find(list => !list.event_occurrence_id); + const scopedLists = lists.filter(list => list.event_occurrence_id === occurrenceId); + const orderedLists = [defaultList, ...scopedLists] + .filter((list): list is CheckInList => Boolean(list)) + .filter((list, index, all) => all.findIndex(other => other.short_id === list.short_id) === index); + + const startCheckIn = (list: CheckInList) => { + openCheckInTab(list.short_id, list.event_occurrence_id ? undefined : occurrenceId); + onClose(); + }; + + const handleCreate = () => { + onCreateForOccurrence(occurrenceId); + onClose(); + }; + + const handleViewAll = () => { + onClose(); + navigate(`/manage/event/${eventId}/check-in`); + }; + + return ( + + + + {orderedLists.map((list, index) => { + const isPrimary = index === 0; + return ( + + ); + })} + + +
+ + + + + + + ); +}; diff --git a/frontend/src/components/routes/event/EventDashboard/NextOccurrenceHero/index.tsx b/frontend/src/components/routes/event/EventDashboard/NextOccurrenceHero/index.tsx index f388cb8bb3..beb29b7493 100644 --- a/frontend/src/components/routes/event/EventDashboard/NextOccurrenceHero/index.tsx +++ b/frontend/src/components/routes/event/EventDashboard/NextOccurrenceHero/index.tsx @@ -13,10 +13,8 @@ import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; import {Event, EventOccurrence, EventOccurrenceStatus, IdParam} from "../../../../../types.ts"; import {useGetEventOccurrence} from "../../../../../queries/useGetEventOccurrence.ts"; -import {useGetEventCheckInLists} from "../../../../../queries/useGetCheckInLists.ts"; +import {useOccurrenceCheckIn} from "../../../../../hooks/useOccurrenceCheckIn.tsx"; import {formatDateWithLocale} from "../../../../../utilites/dates.ts"; -import {launchCheckInForOccurrence} from "../../OccurrencesTab/checkInLaunch.tsx"; -import {CreateCheckInListModal} from "../../../../modals/CreateCheckInListModal"; import classes from "./NextOccurrenceHero.module.scss"; dayjs.extend(utc); @@ -133,9 +131,7 @@ export const NextOccurrenceHero = ({event, eventId}: NextOccurrenceHeroProps) => const occurrenceQuery = useGetEventOccurrence(eventId, nextOccurrence?.id); const occurrence = occurrenceQuery.data ?? nextOccurrence; - const checkInListsQuery = useGetEventCheckInLists(eventId); - const checkInLists = checkInListsQuery?.data?.data; - const [createCheckInForOccurrenceId, setCreateCheckInForOccurrenceId] = useState(); + const {launchCheckIn, checkInModals} = useOccurrenceCheckIn(eventId); const [now, setNow] = useState(() => dayjs()); const countdown = useMemo( @@ -221,11 +217,7 @@ export const NextOccurrenceHero = ({event, eventId}: NextOccurrenceHeroProps) => rightSection={} onClick={() => { if (countdown.state === 'live') { - launchCheckInForOccurrence({ - occurrenceId: Number(occurrence.id), - checkInLists, - onCreateForOccurrence: setCreateCheckInForOccurrenceId, - }); + launchCheckIn(Number(occurrence.id)); return; } navigate(`/manage/event/${eventId}/occurrences/${occurrence.id}`); @@ -236,12 +228,7 @@ export const NextOccurrenceHero = ({event, eventId}: NextOccurrenceHeroProps) => {countdown.state === 'live' ? t`Open check-in` : t`Open occurrence`} - {createCheckInForOccurrenceId && ( - setCreateCheckInForOccurrenceId(undefined)} - initialOccurrenceId={createCheckInForOccurrenceId} - /> - )} + {checkInModals}
); }; diff --git a/frontend/src/components/routes/event/EventDashboard/SetupChecklist.tsx b/frontend/src/components/routes/event/EventDashboard/SetupChecklist.tsx index 969103c8d9..b3b264830a 100644 --- a/frontend/src/components/routes/event/EventDashboard/SetupChecklist.tsx +++ b/frontend/src/components/routes/event/EventDashboard/SetupChecklist.tsx @@ -97,38 +97,19 @@ export const SetupChecklist = ({ const isEmailVerified = !!account?.is_account_email_confirmed; const isRecurring = event.type === EventType.RECURRING; const isSaasMode = !!account?.is_saas_mode_enabled; + const hasTickets = productCount > 0; const steps: Step[] = [ - { - key: 'publish', - title: t`Publish your event`, - helperIncomplete: t`Make it visible so people can buy tickets`, - helperComplete: t`Live`, - complete: event.status === 'LIVE', - actionLabel: t`Publish`, - actionStyle: 'primary', - onAction: onPublish, - }, - ...(isSaasMode ? [{ - key: 'payouts', - title: t`Set up payouts`, - helperIncomplete: t`Connect your bank to receive ticket sales straight to your account`, - helperComplete: t`Bank account connected`, - complete: isStripeConnected, - actionLabel: payoutsButtonLabel, - actionStyle: 'secondary', - onAction: onConnectStripe, - } as Step] : []), { key: 'tickets', title: t`Add tickets`, - helperIncomplete: t`Create ticket types so people can buy them`, + helperIncomplete: t`Set up the tickets you'll sell and their prices`, helperComplete: productCount === 1 ? t`1 ticket type configured` : t`${productCount} ticket types configured`, complete: productCount > 0, actionLabel: t`Add tickets`, - actionStyle: 'secondary', + actionStyle: 'primary', onAction: onAddTickets, }, ...(isRecurring ? [{ @@ -138,9 +119,32 @@ export const SetupChecklist = ({ helperComplete: t`Schedule added`, complete: hasOccurrences, actionLabel: hasOccurrences ? t`Manage schedule` : t`Set up schedule`, - actionStyle: 'secondary', + actionStyle: 'primary', onAction: onSetupSchedule, } as Step] : []), + { + key: 'publish', + title: t`Publish your event`, + helperIncomplete: t`Make it visible so people can buy tickets`, + helperComplete: t`Live`, + complete: event.status === 'LIVE', + actionLabel: t`Publish`, + actionStyle: + isRecurring + ? (hasOccurrences && hasTickets ? 'primary' : 'secondary') + : (!hasTickets ? 'secondary' : 'primary'), + onAction: onPublish, + }, + ...(isSaasMode ? [{ + key: 'payouts', + title: t`Set up payouts`, + helperIncomplete: t`Connect your bank to receive ticket sales straight to your account`, + helperComplete: t`Bank account connected`, + complete: isStripeConnected, + actionLabel: payoutsButtonLabel, + actionStyle: 'primary', + onAction: onConnectStripe, + } as Step] : []), { key: 'details', title: t`Add event details`, diff --git a/frontend/src/components/routes/event/EventDashboard/index.tsx b/frontend/src/components/routes/event/EventDashboard/index.tsx index 0596601b54..3ef67daae6 100644 --- a/frontend/src/components/routes/event/EventDashboard/index.tsx +++ b/frontend/src/components/routes/event/EventDashboard/index.tsx @@ -90,18 +90,23 @@ export const EventDashboard = () => { const hasOccurrences = (occurrencesQuery?.data?.data?.length ?? 0) > 0; const hasCoverImage = (eventImages?.length ?? 0) > 0; + const isNewEvent = new URLSearchParams(location.search).get('new_event') === 'true'; + useEffect(() => { setIsMounted(true); if (typeof window === 'undefined' || !eventId) { return; } + if (isNewEvent) { + window.localStorage.removeItem('setupChecklistDismissed-' + eventId); + setIsChecklistDismissed(false); + return; + } const dismissed = window.localStorage.getItem('setupChecklistDismissed-' + eventId); if (dismissed === 'true') { setIsChecklistDismissed(true); } - }, [eventId]); - - const isNewEvent = new URLSearchParams(location.search).get('new_event') === 'true'; + }, [eventId, isNewEvent]); const dismissChecklist = () => { setIsChecklistDismissed(true); @@ -150,7 +155,7 @@ export const EventDashboard = () => { if (!eventId) { return; } - navigate(`/manage/event/${eventId}/products`); + navigate(`/manage/event/${eventId}/products#create-product`); }; const handleEditDetails = () => { diff --git a/frontend/src/components/routes/event/OccurrenceDetail/index.tsx b/frontend/src/components/routes/event/OccurrenceDetail/index.tsx index 0c7193d832..31b9082261 100644 --- a/frontend/src/components/routes/event/OccurrenceDetail/index.tsx +++ b/frontend/src/components/routes/event/OccurrenceDetail/index.tsx @@ -13,13 +13,11 @@ import {OccurrenceAttendeesAndOrders} from "../../../common/OccurrenceAttendeesA import {OccurrenceEditModal} from "../OccurrencesTab/OccurrenceEditModal"; import {SendMessageModal} from "../../../modals/SendMessageModal"; import {ShareModal} from "../../../modals/ShareModal"; -import {CreateCheckInListModal} from "../../../modals/CreateCheckInListModal"; import {OccurrenceActionBar, OccurrenceMenuActions, statusLabel} from "../OccurrencesTab/OccurrenceMenu"; -import {launchCheckInForOccurrence} from "../OccurrencesTab/checkInLaunch"; +import {useOccurrenceCheckIn} from "../../../../hooks/useOccurrenceCheckIn.tsx"; import {useGetEventOccurrence} from "../../../../queries/useGetEventOccurrence.ts"; import {useGetEvent} from "../../../../queries/useGetEvent.ts"; import {useGetEventStats} from "../../../../queries/useGetEventStats.ts"; -import {useGetEventCheckInLists} from "../../../../queries/useGetCheckInLists.ts"; import {useCancelOccurrence} from "../../../../mutations/useCancelOccurrence.ts"; import {useDeleteEventOccurrence} from "../../../../mutations/useDeleteEventOccurrence.ts"; import {useReactivateOccurrence} from "../../../../mutations/useReactivateOccurrence.ts"; @@ -41,24 +39,14 @@ const OccurrenceDetail = () => { const [editModalOpen, {open: openEditModal, close: closeEditModal}] = useDisclosure(false); const [showMessageModal, setShowMessageModal] = useState(false); const [showShareOccurrence, setShowShareOccurrence] = useState(); - const [createCheckInForOccurrenceId, setCreateCheckInForOccurrenceId] = useState(); - const checkInListsQuery = useGetEventCheckInLists(eventId); - const checkInLists = checkInListsQuery?.data?.data; + const {launchCheckIn, checkInModals} = useOccurrenceCheckIn(eventId); const cancelMutation = useCancelOccurrence(); const deleteMutation = useDeleteEventOccurrence(); const reactivateMutation = useReactivateOccurrence(); const refundRef = useRef(false); - const handleCheckIn = useCallback((occId: number) => { - launchCheckInForOccurrence({ - occurrenceId: occId, - checkInLists, - onCreateForOccurrence: setCreateCheckInForOccurrenceId, - }); - }, [checkInLists]); - const handleCancel = useCallback((occId: number) => { const orderCount = occurrence?.statistics?.orders_created ?? 0; refundRef.current = false; @@ -124,10 +112,10 @@ const OccurrenceDetail = () => { onDelete: handleDelete, onNavigate: navigate, onMessage: () => setShowMessageModal(true), - onCheckIn: handleCheckIn, + onCheckIn: launchCheckIn, onReactivate: handleReactivate, onShare: (occ: EventOccurrence) => setShowShareOccurrence(occ), - }), [eventId, handleCheckIn, handleCancel, handleDelete, handleReactivate, navigate, openEditModal]); + }), [eventId, launchCheckIn, handleCancel, handleDelete, handleReactivate, navigate, openEditModal]); if (occurrenceLoading || !event) { return ( @@ -162,7 +150,7 @@ const OccurrenceDetail = () => { {occurrence && (
- +
)} @@ -250,12 +238,7 @@ const OccurrenceDetail = () => { /> )} - {createCheckInForOccurrenceId && ( - setCreateCheckInForOccurrenceId(undefined)} - initialOccurrenceId={createCheckInForOccurrenceId} - /> - )} + {checkInModals} {showShareOccurrence && ( { - const allActions = buildActions(occurrence, actions); +export const OccurrenceActionBar = ({occurrence, actions, hiddenKeys}: OccurrenceActionBarProps) => { + const allActions = buildActions(occurrence, actions) + .filter(a => !hiddenKeys?.includes(a.key)); const primary = allActions.filter(a => a.group === 'primary'); const overflow = allActions.filter(a => a.group === 'secondary' || a.group === 'danger'); const overflowSecondary = overflow.filter(a => a.group === 'secondary'); diff --git a/frontend/src/components/routes/event/OccurrencesTab/OccurrencesTab.module.scss b/frontend/src/components/routes/event/OccurrencesTab/OccurrencesTab.module.scss index dad294188a..ee6f2e42c8 100644 --- a/frontend/src/components/routes/event/OccurrencesTab/OccurrencesTab.module.scss +++ b/frontend/src/components/routes/event/OccurrencesTab/OccurrencesTab.module.scss @@ -95,6 +95,16 @@ line-height: 1.3; } +.dateTimeLocation { + margin-top: 2px; + font-size: 12px; + color: var(--mantine-color-dimmed); + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + // Status badge .statusBadge { display: inline-flex; diff --git a/frontend/src/components/routes/event/OccurrencesTab/PriceOverrideForm/index.tsx b/frontend/src/components/routes/event/OccurrencesTab/PriceOverrideForm/index.tsx index 089b585b7d..d304746caa 100644 --- a/frontend/src/components/routes/event/OccurrencesTab/PriceOverrideForm/index.tsx +++ b/frontend/src/components/routes/event/OccurrencesTab/PriceOverrideForm/index.tsx @@ -58,6 +58,13 @@ export const OccurrenceProductSettings = ({occurrenceId}: OccurrenceProductSetti }, [overrides]); const handleToggleProduct = (productId: number, enabled: boolean) => { + if (!enabled) { + const liveEnabledCount = products?.filter(p => enabledProductIds.has(p.id!)).length ?? 0; + if (liveEnabledCount <= 1 && enabledProductIds.has(productId)) { + showError(t`At least one product must stay available for this date. To make the date inaccessible, cancel it from the schedule instead.`); + return; + } + } setEnabledProductIds(prev => { const next = new Set(prev); if (enabled) { @@ -103,11 +110,20 @@ export const OccurrenceProductSettings = ({occurrenceId}: OccurrenceProductSetti if (!products) return; setIsSaving(true); + const liveEnabledProductIds = Array.from(enabledProductIds) + .filter(id => products.some(p => p.id === id)); + + if (liveEnabledProductIds.length === 0) { + showError(t`At least one product must stay available for this date. To make the date inaccessible, cancel it from the schedule instead.`); + setIsSaving(false); + return; + } + try { await visibilityMutation.mutateAsync({ eventId, occurrenceId, - productIds: Array.from(enabledProductIds), + productIds: liveEnabledProductIds, }); const disabledProductIds = new Set( diff --git a/frontend/src/components/routes/event/OccurrencesTab/checkInLaunch.tsx b/frontend/src/components/routes/event/OccurrencesTab/checkInLaunch.tsx index ded5fb3469..cc401d555c 100644 --- a/frontend/src/components/routes/event/OccurrencesTab/checkInLaunch.tsx +++ b/frontend/src/components/routes/event/OccurrencesTab/checkInLaunch.tsx @@ -1,60 +1,29 @@ -import {modals} from "@mantine/modals"; -import {Text} from "@mantine/core"; -import {t} from "@lingui/macro"; import {CheckInList} from "../../../../types.ts"; type LaunchArgs = { occurrenceId: number; checkInLists: CheckInList[] | undefined; onCreateForOccurrence: (occurrenceId: number) => void; + onSelectList: (occurrenceId: number) => void; }; -const openInNewTab = (shortId: string | number) => { +export const openCheckInTab = (shortId: string | number, occurrenceId?: number): void => { if (typeof window === 'undefined') return; - window.open(`/check-in/${shortId}`, '_blank'); + const suffix = occurrenceId ? `?occurrence=${occurrenceId}` : ''; + window.open(`/check-in/${shortId}${suffix}`, '_blank'); }; -/** - * Launching check-in from a specific occurrence has three branches: - * - * 1. There's a list scoped to the occurrence — open it. - * 2. There's only a global (no-occurrence) list — prompt: "use the all-occurrences - * list?" or "create a date-specific list?". The previous behaviour silently - * fell through to the global list, which lets staff scan attendees from - * other dates without realising it. - * 3. No relevant list exists — open the create flow scoped to this occurrence. - */ export const launchCheckInForOccurrence = ({ occurrenceId, checkInLists, onCreateForOccurrence, + onSelectList, }: LaunchArgs): void => { - const scoped = checkInLists?.find(list => list.event_occurrence_id === occurrenceId); - if (scoped) { - openInNewTab(scoped.short_id); - return; - } - - const global = checkInLists?.find(list => !list.event_occurrence_id); - if (global) { - modals.openConfirmModal({ - title: t`No date-specific check-in list`, - children: ( - <> - - {t`There's no check-in list scoped to this date. The "${global.name}" list checks in attendees across every date — staff scanning a ticket for a different date will still succeed.`} - - - {t`Use the all-dates list, or create a list for this date?`} - - - ), - labels: {confirm: t`Use all-dates list`, cancel: t`Create for this date`}, - // The "cancel" button is the safer of the two — that's where Mantine - // anchors keyboard focus by default, and it leads to creation. - onCancel: () => onCreateForOccurrence(occurrenceId), - onConfirm: () => openInNewTab(global.short_id), - }); + const hasUsableList = checkInLists?.some(list => + !list.event_occurrence_id || list.event_occurrence_id === occurrenceId + ); + if (hasUsableList) { + onSelectList(occurrenceId); return; } diff --git a/frontend/src/components/routes/event/OccurrencesTab/index.tsx b/frontend/src/components/routes/event/OccurrencesTab/index.tsx index 68e2bd7b49..df8ceb2ca6 100644 --- a/frontend/src/components/routes/event/OccurrencesTab/index.tsx +++ b/frontend/src/components/routes/event/OccurrencesTab/index.tsx @@ -25,6 +25,7 @@ import {useFilterQueryParamSync} from "../../../../hooks/useFilterQueryParamSync import {EventOccurrence, EventOccurrenceStatus, MessageType, QueryFilterFields, QueryFilterOperator, QueryFilters} from "../../../../types.ts"; import {useGetEvent} from "../../../../queries/useGetEvent.ts"; import {formatDateWithLocale} from "../../../../utilites/dates.ts"; +import {getEventLocationDisplay} from "../../../../utilites/effectiveLocation.ts"; import {formatCurrency} from "../../../../utilites/currency.ts"; import {OccurrenceEditModal} from "./OccurrenceEditModal"; import {OccurrenceBulkEditModal} from "./OccurrenceBulkEditModal"; @@ -39,12 +40,10 @@ import {showError, showSuccess} from "../../../../utilites/notifications.tsx"; import {GroupedOccurrenceTable, GroupedTableColumn} from "./GroupedOccurrenceTable"; import {OccurrenceMenuItems, OccurrenceMenuActions, statusLabel, StatusIcon} from "./OccurrenceMenu"; import {openCancelOccurrenceDialog} from "./cancelOccurrenceDialog"; -import {launchCheckInForOccurrence} from "./checkInLaunch"; +import {useOccurrenceCheckIn} from "../../../../hooks/useOccurrenceCheckIn.tsx"; import {ManageOccurrenceModal} from "../../../modals/ManageOccurrenceModal"; import {SendMessageModal} from "../../../modals/SendMessageModal"; import {ShareModal} from "../../../modals/ShareModal"; -import {CreateCheckInListModal} from "../../../modals/CreateCheckInListModal"; -import {useGetEventCheckInLists} from "../../../../queries/useGetCheckInLists.ts"; import {eventHomepageUrl} from "../../../../utilites/urlHelper.ts"; import classes from './OccurrencesTab.module.scss'; @@ -128,10 +127,7 @@ const OccurrencesTab = () => { const [defaultDate, setDefaultDate] = useState(); const [messageOccurrenceId, setMessageOccurrenceId] = useState(); const [shareOccurrence, setShareOccurrence] = useState(); - const [createCheckInForOccurrenceId, setCreateCheckInForOccurrenceId] = useState(); - - const checkInListsQuery = useGetEventCheckInLists(eventId); - const checkInLists = checkInListsQuery?.data?.data; + const {launchCheckIn, checkInModals} = useOccurrenceCheckIn(eventId); const cancelMutation = useCancelOccurrence(); const deleteMutation = useDeleteEventOccurrence(); @@ -262,19 +258,6 @@ const OccurrencesTab = () => { }); }; - const handleCheckIn = useCallback((occurrenceId: number) => { - if (checkInListsQuery.isLoading || checkInListsQuery.isFetching) { - showError(t`Please try again.`); - return; - } - - launchCheckInForOccurrence({ - occurrenceId, - checkInLists, - onCreateForOccurrence: setCreateCheckInForOccurrenceId, - }); - }, [checkInLists, checkInListsQuery.isFetching, checkInListsQuery.isLoading]); - const handlePageChange = (value: number) => { setSelectedIds(new Set()); setSearchParams({pageNumber: value}); @@ -295,7 +278,7 @@ const OccurrencesTab = () => { onNavigate: navigate, onDuplicate: handleDuplicate, onMessage: (id: number) => setMessageOccurrenceId(id), - onCheckIn: handleCheckIn, + onCheckIn: launchCheckIn, onReactivate: handleReactivate, onShare: (occ: EventOccurrence) => setShareOccurrence(occ), }; @@ -311,6 +294,7 @@ const OccurrencesTab = () => { const endTime = occ.end_date ? formatDateWithLocale(occ.end_date, 'timeOnly', event.timezone) : null; + const locationDisplay = getEventLocationDisplay(event, occ); return ( { {occ.label && (
{occ.label}
)} + {locationDisplay && ( +
+ {locationDisplay.isOnline ? t`Online` : locationDisplay.short} +
+ )}
); }, @@ -682,12 +671,7 @@ const OccurrencesTab = () => { /> )} - {createCheckInForOccurrenceId && ( - setCreateCheckInForOccurrenceId(undefined)} - initialOccurrenceId={createCheckInForOccurrenceId} - /> - )} + {checkInModals} {shareOccurrence && event && ( { /> {isRecurring ? ( -
- {t`Dates and times are managed on the`}{' '} - - {t`Occurrence Schedule page`} - . -
+ + {t`This event's dates and times are set on the occurrence schedule.`} +
+ +
+
) : ( void; } const SelectProducts = (props: SelectProductsProps) => { @@ -153,6 +154,13 @@ const SelectProducts = (props: SelectProductsProps) => { const [selectedOccurrenceId, setSelectedOccurrenceId] = useState(undefined); + const {onSelectedOccurrenceChange} = props; + useEffect(() => { + onSelectedOccurrenceChange?.( + (event?.occurrences || []).find(o => Number(o.id) === selectedOccurrenceId) + ); + }, [selectedOccurrenceId, event?.occurrences, onSelectedOccurrenceChange]); + const form = useForm({ initialValues: { products: undefined, @@ -426,6 +434,19 @@ const SelectProducts = (props: SelectProductsProps) => { || products?.every(product => product.is_sold_out) || (needsOccurrenceSelection && !occurrenceSelected); + const unavailableMessage = (() => { + if (eventHasEnded) { + return t`Ticket sales have ended for this event`; + } + if (isRecurring && activeOccurrences.length === 0) { + return t`There are no upcoming dates for this event`; + } + if (!productAreAvailable) { + return t`There are no products available for this event`; + } + return null; + })(); + let productIndex = 0; return ( @@ -439,24 +460,10 @@ const SelectProducts = (props: SelectProductsProps) => { '--widget-secondary-text-color': props.colors?.secondaryText, '--widget-padding': props?.padding, } as React.CSSProperties}> - {!productAreAvailable && !eventHasEnded && ( -
-

- {t`There are no products available for this event`} -

-
- )} - {isRecurring && activeOccurrences.length === 0 && ( -
-

- {t`There are no upcoming dates for this event`} -

-
- )} - {eventHasEnded && ( + {unavailableMessage && (

- {t`Ticket sales have ended for this event`} + {unavailableMessage}

)} @@ -670,7 +677,7 @@ const SelectProducts = (props: SelectProductsProps) => { {product.title}
- {(product.is_available && !!product.quantity_available) && ( + {(product.is_available && !!product.quantity_available && !(isRecurring && product.product_type === ProductType.Ticket)) && ( <> {product.quantity_available === Constants.INFINITE_TICKETS && ( diff --git a/frontend/src/components/routes/welcome/Welcome.module.scss b/frontend/src/components/routes/welcome/Welcome.module.scss index 6be598a113..51477aae49 100644 --- a/frontend/src/components/routes/welcome/Welcome.module.scss +++ b/frontend/src/components/routes/welcome/Welcome.module.scss @@ -414,6 +414,68 @@ } } +.recurringToggle { + margin-bottom: 0.875rem; + padding: 0.75rem 1rem; + border: 1px solid var(--hi-color-gray-2); + border-radius: 8px; + background: var(--hi-color-white); + transition: border-color 0.15s ease, background 0.15s ease; + + &:hover { + border-color: var(--hi-primary); + } + + &:has(input:checked) { + border-color: var(--hi-primary); + background: var(--hi-secondary); + } +} + +.recurringCallout { + margin-bottom: 1em; +} + +.recurringToggleBody { + width: 100%; + align-items: center; + justify-content: space-between; +} + +.recurringToggleLabelWrapper { + cursor: pointer; +} + +.recurringToggleLabel { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.recurringToggleIcon { + flex-shrink: 0; + color: var(--hi-primary); +} + +.recurringToggleText { + display: flex; + flex-direction: column; + gap: 2px; +} + +.recurringToggleTitle { + font-size: 0.95rem; + font-weight: 500; + line-height: 1.2; + color: var(--hi-text); +} + +.recurringToggleHint { + font-size: 0.8rem; + line-height: 1.2; + color: var(--hi-secondary-text); +} + // Clean button .primaryButton { diff --git a/frontend/src/components/routes/welcome/index.tsx b/frontend/src/components/routes/welcome/index.tsx index 5e75f79129..637bec5089 100644 --- a/frontend/src/components/routes/welcome/index.tsx +++ b/frontend/src/components/routes/welcome/index.tsx @@ -2,11 +2,11 @@ import {useGetMe} from "../../../queries/useGetMe.ts"; import {useGetOrganizers} from "../../../queries/useGetOrganizers.ts"; import {t, Trans} from "@lingui/macro"; import {Card} from "../../common/Card"; -import {Button, Center, Container, PinInput, Select, Stack, Text, TextInput} from "@mantine/core"; +import {Button, Center, Container, PinInput, Select, Stack, Switch, Text, TextInput} from "@mantine/core"; import classes from "./Welcome.module.scss"; import {useForm} from "@mantine/form"; import {useDebouncedValue, useMediaQuery} from "@mantine/hooks"; -import {Event} from "../../../types.ts"; +import {Event, EventType, IdParam} from "../../../types.ts"; import {useCreateEvent} from "../../../mutations/useCreateEvent.ts"; import {NavLink, useNavigate} from "react-router"; import {useEffect, useRef, useState} from "react"; @@ -15,11 +15,12 @@ import {LoadingContainer} from "../../common/LoadingContainer"; import {OrganizerCreateForm} from "../../forms/OrganizerForm"; import {useConfirmEmailWithCode} from "../../../mutations/useConfirmEmailWithCode.ts"; import {useResendEmailConfirmation} from "../../../mutations/useResendEmailConfirmation.ts"; -import {IconClock, IconMailCheck, IconSparkles} from "@tabler/icons-react"; +import {IconCalendarRepeat, IconClock, IconMailCheck, IconSparkles} from "@tabler/icons-react"; import {showError, showSuccess} from "../../../utilites/notifications.tsx"; import {DateTimePicker} from "@mantine/dates"; import dayjs from "dayjs"; import {EventCategories} from "../../../constants/eventCategories.ts"; +import {Callout} from "../../common/Callout"; import {getConfig} from "../../../utilites/config.ts"; import {trackEvent, AnalyticsEvents} from "../../../utilites/analytics.ts"; @@ -98,7 +99,7 @@ const ConfirmVerificationPin = ({progressInfo}: { form.reset(); setCompletedPin(''); }, - onError: (error) => { + onError: (error: any) => { showError(error.response?.data?.message || t`Failed to verify email`); // Clear the pin on error so user can try again form.reset(); @@ -217,21 +218,35 @@ const ConfirmVerificationPin = ({progressInfo}: { ); } +interface CreateEventFormValues { + title: string; + type: EventType; + start_date: string | Date | null; + end_date: string | Date | null; + category: string; + organizer_id?: IdParam; +} + export const CreateEvent = ({progressInfo}: { progressInfo?: { currentStep: number, totalSteps: number, progressPercentage: number } }) => { const [selectedCategory, setSelectedCategory] = useState(null); - const form = useForm({ + const form = useForm({ initialValues: { title: '', + type: EventType.SINGLE, start_date: dayjs().add(1, 'day').hour(19).minute(0).second(0).toDate(), end_date: dayjs().add(1, 'day').hour(21).minute(0).second(0).toDate(), category: '', }, validate: { title: (value) => !value ? t`Event name is required` : null, - start_date: (value) => !value ? t`Start date is required` : null, + start_date: (value, values) => { + if (values.type === EventType.RECURRING) return null; + return !value ? t`Start date is required` : null; + }, end_date: (value, values) => { + if (values.type === EventType.RECURRING) return null; if (value && values.start_date && dayjs(value).isBefore(dayjs(values.start_date))) { return t`End date must be after start date`; } @@ -245,19 +260,26 @@ export const CreateEvent = ({progressInfo}: { pageNumber: 1, }); - const handleSubmit = (values: Partial) => { - const submitData = { + const isRecurring = form.values.type === EventType.RECURRING; + + const handleRecurringToggle = (checked: boolean) => { + form.setFieldValue('type', checked ? EventType.RECURRING : EventType.SINGLE); + }; + + const handleSubmit = (values: CreateEventFormValues) => { + const recurring = values.type === EventType.RECURRING; + const submitData: Partial = { ...values, - start_date: values.start_date ? dayjs(values.start_date).toISOString() : undefined, - end_date: values.end_date ? dayjs(values.end_date).toISOString() : undefined, + start_date: !recurring && values.start_date ? dayjs(values.start_date).toISOString() : undefined, + end_date: !recurring && values.end_date ? dayjs(values.end_date).toISOString() : undefined, }; eventMutation.mutate({ eventData: submitData, }, { - onSuccess: (values) => { + onSuccess: (result) => { trackEvent(AnalyticsEvents.FIRST_EVENT_CREATED); - navigate(`/manage/event/${values.data.id}/dashboard?new_event=true`) + navigate(`/manage/event/${result.data.id}/dashboard?new_event=true`); } }); } @@ -358,43 +380,82 @@ export const CreateEvent = ({progressInfo}: {
{/* Date & Time */} -
- { - form.setFieldValue('start_date', value); - if (form.values.end_date && value && dayjs(form.values.end_date).isBefore(dayjs(value))) { - form.setFieldValue('end_date', dayjs(value).add(2, 'hours').toDate()); - } - }} +
+ handleRecurringToggle(event.currentTarget.checked)} disabled={eventMutation.isPending} - /> - - + + + + {t`This is a recurring event`} + + + {t`It happens on more than one date`} + + + + } /> + + {isRecurring ? ( + } + title={t`Set up your schedule in the next steps`} + className={classes.recurringCallout} + > + {t`After your event is created, you can choose how often it repeats from the dashboard.`} + + ) : ( +
+ { + form.setFieldValue('start_date', value); + if (form.values.end_date && value && dayjs(form.values.end_date).isBefore(dayjs(value))) { + form.setFieldValue('end_date', dayjs(value).add(2, 'hours').toDate()); + } + }} + disabled={eventMutation.isPending} + /> + + +
+ )}