diff --git a/packages/shared/src/contexts/RecruiterPaymentContext/RecruiterPaymentContext.tsx b/packages/shared/src/contexts/RecruiterPaymentContext/RecruiterPaymentContext.tsx new file mode 100644 index 00000000000..dc292dca9b4 --- /dev/null +++ b/packages/shared/src/contexts/RecruiterPaymentContext/RecruiterPaymentContext.tsx @@ -0,0 +1,15 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import type { RecruiterContextProviderProps } from './types'; +import { RecruiterPaymentPaddleContextProvider } from './RecruiterPaymentPaddleContext'; + +export const RecruiterPaymentContext = ({ + children, + ...props +}: RecruiterContextProviderProps): ReactElement => { + return ( + + {children} + + ); +}; diff --git a/packages/shared/src/contexts/RecruiterPaymentContext/RecruiterPaymentPaddleContext.tsx b/packages/shared/src/contexts/RecruiterPaymentContext/RecruiterPaymentPaddleContext.tsx new file mode 100644 index 00000000000..7090858c885 --- /dev/null +++ b/packages/shared/src/contexts/RecruiterPaymentContext/RecruiterPaymentPaddleContext.tsx @@ -0,0 +1,78 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useRouter } from 'next/router'; +import { PurchaseType } from '../../graphql/paddle'; +import { usePaddlePayment } from '../../hooks/usePaddlePayment'; +import { useLogContext } from '../LogContext'; +import { useAuthContext } from '../AuthContext'; +import type { + RecruiterPaymentContextData, + RecruiterContextProviderProps, + RecruiterProductOption, +} from './types'; +import { RecruiterPaymentContext } from './types'; +import { webappUrl } from '../../lib/constants'; +import { recruiterPricesQueryOptions } from '../../features/opportunity/graphql'; + +export const RecruiterPaymentPaddleContextProvider = ({ + onCompletion, + origin, + children, +}: RecruiterContextProviderProps): ReactElement => { + const router = useRouter(); + const { user, isLoggedIn } = useAuthContext(); + const { logEvent } = useLogContext(); + const [selectedProduct, setSelectedProduct] = + useState(); + const logRef = useRef(); + logRef.current = logEvent; + + const { data: prices } = useQuery( + recruiterPricesQueryOptions({ + user, + isLoggedIn, + }), + ); + + useEffect(() => { + if (!prices?.length) { + return; + } + + if (selectedProduct) { + return; + } + + setSelectedProduct({ + id: prices[0].priceId, + }); + }, [prices, selectedProduct]); + + const { paddle, openCheckout } = usePaddlePayment({ + successCallback: () => { + router.push( + `${webappUrl}recruiter/${router.query.opportunityId}/prepare`, + ); + }, + priceType: PurchaseType.Recruiter, + }); + + const contextData = useMemo( + () => ({ + paddle, + onCompletion, + selectedProduct, + setSelectedProduct, + openCheckout, + origin, + }), + [onCompletion, openCheckout, origin, paddle, selectedProduct], + ); + + return ( + + {children} + + ); +}; diff --git a/packages/shared/src/contexts/RecruiterPaymentContext/types.ts b/packages/shared/src/contexts/RecruiterPaymentContext/types.ts new file mode 100644 index 00000000000..b902c2c4e23 --- /dev/null +++ b/packages/shared/src/contexts/RecruiterPaymentContext/types.ts @@ -0,0 +1,37 @@ +import type { Paddle } from '@paddle/paddle-js'; +import type { ReactNode } from 'react'; +import { createContext, useContext } from 'react'; +import type { OpenCheckoutFn } from '../payment/context'; +import type { Origin } from '../../lib/log'; + +export const RecruiterPaymentContext = + createContext(undefined); + +export const useRecruiterPaymentContext = (): RecruiterPaymentContextData => + useContext(RecruiterPaymentContext); + +export type RecruiterProductOption = { + id: string; +}; + +export type ProcessingError = { + title: string; + description?: string; + onRequestClose?: () => void; +}; + +export type RecruiterPaymentContextData = { + paddle?: Paddle | undefined; + openCheckout?: OpenCheckoutFn<{ opportunity_id: string }>; + onCompletion?: () => void; + selectedProduct?: RecruiterProductOption; + setSelectedProduct: (product: RecruiterProductOption) => void; + error?: ProcessingError; + origin?: Origin; +}; + +export type RecruiterContextProviderProps = { + children?: ReactNode; + origin?: Origin; + onCompletion?: () => void; +}; diff --git a/packages/shared/src/contexts/payment/context.ts b/packages/shared/src/contexts/payment/context.ts index d444c33f415..ffa13aeac25 100644 --- a/packages/shared/src/contexts/payment/context.ts +++ b/packages/shared/src/contexts/payment/context.ts @@ -2,15 +2,17 @@ import type { Dispatch, ReactNode, SetStateAction } from 'react'; import { createContext, useContext } from 'react'; import type { ProductPricingPreview, PurchaseType } from '../../graphql/paddle'; -export interface OpenCheckoutProps { +export interface OpenCheckoutProps> { priceId: string; giftToUserId?: string; - customData?: Record; + customData?: TCustomData; discountId?: string; quantity?: number; } -export type OpenCheckoutFn = (props: OpenCheckoutProps) => void; +export type OpenCheckoutFn> = ( + props: OpenCheckoutProps, +) => void; export interface PaymentContextData { openCheckout?: OpenCheckoutFn; diff --git a/packages/shared/src/features/opportunity/graphql.ts b/packages/shared/src/features/opportunity/graphql.ts index 3686bf692c2..1ab9b8c08e9 100644 --- a/packages/shared/src/features/opportunity/graphql.ts +++ b/packages/shared/src/features/opportunity/graphql.ts @@ -1,5 +1,8 @@ import { gql } from 'graphql-request'; import { ORGANIZATION_SHORT_FRAGMENT } from '../organizations/graphql'; +import { generateQueryKey, RequestKey, StaleTime } from '../../lib/query'; +import type { LoggedUser } from '../../lib/user'; +import { fetchPricingPreview, PurchaseType } from '../../graphql/paddle'; export const OPPORTUNITY_CONTENT_FRAGMENT = gql` fragment OpportunityContentFragment on OpportunityContentBlock { @@ -125,6 +128,7 @@ export const OPPORTUNITY_FRAGMENT = gql` feedbackQuestions { ...OpportunityFeedbackQuestionFragment } + subscriptionStatus } ${ORGANIZATION_SHORT_FRAGMENT} ${OPPORTUNITY_CONTENT_FRAGMENT} @@ -603,3 +607,24 @@ export const OPPORTUNITY_STATS_QUERY = gql` } } `; + +export const recruiterPricesQueryOptions = ({ + isLoggedIn, + user, +}: { + isLoggedIn: boolean; + user: LoggedUser; +}) => { + return { + queryKey: generateQueryKey( + RequestKey.PricePreview, + user, + PurchaseType.Recruiter, + ), + queryFn: async () => { + return fetchPricingPreview(PurchaseType.Recruiter); + }, + enabled: isLoggedIn, + staleTime: StaleTime.Default, + }; +}; diff --git a/packages/shared/src/features/opportunity/mockData.ts b/packages/shared/src/features/opportunity/mockData.ts index 680bdbfc95a..758b6d447bd 100644 --- a/packages/shared/src/features/opportunity/mockData.ts +++ b/packages/shared/src/features/opportunity/mockData.ts @@ -2,6 +2,7 @@ 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', @@ -61,6 +62,7 @@ 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 b9e1a08ea71..846c409684c 100644 --- a/packages/shared/src/features/opportunity/types.ts +++ b/packages/shared/src/features/opportunity/types.ts @@ -12,6 +12,7 @@ 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', @@ -103,6 +104,7 @@ export type Opportunity = { keywords?: Keyword[]; questions?: OpportunityScreeningQuestion[]; feedbackQuestions?: OpportunityFeedbackQuestion[]; + subscriptionStatus: SubscriptionStatus; }; export type OpportunityMatchDescription = { diff --git a/packages/shared/src/graphql/paddle.ts b/packages/shared/src/graphql/paddle.ts index bd790010a58..f9212f07c3f 100644 --- a/packages/shared/src/graphql/paddle.ts +++ b/packages/shared/src/graphql/paddle.ts @@ -48,6 +48,7 @@ export enum PurchaseType { Plus = 'plus', Organization = 'organization', Cores = 'cores', + Recruiter = 'recruiter', } export enum PlusPlanType { diff --git a/packages/shared/src/hooks/payment/useStoreKitPayment.ts b/packages/shared/src/hooks/payment/useStoreKitPayment.ts index 5f1f61f6d67..8aa89e246a2 100644 --- a/packages/shared/src/hooks/payment/useStoreKitPayment.ts +++ b/packages/shared/src/hooks/payment/useStoreKitPayment.ts @@ -22,6 +22,7 @@ const typeToHandler: Record = { cores: WebKitMessageHandlers.IAPCoresPurchase, plus: WebKitMessageHandlers.IAPSubscriptionRequest, organization: WebKitMessageHandlers.IAPSubscriptionRequest, + recruiter: WebKitMessageHandlers.IAPSubscriptionRequest, // not used for now on apple side }; export const useStoreKitPayment = ({ diff --git a/packages/shared/src/hooks/usePaddlePayment.ts b/packages/shared/src/hooks/usePaddlePayment.ts index a19aa3df39c..56192c4e908 100644 --- a/packages/shared/src/hooks/usePaddlePayment.ts +++ b/packages/shared/src/hooks/usePaddlePayment.ts @@ -150,7 +150,7 @@ export const usePaddlePayment = ({ target_type: targetType, event_name: LogEvent.WarningCheckout, extra: JSON.stringify({ - transaction_id: event?.data.transaction_id, + transaction_id: event?.data?.transaction_id, }), }); break; @@ -159,7 +159,7 @@ export const usePaddlePayment = ({ target_type: targetType, event_name: LogEvent.ErrorCheckout, extra: JSON.stringify({ - transaction_id: event?.data.transaction_id, + transaction_id: event?.data?.transaction_id, }), }); break; @@ -168,7 +168,7 @@ export const usePaddlePayment = ({ target_type: targetType, event_name: LogEvent.ErrorPayment, extra: JSON.stringify({ - transaction_id: event?.data.transaction_id, + transaction_id: event?.data?.transaction_id, }), }); break; @@ -201,12 +201,13 @@ export const usePaddlePayment = ({ }, [router, disabledEvents, targetType, isOrganization, isPlusPlan]); const openCheckout = useCallback( - ({ + ({ priceId, giftToUserId, discountId, quantity = 1, - }: OpenCheckoutProps) => { + customData: customDataProp, + }: OpenCheckoutProps) => { const items: CheckoutLineItem[] = [{ priceId, quantity }]; const customer: CheckoutCustomer = { ...(user?.email @@ -222,6 +223,7 @@ export const usePaddlePayment = ({ }; const customData = { + ...customDataProp, user_id: giftToUserId ?? user?.id, tracking_id: trackingId, ...(!!giftToUserId && { gifter_id: user?.id }), diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index 13e8d3dc813..dc539710d29 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -391,6 +391,7 @@ export enum TargetType { // CV CvBanner = 'cv banner', Post = 'post', + Recruiter = 'recruiter', } export enum TargetId { @@ -501,4 +502,5 @@ export const purchaseTypeToTargetType: Record = { cores: TargetType.Credits, plus: TargetType.Plus, organization: TargetType.Plus, + recruiter: TargetType.Recruiter, }; diff --git a/packages/shared/src/lib/plus.ts b/packages/shared/src/lib/plus.ts index 89d9195f6b7..640df0a7ad8 100644 --- a/packages/shared/src/lib/plus.ts +++ b/packages/shared/src/lib/plus.ts @@ -7,4 +7,5 @@ export enum SubscriptionStatus { Active = 'active', Expired = 'expired', Cancelled = 'cancelled', + None = 'none', } diff --git a/packages/webapp/pages/recruiter/[opportunityId]/analyze.tsx b/packages/webapp/pages/recruiter/[opportunityId]/analyze.tsx index aa0b576f728..4d3b272eea5 100644 --- a/packages/webapp/pages/recruiter/[opportunityId]/analyze.tsx +++ b/packages/webapp/pages/recruiter/[opportunityId]/analyze.tsx @@ -6,18 +6,22 @@ import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; import { useLazyModal } from '@dailydotdev/shared/src/hooks/useLazyModal'; import { LazyModal } from '@dailydotdev/shared/src/components/modals/common/types'; import { useRouter } from 'next/router'; -import { OpportunityPreviewProvider } from '@dailydotdev/shared/src/features/opportunity/context/OpportunityPreviewContext'; +import { + OpportunityPreviewProvider, + useOpportunityPreviewContext, +} from '@dailydotdev/shared/src/features/opportunity/context/OpportunityPreviewContext'; import { ContentSidebar } from '@dailydotdev/shared/src/features/opportunity/components/analyze/ContentSidebar'; import { UserTableWrapper } from '@dailydotdev/shared/src/features/opportunity/components/analyze/UserTableWrapper'; import { webappUrl } from '@dailydotdev/shared/src/lib/constants'; +import { SubscriptionStatus } from '@dailydotdev/shared/src/lib/plus'; import { getLayout } from '../../../components/layouts/RecruiterSelfServeLayout'; const RecruiterPageContent = () => { const { user } = useAuthContext(); const { openModal } = useLazyModal(); const router = useRouter(); - const { opportunityId } = router.query; const [loadingStep, setLoadingStep] = useState(0); + const { opportunity } = useOpportunityPreviewContext(); useEffect(() => { // Always run the full loading animation sequence @@ -32,14 +36,24 @@ const RecruiterPageContent = () => { }, []); const handlePrepareCampaignClick = useCallback(() => { + if (!opportunity) { + return; + } + + if (opportunity.subscriptionStatus !== SubscriptionStatus.Active) { + router.push(`${webappUrl}recruiter/${opportunity.id}/payment`); + + return; + } + if (!user) { openModal({ type: LazyModal.RecruiterSignIn, }); } else { - router.push(`${webappUrl}recruiter/${opportunityId}/prepare`); + router.push(`${webappUrl}recruiter/${opportunity.id}/prepare`); } - }, [user, openModal, router, opportunityId]); + }, [user, openModal, router, opportunity]); return (
diff --git a/packages/webapp/pages/recruiter/payment.tsx b/packages/webapp/pages/recruiter/[opportunityId]/payment.tsx similarity index 85% rename from packages/webapp/pages/recruiter/payment.tsx rename to packages/webapp/pages/recruiter/[opportunityId]/payment.tsx index 0372a44c417..a407b23f8d9 100644 --- a/packages/webapp/pages/recruiter/payment.tsx +++ b/packages/webapp/pages/recruiter/[opportunityId]/payment.tsx @@ -1,8 +1,7 @@ import type { ReactElement, ReactNode } from 'react'; import React, { useEffect, useRef } from 'react'; import { useRouter } from 'next/router'; -import { usePaymentContext } from '@dailydotdev/shared/src/contexts/payment/context'; -import { PaymentContextProvider } from '@dailydotdev/shared/src/contexts/payment'; +import { RecruiterPaymentContext } from '@dailydotdev/shared/src/contexts/RecruiterPaymentContext/RecruiterPaymentContext'; import HeaderLogo from '@dailydotdev/shared/src/components/layout/HeaderLogo'; import { MoveToIcon } from '@dailydotdev/shared/src/components/icons'; import { @@ -16,23 +15,32 @@ import { TypographyType, } from '@dailydotdev/shared/src/components/typography/Typography'; import { FlexCol } from '@dailydotdev/shared/src/components/utilities'; +import { useRecruiterPaymentContext } from '@dailydotdev/shared/src/contexts/RecruiterPaymentContext/types'; +import { + OpportunityPreviewProvider, + useOpportunityPreviewContext, +} from '@dailydotdev/shared/src/features/opportunity/context/OpportunityPreviewContext'; const RecruiterPaymentPage = (): ReactElement => { const router = useRouter(); const checkoutRef = useRef(null); - const { isPaddleReady, openCheckout } = usePaymentContext(); + const { openCheckout, selectedProduct } = useRecruiterPaymentContext(); + const { opportunity } = useOpportunityPreviewContext(); useEffect(() => { - if (!isPaddleReady) { + if (!opportunity) { + return; + } + + if (!selectedProduct) { return; } - // Initialize Paddle checkout with recruiter pricing - // TODO: Replace with actual price ID for recruiter subscription openCheckout({ - priceId: 'your-recruiter-price-id', + priceId: selectedProduct.id, + customData: { opportunity_id: opportunity.id }, }); - }, [isPaddleReady, openCheckout]); + }, [selectedProduct, openCheckout, opportunity]); const handleBack = () => { router.back(); @@ -158,7 +166,11 @@ const RecruiterPaymentPage = (): ReactElement => { RecruiterPaymentPage.getLayout = function getLayout( page: ReactNode, ): ReactNode { - return {page}; + return ( + + {page} + + ); }; export default RecruiterPaymentPage;