From 57348db72a7c36fbdd09dd18a27e87051e007aa8 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Wed, 21 Oct 2026 07:26:57 +0100 Subject: [PATCH 1/6] Update install instructions --- README.de.md | 2 + README.el.md | 2 + README.es.md | 2 + README.fi.md | 2 + README.fr.md | 2 + README.hu.md | 2 + README.it.md | 2 + README.ja.md | 2 + README.ko.md | 2 + README.md | 2 + README.nl.md | 2 + README.pl.md | 2 + README.pt-br.md | 2 + README.pt.md | 2 + README.sk.md | 2 + README.sv.md | 2 + README.tr.md | 2 + README.vi.md | 2 + README.zh-cn.md | 2 + README.zh-hk.md | 2 + .../DeleteSeptember10SignupWaveCommand.php | 263 ++++++++++++++++++ ...DeleteSeptember10SignupWaveCommandTest.php | 167 +++++++++++ 22 files changed, 470 insertions(+) create mode 100644 backend/app/Console/Commands/DeleteSeptember10SignupWaveCommand.php create mode 100644 backend/tests/Feature/Console/DeleteSeptember10SignupWaveCommandTest.php 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/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', + ]); + } +} From 39420e11348604e0658c71e48e72a5ea18d33a7c Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 22 Oct 2026 10:00:58 +0100 Subject: [PATCH 2/6] Redesign event and organizer pages (#1427) --- .claude/rules/features/homepage-sections.md | 8 +- AGENTS.md | 1 + .../DomainObjects/Enums/HomepageLayout.php | 13 + .../Enums/HomepageSectionType.php | 3 +- .../UpdateEventSettingsRequest.php | 3 + .../DTO/UpdateEventSettingsDTO.php | 2 +- .../Domain/Event/CreateEventService.php | 2 +- .../Validators/Rules/HomepageSectionRules.php | 2 +- backend/lang/de.json | 3 +- backend/lang/el.json | 3 +- backend/lang/es.json | 3 +- backend/lang/fi.json | 3 +- backend/lang/fr.json | 3 +- backend/lang/hu.json | 3 +- backend/lang/it.json | 3 +- backend/lang/ja.json | 3 +- backend/lang/ko.json | 3 +- backend/lang/nl.json | 3 +- backend/lang/pl.json | 3 +- backend/lang/pt-br.json | 3 +- backend/lang/pt.json | 3 +- backend/lang/ru.json | 3 +- backend/lang/se.json | 3 +- backend/lang/sk.json | 3 +- backend/lang/tr.json | 3 +- backend/lang/vi.json | 3 +- backend/lang/zh-cn.json | 3 +- backend/lang/zh-hk.json | 3 +- backend/resources/api-reference/openapi.json | 18 +- .../EventSettings/HomepageLayoutTest.php | 57 + .../HomepageSectionValidationMessagesTest.php | 4 +- .../EventSettings/HomepageSectionsTest.php | 41 + .../OrganizerEventsPublicUpcomingTest.php | 69 + .../OrganizerHomepageSectionsTest.php | 12 + .../Event/HomepageSectionsSanitizerTest.php | 15 + e2e/api/types.ts | 1 + e2e/pages/designer.page.ts | 4 + e2e/pages/public-event.page.ts | 12 + .../checkout/promo-code-inclusive-tax.spec.ts | 2 +- e2e/tests/events/event-layouts.unit.spec.ts | 18 + e2e/tests/events/event-page-layouts.spec.ts | 354 +++++ .../events/event-page-navigation.spec.ts | 62 + e2e/tests/events/image-transform.unit.spec.ts | 26 + e2e/tests/events/more-from-organizer.spec.ts | 60 + e2e/tests/events/past-event-page.spec.ts | 11 +- .../lineup/public-lineup-mixed-photos.spec.ts | 6 +- .../homepage-designer-limits.spec.ts | 6 +- .../management/homepage-designer.spec.ts | 25 +- .../homepage-accent-ink.unit.spec.ts | 31 + .../organizer-event-list.unit.spec.ts | 45 + .../organizer-homepage-layout.unit.spec.ts | 2 +- e2e/tests/ssr/route-stylesheets.unit.spec.ts | 31 + e2e/tests/ssr/ssr-locale-hydration.spec.ts | 48 +- .../widget/embedded-lineup-dialogs.spec.ts | 14 +- e2e/utils/posterArt.ts | 25 + frontend/package.json | 2 +- frontend/server.js | 20 +- frontend/src/App.tsx | 8 +- .../CookieConsentBanner.module.scss | 28 +- .../common/EventDocumentHead/index.tsx | 10 +- .../NumberSelector/NumberSelector.module.scss | 9 +- .../src/components/common/ShareIcon/index.tsx | 26 +- .../components/common/StatusToggle/index.tsx | 4 +- .../common/ThemeColorControls/index.tsx | 4 +- .../forms/ProductForm/ledgerSummaries.ts | 4 +- .../src/components/layouts/Checkout/index.tsx | 2 +- .../layouts/EventHomepage/CheckoutBar.tsx | 49 + .../EventHomepage/EventHomepage.module.scss | 1328 +++++++---------- .../layouts/EventHomepage/EventIntro.tsx | 177 +++ .../EventHomepage/Header/Header.module.scss | 15 - .../layouts/EventHomepage/Header/index.tsx | 26 - .../EventHomepage/Lineup/Lineup.module.scss | 115 +- .../EventHomepage/Lineup/LineupGrid.tsx | 3 +- .../EventHomepage/Lineup/ScheduleTimeline.tsx | 7 +- .../layouts/EventHomepage/Lineup/index.tsx | 5 +- .../layouts/EventHomepage/LocationSection.tsx | 30 + .../EventHomepage/MoreEventsSection.tsx | 40 + .../EventHomepage/OrganizerSection.tsx | 86 ++ .../layouts/EventHomepage/SectionNav.tsx | 165 ++ .../EventHomepage/Sections/GallerySection.tsx | 17 +- .../EventHomepage/Sections/SectionShell.tsx | 3 +- .../Sections/Sections.module.scss | 232 ++- .../Sections/SponsorsSection.tsx | 3 +- .../components/layouts/EventHomepage/hooks.ts | 85 ++ .../layouts/EventHomepage/index.tsx | 877 +++-------- .../layouts/HomepageFrame/CoverMedia.tsx | 29 + .../layouts/HomepageFrame/DatePoster.tsx | 10 + .../layouts/HomepageFrame/DateTile.tsx | 9 + .../HomepageFrame/HomepageFrame.module.scss | 354 +++++ .../layouts/HomepageFrame/_homepage.scss | 206 +++ .../layouts/HomepageFrame/index.tsx | 72 + .../EventCard/EventCard.module.scss | 352 ----- .../OrganizerHomepage/EventCard/index.tsx | 149 -- .../EventList/EventList.module.scss | 372 +++++ .../OrganizerHomepage/EventList/index.tsx | 208 +++ .../OrganizerHomepage.module.scss | 835 +++-------- .../OrganizerPastEventsSection.tsx | 8 +- .../layouts/OrganizerHomepage/index.tsx | 480 +++--- .../modals/CreateEventModal/dateTimeFormat.ts | 6 +- .../modals/JoinWaitlistModal/index.tsx | 2 +- .../LayoutPicker/LayoutPicker.module.scss | 83 ++ .../LayoutPicker/LayoutPreview.tsx | 87 ++ .../HomepageDesigner/LayoutPicker/index.tsx | 77 + .../SectionsEditor/SectionContentEditor.tsx | 9 +- .../HomepageDesigner/SectionsEditor/index.tsx | 8 +- .../SectionsEditor/sectionEmptyState.ts | 1 + .../routes/event/HomepageDesigner/index.tsx | 17 +- .../routes/event/TicketDesigner/index.tsx | 2 +- .../OccurrenceSelector.scss | 2 +- .../OccurrenceSelector/index.tsx | 36 +- .../product-widget/SelectProducts/index.tsx | 24 +- frontend/src/constants/eventLayouts.ts | 25 + frontend/src/entry.client.tsx | 3 +- frontend/src/entry.server.tsx | 4 + frontend/src/locales.ts | 12 + frontend/src/locales/de.js | 2 +- frontend/src/locales/de.po | 426 +++--- frontend/src/locales/el.js | 2 +- frontend/src/locales/el.po | 426 +++--- frontend/src/locales/en.js | 2 +- frontend/src/locales/en.po | 426 +++--- frontend/src/locales/es.js | 2 +- frontend/src/locales/es.po | 426 +++--- frontend/src/locales/fi.js | 2 +- frontend/src/locales/fi.po | 426 +++--- frontend/src/locales/fr.js | 2 +- frontend/src/locales/fr.po | 426 +++--- frontend/src/locales/hu.js | 2 +- frontend/src/locales/hu.po | 426 +++--- frontend/src/locales/it.js | 2 +- frontend/src/locales/it.po | 426 +++--- frontend/src/locales/ja.js | 2 +- frontend/src/locales/ja.po | 426 +++--- frontend/src/locales/ko.js | 2 +- frontend/src/locales/ko.po | 426 +++--- frontend/src/locales/nl.js | 2 +- frontend/src/locales/nl.po | 426 +++--- frontend/src/locales/pl.js | 2 +- frontend/src/locales/pl.po | 426 +++--- frontend/src/locales/pt-br.js | 2 +- frontend/src/locales/pt-br.po | 426 +++--- frontend/src/locales/pt.js | 2 +- frontend/src/locales/pt.po | 426 +++--- frontend/src/locales/ru.js | 2 +- frontend/src/locales/ru.po | 426 +++--- frontend/src/locales/se.js | 2 +- frontend/src/locales/se.po | 426 +++--- frontend/src/locales/sk.js | 2 +- frontend/src/locales/sk.po | 426 +++--- frontend/src/locales/tr.js | 2 +- frontend/src/locales/tr.po | 426 +++--- frontend/src/locales/vi.js | 2 +- frontend/src/locales/vi.po | 426 +++--- frontend/src/locales/zh-cn.js | 2 +- frontend/src/locales/zh-cn.po | 426 +++--- frontend/src/locales/zh-hk.js | 2 +- frontend/src/locales/zh-hk.po | 426 +++--- .../queries/useGetOrganizerEventsPublic.ts | 9 + .../routeLoaders/publicEventRouteLoader.ts | 32 +- frontend/src/router.tsx | 4 + frontend/src/ssr/routeStylesheets.d.ts | 4 + frontend/src/ssr/routeStylesheets.js | 18 + frontend/src/styles/widget/default.scss | 48 +- frontend/src/types.ts | 4 + frontend/src/utilites/dates.ts | 36 +- frontend/src/utilites/effectiveLocation.ts | 4 +- frontend/src/utilites/eventListing.ts | 14 +- frontend/src/utilites/groupConsecutive.ts | 16 + .../src/utilites/homepageSectionLayout.ts | 6 +- frontend/src/utilites/homepageSections.ts | 2 + frontend/src/utilites/htmlEntities.ts | 10 + frontend/src/utilites/imageTransform.ts | 28 + frontend/src/utilites/motion.ts | 4 + frontend/src/utilites/occurrenceDefaults.ts | 43 + frontend/src/utilites/organizerLinks.ts | 27 + frontend/src/utilites/themeUtils.ts | 93 +- frontend/vite.config.ts | 3 + 177 files changed, 10532 insertions(+), 6816 deletions(-) create mode 100644 backend/app/DomainObjects/Enums/HomepageLayout.php create mode 100644 backend/tests/Feature/Http/Actions/EventSettings/HomepageLayoutTest.php create mode 100644 backend/tests/Feature/Http/Actions/Events/OrganizerEventsPublicUpcomingTest.php create mode 100644 e2e/tests/events/event-layouts.unit.spec.ts create mode 100644 e2e/tests/events/event-page-layouts.spec.ts create mode 100644 e2e/tests/events/event-page-navigation.spec.ts create mode 100644 e2e/tests/events/image-transform.unit.spec.ts create mode 100644 e2e/tests/events/more-from-organizer.spec.ts create mode 100644 e2e/tests/organizers/homepage-accent-ink.unit.spec.ts create mode 100644 e2e/tests/organizers/organizer-event-list.unit.spec.ts create mode 100644 e2e/tests/ssr/route-stylesheets.unit.spec.ts create mode 100644 e2e/utils/posterArt.ts create mode 100644 frontend/src/components/layouts/EventHomepage/CheckoutBar.tsx create mode 100644 frontend/src/components/layouts/EventHomepage/EventIntro.tsx delete mode 100644 frontend/src/components/layouts/EventHomepage/Header/Header.module.scss delete mode 100644 frontend/src/components/layouts/EventHomepage/Header/index.tsx create mode 100644 frontend/src/components/layouts/EventHomepage/LocationSection.tsx create mode 100644 frontend/src/components/layouts/EventHomepage/MoreEventsSection.tsx create mode 100644 frontend/src/components/layouts/EventHomepage/OrganizerSection.tsx create mode 100644 frontend/src/components/layouts/EventHomepage/SectionNav.tsx create mode 100644 frontend/src/components/layouts/EventHomepage/hooks.ts create mode 100644 frontend/src/components/layouts/HomepageFrame/CoverMedia.tsx create mode 100644 frontend/src/components/layouts/HomepageFrame/DatePoster.tsx create mode 100644 frontend/src/components/layouts/HomepageFrame/DateTile.tsx create mode 100644 frontend/src/components/layouts/HomepageFrame/HomepageFrame.module.scss create mode 100644 frontend/src/components/layouts/HomepageFrame/_homepage.scss create mode 100644 frontend/src/components/layouts/HomepageFrame/index.tsx delete mode 100644 frontend/src/components/layouts/OrganizerHomepage/EventCard/EventCard.module.scss delete mode 100644 frontend/src/components/layouts/OrganizerHomepage/EventCard/index.tsx create mode 100644 frontend/src/components/layouts/OrganizerHomepage/EventList/EventList.module.scss create mode 100644 frontend/src/components/layouts/OrganizerHomepage/EventList/index.tsx create mode 100644 frontend/src/components/routes/event/HomepageDesigner/LayoutPicker/LayoutPicker.module.scss create mode 100644 frontend/src/components/routes/event/HomepageDesigner/LayoutPicker/LayoutPreview.tsx create mode 100644 frontend/src/components/routes/event/HomepageDesigner/LayoutPicker/index.tsx create mode 100644 frontend/src/constants/eventLayouts.ts create mode 100644 frontend/src/ssr/routeStylesheets.d.ts create mode 100644 frontend/src/ssr/routeStylesheets.js create mode 100644 frontend/src/utilites/groupConsecutive.ts create mode 100644 frontend/src/utilites/htmlEntities.ts create mode 100644 frontend/src/utilites/imageTransform.ts create mode 100644 frontend/src/utilites/motion.ts create mode 100644 frontend/src/utilites/occurrenceDefaults.ts create mode 100644 frontend/src/utilites/organizerLinks.ts diff --git a/.claude/rules/features/homepage-sections.md b/.claude/rules/features/homepage-sections.md index 78c63b910a..8534f2762e 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 shows venue name and city when the Location section is on (the section carries the full address and directions), and the full address with directions when it is off. 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/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 @@ + ['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/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/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/lang/de.json b/backend/lang/de.json index 3733d9318e..6f986cecdb 100644 --- a/backend/lang/de.json +++ b/backend/lang/de.json @@ -1198,5 +1198,6 @@ "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." } diff --git a/backend/lang/el.json b/backend/lang/el.json index e172e7a912..a28ba8b156 100644 --- a/backend/lang/el.json +++ b/backend/lang/el.json @@ -1198,5 +1198,6 @@ "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.": "Η επιλεγμένη διάταξη δεν υποστηρίζεται." } diff --git a/backend/lang/es.json b/backend/lang/es.json index 8f32ef091b..6b66657858 100644 --- a/backend/lang/es.json +++ b/backend/lang/es.json @@ -1238,5 +1238,6 @@ "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." } diff --git a/backend/lang/fi.json b/backend/lang/fi.json index 8b74684e90..38fd9dbdca 100644 --- a/backend/lang/fi.json +++ b/backend/lang/fi.json @@ -1386,5 +1386,6 @@ "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." } diff --git a/backend/lang/fr.json b/backend/lang/fr.json index f470eec9b9..0903bdc37d 100644 --- a/backend/lang/fr.json +++ b/backend/lang/fr.json @@ -1198,5 +1198,6 @@ "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." } diff --git a/backend/lang/hu.json b/backend/lang/hu.json index 4c1b8b62d9..2b4a152ba1 100644 --- a/backend/lang/hu.json +++ b/backend/lang/hu.json @@ -1203,5 +1203,6 @@ "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." } diff --git a/backend/lang/it.json b/backend/lang/it.json index 8f0df695cf..759a1abb0c 100644 --- a/backend/lang/it.json +++ b/backend/lang/it.json @@ -1199,5 +1199,6 @@ "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." } diff --git a/backend/lang/ja.json b/backend/lang/ja.json index bc8a2a8f22..66997a88f3 100644 --- a/backend/lang/ja.json +++ b/backend/lang/ja.json @@ -1386,5 +1386,6 @@ "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.": "選択されたレイアウトはサポートされていません。" } diff --git a/backend/lang/ko.json b/backend/lang/ko.json index 9265b57fa6..fa3a3a46de 100644 --- a/backend/lang/ko.json +++ b/backend/lang/ko.json @@ -1387,5 +1387,6 @@ "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.": "선택한 레이아웃은 지원되지 않습니다." } diff --git a/backend/lang/nl.json b/backend/lang/nl.json index c703836f21..69f1a4f8c1 100644 --- a/backend/lang/nl.json +++ b/backend/lang/nl.json @@ -1198,5 +1198,6 @@ "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." } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index 41284c87f3..be91f03af8 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -1386,5 +1386,6 @@ "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." } diff --git a/backend/lang/pt-br.json b/backend/lang/pt-br.json index f9de226c53..2fcd637ebc 100644 --- a/backend/lang/pt-br.json +++ b/backend/lang/pt-br.json @@ -1198,5 +1198,6 @@ "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." } diff --git a/backend/lang/pt.json b/backend/lang/pt.json index 73d702ee02..564d295c49 100644 --- a/backend/lang/pt.json +++ b/backend/lang/pt.json @@ -1198,5 +1198,6 @@ "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." } diff --git a/backend/lang/ru.json b/backend/lang/ru.json index 3de70621db..df5dda5ea3 100644 --- a/backend/lang/ru.json +++ b/backend/lang/ru.json @@ -1140,5 +1140,6 @@ "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.": "Выбранный макет не поддерживается." } diff --git a/backend/lang/se.json b/backend/lang/se.json index 883a3a6b80..e4a5cd7a4a 100644 --- a/backend/lang/se.json +++ b/backend/lang/se.json @@ -1198,5 +1198,6 @@ "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." } diff --git a/backend/lang/sk.json b/backend/lang/sk.json index 4f6b95d329..fd7defcc6d 100644 --- a/backend/lang/sk.json +++ b/backend/lang/sk.json @@ -1198,5 +1198,6 @@ "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é." } diff --git a/backend/lang/tr.json b/backend/lang/tr.json index 7a7f5af416..2b7ee34572 100644 --- a/backend/lang/tr.json +++ b/backend/lang/tr.json @@ -1213,5 +1213,6 @@ "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." } diff --git a/backend/lang/vi.json b/backend/lang/vi.json index 70de21b627..7539fc76e1 100644 --- a/backend/lang/vi.json +++ b/backend/lang/vi.json @@ -1198,5 +1198,6 @@ "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ợ." } diff --git a/backend/lang/zh-cn.json b/backend/lang/zh-cn.json index 6f1ab1982d..6449999c62 100644 --- a/backend/lang/zh-cn.json +++ b/backend/lang/zh-cn.json @@ -1198,5 +1198,6 @@ "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.": "不支持所选布局。" } diff --git a/backend/lang/zh-hk.json b/backend/lang/zh-hk.json index ff90c70286..6da084da0d 100644 --- a/backend/lang/zh-hk.json +++ b/backend/lang/zh-hk.json @@ -1198,5 +1198,6 @@ "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.": "不支援所選版面。" } diff --git a/backend/resources/api-reference/openapi.json b/backend/resources/api-reference/openapi.json index 5bee706ed1..1a9063c2e7 100644 --- a/backend/resources/api-reference/openapi.json +++ b/backend/resources/api-reference/openapi.json @@ -33485,7 +33485,7 @@ "type" ] }, - "maxItems": 40 + "maxItems": 41 }, "seo_keywords": { "type": [ @@ -36350,6 +36350,19 @@ "Lora", null ] + }, + "layout": { + "type": [ + "string", + "null" + ], + "enum": [ + "MARQUEE", + "FLYER", + "STORY", + "INVITE", + null + ] } } }, @@ -36375,6 +36388,7 @@ "SCHEDULE", "LOCATION", "ORGANIZER", + "MORE_EVENTS", "TEXT", "GALLERY", "FAQ", @@ -36482,7 +36496,7 @@ "type" ] }, - "maxItems": 40 + "maxItems": 41 }, "allow_attendee_self_edit": { "type": "boolean", 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/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/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..a76ccbfb90 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; 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/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/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..0b82ed59bd --- /dev/null +++ b/e2e/tests/events/event-page-layouts.spec.ts @@ -0,0 +1,354 @@ +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)); + + 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 - box(document.querySelector('main footer')!.lastElementChild)!.bottom), + 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..7cf523a756 --- /dev/null +++ b/e2e/tests/events/event-page-navigation.spec.ts @@ -0,0 +1,62 @@ +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_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/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..77ae073801 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('marquee')).toHaveAttribute('aria-checked', 'true'); + await expect(authedPage.getByText('Suits conferences and long lineups', { 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/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/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/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/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..1572ce76a7 100644 --- a/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss +++ b/frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss @@ -1,1022 +1,832 @@ @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; +.body { + --section-bleed: 20px; + position: relative; + padding: 24px 20px 8px; - &::after { - content: ''; - position: absolute; - inset: 0; - background: var(--background-overlay); + &[data-has-cover] { + @include hp.lip; } -} - -.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; + @include hp.tablet-up { + --section-bleed: 40px; + padding: 36px 40px 16px; } - // 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.wide { + --section-bleed: 24px; + padding: 44px var(--hp-content-pad, 48px) 24px; } -} -// Main page wrapper -.pageWrapper { - min-height: 100vh; - --background-overlay: #{rgba(white, 0.5)}; + &[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); - &[data-mode="dark"] { - --background-overlay: #{rgba(black, 0.6)}; - } -} - -.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; - - @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: 14px; + 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: 16px; + --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; + } + } + + .body[data-rail] & { + @include hp.wide { + padding: 20px; + border: 1px solid var(--hp-border); + border-radius: 24px; + 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: 18px; } -.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: 18px; +} - :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: 14px; + 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..85513717ad --- /dev/null +++ b/frontend/src/components/layouts/EventHomepage/EventIntro.tsx @@ -0,0 +1,177 @@ +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; + locationDetailsBelow: boolean; + 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, + locationDetailsBelow, + 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={locationDetailsBelow ? location.venueName || location.short || location.full : location.venueName || location.full} + detail={locationDetailsBelow + ? (location.venueName ? location.city : null) + : (location.venueName ? location.full : null)} + extra={!locationDetailsBelow && 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..f02005082d 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,106 @@ 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 = ( + section.type === 'LOCATION')} + variedLocationLabel={variedLocationLabel} + /> + ); + 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-has-cover={!layout.poster && cover ? '' : undefined} + 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..efbc924ce6 --- /dev/null +++ b/frontend/src/components/layouts/HomepageFrame/HomepageFrame.module.scss @@ -0,0 +1,354 @@ +@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.04), 0 12px 32px -12px rgba(15, 15, 20, 0.16); + --hp-shadow-lg: 0 1px 2px rgba(15, 15, 20, 0.03), 0 24px 64px -24px rgba(15, 15, 20, 0.22); + --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.mobile { + box-sizing: content-box; + padding-bottom: hp.$lip; + } + + @include hp.tablet-up { + width: calc(100% - 20px); + margin: 10px 10px 0; + border-radius: hp.$sheet-radius - 10px; + + &[data-sized] { + max-height: min(480px, 56vh); + } + } + + @include hp.wide { + width: calc(100% - 24px); + margin: 12px 12px 0; + border-radius: hp.$sheet-radius - 12px; + } + + &[data-variant="banner"][data-sized] { + max-height: min(36svh, 280px); + + @include hp.tablet-up { + max-height: min(320px, 40vh); + } + } + + &[data-variant="poster"] { + width: 100%; + margin: 0; + padding-bottom: 0; + box-sizing: border-box; + 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 { + 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..53c6861812 --- /dev/null +++ b/frontend/src/components/layouts/HomepageFrame/_homepage.scss @@ -0,0 +1,206 @@ +@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: 32px; +$lip: 22px; + +@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 lip { + @include mobile { + position: relative; + z-index: 1; + margin-top: -$lip; + border-radius: $lip $lip 0 0; + background: var(--hp-surface); + } +} + +@mixin section-title { + margin: 0; + font-family: $font; + font-size: clamp(1.125rem, 1.05rem + 0.3vw, 1.3125rem); + font-weight: 700; + line-height: 1.25; + letter-spacing: -0.015em; + 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: var(--hp-muted); + font-size: 1.0625rem; + line-height: 1.7; + 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..acf6ee5cd8 --- /dev/null +++ b/frontend/src/components/layouts/OrganizerHomepage/EventList/EventList.module.scss @@ -0,0 +1,372 @@ +@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: 176px; + aspect-ratio: 16 / 10; + overflow: hidden; + border-radius: 14px; + background: var(--hp-fill); + + @include hp.narrow { + width: 72px; + aspect-ratio: 1; + border-radius: 12px; + } +} + +.spotlight { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); + gap: 8px; + margin-bottom: 36px; + padding: 10px; + border: 1px solid var(--hp-border); + border-radius: 28px; + background: var(--hp-fill); + color: inherit; + text-decoration: none; + transition: border-color 0.2s hp.$ease, box-shadow 0.2s hp.$ease; + @include hp.focus-ring; + + &:hover { + border-color: var(--hp-border-strong); + box-shadow: var(--hp-shadow-md); + + .artwork { + transform: scale(1.03); + } + + .cta svg { + transform: translateX(3px); + } + } + + @include hp.below-wide { + grid-template-columns: minmax(0, 1fr); + } + + @include hp.mobile { + margin-bottom: 24px; + border-radius: 24px; + } +} + +.spotlightMedia { + position: relative; + display: block; + aspect-ratio: 16 / 10; + overflow: hidden; + border-radius: 20px; + background: var(--hp-fill-strong); + + @include hp.mobile { + border-radius: 16px; + } +} + +.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; +} + +.spotlightArt { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: + radial-gradient(90% 80% at 15% 10%, color-mix(in srgb, var(--hp-accent) 45%, #fff), transparent 60%), + radial-gradient(70% 70% at 90% 100%, color-mix(in srgb, var(--hp-accent) 70%, #000), transparent 70%), + var(--hp-accent); + color: var(--hp-on-accent); + + span { + font-size: 1rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + opacity: 0.85; + } + + strong { + font-size: clamp(4rem, 9vw, 6.5rem); + font-weight: 700; + line-height: 1; + letter-spacing: -0.05em; + } +} + +.spotlightBody { + display: flex; + flex-direction: column; + justify-content: center; + gap: 8px; + min-width: 0; + padding: 24px 28px; + + @include hp.mobile { + padding: 16px 12px 12px; + } +} + +.spotlightTop { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.spotlightLabel { + display: inline-flex; + align-items: center; + height: 26px; + padding: 0 10px; + border-radius: 999px; + background: var(--hp-surface); + color: var(--hp-text); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + box-shadow: var(--hp-shadow-sm); +} + +.spotlightTitle { + margin: 0; + color: var(--hp-text); + font-size: clamp(1.5rem, 1.1rem + 1.3vw, 2.125rem); + font-weight: 700; + line-height: 1.1; + letter-spacing: -0.03em; + text-wrap: balance; + overflow-wrap: anywhere; +} + +.description { + display: -webkit-box; + margin: 0; + overflow: hidden; + color: var(--hp-muted); + font-size: 0.9375rem; + line-height: 1.6; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.spotlightFooter { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 14px; +} + +.cta { + display: inline-flex; + align-items: center; + gap: 8px; + height: 44px; + margin-left: auto; + padding: 0 20px; + border-radius: 999px; + background: var(--hp-accent); + color: var(--hp-on-accent); + font-size: 0.9375rem; + font-weight: 700; + white-space: nowrap; + + svg { + width: 18px; + height: 18px; + transition: transform 0.2s hp.$ease; + } + + @include hp.mobile { + width: 100%; + justify-content: center; + } +} 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..6e6ca19bc7 --- /dev/null +++ b/frontend/src/components/layouts/OrganizerHomepage/EventList/index.tsx @@ -0,0 +1,208 @@ +import {useId} from "react"; +import {Link} from "react-router"; +import {plural, t} from "@lingui/macro"; +import {IconArrowRight, 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} 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), + 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}: { event: Event, details: ListingDetails, id: string }) => ( +
    + {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 + ? + :