Skip to content
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
Original file line number Diff line number Diff line change
@@ -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 (
<RecruiterPaymentPaddleContextProvider {...props}>
{children}
</RecruiterPaymentPaddleContextProvider>
);
};
Original file line number Diff line number Diff line change
@@ -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<RecruiterProductOption>();
const logRef = useRef<typeof logEvent>();
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,
Comment thread
rebelchris marked this conversation as resolved.
});

const contextData = useMemo<RecruiterPaymentContextData>(
() => ({
paddle,
onCompletion,
selectedProduct,
setSelectedProduct,
openCheckout,
origin,
}),
[onCompletion, openCheckout, origin, paddle, selectedProduct],
);

return (
<RecruiterPaymentContext.Provider value={contextData}>
{children}
</RecruiterPaymentContext.Provider>
);
};
37 changes: 37 additions & 0 deletions packages/shared/src/contexts/RecruiterPaymentContext/types.ts
Original file line number Diff line number Diff line change
@@ -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<RecruiterPaymentContextData>(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;
};
8 changes: 5 additions & 3 deletions packages/shared/src/contexts/payment/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TCustomData = Record<string, unknown>> {
priceId: string;
giftToUserId?: string;
customData?: Record<string, unknown>;
customData?: TCustomData;
discountId?: string;
quantity?: number;
}

export type OpenCheckoutFn = (props: OpenCheckoutProps) => void;
export type OpenCheckoutFn<TCustomData = Record<string, unknown>> = (
props: OpenCheckoutProps<TCustomData>,
) => void;

export interface PaymentContextData {
openCheckout?: OpenCheckoutFn;
Expand Down
25 changes: 25 additions & 0 deletions packages/shared/src/features/opportunity/graphql.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -125,6 +128,7 @@ export const OPPORTUNITY_FRAGMENT = gql`
feedbackQuestions {
...OpportunityFeedbackQuestionFragment
}
subscriptionStatus
}
${ORGANIZATION_SHORT_FRAGMENT}
${OPPORTUNITY_CONTENT_FRAGMENT}
Expand Down Expand Up @@ -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,
};
};
2 changes: 2 additions & 0 deletions packages/shared/src/features/opportunity/mockData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -61,6 +62,7 @@ export const mockOpportunity: Opportunity = {
{ keyword: 'JavaScript' },
{ keyword: 'Tailwind CSS' },
],
subscriptionStatus: SubscriptionStatus.None,
};

export const mockAnonymousUserTableData: OpportunityPreviewContextType = {
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/features/opportunity/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -103,6 +104,7 @@ export type Opportunity = {
keywords?: Keyword[];
questions?: OpportunityScreeningQuestion[];
feedbackQuestions?: OpportunityFeedbackQuestion[];
subscriptionStatus: SubscriptionStatus;
};

export type OpportunityMatchDescription = {
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/graphql/paddle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export enum PurchaseType {
Plus = 'plus',
Organization = 'organization',
Cores = 'cores',
Recruiter = 'recruiter',
}

export enum PlusPlanType {
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/hooks/payment/useStoreKitPayment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const typeToHandler: Record<PurchaseType, WebKitMessageHandlers> = {
cores: WebKitMessageHandlers.IAPCoresPurchase,
plus: WebKitMessageHandlers.IAPSubscriptionRequest,
organization: WebKitMessageHandlers.IAPSubscriptionRequest,
recruiter: WebKitMessageHandlers.IAPSubscriptionRequest, // not used for now on apple side
};

export const useStoreKitPayment = ({
Expand Down
12 changes: 7 additions & 5 deletions packages/shared/src/hooks/usePaddlePayment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -201,12 +201,13 @@ export const usePaddlePayment = ({
}, [router, disabledEvents, targetType, isOrganization, isPlusPlan]);

const openCheckout = useCallback(
({
<TCustomData>({
priceId,
giftToUserId,
discountId,
quantity = 1,
}: OpenCheckoutProps) => {
customData: customDataProp,
}: OpenCheckoutProps<TCustomData>) => {
const items: CheckoutLineItem[] = [{ priceId, quantity }];
const customer: CheckoutCustomer = {
...(user?.email
Expand All @@ -222,6 +223,7 @@ export const usePaddlePayment = ({
};

const customData = {
...customDataProp,
user_id: giftToUserId ?? user?.id,
tracking_id: trackingId,
...(!!giftToUserId && { gifter_id: user?.id }),
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/lib/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ export enum TargetType {
// CV
CvBanner = 'cv banner',
Post = 'post',
Recruiter = 'recruiter',
}

export enum TargetId {
Expand Down Expand Up @@ -501,4 +502,5 @@ export const purchaseTypeToTargetType: Record<PurchaseType, TargetType> = {
cores: TargetType.Credits,
plus: TargetType.Plus,
organization: TargetType.Plus,
recruiter: TargetType.Recruiter,
};
1 change: 1 addition & 0 deletions packages/shared/src/lib/plus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export enum SubscriptionStatus {
Active = 'active',
Expired = 'expired',
Cancelled = 'cancelled',
None = 'none',
}
22 changes: 18 additions & 4 deletions packages/webapp/pages/recruiter/[opportunityId]/analyze.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
<div className="flex flex-1 flex-col">
Expand Down
Loading