From 2118f881f651cd8e381f8c4156f52f358835d9f8 Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Thu, 30 Nov 2023 19:52:33 +0530 Subject: [PATCH] feat: Time to complete Metadata (#1416) Co-authored-by: Johannes Co-authored-by: Matthias Nannt --- .../summary/components/FileUploadSummary.tsx | 2 +- .../summary/components/SummaryDropOffs.tsx | 94 ++++++++++++++++--- .../summary/components/SummaryMetadata.tsx | 57 +++++++++-- apps/web/app/lib/formbricks.ts | 4 +- .../s/[surveyId]/components/LinkSurvey.tsx | 1 + .../responses/[responseId]/index.ts | 1 + .../[environmentId]/responses/index.ts | 1 + packages/api/src/api/client/response.ts | 2 + .../migration.sql | 2 + packages/database/schema.prisma | 3 + packages/database/zod-utils.ts | 7 +- packages/js/src/lib/widget.ts | 1 + packages/lib/response/service.ts | 16 +++- packages/lib/response/util.ts | 9 +- packages/lib/surveyState.ts | 5 +- .../general/QuestionConditional.tsx | 26 ++++- .../surveys/src/components/general/Survey.tsx | 12 ++- .../src/components/general/WelcomeCard.tsx | 5 +- .../src/components/questions/CTAQuestion.tsx | 32 ++++++- .../components/questions/ConsentQuestion.tsx | 31 +++++- .../questions/FileUploadQuestion.tsx | 22 ++++- .../questions/MultipleChoiceMultiQuestion.tsx | 27 +++++- .../MultipleChoiceSingleQuestion.tsx | 31 ++++-- .../src/components/questions/NPSQuestion.tsx | 35 +++++-- .../components/questions/OpenTextQuestion.tsx | 23 ++++- .../questions/PictureSelectionQuestion.tsx | 28 ++++-- .../components/questions/RatingQuestion.tsx | 31 ++++-- packages/surveys/src/lib/ttc.ts | 49 ++++++++++ packages/types/responses.ts | 8 ++ 29 files changed, 478 insertions(+), 87 deletions(-) create mode 100644 packages/database/migrations/20231129143833_add_ttc_to_response/migration.sql create mode 100644 packages/surveys/src/lib/ttc.ts diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx index a64bd661ffd5..4c9d8c32e413 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx @@ -78,7 +78,7 @@ export default function FileUploadSummary({ questionSummary, environmentId }: Fi {Array.isArray(response.value) && (response.value.length > 0 ? ( response.value.map((fileUrl, index) => ( -
+
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.tsx index 45a76bc78906..7d0388a7fa3c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs.tsx @@ -1,7 +1,9 @@ import { evaluateCondition } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/evaluateLogic"; import { TResponse } from "@formbricks/types/responses"; import { TSurvey } from "@formbricks/types/surveys"; -import { useMemo } from "react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip"; +import { TimerIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; interface SummaryDropOffsProps { survey: TSurvey; @@ -10,12 +12,45 @@ interface SummaryDropOffsProps { } export default function SummaryDropOffs({ responses, survey, displayCount }: SummaryDropOffsProps) { - const getDropoff = () => { + const initialAvgTtc = useMemo( + () => + survey.questions.reduce((acc, question) => { + acc[question.id] = 0; + return acc; + }, {}), + [survey.questions] + ); + + const [avgTtc, setAvgTtc] = useState(initialAvgTtc); + + interface DropoffMetricsType { + dropoffCount: number[]; + viewsCount: number[]; + dropoffPercentage: number[]; + } + const [dropoffMetrics, setDropoffMetrics] = useState({ + dropoffCount: [], + viewsCount: [], + dropoffPercentage: [], + }); + + const calculateMetrics = useCallback(() => { + let totalTtc = { ...initialAvgTtc }; + let responseCounts = { ...initialAvgTtc }; + let dropoffArr = new Array(survey.questions.length).fill(0); let viewsArr = new Array(survey.questions.length).fill(0); let dropoffPercentageArr = new Array(survey.questions.length).fill(0); responses.forEach((response) => { + // Calculate total time-to-completion + Object.keys(avgTtc).forEach((questionId) => { + if (response.ttc && response.ttc[questionId]) { + totalTtc[questionId] += response.ttc[questionId]; + responseCounts[questionId]++; + } + }); + let currQuesIdx = 0; while (currQuesIdx < survey.questions.length) { @@ -84,6 +119,13 @@ export default function SummaryDropOffs({ responses, survey, displayCount }: Sum } }); + // Calculate the average time for each question + Object.keys(totalTtc).forEach((questionId) => { + totalTtc[questionId] = + responseCounts[questionId] > 0 ? totalTtc[questionId] / responseCounts[questionId] : 0; + }); + + // Calculate drop-off percentages dropoffPercentageArr[0] = (dropoffArr[0] / displayCount) * 100 || 0; for (let i = 1; i < survey.questions.length; i++) { if (viewsArr[i - 1] !== 0) { @@ -91,28 +133,54 @@ export default function SummaryDropOffs({ responses, survey, displayCount }: Sum } } - return [dropoffArr, viewsArr, dropoffPercentageArr]; - }; + return { + newAvgTtc: totalTtc, + dropoffCount: dropoffArr, + viewsCount: viewsArr, + dropoffPercentage: dropoffPercentageArr, + }; + }, [responses, survey.questions, displayCount, initialAvgTtc, avgTtc]); - const [dropoffCount, viewsCount, dropoffPercentage] = useMemo(() => getDropoff(), [responses]); + useEffect(() => { + const { newAvgTtc, dropoffCount, viewsCount, dropoffPercentage } = calculateMetrics(); + setAvgTtc(newAvgTtc); + setDropoffMetrics({ dropoffCount, viewsCount, dropoffPercentage }); + }, [responses]); return (
-
+
Questions
-
Views
-
Drop-off
+
+ + + + + + +

Average time to complete each question.

+
+
+
+
+
Views
+
Drop Offs
{survey.questions.map((question, i) => (
+ className="grid grid-cols-6 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
{question.headline}
-
{viewsCount[i]}
-
- {dropoffCount[i]} - ({Math.round(dropoffPercentage[i])}%) +
+ {avgTtc[question.id] !== undefined ? (avgTtc[question.id] / 1000).toFixed(2) + "s" : "N/A"} +
+
+ {dropoffMetrics.viewsCount[i]} +
+
+ {dropoffMetrics.dropoffCount[i]} + ({Math.round(dropoffMetrics.dropoffPercentage[i])}%)
))} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx index c515e2c1badd..a384e1843132 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx @@ -1,9 +1,10 @@ import { timeSinceConditionally } from "@formbricks/lib/time"; -import { Button } from "@formbricks/ui/Button"; import { TResponse } from "@formbricks/types/responses"; import { TSurvey } from "@formbricks/types/surveys"; +import { Button } from "@formbricks/ui/Button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip"; import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/solid"; +import { useMemo, useState } from "react"; interface SummaryMetadataProps { responses: TResponse[]; @@ -34,6 +35,21 @@ const StatCard = ({ label, percentage, value, tooltipText }) => ( ); +function formatTime(ttc, totalResponses) { + const seconds = ttc / (1000 * totalResponses); + let formattedValue; + + if (seconds >= 60) { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + formattedValue = `${minutes}m ${remainingSeconds.toFixed(2)}s`; + } else { + formattedValue = `${seconds.toFixed(2)}s`; + } + + return formattedValue; +} + export default function SummaryMetadata({ responses, survey, @@ -41,13 +57,30 @@ export default function SummaryMetadata({ setShowDropOffs, showDropOffs, }: SummaryMetadataProps) { - const completedResponses = responses.filter((r) => r.finished).length; + const completedResponsesCount = useMemo(() => responses.filter((r) => r.finished).length, [responses]); + const [validTtcResponsesCount, setValidResponsesCount] = useState(0); + + const ttc = useMemo(() => { + let validTtcResponsesCountAcc = 0; //stores the count of responses that contains a _total value + const ttc = responses.reduce((acc, response) => { + if (response.ttc._total) { + validTtcResponsesCountAcc++; + return acc + response.ttc._total; + } + return acc; + }, 0); + setValidResponsesCount(validTtcResponsesCountAcc); + return ttc; + }, [responses]); + + console.log(ttc); + const totalResponses = responses.length; return (
-
-
+
+

Displays

@@ -62,16 +95,24 @@ export default function SummaryMetadata({ /> - : completedResponses} + percentage={`${Math.round((completedResponsesCount / displayCount) * 100)}%`} + value={responses.length === 0 ? - : completedResponsesCount} tooltipText="People who completed the survey." /> - : totalResponses - completedResponses} + percentage={`${Math.round(((totalResponses - completedResponsesCount) / totalResponses) * 100)}%`} + value={responses.length === 0 ? - : totalResponses - completedResponsesCount} tooltipText="People who started but not completed the survey." /> + - : `${formatTime(ttc, validTtcResponsesCount)}` + } + tooltipText="Average time to complete the survey." + />

diff --git a/apps/web/app/lib/formbricks.ts b/apps/web/app/lib/formbricks.ts index f776e5f5d23e..27f96310c99f 100644 --- a/apps/web/app/lib/formbricks.ts +++ b/apps/web/app/lib/formbricks.ts @@ -3,6 +3,7 @@ import { env } from "@formbricks/lib/env.mjs"; export const formbricksEnabled = typeof env.NEXT_PUBLIC_FORMBRICKS_API_HOST && env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID; +const ttc = { onboarding: 0 }; export const createResponse = async ( surveyId: string, @@ -11,12 +12,12 @@ export const createResponse = async ( finished: boolean = false ): Promise => { const api = formbricks.getApi(); - return await api.client.response.create({ surveyId, userId, finished, data, + ttc, }); }; @@ -30,6 +31,7 @@ export const updateResponse = async ( responseId, finished, data, + ttc, }); }; diff --git a/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx b/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx index a06731e7b4a5..3afb3c97fa79 100644 --- a/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx +++ b/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx @@ -161,6 +161,7 @@ export default function LinkSurvey({ ...responseUpdate.data, ...hiddenFieldsRecord, }, + ttc: responseUpdate.ttc, finished: responseUpdate.finished, meta: { url: window.location.href, diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts index b05690ca5f50..02e2969dbf9a 100644 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts +++ b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/[responseId]/index.ts @@ -63,6 +63,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) surveyId: true, finished: true, data: true, + ttc: true, meta: true, personAttributes: true, singleUseId: true, diff --git a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts index a0602937fa7c..62df05f26d9e 100644 --- a/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts +++ b/apps/web/pages/api/v1/client/environments/[environmentId]/responses/index.ts @@ -107,6 +107,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse) surveyId: true, finished: true, data: true, + ttc: true, meta: true, personAttributes: true, singleUseId: true, diff --git a/packages/api/src/api/client/response.ts b/packages/api/src/api/client/response.ts index dd9265fa9f87..f6dc5fc61e83 100644 --- a/packages/api/src/api/client/response.ts +++ b/packages/api/src/api/client/response.ts @@ -24,10 +24,12 @@ export class ResponseAPI { responseId, finished, data, + ttc, }: TResponseUpdateInputWithResponseId): Promise> { return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/responses/${responseId}`, "PUT", { finished, data, + ttc, }); } } diff --git a/packages/database/migrations/20231129143833_add_ttc_to_response/migration.sql b/packages/database/migrations/20231129143833_add_ttc_to_response/migration.sql new file mode 100644 index 000000000000..1c5acee9dd11 --- /dev/null +++ b/packages/database/migrations/20231129143833_add_ttc_to_response/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Response" ADD COLUMN "ttc" JSONB NOT NULL DEFAULT '{}'; diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index 6f92affcd099..f5a90c9b81e1 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -118,6 +118,9 @@ model Response { /// @zod.custom(imports.ZResponseData) /// [ResponseData] data Json @default("{}") + /// @zod.custom(imports.ZResponseTtc) + /// [ResponseTtc] + ttc Json @default("{}") /// @zod.custom(imports.ZResponseMeta) /// [ResponseMeta] meta Json @default("{}") diff --git a/packages/database/zod-utils.ts b/packages/database/zod-utils.ts index f147db1cf449..61cdddb38687 100644 --- a/packages/database/zod-utils.ts +++ b/packages/database/zod-utils.ts @@ -4,7 +4,12 @@ export const ZActionProperties = z.record(z.string()); export { ZActionClassNoCodeConfig } from "@formbricks/types/actionClasses"; export { ZIntegrationConfig } from "@formbricks/types/integration"; -export { ZResponseData, ZResponsePersonAttributes, ZResponseMeta } from "@formbricks/types/responses"; +export { + ZResponseData, + ZResponsePersonAttributes, + ZResponseMeta, + ZResponseTtc, +} from "@formbricks/types/responses"; export { ZSurveyWelcomeCard, diff --git a/packages/js/src/lib/widget.ts b/packages/js/src/lib/widget.ts index 017d480c65c3..7734c0b2b620 100644 --- a/packages/js/src/lib/widget.ts +++ b/packages/js/src/lib/widget.ts @@ -128,6 +128,7 @@ export const renderWidget = (survey: TSurvey) => { responseQueue.updateSurveyState(surveyState); responseQueue.add({ data: responseUpdate.data, + ttc: responseUpdate.ttc, finished: responseUpdate.finished, }); }, diff --git a/packages/lib/response/service.ts b/packages/lib/response/service.ts index 71e154322da0..044efab00115 100644 --- a/packages/lib/response/service.ts +++ b/packages/lib/response/service.ts @@ -20,7 +20,7 @@ import { unstable_cache } from "next/cache"; import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants"; import { deleteDisplayByResponseId } from "../display/service"; import { createPerson, getPerson, getPersonByUserId, transformPrismaPerson } from "../person/service"; -import { formatResponseDateFields } from "../response/util"; +import { calculateTtcTotal, formatResponseDateFields } from "../response/util"; import { responseNoteCache } from "../responseNote/cache"; import { getResponseNotes } from "../responseNote/service"; import { captureTelemetry } from "../telemetry"; @@ -35,6 +35,7 @@ const responseSelection = { finished: true, data: true, meta: true, + ttc: true, personAttributes: true, singleUseId: true, person: { @@ -269,7 +270,14 @@ export const createResponseLegacy = async (responseInput: TResponseLegacyInput): if (responseInput.personId) { person = await getPerson(responseInput.personId); } - + const ttcTemp = responseInput.ttc; + const questionId = Object.keys(ttcTemp)[0]; + const ttc = responseInput.finished + ? { + ...ttcTemp, + _total: ttcTemp[questionId], // Add _total property with the same value + } + : ttcTemp; const responsePrisma = await prisma.response.create({ data: { survey: { @@ -279,6 +287,7 @@ export const createResponseLegacy = async (responseInput: TResponseLegacyInput): }, finished: responseInput.finished, data: responseInput.data, + ttc, ...(responseInput.personId && { person: { connect: { @@ -287,6 +296,7 @@ export const createResponseLegacy = async (responseInput: TResponseLegacyInput): }, personAttributes: person?.attributes, }), + ...(responseInput.meta && ({ meta: responseInput?.meta } as Prisma.JsonObject)), singleUseId: responseInput.singleUseId, }, @@ -502,6 +512,7 @@ export const updateResponse = async ( ...currentResponse.data, ...responseInput.data, }; + const ttc = responseInput.finished ? calculateTtcTotal(responseInput.ttc) : responseInput.ttc; const responsePrisma = await prisma.response.update({ where: { @@ -510,6 +521,7 @@ export const updateResponse = async ( data: { finished: responseInput.finished, data, + ttc, }, select: responseSelection, }); diff --git a/packages/lib/response/util.ts b/packages/lib/response/util.ts index 224a42ee82b5..59f3c08050dc 100644 --- a/packages/lib/response/util.ts +++ b/packages/lib/response/util.ts @@ -1,6 +1,6 @@ import "server-only"; -import { TResponseDates } from "@formbricks/types/responses"; +import { TResponseDates, TResponseTtc } from "@formbricks/types/responses"; export const formatResponseDateFields = (response: TResponseDates): TResponseDates => { if (typeof response.createdAt === "string") { @@ -24,3 +24,10 @@ export const formatResponseDateFields = (response: TResponseDates): TResponseDat return response; }; + +export function calculateTtcTotal(ttc: TResponseTtc) { + const result = { ...ttc }; + result._total = Object.values(result).reduce((acc: number, val: number) => acc + val, 0); + + return result; +} diff --git a/packages/lib/surveyState.ts b/packages/lib/surveyState.ts index 8a89fc5615ab..9afc44ddc7b8 100644 --- a/packages/lib/surveyState.ts +++ b/packages/lib/surveyState.ts @@ -5,7 +5,7 @@ export class SurveyState { displayId: string | null = null; userId: string | null = null; surveyId: string; - responseAcc: TResponseUpdate = { finished: false, data: {} }; + responseAcc: TResponseUpdate = { finished: false, data: {}, ttc: {} }; singleUseId: string | null; constructor( @@ -74,6 +74,7 @@ export class SurveyState { accumulateResponse(responseUpdate: TResponseUpdate) { this.responseAcc = { finished: responseUpdate.finished, + ttc: responseUpdate.ttc, data: { ...this.responseAcc.data, ...responseUpdate.data }, }; } @@ -90,7 +91,7 @@ export class SurveyState { */ clear() { this.responseId = null; - this.responseAcc = { finished: false, data: {} }; + this.responseAcc = { finished: false, data: {}, ttc: {} }; } } diff --git a/packages/surveys/src/components/general/QuestionConditional.tsx b/packages/surveys/src/components/general/QuestionConditional.tsx index 46392c84b7b9..533006ceb9b9 100644 --- a/packages/surveys/src/components/general/QuestionConditional.tsx +++ b/packages/surveys/src/components/general/QuestionConditional.tsx @@ -7,7 +7,7 @@ import NPSQuestion from "@/components/questions/NPSQuestion"; import OpenTextQuestion from "@/components/questions/OpenTextQuestion"; import PictureSelectionQuestion from "@/components/questions/PictureSelectionQuestion"; import RatingQuestion from "@/components/questions/RatingQuestion"; -import { TResponseData } from "@formbricks/types/responses"; +import { TResponseData, TResponseTtc } from "@formbricks/types/responses"; import { TUploadFileConfig } from "@formbricks/types/storage"; import { TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys"; @@ -15,12 +15,14 @@ interface QuestionConditionalProps { question: TSurveyQuestion; value: string | number | string[]; onChange: (responseData: TResponseData) => void; - onSubmit: (data: TResponseData) => void; + onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; onBack: () => void; onFileUpload: (file: File, config?: TUploadFileConfig) => Promise; isFirstQuestion: boolean; isLastQuestion: boolean; autoFocus?: boolean; + ttc: TResponseTtc; + setTtc: (ttc: TResponseTtc) => void; surveyId: string; } @@ -33,6 +35,8 @@ export default function QuestionConditional({ isFirstQuestion, isLastQuestion, autoFocus = true, + ttc, + setTtc, surveyId, onFileUpload, }: QuestionConditionalProps) { @@ -46,6 +50,8 @@ export default function QuestionConditional({ isFirstQuestion={isFirstQuestion} isLastQuestion={isLastQuestion} autoFocus={autoFocus} + ttc={ttc} + setTtc={setTtc} /> ) : question.type === TSurveyQuestionType.MultipleChoiceSingle ? ( ) : question.type === TSurveyQuestionType.MultipleChoiceMulti ? ( ) : question.type === TSurveyQuestionType.NPS ? ( ) : question.type === TSurveyQuestionType.CTA ? ( ) : question.type === TSurveyQuestionType.Rating ? ( ) : question.type === TSurveyQuestionType.Consent ? ( ) : question.type === TSurveyQuestionType.PictureSelection ? ( ) : question.type === TSurveyQuestionType.FileUpload ? ( ) : null; } diff --git a/packages/surveys/src/components/general/Survey.tsx b/packages/surveys/src/components/general/Survey.tsx index c669913788fe..c6c6f874ca9e 100644 --- a/packages/surveys/src/components/general/Survey.tsx +++ b/packages/surveys/src/components/general/Survey.tsx @@ -3,7 +3,7 @@ import { AutoCloseWrapper } from "@/components/wrappers/AutoCloseWrapper"; import { evaluateCondition } from "@/lib/logicEvaluator"; import { cn } from "@/lib/utils"; import { SurveyBaseProps } from "@/types/props"; -import type { TResponseData } from "@formbricks/types/responses"; +import type { TResponseData, TResponseTtc } from "@formbricks/types/responses"; import { useEffect, useRef, useState } from "preact/hooks"; import ProgressBar from "./ProgressBar"; import QuestionConditional from "./QuestionConditional"; @@ -32,6 +32,7 @@ export function Survey({ const currentQuestionIndex = survey.questions.findIndex((q) => q.id === questionId); const currentQuestion = survey.questions[currentQuestionIndex]; const contentRef = useRef(null); + const [ttc, setTtc] = useState({}); useEffect(() => { if (activeQuestionId === "hidden") return; @@ -53,7 +54,7 @@ export function Survey({ // call onDisplay when component is mounted onDisplay(); if (prefillResponseData) { - onSubmit(prefillResponseData, true); + onSubmit(prefillResponseData, {}, true); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -90,11 +91,12 @@ export function Survey({ setResponseData(updatedResponseData); }; - const onSubmit = (responseData: TResponseData, isFromPrefilling: Boolean = false) => { + const onSubmit = (responseData: TResponseData, ttc: TResponseTtc, isFromPrefilling: Boolean = false) => { + const questionId = Object.keys(responseData)[0]; setLoadingElement(true); const nextQuestionId = getNextQuestionId(responseData, isFromPrefilling); const finished = nextQuestionId === "end"; - onResponse({ data: responseData, finished }); + onResponse({ data: responseData, ttc, finished }); if (finished) { onFinished(); } @@ -154,6 +156,8 @@ export function Survey({ onChange={onChange} onSubmit={onSubmit} onBack={onBack} + ttc={ttc} + setTtc={setTtc} onFileUpload={onFileUpload} isFirstQuestion={ history && prefillResponseData diff --git a/packages/surveys/src/components/general/WelcomeCard.tsx b/packages/surveys/src/components/general/WelcomeCard.tsx index e0005259c720..8eb6f2638310 100644 --- a/packages/surveys/src/components/general/WelcomeCard.tsx +++ b/packages/surveys/src/components/general/WelcomeCard.tsx @@ -1,5 +1,6 @@ import SubmitButton from "@/components/buttons/SubmitButton"; import { calculateElementIdx } from "@/lib/utils"; +import { TResponseData, TResponseTtc } from "@formbricks/types/responses"; import { TSurvey } from "@formbricks/types/surveys"; import Headline from "./Headline"; import HtmlBody from "./HtmlBody"; @@ -10,7 +11,7 @@ interface WelcomeCardProps { fileUrl?: string; buttonLabel?: string; timeToFinish?: boolean; - onSubmit: (data: { [x: string]: any }) => void; + onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; survey: TSurvey; } @@ -85,7 +86,7 @@ export default function WelcomeCard({ isLastQuestion={false} focus={true} onClick={() => { - onSubmit({ ["welcomeCard"]: "clicked" }); + onSubmit({ ["welcomeCard"]: "clicked" }, {}); }} type="button" /> diff --git a/packages/surveys/src/components/questions/CTAQuestion.tsx b/packages/surveys/src/components/questions/CTAQuestion.tsx index f688cf4a3d29..f28ce043a72b 100644 --- a/packages/surveys/src/components/questions/CTAQuestion.tsx +++ b/packages/surveys/src/components/questions/CTAQuestion.tsx @@ -5,15 +5,19 @@ import Headline from "@/components/general/Headline"; import HtmlBody from "@/components/general/HtmlBody"; import { TResponseData } from "@formbricks/types/responses"; import type { TSurveyCTAQuestion } from "@formbricks/types/surveys"; - +import { useState } from "react"; +import { TResponseTtc } from "@formbricks/types/responses"; +import { getUpdatedTtc, useTtc } from "@/lib/ttc"; interface CTAQuestionProps { question: TSurveyCTAQuestion; value: string | number | string[]; onChange: (responseData: TResponseData) => void; - onSubmit: (data: TResponseData) => void; + onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; onBack: () => void; isFirstQuestion: boolean; isLastQuestion: boolean; + ttc: TResponseTtc; + setTtc: (ttc: TResponseTtc) => void; } export default function CTAQuestion({ @@ -22,7 +26,13 @@ export default function CTAQuestion({ onBack, isFirstQuestion, isLastQuestion, + ttc, + setTtc, }: CTAQuestionProps) { + const [startTime, setStartTime] = useState(performance.now()); + + useTtc(question.id, ttc, setTtc, startTime, setStartTime); + return (
{question.imageUrl && } @@ -31,7 +41,15 @@ export default function CTAQuestion({
{!isFirstQuestion && ( - onBack()} /> + { + const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); + setTtc(updatedTtcObj); + onSubmit({ [question.id]: "" }, updatedTtcObj); + onBack(); + }} + /> )}
{!question.required && ( @@ -39,7 +57,9 @@ export default function CTAQuestion({ tabIndex={0} type="button" onClick={() => { - onSubmit({ [question.id]: "dismissed" }); + const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); + setTtc(updatedTtcObj); + onSubmit({ [question.id]: "dismissed" }, updatedTtcObj); }} className="text-heading focus:ring-focus mr-4 flex items-center rounded-md px-3 py-3 text-base font-medium leading-4 hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2"> {question.dismissButtonLabel || "Skip"} @@ -53,7 +73,9 @@ export default function CTAQuestion({ if (question.buttonExternal && question.buttonUrl) { window?.open(question.buttonUrl, "_blank")?.focus(); } - onSubmit({ [question.id]: "clicked" }); + const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); + setTtc(updatedTtcObj); + onSubmit({ [question.id]: "clicked" }, updatedTtcObj); }} type="button" /> diff --git a/packages/surveys/src/components/questions/ConsentQuestion.tsx b/packages/surveys/src/components/questions/ConsentQuestion.tsx index bda1f3f821af..8d930b5dc4df 100644 --- a/packages/surveys/src/components/questions/ConsentQuestion.tsx +++ b/packages/surveys/src/components/questions/ConsentQuestion.tsx @@ -1,19 +1,23 @@ import { BackButton } from "@/components/buttons/BackButton"; import SubmitButton from "@/components/buttons/SubmitButton"; -import QuestionImage from "@/components/general/QuestionImage"; import Headline from "@/components/general/Headline"; import HtmlBody from "@/components/general/HtmlBody"; -import { TResponseData } from "@formbricks/types/responses"; +import QuestionImage from "@/components/general/QuestionImage"; +import { TResponseData, TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyConsentQuestion } from "@formbricks/types/surveys"; +import { useState } from "preact/hooks"; +import { getUpdatedTtc, useTtc } from "@/lib/ttc"; interface ConsentQuestionProps { question: TSurveyConsentQuestion; value: string | number | string[]; onChange: (responseData: TResponseData) => void; - onSubmit: (data: TResponseData) => void; + onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; onBack: () => void; isFirstQuestion: boolean; isLastQuestion: boolean; + ttc: TResponseTtc; + setTtc: (ttc: TResponseTtc) => void; } export default function ConsentQuestion({ @@ -24,7 +28,13 @@ export default function ConsentQuestion({ onBack, isFirstQuestion, isLastQuestion, + ttc, + setTtc, }: ConsentQuestionProps) { + const [startTime, setStartTime] = useState(performance.now()); + + useTtc(question.id, ttc, setTtc, startTime, setStartTime); + return (
{question.imageUrl && } @@ -34,7 +44,9 @@ export default function ConsentQuestion({
{ e.preventDefault(); - onSubmit({ [question.id]: value }); + const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); + setTtc(updatedTtcObj); + onSubmit({ [question.id]: value }, updatedTtcObj); }}>