mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-28 20:49:34 -05:00
feat: Time to complete Metadata (#1416)
Co-authored-by: Johannes <johannes@formbricks.com> Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
committed by
GitHub
parent
05884ead56
commit
2118f881f6
+1
-1
@@ -78,7 +78,7 @@ export default function FileUploadSummary({ questionSummary, environmentId }: Fi
|
||||
{Array.isArray(response.value) &&
|
||||
(response.value.length > 0 ? (
|
||||
response.value.map((fileUrl, index) => (
|
||||
<div className="relative m-2 rounded-lg bg-slate-200">
|
||||
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
|
||||
<a href={fileUrl as string} key={index} download target="_blank">
|
||||
<div className="absolute right-0 top-0 m-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
|
||||
|
||||
+81
-13
@@ -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<DropoffMetricsType>({
|
||||
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 (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
|
||||
<div className="rounded-b-lg bg-white ">
|
||||
<div className="grid h-10 grid-cols-5 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||
<div className="grid h-10 grid-cols-6 items-center border-y border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
|
||||
<div className="col-span-3 pl-4 md:pl-6">Questions</div>
|
||||
<div className="pl-4 text-center md:pl-6">Views</div>
|
||||
<div className="px-4 text-center md:px-6">Drop-off</div>
|
||||
<div className="flex justify-center">
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<TimerIcon className="h-5 w-5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={"top"}>
|
||||
<p className="text-center font-normal">Average time to complete each question.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="px-4 text-center md:px-6">Views</div>
|
||||
<div className="pr-6 text-center md:pl-6">Drop Offs</div>
|
||||
</div>
|
||||
{survey.questions.map((question, i) => (
|
||||
<div
|
||||
key={question.id}
|
||||
className="grid grid-cols-5 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
||||
className="grid grid-cols-6 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
||||
<div className="col-span-3 pl-4 md:pl-6">{question.headline}</div>
|
||||
<div className="whitespace-pre-wrap pl-6 text-center font-semibold">{viewsCount[i]}</div>
|
||||
<div className="px-4 text-center md:px-6">
|
||||
<span className="font-semibold">{dropoffCount[i]} </span>
|
||||
<span>({Math.round(dropoffPercentage[i])}%)</span>
|
||||
<div className="whitespace-pre-wrap text-center font-semibold">
|
||||
{avgTtc[question.id] !== undefined ? (avgTtc[question.id] / 1000).toFixed(2) + "s" : "N/A"}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-center font-semibold">
|
||||
{dropoffMetrics.viewsCount[i]}
|
||||
</div>
|
||||
<div className=" pl-6 text-center md:px-6">
|
||||
<span className="font-semibold">{dropoffMetrics.dropoffCount[i]} </span>
|
||||
<span>({Math.round(dropoffMetrics.dropoffPercentage[i])}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
+49
-8
@@ -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 }) => (
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="mb-4">
|
||||
<div className="flex flex-col-reverse gap-y-2 lg:grid lg:grid-cols-2 lg:gap-x-2">
|
||||
<div className="grid grid-cols-2 gap-4 md:grid md:grid-cols-4 md:gap-x-2">
|
||||
<div className="flex flex-col-reverse gap-y-2 lg:grid lg:grid-cols-3 lg:gap-x-2">
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-5 md:gap-x-2 lg:col-span-2">
|
||||
<div className="flex flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<p className="text-sm text-slate-600">Displays</p>
|
||||
<p className="text-2xl font-bold text-slate-800">
|
||||
@@ -62,16 +95,24 @@ export default function SummaryMetadata({
|
||||
/>
|
||||
<StatCard
|
||||
label="Responses"
|
||||
percentage={`${Math.round((completedResponses / displayCount) * 100)}%`}
|
||||
value={responses.length === 0 ? <span>-</span> : completedResponses}
|
||||
percentage={`${Math.round((completedResponsesCount / displayCount) * 100)}%`}
|
||||
value={responses.length === 0 ? <span>-</span> : completedResponsesCount}
|
||||
tooltipText="People who completed the survey."
|
||||
/>
|
||||
<StatCard
|
||||
label="Drop Offs"
|
||||
percentage={`${Math.round(((totalResponses - completedResponses) / totalResponses) * 100)}%`}
|
||||
value={responses.length === 0 ? <span>-</span> : totalResponses - completedResponses}
|
||||
percentage={`${Math.round(((totalResponses - completedResponsesCount) / totalResponses) * 100)}%`}
|
||||
value={responses.length === 0 ? <span>-</span> : totalResponses - completedResponsesCount}
|
||||
tooltipText="People who started but not completed the survey."
|
||||
/>
|
||||
<StatCard
|
||||
label="Time to Complete"
|
||||
percentage={null}
|
||||
value={
|
||||
validTtcResponsesCount === 0 ? <span>-</span> : `${formatTime(ttc, validTtcResponsesCount)}`
|
||||
}
|
||||
tooltipText="Average time to complete the survey."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-between gap-2 lg:col-span-1">
|
||||
<div className="text-right text-xs text-slate-400">
|
||||
|
||||
@@ -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<any> => {
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -161,6 +161,7 @@ export default function LinkSurvey({
|
||||
...responseUpdate.data,
|
||||
...hiddenFieldsRecord,
|
||||
},
|
||||
ttc: responseUpdate.ttc,
|
||||
finished: responseUpdate.finished,
|
||||
meta: {
|
||||
url: window.location.href,
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,10 +24,12 @@ export class ResponseAPI {
|
||||
responseId,
|
||||
finished,
|
||||
data,
|
||||
ttc,
|
||||
}: TResponseUpdateInputWithResponseId): Promise<Result<{}, NetworkError | Error>> {
|
||||
return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/responses/${responseId}`, "PUT", {
|
||||
finished,
|
||||
data,
|
||||
ttc,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Response" ADD COLUMN "ttc" JSONB NOT NULL DEFAULT '{}';
|
||||
@@ -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("{}")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -128,6 +128,7 @@ export const renderWidget = (survey: TSurvey) => {
|
||||
responseQueue.updateSurveyState(surveyState);
|
||||
responseQueue.add({
|
||||
data: responseUpdate.data,
|
||||
ttc: responseUpdate.ttc,
|
||||
finished: responseUpdate.finished,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {} };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>;
|
||||
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 ? (
|
||||
<MultipleChoiceSingleQuestion
|
||||
@@ -56,6 +62,8 @@ export default function QuestionConditional({
|
||||
onBack={onBack}
|
||||
isFirstQuestion={isFirstQuestion}
|
||||
isLastQuestion={isLastQuestion}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.MultipleChoiceMulti ? (
|
||||
<MultipleChoiceMultiQuestion
|
||||
@@ -66,6 +74,8 @@ export default function QuestionConditional({
|
||||
onBack={onBack}
|
||||
isFirstQuestion={isFirstQuestion}
|
||||
isLastQuestion={isLastQuestion}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.NPS ? (
|
||||
<NPSQuestion
|
||||
@@ -76,6 +86,8 @@ export default function QuestionConditional({
|
||||
onBack={onBack}
|
||||
isFirstQuestion={isFirstQuestion}
|
||||
isLastQuestion={isLastQuestion}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.CTA ? (
|
||||
<CTAQuestion
|
||||
@@ -86,6 +98,8 @@ export default function QuestionConditional({
|
||||
onBack={onBack}
|
||||
isFirstQuestion={isFirstQuestion}
|
||||
isLastQuestion={isLastQuestion}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Rating ? (
|
||||
<RatingQuestion
|
||||
@@ -96,6 +110,8 @@ export default function QuestionConditional({
|
||||
onBack={onBack}
|
||||
isFirstQuestion={isFirstQuestion}
|
||||
isLastQuestion={isLastQuestion}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Consent ? (
|
||||
<ConsentQuestion
|
||||
@@ -106,6 +122,8 @@ export default function QuestionConditional({
|
||||
onBack={onBack}
|
||||
isFirstQuestion={isFirstQuestion}
|
||||
isLastQuestion={isLastQuestion}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.PictureSelection ? (
|
||||
<PictureSelectionQuestion
|
||||
@@ -116,6 +134,8 @@ export default function QuestionConditional({
|
||||
onBack={onBack}
|
||||
isFirstQuestion={isFirstQuestion}
|
||||
isLastQuestion={isLastQuestion}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.FileUpload ? (
|
||||
<FileUploadQuestion
|
||||
@@ -128,6 +148,8 @@ export default function QuestionConditional({
|
||||
isFirstQuestion={isFirstQuestion}
|
||||
isLastQuestion={isLastQuestion}
|
||||
onFileUpload={onFileUpload}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement | null>(null);
|
||||
const [ttc, setTtc] = useState<TResponseTtc>({});
|
||||
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
{question.imageUrl && <QuestionImage imgUrl={question.imageUrl} />}
|
||||
@@ -31,7 +41,15 @@ export default function CTAQuestion({
|
||||
|
||||
<div className="mt-4 flex w-full justify-between">
|
||||
{!isFirstQuestion && (
|
||||
<BackButton backButtonLabel={question.backButtonLabel} onClick={() => onBack()} />
|
||||
<BackButton
|
||||
backButtonLabel={question.backButtonLabel}
|
||||
onClick={() => {
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onSubmit({ [question.id]: "" }, updatedTtcObj);
|
||||
onBack();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-full justify-end">
|
||||
{!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"
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
{question.imageUrl && <QuestionImage imgUrl={question.imageUrl} />}
|
||||
@@ -34,7 +44,9 @@ export default function ConsentQuestion({
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit({ [question.id]: value });
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onSubmit({ [question.id]: value }, updatedTtcObj);
|
||||
}}>
|
||||
<label
|
||||
tabIndex={1}
|
||||
@@ -68,7 +80,16 @@ export default function ConsentQuestion({
|
||||
|
||||
<div className="mt-4 flex w-full justify-between">
|
||||
{!isFirstQuestion && (
|
||||
<BackButton tabIndex={3} backButtonLabel={question.backButtonLabel} onClick={() => onBack()} />
|
||||
<BackButton
|
||||
tabIndex={3}
|
||||
backButtonLabel={question.backButtonLabel}
|
||||
onClick={() => {
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onSubmit({ [question.id]: value }, updatedTtcObj);
|
||||
onBack();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div />
|
||||
<SubmitButton
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyFileUploadQuestion } from "@formbricks/types/surveys";
|
||||
import { BackButton } from "../buttons/BackButton";
|
||||
import SubmitButton from "../buttons/SubmitButton";
|
||||
@@ -6,17 +6,21 @@ import FileInput from "../general/FileInput";
|
||||
import Headline from "../general/Headline";
|
||||
import Subheader from "../general/Subheader";
|
||||
import { TUploadFileConfig } from "@formbricks/types/storage";
|
||||
import { useState } from "preact/hooks";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
|
||||
interface FileUploadQuestionProps {
|
||||
question: TSurveyFileUploadQuestion;
|
||||
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<string>;
|
||||
isFirstQuestion: boolean;
|
||||
isLastQuestion: boolean;
|
||||
surveyId: string;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
}
|
||||
|
||||
export default function FileUploadQuestion({
|
||||
@@ -29,22 +33,30 @@ export default function FileUploadQuestion({
|
||||
isLastQuestion,
|
||||
surveyId,
|
||||
onFileUpload,
|
||||
ttc,
|
||||
setTtc,
|
||||
}: FileUploadQuestionProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
if (question.required) {
|
||||
if (value && (typeof value === "string" || Array.isArray(value)) && value.length > 0) {
|
||||
onSubmit({ [question.id]: typeof value === "string" ? [value] : value });
|
||||
onSubmit({ [question.id]: typeof value === "string" ? [value] : value }, updatedTtcObj);
|
||||
} else {
|
||||
alert("Please upload a file");
|
||||
}
|
||||
} else {
|
||||
if (value) {
|
||||
onSubmit({ [question.id]: typeof value === "string" ? [value] : value });
|
||||
onSubmit({ [question.id]: typeof value === "string" ? [value] : value }, updatedTtcObj);
|
||||
} else {
|
||||
onSubmit({ [question.id]: "skipped" });
|
||||
onSubmit({ [question.id]: "skipped" }, updatedTtcObj);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -7,15 +7,18 @@ import { cn, shuffleQuestions } from "@/lib/utils";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import type { TSurveyMultipleChoiceMultiQuestion } from "@formbricks/types/surveys";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
|
||||
import { TResponseTtc } from "@formbricks/types/responses";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
interface MultipleChoiceMultiProps {
|
||||
question: TSurveyMultipleChoiceMultiQuestion;
|
||||
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 MultipleChoiceMultiQuestion({
|
||||
@@ -26,7 +29,13 @@ export default function MultipleChoiceMultiQuestion({
|
||||
onBack,
|
||||
isFirstQuestion,
|
||||
isLastQuestion,
|
||||
ttc,
|
||||
setTtc,
|
||||
}: MultipleChoiceMultiProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime);
|
||||
|
||||
const getChoicesWithoutOtherLabels = useCallback(
|
||||
() => question.choices.filter((choice) => choice.id !== "other").map((item) => item.label),
|
||||
[question]
|
||||
@@ -89,7 +98,9 @@ export default function MultipleChoiceMultiQuestion({
|
||||
return getChoicesWithoutOtherLabels().includes(item) || item === otherValue;
|
||||
}); // filter out all those values which are either in getChoicesWithoutOtherLabels() (i.e. selected by checkbox) or the latest entered otherValue
|
||||
onChange({ [question.id]: newValue });
|
||||
onSubmit({ [question.id]: newValue });
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onSubmit({ [question.id]: value }, updatedTtcObj);
|
||||
}}
|
||||
className="w-full">
|
||||
{question.imageUrl && <QuestionImage imgUrl={question.imageUrl} />}
|
||||
@@ -196,8 +207,10 @@ export default function MultipleChoiceMultiQuestion({
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") {
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
setTimeout(() => {
|
||||
onSubmit({ [question.id]: value });
|
||||
onSubmit({ [question.id]: value }, updatedTtcObj);
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
@@ -217,7 +230,11 @@ export default function MultipleChoiceMultiQuestion({
|
||||
<BackButton
|
||||
tabIndex={questionChoices.length + 3}
|
||||
backButtonLabel={question.backButtonLabel}
|
||||
onClick={onBack}
|
||||
onClick={() => {
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onBack();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div></div>
|
||||
|
||||
@@ -7,15 +7,18 @@ import { cn, shuffleQuestions } from "@/lib/utils";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import type { TSurveyMultipleChoiceSingleQuestion } from "@formbricks/types/surveys";
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
|
||||
import { TResponseTtc } from "@formbricks/types/responses";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
interface MultipleChoiceSingleProps {
|
||||
question: TSurveyMultipleChoiceSingleQuestion;
|
||||
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 MultipleChoiceSingleQuestion({
|
||||
@@ -26,7 +29,13 @@ export default function MultipleChoiceSingleQuestion({
|
||||
onBack,
|
||||
isFirstQuestion,
|
||||
isLastQuestion,
|
||||
ttc,
|
||||
setTtc,
|
||||
}: MultipleChoiceSingleProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime);
|
||||
|
||||
const [otherSelected, setOtherSelected] = useState(
|
||||
!!value && !question.choices.find((c) => c.label === value)
|
||||
); // initially set to true if value is not in choices
|
||||
@@ -58,7 +67,9 @@ export default function MultipleChoiceSingleQuestion({
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit({ [question.id]: value });
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onSubmit({ [question.id]: value }, updatedTtcObj);
|
||||
}}
|
||||
className="w-full">
|
||||
{question.imageUrl && <QuestionImage imgUrl={question.imageUrl} />}
|
||||
@@ -78,8 +89,10 @@ export default function MultipleChoiceSingleQuestion({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") {
|
||||
onChange({ [question.id]: choice.label });
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
setTimeout(() => {
|
||||
onSubmit({ [question.id]: choice.label });
|
||||
onSubmit({ [question.id]: choice.label }, updatedTtcObj);
|
||||
}, 350);
|
||||
}
|
||||
}}
|
||||
@@ -157,8 +170,10 @@ export default function MultipleChoiceSingleQuestion({
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") {
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
setTimeout(() => {
|
||||
onSubmit({ [question.id]: value });
|
||||
onSubmit({ [question.id]: value }, updatedTtcObj);
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
@@ -178,7 +193,11 @@ export default function MultipleChoiceSingleQuestion({
|
||||
<BackButton
|
||||
backButtonLabel={question.backButtonLabel}
|
||||
tabIndex={questionChoices.length + 3}
|
||||
onClick={onBack}
|
||||
onClick={() => {
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onBack();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div></div>
|
||||
|
||||
@@ -6,15 +6,19 @@ import Subheader from "@/components/general/Subheader";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import type { TSurveyNPSQuestion } from "@formbricks/types/surveys";
|
||||
|
||||
import { useState } from "preact/hooks";
|
||||
import { TResponseTtc } from "@formbricks/types/responses";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
interface NPSQuestionProps {
|
||||
question: TSurveyNPSQuestion;
|
||||
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 NPSQuestion({
|
||||
@@ -25,12 +29,20 @@ export default function NPSQuestion({
|
||||
onBack,
|
||||
isFirstQuestion,
|
||||
isLastQuestion,
|
||||
ttc,
|
||||
setTtc,
|
||||
}: NPSQuestionProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit({ [question.id]: value });
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onSubmit({ [question.id]: value }, updatedTtcObj);
|
||||
}}>
|
||||
{question.imageUrl && <QuestionImage imgUrl={question.imageUrl} />}
|
||||
<Headline headline={question.headline} questionId={question.id} required={question.required} />
|
||||
@@ -45,7 +57,9 @@ export default function NPSQuestion({
|
||||
tabIndex={idx + 1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") {
|
||||
onSubmit({ [question.id]: number });
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onSubmit({ [question.id]: number }, updatedTtcObj);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
@@ -60,9 +74,14 @@ export default function NPSQuestion({
|
||||
className="absolute h-full w-full cursor-pointer opacity-0"
|
||||
onClick={() => {
|
||||
if (question.required) {
|
||||
onSubmit({
|
||||
[question.id]: number,
|
||||
});
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onSubmit(
|
||||
{
|
||||
[question.id]: number,
|
||||
},
|
||||
updatedTtcObj
|
||||
);
|
||||
}
|
||||
onChange({ [question.id]: number });
|
||||
}}
|
||||
@@ -85,6 +104,8 @@ export default function NPSQuestion({
|
||||
tabIndex={isLastQuestion ? 12 : 13}
|
||||
backButtonLabel={question.backButtonLabel}
|
||||
onClick={() => {
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onBack();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -6,16 +6,21 @@ import Subheader from "@/components/general/Subheader";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import type { TSurveyOpenTextQuestion } from "@formbricks/types/surveys";
|
||||
import { useCallback } from "react";
|
||||
import { useState } from "preact/hooks";
|
||||
import { TResponseTtc } from "@formbricks/types/responses";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
|
||||
interface OpenTextQuestionProps {
|
||||
question: TSurveyOpenTextQuestion;
|
||||
value: string | number | string[];
|
||||
onChange: (responseData: TResponseData) => void;
|
||||
onSubmit: (data: TResponseData) => void;
|
||||
onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
|
||||
onBack: () => void;
|
||||
isFirstQuestion: boolean;
|
||||
isLastQuestion: boolean;
|
||||
autoFocus?: boolean;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
}
|
||||
|
||||
export default function OpenTextQuestion({
|
||||
@@ -27,7 +32,13 @@ export default function OpenTextQuestion({
|
||||
isFirstQuestion,
|
||||
isLastQuestion,
|
||||
autoFocus = true,
|
||||
ttc,
|
||||
setTtc,
|
||||
}: OpenTextQuestionProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime);
|
||||
|
||||
const handleInputChange = (inputValue: string) => {
|
||||
// const isValidInput = validateInput(inputValue, question.inputType, question.required);
|
||||
// setIsValid(isValidInput);
|
||||
@@ -50,7 +61,9 @@ export default function OpenTextQuestion({
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
// if ( validateInput(value as string, question.inputType, question.required)) {
|
||||
onSubmit({ [question.id]: value, inputType: question.inputType });
|
||||
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedttc);
|
||||
onSubmit({ [question.id]: value, inputType: question.inputType }, updatedttc);
|
||||
// }
|
||||
}}
|
||||
className="w-full">
|
||||
@@ -75,7 +88,9 @@ export default function OpenTextQuestion({
|
||||
if (e.key === "Enter" && isInputEmpty(value as string)) {
|
||||
e.preventDefault(); // Prevent form submission
|
||||
} else if (e.key === "Enter") {
|
||||
onSubmit({ [question.id]: value });
|
||||
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedttc);
|
||||
onSubmit({ [question.id]: value, inputType: question.inputType }, updatedttc);
|
||||
}
|
||||
}}
|
||||
pattern={question.inputType === "phone" ? "[+][0-9 ]+" : ".*"}
|
||||
@@ -106,6 +121,8 @@ export default function OpenTextQuestion({
|
||||
<BackButton
|
||||
backButtonLabel={question.backButtonLabel}
|
||||
onClick={() => {
|
||||
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedttc);
|
||||
onBack();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,21 +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 QuestionImage from "@/components/general/QuestionImage";
|
||||
import Subheader from "@/components/general/Subheader";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyPictureSelectionQuestion } from "@formbricks/types/surveys";
|
||||
import { useEffect } from "preact/hooks";
|
||||
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
interface PictureSelectionProps {
|
||||
question: TSurveyPictureSelectionQuestion;
|
||||
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 PictureSelectionQuestion({
|
||||
@@ -26,7 +28,13 @@ export default function PictureSelectionQuestion({
|
||||
onBack,
|
||||
isFirstQuestion,
|
||||
isLastQuestion,
|
||||
ttc,
|
||||
setTtc,
|
||||
}: PictureSelectionProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime);
|
||||
|
||||
const addItem = (item: string) => {
|
||||
let values: string[] = [];
|
||||
|
||||
@@ -80,7 +88,9 @@ export default function PictureSelectionQuestion({
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit({ [question.id]: value });
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onSubmit({ [question.id]: value }, updatedTtcObj);
|
||||
}}
|
||||
className="w-full">
|
||||
{question.imageUrl && <QuestionImage imgUrl={question.imageUrl} />}
|
||||
@@ -155,7 +165,11 @@ export default function PictureSelectionQuestion({
|
||||
<BackButton
|
||||
tabIndex={questionChoices.length + 3}
|
||||
backButtonLabel={question.backButtonLabel}
|
||||
onClick={onBack}
|
||||
onClick={() => {
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onBack();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div></div>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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 QuestionImage from "@/components/general/QuestionImage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyRatingQuestion } from "@formbricks/types/surveys";
|
||||
import { useState } from "preact/hooks";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
import {
|
||||
ConfusedFace,
|
||||
FrowningFace,
|
||||
@@ -24,10 +25,12 @@ interface RatingQuestionProps {
|
||||
question: TSurveyRatingQuestion;
|
||||
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 RatingQuestion({
|
||||
@@ -38,15 +41,25 @@ export default function RatingQuestion({
|
||||
onBack,
|
||||
isFirstQuestion,
|
||||
isLastQuestion,
|
||||
ttc,
|
||||
setTtc,
|
||||
}: RatingQuestionProps) {
|
||||
const [hoveredNumber, setHoveredNumber] = useState(0);
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime);
|
||||
|
||||
const handleSelect = (number: number) => {
|
||||
onChange({ [question.id]: number });
|
||||
if (question.required) {
|
||||
onSubmit({
|
||||
[question.id]: number,
|
||||
});
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onSubmit(
|
||||
{
|
||||
[question.id]: number,
|
||||
},
|
||||
updatedTtcObj
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -66,7 +79,9 @@ export default function RatingQuestion({
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit({ [question.id]: value });
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onSubmit({ [question.id]: value }, updatedTtcObj);
|
||||
}}
|
||||
className="w-full">
|
||||
{question.imageUrl && <QuestionImage imgUrl={question.imageUrl} />}
|
||||
@@ -184,6 +199,8 @@ export default function RatingQuestion({
|
||||
tabIndex={!question.required || value ? question.range + 2 : question.range + 1}
|
||||
backButtonLabel={question.backButtonLabel}
|
||||
onClick={() => {
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onBack();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { TResponseTtc } from "@formbricks/types/responses";
|
||||
import { useEffect } from "react";
|
||||
export const getUpdatedTtc = (ttc: TResponseTtc, questionId: string, time: number) => {
|
||||
// Check if the question ID already exists
|
||||
if (ttc.hasOwnProperty(questionId)) {
|
||||
return {
|
||||
...ttc,
|
||||
[questionId]: ttc[questionId] + time,
|
||||
};
|
||||
} else {
|
||||
// If the question ID does not exist, add it to the object
|
||||
return {
|
||||
...ttc,
|
||||
[questionId]: time,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const useTtc = (
|
||||
questionId: string,
|
||||
ttc: TResponseTtc,
|
||||
setTtc: (ttc: TResponseTtc) => void,
|
||||
startTime: number,
|
||||
setStartTime: (time: number) => void
|
||||
) => {
|
||||
useEffect(() => {
|
||||
setStartTime(performance.now());
|
||||
}, [questionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
// Restart the timer when the tab becomes visible again
|
||||
setStartTime(performance.now());
|
||||
} else {
|
||||
const updatedTtc = getUpdatedTtc(ttc, questionId, performance.now() - startTime);
|
||||
setTtc(updatedTtc);
|
||||
}
|
||||
};
|
||||
|
||||
// Attach the event listener
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
// Clean up the event listener when the component is unmounted
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
@@ -7,6 +7,10 @@ export const ZResponseData = z.record(z.union([z.string(), z.number(), z.array(z
|
||||
|
||||
export type TResponseData = z.infer<typeof ZResponseData>;
|
||||
|
||||
export const ZResponseTtc = z.record(z.number());
|
||||
|
||||
export type TResponseTtc = z.infer<typeof ZResponseTtc>;
|
||||
|
||||
export const ZResponsePersonAttributes = ZPersonAttributes.nullable();
|
||||
|
||||
export type TResponsePersonAttributes = z.infer<typeof ZResponsePersonAttributes>;
|
||||
@@ -51,6 +55,7 @@ export const ZResponse = z.object({
|
||||
personAttributes: ZResponsePersonAttributes,
|
||||
finished: z.boolean(),
|
||||
data: ZResponseData,
|
||||
ttc: ZResponseTtc,
|
||||
notes: z.array(ZResponseNote),
|
||||
tags: z.array(ZTag),
|
||||
meta: ZResponseMeta.nullable(),
|
||||
@@ -72,6 +77,7 @@ export const ZResponseInput = z.object({
|
||||
singleUseId: z.string().nullable().optional(),
|
||||
finished: z.boolean(),
|
||||
data: ZResponseData,
|
||||
ttc: ZResponseTtc,
|
||||
meta: z
|
||||
.object({
|
||||
source: z.string().optional(),
|
||||
@@ -98,6 +104,7 @@ export type TResponseLegacyInput = z.infer<typeof ZResponseLegacyInput>;
|
||||
export const ZResponseUpdateInput = z.object({
|
||||
finished: z.boolean(),
|
||||
data: ZResponseData,
|
||||
ttc: ZResponseTtc,
|
||||
});
|
||||
|
||||
export type TResponseUpdateInput = z.infer<typeof ZResponseUpdateInput>;
|
||||
@@ -111,6 +118,7 @@ export type TResponseWithSurvey = z.infer<typeof ZResponseWithSurvey>;
|
||||
export const ZResponseUpdate = z.object({
|
||||
finished: z.boolean(),
|
||||
data: ZResponseData,
|
||||
ttc: ZResponseTtc,
|
||||
meta: z
|
||||
.object({
|
||||
url: z.string().optional(),
|
||||
|
||||
Reference in New Issue
Block a user