diff --git a/.claude/rules/features/homepage-sections.md b/.claude/rules/features/homepage-sections.md index 78c63b910a..c01360ef3b 100644 --- a/.claude/rules/features/homepage-sections.md +++ b/.claude/rules/features/homepage-sections.md @@ -2,7 +2,9 @@ paths: - "backend/app/**/*HomepageSection*.php" - "frontend/src/utilites/homepageSection*.ts" - - "frontend/src/components/layouts/EventHomepage/Sections/**" + - "frontend/src/components/layouts/EventHomepage/**" + - "frontend/src/components/layouts/HomepageFrame/**" + - "frontend/src/constants/eventLayouts.ts" - "frontend/src/components/routes/event/HomepageDesigner/**" - "frontend/src/components/routes/organizer/OrganizerHomepageDesigner/**" --- @@ -11,3 +13,7 @@ paths: - User-supplied `homepage_sections` (events and organizers) are saved only through `HomepageSectionsSanitizer`: it purifies HTML, keeps only image ids owned by this event/organizer, and only allows YouTube/Vimeo video links. - The About section renders `event.description`; never copy the description into a section, because SEO, listings, emails and the API all read it. Extra copy belongs in `TEXT` sections. +- Event page layouts (Marquee, Flyer, Story, Invite) are rows of traits in `constants/eventLayouts.ts`; CSS keys off the traits (`data-rail`, `data-poster`, `data-width`, `data-centered`), never layout names. A new layout is a new row, a designer sketch in `LayoutPicker/LayoutPreview.tsx`, and a backend `HomepageLayout` case. `DEFAULT_EVENT_LAYOUT` applies to every event without a saved layout. +- Sections render in columns from ~350px (phones, Flyer on tablet) to ~690px, so their responsive rules use container queries (`hp.narrow`, `hp.compact`) on the section, never viewport media queries. Dialogs portal to `main` and keep viewport rules. +- Status labels ("This event has ended", "Happening now") come from the server's `lifecycle_status`, never the client clock, so SSR and hydration agree. An ended event renders no Tickets block and no ticket rail; Marquee drops to the medium width. +- The intro always shows the venue's full address with Get directions, even when the Location section repeats it lower down. diff --git a/AGENTS.md b/AGENTS.md index d1ffd0ddee..9fbec27952 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,6 +87,7 @@ Integrations call the API with API keys, so endpoints in the `auth:api,sanctum` ## Frontend - SSR app: guard `window`/`document`, and keep per-request state in `AsyncLocalStorage` (`utilites/ssrRequestContext.ts`), never in axios defaults or module globals. +- Public routes set `handle: {styles: ''}` in `router.tsx` so production SSR links their CSS in `` and skips the white `ssr-loader` overlay; without it the page paints unstyled and shifts (CLS) on hydration. - React Query for all API calls (patterns: `queries/useGetCapacityAssignment.ts`, `mutations/useCreateAffiliate.ts`). - Mantine components; SCSS modules for layout. Reuse existing components before adding new ones, and skip unnecessary React imports. - Notifications via `showSuccess`/`showError` (`utilites/notifications.tsx`), never Mantine's `showNotification`; validation errors via `useFormErrorResponseHandler`. Handle errors in parent components. diff --git a/README.de.md b/README.de.md index a4164ca6ac..21eb3d4cf6 100644 --- a/README.de.md +++ b/README.de.md @@ -132,6 +132,8 @@ Entwickelt mit **Laravel 13** (PHP >=8.3) · **React 19** mit SSR · **TypeScrip git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/README.el.md b/README.el.md index 1fbb9e5022..eb57e18ce5 100644 --- a/README.el.md +++ b/README.el.md @@ -132,6 +132,8 @@ branding, το checkout και τα δεδομένα σας. git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/README.es.md b/README.es.md index a54329dfda..fb3c34f582 100644 --- a/README.es.md +++ b/README.es.md @@ -132,6 +132,8 @@ Desarrollado con **Laravel 13** (PHP >=8.3) · **React 19** con SSR · **TypeScr git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/README.fi.md b/README.fi.md index 15dd630c18..7886b717a4 100644 --- a/README.fi.md +++ b/README.fi.md @@ -132,6 +132,8 @@ Rakennettu teknologioilla **Laravel 13** (PHP >=8.3) · **React 19** ja SSR · * git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/README.fr.md b/README.fr.md index ce54ffb63d..07daf4299e 100644 --- a/README.fr.md +++ b/README.fr.md @@ -132,6 +132,8 @@ Développé avec **Laravel 13** (PHP >=8.3) · **React 19** avec SSR · **TypeSc git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/README.hu.md b/README.hu.md index 863470adb4..dbefeac34e 100644 --- a/README.hu.md +++ b/README.hu.md @@ -132,6 +132,8 @@ Technológiák: **Laravel 13** (PHP >=8.3) · **React 19** SSR-rel · **TypeScri git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/README.it.md b/README.it.md index 2d08caf643..7f62a3a96a 100644 --- a/README.it.md +++ b/README.it.md @@ -132,6 +132,8 @@ Realizzato con **Laravel 13** (PHP >=8.3) · **React 19** con SSR · **TypeScrip git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/README.ja.md b/README.ja.md index 109477aa12..49a2382742 100644 --- a/README.ja.md +++ b/README.ja.md @@ -132,6 +132,8 @@ Eventbrite、Tickettailor、Dice.fm などのチケット販売プラットフ git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/README.ko.md b/README.ko.md index 8b73e5ab92..18c24fc505 100644 --- a/README.ko.md +++ b/README.ko.md @@ -132,6 +132,8 @@ git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/README.md b/README.md index 2eeb8d168e..6bd8d9c349 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,8 @@ Built with **Laravel 13** (PHP >=8.3) · **React 19** with SSR · **TypeScript** git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/README.nl.md b/README.nl.md index 16b0ecca93..d24422c926 100644 --- a/README.nl.md +++ b/README.nl.md @@ -132,6 +132,8 @@ Gebouwd met **Laravel 13** (PHP >=8.3) · **React 19** met SSR · **TypeScript** git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/README.pl.md b/README.pl.md index e5a3fe1fdf..a5d53f693d 100644 --- a/README.pl.md +++ b/README.pl.md @@ -132,6 +132,8 @@ Zbudowany na **Laravel 13** (PHP >=8.3) · **React 19** z SSR · **TypeScript** git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/README.pt-br.md b/README.pt-br.md index b875d1020b..253a7855bf 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -132,6 +132,8 @@ Desenvolvido com **Laravel 13** (PHP >=8.3) · **React 19** com SSR · **TypeScr git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/README.pt.md b/README.pt.md index ffeaee7c08..13691a3832 100644 --- a/README.pt.md +++ b/README.pt.md @@ -132,6 +132,8 @@ Desenvolvido com **Laravel 13** (PHP >=8.3) · **React 19** com SSR · **TypeScr git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/README.sk.md b/README.sk.md index ac1fa0f4a9..c0fa6a8fef 100644 --- a/README.sk.md +++ b/README.sk.md @@ -132,6 +132,8 @@ Postavené na **Laravel 13** (PHP >=8.3) · **React 19** so SSR · **TypeScript* git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/README.sv.md b/README.sv.md index 8b6c596013..eba7c3cc16 100644 --- a/README.sv.md +++ b/README.sv.md @@ -132,6 +132,8 @@ Byggt med **Laravel 13** (PHP >=8.3) · **React 19** med SSR · **TypeScript** git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/README.tr.md b/README.tr.md index 6874bd1023..01941b6368 100644 --- a/README.tr.md +++ b/README.tr.md @@ -132,6 +132,8 @@ ve konferans düzenleyicilerine kadar. Kendiniz çalıştırın ya da Hi.Events git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/README.vi.md b/README.vi.md index 34a1cb2cb8..324a9d54df 100644 --- a/README.vi.md +++ b/README.vi.md @@ -132,6 +132,8 @@ Xây dựng với **Laravel 13** (PHP >=8.3) · **React 19** với SSR · **Type git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/README.zh-cn.md b/README.zh-cn.md index 3edc7ca748..8ea9f1582b 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -132,6 +132,8 @@ git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/README.zh-hk.md b/README.zh-hk.md index f1675a22ba..e9881c5450 100644 --- a/README.zh-hk.md +++ b/README.zh-hk.md @@ -132,6 +132,8 @@ git clone https://github.com/HiEventsDev/hi.events.git cd hi.events/docker/all-in-one +cp .env.example .env + # Generate keys (Linux/macOS) echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env diff --git a/backend/app/Console/Commands/DeleteSeptember10SignupWaveCommand.php b/backend/app/Console/Commands/DeleteSeptember10SignupWaveCommand.php new file mode 100644 index 0000000000..98e81a5713 --- /dev/null +++ b/backend/app/Console/Commands/DeleteSeptember10SignupWaveCommand.php @@ -0,0 +1,263 @@ +option('limit'); + + if ($limit !== null && (int) $limit < 1) { + $this->error('--limit must be a positive number.'); + + return self::FAILURE; + } + + $candidates = $this->findCandidates($databaseManager->connection()); + + [$deletable, $skipped] = $candidates->partition(fn (object $candidate): bool => $candidate->skip_reasons === []); + + $this->info(sprintf( + '%d of the %d listed accounts can be deleted, %d skipped, %d already deleted or no longer owned by a 2026-09-10 signup.', + $deletable->count(), + count(self::ACCOUNT_IDS), + $skipped->count(), + count(self::ACCOUNT_IDS) - $candidates->count(), + )); + + if ($skipped->isNotEmpty()) { + $this->table( + ['Account', 'Owner', 'Skipped because'], + $skipped->map(fn (object $candidate): array => [ + $candidate->account_id, + $candidate->email, + implode(', ', $candidate->skip_reasons), + ])->all(), + ); + } + + if (! $this->option('force')) { + $this->warn('Nothing was deleted. Run again with --force to permanently delete these accounts.'); + + return self::SUCCESS; + } + + $batch = $limit === null ? $deletable : $deletable->take((int) $limit); + $failures = 0; + + $progressBar = $this->output->createProgressBar($batch->count()); + $progressBar->start(); + + foreach ($batch as $candidate) { + try { + $hardDeletionService->deleteAccount($candidate->account_id); + } catch (Throwable $exception) { + $failures++; + $this->newLine(); + $this->error(sprintf('Account %d was not deleted: %s', $candidate->account_id, $exception->getMessage())); + } + + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(); + + $this->info(sprintf('Deleted %d account(s).', $batch->count() - $failures)); + + $remaining = $deletable->count() - $batch->count(); + + if ($remaining > 0) { + $this->info(sprintf('%d account(s) left. Run the command again to continue.', $remaining)); + } + + return $failures === 0 ? self::SUCCESS : self::FAILURE; + } + + private function findCandidates(Connection $connection): Collection + { + $checks = $this->skipChecks($connection); + + $query = $connection->table('accounts') + ->join('account_users', 'account_users.account_id', '=', 'accounts.id') + ->join('users', 'users.id', '=', 'account_users.user_id') + ->whereIn('accounts.id', self::ACCOUNT_IDS) + ->where('account_users.is_account_owner', true) + ->whereNull('accounts.deleted_at') + ->whereNull('account_users.deleted_at') + ->whereNull('users.deleted_at') + ->where('accounts.created_at', '>=', self::SIGNUP_DAY_START) + ->where('accounts.created_at', '<', self::SIGNUP_DAY_END) + ->where('users.created_at', '>=', self::SIGNUP_DAY_START) + ->where('users.created_at', '<', self::SIGNUP_DAY_END) + ->where('users.timezone', 'like', self::OWNER_TIMEZONE_PREFIX.'%') + ->select(['accounts.id as account_id', 'users.email']) + ->orderBy('accounts.id'); + + foreach ($checks as $index => [, $check]) { + $query->selectSub($check->selectRaw('1')->limit(1), "skip_check_$index"); + } + + return $query->get()->map(function (object $candidate) use ($checks): object { + $reasons = []; + + foreach ($checks as $index => [$reason]) { + if ($candidate->{"skip_check_$index"} !== null) { + $reasons[] = $reason; + } + } + + $candidate->skip_reasons = array_values(array_unique($reasons)); + + return $candidate; + }); + } + + /** + * @return array + */ + private function skipChecks(Connection $connection): array + { + return [ + ['Owner belongs to other accounts', $connection->table('account_users as other_memberships') + ->whereColumn('other_memberships.user_id', 'users.id') + ->whereColumn('other_memberships.account_id', '!=', 'accounts.id') + ->whereNull('other_memberships.deleted_at')], + ['Has other members', $connection->table('account_users as other_members') + ->whereColumn('other_members.account_id', 'accounts.id') + ->whereColumn('other_members.user_id', '!=', 'users.id') + ->whereNull('other_members.deleted_at')], + ['Has a superadmin member', $connection->table('account_users as superadmin_members') + ->whereColumn('superadmin_members.account_id', 'accounts.id') + ->where('superadmin_members.role', Role::SUPERADMIN->value)], + ['Has orders', $connection->table('orders') + ->join('events as order_events', 'order_events.id', '=', 'orders.event_id') + ->whereColumn('order_events.account_id', 'accounts.id')], + ['Connected Stripe', $connection->table('accounts as stripe_accounts') + ->whereColumn('stripe_accounts.id', 'accounts.id') + ->whereNotNull('stripe_accounts.stripe_account_id')], + ['Connected Stripe', $connection->table('account_stripe_platforms') + ->whereColumn('account_stripe_platforms.account_id', 'accounts.id')], + ['Connected Stripe', $connection->table('organizer_stripe_platforms') + ->join('organizers as stripe_organizers', 'stripe_organizers.id', '=', 'organizer_stripe_platforms.organizer_id') + ->whereColumn('stripe_organizers.account_id', 'accounts.id')], + ['Created events after 2026-09-10', $connection->table('events as later_events') + ->whereColumn('later_events.account_id', 'accounts.id') + ->where('later_events.created_at', '>=', self::SIGNUP_DAY_END)], + ]; + } +} diff --git a/backend/app/DomainObjects/Enums/HomepageLayout.php b/backend/app/DomainObjects/Enums/HomepageLayout.php new file mode 100644 index 0000000000..cb2071dc25 --- /dev/null +++ b/backend/app/DomainObjects/Enums/HomepageLayout.php @@ -0,0 +1,13 @@ +validated('account_id'), trustedDeviceToken: $request->cookie(TrustedDeviceService::COOKIE_NAME), )); + } catch (InvitationPendingException $e) { + return $this->jsonResponse([ + 'message' => $e->getMessage(), + 'error_code' => InvitationPendingException::ERROR_CODE, + ], ResponseCodes::HTTP_UNAUTHORIZED); } catch (UnauthorizedException $e) { return $this->errorResponse( message: $e->getMessage(), diff --git a/backend/app/Http/Actions/Events/UpdateEventTypeAction.php b/backend/app/Http/Actions/Events/UpdateEventTypeAction.php new file mode 100644 index 0000000000..c430deb357 --- /dev/null +++ b/backend/app/Http/Actions/Events/UpdateEventTypeAction.php @@ -0,0 +1,44 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + try { + $event = $this->handler->handle(new UpdateEventTypeDTO( + event_id: $eventId, + account_id: $this->getAuthenticatedAccountId(), + type: EventType::fromName($request->validated('type')), + )); + } catch (CannotChangeEventTypeException $exception) { + throw ValidationException::withMessages([ + 'type' => $exception->getMessage(), + ]); + } + + return $this->resourceResponse(EventResource::class, $event); + } +} diff --git a/backend/app/Http/Request/Event/UpdateEventTypeRequest.php b/backend/app/Http/Request/Event/UpdateEventTypeRequest.php new file mode 100644 index 0000000000..511d6d0308 --- /dev/null +++ b/backend/app/Http/Request/Event/UpdateEventTypeRequest.php @@ -0,0 +1,17 @@ + ['required', Rule::in(EventType::valuesArray())], + ]; + } +} diff --git a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php index 9b9f2076ad..0522b8dba1 100644 --- a/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php +++ b/backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php @@ -5,6 +5,7 @@ use HiEvents\DomainObjects\Enums\AttendeeDetailsCollectionMethod; use HiEvents\DomainObjects\Enums\HomepageBackgroundType; use HiEvents\DomainObjects\Enums\HomepageFontFamily; +use HiEvents\DomainObjects\Enums\HomepageLayout; use HiEvents\DomainObjects\Enums\HomepageSectionType; use HiEvents\DomainObjects\Enums\PaymentProviders; use HiEvents\DomainObjects\Enums\Permission; @@ -114,6 +115,7 @@ public function rules(): array 'homepage_theme_settings.mode' => ['nullable', 'string', Rule::in(['light', 'dark'])], 'homepage_theme_settings.background_type' => ['nullable', 'string', Rule::in(HomepageBackgroundType::valuesArray())], 'homepage_theme_settings.font_family' => ['nullable', 'string', Rule::in(HomepageFontFamily::valuesArray())], + 'homepage_theme_settings.layout' => ['nullable', 'string', Rule::in(HomepageLayout::valuesArray())], ...HomepageSectionRules::rules(HomepageSectionType::eventValues()), // Self-service settings @@ -164,6 +166,7 @@ public function messages(): array '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.'), 'homepage_theme_settings.font_family.in' => __('The selected font is not supported.'), + 'homepage_theme_settings.layout.in' => __('The selected layout is not supported.'), ...HomepageSectionRules::messages(), ]; } diff --git a/backend/app/Repository/Eloquent/OrderRepository.php b/backend/app/Repository/Eloquent/OrderRepository.php index 99858e7055..4683899065 100644 --- a/backend/app/Repository/Eloquent/OrderRepository.php +++ b/backend/app/Repository/Eloquent/OrderRepository.php @@ -130,7 +130,8 @@ public function findByOrganizerId(int $organizerId, int $accountId, QueryParamsD ->select('orders.*') ->join('events', 'orders.event_id', '=', 'events.id') ->where('events.organizer_id', $organizerId) - ->where('events.account_id', $accountId); + ->where('events.account_id', $accountId) + ->whereNull('events.deleted_at'); $sortBy = $this->validateSortColumn($params->sort_by, OrderDomainObject::class); $this->model = $this->model->orderBy( diff --git a/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventTypeDTO.php b/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventTypeDTO.php new file mode 100644 index 0000000000..0071146370 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Event/DTO/UpdateEventTypeDTO.php @@ -0,0 +1,17 @@ +databaseManager->transaction(function () use ($dto): bool { + $this->transactionLockService->lockEvent($dto->event_id); + + $event = $this->eventRepository->findFirstWhere([ + EventDomainObjectAbstract::ID => $dto->event_id, + EventDomainObjectAbstract::ACCOUNT_ID => $dto->account_id, + ]); + + if ($event === null) { + throw new ResourceNotFoundException(__('Event :id not found', ['id' => $dto->event_id])); + } + + if ($event->getType() === $dto->type->name) { + return false; + } + + if ($dto->type === EventType::SINGLE) { + $this->convertToSingle($event); + } else { + $this->convertToRecurring($event); + } + + return true; + }); + + $event = $this->loadEvent($dto); + + if ($changed) { + $this->dispatcher->dispatchEvent(new EventUpdateEvent($event)); + DispatchEventWebhookJob::dispatch($event->getId(), DomainEventType::EVENT_UPDATED); + } + + return $event; + } + + /** + * @throws CannotChangeEventTypeException + */ + private function convertToSingle(EventDomainObject $event): void + { + $occurrences = $this->occurrenceRepository->findWhere([ + EventOccurrenceDomainObjectAbstract::EVENT_ID => $event->getId(), + ]); + + if ($occurrences->isEmpty()) { + throw new CannotChangeEventTypeException( + __('Add a date before changing to a single event.'), + ); + } + + if ($occurrences->count() > 1) { + throw new CannotChangeEventTypeException( + __('Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.'), + ); + } + + /** @var EventOccurrenceDomainObject $occurrence */ + $occurrence = $occurrences->first(); + + if ($occurrence->isCancelled()) { + throw new CannotChangeEventTypeException( + __('The remaining date is cancelled. Reactivate it before changing to a single event.'), + ); + } + + if ($this->hasOrders($event->getId())) { + throw new CannotChangeEventTypeException( + __('A recurring event with orders cannot be changed to a single event.'), + ); + } + + $this->priceOverrideRepository->deleteWhere([ + ProductPriceOccurrenceOverrideDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrence->getId(), + ]); + + $this->productVisibilityRepository->deleteWhere([ + ProductOccurrenceVisibilityDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrence->getId(), + ]); + + $this->occurrenceRepository->updateFromArray($occurrence->getId(), [ + EventOccurrenceDomainObjectAbstract::CAPACITY => null, + EventOccurrenceDomainObjectAbstract::LABEL => null, + EventOccurrenceDomainObjectAbstract::EVENT_LOCATION_ID => null, + EventOccurrenceDomainObjectAbstract::IS_OVERRIDDEN => false, + ]); + + $this->checkInListRepository->updateWhere( + attributes: [CheckInListDomainObjectAbstract::EVENT_OCCURRENCE_ID => null], + where: [CheckInListDomainObjectAbstract::EVENT_ID => $event->getId()], + ); + + $this->applyEventWideProductQuantities($event->getId()); + + $this->eventRepository->updateFromArray($event->getId(), [ + EventDomainObjectAbstract::TYPE => EventType::SINGLE->name, + EventDomainObjectAbstract::RECURRENCE_RULE => null, + EventDomainObjectAbstract::EVENT_LOCATION_ID => $occurrence->getEventLocationId() ?? $event->getEventLocationId(), + ]); + } + + private function convertToRecurring(EventDomainObject $event): void + { + $occurrences = $this->occurrenceRepository->findWhere([ + EventOccurrenceDomainObjectAbstract::EVENT_ID => $event->getId(), + ]); + + if ($occurrences->count() === 1) { + $this->waitlistEntryRepository->updateWhere( + attributes: [WaitlistEntryDomainObjectAbstract::EVENT_OCCURRENCE_ID => $occurrences->first()->getId()], + where: [ + WaitlistEntryDomainObjectAbstract::EVENT_ID => $event->getId(), + [WaitlistEntryDomainObjectAbstract::EVENT_OCCURRENCE_ID, 'null', null], + ], + ); + } + + $this->eventRepository->updateFromArray($event->getId(), [ + EventDomainObjectAbstract::TYPE => EventType::RECURRING->name, + ]); + } + + private function applyEventWideProductQuantities(int $eventId): void + { + $productIds = $this->productRepository + ->findWhere([ProductDomainObjectAbstract::EVENT_ID => $eventId]) + ->map(fn (ProductDomainObject $product) => $product->getId()) + ->all(); + + if ($productIds === []) { + return; + } + + $this->productPriceRepository->updateWhere( + attributes: [ProductPriceDomainObjectAbstract::QUANTITY_APPLIES_TO => ProductQuantityAppliesTo::EVENT->name], + where: [[ProductPriceDomainObjectAbstract::PRODUCT_ID, 'in', $productIds]], + ); + } + + private function hasOrders(int $eventId): bool + { + return $this->orderRepository->findFirstWhere([ + OrderDomainObjectAbstract::EVENT_ID => $eventId, + static fn (Builder $query) => $query + ->whereIn(OrderDomainObjectAbstract::STATUS, [ + OrderStatus::COMPLETED->name, + OrderStatus::CANCELLED->name, + OrderStatus::AWAITING_OFFLINE_PAYMENT->name, + ]) + ->orWhere(static fn (Builder $reserved) => $reserved + ->where(OrderDomainObjectAbstract::STATUS, OrderStatus::RESERVED->name) + ->where(OrderDomainObjectAbstract::RESERVED_UNTIL, '>', now())), + ]) !== null; + } + + private function loadEvent(UpdateEventTypeDTO $dto): EventDomainObject + { + return $this->eventRepository + ->loadRelation(new Relationship(domainObject: EventLocationDomainObject::class, nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ], name: 'event_location')) + ->loadRelation(new Relationship(domainObject: EventOccurrenceDomainObject::class, nested: [ + new Relationship(domainObject: EventLocationDomainObject::class, nested: [ + new Relationship(domainObject: LocationDomainObject::class, name: 'location'), + ], name: 'event_location'), + ])) + ->findFirstWhere([ + EventDomainObjectAbstract::ID => $dto->event_id, + EventDomainObjectAbstract::ACCOUNT_ID => $dto->account_id, + ]); + } +} diff --git a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php index 113d0712e1..bbc0328eac 100644 --- a/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php +++ b/backend/app/Services/Application/Handlers/EventSettings/DTO/UpdateEventSettingsDTO.php @@ -162,7 +162,7 @@ public static function createWithDefaults( // Homepage theme defaults (simplified 2-color + mode system) homepage_theme_settings: [ - 'accent' => '#8b5cf6', + 'accent' => '#7c3aed', 'background' => '#f5f3ff', 'mode' => 'light', 'background_type' => 'COLOR', diff --git a/backend/app/Services/Domain/Auth/LoginService.php b/backend/app/Services/Domain/Auth/LoginService.php index cefc63eb1f..9ef6263003 100644 --- a/backend/app/Services/Domain/Auth/LoginService.php +++ b/backend/app/Services/Domain/Auth/LoginService.php @@ -10,6 +10,7 @@ use HiEvents\Enterprise\Sso\Exceptions\SsoRequiredException; use HiEvents\Enterprise\Sso\Repository\Interfaces\AccountSsoConnectionRepositoryInterface; use HiEvents\Enterprise\Sso\Services\Domain\SsoEnforcementService; +use HiEvents\Exceptions\InvitationPendingException; use HiEvents\Exceptions\UnauthorizedException; use HiEvents\Models\User; use HiEvents\Repository\Eloquent\Value\Relationship; @@ -112,7 +113,7 @@ public function completeLogin(UserDomainObject $user, ?int $requestedAccountId, $accounts = $userAccounts->map(fn ($accountUser) => $accountUser->getAccount()); - $accountId = $this->getAccountId($accounts, $requestedAccountId); + $accountId = $this->getAccountId($user, $accounts, $requestedAccountId); if ($accountId === null) { return new LoginResponse( @@ -168,6 +169,7 @@ private function accountRequiredByContext(?int $requestedAccountId, AuthContext private function resolveAccountToSignInTo(UserDomainObject $user, ?int $requestedAccountId): ?int { return $this->getAccountId( + $user, $this->findActiveUserAccounts($user)->map(fn ($accountUser) => $accountUser->getAccount()), $requestedAccountId, ); @@ -186,10 +188,10 @@ private function findActiveUserAccounts(UserDomainObject $user): Collection /** * @throws UnauthorizedException */ - private function getAccountId(Collection $accounts, ?int $requestedAccountId): ?int + private function getAccountId(UserDomainObject $user, Collection $accounts, ?int $requestedAccountId): ?int { if ($accounts->isEmpty()) { - throw new UnauthorizedException(__('User account is not active')); + $this->throwNoActiveAccount($user); } if ($accounts->count() === 1) { @@ -209,6 +211,25 @@ private function getAccountId(Collection $accounts, ?int $requestedAccountId): ? return null; } + /** + * @throws UnauthorizedException + */ + private function throwNoActiveAccount(UserDomainObject $user): never + { + $hasPendingInvitation = $this->accountUserRepository->findFirstWhere([ + 'user_id' => $user->getId(), + 'status' => UserStatus::INVITED->name, + ]) !== null; + + if ($hasPendingInvitation) { + throw new InvitationPendingException( + __('You have been invited to join a team. Open the invitation email to finish joining.'), + ); + } + + throw new UnauthorizedException(__('User account is not active')); + } + private function issueToken(int $userId, int $accountId, Role $userRole, AuthContext $context): string { $claims = [ diff --git a/backend/app/Services/Domain/Event/CreateEventService.php b/backend/app/Services/Domain/Event/CreateEventService.php index 602814609a..1851cfc9ba 100644 --- a/backend/app/Services/Domain/Event/CreateEventService.php +++ b/backend/app/Services/Domain/Event/CreateEventService.php @@ -224,7 +224,7 @@ private function createEventSettings( $terminology = ProductTerminology::forCategory($event->getCategory()); $homepageThemeSettings = [ - 'accent' => $organizerThemeSettings['accent'] ?? '#8b5cf6', + 'accent' => $organizerThemeSettings['accent'] ?? '#7c3aed', 'background' => $organizerThemeSettings['background'] ?? '#f5f3ff', 'mode' => $organizerThemeSettings['mode'] ?? 'light', 'background_type' => $eventCoverCreated diff --git a/backend/app/Validators/Rules/HomepageSectionRules.php b/backend/app/Validators/Rules/HomepageSectionRules.php index 43322fc1d9..9d4ae7e221 100644 --- a/backend/app/Validators/Rules/HomepageSectionRules.php +++ b/backend/app/Validators/Rules/HomepageSectionRules.php @@ -14,7 +14,7 @@ class HomepageSectionRules public static function rules(array $allowedTypes): array { return [ - 'homepage_sections' => ['nullable', 'array', 'max:40'], + 'homepage_sections' => ['nullable', 'array', 'max:41'], 'homepage_sections.*.id' => ['required', 'string', 'max:40', 'regex:/^[A-Za-z0-9_-]+$/'], 'homepage_sections.*.type' => ['required', 'string', Rule::in($allowedTypes)], 'homepage_sections.*.enabled' => ['boolean'], diff --git a/backend/config/scramble.php b/backend/config/scramble.php index dec1c16bdb..5ab45b65d3 100644 --- a/backend/config/scramble.php +++ b/backend/config/scramble.php @@ -101,6 +101,7 @@ | `401` | `SESSION_REVOKED` | The session token was signed out everywhere, for example after a password reset | | `401` | `SSO_REQUIRED` | The user's organization requires single sign-on; sign in again through it | | `401` | `SSO_SESSION_EXPIRED` | A session started through single sign-on can't be refreshed; sign in again | +| `401` | `INVITATION_PENDING` | `POST /auth/login` only: the user's only membership is an invitation they haven't accepted; they must open the invitation email | | `403` | `API_KEY_READ_ONLY` | A read-only API key tried to make a change | | `403` | `API_KEY_NOT_ALLOWED` | The endpoint can't be used with an API key | | `403` | `READ_ONLY_ROLE` | The user has view-only access to the account | diff --git a/backend/lang/de.json b/backend/lang/de.json index 3733d9318e..d60609e2d4 100644 --- a/backend/lang/de.json +++ b/backend/lang/de.json @@ -1198,5 +1198,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Anweisungen für Offline-Zahlungen können nur im Dashboard geändert werden, nicht mit einem API-Schlüssel.", "Only members who can manage payments can change the offline payment instructions.": "Nur Mitglieder, die Zahlungen verwalten dürfen, können die Anweisungen für Offline-Zahlungen ändern.", "Sign in with your email and password instead.": "Melden Sie sich stattdessen mit Ihrer E-Mail-Adresse und Ihrem Passwort an.", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Dieses Ticket ist ausverkauft, daher kann dieser Teilnehmer nicht reaktiviert werden. Erhöhen Sie die Menge und versuchen Sie es erneut." + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Dieses Ticket ist ausverkauft, daher kann dieser Teilnehmer nicht reaktiviert werden. Erhöhen Sie die Menge und versuchen Sie es erneut.", + "The selected layout is not supported.": "Das ausgewählte Layout wird nicht unterstützt.", + "Add a date before changing to a single event.": "Fügen Sie ein Datum hinzu, bevor Sie zu einer Einzelveranstaltung wechseln.", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Nur eine wiederkehrende Veranstaltung mit genau einem Datum kann in eine Einzelveranstaltung umgewandelt werden. Löschen Sie zuerst die anderen Daten.", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "Das verbleibende Datum ist abgesagt. Reaktivieren Sie es, bevor Sie zu einer Einzelveranstaltung wechseln.", + "A recurring event with orders cannot be changed to a single event.": "Eine wiederkehrende Veranstaltung mit Bestellungen kann nicht in eine Einzelveranstaltung umgewandelt werden." } diff --git a/backend/lang/el.json b/backend/lang/el.json index e172e7a912..e60fa3a207 100644 --- a/backend/lang/el.json +++ b/backend/lang/el.json @@ -1198,5 +1198,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Οι οδηγίες για τις εκτός σύνδεσης πληρωμές αλλάζουν μόνο από τον πίνακα ελέγχου, όχι με κλειδί API.", "Only members who can manage payments can change the offline payment instructions.": "Μόνο τα μέλη που μπορούν να διαχειρίζονται πληρωμές μπορούν να αλλάξουν τις οδηγίες για τις εκτός σύνδεσης πληρωμές.", "Sign in with your email and password instead.": "Συνδεθείτε με το email και τον κωδικό σας.", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Αυτό το εισιτήριο έχει εξαντληθεί, οπότε ο συμμετέχων δεν μπορεί να επανενεργοποιηθεί. Αυξήστε την ποσότητά του και δοκιμάστε ξανά." + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Αυτό το εισιτήριο έχει εξαντληθεί, οπότε ο συμμετέχων δεν μπορεί να επανενεργοποιηθεί. Αυξήστε την ποσότητά του και δοκιμάστε ξανά.", + "The selected layout is not supported.": "Η επιλεγμένη διάταξη δεν υποστηρίζεται.", + "Add a date before changing to a single event.": "Προσθέστε μια ημερομηνία πριν την αλλαγή σε μεμονωμένη εκδήλωση.", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Μόνο μια επαναλαμβανόμενη εκδήλωση με ακριβώς μία ημερομηνία μπορεί να αλλάξει σε μεμονωμένη εκδήλωση. Διαγράψτε πρώτα τις άλλες ημερομηνίες.", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "Η εναπομείνασα ημερομηνία έχει ακυρωθεί. Ενεργοποιήστε την ξανά πριν την αλλαγή σε μεμονωμένη εκδήλωση.", + "A recurring event with orders cannot be changed to a single event.": "Μια επαναλαμβανόμενη εκδήλωση με παραγγελίες δεν μπορεί να αλλάξει σε μεμονωμένη εκδήλωση." } diff --git a/backend/lang/es.json b/backend/lang/es.json index 8f32ef091b..977ce98c3e 100644 --- a/backend/lang/es.json +++ b/backend/lang/es.json @@ -1238,5 +1238,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Las instrucciones de pago offline solo se pueden cambiar desde el panel, no con una clave de API.", "Only members who can manage payments can change the offline payment instructions.": "Solo los miembros que pueden gestionar los pagos pueden cambiar las instrucciones de pago offline.", "Sign in with your email and password instead.": "Inicia sesión con tu correo electrónico y contraseña.", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Esta entrada está agotada, por lo que no se puede reactivar a este asistente. Aumenta su cantidad e inténtalo de nuevo." + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Esta entrada está agotada, por lo que no se puede reactivar a este asistente. Aumenta su cantidad e inténtalo de nuevo.", + "The selected layout is not supported.": "El diseño seleccionado no es compatible.", + "Add a date before changing to a single event.": "Añade una fecha antes de cambiar a un evento único.", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Solo un evento recurrente con exactamente una fecha puede cambiarse a un evento único. Elimina primero las demás fechas.", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "La fecha restante está cancelada. Reactívala antes de cambiar a un evento único.", + "A recurring event with orders cannot be changed to a single event.": "Un evento recurrente con pedidos no se puede cambiar a un evento único." } diff --git a/backend/lang/fi.json b/backend/lang/fi.json index 8b74684e90..17db4a2325 100644 --- a/backend/lang/fi.json +++ b/backend/lang/fi.json @@ -1386,5 +1386,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Offline-maksun ohjeita voi muuttaa vain hallintapaneelista, ei API-avaimella.", "Only members who can manage payments can change the offline payment instructions.": "Vain jäsenet, joilla on oikeus hallita maksuja, voivat muuttaa offline-maksun ohjeita.", "Sign in with your email and password instead.": "Kirjaudu sen sijaan sisään sähköpostiosoitteellasi ja salasanallasi.", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Tämä lippu on myyty loppuun, joten tätä osallistujaa ei voi aktivoida uudelleen. Kasvata lipun määrää ja yritä uudelleen." + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Tämä lippu on myyty loppuun, joten tätä osallistujaa ei voi aktivoida uudelleen. Kasvata lipun määrää ja yritä uudelleen.", + "The selected layout is not supported.": "Valittua asettelua ei tueta.", + "Add a date before changing to a single event.": "Lisää päivämäärä ennen kuin muutat tapahtuman kertaluonteiseksi.", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Vain toistuvan tapahtuman, jolla on täsmälleen yksi päivämäärä, voi muuttaa kertaluonteiseksi. Poista ensin muut päivämäärät.", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "Jäljellä oleva päivämäärä on peruttu. Aktivoi se uudelleen ennen kuin muutat tapahtuman kertaluonteiseksi.", + "A recurring event with orders cannot be changed to a single event.": "Toistuvaa tapahtumaa, jolla on tilauksia, ei voi muuttaa kertaluonteiseksi." } diff --git a/backend/lang/fr.json b/backend/lang/fr.json index f470eec9b9..1b86e57b50 100644 --- a/backend/lang/fr.json +++ b/backend/lang/fr.json @@ -1198,5 +1198,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Les instructions de paiement hors ligne ne peuvent être modifiées que depuis le tableau de bord, pas avec une clé API.", "Only members who can manage payments can change the offline payment instructions.": "Seuls les membres autorisés à gérer les paiements peuvent modifier les instructions de paiement hors ligne.", "Sign in with your email and password instead.": "Connectez-vous plutôt avec votre adresse e-mail et votre mot de passe.", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Ce billet est épuisé, ce participant ne peut donc pas être réactivé. Augmentez sa quantité et réessayez." + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Ce billet est épuisé, ce participant ne peut donc pas être réactivé. Augmentez sa quantité et réessayez.", + "The selected layout is not supported.": "La mise en page sélectionnée n'est pas prise en charge.", + "Add a date before changing to a single event.": "Ajoutez une date avant de passer à un événement unique.", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Seul un événement récurrent ayant exactement une date peut être transformé en événement unique. Supprimez d'abord les autres dates.", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "La date restante est annulée. Réactivez-la avant de passer à un événement unique.", + "A recurring event with orders cannot be changed to a single event.": "Un événement récurrent comportant des commandes ne peut pas être transformé en événement unique." } diff --git a/backend/lang/hu.json b/backend/lang/hu.json index 4c1b8b62d9..bf8a25362b 100644 --- a/backend/lang/hu.json +++ b/backend/lang/hu.json @@ -1203,5 +1203,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Az offline fizetési utasításokat csak az irányítópulton lehet módosítani, API-kulccsal nem.", "Only members who can manage payments can change the offline payment instructions.": "Az offline fizetési utasításokat csak a fizetések kezelésére jogosult tagok módosíthatják.", "Sign in with your email and password instead.": "Jelentkezzen be inkább e-mail-címével és jelszavával.", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Ez a jegy elfogyott, ezért a résztvevő nem aktiválható újra. Növelje a jegy mennyiségét, és próbálja újra." + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Ez a jegy elfogyott, ezért a résztvevő nem aktiválható újra. Növelje a jegy mennyiségét, és próbálja újra.", + "The selected layout is not supported.": "A kiválasztott elrendezés nem támogatott.", + "Add a date before changing to a single event.": "Adjon hozzá egy dátumot, mielőtt egyszeri eseményre váltana.", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Csak pontosan egy dátummal rendelkező ismétlődő esemény alakítható egyszeri eseménnyé. Először törölje a többi dátumot.", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "A megmaradt dátum le van mondva. Aktiválja újra, mielőtt egyszeri eseményre váltana.", + "A recurring event with orders cannot be changed to a single event.": "Rendelésekkel rendelkező ismétlődő esemény nem alakítható egyszeri eseménnyé." } diff --git a/backend/lang/it.json b/backend/lang/it.json index 8f0df695cf..b3ac4ecd4b 100644 --- a/backend/lang/it.json +++ b/backend/lang/it.json @@ -1199,5 +1199,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Le istruzioni per il pagamento offline possono essere modificate solo dalla dashboard, non con una chiave API.", "Only members who can manage payments can change the offline payment instructions.": "Solo i membri che possono gestire i pagamenti possono modificare le istruzioni per il pagamento offline.", "Sign in with your email and password instead.": "Accedi invece con la tua email e password.", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Questo biglietto è esaurito, quindi il partecipante non può essere riattivato. Aumentane la quantità e riprova." + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Questo biglietto è esaurito, quindi il partecipante non può essere riattivato. Aumentane la quantità e riprova.", + "The selected layout is not supported.": "Il layout selezionato non è supportato.", + "Add a date before changing to a single event.": "Aggiungi una data prima di passare a un evento singolo.", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Solo un evento ricorrente con esattamente una data può essere trasformato in evento singolo. Elimina prima le altre date.", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "La data rimanente è annullata. Riattivala prima di passare a un evento singolo.", + "A recurring event with orders cannot be changed to a single event.": "Un evento ricorrente con ordini non può essere trasformato in evento singolo." } diff --git a/backend/lang/ja.json b/backend/lang/ja.json index bc8a2a8f22..84d129f290 100644 --- a/backend/lang/ja.json +++ b/backend/lang/ja.json @@ -1386,5 +1386,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "オフライン決済の案内はダッシュボードからのみ変更でき、APIキーでは変更できません。", "Only members who can manage payments can change the offline payment instructions.": "オフライン決済の案内を変更できるのは、支払いを管理できるメンバーのみです。", "Sign in with your email and password instead.": "代わりにメールアドレスとパスワードでサインインしてください。", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "このチケットは完売しているため、この参加者を再有効化できません。販売可能数を増やして、もう一度お試しください。" + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "このチケットは完売しているため、この参加者を再有効化できません。販売可能数を増やして、もう一度お試しください。", + "The selected layout is not supported.": "選択されたレイアウトはサポートされていません。", + "Add a date before changing to a single event.": "単発イベントに変更する前に日程を追加してください。", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "日程が1つだけの定期イベントのみ単発イベントに変更できます。先に他の日程を削除してください。", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "残っている日程はキャンセルされています。単発イベントに変更する前に再開してください。", + "A recurring event with orders cannot be changed to a single event.": "注文がある定期イベントは単発イベントに変更できません。" } diff --git a/backend/lang/ko.json b/backend/lang/ko.json index 9265b57fa6..04c448b2a0 100644 --- a/backend/lang/ko.json +++ b/backend/lang/ko.json @@ -1387,5 +1387,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "무통장 입금 안내는 API 키가 아닌 대시보드에서만 변경할 수 있습니다.", "Only members who can manage payments can change the offline payment instructions.": "결제를 관리할 수 있는 구성원만 무통장 입금 안내를 변경할 수 있습니다.", "Sign in with your email and password instead.": "대신 이메일과 비밀번호로 로그인하세요.", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "이 티켓이 매진되어 이 참석자를 다시 활성화할 수 없습니다. 티켓 수량을 늘린 후 다시 시도해 주세요." + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "이 티켓이 매진되어 이 참석자를 다시 활성화할 수 없습니다. 티켓 수량을 늘린 후 다시 시도해 주세요.", + "The selected layout is not supported.": "선택한 레이아웃은 지원되지 않습니다.", + "Add a date before changing to a single event.": "단일 이벤트로 변경하기 전에 날짜를 추가하세요.", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "날짜가 정확히 하나인 반복 이벤트만 단일 이벤트로 변경할 수 있습니다. 먼저 다른 날짜를 삭제하세요.", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "남은 날짜가 취소되었습니다. 단일 이벤트로 변경하기 전에 다시 활성화하세요.", + "A recurring event with orders cannot be changed to a single event.": "주문이 있는 반복 이벤트는 단일 이벤트로 변경할 수 없습니다." } diff --git a/backend/lang/nl.json b/backend/lang/nl.json index c703836f21..c825898f4d 100644 --- a/backend/lang/nl.json +++ b/backend/lang/nl.json @@ -1198,5 +1198,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Instructies voor offline betalingen kun je alleen via het dashboard wijzigen, niet met een API-sleutel.", "Only members who can manage payments can change the offline payment instructions.": "Alleen leden die betalingen mogen beheren, kunnen de instructies voor offline betalingen wijzigen.", "Sign in with your email and password instead.": "Log in plaats daarvan in met je e-mailadres en wachtwoord.", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Dit ticket is uitverkocht, dus deze deelnemer kan niet opnieuw worden geactiveerd. Verhoog het aantal en probeer het opnieuw." + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Dit ticket is uitverkocht, dus deze deelnemer kan niet opnieuw worden geactiveerd. Verhoog het aantal en probeer het opnieuw.", + "The selected layout is not supported.": "De geselecteerde lay-out wordt niet ondersteund.", + "Add a date before changing to a single event.": "Voeg een datum toe voordat je overschakelt naar een eenmalig evenement.", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Alleen een terugkerend evenement met precies één datum kan worden omgezet naar een eenmalig evenement. Verwijder eerst de andere datums.", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "De resterende datum is geannuleerd. Activeer deze opnieuw voordat je overschakelt naar een eenmalig evenement.", + "A recurring event with orders cannot be changed to a single event.": "Een terugkerend evenement met bestellingen kan niet worden omgezet naar een eenmalig evenement." } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index 41284c87f3..4e1259b0c1 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -1386,5 +1386,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Instrukcje płatności offline można zmienić tylko w panelu, a nie za pomocą klucza API.", "Only members who can manage payments can change the offline payment instructions.": "Tylko członkowie, którzy mogą zarządzać płatnościami, mogą zmieniać instrukcje płatności offline.", "Sign in with your email and password instead.": "Zamiast tego zaloguj się za pomocą adresu e-mail i hasła.", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Ten bilet jest wyprzedany, więc nie można ponownie aktywować tego uczestnika. Zwiększ liczbę dostępnych biletów i spróbuj ponownie." + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Ten bilet jest wyprzedany, więc nie można ponownie aktywować tego uczestnika. Zwiększ liczbę dostępnych biletów i spróbuj ponownie.", + "The selected layout is not supported.": "Wybrany układ nie jest obsługiwany.", + "Add a date before changing to a single event.": "Dodaj datę przed zmianą na wydarzenie jednorazowe.", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Tylko wydarzenie cykliczne z dokładnie jedną datą można zmienić na wydarzenie jednorazowe. Najpierw usuń pozostałe daty.", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "Pozostała data została odwołana. Aktywuj ją ponownie przed zmianą na wydarzenie jednorazowe.", + "A recurring event with orders cannot be changed to a single event.": "Wydarzenia cyklicznego z zamówieniami nie można zmienić na wydarzenie jednorazowe." } diff --git a/backend/lang/pt-br.json b/backend/lang/pt-br.json index f9de226c53..503cfad90d 100644 --- a/backend/lang/pt-br.json +++ b/backend/lang/pt-br.json @@ -1198,5 +1198,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "As instruções de pagamento offline só podem ser alteradas pelo painel, não com uma chave de API.", "Only members who can manage payments can change the offline payment instructions.": "Somente membros que podem gerenciar pagamentos podem alterar as instruções de pagamento offline.", "Sign in with your email and password instead.": "Em vez disso, entre com seu e-mail e senha.", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Este ingresso está esgotado, então este participante não pode ser reativado. Aumente a quantidade e tente novamente." + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Este ingresso está esgotado, então este participante não pode ser reativado. Aumente a quantidade e tente novamente.", + "The selected layout is not supported.": "O layout selecionado não é compatível.", + "Add a date before changing to a single event.": "Adicione uma data antes de mudar para um evento único.", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Somente um evento recorrente com exatamente uma data pode ser alterado para um evento único. Exclua as outras datas primeiro.", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "A data restante está cancelada. Reative-a antes de mudar para um evento único.", + "A recurring event with orders cannot be changed to a single event.": "Um evento recorrente com pedidos não pode ser alterado para um evento único." } diff --git a/backend/lang/pt.json b/backend/lang/pt.json index 73d702ee02..a8ecce12cd 100644 --- a/backend/lang/pt.json +++ b/backend/lang/pt.json @@ -1198,5 +1198,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "As instruções de pagamento offline só podem ser alteradas no painel, não com uma chave de API.", "Only members who can manage payments can change the offline payment instructions.": "Apenas os membros que podem gerir pagamentos podem alterar as instruções de pagamento offline.", "Sign in with your email and password instead.": "Em vez disso, inicie sessão com o seu e-mail e palavra-passe.", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Este bilhete está esgotado, pelo que este participante não pode ser reativado. Aumente a quantidade do bilhete e tente novamente." + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Este bilhete está esgotado, pelo que este participante não pode ser reativado. Aumente a quantidade do bilhete e tente novamente.", + "The selected layout is not supported.": "O esquema selecionado não é suportado.", + "Add a date before changing to a single event.": "Adicione uma data antes de mudar para um evento único.", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Apenas um evento recorrente com exatamente uma data pode ser alterado para um evento único. Elimine primeiro as outras datas.", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "A data restante está cancelada. Reative-a antes de mudar para um evento único.", + "A recurring event with orders cannot be changed to a single event.": "Um evento recorrente com encomendas não pode ser alterado para um evento único." } diff --git a/backend/lang/ru.json b/backend/lang/ru.json index 3de70621db..7a488ec48a 100644 --- a/backend/lang/ru.json +++ b/backend/lang/ru.json @@ -1140,5 +1140,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Инструкции по офлайн-оплате можно изменить только в панели управления, а не с помощью API-ключа.", "Only members who can manage payments can change the offline payment instructions.": "Изменять инструкции по офлайн-оплате могут только участники с правом управлять платежами.", "Sign in with your email and password instead.": "Вместо этого войдите с помощью адреса электронной почты и пароля.", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Этот билет распродан, поэтому участника нельзя активировать снова. Увеличьте количество билетов и повторите попытку." + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Этот билет распродан, поэтому участника нельзя активировать снова. Увеличьте количество билетов и повторите попытку.", + "The selected layout is not supported.": "Выбранный макет не поддерживается.", + "Add a date before changing to a single event.": "Добавьте дату, прежде чем менять мероприятие на разовое.", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Только повторяющееся мероприятие ровно с одной датой можно сделать разовым. Сначала удалите остальные даты.", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "Оставшаяся дата отменена. Восстановите её, прежде чем менять мероприятие на разовое.", + "A recurring event with orders cannot be changed to a single event.": "Повторяющееся мероприятие с заказами нельзя сделать разовым." } diff --git a/backend/lang/se.json b/backend/lang/se.json index 883a3a6b80..c1d0fd3bc6 100644 --- a/backend/lang/se.json +++ b/backend/lang/se.json @@ -1198,5 +1198,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Instruktioner för offline-betalning kan bara ändras från instrumentpanelen, inte med en API-nyckel.", "Only members who can manage payments can change the offline payment instructions.": "Endast medlemmar som kan hantera betalningar kan ändra instruktionerna för offline-betalning.", "Sign in with your email and password instead.": "Logga in med din e-postadress och ditt lösenord i stället.", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Den här biljetten är slutsåld, så deltagaren kan inte återaktiveras. Öka antalet och försök igen." + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Den här biljetten är slutsåld, så deltagaren kan inte återaktiveras. Öka antalet och försök igen.", + "The selected layout is not supported.": "Den valda layouten stöds inte.", + "Add a date before changing to a single event.": "Lägg till ett datum innan du byter till ett enskilt evenemang.", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Endast ett återkommande evenemang med exakt ett datum kan ändras till ett enskilt evenemang. Ta bort de andra datumen först.", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "Det återstående datumet är inställt. Återaktivera det innan du byter till ett enskilt evenemang.", + "A recurring event with orders cannot be changed to a single event.": "Ett återkommande evenemang med beställningar kan inte ändras till ett enskilt evenemang." } diff --git a/backend/lang/sk.json b/backend/lang/sk.json index 4f6b95d329..7d83effce7 100644 --- a/backend/lang/sk.json +++ b/backend/lang/sk.json @@ -1198,5 +1198,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Pokyny pre offline platbu je možné zmeniť iba z ovládacieho panela, nie pomocou kľúča API.", "Only members who can manage payments can change the offline payment instructions.": "Pokyny pre offline platbu môžu meniť iba členovia, ktorí môžu spravovať platby.", "Sign in with your email and password instead.": "Namiesto toho sa prihláste e-mailom a heslom.", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Tento lístok je vypredaný, preto účastníka nemožno znova aktivovať. Zvýšte jeho množstvo a skúste to znova." + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Tento lístok je vypredaný, preto účastníka nemožno znova aktivovať. Zvýšte jeho množstvo a skúste to znova.", + "The selected layout is not supported.": "Vybrané rozloženie nie je podporované.", + "Add a date before changing to a single event.": "Pred zmenou na jednorazové podujatie pridajte dátum.", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Na jednorazové podujatie možno zmeniť iba opakované podujatie s presne jedným dátumom. Najprv odstráňte ostatné dátumy.", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "Zostávajúci dátum je zrušený. Pred zmenou na jednorazové podujatie ho znova aktivujte.", + "A recurring event with orders cannot be changed to a single event.": "Opakované podujatie s objednávkami nemožno zmeniť na jednorazové podujatie." } diff --git a/backend/lang/tr.json b/backend/lang/tr.json index 7a7f5af416..26f07452c4 100644 --- a/backend/lang/tr.json +++ b/backend/lang/tr.json @@ -1213,5 +1213,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Çevrimdışı ödeme talimatları yalnızca panelden değiştirilebilir, API anahtarıyla değiştirilemez.", "Only members who can manage payments can change the offline payment instructions.": "Çevrimdışı ödeme talimatlarını yalnızca ödemeleri yönetebilen üyeler değiştirebilir.", "Sign in with your email and password instead.": "Bunun yerine e-posta adresiniz ve şifrenizle giriş yapın.", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Bu bilet tükendi, bu nedenle katılımcı yeniden etkinleştirilemez. Biletin miktarını artırın ve tekrar deneyin." + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Bu bilet tükendi, bu nedenle katılımcı yeniden etkinleştirilemez. Biletin miktarını artırın ve tekrar deneyin.", + "The selected layout is not supported.": "Seçilen düzen desteklenmiyor.", + "Add a date before changing to a single event.": "Tek seferlik etkinliğe geçmeden önce bir tarih ekleyin.", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Yalnızca tam olarak bir tarihi olan tekrarlanan bir etkinlik tek seferlik etkinliğe dönüştürülebilir. Önce diğer tarihleri silin.", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "Kalan tarih iptal edildi. Tek seferlik etkinliğe geçmeden önce yeniden etkinleştirin.", + "A recurring event with orders cannot be changed to a single event.": "Siparişleri olan tekrarlanan bir etkinlik tek seferlik etkinliğe dönüştürülemez." } diff --git a/backend/lang/vi.json b/backend/lang/vi.json index 70de21b627..856b1d884d 100644 --- a/backend/lang/vi.json +++ b/backend/lang/vi.json @@ -1198,5 +1198,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "Chỉ có thể thay đổi hướng dẫn thanh toán offline trong bảng điều khiển, không thể thay đổi bằng khóa API.", "Only members who can manage payments can change the offline payment instructions.": "Chỉ những thành viên có quyền quản lý thanh toán mới có thể thay đổi hướng dẫn thanh toán offline.", "Sign in with your email and password instead.": "Thay vào đó, hãy đăng nhập bằng email và mật khẩu.", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Vé này đã bán hết nên không thể kích hoạt lại người tham dự này. Hãy tăng số lượng vé và thử lại." + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "Vé này đã bán hết nên không thể kích hoạt lại người tham dự này. Hãy tăng số lượng vé và thử lại.", + "The selected layout is not supported.": "Bố cục đã chọn không được hỗ trợ.", + "Add a date before changing to a single event.": "Hãy thêm một ngày trước khi chuyển sang sự kiện đơn lẻ.", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Chỉ sự kiện định kỳ có đúng một ngày mới có thể chuyển thành sự kiện đơn lẻ. Hãy xóa các ngày khác trước.", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "Ngày còn lại đã bị hủy. Hãy kích hoạt lại trước khi chuyển sang sự kiện đơn lẻ.", + "A recurring event with orders cannot be changed to a single event.": "Không thể chuyển sự kiện định kỳ có đơn hàng thành sự kiện đơn lẻ." } diff --git a/backend/lang/zh-cn.json b/backend/lang/zh-cn.json index 6f1ab1982d..122b102844 100644 --- a/backend/lang/zh-cn.json +++ b/backend/lang/zh-cn.json @@ -1198,5 +1198,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "线下付款说明只能在控制台中更改,不能通过 API 密钥更改。", "Only members who can manage payments can change the offline payment instructions.": "只有可以管理付款的成员才能更改线下付款说明。", "Sign in with your email and password instead.": "请改用您的电子邮件和密码登录。", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "此门票已售罄,因此无法重新激活该参与者。请增加其数量后重试。" + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "此门票已售罄,因此无法重新激活该参与者。请增加其数量后重试。", + "The selected layout is not supported.": "不支持所选布局。", + "Add a date before changing to a single event.": "请先添加一个日期,再更改为单次活动。", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "只有恰好有一个日期的周期性活动才能更改为单次活动。请先删除其他日期。", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "剩余的日期已取消。请先重新启用,再更改为单次活动。", + "A recurring event with orders cannot be changed to a single event.": "有订单的周期性活动无法更改为单次活动。" } diff --git a/backend/lang/zh-hk.json b/backend/lang/zh-hk.json index ff90c70286..97ff7c10d0 100644 --- a/backend/lang/zh-hk.json +++ b/backend/lang/zh-hk.json @@ -1198,5 +1198,10 @@ "Offline payment instructions can only be changed from the dashboard, not with an API key.": "線下付款說明只能在控制台中更改,不能透過 API 金鑰更改。", "Only members who can manage payments can change the offline payment instructions.": "只有可以管理付款的成員才能更改線下付款說明。", "Sign in with your email and password instead.": "請改用電郵和密碼登入。", - "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "此門票已售罄,因此無法重新啟用該參加者。請增加門票數量後再試。" + "This ticket is sold out, so this attendee cannot be reactivated. Increase its quantity and try again.": "此門票已售罄,因此無法重新啟用該參加者。請增加門票數量後再試。", + "The selected layout is not supported.": "不支援所選版面。", + "Add a date before changing to a single event.": "請先新增一個日期,再更改為單次活動。", + "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "只有恰好有一個日期的週期性活動才能更改為單次活動。請先刪除其他日期。", + "The remaining date is cancelled. Reactivate it before changing to a single event.": "剩餘的日期已取消。請先重新啟用,再更改為單次活動。", + "A recurring event with orders cannot be changed to a single event.": "有訂單的週期性活動無法更改為單次活動。" } diff --git a/backend/resources/api-reference/openapi.json b/backend/resources/api-reference/openapi.json index 5bee706ed1..e23d4f15d2 100644 --- a/backend/resources/api-reference/openapi.json +++ b/backend/resources/api-reference/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Hi.Events", - "description": "The Hi.Events API powers event management, ticketing, and checkout.\n\n## Authentication\n\nManagement endpoints require a bearer token in the `Authorization` header. Endpoints under `/public/*` — event pages, checkout, ticket lookup, check-in — require no authentication and are marked *(public)*.\n\n### API keys\n\nFor integrations, create an API key under **Settings → API keys** and send it with every request:\n\n```bash\ncurl https://your-hi-events-host/api/events \\\n -H \"Authorization: Bearer hie_…\" \\\n -H \"Accept: application/json\"\n```\n\n- A key acts as the person who created it, in the one account it was created in, with that person's role, permissions and event access. It never has more access than its owner, and changes to the owner's role apply straight away.\n- **Read and write** keys can call every endpoint the owner can. **Read-only** keys can call `GET` endpoints and exports; other requests return `403` with `error_code: API_KEY_READ_ONLY`.\n- Keys can't sign in, or manage the user's profile, sign-in security, team, API keys, payouts or account settings. Those endpoints return `403` with `error_code: API_KEY_NOT_ALLOWED`.\n- An expired, revoked or unknown key returns `401`. A key is revoked when its owner revokes it, when an admin revokes it, when the owner is deactivated or leaves the account, and when the owner's password is reset or their single sign-on changes.\n- The full key is shown once, when it's created. Hi.Events stores only a hash of it.\n\n### Session tokens\n\nThe Hi.Events web app uses a JWT obtained from `POST /auth/login`. It works on every management endpoint, including the ones API keys can't use.\n\n### Two-factor authentication\n\nWhen the user has two-factor authentication turned on, `POST /auth/login` returns `200` with `token: null`, `two_factor_required: true` and a `two_factor_challenge_token` that is valid for 10 minutes. Complete the login with `POST /auth/login/two-factor`, sending the challenge token plus either a 6-digit `code` from the user's authenticator app or a `recovery_code`. A wrong code returns `422`; an expired challenge, or too many wrong codes, returns `401` with `error_code: TWO_FACTOR_CHALLENGE_EXPIRED` and the login must start again. Users with several accounts verify once, then call `POST /auth/login/two-factor` again with the same challenge token and an `account_id`.\n\nTokens work the same once issued: two-factor authentication is only checked at login.\n\nIf an account requires two-factor authentication and the user has not set it up, authenticated endpoints return `403` with `error_code: TWO_FACTOR_SETUP_REQUIRED` until they do. `GET /users/me`, `GET /accounts`, the `/auth/*` endpoints and the `/users/me/two-factor/*` setup endpoints stay available.\n\n## Errors\n\nErrors return JSON with a `message`. Validation errors (`422`) also include an `errors` object keyed by field. Access errors include an `error_code`:\n\n| Status | `error_code` | Meaning |\n| --- | --- | --- |\n| `401` | — | Missing, invalid or expired token, or an invalid, expired or revoked API key |\n| `401` | `SESSION_REVOKED` | The session token was signed out everywhere, for example after a password reset |\n| `401` | `SSO_REQUIRED` | The user's organization requires single sign-on; sign in again through it |\n| `401` | `SSO_SESSION_EXPIRED` | A session started through single sign-on can't be refreshed; sign in again |\n| `403` | `API_KEY_READ_ONLY` | A read-only API key tried to make a change |\n| `403` | `API_KEY_NOT_ALLOWED` | The endpoint can't be used with an API key |\n| `403` | `READ_ONLY_ROLE` | The user has view-only access to the account |\n| `403` | `PERMISSION_REQUIRED` | The user's role lacks a permission the endpoint needs (for example refunds or financial data) |\n| `403` | `ACCESS_RESTRICTED` | The user only has access to some organizers or events, and this isn't one of them |\n| `403` | `TWO_FACTOR_SETUP_REQUIRED` | The account requires two-factor authentication and the user hasn't set it up |\n| `403` | `ACCOUNT_PENDING_DELETION` | The account is scheduled for deletion |\n| `403` | `FEATURE_UNAVAILABLE` | The feature isn't available on this installation or plan |\n| `429` | — | Too many requests; wait for the number of seconds in the `Retry-After` header |\n\n## Rate limits\n\nManagement endpoints are rate limited per user, shared between the web app and all of that user's API keys. The default is 180 requests a minute; self-hosted installations can change it with `APP_API_RATE_LIMIT_PER_MINUTE`.\n\n## Stability\n\nThe API isn't versioned yet. Endpoints that accept API keys are the integration surface, and we keep them backward compatible: new endpoints, optional request fields and response fields can be added at any time, so ignore fields you don't recognise. Removing or renaming an endpoint or field, or changing its type or meaning, is a breaking change; it's avoided where possible and always called out in the release notes. Endpoints marked *Not available to API keys* serve the Hi.Events web app and may change without notice.\n\n## Outgoing webhooks\n\nWebhooks are configured per event or organizer (see the *Webhooks* endpoints) and deliver a `POST` request to your URL for each subscribed event:\n\n```json\n{\n \"event_type\": \"order.created\",\n \"event_sent_at\": \"2026-08-07T10:00:00+00:00\",\n \"payload\": { }\n}\n```\n\n`payload` matches the schema of the corresponding resource (see the *Schemas* section):\n\n| Events | Payload schema |\n| --- | --- |\n| `order.created`, `order.updated`, `order.marked_as_paid`, `order.refunded`, `order.cancelled` | `OrderResource` |\n| `attendee.created`, `attendee.updated`, `attendee.cancelled` | `AttendeeResource` |\n| `product.created`, `product.updated`, `product.deleted` | `ProductResource` |\n| `event.created`, `event.updated`, `event.archived` | `EventResource` |\n| `checkin.created`, `checkin.deleted` | `AttendeeCheckInResource` |\n| `occurrence.cancelled` | `EventOccurrenceResource` |\n\n`email` on an order payload is `null` for box office sales made without a buyer email. `order.created` and `order.cancelled` additionally emit an `attendee.created` / `attendee.cancelled` webhook for each attendee on the order. Deliveries are signed: the `Signature` header contains an HMAC-SHA256 hash of the JSON body using your webhook's secret." + "description": "The Hi.Events API powers event management, ticketing, and checkout.\n\n## Authentication\n\nManagement endpoints require a bearer token in the `Authorization` header. Endpoints under `/public/*` — event pages, checkout, ticket lookup, check-in — require no authentication and are marked *(public)*.\n\n### API keys\n\nFor integrations, create an API key under **Settings → API keys** and send it with every request:\n\n```bash\ncurl https://your-hi-events-host/api/events \\\n -H \"Authorization: Bearer hie_…\" \\\n -H \"Accept: application/json\"\n```\n\n- A key acts as the person who created it, in the one account it was created in, with that person's role, permissions and event access. It never has more access than its owner, and changes to the owner's role apply straight away.\n- **Read and write** keys can call every endpoint the owner can. **Read-only** keys can call `GET` endpoints and exports; other requests return `403` with `error_code: API_KEY_READ_ONLY`.\n- Keys can't sign in, or manage the user's profile, sign-in security, team, API keys, payouts or account settings. Those endpoints return `403` with `error_code: API_KEY_NOT_ALLOWED`.\n- An expired, revoked or unknown key returns `401`. A key is revoked when its owner revokes it, when an admin revokes it, when the owner is deactivated or leaves the account, and when the owner's password is reset or their single sign-on changes.\n- The full key is shown once, when it's created. Hi.Events stores only a hash of it.\n\n### Session tokens\n\nThe Hi.Events web app uses a JWT obtained from `POST /auth/login`. It works on every management endpoint, including the ones API keys can't use.\n\n### Two-factor authentication\n\nWhen the user has two-factor authentication turned on, `POST /auth/login` returns `200` with `token: null`, `two_factor_required: true` and a `two_factor_challenge_token` that is valid for 10 minutes. Complete the login with `POST /auth/login/two-factor`, sending the challenge token plus either a 6-digit `code` from the user's authenticator app or a `recovery_code`. A wrong code returns `422`; an expired challenge, or too many wrong codes, returns `401` with `error_code: TWO_FACTOR_CHALLENGE_EXPIRED` and the login must start again. Users with several accounts verify once, then call `POST /auth/login/two-factor` again with the same challenge token and an `account_id`.\n\nTokens work the same once issued: two-factor authentication is only checked at login.\n\nIf an account requires two-factor authentication and the user has not set it up, authenticated endpoints return `403` with `error_code: TWO_FACTOR_SETUP_REQUIRED` until they do. `GET /users/me`, `GET /accounts`, the `/auth/*` endpoints and the `/users/me/two-factor/*` setup endpoints stay available.\n\n## Errors\n\nErrors return JSON with a `message`. Validation errors (`422`) also include an `errors` object keyed by field. Access errors include an `error_code`:\n\n| Status | `error_code` | Meaning |\n| --- | --- | --- |\n| `401` | — | Missing, invalid or expired token, or an invalid, expired or revoked API key |\n| `401` | `SESSION_REVOKED` | The session token was signed out everywhere, for example after a password reset |\n| `401` | `SSO_REQUIRED` | The user's organization requires single sign-on; sign in again through it |\n| `401` | `SSO_SESSION_EXPIRED` | A session started through single sign-on can't be refreshed; sign in again |\n| `401` | `INVITATION_PENDING` | `POST /auth/login` only: the user's only membership is an invitation they haven't accepted; they must open the invitation email |\n| `403` | `API_KEY_READ_ONLY` | A read-only API key tried to make a change |\n| `403` | `API_KEY_NOT_ALLOWED` | The endpoint can't be used with an API key |\n| `403` | `READ_ONLY_ROLE` | The user has view-only access to the account |\n| `403` | `PERMISSION_REQUIRED` | The user's role lacks a permission the endpoint needs (for example refunds or financial data) |\n| `403` | `ACCESS_RESTRICTED` | The user only has access to some organizers or events, and this isn't one of them |\n| `403` | `TWO_FACTOR_SETUP_REQUIRED` | The account requires two-factor authentication and the user hasn't set it up |\n| `403` | `ACCOUNT_PENDING_DELETION` | The account is scheduled for deletion |\n| `403` | `FEATURE_UNAVAILABLE` | The feature isn't available on this installation or plan |\n| `429` | — | Too many requests; wait for the number of seconds in the `Retry-After` header |\n\n## Rate limits\n\nManagement endpoints are rate limited per user, shared between the web app and all of that user's API keys. The default is 180 requests a minute; self-hosted installations can change it with `APP_API_RATE_LIMIT_PER_MINUTE`.\n\n## Stability\n\nThe API isn't versioned yet. Endpoints that accept API keys are the integration surface, and we keep them backward compatible: new endpoints, optional request fields and response fields can be added at any time, so ignore fields you don't recognise. Removing or renaming an endpoint or field, or changing its type or meaning, is a breaking change; it's avoided where possible and always called out in the release notes. Endpoints marked *Not available to API keys* serve the Hi.Events web app and may change without notice.\n\n## Outgoing webhooks\n\nWebhooks are configured per event or organizer (see the *Webhooks* endpoints) and deliver a `POST` request to your URL for each subscribed event:\n\n```json\n{\n \"event_type\": \"order.created\",\n \"event_sent_at\": \"2026-08-07T10:00:00+00:00\",\n \"payload\": { }\n}\n```\n\n`payload` matches the schema of the corresponding resource (see the *Schemas* section):\n\n| Events | Payload schema |\n| --- | --- |\n| `order.created`, `order.updated`, `order.marked_as_paid`, `order.refunded`, `order.cancelled` | `OrderResource` |\n| `attendee.created`, `attendee.updated`, `attendee.cancelled` | `AttendeeResource` |\n| `product.created`, `product.updated`, `product.deleted` | `ProductResource` |\n| `event.created`, `event.updated`, `event.archived` | `EventResource` |\n| `checkin.created`, `checkin.deleted` | `AttendeeCheckInResource` |\n| `occurrence.cancelled` | `EventOccurrenceResource` |\n\n`email` on an order payload is `null` for box office sales made without a buyer email. `order.created` and `order.cancelled` additionally emit an `attendee.created` / `attendee.cancelled` webhook for each attendee on the order. Deliveries are signed: the `Signature` header contains an HMAC-SHA256 hash of the JSON body using your webhook's secret." }, "security": [ { @@ -8987,6 +8987,97 @@ } } }, + "/events/{eventId}/type": { + "put": { + "operationId": "events.updateEventTypeAction", + "summary": "Update Event Type", + "tags": [ + "Events" + ], + "parameters": [ + { + "name": "eventId", + "in": "path", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateEventTypeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "`EventResource`", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/EventResource" + } + }, + "required": [ + "data" + ] + } + } + } + }, + "422": { + "$ref": "#/components/responses/ValidationException" + }, + "401": { + "$ref": "#/components/responses/AuthenticationException" + }, + "403": { + "description": "The authenticated user is not allowed to perform this action.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + }, + "404": { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ] + } + } + } + } + } + } + }, "/events/{eventId}/deletion-status": { "get": { "operationId": "events.getEventDeletionStatusAction", @@ -33485,7 +33576,7 @@ "type" ] }, - "maxItems": 40 + "maxItems": 41 }, "seo_keywords": { "type": [ @@ -36350,6 +36441,19 @@ "Lora", null ] + }, + "layout": { + "type": [ + "string", + "null" + ], + "enum": [ + "MARQUEE", + "FLYER", + "STORY", + "INVITE", + null + ] } } }, @@ -36375,6 +36479,7 @@ "SCHEDULE", "LOCATION", "ORGANIZER", + "MORE_EVENTS", "TEXT", "GALLERY", "FAQ", @@ -36482,7 +36587,7 @@ "type" ] }, - "maxItems": 40 + "maxItems": 41 }, "allow_attendee_self_edit": { "type": "boolean", @@ -36527,6 +36632,22 @@ ], "title": "UpdateEventStatusRequest" }, + "UpdateEventTypeRequest": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "SINGLE", + "RECURRING" + ] + } + }, + "required": [ + "type" + ], + "title": "UpdateEventTypeRequest" + }, "UpdateLineupEntryRequest": { "type": "object", "properties": { diff --git a/backend/routes/api.php b/backend/routes/api.php index 94863627a6..190af5bbbe 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -231,6 +231,7 @@ use HiEvents\Http\Actions\Events\UpdateEventAction; use HiEvents\Http\Actions\Events\UpdateEventLocationAction; use HiEvents\Http\Actions\Events\UpdateEventStatusAction; +use HiEvents\Http\Actions\Events\UpdateEventTypeAction; use HiEvents\Http\Actions\EventSettings\EditEventSettingsAction; use HiEvents\Http\Actions\EventSettings\GetEventSettingsAction; use HiEvents\Http\Actions\EventSettings\GetPlatformFeePreviewAction; @@ -618,6 +619,7 @@ function (Router $router): void { $router->put('/events/{event_id}', UpdateEventAction::class); $router->patch('/events/{event_id}/event-location', UpdateEventLocationAction::class); $router->put('/events/{event_id}/status', UpdateEventStatusAction::class); + $router->put('/events/{event_id}/type', UpdateEventTypeAction::class); $router->delete('/events/{event_id}', DeleteEventAction::class); $router->get('/events/{event_id}/deletion-status', GetEventDeletionStatusAction::class); $router->post('/events/{event_id}/duplicate', DuplicateEventAction::class); diff --git a/backend/tests/Feature/Auth/LoginTest.php b/backend/tests/Feature/Auth/LoginTest.php index e44a006364..92945355f5 100644 --- a/backend/tests/Feature/Auth/LoginTest.php +++ b/backend/tests/Feature/Auth/LoginTest.php @@ -4,6 +4,9 @@ namespace Tests\Feature\Auth; +use HiEvents\DomainObjects\Enums\Role; +use HiEvents\DomainObjects\Status\UserStatus; +use HiEvents\Models\Account; use HiEvents\Models\AccountConfiguration; use HiEvents\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -110,4 +113,73 @@ public function test_logout(): void ]); $response3->assertStatus(401); } + + public function test_login_with_only_a_pending_invitation_tells_the_user_to_accept_it(): void + { + $password = fake()->password(16); + $user = User::factory()->password($password)->create(); + $this->attachAccount($user, UserStatus::INVITED); + + $response = $this->postJson(self::LOGIN_ROUTE, [ + 'email' => $user->email, + 'password' => $password, + ]); + + $response->assertStatus(401); + $response->assertJsonPath('error_code', 'INVITATION_PENDING'); + $response->assertCookieMissing('token'); + } + + public function test_login_with_a_wrong_password_does_not_reveal_a_pending_invitation(): void + { + $user = User::factory()->password(fake()->password(16))->create(); + $this->attachAccount($user, UserStatus::INVITED); + + $response = $this->postJson(self::LOGIN_ROUTE, [ + 'email' => $user->email, + 'password' => 'invalid_password', + ]); + + $response->assertStatus(401); + $response->assertJsonMissingPath('error_code'); + } + + public function test_login_with_an_inactive_membership_has_no_error_code(): void + { + $password = fake()->password(16); + $user = User::factory()->password($password)->create(); + $this->attachAccount($user, UserStatus::INACTIVE); + + $response = $this->postJson(self::LOGIN_ROUTE, [ + 'email' => $user->email, + 'password' => $password, + ]); + + $response->assertStatus(401); + $response->assertJsonMissingPath('error_code'); + } + + public function test_login_with_an_active_membership_and_a_pending_invitation_succeeds(): void + { + $password = fake()->password(16); + $user = User::factory()->password($password)->withAccount()->create(); + $this->attachAccount($user, UserStatus::INVITED); + + $response = $this->postJson(self::LOGIN_ROUTE, [ + 'email' => $user->email, + 'password' => $password, + ]); + + $response->assertSuccessful(); + $response->assertCookie('token'); + } + + private function attachAccount(User $user, UserStatus $status): void + { + $user->accounts()->attach(Account::factory()->verified()->create(), [ + 'role' => Role::ADMIN, + 'status' => $status, + 'is_account_owner' => false, + ]); + } } diff --git a/backend/tests/Feature/Console/DeleteSeptember10SignupWaveCommandTest.php b/backend/tests/Feature/Console/DeleteSeptember10SignupWaveCommandTest.php new file mode 100644 index 0000000000..4ce047b3ce --- /dev/null +++ b/backend/tests/Feature/Console/DeleteSeptember10SignupWaveCommandTest.php @@ -0,0 +1,167 @@ +createOwner(self::LISTED_ACCOUNT_ID, 'wave@example.com', 'Asia/Jakarta', '2026-09-10 05:00'); + [, $buyerAccount] = $this->createOwner(self::SECOND_LISTED_ACCOUNT_ID, 'buyer@example.com', 'Asia/Jakarta', '2026-09-10 05:10'); + $this->createOrder($buyerAccount); + + $this->artisan('accounts:delete-2026-09-10-signups') + ->expectsOutputToContain('1 of the 1326 listed accounts can be deleted, 1 skipped, 1324 already deleted or no longer owned by a 2026-09-10 signup.') + ->expectsOutputToContain('Has orders') + ->expectsOutputToContain('Nothing was deleted.') + ->assertSuccessful(); + + $this->assertDatabaseHas('users', ['id' => $waveUser->id]); + $this->assertDatabaseHas('accounts', ['id' => $waveAccount->id]); + } + + public function test_force_deletes_only_listed_accounts_that_still_pass_every_check(): void + { + [$waveUser, $waveAccount] = $this->createOwner(self::LISTED_ACCOUNT_ID, 'wave@example.com', 'Asia/Jakarta', '2026-09-10 05:00'); + + [$buyerUser, $buyerAccount] = $this->createOwner(self::SECOND_LISTED_ACCOUNT_ID, 'buyer@example.com', 'Asia/Jakarta', '2026-09-10 05:10'); + $this->createOrder($buyerAccount); + + [$parisUser, $parisAccount] = $this->createOwner(self::THIRD_LISTED_ACCOUNT_ID, 'paris@example.com', 'Europe/Paris', '2026-09-10 05:20'); + + [$unlistedUser, $unlistedAccount] = $this->createOwner(null, 'unlisted@example.com', 'Asia/Jakarta', '2026-09-10 05:30'); + + $this->artisan('accounts:delete-2026-09-10-signups', ['--force' => true]) + ->expectsOutputToContain('1 of the 1326 listed accounts can be deleted, 1 skipped, 1324 already deleted') + ->expectsOutputToContain('Deleted 1 account(s).') + ->assertSuccessful(); + + $this->assertDatabaseMissing('users', ['id' => $waveUser->id]); + $this->assertDatabaseMissing('accounts', ['id' => $waveAccount->id]); + $this->assertDatabaseMissing('organizers', ['account_id' => $waveAccount->id]); + + foreach ([$buyerUser, $parisUser, $unlistedUser] as $survivor) { + $this->assertDatabaseHas('users', ['id' => $survivor->id, 'deleted_at' => null]); + } + + foreach ([$buyerAccount, $parisAccount, $unlistedAccount] as $survivingAccount) { + $this->assertDatabaseHas('accounts', ['id' => $survivingAccount->id, 'deleted_at' => null]); + } + } + + public function test_limit_caps_how_many_accounts_one_run_deletes(): void + { + $this->createOwner(self::LISTED_ACCOUNT_ID, 'first@example.com', 'Asia/Jakarta', '2026-09-10 05:00'); + $this->createOwner(self::SECOND_LISTED_ACCOUNT_ID, 'second@example.com', 'Asia/Jakarta', '2026-09-10 05:01'); + + $this->artisan('accounts:delete-2026-09-10-signups', ['--force' => true, '--limit' => 1]) + ->expectsOutputToContain('Deleted 1 account(s).') + ->expectsOutputToContain('1 account(s) left. Run the command again to continue.') + ->assertSuccessful(); + + $this->assertSame(1, User::query()->whereIn('email', ['first@example.com', 'second@example.com'])->count()); + } + + /** + * @return array{0: User, 1: Account} + */ + private function createOwner(?int $accountId, string $email, string $timezone, string $signedUpAt): array + { + $user = User::forceCreate([ + 'email' => $email, + 'first_name' => 'Signup', + 'last_name' => 'Wave', + 'password' => 'hash', + 'timezone' => $timezone, + 'created_at' => $signedUpAt, + ]); + + $account = $accountId === null + ? Account::forceCreate([ + 'name' => $email, + 'email' => $email, + 'timezone' => $timezone, + 'currency_code' => 'USD', + 'short_id' => 'acc_'.Str::random(10), + 'created_at' => $signedUpAt, + ]) + : $this->createAccountWithId($accountId, $email, $timezone, $signedUpAt); + + AccountUser::forceCreate([ + 'account_id' => $account->id, + 'user_id' => $user->id, + 'role' => 'ADMIN', + 'status' => 'ACTIVE', + 'is_account_owner' => true, + ]); + + Organizer::forceCreate([ + 'name' => $email, + 'account_id' => $account->id, + 'email' => $email, + 'currency' => 'USD', + 'timezone' => $timezone, + 'status' => 'LIVE', + ]); + + return [$user, $account]; + } + + private function createAccountWithId(int $accountId, string $email, string $timezone, string $createdAt): Account + { + DB::insert( + 'insert into accounts (id, name, email, timezone, currency_code, short_id, created_at, updated_at) + overriding system value values (?, ?, ?, ?, ?, ?, ?, ?)', + [$accountId, $email, $email, $timezone, 'USD', 'acc_'.Str::random(10), $createdAt, $createdAt], + ); + + return Account::findOrFail($accountId); + } + + private function createOrder(Account $account): void + { + $eventId = DB::table('events')->insertGetId([ + 'title' => 'Event', + 'account_id' => $account->id, + 'organizer_id' => Organizer::query()->where('account_id', $account->id)->value('id'), + 'user_id' => AccountUser::query()->where('account_id', $account->id)->value('user_id'), + 'short_id' => 'evt_'.Str::random(10), + 'status' => 'DRAFT', + 'timezone' => 'UTC', + 'currency' => 'USD', + 'created_at' => '2026-09-10 06:00', + 'updated_at' => '2026-09-10 06:00', + ]); + + Order::forceCreate([ + 'event_id' => $eventId, + 'short_id' => 'ord_'.Str::random(10), + 'public_id' => Str::random(20), + 'first_name' => 'Jane', + 'last_name' => 'Buyer', + 'email' => 'jane@example.com', + 'total_before_additions' => 0, + 'total_gross' => 0, + 'currency' => 'USD', + 'status' => 'COMPLETED', + ]); + } +} diff --git a/backend/tests/Feature/Http/Actions/EventSettings/HomepageLayoutTest.php b/backend/tests/Feature/Http/Actions/EventSettings/HomepageLayoutTest.php new file mode 100644 index 0000000000..fe5fbb3939 --- /dev/null +++ b/backend/tests/Feature/Http/Actions/EventSettings/HomepageLayoutTest.php @@ -0,0 +1,57 @@ +createAccountOwner(); + $eventId = $this->createEvent($owner, $accountId); + DB::table('events')->where('id', $eventId)->update(['status' => EventStatus::LIVE->name]); + + $this->patchJson("/events/$eventId/settings", [ + 'homepage_theme_settings' => ['accent' => '#0f766e', 'background' => '#f0fdfa', 'mode' => 'light', 'layout' => 'FLYER'], + ], $this->headersFor($owner, $accountId))->assertOk(); + + $this->getJson("/public/events/$eventId") + ->assertOk() + ->assertJsonPath('data.settings.homepage_theme_settings.layout', 'FLYER'); + } + + public function test_unknown_layouts_are_rejected(): void + { + [$owner, $accountId] = $this->createAccountOwner(); + $eventId = $this->createEvent($owner, $accountId); + + $this->patchJson("/events/$eventId/settings", [ + 'homepage_theme_settings' => ['layout' => 'CAROUSEL'], + ], $this->headersFor($owner, $accountId)) + ->assertUnprocessable() + ->assertJsonValidationErrors(['homepage_theme_settings.layout' => 'The selected layout is not supported.']); + } + + private function createEvent($owner, int $accountId): int + { + $organizerId = $this->createOrganizer($accountId); + DB::table('organizer_settings')->insert(['organizer_id' => $organizerId, 'created_at' => now(), 'updated_at' => now()]); + + return $this->postJson('/events', [ + 'title' => 'Layout test', + 'type' => 'SINGLE', + 'organizer_id' => $organizerId, + 'start_date' => '2030-06-14 19:00', + 'currency' => 'EUR', + 'timezone' => 'UTC', + ], $this->headersFor($owner, $accountId))->assertSuccessful()->json('data.id'); + } +} diff --git a/backend/tests/Feature/Http/Actions/EventSettings/HomepageSectionValidationMessagesTest.php b/backend/tests/Feature/Http/Actions/EventSettings/HomepageSectionValidationMessagesTest.php index 6b05a35280..a257124ae3 100644 --- a/backend/tests/Feature/Http/Actions/EventSettings/HomepageSectionValidationMessagesTest.php +++ b/backend/tests/Feature/Http/Actions/EventSettings/HomepageSectionValidationMessagesTest.php @@ -23,9 +23,9 @@ public static function invalidSections(): array return [ 'too many sections' => [ - array_map(fn (int $index) => ['id' => "text_$index", 'type' => 'TEXT'], range(1, 41)), + array_map(fn (int $index) => ['id' => "text_$index", 'type' => 'TEXT'], range(1, 42)), 'homepage_sections', - 'You can add up to 40 sections.', + 'You can add up to 41 sections.', ], 'sections not a list' => ['nope', 'homepage_sections', self::INVALID_SECTION], 'malformed section id' => [[['id' => 'bad id!', 'type' => 'TEXT']], 'homepage_sections.0.id', self::INVALID_SECTION], diff --git a/backend/tests/Feature/Http/Actions/EventSettings/HomepageSectionsTest.php b/backend/tests/Feature/Http/Actions/EventSettings/HomepageSectionsTest.php index a952789c33..e33135eca5 100644 --- a/backend/tests/Feature/Http/Actions/EventSettings/HomepageSectionsTest.php +++ b/backend/tests/Feature/Http/Actions/EventSettings/HomepageSectionsTest.php @@ -53,6 +53,47 @@ public function test_sections_are_saved_cleaned_and_served_on_the_public_event() $this->assertSame([$galleryImageId], $sections[4]['image_ids']); } + public function test_the_more_events_section_keeps_its_heading_and_can_be_hidden(): void + { + [$owner, $accountId] = $this->createAccountOwner(); + $eventId = $this->createEventWithSettings($owner, $accountId); + DB::table('events')->where('id', $eventId)->update(['status' => EventStatus::LIVE->name]); + + $this->patchJson("/events/$eventId/settings", [ + 'homepage_sections' => [ + ['id' => 'more_events', 'type' => 'MORE_EVENTS', 'enabled' => false, 'heading' => 'Also on'], + ], + ], $this->headersFor($owner, $accountId))->assertOk(); + + $sections = $this->getJson("/public/events/$eventId")->assertOk()->json('data.settings.homepage_sections'); + + $this->assertSame(['tickets', 'more_events'], array_column($sections, 'id')); + $this->assertFalse($sections[1]['enabled']); + $this->assertSame('Also on', $sections[1]['heading']); + } + + public function test_every_built_in_plus_thirty_four_custom_sections_can_be_saved(): void + { + [$owner, $accountId] = $this->createAccountOwner(); + $eventId = $this->createEventWithSettings($owner, $accountId); + + $builtIns = array_map( + fn (string $type) => ['id' => strtolower($type), 'type' => $type], + ['TICKETS', 'ABOUT', 'LINEUP', 'SCHEDULE', 'LOCATION', 'ORGANIZER', 'MORE_EVENTS'], + ); + $custom = array_map(fn (int $i) => ['id' => "text_$i", 'type' => 'TEXT', 'body' => "

$i

"], range(1, 34)); + + $this->patchJson("/events/$eventId/settings", [ + 'homepage_sections' => [...$builtIns, ...$custom], + ], $this->headersFor($owner, $accountId))->assertOk(); + + $this->patchJson("/events/$eventId/settings", [ + 'homepage_sections' => [...$builtIns, ...$custom, ['id' => 'text_35', 'type' => 'TEXT', 'body' => '

35

']], + ], $this->headersFor($owner, $accountId)) + ->assertUnprocessable() + ->assertJsonValidationErrors('homepage_sections'); + } + public function test_video_links_must_be_https(): void { [$owner, $accountId] = $this->createAccountOwner(); diff --git a/backend/tests/Feature/Http/Actions/Events/OrganizerEventsPublicUpcomingTest.php b/backend/tests/Feature/Http/Actions/Events/OrganizerEventsPublicUpcomingTest.php new file mode 100644 index 0000000000..f471e49e78 --- /dev/null +++ b/backend/tests/Feature/Http/Actions/Events/OrganizerEventsPublicUpcomingTest.php @@ -0,0 +1,69 @@ +createAccountOwner(); + $organizerId = $this->createLiveOrganizer($accountId, 'Organizer A'); + $otherOrganizerId = $this->createLiveOrganizer($accountId, 'Organizer B'); + + $liveUpcoming = $this->createEventWithOccurrence($accountId, $organizerId, EventStatus::LIVE, now()->addDays(7)); + $this->createEventWithOccurrence($accountId, $organizerId, EventStatus::DRAFT, now()->addDays(7)); + $this->createEventWithOccurrence($accountId, $organizerId, EventStatus::LIVE, now()->subDays(3)); + $this->createEventWithOccurrence($accountId, $otherOrganizerId, EventStatus::LIVE, now()->addDays(7)); + + $response = $this->getJson( + "/public/organizers/$organizerId/events?page=1&per_page=4&query=&sort_by=start_date&sort_direction=asc&eventsStatus=upcoming" + ); + + $response->assertOk(); + $this->assertSame([$liveUpcoming], $response->json('data.*.id')); + $this->assertSame(1, $response->json('meta.total')); + } + + private function createLiveOrganizer(int $accountId, string $name): int + { + $organizerId = $this->createOrganizer($accountId, $name); + DB::table('organizers')->where('id', $organizerId)->update(['status' => OrganizerStatus::LIVE->name]); + + return $organizerId; + } + + private function createEventWithOccurrence( + int $accountId, + int $organizerId, + EventStatus $status, + CarbonInterface $startDate, + ): int { + $eventId = $this->createEvent($accountId, $organizerId); + DB::table('events')->where('id', $eventId)->update(['status' => $status->name]); + + DB::table('event_occurrences')->insert([ + 'short_id' => 'oc_'.uniqid(), + 'event_id' => $eventId, + 'start_date' => $startDate->toDateTimeString(), + 'end_date' => $startDate->copy()->addHours(2)->toDateTimeString(), + 'status' => 'ACTIVE', + 'used_capacity' => 0, + 'is_overridden' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $eventId; + } +} diff --git a/backend/tests/Feature/Http/Actions/Events/UpdateEventTypeActionTest.php b/backend/tests/Feature/Http/Actions/Events/UpdateEventTypeActionTest.php new file mode 100644 index 0000000000..49e5f77012 --- /dev/null +++ b/backend/tests/Feature/Http/Actions/Events/UpdateEventTypeActionTest.php @@ -0,0 +1,366 @@ +owner, $this->accountId] = $this->createAccountOwner(); + $this->organizerId = $this->createOrganizer($this->accountId); + } + + public function test_a_single_event_can_become_recurring_even_with_orders(): void + { + $eventId = $this->createTypedEvent(EventType::SINGLE); + $occurrenceId = $this->createOccurrence($eventId); + $this->createOrder($eventId, OrderStatus::COMPLETED); + + $this->changeType($eventId, EventType::RECURRING) + ->assertOk() + ->assertJsonPath('data.type', EventType::RECURRING->name); + + $this->assertSame(EventType::RECURRING->name, DB::table('events')->where('id', $eventId)->value('type')); + $this->assertNotNull(DB::table('event_occurrences')->where('id', $occurrenceId)->whereNull('deleted_at')->first()); + } + + public function test_a_recurring_event_with_one_date_and_no_orders_becomes_single(): void + { + $eventId = $this->createTypedEvent(EventType::RECURRING, ['frequency' => 'weekly']); + $occurrenceLocationId = $this->createEventLocation($eventId); + $occurrenceId = $this->createOccurrence($eventId, [ + 'capacity' => 50, + 'label' => 'Matinee', + 'event_location_id' => $occurrenceLocationId, + 'is_overridden' => true, + ]); + [$productId, $priceId] = $this->createProduct($eventId); + DB::table('product_price_occurrence_overrides')->insert([ + 'event_occurrence_id' => $occurrenceId, + 'product_price_id' => $priceId, + 'price' => 5, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('product_occurrence_visibility')->insert([ + 'event_occurrence_id' => $occurrenceId, + 'product_id' => $productId, + ]); + + $this->changeType($eventId, EventType::SINGLE) + ->assertOk() + ->assertJsonPath('data.type', EventType::SINGLE->name); + + $event = DB::table('events')->where('id', $eventId)->first(); + $this->assertSame(EventType::SINGLE->name, $event->type); + $this->assertNull($event->recurrence_rule); + $this->assertSame($occurrenceLocationId, $event->event_location_id); + + $occurrence = DB::table('event_occurrences')->where('id', $occurrenceId)->first(); + $this->assertNull($occurrence->capacity); + $this->assertNull($occurrence->label); + $this->assertNull($occurrence->event_location_id); + $this->assertFalse((bool) $occurrence->is_overridden); + + $this->assertSame(0, DB::table('product_price_occurrence_overrides')->where('event_occurrence_id', $occurrenceId)->count()); + $this->assertSame(0, DB::table('product_occurrence_visibility')->where('event_occurrence_id', $occurrenceId)->count()); + } + + public function test_converting_to_single_makes_ticket_quantities_event_wide(): void + { + $eventId = $this->createTypedEvent(EventType::RECURRING); + $this->createOccurrence($eventId); + [, $priceId] = $this->createProduct($eventId); + DB::table('product_prices')->where('id', $priceId)->update(['quantity_applies_to' => 'OCCURRENCE']); + + $this->changeType($eventId, EventType::SINGLE)->assertOk(); + + $this->assertSame('EVENT', DB::table('product_prices')->where('id', $priceId)->value('quantity_applies_to')); + } + + public function test_converting_to_single_unscopes_check_in_lists_from_dates(): void + { + $eventId = $this->createTypedEvent(EventType::RECURRING); + $occurrenceId = $this->createOccurrence($eventId); + $deletedOccurrenceId = $this->createOccurrence($eventId, ['deleted_at' => now()]); + $remainingListId = $this->createCheckInList($eventId, $occurrenceId); + $orphanedListId = $this->createCheckInList($eventId, $deletedOccurrenceId); + + $this->changeType($eventId, EventType::SINGLE)->assertOk(); + + $this->assertNull(DB::table('check_in_lists')->where('id', $remainingListId)->value('event_occurrence_id')); + $this->assertNull(DB::table('check_in_lists')->where('id', $orphanedListId)->value('event_occurrence_id')); + } + + public function test_converting_to_recurring_assigns_undated_waitlist_entries_to_the_existing_date(): void + { + $eventId = $this->createTypedEvent(EventType::SINGLE); + $occurrenceId = $this->createOccurrence($eventId); + [, $priceId] = $this->createProduct($eventId); + $entryId = DB::table('waitlist_entries')->insertGetId([ + 'event_id' => $eventId, + 'product_price_id' => $priceId, + 'email' => 'waiting@example.test', + 'first_name' => 'Waiting', + 'status' => WaitlistEntryStatus::WAITING->name, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->changeType($eventId, EventType::RECURRING)->assertOk(); + + $this->assertSame($occurrenceId, DB::table('waitlist_entries')->where('id', $entryId)->value('event_occurrence_id')); + } + + public function test_converting_to_single_keeps_the_event_location_when_the_date_has_none(): void + { + $eventId = $this->createTypedEvent(EventType::RECURRING); + $eventLocationId = $this->createEventLocation($eventId); + DB::table('events')->where('id', $eventId)->update(['event_location_id' => $eventLocationId]); + $this->createOccurrence($eventId); + + $this->changeType($eventId, EventType::SINGLE)->assertOk(); + + $this->assertSame($eventLocationId, DB::table('events')->where('id', $eventId)->value('event_location_id')); + } + + public function test_a_recurring_event_with_several_dates_cannot_become_single(): void + { + $eventId = $this->createTypedEvent(EventType::RECURRING); + $this->createOccurrence($eventId); + $this->createOccurrence($eventId); + + $this->changeType($eventId, EventType::SINGLE) + ->assertUnprocessable() + ->assertJsonValidationErrors('type'); + + $this->assertSame(EventType::RECURRING->name, DB::table('events')->where('id', $eventId)->value('type')); + } + + public function test_deleted_dates_do_not_count_towards_the_single_date_limit(): void + { + $eventId = $this->createTypedEvent(EventType::RECURRING); + $this->createOccurrence($eventId); + $this->createOccurrence($eventId, ['deleted_at' => now()]); + + $this->changeType($eventId, EventType::SINGLE)->assertOk(); + } + + public function test_a_recurring_event_with_no_dates_cannot_become_single(): void + { + $eventId = $this->createTypedEvent(EventType::RECURRING); + + $this->changeType($eventId, EventType::SINGLE) + ->assertUnprocessable() + ->assertJsonValidationErrors('type'); + } + + public function test_a_recurring_event_whose_only_date_is_cancelled_cannot_become_single(): void + { + $eventId = $this->createTypedEvent(EventType::RECURRING); + $this->createOccurrence($eventId, ['status' => 'CANCELLED']); + + $this->changeType($eventId, EventType::SINGLE) + ->assertUnprocessable() + ->assertJsonValidationErrors('type'); + } + + #[DataProvider('blockingOrders')] + public function test_a_recurring_event_with_orders_cannot_become_single(OrderStatus $status, ?string $reservedUntil): void + { + $eventId = $this->createTypedEvent(EventType::RECURRING); + $this->createOccurrence($eventId); + $this->createOrder($eventId, $status, $reservedUntil === null ? null : Carbon::parse($reservedUntil)); + + $this->changeType($eventId, EventType::SINGLE) + ->assertUnprocessable() + ->assertJsonValidationErrors('type'); + + $this->assertSame(EventType::RECURRING->name, DB::table('events')->where('id', $eventId)->value('type')); + } + + public static function blockingOrders(): array + { + return [ + 'completed' => [OrderStatus::COMPLETED, null], + 'cancelled' => [OrderStatus::CANCELLED, null], + 'awaiting offline payment' => [OrderStatus::AWAITING_OFFLINE_PAYMENT, null], + 'checkout in progress' => [OrderStatus::RESERVED, '+1 hour'], + ]; + } + + #[DataProvider('ignoredOrders')] + public function test_abandoned_and_expired_checkouts_do_not_block_becoming_single(OrderStatus $status, ?string $reservedUntil): void + { + $eventId = $this->createTypedEvent(EventType::RECURRING); + $this->createOccurrence($eventId); + $this->createOrder($eventId, $status, $reservedUntil === null ? null : Carbon::parse($reservedUntil)); + + $this->changeType($eventId, EventType::SINGLE)->assertOk(); + } + + public static function ignoredOrders(): array + { + return [ + 'abandoned' => [OrderStatus::ABANDONED, null], + 'expired checkout' => [OrderStatus::RESERVED, '-1 hour'], + ]; + } + + public function test_setting_the_current_type_changes_nothing(): void + { + $eventId = $this->createTypedEvent(EventType::RECURRING, ['frequency' => 'weekly']); + $this->createOccurrence($eventId); + $this->createOccurrence($eventId); + + $this->changeType($eventId, EventType::RECURRING)->assertOk(); + + $this->assertNotNull(DB::table('events')->where('id', $eventId)->value('recurrence_rule')); + } + + public function test_an_invalid_type_is_rejected(): void + { + $eventId = $this->createTypedEvent(EventType::SINGLE); + + $this->putJson("/events/$eventId/type", ['type' => 'WEEKLY'], $this->headersFor($this->owner, $this->accountId)) + ->assertUnprocessable() + ->assertJsonValidationErrors('type'); + } + + public function test_another_accounts_event_cannot_be_changed(): void + { + [$otherOwner, $otherAccountId] = $this->createAccountOwner(); + $otherEventId = $this->createEvent($otherAccountId, $this->createOrganizer($otherAccountId)); + + $this->putJson("/events/$otherEventId/type", ['type' => EventType::RECURRING->name], $this->headersFor($this->owner, $this->accountId)) + ->assertForbidden(); + + $this->assertSame(EventType::SINGLE->name, DB::table('events')->where('id', $otherEventId)->value('type')); + } + + public function test_a_read_only_member_cannot_change_the_type(): void + { + $eventId = $this->createTypedEvent(EventType::SINGLE); + $viewer = $this->addAccountUser($this->accountId, Role::VIEWER); + + $this->putJson("/events/$eventId/type", ['type' => EventType::RECURRING->name], $this->headersFor($viewer, $this->accountId)) + ->assertForbidden(); + + $this->assertSame(EventType::SINGLE->name, DB::table('events')->where('id', $eventId)->value('type')); + } + + private function changeType(int $eventId, EventType $type) + { + return $this->putJson("/events/$eventId/type", ['type' => $type->name], $this->headersFor($this->owner, $this->accountId)); + } + + private function createTypedEvent(EventType $type, ?array $recurrenceRule = null): int + { + $eventId = $this->createEvent($this->accountId, $this->organizerId); + + DB::table('events')->where('id', $eventId)->update([ + 'type' => $type->name, + 'recurrence_rule' => $recurrenceRule === null ? null : json_encode($recurrenceRule), + ]); + + return $eventId; + } + + private function createOccurrence(int $eventId, array $attributes = []): int + { + return DB::table('event_occurrences')->insertGetId(array_merge([ + 'short_id' => 'oc_'.uniqid(), + 'event_id' => $eventId, + 'start_date' => now()->addWeek(), + 'end_date' => now()->addWeek()->addHours(2), + 'status' => 'ACTIVE', + 'used_capacity' => 0, + 'is_overridden' => false, + 'created_at' => now(), + 'updated_at' => now(), + ], $attributes)); + } + + private function createCheckInList(int $eventId, int $occurrenceId): int + { + return DB::table('check_in_lists')->insertGetId([ + 'short_id' => 'cil_'.uniqid(), + 'name' => 'Door', + 'event_id' => $eventId, + 'event_occurrence_id' => $occurrenceId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function createEventLocation(int $eventId): int + { + return DB::table('event_locations')->insertGetId([ + 'short_id' => 'el_'.uniqid(), + 'event_id' => $eventId, + 'type' => 'ONLINE', + 'online_event_connection_details' => 'https://example.test/stream', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function createProduct(int $eventId): array + { + $productId = DB::table('products')->insertGetId([ + 'title' => 'General Admission', + 'event_id' => $eventId, + 'order' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $priceId = DB::table('product_prices')->insertGetId([ + 'product_id' => $productId, + 'price' => 10, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return [$productId, $priceId]; + } + + private function createOrder(int $eventId, OrderStatus $status, ?Carbon $reservedUntil = null): void + { + DB::table('orders')->insert([ + 'short_id' => 'o_'.uniqid(), + 'event_id' => $eventId, + 'currency' => 'USD', + 'status' => $status->name, + 'reserved_until' => $reservedUntil, + 'public_id' => 'p_'.uniqid(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/backend/tests/Feature/Http/Actions/Organizers/OrganizerHomepageSectionsTest.php b/backend/tests/Feature/Http/Actions/Organizers/OrganizerHomepageSectionsTest.php index f5ea3ad8a9..3543f628d6 100644 --- a/backend/tests/Feature/Http/Actions/Organizers/OrganizerHomepageSectionsTest.php +++ b/backend/tests/Feature/Http/Actions/Organizers/OrganizerHomepageSectionsTest.php @@ -41,6 +41,18 @@ public function test_sections_are_saved_cleaned_and_served_on_the_public_organiz $this->assertSame([$galleryImageId], $sections[4]['image_ids']); } + public function test_event_only_sections_are_rejected(): void + { + [$owner, $accountId] = $this->createAccountOwner(); + $organizerId = $this->createLiveOrganizerWithSettings($accountId); + + $this->patchJson("/organizers/$organizerId/settings", [ + 'homepage_sections' => [['id' => 'more_events', 'type' => 'MORE_EVENTS', 'enabled' => true]], + ], $this->headersFor($owner, $accountId)) + ->assertUnprocessable() + ->assertJsonValidationErrors('homepage_sections.0.type'); + } + public function test_other_settings_updates_keep_saved_sections(): void { [$owner, $accountId] = $this->createAccountOwner(); diff --git a/backend/tests/Feature/Repository/Eloquent/OrderRepositoryTest.php b/backend/tests/Feature/Repository/Eloquent/OrderRepositoryTest.php new file mode 100644 index 0000000000..f0e5dfc516 --- /dev/null +++ b/backend/tests/Feature/Repository/Eloquent/OrderRepositoryTest.php @@ -0,0 +1,90 @@ +repository = $this->app->make(OrderRepository::class); + + $user = User::factory()->withAccount()->create(); + $this->userId = $user->id; + $this->accountId = $user->accounts()->first()->id; + + $this->organizerId = DB::table('organizers')->insertGetId([ + 'account_id' => $this->accountId, + 'name' => 'Test Organizer', + 'email' => 'organizer@example.test', + 'currency' => 'USD', + 'timezone' => 'UTC', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function test_organizer_orders_exclude_orders_from_deleted_events(): void + { + $liveEventId = $this->createEvent(); + $deletedEventId = $this->createEvent(); + + $liveOrderId = $this->createOrder($liveEventId); + $this->createOrder($deletedEventId); + + DB::table('events')->where('id', $deletedEventId)->update(['deleted_at' => now()]); + + $orders = $this->repository->findByOrganizerId($this->organizerId, $this->accountId, new QueryParamsDTO); + + $this->assertSame([$liveOrderId], collect($orders->items())->map(fn ($order) => $order->getId())->all()); + } + + private function createEvent(): int + { + return DB::table('events')->insertGetId([ + 'title' => 'Test Event', + 'account_id' => $this->accountId, + 'user_id' => $this->userId, + 'organizer_id' => $this->organizerId, + 'currency' => 'USD', + 'timezone' => 'UTC', + 'short_id' => 'test_evt_'.uniqid(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + private function createOrder(int $eventId): int + { + return DB::table('orders')->insertGetId([ + 'short_id' => 'ord_'.uniqid(), + 'event_id' => $eventId, + 'currency' => 'USD', + 'status' => OrderStatus::COMPLETED->name, + 'public_id' => 'pub_'.uniqid(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/backend/tests/Unit/Services/Domain/Event/HomepageSectionsSanitizerTest.php b/backend/tests/Unit/Services/Domain/Event/HomepageSectionsSanitizerTest.php index a316cb1a2b..a5ac5081c1 100644 --- a/backend/tests/Unit/Services/Domain/Event/HomepageSectionsSanitizerTest.php +++ b/backend/tests/Unit/Services/Domain/Event/HomepageSectionsSanitizerTest.php @@ -84,6 +84,20 @@ public function test_drops_organizer_sections_from_event_pages(): void $this->assertSame(['TICKETS'], array_column($result, 'type')); } + public function test_event_pages_keep_the_more_events_section_with_its_heading_and_visibility(): void + { + $result = $this->sanitizer->sanitizeForEvent([ + ['id' => 'tickets', 'type' => 'TICKETS', 'enabled' => true], + ['id' => 'more_events', 'type' => 'MORE_EVENTS', 'enabled' => false, 'heading' => ' Also on '], + ['id' => 'more-again', 'type' => 'MORE_EVENTS', 'enabled' => true], + ], 5); + + $this->assertSame([ + ['id' => 'tickets', 'type' => 'TICKETS', 'enabled' => true], + ['id' => 'more_events', 'type' => 'MORE_EVENTS', 'enabled' => false, 'heading' => 'Also on'], + ], $result); + } + public function test_organizer_sections_keep_events_shown_and_only_use_organizer_images(): void { $result = $this->sanitizer->sanitizeForOrganizer([ @@ -93,6 +107,7 @@ public function test_organizer_sections_keep_events_shown_and_only_use_organizer ['id' => 'past-again', 'type' => 'PAST_EVENTS', 'enabled' => true], ['id' => 'tickets', 'type' => 'TICKETS', 'enabled' => true], ['id' => 'lineup', 'type' => 'LINEUP', 'enabled' => true], + ['id' => 'more_events', 'type' => 'MORE_EVENTS', 'enabled' => true], ['id' => 'g1', 'type' => 'GALLERY', 'image_ids' => [30, 10]], ['id' => 's1', 'type' => 'SPONSORS', 'sponsors' => [['image_id' => 40, 'url' => 'https://acme.example'], ['image_id' => 20]]], ], 7); diff --git a/e2e/api/types.ts b/e2e/api/types.ts index 47577b41df..ec386e07e1 100644 --- a/e2e/api/types.ts +++ b/e2e/api/types.ts @@ -87,6 +87,7 @@ export interface CreateEventPayload { organizer_id: number; start_date: string; end_date?: string; + description?: string; category: string; currency?: string; timezone?: string; @@ -116,6 +117,7 @@ export interface CreateProductPayload { show_quantity_remaining?: boolean; waitlist_enabled?: boolean; hide_when_sold_out?: boolean; + start_collapsed?: boolean; sale_start_date?: string; sale_end_date?: string; sequential_tier_release_enabled?: boolean; diff --git a/e2e/pages/designer.page.ts b/e2e/pages/designer.page.ts index 162f0259ff..f961ad89c0 100644 --- a/e2e/pages/designer.page.ts +++ b/e2e/pages/designer.page.ts @@ -46,6 +46,10 @@ export class DesignerPage { return this.page.frameLocator('iframe[title="Event Preview"]'); } + layoutOption(layout: 'marquee' | 'flyer' | 'story' | 'invite'): Locator { + return this.page.getByTestId(`homepage-layout-${layout}`); + } + sectionRow(type: string): Locator { return this.page.getByTestId(`homepage-section-row-${type}`); } diff --git a/e2e/pages/event-settings.page.ts b/e2e/pages/event-settings.page.ts index f30e85a4ae..fd7e4bc771 100644 --- a/e2e/pages/event-settings.page.ts +++ b/e2e/pages/event-settings.page.ts @@ -48,6 +48,15 @@ export class EventSettingsPage { return this.section('danger-zone').getByPlaceholder('delete'); } + get eventTypeChangeButton(): Locator { + return this.page.getByTestId('event-type-change-button'); + } + + async changeEventType(confirmLabel: string): Promise { + await this.eventTypeChangeButton.click(); + await this.page.getByRole('dialog').getByRole('button', { name: confirmLabel }).click(); + } + get deleteButton(): Locator { return this.page.getByTestId('event-delete-button'); } diff --git a/e2e/pages/public-event.page.ts b/e2e/pages/public-event.page.ts index 6f8bc70f38..0f52d42fde 100644 --- a/e2e/pages/public-event.page.ts +++ b/e2e/pages/public-event.page.ts @@ -8,6 +8,18 @@ export class PublicEventPage { await this.page.waitForLoadState('networkidle'); } + ticketsRegion(): Locator { + return this.page.getByRole('region', { name: 'Tickets' }); + } + + sectionBar(): Locator { + return this.page.getByRole('navigation', { name: 'Sections' }); + } + + moreEventsSection(): Locator { + return this.page.locator('section#more-events'); + } + joinWaitlistButton(): Locator { return this.page.getByTestId('join-waitlist-button'); } diff --git a/e2e/tests/account/team-invite.spec.ts b/e2e/tests/account/team-invite.spec.ts index bea80ce328..e04204e47b 100644 --- a/e2e/tests/account/team-invite.spec.ts +++ b/e2e/tests/account/team-invite.spec.ts @@ -36,6 +36,37 @@ test.describe('team invites', () => { await expect(page.getByRole('heading', { level: 1, name: /Dashboard/ })).toBeVisible(); }); + test('an invitee who resets their password instead of accepting is told to open the invitation', async ({ freshAccount, page, mailpit }) => { + const inviteeEmail = uniqueEmail('invitee'); + + const ownerPage = await freshAccount.newAuthedPage(); + const team = new TeamPage(ownerPage); + await team.goto(); + await team.inviteUser({ firstName: 'Robin', lastName: 'Member', email: inviteeEmail, role: 'Organizer' }); + await expect(team.userRow(inviteeEmail).getByText(/^Invited$/)).toBeVisible(); + + await page.goto('/auth/forgot-password'); + await page.waitForLoadState('networkidle'); + await page.getByLabel(/^Email/).fill(inviteeEmail); + await page.getByRole('button', { name: 'Send reset link' }).click(); + await expect(page.getByRole('heading', { name: 'Check your email' })).toBeVisible(); + + const resetUrl = await mailpit.waitForLink(inviteeEmail, /reset-password/, { subjectContains: 'Password reset' }); + await page.goto(resetUrl.pathname); + await page.getByLabel(/^New Password/).fill(INVITEE_PASSWORD); + await page.getByLabel(/^Confirm Password/).fill(INVITEE_PASSWORD); + await page.getByRole('button', { name: 'Reset password' }).click(); + await expect(page).toHaveURL(/\/auth\/login/); + await page.waitForLoadState('networkidle'); + + await page.getByLabel(/^Email/).fill(inviteeEmail); + await page.getByLabel(/^Password/).fill(INVITEE_PASSWORD); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + + await expect(page.getByText(/You've been invited to join a team\. Open the invitation email/)).toBeVisible(); + await expect(page).toHaveURL(/\/auth\/login/); + }); + test('an owner promotes a member to admin and deactivates them', async ({ freshAccount, page, mailpit, publicApi }) => { const memberEmail = uniqueEmail('member'); diff --git a/e2e/tests/checkout/addon-checkout.spec.ts b/e2e/tests/checkout/addon-checkout.spec.ts index 7945e84ed4..f1e56d2ebc 100644 --- a/e2e/tests/checkout/addon-checkout.spec.ts +++ b/e2e/tests/checkout/addon-checkout.spec.ts @@ -54,6 +54,48 @@ test.describe('add-on checkout', () => { await expect(page.getByText('Parking Pass').first()).toBeVisible(); }); + test('a buyer picks a quantity on a collapsed ticket and still sees its add-ons', async ({ page, api, account }) => { + const event = await createDraftEvent(api, account.organizerId); + const categories = await api.listProductCategories(event.eventId); + const categoryId = categories[0].id; + + const addon = await api.createProduct(event.eventId, { + title: 'Parking Pass', + product_type: 'GENERAL', + type: 'FREE', + product_category_id: categoryId, + is_addon_only: true, + prices: [{ price: 0 }], + }); + + await api.createProduct(event.eventId, { + title: 'Festival Ticket', + description: 'Gates open at noon', + product_type: 'TICKET', + type: 'FREE', + product_category_id: categoryId, + addon_product_ids: [addon.id], + start_collapsed: true, + prices: [{ price: 0 }], + }); + + await api.publishEvent(event.eventId); + + const checkout = new CheckoutPage(page); + await checkout.gotoPublicEvent(event.eventId, event.slug); + + await expect(page.getByText('Gates open at noon')).toBeHidden(); + await expect(page.locator('.hi-product-addon').filter({ hasText: 'Parking Pass' })).toHaveCount(0); + + await checkout.setQuantityForProduct('Festival Ticket', 1); + await expect(page.locator('.hi-product-addon').filter({ hasText: 'Parking Pass' })).toBeVisible(); + await checkout.setAddonQuantity('Parking Pass', 1); + await checkout.continueToCheckout(); + + await expect(page.getByText('Festival Ticket').first()).toBeVisible(); + await expect(page.getByText('Parking Pass').first()).toBeVisible(); + }); + test('a paid add-on is charged as part of the order total', async ({ page, api, account }) => { const event = await createDraftEvent(api, account.organizerId); const categories = await api.listProductCategories(event.eventId); diff --git a/e2e/tests/checkout/per-date-quantity-cases.spec.ts b/e2e/tests/checkout/per-date-quantity-cases.spec.ts index c66220983a..6a40726578 100644 --- a/e2e/tests/checkout/per-date-quantity-cases.spec.ts +++ b/e2e/tests/checkout/per-date-quantity-cases.spec.ts @@ -27,13 +27,13 @@ test.describe('per-date quantity edge cases', () => { test('an occurrence capacity caps a per-date ticket below its quantity', async ({ page, api, publicApi, account }) => { const event = await createRecurringLiveEvent(api, account.organizerId, { count: 2, quantityAvailable: 5 }); const [day1, day2] = sortedDates(event); - await api.updateOccurrence(event.eventId, day1.id, { start_date: day1.start_date, end_date: day1.end_date, capacity: 1 }); - await createCompletedOrder(publicApi, event, { eventOccurrenceId: day1.id }); + await api.updateOccurrence(event.eventId, day2.id, { start_date: day2.start_date, end_date: day2.end_date, capacity: 1 }); + await createCompletedOrder(publicApi, event, { eventOccurrenceId: day2.id }); const view = new PublicDateView(page, event); - await view.open(day1); + await view.open(day2); await expect(page.getByText('This date is sold out. Please choose another date.')).toBeVisible(); - await view.expectAvailable(day2); + await view.expectAvailable(day1); }); test('tiers of one product can mix per-date and all-dates quantities', async ({ page, api, publicApi, account }) => { diff --git a/e2e/tests/checkout/promo-code-inclusive-tax.spec.ts b/e2e/tests/checkout/promo-code-inclusive-tax.spec.ts index e161026ed0..1321ec96b9 100644 --- a/e2e/tests/checkout/promo-code-inclusive-tax.spec.ts +++ b/e2e/tests/checkout/promo-code-inclusive-tax.spec.ts @@ -21,7 +21,7 @@ test.describe('promo code with tax-inclusive prices', () => { await api.createPromoCode(event.eventId, { code, discount_type: 'PERCENTAGE', discount: 15 }); const checkout = new CheckoutPage(page); - await page.setViewportSize({ width: 1280, height: 400 }); + await page.setViewportSize({ width: 390, height: 400 }); await checkout.gotoPublicEvent(event.eventId, event.slug); await checkout.applyPromoCode(code); diff --git a/e2e/tests/events/event-layouts.unit.spec.ts b/e2e/tests/events/event-layouts.unit.spec.ts new file mode 100644 index 0000000000..b019556517 --- /dev/null +++ b/e2e/tests/events/event-layouts.unit.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from '@playwright/test'; +import { DEFAULT_EVENT_LAYOUT, EVENT_LAYOUTS, resolveEventLayout } from '../../../frontend/src/constants/eventLayouts'; + +test('a saved layout resolves to its definition', () => { + expect(resolveEventLayout('FLYER')).toMatchObject({ value: 'FLYER', poster: true, ticketRail: false }); + expect(resolveEventLayout('INVITE')).toMatchObject({ value: 'INVITE', width: 'narrow', centeredIntro: true, sectionNav: false }); +}); + +test('events without a layout, or with one that no longer exists, use the default', () => { + expect(resolveEventLayout(undefined).value).toBe(DEFAULT_EVENT_LAYOUT); + expect(resolveEventLayout(null).value).toBe(DEFAULT_EVENT_LAYOUT); + expect(resolveEventLayout('CAROUSEL').value).toBe(DEFAULT_EVENT_LAYOUT); +}); + +test('only the marquee layout keeps tickets in a side rail', () => { + expect(EVENT_LAYOUTS.filter(layout => layout.ticketRail).map(layout => layout.value)).toEqual(['MARQUEE']); + expect(new Set(EVENT_LAYOUTS.map(layout => layout.value)).size).toBe(EVENT_LAYOUTS.length); +}); diff --git a/e2e/tests/events/event-page-layouts.spec.ts b/e2e/tests/events/event-page-layouts.spec.ts new file mode 100644 index 0000000000..b6d12d50c5 --- /dev/null +++ b/e2e/tests/events/event-page-layouts.spec.ts @@ -0,0 +1,355 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { Browser, Page, TestInfo } from '@playwright/test'; +import { test, expect } from '../../fixtures'; +import { createFreshOrganizer, futureStartDate } from '../../api/factory'; +import type { ApiClient } from '../../api/api-client'; +import { renderPosterArt } from '../../utils/posterArt'; +import { stressImageFit } from '../../utils/imageFit'; +import { uniqueName } from '../../utils/unique'; +import { grantedConsentCookie } from '../../utils/consent'; +import { getDerivedColors } from '../../../frontend/src/utilites/themeUtils'; + +type Layout = 'MARQUEE' | 'FLYER' | 'STORY' | 'INVITE'; + +interface Theme { + accent: string; + background: string; + mode: 'light' | 'dark'; +} + +interface Seeded { + kind: string; + eventId: number; + slug: string; + title: string; + theme: Theme; + hasCover: boolean; +} + +const VIEWPORTS = [ + { name: 'phone', width: 390, height: 844 }, + { name: 'tablet', width: 820, height: 1180 }, + { name: 'desktop', width: 1440, height: 900 }, +] as const; + +type Viewport = typeof VIEWPORTS[number]; + +const LONG_COPY = [ + 'Three nights of new music in the old harbour warehouse, with the bar open from six and the first set at seven.', + 'Every ticket includes a drink on arrival. Doors close at eleven and the last ferry leaves at half past.', + 'The venue has step-free access throughout, with seating along the back wall and a quiet room upstairs.', +].map(paragraph => `

${paragraph}

`).join(''); + +const MINUTE = 60_000; + +const utcStamp = (date: Date): string => date.toISOString().slice(0, 16).replace('T', ' ') + ':00'; + +const slotAt = (start: Date, fromMinutes: number, toMinutes: number) => ({ + starts_at: utcStamp(new Date(start.getTime() + fromMinutes * MINUTE)), + ends_at: utcStamp(new Date(start.getTime() + toMinutes * MINUTE)), +}); + +const seedEvent = async ( + api: ApiClient, + browser: Browser, + organizerId: number, + layout: Layout, + spec: { kind: string; theme: Theme; cover?: { width: number; height: number }; description?: string; tickets: number[]; rich?: boolean }, +): Promise => { + const title = uniqueName(`${spec.kind} night`); + const startDate = futureStartDate(); + const event = await api.createEvent({ + title, + type: 'SINGLE', + organizer_id: organizerId, + start_date: startDate, + category: 'MUSIC', + currency: 'USD', + timezone: 'UTC', + ...(spec.description ? { description: spec.description } : {}), + }); + + const [category] = await api.listProductCategories(event.id); + for (const [index, price] of spec.tickets.entries()) { + await api.createProduct(event.id, { + title: ['General admission', 'Balcony', 'Backstage pass'][index], + product_type: 'TICKET', + type: price > 0 ? 'PAID' : 'FREE', + product_category_id: category.id, + prices: [{ price }], + }); + } + + if (spec.cover) { + const buffer = await renderPosterArt(browser, { ...spec.cover, title: 'The Paper Orchard', background: '#1f3a2e', ink: '#f3e7c9' }); + await api.uploadEventImage(event.id, { name: 'cover.jpg', mimeType: 'image/jpeg', buffer }, 'EVENT_COVER'); + } + + if (spec.rich) { + const start = new Date(startDate); + await api.importLineup(event.id, { + people: [ + { name: 'Velvet Harbour', billing_tier: 'HEADLINER' }, + { name: 'Ada Byron', billing_tier: 'HEADLINER' }, + { name: 'The Lantern Club', billing_tier: 'FEATURED' }, + { name: 'Mira Osei', billing_tier: 'FEATURED' }, + { name: 'Tidewater', billing_tier: 'FEATURED' }, + { name: 'June Calloway' }, + { name: 'Nils Varga' }, + { name: 'Ruth Abara' }, + ], + slots: [ + { ...slotAt(start, 0, 45), stage: 'Main Stage', people: ['June Calloway'] }, + { ...slotAt(start, 60, 120), stage: 'Main Stage', people: ['Ada Byron'] }, + { ...slotAt(start, 150, 240), stage: 'Main Stage', people: ['Velvet Harbour'] }, + ], + }); + } + + await api.updateEventSettings(event.id, { + homepage_theme_settings: { ...spec.theme, background_type: 'COLOR', layout }, + homepage_sections: [ + { id: 'tickets', type: 'TICKETS', enabled: true }, + { id: 'about', type: 'ABOUT', enabled: true }, + { id: 'lineup', type: 'LINEUP', enabled: true }, + { id: 'schedule', type: 'SCHEDULE', enabled: true }, + ...(spec.rich ? [ + { id: 'travel', type: 'TEXT', enabled: true, heading: 'Getting there', body: '

The 42 bus stops outside, and the ferry runs until half past eleven.

' }, + { id: 'faq', type: 'FAQ', enabled: true, heading: 'Good to know', faqs: [{ question: 'Is there a cloakroom?', answer: '

Yes, by the main doors.

' }, { question: 'Can I bring a camera?', answer: '

Phones and small cameras only.

' }] }, + ] : []), + { id: 'organizer', type: 'ORGANIZER', enabled: true }, + { id: 'more_events', type: 'MORE_EVENTS', enabled: true }, + ], + }); + await api.publishEvent(event.id); + + return { kind: spec.kind, eventId: event.id, slug: event.slug, title, theme: spec.theme, hasCover: !!spec.cover }; +}; + +const seedEvents = async (api: ApiClient, browser: Browser, layout: Layout): Promise => { + const organizer = await createFreshOrganizer(api); + await api.updateOrganizerStatus(organizer.id, 'LIVE'); + + return [ + await seedEvent(api, browser, organizer.id, layout, { + kind: 'Flyer', + theme: { accent: '#0f766e', background: '#f0fdfa', mode: 'light' }, + cover: { width: 1080, height: 1350 }, + description: LONG_COPY, + tickets: [0, 25, 45], + rich: true, + }), + await seedEvent(api, browser, organizer.id, layout, { + kind: 'Conference', + theme: { accent: '#1d4ed8', background: '#eff6ff', mode: 'light' }, + cover: { width: 1600, height: 900 }, + description: LONG_COPY, + tickets: [120, 240], + }), + await seedEvent(api, browser, organizer.id, layout, { + kind: 'Bare', + theme: { accent: '#f472b6', background: '#0f0f14', mode: 'dark' }, + tickets: [0], + }), + ]; +}; + +const rgb = (hex: string) => { + const value = parseInt(hex.slice(1), 16); + return `rgb(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255})`; +}; + +const measure = (page: Page) => page.evaluate(() => { + const box = (element: Element | null) => { + if (!element) { + return null; + } + const rect = element.getBoundingClientRect(); + return { x: rect.x, y: rect.y + window.scrollY, width: rect.width, height: rect.height, right: rect.right, bottom: rect.bottom + window.scrollY }; + }; + const intro = document.querySelector('main h1')!.closest('header')!; + const column = intro.parentElement!; + const tickets = document.getElementById('tickets'); + const flow = Array.from(column.children) + .filter(child => child !== tickets || getComputedStyle(child).position !== 'sticky') + .map(box) + .filter(rect => rect && rect.height > 0) + .sort((a, b) => a!.y - b!.y); + const gaps = flow.slice(1).map((rect, index) => Math.round(rect!.y - flow[index]!.bottom)); + const sheet = box(document.querySelector('main article')); + const poster = box(document.querySelector('[class*="posterColumn"]')); + const contentBottom = Math.max(...[...flow, box(tickets), poster].filter(rect => rect).map(rect => rect!.bottom)); + const footerContentBottom = Math.max(...Array.from(document.querySelector('main footer')!.children).map(child => box(child)!.bottom)); + + return { + overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + title: box(document.querySelector('main h1')), + tickets: box(tickets), + sheet, + poster, + gaps, + footerGap: Math.round(box(document.querySelector('main footer'))!.y - contentBottom), + footerAtSheetBottom: Math.abs(box(document.querySelector('main footer'))!.bottom - sheet!.bottom) <= 1.5, + spaceBelowFooterContent: Math.round(sheet!.bottom - footerContentBottom), + fitsViewport: sheet!.bottom <= window.innerHeight + 1, + bodyBackground: getComputedStyle(document.body).backgroundColor, + sheetBackground: getComputedStyle(document.querySelector('main article')!).backgroundColor, + accentBand: getComputedStyle(document.querySelector('[class*="dateTileMonth"]') ?? document.body).backgroundColor, + titleAlign: getComputedStyle(document.querySelector('main h1')!).textAlign, + }; +}); + +const shotsDir = process.env.LAYOUT_SHOTS_DIR; + +const attachShot = async (page: Page, testInfo: TestInfo, name: string) => { + const body = await page.screenshot({ fullPage: true }); + await testInfo.attach(name, { body, contentType: 'image/png' }); + return body; +}; + +const saveShot = async (page: Page, testInfo: TestInfo, name: string) => { + if (!shotsDir) { + return; + } + const body = await attachShot(page, testInfo, name); + fs.mkdirSync(shotsDir, { recursive: true }); + fs.writeFileSync(path.join(shotsDir, `${name}.png`), body); +}; + +const viewportState = (page: Page) => page.evaluate(async () => { + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const inView = (element: Element) => { + const rect = element.getBoundingClientRect(); + return rect.bottom > 0 && rect.top < window.innerHeight; + }; + return { + tickets: inView(document.getElementById('tickets')!), + footer: inView(document.querySelector('main footer')!), + titleScrolledAway: document.querySelector('main h1')!.getBoundingClientRect().bottom < 0, + }; +}); + +const expectStructure = (layout: Layout, viewport: Viewport, event: Seeded, metrics: Awaited>) => { + const { title, tickets, sheet, poster } = metrics; + const beside = (left: { right: number }, right: { x: number }) => right.x >= left.right; + + if (layout === 'MARQUEE' && viewport.name === 'desktop') { + expect(beside(title!, tickets!)).toBe(true); + } else { + expect(tickets!.y).toBeGreaterThanOrEqual(title!.bottom); + expect(Math.abs(tickets!.x - title!.x)).toBeLessThan(2); + } + + if (layout === 'FLYER') { + expect(poster).not.toBeNull(); + if (viewport.name === 'phone') { + expect(poster!.bottom).toBeLessThanOrEqual(title!.y); + } else { + expect(beside(poster!, title!)).toBe(true); + } + } else { + expect(poster).toBeNull(); + } + + if (viewport.name === 'desktop') { + const widths: Record = { MARQUEE: [1000, 1200], FLYER: [1000, 1200], STORY: [700, 801], INVITE: [560, 641] }; + expect(sheet!.width).toBeGreaterThanOrEqual(widths[layout][0]); + expect(sheet!.width).toBeLessThanOrEqual(widths[layout][1]); + } + + if (layout === 'INVITE') { + expect(metrics.titleAlign).toBe('center'); + } + + expect(metrics.bodyBackground).toBe(rgb(event.theme.background)); + expect(metrics.sheetBackground).toBe(rgb(getDerivedColors(event.theme.mode).surface)); + expect(metrics.accentBand).toBe(rgb(event.theme.accent)); +}; + +for (const layout of ['MARQUEE', 'FLYER', 'STORY', 'INVITE'] as Layout[]) { + test(`the ${layout.toLowerCase()} layout holds together on phones, tablets and desktops`, async ({ page, api, browser }, testInfo) => { + test.setTimeout(300_000); + const events = await seedEvents(api, browser, layout); + await page.context().addCookies([{ ...grantedConsentCookie(), value: 'analytics=0&advertising=0' }]); + let ticketBarShown = 0; + let sectionBarShown = 0; + const hydrationErrors: string[] = []; + page.on('console', message => { + if (message.type() === 'error' && /hydrat|#418|#423|#425/i.test(message.text())) { + hydrationErrors.push(message.text()); + } + }); + + for (const viewport of VIEWPORTS) { + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + + for (const event of events) { + const shotName = `${layout.toLowerCase()}-${viewport.name}-${event.kind.toLowerCase()}`; + try { + await page.goto(`/event/${event.eventId}/${event.slug}`); + await page.waitForLoadState('networkidle'); + await expect(page.getByRole('heading', { level: 1, name: event.title })).toBeVisible(); + await expect(page.locator('#more-events')).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + + const metrics = await measure(page); + expect(metrics.overflow, `${event.kind} on ${viewport.name} scrolls sideways`).toBeLessThanOrEqual(0); + expect(metrics.gaps.every(gap => Math.abs(gap) <= 1), `${event.kind} on ${viewport.name} has gaps ${metrics.gaps}`).toBe(true); + expect(metrics.footerAtSheetBottom, `${event.kind} on ${viewport.name} footer is not the last thing on the page`).toBe(true); + expect(metrics.spaceBelowFooterContent, `${event.kind} on ${viewport.name} leaves space under the footer`).toBeLessThanOrEqual(40); + expect(metrics.fitsViewport || metrics.footerGap <= 96, `${event.kind} on ${viewport.name} leaves ${metrics.footerGap}px above the footer`).toBe(true); + expectStructure(layout, viewport, event, metrics); + + if (event.hasCover) { + const cover = page.getByRole('img', { name: event.title }); + await expect(cover).toBeVisible(); + expect(await cover.evaluate(image => getComputedStyle(image).objectFit)).toBe('contain'); + } + + await saveShot(page, testInfo, shotName); + + const getTickets = page.getByRole('button', { name: 'Get Tickets' }); + await page.evaluate(() => { + const tickets = document.getElementById('tickets')!; + window.scrollTo({ top: tickets.getBoundingClientRect().bottom + window.scrollY + 40, behavior: 'instant' }); + }); + const pastTickets = await viewportState(page); + if (!pastTickets.tickets && !pastTickets.footer) { + ticketBarShown++; + await expect(getTickets, `${event.kind} on ${viewport.name} has no bar back to the tickets`).toBeVisible(); + } else { + await expect(getTickets, `${event.kind} on ${viewport.name} shows the tickets bar while the tickets or footer are in view`).toBeHidden(); + } + + await page.mouse.move(viewport.width / 2, viewport.height / 2); + await page.mouse.wheel(0, 100_000); + await expect.poll(() => page.evaluate(() => Math.ceil(window.scrollY + window.innerHeight) >= document.documentElement.scrollHeight)).toBe(true); + await expect(getTickets, `${event.kind} on ${viewport.name} covers the footer`).toBeHidden(); + const atBottom = await viewportState(page); + const sectionBar = page.getByRole('navigation', { name: 'Sections' }); + if (viewport.name === 'desktop' && layout !== 'INVITE' && atBottom.titleScrolledAway) { + sectionBarShown++; + await expect(sectionBar, `${event.kind} on ${viewport.name} has no section bar once the title scrolls away`).toBeVisible(); + } else { + await expect(sectionBar, `${event.kind} on ${viewport.name} shows the section bar`).toBeHidden(); + } + + await page.mouse.move(0, 0); + const fit = await stressImageFit(page); + expect(fit.problems, `${event.kind} on ${viewport.name}`).toEqual([]); + expect(fit.horizontalOverflow).toBeLessThanOrEqual(0); + } catch (error) { + await attachShot(page, testInfo, `${shotName}-failure`).catch(() => undefined); + throw error; + } + } + } + + expect(ticketBarShown, 'no page scrolled far enough past the tickets to show the bar').toBeGreaterThan(0); + if (layout !== 'INVITE') { + expect(sectionBarShown, 'no desktop page scrolled far enough to show the section bar').toBeGreaterThan(0); + } + expect(hydrationErrors).toEqual([]); + }); +} diff --git a/e2e/tests/events/event-page-navigation.spec.ts b/e2e/tests/events/event-page-navigation.spec.ts new file mode 100644 index 0000000000..d263d01fa7 --- /dev/null +++ b/e2e/tests/events/event-page-navigation.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from '../../fixtures'; +import { createLiveEventWithProduct } from '../../api/factory'; +import type { ApiClient } from '../../api/api-client'; +import { PublicEventPage } from '../../pages/public-event.page'; + +const travelInfo = Array.from({ length: 18 }, (_, index) => `

Travel note ${index + 1}: the shuttle leaves the square every twenty minutes.

`).join(''); + +const createEventWithSections = async (api: ApiClient, organizerId: number) => { + const event = await createLiveEventWithProduct(api, { organizerId, price: 15 }); + await api.updateEventSettings(event.eventId, { + homepage_theme_settings: { accent: '#7c3aed', background: '#f5f3ff', mode: 'light', background_type: 'COLOR', layout: 'MARQUEE' }, + homepage_sections: [ + { id: 'tickets', type: 'TICKETS', enabled: true }, + { id: 'travel', type: 'TEXT', enabled: true, heading: 'Getting there', body: travelInfo }, + { id: 'faq', type: 'FAQ', enabled: true, heading: 'Questions', faqs: [{ question: 'Is there parking?', answer: '

Free parking opens two hours before doors.

' }] }, + ], + }); + return event; +}; + +test.describe('event page navigation', () => { + test('on a desktop the tickets sit beside the title and the section bar takes the visitor to a section', async ({ page, api, account }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + const event = await createEventWithSections(api, account.organizerId); + const publicPage = new PublicEventPage(page); + await publicPage.goto(event.eventId, event.slug); + + const titleBox = (await page.getByRole('heading', { level: 1, name: event.title }).boundingBox())!; + const ticketsBox = (await publicPage.ticketsRegion().boundingBox())!; + expect(ticketsBox.x).toBeGreaterThan(titleBox.x + titleBox.width); + expect(ticketsBox.y).toBeLessThan(titleBox.y + titleBox.height); + + await expect(publicPage.sectionBar()).toBeHidden(); + await page.mouse.wheel(0, 900); + await expect(publicPage.sectionBar()).toBeVisible(); + + await publicPage.sectionBar().getByRole('link', { name: 'Questions' }).click(); + const faq = page.getByTestId('homepage-section-faq'); + await expect(faq).toBeInViewport(); + await expect(faq).toBeFocused(); + await expect(publicPage.ticketsRegion()).toBeInViewport(); + }); + + test('on a phone the tickets follow the event details and the bottom bar brings the buyer back to them', async ({ page, api, account }) => { + await page.setViewportSize({ width: 390, height: 740 }); + const event = await createEventWithSections(api, account.organizerId); + const publicPage = new PublicEventPage(page); + await publicPage.goto(event.eventId, event.slug); + + const titleBox = (await page.getByRole('heading', { level: 1, name: event.title }).boundingBox())!; + const ticketsBox = (await publicPage.ticketsRegion().boundingBox())!; + expect(ticketsBox.y).toBeGreaterThan(titleBox.y + titleBox.height); + await expect(publicPage.sectionBar()).toBeHidden(); + + await page.evaluate(() => window.scrollTo(0, document.getElementById('tickets')!.getBoundingClientRect().bottom + window.scrollY + 40)); + const getTickets = page.getByRole('button', { name: 'Get Tickets' }); + await expect(getTickets).toBeVisible(); + await getTickets.click(); + + await expect(publicPage.ticketsRegion()).toBeInViewport(); + await expect(publicPage.ticketsRegion()).toBeFocused(); + }); +}); diff --git a/e2e/tests/events/image-transform.unit.spec.ts b/e2e/tests/events/image-transform.unit.spec.ts new file mode 100644 index 0000000000..72f1bf645d --- /dev/null +++ b/e2e/tests/events/image-transform.unit.spec.ts @@ -0,0 +1,26 @@ +import {expect, test} from '@playwright/test'; +import {responsiveImage, sizedImageUrl} from '../../../frontend/src/utilites/imageTransform'; + +const imagekit = 'https://ik.imagekit.io/hievents/tr:w-1400,q-100/event_cover/poster-AbC12.png'; + +test('an ImageKit cover offers smaller widths and keeps the configured quality', () => { + expect(responsiveImage(imagekit, '100vw')).toEqual({ + src: imagekit, + srcSet: [ + 'https://ik.imagekit.io/hievents/tr:w-480,q-100/event_cover/poster-AbC12.png 480w', + 'https://ik.imagekit.io/hievents/tr:w-960,q-100/event_cover/poster-AbC12.png 960w', + 'https://ik.imagekit.io/hievents/tr:w-1400,q-100/event_cover/poster-AbC12.png 1400w', + ].join(', '), + sizes: '100vw', + }); +}); + +test('a small ImageKit image is requested at the width it is shown at', () => { + expect(sizedImageUrl(imagekit, 96)).toBe('https://ik.imagekit.io/hievents/tr:w-96,q-100/event_cover/poster-AbC12.png'); +}); + +test('self-hosted image URLs are left exactly as they are', () => { + const selfHosted = 'https://cdn.example.com/hievents-public/event_cover/poster-AbC12.png'; + expect(responsiveImage(selfHosted, '100vw')).toEqual({src: selfHosted}); + expect(sizedImageUrl(selfHosted, 96)).toBe(selfHosted); +}); diff --git a/e2e/tests/events/more-from-organizer.spec.ts b/e2e/tests/events/more-from-organizer.spec.ts new file mode 100644 index 0000000000..471529689a --- /dev/null +++ b/e2e/tests/events/more-from-organizer.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from '../../fixtures'; +import { createFreshOrganizer, createLiveEventWithFreeTicket } from '../../api/factory'; +import { PublicEventPage } from '../../pages/public-event.page'; +import type { ApiClient } from '../../api/api-client'; + +const showMoreEvents = (api: ApiClient, eventId: number) => api.updateEventSettings(eventId, { + homepage_sections: [ + { id: 'tickets', type: 'TICKETS', enabled: true }, + { id: 'more_events', type: 'MORE_EVENTS', enabled: true }, + ], +}); + +test.describe('more from the organizer', () => { + test('an event page lists the organizer’s other upcoming events and opens them', async ({ page, api }) => { + const organizer = await createFreshOrganizer(api); + await api.updateOrganizerStatus(organizer.id, 'LIVE'); + const current = await createLiveEventWithFreeTicket(api, organizer.id); + const other = await createLiveEventWithFreeTicket(api, organizer.id); + await showMoreEvents(api, current.eventId); + + const eventPage = new PublicEventPage(page); + await eventPage.goto(current.eventId, current.slug); + + const section = eventPage.moreEventsSection(); + await expect(section.getByRole('heading', { name: `More from ${organizer.name}`, level: 2 })).toBeVisible(); + await expect(section.getByRole('link', { name: other.title })).toBeVisible(); + await expect(section.getByRole('link', { name: current.title })).toHaveCount(0); + await expect(section.getByRole('link', { name: 'View all' })).toHaveAttribute('href', new RegExp(`/events/${organizer.id}/`)); + + await section.getByRole('link', { name: other.title }).click(); + await expect(page).toHaveURL(new RegExp(`/event/${other.eventId}/`)); + await expect(page.getByRole('heading', { name: other.title, level: 1 })).toBeVisible(); + }); + + test('an organizer with no other upcoming events gets no section', async ({ page, api }) => { + const organizer = await createFreshOrganizer(api); + await api.updateOrganizerStatus(organizer.id, 'LIVE'); + const only = await createLiveEventWithFreeTicket(api, organizer.id); + await showMoreEvents(api, only.eventId); + + const eventPage = new PublicEventPage(page); + await eventPage.goto(only.eventId, only.slug); + + await expect(page.getByRole('heading', { name: only.title, level: 1 })).toBeVisible(); + await expect(eventPage.moreEventsSection()).toHaveCount(0); + }); + + test('the section stays hidden until the organizer turns it on', async ({ page, api }) => { + const organizer = await createFreshOrganizer(api); + await api.updateOrganizerStatus(organizer.id, 'LIVE'); + const current = await createLiveEventWithFreeTicket(api, organizer.id); + await createLiveEventWithFreeTicket(api, organizer.id); + + const eventPage = new PublicEventPage(page); + await eventPage.goto(current.eventId, current.slug); + + await expect(page.getByRole('heading', { name: current.title, level: 1 })).toBeVisible(); + await expect(eventPage.moreEventsSection()).toHaveCount(0); + }); +}); diff --git a/e2e/tests/events/past-event-page.spec.ts b/e2e/tests/events/past-event-page.spec.ts index 1e1df9fbf5..ae73437fa3 100644 --- a/e2e/tests/events/past-event-page.spec.ts +++ b/e2e/tests/events/past-event-page.spec.ts @@ -4,7 +4,7 @@ import { createFreshOrganizer, createPastEventWithCoverImage } from '../../api/f import { uniqueName } from '../../utils/unique'; test.describe('past event page', () => { - test('a visitor sees a sales ended badge and no way to buy', async ({ page, api }) => { + test('a visitor sees that the event has ended and no way to buy', async ({ page, api }) => { const organizer = await createFreshOrganizer(api, uniqueName('E2E Past Org')); await api.updateOrganizerStatus(organizer.id, 'LIVE'); const event = await createPastEventWithCoverImage(api, organizer.id, { title: uniqueName('E2E Past Event') }); @@ -13,12 +13,13 @@ test.describe('past event page', () => { await publicPage.goto(event.eventId, event.slug); await expect(page.getByRole('heading', { name: event.title })).toBeVisible(); - await expect(page.getByText('Sales ended', { exact: true })).toBeVisible(); - await expect(page.getByText('Ticket sales have ended for this event')).toBeVisible(); + await expect(page.getByText('This event has ended', { exact: true })).toBeVisible(); + await expect(page.getByRole('region', { name: 'Tickets' })).toHaveCount(0); await expect(page.getByText(event.productTitle)).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Get Tickets' })).toHaveCount(0); }); - test('a visitor sees a sales ended badge on a recurring event whose dates have all passed', async ({ page, api }) => { + test('a visitor sees that a recurring event whose dates have all passed has ended', async ({ page, api }) => { const organizer = await createFreshOrganizer(api, uniqueName('E2E Past Recurring Org')); await api.updateOrganizerStatus(organizer.id, 'LIVE'); const event = await createPastEventWithCoverImage(api, organizer.id, { @@ -30,6 +31,6 @@ test.describe('past event page', () => { await publicPage.goto(event.eventId, event.slug); await expect(page.getByRole('heading', { name: event.title })).toBeVisible(); - await expect(page.getByText('Sales ended', { exact: true })).toBeVisible(); + await expect(page.getByText('This event has ended', { exact: true })).toBeVisible(); }); }); diff --git a/e2e/tests/lineup/public-lineup-mixed-photos.spec.ts b/e2e/tests/lineup/public-lineup-mixed-photos.spec.ts index 82721ca8fb..3ee1cfc21f 100644 --- a/e2e/tests/lineup/public-lineup-mixed-photos.spec.ts +++ b/e2e/tests/lineup/public-lineup-mixed-photos.spec.ts @@ -62,6 +62,10 @@ test.describe('public lineup cards with and without photos', () => { await page.setViewportSize({ width: 390, height: 844 }); await new PublicEventPage(page).goto(event.eventId, event.slug); - expect((await box(card(names.headlinerPlain))).height).toBeLessThan(200); + const phonePhoto = await box(card(names.headlinerPhoto)); + const phonePlain = await box(card(names.headlinerPlain)); + expect(Math.abs(phonePlain.y - phonePhoto.y)).toBeLessThan(1); + expect(Math.abs(phonePlain.height - phonePhoto.height)).toBeLessThan(1); + expect(phonePlain.width).toBeLessThan(200); }); }); diff --git a/e2e/tests/management/event-type.spec.ts b/e2e/tests/management/event-type.spec.ts new file mode 100644 index 0000000000..ad23253995 --- /dev/null +++ b/e2e/tests/management/event-type.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from '../../fixtures'; +import { EventSettingsPage } from '../../pages/event-settings.page'; +import { createCompletedOrder, createDraftEvent, createLiveEventWithProduct, createRecurringLiveEvent } from '../../api/factory'; + +test.describe('event type', () => { + test('an organizer turns a single event into a recurring event and back', async ({ authedPage, api, account }) => { + const event = await createDraftEvent(api, account.organizerId); + + const settings = new EventSettingsPage(authedPage); + await settings.goto(event.eventId); + await expect(settings.section('event-type')).toContainText('This is a single event.'); + await expect(settings.section('event-type')).toContainText('while it has exactly one date and no orders'); + + await settings.changeEventType('Change to recurring event'); + await expect(authedPage.getByText('Event changed to a recurring event')).toBeVisible(); + await expect(settings.section('event-type')).toContainText('This is a recurring event.'); + await expect(authedPage.getByRole('link', { name: 'Occurrence Schedule' })).toBeVisible(); + await expect.poll(async () => (await api.getEvent(event.eventId)).type).toBe('RECURRING'); + + await settings.changeEventType('Change to single event'); + await expect(authedPage.getByText('Event changed to a single event')).toBeVisible(); + await expect(settings.section('event-type')).toContainText('This is a single event.'); + await expect(authedPage.getByRole('link', { name: 'Occurrence Schedule' })).toHaveCount(0); + await expect.poll(async () => (await api.getEvent(event.eventId)).type).toBe('SINGLE'); + }); + + test('an organizer is warned that a single event with orders cannot be changed back', async ({ authedPage, api, publicApi, account }) => { + const event = await createLiveEventWithProduct(api, { organizerId: account.organizerId }); + await createCompletedOrder(publicApi, event); + + const settings = new EventSettingsPage(authedPage); + await expect(async () => { + await settings.goto(event.eventId); + await expect(settings.section('event-type')).toContainText('already has orders', { timeout: 2_000 }); + }).toPass(); + + await settings.eventTypeChangeButton.click(); + const dialog = authedPage.getByRole('dialog'); + await expect(dialog).toContainText("This event already has orders, so you won't be able to change it back to a single event."); + await dialog.getByRole('button', { name: 'Change to recurring event' }).click(); + await expect(authedPage.getByText('Event changed to a recurring event')).toBeVisible(); + + await settings.changeEventType('Change to single event'); + await expect(authedPage.getByText('A recurring event with orders cannot be changed to a single event.')).toBeVisible(); + expect((await api.getEvent(event.eventId)).type).toBe('RECURRING'); + }); + + test('a recurring event with several dates stays recurring', async ({ authedPage, api, account }) => { + const event = await createRecurringLiveEvent(api, account.organizerId, { count: 2 }); + + const settings = new EventSettingsPage(authedPage); + await settings.goto(event.eventId); + + await settings.changeEventType('Change to single event'); + await expect(authedPage.getByText('Only a recurring event with exactly one date can be changed to a single event.', { exact: false })).toBeVisible(); + await expect(settings.section('event-type')).toContainText('This is a recurring event.'); + expect((await api.getEvent(event.eventId)).type).toBe('RECURRING'); + }); +}); diff --git a/e2e/tests/management/homepage-designer-limits.spec.ts b/e2e/tests/management/homepage-designer-limits.spec.ts index 92b4789caf..3ddc98a2bc 100644 --- a/e2e/tests/management/homepage-designer-limits.spec.ts +++ b/e2e/tests/management/homepage-designer-limits.spec.ts @@ -98,11 +98,11 @@ test.describe('homepage designer limits', () => { test('the section limit explains why Add section is unavailable', async ({ authedPage: page, api, account }) => { const event = await createLiveEventWithFreeTicket(api, account.organizerId); - const builtIns = ['TICKETS', 'ABOUT', 'LINEUP', 'SCHEDULE', 'LOCATION', 'ORGANIZER']; + const builtIns = ['TICKETS', 'ABOUT', 'LINEUP', 'SCHEDULE', 'LOCATION', 'ORGANIZER', 'MORE_EVENTS']; await api.updateEventSettings(event.eventId, { homepage_sections: [ ...builtIns.map((type) => ({ id: type.toLowerCase(), type, enabled: true })), - ...Array.from({ length: 40 - builtIns.length }, (_, index) => ({ id: `text_${index}`, type: 'TEXT', enabled: true, heading: `Text ${index + 1}`, body: '

Hello

' })), + ...Array.from({ length: 41 - builtIns.length }, (_, index) => ({ id: `text_${index}`, type: 'TEXT', enabled: true, heading: `Text ${index + 1}`, body: '

Hello

' })), ], }); const designer = new DesignerPage(page); @@ -112,7 +112,7 @@ test.describe('homepage designer limits', () => { const addButton = page.getByTestId('homepage-add-section-button'); await expect(addButton).toHaveAttribute('aria-disabled', 'true'); await addButton.hover(); - await expect(page.getByRole('tooltip', { name: 'You can add up to 40 sections.' })).toBeVisible(); + await expect(page.getByRole('tooltip', { name: 'You can add up to 41 sections.' })).toBeVisible(); await addButton.click({ force: true }); await expect(page.getByTestId('homepage-add-section-text')).toHaveCount(0); diff --git a/e2e/tests/management/homepage-designer.spec.ts b/e2e/tests/management/homepage-designer.spec.ts index 04ca9677a3..fd8a8b521f 100644 --- a/e2e/tests/management/homepage-designer.spec.ts +++ b/e2e/tests/management/homepage-designer.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '../../fixtures'; import { DesignerPage } from '../../pages/designer.page'; -import { createDraftEventWithTicket } from '../../api/factory'; +import { createDraftEventWithTicket, createLiveEventWithFreeTicket } from '../../api/factory'; test.describe('homepage designer', () => { test('an organizer previews and saves a new continue button label', { tag: '@smoke' }, async ({ authedPage, api, account }) => { @@ -50,4 +50,27 @@ test.describe('homepage designer', () => { await authedPage.getByRole('link', { name: 'Ticket Designer' }).click(); await expect(authedPage).toHaveURL(/\/ticket-designer$/); }); + + test('an organizer switches the page layout, sees it in the preview and publishes it', async ({ authedPage, page, api, account }) => { + const event = await createLiveEventWithFreeTicket(api, account.organizerId); + const designer = new DesignerPage(authedPage); + await designer.gotoHomepageDesigner(event.eventId); + + await expect(designer.layoutOption('story')).toHaveAttribute('aria-checked', 'true'); + await expect(authedPage.getByText('A focused reading column.', { exact: false })).toBeVisible(); + + await designer.layoutOption('invite').click(); + await expect(designer.layoutOption('invite')).toHaveAttribute('aria-checked', 'true'); + await expect(designer.unsavedStatus).toBeVisible(); + await expect(designer.eventPreview.getByRole('heading', { level: 1, name: event.title })).toHaveCSS('text-align', 'center'); + + await designer.saveButton.click(); + await expect(authedPage.getByText('Successfully Updated Homepage Design')).toBeVisible(); + await expect + .poll(async () => ((await api.getEventSettings(event.eventId)).homepage_theme_settings as { layout?: string }).layout) + .toBe('INVITE'); + + await page.goto(`/event/${event.eventId}/${event.slug}`); + await expect(page.getByRole('heading', { level: 1, name: event.title })).toHaveCSS('text-align', 'center'); + }); }); diff --git a/e2e/tests/management/lineup-import-export.spec.ts b/e2e/tests/management/lineup-import-export.spec.ts index 997760d898..4f29765a26 100644 --- a/e2e/tests/management/lineup-import-export.spec.ts +++ b/e2e/tests/management/lineup-import-export.spec.ts @@ -193,7 +193,8 @@ test.describe('lineup import and export', () => { await expect(preview.getByRole('heading', { name: event.productTitle })).toBeVisible(); await expect(preview.getByTestId('widget-lineup-strip')).toHaveCount(0); await page.getByTestId('widget-group-sections').click(); - await page.getByTestId('widget-show-lineup-switch').click(); + const showLineup = page.getByTestId('widget-show-lineup-switch'); + await expect(() => showLineup.check()).toPass(); await expect(preview.getByTestId('widget-lineup-strip')).toContainText(headliner); await expect(page.getByTestId('widget-embed-code')).toContainText('data-hievents-show-lineup="true"'); }); diff --git a/e2e/tests/organizers/homepage-accent-ink.unit.spec.ts b/e2e/tests/organizers/homepage-accent-ink.unit.spec.ts new file mode 100644 index 0000000000..96ecd8d26d --- /dev/null +++ b/e2e/tests/organizers/homepage-accent-ink.unit.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from '@playwright/test'; +import { checkContrast, getAccentInk, getDerivedColors } from '../../../frontend/src/utilites/themeUtils'; + +const ACCENTS = ['#8b5cf6', '#ff5a36', '#facc15', '#a3e635', '#22d3ee', '#f97316', '#14b8a6', '#9ec19e', '#1e3a8a', '#111111', '#ffffff']; + +test.describe('homepage accent text colour', () => { + for (const mode of ['light', 'dark'] as const) { + for (const accent of ACCENTS) { + test(`${accent} accent text stays readable in ${mode} mode`, () => { + const ink = getAccentInk(accent, mode); + + expect(checkContrast(ink, getDerivedColors(mode).surface).passesAA).toBe(true); + }); + } + } + + test('an accent that is already readable is used as-is', () => { + expect(getAccentInk('#1e3a8a', 'light')).toBe('#1e3a8a'); + }); + + test('a pale accent is darkened rather than replaced, so it keeps its hue', () => { + const ink = getAccentInk('#facc15', 'light'); + + expect(ink).not.toBe('#facc15'); + expect(ink).not.toBe(getDerivedColors('light').textPrimary); + }); + + test('an unreadable colour value falls back to the body text colour', () => { + expect(getAccentInk('not-a-colour', 'dark')).toBe(getDerivedColors('dark').textPrimary); + }); +}); diff --git a/e2e/tests/organizers/organizer-event-list.unit.spec.ts b/e2e/tests/organizers/organizer-event-list.unit.spec.ts new file mode 100644 index 0000000000..3fc154f102 --- /dev/null +++ b/e2e/tests/organizers/organizer-event-list.unit.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test'; +import { decodeHtmlEntities } from '../../../frontend/src/utilites/htmlEntities'; +import { groupConsecutive } from '../../../frontend/src/utilites/groupConsecutive'; + +test.describe('event card description preview', () => { + test('named and numeric entities from the stripped description read as plain text', () => { + expect(decodeHtmlEntities('Statutory & mandatory — "Level 3" — it's free today')) + .toBe('Statutory & mandatory — "Level 3" — it\'s free\u00a0today'); + }); + + test('an escaped tag stays text and is decoded only once', () => { + expect(decodeHtmlEntities('<script>alert(1)</script> &lt;b&gt;')) + .toBe(' <b>'); + }); + + test('unknown or out-of-range entities are left alone', () => { + expect(decodeHtmlEntities('©right; � � Fish & chips')).toBe('©right; � � Fish & chips'); + }); +}); + +test.describe('organizer event list month groups', () => { + const event = (id: number, month: string) => ({ id, month }); + const groups = (events: ReturnType[]) => groupConsecutive(events, (item) => item.month) + .map((group) => ({ key: group.key, ids: group.items.map((item) => item.id) })); + + test('consecutive events in the same month share a heading, in the order the API sorted them', () => { + expect(groups([event(1, '2026-10'), event(2, '2026-10'), event(3, '2026-11'), event(4, '2027-01')])).toEqual([ + { key: '2026-10', ids: [1, 2] }, + { key: '2026-11', ids: [3] }, + { key: '2027-01', ids: [4] }, + ]); + }); + + test('past events sorted newest first group the same way, and undated events get their own group', () => { + expect(groups([event(1, '2026-09'), event(2, '2026-09'), event(3, '2026-08'), event(4, '')])).toEqual([ + { key: '2026-09', ids: [1, 2] }, + { key: '2026-08', ids: [3] }, + { key: '', ids: [4] }, + ]); + }); + + test('an empty page has no groups', () => { + expect(groups([])).toEqual([]); + }); +}); diff --git a/e2e/tests/organizers/organizer-homepage-layout.unit.spec.ts b/e2e/tests/organizers/organizer-homepage-layout.unit.spec.ts index f7c5bb5f6b..ddb93978ab 100644 --- a/e2e/tests/organizers/organizer-homepage-layout.unit.spec.ts +++ b/e2e/tests/organizers/organizer-homepage-layout.unit.spec.ts @@ -65,6 +65,6 @@ test.describe('organizer homepage layout', () => { const event = withDefaultSections([section('TEXT'), section('TICKETS')], EVENT_DEFAULT_SECTIONS); expect(organizer.map((item) => item.type)).toEqual(['ABOUT', 'CONTACT', 'EVENTS', 'PAST_EVENTS', 'FAQ']); - expect(event.map((item) => item.type)).toEqual(['TEXT', 'TICKETS', 'ABOUT', 'LINEUP', 'SCHEDULE', 'LOCATION', 'ORGANIZER']); + expect(event.map((item) => item.type)).toEqual(['TEXT', 'TICKETS', 'ABOUT', 'LINEUP', 'SCHEDULE', 'LOCATION', 'ORGANIZER', 'MORE_EVENTS']); }); }); diff --git a/e2e/tests/organizers/organizer-public-page.spec.ts b/e2e/tests/organizers/organizer-public-page.spec.ts index 0428f96c39..fb38137f3f 100644 --- a/e2e/tests/organizers/organizer-public-page.spec.ts +++ b/e2e/tests/organizers/organizer-public-page.spec.ts @@ -33,13 +33,15 @@ test.describe('organizer public page', () => { }); const [next] = [...event.occurrences].sort((a, b) => a.start_date.localeCompare(b.start_date)); const nextDate = new Date(next.start_date); - const nextLabel = `${nextDate.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' })} ${nextDate.getUTCDate()}`; + const nextMonth = nextDate.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' }); + const nextWeekday = nextDate.toLocaleString('en-US', { weekday: 'short', timeZone: 'UTC' }); const publicPage = new OrganizerPublicPage(page); await publicPage.goto(organizer.id, organizer.slug); const card = publicPage.eventLink(event.title); - await expect(card.getByTestId('organizer-event-card-date')).toContainText(nextLabel); + await expect(card).toContainText(new RegExp(`${nextMonth}\\s*${nextDate.getUTCDate()}(?!\\d)`)); + await expect(card.getByTestId('organizer-event-card-date')).toContainText(nextWeekday); await expect(card.getByTestId('organizer-event-card-date')).toContainText(/7:00\s?PM/); await expect(card.getByText('+2 more dates')).toBeVisible(); await expect(card.getByText('Happening now')).toHaveCount(0); diff --git a/e2e/tests/ssr/route-stylesheets.unit.spec.ts b/e2e/tests/ssr/route-stylesheets.unit.spec.ts new file mode 100644 index 0000000000..f10a6f4c13 --- /dev/null +++ b/e2e/tests/ssr/route-stylesheets.unit.spec.ts @@ -0,0 +1,31 @@ +import {expect, test} from '@playwright/test'; +import {routeStylesheets} from '../../../frontend/src/ssr/routeStylesheets.js'; + +const manifest = { + 'index.html': {file: 'assets/index.js', isEntry: true, css: ['assets/index.css']}, + 'src/components/layouts/PublicEvent/index.tsx': { + file: 'assets/public-event.js', + isDynamicEntry: true, + css: ['assets/public-event.css'], + imports: ['index.html', '_frame.js'], + }, + '_frame.js': {file: 'assets/frame.js', css: ['assets/frame.css'], imports: ['_shared.js']}, + '_shared.js': {file: 'assets/shared.js', css: ['assets/frame.css', 'assets/shared.css'], imports: ['_frame.js']}, +}; + +test('a route links its own stylesheets and those of everything it imports, once each', () => { + expect(routeStylesheets(manifest, ['src/components/layouts/PublicEvent/index.tsx'])).toEqual([ + 'assets/public-event.css', + 'assets/frame.css', + 'assets/shared.css', + ]); +}); + +test('the entry stylesheet the page template already links is left out', () => { + expect(routeStylesheets(manifest, ['index.html'])).toEqual([]); +}); + +test('an unknown route or a missing manifest links nothing', () => { + expect(routeStylesheets(manifest, ['src/unknown.tsx'])).toEqual([]); + expect(routeStylesheets({}, ['src/components/layouts/PublicEvent/index.tsx'])).toEqual([]); +}); diff --git a/e2e/tests/ssr/ssr-locale-hydration.spec.ts b/e2e/tests/ssr/ssr-locale-hydration.spec.ts index 44aa8fb699..7929a6f3b8 100644 --- a/e2e/tests/ssr/ssr-locale-hydration.spec.ts +++ b/e2e/tests/ssr/ssr-locale-hydration.spec.ts @@ -29,8 +29,13 @@ const expectCleanHydration = async (page: Page, errors: string[]) => { expect(errors.filter(error => /hydrat|#418|#423|#425/i.test(error))).toEqual([]); }; -const createPaidEvent = (api: ApiClient, organizerId: number): Promise => - createLiveEventWithProduct(api, {organizerId, price: 25, startDate: nineInTheEveningUtc()}); +const createPaidEvent = (api: ApiClient, organizerId: number, startDate = nineInTheEveningUtc()): Promise => + createLiveEventWithProduct(api, {organizerId, price: 25, startDate}); + +const SWEDISH_MONTHS = ['jan', 'feb', 'mar', 'apr', 'maj', 'jun', 'jul', 'aug', 'sep', 'okt', 'nov', 'dec']; +const POLISH_MONTHS = ['sty', 'lut', 'mar', 'kwi', 'maj', 'cze', 'lip', 'sie', 'wrz', 'paź', 'lis', 'gru']; + +const monthOf = (isoDate: string, months: string[]): string => months[new Date(isoDate).getUTCMonth()]; test.describe('server-rendered dates and prices follow the visitor locale', () => { test.describe('German browser', () => { @@ -41,7 +46,7 @@ test.describe('server-rendered dates and prices follow the visitor locale', () = const path = `/event/${event.eventId}/${event.slug}`; const html = await (await request.get(path, {headers: {'Accept-Language': 'de-DE,de;q=0.9'}})).text(); - expect(html).toMatch(/\s21:00]21:00 { + test.use({locale: 'sv-SE'}); + + test('the event page is rendered in Swedish on the server and hydrates cleanly', async ({page, request, api, account}) => { + const startDate = nineInTheEveningUtc(); + const event = await createPaidEvent(api, account.organizerId, startDate); + const path = `/event/${event.eventId}/${event.slug}`; + + const html = await (await request.get(path, {headers: {'Accept-Language': 'sv-SE,sv;q=0.9'}})).text(); + expect(html).toContain('${monthOf(startDate, SWEDISH_MONTHS)}<`); + expect(html).toMatch(/[\s>]21:00 { test.use({locale: 'en-GB'}); @@ -83,7 +108,7 @@ test.describe('server-rendered dates and prices follow the visitor locale', () = const path = `/event/${event.eventId}/${event.slug}`; const html = await (await request.get(path, {headers: {'Accept-Language': 'en-US', Cookie: 'locale=fr'}})).text(); - expect(html).toMatch(/\s21:00]21:00 { + const startDate = nineInTheEveningUtc(); + const event = await createPaidEvent(api, account.organizerId, startDate); + const path = `/event/${event.eventId}/${event.slug}`; + + const html = await (await request.get(path, {headers: {'Accept-Language': 'en-US', Cookie: 'locale=pl'}})).text(); + expect(html).toContain(`>${monthOf(startDate, POLISH_MONTHS)}<`); + + await page.context().addCookies([{name: 'locale', value: 'pl', url: baseURL!}]); + const errors = hydrationErrorsOn(page); + await page.goto(path); + await expectCleanHydration(page, errors); + await expect(page.locator('html')).toHaveAttribute('lang', 'pl'); + }); + test('a widget embedded in French renders French prices on the server and hydrates cleanly', async ({page, request, api, account}) => { const event = await createPaidEvent(api, account.organizerId); const path = `/widget/${event.eventId}?Locale=fr`; diff --git a/e2e/tests/widget/embedded-lineup-dialogs.spec.ts b/e2e/tests/widget/embedded-lineup-dialogs.spec.ts index 0d26d34456..1ba80365c5 100644 --- a/e2e/tests/widget/embedded-lineup-dialogs.spec.ts +++ b/e2e/tests/widget/embedded-lineup-dialogs.spec.ts @@ -10,12 +10,16 @@ const iframeHeight = (page: Page, title: string) => page.locator(`iframe[title="${title}"]`).evaluate((frame) => frame.getBoundingClientRect().height); const settledIframeHeight = async (page: Page, title: string) => { - await page.locator(`iframe[title="${title}"]`).scrollIntoViewIfNeeded(); + const frame = page.locator(`iframe[title="${title}"]`); + await frame.scrollIntoViewIfNeeded(); let previous = -1; await expect.poll(async () => { - const current = await iframeHeight(page, title); - const settled = current === previous && current > 150; - previous = current; + const { height, resized } = await frame.evaluate((element: HTMLIFrameElement) => ({ + height: element.getBoundingClientRect().height, + resized: element.style.height !== '', + })); + const settled = resized && height === previous; + previous = height; return settled; }, { intervals: [300] }).toBe(true); return previous; @@ -85,7 +89,7 @@ for (const viewport of [{ name: 'desktop', width: 1280, height: 800 }, { name: ' const dialog = lineup.getByRole('dialog', { name }); await expect(dialog.getByRole('heading', { name, level: 3 })).toBeVisible(); - await expect.poll(async () => (await dialog.boundingBox())!.height).toBeGreaterThan(Math.min(collapsedHeight, 400)); + await expect.poll(async () => (await dialog.boundingBox())!.height).toBeGreaterThan(Math.max(collapsedHeight, 300)); const dialogBox = (await dialog.boundingBox())!; expect(dialogBox.y).toBeGreaterThanOrEqual(0); diff --git a/e2e/utils/posterArt.ts b/e2e/utils/posterArt.ts new file mode 100644 index 0000000000..49e46d701d --- /dev/null +++ b/e2e/utils/posterArt.ts @@ -0,0 +1,25 @@ +import type { Browser } from '@playwright/test'; + +export interface PosterArt { + width: number; + height: number; + title: string; + background: string; + ink: string; +} + +export async function renderPosterArt(browser: Browser, art: PosterArt): Promise { + const page = await browser.newPage({ viewport: { width: art.width, height: art.height } }); + const unit = Math.min(art.width, art.height) / 100; + await page.setContent(` + +
+
Harbour Playhouse presents
+
${art.title}
+
Saturday 14 November · Doors 7pm
+
+ `); + const buffer = await page.screenshot({ type: 'jpeg', quality: 82 }); + await page.close(); + return buffer; +} diff --git a/frontend/package.json b/frontend/package.json index 98e47f549c..0a90c4c6d1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,7 @@ "build-strict:csr": "tsc && npm run build:csr", "preview:csr": "vite preview", "dev:ssr": "cross-env NODE_ENV=development node --import ./instrument.mjs server.js --port 5678", - "build:ssr:client": "vite build --ssrManifest --outDir dist/client", + "build:ssr:client": "vite build --outDir dist/client", "build:ssr:server": "vite build --ssr src/entry.server.tsx --outDir dist/server", "start": "cross-env NODE_ENV=production node --import ./instrument.mjs server.js", "build": "npm run messages:extract && npm run build:ssr:client && npm run build:ssr:server", diff --git a/frontend/server.js b/frontend/server.js index b402cbbaff..b62de595d3 100644 --- a/frontend/server.js +++ b/frontend/server.js @@ -17,6 +17,7 @@ import {sitemapIndexHandler, sitemapEventsHandler, sitemapOrganizersHandler} fro import {htmlSafeJsonStringify} from "./src/utilites/safeScriptJson.js"; import {backendRequestHeaders, licenceSimulation} from "./src/ssr/backendRequestHeaders.js"; import {loggableError} from "./src/ssr/loggableError.js"; +import {routeStylesheets} from "./src/ssr/routeStylesheets.js"; installGlobals(); @@ -33,9 +34,9 @@ async function main() { ? await fs.readFile("./dist/client/index.html", "utf-8") : ""; - const ssrManifest = isProduction - ? await fs.readFile("./dist/client/.vite/ssr-manifest.json", "utf-8") - : undefined; + const clientManifest = isProduction + ? JSON.parse(await fs.readFile("./dist/client/.vite/manifest.json", "utf-8").catch(() => "{}")) + : {}; const app = express(); app.use(cookieParser()); @@ -204,14 +205,14 @@ Sitemap: ${frontendUrl}/sitemap.xml render = (await dynamicImport(path.join(__dirname, "./dist/server/entry.server.js"))).render; } - const { appHtml, dehydratedState, helmetContext, themeColors, htmlLang, statusCode, renderErrors } = await render( + const { appHtml, dehydratedState, helmetContext, themeColors, htmlLang, statusCode, renderErrors, stylesheets } = await render( { req, res, backendHeaders: backendRequestHeaders(req), licenceSimulation: licenceSimulation(req), - }, - ssrManifest + stylesheetsFor: (entries) => routeStylesheets(clientManifest, entries), + } ); if (statusCode >= 500) { @@ -249,11 +250,16 @@ Sitemap: ${frontendUrl}/sitemap.xml `); } + const stylesheetLinks = stylesheets + .map((file) => ``) + .join(""); + const html = template .replace('', () => ``) + .replace("", () => `${stylesheetLinks}`) .replace("", () => headSnippets.join("\n")) .replace("", () => appHtml) - .replace("", () => ``) + .replace("", () => ``) .replace("", () => envVariablesHtml) .replace(/.*?/s, () => helmetHtml); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4715e2d7bc..da98ab102d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import {i18n} from "@lingui/core"; import {I18nProvider} from "@lingui/react"; import {ModalsProvider} from "@mantine/modals"; import {DatesProvider} from "@mantine/dates"; +import {toDayjsLocale} from "./locales.ts"; import {DehydratedState, HydrationBoundary, QueryClient, QueryClientProvider} from "@tanstack/react-query"; import {Helmet, HelmetProvider} from "react-helmet-async"; import type {ThemeColors} from "./utilites/themeColors.ts"; @@ -37,6 +38,7 @@ export const App: FC< themeColors: ThemeColors; helmetContext?: any; dehydratedState?: DehydratedState; + ssrStyled?: boolean; }> > = (props) => { const [isLoadedOnBrowser, setIsLoadedOnBrowser] = React.useState(false); @@ -46,7 +48,7 @@ export const App: FC< return ( -
+ />} - + diff --git a/frontend/src/api/event.client.ts b/frontend/src/api/event.client.ts index 97ca370b91..a03603e0d4 100644 --- a/frontend/src/api/event.client.ts +++ b/frontend/src/api/event.client.ts @@ -6,6 +6,7 @@ import { EventDuplicatePayload, EventCounts, EventStats, + EventType, GenericDataResponse, GenericPaginatedResponse, IdParam, @@ -115,6 +116,12 @@ export const eventsClient = { }); return response.data; }, + updateEventType: async (eventId: IdParam, type: EventType) => { + const response = await api.put>('events/' + eventId + '/type', { + type + }); + return response.data; + }, getEventReport: async (eventId: IdParam, reportType: IdParam, startDate?: string, endDate?: string, occurrenceId?: IdParam) => { const params = new URLSearchParams(); diff --git a/frontend/src/components/common/ContactOrganizerModal/ContactOrganizerModal.module.scss b/frontend/src/components/common/ContactOrganizerModal/ContactOrganizerModal.module.scss index a8eed79087..1542f22459 100644 --- a/frontend/src/components/common/ContactOrganizerModal/ContactOrganizerModal.module.scss +++ b/frontend/src/components/common/ContactOrganizerModal/ContactOrganizerModal.module.scss @@ -1,14 +1,56 @@ -.contactModal { +.content { + --mantine-color-body: var(--event-content-bg-color, #ffffff); + --mantine-color-text: var(--event-primary-text-color, #1a1a1a); + --mantine-color-dimmed: var(--event-secondary-color, #525252); + --mantine-color-placeholder: var(--event-secondary-text-color, #737373); + --mantine-color-default-border: var(--event-border-color, rgba(0, 0, 0, 0.1)); + --mantine-primary-color-filled: var(--event-primary-color, #7c3aed); + background: var(--mantine-color-body); + color: var(--mantine-color-text); + font-family: var(--theme-font-family, inherit); + + :global(.mantine-InputWrapper-root .mantine-Input-input) { + --input-bg: var(--mantine-color-body); + + &:not([data-error]) { + --input-color: var(--mantine-color-text); + } + } +} + +.header { + background: var(--mantine-color-body); +} + +.title { + font-size: 1.125rem; + font-weight: 700; + letter-spacing: -0.015em; +} + +.close { + color: var(--mantine-color-dimmed); + + &:hover { + background: color-mix(in srgb, var(--mantine-color-text) 8%, transparent); + } } .cancelButton { + color: var(--mantine-color-text); + + &:hover { + background: color-mix(in srgb, var(--mantine-color-text) 8%, transparent); + } } .submitButton { border-radius: 10px; + background: var(--event-primary-color, var(--mantine-primary-color-filled)); + color: var(--event-accent-contrast, #ffffff); &:hover { - opacity: 0.9; - transform: translateY(-1px); + background: var(--event-primary-color, var(--mantine-primary-color-filled)); + filter: brightness(0.94); } } diff --git a/frontend/src/components/common/ContactOrganizerModal/index.tsx b/frontend/src/components/common/ContactOrganizerModal/index.tsx index 33f992ea91..c812160e54 100644 --- a/frontend/src/components/common/ContactOrganizerModal/index.tsx +++ b/frontend/src/components/common/ContactOrganizerModal/index.tsx @@ -65,7 +65,10 @@ export const ContactOrganizerModal: React.FC = ({ onClose={onClose} title={t`Contact ${organizer?.name || 'Organizer'}`} size="md" - className={classes.contactModal} + radius={18} + withinPortal={false} + overlayProps={{backgroundOpacity: 0.6, blur: 6}} + classNames={{content: classes.content, header: classes.header, title: classes.title, close: classes.close}} >
diff --git a/frontend/src/components/common/CookieConsentBanner/CookieConsentBanner.module.scss b/frontend/src/components/common/CookieConsentBanner/CookieConsentBanner.module.scss index bbc1e9af49..a8417fc87a 100644 --- a/frontend/src/components/common/CookieConsentBanner/CookieConsentBanner.module.scss +++ b/frontend/src/components/common/CookieConsentBanner/CookieConsentBanner.module.scss @@ -7,8 +7,8 @@ max-width: 360px; padding: 16px; border-radius: 12px; - background: var(--event-content-bg-color, var(--organizer-content-bg-color, rgba(255, 255, 255, 0.97))); - border: 1px solid var(--event-border-color, var(--organizer-border-color, rgba(0, 0, 0, 0.08))); + background: var(--event-content-bg-color, rgba(255, 255, 255, 0.97)); + border: 1px solid var(--event-border-color, rgba(0, 0, 0, 0.08)); box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08), 0 1px 4px rgba(0, 0, 0, 0.04); backdrop-filter: blur(12px); animation: slideUp 0.3s ease-out; @@ -46,7 +46,7 @@ .iconWrapper { flex-shrink: 0; - color: var(--event-primary-color, var(--organizer-primary-color, #8b5cf6)); + color: var(--event-primary-color, #7c3aed); margin-top: 1px; } @@ -54,11 +54,11 @@ margin: 0; font-size: 13px; line-height: 1.45; - color: var(--event-primary-text-color, var(--organizer-primary-text-color, #374151)); + color: var(--event-primary-text-color, #374151); } .privacyLink { - color: var(--event-primary-color, var(--organizer-primary-color, #8b5cf6)); + color: var(--event-primary-color, #7c3aed); text-decoration: underline; &:hover { @@ -70,7 +70,7 @@ display: flex; flex-direction: column; margin-bottom: 12px; - border-top: 1px solid var(--event-border-color, var(--organizer-border-color, rgba(0, 0, 0, 0.08))); + border-top: 1px solid var(--event-border-color, rgba(0, 0, 0, 0.08)); } .category { @@ -79,7 +79,7 @@ justify-content: space-between; gap: 12px; padding: 10px 0; - border-bottom: 1px solid var(--event-border-color, var(--organizer-border-color, rgba(0, 0, 0, 0.08))); + border-bottom: 1px solid var(--event-border-color, rgba(0, 0, 0, 0.08)); } .categoryText { @@ -91,13 +91,13 @@ .categoryLabel { font-size: 13px; font-weight: 600; - color: var(--event-primary-text-color, var(--organizer-primary-text-color, #111827)); + color: var(--event-primary-text-color, #111827); } .categoryDescription { font-size: 12px; line-height: 1.4; - color: var(--event-secondary-color, var(--organizer-secondary-color, #6b7280)); + color: var(--event-secondary-color, #6b7280); } .actions { @@ -123,16 +123,16 @@ } .primaryButton { - background: var(--event-primary-color, var(--organizer-primary-color, #8b5cf6)); - color: var(--event-accent-contrast, var(--organizer-accent-contrast, #ffffff)); + background: var(--event-primary-color, #7c3aed); + color: var(--event-accent-contrast, #ffffff); } .ghostButton { background: transparent; - color: var(--event-secondary-color, var(--organizer-secondary-color, #6b7280)); - border: 1px solid var(--event-border-color, var(--organizer-border-color, rgba(0, 0, 0, 0.1))); + color: var(--event-secondary-color, #6b7280); + border: 1px solid var(--event-border-color, rgba(0, 0, 0, 0.1)); &:hover { - background: var(--event-accent-soft, var(--organizer-accent-soft, rgba(0, 0, 0, 0.03))); + background: var(--event-accent-soft, rgba(0, 0, 0, 0.03)); } } diff --git a/frontend/src/components/common/EventDocumentHead/index.tsx b/frontend/src/components/common/EventDocumentHead/index.tsx index 52c5eb2d41..126c5b9be2 100644 --- a/frontend/src/components/common/EventDocumentHead/index.tsx +++ b/frontend/src/components/common/EventDocumentHead/index.tsx @@ -2,7 +2,7 @@ import {Helmet} from "react-helmet-async"; import {Event, LocationType} from "../../../types"; import {eventCoverImageUrl, eventHomepageUrl} from "../../../utilites/urlHelper.ts"; -import {utcToTz} from "../../../utilites/dates.ts"; +import {formatDateWithLocale, utcToTz} from "../../../utilites/dates.ts"; import {summariseEventLocations} from "../../../utilites/effectiveLocation.ts"; import {formatAddress} from "../../../utilites/addressUtilities.ts"; import {htmlSafeJsonStringify} from "../../../utilites/safeScriptJson.js"; @@ -18,7 +18,7 @@ export const EventDocumentHead = ({event}: EventDocumentHeadProps) => { const eventSettings = event.settings; const products = event.product_categories?.flatMap(category => category.products) ?? []; const title = (eventSettings?.seo_title ?? event.title) + ' | ' + event.organizer?.name; - const description = eventSettings?.seo_description ?? event.description_preview; + const providedDescription = eventSettings?.seo_description ?? event.description_preview; const keywords = eventSettings?.seo_keywords; const image = eventCoverImageUrl(event); const url = eventHomepageUrl(event); @@ -60,6 +60,12 @@ export const EventDocumentHead = ({event}: EventDocumentHeadProps) => { const placeName = venueName ?? (effective?.type === LocationType.InPerson && structuredAddress ? formatAddress(structuredAddress) : null); + const description = providedDescription || [ + seriesStartDate ? formatDateWithLocale(seriesStartDate, 'fullDateTime', event.timezone) : null, + placeName, + event.organizer?.name, + ].filter(Boolean).join(' · '); + const includePlace = effective?.type === LocationType.InPerson && address && Object.keys(address).length > 1; const location = includePlace ? { "@type": "http://schema.org/Place", diff --git a/frontend/src/components/common/InlineOrderSummary/InlineOrderSummary.module.scss b/frontend/src/components/common/InlineOrderSummary/InlineOrderSummary.module.scss index 49100ef445..770f8ec66b 100644 --- a/frontend/src/components/common/InlineOrderSummary/InlineOrderSummary.module.scss +++ b/frontend/src/components/common/InlineOrderSummary/InlineOrderSummary.module.scss @@ -78,16 +78,6 @@ } } -.eventImagePlaceholder { - width: 100%; - height: 100%; - background: var(--checkout-accent-soft, #F3F4F6); - display: flex; - align-items: center; - justify-content: center; - color: var(--checkout-accent, #9CA3AF); -} - .eventDetails { flex: 1; min-width: 0; @@ -295,10 +285,6 @@ color: var(--checkout-text-primary, #111827); } -.totalsValueFree { - color: var(--checkout-accent, #10B981); -} - .totalsRowFinal { margin-top: 12px; padding-top: 12px; diff --git a/frontend/src/components/common/InlineOrderSummary/index.tsx b/frontend/src/components/common/InlineOrderSummary/index.tsx index ca210d326c..db7b4e2ace 100644 --- a/frontend/src/components/common/InlineOrderSummary/index.tsx +++ b/frontend/src/components/common/InlineOrderSummary/index.tsx @@ -1,6 +1,6 @@ import {useState} from "react"; import {Collapse, Popover} from "@mantine/core"; -import {IconCalendarEvent, IconChevronDown, IconInfoCircle, IconShieldCheck, IconTag} from "@tabler/icons-react"; +import {IconChevronDown, IconInfoCircle, IconShieldCheck, IconTag} from "@tabler/icons-react"; import {t} from "@lingui/macro"; import classNames from "classnames"; import {Event, LocationType, Order} from "../../../types.ts"; @@ -80,15 +80,11 @@ export const InlineOrderSummary = ({
-
- {coverImage ? ( + {coverImage && ( +
{event.title}/ - ) : ( -
- -
- )} -
+
+ )}
{event.title}
@@ -188,9 +184,7 @@ export const InlineOrderSummary = ({ )} - + {formatCurrency(totalFee, order.currency)}
diff --git a/frontend/src/components/common/NumberSelector/NumberSelector.module.scss b/frontend/src/components/common/NumberSelector/NumberSelector.module.scss index 637a778f0b..2ea6a386e1 100644 --- a/frontend/src/components/common/NumberSelector/NumberSelector.module.scss +++ b/frontend/src/components/common/NumberSelector/NumberSelector.module.scss @@ -10,6 +10,11 @@ padding: 3px; background: var(--hi-card-bg, transparent); transition: border-color 150ms cubic-bezier(0.4, 0, 0.2, 1); + + &:has(input:focus-visible) { + outline: 2px solid var(--hi-accent-text, var(--hi-accent, currentColor)); + outline-offset: 2px; + } } .control { @@ -44,11 +49,11 @@ .increment { background: var(--hi-soft-accent, rgba(0, 0, 0, 0.06)); - color: var(--hi-accent, inherit); + color: var(--hi-accent-text, var(--hi-accent, inherit)); &.control:hover { background: var(--hi-soft-accent-strong, var(--hi-soft-accent, rgba(0, 0, 0, 0.1))); - color: var(--hi-accent, inherit); + color: var(--hi-accent-text, var(--hi-accent, inherit)); } &[data-limit] { diff --git a/frontend/src/components/common/ShareIcon/index.tsx b/frontend/src/components/common/ShareIcon/index.tsx index cac3a0ff24..3e409791a6 100644 --- a/frontend/src/components/common/ShareIcon/index.tsx +++ b/frontend/src/components/common/ShareIcon/index.tsx @@ -1,4 +1,4 @@ -import {ReactNode, useState} from 'react'; +import {cloneElement, ReactElement, useState} from 'react'; import {ActionIcon, Button, CopyButton, Group, Input, Popover} from '@mantine/core'; import { IconBrandFacebook, @@ -20,7 +20,7 @@ interface ShareComponentProps { shareButtonText?: string; hideShareButtonText?: boolean; className?: string; - children?: ReactNode; + children?: ReactElement<{onClick?: () => void}>; } export const ShareComponent = ({ @@ -62,19 +62,15 @@ export const ShareComponent = ({ withArrow > -
- {children ? ( - children - ) : hideShareButtonText ? ( - - - - ) : ( - - )} -
+ {children ? cloneElement(children, {onClick: handleShareClick}) : hideShareButtonText ? ( + + + + ) : ( + + )}
diff --git a/frontend/src/components/common/StatusToggle/index.tsx b/frontend/src/components/common/StatusToggle/index.tsx index f8d2d1be53..a2fe3eac31 100644 --- a/frontend/src/components/common/StatusToggle/index.tsx +++ b/frontend/src/components/common/StatusToggle/index.tsx @@ -78,7 +78,7 @@ export const StatusToggle: React.FC = ({ if (currentStatus === 'PENDING_MANUAL_REVIEW') { return ( -
+
{t`This event is pending manual review and is not visible to the public`} @@ -93,7 +93,7 @@ export const StatusToggle: React.FC = ({ : t`This organizer profile is not published yet`; return ( -
+
{message} +
+ ); + } + + if (mode === 'tickets') { + return ( +
+ {(priceLabel || dateLabel) && ( +
+ {priceLabel && {priceLabel}} + {dateLabel && {dateLabel}} +
+ )} + +
+ ); + } + + return null; +}; diff --git a/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss b/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss index cbe272f5a3..e68fbb95fa 100644 --- a/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss +++ b/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss @@ -1,1022 +1,836 @@ @use "../../../styles/mixins.scss"; +@use "../HomepageFrame/homepage" as hp; -// Design tokens -$font-display: var(--theme-font-family, 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); -$font-body: var(--theme-font-family, 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); - -$radius-xl: 28px; -$radius-lg: 20px; -$radius-md: 14px; -$radius-sm: 10px; -$radius-xs: 6px; - -$transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1); -$transition-medium: 0.25s cubic-bezier(0.4, 0, 0.2, 1); -$transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1); - -// Background styles -.background { - position: fixed; - width: 100%; - height: 100%; - background-size: cover; - background-position: center; - background-repeat: no-repeat; - filter: blur(24px) saturate(1.3); - z-index: -1; - transform: scale(1.08); - left: 0; - right: 0; - top: 0; - bottom: 0; - - &::after { - content: ''; - position: absolute; - inset: 0; - background: var(--background-overlay); - } -} - -.backgroundOverlay { - position: fixed; - inset: 0; - z-index: -1; - - // Color overlay for MIRROR_COVER_IMAGE mode - &::after { - content: ''; - position: absolute; - inset: 0; - background-color: var(--overlay-color, transparent); - opacity: 0.4; - pointer-events: none; - } +.body { + --section-bleed: 20px; + position: relative; + padding: 24px 20px 8px; - // Noise texture - &::before { - content: ''; - position: absolute; - inset: 0; - opacity: 0.03; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); - z-index: 1; + @include hp.tablet-up { + --section-bleed: 40px; + padding: 36px 40px 16px; } -} -// Main page wrapper -.pageWrapper { - min-height: 100vh; - --background-overlay: #{rgba(white, 0.5)}; - - &[data-mode="dark"] { - --background-overlay: #{rgba(black, 0.6)}; + @include hp.wide { + --section-bleed: 24px; + padding: 44px var(--hp-content-pad, 48px) 24px; } -} - -.container { - min-height: 100vh; - font-family: $font-body; - - // Theme variables from EventHomepage component - --primary-color: var(--event-primary-color); - --primary-text-color: var(--event-primary-text-color); - --secondary-color: var(--event-secondary-color); - --secondary-text-color: var(--event-secondary-text-color); - --bg-color: var(--event-bg-color); - --content-bg-color: var(--event-content-bg-color); - --accent-contrast: var(--event-accent-contrast); - --accent-soft: var(--event-accent-soft); - --accent-muted: var(--event-accent-muted); - --border-color: var(--event-border-color); -} -.wrapper { - max-width: 920px; - margin: 0 auto; - padding: 24px 24px 96px; + &[data-rail] { + @include hp.wide { + display: grid; + grid-template-columns: minmax(0, 1fr) clamp(340px, 34%, 400px); + grid-template-rows: repeat(var(--main-rows, 1), auto) 1fr; + column-gap: clamp(40px, 4.5vw, 64px); - @include mixins.respond-below(md) { - padding: 0; + > * { + grid-column: 1; + min-width: 0; + } + } } -} -// Main unified card -.mainCard { - background-color: var(--content-bg-color); - border-radius: $radius-xl; - border: 1px solid var(--border-color); - box-shadow: - 0 4px 6px -1px rgba(0, 0, 0, 0.05), - 0 10px 15px -3px rgba(0, 0, 0, 0.08), - 0 20px 40px -5px rgba(0, 0, 0, 0.1); - overflow: hidden; - margin-bottom: 16px; - animation: fadeInUp 0.5s ease-out backwards; - - @include mixins.respond-below(md) { - border-radius: 0; - border-left: none; - border-right: none; - box-shadow: none; - margin-bottom: 0; + &[data-poster] { + @include hp.tablet-up { + --section-bleed: 16px; + display: grid; + grid-template-columns: clamp(240px, 38%, 420px) minmax(0, 1fr); + align-items: start; + column-gap: clamp(32px, 4vw, 56px); + } } } -// Hero section -.heroSection { - position: relative; - overflow: hidden; -} - -.coverWrapper { - position: relative; - width: 100%; - max-height: 560px; - overflow: hidden; - background-color: var(--accent-soft); +.posterColumn { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; + margin-bottom: 28px; - // When aspect ratio is provided via CSS custom property, use it - // Otherwise fall back to auto sizing based on image - &[style*="--cover-aspect-ratio"] { - aspect-ratio: var(--cover-aspect-ratio); + .introTop { + margin-bottom: 0; } - @include mixins.respond-below(md) { - max-height: 400px; + @include hp.tablet-up { + position: sticky; + top: 24px; + margin-bottom: 0; } } -.coverLqip { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - object-fit: cover; - filter: blur(20px); - transform: scale(1.1); - z-index: 0; +.mainColumn { + min-width: 0; } -.coverImage { - width: 100%; - display: block; - transition: transform $transition-slow; - z-index: 1; - - // When wrapper has aspect ratio, image should fill it - .coverWrapper[style*="--cover-aspect-ratio"] & { - position: relative; - height: 100%; - object-fit: cover; - } - - // When no aspect ratio, let image determine height naturally - .coverWrapper:not([style*="--cover-aspect-ratio"]) & { - height: auto; - } +.intro { + display: flex; + flex-direction: column; + min-width: 0; + padding-bottom: 24px; - .mainCard:hover & { - transform: scale(1.02); + @include hp.wide { + padding-bottom: 28px; } } -.heroGradient { - position: absolute; - inset: 0; - background: linear-gradient( - to top, - rgba(0, 0, 0, 0.7) 0%, - rgba(0, 0, 0, 0.3) 40%, - transparent 100% - ); - pointer-events: none; -} - -.statusBadges { - position: absolute; - top: 20px; - left: 20px; +.introTop { display: flex; - gap: 8px; - z-index: 2; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 24px; } -.statusBadge { - display: inline-flex; +.intro[data-centered] { align-items: center; - gap: 6px; - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(20px); - border-radius: $radius-sm; - padding: 8px 14px; - font-size: 0.8rem; - font-weight: 600; - color: white; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + text-align: center; - svg { - width: 0.7rem; - height: 0.7rem; - filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.3)); + .introTop { + align-self: stretch; + justify-content: center; } -} -// Event header section -.eventHeader { - padding: 28px 32px 24px; - - @include mixins.respond-below(md) { - padding: 24px 20px; + .statusBadge { + align-self: center; } -} -.headerTopRow { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 16px; + .facts { + text-align: left; + } } -.organizerPill { +.host { display: inline-flex; align-items: center; gap: 10px; - background: var(--accent-soft); - border-radius: 100px; - padding: 6px 14px 6px 6px; min-width: 0; + height: 36px; + padding: 0 14px 0 4px; + border-radius: 999px; + background: var(--hp-fill); + color: var(--hp-text); + font-size: 0.875rem; + font-weight: 600; text-decoration: none; - transition: all $transition-fast; - cursor: pointer; + transition: background-color 0.15s hp.$ease; + @include hp.focus-ring; +} - &:hover { - transform: translateY(-1px) scale(1.02); - } +a.host:hover { + background: var(--hp-fill-strong); } -.organizerPillAvatar { +.hostAvatar { flex-shrink: 0; width: 28px; height: 28px; border-radius: 50%; object-fit: cover; - border: 2px solid var(--content-bg-color); -} -.organizerPillAvatarPlaceholder { - flex-shrink: 0; - width: 28px; - height: 28px; - border-radius: 50%; - background: var(--primary-color); - display: flex; - align-items: center; - justify-content: center; - color: var(--accent-contrast); - font-size: 0.75rem; - font-weight: 600; - border: 2px solid var(--content-bg-color); + &[data-placeholder] { + display: grid; + place-items: center; + background: var(--hp-accent); + color: var(--hp-on-accent); + font-size: 0.75rem; + font-weight: 700; + } } -.organizerPillName { +.hostName { + @include mixins.ellipsis; min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 0.9rem; - font-weight: 500; - color: var(--primary-text-color); } -.actionButtons { +.introActions { display: flex; flex-shrink: 0; gap: 8px; } -.actionButton { - width: 42px; - height: 42px; - border-radius: 50%; - background: var(--content-bg-color); - border: 1px solid var(--border-color); - display: flex; +.iconButton { + @include hp.icon-button; +} + +.pillButton { + @include hp.pill-button; +} + +.statusBadge { + display: inline-flex; + align-self: flex-start; align-items: center; - justify-content: center; - cursor: pointer; - color: var(--secondary-color); - transition: all $transition-fast; + gap: 8px; + height: 28px; + margin-bottom: 14px; + padding: 0 12px; + border-radius: 999px; + background: var(--hp-fill-strong); + color: var(--hp-text); + font-size: 0.8125rem; + font-weight: 600; - svg { - width: 1rem; - height: 1rem; + &[data-tone="live"] { + background: var(--hp-live-fill); + color: var(--hp-live-ink); } +} - &:hover { - background: var(--accent-soft); - color: var(--primary-text-color); - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - } +.liveDot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; } -.eventTitle { - font-family: $font-display; - font-size: clamp(1.75rem, 5vw, 2.5rem); - font-weight: 800; - line-height: 1.15; - letter-spacing: -0.02em; - color: var(--primary-text-color); - margin: 0 0 20px; +.title { + margin: 0 0 28px; + font-family: hp.$font; + font-size: clamp(2rem, 1.25rem + 2.2vw, 3rem); + font-weight: 700; + line-height: 1.05; + letter-spacing: -0.035em; + color: var(--hp-text); + text-wrap: balance; + overflow-wrap: anywhere; + + &[data-long] { + font-size: clamp(1.625rem, 1.1rem + 1.6vw, 2.375rem); + line-height: 1.1; + letter-spacing: -0.03em; + } } -.eventMeta { +.facts { display: flex; flex-direction: column; - gap: 14px; + gap: 16px; + margin: 0; + padding: 0; + list-style: none; } -.metaItem { +.fact { display: flex; align-items: center; gap: 14px; + min-width: 0; } -.metaIconBox { - width: 44px; - height: 44px; - border-radius: $radius-md; - background: var(--accent-soft); - display: flex; - align-items: center; - justify-content: center; +.factTile { + display: grid; + place-items: center; flex-shrink: 0; - color: var(--primary-color); - transition: all $transition-fast; + width: 48px; + height: 48px; + overflow: hidden; + border: 1px solid var(--hp-border); + border-radius: 12px; + background: var(--hp-surface); + color: var(--hp-text); svg { - width: 1.1rem; - height: 1.1rem; - } - - &:hover { - background: var(--primary-color); - color: var(--accent-contrast); - transform: scale(1.05); + width: 20px; + height: 20px; } } -.metaContent { - flex: 1; +.factBody { + display: flex; + flex-direction: column; + gap: 2px; min-width: 0; } -.metaPrimary { - font-weight: 600; - color: var(--primary-text-color); +.factTitle { + color: var(--hp-text); font-size: 1rem; - margin-bottom: 2px; + font-weight: 600; + line-height: 1.35; + overflow-wrap: anywhere; } -.metaSecondary { - color: var(--secondary-color); - font-size: 0.9rem; +.factDetail { + color: var(--hp-muted); + font-size: 0.9375rem; + line-height: 1.4; + overflow-wrap: anywhere; } -.metaSecondaryWithIcon { - display: flex; +.factNote, .factLink { + display: inline-flex; align-items: center; - gap: 4px; + align-self: flex-start; + gap: 6px; + min-height: 24px; + margin-top: 2px; + font-size: 0.875rem; +} - svg { - flex-shrink: 0; - } +.factNote { + color: var(--hp-muted); } -.metaLink { - display: inline-flex; - align-items: center; - gap: 4px; - color: var(--primary-color); - font-weight: 500; - font-size: 0.85rem; +.factLink { + color: var(--hp-accent-ink); + font-weight: 600; text-decoration: none; - margin-top: 4px; - - svg { - width: 0.7rem; - height: 0.7rem; - } &:hover { text-decoration: underline; + text-underline-offset: 3px; } } -// Section dividers .section { - padding: 28px 32px; - border-top: 1px solid var(--border-color); + container-type: inline-size; + padding: 24px 0; + scroll-margin-top: 24px; - @include mixins.respond-below(md) { - padding: 24px 20px; + @include hp.wide { + padding: 28px 0; } } -.sectionHeader { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 18px; +@include hp.wide { + :global(html):has(.sectionNav) { + scroll-padding-top: 72px; + } } -.sectionTitle { - font-family: $font-display; - font-size: 1.35rem; - font-weight: 700; - letter-spacing: -0.01em; - color: var(--primary-text-color); - margin: 0; +:global(html):has(.checkoutBar) { + scroll-padding-bottom: 104px; } -// Description / About section -.description { - color: var(--secondary-color); - line-height: 1.7; - font-size: 0.95rem; - overflow-wrap: anywhere; - - p { - margin-bottom: 1em; - - &:last-child { - margin-bottom: 0; - } - } - - a { - color: var(--primary-color); - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } - - h1, h2, h3, h4, h5, h6 { - margin-top: 1.25em; - margin-bottom: 0.5em; - color: var(--primary-text-color); - font-weight: 600; - font-family: $font-display; - - &:first-child { - margin-top: 0; - } - } - - h1 { font-size: 1.375rem; } - h2 { font-size: 1.25rem; } - h3 { font-size: 1.125rem; } - - ul, ol { - padding-left: 1.5em; - margin-bottom: 1em; - } +.section:focus, .tickets:focus { + outline: none; +} - blockquote { - border-left: 3px solid var(--primary-color); - padding-left: 1em; - margin: 1em 0; - color: var(--secondary-color); - font-style: italic; - } +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 20px; +} - img { - max-width: 100%; - height: auto; - border-radius: $radius-md; - margin: 1em 0; - } +.sectionTitle { + @include hp.section-title; } -.readMoreToggle { - color: var(--primary-color); - font-weight: 500; - font-size: 0.9rem; - cursor: pointer; +.sectionLink { display: inline-flex; + flex-shrink: 0; align-items: center; gap: 4px; - margin-top: 8px; - background: none; - border: none; - padding: 0; + min-height: 24px; + border-radius: 6px; + color: var(--hp-accent-ink); + font-size: 0.875rem; + font-weight: 600; + text-decoration: none; + @include hp.focus-ring; svg { - transition: transform $transition-fast; - } - - &[data-expanded="true"] svg { - transform: rotate(180deg); + width: 16px; + height: 16px; } &:hover { text-decoration: underline; + text-underline-offset: 3px; } } -// Location section -.locationContent { - display: grid; - grid-template-columns: 1fr 200px; - gap: 20px; - align-items: start; - - @include mixins.respond-below(md) { - grid-template-columns: 1fr; - } +.description { + @include hp.rich-text; } -.venueDetails { - display: flex; - flex-direction: column; - gap: 4px; -} +.tickets { + padding: 24px 0; + scroll-margin-top: 16px; -.venueName { - font-weight: 600; - color: var(--primary-text-color); - font-size: 1.05rem; + @include hp.wide { + padding: 28px 0; + } } -.venueAddress { - color: var(--secondary-color); - font-size: 0.9rem; - line-height: 1.5; +.body[data-rail] > .tickets { + @include hp.wide { + position: sticky; + top: var(--rail-top, var(--rail-max-top, 24px)); + grid-column: 2; + grid-row: 1 / -1; + align-self: start; + padding: 0; + border-top: 0; + } } -.directionsLink { - display: inline-flex; - align-items: center; - gap: 6px; - color: var(--primary-color); - font-weight: 500; - font-size: 0.9rem; - text-decoration: none; - margin-top: 8px; - - svg { - width: 0.85rem; - height: 0.85rem; - } +@include hp.wide { + main:has(.sectionNav) { + .tickets { + --rail-max-top: 88px; + } - &:hover { - text-decoration: underline; + .posterColumn { + top: 88px; + } } } -.mapContainer { - border-radius: $radius-md; - overflow: hidden; - height: 120px; - cursor: pointer; - position: relative; - transition: all $transition-fast; - display: flex; - align-items: center; - justify-content: center; - - @include mixins.respond-below(md) { +:global(body):has([data-status-banner]) { + .sectionNav { display: none; } - &:hover { - transform: scale(1.02); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + .tickets { + --rail-max-top: 80px; } - img { - width: 100%; - height: 100%; - object-fit: cover; + @include hp.tablet-up { + .posterColumn { + top: 80px; + } } } -.mapPin { - color: var(--primary-color); - position: relative; - z-index: 1; - filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); - transition: opacity $transition-fast; +.ticketsCard { + :global { + .hi-product-widget-container { + --widget-card-bg-color: var(--hp-surface); + --widget-border-color: var(--hp-border); + --widget-radius: 12px; + --widget-accent-text-color: var(--hp-accent-ink); + padding: 0; + background: transparent; + font-family: hp.$font; + } - .mapContainer:hover & { - opacity: 0; + .hi-product-category-title { + font-family: hp.$font; + font-size: 1.125rem; + } + + .hi-product-title h3 { + font-family: hp.$font; + } + + .hi-product-widget-container .hi-footer-row .hi-continue-button { + &:disabled, &[data-disabled] { + background: var(--hp-fill-strong); + color: var(--hp-muted); + opacity: 1; + } + } + } + + .body[data-rail] & { + @include hp.wide { + padding: 20px; + border: 1px solid var(--hp-border); + border-radius: 18px; + background: var(--hp-surface); + box-shadow: var(--hp-shadow-md); + } } } -.mapOverlay { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.1); +.locationCard { display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: opacity $transition-fast; - - .mapContainer:hover & { - opacity: 1; - } + align-items: flex-start; + gap: 16px; + padding: 20px; + border: 1px solid var(--hp-border); + border-radius: 14px; } -.mapOverlayLabel { - background: var(--content-bg-color); - padding: 8px 16px; - border-radius: $radius-sm; - font-size: 0.85rem; - font-weight: 500; - color: var(--primary-text-color); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +.locationText { display: flex; - align-items: center; - gap: 6px; + flex-direction: column; + gap: 2px; + min-width: 0; + padding-top: 2px; +} - svg { - width: 0.85rem; - height: 0.85rem; - } +.locationName { + color: var(--hp-text); + font-size: 1rem; + font-weight: 600; + line-height: 1.4; } -// Tickets section -.ticketsSection { - :global(.hi-product-widget-container) { - --widget-card-bg-color: var(--content-bg-color); - --widget-border-color: var(--border-color); - background: transparent; - padding: 0; - font-family: $font-body; - } +.locationAddress { + color: var(--hp-muted); + font-size: 0.9375rem; + line-height: 1.5; + overflow-wrap: anywhere; +} - :global(.hi-product-category-title) { - font-family: $font-display; - font-size: 1.35rem; - } +.hostCard { + display: flex; + flex-direction: column; + gap: 14px; + padding: 20px; + border: 1px solid var(--hp-border); + border-radius: 14px; +} - :global(.hi-product-title h3) { - font-family: $font-display; +.hostCardTop { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px 14px; + + > .pillButton { + height: 36px; + padding: 0 14px; } -} -// Organizer section -.organizerCard { - display: flex; - gap: 20px; - align-items: flex-start; + @include hp.narrow { + grid-template-columns: auto minmax(0, 1fr); - @include mixins.respond-below(sm) { - flex-direction: column; - align-items: center; - text-align: center; + > .pillButton { + grid-column: 2; + justify-self: start; + } } } -.organizerAvatar { - width: 72px; - height: 72px; - border-radius: $radius-md; - object-fit: cover; +.hostCardAvatar { flex-shrink: 0; -} + width: 48px; + height: 48px; + border-radius: 12px; + object-fit: cover; -.organizerAvatarPlaceholder { - width: 72px; - height: 72px; - border-radius: $radius-md; - background: var(--primary-color); - display: flex; - align-items: center; - justify-content: center; - color: var(--accent-contrast); - font-size: 1.5rem; - font-weight: 700; - flex-shrink: 0; + &[data-placeholder] { + display: grid; + place-items: center; + background: var(--hp-accent-fill); + color: var(--hp-accent-ink); + font-size: 1.125rem; + font-weight: 700; + } } -.organizerContent { +.hostCardIdentity { flex: 1; min-width: 0; } -.organizerHeader { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 12px; - margin-bottom: 8px; - - @include mixins.respond-below(sm) { - flex-direction: column; - align-items: center; - } -} - -.organizerName { - font-family: $font-display; - font-weight: 700; - font-size: 1.15rem; - color: var(--primary-text-color); - margin: 0 0 4px; +.hostCardName { + margin: 0; + font-size: 1rem; + font-weight: 650; + line-height: 1.3; + letter-spacing: -0.01em; + overflow-wrap: anywhere; a { + display: inline-flex; + align-items: center; + min-height: 24px; color: inherit; text-decoration: none; &:hover { - color: var(--primary-color); + color: var(--hp-accent-ink); } } } -.organizerLocation { +.hostCardMeta { display: flex; - align-items: center; - gap: 6px; - font-size: 0.85rem; - color: var(--secondary-color); - - svg { - color: var(--primary-color); - width: 0.75rem; - height: 0.75rem; - } + flex-wrap: wrap; + gap: 4px 14px; + margin-top: 4px; + font-size: 0.875rem; a { - color: inherit; + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + min-height: 24px; + color: var(--hp-muted); text-decoration: none; + overflow-wrap: anywhere; &:hover { - color: var(--primary-color); + color: var(--hp-text); } } } -.organizerBio { - color: var(--secondary-color); - font-size: 0.9rem; - line-height: 1.6; - margin-bottom: 16px; - - p { - margin-bottom: 0.75em; - - &:last-child { - margin-bottom: 0; - } - } - - a { - color: var(--primary-color); - text-decoration: none; - - &:hover { - text-decoration: underline; - } - } +.hostCardBio { + @include hp.rich-text; + font-size: 0.9375rem; } -.organizerActions { +.socialRow { display: flex; - align-items: center; - gap: 12px; flex-wrap: wrap; - - @include mixins.respond-below(sm) { - justify-content: center; - } + gap: 8px; } -.socialLinks { - display: flex; - gap: 8px; +.socialButton { + @include hp.icon-button; } -.socialLink { - width: 38px; - height: 38px; - border-radius: 50%; - background: var(--accent-soft); - display: flex; - align-items: center; - justify-content: center; - color: var(--secondary-color); - text-decoration: none; - font-size: 1rem; - transition: all $transition-fast; +.sectionNav { + display: none; - &:hover { - background: var(--primary-color); - color: white; - transform: translateY(-2px); + @include hp.wide { + position: fixed; + top: 0; + right: 0; + left: 0; + z-index: 101; + display: block; + border-bottom: 1px solid var(--hp-border); + background: var(--hp-glass); + backdrop-filter: saturate(1.6) blur(18px); + -webkit-backdrop-filter: saturate(1.6) blur(18px); + visibility: hidden; + opacity: 0; + transform: translateY(-100%); + transition: transform 0.35s hp.$ease, opacity 0.25s hp.$ease, visibility 0s linear 0.35s; + + &[data-visible] { + visibility: visible; + opacity: 1; + transform: none; + transition: transform 0.35s hp.$ease, opacity 0.25s hp.$ease, visibility 0s; + } } } -.contactButton { - padding: 10px 20px; - border: 1px solid var(--border-color); - background: var(--content-bg-color); - color: var(--primary-text-color); - border-radius: $radius-sm; - font-weight: 600; - font-size: 0.9rem; +.sectionNavInner { display: flex; align-items: center; - gap: 8px; - cursor: pointer; - transition: all $transition-fast; - - svg { - width: 0.9rem; - height: 0.9rem; - } - - &:hover { - background: var(--primary-color); - color: var(--accent-contrast); - border-color: var(--primary-color); - } + gap: 24px; + max-width: calc(var(--hp-sheet-max, 1152px) + 48px); + height: 64px; + margin: 0 auto; + padding: 0 calc(var(--hp-content-pad, 48px) + 24px); } -// Footer section -.footerSection { - padding-top: 20px; - padding-bottom: 20px; +.sectionNavIdentity { display: flex; + flex: 0 1 auto; align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 12px 24px; - color: var(--secondary-color); - font-size: 0.85rem; + gap: 12px; + min-width: 0; + max-width: 38%; +} - @include mixins.respond-below(md) { - flex-direction: column; - justify-content: center; - padding-bottom: 120px; - } +.sectionNavThumb { + flex-shrink: 0; + width: 36px; + height: 36px; + border-radius: 10px; + object-fit: cover; } -.footerLinks { +.sectionNavText { display: flex; - align-items: center; - justify-content: center; - flex-wrap: wrap; - gap: 6px 20px; + flex-direction: column; + min-width: 0; } -.footerSection .poweredByFooter { - margin: 0; +.sectionNavTitle { + @include mixins.ellipsis; + color: var(--hp-text); + font-size: 0.9375rem; + font-weight: 700; + letter-spacing: -0.01em; } -.footerSection .cookieSettingsLink { - display: inline; - margin: 0; - font-family: inherit; - font-size: inherit; - color: var(--secondary-color); - text-decoration: none; - - &:hover { - color: var(--primary-color); - opacity: 1; - } +.sectionNavSubtitle { + @include mixins.ellipsis; + color: var(--hp-muted); + font-size: 0.8125rem; } -.footerLink { - color: var(--secondary-color); - font-size: inherit; - text-decoration: none; - transition: color $transition-fast; +.sectionNavLinks { + display: flex; + gap: 4px; + min-width: 0; + margin-left: auto; + overflow-x: auto; + scrollbar-width: none; - &:hover { - color: var(--primary-color); + &::-webkit-scrollbar { + display: none; + } + + &[data-overflowing] { + mask-image: linear-gradient(to right, #000 calc(100% - 48px), transparent); } } -.poweredByFooter { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - font-size: 0.9rem; - color: var(--secondary-color); +.sectionNavLink { + @include mixins.ellipsis; + flex-shrink: 0; + max-width: 180px; + padding: 8px 14px; + border-radius: 999px; + color: var(--hp-muted); + font-size: 0.875rem; + font-weight: 600; + text-decoration: none; + transition: color 0.15s hp.$ease, background-color 0.15s hp.$ease; + @include hp.focus-ring; - strong { - color: var(--primary-text-color); - font-weight: 600; + &:hover { + background: var(--hp-fill); + color: var(--hp-text); } - a { - color: inherit; - text-decoration: none; - - &:hover { - color: var(--primary-color); - } + &[data-active] { + background: var(--hp-fill-strong); + color: var(--hp-text); } } -// Floating scroll button -.scrollToTicketsButton { +.checkoutBar { position: fixed; - bottom: 24px; - left: 0; right: 0; - width: fit-content; - max-width: calc(100% - 32px); - margin-inline: auto; - z-index: 100; + bottom: 0; + left: 0; + z-index: 95; display: flex; align-items: center; - gap: 8px; - background: var(--primary-color); - color: var(--accent-contrast); - font-family: $font-display; - font-weight: 600; - font-size: 0.875rem; - padding: 12px 20px; - border-radius: 50px; - border: none; - cursor: pointer; - box-shadow: 0 4px 20px var(--accent-soft); - animation: fadeInUp 0.4s ease-out; - transition: all $transition-fast; - - @include mixins.respond-below(sm) { - bottom: 16px; - padding: 10px 16px; - font-size: 0.8125rem; + gap: 16px; + padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); + border-top: 1px solid var(--hp-border); + background: var(--hp-glass); + backdrop-filter: saturate(1.6) blur(18px); + -webkit-backdrop-filter: saturate(1.6) blur(18px); + animation: barIn 0.35s hp.$ease both; + + &[data-mode="continue"] .checkoutButton { + flex: 1; + } + + @include hp.tablet-up { + right: auto; + bottom: 20px; + left: 50%; + width: min(560px, calc(100% - 48px)); + padding: 8px 8px 8px 24px; + border: 1px solid var(--hp-border); + border-radius: 999px; + box-shadow: var(--hp-shadow-lg); + transform: translateX(-50%); + + &[data-mode="continue"] { + width: auto; + padding: 8px; + } } - &:hover { - transform: translateY(-2px); - box-shadow: 0 6px 24px var(--accent-soft); - } + &[data-compact-wide] { + @include hp.wide { + width: auto; + padding: 0; + border: 0; + background: none; + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; - &:active { - transform: translateY(0); + .checkoutSummary { + display: none; + } + + .checkoutButton { + box-shadow: var(--hp-shadow-lg); + } + } } } -// Animations -@keyframes fadeInUp { +@keyframes barIn { from { opacity: 0; - transform: translateY(20px); + translate: 0 16px; } + to { opacity: 1; - transform: translateY(0); + translate: 0 0; } } -// Animate sections -.section { - animation: fadeInUp 0.4s ease-out backwards; - - &:nth-child(2) { animation-delay: 0.1s; } - &:nth-child(3) { animation-delay: 0.15s; } - &:nth-child(4) { animation-delay: 0.2s; } - &:nth-child(5) { animation-delay: 0.25s; } -} +.checkoutSummary { + display: flex; + flex: 1 1 40%; + flex-direction: column; + min-width: 0; -// No cover image adjustments -.heroSection:not(:has(.coverWrapper)) { - .eventHeader { - padding-top: 32px; + @media (max-width: 359px) { + display: none; - @include mixins.respond-below(sm) { - padding-top: 24px; + + .checkoutButton { + flex: 1; } } } -// Online event badge -.onlineEventBadge { +.checkoutPrice { + @include mixins.ellipsis; + color: var(--hp-text); + font-size: 1rem; + font-weight: 700; + letter-spacing: -0.01em; +} + +.checkoutDate { + @include mixins.ellipsis; + color: var(--hp-muted); + font-size: 0.8125rem; +} + +.checkoutButton { display: inline-flex; + flex-shrink: 1; + min-width: 0; + max-width: 100%; align-items: center; - gap: 6px; - background: var(--accent-soft); - color: var(--primary-color); - padding: 6px 12px; - border-radius: $radius-sm; - font-size: 0.85rem; - font-weight: 600; + justify-content: center; + gap: 8px; + height: 48px; + padding: 0 24px; + border: 0; + border-radius: 999px; + background: var(--hp-accent); + color: var(--hp-on-accent); + font: inherit; + font-size: 0.9375rem; + font-weight: 700; + cursor: pointer; + transition: filter 0.15s hp.$ease, transform 0.15s hp.$ease; + @include hp.focus-ring; + + &:hover { + filter: brightness(1.06); + } + + &:active { + transform: scale(0.98); + } svg { - width: 0.9rem; - height: 0.9rem; + flex-shrink: 0; + width: 18px; + height: 18px; } } + +.checkoutLabel { + @include mixins.ellipsis; + min-width: 0; +} diff --git a/frontend/src/components/layouts/EventHomepage/EventIntro.tsx b/frontend/src/components/layouts/EventHomepage/EventIntro.tsx new file mode 100644 index 0000000000..57d441da0b --- /dev/null +++ b/frontend/src/components/layouts/EventHomepage/EventIntro.tsx @@ -0,0 +1,173 @@ +import {ReactNode} from "react"; +import {t} from "@lingui/macro"; +import {IconArrowUpRight, IconCalendarPlus, IconCalendarRepeat, IconMapPin, IconShare2, IconWorld} from "@tabler/icons-react"; +import {Event, EventOccurrence, Organizer, OrganizerStatus} from "../../../types.ts"; +import {EventDateRange} from "../../common/EventDateRange"; +import {ShareComponent} from "../../common/ShareIcon"; +import {CalendarOptionsPopover} from "../../common/CalendarOptionsPopover"; +import {formatDateWithLocale, formatLongDay, isSameDayInTimezone} from "../../../utilites/dates.ts"; +import {eventCoverImage, eventHomepageUrl, organizerHomepageUrl} from "../../../utilites/urlHelper.ts"; +import {EventLocationDisplay} from "../../../utilites/effectiveLocation.ts"; +import {DateTile} from "../HomepageFrame/DateTile.tsx"; +import {sizedImageUrl} from "../../../utilites/imageTransform.ts"; +import classes from "./EventHomepage.module.scss"; + +const LONG_TITLE_LENGTH = 48; + +export interface IntroStatus { + label: string; + tone: 'live' | 'muted'; +} + +interface EventHostProps { + event: Event; + organizer: Organizer; + organizerLogo?: string; + occurrence?: EventOccurrence; + canAddToCalendar: boolean; +} + +interface EventIntroProps extends EventHostProps { + showHost: boolean; + centered: boolean; + dateStart: string | null; + dateEnd: string | null; + seriesLabel: string | null; + lastOccurrenceDate: string | null; + status: IntroStatus | null; + location: EventLocationDisplay | null; + variedLocationLabel: string | null; +} + +export const EventHost = ({event, organizer, organizerLogo, occurrence, canAddToCalendar}: EventHostProps) => { + const organizerIsLive = organizer.status === OrganizerStatus.LIVE; + const organizerContent = ( + <> + {organizerLogo + ? + : } + {organizer.name} + + ); + + return ( +
+ {organizerIsLive + ? {organizerContent} + :
{organizerContent}
} + +
+ + + + {canAddToCalendar && ( + + + + )} +
+
+ ); +}; + +export const EventIntro = ({ + showHost, + centered, + dateStart, + dateEnd, + seriesLabel, + lastOccurrenceDate, + status, + location, + variedLocationLabel, + ...hostProps + }: EventIntroProps) => { + const {event, occurrence} = hostProps; + const tz = event.timezone; + + return ( +
+ {showHost && } + + {status && ( + + {status.tone === 'live' && + )} + +

LONG_TITLE_LENGTH || undefined}>{event.title}

+ +
    + : } + title={dateStart ? formatLongDay(dateStart, tz) : seriesLabel ?? } + detail={dateStart && } + extra={lastOccurrenceDate && ( + + + {t`Multiple dates until ${lastOccurrenceDate}`} + + )} + /> + + {location?.isOnline && ( + } title={t`Online Event`} detail={t`Join from anywhere`}/> + )} + + {location && !location.isOnline && (location.venueName || location.full) && ( + } + title={location.venueName || location.full} + detail={location.venueName ? location.full : null} + extra={location.mapsUrl && ( + + {t`Get directions`} + + + )} + /> + )} + + {variedLocationLabel && ( + } title={variedLocationLabel} detail={t`See schedule`}/> + )} +
+
+ ); +}; + +const Fact = ({tile, title, detail, extra}: { tile: ReactNode, title: ReactNode, detail?: ReactNode, extra?: ReactNode }) => ( +
  • + {tile} + + {title} + {detail && {detail}} + {extra} + +
  • +); + +const TimeRange = ({start, end, tz}: { start: string, end: string | null, tz: string }) => { + const startTime = formatDateWithLocale(start, 'timeOnly', tz); + const zone = formatDateWithLocale(start, 'timezone', tz); + + const zoneLabel = {zone}; + + if (!end) { + return <>{startTime} {zoneLabel}; + } + + const endLabel = formatDateWithLocale(end, isSameDayInTimezone(start, end, tz) ? 'timeOnly' : 'dayMonthTime', tz); + + return <>{startTime} – {endLabel} {zoneLabel}; +}; diff --git a/frontend/src/components/layouts/EventHomepage/Header/Header.module.scss b/frontend/src/components/layouts/EventHomepage/Header/Header.module.scss deleted file mode 100644 index 48384b1886..0000000000 --- a/frontend/src/components/layouts/EventHomepage/Header/Header.module.scss +++ /dev/null @@ -1,15 +0,0 @@ -@use "../../../../styles/mixins.scss"; - -.header { - display: flex; - justify-content: center; - align-items: center; - overflow: hidden; - position: relative; - - img { - width: 100%; - height: 100%; - object-fit: cover; - } -} diff --git a/frontend/src/components/layouts/EventHomepage/Header/index.tsx b/frontend/src/components/layouts/EventHomepage/Header/index.tsx deleted file mode 100644 index 2498ed6572..0000000000 --- a/frontend/src/components/layouts/EventHomepage/Header/index.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import classes from './Header.module.scss' -import { FC } from 'react'; -import { Event } from '../../../../types.ts'; - -export const Header: FC<{ - event: Event -}> = ({ event }) => { - - const coverImage = event?.images?.find((image) => image.type === 'EVENT_COVER'); - - if (!coverImage) { - return <>; - } - - return ( - <> -
    - {event?.title} -
    - - ) -} diff --git a/frontend/src/components/layouts/EventHomepage/Lineup/Lineup.module.scss b/frontend/src/components/layouts/EventHomepage/Lineup/Lineup.module.scss index 2f2622e190..48d234d654 100644 --- a/frontend/src/components/layouts/EventHomepage/Lineup/Lineup.module.scss +++ b/frontend/src/components/layouts/EventHomepage/Lineup/Lineup.module.scss @@ -1,4 +1,5 @@ @use "../../../../styles/mixins.scss"; +@use "../../HomepageFrame/homepage" as hp; $font-display: var(--theme-font-family, 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); $radius-lg: 20px; @@ -6,6 +7,10 @@ $radius-md: 14px; $radius-sm: 10px; $ease: cubic-bezier(0.4, 0, 0.2, 1); +.queryContainer { + container-type: inline-size; +} + .lineup { display: flex; flex-direction: column; @@ -32,7 +37,7 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); min-width: 0; &:focus-visible { - outline: 2px solid var(--event-primary-color); + outline: 2px solid var(--event-accent-ink, var(--event-primary-color)); outline-offset: 3px; border-radius: $radius-md; } @@ -69,7 +74,7 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); font-weight: 700; font-size: clamp(1.1rem, 4vw, 2.2rem); letter-spacing: 0.02em; - color: var(--event-primary-color); + color: var(--event-accent-ink, var(--event-primary-color)); } .cardBody { @@ -156,21 +161,45 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); white-space: normal; } - @include mixins.respond-below(md) { + @include hp.compact { &[data-count="4"] { grid-template-columns: repeat(2, minmax(0, 1fr)); } } - @include mixins.respond-below(sm) { - grid-template-columns: 1fr; + @include hp.narrow { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; - &[data-count="3"], &[data-count="4"] { + &[data-count="1"] { grid-template-columns: 1fr; } + &[data-count="3"], &[data-count="4"] { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .cardMedia { - aspect-ratio: 4 / 3; + &:not([data-empty]) { + flex-grow: 0; + } + + &::after { + display: none; + } + } + + .card:has(.cardMedia:not([data-empty])) .cardBody { + position: static; + color: inherit; + + .cardSubtitle { + color: var(--event-secondary-color); + } + } + + .cardName { + font-size: 1rem; } } } @@ -186,7 +215,7 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); grid-template-columns: repeat(3, minmax(0, 1fr)); } - @include mixins.respond-below(sm) { + @include hp.narrow { grid-template-columns: repeat(2, minmax(0, 1fr)); &[data-count="3"] { @@ -240,6 +269,43 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); aspect-ratio: 1; } +.tier[data-tier="headliner"], .tier[data-tier="featured"] { + &:not(:has(.cardMedia:not([data-empty]))) { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); + + .card { + flex-direction: row; + align-items: center; + gap: 16px; + } + + .cardMedia[data-empty] { + flex-shrink: 0; + width: 72px; + height: 72px; + min-height: 0; + aspect-ratio: 1; + border-radius: 18px; + } + + .monogram { + font-size: 1.35rem; + } + } +} + +.tier[data-tier="featured"]:not(:has(.cardMedia:not([data-empty]))) { + .cardMedia[data-empty] { + width: 60px; + height: 60px; + border-radius: 15px; + } + + .monogram { + font-size: 1.1rem; + } +} + .tier[data-tier="standard"] { grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 6px 14px; @@ -269,7 +335,7 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); font-size: 1rem; } - @include mixins.respond-below(sm) { + @include hp.narrow { grid-template-columns: 1fr; } } @@ -307,7 +373,7 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); } } - @include mixins.respond-below(sm) { + @include hp.narrow { display: none; } } @@ -351,13 +417,12 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); } &[aria-pressed="true"] { - background: var(--event-primary-color); - border-color: var(--event-primary-color); + background: var(--event-accent-solid, var(--event-primary-color)); + border-color: var(--event-accent-solid, var(--event-primary-color)); color: var(--event-accent-contrast); .dayTabDate { color: inherit; - opacity: 0.85; } } } @@ -460,9 +525,13 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); } } - @include mixins.respond-below(sm) { - grid-template-columns: 60px minmax(0, 1fr); + @include hp.narrow { + grid-template-columns: 72px minmax(0, 1fr); gap: 12px; + + .agendaStart { + font-size: 0.875rem; + } } } @@ -472,6 +541,10 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); font-variant-numeric: tabular-nums; } +.agendaStart, .agendaEnd { + white-space: nowrap; +} + .agendaStart { font-weight: 700; font-size: 0.95rem; @@ -602,7 +675,7 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); overflow: hidden; border-radius: 50%; background-color: var(--event-accent-soft); - color: var(--event-primary-color); + color: var(--event-accent-ink, var(--event-primary-color)); font-size: 0.6rem; font-weight: 700; @@ -639,7 +712,7 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); margin-left: -14px; } - @include mixins.respond-below(sm) { + @include hp.narrow { display: none; } } @@ -671,7 +744,7 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); background: linear-gradient(90deg, transparent, var(--event-content-bg-color) 24px); font: inherit; font-weight: 600; - color: var(--event-primary-color); + color: var(--event-accent-ink, var(--event-primary-color)); cursor: pointer; } @@ -914,7 +987,7 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; - color: var(--event-primary-color); + color: var(--event-accent-ink, var(--event-primary-color)); margin-bottom: -8px; } @@ -952,7 +1025,7 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); &:hover { border-color: var(--event-primary-color); background: var(--event-accent-soft); - color: var(--event-primary-color); + color: var(--event-accent-ink, var(--event-primary-color)); } } @@ -1132,7 +1205,7 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); svg { flex-shrink: 0; - color: var(--event-primary-color); + color: var(--event-accent-ink, var(--event-primary-color)); } } diff --git a/frontend/src/components/layouts/EventHomepage/Lineup/LineupGrid.tsx b/frontend/src/components/layouts/EventHomepage/Lineup/LineupGrid.tsx index 07c9c19c2c..b924f9a279 100644 --- a/frontend/src/components/layouts/EventHomepage/Lineup/LineupGrid.tsx +++ b/frontend/src/components/layouts/EventHomepage/Lineup/LineupGrid.tsx @@ -1,6 +1,7 @@ import {t} from "@lingui/macro"; import {LineupEntry} from "../../../../api/lineup.client.ts"; import {BILLING_TIERS, initialsFor, profilePhoto} from "../../../../utilites/lineup.ts"; +import {responsiveImage} from "../../../../utilites/imageTransform.ts"; import classes from "./Lineup.module.scss"; interface LineupGridProps { @@ -54,7 +55,7 @@ const LineupCard = ({entry, onOpen}: { entry: LineupEntry, onOpen: () => void }) data-empty={!photo || undefined} > {photo - ? + ? : {initialsFor(entry.profile.name)}} diff --git a/frontend/src/components/layouts/EventHomepage/Lineup/ScheduleTimeline.tsx b/frontend/src/components/layouts/EventHomepage/Lineup/ScheduleTimeline.tsx index 77e725d93a..5a207cd179 100644 --- a/frontend/src/components/layouts/EventHomepage/Lineup/ScheduleTimeline.tsx +++ b/frontend/src/components/layouts/EventHomepage/Lineup/ScheduleTimeline.tsx @@ -5,11 +5,12 @@ import {IconLayoutColumns, IconList, IconMapPin} from "@tabler/icons-react"; import {Event} from "../../../../types.ts"; import {EventLineup, EventSpace, LineupEntry, ScheduleItem} from "../../../../api/lineup.client.ts"; import {formatDateWithLocale, getSafeLocale} from "../../../../utilites/dates.ts"; -import {getClientLocale} from "../../../../locales.ts"; +import {getClientLocale, toDayjsLocale} from "../../../../locales.ts"; import {initialsFor, profilePhoto, scheduleItemTitle} from "../../../../utilites/lineup.ts"; import {assignScheduleLanes, groupScheduleByDay} from "../../../../utilites/lineupSchedule.ts"; import {UserGeneratedContent} from "../../../common/UserGeneratedContent"; import {HomepageSectionClasses} from "./index.tsx"; +import {sizedImageUrl} from "../../../../utilites/imageTransform.ts"; import classes from "./Lineup.module.scss"; interface ScheduleTimelineProps { @@ -45,7 +46,7 @@ const useNow = () => { }; const formatScheduleWeekday = (utcDate: string, tz: string) => - dayjs.utc(utcDate).tz(tz).locale(getSafeLocale(getClientLocale())).format('dddd'); + dayjs.utc(utcDate).tz(tz).locale(toDayjsLocale(getSafeLocale(getClientLocale()))).format('dddd'); const isLive = (item: ScheduleItem, now: number | null) => now !== null && dayjs.utc(item.starts_at).valueOf() <= now @@ -277,7 +278,7 @@ export const PerformerAvatar = ({entry, size = 'sm'}: { entry: LineupEntry, size return ( - {photo ? : initialsFor(entry.profile.name)} + {photo ? : initialsFor(entry.profile.name)} ); }; diff --git a/frontend/src/components/layouts/EventHomepage/Lineup/index.tsx b/frontend/src/components/layouts/EventHomepage/Lineup/index.tsx index df5845f3e9..d04711ea9f 100644 --- a/frontend/src/components/layouts/EventHomepage/Lineup/index.tsx +++ b/frontend/src/components/layouts/EventHomepage/Lineup/index.tsx @@ -9,6 +9,7 @@ import {ScheduleTimeline} from "./ScheduleTimeline.tsx"; import {ProfileModal} from "./ProfileModal.tsx"; import {SessionModal} from "./SessionModal.tsx"; import {useReturnFocusOnClose} from "../../../../hooks/useReturnFocusOnClose.ts"; +import classes from "./Lineup.module.scss"; export interface HomepageSectionClasses { section: string; @@ -136,7 +137,7 @@ export const EventLineupSection = ({event, occurrence, heading, sectionClasses, } return ( -
    +

    {heading || getLineupTerminology(event.category).lineup}

    @@ -156,7 +157,7 @@ export const EventScheduleSection = ({event, occurrence, heading, sectionClasses } return ( -
    +
    ( +
    +
    +

    {heading}

    +
    +
    + +
    + {location.venueName &&
    {location.venueName}
    } + {location.full &&
    {location.full}
    } + {location.mapsUrl && ( + + {t`Get directions`} + + + )} +
    +
    +
    +); diff --git a/frontend/src/components/layouts/EventHomepage/MoreEventsSection.tsx b/frontend/src/components/layouts/EventHomepage/MoreEventsSection.tsx new file mode 100644 index 0000000000..7810756acb --- /dev/null +++ b/frontend/src/components/layouts/EventHomepage/MoreEventsSection.tsx @@ -0,0 +1,40 @@ +import {Link} from "react-router"; +import {t} from "@lingui/macro"; +import {IconArrowRight} from "@tabler/icons-react"; +import {Event, IdParam, Organizer} from "../../../types.ts"; +import {UPCOMING_EVENTS_PREVIEW_FILTERS, useGetOrganizerPublicEvents} from "../../../queries/useGetOrganizerEventsPublic.ts"; +import {organizerHomepagePath} from "../../../utilites/urlHelper.ts"; +import {EventRows} from "../OrganizerHomepage/EventList"; +import classes from "./EventHomepage.module.scss"; + +const MAX_EVENTS = 3; + +interface MoreEventsSectionProps { + heading?: string | null; + organizer: Organizer; + currentEventId: IdParam; +} + +export const MoreEventsSection = ({heading, organizer, currentEventId}: MoreEventsSectionProps) => { + const {data} = useGetOrganizerPublicEvents(organizer.id!, UPCOMING_EVENTS_PREVIEW_FILTERS); + const events = (data?.data ?? []) + .filter(event => String(event.id) !== String(currentEventId)) + .slice(0, MAX_EVENTS) as Event[]; + + if (events.length === 0) { + return null; + } + + return ( +
    +
    +

    {heading || t`More from ${organizer.name}`}

    + + {t`View all`} +
    + +
    + ); +}; diff --git a/frontend/src/components/layouts/EventHomepage/OrganizerSection.tsx b/frontend/src/components/layouts/EventHomepage/OrganizerSection.tsx new file mode 100644 index 0000000000..5e94cca9bb --- /dev/null +++ b/frontend/src/components/layouts/EventHomepage/OrganizerSection.tsx @@ -0,0 +1,86 @@ +import {t} from "@lingui/macro"; +import {IconMail, IconMapPin, IconWorld} from "@tabler/icons-react"; +import {Organizer} from "../../../types.ts"; +import {getGoogleMapsUrl, getShortLocationDisplay} from "../../../utilites/addressUtilities.ts"; +import {organizerHomepageUrl} from "../../../utilites/urlHelper.ts"; +import {organizerSocialLinks, websiteHostname} from "../../../utilites/organizerLinks.ts"; +import {UserGeneratedContent} from "../../common/UserGeneratedContent"; +import {sizedImageUrl} from "../../../utilites/imageTransform.ts"; +import classes from "./EventHomepage.module.scss"; + +interface OrganizerSectionProps { + heading: string; + organizer: Organizer; + logo?: string; + onContact: () => void; +} + +export const OrganizerSection = ({heading, organizer, logo, onContact}: OrganizerSectionProps) => { + const address = organizer.location?.structured_address; + const shortAddress = getShortLocationDisplay(address); + const socialLinks = organizerSocialLinks(organizer); + const website = organizer.website; + const hostname = website ? websiteHostname(website) : null; + + return ( +
    +
    +

    {heading}

    +
    +
    +
    + {logo + ? + : } +
    +

    + {organizer.name} +

    + {(shortAddress || hostname) && ( +
    + {shortAddress && address && ( + + + {shortAddress} + + )} + {website && hostname && ( + + + {hostname} + + )} +
    + )} +
    + +
    + + {organizer.description && ( + + )} + + {socialLinks.length > 0 && ( +
    + {socialLinks.map(({platform, url, label, Icon}) => ( + + + + ))} +
    + )} +
    +
    + ); +}; diff --git a/frontend/src/components/layouts/EventHomepage/SectionNav.tsx b/frontend/src/components/layouts/EventHomepage/SectionNav.tsx new file mode 100644 index 0000000000..77571447e5 --- /dev/null +++ b/frontend/src/components/layouts/EventHomepage/SectionNav.tsx @@ -0,0 +1,165 @@ +import {MouseEvent, useEffect, useRef, useState} from "react"; +import {t} from "@lingui/macro"; +import {useMediaQuery} from "@mantine/hooks"; +import {WIDE_LAYOUT_QUERY} from "./hooks.ts"; +import {scrollBehavior} from "../../../utilites/motion.ts"; +import {sizedImageUrl} from "../../../utilites/imageTransform.ts"; +import classes from "./EventHomepage.module.scss"; + +interface NavItem { + id: string; + label: string; +} + +interface SectionNavProps { + container: HTMLElement | null; + title: string; + subtitle?: string | null; + thumbnail?: string; +} + +const ACTIVE_LINE = 140; +const SECTION_SELECTOR = ':scope > section[id]'; + +const sameItems = (a: NavItem[], b: NavItem[]) => + a.length === b.length && a.every((item, index) => item.id === b[index].id && item.label === b[index].label); + +export const SectionNav = ({container, title, subtitle, thumbnail}: SectionNavProps) => { + const [items, setItems] = useState([]); + const [visible, setVisible] = useState(false); + const [activeId, setActiveId] = useState(null); + const [overflowing, setOverflowing] = useState(false); + const linksRef = useRef(null); + const wide = useMediaQuery(WIDE_LAYOUT_QUERY); + const scope = wide ? container : null; + + useEffect(() => { + if (!scope) { + setItems([]); + return; + } + let frame = 0; + const collect = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const next = Array.from(scope.querySelectorAll(SECTION_SELECTOR)) + .map(section => ({id: section.id, label: section.querySelector('h2')?.textContent?.trim() ?? ''})) + .filter(item => item.label); + setItems(current => sameItems(current, next) ? current : next); + }); + }; + collect(); + const observer = new MutationObserver(collect); + observer.observe(scope, {childList: true, subtree: true, characterData: true}); + + return () => { + observer.disconnect(); + cancelAnimationFrame(frame); + }; + }, [scope]); + + useEffect(() => { + const heading = scope?.querySelector('h1'); + if (!heading) { + return; + } + const observer = new IntersectionObserver(([entry]) => { + setVisible(!entry.isIntersecting && entry.boundingClientRect.top < 0); + }); + observer.observe(heading); + + return () => observer.disconnect(); + }, [scope]); + + useEffect(() => { + if (!visible || items.length === 0) { + return; + } + let frame = 0; + const update = () => { + frame = 0; + const passed = items.filter(item => (document.getElementById(item.id)?.getBoundingClientRect().top ?? Infinity) <= ACTIVE_LINE); + setActiveId(passed[passed.length - 1]?.id ?? null); + }; + const onScroll = () => { + if (!frame) { + frame = requestAnimationFrame(update); + } + }; + update(); + window.addEventListener('scroll', onScroll, {passive: true}); + + return () => { + window.removeEventListener('scroll', onScroll); + cancelAnimationFrame(frame); + }; + }, [visible, items]); + + useEffect(() => { + const links = linksRef.current; + if (!links) { + return; + } + const measure = () => setOverflowing(links.scrollWidth > links.clientWidth + 1); + const observer = new ResizeObserver(measure); + observer.observe(links); + measure(); + + return () => observer.disconnect(); + }, [items]); + + useEffect(() => { + const links = linksRef.current; + const active = links?.querySelector('[data-active]'); + if (!links || !active) { + return; + } + const start = active.offsetLeft - links.offsetLeft; + if (start < links.scrollLeft || start + active.offsetWidth > links.scrollLeft + links.clientWidth) { + links.scrollTo({left: start - 24, behavior: scrollBehavior()}); + } + }, [activeId]); + + if (items.length < 2) { + return null; + } + + const jumpTo = (clickEvent: MouseEvent, id: string) => { + clickEvent.preventDefault(); + const target = document.getElementById(id); + if (!target) { + return; + } + target.scrollIntoView({behavior: scrollBehavior(), block: 'start'}); + target.setAttribute('tabindex', '-1'); + target.focus({preventScroll: true}); + }; + + return ( + + ); +}; diff --git a/frontend/src/components/layouts/EventHomepage/Sections/GallerySection.tsx b/frontend/src/components/layouts/EventHomepage/Sections/GallerySection.tsx index 98a2539c76..4fa4c27359 100644 --- a/frontend/src/components/layouts/EventHomepage/Sections/GallerySection.tsx +++ b/frontend/src/components/layouts/EventHomepage/Sections/GallerySection.tsx @@ -5,8 +5,11 @@ import {IconChevronLeft, IconChevronRight, IconX} from "@tabler/icons-react"; import {HomepageSection, Image} from "../../../../types.ts"; import {HomepageSectionClasses} from "../Lineup"; import {SectionShell} from "./SectionShell.tsx"; +import {responsiveImage} from "../../../../utilites/imageTransform.ts"; import classes from "./Sections.module.scss"; +const MAX_TILES = 5; + export const GallerySection = ({images: availableImages, section, sectionClasses}: { images: Image[], section: HomepageSection, @@ -21,11 +24,16 @@ export const GallerySection = ({images: availableImages, section, sectionClasses return null; } + const tiles = images.slice(0, MAX_TILES); + const hiddenCount = images.length - tiles.length; + const totalCount = images.length; + return ( -
    - {images.map((image, index) => { +
    + {tiles.map((image, index) => { const photoNumber = index + 1; + const showsMore = hiddenCount > 0 && index === tiles.length - 1; return ( ); })} diff --git a/frontend/src/components/layouts/EventHomepage/Sections/SectionShell.tsx b/frontend/src/components/layouts/EventHomepage/Sections/SectionShell.tsx index 2442f1805e..428ae2f794 100644 --- a/frontend/src/components/layouts/EventHomepage/Sections/SectionShell.tsx +++ b/frontend/src/components/layouts/EventHomepage/Sections/SectionShell.tsx @@ -1,6 +1,7 @@ import {ReactNode} from "react"; import {HomepageSection} from "../../../../types.ts"; import {HomepageSectionClasses} from "../Lineup"; +import classes from "./Sections.module.scss"; interface SectionShellProps { section: HomepageSection; @@ -13,7 +14,7 @@ export const SectionShell = ({section, sectionClasses, fallbackHeading, children const heading = section.heading || fallbackHeading; return ( -
    +
    {heading && (

    {heading}

    diff --git a/frontend/src/components/layouts/EventHomepage/Sections/Sections.module.scss b/frontend/src/components/layouts/EventHomepage/Sections/Sections.module.scss index c29cdb7e2e..7422f3d69e 100644 --- a/frontend/src/components/layouts/EventHomepage/Sections/Sections.module.scss +++ b/frontend/src/components/layouts/EventHomepage/Sections/Sections.module.scss @@ -1,52 +1,99 @@ @use "../../../../styles/mixins.scss"; +@use "../../HomepageFrame/homepage" as hp; +$radius-lg: 20px; $radius-md: 14px; -$radius-sm: 10px; -$ease: cubic-bezier(0.4, 0, 0.2, 1); +$ease: cubic-bezier(0.22, 1, 0.36, 1); + +.queryContainer { + container-type: inline-size; +} .richText { - font-size: 0.95rem; + max-width: 72ch; + font-size: 1rem; line-height: 1.7; color: var(--event-secondary-color); overflow-wrap: anywhere; + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + p { - margin: 0 0 0.9em; + margin: 0 0 1em; } a { - color: var(--event-primary-color); + color: var(--event-accent-ink, var(--event-primary-color)); + text-underline-offset: 3px; + } + + strong { + color: var(--event-primary-text-color); + } + + h1, h2, h3, h4, h5, h6 { + margin: 1.4em 0 0.5em; + font-weight: 650; + line-height: 1.3; + color: var(--event-primary-text-color); + } + + ul, ol { + margin: 0 0 1em; + padding-left: 1.4em; } } .faqList { display: flex; flex-direction: column; - border-top: 1px solid var(--event-border-color); } .faq { - border-bottom: 1px solid var(--event-border-color); + & + & { + border-top: 1px solid var(--event-border-color); + } summary, .faqQuestion { display: flex; align-items: center; justify-content: space-between; - gap: 16px; - padding: 16px 0; + gap: 20px; + padding: 18px 0; font-weight: 600; - font-size: 0.98rem; + font-size: 1rem; + line-height: 1.45; color: var(--event-primary-text-color); overflow-wrap: anywhere; } + &:first-child summary, &:first-child .faqQuestion { + padding-top: 4px; + } + summary { cursor: pointer; list-style: none; + border-radius: 6px; &::-webkit-details-marker { display: none; } + + &:focus-visible { + outline: 2px solid var(--event-accent-ink, var(--event-primary-color)); + outline-offset: 4px; + } + + &:hover .faqIcon { + background: color-mix(in srgb, var(--event-primary-color) 18%, transparent); + } } &[open] .faqIcon { @@ -56,19 +103,31 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); .faqIcon { flex-shrink: 0; - color: var(--event-primary-color); - transition: transform 0.2s $ease; + box-sizing: content-box; + padding: 5px; + border-radius: 50%; + background: var(--event-accent-soft, color-mix(in srgb, var(--event-primary-color) 10%, transparent)); + color: var(--event-accent-ink, var(--event-primary-color)); + transition: transform 0.25s $ease, background-color 0.15s $ease; } .faqAnswer { - padding: 0 32px 16px 0; - font-size: 0.92rem; - line-height: 1.65; + padding: 0 48px 20px 0; + font-size: 0.95rem; + line-height: 1.7; color: var(--event-secondary-color); overflow-wrap: anywhere; p { - margin: 0 0 0.6em; + margin: 0 0 0.7em; + + &:last-child { + margin-bottom: 0; + } + } + + @include hp.narrow { + padding-right: 0; } } @@ -76,7 +135,7 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); position: relative; aspect-ratio: 16 / 9; overflow: hidden; - border-radius: $radius-md; + border-radius: $radius-lg; background: #000; iframe { @@ -86,6 +145,10 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); height: 100%; border: 0; } + + @include hp.narrow { + border-radius: $radius-md; + } } .videoPlaceholder { @@ -95,50 +158,61 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); flex-direction: column; align-items: center; justify-content: center; - gap: 12px; + gap: 14px; border: 0; background: - radial-gradient(circle at 30% 20%, var(--event-accent-muted), transparent 60%), + radial-gradient(circle at 25% 20%, var(--event-accent-muted), transparent 60%), linear-gradient(135deg, var(--event-primary-color), #000 120%); cursor: pointer; + + &:focus-visible { + outline: 3px solid #fff; + outline-offset: -6px; + } } .videoPlay { display: grid; place-items: center; - width: 72px; - height: 72px; + width: 76px; + height: 76px; border-radius: 50%; - background: rgba(255, 255, 255, 0.95); + background: rgba(255, 255, 255, 0.96); color: #000; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); - transition: transform 0.2s $ease; + box-shadow: 0 16px 40px -8px rgba(0, 0, 0, 0.45), 0 0 0 10px rgba(255, 255, 255, 0.16); + transition: transform 0.25s $ease, box-shadow 0.25s $ease; .videoPlaceholder:hover & { transform: scale(1.06); + box-shadow: 0 16px 40px -8px rgba(0, 0, 0, 0.45), 0 0 0 14px rgba(255, 255, 255, 0.2); } } .videoNote { - font-size: 0.78rem; + font-size: 0.8rem; font-weight: 600; - color: rgba(255, 255, 255, 0.85); + letter-spacing: 0.01em; + color: rgba(255, 255, 255, 0.88); } .gallery { display: grid; grid-template-columns: repeat(4, 1fr); - grid-auto-rows: 150px; + grid-auto-rows: 140px; gap: 8px; &[data-count="1"] { grid-template-columns: 1fr; - grid-auto-rows: 360px; + grid-auto-rows: auto; + + .galleryItem { + aspect-ratio: 16 / 9; + } } &[data-count="2"] { grid-template-columns: repeat(2, 1fr); - grid-auto-rows: 260px; + grid-auto-rows: 280px; } &[data-count="3"] { @@ -150,28 +224,48 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); } } - &[data-count="4"], &[data-count="5"] { + &[data-count="4"] { + grid-template-columns: 2fr 1fr 1fr; + grid-auto-rows: 160px; + + .galleryItem:first-child { + grid-row: span 2; + } + + .galleryItem:last-child { + grid-column: span 2; + } + } + + &[data-count="5"] { .galleryItem:first-child { grid-column: span 2; grid-row: span 2; } } - @include mixins.respond-below(sm) { + @include hp.narrow { grid-template-columns: repeat(2, 1fr); - grid-auto-rows: 130px; - - &[data-count="1"] { - grid-template-columns: 1fr; - grid-auto-rows: 240px; - } + grid-auto-rows: 120px; + gap: 6px; &[data-count="2"] { - grid-auto-rows: 130px; + grid-auto-rows: 160px; } - &[data-count="3"] { + &[data-count="3"], &[data-count="4"] { grid-template-columns: repeat(2, 1fr); + grid-auto-rows: 120px; + } + + &[data-count="3"] .galleryItem:first-child, &[data-count="5"] .galleryItem:first-child { + grid-column: span 2; + grid-row: span 1; + } + + &[data-count="4"] .galleryItem:first-child, &[data-count="4"] .galleryItem:last-child { + grid-column: auto; + grid-row: auto; } } } @@ -181,19 +275,48 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); overflow: hidden; padding: 0; border: 0; - border-radius: $radius-sm; + border-radius: $radius-md; cursor: zoom-in; img { width: 100%; height: 100%; object-fit: cover; - transition: transform 0.5s $ease; + transition: transform 0.6s $ease; } &:hover img { transform: scale(1.04); } + + &:focus-visible { + outline: 2px solid var(--event-accent-ink, var(--event-primary-color)); + outline-offset: 2px; + } + + &:first-child { + border-radius: $radius-lg $radius-md $radius-md $radius-lg; + } + + @include hp.narrow { + border-radius: 10px; + + &:first-child { + border-radius: 10px; + } + } +} + +.galleryMore { + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: rgba(0, 0, 0, 0.5); + color: #fff; + font-size: 1.5rem; + font-weight: 700; + letter-spacing: -0.01em; } .lightbox { @@ -212,7 +335,7 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); max-width: min(92vw, 1600px); max-height: 86vh; object-fit: contain; - border-radius: 6px; + border-radius: 8px; } .lightboxClose, .lightboxPrev, .lightboxNext { @@ -226,6 +349,8 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); background: rgba(255, 255, 255, 0.12); color: #fff; cursor: pointer; + backdrop-filter: blur(8px); + transition: background-color 0.15s $ease; &:hover { background: rgba(255, 255, 255, 0.24); @@ -254,15 +379,18 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); bottom: 20px; left: 50%; transform: translateX(-50%); + padding: 4px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.1); font-size: 0.8rem; font-variant-numeric: tabular-nums; - color: rgba(255, 255, 255, 0.7); + color: rgba(255, 255, 255, 0.8); } .sponsors { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 12px; + gap: 10px; } .sponsor { @@ -270,13 +398,13 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); align-items: center; justify-content: center; box-sizing: border-box; - height: 84px; + height: 88px; min-width: 0; - padding: 14px 18px; + padding: 16px 20px; overflow: hidden; - border: 1px solid var(--event-border-color); - border-radius: $radius-sm; - transition: border-color 0.15s $ease, transform 0.15s $ease; + border-radius: $radius-md; + background: color-mix(in srgb, var(--event-primary-text-color) 3%, var(--event-content-bg-color, transparent)); + transition: background-color 0.2s $ease; img { display: block; @@ -286,12 +414,12 @@ $ease: cubic-bezier(0.4, 0, 0.2, 1); max-height: 100%; object-fit: contain; filter: grayscale(1); - opacity: 0.8; - transition: filter 0.2s $ease, opacity 0.2s $ease; + opacity: 0.75; + transition: filter 0.25s $ease, opacity 0.25s $ease; } &:hover { - border-color: var(--event-primary-color); + background: color-mix(in srgb, var(--event-primary-text-color) 6%, var(--event-content-bg-color, transparent)); img { filter: none; diff --git a/frontend/src/components/layouts/EventHomepage/Sections/SponsorsSection.tsx b/frontend/src/components/layouts/EventHomepage/Sections/SponsorsSection.tsx index 38fd17f21e..ac29f892b6 100644 --- a/frontend/src/components/layouts/EventHomepage/Sections/SponsorsSection.tsx +++ b/frontend/src/components/layouts/EventHomepage/Sections/SponsorsSection.tsx @@ -2,6 +2,7 @@ import {t} from "@lingui/macro"; import {HomepageSection, Image} from "../../../../types.ts"; import {HomepageSectionClasses} from "../Lineup"; import {SectionShell} from "./SectionShell.tsx"; +import {sizedImageUrl} from "../../../../utilites/imageTransform.ts"; import classes from "./Sections.module.scss"; export const SponsorsSection = ({images: availableImages, section, sectionClasses}: { @@ -21,7 +22,7 @@ export const SponsorsSection = ({images: availableImages, section, sectionClasse
    {sponsors.map(({sponsor, image}) => { - const logo = {sponsor.name; + const logo = {sponsor.name; return sponsor.url ? ( diff --git a/frontend/src/components/layouts/EventHomepage/hooks.ts b/frontend/src/components/layouts/EventHomepage/hooks.ts new file mode 100644 index 0000000000..0f448b3715 --- /dev/null +++ b/frontend/src/components/layouts/EventHomepage/hooks.ts @@ -0,0 +1,85 @@ +import {useEffect, useState} from "react"; + +const RAIL_EDGE_GAP = 24; +export const WIDE_LAYOUT_QUERY = '(min-width: 1080px)'; + +export const useInViewport = (node: Element | null, threshold: number): boolean | null => { + const [inView, setInView] = useState(null); + + useEffect(() => { + if (!node) { + setInView(null); + return; + } + const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), {threshold}); + observer.observe(node); + + return () => observer.disconnect(); + }, [node, threshold]); + + return inView; +}; + +export const useStickyRail = (node: HTMLElement | null) => { + useEffect(() => { + if (!node) { + return; + } + const wide = window.matchMedia(WIDE_LAYOUT_QUERY); + let offset = 0; + let lastScrollY = window.scrollY; + let frame = 0; + + const bounds = () => { + const maxTop = parseFloat(getComputedStyle(node).getPropertyValue('--rail-max-top')) || RAIL_EDGE_GAP; + const minTop = Math.min(maxTop, window.innerHeight - node.offsetHeight - RAIL_EDGE_GAP); + return {minTop, maxTop}; + }; + const apply = (nextOffset: number) => { + const {minTop, maxTop} = bounds(); + const top = Math.min(maxTop, Math.max(minTop, maxTop - nextOffset)); + offset = maxTop - top; + node.style.setProperty('--rail-top', `${top}px`); + }; + const onScroll = () => { + if (frame) { + return; + } + frame = requestAnimationFrame(() => { + frame = 0; + const scrollY = window.scrollY; + if (wide.matches) { + apply(offset + (scrollY - lastScrollY)); + } + lastScrollY = scrollY; + }); + }; + const onResize = () => wide.matches && apply(offset); + const onFocus = (focusEvent: FocusEvent) => { + if (!wide.matches || !(focusEvent.target instanceof HTMLElement)) { + return; + } + const {maxTop} = bounds(); + const rect = focusEvent.target.getBoundingClientRect(); + if (rect.top < maxTop) { + apply(offset - (maxTop - rect.top)); + } else if (rect.bottom > window.innerHeight - RAIL_EDGE_GAP) { + apply(offset + (rect.bottom - window.innerHeight + RAIL_EDGE_GAP)); + } + }; + + const observer = new ResizeObserver(onResize); + observer.observe(node); + window.addEventListener('scroll', onScroll, {passive: true}); + window.addEventListener('resize', onResize); + node.addEventListener('focusin', onFocus); + + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); + window.removeEventListener('scroll', onScroll); + window.removeEventListener('resize', onResize); + node.removeEventListener('focusin', onFocus); + }; + }, [node]); +}; diff --git a/frontend/src/components/layouts/EventHomepage/index.tsx b/frontend/src/components/layouts/EventHomepage/index.tsx index 8b8815746d..a55a0f89fe 100644 --- a/frontend/src/components/layouts/EventHomepage/index.tsx +++ b/frontend/src/components/layouts/EventHomepage/index.tsx @@ -4,47 +4,37 @@ import {CustomHomepageSection} from "./Sections"; import {resolveHomepageSections} from "../../../utilites/homepageSections.ts"; import {initialSelectedOccurrence} from "../../../utilites/lineupSchedule.ts"; import SelectProducts from "../../routes/product-widget/SelectProducts"; +import {defaultOccurrence} from "../../../utilites/occurrenceDefaults.ts"; import "../../../styles/widget/default.scss"; -import React, {Fragment, useCallback, useEffect, useRef, useState} from "react"; +import {CSSProperties, Fragment, useCallback, useEffect, useRef, useState} from "react"; import {EventDocumentHead} from "../../common/EventDocumentHead"; -import {eventCoverImage, eventHomepageUrl, imageUrl, organizerHomepageUrl} from "../../../utilites/urlHelper.ts"; +import {eventCoverImage} from "../../../utilites/urlHelper.ts"; import {Event, EventLifecycleStatus, EventOccurrence, EventType, HomepageSection, HomepageSectionType, OrganizerStatus} from "../../../types.ts"; import {EventNotAvailable} from "./EventNotAvailable"; -import { - IconArrowUpRight, - IconCalendar, - IconCalendarOff, - IconCalendarPlus, - IconCalendarRepeat, - IconExternalLink, - IconMail, - IconMapPin, - IconMaximize, - IconShare, - IconTicket, - IconWorld -} from "@tabler/icons-react"; -import {Anchor} from "@mantine/core"; import {t} from "@lingui/macro"; -import {PoweredByFooter} from "../../common/PoweredByFooter"; import {ContactOrganizerModal} from "../../common/ContactOrganizerModal"; -import {socialMediaConfig} from "../../../constants/socialMediaConfig"; -import {getGoogleMapsUrl, getShortLocationDisplay} from "../../../utilites/addressUtilities.ts"; import {buildEventLocationDisplay, summariseEventLocations} from "../../../utilites/effectiveLocation.ts"; import {StatusToggle} from "../../common/StatusToggle"; -import {getConfig} from "../../../utilites/config.ts"; -import {computeThemeVariables, validateThemeSettings} from "../../../utilites/themeUtils.ts"; +import {validateThemeSettings} from "../../../utilites/themeUtils.ts"; import {useOrganizerTrackingPixels} from "../../../hooks/useOrganizerTrackingPixels"; -import {trackPixelEvent, hasActivePixels} from "../../../utilites/trackingPixels"; -import {CookieSettingsLink} from "../../common/CookieSettingsLink"; -import {removeTransparency} from "../../../utilites/colorHelper.ts"; -import {ensureHomepageFontLoaded} from "../../../utilites/fontLoader.ts"; -import {ShareComponent} from "../../common/ShareIcon"; -import {EventDateRange} from "../../common/EventDateRange"; -import {CalendarOptionsPopover} from "../../common/CalendarOptionsPopover"; -import {formatDateWithLocale, isDateInPast, isSameDayInTimezone} from "../../../utilites/dates.ts"; +import {hasActivePixels, trackPixelEvent} from "../../../utilites/trackingPixels"; +import {formatDateWithLocale, isSameDayInTimezone} from "../../../utilites/dates.ts"; import {formatCurrency} from "../../../utilites/currency.ts"; +import {summariseEventPrice, summariseEventSchedule} from "../../../utilites/eventListing.ts"; +import {eventPriceLabel, eventScheduleLabel} from "../../../utilites/eventListingLabels.ts"; import {UserGeneratedContent} from "../../common/UserGeneratedContent"; +import {HomepageFrame} from "../HomepageFrame"; +import {CoverMedia} from "../HomepageFrame/CoverMedia.tsx"; +import {DatePoster} from "../HomepageFrame/DatePoster.tsx"; +import {MoreEventsSection} from "./MoreEventsSection.tsx"; +import {EventHost, EventIntro, IntroStatus} from "./EventIntro.tsx"; +import {LocationSection} from "./LocationSection.tsx"; +import {OrganizerSection} from "./OrganizerSection.tsx"; +import {SectionNav} from "./SectionNav.tsx"; +import {CheckoutBar, CheckoutBarMode} from "./CheckoutBar.tsx"; +import {useInViewport, useStickyRail} from "./hooks.ts"; +import {scrollBehavior} from "../../../utilites/motion.ts"; +import {resolveEventLayout} from "../../../constants/eventLayouts.ts"; interface EventHomepageProps { event?: Event; @@ -56,16 +46,23 @@ interface EventHomepageProps { const EventHomepage = ({...loaderData}: EventHomepageProps) => { const {event, promoCodeValid, promoCode, initialOccurrenceId, isPreview = false} = loaderData; - const [showScrollButton, setShowScrollButton] = useState(false); const [contactModalOpen, setContactModalOpen] = useState(false); const [selectedOccurrence, setSelectedOccurrence] = useState( - () => initialSelectedOccurrence(event?.occurrences, initialOccurrenceId), + () => initialOccurrenceId || !event + ? initialSelectedOccurrence(event?.occurrences, initialOccurrenceId) + : initialSelectedOccurrence(event.occurrences, null) ?? defaultOccurrence(event), ); const occurrenceSelectionReported = useRef(false); const [selectedCart, setSelectedCart] = useState({quantity: 0, total: 0}); const [continueButtonNode, setContinueButtonNode] = useState(null); - const [continueButtonInView, setContinueButtonInView] = useState(false); - const ticketsSectionRef = useRef(null); + const [ticketsNode, setTicketsNode] = useState(null); + const [contentNode, setContentNode] = useState(null); + const ticketsInView = useInViewport(ticketsNode, 0); + const continueButtonInView = useInViewport(continueButtonNode, 0.5); + const [footerNode, setFooterNode] = useState(null); + const footerInView = useInViewport(footerNode, 0); + const layout = resolveEventLayout(event?.settings?.homepage_theme_settings?.layout); + useStickyRail(layout.ticketRail ? ticketsNode : null); const handleSelectedOccurrenceChange = useCallback((occurrence?: EventOccurrence) => { if (!occurrence && !occurrenceSelectionReported.current) { @@ -80,21 +77,6 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => { [], ); - 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 {pixelsReady} = useOrganizerTrackingPixels( event?.organizer?.settings?.tracking_pixels ); @@ -109,130 +91,67 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => { } }, [event?.id, pixelsReady]); - useEffect(() => { - let showTimer: NodeJS.Timeout; - - const checkTicketsPosition = () => { - if (ticketsSectionRef.current) { - const rect = ticketsSectionRef.current.getBoundingClientRect(); - const isBelowFold = rect.top > window.innerHeight; - const isAboveView = rect.bottom < 0; - const shouldShowButton = isBelowFold || isAboveView; - setShowScrollButton(shouldShowButton); - } - }; - - showTimer = setTimeout(() => { - checkTicketsPosition(); - }, 500); - - const handleScroll = () => { - checkTicketsPosition(); - }; - - const handleResize = () => { - checkTicketsPosition(); - }; - - window.addEventListener('scroll', handleScroll); - window.addEventListener('resize', handleResize); - - return () => { - clearTimeout(showTimer); - window.removeEventListener('scroll', handleScroll); - window.removeEventListener('resize', handleResize); - }; - }, []); - - const themeSettings = validateThemeSettings(event?.settings?.homepage_theme_settings); - - useEffect(() => { - ensureHomepageFontLoaded(themeSettings.font_family); - }, [themeSettings.font_family]); - - const scrollToTickets = () => { - ticketsSectionRef.current?.scrollIntoView({behavior: 'smooth', block: 'start'}); - }; - if (!event) { return ; } - const cssVars = computeThemeVariables(themeSettings); - const backgroundType = themeSettings.background_type; - - const themeStyles = { - '--event-bg-color': themeSettings.background, - '--event-content-bg-color': cssVars['--theme-surface'], - '--event-primary-color': themeSettings.accent, - '--event-primary-text-color': cssVars['--theme-text-primary'], - '--event-secondary-color': cssVars['--theme-text-secondary'], - '--event-secondary-text-color': cssVars['--theme-text-tertiary'], - '--event-accent-contrast': cssVars['--theme-accent-contrast'], - '--event-accent-soft': cssVars['--theme-accent-soft'], - '--event-accent-muted': cssVars['--theme-accent-muted'], - '--event-border-color': cssVars['--theme-border'], - '--theme-font-family': cssVars['--theme-font-family'], - fontFamily: cssVars['--theme-font-family'], - } as React.CSSProperties; - - const coverImageData = eventCoverImage(event); - const coverImage = coverImageData?.url; + const themeSettings = validateThemeSettings(event.settings?.homepage_theme_settings); + const tz = event.timezone; + const cover = eventCoverImage(event); const organizer = event.organizer!; - const organizerSocials = organizer?.settings?.social_media_handles; - const organizerLogo = imageUrl('ORGANIZER_LOGO', organizer?.images); - const organizerLocation = organizer?.location?.structured_address; - const websiteUrl = organizer?.website; + const organizerLogo = organizer.images?.find(image => image.type === 'ORGANIZER_LOGO')?.url; + const organizerIsLive = organizer.status === OrganizerStatus.LIVE; + const locationSummary = summariseEventLocations(event); - const singleLocationDisplay = locationSummary.kind === 'single' + const location = locationSummary.kind === 'single' ? buildEventLocationDisplay(event, locationSummary.eventLocation, locationSummary.isEventDefault) : null; - const hasMixedModes = locationSummary.kind === 'varied' && locationSummary.types.length > 1; - const hasMultipleLocations = locationSummary.kind === 'varied' && locationSummary.types.length === 1; - const isOnlineEvent = singleLocationDisplay?.isOnline === true; - const hasLocation = singleLocationDisplay !== null && !singleLocationDisplay.isOnline; - const venueName = singleLocationDisplay?.venueName ?? null; - const formattedAddress = singleLocationDisplay?.full ?? ''; - const mapUrl = singleLocationDisplay?.mapsUrl ?? null; - const multipleLocationsLabel = hasMultipleLocations ? t`Multiple locations` : null; - const mixedModesLabel = hasMixedModes ? t`Online & in-person — see schedule` : null; - - const socialLinks = organizerSocials ? Object.entries(organizerSocials) - .filter(([platform, handle]) => handle && socialMediaConfig[platform as keyof typeof socialMediaConfig]) - .map(([platform, handle]) => ({ - platform, - handle: handle as string, - config: socialMediaConfig[platform as keyof typeof socialMediaConfig] - })) : []; + const hasVenue = !!location && !location.isOnline && !!(location.venueName || location.full); + const variedLocationLabel = locationSummary.kind !== 'varied' + ? null + : locationSummary.types.length > 1 ? t`Online & in-person — see schedule` : t`Multiple locations`; + + const schedule = summariseEventSchedule(event); + const hasUpcomingDate = event.type !== EventType.RECURRING + || !!event.next_occurrence_start_date + || (event.occurrences ?? []).some(occurrence => !occurrence.is_past); + const showsSingleDate = !!selectedOccurrence || (!schedule.isSeriesRange && hasUpcomingDate); + const dateStart = selectedOccurrence?.start_date ?? (showsSingleDate ? schedule.start : null); + const dateEnd = selectedOccurrence ? (selectedOccurrence.end_date ?? null) : schedule.end; + const seriesLabel = !showsSingleDate && schedule.isSeriesRange && schedule.start ? eventScheduleLabel(schedule, tz) : null; + const hasLaterDates = !!event.last_occurrence_date && !!event.next_occurrence_start_date + && !isSameDayInTimezone(event.last_occurrence_date, event.next_occurrence_start_date, tz); + const lastOccurrenceDate = hasLaterDates + ? formatDateWithLocale(event.last_occurrence_date!, 'shortDate', tz) + : null; const eventHasEnded = event.lifecycle_status === EventLifecycleStatus.ENDED; + const products = event.products || event.product_categories?.flatMap(category => category.products || []) || []; + const status: IntroStatus | null = eventHasEnded + ? {label: t`This event has ended`, tone: 'muted'} + : products.length > 0 && products.every(product => product.is_sold_out) + ? {label: t`Sold Out`, tone: 'muted'} + : event.lifecycle_status === EventLifecycleStatus.ONGOING ? {label: t`Happening now`, tone: 'live'} : null; + const ticketRail = layout.ticketRail && !eventHasEnded; + const sheetWidth = layout.ticketRail && !ticketRail ? 'medium' : layout.width; + + const price = eventHasEnded ? null : summariseEventPrice(event); + const priceLabel = price ? eventPriceLabel(price, event.currency) : null; + const shortDateLabel = dateStart + ? `${formatDateWithLocale(dateStart, 'weekdayDate', tz)} · ${formatDateWithLocale(dateStart, 'timeOnly', tz)}` + : null; - const getStatusBadge = () => { - if (eventHasEnded) { - return {text: t`Sales ended`}; - } - - const products = event.products || event.product_categories?.flatMap(c => c.products || []) || []; - - if (products.length > 0 && products.every(p => p.is_sold_out)) { - return {text: t`Sold Out`}; - } - - 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 canAddToCalendar = event.type !== EventType.RECURRING || !!selectedOccurrence; - const hasLaterDates = !!event.last_occurrence_date && !!event.next_occurrence_start_date - && !isSameDayInTimezone(event.last_occurrence_date, event.next_occurrence_start_date, event.timezone); - const lastOccurrenceDate = hasLaterDates - ? formatDateWithLocale(event.last_occurrence_date!, 'shortDate', event.timezone) - : null; - const showFloatingCheckoutButton = selectedCart.quantity > 0 && !!continueButtonNode && !continueButtonInView; - - const homepageSections = resolveHomepageSections(event.settings?.homepage_sections).filter(section => section.enabled || section.type === 'TICKETS'); + const hasCart = selectedCart.quantity > 0 && !!continueButtonNode; + const checkoutBarMode: CheckoutBarMode | null = footerInView + ? null + : hasCart + ? (continueButtonInView === false ? 'continue' : null) + : (ticketsInView === false && !eventHasEnded && price?.kind !== 'soldOut' ? 'tickets' : null); + + const homepageSections = resolveHomepageSections(event.settings?.homepage_sections) + .filter(section => section.type === 'TICKETS' ? !eventHasEnded : section.enabled); const sectionHeading = (type: HomepageSectionType, fallback: string) => homepageSections.find(section => section.type === type)?.heading || fallback; const sectionClasses = { @@ -241,238 +160,105 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => { sectionTitle: classes.sectionTitle, }; const lineupOccurrence = event.type === EventType.RECURRING ? selectedOccurrence : undefined; - - const ticketsBlock = ( -
    - -
    - ); - - const aboutBlock = event?.description && ( -
    -
    -

    {sectionHeading('ABOUT', t`About`)}

    -
    - -
    - ); - - const locationBlock = hasLocation && ( -
    -
    -

    {sectionHeading('LOCATION', t`Location`)}

    -
    -
    -
    - {venueName && ( -
    - {venueName} -
    - )} - {formattedAddress && ( -
    - {formattedAddress} -
    - )} - {mapUrl && ( - - - {t`Get Directions`} - - )} -
    - {mapUrl && ( - - - - - - - - - - - - - - - -
    - - - {t`View Map`} - -
    -
    - )} -
    -
    - ); - - const organizerBlock = organizer && organizer.status === OrganizerStatus.LIVE && ( -
    -
    -

    {sectionHeading('ORGANIZER', t`Organizer`)}

    -
    -
    - {organizerLogo ? ( - {organizer.name} - ) : ( -
    - {organizer.name.charAt(0).toUpperCase()} -
    - )} -
    -
    -
    -

    - - {organizer.name} - -

    - {getShortLocationDisplay(organizerLocation) && ( - - )} -
    -
    - - {organizer.description && ( - - )} - -
    - {socialLinks.length > 0 && ( -
    - {socialLinks.map(({platform, handle, config}) => { - const IconComponent = config.icon; - const url = config.baseUrl + handle; - const label = config.name(); - return ( - - - - ); - })} -
    - )} - {websiteUrl && (() => { - try { - const hostname = new URL(websiteUrl).hostname; - return ( - - - - ); - } catch { - return null; - } - })()} - -
    -
    -
    -
    - ); + const mainRowCount = 1 + homepageSections.filter(section => section.type !== 'TICKETS').length; const renderSection = (section: HomepageSection) => { switch (section.type) { case 'TICKETS': - return ticketsBlock; + return ( +
    +
    + +
    +
    + ); case 'ABOUT': - return aboutBlock; + return event.description ? ( +
    +
    +

    {sectionHeading('ABOUT', t`About`)}

    +
    + +
    + ) : null; case 'LINEUP': return ; case 'SCHEDULE': return ; case 'LOCATION': - return locationBlock; + return hasVenue ? : null; case 'ORGANIZER': - return organizerBlock; + return organizerIsLive ? ( + setContactModalOpen(true)} + /> + ) : null; + case 'MORE_EVENTS': + return organizerIsLive + ? + : null; default: return ; } }; + const hostProps = { + event, + organizer, + organizerLogo, + occurrence: selectedOccurrence, + canAddToCalendar: event.type !== EventType.RECURRING || !!selectedOccurrence, + }; + const intro = ( + + ); + const sections = homepageSections.map(section => ( + {renderSection(section)} + )); + return ( <> - {!isPreview && event?.status && event?.id && ( + {!isPreview && event.status && event.id && ( { /> )} -
    } + outside={( + <> + 0 + ? `${continueButtonText} (${formatCurrency(selectedCart.total, event.currency)})` + : continueButtonText} + onGetTickets={() => { + ticketsNode?.scrollIntoView({behavior: scrollBehavior(), block: 'start'}); + ticketsNode?.focus({preventScroll: true}); + }} + onContinue={() => continueButtonNode?.click()} + /> + setContactModalOpen(false)} + organizer={organizer} + /> + + )} > - + - {event && } + {!layout.poster && cover && } - {(coverImage && backgroundType === 'MIRROR_COVER_IMAGE') ? ( -
    - ) : ( -
    - )}
    - -
    -
    -
    -
    - {coverImage && ( -
    - {coverImageData?.lqip_base64 && ( - - )} - {event.title} -
    - {statusBadge && ( -
    - - - {statusBadge.text} - -
    - )} -
    - )} - -
    -
    - {organizer && organizer.status === OrganizerStatus.LIVE ? ( - - {organizerLogo ? ( - {organizer.name} - ) : ( - - {organizer.name.charAt(0).toUpperCase()} - - )} - - {organizer.name} - - - ) : ( -
    - {organizerLogo ? ( - {organizer?.name - ) : ( - - {organizer?.name?.charAt(0).toUpperCase() || '?'} - - )} - - {organizer?.name} - -
    - )} - -
    - - - - {canAddToCalendar && ( - - - - )} -
    -
    - -

    {event.title}

    - -
    -
    -
    - -
    -
    -
    - -
    - {event.type === EventType.RECURRING && lastOccurrenceDate && ( -
    - - {t`Multiple dates until ${lastOccurrenceDate}`} -
    - )} -
    -
    - - {event.type !== EventType.RECURRING && event.end_date && isDateInPast(event.end_date) && ( -
    -
    - -
    -
    -
    {t`This event has ended`}
    -
    -
    - )} - - {isOnlineEvent && ( -
    -
    - -
    -
    -
    {t`Online Event`}
    -
    - {t`Join from anywhere`} -
    -
    -
    - )} - - {mixedModesLabel && ( -
    -
    - -
    -
    -
    {mixedModesLabel}
    -
    -
    - )} - - {multipleLocationsLabel && ( -
    -
    - -
    -
    -
    {multipleLocationsLabel}
    -
    - {t`See schedule`} -
    -
    -
    - )} - - {hasLocation && ( -
    -
    - -
    -
    - {venueName && ( -
    - {venueName} -
    - )} - {formattedAddress && ( -
    - {formattedAddress} -
    - )} - {mapUrl && ( - - {t`View on Google Maps`} - - - )} -
    -
    - )} -
    -
    + ref={layout.poster ? undefined : setContentNode} + className={classes.body} + data-rail={ticketRail || undefined} + data-poster={layout.poster || undefined} + style={ticketRail ? {'--main-rows': mainRowCount} as CSSProperties : undefined} + > + {layout.poster ? ( + <> +
    + {cover && } + {!cover && (dateStart ?? schedule.start) && } +
    - - {homepageSections.map(section => ( - {renderSection(section)} - ))} - -
    - -
    - - {t`Privacy Policy`} - - - {t`Terms of Service`} - - -
    +
    + {intro} + {sections}
    -
    -
    - - {showFloatingCheckoutButton && ( - - )} - {!showFloatingCheckoutButton && showScrollButton && !eventHasEnded && ( - + + ) : ( + <> + {intro} + {sections} + )} - - setContactModalOpen(false)} - organizer={organizer} - />
    -
    + ); }; diff --git a/frontend/src/components/layouts/HomepageFrame/CoverMedia.tsx b/frontend/src/components/layouts/HomepageFrame/CoverMedia.tsx new file mode 100644 index 0000000000..3aadd757ab --- /dev/null +++ b/frontend/src/components/layouts/HomepageFrame/CoverMedia.tsx @@ -0,0 +1,29 @@ +import {CSSProperties} from "react"; +import {Image} from "../../../types.ts"; +import {responsiveImage} from "../../../utilites/imageTransform.ts"; +import classes from "./HomepageFrame.module.scss"; + +type CoverVariant = 'hero' | 'banner' | 'poster'; + +const BANNER_SIZES = '(min-width: 1320px) 1272px, 100vw'; +const POSTER_SIZES = '(min-width: 768px) 420px, 100vw'; + +export const CoverMedia = ({image, alt, variant = 'hero'}: { image: Image, alt: string, variant?: CoverVariant }) => { + const sized = !!(image.width && image.height); + const style = { + '--cover-ratio': sized ? `${image.width} / ${image.height}` : undefined, + backgroundColor: image.avg_colour ?? undefined, + } as CSSProperties; + + return ( +
    + + {alt} +
    + ); +}; diff --git a/frontend/src/components/layouts/HomepageFrame/DatePoster.tsx b/frontend/src/components/layouts/HomepageFrame/DatePoster.tsx new file mode 100644 index 0000000000..894d9c47cc --- /dev/null +++ b/frontend/src/components/layouts/HomepageFrame/DatePoster.tsx @@ -0,0 +1,10 @@ +import {formatDateWithLocale, formatWeekday} from "../../../utilites/dates.ts"; +import classes from "./HomepageFrame.module.scss"; + +export const DatePoster = ({date, tz}: { date: string, tz: string }) => ( + +); diff --git a/frontend/src/components/layouts/HomepageFrame/DateTile.tsx b/frontend/src/components/layouts/HomepageFrame/DateTile.tsx new file mode 100644 index 0000000000..dc1220167e --- /dev/null +++ b/frontend/src/components/layouts/HomepageFrame/DateTile.tsx @@ -0,0 +1,9 @@ +import {formatDateWithLocale} from "../../../utilites/dates.ts"; +import classes from "./HomepageFrame.module.scss"; + +export const DateTile = ({date, tz, size = 'md'}: { date: string, tz: string, size?: 'md' | 'lg' }) => ( + +); diff --git a/frontend/src/components/layouts/HomepageFrame/HomepageFrame.module.scss b/frontend/src/components/layouts/HomepageFrame/HomepageFrame.module.scss new file mode 100644 index 0000000000..44b22a9b59 --- /dev/null +++ b/frontend/src/components/layouts/HomepageFrame/HomepageFrame.module.scss @@ -0,0 +1,352 @@ +@use "../../../styles/mixins.scss"; +@use "homepage" as hp; + +.page { + --hp-text: var(--event-primary-text-color); + --hp-muted: var(--event-secondary-color); + --hp-accent: var(--event-primary-color); + --hp-on-accent: var(--event-accent-contrast); + --hp-accent-solid: var(--event-accent-solid, var(--hp-accent)); + --hp-surface: var(--event-content-bg-color); + --hp-border: var(--event-border-color); + --hp-border-strong: color-mix(in srgb, var(--hp-text) 22%, transparent); + --hp-fill: color-mix(in srgb, var(--hp-text) 4%, var(--hp-surface)); + --hp-fill-strong: color-mix(in srgb, var(--hp-text) 8%, var(--hp-surface)); + --hp-accent-fill: color-mix(in srgb, var(--hp-accent) 10%, var(--hp-surface)); + --hp-accent-ink: var(--event-accent-ink); + --hp-shadow-sm: 0 1px 2px rgba(15, 15, 20, 0.04), 0 2px 8px -2px rgba(15, 15, 20, 0.06); + --hp-shadow-md: 0 1px 2px rgba(15, 15, 20, 0.05), 0 8px 20px -10px rgba(15, 15, 20, 0.18); + --hp-shadow-lg: 0 1px 2px rgba(15, 15, 20, 0.05), 0 16px 40px -20px rgba(15, 15, 20, 0.24); + --hp-glass: color-mix(in srgb, var(--hp-surface) 82%, transparent); + --hp-live-fill: color-mix(in srgb, #ef4444 12%, var(--hp-surface)); + --hp-live-ink: #b91c1c; + + position: relative; + min-height: 100vh; + font-family: hp.$font; + + &[data-width="medium"] { + --hp-sheet-max: 800px; + --hp-content-pad: 56px; + } + + &[data-width="narrow"] { + --hp-sheet-max: 640px; + --hp-content-pad: 48px; + } + + color: var(--hp-text); + -webkit-font-smoothing: antialiased; + + &[data-mode="dark"] { + --hp-live-fill: color-mix(in srgb, #ef4444 20%, var(--hp-surface)); + --hp-live-ink: #fca5a5; + --hp-fill: color-mix(in srgb, var(--hp-text) 6%, var(--hp-surface)); + --hp-fill-strong: color-mix(in srgb, var(--hp-text) 11%, var(--hp-surface)); + --hp-accent-fill: color-mix(in srgb, var(--hp-accent) 16%, var(--hp-surface)); + --hp-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3), 0 2px 8px -2px rgba(0, 0, 0, 0.4); + --hp-shadow-md: 0 1px 2px rgba(0, 0, 0, 0.3), 0 12px 32px -12px rgba(0, 0, 0, 0.6); + --hp-shadow-lg: 0 1px 2px rgba(0, 0, 0, 0.3), 0 24px 64px -24px rgba(0, 0, 0, 0.7); + } +} + +@media (prefers-reduced-motion: reduce) { + .page { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + } +} + +.backdrop { + position: fixed; + inset: 0; + z-index: -1; + overflow: hidden; + background: + radial-gradient(120% 60% at 50% -10%, color-mix(in srgb, var(--hp-accent) 14%, transparent), transparent 70%), + var(--event-bg-color); + + &[data-mirror] { + background-size: cover; + background-position: center; + filter: blur(48px) saturate(1.35); + transform: scale(1.2); + + &::after { + content: ''; + position: absolute; + inset: 0; + background: color-mix(in srgb, var(--event-bg-color) 62%, transparent); + } + } +} + +.shell { + position: relative; + + @include hp.tablet-up { + max-width: 1200px; + margin: 0 auto; + padding: 24px; + } +} + +.sheet { + position: relative; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 100svh; + background: var(--hp-surface); + + @include hp.tablet-up { + min-height: calc(100svh - 48px); + max-width: min(880px, var(--hp-sheet-max, 880px)); + margin: 0 auto; + border: 1px solid var(--hp-border); + border-radius: hp.$sheet-radius; + box-shadow: var(--hp-shadow-lg); + } + + @include hp.wide { + max-width: var(--hp-sheet-max, none); + } +} + +.cover { + position: relative; + width: 100%; + overflow: hidden; + background: var(--hp-fill); + + &[data-sized] { + aspect-ratio: var(--cover-ratio); + max-height: min(68svh, 560px); + } + + @include hp.tablet-up { + width: calc(100% - 16px); + margin: 8px 8px 0; + border-radius: hp.$sheet-radius - 8px; + + &[data-sized] { + max-height: min(480px, 56vh); + } + } + + @include hp.wide { + width: calc(100% - 16px); + margin: 8px 8px 0; + border-radius: hp.$sheet-radius - 8px; + } + + &[data-variant="banner"][data-sized] { + max-height: min(40svh, 300px); + + @include hp.tablet-up { + max-height: min(420px, 50vh); + } + + .coverImage { + object-fit: cover; + } + } + + &[data-variant="poster"] { + width: 100%; + margin: 0; + border-radius: 20px; + box-shadow: var(--hp-shadow-md); + + &[data-sized] { + max-height: min(72svh, 620px); + } + + @include hp.tablet-up { + &[data-sized] { + max-height: max(280px, calc(100svh - 200px)); + } + } + } +} + +.datePoster { + display: flex; + flex-direction: column; + align-items: center; + overflow: hidden; + border: 1px solid var(--hp-border); + border-radius: 20px; + background: var(--hp-surface); + color: var(--hp-text); + line-height: 1; + + @include hp.mobile { + display: none; + } +} + +.datePosterMonth { + align-self: stretch; + padding: 14px 0 12px; + background: var(--hp-accent-solid); + color: var(--hp-on-accent); + font-size: 0.875rem; + font-weight: 700; + letter-spacing: 0.2em; + text-align: center; + text-transform: uppercase; +} + +.datePosterDay { + padding: clamp(28px, 4vw, 44px) 0 10px; + font-size: clamp(5rem, 9vw, 7.5rem); + font-weight: 700; + letter-spacing: -0.05em; +} + +.datePosterWeekday { + padding-bottom: clamp(24px, 3vw, 36px); + color: var(--hp-muted); + font-size: 1rem; + font-weight: 600; +} + +.coverBackdrop { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + filter: blur(32px) saturate(1.3); + transform: scale(1.2); +} + +.coverImage { + position: relative; + display: block; + width: 100%; + height: 100%; + object-fit: contain; + + .cover:not([data-sized]) & { + height: auto; + max-height: 560px; + } + + .cover[data-variant="banner"]:not([data-sized]) & { + max-height: 320px; + } +} + +.dateTile { + --tile-size: 48px; + display: flex; + flex-direction: column; + flex-shrink: 0; + width: var(--tile-size); + height: var(--tile-size); + overflow: hidden; + border: 1px solid var(--hp-border); + border-radius: calc(var(--tile-size) * 0.29); + background: var(--hp-surface); + text-align: center; + + &[data-size="lg"] { + --tile-size: 60px; + } +} + +.dateTileMonth { + padding: calc(var(--tile-size) * 0.06) 0 calc(var(--tile-size) * 0.04); + background: var(--hp-accent-solid); + color: var(--hp-on-accent); + font-size: calc(var(--tile-size) * 0.2); + font-weight: 700; + line-height: 1.2; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.dateTileDay { + display: grid; + flex: 1; + place-items: center; + color: var(--hp-text); + font-size: calc(var(--tile-size) * 0.38); + font-weight: 700; + line-height: 1; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} + +.footer { + isolation: isolate; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px 24px; + margin-top: auto; + padding: 24px 20px 32px; + border-top: 1px solid var(--hp-border); + color: var(--hp-muted); + font-size: 0.875rem; + + @include hp.mobile { + flex-direction: column; + text-align: center; + } + + @include hp.tablet-up { + padding-inline: 40px; + } + + @include hp.wide { + padding-inline: var(--hp-content-pad, 48px); + } +} + +.poweredBy { + margin: 0; + font-size: 0.875rem; + color: var(--hp-muted); + + a { + color: var(--hp-text); + font-weight: 600; + } +} + +.footerLinks { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 6px 20px; +} + +.footerLinks .footerLink { + display: inline-flex; + align-items: center; + min-height: 24px; + margin: 0; + padding: 0; + border: 0; + background: none; + color: var(--hp-muted); + font: inherit; + font-size: inherit; + text-decoration: none; + cursor: pointer; + transition: color 0.15s hp.$ease; + + &:hover { + color: var(--hp-text); + text-decoration: none; + opacity: 1; + } +} diff --git a/frontend/src/components/layouts/HomepageFrame/_homepage.scss b/frontend/src/components/layouts/HomepageFrame/_homepage.scss new file mode 100644 index 0000000000..4193e4d17c --- /dev/null +++ b/frontend/src/components/layouts/HomepageFrame/_homepage.scss @@ -0,0 +1,195 @@ +@use "../../../styles/mixins.scss"; + +$font: var(--theme-font-family, 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); +$ease: cubic-bezier(0.22, 1, 0.36, 1); +$wide: 1080px; +$sheet-radius: 24px; + +@mixin wide { + @media (min-width: $wide) { + @content; + } +} + +@mixin below-wide { + @media (max-width: $wide - 1px) { + @content; + } +} + +$narrow-container: 480px; +$compact-container: 520px; + +@mixin narrow { + @container (max-width: #{$narrow-container - 1px}) { + @content; + } +} + +@mixin compact { + @container (max-width: #{$compact-container - 1px}) { + @content; + } +} + +@mixin tablet-up { + @include mixins.respond-above(md) { + @content; + } +} + +@mixin mobile { + @include mixins.respond-below(md) { + @content; + } +} + +@mixin section-title { + margin: 0; + font-family: $font; + font-size: clamp(1.25rem, 1.15rem + 0.4vw, 1.5rem); + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.025em; + color: var(--hp-text); + text-wrap: balance; +} + +@mixin focus-ring { + &:focus-visible { + outline: 2px solid var(--hp-accent-ink); + outline-offset: 2px; + } +} + +@mixin pill-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 40px; + padding: 0 16px; + border: 1px solid var(--hp-border); + border-radius: 999px; + background: var(--hp-surface); + color: var(--hp-text); + font: inherit; + font-size: 0.875rem; + font-weight: 600; + white-space: nowrap; + text-decoration: none; + cursor: pointer; + transition: background-color 0.15s $ease, border-color 0.15s $ease; + @include focus-ring; + + &:hover { + background: var(--hp-fill); + border-color: var(--hp-border-strong); + } + + svg { + flex-shrink: 0; + width: 16px; + height: 16px; + } +} + +@mixin icon-button { + @include pill-button; + width: 36px; + height: 36px; + padding: 0; + border-color: transparent; + background: var(--hp-fill); + color: var(--hp-text); + + &:hover { + border-color: transparent; + background: var(--hp-fill-strong); + } +} + +@mixin rich-text { + color: color-mix(in srgb, var(--hp-text) 84%, var(--hp-surface)); + font-size: 1.0625rem; + line-height: 1.6; + overflow-wrap: anywhere; + + @include mobile { + font-size: 1rem; + } + + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + + p { + margin: 0 0 1em; + } + + a { + color: var(--hp-accent-ink); + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 3px; + } + + strong { + color: var(--hp-text); + font-weight: 600; + } + + h1, h2, h3, h4, h5, h6 { + margin: 1.6em 0 0.5em; + font-family: $font; + font-weight: 650; + line-height: 1.3; + letter-spacing: -0.01em; + color: var(--hp-text); + } + + h1 { + font-size: 1.375rem; + } + + h2 { + font-size: 1.25rem; + } + + h3 { + font-size: 1.125rem; + } + + ul, ol { + margin: 0 0 1em; + padding-left: 1.4em; + } + + li + li { + margin-top: 0.35em; + } + + blockquote { + margin: 1.4em 0; + padding-left: 1em; + border-left: 3px solid var(--hp-accent); + color: var(--hp-text); + } + + img { + max-width: 100%; + height: auto; + margin: 1em 0; + border-radius: 14px; + } + + hr { + margin: 2em 0; + border: 0; + border-top: 1px solid var(--hp-border); + } +} diff --git a/frontend/src/components/layouts/HomepageFrame/index.tsx b/frontend/src/components/layouts/HomepageFrame/index.tsx new file mode 100644 index 0000000000..998afaa3c7 --- /dev/null +++ b/frontend/src/components/layouts/HomepageFrame/index.tsx @@ -0,0 +1,72 @@ +import {ReactNode, Ref, useEffect} from "react"; +import {Anchor} from "@mantine/core"; +import {t} from "@lingui/macro"; +import {HomepageThemeSettings} from "../../../types.ts"; +import {SheetWidth} from "../../../constants/eventLayouts.ts"; +import {homepageThemeStyle} from "../../../utilites/themeUtils.ts"; +import {removeTransparency} from "../../../utilites/colorHelper.ts"; +import {ensureHomepageFontLoaded} from "../../../utilites/fontLoader.ts"; +import {getConfig} from "../../../utilites/config.ts"; +import {PoweredByFooter} from "../../common/PoweredByFooter"; +import {CookieSettingsLink} from "../../common/CookieSettingsLink"; +import classes from "./HomepageFrame.module.scss"; + +interface HomepageFrameProps { + themeSettings: HomepageThemeSettings; + mirrorImageUrl?: string | null; + footerSource: string; + footerRef?: Ref; + width?: SheetWidth; + header?: ReactNode; + outside?: ReactNode; + children: ReactNode; +} + +export const HomepageFrame = ({themeSettings, mirrorImageUrl, footerSource, footerRef, width = 'wide', header, outside, children}: HomepageFrameProps) => { + useEffect(() => { + ensureHomepageFontLoaded(themeSettings.font_family); + }, [themeSettings.font_family]); + + const mirrorImage = themeSettings.background_type === 'MIRROR_COVER_IMAGE' ? mirrorImageUrl : null; + + return ( +
    + +
    + ); +}; + +const HomepageFooter = ({source, footerRef}: { source: string, footerRef?: Ref }) => ( +
    + +
    + + {t`Privacy Policy`} + + + {t`Terms of Service`} + + +
    +
    +); diff --git a/frontend/src/components/layouts/OrganizerHomepage/EventCard/EventCard.module.scss b/frontend/src/components/layouts/OrganizerHomepage/EventCard/EventCard.module.scss deleted file mode 100644 index 8b35a12fa7..0000000000 --- a/frontend/src/components/layouts/OrganizerHomepage/EventCard/EventCard.module.scss +++ /dev/null @@ -1,352 +0,0 @@ -@use "../../../../styles/mixins.scss"; - -// Design tokens - matching OrganizerHomepage -$font-display: var(--theme-font-family, 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); -$font-body: var(--theme-font-family, 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif); - -$radius-lg: 20px; -$radius-md: 14px; -$radius-sm: 10px; -$radius-xs: 6px; - -$transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1); -$transition-slow: 0.4s cubic-bezier(0.4, 0, 0.2, 1); - -.eventCardLink { - text-decoration: none; - color: inherit; - display: block; - - &:hover { - .eventCard { - background: var(--accent-soft); - - .imageWrapper { - img { - transform: scale(1.03); - } - - } - - .shareButton { - opacity: 1; - transform: scale(1); - } - - } - } -} - -.eventCard { - // Inherit CSS variables from parent - --primary-color: inherit; - --primary-text-color: inherit; - --secondary-color: inherit; - --secondary-text-color: inherit; - --bg-color: inherit; - --content-bg-color: inherit; - --border-color: inherit; - --accent-soft: inherit; - --accent-muted: inherit; - --accent-contrast: inherit; - - font-family: $font-body; - background: transparent; - border-radius: $radius-md; - overflow: visible; - transition: all $transition-fast; - display: flex; - flex-direction: column; - height: 100%; - padding: 12px; - gap: 12px; - - @include mixins.respond-above('sm') { - flex-direction: row; - height: auto; - min-height: 140px; - padding: 12px; - gap: 16px; - } -} - -// Image Section -.eventImage { - position: relative; - flex: 0 0 auto; - - @include mixins.respond-below('sm') { - height: 160px; - } - - @include mixins.respond-above('sm') { - width: 200px; - height: 120px; - } -} - -// Image wrapper with its own border radius -.imageWrapper { - position: relative; - width: 100%; - height: 100%; - border-radius: $radius-sm; - overflow: hidden; - background: var(--bg-color); - - @include mixins.respond-below('sm') { - max-height: 160px; - } - - img { - width: 100%; - height: 100%; - object-fit: cover; - transition: transform $transition-slow; - } -} - -// Placeholder when no image -.placeholderImage { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - background: linear-gradient(135deg, var(--accent-soft), var(--bg-color)); - color: var(--primary-color); -} - -.placeholderDate { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - font-family: $font-display; - line-height: 1; -} - -.placeholderMonth { - font-size: 0.75rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.1em; -} - -.placeholderDay { - font-size: 2.25rem; - font-weight: 700; - letter-spacing: -0.02em; - color: var(--primary-text-color); -} - -// Image Overlay - contains floating elements -.imageOverlay { - position: absolute; - top: 8px; - left: 8px; - display: flex; - align-items: center; - gap: 6px; - z-index: 10; -} - -// Share button -.shareButton { - opacity: 0; - transform: scale(0.8); - transition: all $transition-fast; - background: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(10px); - border-radius: $radius-xs; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - padding: 4px; - - @media (max-width: 640px) { - opacity: 1; - transform: scale(1); - } -} - -.shareIcon { - color: var(--secondary-color); - transition: all $transition-fast; - - &:hover { - transform: scale(1.1); - color: var(--primary-text-color); - } -} - -// Live indicator -.liveIndicator { - display: flex; - align-items: center; - gap: 4px; - padding: 4px 8px; - background: #ef4444; - backdrop-filter: blur(10px); - border-radius: $radius-xs; - box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3); -} - -.liveText { - color: white; - font-size: 0.625rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.08em; -} - -.liveDot { - width: 5px; - height: 5px; - background: white; - border-radius: 50%; - animation: pulse 1.5s ease-in-out infinite; -} - -@keyframes pulse { - 0%, 100% { - transform: scale(1); - opacity: 1; - } - 50% { - transform: scale(1.5); - opacity: 0.5; - } -} - -// Content Section -.eventContent { - flex: 1; - display: flex; - flex-direction: column; - gap: 8px; - min-width: 0; - justify-content: center; - - @include mixins.respond-below('sm') { - padding: 0 4px; - } - - @include mixins.respond-above('sm') { - padding-right: 4px; - } -} - -.eventHeader { - display: flex; - flex-direction: column; - gap: 4px; -} - -.eventTitle { - font-family: $font-display; - font-size: 1.05rem; - font-weight: 700; - margin: 0; - color: var(--primary-text-color); - line-height: 1.3; - letter-spacing: -0.01em; - - @include mixins.respond-below('sm') { - font-size: 0.95rem; - } -} - -.eventDate { - color: var(--primary-color); - font-size: 0.8125rem; - font-weight: 600; - line-height: 1.4; -} - -.timezone { - font-weight: 500; - color: var(--secondary-color); -} - -.eventDescription { - font-size: 0.8125rem; - color: var(--secondary-color); - line-height: 1.5; - margin: 0; - overflow: hidden; - text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} - -// Footer section -.eventFooter { - margin-top: auto; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - flex-wrap: wrap; - padding-top: 4px; - - @include mixins.respond-below('sm') { - gap: 8px; - } -} - -.eventMeta { - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; -} - -.metaItem { - display: flex; - align-items: center; - gap: 5px; - color: var(--secondary-color); - font-size: 0.8125rem; - font-weight: 500; - - svg { - color: var(--accent-muted); - flex-shrink: 0; - } -} - -// Price section -.priceSection { - display: flex; - align-items: center; - gap: 5px; - font-family: $font-display; - font-weight: 700; - font-size: 0.85rem; - color: var(--primary-text-color); - padding: 6px 12px; - background: var(--accent-soft); - border-radius: $radius-xs; - white-space: nowrap; - - svg { - color: var(--primary-color); - flex-shrink: 0; - } - - &.priceSoldOut { - background: var(--bg-color); - color: var(--secondary-color); - } -} - -.eventCardEnded { - .imageWrapper { - filter: grayscale(0.6); - opacity: 0.8; - } - - .eventDate { - color: var(--secondary-color); - } -} diff --git a/frontend/src/components/layouts/OrganizerHomepage/EventCard/index.tsx b/frontend/src/components/layouts/OrganizerHomepage/EventCard/index.tsx deleted file mode 100644 index 76abd3d3c8..0000000000 --- a/frontend/src/components/layouts/OrganizerHomepage/EventCard/index.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import React from 'react'; -import {Link} from "react-router"; -import {Event, LocationType} from "../../../../types.ts"; -import classes from './EventCard.module.scss'; -import {formatDateWithLocale} from "../../../../utilites/dates.ts"; -import {t} from "@lingui/macro"; -import {eventHomepagePath, eventHomepageUrl} from "../../../../utilites/urlHelper.ts"; -import {ShareComponent} from "../../../common/ShareIcon"; -import {IconCalendar, IconCalendarRepeat, IconMapPin, IconTicket, IconWifi} from '@tabler/icons-react'; -import {summariseEventLocations} from "../../../../utilites/effectiveLocation.ts"; -import {formatAddress} from "../../../../utilites/addressUtilities.ts"; -import {summariseEventPrice, summariseEventSchedule} from "../../../../utilites/eventListing.ts"; -import {eventPriceLabel, eventScheduleLabel} from "../../../../utilites/eventListingLabels.ts"; - -interface EventCardProps { - event: Event; -} - -export const EventCard: React.FC = ({event}) => { - const schedule = summariseEventSchedule(event); - const price = schedule.hasEnded ? null : summariseEventPrice(event); - const tz = event.timezone; - - const dateLine = eventScheduleLabel(schedule, tz); - const prettyTimezone = schedule.start && !schedule.isSeriesRange - ? formatDateWithLocale(schedule.start, "timezone", tz) - : null; - const moreDatesCount = schedule.additionalDateCount; - - const coverImage = event.images?.find(img => img.type === 'EVENT_COVER'); - const locationSummary = summariseEventLocations(event); - const isOnlineEvent = locationSummary.kind === 'single' && locationSummary.eventLocation.type === LocationType.Online; - const locationLabel: string | null = (() => { - if (locationSummary.kind === 'none') return null; - if (locationSummary.kind === 'varied') { - return locationSummary.types.length > 1 ? t`Online & in-person` : t`Multiple locations`; - } - const eventLocation = locationSummary.eventLocation; - if (eventLocation.type === LocationType.Online) return t`Online`; - const city = eventLocation.location?.structured_address?.city; - const venueName = eventLocation.location?.name || eventLocation.location?.structured_address?.venue_name; - const formatted = eventLocation.location?.structured_address ? formatAddress(eventLocation.location.structured_address) : ''; - return venueName ?? city ?? (formatted ? formatted : null); - })(); - const location = !isOnlineEvent ? locationLabel : null; - - const eventPath = eventHomepagePath(event); - - return ( - -
    -
    -
    - {coverImage ? ( - {event.title} - ) : ( - - )} - -
    - {schedule.isHappeningNow && ( -
    - - {t`Happening now`} -
    - )} -
    e.preventDefault()}> - -
    -
    -
    -
    - -
    -
    -
    - {dateLine} - {prettyTimezone && ( - ({prettyTimezone}) - )} -
    -

    {event.title}

    -
    - - {event.description_preview && ( -

    - {event.description_preview} -

    - )} - -
    -
    - {(location || isOnlineEvent) && ( -
    - {isOnlineEvent ? ( - <>{t`Online Event`} - ) : ( - <>{location} - )} -
    - )} - {moreDatesCount > 0 && ( -
    - - - {moreDatesCount === 1 ? t`+1 more date` : t`+${moreDatesCount} more dates`} - -
    - )} -
    - - {price && ( -
    - {price.kind !== 'soldOut' && } - {eventPriceLabel(price, event.currency)} -
    - )} -
    -
    -
    - - ); -}; diff --git a/frontend/src/components/layouts/OrganizerHomepage/EventList/EventList.module.scss b/frontend/src/components/layouts/OrganizerHomepage/EventList/EventList.module.scss new file mode 100644 index 0000000000..74c4cf8ae0 --- /dev/null +++ b/frontend/src/components/layouts/OrganizerHomepage/EventList/EventList.module.scss @@ -0,0 +1,213 @@ +@use "../../../../styles/mixins.scss"; +@use "../../HomepageFrame/homepage" as hp; + +.list { + container-type: inline-size; + display: flex; + flex-direction: column; + gap: 28px; +} + +.groupLabel { + display: flex; + align-items: center; + gap: 14px; + margin: 0 0 6px; + color: var(--hp-muted); + font-size: 0.8125rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.rows { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0 -14px; + + @include hp.narrow { + margin: 0 -10px; + } +} + +.row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 20px; + padding: 14px; + border-radius: 18px; + color: inherit; + text-decoration: none; + transition: background-color 0.15s hp.$ease; + @include hp.focus-ring; + + &:hover { + background: var(--hp-fill); + + .title { + color: var(--hp-accent-ink); + } + + .artwork { + transform: scale(1.04); + } + } + + &[data-ended] { + .thumb { + filter: grayscale(0.7); + opacity: 0.85; + } + } + + @include hp.narrow { + align-items: start; + gap: 14px; + padding: 12px 10px; + } +} + +.tilePlaceholder { + width: 60px; + height: 60px; + border: 1px dashed var(--hp-border-strong); + border-radius: 17px; +} + +.rowBody { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.date { + color: var(--hp-muted); + font-size: 0.875rem; + font-weight: 500; + line-height: 1.45; +} + +.timezone { + color: var(--hp-muted); + font-weight: 500; +} + +.title { + margin: 0; + color: var(--hp-text); + font-size: 1.125rem; + font-weight: 650; + line-height: 1.3; + letter-spacing: -0.015em; + overflow-wrap: anywhere; + transition: color 0.15s hp.$ease; + + @include hp.narrow { + font-size: 1rem; + } +} + +.meta { + display: flex; + flex-wrap: wrap; + gap: 2px 14px; + min-width: 0; + color: var(--hp-muted); + font-size: 0.875rem; +} + +.metaItem { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 100%; + + svg { + flex-shrink: 0; + width: 15px; + height: 15px; + opacity: 0.8; + } +} + +.metaText { + @include mixins.ellipsis; +} + +.price { + color: var(--hp-text); + font-weight: 650; + + &[data-sold-out] { + color: var(--hp-muted); + font-weight: 500; + } +} + +.liveBadge { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--hp-live-ink); + font-weight: 600; +} + +.liveDot { + flex-shrink: 0; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; +} + +.thumb { + position: relative; + display: block; + width: 160px; + aspect-ratio: 16 / 10; + overflow: hidden; + border-radius: 14px; + background: var(--hp-fill); + + @include hp.narrow { + width: 72px; + aspect-ratio: 1; + border-radius: 12px; + } +} + +.artworkFill { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + filter: blur(18px) saturate(1.2); + transform: scale(1.25); +} + +.artwork { + position: relative; + display: block; + width: 100%; + height: 100%; + object-fit: contain; + transition: transform 0.6s hp.$ease; +} + +.description { + margin: 1px 0 0; + color: var(--hp-muted); + font-size: 0.875rem; + line-height: 1.5; + @include mixins.ellipsis; + + @include hp.narrow { + display: none; + } +} + diff --git a/frontend/src/components/layouts/OrganizerHomepage/EventList/index.tsx b/frontend/src/components/layouts/OrganizerHomepage/EventList/index.tsx new file mode 100644 index 0000000000..387fdfdbeb --- /dev/null +++ b/frontend/src/components/layouts/OrganizerHomepage/EventList/index.tsx @@ -0,0 +1,172 @@ +import {useId} from "react"; +import {Link} from "react-router"; +import {plural, t} from "@lingui/macro"; +import {IconCalendarRepeat, IconMapPin, IconWorld} from "@tabler/icons-react"; +import {Event, Image, LocationType} from "../../../../types.ts"; +import {eventHomepagePath} from "../../../../utilites/urlHelper.ts"; +import {formatDate, formatDateWithLocale, formatMonthHeading} from "../../../../utilites/dates.ts"; +import {formatAddress} from "../../../../utilites/addressUtilities.ts"; +import {summariseEventLocations} from "../../../../utilites/effectiveLocation.ts"; +import {summariseEventPrice, summariseEventSchedule} from "../../../../utilites/eventListing.ts"; +import {eventPriceLabel, eventScheduleLabel, eventTimeLabel} from "../../../../utilites/eventListingLabels.ts"; +import {decodeHtmlEntities} from "../../../../utilites/htmlEntities.ts"; +import {groupConsecutive} from "../../../../utilites/groupConsecutive.ts"; +import {responsiveImage} from "../../../../utilites/imageTransform.ts"; +import {DateTile} from "../../HomepageFrame/DateTile.tsx"; +import classes from "./EventList.module.scss"; + +const MONTH_KEY = 'YYYY-MM'; + +const latestOccurrenceStart = (event: Event): string | null => (event.occurrences ?? []) + .map(occurrence => occurrence.start_date) + .reduce((latest, start) => !latest || start > latest ? start : latest, null); + +const anchorDate = (event: Event, past: boolean, schedule = summariseEventSchedule(event)): string | null => past + ? latestOccurrenceStart(event) ?? event.end_date ?? schedule.start + : schedule.start; + +const listingDetails = (event: Event, past: boolean) => { + const schedule = summariseEventSchedule(event); + const price = schedule.hasEnded ? null : summariseEventPrice(event); + const locations = summariseEventLocations(event); + const isOnline = locations.kind === 'single' && locations.eventLocation.type === LocationType.Online; + const venue = (() => { + if (locations.kind === 'none' || isOnline) { + return null; + } + if (locations.kind === 'varied') { + return locations.types.length > 1 ? t`Online & in-person` : t`Multiple locations`; + } + const address = locations.eventLocation.location?.structured_address; + const venueName = locations.eventLocation.location?.name || address?.venue_name; + return venueName ?? address?.city ?? (address ? formatAddress(address) || null : null); + })(); + + return { + schedule, + price, + isOnline, + venue, + anchorDate: anchorDate(event, past, schedule), + cover: event.images?.find(image => image.type === 'EVENT_COVER'), + dateLine: eventScheduleLabel(schedule, event.timezone), + timeLine: eventTimeLabel(schedule, event.timezone), + description: decodeHtmlEntities(event.description_preview || ''), + timezone: schedule.start && !schedule.isSeriesRange ? formatDateWithLocale(schedule.start, 'timezone', event.timezone) : null, + }; +}; + +type ListingDetails = ReturnType; + +const Artwork = ({image, sizes, lazy}: { image: Image, sizes: string, lazy?: boolean }) => ( + <> + {image.lqip_base64 && } + + +); + +const DateLine = ({event, details, id, compact}: { event: Event, details: ListingDetails, id: string, compact?: boolean }) => ( +
    + {compact && details.timeLine ? details.timeLine : details.dateLine} + {details.timezone && ( + {` (${details.timezone})`} + )} +
    +); + +const Price = ({event, details}: { event: Event, details: ListingDetails }) => details.price ? ( + + {eventPriceLabel(details.price, event.currency)} + +) : null; + +const LiveBadge = () => ( + + +); + +const Meta = ({event, details, withPrice}: { event: Event, details: ListingDetails, withPrice?: boolean }) => { + const moreDatesCount = details.schedule.additionalDateCount; + const showPrice = withPrice && !!details.price; + if (!details.isOnline && !details.venue && moreDatesCount === 0 && !showPrice && !details.schedule.isHappeningNow) { + return null; + } + + return ( +
    + {withPrice && details.schedule.isHappeningNow && } + {details.isOnline && {t`Online Event`}} + {details.venue && {details.venue}} + {moreDatesCount > 0 && ( + + + {plural(moreDatesCount, {one: '+# more date', other: '+# more dates'})} + + )} + {showPrice && } +
    + ); +}; + +type RowTitleTag = 'h3' | 'h4'; + +const EventRow = ({event, past, titleAs: Title}: { event: Event, past: boolean, titleAs: RowTitleTag }) => { + const details = listingDetails(event, past); + const id = useId(); + + return ( + + {details.anchorDate + ? + :