diff --git a/.env.development b/.env.development index 66244ad78..aceee2814 100644 --- a/.env.development +++ b/.env.development @@ -3,3 +3,13 @@ REACT_APP_TERMS_API_URL=https://api.topcoder-dev.com/v5/terms REACT_APP_RESOURCES_API_URL=https://api.topcoder-dev.com/v6/resources REACT_APP_MEMBER_API_URL=https://api.topcoder-dev.com/v6/members REACT_APP_RESOURCE_ROLES_API_URL=https://api.topcoder-dev.com/v6/resource-roles +REACT_APP_CONTENTFUL_ACCESS_TOKEN=CFPAT-ff4ab52ffbcc85488d7f65bdecd0726117d71a1ad3c71fef567c74f09a8b75a9 +REACT_APP_CONTENTFUL_SPACE_ID=b5f1djy59z3a +REACT_APP_CONTENTFUL_CHANGELOG_ENTRY_ID= +REACT_APP_CONTENTFUL_EDU_ACCESS_TOKEN=EXl1Y8-6VFyHBqJqlxGp2LKmyNJJPutDbKH977G07eg +REACT_APP_CONTENTFUL_EDU_SPACE_ID=piwi0eufbb2g +REACT_APP_CHALLENGE_API_URL= +REACT_APP_CHALLENGE_API_VERSION= +REACT_APP_COMMUNITY_APP_URL= +REACT_APP_REVIEW_APP_URL= +REACT_APP_DIRECT_PROJECT_URL= diff --git a/package.json b/package.json index 15d23e286..c921ae431 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.40.0", + "@contentful/rich-text-html-renderer": "^17.1.6", "@datadog/browser-logs": "^4.50.1", "@hello-pangea/dnd": "^18.0.1", "@heroicons/react": "^1.0.6", diff --git a/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.tsx b/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.tsx index 9d5f943c8..410e80966 100644 --- a/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.tsx +++ b/src/apps/admin/src/lib/components/common/DropdownMenu/DropdownMenu.tsx @@ -10,10 +10,10 @@ import { usePopper } from 'react-popper' import cn from 'classnames' import { Placement } from '@popperjs/core' -import { Portal } from '~/libs/ui' -import { useClickOutsideMultipleElements } from '~/libs/shared' +import { Portal } from '~/libs/ui/lib/components/portal' +import { useClickOutsideMultipleElements } from '~/libs/shared/lib/hooks/use-click-outside.hook' -import { useOnScroll } from '../../../hooks' +import { useOnScroll } from '../../../hooks/useOnScroll' import styles from './DropdownMenu.module.scss' diff --git a/src/apps/community/index.ts b/src/apps/community/index.ts new file mode 100644 index 000000000..6f39cd49b --- /dev/null +++ b/src/apps/community/index.ts @@ -0,0 +1 @@ +export * from './src' diff --git a/src/apps/community/src/CommunityApp.tsx b/src/apps/community/src/CommunityApp.tsx new file mode 100644 index 000000000..b3e70cefc --- /dev/null +++ b/src/apps/community/src/CommunityApp.tsx @@ -0,0 +1,107 @@ +import { FC, useContext, useEffect, useMemo } from 'react' +import { + Outlet, + Routes, + useLocation, + useNavigate, +} from 'react-router-dom' + +import { AppSubdomain, EnvironmentConfig } from '~/config' +import { routerContext, RouterContextData } from '~/libs/core' + +import { resolveCommunityIdFromHost } from './config/community-id.config' +import { + communityLoaderRouteId, + forumListingRouteId, + rootRoute, +} from './config/routes.config' +import { Layout, type LayoutVariant } from './lib' +import { toolTitle } from './community-app.routes' +import './lib/styles/index.scss' + +function withLeadingSlash(path: string): string { + return path.startsWith('/') + ? path + : `/${path}` +} + +function normalizePath(path: string): string { + const collapsed = path.replace(/\/{2,}/g, '/') + if (collapsed.length > 1 && collapsed.endsWith('/')) { + return collapsed.slice(0, -1) + } + + return collapsed || '/' +} + +/** + * Root shell for the community app routes. + * + * @returns The app layout and routed child content. + */ +const CommunityApp: FC = () => { + const { getChildRoutes }: RouterContextData = useContext(routerContext) + const location = useLocation() + const navigate = useNavigate() + const childRoutes = useMemo(() => getChildRoutes(toolTitle), [getChildRoutes]) + const routedCommunityId = useMemo( + () => resolveCommunityIdFromHost(window.location.host), + [], + ) + const communityBasePath = useMemo( + () => normalizePath(withLeadingSlash(rootRoute)), + [], + ) + const normalizedPath = useMemo( + () => normalizePath(location.pathname), + [location.pathname], + ) + const isCommunityPage = normalizedPath.includes(`/${communityLoaderRouteId}`) + const isForumPage = normalizedPath.includes(`/${forumListingRouteId}`) + + const variant: LayoutVariant + = EnvironmentConfig.SUBDOMAIN !== AppSubdomain.community + && !isCommunityPage + && !isForumPage + ? 'standard' + : 'community' + + useEffect(() => { + if (!routedCommunityId || isCommunityPage) { + return + } + + const isRootPath = normalizedPath === '/' + || normalizedPath === communityBasePath + if (!isRootPath) { + return + } + + const destination = normalizePath( + `${communityBasePath}/${communityLoaderRouteId}/${routedCommunityId}`, + ) + navigate(destination, { replace: true }) + }, [ + communityBasePath, + isCommunityPage, + navigate, + normalizedPath, + routedCommunityId, + ]) + + useEffect(() => { + document.body.classList.add('community-app') + return () => { + document.body.classList.remove('community-app') + } + }, []) + + return ( + + + {childRoutes.length > 0 && {childRoutes}} + + ) +} + +export default CommunityApp diff --git a/src/apps/community/src/community-app.routes.tsx b/src/apps/community/src/community-app.routes.tsx new file mode 100644 index 000000000..5c8adf3dc --- /dev/null +++ b/src/apps/community/src/community-app.routes.tsx @@ -0,0 +1,50 @@ +/** + * App routes. + */ +import { AppSubdomain, ToolTitle } from '~/config' +import { + lazyLoad, + LazyLoadedComponent, + PlatformRoute, +} from '~/libs/core' + +import { rootRoute } from './config/routes.config' +import { + challengeDetailRoutes, + challengeListingRoutes, + changelogRoutes, + communityLoaderRoutes, + forumRoutes, + homeRoutes, + submissionManagementRoutes, + submissionRoutes, + thriveRoutes, + timelineWallRoutes, +} from './pages' + +const CommunityApp: LazyLoadedComponent = lazyLoad(() => import('./CommunityApp')) + +export const toolTitle: string = ToolTitle.community + +export const communityRoutes: ReadonlyArray = [ + { + children: [ + ...challengeDetailRoutes, + ...challengeListingRoutes, + ...changelogRoutes, + ...communityLoaderRoutes, + ...forumRoutes, + ...homeRoutes, + ...submissionRoutes, + ...submissionManagementRoutes, + ...timelineWallRoutes, + ...thriveRoutes, + ], + domain: AppSubdomain.community, + element: , + id: toolTitle, + layoutVariant: 'community', + route: rootRoute, + title: toolTitle, + }, +] diff --git a/src/apps/community/src/config/community-id.config.ts b/src/apps/community/src/config/community-id.config.ts new file mode 100644 index 000000000..74b4e8bad --- /dev/null +++ b/src/apps/community/src/config/community-id.config.ts @@ -0,0 +1,41 @@ +/** + * Community ids with explicit `__community__/{communityId}` route wiring. + */ +export const explicitCommunityIds = [ + 'wipro', + 'veterans', + 'qa', + 'mobile', + 'iot', + 'cognitive', + 'blockchain', + 'cs', + 'demoexpert', + 'srmx', + 'taskforce', + 'tcproddev', + 'community2', +] as const + +export type ExplicitCommunityId = (typeof explicitCommunityIds)[number] + +const explicitCommunityIdSet: ReadonlySet = new Set(explicitCommunityIds) + +/** + * Resolves a routed community id from a browser host value. + * + * @param host Browser `location.host` value. + * @returns Community id when the host subdomain matches a routed community. + */ +export function resolveCommunityIdFromHost(host: string): ExplicitCommunityId | undefined { + const hostname = host + .toLowerCase() + .split(':')[0] + const subdomain = hostname.split('.')[0] + + if (!subdomain || !explicitCommunityIdSet.has(subdomain)) { + return undefined + } + + return subdomain as ExplicitCommunityId +} diff --git a/src/apps/community/src/config/index.config.ts b/src/apps/community/src/config/index.config.ts new file mode 100644 index 000000000..cc25510a8 --- /dev/null +++ b/src/apps/community/src/config/index.config.ts @@ -0,0 +1,50 @@ +/** + * Resource role id used to register challenge submitters. + */ +export const SUBMITTER_ROLE_ID = '732339e7-8e30-49d7-9198-cccf9451e221' + +/** + * Challenge statuses used by community challenge filters. + */ +export enum CHALLENGE_STATUS { + Active = 'Active', + Completed = 'Completed', + Draft = 'Draft', +} + +/** + * Supported Thrive article types. + */ +export const THRIVE_ARTICLE_TYPES = ['Article', 'Video', 'Forum post'] as const + +/** + * Supported Thrive track keys. + */ +export const THRIVE_TRACK_KEYS = [ + 'dataScience', + 'competitiveProgramming', + 'design', + 'development', + 'qualityAssurance', + 'topcoder', +] as const + +/** + * Max retry attempts when checking term agreement status. + */ +export const TERMS_CHECK_MAX_ATTEMPTS = 5 + +/** + * Delay between term status retries in milliseconds. + */ +export const TERMS_CHECK_DELAY_MS = 5000 + +/** + * Wipro email domain used for challenge registration checks. + */ +export const WIPRO_EMAIL_DOMAIN = '@wipro.com' + +/** + * External TopGear redirect url. + */ +export const TOPGEAR_REDIRECT_URL = 'https://topgear-app.wipro.com' diff --git a/src/apps/community/src/config/routes.config.ts b/src/apps/community/src/config/routes.config.ts new file mode 100644 index 000000000..10192dc2f --- /dev/null +++ b/src/apps/community/src/config/routes.config.ts @@ -0,0 +1,25 @@ +/** + * Common config for routes in community app. + */ +import { AppSubdomain, EnvironmentConfig } from '~/config' + +export const rootRoute: string + = EnvironmentConfig.SUBDOMAIN === AppSubdomain.community + ? '' + : '/community' + +export const challengeListingRouteId = 'challenges' +export const challengeDetailRouteId = 'challenges/:challengeId' +export const submissionRouteId = 'challenges/:challengeId/submit' +export const submissionManagementRouteId = 'challenges/:challengeId/my-submissions' +export const forumListingRouteId = 'forums' +export const forumCreateTopicRouteId = 'forums/new' +export const forumTopicRouteId = 'forums/:topicId' +export const homeRouteId = 'home' +export const thriveListingRouteId = 'thrive' +export const thriveTracksRouteId = 'thrive/tracks' +export const thriveSearchRouteId = 'thrive/search' +export const thriveArticleRouteId = 'thrive/:articleTitle' +export const changelogRouteId = 'changelog' +export const timelineWallRouteId = 'timeline-wall' +export const communityLoaderRouteId = '__community__' diff --git a/src/apps/community/src/index.ts b/src/apps/community/src/index.ts new file mode 100644 index 000000000..7e17c17d1 --- /dev/null +++ b/src/apps/community/src/index.ts @@ -0,0 +1,2 @@ +export { communityRoutes } from './community-app.routes' +export { rootRoute as communityRootRoute } from './config/routes.config' diff --git a/src/apps/community/src/lib/components/AccessDenied/AccessDenied.module.scss b/src/apps/community/src/lib/components/AccessDenied/AccessDenied.module.scss new file mode 100644 index 000000000..c05d6f383 --- /dev/null +++ b/src/apps/community/src/lib/components/AccessDenied/AccessDenied.module.scss @@ -0,0 +1,37 @@ +@import '@libs/ui/styles/includes'; + +.container { + align-items: center; + display: flex; + flex-direction: column; + gap: $sp-2; + justify-content: center; + min-height: 420px; + padding: $sp-6 $sp-3; + text-align: center; +} + +.logo { + color: var(--black-100); + height: 36px; + width: 200px; +} + +.title { + color: var(--black-100); + font-size: 28px; + font-weight: 600; + margin: 0; +} + +.message { + color: var(--GrayFontColor); + margin: 0; + max-width: 480px; +} + +.meta { + color: var(--GrayFontColor); + font-size: 12px; + margin: 0; +} diff --git a/src/apps/community/src/lib/components/AccessDenied/AccessDenied.tsx b/src/apps/community/src/lib/components/AccessDenied/AccessDenied.tsx new file mode 100644 index 000000000..9bb3dc79b --- /dev/null +++ b/src/apps/community/src/lib/components/AccessDenied/AccessDenied.tsx @@ -0,0 +1,72 @@ +import { FC, useCallback } from 'react' + +import { authUrlLogin } from '~/libs/core' +import { Button, TcLogoSvg } from '~/libs/ui' + +import styles from './AccessDenied.module.scss' + +export enum AccessDeniedCause { + NOT_AUTHENTICATED = 'NOT_AUTHENTICATED', + NOT_AUTHORIZED = 'NOT_AUTHORIZED', +} + +export interface AccessDeniedProps { + cause: AccessDeniedCause + communityId?: string +} + +/** + * Renders a standardized access denied view for authentication/authorization failures. + * + * @param props Access denied cause with optional community id for login attribution. + * @returns Access denied message with contextual action. + */ +const AccessDenied: FC = (props: AccessDeniedProps) => { + const handleLogin = useCallback((): void => { + window.location.assign(authUrlLogin(window.location.href)) + }, []) + const handleBackToHome = useCallback((): void => { + window.location.assign('/') + }, []) + + if (props.cause === AccessDeniedCause.NOT_AUTHENTICATED) { + return ( +
+ +

Access Denied

+

+ You must be authenticated to access this page. +

+ {props.communityId && ( +

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

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

Not Authorized

+

+ You are not authorized to access this page. +

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