From 6632213a3d39789369b4038ed67a1b8d194bd127 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 13 Aug 2026 11:21:57 +1000 Subject: [PATCH 01/14] feat: add Topcoder opportunities experience --- src/apps/admin/src/lib/styles/index.scss | 1 + src/apps/blog/README.md | 18 + src/apps/blog/index.ts | 1 + src/apps/blog/src/Blog.module.scss | 286 ++++++ src/apps/blog/src/BlogApp.tsx | 33 + src/apps/blog/src/blog.routes.tsx | 50 ++ .../blog/src/components/BlogPagination.tsx | 80 ++ src/apps/blog/src/components/BlogPostCard.tsx | 43 + src/apps/blog/src/components/index.ts | 2 + src/apps/blog/src/index.ts | 1 + src/apps/blog/src/models/blog.models.ts | 22 + src/apps/blog/src/models/index.ts | 1 + src/apps/blog/src/pages/BlogLegacyRoute.tsx | 18 + src/apps/blog/src/pages/BlogListPage.tsx | 65 ++ src/apps/blog/src/pages/BlogPostPage.tsx | 118 +++ src/apps/blog/src/utils/blog.utils.spec.ts | 23 + src/apps/blog/src/utils/blog.utils.ts | 82 ++ src/apps/blog/src/utils/index.ts | 1 + src/apps/calendar/src/lib/styles/index.scss | 1 + src/apps/copilots/src/CopilotsApp.tsx | 8 +- src/apps/copilots/src/styles/index.scss | 1 + src/apps/engagements/src/EngagementsApp.tsx | 8 +- src/apps/engagements/src/styles/index.scss | 1 + src/apps/opportunities/index.tsx | 1 + .../opportunities/src/OpportunitiesApp.tsx | 32 + .../opportunities/src/assets/competition.svg | 3 + src/apps/opportunities/src/assets/copilot.svg | 3 + .../opportunities/src/assets/engagement.svg | 3 + src/apps/opportunities/src/assets/review.svg | 3 + .../ChallengeDetailHeader.module.scss | 227 +++++ .../src/components/ChallengeDetailHeader.tsx | 141 +++ .../components/ChallengeMarkdown.module.scss | 86 ++ .../src/components/ChallengeMarkdown.tsx | 134 +++ .../components/ChallengeSidebar.module.scss | 94 ++ .../src/components/ChallengeSidebar.tsx | 95 ++ .../ChallengeTermsModal.module.scss | 41 + .../src/components/ChallengeTermsModal.tsx | 83 ++ .../OpportunityFiltersPanel.module.scss | 118 +++ .../components/OpportunityFiltersPanel.tsx | 138 +++ .../components/OpportunityHero.module.scss | 153 ++++ .../src/components/OpportunityHero.tsx | 130 +++ .../OpportunityListCard.module.scss | 133 +++ .../src/components/OpportunityListCard.tsx | 262 ++++++ .../OpportunityPagination.module.scss | 79 ++ .../src/components/OpportunityPagination.tsx | 96 ++ .../components/ReportIssueModal.module.scss | 38 + .../src/components/ReportIssueModal.tsx | 115 +++ .../opportunities/src/components/index.ts | 9 + .../src/components/opportunity.utils.spec.ts | 34 + src/apps/opportunities/src/index.ts | 2 + src/apps/opportunities/src/models/index.ts | 1 + .../src/models/opportunity.models.ts | 235 +++++ .../src/opportunities.routes.tsx | 51 ++ .../pages/ChallengeDetailsPage.module.scss | 319 +++++++ .../src/pages/ChallengeDetailsPage.tsx | 539 +++++++++++ .../src/pages/OpportunitiesPage.module.scss | 191 ++++ .../src/pages/OpportunitiesPage.tsx | 311 +++++++ .../ReviewOpportunityDetailsPage.module.scss | 332 +++++++ .../pages/ReviewOpportunityDetailsPage.tsx | 299 +++++++ src/apps/opportunities/src/pages/index.ts | 3 + src/apps/opportunities/src/services/index.ts | 1 + .../services/opportunities.service.spec.ts | 47 + .../src/services/opportunities.service.ts | 453 ++++++++++ src/apps/opportunities/src/styles/index.scss | 6 + src/apps/platform/src/platform.routes.tsx | 6 + src/apps/procurement/src/ProcurementApp.tsx | 8 +- src/apps/procurement/src/styles/index.scss | 1 + src/apps/reports/src/lib/styles/index.scss | 1 + src/apps/review/src/lib/styles/index.scss | 1 + src/apps/support/src/lib/styles/index.scss | 1 + src/apps/thrive/README.md | 19 + src/apps/thrive/index.ts | 1 + src/apps/thrive/src/Thrive.module.scss | 843 ++++++++++++++++++ src/apps/thrive/src/ThriveApp.tsx | 33 + .../src/components/ThriveArticleCard.tsx | 80 ++ .../src/components/ThriveFilterPanel.tsx | 111 +++ .../thrive/src/components/ThriveResults.tsx | 103 +++ .../thrive/src/components/ThriveSearchBar.tsx | 71 ++ src/apps/thrive/src/components/index.ts | 4 + src/apps/thrive/src/config/index.ts | 1 + src/apps/thrive/src/config/thrive.config.ts | 81 ++ src/apps/thrive/src/index.ts | 2 + src/apps/thrive/src/models/index.ts | 1 + src/apps/thrive/src/models/thrive.models.ts | 63 ++ .../src/pages/article/ThriveArticlePage.tsx | 217 +++++ .../thrive/src/pages/home/ThriveHomePage.tsx | 169 ++++ .../src/pages/search/ThriveSearchPage.tsx | 76 ++ .../src/pages/tracks/ThriveTracksPage.tsx | 164 ++++ src/apps/thrive/src/thrive.routes.tsx | 59 ++ src/apps/thrive/src/utils/index.ts | 1 + .../thrive/src/utils/thrive.utils.spec.ts | 90 ++ src/apps/thrive/src/utils/thrive.utils.ts | 271 ++++++ src/apps/work/src/lib/styles/index.scss | 1 + src/config/constants.ts | 8 +- src/libs/cms/CmsMarkdown.tsx | 123 +++ src/libs/cms/README.md | 19 + src/libs/cms/cms.client.spec.ts | 90 ++ src/libs/cms/cms.client.ts | 301 +++++++ src/libs/cms/cms.types.ts | 95 ++ src/libs/cms/index.ts | 4 + src/libs/cms/use-cms-collection.ts | 60 ++ src/libs/core/lib/xhr/xhr-functions/index.ts | 1 + src/libs/ui/lib/styles/2026/README.md | 17 + src/libs/ui/lib/styles/2026/_scope.scss | 106 +++ src/libs/ui/lib/styles/2026/_tokens.scss | 26 + src/libs/ui/lib/styles/2026/index.scss | 3 + 106 files changed, 9232 insertions(+), 5 deletions(-) create mode 100644 src/apps/blog/README.md create mode 100644 src/apps/blog/index.ts create mode 100644 src/apps/blog/src/Blog.module.scss create mode 100644 src/apps/blog/src/BlogApp.tsx create mode 100644 src/apps/blog/src/blog.routes.tsx create mode 100644 src/apps/blog/src/components/BlogPagination.tsx create mode 100644 src/apps/blog/src/components/BlogPostCard.tsx create mode 100644 src/apps/blog/src/components/index.ts create mode 100644 src/apps/blog/src/index.ts create mode 100644 src/apps/blog/src/models/blog.models.ts create mode 100644 src/apps/blog/src/models/index.ts create mode 100644 src/apps/blog/src/pages/BlogLegacyRoute.tsx create mode 100644 src/apps/blog/src/pages/BlogListPage.tsx create mode 100644 src/apps/blog/src/pages/BlogPostPage.tsx create mode 100644 src/apps/blog/src/utils/blog.utils.spec.ts create mode 100644 src/apps/blog/src/utils/blog.utils.ts create mode 100644 src/apps/blog/src/utils/index.ts create mode 100644 src/apps/copilots/src/styles/index.scss create mode 100644 src/apps/engagements/src/styles/index.scss create mode 100644 src/apps/opportunities/index.tsx create mode 100644 src/apps/opportunities/src/OpportunitiesApp.tsx create mode 100644 src/apps/opportunities/src/assets/competition.svg create mode 100644 src/apps/opportunities/src/assets/copilot.svg create mode 100644 src/apps/opportunities/src/assets/engagement.svg create mode 100644 src/apps/opportunities/src/assets/review.svg create mode 100644 src/apps/opportunities/src/components/ChallengeDetailHeader.module.scss create mode 100644 src/apps/opportunities/src/components/ChallengeDetailHeader.tsx create mode 100644 src/apps/opportunities/src/components/ChallengeMarkdown.module.scss create mode 100644 src/apps/opportunities/src/components/ChallengeMarkdown.tsx create mode 100644 src/apps/opportunities/src/components/ChallengeSidebar.module.scss create mode 100644 src/apps/opportunities/src/components/ChallengeSidebar.tsx create mode 100644 src/apps/opportunities/src/components/ChallengeTermsModal.module.scss create mode 100644 src/apps/opportunities/src/components/ChallengeTermsModal.tsx create mode 100644 src/apps/opportunities/src/components/OpportunityFiltersPanel.module.scss create mode 100644 src/apps/opportunities/src/components/OpportunityFiltersPanel.tsx create mode 100644 src/apps/opportunities/src/components/OpportunityHero.module.scss create mode 100644 src/apps/opportunities/src/components/OpportunityHero.tsx create mode 100644 src/apps/opportunities/src/components/OpportunityListCard.module.scss create mode 100644 src/apps/opportunities/src/components/OpportunityListCard.tsx create mode 100644 src/apps/opportunities/src/components/OpportunityPagination.module.scss create mode 100644 src/apps/opportunities/src/components/OpportunityPagination.tsx create mode 100644 src/apps/opportunities/src/components/ReportIssueModal.module.scss create mode 100644 src/apps/opportunities/src/components/ReportIssueModal.tsx create mode 100644 src/apps/opportunities/src/components/index.ts create mode 100644 src/apps/opportunities/src/components/opportunity.utils.spec.ts create mode 100644 src/apps/opportunities/src/index.ts create mode 100644 src/apps/opportunities/src/models/index.ts create mode 100644 src/apps/opportunities/src/models/opportunity.models.ts create mode 100644 src/apps/opportunities/src/opportunities.routes.tsx create mode 100644 src/apps/opportunities/src/pages/ChallengeDetailsPage.module.scss create mode 100644 src/apps/opportunities/src/pages/ChallengeDetailsPage.tsx create mode 100644 src/apps/opportunities/src/pages/OpportunitiesPage.module.scss create mode 100644 src/apps/opportunities/src/pages/OpportunitiesPage.tsx create mode 100644 src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.module.scss create mode 100644 src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.tsx create mode 100644 src/apps/opportunities/src/pages/index.ts create mode 100644 src/apps/opportunities/src/services/index.ts create mode 100644 src/apps/opportunities/src/services/opportunities.service.spec.ts create mode 100644 src/apps/opportunities/src/services/opportunities.service.ts create mode 100644 src/apps/opportunities/src/styles/index.scss create mode 100644 src/apps/procurement/src/styles/index.scss create mode 100644 src/apps/thrive/README.md create mode 100644 src/apps/thrive/index.ts create mode 100644 src/apps/thrive/src/Thrive.module.scss create mode 100644 src/apps/thrive/src/ThriveApp.tsx create mode 100644 src/apps/thrive/src/components/ThriveArticleCard.tsx create mode 100644 src/apps/thrive/src/components/ThriveFilterPanel.tsx create mode 100644 src/apps/thrive/src/components/ThriveResults.tsx create mode 100644 src/apps/thrive/src/components/ThriveSearchBar.tsx create mode 100644 src/apps/thrive/src/components/index.ts create mode 100644 src/apps/thrive/src/config/index.ts create mode 100644 src/apps/thrive/src/config/thrive.config.ts create mode 100644 src/apps/thrive/src/index.ts create mode 100644 src/apps/thrive/src/models/index.ts create mode 100644 src/apps/thrive/src/models/thrive.models.ts create mode 100644 src/apps/thrive/src/pages/article/ThriveArticlePage.tsx create mode 100644 src/apps/thrive/src/pages/home/ThriveHomePage.tsx create mode 100644 src/apps/thrive/src/pages/search/ThriveSearchPage.tsx create mode 100644 src/apps/thrive/src/pages/tracks/ThriveTracksPage.tsx create mode 100644 src/apps/thrive/src/thrive.routes.tsx create mode 100644 src/apps/thrive/src/utils/index.ts create mode 100644 src/apps/thrive/src/utils/thrive.utils.spec.ts create mode 100644 src/apps/thrive/src/utils/thrive.utils.ts create mode 100644 src/libs/cms/CmsMarkdown.tsx create mode 100644 src/libs/cms/README.md create mode 100644 src/libs/cms/cms.client.spec.ts create mode 100644 src/libs/cms/cms.client.ts create mode 100644 src/libs/cms/cms.types.ts create mode 100644 src/libs/cms/index.ts create mode 100644 src/libs/cms/use-cms-collection.ts create mode 100644 src/libs/ui/lib/styles/2026/README.md create mode 100644 src/libs/ui/lib/styles/2026/_scope.scss create mode 100644 src/libs/ui/lib/styles/2026/_tokens.scss create mode 100644 src/libs/ui/lib/styles/2026/index.scss diff --git a/src/apps/admin/src/lib/styles/index.scss b/src/apps/admin/src/lib/styles/index.scss index 1b4138a17..46be06db6 100644 --- a/src/apps/admin/src/lib/styles/index.scss +++ b/src/apps/admin/src/lib/styles/index.scss @@ -1,4 +1,5 @@ @import './includes'; +@import '@libs/ui/styles/2026/index'; body.admin-app { color: $body-color; diff --git a/src/apps/blog/README.md b/src/apps/blog/README.md new file mode 100644 index 000000000..45fae585d --- /dev/null +++ b/src/apps/blog/README.md @@ -0,0 +1,18 @@ +# Blog + +The Blog sub-application ports the community-app list, pagination, post, and related-post experience to +platform-ui. It reads only the Payload CMS Contentful-compatibility API at `https://cms.topcoder-dev.com` +and renders migrated media only from `https://assets.topcoder-dev.com`. + +## Routes + +- `/blog` +- `/blog/page/:page` +- `/blog/post/:slug` +- `/blog/:page` and `/blog/:slug` (community-app-compatible aliases) + +## Configuration + +Set `REACT_APP_PAYLOAD_CMS_DEFAULT_ACCESS_TOKEN` to the Payload delivery credential for retained space +`b5f1djy59z3a`. The browser client sends it as a bearer credential. Missing or rejected credentials produce +a visible page error; there is deliberately no Contentful, Octana, or community-app proxy fallback. diff --git a/src/apps/blog/index.ts b/src/apps/blog/index.ts new file mode 100644 index 000000000..6f39cd49b --- /dev/null +++ b/src/apps/blog/index.ts @@ -0,0 +1 @@ +export * from './src' diff --git a/src/apps/blog/src/Blog.module.scss b/src/apps/blog/src/Blog.module.scss new file mode 100644 index 000000000..02e3a5646 --- /dev/null +++ b/src/apps/blog/src/Blog.module.scss @@ -0,0 +1,286 @@ +.blog, +.blogPost { + width: 100%; + min-height: 100vh; + background: #fff; + color: #2a2a2a; + font-family: Roboto, Arial, sans-serif; +} + +.blogList, +.postContentWrapper { + width: calc(100% - 30px); + max-width: 1280px; + margin: 3em auto; +} + +.postCard { + display: flex; + gap: 1.5em; + margin: 2em 0; +} + +.cardImage { + flex: 0 0 250px; + overflow: hidden; + border-radius: 6px; + + img { + width: 100%; + height: 100%; + min-height: 190px; + object-fit: cover; + } +} + +.cardContent { + flex: 1; + min-width: 0; + + > a { + color: #2a2a2a; + text-decoration: none; + } + + h1 { + margin: 0 0 16px; + font-size: 30px; + font-weight: 400; + line-height: 1.2; + } +} + +.description { + color: #555; + font-size: 16px; + line-height: 1.6; + + p { + margin: 0 0 22px; + } +} + +.readMore { + display: inline-block; + padding: 11px 24px; + border-radius: 22px; + background: #137d60; + color: #fff !important; + font-size: 14px; + font-weight: 700; +} + +.pagination { + display: flex; + align-items: center; + gap: 24px; + margin-top: 40px; + justify-content: center; + + form, + .paginationLinks { + display: flex; + align-items: center; + gap: 12px; + } + + a { + display: grid; + width: 32px; + height: 32px; + border: 1px solid #aaa; + border-radius: 50%; + color: #2a2a2a; + font-size: 21px; + text-decoration: none; + place-items: center; + + &[aria-disabled="true"] { + opacity: 0.35; + pointer-events: none; + } + } + + input { + width: 48px; + height: 34px; + border: 1px solid #aaa; + text-align: center; + } + + button { + height: 34px; + padding: 0 18px; + border: 0; + border-radius: 17px; + background: #137d60; + color: #fff; + font-weight: 700; + } +} + +.postHeader { + display: grid; + grid-template-columns: 40px minmax(0, 1fr); + gap: 20px; + margin-bottom: 38px; + + h1 { + margin: 15px; + font-size: clamp(32px, 5vw, 48px); + font-weight: 700; + line-height: 1.2; + } +} + +.back { + color: #2a2a2a; + font-size: 34px; + line-height: 1; + text-decoration: none; +} + +.postMeta { + display: flex; + flex-wrap: wrap; + gap: 20px; + color: #777; + font-size: 14px; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; + + span { + padding: 5px 10px; + border-radius: 4px; + background: #e7f5f1; + color: #137d60; + font-size: 12px; + font-weight: 700; + } +} + +.heroImage { + display: block; + width: 100%; + max-height: 650px; + margin-bottom: 45px; + object-fit: cover; +} + +.markdown { + max-width: 920px; + margin: auto; + font-size: 17px; + line-height: 1.7; + + h1, + h2, + h3, + h4 { + margin: 1.7em 0 0.55em; + line-height: 1.2; + } + + img { + max-width: 100%; + height: auto; + } + + iframe { + width: 100%; + min-height: 420px; + border: 0; + } + + table { + display: block; + max-width: 100%; + overflow-x: auto; + border-collapse: collapse; + } + + th, + td { + padding: 8px 12px; + border: 1px solid #ddd; + } +} + +.related { + margin-top: 75px; + padding-top: 40px; + border-top: 1px solid #ddd; + + > h2 { + margin-bottom: 24px; + font-size: 30px; + } + + > div { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 24px; + } +} + +.relatedCard { + overflow: hidden; + border: 1px solid #ddd; + border-radius: 5px; + + .cardImage { + display: block; + width: 100%; + height: 165px; + } + + .cardContent { + padding: 20px; + } + + h1 { + font-size: 20px; + } +} + +.pageState, +.error { + min-height: 40vh; + padding: 80px 20px; + color: #777; + text-align: center; +} + +.error { + color: #d0011b; +} + +@media (max-width: 700px) { + .postCard { + flex-direction: column; + gap: 20px; + } + + .cardImage { + flex-basis: auto; + height: 220px; + } + + .pagination { + flex-direction: column; + } + + .related > div { + grid-template-columns: 1fr; + } + + .postMeta { + flex-direction: column; + gap: 7px; + } +} diff --git a/src/apps/blog/src/BlogApp.tsx b/src/apps/blog/src/BlogApp.tsx new file mode 100644 index 000000000..9e945fdb3 --- /dev/null +++ b/src/apps/blog/src/BlogApp.tsx @@ -0,0 +1,33 @@ +import type { FC } from 'react' +import { useContext, useEffect, useMemo } from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import type { RouterContextData } from '~/libs/core' +import { routerContext } from '~/libs/core' + +import { toolTitle } from './blog.routes' + +/** + * Hosts Blog's nested routes and scopes the active body class to the sub-application lifetime. + * + * @returns the active Blog route. + * @throws Does not throw. + */ +const BlogApp: FC = () => { + const { getChildRoutes }: RouterContextData = useContext(routerContext) + const childRoutes = useMemo(() => getChildRoutes(toolTitle), [getChildRoutes]) + + useEffect(() => { + document.body.classList.add('blog-app') + return () => document.body.classList.remove('blog-app') + }, []) + + return ( + <> + + {childRoutes} + + ) +} + +export default BlogApp diff --git a/src/apps/blog/src/blog.routes.tsx b/src/apps/blog/src/blog.routes.tsx new file mode 100644 index 000000000..d14188678 --- /dev/null +++ b/src/apps/blog/src/blog.routes.tsx @@ -0,0 +1,50 @@ +import { ToolTitle } from '~/config' +import type { LazyLoadedComponent, PlatformRoute } from '~/libs/core' +import { lazyLoad } from '~/libs/core' + +const BlogApp: LazyLoadedComponent = lazyLoad(() => import('./BlogApp')) +const BlogListPage: LazyLoadedComponent = lazyLoad(() => import('./pages/BlogListPage'), 'BlogListPage') +const BlogPostPage: LazyLoadedComponent = lazyLoad(() => import('./pages/BlogPostPage'), 'BlogPostPage') +const BlogLegacyRoute: LazyLoadedComponent = lazyLoad( + () => import('./pages/BlogLegacyRoute'), + 'BlogLegacyRoute', +) + +/** Tool title used by the platform router to resolve Blog child routes. */ +export const toolTitle: string = ToolTitle.blog + +/** Public Payload-backed Blog route tree, including legacy community-app URLs. */ +export const blogRoutes: ReadonlyArray = [ + { + children: [ + { + children: [], + element: , + id: 'Blog home', + route: '', + }, + { + children: [], + element: , + id: 'Blog page', + route: 'page/:page', + }, + { + children: [], + element: , + id: 'Blog post', + route: 'post/:slug', + }, + { + children: [], + element: , + id: 'Legacy Blog page or post', + route: ':slugOrPage', + }, + ], + element: , + id: toolTitle, + route: '/blog', + title: toolTitle, + }, +] diff --git a/src/apps/blog/src/components/BlogPagination.tsx b/src/apps/blog/src/components/BlogPagination.tsx new file mode 100644 index 000000000..fc9d34321 --- /dev/null +++ b/src/apps/blog/src/components/BlogPagination.tsx @@ -0,0 +1,80 @@ +/* eslint-disable react/jsx-no-bind */ + +import type { FC, FormEvent } from 'react' +import { useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' + +import styles from '../Blog.module.scss' + +interface BlogPaginationProps { + page: number + totalPages: number +} + +/** + * Renders first, previous, next, last, and direct-page controls from community-app Blog. + * + * @param props current one-based page and available page count. + * @returns Blog pagination navigation. + * @throws Does not throw; invalid direct-page values are ignored. + */ +export const BlogPagination: FC = (props: BlogPaginationProps) => { + const navigate = useNavigate() + const [input, setInput] = useState('') + /** + * Builds the canonical URL for one Blog page. + * + * @param page one-based page number. + * @returns the root route for page one or an explicit pagination route. + * @throws Does not throw. + */ + const route = (page: number): string => (page === 1 ? '/blog' : `/blog/page/${page}`) + + /** + * Navigates to a valid direct-page input. + * + * @param event browser form event to prevent from reloading the application. + * @returns nothing after navigation or validation. + * @throws Does not throw. + */ + const submit = (event: FormEvent): void => { + event.preventDefault() + const page = Number(input) + if (Number.isInteger(page) && page >= 1 && page <= props.totalPages) { + navigate(route(page)) + } + } + + return ( + + ) +} diff --git a/src/apps/blog/src/components/BlogPostCard.tsx b/src/apps/blog/src/components/BlogPostCard.tsx new file mode 100644 index 000000000..eab1f84b8 --- /dev/null +++ b/src/apps/blog/src/components/BlogPostCard.tsx @@ -0,0 +1,43 @@ +import type { FC } from 'react' +import { Link } from 'react-router-dom' + +import type { CmsResource } from '~/libs/cms' +import { CmsMarkdown } from '~/libs/cms' + +import type { BlogPostFields } from '../models' +import { getBlogHeroUrl } from '../utils' +import styles from '../Blog.module.scss' + +interface BlogPostCardProps { + post: CmsResource + related?: boolean +} + +/** + * Renders the community-app Blog list treatment for one Payload-hosted post. + * + * @param props post record plus the optional compact related-post treatment. + * @returns linked hero image, title, description, and read-more action. + * @throws Does not throw. + */ +export const BlogPostCard: FC = (props: BlogPostCardProps) => { + const image = getBlogHeroUrl(props.post) + const href = `/blog/post/${encodeURIComponent(props.post.fields.slug)}` + + return ( +
+ {image && ( + + {props.post.fields.title} + + )} +
+

{props.post.fields.title}

+ {props.post.fields.description && ( + {props.post.fields.description} + )} + Read More +
+
+ ) +} diff --git a/src/apps/blog/src/components/index.ts b/src/apps/blog/src/components/index.ts new file mode 100644 index 000000000..58d77b790 --- /dev/null +++ b/src/apps/blog/src/components/index.ts @@ -0,0 +1,2 @@ +export * from './BlogPagination' +export * from './BlogPostCard' diff --git a/src/apps/blog/src/index.ts b/src/apps/blog/src/index.ts new file mode 100644 index 000000000..084cae555 --- /dev/null +++ b/src/apps/blog/src/index.ts @@ -0,0 +1 @@ +export { blogRoutes } from './blog.routes' diff --git a/src/apps/blog/src/models/blog.models.ts b/src/apps/blog/src/models/blog.models.ts new file mode 100644 index 000000000..6f083d999 --- /dev/null +++ b/src/apps/blog/src/models/blog.models.ts @@ -0,0 +1,22 @@ +import type { CmsLink, CmsResource } from '~/libs/cms' + +/** Linked Blog value after Payload compatibility relationship resolution. */ +export type BlogReference> = CmsLink | CmsResource + +/** Fields stored for a Blog author record. */ +export interface BlogAuthorFields extends Record { + name: string +} + +/** Fields stored for a public Blog post. */ +export interface BlogPostFields extends Record { + author?: BlogReference + body?: string + description?: string + heroImage?: BlogReference> + relatedPosts?: Array> + slug: string + tags?: string[] + theme?: string + title: string +} diff --git a/src/apps/blog/src/models/index.ts b/src/apps/blog/src/models/index.ts new file mode 100644 index 000000000..c2854c354 --- /dev/null +++ b/src/apps/blog/src/models/index.ts @@ -0,0 +1 @@ +export * from './blog.models' diff --git a/src/apps/blog/src/pages/BlogLegacyRoute.tsx b/src/apps/blog/src/pages/BlogLegacyRoute.tsx new file mode 100644 index 000000000..a2eba81d9 --- /dev/null +++ b/src/apps/blog/src/pages/BlogLegacyRoute.tsx @@ -0,0 +1,18 @@ +import type { FC } from 'react' +import { useParams } from 'react-router-dom' + +import { BlogListPage } from './BlogListPage' +import { BlogPostPage } from './BlogPostPage' + +/** + * Preserves community-app Blog URLs where a page number and post slug shared one route segment. + * + * @returns a paginated list for numeric segments, otherwise the matching post. + * @throws Does not throw. + */ +export const BlogLegacyRoute: FC = () => { + const { slugOrPage = '' }: { slugOrPage?: string } = useParams<{ slugOrPage: string }>() + return /^\d+$/.test(slugOrPage) + ? + : +} diff --git a/src/apps/blog/src/pages/BlogListPage.tsx b/src/apps/blog/src/pages/BlogListPage.tsx new file mode 100644 index 000000000..6221683ef --- /dev/null +++ b/src/apps/blog/src/pages/BlogListPage.tsx @@ -0,0 +1,65 @@ +import type { FC } from 'react' +import { useMemo } from 'react' +import { Helmet } from 'react-helmet' +import { Navigate, useParams } from 'react-router-dom' + +import { useCmsCollection } from '~/libs/cms' + +import { BlogPagination, BlogPostCard } from '../components' +import type { BlogPostFields } from '../models' +import { BLOG_PAGE_SIZE } from '../utils' +import styles from '../Blog.module.scss' + +interface BlogListPageProps { + pageOverride?: string +} + +/** + * Loads one page of public Blog posts directly from Payload CMS. + * + * @param props optional page supplied by the legacy `/blog/:page` route. + * @returns the post list and community-app-compatible pagination controls. + * @throws Does not throw; request failures are rendered inline. + */ +export const BlogListPage: FC = (props: BlogListPageProps) => { + const params = useParams<{ page: string }>() + const segment = props.pageOverride || params.page || '1' + const page = /^\d+$/.test(segment) && Number(segment) > 0 ? Number(segment) : 1 + const query = useMemo(() => ({ + content_type: 'blogPost', + 'fields.title[exists]': true, + include: 3, + limit: BLOG_PAGE_SIZE, + order: '-sys.createdAt', + skip: (page - 1) * BLOG_PAGE_SIZE, + }), [page]) + const posts = useCmsCollection('default', query) + const totalPages = Math.max(1, Math.ceil((posts.data?.total || 0) / BLOG_PAGE_SIZE)) + + if (posts.data && page > totalPages) { + return + } + + return ( +
+ + + + Topcoder Blog + {page > 1 ? ` — Page ${page}` : ''} + + +
+ {posts.loading &&
Loading Blog posts…
} + {posts.error &&
{posts.error.message}
} + {posts.data?.items.map(post => )} + {posts.data && posts.data.items.length === 0 && ( +
No Blog posts are published yet.
+ )} + {posts.data && posts.data.total > 0 && ( + + )} +
+
+ ) +} diff --git a/src/apps/blog/src/pages/BlogPostPage.tsx b/src/apps/blog/src/pages/BlogPostPage.tsx new file mode 100644 index 000000000..b47615b55 --- /dev/null +++ b/src/apps/blog/src/pages/BlogPostPage.tsx @@ -0,0 +1,118 @@ +import type { FC } from 'react' +import { useMemo } from 'react' +import { Helmet } from 'react-helmet' +import { Link, useParams } from 'react-router-dom' + +import type { CmsResource } from '~/libs/cms' +import { CmsMarkdown, useCmsCollection } from '~/libs/cms' + +import { BlogPostCard } from '../components' +import type { BlogPostFields } from '../models' +import { + formatBlogDate, + getBlogAuthorName, + getBlogHeroUrl, + isResolvedBlogResource, +} from '../utils' +import styles from '../Blog.module.scss' + +interface BlogPostPageProps { + slugOverride?: string +} + +/** + * Loads and renders one public Payload-hosted Blog post selected by slug. + * + * @param props optional slug supplied by the legacy `/blog/:slug` route. + * @returns the post header, metadata, image, markdown body, and related posts. + * @throws Does not throw; missing or failed records render a page state. + */ +export const BlogPostPage: FC = (props: BlogPostPageProps) => { + const params = useParams<{ slug: string }>() + const slug = props.slugOverride || params.slug || '' + const query = useMemo(() => ({ + content_type: 'blogPost', + 'fields.slug': slug, + 'fields.title[exists]': true, + include: 10, + limit: 1, + }), [slug]) + const result = useCmsCollection('default', query) + const post = result.data?.items[0] + + if (result.loading) { + return
Loading Blog post…
+ } + + if (result.error) { + return
{result.error.message}
+ } + + if (!post) { + return ( +
+

Blog post not found

+ Return to Blog +
+ ) + } + + const fields = post.fields + const heroImage = getBlogHeroUrl(post) + const relatedPosts = (fields.relatedPosts || []) + .filter(isResolvedBlogResource) as Array> + + return ( +
+ + {fields.description && } + {heroImage && } + + {fields.title} + {' '} + | Topcoder Blog + + +
+
+ ← +
+

{fields.title}

+
+ {getBlogAuthorName(fields.author) && ( + + ● + {getBlogAuthorName(fields.author)} + + )} + +
+ {fields.tags && ( +
+ {fields.tags.map(tag => {tag})} +
+ )} +
+
+ {heroImage && {fields.title}} +
+ {fields.description && {fields.description}} + {fields.body && {fields.body}} +
+ {relatedPosts.length > 0 && ( +
+

Related Posts

+
+ {relatedPosts.map(related => ( + + ))} +
+
+ )} +
+
+ ) +} diff --git a/src/apps/blog/src/utils/blog.utils.spec.ts b/src/apps/blog/src/utils/blog.utils.spec.ts new file mode 100644 index 000000000..5142e6e5a --- /dev/null +++ b/src/apps/blog/src/utils/blog.utils.spec.ts @@ -0,0 +1,23 @@ +import { formatBlogDate, normalizeBlogPage } from './blog.utils' + +jest.mock('~/libs/cms', () => ({ + getCmsResourceAssetUrl: jest.fn(), +}), { virtual: true }) + +describe('Blog utilities', () => { + it('normalizes page segments to the available one-based range', () => { + expect(normalizeBlogPage('3', 51, 10)) + .toBe(3) + expect(normalizeBlogPage('99', 21, 10)) + .toBe(3) + expect(normalizeBlogPage('post-slug', 21, 10)) + .toBe(1) + }) + + it('formats valid dates and suppresses invalid metadata', () => { + expect(formatBlogDate('not-a-date')) + .toBe('') + expect(formatBlogDate('2026-08-13T00:00:00.000Z')) + .toContain('2026') + }) +}) diff --git a/src/apps/blog/src/utils/blog.utils.ts b/src/apps/blog/src/utils/blog.utils.ts new file mode 100644 index 000000000..71f1f1959 --- /dev/null +++ b/src/apps/blog/src/utils/blog.utils.ts @@ -0,0 +1,82 @@ +import type { CmsResource } from '~/libs/cms' +import { getCmsResourceAssetUrl } from '~/libs/cms' + +import type { BlogAuthorFields, BlogPostFields, BlogReference } from '../models' + +/** Number of posts retained from the community-app Blog page. */ +export const BLOG_PAGE_SIZE = 10 + +/** + * Identifies a linked Blog value that Payload's compatibility response resolved. + * + * @param value retained link or resolved record. + * @returns true when the value embeds fields. + * @throws Does not throw. + */ +export function isResolvedBlogResource>( + value: BlogReference | undefined, +): value is CmsResource { + return Boolean(value && 'fields' in value) +} + +/** + * Returns an approved migrated hero-image URL for a Blog post. + * + * @param post Blog fields or full CMS record. + * @returns an assets.topcoder-dev.com URL, or undefined when no safe image exists. + * @throws Does not throw. + */ +export function getBlogHeroUrl(post: BlogPostFields | CmsResource): string | undefined { + const fields: BlogPostFields = 'fields' in post && 'sys' in post + ? (post as CmsResource).fields + : post as BlogPostFields + return getCmsResourceAssetUrl(fields.heroImage) +} + +/** + * Reads an embedded author name from a resolved Blog relationship. + * + * @param author resolved or retained author relationship. + * @returns the author name, or an empty string while unavailable. + * @throws Does not throw. + */ +export function getBlogAuthorName(author: BlogReference | undefined): string { + return isResolvedBlogResource(author) ? author.fields.name : '' +} + +/** + * Formats Blog update metadata using the legacy weekday/month presentation. + * + * @param value retained CMS update timestamp. + * @returns a localized date or an empty string for invalid input. + * @throws Does not throw. + */ +export function formatBlogDate(value: string | undefined): string { + const date = new Date(value || '') + return Number.isNaN(date.getTime()) ? '' : new Intl.DateTimeFormat('en-US', { + day: '2-digit', + month: 'long', + weekday: 'long', + year: 'numeric', + }) + .format(date) +} + +/** + * Normalizes a requested Blog page to the available one-based range. + * + * @param requested numeric route segment. + * @param total total number of matching Blog posts. + * @param pageSize records rendered per page. + * @returns a safe one-based page number. + * @throws Does not throw. + */ +export function normalizeBlogPage( + requested: string | undefined, + total: number = 0, + pageSize: number = BLOG_PAGE_SIZE, +): number { + const parsed = Number(requested) + const maximum = Math.max(1, Math.ceil(total / pageSize)) + return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, maximum) : 1 +} diff --git a/src/apps/blog/src/utils/index.ts b/src/apps/blog/src/utils/index.ts new file mode 100644 index 000000000..e4634e03a --- /dev/null +++ b/src/apps/blog/src/utils/index.ts @@ -0,0 +1 @@ +export * from './blog.utils' diff --git a/src/apps/calendar/src/lib/styles/index.scss b/src/apps/calendar/src/lib/styles/index.scss index 6c1ffcaa9..0cb9f9436 100644 --- a/src/apps/calendar/src/lib/styles/index.scss +++ b/src/apps/calendar/src/lib/styles/index.scss @@ -1,4 +1,5 @@ @import '@libs/ui/styles/includes'; +@import '@libs/ui/styles/2026/index'; :root { --LeaveColor: #4caf50; diff --git a/src/apps/copilots/src/CopilotsApp.tsx b/src/apps/copilots/src/CopilotsApp.tsx index 5c7198c2a..80e20473e 100644 --- a/src/apps/copilots/src/CopilotsApp.tsx +++ b/src/apps/copilots/src/CopilotsApp.tsx @@ -1,14 +1,20 @@ -import { FC, useContext } from 'react' +import { FC, useContext, useEffect } from 'react' import { Outlet, Routes } from 'react-router-dom' import { routerContext, RouterContextData } from '~/libs/core' import { SharedSwrConfig } from '~/libs/shared' import { toolTitle } from './copilots.routes' +import './styles/index.scss' const CopilotsApp: FC<{}> = () => { const { getChildRoutes }: RouterContextData = useContext(routerContext) + useEffect(() => { + document.body.classList.add('copilots-app') + return () => document.body.classList.remove('copilots-app') + }, []) + return ( diff --git a/src/apps/copilots/src/styles/index.scss b/src/apps/copilots/src/styles/index.scss new file mode 100644 index 000000000..3dafeb7c5 --- /dev/null +++ b/src/apps/copilots/src/styles/index.scss @@ -0,0 +1 @@ +@import '@libs/ui/styles/2026/index'; diff --git a/src/apps/engagements/src/EngagementsApp.tsx b/src/apps/engagements/src/EngagementsApp.tsx index 59e907ff9..0fff5e799 100644 --- a/src/apps/engagements/src/EngagementsApp.tsx +++ b/src/apps/engagements/src/EngagementsApp.tsx @@ -1,5 +1,5 @@ import type { FC } from 'react' -import { useContext } from 'react' +import { useContext, useEffect } from 'react' import { Outlet, Routes } from 'react-router-dom' import type { RouterContextData } from '~/libs/core' @@ -7,10 +7,16 @@ import { routerContext } from '~/libs/core' import { toolTitle } from './engagements.routes' import { EngagementsSwr } from './lib' +import './styles/index.scss' const EngagementsApp: FC<{}> = () => { const { getChildRoutes }: RouterContextData = useContext(routerContext) + useEffect(() => { + document.body.classList.add('engagements-app') + return () => document.body.classList.remove('engagements-app') + }, []) + return ( diff --git a/src/apps/engagements/src/styles/index.scss b/src/apps/engagements/src/styles/index.scss new file mode 100644 index 000000000..3dafeb7c5 --- /dev/null +++ b/src/apps/engagements/src/styles/index.scss @@ -0,0 +1 @@ +@import '@libs/ui/styles/2026/index'; diff --git a/src/apps/opportunities/index.tsx b/src/apps/opportunities/index.tsx new file mode 100644 index 000000000..6f39cd49b --- /dev/null +++ b/src/apps/opportunities/index.tsx @@ -0,0 +1 @@ +export * from './src' diff --git a/src/apps/opportunities/src/OpportunitiesApp.tsx b/src/apps/opportunities/src/OpportunitiesApp.tsx new file mode 100644 index 000000000..1ed85be06 --- /dev/null +++ b/src/apps/opportunities/src/OpportunitiesApp.tsx @@ -0,0 +1,32 @@ +import { FC, useContext, useEffect, useMemo } from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import { routerContext, RouterContextData } from '~/libs/core' + +import { toolTitle } from './opportunities.routes' +import './styles/index.scss' + +/** + * Hosts nested Opportunities routes and scopes the 2026 design system to this app. + * + * @returns the active child page and its nested Router elements. + * @throws Does not throw. + */ +const OpportunitiesApp: FC = () => { + const { getChildRoutes }: RouterContextData = useContext(routerContext) + const childRoutes = useMemo(() => getChildRoutes(toolTitle), [getChildRoutes]) + + useEffect(() => { + document.body.classList.add('opportunities-app', 'tc-2026') + return () => document.body.classList.remove('opportunities-app', 'tc-2026') + }, []) + + return ( + <> + + {childRoutes} + + ) +} + +export default OpportunitiesApp diff --git a/src/apps/opportunities/src/assets/competition.svg b/src/apps/opportunities/src/assets/competition.svg new file mode 100644 index 000000000..42bae7c2e --- /dev/null +++ b/src/apps/opportunities/src/assets/competition.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/opportunities/src/assets/copilot.svg b/src/apps/opportunities/src/assets/copilot.svg new file mode 100644 index 000000000..d4bb52c4d --- /dev/null +++ b/src/apps/opportunities/src/assets/copilot.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/opportunities/src/assets/engagement.svg b/src/apps/opportunities/src/assets/engagement.svg new file mode 100644 index 000000000..2e8dfc8bf --- /dev/null +++ b/src/apps/opportunities/src/assets/engagement.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/opportunities/src/assets/review.svg b/src/apps/opportunities/src/assets/review.svg new file mode 100644 index 000000000..a00dba66a --- /dev/null +++ b/src/apps/opportunities/src/assets/review.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/apps/opportunities/src/components/ChallengeDetailHeader.module.scss b/src/apps/opportunities/src/components/ChallengeDetailHeader.module.scss new file mode 100644 index 000000000..18864b4f1 --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeDetailHeader.module.scss @@ -0,0 +1,227 @@ +.header { + background: #002a3a; + color: #fff; + overflow: hidden; + position: relative; +} + +.inner { + margin: 0 auto; + max-width: 1120px; + padding: 25px 24px 28px; + position: relative; + z-index: 1; +} + +.rings { + border: 2px dashed rgba(0, 205, 194, .35); + border-radius: 50%; + height: 360px; + position: absolute; + right: -50px; + top: -120px; + width: 360px; + + &::before, + &::after { + border: 2px dashed rgba(0, 205, 194, .35); + border-radius: 50%; + content: ''; + inset: 55px; + position: absolute; + } + + &::after { + inset: 115px; + } +} + +.breadcrumbs { + color: #9bb2bb; + display: flex; + flex-wrap: wrap; + font-size: 11px; + gap: 8px; + margin-bottom: 23px; + + a { + color: inherit; + text-decoration: none; + } +} + +.layout { + align-items: end; + display: grid; + gap: 40px; + grid-template-columns: minmax(0, 1fr) 330px; +} + +.catalog, +.skills, +.timeline, +.prizes { + align-items: center; + display: flex; + flex-wrap: wrap; +} + +.catalog { + font-size: 11px; + gap: 8px; + + span:first-child { + border: 1px solid #8eb6c0; + padding: 2px 5px; + } +} + +.copy h1 { + font-family: 'Figtree', sans-serif; + font-size: 34px; + font-weight: 700; + line-height: 39px; + margin: 13px 0 12px; + max-width: 720px; + text-transform: none; +} + +.skills { + gap: 6px; + + span { + background: rgba(255, 255, 255, .16); + font-size: 10px; + padding: 2px 6px; + } +} + +.timeline { + color: #d5e3e7; + font-size: 12px; + gap: 20px; + margin-top: 28px; + + span, + a { + align-items: center; + display: flex; + gap: 7px; + } + + svg { + color: #23ccc3; + height: 18px; + width: 18px; + } + + a { + border: 1px solid #00b5ad; + border-radius: 4px; + color: #33d8d0; + font-weight: 700; + padding: 7px 12px; + text-decoration: none; + } +} + +.actionCard { + background: #0d3445; + border-radius: 7px; + display: flex; + flex-direction: column; + gap: 14px; + padding: 23px; + + small { + color: #a9bec6; + font-size: 11px; + } + + button, + a { + align-items: center; + border-radius: 3px; + box-sizing: border-box; + display: flex; + font-family: inherit; + font-size: 13px; + font-weight: 700; + justify-content: center; + min-height: 38px; + text-decoration: none; + width: 100%; + } +} + +.prizes { + color: #30ddd3; + gap: 17px; + + strong { + align-items: center; + display: flex; + font-family: 'Figtree', sans-serif; + font-size: 20px; + gap: 5px; + + span { + align-items: center; + background: #f4cf33; + border-radius: 50%; + color: #0d3445; + display: flex; + font-size: 8px; + height: 15px; + justify-content: center; + width: 15px; + } + } +} + +.secondary { + background: transparent; + border: 1px solid #fff; + color: #fff; + cursor: pointer; +} + +.primary { + background: #008f89; + border: 1px solid #008f89; + color: #fff; + gap: 7px; + + &[aria-disabled='true'] { + cursor: not-allowed; + opacity: .45; + pointer-events: none; + } + + svg { + height: 17px; + width: 17px; + } +} + +@media (max-width: 800px) { + .layout { + grid-template-columns: 1fr; + } + + .actionCard { + max-width: 420px; + } +} + +@media (max-width: 520px) { + .copy h1 { + font-size: 28px; + line-height: 33px; + } + + .timeline { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/src/apps/opportunities/src/components/ChallengeDetailHeader.tsx b/src/apps/opportunities/src/components/ChallengeDetailHeader.tsx new file mode 100644 index 000000000..547216400 --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeDetailHeader.tsx @@ -0,0 +1,141 @@ +import { FC } from 'react' +import { Link } from 'react-router-dom' + +import { IconOutline } from '~/libs/ui' + +import { ChallengeOpportunity, ChallengePhase } from '../models' + +import styles from './ChallengeDetailHeader.module.scss' + +interface ChallengeDetailHeaderProps { + busy: boolean + challenge: ChallengeOpportunity + isRegistered: boolean + onRegister: () => void + onUnregister: () => void +} + +/** Returns a catalog name from either v5-compatible or v6 challenge data. */ +function catalogName(value: string | { name?: string } | undefined, fallback: string): string { + return typeof value === 'string' ? value : value?.name || fallback +} + +/** Formats a challenge date range for the hero. */ +function dateRange(challenge: ChallengeOpportunity): string { + const formatter = new Intl.DateTimeFormat('en-US', { day: 'numeric', month: 'short', year: 'numeric' }) + const start = challenge.startDate ? new Date(challenge.startDate) : undefined + const end = challenge.endDate ? new Date(challenge.endDate) : undefined + if (!start || Number.isNaN(start.getTime())) return 'Schedule to be announced' + if (!end || Number.isNaN(end.getTime())) return formatter.format(start) + return `${formatter.format(start)} – ${formatter.format(end)}` +} + +/** Returns the first currently open phase for deadline display. */ +function currentPhase(challenge: ChallengeOpportunity): ChallengePhase | undefined { + return challenge.phases?.find(phase => phase.isOpen) + ?? challenge.phases?.find(phase => { + const end = phase.scheduledEndDate ? new Date(phase.scheduledEndDate) : undefined + return end && end.getTime() > Date.now() + }) +} + +/** Formats challenge prizes in placement order. */ +function prizes(challenge: ChallengeOpportunity): number[] { + const placement = challenge.prizeSets?.find(set => set.type?.toLowerCase() === 'placement') + ?? challenge.prizeSets?.[0] + return (placement?.prizes ?? []).map(prize => Number(prize.value ?? 0)) + .filter(value => value > 0) +} + +/** + * Renders the Figma challenge title, phase context, prizes, and member actions. + * + * @param props challenge and registration state. + * @returns dark challenge detail masthead. + * @throws Does not throw. + */ +export const ChallengeDetailHeader: FC = props => { + const phase = currentPhase(props.challenge) + const challengePrizes = prizes(props.challenge) + const type = catalogName(props.challenge.type, 'Challenge') + const track = catalogName(props.challenge.track, 'Competition') + + return ( +
+
+ ) +} diff --git a/src/apps/opportunities/src/components/ChallengeMarkdown.module.scss b/src/apps/opportunities/src/components/ChallengeMarkdown.module.scss new file mode 100644 index 000000000..4374d4e2b --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeMarkdown.module.scss @@ -0,0 +1,86 @@ +.markdown { + color: #28343a; + font-size: 14px; + line-height: 1.65; + + h1, + h2, + h3, + h4 { + color: #15191c; + font-family: 'Figtree', sans-serif; + font-weight: 700; + margin: 28px 0 12px; + scroll-margin-top: 96px; + text-transform: none; + } + + h1 { font-size: 26px; } + h2 { font-size: 20px; } + h3 { font-size: 17px; } + h4 { font-size: 15px; } + + p, + ul, + ol, + blockquote, + pre, + table { + margin: 0 0 16px; + } + + ul, + ol { + list-style: revert; + padding-left: 26px; + } + + a { + color: #00797a; + font-weight: 700; + } + + img { + height: auto; + max-width: 100%; + } + + table { + border-collapse: collapse; + display: block; + max-width: 100%; + overflow-x: auto; + } + + th, + td { + border: 1px solid #cfd7dc; + padding: 8px 10px; + text-align: left; + } + + blockquote { + border-left: 4px solid #008f89; + color: #59676e; + padding-left: 15px; + } + + code { + background: #edf1f3; + border-radius: 3px; + padding: 2px 4px; + } + + pre { + background: #062d3d; + border-radius: 4px; + color: #fff; + overflow-x: auto; + padding: 14px; + + code { + background: transparent; + padding: 0; + } + } +} diff --git a/src/apps/opportunities/src/components/ChallengeMarkdown.tsx b/src/apps/opportunities/src/components/ChallengeMarkdown.tsx new file mode 100644 index 000000000..2fd009e6a --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeMarkdown.tsx @@ -0,0 +1,134 @@ +/* eslint-disable ordered-imports/ordered-imports */ +import { FC, ReactNode, useEffect, useMemo } from 'react' +import ReactMarkdown, { Components, Options as ReactMarkdownOptions } from 'react-markdown' +import type { HeadingProps } from 'react-markdown/lib/ast-to-react' +import remarkBreaks from 'remark-breaks' +import remarkGfm from 'remark-gfm' + +import styles from './ChallengeMarkdown.module.scss' + +const Markdown = ReactMarkdown as unknown as FC + +export interface ChallengeTocItem { + id: string + label: string + level: 2 | 3 +} + +interface ChallengeMarkdownProps { + markdown: string + onTableOfContents?: (items: ChallengeTocItem[]) => void +} + +/** + * Creates a stable fragment identifier from a Markdown heading. + * + * @param value heading text. + * @returns URL-safe lowercase fragment. + * @throws Does not throw. + */ +export function headingSlug(value: string): string { + return value + .toLowerCase() + .replace(/<[^>]+>/g, '') + .replace(/[^a-z0-9\s-]/g, '') + .trim() + .replace(/\s+/g, '-') + || 'section' +} + +/** + * Extracts second- and third-level Markdown headings for the challenge table of contents. + * Source line numbers keep duplicate heading fragments stable and unique. + * + * @param markdown challenge specification Markdown. + * @returns ordered table-of-contents entries. + * @throws Does not throw. + */ +export function extractTableOfContents(markdown: string): ChallengeTocItem[] { + return markdown.split('\n') + .map((line, index) => ({ + line: index + 1, + match: /^(#{2,3})\s+(.+?)\s*#*$/.exec(line.trim()), + })) + .filter((entry): entry is { line: number, match: RegExpExecArray } => !!entry.match) + .map(entry => { + const label = entry.match[2] + .replace(/\[([^\]]+)]\([^)]*\)/g, '$1') + .replace(/[*_`~]/g, '') + .trim() + return { + id: `${headingSlug(label)}-${entry.line}`, + label, + level: entry.match[1].length as 2 | 3, + } + }) +} + +/** + * Converts nested Markdown children to plain text for heading fragment generation. + * + * @param children rendered heading children. + * @returns concatenated plain text. + * @throws Does not throw. + */ +function childText(children: ReactNode): string { + if (typeof children === 'string' || typeof children === 'number') return String(children) + if (Array.isArray(children)) { + return children.map(childText) + .join('') + } + + return '' +} + +/** + * Renders a Markdown heading whose stable fragment matches the generated TOC. + * + * @param props heading level, source location, and rendered children from React Markdown. + * @returns an H2 or H3 with a stable source-line fragment identifier. + * @throws Does not throw. + */ +const MarkdownHeading: FC = props => { + const label = childText(props.children) + const line = props.node?.position?.start.line ?? 0 + const id = `${headingSlug(label)}-${line}` + return props.level === 3 + ?

{props.children}

+ :

{props.children}

+} + +const MARKDOWN_COMPONENTS: Components = { + h2: MarkdownHeading, + h3: MarkdownHeading, +} + +/** + * Renders a challenge Markdown specification with fragment-addressable headings. + * + * @param props specification Markdown and optional TOC observer. + * @returns sanitized Markdown presentation supporting tables and inline HTML. + * @throws Does not throw. + */ +export const ChallengeMarkdown: FC = props => { + const tableOfContents = useMemo(() => extractTableOfContents(props.markdown), [props.markdown]) + const onTableOfContents = props.onTableOfContents + + useEffect(() => { + onTableOfContents?.(tableOfContents) + }, [onTableOfContents, tableOfContents]) + + return ( +
+ + {props.markdown} + +
+ ) +} diff --git a/src/apps/opportunities/src/components/ChallengeSidebar.module.scss b/src/apps/opportunities/src/components/ChallengeSidebar.module.scss new file mode 100644 index 000000000..43a201920 --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeSidebar.module.scss @@ -0,0 +1,94 @@ +.sidebar { + display: flex; + flex-direction: column; + gap: 14px; +} + +.card, +.promo { + background: #fff; + border: 1px solid #e0e5e8; + border-radius: 5px; + color: #3e4b52; + padding: 18px; + + h3 { + align-items: center; + color: #1b2327; + display: flex; + font-family: 'Figtree', sans-serif; + font-size: 14px; + font-weight: 700; + gap: 8px; + line-height: 19px; + text-transform: none; + + svg { + color: #008f89; + height: 17px; + width: 17px; + } + } + + p, + li { + font-size: 11px; + line-height: 16px; + } + + p { + margin: 9px 0; + } + + ol { + list-style: decimal; + margin: 10px 0 0; + padding-left: 17px; + } + + a, + button { + align-items: center; + background: none; + border: 0; + color: #00797a; + cursor: pointer; + display: flex; + font: inherit; + font-size: 11px; + font-weight: 700; + gap: 5px; + margin-top: 7px; + padding: 0; + text-align: left; + text-decoration: none; + + svg { + height: 13px; + width: 13px; + } + } +} + +.promo { + background: linear-gradient(135deg, #172b4d, #76439b); + color: #fff; + + h3, + a { + color: #fff; + } +} + +.promoArt { + align-items: center; + background: linear-gradient(135deg, #23ccc3, #ce3b9a); + border-radius: 4px; + display: flex; + font-family: 'Figtree', sans-serif; + font-size: 24px; + font-weight: 800; + height: 60px; + justify-content: center; + margin-bottom: 14px; +} diff --git a/src/apps/opportunities/src/components/ChallengeSidebar.tsx b/src/apps/opportunities/src/components/ChallengeSidebar.tsx new file mode 100644 index 000000000..de6cc12ad --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeSidebar.tsx @@ -0,0 +1,95 @@ +import { FC, ReactNode } from 'react' +import { Link } from 'react-router-dom' + +import { IconOutline } from '~/libs/ui' + +import { ChallengeOpportunity } from '../models' + +import styles from './ChallengeSidebar.module.scss' + +interface ChallengeSidebarProps { + challenge: ChallengeOpportunity + onContactTeam: () => void + onShowTerms: () => void +} + +interface SidebarCardProps { + children: ReactNode + icon: ReactNode + title: string +} + +/** + * Renders one reusable challenge right-rail card. + * + * @param props card icon, title, and body content. + * @returns a styled sidebar section. + * @throws Does not throw. + */ +const SidebarCard: FC = props => ( +
+

+ {props.icon} + {props.title} +

+ {props.children} +
+) + +/** + * Renders the challenge-details support column, including Review App, learning, + * submission guidance, challenge terms, source files, and team contact. + * + * @param props challenge context and modal actions. + * @returns Figma right rail cards. + * @throws Does not throw. + */ +export const ChallengeSidebar: FC = props => ( + +) diff --git a/src/apps/opportunities/src/components/ChallengeTermsModal.module.scss b/src/apps/opportunities/src/components/ChallengeTermsModal.module.scss new file mode 100644 index 000000000..376248a80 --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeTermsModal.module.scss @@ -0,0 +1,41 @@ +.body { + color: #344148; + display: flex; + flex-direction: column; + font-family: 'Nunito Sans', sans-serif; + font-size: 14px; + gap: 16px; + line-height: 20px; + + p, + ul { + margin: 0; + } + + ul { + list-style: disc; + padding-left: 22px; + } + + a { + color: #00797a; + font-weight: 700; + } + + label { + align-items: flex-start; + background: #f1f7f7; + border-radius: 4px; + cursor: pointer; + display: flex; + gap: 10px; + padding: 14px; + + input { + accent-color: #008f89; + height: 17px; + margin-top: 2px; + width: 17px; + } + } +} diff --git a/src/apps/opportunities/src/components/ChallengeTermsModal.tsx b/src/apps/opportunities/src/components/ChallengeTermsModal.tsx new file mode 100644 index 000000000..aa310324e --- /dev/null +++ b/src/apps/opportunities/src/components/ChallengeTermsModal.tsx @@ -0,0 +1,83 @@ +/* eslint-disable react/jsx-no-bind */ +import { FC, useEffect, useState } from 'react' + +import { BaseModal, Button } from '~/libs/ui' + +import { ChallengeTerm } from '../models' + +import styles from './ChallengeTermsModal.module.scss' + +interface ChallengeTermsModalProps { + busy?: boolean + onAccept: () => void + onClose: () => void + open: boolean + terms: ChallengeTerm[] +} + +/** + * Renders the concise Figma registration-terms confirmation. + * + * @param props required terms, submit state, and accept/close actions. + * @returns modal that requires explicit acknowledgement before registration. + * @throws Does not throw. + */ +export const ChallengeTermsModal: FC = props => { + const [accepted, setAccepted] = useState(false) + + useEffect(() => { + if (props.open) setAccepted(false) + }, [props.open]) + + return ( + + + + + Search skills, technologies, projects + {props.isAuthenticated && ( + + )} +
+ Status + {statuses.map((option: StatusOption) => ( + + ))} +
+ {showTracks && ( +
+ Track + {TRACKS.map((track: string) => ( + + ))} +
+ )} + + ) +} diff --git a/src/apps/opportunities/src/components/OpportunityHero.module.scss b/src/apps/opportunities/src/components/OpportunityHero.module.scss new file mode 100644 index 000000000..c7226a5ef --- /dev/null +++ b/src/apps/opportunities/src/components/OpportunityHero.module.scss @@ -0,0 +1,153 @@ +.hero { + background: #002a3a; + color: #fff; + min-height: 216px; + overflow: visible; + position: relative; +} + +.inner { + margin: 0 auto; + max-width: 1120px; + padding: 25px 24px 0; + position: relative; + z-index: 1; + + h1 { + font-family: 'Figtree', sans-serif; + font-size: 28px; + font-weight: 700; + line-height: 34px; + text-transform: none; + + span { + color: #2ed8cf; + } + } +} + +.map { + background-image: radial-gradient(circle, rgba(101, 153, 163, .42) 1.8px, transparent 2px); + background-size: 9px 9px; + clip-path: polygon(42% 0, 100% 0, 100% 80%, 85% 70%, 74% 95%, 56% 72%, 36% 86%, 18% 55%); + height: 180px; + opacity: .6; + position: absolute; + right: 5%; + top: 0; + width: 50%; +} + +.breadcrumbs { + align-items: center; + color: #9bb2bb; + display: flex; + font-size: 12px; + gap: 8px; + margin-bottom: 20px; + + a { + color: inherit; + text-decoration: none; + } +} + +.subtitle { + color: #d8e2e6; + font-size: 16px; + margin: 5px 0 29px; +} + +.cells { + display: grid; + gap: 20px; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.cell { + align-items: flex-start; + background: #0d3445; + border: 1px dashed #708d98; + border-radius: 6px; + color: #fff; + display: flex; + gap: 13px; + min-height: 86px; + padding: 18px; + text-decoration: none; + transition: background .15s ease, transform .15s ease; + + > svg { + flex: 0 0 28px; + height: 28px; + width: 28px; + } + + &:hover { + background: #125064; + transform: translateY(-2px); + } +} + +.active { + background: #07968f; + border-color: transparent; + + &:hover { + background: #07857f; + } +} + +.cellCopy { + display: flex; + flex-direction: column; + font-size: 11px; + line-height: 16px; + min-width: 0; + + strong { + font-family: 'Figtree', sans-serif; + font-size: 15px; + line-height: 20px; + white-space: nowrap; + } +} + +.metrics { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 5px; + + small { + background: rgba(255, 255, 255, .16); + border-radius: 2px; + font-size: 9px; + line-height: 15px; + padding: 0 5px; + } +} + +@media (max-width: 900px) { + .hero { + padding-bottom: 18px; + } + + .cells { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 520px) { + .inner { + padding-inline: 16px; + } + + .cells { + grid-template-columns: 1fr; + } + + .cellCopy strong { + white-space: normal; + } +} diff --git a/src/apps/opportunities/src/components/OpportunityHero.tsx b/src/apps/opportunities/src/components/OpportunityHero.tsx new file mode 100644 index 000000000..6b420d173 --- /dev/null +++ b/src/apps/opportunities/src/components/OpportunityHero.tsx @@ -0,0 +1,130 @@ +/* eslint-disable ordered-imports/ordered-imports */ +import { FC } from 'react' +import { NavLink } from 'react-router-dom' +import classNames from 'classnames' + +import { OpportunityKind, OpportunitySummary } from '../models' + +import { ReactComponent as CompetitionIcon } from '../assets/competition.svg' +import { ReactComponent as CopilotIcon } from '../assets/copilot.svg' +import { ReactComponent as EngagementIcon } from '../assets/engagement.svg' +import { ReactComponent as ReviewIcon } from '../assets/review.svg' +import styles from './OpportunityHero.module.scss' + +interface OpportunityHeroProps { + active: OpportunityKind + summary?: OpportunitySummary +} + +interface CellConfig { + description: string + icon: FC + kind: OpportunityKind + label: string +} + +const cells: CellConfig[] = [ + { + description: 'Compete & win prizes', + icon: CompetitionIcon, + kind: 'competitions', + label: 'Competitions', + }, + { + description: 'Contract work with clients', + icon: EngagementIcon, + kind: 'engagements', + label: 'Engagements', + }, + { + description: 'Lead & coordinate competitions', + icon: CopilotIcon, + kind: 'copilots', + label: 'Copilot Opportunities', + }, + { + description: 'Evaluate submissions to earn', + icon: ReviewIcon, + kind: 'reviews', + label: 'Review Opportunities', + }, +] + +/** + * Formats compact currency used in the opportunity summary cells. + * + * @param value amount in US dollars. + * @returns compact currency text such as `$38.5k`. + * @throws Does not throw. + */ +function formatCompactCurrency(value?: number): string | undefined { + if (value === undefined) return undefined + return new Intl.NumberFormat('en-US', { + currency: 'USD', + maximumFractionDigits: 1, + notation: 'compact', + style: 'currency', + }) + .format(value) + .replace('K', 'k') +} + +/** + * Renders the dark Opportunities masthead and the four Figma navigation cells. + * + * @param props active cell and the single-call aggregation summary. + * @returns hero navigation for all opportunity types. + * @throws Does not throw. + */ +export const OpportunityHero: FC = props => ( +
+
+) diff --git a/src/apps/opportunities/src/components/OpportunityListCard.module.scss b/src/apps/opportunities/src/components/OpportunityListCard.module.scss new file mode 100644 index 000000000..dbdb2027c --- /dev/null +++ b/src/apps/opportunities/src/components/OpportunityListCard.module.scss @@ -0,0 +1,133 @@ +.card { + background: #fff; + border: 1px solid #dce2e5; + border-radius: 6px; + color: #13191d; + display: grid; + grid-template-columns: minmax(0, 1fr) 230px; + min-height: 130px; + overflow: hidden; + text-decoration: none; + transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease; + + &:hover { + border-color: #008f89; + box-shadow: 0 5px 18px rgba(0, 42, 58, .1); + transform: translateY(-1px); + } +} + +.main { + min-width: 0; + padding: 20px 22px; + + h3 { + color: #15191c; + font-family: 'Figtree', sans-serif; + font-size: 20px; + font-weight: 700; + line-height: 24px; + margin: 8px 0; + text-transform: none; + } + + p { + color: #3f4a50; + font-size: 13px; + line-height: 19px; + margin: 11px 0 0; + } +} + +.eyebrow { + align-items: center; + color: #303b40; + display: flex; + font-size: 10px; + gap: 7px; +} + +.badge { + background: #c8f5d6; + color: #137d60; + padding: 2px 5px; +} + +.state { + border: 1px solid #7fa08e; + color: #137d60; + margin-left: auto; + padding: 2px 6px; +} + +.skills { + display: flex; + flex-wrap: wrap; + gap: 5px; + + span { + background: #edf0f2; + color: #30383d; + font-size: 9px; + line-height: 16px; + padding: 0 5px; + } +} + +.meta { + align-content: center; + border-left: 1px solid #dce2e5; + display: grid; + gap: 12px; + margin: 0; + padding: 20px; + + div { + align-items: center; + display: grid; + font-size: 12px; + gap: 4px; + grid-template-columns: 18px auto 1fr; + } + + svg { + color: #008f89; + height: 17px; + width: 17px; + } + + dt { + font-weight: 700; + } + + dd { + margin: 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +@media (max-width: 800px) { + .card { + grid-template-columns: 1fr; + } + + .meta { + border-left: 0; + border-top: 1px solid #dce2e5; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 480px) { + .main { + padding: 16px; + } + + .meta { + grid-template-columns: 1fr; + padding: 16px; + } +} diff --git a/src/apps/opportunities/src/components/OpportunityListCard.tsx b/src/apps/opportunities/src/components/OpportunityListCard.tsx new file mode 100644 index 000000000..7796cc5ef --- /dev/null +++ b/src/apps/opportunities/src/components/OpportunityListCard.tsx @@ -0,0 +1,262 @@ +/* eslint-disable ordered-imports/ordered-imports */ +import { FC, ReactNode } from 'react' +import { Link } from 'react-router-dom' +import { IconOutline } from '~/libs/ui' + +import { + ChallengeOpportunity, + CopilotOpportunity, + EngagementOpportunity, + OpportunityItem, + OpportunityKind, + OpportunitySkill, + ReviewOpportunity, +} from '../models' + +import styles from './OpportunityListCard.module.scss' + +interface OpportunityListCardProps { + item: OpportunityItem + kind: OpportunityKind +} + +interface CardViewModel { + badge: string + description?: string + href: string + meta: Array<{ icon: ReactNode; label: string; value: string }> + skills: string[] + state?: string + title: string + type?: string +} + +/** + * Returns a useful string from an API value that can be a name object. + * + * @param value string or catalog object. + * @param fallback text used when no name exists. + * @returns display name. + * @throws Does not throw. + */ +function catalogName(value: string | { name?: string } | undefined, fallback: string): string { + if (typeof value === 'string') return value + return value?.name || fallback +} + +/** + * Formats a date for compact card metadata. + * + * @param value ISO date from an owning API. + * @returns localized date, or `TBD` when absent/invalid. + * @throws Does not throw. + */ +function formatDate(value?: string): string { + if (!value) return 'TBD' + const date = new Date(value) + return Number.isNaN(date.getTime()) + ? 'TBD' + : new Intl.DateTimeFormat('en-US', { day: 'numeric', month: 'short', year: 'numeric' }) + .format(date) +} + +/** + * Formats a dollar amount without unnecessary decimal places. + * + * @param value dollar value. + * @returns US currency text. + * @throws Does not throw. + */ +function formatMoney(value?: number): string { + return new Intl.NumberFormat('en-US', { + currency: 'USD', + maximumFractionDigits: 0, + style: 'currency', + }) + .format(value ?? 0) +} + +/** + * Produces a plain excerpt from Markdown or HTML description content. + * + * @param value rich text from the API. + * @returns short plain-text excerpt. + * @throws Does not throw. + */ +function descriptionExcerpt(value?: string): string | undefined { + if (!value) return undefined + const plain = value + .replace(/<[^>]*>/g, ' ') + .replace(/[#*_>`~()]/g, '') + .replace(/\[/g, '') + .replace(/]/g, '') + .replace(/\s+/g, ' ') + .trim() + return plain.length > 180 ? `${plain.slice(0, 177)}…` : plain +} + +/** Converts challenge data to the shared card presentation model. */ +function challengeView(item: ChallengeOpportunity): CardViewModel { + const prize = item.overviewTotalPrizes + ?? item.prizeSets?.flatMap(set => set.prizes ?? []) + .reduce((sum, current) => sum + (current.value ?? 0), 0) + ?? 0 + const track = catalogName(item.track, 'Competition') + return { + badge: track, + description: descriptionExcerpt(item.description ?? item.overview), + href: `/opportunities/challenge/${item.id}`, + meta: [ + { icon: , label: 'Prize', value: formatMoney(prize) }, + { + icon: , + label: 'Registrants', + value: String(item.numOfRegistrants ?? 0), + }, + { + icon: , + label: 'Submissions', + value: String(item.numOfSubmissions ?? 0), + }, + ], + skills: [...(item.skills ?? []).map(skill => skill.name), ...(item.tags ?? [])], + state: item.status === 'ACTIVE' ? 'Open for registration' : item.status, + title: item.name, + type: catalogName(item.type, 'Challenge'), + } +} + +/** Converts engagement data to the shared card presentation model. */ +function engagementView(item: EngagementOpportunity): CardViewModel { + const length = item.duration?.lengthInWeeks + ? `${item.duration.lengthInWeeks} weeks` + : item.duration?.lengthInMonths + ? `${item.duration.lengthInMonths} months` + : 'TBD' + return { + badge: item.role || 'Engagement', + description: descriptionExcerpt(item.description), + href: `/engagements/${item.nanoId ?? item.id}`, + meta: [ + { icon: , label: 'Role', value: item.role || 'Contributor' }, + { icon: , label: 'Duration', value: length }, + { icon: , label: 'Start', value: formatDate(item.anticipatedStart) }, + { + icon: , + label: 'Payment', + value: item.compensationRange || 'Negotiable', + }, + ], + skills: item.requiredSkills ?? [], + state: item.status === 'OPEN' ? 'Open for application' : item.status, + title: item.title, + type: item.workload, + } +} + +/** Converts copilot data to the shared card presentation model. */ +function copilotView(item: CopilotOpportunity): CardViewModel { + return { + badge: item.projectType || item.type || 'Copilot', + description: descriptionExcerpt(item.overview), + href: `/copilots/opportunity/${item.id}`, + meta: [ + { + icon: , + label: 'Hours / week', + value: String(item.numHoursPerWeek ?? 'TBD'), + }, + { + icon: , + label: 'Duration', + value: item.numWeeks ? `${item.numWeeks} weeks` : 'TBD', + }, + { icon: , label: 'Start', value: formatDate(item.startDate) }, + ], + skills: (item.skills ?? []).map((skill: OpportunitySkill) => skill.name), + state: item.hasApplied ? 'Applied' : item.status === 'active' ? 'Open for application' : item.status, + title: item.opportunityTitle || item.projectName || item.project?.name || 'Copilot Opportunity', + type: item.complexity, + } +} + +/** Converts review data to the shared card presentation model. */ +function reviewView(item: ReviewOpportunity): CardViewModel { + const track = String(item.challengeData?.track ?? item.challengeData?.trackName ?? 'Review') + const technologies = item.challengeData?.technologies + return { + badge: track, + href: `/opportunities/review/${item.id}`, + meta: [ + { icon: , label: 'Role', value: item.payments?.[0]?.role || 'Reviewer' }, + { icon: , label: 'Start', value: formatDate(item.startDate) }, + { + icon: , + label: 'Applications', + value: String(item.applications?.length ?? 0), + }, + ], + skills: Array.isArray(technologies) ? technologies.map(String) : [], + state: item.myApplications?.length ? 'Applied' : item.canApply ? 'Open for application' : item.status, + title: item.challengeName || String(item.challengeData?.name ?? 'Review Opportunity'), + type: String(item.challengeData?.type ?? item.type ?? ''), + } +} + +/** + * Selects the owning API adapter for a list item. + * + * @param kind opportunity domain selected in the hero. + * @param item raw owning API response. + * @returns shared Figma card presentation data. + * @throws Does not throw when called with matching kind/item data. + */ +function toViewModel(kind: OpportunityKind, item: OpportunityItem): CardViewModel { + if (kind === 'competitions') return challengeView(item as ChallengeOpportunity) + if (kind === 'engagements') return engagementView(item as EngagementOpportunity) + if (kind === 'copilots') return copilotView(item as CopilotOpportunity) + return reviewView(item as ReviewOpportunity) +} + +/** + * Renders a responsive list card shared by all four owning API payloads. + * + * @param props opportunity kind and raw item. + * @returns linked opportunity card with tags and domain-specific metadata. + * @throws Does not throw. + */ +export const OpportunityListCard: FC = props => { + const card = toViewModel(props.kind, props.item) + const visibleSkills = card.skills.filter(Boolean) + .slice(0, 5) + const remaining = Math.max(0, card.skills.filter(Boolean).length - visibleSkills.length) + + return ( + +
+
+ {card.badge} + {card.type && {card.type}} + {card.state && {card.state}} +
+

{card.title}

+ {visibleSkills.length > 0 && ( +
+ {visibleSkills.map((skill: string) => {skill})} + {remaining > 0 && {`+${remaining}`}} +
+ )} + {card.description &&

{card.description}

} +
+
+ {card.meta.map(row => ( +
+ {row.icon} +
{`${row.label}:`}
+
{row.value}
+
+ ))} +
+ + ) +} diff --git a/src/apps/opportunities/src/components/OpportunityPagination.module.scss b/src/apps/opportunities/src/components/OpportunityPagination.module.scss new file mode 100644 index 000000000..9d861e27d --- /dev/null +++ b/src/apps/opportunities/src/components/OpportunityPagination.module.scss @@ -0,0 +1,79 @@ +.pagination { + align-items: center; + color: #4b565c; + display: grid; + font-size: 11px; + gap: 16px; + grid-template-columns: auto 1fr auto; + + label { + align-items: center; + display: flex; + gap: 7px; + } + + select { + background: transparent; + border: 0; + color: inherit; + font: inherit; + font-weight: 700; + } + + nav { + align-items: center; + display: flex; + gap: 6px; + } + + button { + align-items: center; + background: #fff; + border: 1px solid #d8dee2; + border-radius: 3px; + color: #3b474d; + cursor: pointer; + display: flex; + height: 30px; + justify-content: center; + min-width: 30px; + padding: 0 7px; + + &:disabled { + cursor: default; + opacity: .35; + } + + svg { + height: 14px; + width: 14px; + } + } + + .active { + background: #008f89; + border-color: #008f89; + color: #fff; + } +} + +@media (max-width: 760px) { + .pagination { + grid-template-columns: 1fr auto; + + > span { + display: none; + } + } +} + +@media (max-width: 480px) { + .pagination { + align-items: flex-start; + grid-template-columns: 1fr; + + nav { + flex-wrap: wrap; + } + } +} diff --git a/src/apps/opportunities/src/components/OpportunityPagination.tsx b/src/apps/opportunities/src/components/OpportunityPagination.tsx new file mode 100644 index 000000000..3f1bec876 --- /dev/null +++ b/src/apps/opportunities/src/components/OpportunityPagination.tsx @@ -0,0 +1,96 @@ +/* eslint-disable no-confusing-arrow, ordered-imports/ordered-imports, react/jsx-no-bind, react/no-array-index-key */ +import { ChangeEvent, FC } from 'react' +import { IconOutline } from '~/libs/ui' + +import styles from './OpportunityPagination.module.scss' + +interface OpportunityPaginationProps { + onPageChange: (page: number) => void + onPerPageChange: (perPage: number) => void + page: number + perPage: number + total: number + totalPages: number +} + +/** + * Calculates the compact page-number window used above and below results. + * + * @param page active page. + * @param totalPages total available pages. + * @returns page numbers and a zero sentinel for ellipses. + * @throws Does not throw. + */ +export function paginationWindow(page: number, totalPages: number): number[] { + if (totalPages <= 7) return Array.from({ length: totalPages }, (_, index) => index + 1) + const values = new Set([1, totalPages, page - 1, page, page + 1]) + const sorted = Array.from(values) + .filter(value => value > 0 && value <= totalPages) + .sort((a, b) => a - b) + return sorted.reduce((result, value, index) => { + if (index > 0 && value - sorted[index - 1] > 1) result.push(0) + result.push(value) + return result + }, []) +} + +/** + * Renders list page-size, range, and navigation controls. + * + * @param props current pagination metadata and change callbacks. + * @returns accessible pagination controls. + * @throws Does not throw. + */ +export const OpportunityPagination: FC = props => { + const start = props.total === 0 ? 0 : ((props.page - 1) * props.perPage) + 1 + const end = Math.min(props.total, props.page * props.perPage) + const pages = paginationWindow(props.page, props.totalPages) + + /** Applies a selected page size. */ + const handlePerPage = (event: ChangeEvent): void => { + props.onPerPageChange(Number(event.target.value)) + } + + return ( +
+ + {`${start} - ${end} of ${props.total} items`} + +
+ ) +} diff --git a/src/apps/opportunities/src/components/ReportIssueModal.module.scss b/src/apps/opportunities/src/components/ReportIssueModal.module.scss new file mode 100644 index 000000000..613898894 --- /dev/null +++ b/src/apps/opportunities/src/components/ReportIssueModal.module.scss @@ -0,0 +1,38 @@ +.form { + color: #46535a; + display: flex; + flex-direction: column; + font-family: 'Nunito Sans', sans-serif; + gap: 16px; + + > p { + margin: 0; + } +} + +.success { + align-items: center; + color: #344148; + display: flex; + flex-direction: column; + font-family: 'Nunito Sans', sans-serif; + padding: 20px; + text-align: center; + + svg { + color: #008f89; + height: 52px; + width: 52px; + } + + h3 { + font-family: 'Figtree', sans-serif; + font-size: 20px; + margin: 14px 0 5px; + text-transform: none; + } + + p { + margin: 0; + } +} diff --git a/src/apps/opportunities/src/components/ReportIssueModal.tsx b/src/apps/opportunities/src/components/ReportIssueModal.tsx new file mode 100644 index 000000000..eb6d006ed --- /dev/null +++ b/src/apps/opportunities/src/components/ReportIssueModal.tsx @@ -0,0 +1,115 @@ +/* eslint-disable react/jsx-no-bind */ +import { FC, useState } from 'react' +import { toast } from 'react-toastify' + +import { createSupportTicket } from '~/apps/support/src/lib/services/support.service' +import { SupportMarkdownEditor } from '~/apps/support/src/lib/components/SupportMarkdownEditor' +import { BaseModal, Button, IconOutline } from '~/libs/ui' + +import styles from './ReportIssueModal.module.scss' + +interface ReportIssueModalProps { + challengeId?: string + onClose: () => void + open: boolean +} + +/** + * Renders the Figma Report an Issue flow backed by support-api-v6. Subject, + * category, and separate file fields are intentionally omitted; the Markdown + * editor owns drag/drop and paste uploads. + * + * @param props optional challenge context and modal state. + * @returns Markdown-only support ticket form and success confirmation. + * @throws Does not throw; request errors remain visible inside the modal. + */ +export const ReportIssueModal: FC = props => { + const [description, setDescription] = useState('') + const [error, setError] = useState() + const [busy, setBusy] = useState(false) + const [created, setCreated] = useState(false) + const contextId = `opportunity-${props.challengeId ?? 'general'}` + + /** Clears local state and closes the dialog. */ + const close = (): void => { + if (busy) return + setDescription('') + setError(undefined) + setCreated(false) + props.onClose() + } + + /** Creates a challenge-scoped support ticket. */ + const submit = async (): Promise => { + const normalized = description.trim() + if (!normalized) { + setError('Enter a description of the issue.') + return + } + + setBusy(true) + setError(undefined) + try { + await createSupportTicket({ + ...(props.challengeId ? { challengeId: props.challengeId } : {}), + description: normalized, + }) + setCreated(true) + toast.success('Your issue was sent to the Platform Team.') + } catch (requestError) { + const message = requestError instanceof Error ? requestError.message : 'The issue could not be submitted.' + setError(message) + } finally { + setBusy(false) + } + } + + return ( + + ) : ( + <> + + ))} + + +
+
+ +
+ setIssueOpen(true)} + onShowTerms={() => setTermsOpen(true)} + /> +
+ setTermsOpen(false)} + open={termsOpen} + terms={challenge.terms ?? []} + /> + setIssueOpen(false)} + open={issueOpen} + /> + + ) +} + +interface ChallengeTabContentProps { + activeTab: ChallengeTab + challenge: ChallengeOpportunity + memberId?: string +} + +/** Renders only the active challenge tab and defers its domain request until selected. */ +const ChallengeTabContent: FC = props => { + if (props.activeTab === 'requirements') return + if (props.activeTab === 'registrants') return + if (props.activeTab === 'submissions') { + return + } + + if (props.activeTab === 'mine') { + return props.memberId + ? + : + } + + if (props.activeTab === 'forum') return + return +} + +/** Renders the Markdown requirements and generated table of contents. */ +const RequirementsTab: FC<{ challenge: ChallengeOpportunity }> = props => { + const markdown = props.challenge.description || props.challenge.overview || 'Requirements are not available yet.' + const toc = useMemo(() => { + const seen = new Map() + return markdown.split('\n') + .map(line => /^(#{2,3})\s+(.+?)\s*#*$/.exec(line.trim())) + .filter((match): match is RegExpExecArray => !!match) + .map(match => { + const label = match[2].replace(/[*_`~]/g, '') + .trim() + const base = label.toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .trim() + .replace(/\s+/g, '-') || 'section' + const count = (seen.get(base) ?? 0) + 1 + seen.set(base, count) + return { + id: count === 1 ? base : `${base}-${count}`, + label, + level: match[1].length as 2 | 3, + } + }) + }, [markdown]) + + return ( +
+
+ Please read the challenge requirements carefully and watch the forum for updates. +
+ {toc.length > 0 && ( + + )} + + +
+ ) +} + +/** Renders a compact timeline table addressable by the hero anchor. */ +const ChallengeTimeline: FC<{ challenge: ChallengeOpportunity }> = props => ( +
+

Reference Timeline

+ + + + + + + + + + {(props.challenge.phases ?? []).map(phase => ( + + + + + + ))} + +
PhaseOpensCloses
{phase.name}{formatTimestamp(phase.actualStartDate ?? phase.scheduledStartDate)}{formatTimestamp(phase.actualEndDate ?? phase.scheduledEndDate)}
+
+) + +/** Loads registrants only after the Registrants tab is selected. */ +const RegistrantsTab: FC<{ challengeId: string }> = props => { + const response: SWRResponse = useSWR( + ['opportunities:registrants', props.challengeId], + () => getChallengeResources(props.challengeId), + { revalidateOnFocus: false }, + ) + if (response.isValidating && !response.data) return + if (response.error) return response.mutate()} /> + return ( +
+

{`Registrants (${response.data?.length ?? 0})`}

+ + + + + + + + + {response.data?.map(resource => ( + + + + + ))} + +
HandleMember ID
{resource.memberHandle || resource.memberId || 'Member'}{resource.memberId || '—'}
+
+ ) +} + +interface SubmissionsTabProps { + challenge: ChallengeOpportunity + memberId?: string + mine?: boolean +} + +/** Loads and paginates submissions only after a submission tab is selected. */ +const SubmissionsTab: FC = props => { + const [page, setPage] = useState(1) + const [perPage, setPerPage] = useState(10) + const response: SWRResponse, Error> = useSWR( + ['opportunities:submissions', props.challenge.id, props.memberId, page, perPage], + () => getChallengeSubmissions(props.challenge.id, page, perPage, props.memberId), + { revalidateOnFocus: false }, + ) + const track = catalogName(props.challenge.track) + .toLowerCase() + if (response.isValidating && !response.data) return + if (response.error) return response.mutate()} /> + if (!response.data?.items.length) { + const emptyText = props.mine + ? 'You have not submitted a solution yet.' + : 'No submissions are visible yet.' + return + } + + const pagination = ( + { + setPerPage(value) + setPage(1) + }} + page={response.data.page} + perPage={response.data.perPage} + total={response.data.total} + totalPages={response.data.totalPages} + /> + ) + return ( +
+

{props.mine ? 'My Submissions' : 'All Submissions'}

+ {pagination} + {track === 'design' ? ( +
+ {response.data.items.map(submission => ( + + ))} +
+ ) : ( +
+ + + + + + + + + + {response.data.items.map(submission => ( + + + + + + ))} + +
HandleSubmission DateType
{submissionHandle(submission)}{formatTimestamp(submission.submittedDate ?? submission.createdAt)}{submission.type || 'Submission'}
+
+ )} + {pagination} +
+ ) +} + +/** Resolves the best available submission handle. */ +function submissionHandle(submission: ChallengeSubmission): string { + return submission.memberHandle + || submission.registrant?.memberHandle + || submission.registrant?.handle + || submission.createdBy + || submission.memberId + || 'Member' +} + +/** Renders one design submission preview, hiding unreleased/missing images on 404. */ +const SubmissionPreview: FC<{ submission: ChallengeSubmission }> = props => { + const [previewAvailable, setPreviewAvailable] = useState(true) + /** Hides a preview when the Review API enforces its release gate. */ + const hidePreview = (event: SyntheticEvent): void => { + event.currentTarget.removeAttribute('src') + setPreviewAvailable(false) + } + + return ( +
+ {previewAvailable ? ( + {`Submission + ) : ( +
+ + Preview available after review +
+ )} + {submissionHandle(props.submission)} + {formatTimestamp(props.submission.submittedDate ?? props.submission.createdAt)} +
+ ) +} + +/** Renders the forum handoff without loading forum data before selection. */ +const ForumTab: FC<{ challenge: ChallengeOpportunity }> = props => ( +
+ +

Challenge Forum

+

Ask questions and follow clarifications from the challenge team.

+ + Open forum + + +
+) + +/** Renders challenge winners once present in the Challenge API response. */ +const WinnersTab: FC<{ challenge: ChallengeOpportunity }> = props => { + if (!props.challenge.winners?.length) return + return ( +
+

Winners

+ + + + + + + + + + {props.challenge.winners.map((winner, index) => ( + + + + + + ))} + +
PlacementHandlePrize
{winner.placement ?? index + 1}{winner.handle ?? winner.userId ?? 'Member'}{winner.prize === undefined ? '—' : `$${winner.prize.toLocaleString()}`}
+
+ ) +} + +/** Renders the auth handoff for private member tabs. */ +const SignInTab: FC = () => ( +
+ +

Sign in to view your submissions

+ Sign in +
+) + +/** Renders a retryable lazy-tab error state. */ +const TabError: FC<{ onRetry: () => void }> = props => ( +
+ +

This section could not be loaded.

+ +
+) + +/** Renders a neutral empty tab message. */ +const EmptyTab: FC<{ text: string }> = props => ( +
+ +

{props.text}

+
+) + +export default ChallengeDetailsPage diff --git a/src/apps/opportunities/src/pages/OpportunitiesPage.module.scss b/src/apps/opportunities/src/pages/OpportunitiesPage.module.scss new file mode 100644 index 000000000..ad6debede --- /dev/null +++ b/src/apps/opportunities/src/pages/OpportunitiesPage.module.scss @@ -0,0 +1,191 @@ +.page { + background: #f4f7f9; + color: #15191c; + font-family: 'Nunito Sans', sans-serif; + min-height: 70vh; +} + +.content { + margin: 0 auto; + max-width: 1120px; + padding: 66px 24px 72px; +} + +.titleRow { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 32px; + + h2 { + font-family: 'Figtree', sans-serif; + font-size: 22px; + font-weight: 700; + line-height: 28px; + text-transform: none; + } +} + +.sort { + align-items: center; + display: flex; + font-size: 12px; + gap: 6px; + + svg { + height: 16px; + width: 16px; + } + + select { + background: transparent; + border: 0; + color: #3c474c; + font: inherit; + } +} + +.body { + align-items: start; + display: grid; + gap: 24px; + grid-template-columns: 245px minmax(0, 1fr); +} + +.sidebar, +.results, +.list, +.loading { + display: flex; + flex-direction: column; +} + +.sidebar { + gap: 16px; +} + +.results { + gap: 18px; + min-width: 0; +} + +.list { + gap: 14px; +} + +.learning { + background: #008f89; + border-radius: 5px; + color: #fff; + padding: 22px; + + h3 { + font-family: 'Figtree', sans-serif; + font-size: 16px; + font-weight: 700; + line-height: 21px; + text-transform: none; + } + + p { + font-size: 13px; + line-height: 19px; + margin: 9px 0 13px; + } + + a { + align-items: center; + color: #fff; + display: flex; + font-size: 13px; + font-weight: 700; + gap: 7px; + text-decoration: none; + + svg { + height: 16px; + width: 16px; + } + } +} + +.message { + align-items: center; + background: #fff; + border: 1px solid #dce2e5; + border-radius: 6px; + display: flex; + flex-direction: column; + padding: 46px 24px; + text-align: center; + + svg { + color: #008f89; + height: 34px; + width: 34px; + } + + h3 { + font-family: 'Figtree', sans-serif; + font-size: 18px; + font-weight: 700; + margin: 13px 0 4px; + text-transform: none; + } + + p { + color: #59666c; + margin: 0; + } + + button { + background: #008f89; + border: 0; + border-radius: 4px; + color: #fff; + cursor: pointer; + font: inherit; + font-weight: 700; + margin-top: 16px; + padding: 8px 16px; + } +} + +.loading { + gap: 14px; + + span { + animation: pulse 1.2s ease-in-out infinite alternate; + background: linear-gradient(90deg, #e7ecef, #f7f9fa, #e7ecef); + border: 1px solid #dce2e5; + border-radius: 6px; + height: 130px; + } +} + +@keyframes pulse { + from { opacity: .55; } + to { opacity: 1; } +} + +@media (max-width: 760px) { + .content { + padding-top: 40px; + } + + .body { + grid-template-columns: 1fr; + } +} + +@media (max-width: 520px) { + .content { + padding-inline: 16px; + } + + .titleRow { + align-items: flex-start; + flex-direction: column; + gap: 12px; + } +} diff --git a/src/apps/opportunities/src/pages/OpportunitiesPage.tsx b/src/apps/opportunities/src/pages/OpportunitiesPage.tsx new file mode 100644 index 000000000..cd43f723c --- /dev/null +++ b/src/apps/opportunities/src/pages/OpportunitiesPage.tsx @@ -0,0 +1,311 @@ +/* eslint-disable react/jsx-no-bind */ +import { + ChangeEvent, + FC, + useDeferredValue, + useMemo, + useState, +} from 'react' +import { Link, useParams } from 'react-router-dom' +import useSWR, { SWRResponse } from 'swr' + +import { + ProfileContextData, + useProfileContext, +} from '~/libs/core' +import { IconOutline } from '~/libs/ui' + +import { + OpportunityFiltersPanel, + OpportunityHero, + OpportunityListCard, + OpportunityPagination, +} from '../components' +import { + OpportunityFilters, + OpportunityItem, + OpportunityKind, + OpportunityPage, + OpportunitySummary, +} from '../models' +import { + getOpportunityPage, + getOpportunitySummary, +} from '../services' + +import styles from './OpportunitiesPage.module.scss' + +const KIND_LABELS: Record = { + competitions: 'Competitions', + copilots: 'Copilot Opportunities', + engagements: 'Engagements', + reviews: 'Review Opportunities', +} + +const VALID_KINDS = new Set([ + 'competitions', + 'engagements', + 'copilots', + 'reviews', +]) + +/** + * Resolves an optional route segment to a supported opportunity domain. + * + * @param value route parameter. + * @returns matching domain, defaulting to competitions for the root route. + * @throws Does not throw. + */ +export function resolveOpportunityKind(value?: string): OpportunityKind { + return VALID_KINDS.has(value as OpportunityKind) ? value as OpportunityKind : 'competitions' +} + +/** + * Checks a Topcoder profile role without depending on capitalization. + * + * @param roles token role names. + * @param expected role being checked. + * @returns true when the role exists. + * @throws Does not throw. + */ +function hasRole(roles: string[] | undefined, expected: string): boolean { + return !!roles?.some(role => role.toLowerCase() === expected.toLowerCase()) +} + +/** + * Builds the first-page status appropriate for each owning API. + * + * @param kind active opportunity domain. + * @returns default status filter. + * @throws Does not throw. + */ +function defaultStatus(kind: OpportunityKind): string { + if (kind === 'competitions') return 'ACTIVE' + if (kind === 'copilots') return 'active' + return 'OPEN' +} + +interface LearningCardProps { + body: string + href: string + title: string +} + +/** + * Renders the role-learning callout required for members who do not yet have a + * reviewer or copilot role. + * + * @param props title, explanatory text, and Thrive destination. + * @returns role education callout. + * @throws Does not throw. + */ +const LearningCard: FC = props => ( + +) + +/** + * Renders stable card-shaped placeholders during the first page request. + * + * @returns animated list placeholder. + * @throws Does not throw. + */ +const ResultsLoading: FC = () => ( +
+ {[1, 2, 3, 4].map(value => )} +
+) + +/** + * Renders the unified Opportunities listing while keeping each tab's data call + * lazy, filtered, and paginated through its owning v6 API. + * + * @returns the four-cell hero, server-backed filters, and one active result page. + * @throws Does not throw; request failures render a retryable in-page state. + */ +export const OpportunitiesPage: FC = () => { + const params = useParams<{ kind?: string }>() + const kind = resolveOpportunityKind(params.kind) + const { profile }: ProfileContextData = useProfileContext() + const [search, setSearch] = useState('') + const deferredSearch = useDeferredValue(search.trim()) + const [page, setPage] = useState(1) + const [perPage, setPerPage] = useState(10) + const [applied, setApplied] = useState(false) + const [tracks, setTracks] = useState([]) + const [status, setStatus] = useState(defaultStatus(kind)) + const [sort, setSort] = useState('') + + const filters = useMemo(() => ({ + applied, + memberId: profile?.userId === undefined ? undefined : String(profile.userId), + page, + perPage, + search: deferredSearch || undefined, + sort: sort || undefined, + statuses: status ? [status] : undefined, + tracks: tracks.length ? tracks : undefined, + }), [applied, deferredSearch, page, perPage, profile?.userId, sort, status, tracks]) + + const summaryResponse: SWRResponse = useSWR( + 'opportunities:summary', + getOpportunitySummary, + { revalidateOnFocus: false }, + ) + const pageResponse: SWRResponse, Error> = useSWR( + ['opportunities:list', kind, filters], + () => getOpportunityPage(kind, filters), + { revalidateOnFocus: false }, + ) + const data = pageResponse.data + const isReviewer = hasRole(profile?.roles, 'reviewer') + const isCopilot = hasRole(profile?.roles, 'copilot') + + /** Resets active controls and their server page. */ + const resetFilters = (): void => { + setSearch('') + setApplied(false) + setTracks([]) + setStatus(defaultStatus(kind)) + setSort('') + setPage(1) + } + + /** Updates a track facet and starts again at page one. */ + const updateTrack = (track: string, checked: boolean): void => { + setTracks(current => (checked + ? Array.from(new Set([...current, track])) + : current.filter(value => value !== track))) + setPage(1) + } + + /** Updates the active status and starts again at page one. */ + const updateStatus = (value: string): void => { + setStatus(value) + setPage(1) + } + + /** Updates the ownership filter and starts again at page one. */ + const updateApplied = (checked: boolean): void => { + setApplied(checked) + setPage(1) + } + + /** Updates the search input and starts again at page one. */ + const updateSearch = (value: string): void => { + setSearch(value) + setPage(1) + } + + /** Updates the page size and starts again at page one. */ + const updatePerPage = (value: number): void => { + setPerPage(value) + setPage(1) + } + + /** Applies list sorting selected in the toolbar. */ + const updateSort = (event: ChangeEvent): void => { + setSort(event.target.value) + setPage(1) + } + + return ( +
+ +
+
+

{`Browse ${KIND_LABELS[kind]}`}

+ +
+
+
+ + {kind === 'reviews' && !isReviewer && ( + + )} + {kind === 'copilots' && !isCopilot && ( + + )} +
+
+ + {pageResponse.isValidating && !data && } + {pageResponse.error && ( +
+ +

We couldn't load these opportunities.

+

Please try again. Your filters have been preserved.

+ +
+ )} + {!pageResponse.error && data?.items.length === 0 && ( +
+ +

No matching opportunities

+

Try removing a filter or changing your search.

+
+ )} +
+ {data?.items.map((item: OpportunityItem) => ( + + ))} +
+ {(data?.items.length ?? 0) > 0 && ( + + )} +
+
+
+
+ ) +} + +export default OpportunitiesPage diff --git a/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.module.scss b/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.module.scss new file mode 100644 index 000000000..f6b7cbafc --- /dev/null +++ b/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.module.scss @@ -0,0 +1,332 @@ +.page { + background: #f4f7f9; + color: #1d272c; + font-family: 'Nunito Sans', sans-serif; + min-height: 70vh; +} + +.loading, +.notFound { + align-items: center; + display: flex; + justify-content: center; + min-height: 60vh; +} + +.notFound { + flex-direction: column; + gap: 10px; + + > svg { + color: #008f89; + height: 50px; + width: 50px; + } + + h1 { + font-family: 'Figtree', sans-serif; + text-transform: none; + } + + a { color: #00797a; } +} + +.header { + background: #002a3a; + color: #fff; + overflow: hidden; + position: relative; +} + +.rings { + border: 2px dashed rgba(0, 205, 194, .35); + border-radius: 50%; + height: 360px; + position: absolute; + right: -60px; + top: -130px; + width: 360px; + + &::before { + border: 2px dashed rgba(0, 205, 194, .35); + border-radius: 50%; + content: ''; + inset: 60px; + position: absolute; + } +} + +.headerInner, +.content, +.tabs > div { + margin: 0 auto; + max-width: 1120px; +} + +.headerInner { + padding: 25px 24px 30px; + position: relative; + z-index: 1; +} + +.breadcrumbs { + color: #9bb2bb; + display: flex; + flex-wrap: wrap; + font-size: 11px; + gap: 8px; + margin-bottom: 25px; + + a { color: inherit; text-decoration: none; } +} + +.heroLayout { + align-items: end; + display: grid; + gap: 35px; + grid-template-columns: minmax(0, 1fr) 350px; + + h1 { + font-family: 'Figtree', sans-serif; + font-size: 35px; + font-weight: 700; + line-height: 41px; + margin: 14px 0 20px; + max-width: 700px; + text-transform: none; + } +} + +.badges, +.meta { + align-items: center; + display: flex; + flex-wrap: wrap; +} + +.badges { + font-size: 10px; + gap: 8px; + + span:first-child { + border: 1px solid #36c77b; + color: #55e59a; + padding: 2px 5px; + } +} + +.meta { + color: #d1e1e5; + font-size: 12px; + gap: 22px; + + span { + align-items: center; + display: flex; + gap: 8px; + } + + svg { + color: #23ccc3; + height: 18px; + width: 18px; + } +} + +.compensation { + background: #0d3445; + border-radius: 7px; + display: flex; + flex-direction: column; + gap: 14px; + padding: 25px; + + > small { + color: #a9bec6; + text-align: center; + } + + > div { + display: grid; + gap: 2px 16px; + grid-template-columns: 1fr 1fr; + } + + strong { + color: #30ddd3; + font-family: 'Figtree', sans-serif; + font-size: 28px; + } + + span { + color: #a9bec6; + font-size: 10px; + } + + button { + align-items: center; + background: #008f89; + border: 0; + border-radius: 3px; + color: #fff; + cursor: pointer; + display: flex; + font: inherit; + font-size: 13px; + font-weight: 700; + gap: 7px; + justify-content: center; + min-height: 38px; + margin-top: 6px; + + &:disabled { + cursor: not-allowed; + opacity: .45; + } + + svg { height: 17px; width: 17px; } + } +} + +.tabs { + background: #fff; + border-bottom: 1px solid #ccd5d9; + + > div { + display: flex; + padding: 0 24px; + } + + button { + background: none; + border: 0; + border-bottom: 3px solid transparent; + color: #3b474d; + cursor: pointer; + font: inherit; + padding: 14px 22px 11px; + + span { color: #008f89; margin-left: 4px; } + } + + .activeTab { + border-bottom-color: #008f89; + color: #00797a; + font-weight: 700; + } +} + +.content { + align-items: start; + display: grid; + gap: 28px; + grid-template-columns: minmax(0, 1fr) 320px; + padding: 30px 24px 80px; +} + +.mainContent { min-width: 0; } + +.requirements, +.applications, +.card { + background: #fff; + border: 1px solid #dfe5e8; + border-radius: 5px; + padding: 25px; +} + +.notice { + background: #fff9dc; + border: 1px solid #e5c94f; + color: #705c00; + font-size: 12px; + margin-bottom: 20px; + padding: 12px; +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 14px; +} + +.learning { + background: #008f89; + border-radius: 5px; + color: #fff; + padding: 22px; + + h2, + p { margin: 0 0 9px; } + + h2 { + font-family: 'Figtree', sans-serif; + font-size: 17px; + text-transform: none; + } + + a { + align-items: center; + color: #fff; + display: flex; + font-weight: 700; + gap: 6px; + text-decoration: none; + + svg { height: 15px; width: 15px; } + } +} + +.card { + h2 { + align-items: center; + display: flex; + font-family: 'Figtree', sans-serif; + font-size: 15px; + gap: 8px; + text-transform: none; + + svg { color: #008f89; height: 18px; width: 18px; } + } + + p { color: #59676e; font-size: 12px; line-height: 17px; } + + a, + button { + background: none; + border: 0; + color: #00797a; + cursor: pointer; + display: block; + font: inherit; + font-size: 12px; + font-weight: 700; + margin-top: 8px; + padding: 0; + text-decoration: none; + } +} + +.applications { + h2 { + font-family: 'Figtree', sans-serif; + font-size: 21px; + margin-bottom: 18px; + text-transform: none; + } + + table { border-collapse: collapse; width: 100%; } + th, + td { border-bottom: 1px solid #dce2e5; font-size: 12px; padding: 11px 9px; text-align: left; } +} + +@media (max-width: 850px) { + .heroLayout, + .content { grid-template-columns: 1fr; } +} + +@media (max-width: 520px) { + .heroLayout h1 { font-size: 28px; line-height: 33px; } + .content, + .headerInner { padding-inline: 16px; } +} diff --git a/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.tsx b/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.tsx new file mode 100644 index 000000000..7c8694cd8 --- /dev/null +++ b/src/apps/opportunities/src/pages/ReviewOpportunityDetailsPage.tsx @@ -0,0 +1,299 @@ +/* eslint-disable no-use-before-define, react/jsx-no-bind */ +import { FC, useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import { toast } from 'react-toastify' +import useSWR, { SWRResponse } from 'swr' + +import { + authUrlLogin, + ProfileContextData, + useProfileContext, +} from '~/libs/core' +import { IconOutline, LoadingSpinner } from '~/libs/ui' + +import { ChallengeMarkdown, ReportIssueModal } from '../components' +import { ReviewApplicationSummary, ReviewOpportunity } from '../models' +import { applyToReviewOpportunity, getReviewOpportunity } from '../services' + +import styles from './ReviewOpportunityDetailsPage.module.scss' + +type ReviewTab = 'requirements' | 'applications' + +const REASON_LABELS: Record = { + ALREADY_APPLIED: 'Application submitted', + CHALLENGE_NOT_ACTIVE: 'Challenge is no longer active', + NO_OPEN_POSITIONS: 'All reviewer positions are filled', + NOT_AUTHENTICATED: 'Sign in to apply', + NOT_REVIEWER: 'Reviewer role required', + OPPORTUNITY_CLOSED: 'Applications are closed', +} + +/** Returns challenge data as a safely displayable string. */ +function challengeField(opportunity: ReviewOpportunity, key: string, fallback: string): string { + const value = opportunity.challengeData?.[key] + if (typeof value === 'string' || typeof value === 'number') return String(value) + if (value && typeof value === 'object' && 'name' in value) { + return String((value as { name?: unknown }).name ?? fallback) + } + + return fallback +} + +/** Formats Review API dates for the detail masthead. */ +function formatDate(value?: string): string { + if (!value) return 'TBD' + const date = new Date(value) + return Number.isNaN(date.getTime()) ? 'TBD' : new Intl.DateTimeFormat('en-US', { + day: 'numeric', + month: 'short', + year: 'numeric', + }) + .format(date) +} + +/** Formats seconds as the review period end date. */ +function reviewEnd(startDate?: string, duration?: number): string { + if (!startDate || !duration) return 'TBD' + const end = new Date(new Date(startDate) + .getTime() + (duration * 1000)) + return formatDate(end.toISOString()) +} + +/** + * Renders a public review opportunity with API-authoritative reviewer gating. + * Non-reviewers receive the education card and an inactive CTA; reviewers do + * not receive that card and can apply only when `canApply` is true. + * + * @returns detail route for `/opportunities/review/:reviewOpportunityId`. + * @throws Does not throw; API failures render an in-page state. + */ +export const ReviewOpportunityDetailsPage: FC = () => { + const routeParams: Readonly<{ reviewOpportunityId?: string }> = useParams<{ reviewOpportunityId: string }>() + const reviewOpportunityId = routeParams.reviewOpportunityId ?? '' + const { profile }: ProfileContextData = useProfileContext() + const [activeTab, setActiveTab] = useState('requirements') + const [busy, setBusy] = useState(false) + const [issueOpen, setIssueOpen] = useState(false) + const response: SWRResponse = useSWR( + reviewOpportunityId ? `opportunities:review:${reviewOpportunityId}` : undefined, + () => getReviewOpportunity(reviewOpportunityId), + { revalidateOnFocus: false }, + ) + const opportunity = response.data + const isReviewer = !!profile?.roles?.some(role => role.toLowerCase() === 'reviewer') + + /** Applies through the Review API and refreshes server-authoritative state. */ + const apply = async (): Promise => { + if (!profile) { + window.location.assign(authUrlLogin(window.location.href)) + return + } + + if (!opportunity?.canApply) return + setBusy(true) + try { + await applyToReviewOpportunity(opportunity.id) + await response.mutate() + toast.success('Your reviewer application was submitted.') + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Application failed.') + } finally { + setBusy(false) + } + } + + if (response.isValidating && !opportunity) { + return
+ } + + if (response.error || !opportunity) { + return ( +
+ +

Review opportunity not found

+ Browse review opportunities +
+ ) + } + + const title = opportunity.challengeName || challengeField(opportunity, 'name', 'Review Opportunity') + const track = challengeField(opportunity, 'track', 'Competition') + const type = challengeField(opportunity, 'type', opportunity.type || 'Review') + const description = challengeField( + opportunity, + 'description', + challengeField(opportunity, 'overview', 'Challenge requirements are not available yet.'), + ) + const applications = opportunity.applications?.filter(application => application.status !== 'CANCELLED') ?? [] + const disabledLabel = REASON_LABELS[opportunity.canApplyReason ?? ''] ?? 'Apply to be a reviewer' + + return ( +
+
+
+ +
+
+ {activeTab === 'requirements' ? ( +
+
+ Read the specification carefully and watch the challenge forum for updates. +
+ +
+ ) : ( + + )} +
+ +
+ setIssueOpen(false)} + open={issueOpen} + /> +
+ ) +} + +/** Renders review applications without exposing member-only mutation actions. */ +const Applications: FC<{ applications: ReviewApplicationSummary[] }> = props => ( +
+

Reviewer Applications

+ {props.applications.length === 0 ? ( +

No applications have been submitted yet.

+ ) : ( + + + + + + + + + + + {props.applications.map(application => ( + + + + + + + ))} + +
MemberRoleStatusApplied
{application.userHandle || application.userId || 'Member'}{application.role || 'Reviewer'}{application.status || 'Pending'}{formatDate(application.createdAt)}
+ )} +
+) + +export default ReviewOpportunityDetailsPage diff --git a/src/apps/opportunities/src/pages/index.ts b/src/apps/opportunities/src/pages/index.ts new file mode 100644 index 000000000..228780b72 --- /dev/null +++ b/src/apps/opportunities/src/pages/index.ts @@ -0,0 +1,3 @@ +export { default as ChallengeDetailsPage } from './ChallengeDetailsPage' +export { default as OpportunitiesPage } from './OpportunitiesPage' +export { default as ReviewOpportunityDetailsPage } from './ReviewOpportunityDetailsPage' diff --git a/src/apps/opportunities/src/services/index.ts b/src/apps/opportunities/src/services/index.ts new file mode 100644 index 000000000..032c80c19 --- /dev/null +++ b/src/apps/opportunities/src/services/index.ts @@ -0,0 +1 @@ +export * from './opportunities.service' diff --git a/src/apps/opportunities/src/services/opportunities.service.spec.ts b/src/apps/opportunities/src/services/opportunities.service.spec.ts new file mode 100644 index 000000000..681273ddd --- /dev/null +++ b/src/apps/opportunities/src/services/opportunities.service.spec.ts @@ -0,0 +1,47 @@ +/* eslint-disable ordered-imports/ordered-imports */ +import { normalizeOpportunitySummary } from './opportunities.service' + +jest.mock('~/config', () => ({ + EnvironmentConfig: { + API: { V5: 'https://api.example/v5', V6: 'https://api.example/v6' }, + }, +}), { virtual: true }) +jest.mock('~/libs/core', () => ({ + xhrDeleteAsync: jest.fn(), + xhrGetAsync: jest.fn(), + xhrGlobalInstance: { get: jest.fn() }, + xhrPostAsync: jest.fn(), +}), { virtual: true }) + +describe('opportunities service normalization', () => { + it('normalizes the aggregation service cells and accepted legacy aliases', () => { + expect(normalizeOpportunitySummary({ + result: { + content: { + cells: { + challenges: { openCount: 12, totalPrize: '38500' }, + copilotOpportunities: { count: 2 }, + engagements: { total: 8 }, + reviewOpportunities: { count: 3 }, + }, + }, + }, + })) + .toEqual({ + competitions: { amount: 38500, count: 12 }, + copilots: { count: 2 }, + engagements: { count: 8 }, + reviews: { count: 3 }, + }) + }) + + it('uses safe zero counts when aggregation cells are absent or invalid', () => { + expect(normalizeOpportunitySummary({ competitions: { count: -1 } })) + .toEqual({ + competitions: { count: 0 }, + copilots: { count: 0 }, + engagements: { count: 0 }, + reviews: { count: 0 }, + }) + }) +}) diff --git a/src/apps/opportunities/src/services/opportunities.service.ts b/src/apps/opportunities/src/services/opportunities.service.ts new file mode 100644 index 000000000..67105c4bb --- /dev/null +++ b/src/apps/opportunities/src/services/opportunities.service.ts @@ -0,0 +1,453 @@ +import { AxiosHeaders, AxiosResponse } from 'axios' + +import { EnvironmentConfig } from '~/config' +import { + xhrDeleteAsync, + xhrGetAsync, + xhrGlobalInstance, + xhrPostAsync, +} from '~/libs/core' + +import { + ApiEnvelope, + ApiListResponse, + ChallengeOpportunity, + ChallengeResource, + ChallengeResourceRole, + ChallengeSubmission, + ChallengeTerm, + CopilotOpportunity, + EngagementOpportunity, + OpportunityCellSummary, + OpportunityFilters, + OpportunityKind, + OpportunityPage, + OpportunitySummary, + ReviewOpportunity, +} from '../models' + +const V6_URL = EnvironmentConfig.API.V6 + +const DEFAULT_SUMMARY: OpportunitySummary = { + competitions: { amount: 0, count: 0 }, + copilots: { amount: 0, count: 0 }, + engagements: { count: 0 }, + reviews: { count: 0 }, +} + +/** + * Converts a value into a finite non-negative number. + * + * @param value API value that may be numeric or a serialized number. + * @param fallback value used when conversion is not possible. + * @returns normalized number. + * @throws Does not throw. + */ +function toNumber(value: unknown, fallback: number = 0): number { + const converted = Number(value) + return Number.isFinite(converted) && converted >= 0 ? converted : fallback +} + +/** + * Reads a response header using case-insensitive Axios header access. + * + * @param headers Axios response headers. + * @param names accepted header names in priority order. + * @returns the first finite numeric header, or undefined. + * @throws Does not throw. + */ +function readNumericHeader(headers: AxiosHeaders, names: string[]): number | undefined { + const match = names + .map(name => Number(headers.get(name))) + .find(value => Number.isFinite(value) && value >= 0) + return match +} + +/** + * Appends non-empty filters as repeatable query parameters. + * + * @param url destination URL being constructed. + * @param name query parameter name. + * @param values values to append. + * @returns void. + * @throws Does not throw. + */ +function appendValues(url: URL, name: string, values?: string[]): void { + values?.filter(Boolean) + .forEach(value => url.searchParams.append(name, value)) +} + +/** + * Unwraps Topcoder's standard result envelope while accepting bare payloads. + * + * @param payload bare data or standard response envelope. + * @returns unwrapped content. + * @throws Does not throw. + */ +function unwrap(payload: T | ApiEnvelope): T { + const envelope = payload as ApiEnvelope + return envelope.result?.content ?? envelope.content ?? payload as T +} + +/** + * Builds a consistent page model from a list response and its pagination headers. + * + * @param response Axios response containing a bare array or standard envelope. + * @param fallbackPage requested page when the API omits headers. + * @param fallbackPerPage requested page size when the API omits headers. + * @returns normalized page model. + * @throws Does not throw. + */ +function normalizePage( + response: AxiosResponse | ApiListResponse>, + fallbackPage: number, + fallbackPerPage: number, +): OpportunityPage { + const listResponse = response.data as ApiListResponse + const unwrapped = unwrap(response.data as T[] | ApiEnvelope) + const items = Array.isArray(unwrapped) ? unwrapped : listResponse.data ?? [] + const envelope = response.data as ApiEnvelope + const metadata = envelope.result?.metadata ?? envelope.metadata ?? listResponse.meta ?? {} + const headers = response.headers as AxiosHeaders + const page = readNumericHeader(headers, ['x-page']) ?? toNumber(metadata.page, fallbackPage) + const perPage = readNumericHeader(headers, ['x-per-page']) + ?? toNumber(metadata.perPage ?? metadata.limit, fallbackPerPage) + const total = readNumericHeader(headers, ['x-total', 'x-total-count']) + ?? toNumber(metadata.total ?? metadata.totalCount, items.length) + const totalPages = readNumericHeader(headers, ['x-total-pages']) + ?? toNumber(metadata.totalPages, perPage > 0 ? Math.ceil(total / perPage) : 0) + return { items, page, perPage, total, totalPages } +} + +/** + * Converts any supported aggregation response shape to the four-cell UI contract. + * + * @param payload aggregation service response. + * @returns summaries for competitions, engagements, copilots, and reviews. + * @throws Does not throw; missing cells use zero values. + */ +export function normalizeOpportunitySummary(payload: unknown): OpportunitySummary { + const content = unwrap(payload as ApiEnvelope>) as Record + const source = content?.cells ?? content?.summary ?? content ?? {} + const aliases: Record = { + competitions: ['competitions', 'challenges'], + copilots: ['copilots', 'copilotOpportunities'], + engagements: ['engagements'], + reviews: ['reviews', 'reviewOpportunities'], + } + + return (Object.keys(aliases) as OpportunityKind[]) + .reduce((result, kind) => { + const raw = aliases[kind] + .map(alias => source[alias]) + .find(Boolean) ?? {} + const cell: OpportunityCellSummary = { + count: toNumber(raw.count ?? raw.openCount ?? raw.total), + ...(raw.amount !== undefined || raw.totalPrize !== undefined || raw.availablePayment !== undefined + ? { amount: toNumber(raw.amount ?? raw.totalPrize ?? raw.availablePayment) } + : {}), + ...(raw.amountLabel ? { amountLabel: String(raw.amountLabel) } : {}), + ...(raw.tag ? { tag: String(raw.tag) } : {}), + } + result[kind] = cell + return result + }, { ...DEFAULT_SUMMARY }) +} + +/** + * Loads all four headline cells in one request to opportunities-api-v6. + * + * @returns current public opportunity totals and available amounts. + * @throws Propagates API/network errors to the page error boundary. + */ +export async function getOpportunitySummary(): Promise { + const response = await xhrGetAsync(`${V6_URL}/opportunities/summary`) + return normalizeOpportunitySummary(response) +} + +/** + * Loads one filtered page from the owning domain API. + * + * @param kind active opportunity type. + * @param filters search, facets, sorting, and pagination values. + * @returns normalized page with list data and total metadata. + * @throws Propagates API/network errors to the caller. + */ +export async function getOpportunityPage( + kind: OpportunityKind, + filters: OpportunityFilters, +): Promise> { + const page = Math.max(1, filters.page) + const perPage = Math.max(1, filters.perPage) + let endpoint: string + const url = new URL(V6_URL) + + if (kind === 'competitions') { + endpoint = `${V6_URL}/challenges` + url.pathname = new URL(endpoint).pathname + url.searchParams.set('page', String(page)) + url.searchParams.set('perPage', String(perPage)) + url.searchParams.set('sortBy', filters.sort || 'updatedAt') + url.searchParams.set('sortOrder', 'desc') + if (filters.search) url.searchParams.set('search', filters.search) + const competitionStatuses = filters.statuses?.includes('REGISTRATION') + ? ['ACTIVE'] + : filters.statuses + appendValues(url, 'status', competitionStatuses) + if (filters.statuses?.includes('REGISTRATION')) url.searchParams.set('phase', 'Registration') + appendValues(url, 'track', filters.tracks) + appendValues(url, 'type', filters.types) + appendValues(url, 'tags', filters.skills) + if (filters.applied && filters.memberId) url.searchParams.set('memberId', filters.memberId) + } else if (kind === 'engagements') { + endpoint = `${V6_URL}/engagements/engagements` + url.pathname = new URL(endpoint).pathname + url.searchParams.set('page', String(page)) + url.searchParams.set('perPage', String(perPage)) + if (filters.search) url.searchParams.set('search', filters.search) + appendValues(url, 'status', filters.statuses) + appendValues(url, 'requiredSkills', filters.skills) + if (filters.applied) url.searchParams.set('appliedByMe', 'true') + } else if (kind === 'copilots') { + endpoint = `${V6_URL}/projects/copilots/opportunities` + url.pathname = new URL(endpoint).pathname + url.searchParams.set('page', String(page)) + url.searchParams.set('pageSize', String(perPage)) + url.searchParams.set('sort', filters.sort || 'createdAt desc') + if (filters.search) url.searchParams.set('search', filters.search) + appendValues(url, 'status', filters.statuses) + appendValues(url, 'type', filters.types) + appendValues(url, 'skills', filters.skills) + if (filters.applied) url.searchParams.set('myApplications', 'true') + } else { + endpoint = `${V6_URL}/review-opportunities/search` + url.pathname = new URL(endpoint).pathname + url.searchParams.set('offset', String((page - 1) * perPage)) + url.searchParams.set('limit', String(perPage)) + url.searchParams.set('sortBy', filters.sort || 'startDate') + url.searchParams.set('sortOrder', 'asc') + if (filters.search) url.searchParams.set('search', filters.search) + appendValues(url, 'statuses', filters.statuses) + appendValues(url, 'tracks', filters.tracks) + appendValues(url, 'types', filters.types) + if (filters.applied) url.searchParams.set('appliedByMe', 'true') + } + + const response = await xhrGlobalInstance.get(url.toString()) as AxiosResponse< + any[] | ApiEnvelope | ApiListResponse + > + return normalizePage(response, page, perPage) +} + +/** + * Loads a complete challenge record for the Opportunities detail route. + * + * @param challengeId challenge UUID. + * @returns challenge details. + * @throws Propagates not-found, visibility, and network errors. + */ +export function getChallengeOpportunity(challengeId: string): Promise { + return xhrGetAsync(`${V6_URL}/challenges/${encodeURIComponent(challengeId)}`) +} + +/** + * Loads a review opportunity, including challenge summary and caller eligibility. + * + * @param opportunityId review opportunity UUID. + * @returns review opportunity details. + * @throws Propagates not-found and network errors. + */ +export async function getReviewOpportunity(opportunityId: string): Promise { + const response = await xhrGetAsync>( + `${V6_URL}/review-opportunities/${encodeURIComponent(opportunityId)}`, + ) + return unwrap(response) +} + +/** + * Submits an application for a single review opportunity. + * + * @param opportunityId review opportunity UUID. + * @param role reviewer role selected by the member. + * @returns created application response. + * @throws Propagates validation, role, conflict, and network errors. + */ +export interface ReviewApplicationResponse { + id?: string + status?: string +} + +export function applyToReviewOpportunity( + opportunityId: string, + role: string = 'REVIEWER', +): Promise { + return xhrPostAsync<{ opportunityId: string; role: string }, ReviewApplicationResponse>( + `${V6_URL}/review-applications`, + { opportunityId, role }, + ) +} + +export type CompetitionPage = OpportunityPage +export type EngagementPage = OpportunityPage +export type CopilotPage = OpportunityPage +export type ReviewPage = OpportunityPage + +interface SubmissionApiResponse { + data?: ChallengeSubmission[] + meta?: { + page?: number + perPage?: number + total?: number + totalPages?: number + } + result?: { + content?: ChallengeSubmission[] + metadata?: Record + } +} + +/** + * Loads one submissions page for a challenge detail tab. + * + * @param challengeId challenge UUID. + * @param page one-based page. + * @param perPage page size. + * @returns normalized submissions page. + * @throws Propagates Review API and network errors. + */ +export async function getChallengeSubmissions( + challengeId: string, + page: number, + perPage: number, + memberId?: string, +): Promise> { + const url = new URL(`${V6_URL}/submissions`) + url.searchParams.set('challengeId', challengeId) + url.searchParams.set('page', String(page)) + url.searchParams.set('perPage', String(perPage)) + if (memberId) url.searchParams.set('memberId', memberId) + const response = await xhrGetAsync(url.toString()) + if (Array.isArray(response)) { + return { + items: response, + page, + perPage, + total: response.length, + totalPages: response.length ? 1 : 0, + } + } + + const items = response.data ?? response.result?.content ?? [] + const metadata = response.meta ?? response.result?.metadata ?? {} + const total = toNumber(metadata.total, items.length) + const normalizedPerPage = toNumber(metadata.perPage, perPage) + return { + items, + page: toNumber(metadata.page, page), + perPage: normalizedPerPage, + total, + totalPages: toNumber( + metadata.totalPages, + normalizedPerPage > 0 ? Math.ceil(total / normalizedPerPage) : 0, + ), + } +} + +/** + * Returns the public Review API preview endpoint for a released design submission. + * + * @param submissionId submission UUID. + * @returns absolute preview URL; the API redirects to an immutable Payload asset. + * @throws Does not throw. + */ +export function getSubmissionPreviewUrl(submissionId: string): string { + return `${V6_URL}/submissions/${encodeURIComponent(submissionId)}/preview` +} + +/** + * Loads resources belonging to a challenge, optionally scoped to one member. + * + * @param challengeId challenge UUID. + * @param memberId optional authenticated member ID. + * @returns challenge resources visible to the caller. + * @throws Propagates Resource API and network errors. + */ +export async function getChallengeResources( + challengeId: string, + memberId?: string, +): Promise { + const url = new URL(`${V6_URL}/resources`) + url.searchParams.set('challengeId', challengeId) + url.searchParams.set('page', '1') + url.searchParams.set('perPage', '500') + if (memberId) url.searchParams.set('memberId', memberId) + const response = await xhrGetAsync>(url.toString()) + return unwrap(response) +} + +/** + * Resolves the canonical Submitter role used by challenge registration. + * + * @returns Submitter resource role. + * @throws Error when Resource API does not expose a Submitter role. + */ +async function getSubmitterRole(): Promise { + const response = await xhrGetAsync>( + `${V6_URL}/resource-roles?page=1&perPage=500`, + ) + const role = unwrap(response) + .find(item => item.name.trim() + .toLowerCase() === 'submitter') + if (!role) throw new Error('The Submitter resource role is not available.') + return role +} + +/** + * Registers the authenticated profile as a challenge submitter. + * + * @param challengeId challenge UUID. + * @param memberHandle authenticated member handle. + * @returns created resource. + * @throws Propagates role resolution, terms, conflict, and network errors. + */ +export async function registerForChallenge( + challengeId: string, + memberHandle: string, +): Promise { + const role = await getSubmitterRole() + return xhrPostAsync<{ + challengeId: string + memberHandle: string + roleId: string + }, ChallengeResource>(`${V6_URL}/resources`, { + challengeId, + memberHandle, + roleId: role.id, + }) +} + +/** + * Removes an authenticated member's submitter resource. + * + * @param resourceId resource UUID resolved from the member-scoped list. + * @returns void after deletion. + * @throws Propagates Resource API authorization and network errors. + */ +export async function unregisterFromChallenge(resourceId: string): Promise { + await xhrDeleteAsync(`${V6_URL}/resources/${encodeURIComponent(resourceId)}`) +} + +/** + * Electronically agrees to every unaccepted term required by a challenge. + * + * @param terms challenge term references. + * @returns void after all agreements succeed. + * @throws Propagates Terms API validation and network errors. + */ +export async function agreeToChallengeTerms(terms: ChallengeTerm[]): Promise { + const required = terms.filter(term => term.id && !term.agreed) + await Promise.all(required.map(term => xhrPostAsync, unknown>( + `${EnvironmentConfig.API.V5}/terms/${encodeURIComponent(term.id as string)}/agree`, + {}, + ))) +} diff --git a/src/apps/opportunities/src/styles/index.scss b/src/apps/opportunities/src/styles/index.scss new file mode 100644 index 000000000..810333912 --- /dev/null +++ b/src/apps/opportunities/src/styles/index.scss @@ -0,0 +1,6 @@ +@import url('https://fonts.googleapis.com/css2?family=Figtree:wght@400;500;600;700;800&family=Nunito+Sans:wght@400;500;600;700&display=swap'); +@import '@libs/ui/styles/2026/index'; + +body.opportunities-app { + background: #f4f7f9; +} diff --git a/src/apps/platform/src/platform.routes.tsx b/src/apps/platform/src/platform.routes.tsx index 87fc44c02..2922b9d48 100644 --- a/src/apps/platform/src/platform.routes.tsx +++ b/src/apps/platform/src/platform.routes.tsx @@ -18,6 +18,9 @@ import { customerPortalRoutes } from '~/apps/customer-portal' import { procurementRoutes } from '~/apps/procurement' import { statusRoutes } from '~/apps/status' import { supportRoutes } from '~/apps/support' +import { blogRoutes } from '~/apps/blog' +import { thriveRoutes } from '~/apps/thrive' +import { opportunitiesRoutes } from '~/apps/opportunities' const Home: LazyLoadedComponent = lazyLoad( () => import('./routes/home'), @@ -37,6 +40,7 @@ export const platformRoutes: Array = [ // is determined by finding the first route // that matches the current path ...onboardingRoutes, + ...opportunitiesRoutes, ...devCenterRoutes, ...copilotsRoutes, ...learnRoutes, @@ -51,6 +55,8 @@ export const platformRoutes: Array = [ ...procurementRoutes, ...statusRoutes, ...supportRoutes, + ...blogRoutes, + ...thriveRoutes, ...homeRoutes, ...adminRoutes, ...reportsRoutes, diff --git a/src/apps/procurement/src/ProcurementApp.tsx b/src/apps/procurement/src/ProcurementApp.tsx index bb02ff3cc..906817356 100644 --- a/src/apps/procurement/src/ProcurementApp.tsx +++ b/src/apps/procurement/src/ProcurementApp.tsx @@ -2,7 +2,7 @@ /** * Shell for the hidden procurement app. */ -import { FC, useContext, useMemo } from 'react' +import { FC, useContext, useEffect, useMemo } from 'react' import { NavLink, Outlet, Routes } from 'react-router-dom' import classNames from 'classnames' @@ -19,6 +19,7 @@ import { } from './config/routes.config' import { toolTitle } from './procurement-app.routes' import styles from './ProcurementApp.module.scss' +import './styles/index.scss' interface ProcurementNavigationItem { label: string @@ -58,6 +59,11 @@ const ProcurementApp: FC = () => { const { getChildRoutes }: RouterContextData = useContext(routerContext) const childRoutes = useMemo(() => getChildRoutes(toolTitle), [getChildRoutes]) + useEffect(() => { + document.body.classList.add('procurement-app') + return () => document.body.classList.remove('procurement-app') + }, []) + return ( div { + display: flex; + align-items: baseline; + gap: 7px; + } + + strong { + font-family: "Barlow Condensed", Arial, sans-serif; + font-size: 31px; + } + + span { + font-size: 13px; + text-transform: uppercase; + } +} + +.trackHeroArt { + position: absolute; + right: 8vw; + bottom: -55px; + opacity: 0.38; + font-family: monospace; + font-size: clamp(180px, 27vw, 400px); + font-weight: 900; +} + +.lightText { + color: #fff; +} + +.searchHero { + display: flex; + min-height: 170px; + padding: 50px 20px 70px; + background: linear-gradient(98.81deg, #8b41b0, #ef476f); + justify-content: center; +} + +.resultsLayout { + display: grid; + grid-template-columns: 250px minmax(0, 1fr); + gap: 55px; + width: calc(100% - 40px); + max-width: 1280px; + margin: 25px auto 100px; +} + +.trackTree { + h2 { + margin: 0 0 12px; + font-size: 18px; + } + + button { + display: block; + width: 100%; + padding: 9px 10px; + border: 0; + background: transparent; + color: #555; + text-align: left; + cursor: pointer; + } + + div button + button { + padding-left: 28px; + font-size: 13px; + } + + .selectedTrack { + border-left: 4px solid #137d60; + background: #f1f8f6; + color: #2a2a2a; + font-weight: 700; + } + + .selectedCategory { + color: #137d60; + font-weight: 700; + } +} + +.resultsMain { + min-width: 0; + + > h1, + > h2 { + margin: 0 0 22px; + font-family: "Barlow Condensed", Arial, sans-serif; + font-size: 42px; + text-transform: uppercase; + } +} + +.filterPanel { + display: grid; + grid-template-columns: repeat(5, minmax(110px, 1fr)); + gap: 14px; + margin-bottom: 32px; + padding: 18px; + border: 1px solid #ddd; + background: #f7f7f7; + + h3 { + display: none; + } + + label { + display: flex; + flex-direction: column; + gap: 6px; + color: #555; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + } + + input, + select { + width: 100%; + height: 40px; + padding: 0 10px; + border: 1px solid #aaa; + border-radius: 2px; + background: #fff; + color: #2a2a2a; + font-size: 14px; + text-transform: none; + } +} + +.dateFields { + display: flex; + gap: 8px; +} + +.outlineButton { + align-self: end; + height: 40px; + border: 1px solid #137d60; + border-radius: 20px; + background: #fff; + color: #137d60; + font-weight: 700; + cursor: pointer; +} + +.tabs { + display: flex; + margin-bottom: 24px; + border-bottom: 1px solid #ddd; + + button { + padding: 13px 22px; + border: 0; + border-bottom: 3px solid transparent; + background: transparent; + color: #767676; + font-size: 15px; + font-weight: 700; + cursor: pointer; + + span { + margin-left: 7px; + color: #999; + } + } + + .activeTab { + border-bottom-color: #137d60; + color: #137d60; + } +} + +.resultList { + display: flex; + flex-direction: column; + gap: 18px; +} + +.resultCard { + min-height: 190px; + + .cardImage { + width: 235px; + object-fit: cover; + } +} + +.videoGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + + .resultCard { + flex-direction: column; + } + + .cardImage { + width: 100%; + height: 190px; + object-fit: cover; + } +} + +.loadMore, +.discordButton, +.videoLink { + display: block; + width: fit-content; + margin: 28px auto 0; + padding: 12px 28px; + border: 0; + border-radius: 22px; + background: #137d60; + color: #fff; + font-weight: 700; + text-decoration: none; + cursor: pointer; +} + +.loading, +.empty, +.error, +.pageState { + padding: 32px 20px; + color: #767676; + text-align: center; +} + +.error { + color: #d0011b; +} + +.pageState { + min-height: 60vh; + padding-top: 100px; +} + +.articleHero { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(360px, 50%); + min-height: 475px; + overflow: hidden; + background: #f4f4f4; + + > div { + align-self: center; + width: 100%; + max-width: 640px; + margin-left: auto; + padding: 55px 50px 55px 20px; + } + + time { + color: #555; + font-size: 15px; + font-weight: 700; + text-transform: uppercase; + } + + h1 { + margin: 16px 0 0; + font-family: "Barlow Condensed", Arial, sans-serif; + font-size: clamp(48px, 6vw, 80px); + line-height: 0.98; + text-transform: uppercase; + } + + > img { + width: 100%; + height: 475px; + object-fit: cover; + clip-path: polygon(5% 8%, 80% 0, 100% 25%, 96% 82%, 70% 100%, 15% 94%, 0 65%); + } +} + +.articleLayout { + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + gap: 70px; + width: calc(100% - 40px); + max-width: 1280px; + margin: 70px auto 90px; +} + +.articleSidebar { + min-width: 0; +} + +.author { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + + img { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + } + + strong, + span { + display: block; + font-size: 14px; + } +} + +.metadata { + padding: 19px 0; + border-top: 1px dashed #aaa; + + h2 { + margin: 0 0 8px; + font-size: 12px; + letter-spacing: 1px; + text-transform: uppercase; + } + + > div { + font-size: 14px; + line-height: 1.5; + } +} + +.pill { + display: inline-block; + margin: 3px 3px 3px 0; + padding: 3px 6px; + border: 1px solid #555; + border-radius: 4px; + color: #2a2a2a; + font-size: 11px; + text-decoration: none; +} + +.shareLinks { + display: flex; + gap: 7px; + + a { + display: grid; + width: 30px; + height: 30px; + border-radius: 50%; + background: #137d60; + color: #fff; + font-weight: 700; + text-decoration: none; + place-items: center; + } +} + +.articleContent { + min-width: 0; + color: #2a2a2a; + font-size: 16px; + line-height: 1.625; + + h1, + h2, + h3, + h4 { + margin: 1.7em 0 0.55em; + font-family: "Barlow Condensed", Arial, sans-serif; + line-height: 1.1; + text-transform: uppercase; + } + + img { + max-width: 100%; + height: auto; + } + + table { + display: block; + max-width: 100%; + overflow-x: auto; + border-collapse: collapse; + } + + th, + td { + padding: 8px 12px; + border: 1px solid #ddd; + } + + iframe { + width: 100%; + min-height: 420px; + border: 0; + } +} + +.voteSummary { + display: flex; + gap: 24px; + margin-top: 40px; + color: #555; + justify-content: center; +} + +.recommended { + padding: 65px max(20px, calc((100% - 1280px) / 2)); + background: #f4f4f4; + + h2 { + font-family: "Barlow Condensed", Arial, sans-serif; + font-size: 42px; + text-transform: uppercase; + } +} + +.featured { + padding: 75px max(20px, calc((100% - 1280px) / 2)); + background: #1f7e8a; + + h2 { + margin: 0 0 28px; + color: #fff; + font-family: "Barlow Condensed", Arial, sans-serif; + font-size: 42px; + text-transform: uppercase; + } +} + +.featuredCards { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 20px; +} + +.thriveExtras { + padding: 100px 20px; + text-align: center; + + h2 { + margin: 0 0 25px; + font-family: "Barlow Condensed", Arial, sans-serif; + font-size: 42px; + text-transform: uppercase; + } + + a { + display: inline-block; + padding: 14px 26px; + border-radius: 24px; + background: #137d60; + color: #fff; + font-weight: 700; + text-decoration: none; + text-transform: uppercase; + } +} + +@media (max-width: 900px) { + .homeCards, + .featuredCards, + .videoGrid { + grid-template-columns: 1fr; + } + + .resultsLayout { + grid-template-columns: 1fr; + gap: 25px; + } + + .filterPanel { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .articleLayout { + grid-template-columns: 1fr; + gap: 30px; + } + + .articleSidebar { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 24px; + } +} + +@media (max-width: 600px) { + .homeHero { + min-height: 620px; + } + + .homeHeroContent { + padding-top: 190px; + + h1 { + font-size: 112px; + } + + p { + font-size: 20px; + line-height: 27px; + } + } + + .searchBar { + height: auto; + flex-wrap: wrap; + + select, + input { + height: 52px; + } + + select { + width: 40%; + } + + input { + width: 60%; + } + + button { + width: 100%; + height: 46px; + } + } + + .trackHeader { + align-items: flex-start; + } + + .filterPanel, + .articleSidebar { + grid-template-columns: 1fr; + } + + .tabs { + overflow-x: auto; + + button { + padding: 12px; + white-space: nowrap; + } + } + + .resultCard, + .articleHero { + grid-template-columns: 1fr; + flex-direction: column; + } + + .resultCard .cardImage { + width: 100%; + height: 180px; + } + + .articleHero > div { + padding: 50px 20px 25px; + } + + .articleHero > img { + height: 270px; + } +} diff --git a/src/apps/thrive/src/ThriveApp.tsx b/src/apps/thrive/src/ThriveApp.tsx new file mode 100644 index 000000000..03350cbe7 --- /dev/null +++ b/src/apps/thrive/src/ThriveApp.tsx @@ -0,0 +1,33 @@ +import type { FC } from 'react' +import { useContext, useEffect, useMemo } from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import type { RouterContextData } from '~/libs/core' +import { routerContext } from '~/libs/core' + +import { toolTitle } from './thrive.routes' + +/** + * Hosts Thrive's nested routes and applies a scoped body class while the sub-application is active. + * + * @returns the active Thrive route. + * @throws Does not throw. + */ +const ThriveApp: FC = () => { + const { getChildRoutes }: RouterContextData = useContext(routerContext) + const childRoutes = useMemo(() => getChildRoutes(toolTitle), [getChildRoutes]) + + useEffect(() => { + document.body.classList.add('thrive-app') + return () => document.body.classList.remove('thrive-app') + }, []) + + return ( + <> + + {childRoutes} + + ) +} + +export default ThriveApp diff --git a/src/apps/thrive/src/components/ThriveArticleCard.tsx b/src/apps/thrive/src/components/ThriveArticleCard.tsx new file mode 100644 index 000000000..d81257f73 --- /dev/null +++ b/src/apps/thrive/src/components/ThriveArticleCard.tsx @@ -0,0 +1,80 @@ +import type { FC } from 'react' +import { Link } from 'react-router-dom' + +import type { CmsResource } from '~/libs/cms' + +import type { ThriveArticleFields } from '../models' +import { + formatThriveDate, + getMarkdownPreview, + getThriveArticleUrl, + getThriveAuthors, + getThriveImageUrl, +} from '../utils' +import styles from '../Thrive.module.scss' + +interface ThriveArticleCardProps { + article: CmsResource + compact?: boolean +} + +/** + * Renders a Thrive article, video, or forum-post summary using resolved Payload relationships. + * + * @param props CMS article record and optional compact home-page treatment. + * @returns an accessible card linked to the local article route or its configured external URL. + * @throws URIError only when an invalid legacy article title cannot be encoded. + */ +export const ThriveArticleCard: FC = (props: ThriveArticleCardProps) => { + const fields = props.article.fields + const authors = getThriveAuthors(fields) + const href = getThriveArticleUrl(fields) + const image = getThriveImageUrl(props.article) + const external = fields.externalArticle && Boolean(fields.contentUrl) + const content = ( + <> + {image && } +
+
{fields.type}
+

{fields.title}

+ {!props.compact &&

{getMarkdownPreview(fields.content, 180)}

} +
+ + {authors.map(author => author.fields.name) + .join(', ')} + + {formatThriveDate(fields.creationDate || props.article.sys.createdAt)} + {fields.readTime && {fields.readTime}} +
+ {!props.compact && ( +
+ + ♥ + {fields.upvotes || 0} + + + ◌ + {' '} + {fields.commentsCount || 0} + +
+ )} +
+ + ) + + return external ? ( + + {content} + + ) : ( + + {content} + + ) +} diff --git a/src/apps/thrive/src/components/ThriveFilterPanel.tsx b/src/apps/thrive/src/components/ThriveFilterPanel.tsx new file mode 100644 index 000000000..e6347c81a --- /dev/null +++ b/src/apps/thrive/src/components/ThriveFilterPanel.tsx @@ -0,0 +1,111 @@ +/* eslint-disable react/jsx-no-bind */ + +import type { FC, FormEvent } from 'react' +import { useEffect, useState } from 'react' + +import type { ThriveFilters } from '../models' +import styles from '../Thrive.module.scss' + +interface ThriveFilterPanelProps { + filters: ThriveFilters + onApply: (filters: Partial) => void + showTrack?: boolean +} + +/** + * Renders the author, tag, date, track, and sort controls shared by Thrive result pages. + * + * @param props active URL filters and a callback that persists changes to the route. + * @returns a filter form matching the controls from community-app Thrive. + * @throws Does not throw. + */ +export const ThriveFilterPanel: FC = (props: ThriveFilterPanelProps) => { + const [author, setAuthor] = useState(props.filters.author || '') + const [endDate, setEndDate] = useState(props.filters.endDate || '') + const [startDate, setStartDate] = useState(props.filters.startDate || '') + const [tags, setTags] = useState(props.filters.tags.join(', ')) + const [track, setTrack] = useState(props.filters.track || '') + + useEffect(() => { + setAuthor(props.filters.author || '') + setEndDate(props.filters.endDate || '') + setStartDate(props.filters.startDate || '') + setTags(props.filters.tags.join(', ')) + setTrack(props.filters.track || '') + }, [props.filters]) + + /** + * Converts form fields into route-safe filter values. + * + * @param event browser form event to prevent from reloading the application. + * @returns nothing after forwarding the normalized filters. + * @throws Does not throw. + */ + const submit = (event: FormEvent): void => { + event.preventDefault() + props.onApply({ + author: author.trim() || undefined, + endDate: endDate || undefined, + sortBy: props.filters.sortBy, + startDate: startDate || undefined, + tags: tags.split(',') + .map(tag => tag.trim()) + .filter(Boolean), + track: track || undefined, + }) + } + + return ( +
+

Filter content

+ {props.showTrack && ( + + )} + + +
+ + +
+ + +
+ ) +} diff --git a/src/apps/thrive/src/components/ThriveResults.tsx b/src/apps/thrive/src/components/ThriveResults.tsx new file mode 100644 index 000000000..45d214dcc --- /dev/null +++ b/src/apps/thrive/src/components/ThriveResults.tsx @@ -0,0 +1,103 @@ +/* eslint-disable ordered-imports/ordered-imports, react/jsx-no-bind */ + +import type { FC } from 'react' +import { useEffect, useMemo, useState } from 'react' + +import type { CmsResource } from '~/libs/cms' +import { useCmsCollection } from '~/libs/cms' + +import { THRIVE_CONTENT_TYPES } from '../config' +import type { + ThriveArticleFields, + ThriveCategoryFields, + ThriveContentType, + ThriveFilters, + ThrivePersonFields, +} from '../models' +import { buildThriveContentQuery } from '../utils' +import { ThriveArticleCard } from './ThriveArticleCard' +import styles from '../Thrive.module.scss' + +interface ThriveResultsProps { + categories: Array> + filters: ThriveFilters +} + +/** + * Loads only the selected Thrive result tab and refreshes it whenever route filters change. + * + * @param props normalized filters plus the resolved taxonomy needed for category IDs. + * @returns tabbed article, video, and forum-post results with incremental pagination. + * @throws Does not throw; Payload errors are shown inline. + */ +export const ThriveResults: FC = (props: ThriveResultsProps) => { + const [activeType, setActiveType] = useState('Article') + const [limit, setLimit] = useState(5) + const authorQuery = useMemo(() => ({ + content_type: 'person', + limit: 1, + query: props.filters.author, + }), [props.filters.author]) + const authors = useCmsCollection('edu', authorQuery, Boolean(props.filters.author)) + const authorId = props.filters.author + ? authors.data?.items[0]?.sys.id || (authors.loading ? undefined : 'NO_SUCH_ID') + : undefined + const contentQuery = useMemo(() => buildThriveContentQuery( + activeType, + props.filters, + props.categories, + authorId, + limit, + 0, + ), [activeType, authorId, limit, props.categories, props.filters]) + const content = useCmsCollection( + 'edu', + contentQuery, + !props.filters.author || Boolean(authorId), + ) + + useEffect(() => { + setLimit(5) + }, [activeType, props.filters]) + + return ( +
+
+ {THRIVE_CONTENT_TYPES.map(type => ( + + ))} +
+ {content.loading && ( +
+ Loading + {activeType.toLowerCase()} + s… +
+ )} + {content.error &&
{content.error.message}
} + {!content.loading && !content.error && !content.data?.items.length && ( +
No results found
+ )} +
+ {content.data?.items.map(article => ( + + ))} +
+ {content.data && content.data.items.length < content.data.total && ( + + )} +
+ ) +} diff --git a/src/apps/thrive/src/components/ThriveSearchBar.tsx b/src/apps/thrive/src/components/ThriveSearchBar.tsx new file mode 100644 index 000000000..6a55eeb0f --- /dev/null +++ b/src/apps/thrive/src/components/ThriveSearchBar.tsx @@ -0,0 +1,71 @@ +/* eslint-disable react/jsx-no-bind */ + +import type { FC, FormEvent } from 'react' +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import { THRIVE_ROOT_ROUTE } from '../config' +import styles from '../Thrive.module.scss' + +interface ThriveSearchBarProps { + initialMode?: 'phrase' | 'title' + initialValue?: string +} + +/** + * Renders the legacy Thrive keyword/title search and drives the Payload-backed search route. + * + * @param props optional initial phrase and search mode derived from the current URL. + * @returns a Thrive search form. + * @throws Does not throw; blank searches navigate to the unfiltered search page. + */ +export const ThriveSearchBar: FC = (props: ThriveSearchBarProps) => { + const navigate = useNavigate() + const [mode, setMode] = useState<'phrase' | 'title'>(props.initialMode || 'phrase') + const [value, setValue] = useState(props.initialValue || '') + + useEffect(() => { + setMode(props.initialMode || 'phrase') + setValue(props.initialValue || '') + }, [props.initialMode, props.initialValue]) + + /** + * Submits the current phrase to the canonical Thrive search route. + * + * @param event browser form event to prevent from reloading the application. + * @returns nothing after navigation is requested. + * @throws Does not throw. + */ + const submit = (event: FormEvent): void => { + event.preventDefault() + const query = new URLSearchParams() + if (value.trim()) { + query.set(mode, value.trim() + .slice(0, 115)) + } + + navigate(`${THRIVE_ROOT_ROUTE}/search${query.size ? `?${query.toString()}` : ''}`) + } + + return ( +
+ + setValue(event.target.value)} + placeholder='Search Thrive' + type='search' + value={value} + /> + +
+ ) +} diff --git a/src/apps/thrive/src/components/index.ts b/src/apps/thrive/src/components/index.ts new file mode 100644 index 000000000..138648d91 --- /dev/null +++ b/src/apps/thrive/src/components/index.ts @@ -0,0 +1,4 @@ +export * from './ThriveArticleCard' +export * from './ThriveFilterPanel' +export * from './ThriveResults' +export * from './ThriveSearchBar' diff --git a/src/apps/thrive/src/config/index.ts b/src/apps/thrive/src/config/index.ts new file mode 100644 index 000000000..c2dc5e6a8 --- /dev/null +++ b/src/apps/thrive/src/config/index.ts @@ -0,0 +1 @@ +export * from './thrive.config' diff --git a/src/apps/thrive/src/config/thrive.config.ts b/src/apps/thrive/src/config/thrive.config.ts new file mode 100644 index 000000000..583b4ecb3 --- /dev/null +++ b/src/apps/thrive/src/config/thrive.config.ts @@ -0,0 +1,81 @@ +import { ThriveTrack } from '../models' + +/** Root route for the Payload-backed Thrive application. */ +export const THRIVE_ROOT_ROUTE = '/thrive' + +/** Content types displayed in the tabbed Thrive results view. */ +export const THRIVE_CONTENT_TYPES = ['Article', 'Video', 'Forum post'] as const + +/** Compact field projection used by track cards to avoid downloading full article bodies. */ +export const THRIVE_CARD_SELECT = [ + 'sys.id', + 'sys.type', + 'sys.createdAt', + 'fields.commentsCount', + 'fields.avatar', + 'fields.contentAuthor', + 'fields.contentUrl', + 'fields.creationDate', + 'fields.externalArticle', + 'fields.featuredImage', + 'fields.name', + 'fields.readTime', + 'fields.slug', + 'fields.tags', + 'fields.tcHandle', + 'fields.title', + 'fields.type', + 'fields.upvotes', + 'fields.file', +].join(',') + +/** Legacy Thrive tracks in the same order and palette as community-app. */ +export const THRIVE_TRACKS: ThriveTrack[] = [ + { + accent: '#fdbd18', + banner: '#ffa45d', + icon: '', + name: 'Competitive Programming', + }, + { + accent: '#f46500', + banner: '#ffa45d', + icon: '∿', + name: 'Data Science', + }, + { + accent: '#137d60', + banner: '#50ade8', + icon: '◆', + name: 'Design', + }, + { + accent: '#26b3c5', + banner: '#8afb8a', + icon: '{ }', + name: 'Development', + }, + { + accent: '#0ab88a', + banner: '#8afb8a', + icon: '✓', + name: 'QA', + }, + { + accent: '#ef476f', + banner: '#ef476f', + icon: '●', + name: 'Gig Work', + }, + { + accent: '#8231a9', + banner: '#2a2a2a', + icon: 'TC', + name: 'Topcoder', + }, +] + +/** Default migrated Thrive banner used when an article has no featured image. */ +export const THRIVE_DEFAULT_BANNER = 'https://assets.topcoder-dev.com/media/contentful/' + + 'images.ctfassets.net/97/970c80d628d90c49e1a8817954d5ef8ddcffdf1b0a0e2cdb74a2decd7cabe8ee/' + + '0C37CB5E-B253-4804-8935-78E64E67589E-970c80d628d90c49e1a8817954d5ef8ddcffdf1b0a0e2cdb74a2decd7cabe8ee.png' diff --git a/src/apps/thrive/src/index.ts b/src/apps/thrive/src/index.ts new file mode 100644 index 000000000..36d1c6aed --- /dev/null +++ b/src/apps/thrive/src/index.ts @@ -0,0 +1,2 @@ +export { thriveRoutes } from './thrive.routes' +export { THRIVE_ROOT_ROUTE } from './config' diff --git a/src/apps/thrive/src/models/index.ts b/src/apps/thrive/src/models/index.ts new file mode 100644 index 000000000..6400fe598 --- /dev/null +++ b/src/apps/thrive/src/models/index.ts @@ -0,0 +1 @@ +export * from './thrive.models' diff --git a/src/apps/thrive/src/models/thrive.models.ts b/src/apps/thrive/src/models/thrive.models.ts new file mode 100644 index 000000000..e87b25202 --- /dev/null +++ b/src/apps/thrive/src/models/thrive.models.ts @@ -0,0 +1,63 @@ +import { CmsLink, CmsResource } from '~/libs/cms' + +/** Thrive content types retained from the EDU CMS space. */ +export type ThriveContentType = 'Article' | 'Forum post' | 'Video' + +/** Linked CMS value after compatibility-link resolution. */ +export type ThriveReference> = CmsLink | CmsResource + +/** Fields stored for a Thrive content taxonomy record. */ +export interface ThriveCategoryFields extends Record { + name: string + trackParent: string +} + +/** Fields stored for a Thrive content author. */ +export interface ThrivePersonFields extends Record { + avatar?: ThriveReference> + name: string + tcHandle?: string +} + +/** Fields stored for a Thrive Article, Video, or Forum post record. */ +export interface ThriveArticleFields extends Record { + commentsCount?: number + content: string + contentAuthor?: Array> + contentCategory?: Array> + contentUrl?: string + creationDate?: string + downvotes?: number + externalArticle?: boolean + featuredImage?: ThriveReference> + openExternalLinksInNewTab?: boolean + readTime?: string + recommended?: Array> + slug?: string + tags?: string[] + title: string + trackCategory?: string | string[] + type: ThriveContentType + upvotes?: number +} + +/** One supported Thrive track and its legacy visual treatment. */ +export interface ThriveTrack { + accent: string + banner: string + icon: string + name: string +} + +/** Route-driven filters shared by the Thrive Tracks and Search pages. */ +export interface ThriveFilters { + author?: string + endDate?: string + phrase?: string + sortBy?: 'Content Publish Date' | 'Likes' + startDate?: string + tags: string[] + tax?: string[] + title?: string + track?: string +} diff --git a/src/apps/thrive/src/pages/article/ThriveArticlePage.tsx b/src/apps/thrive/src/pages/article/ThriveArticlePage.tsx new file mode 100644 index 000000000..c6b4e05d5 --- /dev/null +++ b/src/apps/thrive/src/pages/article/ThriveArticlePage.tsx @@ -0,0 +1,217 @@ +import type { FC, ReactNode } from 'react' +import { useMemo } from 'react' +import { Helmet } from 'react-helmet' +import { Link, useParams } from 'react-router-dom' + +import type { CmsResource } from '~/libs/cms' +import { CmsMarkdown, getCmsResourceAssetUrl, useCmsCollection } from '~/libs/cms' + +import { ThriveArticleCard } from '../../components' +import { THRIVE_DEFAULT_BANNER } from '../../config' +import type { ThriveArticleFields } from '../../models' +import { + formatThriveDate, + getMarkdownPreview, + getThriveAuthors, + getThriveCategories, + getThriveImageUrl, + isResolvedCmsResource, +} from '../../utils' +import styles from '../../Thrive.module.scss' + +interface MetadataProps { + children: ReactNode + label: string +} + +/** + * Renders one separated metadata group in the Thrive article sidebar. + * + * @param props uppercase group label and rendered metadata values. + * @returns a bordered metadata section. + * @throws Does not throw. + */ +const Metadata: FC = (props: MetadataProps) => ( +
+

{props.label}

+
{props.children}
+
+) + +/** + * Renders one migrated Thrive article selected by slug, with legacy-title URL compatibility. + * + * @returns article hero, metadata sidebar, markdown body, sharing actions, and recommendations. + * @throws Does not throw; missing and failed records render a page state. + */ +export const ThriveArticlePage: FC = () => { + const { slug = '' }: { slug?: string } = useParams<{ slug: string }>() + const slugQuery = useMemo(() => ({ + content_type: 'article', + 'fields.slug': slug, + include: 10, + limit: 1, + }), [slug]) + const slugResult = useCmsCollection('edu', slugQuery) + const needsTitleFallback = Boolean(slugResult.data && slugResult.data.total === 0) + const titleQuery = useMemo(() => ({ + content_type: 'article', + 'fields.title[match]': slug, + include: 10, + limit: 10, + }), [slug]) + const titleResult = useCmsCollection('edu', titleQuery, needsTitleFallback) + const article = slugResult.data?.items[0] || titleResult.data?.items.find(item => ( + item.fields.title === slug + || encodeURIComponent(item.fields.title) === encodeURIComponent(slug) + )) + const error = slugResult.error || titleResult.error + const loading = slugResult.loading || (needsTitleFallback && titleResult.loading) + + if (loading) { + return
Loading article…
+ } + + if (error) { + return
{error.message}
+ } + + if (!article) { + return ( +
+

Article not found

+ Return to Thrive +
+ ) + } + + const fields = article.fields + const authors = getThriveAuthors(fields) + const categories = getThriveCategories(fields) + const image = getThriveImageUrl(article) || THRIVE_DEFAULT_BANNER + const recommended = (fields.recommended || []) + .filter(isResolvedCmsResource) as Array> + const shareUrl = encodeURIComponent(window.location.href) + + return ( +
+ + + + {fields.title} + +
+
+ +

{fields.title}

+
+ +
+
+ +
+ {fields.content} + {fields.type === 'Video' && fields.contentUrl && ( + + Watch video + + )} +
+ + ♥ + {fields.upvotes || 0} + + + ♡ + {fields.downvotes || 0} + +
+ + Chat on Discord + +
+
+ {recommended.length > 0 && ( +
+

Recommended for you

+
+ {recommended.map(item => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/src/apps/thrive/src/pages/home/ThriveHomePage.tsx b/src/apps/thrive/src/pages/home/ThriveHomePage.tsx new file mode 100644 index 000000000..941beb3c9 --- /dev/null +++ b/src/apps/thrive/src/pages/home/ThriveHomePage.tsx @@ -0,0 +1,169 @@ +import type { FC } from 'react' +import { useMemo } from 'react' +import { Helmet } from 'react-helmet' +import { Link } from 'react-router-dom' + +import type { CmsResource } from '~/libs/cms' +import { useCmsCollection } from '~/libs/cms' + +import { ThriveArticleCard, ThriveSearchBar } from '../../components' +import { THRIVE_CARD_SELECT, THRIVE_ROOT_ROUTE, THRIVE_TRACKS } from '../../config' +import type { ThriveArticleFields, ThriveCategoryFields, ThriveTrack } from '../../models' +import { collectThriveArticles } from '../../utils' +import styles from '../../Thrive.module.scss' + +interface TrackSectionProps { + categories: Array> + track: ThriveTrack +} + +/** + * Loads and renders the three newest Payload-hosted articles for one Thrive track. + * + * @param props visual track definition used for the query and legacy color treatment. + * @returns a home-page track section with article cards. + * @throws Does not throw; loading and empty states render in place. + */ +const TrackSection: FC = (props: TrackSectionProps) => { + const query = useMemo(() => ({ + content_type: 'article', + 'fields.trackCategory': props.track.name, + 'fields.type': 'Article', + include: 3, + limit: 3, + order: '-sys.createdAt', + select: THRIVE_CARD_SELECT, + }), [props.track.name]) + const articles = useCmsCollection('edu', query) + const route = `${THRIVE_ROOT_ROUTE}/tracks?track=${encodeURIComponent(props.track.name)}` + + return ( +
+
+ +
+ {props.track.name} +
+ {props.categories.map(category => ( + + {category.fields.name} + + ))} +
+
+
+ {articles.loading &&
Loading articles…
} + {articles.error &&
{articles.error.message}
} +
+ {articles.data?.items.map(article => ( + + ))} +
+ {!articles.loading && !articles.error && !articles.data?.items.length && ( +
No published articles in this track yet.
+ )} +
+ ) +} + +/** + * Renders the CMS-editable featured article group retained from the Thrive home viewport. + * + * @returns up to four resolved featured articles plus the article-submission call to action. + * @throws Does not throw; unavailable viewport content is omitted. + */ +const HomeFeatured: FC = () => { + const query = useMemo(() => ({ + include: 10, + limit: 1, + 'sys.id': '4K24asXZxb5i48wXXaWjEC', + }), []) + const viewport = useCmsCollection>('edu', query) + const articles = collectThriveArticles(viewport.data?.items[0]) + .slice(0, 4) + + return ( + <> + {articles.length > 0 && ( +
+
+

Featured Articles

+
+ {articles.map(article => ( + + ))} +
+
+
+ )} +
+

Thrive Extras

+ Learn how to submit +
+ + ) +} + +/** + * Renders the Payload-backed Thrive landing page ported from community-app. + * + * @returns the Thrive hero, search, and seven article track sections. + * @throws Does not throw. + */ +export const ThriveHomePage: FC = () => { + const taxonomyQuery = useMemo(() => ({ + content_type: 'contentCategory', + limit: 1000, + order: 'fields.name', + }), []) + const taxonomy = useCmsCollection('edu', taxonomyQuery) + + return ( +
+ + + Tutorials And Workshops That Matter | Thrive | Topcoder + +
+ +
+

THRIVE

+

Grow with us. Tutorials and workshops that matter.

+ + + Don’t know what a challenge is? + {' '} + + Find out here + + . + +
+
+
+
+ {THRIVE_TRACKS.map(track => ( + category.fields.trackParent === track.name)} + key={track.name} + track={track} + /> + ))} +
+ +
+ ) +} diff --git a/src/apps/thrive/src/pages/search/ThriveSearchPage.tsx b/src/apps/thrive/src/pages/search/ThriveSearchPage.tsx new file mode 100644 index 000000000..334ceda75 --- /dev/null +++ b/src/apps/thrive/src/pages/search/ThriveSearchPage.tsx @@ -0,0 +1,76 @@ +/* eslint-disable react/jsx-no-bind */ + +import type { FC } from 'react' +import { useMemo } from 'react' +import { Helmet } from 'react-helmet' +import { useLocation, useSearchParams } from 'react-router-dom' + +import { useCmsCollection } from '~/libs/cms' + +import { ThriveFilterPanel, ThriveResults, ThriveSearchBar } from '../../components' +import type { ThriveCategoryFields, ThriveFilters } from '../../models' +import { parseThriveFilters } from '../../utils' +import styles from '../../Thrive.module.scss' + +/** + * Renders route-driven search across Payload-backed Thrive articles, videos, and forum posts. + * + * @returns the Thrive search bar, advanced filters, and lazy active result tab. + * @throws Does not throw; CMS failures render inline. + */ +export const ThriveSearchPage: FC = () => { + const location = useLocation() + const [searchParams, setSearchParams] = useSearchParams() + const filters = useMemo(() => parseThriveFilters(location.search), [location.search]) + const taxonomyQuery = useMemo(() => ({ + content_type: 'contentCategory', + include: 1, + limit: 1000, + order: 'fields.name', + }), []) + const taxonomy = useCmsCollection('edu', taxonomyQuery) + + /** + * Merges advanced search filters into the browser URL. + * + * @param updates selected search filters to replace in the route. + * @returns nothing after the router search state is updated. + * @throws Does not throw. + */ + const updateFilters = (updates: Partial): void => { + const next = new URLSearchParams(searchParams) + Object.entries(updates) + .forEach(([key, value]) => { + next.delete(key) + next.delete(`${key}[]`) + if (Array.isArray(value)) { + value.forEach(item => next.append(key, item)) + } else if (value) { + next.set(key, String(value)) + } + }) + setSearchParams(next) + } + + return ( +
+ Search Thrive | Topcoder +
+ +
+
+
+ +
+

Search Results

+ {taxonomy.loading &&
Loading Thrive taxonomy…
} + {taxonomy.error &&
{taxonomy.error.message}
} + {taxonomy.data && } +
+
+
+ ) +} diff --git a/src/apps/thrive/src/pages/tracks/ThriveTracksPage.tsx b/src/apps/thrive/src/pages/tracks/ThriveTracksPage.tsx new file mode 100644 index 000000000..0b89c9f46 --- /dev/null +++ b/src/apps/thrive/src/pages/tracks/ThriveTracksPage.tsx @@ -0,0 +1,164 @@ +/* eslint-disable react/jsx-no-bind */ + +import type { FC } from 'react' +import { useMemo } from 'react' +import { Helmet } from 'react-helmet' +import { useLocation, useSearchParams } from 'react-router-dom' + +import { useCmsCollection } from '~/libs/cms' + +import { ThriveFilterPanel, ThriveResults, ThriveSearchBar } from '../../components' +import { THRIVE_TRACKS } from '../../config' +import type { ThriveArticleFields, ThriveCategoryFields, ThriveContentType, ThriveFilters } from '../../models' +import { groupThriveCategories, parseThriveFilters } from '../../utils' +import styles from '../../Thrive.module.scss' + +interface TrackCounterProps { + label: string + track: string + type: ThriveContentType +} + +/** + * Loads only the total count for one content type in the selected Thrive track. + * + * @param props track/type filter and pluralized display label. + * @returns one compact hero statistic. + * @throws Does not throw; an unavailable count renders as an em dash. + */ +const TrackCounter: FC = (props: TrackCounterProps) => { + const query = useMemo(() => ({ + content_type: 'article', + 'fields.trackCategory': props.track, + 'fields.type': props.type, + limit: 0, + }), [props.track, props.type]) + const result = useCmsCollection('edu', query) + + return ( +
+ {result.data?.total ?? '—'} + {props.label} +
+ ) +} + +/** + * Renders the track/category explorer and persists every selection in the route query string. + * + * @returns the Thrive track hero, taxonomy, filters, and active result tab. + * @throws Does not throw; CMS failures render as page state. + */ +export const ThriveTracksPage: FC = () => { + const location = useLocation() + const [searchParams, setSearchParams] = useSearchParams() + const parsedFilters = useMemo(() => parseThriveFilters(location.search), [location.search]) + const selectedTrack = THRIVE_TRACKS.some(track => track.name === parsedFilters.track) + ? parsedFilters.track as string + : THRIVE_TRACKS[0].name + const filters = useMemo(() => ({ + author: parsedFilters.author, + endDate: parsedFilters.endDate, + phrase: parsedFilters.phrase, + sortBy: parsedFilters.sortBy, + startDate: parsedFilters.startDate, + tags: parsedFilters.tags, + tax: parsedFilters.tax, + title: parsedFilters.title, + track: selectedTrack, + }), [parsedFilters, selectedTrack]) + const taxonomyQuery = useMemo(() => ({ + content_type: 'contentCategory', + include: 1, + limit: 1000, + order: 'fields.name', + }), []) + const taxonomy = useCmsCollection('edu', taxonomyQuery) + const grouped = useMemo(() => groupThriveCategories(taxonomy.data?.items || []), [taxonomy.data]) + const track = THRIVE_TRACKS.find(item => item.name === selectedTrack) || THRIVE_TRACKS[0] + + /** + * Merges a filter change into the current URL without discarding search criteria. + * + * @param updates selected filter values to replace in the route. + * @returns nothing after the router search state is updated. + * @throws Does not throw. + */ + const updateFilters = (updates: Partial): void => { + const next = new URLSearchParams(searchParams) + Object.entries(updates) + .forEach(([key, value]) => { + next.delete(key) + next.delete(`${key}[]`) + if (Array.isArray(value)) { + value.forEach(item => next.append(key, item)) + } else if (value) { + next.set(key, String(value)) + } + }) + setSearchParams(next) + } + + return ( +
+ + + {selectedTrack} + {' '} + tutorials | Thrive | Topcoder + + +
+
{track.icon}
+
+

{selectedTrack}

+
+ + + +
+ +
+
+
+
+ +
+

{filters.tax?.[0] || selectedTrack}

+ + {taxonomy.loading &&
Loading Thrive taxonomy…
} + {taxonomy.error &&
{taxonomy.error.message}
} + {taxonomy.data && } +
+
+
+ ) +} diff --git a/src/apps/thrive/src/thrive.routes.tsx b/src/apps/thrive/src/thrive.routes.tsx new file mode 100644 index 000000000..f45a83ac2 --- /dev/null +++ b/src/apps/thrive/src/thrive.routes.tsx @@ -0,0 +1,59 @@ +import { ToolTitle } from '~/config' +import type { LazyLoadedComponent, PlatformRoute } from '~/libs/core' +import { lazyLoad } from '~/libs/core' + +import { THRIVE_ROOT_ROUTE } from './config' + +const ThriveApp: LazyLoadedComponent = lazyLoad(() => import('./ThriveApp')) +const ThriveHomePage: LazyLoadedComponent = lazyLoad(() => import('./pages/home/ThriveHomePage'), 'ThriveHomePage') +const ThriveTracksPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/tracks/ThriveTracksPage'), + 'ThriveTracksPage', +) +const ThriveSearchPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/search/ThriveSearchPage'), + 'ThriveSearchPage', +) +const ThriveArticlePage: LazyLoadedComponent = lazyLoad( + () => import('./pages/article/ThriveArticlePage'), + 'ThriveArticlePage', +) + +/** Tool title used by the platform router to resolve Thrive child routes. */ +export const toolTitle: string = ToolTitle.thrive + +/** Public Payload-backed Thrive route tree. */ +export const thriveRoutes: ReadonlyArray = [ + { + children: [ + { + children: [], + element: , + id: 'Thrive home', + route: '', + }, + { + children: [], + element: , + id: 'Thrive tracks', + route: 'tracks', + }, + { + children: [], + element: , + id: 'Search Thrive', + route: 'search', + }, + { + children: [], + element: , + id: 'Thrive article', + route: 'articles/:slug', + }, + ], + element: , + id: toolTitle, + route: THRIVE_ROOT_ROUTE, + title: toolTitle, + }, +] diff --git a/src/apps/thrive/src/utils/index.ts b/src/apps/thrive/src/utils/index.ts new file mode 100644 index 000000000..4e8622bd6 --- /dev/null +++ b/src/apps/thrive/src/utils/index.ts @@ -0,0 +1 @@ +export * from './thrive.utils' diff --git a/src/apps/thrive/src/utils/thrive.utils.spec.ts b/src/apps/thrive/src/utils/thrive.utils.spec.ts new file mode 100644 index 000000000..8e6799e4d --- /dev/null +++ b/src/apps/thrive/src/utils/thrive.utils.spec.ts @@ -0,0 +1,90 @@ +/* eslint-disable ordered-imports/ordered-imports */ + +import type { CmsResource } from '~/libs/cms' + +import type { ThriveCategoryFields, ThriveFilters } from '../models' +import { + buildThriveContentQuery, + collectThriveArticles, + getMarkdownPreview, + getThriveArticleUrl, + parseThriveFilters, +} from './thrive.utils' + +jest.mock('~/libs/cms', () => ({ + getCmsResourceAssetUrl: jest.fn(), +}), { virtual: true }) + +describe('Thrive utilities', () => { + it('normalizes repeated route filters and caps search terms', () => { + const filters = parseThriveFilters( + `?tags=ux,figma&tags[]=design&sortBy=Likes&phrase=${'a'.repeat(130)}`, + ) + + expect(filters.tags) + .toEqual(['ux', 'figma', 'design']) + expect(filters.sortBy) + .toBe('Likes') + expect(filters.phrase) + .toHaveLength(115) + }) + + it('maps taxonomy names and the resolved author to compatibility filters', () => { + const filters: ThriveFilters = { + sortBy: 'Content Publish Date', + tags: ['react'], + tax: ['Frontend'], + track: 'Development', + } + const categories: Array> = [{ + fields: { name: 'Frontend', trackParent: 'Development' }, + sys: { id: 'frontend-id', type: 'Entry' }, + }] + + expect(buildThriveContentQuery('Article', filters, categories, 'author-id', 5, 0)) + .toMatchObject({ + 'fields.contentAuthor.sys.id': 'author-id', + 'fields.contentCategory.sys.id[in]': 'frontend-id', + 'fields.tags[all]': 'react', + 'fields.trackCategory': 'Development', + 'fields.type': 'Article', + }) + }) + + it('builds local and external article destinations', () => { + expect(getThriveArticleUrl({ + content: '', + slug: 'payload-migration', + title: 'Payload migration', + type: 'Article', + })) + .toBe('/thrive/articles/payload-migration') + expect(getThriveArticleUrl({ + content: '', + contentUrl: 'https://www.topcoder.com/article', + externalArticle: true, + title: 'External', + type: 'Article', + })) + .toBe('https://www.topcoder.com/article') + }) + + it('creates plain compact previews from markdown', () => { + expect(getMarkdownPreview('## Hello [Topcoder](https://topcoder.com) **members**', 20)) + .toBe('Hello Topcoder membe…') + }) + + it('finds unique articles nested in an editable home viewport', () => { + const article: CmsResource = { + fields: { + content: 'Featured body', + title: 'Featured', + type: 'Article', + }, + sys: { id: 'featured', type: 'Entry' }, + } + + expect(collectThriveArticles({ fields: { content: [article, article] } })) + .toEqual([article]) + }) +}) diff --git a/src/apps/thrive/src/utils/thrive.utils.ts b/src/apps/thrive/src/utils/thrive.utils.ts new file mode 100644 index 000000000..52c23c596 --- /dev/null +++ b/src/apps/thrive/src/utils/thrive.utils.ts @@ -0,0 +1,271 @@ +import { + CmsQuery, + CmsResource, + getCmsResourceAssetUrl, +} from '~/libs/cms' + +import { + ThriveArticleFields, + ThriveCategoryFields, + ThriveContentType, + ThriveFilters, + ThrivePersonFields, + ThriveReference, +} from '../models' + +/** + * Identifies an embedded CMS resource after compatibility-link resolution. + * + * @param value retained link or resolved resource. + * @returns true when fields are available on the embedded record. + * @throws Does not throw. + */ +export function isResolvedCmsResource>( + value: ThriveReference | undefined, +): value is CmsResource { + return Boolean(value && 'fields' in value) +} + +/** + * Returns an article's approved Payload-hosted featured image URL. + * + * @param article Thrive article entry or fields. + * @returns an assets.topcoder-dev.com URL, or undefined when no migrated image exists. + * @throws Does not throw. + */ +export function getThriveImageUrl( + article: CmsResource | ThriveArticleFields, +): string | undefined { + const fields: ThriveArticleFields = 'fields' in article && 'sys' in article + ? (article as CmsResource).fields + : article as ThriveArticleFields + return getCmsResourceAssetUrl(fields.featuredImage) +} + +/** + * Returns resolved authors attached to one Thrive record. + * + * @param article Thrive content fields. + * @returns embedded author resources in source order. + * @throws Does not throw. + */ +export function getThriveAuthors(article: ThriveArticleFields): Array> { + return (article.contentAuthor || []) + .filter(isResolvedCmsResource) +} + +/** + * Returns resolved taxonomy categories attached to one Thrive record. + * + * @param article Thrive content fields. + * @returns embedded taxonomy resources in source order. + * @throws Does not throw. + */ +export function getThriveCategories( + article: ThriveArticleFields, +): Array> { + return (article.contentCategory || []) + .filter(isResolvedCmsResource) +} + +/** + * Builds the canonical internal or external destination for a Thrive record. + * + * @param article Thrive content fields. + * @returns external content URL when configured, otherwise the local article route. + * @throws URIError when an article title cannot be encoded for its legacy slug fallback. + */ +export function getThriveArticleUrl(article: ThriveArticleFields): string { + if (article.externalArticle && article.contentUrl) { + return article.contentUrl + } + + return `/thrive/articles/${encodeURIComponent(article.slug || article.title)}` +} + +/** + * Removes markdown and HTML syntax for compact article-card previews. + * + * @param markdown CMS-authored article content. + * @param maximumLength maximum returned character count before an ellipsis. + * @returns whitespace-normalized preview text. + * @throws Does not throw. + */ +export function getMarkdownPreview(markdown: string, maximumLength: number = 150): string { + const text = String(markdown || '') + .replace(/<[^>]*>/g, ' ') + .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/[`#>*_~|]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + return text.length > maximumLength ? `${text.slice(0, maximumLength) + .trim()}…` : text +} + +/** + * Formats a retained CMS publication date in the legacy Thrive display format. + * + * @param value ISO date from the article fields or system metadata. + * @returns localized month, day, and year, or an empty string for an invalid date. + * @throws Does not throw. + */ +export function formatThriveDate(value: string | undefined): string { + const date = new Date(value || '') + return Number.isNaN(date.getTime()) + ? '' + : new Intl.DateTimeFormat('en-US', { + day: 'numeric', + month: 'long', + year: 'numeric', + }) + .format(date) +} + +/** + * Converts URL search parameters into normalized Thrive filters. + * + * @param search current location search string. + * @returns normalized track, taxonomy, author, date, tag, and phrase filters. + * @throws Does not throw for repeated or malformed values. + */ +export function parseThriveFilters(search: string): ThriveFilters { + const query = new URLSearchParams(search) + /** + * Reads comma-separated and repeated values for one route parameter. + * + * @param name route query key. + * @returns trimmed, non-empty values in URL order. + * @throws Does not throw. + */ + const values = (name: string): string[] => query.getAll(name) + .flatMap(value => value.split(',')) + .map(value => value.trim()) + .filter(Boolean) + const sortBy = query.get('sortBy') === 'Likes' ? 'Likes' : 'Content Publish Date' + return { + author: query.get('author') || undefined, + endDate: query.get('endDate') || undefined, + phrase: (query.get('phrase') || undefined)?.slice(0, 115), + sortBy, + startDate: query.get('startDate') || undefined, + tags: [...values('tags'), ...values('tags[]')], + tax: values('tax'), + title: (query.get('title') || undefined)?.slice(0, 115), + track: query.get('track') || undefined, + } +} + +/** + * Builds one Payload CMS query for a Thrive content type and active filters. + * + * @param type Article, Video, or Forum post tab. + * @param filters route-driven filters selected by the visitor. + * @param categories resolved taxonomy used to translate category names to retained IDs. + * @param authorId resolved author identifier, or the no-match sentinel. + * @param limit page size requested from Payload. + * @param skip number of matching records already rendered. + * @returns compatibility query accepted by cms.topcoder-dev.com. + * @throws Does not throw. + */ +export function buildThriveContentQuery( + type: ThriveContentType, + filters: ThriveFilters, + categories: Array>, + authorId?: string, + limit: number = 5, + skip: number = 0, +): CmsQuery { + const categoryIds = categories + .filter(category => !filters.track || category.fields.trackParent === filters.track) + .filter(category => !filters.tax?.length || filters.tax.includes(category.fields.name)) + .map(category => category.sys.id) + return { + content_type: 'article', + 'fields.contentAuthor.sys.id': authorId, + 'fields.contentCategory.sys.id[in]': filters.tax?.length + ? categoryIds.join(',') || 'NO_SUCH_ID' + : undefined, + 'fields.creationDate[gte]': filters.startDate, + 'fields.creationDate[lte]': filters.endDate, + 'fields.tags[all]': filters.tags.length ? filters.tags.join(',') : undefined, + 'fields.title[match]': filters.title, + 'fields.trackCategory': filters.track, + 'fields.type': type, + include: 3, + limit, + order: filters.sortBy === 'Likes' + ? '-fields.upvotes,-fields.creationDate' + : '-fields.creationDate', + query: filters.phrase, + skip, + } +} + +/** + * Groups resolved Thrive taxonomy records by their parent track. + * + * @param categories Payload-hosted content-category records. + * @returns alphabetized categories keyed by track name. + * @throws Does not throw. + */ +export function groupThriveCategories( + categories: Array>, +): Record>> { + return categories.reduce>>>((groups, category) => { + const track = category.fields.trackParent + groups[track] = [...(groups[track] || []), category] + .sort((first, second) => first.fields.name.localeCompare(second.fields.name)) + return groups + }, {}) +} + +/** + * Finds resolved Thrive articles nested inside a Payload-backed editable viewport. + * + * @param value resolved CMS viewport, relationship, collection, or field value. + * @returns unique embedded article records in authored traversal order. + * @throws Does not throw for malformed or cyclic CMS values. + */ +export function collectThriveArticles(value: unknown): Array> { + const articles = new Map>() + const visited = new WeakSet() + + /** + * Traverses one viewport branch while protecting against malformed cyclic objects. + * + * @param nested current relationship or field value. + * @returns nothing after matching articles and children are inspected. + * @throws Does not throw. + */ + const visit = (nested: unknown): void => { + if (!nested || typeof nested !== 'object' || visited.has(nested)) { + return + } + + visited.add(nested) + if (Array.isArray(nested)) { + nested.forEach(visit) + return + } + + const record = nested as Record + const fields = record.fields as Record | undefined + const sys = record.sys as Record | undefined + if ( + sys?.type === 'Entry' + && typeof sys.id === 'string' + && typeof fields?.content === 'string' + && typeof fields.title === 'string' + && ['Article', 'Forum post', 'Video'].includes(String(fields.type)) + ) { + articles.set(sys.id, nested as CmsResource) + } + + Object.values(record) + .forEach(visit) + } + + visit(value) + return Array.from(articles.values()) +} diff --git a/src/apps/work/src/lib/styles/index.scss b/src/apps/work/src/lib/styles/index.scss index d30168998..0042dc496 100644 --- a/src/apps/work/src/lib/styles/index.scss +++ b/src/apps/work/src/lib/styles/index.scss @@ -1,4 +1,5 @@ @import '@libs/ui/styles/includes'; +@import '@libs/ui/styles/2026/index'; @import url('https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,200..1000;1,6..12,200..1000&display=swap'); :root { diff --git a/src/config/constants.ts b/src/config/constants.ts index 62a460111..29bdd1d5f 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -17,11 +17,13 @@ export enum AppSubdomain { reports = 'reports', procurement = 'procurement', status = 'status', - support = 'support' + support = 'support', + opportunities = 'opportunities' } export enum ToolTitle { accounts = 'Account Settings', + blog = 'Blog', devCenter = 'Dev Center', earn = 'Opportunity Feed', profiles = 'Profiles', @@ -40,7 +42,9 @@ export enum ToolTitle { reports = 'Reports', procurement = 'Procurement', status = 'Status', - support = 'Support' + support = 'Support', + opportunities = 'Opportunities', + thrive = 'Thrive' } export const PageSubheaderPortalId: string = 'page-subheader-portal-el' diff --git a/src/libs/cms/CmsMarkdown.tsx b/src/libs/cms/CmsMarkdown.tsx new file mode 100644 index 000000000..f10389647 --- /dev/null +++ b/src/libs/cms/CmsMarkdown.tsx @@ -0,0 +1,123 @@ +import type { Components, Options as ReactMarkdownOptions } from 'react-markdown' +import type { FC, ReactNode } from 'react' +import DOMPurify from 'dompurify' +import ReactMarkdown from 'react-markdown' +import rehypeRaw from 'rehype-raw' +import remarkBreaks from 'remark-breaks' +import remarkGfm from 'remark-gfm' + +import { getPayloadAssetUrl, getSafeCmsLink } from './cms.client' + +const Markdown = ReactMarkdown as unknown as FC + +const CMS_MARKDOWN_SANITIZE_OPTIONS = { + ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'loading', 'target'], + ADD_TAGS: ['iframe'], +} as const + +interface CmsMarkdownProps { + children: string + className?: string +} + +interface MarkdownLinkProps { + children?: ReactNode + href?: string +} + +interface MarkdownImageProps { + alt?: string + src?: string +} + +interface MarkdownIframeProps { + src?: string + title?: string +} + +/** + * Renders a CMS-authored link while blocking retired Contentful and Octana hosts. + * + * @param props markdown link target and children. + * @returns safe anchor content, or plain content when the target is blocked. + * @throws Does not throw. + */ +const MarkdownLink: FC = (props: MarkdownLinkProps) => { + const href = getSafeCmsLink(props.href) + return href ? ( + {props.children} + ) : {props.children} +} + +/** + * Renders only images that Payload has migrated to assets.topcoder-dev.com. + * + * @param props markdown image source and alternative text. + * @returns an image for the approved media origin, or no element for legacy origins. + * @throws Does not throw. + */ +const MarkdownImage: FC = (props: MarkdownImageProps) => { + const src = getPayloadAssetUrl(props.src) + return src ? {props.alt : <> +} + +/** + * Renders CMS video embeds only for YouTube's HTTPS player origin. + * + * @param props iframe source and accessible title. + * @returns an allow-listed iframe, or no element for an unsupported source. + * @throws Does not throw for malformed URLs. + */ +const MarkdownIframe: FC = (props: MarkdownIframeProps) => { + try { + const src = new URL(props.src || '') + if (src.protocol !== 'https:' || !['www.youtube.com', 'youtube.com'].includes(src.hostname)) { + return <> + } + + return ( +