diff --git a/backend/app/DomainObjects/Generated/EventDailyStatisticDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventDailyStatisticDomainObjectAbstract.php index ac490a6fe7..29c3471e38 100644 --- a/backend/app/DomainObjects/Generated/EventDailyStatisticDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventDailyStatisticDomainObjectAbstract.php @@ -27,6 +27,7 @@ abstract class EventDailyStatisticDomainObjectAbstract extends \HiEvents\DomainO final public const TOTAL_VIEWS = 'total_views'; final public const ATTENDEES_REGISTERED = 'attendees_registered'; final public const ORDERS_CANCELLED = 'orders_cancelled'; + final public const EXTERNAL_REGISTRATION_CLICKS = 'external_registration_clicks'; protected int $id; protected int $event_id; @@ -45,6 +46,7 @@ abstract class EventDailyStatisticDomainObjectAbstract extends \HiEvents\DomainO protected int $total_views = 0; protected int $attendees_registered = 0; protected int $orders_cancelled = 0; + protected int $external_registration_clicks = 0; public function toArray(): array { @@ -66,6 +68,7 @@ public function toArray(): array 'total_views' => $this->total_views ?? null, 'attendees_registered' => $this->attendees_registered ?? null, 'orders_cancelled' => $this->orders_cancelled ?? null, + 'external_registration_clicks' => $this->external_registration_clicks ?? null, ]; } @@ -255,4 +258,15 @@ public function getOrdersCancelled(): int { return $this->orders_cancelled; } + + public function setExternalRegistrationClicks(int $external_registration_clicks): self + { + $this->external_registration_clicks = $external_registration_clicks; + return $this; + } + + public function getExternalRegistrationClicks(): int + { + return $this->external_registration_clicks; + } } diff --git a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php index b0908cf639..4d67ce30c5 100644 --- a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php @@ -64,6 +64,11 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ final public const HOMEPAGE_THEME_SETTINGS = 'homepage_theme_settings'; final public const PASS_PLATFORM_FEE_TO_BUYER = 'pass_platform_fee_to_buyer'; final public const ALLOW_ATTENDEE_SELF_EDIT = 'allow_attendee_self_edit'; + final public const IS_EXTERNAL_REGISTRATION = 'is_external_registration'; + final public const EXTERNAL_REGISTRATION_URL = 'external_registration_url'; + final public const EXTERNAL_REGISTRATION_BUTTON_TEXT = 'external_registration_button_text'; + final public const EXTERNAL_REGISTRATION_MESSAGE = 'external_registration_message'; + final public const EXTERNAL_REGISTRATION_HOST = 'external_registration_host'; protected int $id; protected int $event_id; @@ -119,6 +124,11 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ protected array|string|null $homepage_theme_settings = null; protected bool $pass_platform_fee_to_buyer = false; protected bool $allow_attendee_self_edit = true; + protected bool $is_external_registration = false; + protected ?string $external_registration_url = null; + protected ?string $external_registration_button_text = null; + protected ?string $external_registration_message = null; + protected ?string $external_registration_host = null; public function toArray(): array { @@ -177,6 +187,11 @@ public function toArray(): array 'homepage_theme_settings' => $this->homepage_theme_settings ?? null, 'pass_platform_fee_to_buyer' => $this->pass_platform_fee_to_buyer ?? null, 'allow_attendee_self_edit' => $this->allow_attendee_self_edit ?? null, + 'is_external_registration' => $this->is_external_registration ?? null, + 'external_registration_url' => $this->external_registration_url ?? null, + 'external_registration_button_text' => $this->external_registration_button_text ?? null, + 'external_registration_message' => $this->external_registration_message ?? null, + 'external_registration_host' => $this->external_registration_host ?? null, ]; } @@ -774,4 +789,59 @@ public function getAllowAttendeeSelfEdit(): bool { return $this->allow_attendee_self_edit; } + + public function setIsExternalRegistration(bool $is_external_registration): self + { + $this->is_external_registration = $is_external_registration; + return $this; + } + + public function getIsExternalRegistration(): bool + { + return $this->is_external_registration; + } + + public function setExternalRegistrationUrl(?string $external_registration_url): self + { + $this->external_registration_url = $external_registration_url; + return $this; + } + + public function getExternalRegistrationUrl(): ?string + { + return $this->external_registration_url; + } + + public function setExternalRegistrationButtonText(?string $external_registration_button_text): self + { + $this->external_registration_button_text = $external_registration_button_text; + return $this; + } + + public function getExternalRegistrationButtonText(): ?string + { + return $this->external_registration_button_text; + } + + public function setExternalRegistrationMessage(?string $external_registration_message): self + { + $this->external_registration_message = $external_registration_message; + return $this; + } + + public function getExternalRegistrationMessage(): ?string + { + return $this->external_registration_message; + } + + public function setExternalRegistrationHost(?string $external_registration_host): self + { + $this->external_registration_host = $external_registration_host; + return $this; + } + + public function getExternalRegistrationHost(): ?string + { + return $this->external_registration_host; + } } diff --git a/backend/app/DomainObjects/Generated/EventStatisticDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventStatisticDomainObjectAbstract.php index 259128d3c2..35b74ab3eb 100644 --- a/backend/app/DomainObjects/Generated/EventStatisticDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventStatisticDomainObjectAbstract.php @@ -27,6 +27,7 @@ abstract class EventStatisticDomainObjectAbstract extends \HiEvents\DomainObject final public const TOTAL_REFUNDED = 'total_refunded'; final public const ATTENDEES_REGISTERED = 'attendees_registered'; final public const ORDERS_CANCELLED = 'orders_cancelled'; + final public const EXTERNAL_REGISTRATION_CLICKS = 'external_registration_clicks'; protected int $id; protected int $event_id; @@ -45,6 +46,7 @@ abstract class EventStatisticDomainObjectAbstract extends \HiEvents\DomainObject protected float $total_refunded = 0.0; protected int $attendees_registered = 0; protected int $orders_cancelled = 0; + protected int $external_registration_clicks = 0; public function toArray(): array { @@ -66,6 +68,7 @@ public function toArray(): array 'total_refunded' => $this->total_refunded ?? null, 'attendees_registered' => $this->attendees_registered ?? null, 'orders_cancelled' => $this->orders_cancelled ?? null, + 'external_registration_clicks' => $this->external_registration_clicks ?? null, ]; } @@ -255,4 +258,15 @@ public function getOrdersCancelled(): int { return $this->orders_cancelled; } + + public function setExternalRegistrationClicks(int $external_registration_clicks): self + { + $this->external_registration_clicks = $external_registration_clicks; + return $this; + } + + public function getExternalRegistrationClicks(): int + { + return $this->external_registration_clicks; + } } diff --git a/backend/app/Http/Actions/Events/TrackExternalRegistrationClickAction.php b/backend/app/Http/Actions/Events/TrackExternalRegistrationClickAction.php new file mode 100644 index 0000000000..94c8e12b79 --- /dev/null +++ b/backend/app/Http/Actions/Events/TrackExternalRegistrationClickAction.php @@ -0,0 +1,41 @@ +eventStatisticsIncrementService->incrementExternalRegistrationClick($eventId); + + return $this->successResponse(); + } catch (Throwable $e) { + // Silent failure - log error but don't block user + $this->logger->error( + 'Failed to track external registration click', + [ + 'event_id' => $eventId, + 'exception' => $e::class, + 'message' => $e->getMessage(), + ] + ); + + // Still return success to not block the user's navigation + return $this->successResponse(); + } + } +} diff --git a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php index 78b78b7a3e..1553a4d538 100644 --- a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php +++ b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php @@ -101,6 +101,31 @@ public function rules(): array // Self-service settings 'allow_attendee_self_edit' => ['boolean'], + + // External registration settings + 'is_external_registration' => ['boolean'], + 'external_registration_url' => [ + 'nullable', + 'url', + 'max:500', + Rule::requiredIf(fn() => $this->input('is_external_registration') === true) + ], + 'external_registration_button_text' => [ + 'nullable', + 'string', + 'max:100', + Rule::requiredIf(fn() => $this->input('is_external_registration') === true) + ], + 'external_registration_message' => [ + 'nullable', + 'string', + 'max:500' + ], + 'external_registration_host' => [ + 'nullable', + 'string', + 'max:255' + ], ]; } @@ -143,6 +168,13 @@ public function messages(): array 'homepage_theme_settings.background' => $colorMessage, 'homepage_theme_settings.mode.in' => __('The mode must be light or dark.'), 'homepage_theme_settings.background_type.in' => __('The background type must be COLOR or MIRROR_COVER_IMAGE.'), + + // External registration messages + 'external_registration_url.required' => __('The external registration URL is required when external registration is enabled.'), + 'external_registration_url.url' => __('The external registration URL must be a valid URL.'), + 'external_registration_url.max' => __('The external registration URL may not be greater than 500 characters.'), + 'external_registration_button_text.required' => __('The button text is required when external registration is enabled.'), + 'external_registration_button_text.max' => __('The button text may not be greater than 100 characters.'), ]; } } diff --git a/backend/app/Resources/Event/EventSettingsResource.php b/backend/app/Resources/Event/EventSettingsResource.php index 2d933583c7..776e7cf7af 100644 --- a/backend/app/Resources/Event/EventSettingsResource.php +++ b/backend/app/Resources/Event/EventSettingsResource.php @@ -79,6 +79,13 @@ public function toArray($request): array // Self-service settings 'allow_attendee_self_edit' => $this->getAllowAttendeeSelfEdit(), + + // External registration settings + 'is_external_registration' => $this->getIsExternalRegistration(), + 'external_registration_url' => $this->getExternalRegistrationUrl(), + 'external_registration_button_text' => $this->getExternalRegistrationButtonText(), + 'external_registration_message' => $this->getExternalRegistrationMessage(), + 'external_registration_host' => $this->getExternalRegistrationHost(), ]; } } diff --git a/backend/app/Resources/Event/EventSettingsResourcePublic.php b/backend/app/Resources/Event/EventSettingsResourcePublic.php index b6e0e1a975..357c99649f 100644 --- a/backend/app/Resources/Event/EventSettingsResourcePublic.php +++ b/backend/app/Resources/Event/EventSettingsResourcePublic.php @@ -85,6 +85,13 @@ public function toArray($request): array // Self-service settings 'allow_attendee_self_edit' => $this->getAllowAttendeeSelfEdit(), + + // External registration settings + 'is_external_registration' => $this->getIsExternalRegistration(), + 'external_registration_url' => $this->getExternalRegistrationUrl(), + 'external_registration_button_text' => $this->getExternalRegistrationButtonText(), + 'external_registration_message' => $this->getExternalRegistrationMessage(), + 'external_registration_host' => $this->getExternalRegistrationHost(), ]; } } diff --git a/backend/app/Resources/Event/EventStatisticsResource.php b/backend/app/Resources/Event/EventStatisticsResource.php index 1b9d5c14fe..a2151ccf89 100644 --- a/backend/app/Resources/Event/EventStatisticsResource.php +++ b/backend/app/Resources/Event/EventStatisticsResource.php @@ -23,6 +23,7 @@ public function toArray(Request $request): array 'products_sold' => $this->getProductsSold(), 'attendees_registered' => $this->getAttendeesRegistered(), 'total_refunded' => $this->getTotalRefunded(), + 'external_registration_clicks' => $this->getExternalRegistrationClicks(), ]; } } diff --git a/backend/app/Services/Application/Handlers/Event/DTO/EventStatsResponseDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/EventStatsResponseDTO.php index ef5e25dfd7..6f76d0af55 100644 --- a/backend/app/Services/Application/Handlers/Event/DTO/EventStatsResponseDTO.php +++ b/backend/app/Services/Application/Handlers/Event/DTO/EventStatsResponseDTO.php @@ -25,6 +25,7 @@ public function __construct( public float $total_tax, public float $total_views, public float $total_refunded, + public int $total_external_registration_clicks, ) { } diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php index 3871b386c0..c7dd214214 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php @@ -83,6 +83,13 @@ public function __construct( // Self-service settings public readonly bool $allow_attendee_self_edit = false, + + // External registration settings + public readonly bool $is_external_registration = false, + public readonly ?string $external_registration_url = null, + public readonly ?string $external_registration_button_text = null, + public readonly ?string $external_registration_message = null, + public readonly ?string $external_registration_host = null, ) { } @@ -165,6 +172,13 @@ public static function createWithDefaults( // Self-service defaults allow_attendee_self_edit: false, + + // External registration defaults + is_external_registration: false, + external_registration_url: null, + external_registration_button_text: null, + external_registration_message: null, + external_registration_host: null, ); } } diff --git a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php index fe2c7d0964..8c70429f8f 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php @@ -137,6 +137,21 @@ public function handle(PartialUpdateEventSettingsDTO $eventSettingsDTO): EventSe // Self-service settings 'allow_attendee_self_edit' => $eventSettingsDTO->settings['allow_attendee_self_edit'] ?? $existingSettings->getAllowAttendeeSelfEdit(), + + // External registration settings + 'is_external_registration' => $eventSettingsDTO->settings['is_external_registration'] ?? $existingSettings->getIsExternalRegistration(), + 'external_registration_url' => array_key_exists('external_registration_url', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['external_registration_url'] + : $existingSettings->getExternalRegistrationUrl(), + 'external_registration_button_text' => array_key_exists('external_registration_button_text', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['external_registration_button_text'] + : $existingSettings->getExternalRegistrationButtonText(), + 'external_registration_message' => array_key_exists('external_registration_message', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['external_registration_message'] + : $existingSettings->getExternalRegistrationMessage(), + 'external_registration_host' => array_key_exists('external_registration_host', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['external_registration_host'] + : $existingSettings->getExternalRegistrationHost(), ]), ); } diff --git a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php index 23568784a2..83097abcf2 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php @@ -93,7 +93,14 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje 'homepage_theme_settings' => $settings->homepage_theme_settings, // Self-service settings - 'allow_attendee_self_edit' => $settings->allow_attendee_self_edit + 'allow_attendee_self_edit' => $settings->allow_attendee_self_edit, + + // External registration settings + 'is_external_registration' => $settings->is_external_registration, + 'external_registration_url' => $settings->external_registration_url ? trim($settings->external_registration_url) : null, + 'external_registration_button_text' => $settings->external_registration_button_text ? trim($settings->external_registration_button_text) : null, + 'external_registration_message' => $settings->external_registration_message, + 'external_registration_host' => $settings->external_registration_host ? trim($settings->external_registration_host) : null, ], where: [ 'event_id' => $settings->event_id, diff --git a/backend/app/Services/Domain/Event/DTO/EventDailyStatsResponseDTO.php b/backend/app/Services/Domain/Event/DTO/EventDailyStatsResponseDTO.php index e15c8fd1c5..6cc5aa2e18 100644 --- a/backend/app/Services/Domain/Event/DTO/EventDailyStatsResponseDTO.php +++ b/backend/app/Services/Domain/Event/DTO/EventDailyStatsResponseDTO.php @@ -13,6 +13,7 @@ public function __construct( public int $orders_created, public int $attendees_registered, public float $total_refunded, + public int $external_registration_clicks, ) { diff --git a/backend/app/Services/Domain/Event/EventStatsFetchService.php b/backend/app/Services/Domain/Event/EventStatsFetchService.php index 08da05a41c..8320cedc22 100644 --- a/backend/app/Services/Domain/Event/EventStatsFetchService.php +++ b/backend/app/Services/Domain/Event/EventStatsFetchService.php @@ -32,7 +32,8 @@ public function getEventStats(EventStatsRequestDTO $requestData): EventStatsResp SUM(es.total_fee) AS total_fees, SUM(es.total_views) AS total_views, SUM(es.total_refunded) AS total_refunded, - SUM(es.attendees_registered) AS attendees_registered + SUM(es.attendees_registered) AS attendees_registered, + SUM(es.external_registration_clicks) AS total_external_registration_clicks FROM event_statistics es WHERE es.event_id = :eventId @@ -55,6 +56,7 @@ public function getEventStats(EventStatsRequestDTO $requestData): EventStatsResp total_tax: $totalsResult->total_tax ?? 0, total_views: $totalsResult->total_views ?? 0, total_refunded: $totalsResult->total_refunded ?? 0, + total_external_registration_clicks: $totalsResult->total_external_registration_clicks ?? 0, ); } @@ -82,7 +84,8 @@ public function getDailyEventStats(EventStatsRequestDTO $requestData): Collectio COALESCE(SUM(eds.orders_created), 0) AS orders_created, COALESCE(SUM(eds.products_sold), 0) AS products_sold, COALESCE(SUM(eds.attendees_registered), 0) AS attendees_registered, - COALESCE(SUM(eds.total_refunded), 0) AS total_refunded + COALESCE(SUM(eds.total_refunded), 0) AS total_refunded, + COALESCE(SUM(eds.external_registration_clicks), 0) AS external_registration_clicks FROM date_series ds LEFT JOIN event_daily_statistics eds ON ds.date = eds.date AND eds.deleted_at IS NULL AND eds.event_id = :eventId GROUP BY ds.date @@ -109,6 +112,7 @@ public function getDailyEventStats(EventStatsRequestDTO $requestData): Collectio orders_created: $result->orders_created, attendees_registered: $result->attendees_registered, total_refunded: $result->total_refunded, + external_registration_clicks: $result->external_registration_clicks, ); }); } diff --git a/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php b/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php index d6b7a1b826..41d53166b3 100644 --- a/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php +++ b/backend/app/Services/Domain/EventStatistics/EventStatisticsIncrementService.php @@ -297,4 +297,169 @@ private function incrementProductStatistics(OrderDomainObject $order): void ] ); } + + /** + * Increment external registration click count for an event + * + * @throws EventStatisticsVersionMismatchException + * @throws Throwable + */ + public function incrementExternalRegistrationClick(int $eventId): void + { + $this->retrier->retry( + callableAction: function () use ($eventId): void { + $this->databaseManager->transaction(function () use ($eventId): void { + $this->incrementAggregateExternalClicks($eventId); + $this->incrementDailyExternalClicks($eventId); + }); + }, + onFailure: function (int $attempt, Throwable $e) use ($eventId): void { + $this->logger->error( + 'Failed to increment external registration clicks after multiple attempts', + [ + 'event_id' => $eventId, + 'attempts' => $attempt, + 'exception' => $e::class, + 'message' => $e->getMessage(), + ] + ); + }, + retryOn: [EventStatisticsVersionMismatchException::class] + ); + } + + /** + * Increment aggregate external registration clicks + * + * @throws EventStatisticsVersionMismatchException + */ + private function incrementAggregateExternalClicks(int $eventId): void + { + $eventStatistics = $this->eventStatisticsRepository->findFirstWhere([ + 'event_id' => $eventId, + ]); + + if ($eventStatistics === null) { + $this->eventStatisticsRepository->create([ + 'event_id' => $eventId, + 'external_registration_clicks' => 1, + 'products_sold' => 0, + 'attendees_registered' => 0, + 'sales_total_gross' => 0, + 'sales_total_before_additions' => 0, + 'total_tax' => 0, + 'total_fee' => 0, + 'orders_created' => 0, + 'orders_cancelled' => 0, + ]); + + $this->logger->info( + 'Event aggregate statistics created for external registration click', + [ + 'event_id' => $eventId, + ] + ); + + return; + } + + $updates = [ + 'external_registration_clicks' => $eventStatistics->getExternalRegistrationClicks() + 1, + 'version' => $eventStatistics->getVersion() + 1, + ]; + + $updated = $this->eventStatisticsRepository->updateWhere( + attributes: $updates, + where: [ + 'event_id' => $eventId, + 'version' => $eventStatistics->getVersion(), + ] + ); + + if ($updated === 0) { + throw new EventStatisticsVersionMismatchException( + 'Event statistics version mismatch. Expected version ' + . $eventStatistics->getVersion() . ' but it was already updated.' + ); + } + + $this->logger->info( + 'Event aggregate external registration clicks incremented', + [ + 'event_id' => $eventId, + 'new_version' => $eventStatistics->getVersion() + 1, + ] + ); + } + + /** + * Increment daily external registration clicks + * + * @throws EventStatisticsVersionMismatchException + */ + private function incrementDailyExternalClicks(int $eventId): void + { + $today = Carbon::now()->format('Y-m-d'); + + $eventDailyStatistic = $this->eventDailyStatisticRepository->findFirstWhere([ + 'event_id' => $eventId, + 'date' => $today, + ]); + + if ($eventDailyStatistic === null) { + $this->eventDailyStatisticRepository->create([ + 'event_id' => $eventId, + 'date' => $today, + 'external_registration_clicks' => 1, + 'products_sold' => 0, + 'attendees_registered' => 0, + 'sales_total_gross' => 0, + 'sales_total_before_additions' => 0, + 'total_tax' => 0, + 'total_fee' => 0, + 'orders_created' => 0, + 'orders_cancelled' => 0, + ]); + + $this->logger->info( + 'Event daily statistics created for external registration click', + [ + 'event_id' => $eventId, + 'date' => $today, + ] + ); + + return; + } + + $updates = [ + 'external_registration_clicks' => $eventDailyStatistic->getExternalRegistrationClicks() + 1, + 'version' => $eventDailyStatistic->getVersion() + 1, + ]; + + $updated = $this->eventDailyStatisticRepository->updateWhere( + attributes: $updates, + where: [ + 'event_id' => $eventId, + 'date' => $today, + 'version' => $eventDailyStatistic->getVersion(), + ], + ); + + if ($updated === 0) { + throw new EventStatisticsVersionMismatchException( + 'Event daily statistics version mismatch. Expected version ' + . $eventDailyStatistic->getVersion() . ' but it was already updated.' + ); + } + + $this->logger->info( + 'Event daily external registration clicks incremented', + [ + 'event_id' => $eventId, + 'date' => $today, + 'new_version' => $eventDailyStatistic->getVersion() + 1, + ] + ); + } } diff --git a/backend/database/migrations/2026_01_27_100000_add_external_registration_to_event_settings.php b/backend/database/migrations/2026_01_27_100000_add_external_registration_to_event_settings.php new file mode 100644 index 0000000000..6e30d9f686 --- /dev/null +++ b/backend/database/migrations/2026_01_27_100000_add_external_registration_to_event_settings.php @@ -0,0 +1,27 @@ +boolean('is_external_registration')->default(false); + $table->string('external_registration_url', 500)->nullable(); + $table->string('external_registration_button_text', 100)->nullable(); + }); + } + + public function down(): void + { + Schema::table('event_settings', static function (Blueprint $table) { + $table->dropColumn([ + 'is_external_registration', + 'external_registration_url', + 'external_registration_button_text', + ]); + }); + } +}; diff --git a/backend/database/migrations/2026_01_27_100001_add_external_registration_clicks_to_statistics.php b/backend/database/migrations/2026_01_27_100001_add_external_registration_clicks_to_statistics.php new file mode 100644 index 0000000000..d75d277636 --- /dev/null +++ b/backend/database/migrations/2026_01_27_100001_add_external_registration_clicks_to_statistics.php @@ -0,0 +1,38 @@ +unsignedInteger('external_registration_clicks')->default(0); + }); + } + + if (!Schema::hasColumn('event_daily_statistics', 'external_registration_clicks')) { + Schema::table('event_daily_statistics', function (Blueprint $table) { + $table->unsignedInteger('external_registration_clicks')->default(0); + }); + } + } + + public function down(): void + { + if (Schema::hasColumn('event_statistics', 'external_registration_clicks')) { + Schema::table('event_statistics', function (Blueprint $table) { + $table->dropColumn('external_registration_clicks'); + }); + } + + if (Schema::hasColumn('event_daily_statistics', 'external_registration_clicks')) { + Schema::table('event_daily_statistics', function (Blueprint $table) { + $table->dropColumn('external_registration_clicks'); + }); + } + } +}; diff --git a/backend/database/migrations/2026_01_27_100002_add_external_registration_message_and_host.php b/backend/database/migrations/2026_01_27_100002_add_external_registration_message_and_host.php new file mode 100644 index 0000000000..1eb07b3c03 --- /dev/null +++ b/backend/database/migrations/2026_01_27_100002_add_external_registration_message_and_host.php @@ -0,0 +1,25 @@ +text('external_registration_message')->nullable(); + $table->string('external_registration_host', 255)->nullable(); + }); + } + + public function down(): void + { + Schema::table('event_settings', static function (Blueprint $table) { + $table->dropColumn([ + 'external_registration_message', + 'external_registration_host', + ]); + }); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index c84c87fe99..affd363410 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -52,6 +52,7 @@ use HiEvents\Http\Actions\Events\GetEventAction; use HiEvents\Http\Actions\Events\GetEventPublicAction; use HiEvents\Http\Actions\Events\GetEventsAction; +use HiEvents\Http\Actions\Events\TrackExternalRegistrationClickAction; use HiEvents\Http\Actions\Events\GetOrganizerEventsPublicAction; use HiEvents\Http\Actions\Events\Images\CreateEventImageAction; use HiEvents\Http\Actions\Events\Images\DeleteEventImageAction; @@ -452,6 +453,7 @@ function (Router $router): void { function (Router $router): void { // Events $router->get('/events/{event_id}', GetEventPublicAction::class); + $router->post('/events/{event_id}/external-registration-click', TrackExternalRegistrationClickAction::class); // Organizers $router->get('/organizers/{organizer_id}', GetPublicOrganizerAction::class); diff --git a/frontend/src/api/event.client.ts b/frontend/src/api/event.client.ts index c4777626cb..04b5f79f61 100644 --- a/frontend/src/api/event.client.ts +++ b/frontend/src/api/event.client.ts @@ -102,4 +102,9 @@ export const eventsClientPublic = { const response = await publicApi.get>('events/' + eventId + (promoCode ? '?promo_code=' + promoCode : '')); return response.data; }, + + trackExternalRegistrationClick: async (eventId: IdParam) => { + const response = await publicApi.post('events/' + eventId + '/external-registration-click'); + return response.data; + }, } diff --git a/frontend/src/components/common/EventCard/EventCard.module.scss b/frontend/src/components/common/EventCard/EventCard.module.scss index e1b4bfad6e..270990c645 100644 --- a/frontend/src/components/common/EventCard/EventCard.module.scss +++ b/frontend/src/components/common/EventCard/EventCard.module.scss @@ -344,6 +344,52 @@ font-weight: 500; } +.externalManagementBadge { + display: flex; + align-items: center; + justify-content: center; + padding: 8px 14px; + background: var(--mantine-color-blue-0); + border: 1px solid var(--mantine-color-blue-2); + border-radius: var(--hi-radius-md); + flex-shrink: 0; + + @include mixins.respond-below(lg, true) { + padding: 6px 12px; + } +} + +.externalManagementText { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.externalManagementLabel { + font-size: 10px; + font-weight: 600; + color: var(--mantine-color-dimmed); + text-transform: uppercase; + letter-spacing: 0.5px; + white-space: nowrap; + + @include mixins.respond-below(lg, true) { + font-size: 9px; + } +} + +.externalManagementHost { + font-size: 13px; + font-weight: 700; + color: var(--mantine-color-blue-7); + white-space: nowrap; + + @include mixins.respond-below(lg, true) { + font-size: 12px; + } +} + .menuButton { flex-shrink: 0; diff --git a/frontend/src/components/common/EventCard/index.tsx b/frontend/src/components/common/EventCard/index.tsx index 59ecbc963f..11b6556171 100644 --- a/frontend/src/components/common/EventCard/index.tsx +++ b/frontend/src/components/common/EventCard/index.tsx @@ -165,6 +165,7 @@ export function EventCard({event}: EventCardProps) { const isEnded = event.lifecycle_status === 'ENDED'; const isDraft = event.status === 'DRAFT'; + const isExternalRegistration = event.settings?.is_external_registration; return ( <> @@ -220,6 +221,17 @@ export function EventCard({event}: EventCardProps) { + {isExternalRegistration && ( +
+
+ {t`Externally Managed by`} + + {event.settings.external_registration_host || t`External Platform`} + +
+
+ )} +
diff --git a/frontend/src/components/common/StatBoxes/index.tsx b/frontend/src/components/common/StatBoxes/index.tsx index de13b39e1e..21cf0a3a3b 100644 --- a/frontend/src/components/common/StatBoxes/index.tsx +++ b/frontend/src/components/common/StatBoxes/index.tsx @@ -1,5 +1,5 @@ import classes from "./StatBoxes.module.scss"; -import {IconCash, IconCreditCardRefund, IconEye, IconReceipt, IconShoppingCart, IconUsers} from "@tabler/icons-react"; +import {IconCash, IconCreditCardRefund, IconEye, IconExternalLink, IconReceipt, IconShoppingCart, IconUsers} from "@tabler/icons-react"; import {Card} from "../Card"; import {useGetEventStats} from "../../../queries/useGetEventStats.ts"; import {useParams} from "react-router"; @@ -8,6 +8,7 @@ import {useGetEvent} from "../../../queries/useGetEvent.ts"; import {formatCurrency} from "../../../utilites/currency.ts"; import {formatNumber} from "../../../utilites/helpers.ts"; import {ReactNode} from "react"; +import {useGetEventSettings} from "../../../queries/useGetEventSettings.ts"; interface StatBoxProps { number: string | number; @@ -38,6 +39,7 @@ export const StatBoxes = () => { const eventQuery = useGetEvent(eventId); const event = eventQuery?.data; const {data: eventStats} = eventStatsQuery; + const {data: eventSettings} = useGetEventSettings(eventId); const data = [ { @@ -78,6 +80,15 @@ export const StatBoxes = () => { } ]; + if (eventSettings?.is_external_registration) { + data.push({ + number: formatNumber(eventStats?.total_external_registration_clicks as number), + description: t`Redirections`, + icon: , + backgroundColor: '#F59E0B' + }); + } + return (
{data.map((stat) => ( diff --git a/frontend/src/components/layouts/AppLayout/Sidebar/index.tsx b/frontend/src/components/layouts/AppLayout/Sidebar/index.tsx index cb06fd4014..e264babc77 100644 --- a/frontend/src/components/layouts/AppLayout/Sidebar/index.tsx +++ b/frontend/src/components/layouts/AppLayout/Sidebar/index.tsx @@ -27,6 +27,10 @@ export const Sidebar: React.FC = ({ const renderLinks = () => { return navItems.map((item) => { + if (item.showWhen && !item.showWhen()) { + return null; + } + if (!item.link && item.link !== "" && item.onClick === undefined) { return (
@@ -35,10 +39,6 @@ export const Sidebar: React.FC = ({ ); } - if (item.showWhen && !item.showWhen()) { - return null; - } - if (item.loading) { return  ; } diff --git a/frontend/src/components/layouts/Event/index.tsx b/frontend/src/components/layouts/Event/index.tsx index 877818dd78..d0c705861b 100644 --- a/frontend/src/components/layouts/Event/index.tsx +++ b/frontend/src/components/layouts/Event/index.tsx @@ -100,22 +100,69 @@ const EventLayout = () => { {label: t`Setup & Design`}, {link: 'settings', label: t`Event Settings`, icon: IconSettings}, {link: 'homepage-designer', label: t`Homepage Designer`, icon: IconPaint}, - {link: 'ticket-designer', label: t`Ticket Designer`, icon: IconTicket}, - {link: 'questions', label: t`Registration Questions`, icon: IconUserQuestion}, + { + link: 'ticket-designer', + label: t`Ticket Designer`, + icon: IconTicket, + showWhen: () => !eventSettings?.is_external_registration + }, + { + link: 'questions', + label: t`Registration Questions`, + icon: IconUserQuestion, + showWhen: () => !eventSettings?.is_external_registration + }, // 3. Ticketing & Sales {label: t`Ticketing & Sales`}, - {link: 'products', label: t`Tickets & Products`, icon: IconTicket}, - {link: 'orders', label: t`Orders`, icon: IconReceipt, badge: eventStats?.total_orders}, - {link: 'promo-codes', label: t`Promo Codes`, icon: IconDiscount2}, - {link: 'affiliates', label: t`Affiliates`, icon: IconTrendingUp}, + { + link: 'products', + label: t`Tickets & Products`, + icon: IconTicket + }, + { + link: 'orders', + label: t`Orders`, + icon: IconReceipt, + badge: eventStats?.total_orders, + showWhen: () => !eventSettings?.is_external_registration + }, + { + link: 'promo-codes', + label: t`Promo Codes`, + icon: IconDiscount2, + showWhen: () => !eventSettings?.is_external_registration + }, + { + link: 'affiliates', + label: t`Affiliates`, + icon: IconTrendingUp, + showWhen: () => !eventSettings?.is_external_registration + }, // 4. GUESTS {label: t`Guest Management`}, - {link: 'attendees', label: t`Attendees`, icon: IconUsers, badge: eventStats?.total_attendees_registered}, - {link: 'check-in', label: t`Check-In Lists`, icon: IconQrcode}, - {link: 'messages', label: t`Messages`, icon: IconSend}, - {link: 'capacity-assignments', label: t`Capacity Management`, icon: IconUsersGroup}, + { + link: 'attendees', + label: t`Attendees`, + icon: IconUsers, + badge: eventStats?.total_attendees_registered + }, + { + link: 'check-in', + label: t`Check-In Lists`, + icon: IconQrcode + }, + { + link: 'messages', + label: t`Messages`, + icon: IconSend + }, + { + link: 'capacity-assignments', + label: t`Capacity Management`, + icon: IconUsersGroup + }, // 5. INTEGRATIONS {label: t`Integrations`}, diff --git a/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss b/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss index 1637015eb8..4fa49fad10 100644 --- a/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss +++ b/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss @@ -1336,3 +1336,68 @@ $transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1); height: 0.9rem; } } + +// External registration styles +.externalRegistrationCard { + background: var(--card-background); + border-radius: $radius-lg; + padding: 32px; + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + + @include mixins.respond-below(sm) { + padding: 24px; + } +} + +.externalRegistrationHost { + color: var(--text-secondary); + font-size: 0.9rem; + margin: 0; + line-height: 1.5; + + strong { + color: var(--primary-text-color); + font-weight: 600; + } +} + +.externalRegistrationMessage { + color: var(--text-secondary); + font-size: 1rem; + margin: 0; + line-height: 1.6; +} + +.externalRegistrationButton { + display: inline-flex; + align-items: center; + gap: 10px; + background: var(--primary-color); + color: var(--accent-contrast); + font-family: $font-display; + font-weight: 600; + font-size: 1rem; + padding: 14px 28px; + border-radius: 50px; + text-decoration: none; + transition: all $transition-fast; + box-shadow: 0 2px 12px var(--accent-soft); + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 20px var(--accent-soft); + } + + &:active { + transform: translateY(0); + } + + @include mixins.respond-below(sm) { + padding: 12px 24px; + font-size: 0.9375rem; + } +} diff --git a/frontend/src/components/layouts/EventHomepage/index.tsx b/frontend/src/components/layouts/EventHomepage/index.tsx index 42d8114e9a..7b1a087e8a 100644 --- a/frontend/src/components/layouts/EventHomepage/index.tsx +++ b/frontend/src/components/layouts/EventHomepage/index.tsx @@ -5,6 +5,8 @@ import React, {useEffect, useRef, useState} from "react"; import {EventDocumentHead} from "../../common/EventDocumentHead"; import {eventCoverImage, eventHomepageUrl, imageUrl, organizerHomepageUrl} from "../../../utilites/urlHelper.ts"; import {Event, OrganizerStatus} from "../../../types.ts"; +import {eventsClientPublic} from "../../../api/event.client.ts"; +import {trackEvent, AnalyticsEvents} from "../../../utilites/analytics.ts"; import {EventNotAvailable} from "./EventNotAvailable"; import { IconArrowUpRight, @@ -90,6 +92,16 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => { ticketsSectionRef.current?.scrollIntoView({behavior: 'smooth', block: 'start'}); }; + const handleExternalRegistrationClick = async () => { + trackEvent(AnalyticsEvents.EXTERNAL_REGISTRATION_CLICKED); + try { + await eventsClientPublic.trackExternalRegistrationClick(event?.id); + } catch (error) { + // Silent fail - don't block navigation + console.error('Failed to track click:', error); + } + }; + if (!event) { return ; } @@ -468,25 +480,57 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => { )} {/* Tickets Section */} -
- -
+ {!event.settings?.is_external_registration && ( +
+ +
+ )} + + {/* External Registration Section */} + {event.settings?.is_external_registration && ( +
+
+

{t`Registration`}

+
+
+ {event.settings.external_registration_host && ( +

+ {t`Hosted by:`} {event.settings.external_registration_host} +

+ )} +

+ {event.settings.external_registration_message || t`This event uses external registration.`} +

+ + + {event.settings.external_registration_button_text || t`Register Externally`} + +
+
+ )} {/* Organizer Section */} {organizer && organizer.status === OrganizerStatus.LIVE && ( @@ -610,7 +654,7 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => {
{/* Floating Scroll Button */} - {showScrollButton && ( + {showScrollButton && !event.settings?.is_external_registration && ( )} + {/* Floating External Registration Button */} + {showScrollButton && event.settings?.is_external_registration && ( + + + {event.settings.external_registration_button_text || t`Register Externally`} + + )} + {/* Contact Modal */} { + const {eventId} = useParams(); + const eventSettingsQuery = useGetEventSettings(eventId); + const updateMutation = useUpdateEventSettings(); + const form = useForm({ + initialValues: { + is_external_registration: false, + external_registration_url: '', + external_registration_button_text: '', + external_registration_message: '', + external_registration_host: '', + }, + validate: { + external_registration_url: (value, values) => { + if (values.is_external_registration && !value) { + return t`External registration URL is required`; + } + if (value && !value.match(/^https?:\/\/.+/)) { + return t`Please enter a valid URL`; + } + }, + external_registration_button_text: (value, values) => { + if (values.is_external_registration && !value) { + return t`Button text is required`; + } + }, + }, + }); + const formErrorHandle = useFormErrorResponseHandler(); + + useEffect(() => { + if (eventSettingsQuery?.isFetched && eventSettingsQuery?.data) { + form.setValues({ + is_external_registration: eventSettingsQuery.data.is_external_registration ?? false, + external_registration_url: eventSettingsQuery.data.external_registration_url ?? '', + external_registration_button_text: eventSettingsQuery.data.external_registration_button_text ?? '', + external_registration_message: eventSettingsQuery.data.external_registration_message ?? '', + external_registration_host: eventSettingsQuery.data.external_registration_host ?? '', + }); + } + }, [eventSettingsQuery.isFetched]); + + const handleSubmit = (values: Partial) => { + updateMutation.mutate({ + eventSettings: values, + eventId: eventId, + }, { + onSuccess: () => { + showSuccess(t`Registration settings updated successfully`); + }, + onError: (error) => { + formErrorHandle(form, error); + } + }); + } + + return ( + + +
+
+ + + {form.values.is_external_registration && ( + <> + } + maxLength={255} + mb="md" + /> + + } + mb="md" + /> + + + +