From ea47b345a4c6653518c9173e7f5d17c510643450 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sat, 28 Feb 2026 08:15:30 +1100 Subject: [PATCH 1/3] Initial community app work --- package.json | 1 + src/apps/community/index.ts | 1 + src/apps/community/src/CommunityApp.tsx | 104 +++ .../community/src/community-app.routes.tsx | 48 ++ .../src/config/community-id.config.ts | 41 ++ src/apps/community/src/config/index.config.ts | 50 ++ .../community/src/config/routes.config.ts | 19 + src/apps/community/src/index.ts | 2 + .../AccessDenied/AccessDenied.module.scss | 37 + .../components/AccessDenied/AccessDenied.tsx | 72 ++ .../src/lib/components/AccessDenied/index.ts | 2 + .../CommunityHeader.module.scss | 225 ++++++ .../CommunityHeader/CommunityHeader.tsx | 385 ++++++++++ .../lib/components/CommunityHeader/index.ts | 5 + .../CommunityLayout.module.scss | 20 + .../CommunityLayout/CommunityLayout.tsx | 55 ++ .../lib/components/CommunityLayout/index.ts | 2 + .../lib/components/Layout/Layout.module.scss | 29 + .../src/lib/components/Layout/Layout.tsx | 52 ++ .../src/lib/components/Layout/index.ts | 2 + .../TermsModal/TermsModal.module.scss | 140 ++++ .../lib/components/TermsModal/TermsModal.tsx | 284 ++++++++ .../src/lib/components/TermsModal/index.ts | 2 + .../community/src/lib/components/index.ts | 5 + src/apps/community/src/lib/hooks/index.ts | 13 + .../community/src/lib/hooks/useChallenge.ts | 33 + .../community/src/lib/hooks/useChallenges.ts | 48 ++ .../src/lib/hooks/useCommunityMeta.ts | 33 + .../src/lib/hooks/useMemberSubmissions.ts | 56 ++ .../src/lib/hooks/useMySubmissions.ts | 60 ++ .../community/src/lib/hooks/useRegistrants.ts | 33 + .../community/src/lib/hooks/useSubmissions.ts | 33 + .../src/lib/hooks/useSubmitChallenge.ts | 75 ++ src/apps/community/src/lib/hooks/useTerms.ts | 376 ++++++++++ .../src/lib/hooks/useThriveArticle.ts | 34 + .../src/lib/hooks/useThriveArticles.ts | 34 + .../src/lib/hooks/useTimelineWall.ts | 252 +++++++ .../community/src/lib/hooks/useUserGroups.ts | 56 ++ src/apps/community/src/lib/index.ts | 5 + .../lib/models/BackendChallengeInfo.model.ts | 268 +++++++ .../lib/models/BackendCommunityMeta.model.ts | 40 ++ .../src/lib/models/BackendSubmission.model.ts | 16 + .../src/lib/models/ChallengeInfo.model.ts | 51 ++ .../src/lib/models/CommunityMeta.model.ts | 39 ++ .../src/lib/models/SubmissionInfo.model.ts | 30 + .../src/lib/models/TermInfo.model.ts | 39 ++ .../src/lib/models/ThriveArticle.model.ts | 152 ++++ .../src/lib/models/TimelineEvent.model.ts | 53 ++ src/apps/community/src/lib/models/index.ts | 9 + .../src/lib/services/challenges.service.ts | 169 +++++ .../src/lib/services/communities.service.ts | 39 ++ .../src/lib/services/contentful.service.ts | 234 +++++++ src/apps/community/src/lib/services/index.ts | 6 + .../src/lib/services/submissions.service.ts | 171 +++++ .../src/lib/services/terms.service.ts | 84 +++ .../src/lib/services/timeline-wall.service.ts | 206 ++++++ src/apps/community/src/lib/styles/index.scss | 30 + .../src/lib/utils/challenge-detail.utils.ts | 159 +++++ .../lib/utils/challenge-listing.buckets.ts | 79 +++ src/apps/community/src/lib/utils/index.ts | 2 + .../ChallengeDetail.module.scss | 51 ++ .../challenge-detail/ChallengeDetail.tsx | 655 ++++++++++++++++++ .../challenge-detail.routes.tsx | 16 + .../ChallengeHeader.module.scss | 190 +++++ .../ChallengeHeader/ChallengeHeader.tsx | 302 ++++++++ .../components/ChallengeHeader/index.ts | 1 + .../Checkpoints/Checkpoints.module.scss | 63 ++ .../components/Checkpoints/Checkpoints.tsx | 74 ++ .../components/Checkpoints/index.ts | 1 + .../Registrants/Registrants.module.scss | 105 +++ .../components/Registrants/Registrants.tsx | 384 ++++++++++ .../components/Registrants/index.ts | 1 + .../SecurityReminderModal.module.scss | 13 + .../SecurityReminderModal.tsx | 45 ++ .../components/SecurityReminderModal/index.ts | 1 + .../Specification/Specification.module.scss | 101 +++ .../Specification/Specification.tsx | 294 ++++++++ .../components/Specification/index.ts | 1 + .../Submissions/Submissions.module.scss | 108 +++ .../components/Submissions/Submissions.tsx | 261 +++++++ .../components/Submissions/index.ts | 1 + .../ThriveArticlesSidebar.module.scss | 70 ++ .../ThriveArticlesSidebar.tsx | 80 +++ .../components/ThriveArticlesSidebar/index.ts | 1 + .../components/Winners/Winners.module.scss | 86 +++ .../components/Winners/Winners.tsx | 165 +++++ .../components/Winners/index.ts | 1 + .../src/pages/challenge-detail/index.ts | 2 + .../ChallengeCard/ChallengeCard.module.scss | 89 +++ .../ChallengeCard/ChallengeCard.tsx | 155 +++++ .../challenge-listing/ChallengeCard/index.ts | 2 + .../ChallengeFilters.module.scss | 63 ++ .../ChallengeFilters/ChallengeFilters.tsx | 82 +++ .../ChallengeFilters/index.ts | 2 + .../ChallengeListing.module.scss | 69 ++ .../challenge-listing/ChallengeListing.tsx | 294 ++++++++ .../challenge-listing.routes.tsx | 16 + .../src/pages/challenge-listing/index.ts | 2 + .../pages/changelog/ChangelogPage.module.scss | 64 ++ .../src/pages/changelog/ChangelogPage.tsx | 154 ++++ .../src/pages/changelog/changelog.routes.tsx | 22 + .../community/src/pages/changelog/index.ts | 1 + .../CommunityContentPage.tsx | 173 +++++ .../communities/CommunityContentPage/index.ts | 1 + .../community.context-provider.tsx | 23 + .../CommunityContext/community.context.ts | 19 + .../communities/CommunityContext/index.ts | 2 + .../CommunityLeaderboardPage.module.scss | 58 ++ .../CommunityLeaderboardPage.tsx | 102 +++ .../CommunityLeaderboardPage/index.ts | 1 + .../CommunityLoader/CommunityLoader.tsx | 139 ++++ .../communities/CommunityLoader/index.ts | 1 + .../pages/communities/community.routes.tsx | 187 +++++ .../community/src/pages/communities/index.ts | 4 + .../src/pages/home/HomePage.module.scss | 27 + .../community/src/pages/home/HomePage.tsx | 51 ++ .../ChallengesFeedPanel.module.scss | 78 +++ .../ChallengesFeedPanel.tsx | 110 +++ .../components/ChallengesFeedPanel/index.ts | 1 + .../MySubmissionsPanel.module.scss | 91 +++ .../MySubmissionsPanel/MySubmissionsPanel.tsx | 117 ++++ .../components/MySubmissionsPanel/index.ts | 1 + .../TCTimeWidget/TCTimeWidget.module.scss | 21 + .../components/TCTimeWidget/TCTimeWidget.tsx | 47 ++ .../home/components/TCTimeWidget/index.ts | 1 + .../ThriveArticlesFeedPanel.module.scss | 62 ++ .../ThriveArticlesFeedPanel.tsx | 73 ++ .../ThriveArticlesFeedPanel/index.ts | 1 + .../community/src/pages/home/home.routes.tsx | 20 + src/apps/community/src/pages/home/index.ts | 1 + src/apps/community/src/pages/index.ts | 9 + .../SubmissionManagementPage.module.scss | 84 +++ .../SubmissionManagementPage.tsx | 262 +++++++ .../DownloadArtifactsModal.module.scss | 49 ++ .../DownloadArtifactsModal.tsx | 147 ++++ .../DownloadArtifactsModal/index.ts | 1 + .../ScreeningDetails.module.scss | 64 ++ .../ScreeningDetails/ScreeningDetails.tsx | 147 ++++ .../components/ScreeningDetails/index.ts | 1 + .../SubmissionsTable.module.scss | 39 ++ .../SubmissionsTable/SubmissionsTable.tsx | 403 +++++++++++ .../components/SubmissionsTable/index.ts | 1 + .../WorkflowRuns/WorkflowRuns.module.scss | 23 + .../components/WorkflowRuns/WorkflowRuns.tsx | 127 ++++ .../components/WorkflowRuns/index.ts | 1 + .../src/pages/submission-management/index.ts | 1 + .../submission-management.routes.tsx | 16 + .../submission/SubmissionPage.module.scss | 132 ++++ .../src/pages/submission/SubmissionPage.tsx | 424 ++++++++++++ .../FilePicker/FilePicker.module.scss | 78 +++ .../components/FilePicker/FilePicker.tsx | 326 +++++++++ .../submission/components/FilePicker/index.ts | 1 + .../UploadingState/UploadingState.module.scss | 56 ++ .../UploadingState/UploadingState.tsx | 98 +++ .../components/UploadingState/index.ts | 1 + .../community/src/pages/submission/index.ts | 1 + .../pages/submission/submission.routes.tsx | 16 + .../thrive/ThriveArticlePage.module.scss | 119 ++++ .../src/pages/thrive/ThriveArticlePage.tsx | 143 ++++ src/apps/community/src/pages/thrive/index.ts | 1 + .../src/pages/thrive/thrive.routes.tsx | 19 + .../TimelineWallPage.module.scss | 203 ++++++ .../pages/timeline-wall/TimelineWallPage.tsx | 368 ++++++++++ .../assets/top-banner-mobile.png | Bin 0 -> 188548 bytes .../pages/timeline-wall/assets/top-banner.png | Bin 0 -> 904786 bytes .../AddEventButton/AddEventButton.module.scss | 163 +++++ .../AddEventButton/AddEventButton.tsx | 244 +++++++ .../components/AddEventButton/index.ts | 1 + .../ApprovalItem/ApprovalItem.module.scss | 141 ++++ .../components/ApprovalItem/ApprovalItem.tsx | 150 ++++ .../components/ApprovalItem/index.ts | 1 + .../EventItem/EventItem.module.scss | 168 +++++ .../components/EventItem/EventItem.tsx | 165 +++++ .../components/EventItem/index.ts | 1 + .../ModalConfirmReject.module.scss | 38 + .../ModalConfirmReject/ModalConfirmReject.tsx | 115 +++ .../components/ModalConfirmReject/index.ts | 1 + .../ModalDeleteConfirmation.module.scss | 6 + .../ModalDeleteConfirmation.tsx | 54 ++ .../ModalDeleteConfirmation/index.ts | 1 + .../ModalEventAdd/ModalEventAdd.module.scss | 21 + .../ModalEventAdd/ModalEventAdd.tsx | 65 ++ .../components/ModalEventAdd/index.ts | 1 + .../ModalPhotoViewer.module.scss | 68 ++ .../ModalPhotoViewer/ModalPhotoViewer.tsx | 117 ++++ .../components/ModalPhotoViewer/index.ts | 1 + .../PendingApprovals.module.scss | 16 + .../PendingApprovals/PendingApprovals.tsx | 53 ++ .../components/PendingApprovals/index.ts | 1 + .../RightFilter/RightFilter.module.scss | 96 +++ .../components/RightFilter/RightFilter.tsx | 186 +++++ .../components/RightFilter/index.ts | 1 + .../TimelineEvents/TimelineEvents.module.scss | 100 +++ .../TimelineEvents/TimelineEvents.tsx | 174 +++++ .../components/TimelineEvents/index.ts | 1 + .../pages/timeline-wall/components/index.ts | 10 + .../src/pages/timeline-wall/index.ts | 1 + .../timeline-wall/timeline-wall.routes.tsx | 22 + src/apps/platform/src/PlatformApp.tsx | 36 +- .../src/components/app-header/AppHeader.tsx | 18 +- src/apps/platform/src/platform.routes.tsx | 2 + .../providers/platform-router.provider.tsx | 7 +- .../ChallengeDetailsContent.tsx | 21 +- src/apps/review/src/lib/constants.ts | 6 + .../src/lib/hooks/useFetchScreeningReview.ts | 11 +- .../ChallengeDetailsPage.tsx | 2 + src/config/constants.ts | 6 +- src/config/environments/default.env.ts | 18 + .../environments/global-config.model.ts | 10 + .../core/lib/router/platform-route.model.ts | 3 + .../router.context-provider.tsx | 1 + .../router/router-context/router.context.tsx | 4 +- yarn.lock | 56 +- 213 files changed, 15819 insertions(+), 26 deletions(-) create mode 100644 src/apps/community/index.ts create mode 100644 src/apps/community/src/CommunityApp.tsx create mode 100644 src/apps/community/src/community-app.routes.tsx create mode 100644 src/apps/community/src/config/community-id.config.ts create mode 100644 src/apps/community/src/config/index.config.ts create mode 100644 src/apps/community/src/config/routes.config.ts create mode 100644 src/apps/community/src/index.ts create mode 100644 src/apps/community/src/lib/components/AccessDenied/AccessDenied.module.scss create mode 100644 src/apps/community/src/lib/components/AccessDenied/AccessDenied.tsx create mode 100644 src/apps/community/src/lib/components/AccessDenied/index.ts create mode 100644 src/apps/community/src/lib/components/CommunityHeader/CommunityHeader.module.scss create mode 100644 src/apps/community/src/lib/components/CommunityHeader/CommunityHeader.tsx create mode 100644 src/apps/community/src/lib/components/CommunityHeader/index.ts create mode 100644 src/apps/community/src/lib/components/CommunityLayout/CommunityLayout.module.scss create mode 100644 src/apps/community/src/lib/components/CommunityLayout/CommunityLayout.tsx create mode 100644 src/apps/community/src/lib/components/CommunityLayout/index.ts create mode 100644 src/apps/community/src/lib/components/Layout/Layout.module.scss create mode 100644 src/apps/community/src/lib/components/Layout/Layout.tsx create mode 100644 src/apps/community/src/lib/components/Layout/index.ts create mode 100644 src/apps/community/src/lib/components/TermsModal/TermsModal.module.scss create mode 100644 src/apps/community/src/lib/components/TermsModal/TermsModal.tsx create mode 100644 src/apps/community/src/lib/components/TermsModal/index.ts create mode 100644 src/apps/community/src/lib/components/index.ts create mode 100644 src/apps/community/src/lib/hooks/index.ts create mode 100644 src/apps/community/src/lib/hooks/useChallenge.ts create mode 100644 src/apps/community/src/lib/hooks/useChallenges.ts create mode 100644 src/apps/community/src/lib/hooks/useCommunityMeta.ts create mode 100644 src/apps/community/src/lib/hooks/useMemberSubmissions.ts create mode 100644 src/apps/community/src/lib/hooks/useMySubmissions.ts create mode 100644 src/apps/community/src/lib/hooks/useRegistrants.ts create mode 100644 src/apps/community/src/lib/hooks/useSubmissions.ts create mode 100644 src/apps/community/src/lib/hooks/useSubmitChallenge.ts create mode 100644 src/apps/community/src/lib/hooks/useTerms.ts create mode 100644 src/apps/community/src/lib/hooks/useThriveArticle.ts create mode 100644 src/apps/community/src/lib/hooks/useThriveArticles.ts create mode 100644 src/apps/community/src/lib/hooks/useTimelineWall.ts create mode 100644 src/apps/community/src/lib/hooks/useUserGroups.ts create mode 100644 src/apps/community/src/lib/index.ts create mode 100644 src/apps/community/src/lib/models/BackendChallengeInfo.model.ts create mode 100644 src/apps/community/src/lib/models/BackendCommunityMeta.model.ts create mode 100644 src/apps/community/src/lib/models/BackendSubmission.model.ts create mode 100644 src/apps/community/src/lib/models/ChallengeInfo.model.ts create mode 100644 src/apps/community/src/lib/models/CommunityMeta.model.ts create mode 100644 src/apps/community/src/lib/models/SubmissionInfo.model.ts create mode 100644 src/apps/community/src/lib/models/TermInfo.model.ts create mode 100644 src/apps/community/src/lib/models/ThriveArticle.model.ts create mode 100644 src/apps/community/src/lib/models/TimelineEvent.model.ts create mode 100644 src/apps/community/src/lib/models/index.ts create mode 100644 src/apps/community/src/lib/services/challenges.service.ts create mode 100644 src/apps/community/src/lib/services/communities.service.ts create mode 100644 src/apps/community/src/lib/services/contentful.service.ts create mode 100644 src/apps/community/src/lib/services/index.ts create mode 100644 src/apps/community/src/lib/services/submissions.service.ts create mode 100644 src/apps/community/src/lib/services/terms.service.ts create mode 100644 src/apps/community/src/lib/services/timeline-wall.service.ts create mode 100644 src/apps/community/src/lib/styles/index.scss create mode 100644 src/apps/community/src/lib/utils/challenge-detail.utils.ts create mode 100644 src/apps/community/src/lib/utils/challenge-listing.buckets.ts create mode 100644 src/apps/community/src/lib/utils/index.ts create mode 100644 src/apps/community/src/pages/challenge-detail/ChallengeDetail.module.scss create mode 100644 src/apps/community/src/pages/challenge-detail/ChallengeDetail.tsx create mode 100644 src/apps/community/src/pages/challenge-detail/challenge-detail.routes.tsx create mode 100644 src/apps/community/src/pages/challenge-detail/components/ChallengeHeader/ChallengeHeader.module.scss create mode 100644 src/apps/community/src/pages/challenge-detail/components/ChallengeHeader/ChallengeHeader.tsx create mode 100644 src/apps/community/src/pages/challenge-detail/components/ChallengeHeader/index.ts create mode 100644 src/apps/community/src/pages/challenge-detail/components/Checkpoints/Checkpoints.module.scss create mode 100644 src/apps/community/src/pages/challenge-detail/components/Checkpoints/Checkpoints.tsx create mode 100644 src/apps/community/src/pages/challenge-detail/components/Checkpoints/index.ts create mode 100644 src/apps/community/src/pages/challenge-detail/components/Registrants/Registrants.module.scss create mode 100644 src/apps/community/src/pages/challenge-detail/components/Registrants/Registrants.tsx create mode 100644 src/apps/community/src/pages/challenge-detail/components/Registrants/index.ts create mode 100644 src/apps/community/src/pages/challenge-detail/components/SecurityReminderModal/SecurityReminderModal.module.scss create mode 100644 src/apps/community/src/pages/challenge-detail/components/SecurityReminderModal/SecurityReminderModal.tsx create mode 100644 src/apps/community/src/pages/challenge-detail/components/SecurityReminderModal/index.ts create mode 100644 src/apps/community/src/pages/challenge-detail/components/Specification/Specification.module.scss create mode 100644 src/apps/community/src/pages/challenge-detail/components/Specification/Specification.tsx create mode 100644 src/apps/community/src/pages/challenge-detail/components/Specification/index.ts create mode 100644 src/apps/community/src/pages/challenge-detail/components/Submissions/Submissions.module.scss create mode 100644 src/apps/community/src/pages/challenge-detail/components/Submissions/Submissions.tsx create mode 100644 src/apps/community/src/pages/challenge-detail/components/Submissions/index.ts create mode 100644 src/apps/community/src/pages/challenge-detail/components/ThriveArticlesSidebar/ThriveArticlesSidebar.module.scss create mode 100644 src/apps/community/src/pages/challenge-detail/components/ThriveArticlesSidebar/ThriveArticlesSidebar.tsx create mode 100644 src/apps/community/src/pages/challenge-detail/components/ThriveArticlesSidebar/index.ts create mode 100644 src/apps/community/src/pages/challenge-detail/components/Winners/Winners.module.scss create mode 100644 src/apps/community/src/pages/challenge-detail/components/Winners/Winners.tsx create mode 100644 src/apps/community/src/pages/challenge-detail/components/Winners/index.ts create mode 100644 src/apps/community/src/pages/challenge-detail/index.ts create mode 100644 src/apps/community/src/pages/challenge-listing/ChallengeCard/ChallengeCard.module.scss create mode 100644 src/apps/community/src/pages/challenge-listing/ChallengeCard/ChallengeCard.tsx create mode 100644 src/apps/community/src/pages/challenge-listing/ChallengeCard/index.ts create mode 100644 src/apps/community/src/pages/challenge-listing/ChallengeFilters/ChallengeFilters.module.scss create mode 100644 src/apps/community/src/pages/challenge-listing/ChallengeFilters/ChallengeFilters.tsx create mode 100644 src/apps/community/src/pages/challenge-listing/ChallengeFilters/index.ts create mode 100644 src/apps/community/src/pages/challenge-listing/ChallengeListing.module.scss create mode 100644 src/apps/community/src/pages/challenge-listing/ChallengeListing.tsx create mode 100644 src/apps/community/src/pages/challenge-listing/challenge-listing.routes.tsx create mode 100644 src/apps/community/src/pages/challenge-listing/index.ts create mode 100644 src/apps/community/src/pages/changelog/ChangelogPage.module.scss create mode 100644 src/apps/community/src/pages/changelog/ChangelogPage.tsx create mode 100644 src/apps/community/src/pages/changelog/changelog.routes.tsx create mode 100644 src/apps/community/src/pages/changelog/index.ts create mode 100644 src/apps/community/src/pages/communities/CommunityContentPage/CommunityContentPage.tsx create mode 100644 src/apps/community/src/pages/communities/CommunityContentPage/index.ts create mode 100644 src/apps/community/src/pages/communities/CommunityContext/community.context-provider.tsx create mode 100644 src/apps/community/src/pages/communities/CommunityContext/community.context.ts create mode 100644 src/apps/community/src/pages/communities/CommunityContext/index.ts create mode 100644 src/apps/community/src/pages/communities/CommunityLeaderboardPage/CommunityLeaderboardPage.module.scss create mode 100644 src/apps/community/src/pages/communities/CommunityLeaderboardPage/CommunityLeaderboardPage.tsx create mode 100644 src/apps/community/src/pages/communities/CommunityLeaderboardPage/index.ts create mode 100644 src/apps/community/src/pages/communities/CommunityLoader/CommunityLoader.tsx create mode 100644 src/apps/community/src/pages/communities/CommunityLoader/index.ts create mode 100644 src/apps/community/src/pages/communities/community.routes.tsx create mode 100644 src/apps/community/src/pages/communities/index.ts create mode 100644 src/apps/community/src/pages/home/HomePage.module.scss create mode 100644 src/apps/community/src/pages/home/HomePage.tsx create mode 100644 src/apps/community/src/pages/home/components/ChallengesFeedPanel/ChallengesFeedPanel.module.scss create mode 100644 src/apps/community/src/pages/home/components/ChallengesFeedPanel/ChallengesFeedPanel.tsx create mode 100644 src/apps/community/src/pages/home/components/ChallengesFeedPanel/index.ts create mode 100644 src/apps/community/src/pages/home/components/MySubmissionsPanel/MySubmissionsPanel.module.scss create mode 100644 src/apps/community/src/pages/home/components/MySubmissionsPanel/MySubmissionsPanel.tsx create mode 100644 src/apps/community/src/pages/home/components/MySubmissionsPanel/index.ts create mode 100644 src/apps/community/src/pages/home/components/TCTimeWidget/TCTimeWidget.module.scss create mode 100644 src/apps/community/src/pages/home/components/TCTimeWidget/TCTimeWidget.tsx create mode 100644 src/apps/community/src/pages/home/components/TCTimeWidget/index.ts create mode 100644 src/apps/community/src/pages/home/components/ThriveArticlesFeedPanel/ThriveArticlesFeedPanel.module.scss create mode 100644 src/apps/community/src/pages/home/components/ThriveArticlesFeedPanel/ThriveArticlesFeedPanel.tsx create mode 100644 src/apps/community/src/pages/home/components/ThriveArticlesFeedPanel/index.ts create mode 100644 src/apps/community/src/pages/home/home.routes.tsx create mode 100644 src/apps/community/src/pages/home/index.ts create mode 100644 src/apps/community/src/pages/index.ts create mode 100644 src/apps/community/src/pages/submission-management/SubmissionManagementPage.module.scss create mode 100644 src/apps/community/src/pages/submission-management/SubmissionManagementPage.tsx create mode 100644 src/apps/community/src/pages/submission-management/components/DownloadArtifactsModal/DownloadArtifactsModal.module.scss create mode 100644 src/apps/community/src/pages/submission-management/components/DownloadArtifactsModal/DownloadArtifactsModal.tsx create mode 100644 src/apps/community/src/pages/submission-management/components/DownloadArtifactsModal/index.ts create mode 100644 src/apps/community/src/pages/submission-management/components/ScreeningDetails/ScreeningDetails.module.scss create mode 100644 src/apps/community/src/pages/submission-management/components/ScreeningDetails/ScreeningDetails.tsx create mode 100644 src/apps/community/src/pages/submission-management/components/ScreeningDetails/index.ts create mode 100644 src/apps/community/src/pages/submission-management/components/SubmissionsTable/SubmissionsTable.module.scss create mode 100644 src/apps/community/src/pages/submission-management/components/SubmissionsTable/SubmissionsTable.tsx create mode 100644 src/apps/community/src/pages/submission-management/components/SubmissionsTable/index.ts create mode 100644 src/apps/community/src/pages/submission-management/components/WorkflowRuns/WorkflowRuns.module.scss create mode 100644 src/apps/community/src/pages/submission-management/components/WorkflowRuns/WorkflowRuns.tsx create mode 100644 src/apps/community/src/pages/submission-management/components/WorkflowRuns/index.ts create mode 100644 src/apps/community/src/pages/submission-management/index.ts create mode 100644 src/apps/community/src/pages/submission-management/submission-management.routes.tsx create mode 100644 src/apps/community/src/pages/submission/SubmissionPage.module.scss create mode 100644 src/apps/community/src/pages/submission/SubmissionPage.tsx create mode 100644 src/apps/community/src/pages/submission/components/FilePicker/FilePicker.module.scss create mode 100644 src/apps/community/src/pages/submission/components/FilePicker/FilePicker.tsx create mode 100644 src/apps/community/src/pages/submission/components/FilePicker/index.ts create mode 100644 src/apps/community/src/pages/submission/components/UploadingState/UploadingState.module.scss create mode 100644 src/apps/community/src/pages/submission/components/UploadingState/UploadingState.tsx create mode 100644 src/apps/community/src/pages/submission/components/UploadingState/index.ts create mode 100644 src/apps/community/src/pages/submission/index.ts create mode 100644 src/apps/community/src/pages/submission/submission.routes.tsx create mode 100644 src/apps/community/src/pages/thrive/ThriveArticlePage.module.scss create mode 100644 src/apps/community/src/pages/thrive/ThriveArticlePage.tsx create mode 100644 src/apps/community/src/pages/thrive/index.ts create mode 100644 src/apps/community/src/pages/thrive/thrive.routes.tsx create mode 100644 src/apps/community/src/pages/timeline-wall/TimelineWallPage.module.scss create mode 100644 src/apps/community/src/pages/timeline-wall/TimelineWallPage.tsx create mode 100644 src/apps/community/src/pages/timeline-wall/assets/top-banner-mobile.png create mode 100644 src/apps/community/src/pages/timeline-wall/assets/top-banner.png create mode 100644 src/apps/community/src/pages/timeline-wall/components/AddEventButton/AddEventButton.module.scss create mode 100644 src/apps/community/src/pages/timeline-wall/components/AddEventButton/AddEventButton.tsx create mode 100644 src/apps/community/src/pages/timeline-wall/components/AddEventButton/index.ts create mode 100644 src/apps/community/src/pages/timeline-wall/components/ApprovalItem/ApprovalItem.module.scss create mode 100644 src/apps/community/src/pages/timeline-wall/components/ApprovalItem/ApprovalItem.tsx create mode 100644 src/apps/community/src/pages/timeline-wall/components/ApprovalItem/index.ts create mode 100644 src/apps/community/src/pages/timeline-wall/components/EventItem/EventItem.module.scss create mode 100644 src/apps/community/src/pages/timeline-wall/components/EventItem/EventItem.tsx create mode 100644 src/apps/community/src/pages/timeline-wall/components/EventItem/index.ts create mode 100644 src/apps/community/src/pages/timeline-wall/components/ModalConfirmReject/ModalConfirmReject.module.scss create mode 100644 src/apps/community/src/pages/timeline-wall/components/ModalConfirmReject/ModalConfirmReject.tsx create mode 100644 src/apps/community/src/pages/timeline-wall/components/ModalConfirmReject/index.ts create mode 100644 src/apps/community/src/pages/timeline-wall/components/ModalDeleteConfirmation/ModalDeleteConfirmation.module.scss create mode 100644 src/apps/community/src/pages/timeline-wall/components/ModalDeleteConfirmation/ModalDeleteConfirmation.tsx create mode 100644 src/apps/community/src/pages/timeline-wall/components/ModalDeleteConfirmation/index.ts create mode 100644 src/apps/community/src/pages/timeline-wall/components/ModalEventAdd/ModalEventAdd.module.scss create mode 100644 src/apps/community/src/pages/timeline-wall/components/ModalEventAdd/ModalEventAdd.tsx create mode 100644 src/apps/community/src/pages/timeline-wall/components/ModalEventAdd/index.ts create mode 100644 src/apps/community/src/pages/timeline-wall/components/ModalPhotoViewer/ModalPhotoViewer.module.scss create mode 100644 src/apps/community/src/pages/timeline-wall/components/ModalPhotoViewer/ModalPhotoViewer.tsx create mode 100644 src/apps/community/src/pages/timeline-wall/components/ModalPhotoViewer/index.ts create mode 100644 src/apps/community/src/pages/timeline-wall/components/PendingApprovals/PendingApprovals.module.scss create mode 100644 src/apps/community/src/pages/timeline-wall/components/PendingApprovals/PendingApprovals.tsx create mode 100644 src/apps/community/src/pages/timeline-wall/components/PendingApprovals/index.ts create mode 100644 src/apps/community/src/pages/timeline-wall/components/RightFilter/RightFilter.module.scss create mode 100644 src/apps/community/src/pages/timeline-wall/components/RightFilter/RightFilter.tsx create mode 100644 src/apps/community/src/pages/timeline-wall/components/RightFilter/index.ts create mode 100644 src/apps/community/src/pages/timeline-wall/components/TimelineEvents/TimelineEvents.module.scss create mode 100644 src/apps/community/src/pages/timeline-wall/components/TimelineEvents/TimelineEvents.tsx create mode 100644 src/apps/community/src/pages/timeline-wall/components/TimelineEvents/index.ts create mode 100644 src/apps/community/src/pages/timeline-wall/components/index.ts create mode 100644 src/apps/community/src/pages/timeline-wall/index.ts create mode 100644 src/apps/community/src/pages/timeline-wall/timeline-wall.routes.tsx diff --git a/package.json b/package.json index 123a8f9aa..8ab2125e2 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "sb:build": "storybook build -o build/storybook" }, "dependencies": { + "@contentful/rich-text-html-renderer": "^17.1.6", "@datadog/browser-logs": "^4.50.1", "@hello-pangea/dnd": "^18.0.1", "@heroicons/react": "^1.0.6", diff --git a/src/apps/community/index.ts b/src/apps/community/index.ts new file mode 100644 index 000000000..6f39cd49b --- /dev/null +++ b/src/apps/community/index.ts @@ -0,0 +1 @@ +export * from './src' diff --git a/src/apps/community/src/CommunityApp.tsx b/src/apps/community/src/CommunityApp.tsx new file mode 100644 index 000000000..6c2fdc0f0 --- /dev/null +++ b/src/apps/community/src/CommunityApp.tsx @@ -0,0 +1,104 @@ +import { FC, useContext, useEffect, useMemo } from 'react' +import { + Outlet, + Routes, + useLocation, + useNavigate, +} from 'react-router-dom' + +import { AppSubdomain, EnvironmentConfig } from '~/config' +import { routerContext, RouterContextData } from '~/libs/core' + +import { resolveCommunityIdFromHost } from './config/community-id.config' +import { + communityLoaderRouteId, + rootRoute, +} from './config/routes.config' +import { Layout, type LayoutVariant } from './lib' +import { toolTitle } from './community-app.routes' +import './lib/styles/index.scss' + +function withLeadingSlash(path: string): string { + return path.startsWith('/') + ? path + : `/${path}` +} + +function normalizePath(path: string): string { + const collapsed = path.replace(/\/{2,}/g, '/') + if (collapsed.length > 1 && collapsed.endsWith('/')) { + return collapsed.slice(0, -1) + } + + return collapsed || '/' +} + +/** + * Root shell for the community app routes. + * + * @returns The app layout and routed child content. + */ +const CommunityApp: FC = () => { + const { getChildRoutes }: RouterContextData = useContext(routerContext) + const location = useLocation() + const navigate = useNavigate() + const childRoutes = useMemo(() => getChildRoutes(toolTitle), [getChildRoutes]) + const routedCommunityId = useMemo( + () => resolveCommunityIdFromHost(window.location.host), + [], + ) + const communityBasePath = useMemo( + () => normalizePath(withLeadingSlash(rootRoute)), + [], + ) + const normalizedPath = useMemo( + () => normalizePath(location.pathname), + [location.pathname], + ) + const isCommunityPage = normalizedPath.includes(`/${communityLoaderRouteId}`) + + const variant: LayoutVariant + = EnvironmentConfig.SUBDOMAIN !== AppSubdomain.community + && !isCommunityPage + ? 'standard' + : 'community' + + useEffect(() => { + if (!routedCommunityId || isCommunityPage) { + return + } + + const isRootPath = normalizedPath === '/' + || normalizedPath === communityBasePath + if (!isRootPath) { + return + } + + const destination = normalizePath( + `${communityBasePath}/${communityLoaderRouteId}/${routedCommunityId}`, + ) + navigate(destination, { replace: true }) + }, [ + communityBasePath, + isCommunityPage, + navigate, + normalizedPath, + routedCommunityId, + ]) + + useEffect(() => { + document.body.classList.add('community-app') + return () => { + document.body.classList.remove('community-app') + } + }, []) + + return ( + + + {childRoutes} + + ) +} + +export default CommunityApp diff --git a/src/apps/community/src/community-app.routes.tsx b/src/apps/community/src/community-app.routes.tsx new file mode 100644 index 000000000..d9a77d532 --- /dev/null +++ b/src/apps/community/src/community-app.routes.tsx @@ -0,0 +1,48 @@ +/** + * App routes. + */ +import { AppSubdomain, ToolTitle } from '~/config' +import { + lazyLoad, + LazyLoadedComponent, + PlatformRoute, +} from '~/libs/core' + +import { rootRoute } from './config/routes.config' +import { + challengeDetailRoutes, + challengeListingRoutes, + changelogRoutes, + communityLoaderRoutes, + homeRoutes, + submissionManagementRoutes, + submissionRoutes, + thriveRoutes, + timelineWallRoutes, +} from './pages' + +const CommunityApp: LazyLoadedComponent = lazyLoad(() => import('./CommunityApp')) + +export const toolTitle: string = ToolTitle.community + +export const communityRoutes: ReadonlyArray = [ + { + children: [ + ...challengeDetailRoutes, + ...challengeListingRoutes, + ...changelogRoutes, + ...communityLoaderRoutes, + ...homeRoutes, + ...submissionRoutes, + ...submissionManagementRoutes, + ...timelineWallRoutes, + ...thriveRoutes, + ], + domain: AppSubdomain.community, + element: , + id: toolTitle, + layoutVariant: 'community', + route: rootRoute, + title: toolTitle, + }, +] diff --git a/src/apps/community/src/config/community-id.config.ts b/src/apps/community/src/config/community-id.config.ts new file mode 100644 index 000000000..74b4e8bad --- /dev/null +++ b/src/apps/community/src/config/community-id.config.ts @@ -0,0 +1,41 @@ +/** + * Community ids with explicit `__community__/{communityId}` route wiring. + */ +export const explicitCommunityIds = [ + 'wipro', + 'veterans', + 'qa', + 'mobile', + 'iot', + 'cognitive', + 'blockchain', + 'cs', + 'demoexpert', + 'srmx', + 'taskforce', + 'tcproddev', + 'community2', +] as const + +export type ExplicitCommunityId = (typeof explicitCommunityIds)[number] + +const explicitCommunityIdSet: ReadonlySet = new Set(explicitCommunityIds) + +/** + * Resolves a routed community id from a browser host value. + * + * @param host Browser `location.host` value. + * @returns Community id when the host subdomain matches a routed community. + */ +export function resolveCommunityIdFromHost(host: string): ExplicitCommunityId | undefined { + const hostname = host + .toLowerCase() + .split(':')[0] + const subdomain = hostname.split('.')[0] + + if (!subdomain || !explicitCommunityIdSet.has(subdomain)) { + return undefined + } + + return subdomain as ExplicitCommunityId +} diff --git a/src/apps/community/src/config/index.config.ts b/src/apps/community/src/config/index.config.ts new file mode 100644 index 000000000..cc25510a8 --- /dev/null +++ b/src/apps/community/src/config/index.config.ts @@ -0,0 +1,50 @@ +/** + * Resource role id used to register challenge submitters. + */ +export const SUBMITTER_ROLE_ID = '732339e7-8e30-49d7-9198-cccf9451e221' + +/** + * Challenge statuses used by community challenge filters. + */ +export enum CHALLENGE_STATUS { + Active = 'Active', + Completed = 'Completed', + Draft = 'Draft', +} + +/** + * Supported Thrive article types. + */ +export const THRIVE_ARTICLE_TYPES = ['Article', 'Video', 'Forum post'] as const + +/** + * Supported Thrive track keys. + */ +export const THRIVE_TRACK_KEYS = [ + 'dataScience', + 'competitiveProgramming', + 'design', + 'development', + 'qualityAssurance', + 'topcoder', +] as const + +/** + * Max retry attempts when checking term agreement status. + */ +export const TERMS_CHECK_MAX_ATTEMPTS = 5 + +/** + * Delay between term status retries in milliseconds. + */ +export const TERMS_CHECK_DELAY_MS = 5000 + +/** + * Wipro email domain used for challenge registration checks. + */ +export const WIPRO_EMAIL_DOMAIN = '@wipro.com' + +/** + * External TopGear redirect url. + */ +export const TOPGEAR_REDIRECT_URL = 'https://topgear-app.wipro.com' diff --git a/src/apps/community/src/config/routes.config.ts b/src/apps/community/src/config/routes.config.ts new file mode 100644 index 000000000..9ab91e847 --- /dev/null +++ b/src/apps/community/src/config/routes.config.ts @@ -0,0 +1,19 @@ +/** + * Common config for routes in community app. + */ +import { AppSubdomain, EnvironmentConfig } from '~/config' + +export const rootRoute: string + = EnvironmentConfig.SUBDOMAIN === AppSubdomain.community + ? '' + : '/community' + +export const challengeListingRouteId = 'challenges' +export const challengeDetailRouteId = 'challenges/:challengeId' +export const submissionRouteId = 'challenges/:challengeId/submit' +export const submissionManagementRouteId = 'challenges/:challengeId/my-submissions' +export const homeRouteId = 'home' +export const thriveArticleRouteId = 'thrive/:articleTitle' +export const changelogRouteId = 'changelog' +export const timelineWallRouteId = 'timeline-wall' +export const communityLoaderRouteId = '__community__' diff --git a/src/apps/community/src/index.ts b/src/apps/community/src/index.ts new file mode 100644 index 000000000..7e17c17d1 --- /dev/null +++ b/src/apps/community/src/index.ts @@ -0,0 +1,2 @@ +export { communityRoutes } from './community-app.routes' +export { rootRoute as communityRootRoute } from './config/routes.config' diff --git a/src/apps/community/src/lib/components/AccessDenied/AccessDenied.module.scss b/src/apps/community/src/lib/components/AccessDenied/AccessDenied.module.scss new file mode 100644 index 000000000..c05d6f383 --- /dev/null +++ b/src/apps/community/src/lib/components/AccessDenied/AccessDenied.module.scss @@ -0,0 +1,37 @@ +@import '@libs/ui/styles/includes'; + +.container { + align-items: center; + display: flex; + flex-direction: column; + gap: $sp-2; + justify-content: center; + min-height: 420px; + padding: $sp-6 $sp-3; + text-align: center; +} + +.logo { + color: var(--black-100); + height: 36px; + width: 200px; +} + +.title { + color: var(--black-100); + font-size: 28px; + font-weight: 600; + margin: 0; +} + +.message { + color: var(--GrayFontColor); + margin: 0; + max-width: 480px; +} + +.meta { + color: var(--GrayFontColor); + font-size: 12px; + margin: 0; +} diff --git a/src/apps/community/src/lib/components/AccessDenied/AccessDenied.tsx b/src/apps/community/src/lib/components/AccessDenied/AccessDenied.tsx new file mode 100644 index 000000000..9bb3dc79b --- /dev/null +++ b/src/apps/community/src/lib/components/AccessDenied/AccessDenied.tsx @@ -0,0 +1,72 @@ +import { FC, useCallback } from 'react' + +import { authUrlLogin } from '~/libs/core' +import { Button, TcLogoSvg } from '~/libs/ui' + +import styles from './AccessDenied.module.scss' + +export enum AccessDeniedCause { + NOT_AUTHENTICATED = 'NOT_AUTHENTICATED', + NOT_AUTHORIZED = 'NOT_AUTHORIZED', +} + +export interface AccessDeniedProps { + cause: AccessDeniedCause + communityId?: string +} + +/** + * Renders a standardized access denied view for authentication/authorization failures. + * + * @param props Access denied cause with optional community id for login attribution. + * @returns Access denied message with contextual action. + */ +const AccessDenied: FC = (props: AccessDeniedProps) => { + const handleLogin = useCallback((): void => { + window.location.assign(authUrlLogin(window.location.href)) + }, []) + const handleBackToHome = useCallback((): void => { + window.location.assign('/') + }, []) + + if (props.cause === AccessDeniedCause.NOT_AUTHENTICATED) { + return ( +
+ +

Access Denied

+

+ You must be authenticated to access this page. +

+ {props.communityId && ( +

+ Community: + {' '} + {props.communityId} +

+ )} +
+ ) + } + + return ( +
+ +

Not Authorized

+

+ You are not authorized to access this page. +

+
+ ) +} + +export default AccessDenied diff --git a/src/apps/community/src/lib/components/AccessDenied/index.ts b/src/apps/community/src/lib/components/AccessDenied/index.ts new file mode 100644 index 000000000..1943711f0 --- /dev/null +++ b/src/apps/community/src/lib/components/AccessDenied/index.ts @@ -0,0 +1,2 @@ +export { default as AccessDenied } from './AccessDenied' +export * from './AccessDenied' diff --git a/src/apps/community/src/lib/components/CommunityHeader/CommunityHeader.module.scss b/src/apps/community/src/lib/components/CommunityHeader/CommunityHeader.module.scss new file mode 100644 index 000000000..1d2375171 --- /dev/null +++ b/src/apps/community/src/lib/components/CommunityHeader/CommunityHeader.module.scss @@ -0,0 +1,225 @@ +@import '@libs/ui/styles/includes'; + +.header { + background: #f8fafc; + border-bottom: 1px solid #e5e7eb; + display: flex; + flex-direction: column; + gap: $sp-2; + padding: $sp-3 $sp-4; + width: 100%; +} + +.topBar { + align-items: center; + display: flex; + gap: $sp-3; +} + +.mobileToggle { + align-items: center; + background: transparent; + border: 1px solid #d1d5db; + border-radius: 6px; + cursor: pointer; + display: none; + flex-direction: column; + gap: 3px; + height: 32px; + justify-content: center; + padding: 0; + width: 32px; + + span { + background: #111827; + border-radius: 1px; + display: block; + height: 2px; + width: 14px; + } + + @include ltesm { + display: inline-flex; + } +} + +.logoBar { + align-items: center; + display: flex; + flex: 1; + flex-wrap: wrap; + gap: $sp-3; +} + +.logoLink { + display: inline-flex; +} + +.logoImage { + display: block; + max-height: 44px; + max-width: 220px; + object-fit: contain; +} + +.userSection { + align-items: center; + display: flex; + gap: $sp-2; + margin-left: auto; + position: relative; +} + +.userHandle { + align-items: center; + background: transparent; + border: 0; + color: var(--black-100); + cursor: pointer; + display: inline-flex; + font-size: 14px; + font-weight: 600; + gap: $sp-2; + margin: 0; + padding: 0; +} + +.avatarImage { + border-radius: 50%; + display: block; + height: 28px; + object-fit: cover; + width: 28px; +} + +.avatarFallback { + align-items: center; + background: #e5e7eb; + border-radius: 50%; + display: inline-flex; + height: 28px; + justify-content: center; + overflow: hidden; + width: 28px; + + :global(svg) { + height: 20px; + width: 20px; + } +} + +.userDropdown { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + list-style: none; + margin: 0; + min-width: 180px; + padding: $sp-1 0; + position: absolute; + right: 0; + top: calc(100% + $sp-2); + z-index: 10; +} + +.userDropdownItem { + color: var(--black-100); + display: block; + font-size: 14px; + padding: $sp-2 $sp-3; + text-decoration: none; + + &:hover { + background: #f3f4f6; + } +} + +.authButton { + background: transparent; + border: 1px solid #d1d5db; + border-radius: 6px; + color: var(--black-100); + cursor: pointer; + font-size: 14px; + font-weight: 600; + padding: $sp-2 $sp-3; +} + +.authButtonPrimary { + background: #0d61bf; + border: 1px solid #0d61bf; + border-radius: 6px; + color: #fff; + cursor: pointer; + font-size: 14px; + font-weight: 600; + padding: $sp-2 $sp-3; +} + +.nav { + display: flex; + flex-wrap: wrap; + gap: $sp-3; + + @include ltesm { + display: none; + } +} + +.navOpen { + display: flex; + flex-wrap: wrap; + gap: $sp-3; + + @include ltesm { + border-top: 1px solid #e5e7eb; + display: flex; + flex-direction: column; + gap: $sp-2; + padding-top: $sp-2; + } +} + +.navItem { + color: var(--black-100); + font-size: 14px; + font-weight: 500; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.navItemActive { + color: var(--black-100); + font-size: 14px; + font-weight: 700; + text-decoration: underline; +} + +@include ltesm { + .topBar { + gap: $sp-2; + } + + .logoBar { + gap: $sp-2; + } + + .logoImage { + max-height: 32px; + max-width: 140px; + } + + .userHandle { + font-size: 12px; + } + + .authButton, + .authButtonPrimary { + font-size: 12px; + padding: $sp-1 $sp-2; + } +} diff --git a/src/apps/community/src/lib/components/CommunityHeader/CommunityHeader.tsx b/src/apps/community/src/lib/components/CommunityHeader/CommunityHeader.tsx new file mode 100644 index 000000000..4f8d7007a --- /dev/null +++ b/src/apps/community/src/lib/components/CommunityHeader/CommunityHeader.tsx @@ -0,0 +1,385 @@ +import { FC, useCallback, useContext, useMemo, useState } from 'react' +import { useLocation } from 'react-router-dom' + +import { EnvironmentConfig } from '~/config' +import { + authUrlLogin, + authUrlSignup, + profileContext, + ProfileContextData, +} from '~/libs/core' +import { ConfigContextValue, useConfigContext } from '~/libs/shared' +import { DefaultMemberIcon } from '~/libs/ui' + +import { CommunityMeta } from '../../models' + +import styles from './CommunityHeader.module.scss' + +interface CommunityHeaderProps { + baseUrl?: string + meta: CommunityMeta +} + +interface ResolvedCommunityLogo { + alt: string + href: string + src: string +} + +interface ResolvedMenuItem { + href: string + label: string + openNewTab: boolean +} + +interface AvatarProps { + photoUrl?: string + userHandle: string + userRating?: number +} + +/** + * Converts an unknown value into a string when possible. + * + * @param value Value from metadata payload. + * @returns Trimmed string or undefined. + */ +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() + ? value + : undefined +} + +/** + * Builds a normalized logo model from community metadata. + * + * @param logo Raw logo record. + * @param index Zero-based logo index. + * @returns Normalized logo model. + */ +function resolveLogo(logo: Record, index: number): ResolvedCommunityLogo | undefined { + const src = asString(logo.img) + ?? asString(logo.image) + ?? asString(logo.imageUrl) + ?? asString(logo.imageURL) + ?? asString(logo.src) + if (!src) { + return undefined + } + + return { + alt: asString(logo.alt) + ?? asString(logo.title) + ?? asString(logo.name) + ?? `community-logo-${index + 1}`, + href: asString(logo.href) + ?? asString(logo.link) + ?? asString(logo.url) + ?? '#', + src, + } +} + +/** + * Builds a normalized menu item model from community metadata. + * + * @param menuItem Raw menu record. + * @returns Normalized menu item. + */ +function resolveMenuItem(menuItem: Record): ResolvedMenuItem | undefined { + const label = asString(menuItem.title) + ?? asString(menuItem.label) + ?? asString(menuItem.name) + ?? asString(menuItem.text) + const href = asString(menuItem.href) + ?? asString(menuItem.link) + ?? asString(menuItem.url) + ?? asString(menuItem.path) + + if (!label || !href) { + return undefined + } + + return { + href, + label, + openNewTab: menuItem.openNewTab === true, + } +} + +/** + * Removes trailing slashes from non-root pathnames before route comparisons. + * + * @param pathname Pathname to normalize. + * @returns Normalized pathname. + */ +function normalizePathname(pathname: string): string { + if (!pathname) { + return '/' + } + + if (pathname !== '/' && pathname.endsWith('/')) { + return pathname.slice(0, -1) + } + + return pathname +} + +/** + * Removes the community base URL prefix for relative navigation item matching. + * + * @param pathname Current browser pathname. + * @param baseUrl Community route base URL. + * @returns Pathname without the base URL prefix. + */ +function stripBaseUrlPrefix(pathname: string, baseUrl?: string): string { + if (!baseUrl) { + return pathname + } + + if (pathname === baseUrl) { + return '/' + } + + if (pathname.startsWith(`${baseUrl}/`)) { + return pathname.slice(baseUrl.length) || '/' + } + + return pathname +} + +/** + * Renders the profile avatar image or default member icon. + * + * @param props Avatar data. + * @returns Profile avatar element. + */ +const Avatar: FC = (props: AvatarProps) => { + if (props.photoUrl) { + return ( + {`${props.userHandle} + ) + } + + const ratingLabel = typeof props.userRating === 'number' + ? `Rating ${props.userRating}` + : 'Unrated member' + + return ( + + + + ) +} + +/** + * Community branded header that renders logos, metadata navigation and user actions. + * + * @param props Community metadata and optional base URL for relative links. + * @returns Community header with branding, nav and user menu. + */ +const CommunityHeader: FC = (props: CommunityHeaderProps) => { + const [isMobileOpen, setIsMobileOpen] = useState(false) + const [isUserMenuOpen, setIsUserMenuOpen] = useState(false) + const { profile }: ProfileContextData = useContext(profileContext) + const { logoutUrl }: ConfigContextValue = useConfigContext() + const location = useLocation() + + const logos = useMemo( + () => props.meta.logos + .map((logo, index) => resolveLogo(logo as Record, index)) + .filter((logo): logo is ResolvedCommunityLogo => Boolean(logo)), + [props.meta.logos], + ) + const menuItems = useMemo( + () => props.meta.menuItems + .map(item => resolveMenuItem(item as Record)) + .filter((item): item is ResolvedMenuItem => Boolean(item)), + [props.meta.menuItems], + ) + + const isWiproMember = profile?.email?.includes('@wipro.com') === true + const communityBaseUrl = EnvironmentConfig.COMMUNITY_APP_URL ?? EnvironmentConfig.TOPCODER_URL + const profileLink = isWiproMember + ? 'https://topgear-app.wipro.com/user-details' + : `${EnvironmentConfig.TOPCODER_URL}/members/${profile?.handle ?? ''}` + const paymentsLink = isWiproMember + ? 'https://topgear-app.wipro.com/my_payments' + : `${communityBaseUrl}/PactsMemberServlet?module=PaymentHistory&full_list=false` + const routePathname = normalizePathname(stripBaseUrlPrefix(location.pathname, props.baseUrl)) + const toggleMobileMenu = useCallback(() => { + setIsMobileOpen(previous => !previous) + }, []) + const toggleUserMenu = useCallback(() => { + setIsUserMenuOpen(previous => !previous) + }, []) + const closeUserMenu = useCallback(() => { + setIsUserMenuOpen(false) + }, []) + const navigateToLogin = useCallback(() => { + window.location.assign(authUrlLogin(window.location.href)) + }, []) + const navigateToSignup = useCallback(() => { + window.location.assign(authUrlSignup()) + }, []) + + return ( +
+
+ + + {logos.length > 0 && ( +
+ {logos.map(logo => ( + + {logo.alt} + + ))} +
+ )} + +
+ {profile ? ( + <> + + + {isUserMenuOpen && ( + + )} + + ) : ( + <> + + + + )} +
+
+ + {menuItems.length > 0 && ( + + )} +
+ ) +} + +export default CommunityHeader + +export type { + CommunityHeaderProps, + ResolvedCommunityLogo, + ResolvedMenuItem, +} diff --git a/src/apps/community/src/lib/components/CommunityHeader/index.ts b/src/apps/community/src/lib/components/CommunityHeader/index.ts new file mode 100644 index 000000000..849d3ff6e --- /dev/null +++ b/src/apps/community/src/lib/components/CommunityHeader/index.ts @@ -0,0 +1,5 @@ +import CommunityHeader from './CommunityHeader' + +export { CommunityHeader } +export * from './CommunityHeader' +export default CommunityHeader diff --git a/src/apps/community/src/lib/components/CommunityLayout/CommunityLayout.module.scss b/src/apps/community/src/lib/components/CommunityLayout/CommunityLayout.module.scss new file mode 100644 index 000000000..184abda96 --- /dev/null +++ b/src/apps/community/src/lib/components/CommunityLayout/CommunityLayout.module.scss @@ -0,0 +1,20 @@ +@import '@libs/ui/styles/includes'; + +.layout { + background: #fff; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.content { + flex: 1; +} + +.footer { + background: #f8fafc; + border-top: 1px solid #e5e7eb; + color: var(--GrayFontColor); + font-size: 12px; + padding: $sp-3 $sp-4; +} diff --git a/src/apps/community/src/lib/components/CommunityLayout/CommunityLayout.tsx b/src/apps/community/src/lib/components/CommunityLayout/CommunityLayout.tsx new file mode 100644 index 000000000..26e5ca3a5 --- /dev/null +++ b/src/apps/community/src/lib/components/CommunityLayout/CommunityLayout.tsx @@ -0,0 +1,55 @@ +import { FC, ReactNode } from 'react' + +import { CommunityMeta } from '../../models' +import { CommunityHeader } from '../CommunityHeader' + +import styles from './CommunityLayout.module.scss' + +interface CommunityLayoutProps { + baseUrl?: string + children: ReactNode + meta: CommunityMeta +} + +/** + * Extracts footer text from a community metadata blob. + * + * @param metadata Arbitrary community metadata. + * @returns Footer text when configured. + */ +function getFooterText(metadata: Record): string | undefined { + const footerText = metadata.footerText + return typeof footerText === 'string' && footerText.trim() + ? footerText + : undefined +} + +/** + * Community shell that renders metadata-driven branding, navigation and route content. + * + * @param props Community metadata, community route base URL and nested route content. + * @returns Header/content/footer community layout. + */ +const CommunityLayout: FC = (props: CommunityLayoutProps) => { + const footerText = getFooterText(props.meta.metadata) + ?? '© Topcoder. All rights reserved.' + + return ( +
+ + +
+ {props.children} +
+ +
+ {footerText} +
+
+ ) +} + +export default CommunityLayout diff --git a/src/apps/community/src/lib/components/CommunityLayout/index.ts b/src/apps/community/src/lib/components/CommunityLayout/index.ts new file mode 100644 index 000000000..0349825ba --- /dev/null +++ b/src/apps/community/src/lib/components/CommunityLayout/index.ts @@ -0,0 +1,2 @@ +export { default as CommunityLayout } from './CommunityLayout' +export * from './CommunityLayout' diff --git a/src/apps/community/src/lib/components/Layout/Layout.module.scss b/src/apps/community/src/lib/components/Layout/Layout.module.scss new file mode 100644 index 000000000..798b0cc28 --- /dev/null +++ b/src/apps/community/src/lib/components/Layout/Layout.module.scss @@ -0,0 +1,29 @@ +@import '@libs/ui/styles/includes'; + +.contentLayoutOuter { + margin: $sp-6 auto !important; +} + +.contentLayoutInner { + box-sizing: border-box; + width: 100%; +} + +.standardLayout { + position: relative; +} + +.communityLayout { + display: flex; + flex-direction: column; + min-height: 100%; +} + +.communityHeaderSlot, +.communityFooterSlot { + width: 100%; +} + +.communityContent { + flex: 1; +} diff --git a/src/apps/community/src/lib/components/Layout/Layout.tsx b/src/apps/community/src/lib/components/Layout/Layout.tsx new file mode 100644 index 000000000..2b3143f73 --- /dev/null +++ b/src/apps/community/src/lib/components/Layout/Layout.tsx @@ -0,0 +1,52 @@ +import { FC, PropsWithChildren } from 'react' + +import { AppFooter } from '~/apps/platform/src/components/app-footer' +import { AppHeader } from '~/apps/platform/src/components/app-header' +import { ContentLayout } from '~/libs/ui' + +import styles from './Layout.module.scss' + +export type LayoutVariant = 'standard' | 'community' + +interface LayoutProps { + variant?: LayoutVariant +} + +/** + * Passthrough layout used by routes that do not need additional wrappers. + * + * @param props Child content to render. + * @returns The child content unchanged. + */ +export const NullLayout: FC = props => ( + <>{props.children} +) + +/** + * Shared community app layout. + * + * @param props Layout variant and nested route content. + * @returns The standard app shell/content layout or plain community children. + */ +export const Layout: FC> = props => { + const variant: LayoutVariant = props.variant ?? 'standard' + + if (variant === 'community') { + return <>{props.children} + } + + return ( + <> + + +
{props.children}
+
+ + + ) +} + +export default Layout diff --git a/src/apps/community/src/lib/components/Layout/index.ts b/src/apps/community/src/lib/components/Layout/index.ts new file mode 100644 index 000000000..a2e510e8c --- /dev/null +++ b/src/apps/community/src/lib/components/Layout/index.ts @@ -0,0 +1,2 @@ +export * from './Layout' +export { default as Layout } from './Layout' diff --git a/src/apps/community/src/lib/components/TermsModal/TermsModal.module.scss b/src/apps/community/src/lib/components/TermsModal/TermsModal.module.scss new file mode 100644 index 000000000..22856b97a --- /dev/null +++ b/src/apps/community/src/lib/components/TermsModal/TermsModal.module.scss @@ -0,0 +1,140 @@ +@import '@libs/ui/styles/includes'; + +.modalBody { + display: flex; + flex-direction: column; + gap: $sp-3; + min-height: 520px; +} + +.layout { + display: flex; + flex: 1; + gap: $sp-3; + min-height: 0; +} + +.sidebar { + border: 1px solid var(--GrayBorder); + border-radius: 8px; + display: flex; + flex: 0 0 30%; + flex-direction: column; + gap: $sp-1; + min-height: 0; + overflow: auto; + padding: $sp-2; +} + +.termItem { + align-items: center; + background: #fff; + border: 1px solid var(--GrayBorder); + border-radius: 6px; + color: var(--GrayFontColor); + cursor: pointer; + display: flex; + font-size: 13px; + gap: $sp-2; + justify-content: space-between; + padding: $sp-2; + text-align: left; +} + +.termItemActive { + border-color: var(--teal-100); + box-shadow: inset 0 0 0 1px var(--teal-100); +} + +.termTitle { + line-height: 1.4; +} + +.agreedIcon { + color: var(--green-100); + flex: 0 0 auto; + height: 16px; + width: 16px; +} + +.content { + border: 1px solid var(--GrayBorder); + border-radius: 8px; + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + padding: $sp-3; +} + +.contentScroll { + color: var(--GrayFontColor); + flex: 1; + line-height: 1.5; + min-height: 0; + overflow: auto; + white-space: pre-wrap; +} + +.docuSignFrame { + border: 0; + flex: 1; + min-height: 420px; + width: 100%; +} + +.actions { + display: flex; + gap: $sp-2; + justify-content: flex-end; + margin-top: $sp-3; +} + +.spinnerWrap { + align-items: center; + display: flex; + flex: 1; + justify-content: center; + min-height: 220px; +} + +.emptyState { + align-items: center; + color: var(--GrayFontColor); + display: flex; + flex: 1; + justify-content: center; + text-align: center; +} + +.link { + word-break: break-word; +} + +.error { + background: #fff1f0; + border: 1px solid #f5c2c7; + border-radius: 6px; + color: #842029; + font-size: 12px; + padding: $sp-2; +} + +@media (max-width: 768px) { + .modalBody { + min-height: 360px; + } + + .layout { + flex-direction: column; + } + + .sidebar { + flex-basis: auto; + max-height: 180px; + } + + .docuSignFrame { + min-height: 300px; + } +} diff --git a/src/apps/community/src/lib/components/TermsModal/TermsModal.tsx b/src/apps/community/src/lib/components/TermsModal/TermsModal.tsx new file mode 100644 index 000000000..8dddab478 --- /dev/null +++ b/src/apps/community/src/lib/components/TermsModal/TermsModal.tsx @@ -0,0 +1,284 @@ +import { + FC, + MouseEvent, + useCallback, + useEffect, + useMemo, + useRef, +} from 'react' +import classNames from 'classnames' + +import { + BaseModal, + Button, + IconCheck, + LoadingSpinner, +} from '~/libs/ui' + +import { useTerms } from '../../hooks' +import type { TermInfo } from '../../models' + +import styles from './TermsModal.module.scss' + +interface TermsModalProps { + challengeId: string + onAllAgreed: () => void + onClose: () => void + termIds: string[] +} + +/** + * Builds a DocuSign callback URL that returns to the current page. + * + * @param templateId DocuSign template id to persist in the callback URL. + * @returns Absolute callback URL with `docuSignReturn` query param. + */ +function buildDocuSignReturnUrl(templateId: string): string { + const returnUrl = new URL(window.location.href) + returnUrl.searchParams.set('docuSignReturn', templateId) + + return returnUrl.toString() +} + +/** + * Displays the challenge terms flow and captures all required agreements. + * + * @param props Challenge term ids with completion and close callbacks. + * @returns Terms modal with sidebar navigation and agreeability-specific content. + */ +const TermsModal: FC = (props: TermsModalProps) => { + const { + agreeTerm, + checkStatus, + clearDocuSignUrl, + getDocuSignUrl, + loadTerms, + selectTerm, + signDocuSign, + state, + }: ReturnType = useTerms() + const notifiedAllAgreedRef = useRef(false) + const activeTemplateRef = useRef(undefined) + const loadedTemplateRef = useRef(undefined) + const signedTemplateRef = useRef(undefined) + const requestedTemplateRef = useRef(undefined) + + useEffect(() => { + notifiedAllAgreedRef.current = false + requestedTemplateRef.current = undefined + loadTerms(props.termIds) + .catch(() => undefined) + }, [loadTerms, props.termIds]) + + useEffect(() => { + if (!state.canRegister || notifiedAllAgreedRef.current) { + return + } + + notifiedAllAgreedRef.current = true + props.onAllAgreed() + }, [props.onAllAgreed, state.canRegister]) + + const selectedDocuSignTemplateId = useMemo(() => { + if (state.selectedTerm?.agreeabilityType !== 'DocuSignable') { + return undefined + } + + return state.selectedTerm.docusignTemplateId + }, [state.selectedTerm]) + + useEffect(() => { + if (activeTemplateRef.current === selectedDocuSignTemplateId) { + return + } + + activeTemplateRef.current = selectedDocuSignTemplateId + loadedTemplateRef.current = undefined + requestedTemplateRef.current = undefined + clearDocuSignUrl() + }, [clearDocuSignUrl, selectedDocuSignTemplateId]) + + useEffect(() => { + if (!selectedDocuSignTemplateId) { + return + } + + const shouldRequestUrl = !state.docuSignUrl || loadedTemplateRef.current !== selectedDocuSignTemplateId + if (!shouldRequestUrl) { + return + } + + requestedTemplateRef.current = selectedDocuSignTemplateId + const returnUrl = buildDocuSignReturnUrl(selectedDocuSignTemplateId) + getDocuSignUrl(selectedDocuSignTemplateId, returnUrl) + .then(() => { + loadedTemplateRef.current = selectedDocuSignTemplateId + }) + .catch(() => undefined) + }, [getDocuSignUrl, selectedDocuSignTemplateId, state.docuSignUrl]) + + const hasDocuSignUrlForSelectedTemplate = Boolean( + state.docuSignUrl + && selectedDocuSignTemplateId + && loadedTemplateRef.current === selectedDocuSignTemplateId, + ) + + useEffect(() => { + const templateId = new URLSearchParams(window.location.search) + .get('docuSignReturn') + if (!templateId || signedTemplateRef.current === templateId) { + return + } + + const matchedTerm = state.terms.find(term => term.docusignTemplateId === templateId) + if (!matchedTerm) { + return + } + + signedTemplateRef.current = templateId + signDocuSign(matchedTerm.id) + checkStatus(props.termIds) + .catch(() => undefined) + }, [checkStatus, props.termIds, signDocuSign, state.terms]) + + const handleSelectTerm = useCallback((event: MouseEvent): void => { + const termId = event.currentTarget.dataset.termId + if (!termId) { + return + } + + const term = state.terms.find(item => item.id === termId) + if (term) { + selectTerm(term) + } + }, [selectTerm, state.terms]) + + const handleAgree = useCallback(async (): Promise => { + if (!state.selectedTerm) { + return + } + + await agreeTerm(state.selectedTerm.id) + await checkStatus(props.termIds) + }, [agreeTerm, checkStatus, props.termIds, state.selectedTerm]) + + const renderTermContent = useCallback((term: TermInfo): JSX.Element => { + if (term.agreeabilityType === 'Electronically-agreeable') { + return ( + <> +
{term.text ?? 'No term text available.'}
+ +
+ +
+ + ) + } + + if (term.agreeabilityType === 'DocuSignable') { + if (!term.docusignTemplateId) { + return ( +
+ DocuSign template id is not available for this term. +
+ ) + } + + if (!hasDocuSignUrlForSelectedTemplate) { + return ( +
+ +
+ ) + } + + return ( +