diff --git a/packages/shared/src/contexts/RecruiterPaymentContext/RecruiterPaymentPaddleContext.tsx b/packages/shared/src/contexts/RecruiterPaymentContext/RecruiterPaymentPaddleContext.tsx index 6e7e1d0b1f5..bd56b0c574e 100644 --- a/packages/shared/src/contexts/RecruiterPaymentContext/RecruiterPaymentPaddleContext.tsx +++ b/packages/shared/src/contexts/RecruiterPaymentContext/RecruiterPaymentPaddleContext.tsx @@ -56,7 +56,7 @@ export const RecruiterPaymentPaddleContextProvider = ({ const { paddle, openCheckout } = usePaddlePayment({ successCallback: () => { - router.push( + router.replace( `${webappUrl}recruiter/${router.query.opportunityId}/matches`, ); }, diff --git a/packages/shared/src/features/opportunity/graphql.ts b/packages/shared/src/features/opportunity/graphql.ts index c62787f1348..739d2072226 100644 --- a/packages/shared/src/features/opportunity/graphql.ts +++ b/packages/shared/src/features/opportunity/graphql.ts @@ -129,9 +129,9 @@ export const OPPORTUNITY_FRAGMENT = gql` feedbackQuestions { ...OpportunityFeedbackQuestionFragment } - subscriptionStatus flags { batchSize + plan } } ${ORGANIZATION_SHORT_FRAGMENT} diff --git a/packages/shared/src/features/opportunity/mockData.ts b/packages/shared/src/features/opportunity/mockData.ts index 8202b223659..c714ba85e86 100644 --- a/packages/shared/src/features/opportunity/mockData.ts +++ b/packages/shared/src/features/opportunity/mockData.ts @@ -2,7 +2,6 @@ import type { Opportunity } from './types'; import type { OpportunityPreviewContextType } from './context/OpportunityPreviewContext'; import { SeniorityLevel } from './protobuf/opportunity'; import { SourceMemberRole, SourceType } from '../../graphql/sources'; -import { SubscriptionStatus } from '../../lib/plus'; export const mockOpportunity: Opportunity = { id: '89f3daff-d6bb-4652-8f9c-b9f7254c9af1', @@ -64,7 +63,6 @@ export const mockOpportunity: Opportunity = { { keyword: 'JavaScript' }, { keyword: 'Tailwind CSS' }, ], - subscriptionStatus: SubscriptionStatus.None, }; export const mockAnonymousUserTableData: OpportunityPreviewContextType = { diff --git a/packages/shared/src/features/opportunity/types.ts b/packages/shared/src/features/opportunity/types.ts index 40ab2df2c1b..425f3ff3977 100644 --- a/packages/shared/src/features/opportunity/types.ts +++ b/packages/shared/src/features/opportunity/types.ts @@ -12,7 +12,6 @@ import type { LocationType } from './protobuf/util'; import type { Connection } from '../../graphql/common'; import type { Squad } from '../../graphql/sources'; import type { TopReader } from '../../components/badges/TopReaderBadge'; -import type { SubscriptionStatus } from '../../lib/plus'; export enum OpportunityMatchStatus { Pending = 'pending', @@ -87,9 +86,10 @@ export type OpportunityScreeningAnswer = { answer: string; }; -type OpportunityFlagsPublic = { - batchSize?: number; -}; +type OpportunityFlagsPublic = Partial<{ + batchSize: number; + plan: string; +}>; export type Opportunity = { id: string; @@ -107,7 +107,6 @@ export type Opportunity = { keywords?: Keyword[]; questions?: OpportunityScreeningQuestion[]; feedbackQuestions?: OpportunityFeedbackQuestion[]; - subscriptionStatus: SubscriptionStatus; flags?: OpportunityFlagsPublic; }; diff --git a/packages/shared/src/lib/schema/opportunity.ts b/packages/shared/src/lib/schema/opportunity.ts index 723ef50d63f..5ebff81e9d2 100644 --- a/packages/shared/src/lib/schema/opportunity.ts +++ b/packages/shared/src/lib/schema/opportunity.ts @@ -60,11 +60,6 @@ export const opportunityEditInfoSchema = z.object({ ), ), }), - organization: z - .object({ - name: z.string().nonempty().max(60), - }) - .nullish(), }); export const createOpportunityEditContentSchema = ({ @@ -111,6 +106,9 @@ export const opportunityEditQuestionsSchema = z.object({ export const opportunityEditStep1Schema = opportunityEditInfoSchema.extend({ content: opportunityEditContentSchema.shape.content, + organization: z.object({ + name: z.string().nonempty(), + }), }); export const opportunityEditStep2Schema = opportunityEditQuestionsSchema.extend( diff --git a/packages/webapp/pages/recruiter/[opportunityId]/matches/index.tsx b/packages/webapp/pages/recruiter/[opportunityId]/matches/index.tsx index d3b98ec694f..e4abcc50e5a 100644 --- a/packages/webapp/pages/recruiter/[opportunityId]/matches/index.tsx +++ b/packages/webapp/pages/recruiter/[opportunityId]/matches/index.tsx @@ -1,7 +1,7 @@ import type { ReactElement } from 'react'; import React from 'react'; import { useRouter } from 'next/router'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ConnectHeader } from '@dailydotdev/shared/src/components/recruiter/ConnectHeader'; import { ConnectProgress } from '@dailydotdev/shared/src/components/recruiter/ConnectProgress'; import { Loader } from '@dailydotdev/shared/src/components/Loader'; @@ -25,6 +25,10 @@ import { import { GenericLoaderSpinner } from '@dailydotdev/shared/src/components/utilities/loaders'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { opportunityByIdOptions } from '@dailydotdev/shared/src/features/opportunity/queries'; +import { oneMinute } from '@dailydotdev/shared/src/lib/dateFormat'; +import { transactionRefetchIntervalMs } from '@dailydotdev/shared/src/graphql/njord'; +import { OpportunityState } from '@dailydotdev/shared/src/features/opportunity/protobuf/opportunity'; import { getLayout } from '../../../../components/layouts/RecruiterSelfServeLayout'; function RecruiterMatchesPage(): ReactElement { @@ -32,6 +36,42 @@ function RecruiterMatchesPage(): ReactElement { const { opportunityId } = router.query; const queryClient = useQueryClient(); + const { data: opportunity } = useQuery({ + ...opportunityByIdOptions({ + id: opportunityId as string, + }), + refetchInterval: (query) => { + const retries = Math.max( + query.state.dataUpdateCount, + query.state.fetchFailureCount, + ); + + // transactions are mostly processed withing few seconds + // so for now we stop retrying after 1 minute + const maxRetries = (oneMinute * 1000) / transactionRefetchIntervalMs; + + if (retries > maxRetries) { + return false; + } + + const queryError = query.state.error; + + // in case of query error keep refetching until maxRetries is reached + if (queryError) { + return transactionRefetchIntervalMs; + } + + const isReadyForMatches = + query.state.data?.state !== OpportunityState.DRAFT; + + if (isReadyForMatches) { + return false; + } + + return transactionRefetchIntervalMs; + }, + }); + const { allMatches, isLoading, data } = useOpportunityMatches({ opportunityId: opportunityId as string, status: 'candidate_accepted', @@ -95,6 +135,8 @@ function RecruiterMatchesPage(): ReactElement { ); } + const isReadyForMatches = opportunity?.state !== OpportunityState.DRAFT; + return (
@@ -142,8 +184,10 @@ function RecruiterMatchesPage(): ReactElement { color={TypographyColor.Tertiary} center > - We’re already talking to the right developers for you — all - opt-in, all high-intent. + {isReadyForMatches && + "We're already talking to the right developers for you — all opt-in, all high-intent."} + {!isReadyForMatches && + 'We are gonna start reaching to developers soon, we are still processing your data and payment...'}
diff --git a/packages/webapp/pages/recruiter/[opportunityId]/payment.tsx b/packages/webapp/pages/recruiter/[opportunityId]/payment.tsx index 6eabd185ef7..7cab0c2f00e 100644 --- a/packages/webapp/pages/recruiter/[opportunityId]/payment.tsx +++ b/packages/webapp/pages/recruiter/[opportunityId]/payment.tsx @@ -25,12 +25,15 @@ import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; import { recruiterPricesQueryOptions } from '@dailydotdev/shared/src/features/opportunity/graphql'; import { useQuery } from '@tanstack/react-query'; import { Loader } from '@dailydotdev/shared/src/components/Loader'; +import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; +import { useToastNotification } from '@dailydotdev/shared/src/hooks'; const RecruiterPaymentPage = (): ReactElement => { const router = useRouter(); const checkoutRef = useRef(null); const { openCheckout, selectedProduct } = useRecruiterPaymentContext(); const { opportunity } = useOpportunityPreviewContext(); + const { displayToast } = useToastNotification(); useEffect(() => { if (!opportunity) { @@ -47,6 +50,20 @@ const RecruiterPaymentPage = (): ReactElement => { }); }, [selectedProduct, openCheckout, opportunity]); + useEffect(() => { + if (!opportunity) { + return; + } + + if (!opportunity.organization) { + router.replace(`${webappUrl}recruiter/${opportunity.id}/prepare`); + + displayToast( + 'Organization info missing, please enter it to proceed with payment.', + ); + } + }, [displayToast, opportunity, router]); + const handleBack = () => { router.back(); }; diff --git a/packages/webapp/pages/recruiter/[opportunityId]/prepare.tsx b/packages/webapp/pages/recruiter/[opportunityId]/prepare.tsx index b03cf7d1a3e..147b88cee06 100644 --- a/packages/webapp/pages/recruiter/[opportunityId]/prepare.tsx +++ b/packages/webapp/pages/recruiter/[opportunityId]/prepare.tsx @@ -11,19 +11,54 @@ import { } from '@dailydotdev/shared/src/components/opportunity/OpportunityEditContext'; import { useRouter } from 'next/router'; import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; +import { opportunityEditStep1Schema } from '@dailydotdev/shared/src/lib/schema/opportunity'; +import { usePrompt } from '@dailydotdev/shared/src/hooks/usePrompt'; +import { labels } from '@dailydotdev/shared/src/lib/labels'; import { getLayout } from '../../../components/layouts/RecruiterSelfServeLayout'; import JobPage from '../../jobs/[id]'; function PreparePage(): ReactElement { const router = useRouter(); - const { opportunityId } = useOpportunityEditContext(); + const { opportunityId, onValidateOpportunity } = useOpportunityEditContext(); + const { showPrompt } = usePrompt(); return (
{ + onClick: async () => { + const result = onValidateOpportunity({ + schema: opportunityEditStep1Schema, + }); + + if (result.error) { + await showPrompt({ + title: labels.opportunity.requiredMissingNotice.title, + description: ( +
+ + {labels.opportunity.requiredMissingNotice.description} + +
    + {result.error.issues.map((issue) => { + const path = issue.path.join('.'); + + return
  • • {path}
  • ; + })} +
+
+ ), + okButton: { + className: '!w-full', + title: labels.opportunity.requiredMissingNotice.okButton, + }, + cancelButton: null, + }); + + return; + } + router.push(`${webappUrl}recruiter/${opportunityId}/plans`); }, }}