From 9bbc54a33645e2708dcea616294fbca51f5cbc01 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 30 Jan 2025 17:14:46 +0100 Subject: [PATCH] feat: add access to prolific respondant --- .eslintrc.js | 4 ++ src/entities/activity/ui/ActivityCardItem.tsx | 2 +- .../useProlificCompletionCodeQuery.ts | 20 +++++++ .../useProlificIntegrationEnabledQuery.ts | 33 ++++++++++++ .../applet/model/hooks/useEntityComplete.ts | 53 ++++++++++++++++++- .../model/hooks/useSaveProlificParams.ts | 23 ++++++++ src/entities/applet/model/selectors.ts | 5 ++ src/entities/applet/model/slice.ts | 10 ++++ src/entities/applet/model/types.ts | 6 +++ src/features/PassSurvey/hooks/useAnswers.ts | 4 ++ .../hooks/useSubmitAnswersMutation.ts | 13 ++++- .../model/AnswersConstructService.ts | 7 +++ src/features/SaveAssessmentAndExit/index.tsx | 5 +- src/i18n/el/translation.json | 6 ++- src/i18n/en/translation.json | 6 ++- src/i18n/fr/translation.json | 6 ++- src/shared/api/hooks/useBaseMutation.ts | 2 +- src/shared/api/hooks/useBaseQuery.ts | 4 +- src/shared/api/services/index.ts | 1 + .../services/integrations/prolific.service.ts | 35 ++++++++++++ src/shared/api/types/activity.ts | 3 ++ src/shared/api/types/base.ts | 2 +- src/shared/constants/routes.ts | 9 ++-- src/shared/ui/Banners/SuccessBanner/index.tsx | 2 +- src/widgets/ActivityGroups/ui/index.tsx | 51 +++++++++++++++++- src/widgets/Survey/ui/PassingScreen.tsx | 12 ++++- src/widgets/Survey/ui/index.tsx | 2 +- 27 files changed, 305 insertions(+), 21 deletions(-) create mode 100644 src/entities/applet/api/integrations/useProlificCompletionCodeQuery.ts create mode 100644 src/entities/applet/api/integrations/useProlificIntegrationEnabledQuery.ts create mode 100644 src/entities/applet/model/hooks/useSaveProlificParams.ts create mode 100644 src/shared/api/services/integrations/prolific.service.ts diff --git a/.eslintrc.js b/.eslintrc.js index cff9bd2bd..3fb39a174 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -51,6 +51,10 @@ module.exports = { pattern: '@(~app|~shared|~features|~pages|~entities)/**', group: 'internal', }, + { + pattern: '~/lib/**', + group: 'internal', + }, ], pathGroupsExcludedImportTypes: ['internal', 'react'], 'newlines-between': 'always', diff --git a/src/entities/activity/ui/ActivityCardItem.tsx b/src/entities/activity/ui/ActivityCardItem.tsx index 8cdfbe533..5ba6ffa3e 100644 --- a/src/entities/activity/ui/ActivityCardItem.tsx +++ b/src/entities/activity/ui/ActivityCardItem.tsx @@ -1,8 +1,8 @@ import { useMemo } from 'react'; import { AdditionalTextResponse } from './AdditionalTextResponse'; -import { Answer, hasAdditionalResponse, requiresAdditionalResponse } from '../lib'; import { ItemPicker } from './items/ItemPicker'; +import { Answer, hasAdditionalResponse, requiresAdditionalResponse } from '../lib'; import { appletModel } from '~/entities/applet'; import { SliderAnimation } from '~/shared/animations'; diff --git a/src/entities/applet/api/integrations/useProlificCompletionCodeQuery.ts b/src/entities/applet/api/integrations/useProlificCompletionCodeQuery.ts new file mode 100644 index 000000000..92e1bf914 --- /dev/null +++ b/src/entities/applet/api/integrations/useProlificCompletionCodeQuery.ts @@ -0,0 +1,20 @@ +import { ProlificService, QueryOptions, ReturnAwaited, useBaseQuery } from '~/shared/api'; + +type FetchFn = typeof ProlificService.getStudyCompletionCodes; +type Options = QueryOptions; + +type Params = { + appletId: string; + studyId: string; +}; + +export const useProlificCompletionCodeQuery = >( + params: Params, + options?: Options, +) => { + return useBaseQuery( + ['prolificCompletionCode', params], + () => ProlificService.getStudyCompletionCodes(params.appletId, params.studyId), + options, + ); +}; diff --git a/src/entities/applet/api/integrations/useProlificIntegrationEnabledQuery.ts b/src/entities/applet/api/integrations/useProlificIntegrationEnabledQuery.ts new file mode 100644 index 000000000..789b26d0b --- /dev/null +++ b/src/entities/applet/api/integrations/useProlificIntegrationEnabledQuery.ts @@ -0,0 +1,33 @@ +import { ProlificService, QueryOptions, ReturnAwaited, useBaseQuery } from '~/shared/api'; + +type FetchFn = typeof ProlificService.isProlificIntergrationEnabled; +type Options = QueryOptions; + +type PublicParams = { + isPublic: true; + publicAppletKey: string; +}; + +type PrivateParams = { + isPublic: false; + appletId: string; +}; + +export const useProlificIntegrationStateQuery = >( + params: (PublicParams | PrivateParams) & { prolificStudyId: null | string }, + options?: Options, +) => { + return useBaseQuery( + ['prolificIntegrationEnabled', params], + () => { + if (params.isPublic) { + return ProlificService.isProlificIntergrationEnabled( + params.publicAppletKey, + params.prolificStudyId ?? undefined, + ); + } + return ProlificService.isProlificIntergrationEnabled(params.appletId); + }, + options, + ); +}; diff --git a/src/entities/applet/model/hooks/useEntityComplete.ts b/src/entities/applet/model/hooks/useEntityComplete.ts index 175ac6227..e717ce118 100644 --- a/src/entities/applet/model/hooks/useEntityComplete.ts +++ b/src/entities/applet/model/hooks/useEntityComplete.ts @@ -1,12 +1,18 @@ import { useCallback } from 'react'; +import { t } from 'i18next'; import type { NavigateOptions } from 'react-router/dist/lib/context'; +import { useUpdateProlificParams } from './useSaveProlificParams'; + import { ActivityPipelineType } from '~/abstract/lib'; import { appletModel } from '~/entities/applet'; +import { useProlificCompletionCodeQuery } from '~/entities/applet/api/integrations/useProlificCompletionCodeQuery'; +import { prolificParamsSelector } from '~/entities/applet/model/selectors'; +import { useBanners } from '~/entities/banner/model'; import { ActivityFlowDTO } from '~/shared/api'; import ROUTES from '~/shared/constants/routes'; -import { useCustomNavigation } from '~/shared/utils'; +import { useAppSelector, useCustomNavigation } from '~/shared/utils'; type CompletionType = 'regular' | 'autoCompletion'; @@ -36,6 +42,22 @@ export const useEntityComplete = (props: Props) => { const { entityCompleted, flowUpdated, getGroupProgress } = appletModel.hooks.useGroupProgressStateManager(); + const prolificParams = useAppSelector(prolificParamsSelector); + const { data: completionCodesReponse, isError: isCompletionCodesReponseError } = + useProlificCompletionCodeQuery( + { + appletId: props.appletId, + studyId: prolificParams?.studyId ?? '', + }, + { + enabled: !!prolificParams, + }, + ); + + const { addErrorBanner } = useBanners(); + + const { clearProlificParams } = useUpdateProlificParams(); + const completeEntityAndRedirect = useCallback( (completionType: CompletionType) => { entityCompleted({ @@ -50,6 +72,30 @@ export const useEntityComplete = (props: Props) => { return; } + if (prolificParams && props.publicAppletKey) { + if (!isCompletionCodesReponseError && completionCodesReponse) { + clearProlificParams(); // Resetting redux state after completion + + const { completionCodes } = completionCodesReponse.data; + for (const code of completionCodes) { + if (code.codeType === 'COMPLETED') { + window.location.replace( + `https://app.prolific.com/submissions/complete?cc=${code.code}`, + ); + return; + } + } + + addErrorBanner({ children: t('prolific.nocode'), duration: null }); + return navigator.navigate( + ROUTES.publicJoin.navigateTo(props.publicAppletKey, prolificParams), + { + replace: true, + }, + ); + } + } + if (props.publicAppletKey) { return navigator.navigate(ROUTES.publicJoin.navigateTo(props.publicAppletKey), { replace: true, @@ -70,6 +116,11 @@ export const useEntityComplete = (props: Props) => { entityCompleted, isInMultiInformantFlow, navigator, + prolificParams, + completionCodesReponse, + isCompletionCodesReponseError, + clearProlificParams, + addErrorBanner, props.activityId, props.appletId, props.eventId, diff --git a/src/entities/applet/model/hooks/useSaveProlificParams.ts b/src/entities/applet/model/hooks/useSaveProlificParams.ts new file mode 100644 index 000000000..b254ebcdb --- /dev/null +++ b/src/entities/applet/model/hooks/useSaveProlificParams.ts @@ -0,0 +1,23 @@ +import { useCallback } from 'react'; + +import { actions } from '../slice'; +import type { ProlificUrlParamsPayload } from '../types'; + +import { useAppDispatch } from '~/shared/utils'; + +export const useUpdateProlificParams = () => { + const dispatch = useAppDispatch(); + + const saveProlificParams = useCallback( + (payload: ProlificUrlParamsPayload) => { + dispatch(actions.saveProlificParams(payload)); + }, + [dispatch], + ); + + const clearProlificParams = useCallback(() => { + dispatch(actions.clearProlificParams()); + }, [dispatch]); + + return { saveProlificParams, clearProlificParams }; +}; diff --git a/src/entities/applet/model/selectors.ts b/src/entities/applet/model/selectors.ts index 6b6717ea2..f67382323 100644 --- a/src/entities/applet/model/selectors.ts +++ b/src/entities/applet/model/selectors.ts @@ -42,4 +42,9 @@ export const multiInformantStateSelector = createSelector( (state) => state.multiInformantState, ); +export const prolificParamsSelector = createSelector( + appletsSelector, + (state) => state.prolificParams, +); + export const selectConsents = createSelector(appletsSelector, (applets) => applets.consents); diff --git a/src/entities/applet/model/slice.ts b/src/entities/applet/model/slice.ts index d9acf721b..e3659db4c 100644 --- a/src/entities/applet/model/slice.ts +++ b/src/entities/applet/model/slice.ts @@ -22,6 +22,7 @@ import { UpdateUserEventByIndexPayload, RemoveGroupProgressPayload, SaveSummaryDataInContext, + ProlificUrlParamsPayload, } from './types'; import { @@ -41,6 +42,7 @@ type InitialState = { completions: CompletedEventEntities; multiInformantState: MultiInformantState; consents: ActivityConsents; + prolificParams?: ProlificUrlParamsPayload; }; const initialState: InitialState = { @@ -371,6 +373,14 @@ const appletsSlice = createSlice({ consents.shareMediaToPublic = !currentValue; }, + + saveProlificParams: (state, action: PayloadAction) => { + state.prolificParams = action.payload; + }, + + clearProlificParams: (state) => { + state.prolificParams = undefined; + }, }, }); diff --git a/src/entities/applet/model/types.ts b/src/entities/applet/model/types.ts index 9b2c7abff..d965374d6 100644 --- a/src/entities/applet/model/types.ts +++ b/src/entities/applet/model/types.ts @@ -212,6 +212,12 @@ export type UpdateUserEventByIndexPayload = { userEvent: UserEvent; }; +export type ProlificUrlParamsPayload = { + prolificPid: string; + studyId: string; + sessionId: string; +}; + export type SupportableActivities = Record; export type CompletedEntitiesState = Record; diff --git a/src/features/PassSurvey/hooks/useAnswers.ts b/src/features/PassSurvey/hooks/useAnswers.ts index 46cbb68f0..78c364d54 100644 --- a/src/features/PassSurvey/hooks/useAnswers.ts +++ b/src/features/PassSurvey/hooks/useAnswers.ts @@ -4,6 +4,7 @@ import { SurveyContext } from '../lib'; import AnswersConstructService from '../model/AnswersConstructService'; import { appletModel } from '~/entities/applet'; +import { ProlificUrlParamsPayload as ProlificUrlParams } from '~/entities/applet/model'; import { ActivityFlowDTO, AnswerPayload, EncryptionDTO, ScheduleEventDto } from '~/shared/api'; export type BuildAnswerParams = { @@ -19,6 +20,8 @@ export type BuildAnswerParams = { publicAppletKey?: string | null; encryption?: EncryptionDTO | null; flow?: ActivityFlowDTO | null; + + prolificParams?: ProlificUrlParams; }; export interface AnswerBuilder { @@ -61,6 +64,7 @@ export const useAnswerBuilder = (): AnswerBuilder => { encryption, publicAppletKey: params.publicAppletKey ?? context.publicAppletKey, isFlowCompleted: params.isFlowCompleted, + prolificParams: params.prolificParams, }); const answer = answerConstructService.construct(); diff --git a/src/features/PassSurvey/hooks/useSubmitAnswersMutation.ts b/src/features/PassSurvey/hooks/useSubmitAnswersMutation.ts index 896925dac..88fc5dc0e 100644 --- a/src/features/PassSurvey/hooks/useSubmitAnswersMutation.ts +++ b/src/features/PassSurvey/hooks/useSubmitAnswersMutation.ts @@ -1,6 +1,6 @@ import { useContext } from 'react'; -import { AxiosResponse } from 'axios'; +import { AxiosError, AxiosResponse } from 'axios'; import { SurveyContext } from '..'; @@ -20,9 +20,10 @@ import { type Props = { isPublic: boolean; onSubmitSuccess?: (variables: AnswerPayload) => void; + onSubmitError?: () => void; }; -export const useSubmitAnswersMutations = ({ isPublic, onSubmitSuccess }: Props) => { +export const useSubmitAnswersMutations = ({ isPublic, onSubmitSuccess, onSubmitError }: Props) => { const { isInMultiInformantFlow, getMultiInformantState, updateMultiInformantState } = appletModel.hooks.useMultiInformantState(); @@ -71,6 +72,14 @@ export const useSubmitAnswersMutations = ({ isPublic, onSubmitSuccess }: Props) mutateAsync: publicSubmitAsync, } = usePublicSaveAnswerMutation({ onSuccess, + onError: (error) => { + if (error instanceof AxiosError && error.response.status === 400) { + onSubmitError?.(); + return; + } + + throw error; + }, }); return { diff --git a/src/features/PassSurvey/model/AnswersConstructService.ts b/src/features/PassSurvey/model/AnswersConstructService.ts index 585d60cc3..50f0dc028 100644 --- a/src/features/PassSurvey/model/AnswersConstructService.ts +++ b/src/features/PassSurvey/model/AnswersConstructService.ts @@ -6,6 +6,7 @@ import { ItemAnswer, mapAlerts, mapToAnswers } from '../helpers'; import { ActivityPipelineType, GroupProgress } from '~/abstract/lib'; import { DefaultAnswer } from '~/entities/activity'; import { appletModel } from '~/entities/applet'; +import { ProlificUrlParamsPayload } from '~/entities/applet/model'; import { userModel } from '~/entities/user'; import { ActivityFlowDTO, @@ -48,6 +49,8 @@ type Input = { publicAppletKey: string | null; isFlowCompleted?: boolean; + + prolificParams?: ProlificUrlParamsPayload; }; export default class AnswersConstructService implements ICompletionConstructService { @@ -73,6 +76,8 @@ export default class AnswersConstructService implements ICompletionConstructServ private isFlowCompleted: boolean | undefined; // We can enforce this to be defined if needed + private prolificParams?: ProlificUrlParamsPayload; + constructor(input: Input) { this.activityId = input.activityId; this.event = input.event; @@ -85,6 +90,7 @@ export default class AnswersConstructService implements ICompletionConstructServ this.items = input.items; this.userEvents = input.userEvents; this.isFlowCompleted = input.isFlowCompleted; + this.prolificParams = input.prolificParams; } public construct(): AnswerPayload { @@ -116,6 +122,7 @@ export default class AnswersConstructService implements ICompletionConstructServ version: this.appletVersion, createdAt: new Date().getTime(), isFlowCompleted: isSurveyCompleted, + prolificParams: this.prolificParams, answer: { answer: encryptedAnswers, itemIds: answersDictionary.itemIds, diff --git a/src/features/SaveAssessmentAndExit/index.tsx b/src/features/SaveAssessmentAndExit/index.tsx index 8b4ad9205..7a31723be 100644 --- a/src/features/SaveAssessmentAndExit/index.tsx +++ b/src/features/SaveAssessmentAndExit/index.tsx @@ -4,12 +4,14 @@ import ButtonBase from '@mui/material/ButtonBase'; import { SurveyContext } from '../PassSurvey'; +import { prolificParamsSelector } from '~/entities/applet/model/selectors'; import { ROUTES, Theme } from '~/shared/constants'; import Text from '~/shared/ui/Text'; import { addSurveyPropsToEvent, Mixpanel, MixpanelEventType, + useAppSelector, useCustomNavigation, useCustomTranslation, } from '~/shared/utils'; @@ -24,6 +26,7 @@ export const SaveAndExitButton = ({ appletId, publicAppletKey }: Props) => { const { applet, activityId, flow } = useContext(SurveyContext); const navigator = useCustomNavigation(); + const prolificParams = useAppSelector(prolificParamsSelector); const onSaveAndExitClick = () => { Mixpanel.track( @@ -35,7 +38,7 @@ export const SaveAndExitButton = ({ appletId, publicAppletKey }: Props) => { return navigator.navigate( publicAppletKey - ? ROUTES.publicJoin.navigateTo(publicAppletKey) + ? ROUTES.publicJoin.navigateTo(publicAppletKey, prolificParams) : ROUTES.appletDetails.navigateTo(appletId), ); }; diff --git a/src/i18n/el/translation.json b/src/i18n/el/translation.json index b58364980..28af934bb 100644 --- a/src/i18n/el/translation.json +++ b/src/i18n/el/translation.json @@ -330,6 +330,10 @@ "privacy": "Απόρρητο", "termsOfService": "Όροι" }, - "noActivities": "Δεν υπάρχουν διαθέσιμες δραστηριότητες για να ολοκληρώσετε αυτήν τη στιγμή" + "noActivities": "Δεν υπάρχουν διαθέσιμες δραστηριότητες για να ολοκληρώσετε αυτήν τη στιγμή", + "prolific": { + "nocode": "Παρουσιάστηκε σφάλμα κατά την ανάκτηση του κωδικού ολοκλήρωσης. Παρακαλώ χρησιμοποιήστε την πολιτική NOCODE στο Prolific.", + "alreadyAnswered": "Έχετε ήδη απαντήσει σε αυτήν τη δραστηριότητα. Παρακαλώ επιστρέψτε στο Prolific και υποβάλετε τον κωδικό ολοκλήρωσής σας." + } } } diff --git a/src/i18n/en/translation.json b/src/i18n/en/translation.json index 253a9f871..22ee39022 100644 --- a/src/i18n/en/translation.json +++ b/src/i18n/en/translation.json @@ -337,6 +337,10 @@ "timeRemaining": "{{time}} remaining", "forItem": "for this item", "noApplets": "No applets", - "noActivities": "No activities are available for you to complete right now" + "noActivities": "No activities are available for you to complete right now", + "prolific": { + "nocode": "An error occurred while retrieving the completion code. Please use the NOCODE policy in Prolific.", + "alreadyAnswered": "You have already answered to this activity. Please return to Prolific and submit your completion code." + } } } diff --git a/src/i18n/fr/translation.json b/src/i18n/fr/translation.json index 801a25235..5573e6be4 100644 --- a/src/i18n/fr/translation.json +++ b/src/i18n/fr/translation.json @@ -356,6 +356,10 @@ "timeRemaining": "{{time}} restantes", "forItem": "pour cet objet", "noApplets": "Pas d'applets", - "noActivities": "Aucune activité n'est disponible pour le moment" + "noActivities": "Aucune activité n'est disponible pour le moment", + "prolific": { + "nocode": "Une erreur s'est produite lors de la récupération du code de validation. Veuillez utiliser la politique NOCODE sur Prolific.", + "alreadyAnswered": "Vous avez déjà répondu à cette activité. Veuillez retourner sur Prolific et soumettre votre code de validation." + } } } diff --git a/src/shared/api/hooks/useBaseMutation.ts b/src/shared/api/hooks/useBaseMutation.ts index 0cf216732..187db0d54 100644 --- a/src/shared/api/hooks/useBaseMutation.ts +++ b/src/shared/api/hooks/useBaseMutation.ts @@ -14,7 +14,7 @@ const useBaseMutation = ( return useMutation(key, mutationFn, { ...options, onError: (error: BaseError, variables: TRequest, context: unknown) => { - if (error?.response?.data?.result.length) { + if (error?.response?.data?.result && error.response?.data?.result.length > 0) { error.evaluatedMessage = error.response?.data?.result[0].message; } else { error.evaluatedMessage = error.message; diff --git a/src/shared/api/hooks/useBaseQuery.ts b/src/shared/api/hooks/useBaseQuery.ts index d48a89f09..f28072c97 100644 --- a/src/shared/api/hooks/useBaseQuery.ts +++ b/src/shared/api/hooks/useBaseQuery.ts @@ -12,9 +12,9 @@ const useBaseQuery = ( return useQuery(key, queryFn, { ...options, onError: (error: BaseError) => { - const errorRecords = error.response?.data?.result; + const errorRecords = error.response?.data?.result ?? []; - if (errorRecords?.length) { + if (errorRecords.length) { const firstRecord = errorRecords[0]; error.evaluatedMessage = firstRecord.message; diff --git a/src/shared/api/services/index.ts b/src/shared/api/services/index.ts index 72ecf878d..9f860b74b 100644 --- a/src/shared/api/services/index.ts +++ b/src/shared/api/services/index.ts @@ -9,3 +9,4 @@ export { default as subjectService } from './subject.service'; export { default as answerService } from './answer.service'; export { default as workspaceService } from './workspace.service'; export { default as assignmentService } from './assignment.service'; +export { default as ProlificService } from './integrations/prolific.service'; diff --git a/src/shared/api/services/integrations/prolific.service.ts b/src/shared/api/services/integrations/prolific.service.ts new file mode 100644 index 000000000..37b1ec075 --- /dev/null +++ b/src/shared/api/services/integrations/prolific.service.ts @@ -0,0 +1,35 @@ +import { axiosService } from '~/shared/api'; + +type ProlificIntegrationResponse = { + enabled: boolean; +}; + +type CompletionCodeAction = { + action: string; +}; + +type ProlificCompletionCode = { + code: string; + codeType: string; + actions: CompletionCodeAction[]; + actor: string; +}; + +type ProlificCompletionCodeList = { + completionCodes: ProlificCompletionCode[]; +}; + +const getProlificService = () => { + return { + isProlificIntergrationEnabled: (appletId?: string, prolificStudyId?: string) => + axiosService.get( + `/integrations/prolific/applet/${appletId}/study_id/${prolificStudyId}`, + ), + getStudyCompletionCodes: (appletId: string, studyId: string) => + axiosService.get( + `/integrations/prolific/applet/${appletId}/completion_codes/${studyId}`, + ), + }; +}; + +export default getProlificService(); diff --git a/src/shared/api/types/activity.ts b/src/shared/api/types/activity.ts index ec2443c51..e28211764 100644 --- a/src/shared/api/types/activity.ts +++ b/src/shared/api/types/activity.ts @@ -17,6 +17,8 @@ import { TimeRangeItemDTO, } from './item'; +import { ProlificUrlParamsPayload } from '~/entities/applet/model'; + export type ID = string; export interface GetCompletedEntitiesPayload { @@ -133,6 +135,7 @@ export type AnswerPayload = { }; sourceSubjectId?: ID | null; targetSubjectId?: ID | null; + prolificParams?: ProlificUrlParamsPayload; }; export type AlertDTO = { diff --git a/src/shared/api/types/base.ts b/src/shared/api/types/base.ts index cd00cdecf..c1f35d964 100644 --- a/src/shared/api/types/base.ts +++ b/src/shared/api/types/base.ts @@ -6,7 +6,7 @@ interface Result { export type BaseError = { message?: string; - response: { data: { result: Array } }; + response: { data: { result?: Array } }; evaluatedMessage?: string; }; diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts index dec08e833..5882238f7 100644 --- a/src/shared/constants/routes.ts +++ b/src/shared/constants/routes.ts @@ -1,3 +1,5 @@ +import { ProlificUrlParamsPayload } from '~/entities/applet/model'; + const ROUTES = { // Public routes login: { @@ -17,7 +19,8 @@ const ROUTES = { }, publicJoin: { path: '/public/:joinLinkKey', - navigateTo: (joinLinkKey: string) => `/public/${joinLinkKey}`, + navigateTo: (joinLinkKey: string, prolificParams?: ProlificUrlParamsPayload) => + `/public/${joinLinkKey}${prolificParams ? `?PROLIFIC_PID=${prolificParams.prolificPid}&STUDY_ID=${prolificParams.studyId}&SESSION_ID=${prolificParams.sessionId}` : ''}`, }, privateJoin: { path: '/join/:joinLinkKey', @@ -42,9 +45,7 @@ const ROUTES = { flowId: string | null; publicAppletKey: string; }) => - `/public/applets/${appletId}/activityId/${activityId}/event/${eventId}/entityType/${entityType}/publicAppletKey/${publicAppletKey}?${ - flowId ? `flowId=${flowId}` : '' - }`, + `/public/applets/${appletId}/activityId/${activityId}/event/${eventId}/entityType/${entityType}/publicAppletKey/${publicAppletKey}?${flowId ? `flowId=${flowId}` : ''}`, }, publicAutoCompletion: { path: '/public/auto-completion', diff --git a/src/shared/ui/Banners/SuccessBanner/index.tsx b/src/shared/ui/Banners/SuccessBanner/index.tsx index 20a986602..bb4e27cc4 100644 --- a/src/shared/ui/Banners/SuccessBanner/index.tsx +++ b/src/shared/ui/Banners/SuccessBanner/index.tsx @@ -1,5 +1,5 @@ -import { Banner, BannerProps } from '../Banner'; import { SUCCESS_BANNER_DURATION } from './lib/const'; +import { Banner, BannerProps } from '../Banner'; export const SuccessBanner = ({ children, ...props }: BannerProps) => { return ( diff --git a/src/widgets/ActivityGroups/ui/index.tsx b/src/widgets/ActivityGroups/ui/index.tsx index 4bb39c274..28c4bc237 100644 --- a/src/widgets/ActivityGroups/ui/index.tsx +++ b/src/widgets/ActivityGroups/ui/index.tsx @@ -1,12 +1,14 @@ import { useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { ActivityGroupList } from './ActivityGroupList'; import { AppletDetailsContext } from '../lib'; import { useTakeNowRedirect } from '../model/hooks/useTakeNowRedirect'; import { appletModel, useAppletBaseInfoByIdQuery } from '~/entities/applet'; +import { useProlificIntegrationStateQuery } from '~/entities/applet/api/integrations/useProlificIntegrationEnabledQuery'; +import { useUpdateProlificParams } from '~/entities/applet/model/hooks/useSaveProlificParams'; import { useMyAssignmentsQuery } from '~/entities/assignment'; import { useEventsbyAppletIdQuery } from '~/entities/event'; import { TakeNowSuccessModalProps } from '~/features/TakeNow/lib/types'; @@ -33,6 +35,11 @@ type PrivateAppletDetails = { type Props = PublicAppletDetails | PrivateAppletDetails; +type ProlificState = { + isError: boolean; + paramExists: boolean; +}; + export const ActivityGroups = (props: Props) => { const { appletId, isPublic, publicAppletKey, startActivityOrFlow } = props; const { t } = useCustomTranslation(); @@ -44,6 +51,11 @@ export const ActivityGroups = (props: Props) => { }); const { featureFlag } = useFeatureFlags(); const isAssignmentsEnabled = featureFlag(FeatureFlag.EnableActivityAssign, false) && !isPublic; + const [searchParams] = useSearchParams(); + const [prolificState, setProlificState] = useState({ + isError: false, + paramExists: false, + }); const { isError: isAppletError, @@ -75,6 +87,24 @@ export const ActivityGroups = (props: Props) => { ensureMultiInformantStateExists, } = appletModel.hooks.useMultiInformantState(); + const { prolificPid, studyId, sessionId } = { + prolificPid: searchParams.get('PROLIFIC_PID'), + studyId: searchParams.get('STUDY_ID'), + sessionId: searchParams.get('SESSION_ID'), + }; + + const isFromProlific = !!(prolificPid || studyId || sessionId); + const hasValidProlificParams = !!(prolificPid && studyId && sessionId); + + const { data: prolificIntegrationStateResponse } = useProlificIntegrationStateQuery( + { ...props, prolificStudyId: studyId }, + { + enabled: prolificState.paramExists, + }, + ); + + const { saveProlificParams, clearProlificParams } = useUpdateProlificParams(); + useOnceEffect(() => { ensureMultiInformantStateExists(); @@ -94,6 +124,17 @@ export const ActivityGroups = (props: Props) => { }); setTakeNowSuccessModalState({ isOpen: true, ...getMultiInformantState() }); } + + if (isFromProlific && hasValidProlificParams) { + saveProlificParams({ prolificPid, studyId, sessionId }); + } else { + clearProlificParams(); // Clear any prolificParams params from global state + } + + setProlificState({ + isError: !hasValidProlificParams, + paramExists: isFromProlific, + }); }); useTakeNowRedirect({ @@ -109,7 +150,13 @@ export const ActivityGroups = (props: Props) => { return ; } - if (isEventsError || isAppletError || (isAssignmentsEnabled && isAssignmentsError)) { + if ( + isEventsError || + isAppletError || + (isAssignmentsEnabled && isAssignmentsError) || + (prolificState.paramExists && + (!prolificIntegrationStateResponse?.data.enabled || prolificState.isError)) + ) { return ( {t('additional.invalid_public_url')} diff --git a/src/widgets/Survey/ui/PassingScreen.tsx b/src/widgets/Survey/ui/PassingScreen.tsx index 855f78417..3b541836a 100644 --- a/src/widgets/Survey/ui/PassingScreen.tsx +++ b/src/widgets/Survey/ui/PassingScreen.tsx @@ -6,6 +6,7 @@ import { useAutoForward, useSurveyState } from '../model/hooks'; import { ActivityPipelineType, FlowProgress, FlowSummaryData, getProgressId } from '~/abstract/lib'; import { ActivityCardItem, Answer, useTextVariablesReplacer } from '~/entities/activity'; import { appletModel } from '~/entities/applet'; +import { prolificParamsSelector } from '~/entities/applet/model/selectors'; import { useBanners } from '~/entities/banner/model'; import { AutoCompletionModel } from '~/features/AutoCompletion'; import { @@ -32,7 +33,7 @@ const PassingScreen = (props: Props) => { const [isSubmitModalOpen, openSubmitModal, closeSubmitModal] = useModal(); - const { addWarningBanner, addSuccessBanner, removeWarningBanner } = useBanners(); + const { addWarningBanner, addSuccessBanner, removeWarningBanner, addErrorBanner } = useBanners(); const context = useContext(SurveyContext); @@ -45,6 +46,8 @@ const PassingScreen = (props: Props) => { ), ); + const prolificParams = useAppSelector(prolificParamsSelector); + const groupProgress = appletModel.hooks.useGroupProgressRecord({ entityId: context.entityId, eventId: context.eventId, @@ -190,8 +193,14 @@ const PassingScreen = (props: Props) => { return completeActivity(); }; + const onSubmitError = () => { + closeSubmitModal(); + addErrorBanner(t('prolific.alreadyAnswered')); + }; + const { submitAnswers, isLoading } = useSubmitAnswersMutations({ onSubmitSuccess, + onSubmitError, isPublic: !!context.publicAppletKey, }); @@ -204,6 +213,7 @@ const PassingScreen = (props: Props) => { activityId: context.activityId, userEvents: activityProgress.userEvents.concat(doneEvent), items: activityProgress.items, + prolificParams, }); return submitAnswers(answer); diff --git a/src/widgets/Survey/ui/index.tsx b/src/widgets/Survey/ui/index.tsx index 599d8c49a..2d9ecabd7 100644 --- a/src/widgets/Survey/ui/index.tsx +++ b/src/widgets/Survey/ui/index.tsx @@ -95,7 +95,7 @@ export const SurveyWidget = (props: Props) => { publicAppletKey={publicAppletKey} appletId={appletId} errorLabel={ - publicAppletKey ? t('additional.invalid_public_url') : (error?.evaluatedMessage ?? '') + publicAppletKey ? t('additional.invalid_public_url') : error?.evaluatedMessage ?? '' } /> );