Skip to content

feat(PM-578): Apply for copilot opportunity #1067

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
May 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/apps/copilots/src/models/CopilotApplication.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface CopilotApplication {
id: number,
notes?: string,
createdAt: Date,
opportunityId: string,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/* eslint-disable react/jsx-no-bind */
import { FC, useCallback, useState } from 'react'

import { BaseModal, Button, InputTextarea } from '~/libs/ui'

import { applyCopilotOpportunity } from '../../../services/copilot-opportunities'

import styles from './styles.module.scss'

interface ApplyOpportunityModalProps {
onClose: () => void
copilotOpportunityId: number
projectName: string
onApplied: () => void
}

const ApplyOpportunityModal: FC<ApplyOpportunityModalProps> = props => {
const [notes, setNotes] = useState('')
const [success, setSuccess] = useState(false)

const onApply = useCallback(async () => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding error handling for the applyCopilotOpportunity function call to manage any potential failures or exceptions that might occur during the API request.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hentrymartin please wrap the applyCopilotOpportunity in try/catch block to avoid calling props.onApplied() & setSuccess(true) if the API call fails for some reason.

try {
await applyCopilotOpportunity(props.copilotOpportunityId, {
notes,
})

props.onApplied()
setSuccess(true)
} catch (e) {
setSuccess(true)
}
}, [props.copilotOpportunityId, notes])

const onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void = useCallback(e => {
setNotes(e.target.value)
}, [setNotes])

return (
<BaseModal
onClose={props.onClose}
open
size='lg'
title={
success
? `Your Application for ${props.projectName} Has Been Received!`
: `Confirm Your Copilot Application for ${props.projectName}`
}
buttons={
!success ? (
<>
<Button primary onClick={onApply} label='Apply' />
<Button secondary onClick={props.onClose} label='Cancel' />
</>
) : (
<Button primary onClick={props.onClose} label='Close' />
)
}
>
<div className={styles.applyCopilotModal}>
<div className={styles.info}>
{
success
? `We appreciate the time and effort you've taken to apply
for this exciting opportunity. Our team is committed
to providing a seamless and efficient process to ensure a
great experience for all copilots. We will review your application
within short time.`
: `We're excited to see your interest in joining our team as a copilot
for the ${props.projectName} project! Before we proceed, we want to
ensure that you have carefully reviewed the project requirements and
are committed to meeting them.`
}
</div>
{
!success && (
<InputTextarea
name='Notes'
onChange={onChange}
value={notes}
/>
)
}
</div>
</BaseModal>
)
}

export default ApplyOpportunityModal
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as ApplyOpportunityModal } from './ApplyOpportunityModal'
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@import '@libs/ui/styles/includes';

.applyCopilotModal {
.info {
margin-bottom: 12px;
}
}
62 changes: 59 additions & 3 deletions src/apps/copilots/src/pages/copilot-opportunity-details/index.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,48 @@
import { FC, useEffect, useState } from 'react'
/* eslint-disable react/jsx-no-bind */
import {
FC,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { mutate } from 'swr'
import moment from 'moment'

import {
ButtonProps,
ContentLayout,
IconOutline,
LoadingSpinner,
PageTitle,
} from '~/libs/ui'
import { profileContext, ProfileContextData, UserRole } from '~/libs/core'

import { CopilotOpportunityResponse, useCopilotOpportunity } from '../../services/copilot-opportunities'
import { CopilotApplication } from '../../models/CopilotApplication'
import {
copilotBaseUrl,
CopilotOpportunityResponse,
useCopilotApplications,
useCopilotOpportunity,
} from '../../services/copilot-opportunities'
import { copilotRoutesMap } from '../../copilots.routes'

import { ApplyOpportunityModal } from './apply-opportunity-modal'
import styles from './styles.module.scss'

const CopilotOpportunityDetails: FC<{}> = () => {
const { opportunityId }: {opportunityId?: string} = useParams<{ opportunityId?: string }>()
const navigate = useNavigate()
const [showNotFound, setShowNotFound] = useState(false)
const [showApplyOpportunityModal, setShowApplyOpportunityModal] = useState(false)
const { profile }: ProfileContextData = useContext(profileContext)
const isCopilot: boolean = useMemo(
() => !!profile?.roles?.some(role => role === UserRole.copilot),
[profile],
)
const { data: copilotApplications }: { data?: CopilotApplication[] } = useCopilotApplications(opportunityId)

if (!opportunityId) {
navigate(copilotRoutesMap.CopilotOpportunityList)
Expand All @@ -35,6 +60,14 @@ const CopilotOpportunityDetails: FC<{}> = () => {
return () => clearTimeout(timer) // Cleanup on unmount
}, [opportunity])

const onApplied: () => void = useCallback(() => {
mutate(`${copilotBaseUrl}/copilots/opportunity/${opportunityId}/applications`)
}, [])

const onCloseApplyModal: () => void = useCallback(() => {
setShowApplyOpportunityModal(false)
}, [setShowApplyOpportunityModal])

if (!opportunity && showNotFound) {
return (
<ContentLayout title='Copilot Opportunity Details'>
Expand All @@ -44,8 +77,20 @@ const CopilotOpportunityDetails: FC<{}> = () => {
)
}

const applyCopilotOpportunityButton: ButtonProps = {
label: 'Apply as Copilot',
onClick: () => setShowApplyOpportunityModal(true),
}

return (
<ContentLayout title='Copilot Opportunity'>
<ContentLayout
title='Copilot Opportunity'
buttonConfig={
isCopilot
&& copilotApplications
&& copilotApplications.length === 0 ? applyCopilotOpportunityButton : undefined
}
>
<PageTitle>Copilot Opportunity</PageTitle>
{isValidating && !showNotFound && (
<LoadingSpinner />
Expand Down Expand Up @@ -132,6 +177,17 @@ const CopilotOpportunityDetails: FC<{}> = () => {
<span className={styles.textCaps}>{opportunity?.requiresCommunication}</span>
</div>
</div>
{
showApplyOpportunityModal
&& opportunity && (
<ApplyOpportunityModal
copilotOpportunityId={opportunity?.id}
onClose={onCloseApplyModal}
projectName={opportunity?.projectName}
onApplied={onApplied}
/>
)
}
</ContentLayout>
)
}
Expand Down
45 changes: 41 additions & 4 deletions src/apps/copilots/src/services/copilot-opportunities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import useSWR, { SWRResponse } from 'swr'
import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite'

import { EnvironmentConfig } from '~/config'
import { xhrGetAsync } from '~/libs/core'
import { xhrGetAsync, xhrPostAsync } from '~/libs/core'
import { buildUrl } from '~/libs/shared/lib/utils/url'

import { CopilotOpportunity } from '../models/CopilotOpportunity'
import { CopilotApplication } from '../models/CopilotApplication'

const baseUrl = `${EnvironmentConfig.API.V5}/projects`
export const copilotBaseUrl = `${EnvironmentConfig.API.V5}/projects`

const PAGE_SIZE = 20

Expand Down Expand Up @@ -42,7 +43,9 @@ export interface CopilotOpportunitiesResponse {
export const useCopilotOpportunities = (): CopilotOpportunitiesResponse => {
const getKey = (pageIndex: number, previousPageData: CopilotOpportunity[]): string | undefined => {
if (previousPageData && previousPageData.length < PAGE_SIZE) return undefined
return `${baseUrl}/copilots/opportunities?page=${pageIndex + 1}&pageSize=${PAGE_SIZE}&sort=createdAt desc`
return `
${copilotBaseUrl}/copilots/opportunities?page=${pageIndex + 1}&pageSize=${PAGE_SIZE}&sort=createdAt desc
`
}

const fetcher = (url: string): Promise<CopilotOpportunity[]> => xhrGetAsync<CopilotOpportunity[]>(url)
Expand All @@ -68,14 +71,16 @@ export const useCopilotOpportunities = (): CopilotOpportunitiesResponse => {

export type CopilotOpportunityResponse = SWRResponse<CopilotOpportunity, CopilotOpportunity>

export type CopilotApplicationResponse = SWRResponse<CopilotApplication[], CopilotApplication[]>

/**
* Custom hook to fetch copilot opportunity by id.
*
* @param {string} opportunityId - The unique identifier of the copilot request.
* @returns {CopilotOpportunityResponse} - The response containing the copilot request data.
*/
export const useCopilotOpportunity = (opportunityId?: string): CopilotOpportunityResponse => {
const url = opportunityId ? buildUrl(`${baseUrl}/copilot/opportunity/${opportunityId}`) : undefined
const url = opportunityId ? buildUrl(`${copilotBaseUrl}/copilot/opportunity/${opportunityId}`) : undefined

const fetcher = (urlp: string): Promise<CopilotOpportunity> => xhrGetAsync<CopilotOpportunity>(urlp)
.then(copilotOpportunityFactory)
Expand All @@ -85,3 +90,35 @@ export const useCopilotOpportunity = (opportunityId?: string): CopilotOpportunit
revalidateOnFocus: false,
})
}

/**
* apply copilot opportunity
* @param opportunityId
* @param request
* @returns
*/
export const applyCopilotOpportunity = async (opportunityId: number, request: {
notes?: string;
}): Promise<CopilotApplication> => {
const url = `${copilotBaseUrl}/copilots/opportunity/${opportunityId}/apply`

return xhrPostAsync(url, request, {})
}

/**
* Custom hook to fetch copilot applications by opportunity id.
*
* @param {string} opportunityId - The unique identifier of the copilot request.
* @returns {CopilotApplicationResponse} - The response containing the copilot application data.
*/
export const useCopilotApplications = (opportunityId?: string): CopilotApplicationResponse => {
const url = opportunityId
? buildUrl(`${copilotBaseUrl}/copilots/opportunity/${opportunityId}/applications`)
: undefined

const fetcher = (urlp: string): Promise<CopilotApplication[]> => xhrGetAsync<CopilotApplication[]>(urlp)
.then(data => data)
.catch(() => [])

return useSWR(url, fetcher)
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ export enum UserRole {
paymentProviderViewer = 'PaymentProvider Viewer',
projectManager = 'Project Manager',
taxFormAdmin = 'TaxForm Admin',
taxFormViewer = 'TaxForm Viewer'
taxFormViewer = 'TaxForm Viewer',
copilot = 'copilot'
}