From 4f3e058e9b4e3acc6c93a44b5f731a7d32abfad0 Mon Sep 17 00:00:00 2001 From: Dave Earley Date: Wed, 29 Jul 2026 21:19:25 +0100 Subject: [PATCH] Feature: Announcement management --- .gitignore | 1 + .../AnnouncementDomainObject.php | 7 + .../AnnouncementUserDomainObject.php | 7 + .../Enums/AnnouncementDisplayType.php | 11 + .../Enums/AnnouncementTargetType.php | 12 + .../AnnouncementDomainObjectAbstract.php | 216 ++++++++ .../AnnouncementUserDomainObjectAbstract.php | 118 ++++ .../Status/AnnouncementStatus.php | 13 + .../AnnouncementNotFoundException.php | 7 + .../CreateAnnouncementAction.php | 45 ++ .../DeleteAnnouncementAction.php | 31 ++ .../GetAllAnnouncementsAction.php | 35 ++ .../UpdateAnnouncementAction.php | 49 ++ .../DismissAnnouncementAction.php | 37 ++ .../GetActiveAnnouncementsAction.php | 43 ++ .../UpsertAnnouncementRequest.php | 38 ++ backend/app/Models/Announcement.php | 41 ++ backend/app/Models/AnnouncementUser.php | 26 + .../Providers/RepositoryServiceProvider.php | 6 + .../Repository/Eloquent/AccountRepository.php | 6 +- .../Eloquent/AnnouncementRepository.php | 73 +++ .../Eloquent/AnnouncementUserRepository.php | 61 ++ .../AnnouncementRepositoryInterface.php | 20 + .../AnnouncementUserRepositoryInterface.php | 15 + .../AdminAnnouncementResource.php | 53 ++ .../Announcement/AnnouncementResource.php | 26 + .../AnnouncementPayloadNormaliser.php | 40 ++ .../CreateAnnouncementHandler.php | 20 + .../DTO/DismissAnnouncementDTO.php | 13 + .../DTO/GetActiveAnnouncementsDTO.php | 13 + .../DTO/GetAllAnnouncementsDTO.php | 13 + .../DTO/UpsertAnnouncementDTO.php | 22 + .../DeleteAnnouncementHandler.php | 25 + .../DismissAnnouncementHandler.php | 34 ++ .../GetActiveAnnouncementsHandler.php | 40 ++ .../GetAllAnnouncementsHandler.php | 83 +++ .../UpdateAnnouncementHandler.php | 31 ++ ...7_29_000000_create_announcements_table.php | 35 ++ ...000001_create_announcement_users_table.php | 27 + backend/lang/de.json | 5 +- backend/lang/el.json | 5 +- backend/lang/es.json | 3 +- backend/lang/fr.json | 5 +- backend/lang/hu.json | 5 +- backend/lang/it.json | 5 +- backend/lang/nl.json | 5 +- backend/lang/pl.json | 3 +- backend/lang/pt-br.json | 5 +- backend/lang/pt.json | 5 +- backend/lang/ru.json | 5 +- backend/lang/se.json | 3 +- backend/lang/sk.json | 5 +- backend/lang/tr.json | 5 +- backend/lang/vi.json | 5 +- backend/lang/zh-cn.json | 5 +- backend/lang/zh-hk.json | 5 +- backend/routes/api.php | 16 + .../CreateAnnouncementHandlerTest.php | 129 +++++ .../DeleteAnnouncementHandlerTest.php | 65 +++ .../DismissAnnouncementHandlerTest.php | 75 +++ .../GetActiveAnnouncementsHandlerTest.php | 87 +++ .../GetAllAnnouncementsHandlerTest.php | 109 ++++ .../UpdateAnnouncementHandlerTest.php | 98 ++++ e2e/api/api-client.ts | 21 + e2e/tests/admin/announcements.spec.ts | 96 ++++ frontend/src/api/announcement.client.ts | 85 +++ .../AnnouncementBanner.tsx | 44 ++ .../AnnouncementDisplay.module.scss | 127 +++++ .../AnnouncementDisplay/AnnouncementModal.tsx | 58 ++ .../common/AnnouncementDisplay/index.tsx | 65 +++ .../src/components/layouts/Admin/index.tsx | 3 +- .../components/layouts/AppLayout/index.tsx | 2 + .../layouts/DefaultLayout/index.tsx | 2 + .../AnnouncementTargetPicker.tsx | 127 +++++ .../Announcements/Announcements.module.scss | 34 ++ .../routes/admin/Announcements/index.tsx | 519 ++++++++++++++++++ frontend/src/locales/de.js | 2 +- frontend/src/locales/de.po | 257 ++++++++- frontend/src/locales/el.js | 2 +- frontend/src/locales/el.po | 257 ++++++++- frontend/src/locales/en.js | 2 +- frontend/src/locales/en.po | 255 ++++++++- frontend/src/locales/es.js | 2 +- frontend/src/locales/es.po | 257 ++++++++- frontend/src/locales/fr.js | 2 +- frontend/src/locales/fr.po | 257 ++++++++- frontend/src/locales/hu.js | 2 +- frontend/src/locales/hu.po | 257 ++++++++- frontend/src/locales/it.js | 2 +- frontend/src/locales/it.po | 257 ++++++++- frontend/src/locales/nl.js | 2 +- frontend/src/locales/nl.po | 257 ++++++++- frontend/src/locales/pl.js | 2 +- frontend/src/locales/pl.po | 257 ++++++++- frontend/src/locales/pt-br.js | 2 +- frontend/src/locales/pt-br.po | 257 ++++++++- frontend/src/locales/pt.js | 2 +- frontend/src/locales/pt.po | 257 ++++++++- frontend/src/locales/ru.js | 2 +- frontend/src/locales/ru.po | 255 ++++++++- frontend/src/locales/se.js | 2 +- frontend/src/locales/se.po | 257 ++++++++- frontend/src/locales/sk.js | 2 +- frontend/src/locales/sk.po | 257 ++++++++- frontend/src/locales/tr.js | 2 +- frontend/src/locales/tr.po | 257 ++++++++- frontend/src/locales/vi.js | 2 +- frontend/src/locales/vi.po | 257 ++++++++- frontend/src/locales/zh-cn.js | 2 +- frontend/src/locales/zh-cn.po | 257 ++++++++- frontend/src/locales/zh-hk.js | 2 +- frontend/src/locales/zh-hk.po | 257 ++++++++- .../src/mutations/useCreateAnnouncement.ts | 14 + .../src/mutations/useDeleteAnnouncement.ts | 15 + .../src/mutations/useDismissAnnouncement.ts | 22 + .../src/mutations/useUpdateAnnouncement.ts | 16 + .../src/queries/useGetActiveAnnouncements.ts | 21 + .../src/queries/useGetAdminAnnouncements.ts | 11 + frontend/src/router.tsx | 7 + 119 files changed, 7839 insertions(+), 159 deletions(-) create mode 100644 backend/app/DomainObjects/AnnouncementDomainObject.php create mode 100644 backend/app/DomainObjects/AnnouncementUserDomainObject.php create mode 100644 backend/app/DomainObjects/Enums/AnnouncementDisplayType.php create mode 100644 backend/app/DomainObjects/Enums/AnnouncementTargetType.php create mode 100644 backend/app/DomainObjects/Generated/AnnouncementDomainObjectAbstract.php create mode 100644 backend/app/DomainObjects/Generated/AnnouncementUserDomainObjectAbstract.php create mode 100644 backend/app/DomainObjects/Status/AnnouncementStatus.php create mode 100644 backend/app/Exceptions/AnnouncementNotFoundException.php create mode 100644 backend/app/Http/Actions/Admin/Announcements/CreateAnnouncementAction.php create mode 100644 backend/app/Http/Actions/Admin/Announcements/DeleteAnnouncementAction.php create mode 100644 backend/app/Http/Actions/Admin/Announcements/GetAllAnnouncementsAction.php create mode 100644 backend/app/Http/Actions/Admin/Announcements/UpdateAnnouncementAction.php create mode 100644 backend/app/Http/Actions/Announcements/DismissAnnouncementAction.php create mode 100644 backend/app/Http/Actions/Announcements/GetActiveAnnouncementsAction.php create mode 100644 backend/app/Http/Request/Announcement/UpsertAnnouncementRequest.php create mode 100644 backend/app/Models/Announcement.php create mode 100644 backend/app/Models/AnnouncementUser.php create mode 100644 backend/app/Repository/Eloquent/AnnouncementRepository.php create mode 100644 backend/app/Repository/Eloquent/AnnouncementUserRepository.php create mode 100644 backend/app/Repository/Interfaces/AnnouncementRepositoryInterface.php create mode 100644 backend/app/Repository/Interfaces/AnnouncementUserRepositoryInterface.php create mode 100644 backend/app/Resources/Announcement/AdminAnnouncementResource.php create mode 100644 backend/app/Resources/Announcement/AnnouncementResource.php create mode 100644 backend/app/Services/Application/Handlers/Announcement/AnnouncementPayloadNormaliser.php create mode 100644 backend/app/Services/Application/Handlers/Announcement/CreateAnnouncementHandler.php create mode 100644 backend/app/Services/Application/Handlers/Announcement/DTO/DismissAnnouncementDTO.php create mode 100644 backend/app/Services/Application/Handlers/Announcement/DTO/GetActiveAnnouncementsDTO.php create mode 100644 backend/app/Services/Application/Handlers/Announcement/DTO/GetAllAnnouncementsDTO.php create mode 100644 backend/app/Services/Application/Handlers/Announcement/DTO/UpsertAnnouncementDTO.php create mode 100644 backend/app/Services/Application/Handlers/Announcement/DeleteAnnouncementHandler.php create mode 100644 backend/app/Services/Application/Handlers/Announcement/DismissAnnouncementHandler.php create mode 100644 backend/app/Services/Application/Handlers/Announcement/GetActiveAnnouncementsHandler.php create mode 100644 backend/app/Services/Application/Handlers/Announcement/GetAllAnnouncementsHandler.php create mode 100644 backend/app/Services/Application/Handlers/Announcement/UpdateAnnouncementHandler.php create mode 100644 backend/database/migrations/2026_07_29_000000_create_announcements_table.php create mode 100644 backend/database/migrations/2026_07_29_000001_create_announcement_users_table.php create mode 100644 backend/tests/Unit/Services/Application/Handlers/Announcement/CreateAnnouncementHandlerTest.php create mode 100644 backend/tests/Unit/Services/Application/Handlers/Announcement/DeleteAnnouncementHandlerTest.php create mode 100644 backend/tests/Unit/Services/Application/Handlers/Announcement/DismissAnnouncementHandlerTest.php create mode 100644 backend/tests/Unit/Services/Application/Handlers/Announcement/GetActiveAnnouncementsHandlerTest.php create mode 100644 backend/tests/Unit/Services/Application/Handlers/Announcement/GetAllAnnouncementsHandlerTest.php create mode 100644 backend/tests/Unit/Services/Application/Handlers/Announcement/UpdateAnnouncementHandlerTest.php create mode 100644 e2e/tests/admin/announcements.spec.ts create mode 100644 frontend/src/api/announcement.client.ts create mode 100644 frontend/src/components/common/AnnouncementDisplay/AnnouncementBanner.tsx create mode 100644 frontend/src/components/common/AnnouncementDisplay/AnnouncementDisplay.module.scss create mode 100644 frontend/src/components/common/AnnouncementDisplay/AnnouncementModal.tsx create mode 100644 frontend/src/components/common/AnnouncementDisplay/index.tsx create mode 100644 frontend/src/components/routes/admin/Announcements/AnnouncementTargetPicker.tsx create mode 100644 frontend/src/components/routes/admin/Announcements/Announcements.module.scss create mode 100644 frontend/src/components/routes/admin/Announcements/index.tsx create mode 100644 frontend/src/mutations/useCreateAnnouncement.ts create mode 100644 frontend/src/mutations/useDeleteAnnouncement.ts create mode 100644 frontend/src/mutations/useDismissAnnouncement.ts create mode 100644 frontend/src/mutations/useUpdateAnnouncement.ts create mode 100644 frontend/src/queries/useGetActiveAnnouncements.ts create mode 100644 frontend/src/queries/useGetAdminAnnouncements.ts diff --git a/.gitignore b/.gitignore index 0ed72ecbb1..dde09eda35 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ prompts/ .claude/worktrees/ .claude/scheduled_tasks.lock tmp_translate/ +test_results/ diff --git a/backend/app/DomainObjects/AnnouncementDomainObject.php b/backend/app/DomainObjects/AnnouncementDomainObject.php new file mode 100644 index 0000000000..7b9f612bd4 --- /dev/null +++ b/backend/app/DomainObjects/AnnouncementDomainObject.php @@ -0,0 +1,7 @@ + $this->id ?? null, + 'title' => $this->title ?? null, + 'content' => $this->content ?? null, + 'status' => $this->status ?? null, + 'display_type' => $this->display_type ?? null, + 'emoji' => $this->emoji ?? null, + 'target_type' => $this->target_type ?? null, + 'target_account_ids' => $this->target_account_ids ?? null, + 'target_user_ids' => $this->target_user_ids ?? null, + 'cta_label' => $this->cta_label ?? null, + 'cta_url' => $this->cta_url ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + 'deleted_at' => $this->deleted_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setTitle(string $title): self + { + $this->title = $title; + return $this; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setContent(string $content): self + { + $this->content = $content; + return $this; + } + + public function getContent(): string + { + return $this->content; + } + + public function setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setDisplayType(string $display_type): self + { + $this->display_type = $display_type; + return $this; + } + + public function getDisplayType(): string + { + return $this->display_type; + } + + public function setEmoji(?string $emoji): self + { + $this->emoji = $emoji; + return $this; + } + + public function getEmoji(): ?string + { + return $this->emoji; + } + + public function setTargetType(string $target_type): self + { + $this->target_type = $target_type; + return $this; + } + + public function getTargetType(): string + { + return $this->target_type; + } + + public function setTargetAccountIds(array|string|null $target_account_ids): self + { + $this->target_account_ids = $target_account_ids; + return $this; + } + + public function getTargetAccountIds(): array|string|null + { + return $this->target_account_ids; + } + + public function setTargetUserIds(array|string|null $target_user_ids): self + { + $this->target_user_ids = $target_user_ids; + return $this; + } + + public function getTargetUserIds(): array|string|null + { + return $this->target_user_ids; + } + + public function setCtaLabel(?string $cta_label): self + { + $this->cta_label = $cta_label; + return $this; + } + + public function getCtaLabel(): ?string + { + return $this->cta_label; + } + + public function setCtaUrl(?string $cta_url): self + { + $this->cta_url = $cta_url; + return $this; + } + + public function getCtaUrl(): ?string + { + return $this->cta_url; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } +} diff --git a/backend/app/DomainObjects/Generated/AnnouncementUserDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AnnouncementUserDomainObjectAbstract.php new file mode 100644 index 0000000000..9f69d10f8f --- /dev/null +++ b/backend/app/DomainObjects/Generated/AnnouncementUserDomainObjectAbstract.php @@ -0,0 +1,118 @@ + $this->id ?? null, + 'announcement_id' => $this->announcement_id ?? null, + 'user_id' => $this->user_id ?? null, + 'first_seen_at' => $this->first_seen_at ?? null, + 'dismissed_at' => $this->dismissed_at ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setAnnouncementId(int $announcement_id): self + { + $this->announcement_id = $announcement_id; + return $this; + } + + public function getAnnouncementId(): int + { + return $this->announcement_id; + } + + public function setUserId(int $user_id): self + { + $this->user_id = $user_id; + return $this; + } + + public function getUserId(): int + { + return $this->user_id; + } + + public function setFirstSeenAt(string $first_seen_at): self + { + $this->first_seen_at = $first_seen_at; + return $this; + } + + public function getFirstSeenAt(): string + { + return $this->first_seen_at; + } + + public function setDismissedAt(?string $dismissed_at): self + { + $this->dismissed_at = $dismissed_at; + return $this; + } + + public function getDismissedAt(): ?string + { + return $this->dismissed_at; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } +} diff --git a/backend/app/DomainObjects/Status/AnnouncementStatus.php b/backend/app/DomainObjects/Status/AnnouncementStatus.php new file mode 100644 index 0000000000..488810e9c2 --- /dev/null +++ b/backend/app/DomainObjects/Status/AnnouncementStatus.php @@ -0,0 +1,13 @@ +minimumAllowedRole(Role::SUPERADMIN); + + $announcement = $this->handler->handle(new UpsertAnnouncementDTO( + title: $request->validated('title'), + content: $request->validated('content'), + status: $request->validated('status'), + displayType: $request->validated('display_type'), + targetType: $request->validated('target_type'), + emoji: $request->validated('emoji'), + targetAccountIds: $request->validated('target_account_ids'), + targetUserIds: $request->validated('target_user_ids'), + ctaLabel: $request->validated('cta_label'), + ctaUrl: $request->validated('cta_url'), + )); + + return $this->resourceResponse( + resource: AdminAnnouncementResource::class, + data: $announcement, + statusCode: Response::HTTP_CREATED, + ); + } +} diff --git a/backend/app/Http/Actions/Admin/Announcements/DeleteAnnouncementAction.php b/backend/app/Http/Actions/Admin/Announcements/DeleteAnnouncementAction.php new file mode 100644 index 0000000000..d7ac4def2b --- /dev/null +++ b/backend/app/Http/Actions/Admin/Announcements/DeleteAnnouncementAction.php @@ -0,0 +1,31 @@ +minimumAllowedRole(Role::SUPERADMIN); + + try { + $this->handler->handle($announcementId); + } catch (AnnouncementNotFoundException) { + return $this->notFoundResponse(); + } + + return $this->deletedResponse(); + } +} diff --git a/backend/app/Http/Actions/Admin/Announcements/GetAllAnnouncementsAction.php b/backend/app/Http/Actions/Admin/Announcements/GetAllAnnouncementsAction.php new file mode 100644 index 0000000000..56bfdce157 --- /dev/null +++ b/backend/app/Http/Actions/Admin/Announcements/GetAllAnnouncementsAction.php @@ -0,0 +1,35 @@ +minimumAllowedRole(Role::SUPERADMIN); + + $announcements = $this->handler->handle(new GetAllAnnouncementsDTO( + perPage: min((int) $request->query('per_page', 20), 100), + search: $request->query('search'), + )); + + return $this->resourceResponse( + resource: AdminAnnouncementResource::class, + data: $announcements, + ); + } +} diff --git a/backend/app/Http/Actions/Admin/Announcements/UpdateAnnouncementAction.php b/backend/app/Http/Actions/Admin/Announcements/UpdateAnnouncementAction.php new file mode 100644 index 0000000000..a999a01f3f --- /dev/null +++ b/backend/app/Http/Actions/Admin/Announcements/UpdateAnnouncementAction.php @@ -0,0 +1,49 @@ +minimumAllowedRole(Role::SUPERADMIN); + + try { + $announcement = $this->handler->handle(new UpsertAnnouncementDTO( + title: $request->validated('title'), + content: $request->validated('content'), + status: $request->validated('status'), + displayType: $request->validated('display_type'), + targetType: $request->validated('target_type'), + emoji: $request->validated('emoji'), + targetAccountIds: $request->validated('target_account_ids'), + targetUserIds: $request->validated('target_user_ids'), + ctaLabel: $request->validated('cta_label'), + ctaUrl: $request->validated('cta_url'), + announcementId: $announcementId, + )); + } catch (AnnouncementNotFoundException) { + return $this->notFoundResponse(); + } + + return $this->resourceResponse( + resource: AdminAnnouncementResource::class, + data: $announcement, + ); + } +} diff --git a/backend/app/Http/Actions/Announcements/DismissAnnouncementAction.php b/backend/app/Http/Actions/Announcements/DismissAnnouncementAction.php new file mode 100644 index 0000000000..95fcb36e1f --- /dev/null +++ b/backend/app/Http/Actions/Announcements/DismissAnnouncementAction.php @@ -0,0 +1,37 @@ +get('is_impersonating', false)) { + return $this->noContentResponse(); + } + + try { + $this->handler->handle(new DismissAnnouncementDTO( + announcementId: $announcementId, + userId: $this->getAuthenticatedUser()->getId(), + )); + } catch (AnnouncementNotFoundException) { + return $this->notFoundResponse(); + } + + return $this->noContentResponse(); + } +} diff --git a/backend/app/Http/Actions/Announcements/GetActiveAnnouncementsAction.php b/backend/app/Http/Actions/Announcements/GetActiveAnnouncementsAction.php new file mode 100644 index 0000000000..c7c4cfba85 --- /dev/null +++ b/backend/app/Http/Actions/Announcements/GetActiveAnnouncementsAction.php @@ -0,0 +1,43 @@ +get('is_impersonating', false)) { + return $this->jsonResponse(['data' => []]); + } + + try { + $accountId = $this->getAuthenticatedAccountId(); + } catch (UnauthorizedException) { + return $this->jsonResponse(['data' => []]); + } + + $announcements = $this->handler->handle(new GetActiveAnnouncementsDTO( + userId: $this->getAuthenticatedUser()->getId(), + accountId: $accountId, + )); + + return $this->resourceResponse( + resource: AnnouncementResource::class, + data: $announcements, + ); + } +} diff --git a/backend/app/Http/Request/Announcement/UpsertAnnouncementRequest.php b/backend/app/Http/Request/Announcement/UpsertAnnouncementRequest.php new file mode 100644 index 0000000000..f5eedf0344 --- /dev/null +++ b/backend/app/Http/Request/Announcement/UpsertAnnouncementRequest.php @@ -0,0 +1,38 @@ + ['required', 'string', 'max:150'], + 'content' => [ + 'required', + 'string', + 'max:50000', + Rule::when( + $this->input('display_type') === AnnouncementDisplayType::BANNER->name, + ['max:200'], + ), + ], + 'status' => ['required', Rule::in(AnnouncementStatus::valuesArray())], + 'display_type' => ['required', Rule::in(AnnouncementDisplayType::valuesArray())], + 'emoji' => ['nullable', 'string', 'max:16', 'required_if:display_type,'.AnnouncementDisplayType::MODAL->name], + 'target_type' => ['required', Rule::in(AnnouncementTargetType::valuesArray())], + 'target_account_ids' => ['array', 'max:500', 'required_if:target_type,'.AnnouncementTargetType::ACCOUNTS->name], + 'target_account_ids.*' => ['integer'], + 'target_user_ids' => ['array', 'max:500', 'required_if:target_type,'.AnnouncementTargetType::USERS->name], + 'target_user_ids.*' => ['integer'], + 'cta_label' => ['nullable', 'string', 'max:60', 'required_with:cta_url'], + 'cta_url' => ['nullable', 'url:http,https', 'max:2048', 'required_with:cta_label'], + ]; + } +} diff --git a/backend/app/Models/Announcement.php b/backend/app/Models/Announcement.php new file mode 100644 index 0000000000..fed6e4a003 --- /dev/null +++ b/backend/app/Models/Announcement.php @@ -0,0 +1,41 @@ +hasMany(AnnouncementUser::class); + } + + protected function getCastMap(): array + { + return [ + AnnouncementDomainObjectAbstract::TARGET_ACCOUNT_IDS => 'array', + AnnouncementDomainObjectAbstract::TARGET_USER_IDS => 'array', + ]; + } + + protected function getFillableFields(): array + { + return [ + AnnouncementDomainObjectAbstract::TITLE, + AnnouncementDomainObjectAbstract::CONTENT, + AnnouncementDomainObjectAbstract::STATUS, + AnnouncementDomainObjectAbstract::DISPLAY_TYPE, + AnnouncementDomainObjectAbstract::EMOJI, + AnnouncementDomainObjectAbstract::TARGET_TYPE, + AnnouncementDomainObjectAbstract::TARGET_ACCOUNT_IDS, + AnnouncementDomainObjectAbstract::TARGET_USER_IDS, + AnnouncementDomainObjectAbstract::CTA_LABEL, + AnnouncementDomainObjectAbstract::CTA_URL, + ]; + } +} diff --git a/backend/app/Models/AnnouncementUser.php b/backend/app/Models/AnnouncementUser.php new file mode 100644 index 0000000000..6b4249dd52 --- /dev/null +++ b/backend/app/Models/AnnouncementUser.php @@ -0,0 +1,26 @@ + 'datetime', + AnnouncementUserDomainObjectAbstract::DISMISSED_AT => 'datetime', + ]; + } + + protected function getFillableFields(): array + { + return [ + AnnouncementUserDomainObjectAbstract::ANNOUNCEMENT_ID, + AnnouncementUserDomainObjectAbstract::USER_ID, + AnnouncementUserDomainObjectAbstract::FIRST_SEEN_AT, + AnnouncementUserDomainObjectAbstract::DISMISSED_AT, + ]; + } +} diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index bc3b7c7917..ad28f4bc4f 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -10,6 +10,8 @@ use HiEvents\Repository\Eloquent\AccountRepository; use HiEvents\Repository\Eloquent\AccountUserRepository; use HiEvents\Repository\Eloquent\AffiliateRepository; +use HiEvents\Repository\Eloquent\AnnouncementRepository; +use HiEvents\Repository\Eloquent\AnnouncementUserRepository; use HiEvents\Repository\Eloquent\AttendeeCheckInRepository; use HiEvents\Repository\Eloquent\AttendeeRepository; use HiEvents\Repository\Eloquent\CapacityAssignmentRepository; @@ -65,6 +67,8 @@ use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface; use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface; +use HiEvents\Repository\Interfaces\AnnouncementRepositoryInterface; +use HiEvents\Repository\Interfaces\AnnouncementUserRepositoryInterface; use HiEvents\Repository\Interfaces\AttendeeCheckInRepositoryInterface; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\CapacityAssignmentRepositoryInterface; @@ -169,6 +173,8 @@ class RepositoryServiceProvider extends ServiceProvider OrganizerConfigurationRepositoryInterface::class => OrganizerConfigurationRepository::class, TicketLookupTokenRepositoryInterface::class => TicketLookupTokenRepository::class, AccountMessagingTierRepositoryInterface::class => AccountMessagingTierRepository::class, + AnnouncementRepositoryInterface::class => AnnouncementRepository::class, + AnnouncementUserRepositoryInterface::class => AnnouncementUserRepository::class, WaitlistEntryRepositoryInterface::class => WaitlistEntryRepository::class, EventOccurrenceRepositoryInterface::class => EventOccurrenceRepository::class, EventOccurrenceStatisticRepositoryInterface::class => EventOccurrenceStatisticRepository::class, diff --git a/backend/app/Repository/Eloquent/AccountRepository.php b/backend/app/Repository/Eloquent/AccountRepository.php index a7c5b01801..bcc3232617 100644 --- a/backend/app/Repository/Eloquent/AccountRepository.php +++ b/backend/app/Repository/Eloquent/AccountRepository.php @@ -53,10 +53,10 @@ public function getAllAccountsWithCounts(?string $search, int $perPage): LengthA if ($search) { $query->where(function ($q) use ($search) { - $q->where('accounts.name', 'like', "{$search}%") - ->orWhere('accounts.email', 'like', "{$search}%") + $q->where('accounts.name', 'ilike', "%{$search}%") + ->orWhere('accounts.email', 'ilike', "%{$search}%") ->orWhereHas('users', function ($userQuery) use ($search) { - $userQuery->where('users.email', 'like', "{$search}%"); + $userQuery->where('users.email', 'ilike', "%{$search}%"); }); }); } diff --git a/backend/app/Repository/Eloquent/AnnouncementRepository.php b/backend/app/Repository/Eloquent/AnnouncementRepository.php new file mode 100644 index 0000000000..e83b85bbdf --- /dev/null +++ b/backend/app/Repository/Eloquent/AnnouncementRepository.php @@ -0,0 +1,73 @@ + + */ +class AnnouncementRepository extends BaseRepository implements AnnouncementRepositoryInterface +{ + protected function getModel(): string + { + return Announcement::class; + } + + public function getDomainObject(): string + { + return AnnouncementDomainObject::class; + } + + public function getAnnouncementsWithCounts(?string $search, int $perPage): LengthAwarePaginator + { + return $this->runQuery(function () use ($search, $perPage) { + $query = $this->model + ->withCount([ + 'announcementUsers as seen_count', + 'announcementUsers as dismissed_count' => fn ($query) => $query->whereNotNull('dismissed_at'), + ]); + + if ($search) { + $query->where('title', 'ilike', "%{$search}%"); + } + + return $query->orderByDesc('id')->paginate($perPage); + }); + } + + public function findActiveForUser(int $userId, int $accountId): Collection + { + return $this->runQuery(function () use ($userId, $accountId) { + $announcements = $this->model + ->where('status', AnnouncementStatus::PUBLISHED->name) + ->where(function ($query) use ($userId, $accountId) { + $query + ->where('target_type', AnnouncementTargetType::ALL->name) + ->orWhere(function ($query) use ($accountId) { + $query->where('target_type', AnnouncementTargetType::ACCOUNTS->name) + ->whereJsonContains('target_account_ids', $accountId); + }) + ->orWhere(function ($query) use ($userId) { + $query->where('target_type', AnnouncementTargetType::USERS->name) + ->whereJsonContains('target_user_ids', $userId); + }); + }) + ->whereDoesntHave('announcementUsers', function ($query) use ($userId) { + $query->where('user_id', $userId)->whereNotNull('dismissed_at'); + }) + ->orderByDesc('id') + ->get(); + + return $this->handleResults($announcements, AnnouncementDomainObject::class); + }); + } +} diff --git a/backend/app/Repository/Eloquent/AnnouncementUserRepository.php b/backend/app/Repository/Eloquent/AnnouncementUserRepository.php new file mode 100644 index 0000000000..8432d9557b --- /dev/null +++ b/backend/app/Repository/Eloquent/AnnouncementUserRepository.php @@ -0,0 +1,61 @@ + + */ +class AnnouncementUserRepository extends BaseRepository implements AnnouncementUserRepositoryInterface +{ + protected function getModel(): string + { + return AnnouncementUser::class; + } + + public function getDomainObject(): string + { + return AnnouncementUserDomainObject::class; + } + + public function markSeen(array $announcementIds, int $userId): void + { + $now = Carbon::now(); + + $this->model->insertOrIgnore( + collect($announcementIds)->map(fn ($announcementId) => [ + 'announcement_id' => $announcementId, + 'user_id' => $userId, + 'first_seen_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ])->all(), + ); + } + + public function markDismissed(int $announcementId, int $userId): void + { + $now = Carbon::now(); + + $this->model->upsert( + [ + [ + 'announcement_id' => $announcementId, + 'user_id' => $userId, + 'first_seen_at' => $now, + 'dismissed_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ], + ], + ['announcement_id', 'user_id'], + ['dismissed_at', 'updated_at'], + ); + } +} diff --git a/backend/app/Repository/Interfaces/AnnouncementRepositoryInterface.php b/backend/app/Repository/Interfaces/AnnouncementRepositoryInterface.php new file mode 100644 index 0000000000..1df754e0da --- /dev/null +++ b/backend/app/Repository/Interfaces/AnnouncementRepositoryInterface.php @@ -0,0 +1,20 @@ + + */ +interface AnnouncementRepositoryInterface extends RepositoryInterface +{ + public function getAnnouncementsWithCounts(?string $search, int $perPage): LengthAwarePaginator; + + /** + * @return Collection + */ + public function findActiveForUser(int $userId, int $accountId): Collection; +} diff --git a/backend/app/Repository/Interfaces/AnnouncementUserRepositoryInterface.php b/backend/app/Repository/Interfaces/AnnouncementUserRepositoryInterface.php new file mode 100644 index 0000000000..34813b92ad --- /dev/null +++ b/backend/app/Repository/Interfaces/AnnouncementUserRepositoryInterface.php @@ -0,0 +1,15 @@ + + */ +interface AnnouncementUserRepositoryInterface extends RepositoryInterface +{ + public function markSeen(array $announcementIds, int $userId): void; + + public function markDismissed(int $announcementId, int $userId): void; +} diff --git a/backend/app/Resources/Announcement/AdminAnnouncementResource.php b/backend/app/Resources/Announcement/AdminAnnouncementResource.php new file mode 100644 index 0000000000..ac6b24c1b6 --- /dev/null +++ b/backend/app/Resources/Announcement/AdminAnnouncementResource.php @@ -0,0 +1,53 @@ +resource instanceof AnnouncementDomainObject) { + return [ + 'id' => $this->resource->getId(), + 'title' => $this->resource->getTitle(), + 'content' => $this->resource->getContent(), + 'status' => $this->resource->getStatus(), + 'display_type' => $this->resource->getDisplayType(), + 'emoji' => $this->resource->getEmoji(), + 'target_type' => $this->resource->getTargetType(), + 'target_account_ids' => $this->resource->getTargetAccountIds(), + 'target_user_ids' => $this->resource->getTargetUserIds(), + 'cta_label' => $this->resource->getCtaLabel(), + 'cta_url' => $this->resource->getCtaUrl(), + 'created_at' => $this->resource->getCreatedAt(), + 'updated_at' => $this->resource->getUpdatedAt(), + 'target_names' => (object) [], + 'seen_count' => 0, + 'dismissed_count' => 0, + ]; + } + + return [ + 'id' => $this->resource->id, + 'title' => $this->resource->title, + 'content' => $this->resource->content, + 'status' => $this->resource->status, + 'display_type' => $this->resource->display_type, + 'emoji' => $this->resource->emoji, + 'target_type' => $this->resource->target_type, + 'target_account_ids' => $this->resource->target_account_ids, + 'target_user_ids' => $this->resource->target_user_ids, + 'cta_label' => $this->resource->cta_label, + 'cta_url' => $this->resource->cta_url, + 'created_at' => $this->resource->created_at, + 'updated_at' => $this->resource->updated_at, + 'target_names' => (object) ($this->resource->target_names ?? []), + 'seen_count' => $this->resource->seen_count ?? 0, + 'dismissed_count' => $this->resource->dismissed_count ?? 0, + ]; + } +} diff --git a/backend/app/Resources/Announcement/AnnouncementResource.php b/backend/app/Resources/Announcement/AnnouncementResource.php new file mode 100644 index 0000000000..5d58298a3a --- /dev/null +++ b/backend/app/Resources/Announcement/AnnouncementResource.php @@ -0,0 +1,26 @@ + $this->getId(), + 'title' => $this->getTitle(), + 'content' => $this->getContent(), + 'display_type' => $this->getDisplayType(), + 'emoji' => $this->getEmoji(), + 'cta_label' => $this->getCtaLabel(), + 'cta_url' => $this->getCtaUrl(), + ]; + } +} diff --git a/backend/app/Services/Application/Handlers/Announcement/AnnouncementPayloadNormaliser.php b/backend/app/Services/Application/Handlers/Announcement/AnnouncementPayloadNormaliser.php new file mode 100644 index 0000000000..d945fb429e --- /dev/null +++ b/backend/app/Services/Application/Handlers/Announcement/AnnouncementPayloadNormaliser.php @@ -0,0 +1,40 @@ +displayType === AnnouncementDisplayType::MODAL->name; + + return [ + AnnouncementDomainObjectAbstract::TITLE => $dto->title, + AnnouncementDomainObjectAbstract::CONTENT => $isModal + ? $this->purifier->purify($dto->content) + : $dto->content, + AnnouncementDomainObjectAbstract::STATUS => $dto->status, + AnnouncementDomainObjectAbstract::DISPLAY_TYPE => $dto->displayType, + AnnouncementDomainObjectAbstract::EMOJI => $isModal ? $dto->emoji : null, + AnnouncementDomainObjectAbstract::TARGET_TYPE => $dto->targetType, + AnnouncementDomainObjectAbstract::TARGET_ACCOUNT_IDS => $dto->targetType === AnnouncementTargetType::ACCOUNTS->name + ? array_values(array_map('intval', $dto->targetAccountIds ?? [])) + : null, + AnnouncementDomainObjectAbstract::TARGET_USER_IDS => $dto->targetType === AnnouncementTargetType::USERS->name + ? array_values(array_map('intval', $dto->targetUserIds ?? [])) + : null, + AnnouncementDomainObjectAbstract::CTA_LABEL => $dto->ctaLabel, + AnnouncementDomainObjectAbstract::CTA_URL => $dto->ctaUrl, + ]; + } +} diff --git a/backend/app/Services/Application/Handlers/Announcement/CreateAnnouncementHandler.php b/backend/app/Services/Application/Handlers/Announcement/CreateAnnouncementHandler.php new file mode 100644 index 0000000000..9609c95535 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Announcement/CreateAnnouncementHandler.php @@ -0,0 +1,20 @@ +announcementRepository->create($this->normaliser->normalise($dto)); + } +} diff --git a/backend/app/Services/Application/Handlers/Announcement/DTO/DismissAnnouncementDTO.php b/backend/app/Services/Application/Handlers/Announcement/DTO/DismissAnnouncementDTO.php new file mode 100644 index 0000000000..898bcd8b58 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Announcement/DTO/DismissAnnouncementDTO.php @@ -0,0 +1,13 @@ +announcementRepository->findFirstWhere(['id' => $announcementId]) === null) { + throw new AnnouncementNotFoundException(__('Announcement not found')); + } + + $this->announcementRepository->deleteById($announcementId); + } +} diff --git a/backend/app/Services/Application/Handlers/Announcement/DismissAnnouncementHandler.php b/backend/app/Services/Application/Handlers/Announcement/DismissAnnouncementHandler.php new file mode 100644 index 0000000000..6a3b7bb986 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Announcement/DismissAnnouncementHandler.php @@ -0,0 +1,34 @@ +announcementRepository->findFirstWhere([ + 'id' => $dto->announcementId, + 'status' => AnnouncementStatus::PUBLISHED->name, + ]); + + if ($announcement === null) { + throw new AnnouncementNotFoundException(__('Announcement not found')); + } + + $this->announcementUserRepository->markDismissed($dto->announcementId, $dto->userId); + } +} diff --git a/backend/app/Services/Application/Handlers/Announcement/GetActiveAnnouncementsHandler.php b/backend/app/Services/Application/Handlers/Announcement/GetActiveAnnouncementsHandler.php new file mode 100644 index 0000000000..96498d5586 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Announcement/GetActiveAnnouncementsHandler.php @@ -0,0 +1,40 @@ + + */ + public function handle(GetActiveAnnouncementsDTO $dto): Collection + { + $eligible = $this->announcementRepository->findActiveForUser($dto->userId, $dto->accountId); + + $announcements = collect([ + $eligible->first(fn (AnnouncementDomainObject $announcement) => $announcement->getDisplayType() === AnnouncementDisplayType::BANNER->name), + $eligible->first(fn (AnnouncementDomainObject $announcement) => $announcement->getDisplayType() === AnnouncementDisplayType::MODAL->name), + ])->filter()->values(); + + if ($announcements->isNotEmpty()) { + $this->announcementUserRepository->markSeen( + $announcements->map(fn (AnnouncementDomainObject $announcement) => $announcement->getId())->all(), + $dto->userId, + ); + } + + return $announcements; + } +} diff --git a/backend/app/Services/Application/Handlers/Announcement/GetAllAnnouncementsHandler.php b/backend/app/Services/Application/Handlers/Announcement/GetAllAnnouncementsHandler.php new file mode 100644 index 0000000000..49931b6d3e --- /dev/null +++ b/backend/app/Services/Application/Handlers/Announcement/GetAllAnnouncementsHandler.php @@ -0,0 +1,83 @@ +announcementRepository->getAnnouncementsWithCounts($dto->search, $dto->perPage); + + $accountNames = $this->resolveAccountNames($announcements->items()); + $userNames = $this->resolveUserNames($announcements->items()); + + foreach ($announcements->items() as $announcement) { + $announcement->setAttribute('target_names', match ($announcement->target_type) { + AnnouncementTargetType::ACCOUNTS->name => collect($announcement->target_account_ids) + ->mapWithKeys(fn ($id) => [$id => $accountNames[$id] ?? "#{$id}"])->all(), + AnnouncementTargetType::USERS->name => collect($announcement->target_user_ids) + ->mapWithKeys(fn ($id) => [$id => $userNames[$id] ?? "#{$id}"])->all(), + default => [], + }); + } + + return $announcements; + } + + private function resolveAccountNames(array $announcements): array + { + $accountIds = collect($announcements) + ->where('target_type', AnnouncementTargetType::ACCOUNTS->name) + ->flatMap(fn (Announcement $announcement) => $announcement->target_account_ids ?? []) + ->unique() + ->values(); + + if ($accountIds->isEmpty()) { + return []; + } + + return $this->accountRepository + ->findWhereIn('id', $accountIds->all()) + ->mapWithKeys(fn (AccountDomainObject $account) => [$account->getId() => $account->getName()]) + ->all(); + } + + private function resolveUserNames(array $announcements): array + { + $userIds = collect($announcements) + ->where('target_type', AnnouncementTargetType::USERS->name) + ->flatMap(fn (Announcement $announcement) => $announcement->target_user_ids ?? []) + ->unique() + ->values(); + + if ($userIds->isEmpty()) { + return []; + } + + return $this->userRepository + ->includeDeleted() + ->findWhereIn('id', $userIds->all()) + ->mapWithKeys(fn (UserDomainObject $user) => [ + $user->getId() => $user->getDeletedAt() === null + ? $user->getFullName() + : $user->getFullName().' '.__('(deactivated)'), + ]) + ->all(); + } +} diff --git a/backend/app/Services/Application/Handlers/Announcement/UpdateAnnouncementHandler.php b/backend/app/Services/Application/Handlers/Announcement/UpdateAnnouncementHandler.php new file mode 100644 index 0000000000..f08db0d871 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Announcement/UpdateAnnouncementHandler.php @@ -0,0 +1,31 @@ +announcementRepository->findFirstWhere(['id' => $dto->announcementId]) === null) { + throw new AnnouncementNotFoundException(__('Announcement not found')); + } + + return $this->announcementRepository->updateFromArray( + $dto->announcementId, + $this->normaliser->normalise($dto), + ); + } +} diff --git a/backend/database/migrations/2026_07_29_000000_create_announcements_table.php b/backend/database/migrations/2026_07_29_000000_create_announcements_table.php new file mode 100644 index 0000000000..9159af4d36 --- /dev/null +++ b/backend/database/migrations/2026_07_29_000000_create_announcements_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('title'); + $table->text('content'); + $table->string('status')->default(AnnouncementStatus::DRAFT->name); + $table->string('display_type'); + $table->string('emoji')->nullable(); + $table->string('target_type')->default(AnnouncementTargetType::ALL->name); + $table->json('target_account_ids')->nullable(); + $table->json('target_user_ids')->nullable(); + $table->string('cta_label')->nullable(); + $table->string('cta_url')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index('status'); + }); + } + + public function down(): void + { + Schema::dropIfExists('announcements'); + } +}; diff --git a/backend/database/migrations/2026_07_29_000001_create_announcement_users_table.php b/backend/database/migrations/2026_07_29_000001_create_announcement_users_table.php new file mode 100644 index 0000000000..f428f07b9d --- /dev/null +++ b/backend/database/migrations/2026_07_29_000001_create_announcement_users_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('announcement_id')->constrained('announcements')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->timestamp('first_seen_at'); + $table->timestamp('dismissed_at')->nullable(); + $table->timestamps(); + + $table->unique(['announcement_id', 'user_id']); + $table->index('user_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('announcement_users'); + } +}; diff --git a/backend/lang/de.json b/backend/lang/de.json index af48595a11..0a4691ebea 100644 --- a/backend/lang/de.json +++ b/backend/lang/de.json @@ -662,5 +662,6 @@ "Stripe payment account not connected": "", "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", - "Review Message": "" -} \ No newline at end of file + "Review Message": "", + "(deactivated)": "(deaktiviert)" +} diff --git a/backend/lang/el.json b/backend/lang/el.json index 87a2e0bea0..8e5e5e1fc5 100644 --- a/backend/lang/el.json +++ b/backend/lang/el.json @@ -662,5 +662,6 @@ "Stripe payment account not connected": "Ο λογαριασμός πληρωμής Stripe δεν είναι συνδεδεμένος", "No completed paid orders on this account": "Δεν υπάρχουν ολοκληρωμένες πληρωμένες παραγγελίες σε αυτόν τον λογαριασμό", "Event was created less than 24 hours ago": "Η εκδήλωση δημιουργήθηκε πριν από λιγότερο από 24 ώρες", - "Review Message": "Αξιολόγηση Μηνύματος" -} \ No newline at end of file + "Review Message": "Αξιολόγηση Μηνύματος", + "(deactivated)": "(απενεργοποιημένος)" +} diff --git a/backend/lang/es.json b/backend/lang/es.json index 1a099685cf..fe09fc0b0d 100644 --- a/backend/lang/es.json +++ b/backend/lang/es.json @@ -707,5 +707,6 @@ "You are already on the waitlist for this product": "Ya estás en la lista de espera de este producto", "You cannot archive the last active organizer on your account.": "No puedes archivar el último organizador activo de tu cuenta.", "You cannot delete the last organizer on your account.": "No puedes eliminar el último organizador de tu cuenta.", - "You must acknowledge your data controller responsibilities before enabling tracking pixels.": "Debes reconocer tus responsabilidades como responsable del tratamiento de datos antes de habilitar los píxeles de seguimiento." + "You must acknowledge your data controller responsibilities before enabling tracking pixels.": "Debes reconocer tus responsabilidades como responsable del tratamiento de datos antes de habilitar los píxeles de seguimiento.", + "(deactivated)": "(desactivado)" } diff --git a/backend/lang/fr.json b/backend/lang/fr.json index 272fee79d6..ed79d7db1c 100644 --- a/backend/lang/fr.json +++ b/backend/lang/fr.json @@ -662,5 +662,6 @@ "Stripe payment account not connected": "", "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", - "Review Message": "" -} \ No newline at end of file + "Review Message": "", + "(deactivated)": "(désactivé)" +} diff --git a/backend/lang/hu.json b/backend/lang/hu.json index 0dd61eeb40..7217c84594 100644 --- a/backend/lang/hu.json +++ b/backend/lang/hu.json @@ -662,5 +662,6 @@ "Stripe payment account not connected": "", "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", - "Review Message": "" -} \ No newline at end of file + "Review Message": "", + "(deactivated)": "(deaktivált)" +} diff --git a/backend/lang/it.json b/backend/lang/it.json index b9283f6b07..0e968fb12f 100644 --- a/backend/lang/it.json +++ b/backend/lang/it.json @@ -663,5 +663,6 @@ "Stripe payment account not connected": "", "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", - "Review Message": "" -} \ No newline at end of file + "Review Message": "", + "(deactivated)": "(disattivato)" +} diff --git a/backend/lang/nl.json b/backend/lang/nl.json index c772413339..c702833781 100644 --- a/backend/lang/nl.json +++ b/backend/lang/nl.json @@ -662,5 +662,6 @@ "Stripe payment account not connected": "", "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", - "Review Message": "" -} \ No newline at end of file + "Review Message": "", + "(deactivated)": "(gedeactiveerd)" +} diff --git a/backend/lang/pl.json b/backend/lang/pl.json index fb2eadf68b..8f11077795 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -662,5 +662,6 @@ "Stripe payment account not connected": "Konto płatności Stripe nie połączone", "No completed paid orders on this account": "Brak zakończonych płatnych zamówień na tym koncie", "Event was created less than 24 hours ago": "Wydarzenie zostało utworzone mniej niż 24 godziny temu", - "Review Message": "Przejrzyj wiadomość" + "Review Message": "Przejrzyj wiadomość", + "(deactivated)": "(dezaktywowany)" } diff --git a/backend/lang/pt-br.json b/backend/lang/pt-br.json index c4062eeeb8..1662004fc9 100644 --- a/backend/lang/pt-br.json +++ b/backend/lang/pt-br.json @@ -662,5 +662,6 @@ "Stripe payment account not connected": "", "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", - "Review Message": "" -} \ No newline at end of file + "Review Message": "", + "(deactivated)": "(desativado)" +} diff --git a/backend/lang/pt.json b/backend/lang/pt.json index 84e1e228df..496fe45871 100644 --- a/backend/lang/pt.json +++ b/backend/lang/pt.json @@ -662,5 +662,6 @@ "Stripe payment account not connected": "", "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", - "Review Message": "" -} \ No newline at end of file + "Review Message": "", + "(deactivated)": "(desativado)" +} diff --git a/backend/lang/ru.json b/backend/lang/ru.json index c9a3417757..79c4668b28 100644 --- a/backend/lang/ru.json +++ b/backend/lang/ru.json @@ -677,5 +677,6 @@ "Stripe payment account not connected": "", "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", - "Review Message": "" -} \ No newline at end of file + "Review Message": "", + "(deactivated)": "(деактивирован)" +} diff --git a/backend/lang/se.json b/backend/lang/se.json index 0c64cfd25d..07a8324500 100644 --- a/backend/lang/se.json +++ b/backend/lang/se.json @@ -490,5 +490,6 @@ "You have received a new message from": "Du har fått ett nytt meddelande från", "Reply to :name": "Svara till :name", "This message was sent via your organizer contact form.": "Detta meddelande skickades via ditt kontaktformulär för arrangörer.", - "Your email confirmation code is:": "Din e-postbekräftelsekod är:" + "Your email confirmation code is:": "Din e-postbekräftelsekod är:", + "(deactivated)": "(avaktiverad)" } diff --git a/backend/lang/sk.json b/backend/lang/sk.json index 50a4fac0c7..526ca4c38d 100644 --- a/backend/lang/sk.json +++ b/backend/lang/sk.json @@ -662,5 +662,6 @@ "Stripe payment account not connected": "Stripe platobný účet nie je pripojený", "No completed paid orders on this account": "Žiadne dokončené platené objednávky na tomto účte", "Event was created less than 24 hours ago": "Udalosť bola vytvorená pred menej ako 24 hodinami", - "Review Message": "Skontrolovať správu" -} \ No newline at end of file + "Review Message": "Skontrolovať správu", + "(deactivated)": "(deaktivovaný)" +} diff --git a/backend/lang/tr.json b/backend/lang/tr.json index b8e49c37c4..d609b4e87b 100644 --- a/backend/lang/tr.json +++ b/backend/lang/tr.json @@ -677,5 +677,6 @@ "Stripe payment account not connected": "", "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", - "Review Message": "" -} \ No newline at end of file + "Review Message": "", + "(deactivated)": "(devre dışı)" +} diff --git a/backend/lang/vi.json b/backend/lang/vi.json index 613ffa3570..0ada850e10 100644 --- a/backend/lang/vi.json +++ b/backend/lang/vi.json @@ -626,5 +626,6 @@ "Stripe payment account not connected": "", "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", - "Review Message": "" -} \ No newline at end of file + "Review Message": "", + "(deactivated)": "(đã hủy kích hoạt)" +} diff --git a/backend/lang/zh-cn.json b/backend/lang/zh-cn.json index 7f2ca9dc92..3a9a7f9457 100644 --- a/backend/lang/zh-cn.json +++ b/backend/lang/zh-cn.json @@ -662,5 +662,6 @@ "Stripe payment account not connected": "", "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", - "Review Message": "" -} \ No newline at end of file + "Review Message": "", + "(deactivated)": "(已停用)" +} diff --git a/backend/lang/zh-hk.json b/backend/lang/zh-hk.json index f3502c24c5..c0ad789ddf 100644 --- a/backend/lang/zh-hk.json +++ b/backend/lang/zh-hk.json @@ -662,5 +662,6 @@ "Stripe payment account not connected": "", "No completed paid orders on this account": "", "Event was created less than 24 hours ago": "", - "Review Message": "" -} \ No newline at end of file + "Review Message": "", + "(deactivated)": "(已停用)" +} diff --git a/backend/routes/api.php b/backend/routes/api.php index 660d1a015d..c66e92daf5 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -6,6 +6,10 @@ use HiEvents\Http\Actions\Admin\Accounts\GetAccountAction as GetAdminAccountAction; use HiEvents\Http\Actions\Admin\Accounts\GetAllAccountsAction as GetAllAdminAccountsAction; use HiEvents\Http\Actions\Admin\Accounts\UpdateAccountMessagingTierAction; +use HiEvents\Http\Actions\Admin\Announcements\CreateAnnouncementAction; +use HiEvents\Http\Actions\Admin\Announcements\DeleteAnnouncementAction; +use HiEvents\Http\Actions\Admin\Announcements\GetAllAnnouncementsAction; +use HiEvents\Http\Actions\Admin\Announcements\UpdateAnnouncementAction; use HiEvents\Http\Actions\Admin\Attribution\GetUtmAttributionStatsAction; use HiEvents\Http\Actions\Admin\Configurations\CreateConfigurationAction; use HiEvents\Http\Actions\Admin\Configurations\DeleteConfigurationAction; @@ -37,6 +41,8 @@ use HiEvents\Http\Actions\Affiliates\GetAffiliateAction; use HiEvents\Http\Actions\Affiliates\GetAffiliatesAction; use HiEvents\Http\Actions\Affiliates\UpdateAffiliateAction; +use HiEvents\Http\Actions\Announcements\DismissAnnouncementAction; +use HiEvents\Http\Actions\Announcements\GetActiveAnnouncementsAction; use HiEvents\Http\Actions\Attendees\CheckInAttendeeAction; use HiEvents\Http\Actions\Attendees\CreateAttendeeAction; use HiEvents\Http\Actions\Attendees\EditAttendeeAction; @@ -290,6 +296,10 @@ function (Router $router): void { $router->post('/users/{user_id}/resend-email-confirmation', ResendEmailConfirmationAction::class); $router->post('/users/{user_id}/confirm-email-with-code', ConfirmEmailWithCodeAction::class); + // Announcements + $router->get('/announcements/active', GetActiveAnnouncementsAction::class); + $router->post('/announcements/{announcement_id}/dismiss', DismissAnnouncementAction::class); + // Accounts $router->get('/accounts/{account_id?}', GetAccountAction::class); $router->put('/accounts/{account_id?}', UpdateAccountAction::class); @@ -540,6 +550,12 @@ function (Router $router): void { $router->get('/messages', GetAllAdminMessagesAction::class); $router->post('/messages/{message_id}/approve', ApproveMessageAction::class); + // Announcements + $router->get('/announcements', GetAllAnnouncementsAction::class); + $router->post('/announcements', CreateAnnouncementAction::class); + $router->put('/announcements/{announcement_id}', UpdateAnnouncementAction::class); + $router->delete('/announcements/{announcement_id}', DeleteAnnouncementAction::class); + // Messaging Tiers $router->get('/messaging-tiers', GetMessagingTiersAction::class); $router->put('/accounts/{account_id}/messaging-tier', UpdateAccountMessagingTierAction::class); diff --git a/backend/tests/Unit/Services/Application/Handlers/Announcement/CreateAnnouncementHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Announcement/CreateAnnouncementHandlerTest.php new file mode 100644 index 0000000000..56d5777e45 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Announcement/CreateAnnouncementHandlerTest.php @@ -0,0 +1,129 @@ +announcementRepository = Mockery::mock(AnnouncementRepositoryInterface::class); + $this->purifier = Mockery::mock(HtmlPurifierService::class); + + $this->handler = new CreateAnnouncementHandler( + $this->announcementRepository, + new AnnouncementPayloadNormaliser($this->purifier), + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function test_modal_content_is_purified_and_emoji_kept(): void + { + $dto = new UpsertAnnouncementDTO( + title: 'New feature', + content: '

Hello

', + status: AnnouncementStatus::PUBLISHED->name, + displayType: AnnouncementDisplayType::MODAL->name, + targetType: AnnouncementTargetType::ALL->name, + emoji: '🎉', + ); + + $this->purifier + ->shouldReceive('purify') + ->once() + ->with($dto->content) + ->andReturn('

Hello

'); + + $this->announcementRepository + ->shouldReceive('create') + ->once() + ->with(Mockery::on(function (array $attributes) { + return $attributes[AnnouncementDomainObjectAbstract::CONTENT] === '

Hello

' + && $attributes[AnnouncementDomainObjectAbstract::EMOJI] === '🎉' + && $attributes[AnnouncementDomainObjectAbstract::TARGET_ACCOUNT_IDS] === null + && $attributes[AnnouncementDomainObjectAbstract::TARGET_USER_IDS] === null; + })) + ->andReturn(new AnnouncementDomainObject); + + $this->assertInstanceOf(AnnouncementDomainObject::class, $this->handler->handle($dto)); + } + + public function test_banner_content_is_kept_raw_and_emoji_discarded(): void + { + $dto = new UpsertAnnouncementDTO( + title: 'Maintenance', + content: 'Prices < $100 this Saturday', + status: AnnouncementStatus::PUBLISHED->name, + displayType: AnnouncementDisplayType::BANNER->name, + targetType: AnnouncementTargetType::ALL->name, + emoji: '🎉', + ctaLabel: 'Learn more', + ctaUrl: 'https://hi.events/changelog', + ); + + $this->purifier->shouldNotReceive('purify'); + + $this->announcementRepository + ->shouldReceive('create') + ->once() + ->with(Mockery::on(function (array $attributes) { + return $attributes[AnnouncementDomainObjectAbstract::CONTENT] === 'Prices < $100 this Saturday' + && $attributes[AnnouncementDomainObjectAbstract::EMOJI] === null + && $attributes[AnnouncementDomainObjectAbstract::CTA_LABEL] === 'Learn more' + && $attributes[AnnouncementDomainObjectAbstract::CTA_URL] === 'https://hi.events/changelog'; + })) + ->andReturn(new AnnouncementDomainObject); + + $this->assertInstanceOf(AnnouncementDomainObject::class, $this->handler->handle($dto)); + } + + public function test_target_ids_are_int_cast_and_mismatched_target_list_is_nulled(): void + { + $dto = new UpsertAnnouncementDTO( + title: 'For some accounts', + content: 'Hello', + status: AnnouncementStatus::DRAFT->name, + displayType: AnnouncementDisplayType::BANNER->name, + targetType: AnnouncementTargetType::ACCOUNTS->name, + targetAccountIds: ['5', 7], + targetUserIds: [9], + ); + + $this->announcementRepository + ->shouldReceive('create') + ->once() + ->with(Mockery::on(function (array $attributes) { + return $attributes[AnnouncementDomainObjectAbstract::TARGET_ACCOUNT_IDS] === [5, 7] + && $attributes[AnnouncementDomainObjectAbstract::TARGET_USER_IDS] === null; + })) + ->andReturn(new AnnouncementDomainObject); + + $this->assertInstanceOf(AnnouncementDomainObject::class, $this->handler->handle($dto)); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Announcement/DeleteAnnouncementHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Announcement/DeleteAnnouncementHandlerTest.php new file mode 100644 index 0000000000..75f17932ab --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Announcement/DeleteAnnouncementHandlerTest.php @@ -0,0 +1,65 @@ +announcementRepository = Mockery::mock(AnnouncementRepositoryInterface::class); + $this->handler = new DeleteAnnouncementHandler($this->announcementRepository); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function test_missing_announcement_throws(): void + { + $this->announcementRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => 77]) + ->andReturnNull(); + + $this->announcementRepository->shouldNotReceive('deleteById'); + + $this->expectException(AnnouncementNotFoundException::class); + + $this->handler->handle(77); + } + + public function test_delete_soft_deletes(): void + { + $this->expectNotToPerformAssertions(); + + $this->announcementRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => 77]) + ->andReturn(new AnnouncementDomainObject); + + $this->announcementRepository + ->shouldReceive('deleteById') + ->once() + ->with(77); + + $this->handler->handle(77); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Announcement/DismissAnnouncementHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Announcement/DismissAnnouncementHandlerTest.php new file mode 100644 index 0000000000..3da9e276e3 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Announcement/DismissAnnouncementHandlerTest.php @@ -0,0 +1,75 @@ +announcementRepository = Mockery::mock(AnnouncementRepositoryInterface::class); + $this->announcementUserRepository = Mockery::mock(AnnouncementUserRepositoryInterface::class); + + $this->handler = new DismissAnnouncementHandler( + $this->announcementRepository, + $this->announcementUserRepository, + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + public function test_unpublished_or_missing_announcement_throws(): void + { + $this->announcementRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => 5, 'status' => AnnouncementStatus::PUBLISHED->name]) + ->andReturnNull(); + + $this->announcementUserRepository->shouldNotReceive('markDismissed'); + + $this->expectException(AnnouncementNotFoundException::class); + + $this->handler->handle(new DismissAnnouncementDTO(announcementId: 5, userId: 9)); + } + + public function test_dismiss_marks_dismissed(): void + { + $this->expectNotToPerformAssertions(); + + $this->announcementRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => 5, 'status' => AnnouncementStatus::PUBLISHED->name]) + ->andReturn(new AnnouncementDomainObject); + + $this->announcementUserRepository + ->shouldReceive('markDismissed') + ->once() + ->with(5, 9); + + $this->handler->handle(new DismissAnnouncementDTO(announcementId: 5, userId: 9)); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Announcement/GetActiveAnnouncementsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Announcement/GetActiveAnnouncementsHandlerTest.php new file mode 100644 index 0000000000..2dbfd22955 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Announcement/GetActiveAnnouncementsHandlerTest.php @@ -0,0 +1,87 @@ +announcementRepository = Mockery::mock(AnnouncementRepositoryInterface::class); + $this->announcementUserRepository = Mockery::mock(AnnouncementUserRepositoryInterface::class); + + $this->handler = new GetActiveAnnouncementsHandler( + $this->announcementRepository, + $this->announcementUserRepository, + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + private function makeAnnouncement(int $id, AnnouncementDisplayType $displayType): AnnouncementDomainObject + { + return (new AnnouncementDomainObject) + ->setId($id) + ->setDisplayType($displayType->name); + } + + public function test_newest_banner_and_modal_are_returned_and_marked_seen(): void + { + $this->announcementRepository + ->shouldReceive('findActiveForUser') + ->once() + ->with(7, 3) + ->andReturn(new Collection([ + $this->makeAnnouncement(30, AnnouncementDisplayType::MODAL), + $this->makeAnnouncement(20, AnnouncementDisplayType::BANNER), + $this->makeAnnouncement(10, AnnouncementDisplayType::BANNER), + ])); + + $this->announcementUserRepository + ->shouldReceive('markSeen') + ->once() + ->with([20, 30], 7); + + $result = $this->handler->handle(new GetActiveAnnouncementsDTO(userId: 7, accountId: 3)); + + $this->assertCount(2, $result); + $this->assertSame([20, 30], $result->map(fn (AnnouncementDomainObject $a) => $a->getId())->all()); + } + + public function test_empty_result_skips_mark_seen(): void + { + $this->announcementRepository + ->shouldReceive('findActiveForUser') + ->once() + ->with(7, 3) + ->andReturn(new Collection); + + $this->announcementUserRepository->shouldNotReceive('markSeen'); + + $result = $this->handler->handle(new GetActiveAnnouncementsDTO(userId: 7, accountId: 3)); + + $this->assertTrue($result->isEmpty()); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Announcement/GetAllAnnouncementsHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Announcement/GetAllAnnouncementsHandlerTest.php new file mode 100644 index 0000000000..14406a7434 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Announcement/GetAllAnnouncementsHandlerTest.php @@ -0,0 +1,109 @@ +announcementRepository = Mockery::mock(AnnouncementRepositoryInterface::class); + $this->accountRepository = Mockery::mock(AccountRepositoryInterface::class); + $this->userRepository = Mockery::mock(UserRepositoryInterface::class); + + $this->handler = new GetAllAnnouncementsHandler( + $this->announcementRepository, + $this->accountRepository, + $this->userRepository, + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + private function makeAnnouncement(array $attributes): Announcement + { + return (new Announcement)->setRawAttributes($attributes); + } + + public function test_target_names_are_resolved_with_fallbacks_and_deactivated_suffix(): void + { + $accountsAnnouncement = $this->makeAnnouncement([ + 'id' => 1, + 'target_type' => 'ACCOUNTS', + 'target_account_ids' => json_encode([5, 999]), + ]); + $usersAnnouncement = $this->makeAnnouncement([ + 'id' => 2, + 'target_type' => 'USERS', + 'target_user_ids' => json_encode([7]), + ]); + $allAnnouncement = $this->makeAnnouncement([ + 'id' => 3, + 'target_type' => 'ALL', + ]); + + $this->announcementRepository + ->shouldReceive('getAnnouncementsWithCounts') + ->once() + ->with('promo', 20) + ->andReturn(new LengthAwarePaginator( + [$accountsAnnouncement, $usersAnnouncement, $allAnnouncement], + 3, + 20, + )); + + $this->accountRepository + ->shouldReceive('findWhereIn') + ->once() + ->with('id', [5, 999]) + ->andReturn(new Collection([ + (new AccountDomainObject)->setId(5)->setName('Acme Events'), + ])); + + $this->userRepository->shouldReceive('includeDeleted')->once()->andReturnSelf(); + $this->userRepository + ->shouldReceive('findWhereIn') + ->once() + ->with('id', [7]) + ->andReturn(new Collection([ + (new UserDomainObject) + ->setId(7) + ->setFirstName('Jane') + ->setLastName('Doe') + ->setDeletedAt('2026-01-01 00:00:00'), + ])); + + $this->handler->handle(new GetAllAnnouncementsDTO(perPage: 20, search: 'promo')); + + $this->assertSame([5 => 'Acme Events', 999 => '#999'], $accountsAnnouncement->target_names); + $this->assertSame([7 => 'Jane Doe (deactivated)'], $usersAnnouncement->target_names); + $this->assertSame([], $allAnnouncement->target_names); + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Announcement/UpdateAnnouncementHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Announcement/UpdateAnnouncementHandlerTest.php new file mode 100644 index 0000000000..eead8361c7 --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Announcement/UpdateAnnouncementHandlerTest.php @@ -0,0 +1,98 @@ +announcementRepository = Mockery::mock(AnnouncementRepositoryInterface::class); + $this->purifier = Mockery::mock(HtmlPurifierService::class); + + $this->handler = new UpdateAnnouncementHandler( + $this->announcementRepository, + new AnnouncementPayloadNormaliser($this->purifier), + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + private function makeDto(): UpsertAnnouncementDTO + { + return new UpsertAnnouncementDTO( + title: 'Updated', + content: '

Body

', + status: AnnouncementStatus::PUBLISHED->name, + displayType: AnnouncementDisplayType::MODAL->name, + targetType: AnnouncementTargetType::ALL->name, + emoji: '🚀', + announcementId: 44, + ); + } + + public function test_missing_announcement_throws(): void + { + $this->announcementRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => 44]) + ->andReturnNull(); + + $this->expectException(AnnouncementNotFoundException::class); + + $this->handler->handle($this->makeDto()); + } + + public function test_update_purifies_modal_content(): void + { + $this->announcementRepository + ->shouldReceive('findFirstWhere') + ->once() + ->with(['id' => 44]) + ->andReturn(new AnnouncementDomainObject); + + $this->purifier + ->shouldReceive('purify') + ->once() + ->with('

Body

') + ->andReturn('

Body

'); + + $this->announcementRepository + ->shouldReceive('updateFromArray') + ->once() + ->with(44, Mockery::on(function (array $attributes) { + return $attributes[AnnouncementDomainObjectAbstract::CONTENT] === '

Body

' + && $attributes[AnnouncementDomainObjectAbstract::STATUS] === AnnouncementStatus::PUBLISHED->name; + })) + ->andReturn(new AnnouncementDomainObject); + + $this->assertInstanceOf(AnnouncementDomainObject::class, $this->handler->handle($this->makeDto())); + } +} diff --git a/e2e/api/api-client.ts b/e2e/api/api-client.ts index 7af2598e7f..665b728989 100644 --- a/e2e/api/api-client.ts +++ b/e2e/api/api-client.ts @@ -282,6 +282,19 @@ export class ApiClient { } } +export interface UpsertAnnouncementPayload { + title: string; + content: string; + status: 'DRAFT' | 'PUBLISHED'; + display_type: 'BANNER' | 'MODAL'; + emoji?: string; + target_type: 'ALL' | 'ACCOUNTS' | 'USERS'; + target_account_ids?: number[]; + target_user_ids?: number[]; + cta_label?: string; + cta_url?: string; +} + export class AdminApiClient { constructor(private readonly request: APIRequestContext) {} @@ -293,4 +306,12 @@ export class AdminApiClient { }), ); } + + createAnnouncement(payload: UpsertAnnouncementPayload): Promise<{ id: number }> { + return unwrap(this.request.post('admin/announcements', { headers: jsonHeaders, data: payload })); + } + + deleteAnnouncement(announcementId: number): Promise { + return check(this.request.delete(`admin/announcements/${announcementId}`, { headers: jsonHeaders })); + } } diff --git a/e2e/tests/admin/announcements.spec.ts b/e2e/tests/admin/announcements.spec.ts new file mode 100644 index 0000000000..6e19af9df1 --- /dev/null +++ b/e2e/tests/admin/announcements.spec.ts @@ -0,0 +1,96 @@ +import { test, expect } from '../../fixtures'; +import { uniqueName } from '../../utils/unique'; + +test.describe('announcements', () => { + const bannerTitle = uniqueName('E2E Banner'); + const modalTitle = uniqueName('E2E Modal'); + + test( + 'a targeted user sees, closes and dismisses announcements, and the admin tally updates', + { tag: '@admin' }, + async ({ adminApi, freshAccount, superAdminPage }) => { + const created: number[] = []; + + try { + const banner = await adminApi.createAnnouncement({ + title: bannerTitle, + content: 'We shipped something new for you', + status: 'PUBLISHED', + display_type: 'BANNER', + target_type: 'USERS', + target_user_ids: [freshAccount.userId], + cta_label: 'Learn more', + cta_url: 'https://hi.events/changelog', + }); + created.push(banner.id); + const modal = await adminApi.createAnnouncement({ + title: modalTitle, + content: '

Here is a rich announcement

', + status: 'PUBLISHED', + display_type: 'MODAL', + emoji: '🎉', + target_type: 'USERS', + target_user_ids: [freshAccount.userId], + }); + created.push(modal.id); + + const page = await freshAccount.newAuthedPage(); + await page.goto('/manage/events'); + + const announcementModal = page.getByTestId('announcement-modal'); + await expect(announcementModal).toBeVisible(); + await expect(announcementModal.getByText(modalTitle)).toBeVisible(); + await expect(announcementModal.getByText('Here is a rich announcement')).toBeVisible(); + await expect(page.getByTestId('announcement-banner')).toHaveCount(0); + + await page.keyboard.press('Escape'); + await expect(announcementModal).toHaveCount(0); + + const bannerStrip = page.getByTestId('announcement-banner'); + await expect(bannerStrip).toBeVisible(); + await expect(bannerStrip.getByText(bannerTitle)).toBeVisible(); + await expect(page.getByTestId('announcement-banner-cta')).toHaveAttribute( + 'href', + 'https://hi.events/changelog', + ); + + await page.reload(); + await expect(page.getByTestId('announcement-modal')).toBeVisible(); + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/dismiss') && response.ok()), + page.getByTestId('announcement-modal-dismiss').click(), + ]); + await expect(page.getByTestId('announcement-modal')).toHaveCount(0); + + await Promise.all([ + page.waitForResponse((response) => response.url().includes('/dismiss') && response.ok()), + page.getByTestId('announcement-banner-dismiss').click(), + ]); + await expect(page.getByTestId('announcement-banner')).toHaveCount(0); + + const activeAfterDismiss = page.waitForResponse( + (response) => response.url().includes('/announcements/active') && response.ok(), + ); + await page.reload(); + await activeAfterDismiss; + await expect(page.getByTestId('announcement-modal').getByText(modalTitle)).toHaveCount(0); + await expect(page.getByTestId('announcement-banner').getByText(bannerTitle)).toHaveCount(0); + + await superAdminPage.goto('/admin/announcements'); + await expect(superAdminPage.getByRole('heading', { name: 'Announcements', exact: true })).toBeVisible(); + await expect(superAdminPage.getByTestId('announcement-modal')).toHaveCount(0); + await expect(superAdminPage.getByTestId('announcement-banner')).toHaveCount(0); + + await superAdminPage.getByPlaceholder('Search by title...').fill(modalTitle); + const modalRow = superAdminPage.getByRole('row').filter({ hasText: modalTitle }); + await expect(modalRow).toBeVisible(); + await expect(modalRow.getByText('Users (1)')).toBeVisible(); + await expect(modalRow.getByRole('cell', { name: '1', exact: true })).toHaveCount(2); + } finally { + const results = await Promise.allSettled(created.map((id) => adminApi.deleteAnnouncement(id))); + const failed = results.find((result) => result.status === 'rejected'); + if (failed) throw (failed as PromiseRejectedResult).reason; + } + }, + ); +}); diff --git a/frontend/src/api/announcement.client.ts b/frontend/src/api/announcement.client.ts new file mode 100644 index 0000000000..f303a3217e --- /dev/null +++ b/frontend/src/api/announcement.client.ts @@ -0,0 +1,85 @@ +import {api} from "./client"; +import {GenericDataResponse, GenericPaginatedResponse, IdParam} from "../types"; + +export type AnnouncementDisplayType = 'BANNER' | 'MODAL'; +export type AnnouncementTargetType = 'ALL' | 'ACCOUNTS' | 'USERS'; +export type AnnouncementStatus = 'DRAFT' | 'PUBLISHED'; + +export interface Announcement { + id: IdParam; + title: string; + content: string; + display_type: AnnouncementDisplayType; + emoji: string | null; + cta_label: string | null; + cta_url: string | null; +} + +export interface AdminAnnouncement extends Announcement { + status: AnnouncementStatus; + target_type: AnnouncementTargetType; + target_account_ids: number[] | null; + target_user_ids: number[] | null; + target_names: Record; + seen_count: number; + dismissed_count: number; + created_at: string; + updated_at: string; +} + +export interface UpsertAnnouncementData { + title: string; + content: string; + status: AnnouncementStatus; + display_type: AnnouncementDisplayType; + emoji?: string | null; + target_type: AnnouncementTargetType; + target_account_ids?: number[]; + target_user_ids?: number[]; + cta_label?: string | null; + cta_url?: string | null; +} + +export interface GetAdminAnnouncementsParams { + page?: number; + per_page?: number; + search?: string; +} + +export const announcementClient = { + all: async (params: GetAdminAnnouncementsParams = {}) => { + const response = await api.get>('admin/announcements', { + params: { + page: params.page || 1, + per_page: params.per_page || 20, + search: params.search || undefined, + } + }); + return response.data; + }, + + create: async (data: UpsertAnnouncementData) => { + const response = await api.post>('admin/announcements', data); + return response.data; + }, + + update: async (announcementId: IdParam, data: UpsertAnnouncementData) => { + const response = await api.put>(`admin/announcements/${announcementId}`, data); + return response.data; + }, + + delete: async (announcementId: IdParam) => { + const response = await api.delete(`admin/announcements/${announcementId}`); + return response.data; + }, + + active: async () => { + const response = await api.get>('announcements/active'); + return response.data; + }, + + dismiss: async (announcementId: IdParam) => { + const response = await api.post(`announcements/${announcementId}/dismiss`); + return response.data; + }, +}; diff --git a/frontend/src/components/common/AnnouncementDisplay/AnnouncementBanner.tsx b/frontend/src/components/common/AnnouncementDisplay/AnnouncementBanner.tsx new file mode 100644 index 0000000000..ee540001a1 --- /dev/null +++ b/frontend/src/components/common/AnnouncementDisplay/AnnouncementBanner.tsx @@ -0,0 +1,44 @@ +import {ActionIcon, Button} from "@mantine/core"; +import {t} from "@lingui/macro"; +import {IconSpeakerphone, IconX} from "@tabler/icons-react"; +import {Announcement} from "../../../api/announcement.client"; +import classes from "./AnnouncementDisplay.module.scss"; + +interface AnnouncementBannerProps { + announcement: Announcement; + onDismiss: () => void; +} + +export const AnnouncementBanner = ({announcement, onDismiss}: AnnouncementBannerProps) => ( +
+ +
+ {announcement.title} + {announcement.content} +
+
+ {announcement.cta_label && announcement.cta_url && ( + + )} + + + +
+
+); diff --git a/frontend/src/components/common/AnnouncementDisplay/AnnouncementDisplay.module.scss b/frontend/src/components/common/AnnouncementDisplay/AnnouncementDisplay.module.scss new file mode 100644 index 0000000000..b6be025d6b --- /dev/null +++ b/frontend/src/components/common/AnnouncementDisplay/AnnouncementDisplay.module.scss @@ -0,0 +1,127 @@ +.banner { + position: sticky; + top: 0; + z-index: 11; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.65rem 1.25rem; + background: linear-gradient(135deg, var(--mantine-color-primary-0), var(--mantine-color-secondary-0)); + border-bottom: 1px solid var(--mantine-color-primary-2); + color: var(--hi-primary); + animation: bannerSlideDown 0.35s ease-out; + + @media (max-width: 768px) { + flex-wrap: wrap; + padding: 0.65rem 1rem; + } +} + +@keyframes bannerSlideDown { + from { + transform: translateY(-100%); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.bannerIcon { + flex-shrink: 0; + color: var(--hi-primary-alt); +} + +.bannerText { + display: flex; + align-items: baseline; + gap: 0.5rem; + flex: 1; + min-width: 0; + + @media (max-width: 768px) { + flex-direction: column; + gap: 0.15rem; + } +} + +.bannerTitle { + font-weight: 600; + font-size: 0.9375rem; + white-space: nowrap; +} + +.bannerContent { + font-size: 0.9rem; + color: var(--hi-secondary-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + @media (max-width: 768px) { + white-space: normal; + } +} + +.bannerActions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; + margin-left: auto; +} + +.modal { + :global(.mantine-Modal-body) { + padding: 0; + } +} + +.modalContent { + display: flex; + flex-direction: column; + align-items: center; + padding: 2rem; + text-align: center; +} + +.modalEmoji { + margin-bottom: 1rem; +} + +.modalTitle { + font-size: 1.5rem; + font-weight: 700; + color: var(--mantine-color-dark-7); + margin-bottom: 0.5rem; +} + +.modalBody { + color: var(--mantine-color-dimmed); + font-size: 0.95rem; + margin-bottom: 1.5rem; + max-width: 100%; + overflow-wrap: break-word; + + img { + max-width: 100%; + height: auto; + border-radius: var(--hi-radius-md); + } + + a { + color: var(--hi-link-color); + } + + p:last-child { + margin-bottom: 0; + } +} + +.modalActions { + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; +} diff --git a/frontend/src/components/common/AnnouncementDisplay/AnnouncementModal.tsx b/frontend/src/components/common/AnnouncementDisplay/AnnouncementModal.tsx new file mode 100644 index 0000000000..932c679aeb --- /dev/null +++ b/frontend/src/components/common/AnnouncementDisplay/AnnouncementModal.tsx @@ -0,0 +1,58 @@ +import {Button, Modal} from "@mantine/core"; +import {t} from "@lingui/macro"; +import {Announcement} from "../../../api/announcement.client"; +import {BouncingEmoji} from "../BouncingEmoji"; +import classes from "./AnnouncementDisplay.module.scss"; + +interface AnnouncementModalProps { + announcement: Announcement; + onDismiss: () => void; + onClose: () => void; +} + +export const AnnouncementModal = ({announcement, onDismiss, onClose}: AnnouncementModalProps) => ( + +
+
+ +
+ +
{announcement.title}
+ +
+ +
+ {announcement.cta_label && announcement.cta_url && ( + + )} + +
+
+ +); diff --git a/frontend/src/components/common/AnnouncementDisplay/index.tsx b/frontend/src/components/common/AnnouncementDisplay/index.tsx new file mode 100644 index 0000000000..2ad3ce5833 --- /dev/null +++ b/frontend/src/components/common/AnnouncementDisplay/index.tsx @@ -0,0 +1,65 @@ +import {useState} from "react"; +import {useLocation} from "react-router"; +import {useGetActiveAnnouncements} from "../../../queries/useGetActiveAnnouncements"; +import {useDismissAnnouncement} from "../../../mutations/useDismissAnnouncement"; +import {Announcement} from "../../../api/announcement.client"; +import {IdParam} from "../../../types"; +import {AnnouncementBanner} from "./AnnouncementBanner"; +import {AnnouncementModal} from "./AnnouncementModal"; + +const sessionHiddenIds = new Set(); +const sessionClosedModalIds = new Set(); + +const AnnouncementDisplay = () => { + const location = useLocation(); + const isAdminRoute = location.pathname.startsWith('/admin'); + const {data: announcements} = useGetActiveAnnouncements(!isAdminRoute); + const dismissMutation = useDismissAnnouncement(); + const [, setRenderCount] = useState(0); + const rerender = () => setRenderCount((count) => count + 1); + + if (isAdminRoute || !announcements?.length) { + return null; + } + + const isPending = (announcement: Announcement) => !sessionHiddenIds.has(announcement.id); + const banner = announcements.filter(isPending).find((a) => a.display_type === 'BANNER'); + const modal = announcements.filter(isPending).find((a) => a.display_type === 'MODAL'); + const showModal = !!modal && !sessionClosedModalIds.has(modal.id); + + const dismiss = (announcement: Announcement) => { + sessionHiddenIds.add(announcement.id); + rerender(); + dismissMutation.mutate(announcement.id, { + onError: () => { + sessionHiddenIds.delete(announcement.id); + rerender(); + }, + }); + }; + + const closeModal = (announcement: Announcement) => { + sessionClosedModalIds.add(announcement.id); + rerender(); + }; + + return ( + <> + {banner && !showModal && ( + dismiss(banner)} + /> + )} + {showModal && ( + dismiss(modal)} + onClose={() => closeModal(modal)} + /> + )} + + ); +}; + +export default AnnouncementDisplay; diff --git a/frontend/src/components/layouts/Admin/index.tsx b/frontend/src/components/layouts/Admin/index.tsx index 9a6e665c84..0a2e428241 100644 --- a/frontend/src/components/layouts/Admin/index.tsx +++ b/frontend/src/components/layouts/Admin/index.tsx @@ -1,4 +1,4 @@ -import {IconUsers, IconBuildingBank, IconLayoutDashboard, IconCalendar, IconReceipt, IconSettings, IconChartBar, IconAlertTriangle, IconMail} from "@tabler/icons-react"; +import {IconUsers, IconBuildingBank, IconLayoutDashboard, IconCalendar, IconReceipt, IconSettings, IconChartBar, IconAlertTriangle, IconMail, IconSpeakerphone} from "@tabler/icons-react"; import {t} from "@lingui/macro"; import {NavItem, BreadcrumbItem} from "../AppLayout/types"; import AppLayout from "../AppLayout"; @@ -16,6 +16,7 @@ const AdminLayout = () => { {link: 'events', label: t`Events`, icon: IconCalendar}, {link: 'orders', label: t`Orders`, icon: IconReceipt}, {link: 'messages', label: t`Messages`, icon: IconMail}, + {link: 'announcements', label: t`Announcements`, icon: IconSpeakerphone}, {link: 'attribution', label: t`UTM Analytics`, icon: IconChartBar}, {link: 'failed-jobs', label: t`Failed Jobs`, icon: IconAlertTriangle}, {link: 'configurations', label: t`Configurations`, icon: IconSettings}, diff --git a/frontend/src/components/layouts/AppLayout/index.tsx b/frontend/src/components/layouts/AppLayout/index.tsx index f6ed9c52a6..8ee266adc0 100644 --- a/frontend/src/components/layouts/AppLayout/index.tsx +++ b/frontend/src/components/layouts/AppLayout/index.tsx @@ -8,6 +8,7 @@ import {IconLayoutSidebar} from "@tabler/icons-react"; import {UnstyledButton, VisuallyHidden} from "@mantine/core"; import {t} from "@lingui/macro"; import ImpersonationBanner from "../../common/ImpersonationBanner"; +import AnnouncementDisplay from "../../common/AnnouncementDisplay"; interface AppLayoutProps { navItems: NavItem[]; @@ -81,6 +82,7 @@ const AppLayout: React.FC = ({ return ( <> +
{ return ( <> +
}/> diff --git a/frontend/src/components/routes/admin/Announcements/AnnouncementTargetPicker.tsx b/frontend/src/components/routes/admin/Announcements/AnnouncementTargetPicker.tsx new file mode 100644 index 0000000000..d76f9cf006 --- /dev/null +++ b/frontend/src/components/routes/admin/Announcements/AnnouncementTargetPicker.tsx @@ -0,0 +1,127 @@ +import {MultiSelect} from "@mantine/core"; +import {t} from "@lingui/macro"; +import {useEffect, useMemo, useState} from "react"; +import {useGetAllAccounts} from "../../../../queries/useGetAllAccounts"; +import {useGetAllUsers} from "../../../../queries/useGetAllUsers"; +import {AnnouncementTargetType} from "../../../../api/announcement.client"; + +interface AnnouncementTargetPickerProps { + targetType: Exclude; + value: number[]; + onChange: (ids: number[]) => void; + initialLabels: Record; + error?: string; +} + +interface TargetOption { + id: number; + label: string; +} + +interface PickerBaseProps extends Omit { + label: string; + searchResults: TargetOption[]; + isFetching: boolean; + search: string; + onSearchChange: (search: string) => void; +} + +const PickerBase = ({label, searchResults, isFetching, search, onSearchChange, value, onChange, initialLabels, error}: PickerBaseProps) => { + const [knownLabels, setKnownLabels] = useState>(initialLabels); + + useEffect(() => { + if (!searchResults.length) { + return; + } + setKnownLabels((labels) => ({ + ...labels, + ...Object.fromEntries(searchResults.map((result) => [result.id, result.label])), + })); + }, [searchResults]); + + const options = useMemo(() => { + const ids = new Set([...searchResults.map((result) => result.id), ...value]); + return [...ids].map((id) => ({ + value: id.toString(), + label: knownLabels[id] || `#${id}`, + })); + }, [searchResults, value, knownLabels]); + + return ( + id.toString())} + onChange={(ids) => onChange(ids.map(Number))} + searchable + searchValue={search} + onSearchChange={onSearchChange} + filter={({options}) => options} + nothingFoundMessage={isFetching ? t`Searching...` : t`No results found`} + error={error} + data-testid="announcement-target-picker" + /> + ); +}; + +const useDebouncedSearch = () => { + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(search), 300); + return () => clearTimeout(timer); + }, [search]); + + return {search, setSearch, debouncedSearch}; +}; + +const AccountPicker = (props: Omit) => { + const {search, setSearch, debouncedSearch} = useDebouncedSearch(); + const {data, isFetching} = useGetAllAccounts({search: debouncedSearch, per_page: 20}); + + const searchResults = useMemo(() => (data?.data || []).map((account) => ({ + id: Number(account.id), + label: `${account.name} (${account.email})`, + })), [data]); + + return ( + + ); +}; + +const UserPicker = (props: Omit) => { + const {search, setSearch, debouncedSearch} = useDebouncedSearch(); + const {data, isFetching} = useGetAllUsers({search: debouncedSearch, per_page: 20}); + + const searchResults = useMemo(() => (data?.data || []).map((user) => ({ + id: Number(user.id), + label: `${user.full_name || user.email} (${user.email})`, + })), [data]); + + return ( + + ); +}; + +export const AnnouncementTargetPicker = ({targetType, ...props}: AnnouncementTargetPickerProps) => { + return targetType === 'ACCOUNTS' + ? + : ; +}; diff --git a/frontend/src/components/routes/admin/Announcements/Announcements.module.scss b/frontend/src/components/routes/admin/Announcements/Announcements.module.scss new file mode 100644 index 0000000000..0258c787a2 --- /dev/null +++ b/frontend/src/components/routes/admin/Announcements/Announcements.module.scss @@ -0,0 +1,34 @@ +.announcementsTable { + td { + white-space: nowrap; + } + + td:first-child { + white-space: normal; + min-width: 220px; + } + + :global(.mantine-Badge-label) { + overflow: visible; + text-overflow: clip; + } +} + +.emojiPreview { + display: flex; + align-items: center; + justify-content: center; + min-width: 60px; + padding-bottom: 4px; +} + +.bannerPreview { + border: 1px dashed var(--hi-color-gray-2); + border-radius: var(--hi-radius-md); + overflow: hidden; + + > div { + position: static; + animation: none; + } +} diff --git a/frontend/src/components/routes/admin/Announcements/index.tsx b/frontend/src/components/routes/admin/Announcements/index.tsx new file mode 100644 index 0000000000..6c4549b469 --- /dev/null +++ b/frontend/src/components/routes/admin/Announcements/index.tsx @@ -0,0 +1,519 @@ +import { + ActionIcon, + Badge, + Button, + Container, + Group, + Menu, + Pagination, + SegmentedControl, + Select, + Skeleton, + Stack, + Table, + Text, + TextInput, + Title, + Tooltip, +} from "@mantine/core"; +import {t} from "@lingui/macro"; +import { + IconCopy, + IconDotsVertical, + IconEdit, + IconEye, + IconPlus, + IconSearch, + IconSpeakerphone, + IconTrash, +} from "@tabler/icons-react"; +import {useEffect, useState} from "react"; +import {useForm} from "@mantine/form"; +import {useGetAdminAnnouncements} from "../../../../queries/useGetAdminAnnouncements"; +import {useCreateAnnouncement} from "../../../../mutations/useCreateAnnouncement"; +import {useUpdateAnnouncement} from "../../../../mutations/useUpdateAnnouncement"; +import {useDeleteAnnouncement} from "../../../../mutations/useDeleteAnnouncement"; +import { + AdminAnnouncement, + AnnouncementDisplayType, + AnnouncementStatus, + AnnouncementTargetType, + UpsertAnnouncementData, +} from "../../../../api/announcement.client"; +import {Modal} from "../../../common/Modal"; +import {Editor} from "../../../common/Editor"; +import {BouncingEmoji} from "../../../common/BouncingEmoji"; +import {AnnouncementBanner} from "../../../common/AnnouncementDisplay/AnnouncementBanner"; +import {AnnouncementModal} from "../../../common/AnnouncementDisplay/AnnouncementModal"; +import {AnnouncementTargetPicker} from "./AnnouncementTargetPicker"; +import {showError, showSuccess} from "../../../../utilites/notifications"; +import {useFormErrorResponseHandler} from "../../../../hooks/useFormErrorResponseHandler"; +import tableStyles from "../../../../styles/admin-table.module.scss"; +import classes from "./Announcements.module.scss"; + +interface AnnouncementFormValues { + title: string; + content: string; + display_type: AnnouncementDisplayType; + emoji: string; + target_type: AnnouncementTargetType; + target_account_ids: number[]; + target_user_ids: number[]; + cta_label: string; + cta_url: string; + status: AnnouncementStatus; +} + +const targetSummary = (announcement: AdminAnnouncement) => { + const names = Object.values(announcement.target_names || {}); + + if (announcement.target_type === 'ACCOUNTS') { + return {label: t`Accounts (${names.length})`, tooltip: names.join(', ')}; + } + if (announcement.target_type === 'USERS') { + return {label: t`Users (${names.length})`, tooltip: names.join(', ')}; + } + return {label: t`All users`, tooltip: undefined}; +}; + +const Announcements = () => { + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [showCreateModal, setShowCreateModal] = useState(false); + const [editingAnnouncement, setEditingAnnouncement] = useState(null); + const [duplicatingAnnouncement, setDuplicatingAnnouncement] = useState(null); + + const {data: announcementsData, isLoading} = useGetAdminAnnouncements({ + page, + per_page: 20, + search: debouncedSearch, + }); + const deleteMutation = useDeleteAnnouncement(); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, 500); + + return () => clearTimeout(timer); + }, [search]); + + const announcements = announcementsData?.data || []; + + const handleDelete = (announcement: AdminAnnouncement) => { + if (window.confirm(t`Are you sure you want to delete this announcement? Users will no longer see it.`)) { + deleteMutation.mutate(announcement.id, { + onSuccess: () => showSuccess(t`Announcement deleted`), + onError: () => showError(t`Failed to delete announcement`), + }); + } + }; + + return ( + <> + + + + {t`Announcements`} + + + + } + value={search} + onChange={(e) => setSearch(e.target.value)} + /> + + {isLoading ? ( + + + + + ) : announcements.length === 0 ? ( +
+ + + {t`No announcements yet`} + + {t`Create an announcement to show a message to your users.`} + + +
+ ) : ( +
+
+ + + + {t`Title`} + {t`Type`} + {t`Status`} + {t`Audience`} + {t`Seen`} + {t`Dismissed`} + {t`Updated`} + + + + + {announcements.map((announcement) => { + const target = targetSummary(announcement); + const isDraft = announcement.status === 'DRAFT'; + return ( + + + {announcement.title} + + + + {announcement.display_type === 'MODAL' ? t`Modal` : t`Banner`} + + + + + {isDraft ? t`Draft` : t`Published`} + + + + + {target.label} + + + {isDraft ? '—' : announcement.seen_count} + {isDraft ? '—' : announcement.dismissed_count} + + + {new Date(announcement.updated_at).toLocaleDateString()} + + + + + + + + + + + } + onClick={() => setEditingAnnouncement(announcement)} + data-testid="announcement-edit-menu-item" + > + {t`Edit`} + + } + onClick={() => setDuplicatingAnnouncement(announcement)} + data-testid="announcement-duplicate-menu-item" + > + {t`Duplicate`} + + } + onClick={() => handleDelete(announcement)} + data-testid="announcement-delete-menu-item" + > + {t`Delete`} + + + + + + ); + })} + +
+
+
+ )} + + {announcementsData?.meta && announcementsData.meta.last_page > 1 && ( + + )} +
+
+ + {showCreateModal && ( + setShowCreateModal(false)}/> + )} + + {editingAnnouncement && ( + setEditingAnnouncement(null)} + /> + )} + + {duplicatingAnnouncement && ( + setDuplicatingAnnouncement(null)} + /> + )} + + ); +}; + +interface AnnouncementFormModalProps { + announcement?: AdminAnnouncement; + duplicate?: boolean; + onClose: () => void; +} + +const AnnouncementFormModal = ({announcement, duplicate, onClose}: AnnouncementFormModalProps) => { + const createMutation = useCreateAnnouncement(); + const updateMutation = useUpdateAnnouncement(); + const formErrorHandler = useFormErrorResponseHandler(); + const [previewing, setPreviewing] = useState(false); + const isEditing = !!announcement && !duplicate; + + const form = useForm({ + initialValues: { + title: (duplicate ? t`${announcement?.title} (copy)` : announcement?.title) || '', + content: announcement?.content || '', + display_type: announcement?.display_type || 'BANNER', + emoji: announcement?.emoji || '🎉', + target_type: announcement?.target_type || 'ALL', + target_account_ids: announcement?.target_account_ids || [], + target_user_ids: announcement?.target_user_ids || [], + cta_label: announcement?.cta_label || '', + cta_url: announcement?.cta_url || '', + status: (duplicate ? 'DRAFT' : announcement?.status) || 'DRAFT', + }, + validate: { + title: (value) => value.trim() ? null : t`Title is required`, + content: (value, values) => { + if (!value.trim()) return t`Content is required`; + if (values.display_type === 'BANNER' && value.length > 200) { + return t`Banner content must be 200 characters or less`; + } + return null; + }, + emoji: (value, values) => + values.display_type === 'MODAL' && !value.trim() ? t`Emoji is required for modals` : null, + target_account_ids: (value, values) => + values.target_type === 'ACCOUNTS' && value.length === 0 ? t`Select at least one account` : null, + target_user_ids: (value, values) => + values.target_type === 'USERS' && value.length === 0 ? t`Select at least one user` : null, + cta_url: (value, values) => { + if (values.cta_label && !value) return t`A URL is required when a button label is set`; + if (value && !/^https?:\/\//.test(value)) return t`URL must start with http:// or https://`; + return null; + }, + cta_label: (value, values) => + values.cta_url && !value ? t`A button label is required when a URL is set` : null, + }, + }); + + const toPayload = (values: AnnouncementFormValues): UpsertAnnouncementData => ({ + title: values.title, + content: values.content, + status: values.status, + display_type: values.display_type, + emoji: values.display_type === 'MODAL' ? values.emoji : null, + target_type: values.target_type, + target_account_ids: values.target_type === 'ACCOUNTS' ? values.target_account_ids : undefined, + target_user_ids: values.target_type === 'USERS' ? values.target_user_ids : undefined, + cta_label: values.cta_label || null, + cta_url: values.cta_url || null, + }); + + const handleSubmit = (values: AnnouncementFormValues) => { + const payload = toPayload(values); + const onSuccess = () => { + showSuccess(isEditing ? t`Announcement updated` : t`Announcement created`); + onClose(); + }; + const onError = (error: any) => formErrorHandler(form, error); + + if (isEditing) { + updateMutation.mutate({announcementId: announcement.id, data: payload}, {onSuccess, onError}); + } else { + createMutation.mutate(payload, {onSuccess, onError}); + } + }; + + const previewAnnouncement = { + id: 0, + title: form.values.title || t`Announcement title`, + content: form.values.content || t`Announcement content`, + display_type: form.values.display_type, + emoji: form.values.emoji, + cta_label: form.values.cta_label || null, + cta_url: form.values.cta_url || null, + }; + + const isBanner = form.values.display_type === 'BANNER'; + + return ( + +
+ + + + + + {isBanner ? ( + + ) : ( + form.setFieldValue('content', value)} + error={form.errors.content as string} + editorType="simple" + /> + )} + + {!isBanner && ( + + +
+ +
+
+ )} + +
+ {t`Audience`} + +
+ + {form.values.target_type === 'ACCOUNTS' && ( + form.setFieldValue('target_account_ids', ids)} + initialLabels={announcement?.target_type === 'ACCOUNTS' ? announcement.target_names : {}} + error={form.errors.target_account_ids as string} + /> + )} + + {form.values.target_type === 'USERS' && ( + form.setFieldValue('target_user_ids', ids)} + initialLabels={announcement?.target_type === 'USERS' ? announcement.target_names : {}} + error={form.errors.target_user_ids as string} + /> + )} + + + + + + +