diff --git a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php index 44ee7d4597..063047b600 100644 --- a/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/EventSettingDomainObjectAbstract.php @@ -69,6 +69,7 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ final public const ALLOW_COPY_DETAILS_TO_ALL_ATTENDEES = 'allow_copy_details_to_all_attendees'; final public const SHOW_AVAILABLE_OCCURRENCE_CAPACITY = 'show_available_occurrence_capacity'; final public const HIDE_SOLD_OUT_OCCURRENCES = 'hide_sold_out_occurrences'; + final public const GET_TICKETS_BUTTON_TEXT = 'get_tickets_button_text'; protected int $id; protected int $event_id; @@ -129,6 +130,7 @@ abstract class EventSettingDomainObjectAbstract extends \HiEvents\DomainObjects\ protected bool $allow_copy_details_to_all_attendees = true; protected bool $show_available_occurrence_capacity = false; protected bool $hide_sold_out_occurrences = false; + protected ?string $get_tickets_button_text = null; public function toArray(): array { @@ -192,6 +194,7 @@ public function toArray(): array 'allow_copy_details_to_all_attendees' => $this->allow_copy_details_to_all_attendees ?? null, 'show_available_occurrence_capacity' => $this->show_available_occurrence_capacity ?? null, 'hide_sold_out_occurrences' => $this->hide_sold_out_occurrences ?? null, + 'get_tickets_button_text' => $this->get_tickets_button_text ?? null, ]; } @@ -844,4 +847,15 @@ public function getHideSoldOutOccurrences(): bool { return $this->hide_sold_out_occurrences; } + + public function setGetTicketsButtonText(?string $get_tickets_button_text): self + { + $this->get_tickets_button_text = $get_tickets_button_text; + return $this; + } + + public function getGetTicketsButtonText(): ?string + { + return $this->get_tickets_button_text; + } } diff --git a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php index 1ca4a475c0..fcf5d328b8 100644 --- a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php +++ b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php @@ -23,6 +23,7 @@ public function rules(): array 'email_footer_message' => ['string', 'nullable'], 'continue_button_text' => ['string', 'nullable', 'max:100'], + 'get_tickets_button_text' => ['string', 'nullable', 'max:100'], 'support_email' => ['email', 'nullable'], 'require_attendee_details' => ['boolean'], 'attendee_details_collection_method' => [Rule::in(AttendeeDetailsCollectionMethod::valuesArray())], diff --git a/backend/app/Resources/Event/EventSettingsResource.php b/backend/app/Resources/Event/EventSettingsResource.php index f54a52084b..69360b99bf 100644 --- a/backend/app/Resources/Event/EventSettingsResource.php +++ b/backend/app/Resources/Event/EventSettingsResource.php @@ -17,6 +17,7 @@ public function toArray($request): array 'post_checkout_message' => $this->getPostCheckoutMessage(), 'product_page_message' => $this->getProductPageMessage(), 'continue_button_text' => $this->getContinueButtonText(), + 'get_tickets_button_text' => $this->getGetTicketsButtonText(), 'required_attendee_details' => $this->getRequireAttendeeDetails(), 'attendee_details_collection_method' => $this->getAttendeeDetailsCollectionMethod(), 'email_footer_message' => $this->getEmailFooterMessage(), diff --git a/backend/app/Resources/Event/EventSettingsResourcePublic.php b/backend/app/Resources/Event/EventSettingsResourcePublic.php index 0ee00a04a8..882f838b8d 100644 --- a/backend/app/Resources/Event/EventSettingsResourcePublic.php +++ b/backend/app/Resources/Event/EventSettingsResourcePublic.php @@ -31,6 +31,7 @@ public function toArray($request): array 'product_page_message' => $this->getProductPageMessage(), 'continue_button_text' => $this->getContinueButtonText(), + 'get_tickets_button_text' => $this->getGetTicketsButtonText(), 'required_attendee_details' => $this->getRequireAttendeeDetails(), 'attendee_details_collection_method' => $this->getAttendeeDetailsCollectionMethod(), 'email_footer_message' => $this->getEmailFooterMessage(), diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php index a058feb7f9..f1489858c6 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php @@ -88,6 +88,8 @@ public function __construct( // Waitlist settings public readonly ?bool $waitlist_auto_process = null, public readonly ?int $waitlist_offer_timeout_minutes = null, + + public readonly ?string $get_tickets_button_text = null, ) {} public static function createWithDefaults( diff --git a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php index debdd52d07..442b5b803e 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandler.php @@ -46,6 +46,9 @@ public function handle(PartialUpdateEventSettingsDTO $eventSettingsDTO): EventSe 'continue_button_text' => array_key_exists('continue_button_text', $eventSettingsDTO->settings) ? $eventSettingsDTO->settings['continue_button_text'] : $existingSettings->getContinueButtonText(), + 'get_tickets_button_text' => array_key_exists('get_tickets_button_text', $eventSettingsDTO->settings) + ? $eventSettingsDTO->settings['get_tickets_button_text'] + : $existingSettings->getGetTicketsButtonText(), 'homepage_background_color' => $eventSettingsDTO->settings['homepage_background_color'] ?? $existingSettings->getHomepageBackgroundColor(), 'homepage_primary_color' => $eventSettingsDTO->settings['homepage_primary_color'] ?? $existingSettings->getHomepagePrimaryColor(), diff --git a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php index b95aa11943..4f53441571 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php +++ b/backend/app/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandler.php @@ -40,6 +40,9 @@ public function handle(UpdateEventSettingsDTO $settings): EventSettingDomainObje 'require_attendee_details' => $settings->require_attendee_details, 'attendee_details_collection_method' => $settings->attendee_details_collection_method->name, 'continue_button_text' => trim($settings->continue_button_text), + 'get_tickets_button_text' => $settings->get_tickets_button_text === null + ? null + : trim($settings->get_tickets_button_text), 'homepage_background_color' => $settings->homepage_background_color, 'homepage_primary_color' => $settings->homepage_primary_color, diff --git a/backend/database/migrations/2026_08_08_000000_add_get_tickets_button_text_to_event_settings.php b/backend/database/migrations/2026_08_08_000000_add_get_tickets_button_text_to_event_settings.php new file mode 100644 index 0000000000..f6bbcf2f2f --- /dev/null +++ b/backend/database/migrations/2026_08_08_000000_add_get_tickets_button_text_to_event_settings.php @@ -0,0 +1,22 @@ +string('get_tickets_button_text', 100)->nullable(); + }); + } + + public function down(): void + { + Schema::table('event_settings', static function (Blueprint $table) { + $table->dropColumn('get_tickets_button_text'); + }); + } +}; diff --git a/backend/tests/Unit/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandlerTest.php index 9930b62ffe..5b1c11e1db 100644 --- a/backend/tests/Unit/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/EventSettings/PartialUpdateEventSettingsHandlerTest.php @@ -39,14 +39,40 @@ public function test_omitted_show_copy_details_key_falls_back_to_existing_value( $this->assertFalse($dto->allow_copy_details_to_all_attendees); } + public function test_explicit_get_tickets_button_text_is_passed_through(): void + { + $dto = $this->runPartialUpdate( + existingValue: true, + settings: ['get_tickets_button_text' => 'Grab a spot'], + existingGetTicketsButtonText: 'Get Tickets', + ); + + $this->assertSame('Grab a spot', $dto->get_tickets_button_text); + } + + public function test_omitted_get_tickets_button_text_falls_back_to_existing_value(): void + { + $dto = $this->runPartialUpdate( + existingValue: true, + settings: [], + existingGetTicketsButtonText: 'Get Tickets', + ); + + $this->assertSame('Get Tickets', $dto->get_tickets_button_text); + } + /** * Drives the partial handler and returns the UpdateEventSettingsDTO it forwards * to the (mocked) full handler, so we can assert how the field was resolved. */ - private function runPartialUpdate(bool $existingValue, array $settings): UpdateEventSettingsDTO - { + private function runPartialUpdate( + bool $existingValue, + array $settings, + ?string $existingGetTicketsButtonText = null, + ): UpdateEventSettingsDTO { $existingSettings = (new EventSettingDomainObject) ->setAllowCopyDetailsToAllAttendees($existingValue) + ->setGetTicketsButtonText($existingGetTicketsButtonText) ->setPaymentProviders([]); $repository = Mockery::mock(EventSettingsRepositoryInterface::class); diff --git a/backend/tests/Unit/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandlerTest.php index dd546b1a8f..10830d0b0b 100644 --- a/backend/tests/Unit/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandlerTest.php +++ b/backend/tests/Unit/Services/Application/Handlers/EventSettings/UpdateEventSettingsHandlerTest.php @@ -155,9 +155,72 @@ public function test_persists_allow_copy_details_to_all_attendees(): void ); } + public function test_persists_trimmed_get_tickets_button_text(): void + { + Event::fake(); + + $existingSettings = new EventSettingDomainObject; + + $this->eventSettingsRepository + ->shouldReceive('findFirstWhere') + ->with(['event_id' => 1]) + ->twice() + ->andReturn($existingSettings); + + $captured = ['missing']; + $this->eventSettingsRepository + ->shouldReceive('updateWhere') + ->once() + ->andReturnUsing(function (...$args) use (&$captured) { + foreach ($args as $arg) { + if (is_array($arg) && array_key_exists('get_tickets_button_text', $arg)) { + $captured = [$arg['get_tickets_button_text']]; + } + } + + return 1; + }); + + $this->handler->handle($this->createDTO(get_tickets_button_text: ' Grab a spot ')); + + $this->assertSame(['Grab a spot'], $captured); + } + + public function test_persists_null_get_tickets_button_text(): void + { + Event::fake(); + + $existingSettings = new EventSettingDomainObject; + + $this->eventSettingsRepository + ->shouldReceive('findFirstWhere') + ->with(['event_id' => 1]) + ->twice() + ->andReturn($existingSettings); + + $captured = ['missing']; + $this->eventSettingsRepository + ->shouldReceive('updateWhere') + ->once() + ->andReturnUsing(function (...$args) use (&$captured) { + foreach ($args as $arg) { + if (is_array($arg) && array_key_exists('get_tickets_button_text', $arg)) { + $captured = [$arg['get_tickets_button_text']]; + } + } + + return 1; + }); + + $this->handler->handle($this->createDTO()); + + $this->assertSame([null], $captured); + } + private function createDTO( ?bool $waitlist_auto_process = null, bool $allow_copy_details_to_all_attendees = true, + ?string $get_tickets_button_text = null, ): UpdateEventSettingsDTO { return UpdateEventSettingsDTO::fromArray([ 'account_id' => 1, @@ -166,6 +229,7 @@ private function createDTO( 'pre_checkout_message' => null, 'email_footer_message' => null, 'continue_button_text' => 'Continue', + 'get_tickets_button_text' => $get_tickets_button_text, 'support_email' => 'test@test.com', 'homepage_background_color' => '#ffffff', 'homepage_primary_color' => '#000000', diff --git a/frontend/src/components/layouts/EventHomepage/index.tsx b/frontend/src/components/layouts/EventHomepage/index.tsx index 90fc714bc4..451e9f2805 100644 --- a/frontend/src/components/layouts/EventHomepage/index.tsx +++ b/frontend/src/components/layouts/EventHomepage/index.tsx @@ -1,7 +1,7 @@ import classes from "./EventHomepage.module.scss"; import SelectProducts from "../../routes/product-widget/SelectProducts"; import "../../../styles/widget/default.scss"; -import React, {useEffect, useRef, useState} from "react"; +import React, {useCallback, useEffect, useRef, useState} from "react"; import {EventDocumentHead} from "../../common/EventDocumentHead"; import {eventCoverImage, eventHomepageUrl, imageUrl, organizerHomepageUrl} from "../../../utilites/urlHelper.ts"; import {Event, EventOccurrence, EventType, OrganizerStatus} from "../../../types.ts"; @@ -39,6 +39,7 @@ import {ShareComponent} from "../../common/ShareIcon"; import {EventDateRange} from "../../common/EventDateRange"; import {CalendarOptionsPopover} from "../../common/CalendarOptionsPopover"; import {isDateInPast} from "../../../utilites/dates.ts"; +import {formatCurrency} from "../../../utilites/currency.ts"; interface EventHomepageProps { event?: Event; @@ -52,8 +53,31 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => { const [showScrollButton, setShowScrollButton] = useState(false); const [contactModalOpen, setContactModalOpen] = useState(false); const [selectedOccurrence, setSelectedOccurrence] = useState(); + const [selectedCart, setSelectedCart] = useState({quantity: 0, total: 0}); + const [continueButtonNode, setContinueButtonNode] = useState(null); + const [continueButtonInView, setContinueButtonInView] = useState(false); const ticketsSectionRef = useRef(null); + const handleCartChange = useCallback( + (cart: {quantity: number; total: number}) => setSelectedCart(cart), + [], + ); + + useEffect(() => { + if (!continueButtonNode) { + setContinueButtonInView(false); + return; + } + + const observer = new IntersectionObserver( + ([entry]) => setContinueButtonInView(entry.isIntersecting), + {threshold: 0.5}, + ); + observer.observe(continueButtonNode); + + return () => observer.disconnect(); + }, [continueButtonNode]); + const {consentPending, consentGranted, onConsent} = useOrganizerTrackingPixels( event?.organizer?.settings?.tracking_pixels ); @@ -167,25 +191,17 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => { const getStatusBadge = () => { const products = event.products || event.product_categories?.flatMap(c => c.products || []) || []; - if (products.length === 0) { - return null; - } - - const availableProducts = products.filter(p => p.is_available && !p.is_sold_out); - const allSoldOut = products.every(p => p.is_sold_out); - - if (allSoldOut) { - return {text: t`Sold Out`, variant: 'danger'}; - } - - if (availableProducts.length === 0) { - return null; + if (products.length > 0 && products.every(p => p.is_sold_out)) { + return {text: t`Sold Out`}; } - return {text: t`Tickets Available`, variant: 'success'}; + return null; }; const statusBadge = getStatusBadge(); + const getTicketsButtonText = event.settings?.get_tickets_button_text || t`Get Tickets`; + const continueButtonText = event.settings?.continue_button_text || t`Continue`; + const showFloatingCheckoutButton = selectedCart.quantity > 0 && !!continueButtonNode && !continueButtonInView; return ( <> @@ -561,6 +577,8 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => { showPoweredBy={false} initialOccurrenceId={initialOccurrenceId} onSelectedOccurrenceChange={setSelectedOccurrence} + onCartChange={handleCartChange} + continueButtonRef={setContinueButtonNode} /> @@ -685,14 +703,24 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => { - {/* Floating Scroll Button */} - {showScrollButton && ( + {showFloatingCheckoutButton && ( + + )} + {!showFloatingCheckoutButton && showScrollButton && ( )} diff --git a/frontend/src/components/layouts/EventHomepagePreview/index.tsx b/frontend/src/components/layouts/EventHomepagePreview/index.tsx index 36c2f94df2..15dc58ba40 100644 --- a/frontend/src/components/layouts/EventHomepagePreview/index.tsx +++ b/frontend/src/components/layouts/EventHomepagePreview/index.tsx @@ -9,6 +9,7 @@ import EventHomepage from "../EventHomepage"; interface PreviewSettings { homepage_theme_settings?: Partial; continue_button_text?: string; + get_tickets_button_text?: string; } const EventHomepagePreview = () => { @@ -45,6 +46,7 @@ const EventHomepagePreview = () => { ...event.settings, homepage_theme_settings: previewSettings.homepage_theme_settings as HomepageThemeSettings || event.settings.homepage_theme_settings, continue_button_text: previewSettings.continue_button_text ?? event.settings.continue_button_text, + get_tickets_button_text: previewSettings.get_tickets_button_text ?? event.settings.get_tickets_button_text, } }; } diff --git a/frontend/src/components/routes/event/HomepageDesigner/index.tsx b/frontend/src/components/routes/event/HomepageDesigner/index.tsx index edf7bfbb03..51315b1c97 100644 --- a/frontend/src/components/routes/event/HomepageDesigner/index.tsx +++ b/frontend/src/components/routes/event/HomepageDesigner/index.tsx @@ -26,6 +26,7 @@ import {DEFAULT_HOMEPAGE_FONT} from "../../../../constants/homepageFonts.ts"; interface FormValues { homepage_theme_settings: Partial; continue_button_text: string; + get_tickets_button_text: string; } const HomepageDesigner = () => { @@ -54,6 +55,7 @@ const HomepageDesigner = () => { font_family: DEFAULT_HOMEPAGE_FONT, }, continue_button_text: '', + get_tickets_button_text: '', } }); @@ -67,6 +69,7 @@ const HomepageDesigner = () => { form.setValues({ homepage_theme_settings: themeSettings, continue_button_text: settings.continue_button_text, + get_tickets_button_text: settings.get_tickets_button_text || '', }); } }, [eventSettingsQuery.isFetched]); @@ -91,6 +94,7 @@ const HomepageDesigner = () => { const eventSettings: Partial = { homepage_theme_settings: validatedTheme, continue_button_text: values.continue_button_text, + get_tickets_button_text: values.get_tickets_button_text, // Also update legacy fields for backward compatibility during transition homepage_primary_color: validatedTheme.accent, homepage_body_background_color: validatedTheme.background, @@ -126,6 +130,7 @@ const HomepageDesigner = () => { const settingsToSend = { homepage_theme_settings: themeSettings, continue_button_text: form.values.continue_button_text, + get_tickets_button_text: form.values.get_tickets_button_text, }; const settingsJson = JSON.stringify(settingsToSend); @@ -276,6 +281,13 @@ const HomepageDesigner = () => { size="sm" {...form.getInputProps('continue_button_text')} /> + diff --git a/frontend/src/components/routes/product-widget/SelectProducts/index.tsx b/frontend/src/components/routes/product-widget/SelectProducts/index.tsx index d90f7753c0..8d5f273282 100644 --- a/frontend/src/components/routes/product-widget/SelectProducts/index.tsx +++ b/frontend/src/components/routes/product-widget/SelectProducts/index.tsx @@ -104,6 +104,8 @@ interface SelectProductsProps { showPoweredBy?: boolean; initialOccurrenceId?: number | null; onSelectedOccurrenceChange?: (occurrence?: EventOccurrence) => void; + onCartChange?: (cart: {quantity: number; total: number}) => void; + continueButtonRef?: React.Ref; } const SelectProducts = (props: SelectProductsProps) => { @@ -461,6 +463,37 @@ const SelectProducts = (props: SelectProductsProps) => { return total; }, [form.values.products]); + const selectedProductsTotal = useMemo(() => { + let total = 0; + form.values.products?.forEach(({product_id, quantities}) => { + const product = productsById.get(product_id); + if (!product) { + return; + } + quantities?.forEach(({quantity, price_id, price}) => { + const selectedQuantity = Number(quantity); + if (!selectedQuantity) { + return; + } + if (product.type === 'DONATION') { + total += selectedQuantity * Number(price || 0); + return; + } + const productPrice = product.prices?.find(p => Number(p.id) === price_id); + if (productPrice) { + total += selectedQuantity * getDisplayPrice(productPrice, event?.settings?.price_display_mode); + } + }); + }); + + return total; + }, [form.values.products, productsById, event?.settings?.price_display_mode]); + + const {onCartChange} = props; + useEffect(() => { + onCartChange?.({quantity: selectedProductQuantitySum, total: selectedProductsTotal}); + }, [selectedProductQuantitySum, selectedProductsTotal, onCartChange]); + useEffect(() => { if (form.values.promo_code) { const promo_code = form.values.promo_code; @@ -850,6 +883,7 @@ const SelectProducts = (props: SelectProductsProps) => { }} className={'hi-product-page-message'}/> )}