Add Back Button to Surveys (#501)

* add back button, next with local storaage wip

* handle submission and skip submission logic

* handle showing stored value on same concurrent question type.

* remove console.log

* fix next button not showing, add saving answer on pressing back to local storage

* add temp props to QuestionCondition in preview modal

* add temp props to QuestionCondition in preview modal again...

* update navigation logic

* update survey question preview

* add back-button component

* add back button to formbricks/js

* refactor localStorage functions to lib

* remove unused import

* add form prefilling when reloading forms

* merge main into branch

* Revert "merge main into branch"

This reverts commit 13bc9c06ec.

* rename localStorage key answers->responses

* rename answers -> responses in linkSurvey lib

* when survey page reloaded jump to next question instead of current question

* rename getStoredAnswer -> getStoredResponse

* continue renaming

* continue renaming

* rename answerValue -> responseValue

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
tyjkerr
2023-08-02 18:08:20 +07:00
committed by GitHub
parent ae702ddd06
commit ec0d3f2fa2
27 changed files with 13881 additions and 6095 deletions
@@ -41,6 +41,11 @@ export default function PreviewSurvey({
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
const [lastActiveQuestionId, setLastActiveQuestionId] = useState("");
const [showFormbricksSignature, setShowFormbricksSignature] = useState(false);
const [finished, setFinished] = useState(false);
const [storedResponseValue, setStoredResponseValue] = useState<any>();
const [storedResponse, setStoredResponse] = useState<Record<string, any>>({});
const showBackButton = progress !== 0 && !finished;
useEffect(() => {
if (product) {
@@ -129,54 +134,54 @@ export default function PreviewSurvey({
}
}, [activeQuestionId, surveyType, questions, setActiveQuestionId, thankYouCard]);
function evaluateCondition(logic: Logic, answerValue: any): boolean {
function evaluateCondition(logic: Logic, responseValue: any): boolean {
switch (logic.condition) {
case "equals":
return (
(Array.isArray(answerValue) && answerValue.length === 1 && answerValue.includes(logic.value)) ||
answerValue.toString() === logic.value
(Array.isArray(responseValue) && responseValue.length === 1 && responseValue.includes(logic.value)) ||
responseValue.toString() === logic.value
);
case "notEquals":
return answerValue !== logic.value;
return responseValue !== logic.value;
case "lessThan":
return logic.value !== undefined && answerValue < logic.value;
return logic.value !== undefined && responseValue < logic.value;
case "lessEqual":
return logic.value !== undefined && answerValue <= logic.value;
return logic.value !== undefined && responseValue <= logic.value;
case "greaterThan":
return logic.value !== undefined && answerValue > logic.value;
return logic.value !== undefined && responseValue > logic.value;
case "greaterEqual":
return logic.value !== undefined && answerValue >= logic.value;
return logic.value !== undefined && responseValue >= logic.value;
case "includesAll":
return (
Array.isArray(answerValue) &&
Array.isArray(responseValue) &&
Array.isArray(logic.value) &&
logic.value.every((v) => answerValue.includes(v))
logic.value.every((v) => responseValue.includes(v))
);
case "includesOne":
return (
Array.isArray(answerValue) &&
Array.isArray(responseValue) &&
Array.isArray(logic.value) &&
logic.value.some((v) => answerValue.includes(v))
logic.value.some((v) => responseValue.includes(v))
);
case "accepted":
return answerValue === "accepted";
return responseValue === "accepted";
case "clicked":
return answerValue === "clicked";
return responseValue === "clicked";
case "submitted":
if (typeof answerValue === "string") {
return answerValue !== "dismissed" && answerValue !== "" && answerValue !== null;
} else if (Array.isArray(answerValue)) {
return answerValue.length > 0;
} else if (typeof answerValue === "number") {
return answerValue !== null;
if (typeof responseValue === "string") {
return responseValue !== "dismissed" && responseValue !== "" && responseValue !== null;
} else if (Array.isArray(responseValue)) {
return responseValue.length > 0;
} else if (typeof responseValue === "number") {
return responseValue !== null;
}
return false;
case "skipped":
return (
(Array.isArray(answerValue) && answerValue.length === 0) ||
answerValue === "" ||
answerValue === null ||
answerValue === "dismissed"
(Array.isArray(responseValue) && responseValue.length === 0) ||
responseValue === "" ||
responseValue === null ||
responseValue === "dismissed"
);
default:
return false;
@@ -191,14 +196,14 @@ export default function PreviewSurvey({
const currentQuestionIndex = questions.findIndex((q) => q.id === activeQuestionId);
if (currentQuestionIndex === -1) throw new Error("Question not found");
const answerValue = answer[activeQuestionId];
const responseValue = answer[activeQuestionId];
const currentQuestion = questions[currentQuestionIndex];
if (currentQuestion.logic && currentQuestion.logic.length > 0) {
for (let logic of currentQuestion.logic) {
if (!logic.destination) continue;
if (evaluateCondition(logic, answerValue)) {
if (evaluateCondition(logic, responseValue)) {
return logic.destination;
}
}
@@ -207,11 +212,13 @@ export default function PreviewSurvey({
}
const gotoNextQuestion = (data) => {
setStoredResponse({ ...storedResponse, ...data });
const nextQuestionId = getNextQuestion(data);
setStoredResponseValue(storedResponse[nextQuestionId]);
if (nextQuestionId !== "end") {
setActiveQuestionId(nextQuestionId);
} else {
setFinished(true);
if (thankYouCard?.enabled) {
setActiveQuestionId("thank-you-card");
setProgress(1);
@@ -225,6 +232,15 @@ export default function PreviewSurvey({
}
};
function goToPreviousQuestion(data: any) {
setStoredResponse({ ...storedResponse, ...data });
const currentQuestionIndex = questions.findIndex((q) => q.id === activeQuestionId);
if (currentQuestionIndex === -1) throw new Error("Question not found");
const previousQuestionId = questions[currentQuestionIndex - 1].id;
setStoredResponseValue(storedResponse[previousQuestionId]);
setActiveQuestionId(previousQuestionId);
}
useEffect(() => {
if (environment && environment.widgetSetupCompleted) {
setWidgetSetupCompleted(true);
@@ -280,6 +296,9 @@ export default function PreviewSurvey({
brandColor={brandColor}
lastQuestion={idx === questions.length - 1}
onSubmit={gotoNextQuestion}
storedResponseValue={storedResponseValue}
goToNextQuestion={gotoNextQuestion}
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
autoFocus={false}
/>
) : null
@@ -308,6 +327,9 @@ export default function PreviewSurvey({
brandColor={brandColor}
lastQuestion={idx === questions.length - 1}
onSubmit={gotoNextQuestion}
storedResponseValue={storedResponseValue}
goToNextQuestion={gotoNextQuestion}
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
autoFocus={false}
/>
) : null
+7
View File
@@ -34,8 +34,12 @@ export default function LinkSurvey({ survey }: LinkSurveyProps) {
initiateCountdown,
restartSurvey,
submitResponse,
goToPreviousQuestion,
goToNextQuestion,
storedResponseValue,
} = useLinkSurveyUtils(survey);
const showBackButton = progress !== 0 && !finished;
// Create a reference to the top element
const topRef = useRef<HTMLDivElement>(null);
const [autoFocus, setAutofocus] = useState(false);
@@ -98,6 +102,9 @@ export default function LinkSurvey({ survey }: LinkSurveyProps) {
brandColor={survey.brandColor}
lastQuestion={lastQuestion}
onSubmit={submitResponse}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
autoFocus={autoFocus}
/>
)}
@@ -0,0 +1,17 @@
import { Button } from "@formbricks/ui";
interface BackButtonProps {
onClick: () => void;
}
export function BackButton({ onClick }: BackButtonProps) {
return (
<Button
type="button"
variant="minimal"
className="mr-auto px-3 py-3 text-base font-medium leading-4 focus:ring-offset-2"
onClick={() => onClick()}>
Back
</Button>
);
}
+21 -3
View File
@@ -3,30 +3,48 @@ import Headline from "./Headline";
import HtmlBody from "./HtmlBody";
import { cn } from "@/../../packages/lib/cn";
import { isLight } from "@/lib/utils";
import { Response } from "@formbricks/types/js";
import { BackButton } from "@/components/preview/BackButton";
interface CTAQuestionProps {
question: CTAQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: string | null;
goToNextQuestion: (answer: Response["data"]) => void;
goToPreviousQuestion?: (answer?: Response["data"]) => void;
}
export default function CTAQuestion({ question, onSubmit, lastQuestion, brandColor }: CTAQuestionProps) {
export default function CTAQuestion({
question,
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: CTAQuestionProps) {
return (
<div>
<Headline headline={question.headline} questionId={question.id} />
<HtmlBody htmlString={question.html || ""} questionId={question.id} />
<div className="mt-4 flex w-full justify-end">
{goToPreviousQuestion && <BackButton onClick={() => goToPreviousQuestion()} />}
<div></div>
{!question.required && (
{(!question.required || storedResponseValue) && (
<button
type="button"
onClick={() => {
if (storedResponseValue) {
goToNextQuestion({ [question.id]: "clicked" });
return;
}
onSubmit({ [question.id]: "dismissed" });
}}
className="mr-4 flex items-center rounded-md px-3 py-3 text-base font-medium leading-4 text-slate-500 hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 dark:border-slate-400 dark:text-slate-400">
{question.dismissButtonLabel || "Skip"}
{storedResponseValue === "clicked" ? "Next" : question.dismissButtonLabel || "Skip"}
</button>
)}
<button
@@ -1,14 +1,20 @@
import { cn } from "@/../../packages/lib/cn";
import { BackButton } from "@/components/preview/BackButton";
import { isLight } from "@/lib/utils";
import { Response } from "@formbricks/types/js";
import type { ConsentQuestion } from "@formbricks/types/questions";
import { useEffect, useState } from "react";
import Headline from "./Headline";
import HtmlBody from "./HtmlBody";
import { cn } from "@/../../packages/lib/cn";
import { isLight } from "@/lib/utils";
interface ConsentQuestionProps {
question: ConsentQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: string | null;
goToNextQuestion: (answer: Response["data"]) => void;
goToPreviousQuestion?: (answer?: Response["data"]) => void;
}
export default function ConsentQuestion({
@@ -16,18 +22,42 @@ export default function ConsentQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: ConsentQuestionProps) {
const [answer, setAnswer] = useState<string>("dismissed");
useEffect(() => {
setAnswer(storedResponseValue ?? "dismissed");
}, [storedResponseValue, question]);
const handleOnChange = () => {
answer === "accepted" ? setAnswer("dissmissed") : setAnswer("accepted");
};
const handleSumbit = (value: string) => {
const data = {
[question.id]: value,
};
if (storedResponseValue === value) {
goToNextQuestion(data);
setAnswer("dismissed");
return;
}
onSubmit(data);
setAnswer("dismissed");
};
return (
<div>
<Headline headline={question.headline} questionId={question.id} />
<HtmlBody htmlString={question.html || ""} questionId={question.id} />
<form
onSubmit={(e) => {
e.preventDefault();
const checkbox = document.getElementById(question.id) as HTMLInputElement;
onSubmit({ [question.id]: checkbox.checked ? "accepted" : "dismissed" });
handleSumbit(answer);
}}>
<label className="relative z-10 mt-4 flex w-full cursor-pointer items-center rounded-md border border-gray-200 bg-slate-50 p-4 text-sm focus:outline-none">
<input
@@ -37,6 +67,8 @@ export default function ConsentQuestion({
value={question.label}
className="h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${question.id}-label`}
onChange={handleOnChange}
checked={answer === "accepted"}
style={{ borderColor: brandColor, color: brandColor }}
required={question.required}
/>
@@ -45,7 +77,17 @@ export default function ConsentQuestion({
</span>
</label>
<div className="mt-4 flex w-full justify-end">
<div className="mt-4 flex w-full justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() =>
goToPreviousQuestion({
[question.id]: answer,
})
}
/>
)}
<div></div>
<button
type="submit"
className={cn(
@@ -1,4 +1,4 @@
import { Input } from "@/../../packages/ui";
import { Input } from "@formbricks/ui";
import SubmitButton from "@/components/preview/SubmitButton";
import { shuffleArray } from "@/lib/utils";
import { cn } from "@formbricks/lib/cn";
@@ -6,12 +6,18 @@ import type { Choice, MultipleChoiceMultiQuestion } from "@formbricks/types/ques
import { useEffect, useState } from "react";
import Headline from "./Headline";
import Subheader from "./Subheader";
import _ from "lodash";
import { Response } from "@formbricks/types/js";
import { BackButton } from "@/components/preview/BackButton";
interface MultipleChoiceMultiProps {
question: MultipleChoiceMultiQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: string[] | null;
goToNextQuestion: (answer: Response["data"]) => void;
goToPreviousQuestion?: (answer: Response["data"]) => void;
}
export default function MultipleChoiceMultiQuestion({
@@ -19,11 +25,32 @@ export default function MultipleChoiceMultiQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: MultipleChoiceMultiProps) {
const [selectedChoices, setSelectedChoices] = useState<string[]>([]);
const [isAtLeastOneChecked, setIsAtLeastOneChecked] = useState(false);
const [showOther, setShowOther] = useState(false);
const [otherSpecified, setOtherSpecified] = useState("");
const nonOtherChoiceLabels = question.choices
.filter((label) => label.id !== "other")
.map((choice) => choice.label);
useEffect(() => {
const nonOtherSavedChoices = storedResponseValue?.filter((answer) => nonOtherChoiceLabels.includes(answer));
const savedOtherSpecified = storedResponseValue?.find((answer) => !nonOtherChoiceLabels.includes(answer));
setSelectedChoices(nonOtherSavedChoices ?? []);
if (savedOtherSpecified) {
setOtherSpecified(savedOtherSpecified);
setShowOther(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storedResponseValue, question.id]);
const [questionChoices, setQuestionChoices] = useState<Choice[]>(
question.choices
? question.shuffleOption !== "none"
@@ -31,12 +58,33 @@ export default function MultipleChoiceMultiQuestion({
: question.choices
: []
);
/* const [isIphone, setIsIphone] = useState(false);
*/
useEffect(() => {
setIsAtLeastOneChecked(selectedChoices.length > 0 || otherSpecified.length > 0);
}, [selectedChoices, otherSpecified]);
const resetForm = () => {
setSelectedChoices([]); // reset value
setShowOther(false);
setOtherSpecified("");
};
const handleSubmit = () => {
const data = {
[question.id]: selectedChoices,
};
if (_.xor(selectedChoices, storedResponseValue).length === 0) {
goToNextQuestion(data);
return;
}
if (question.required && selectedChoices.length <= 0) {
return;
}
onSubmit(data);
};
useEffect(() => {
setQuestionChoices(
question.choices
@@ -55,20 +103,8 @@ export default function MultipleChoiceMultiQuestion({
if (otherSpecified.length > 0 && showOther) {
selectedChoices.push(otherSpecified);
}
if (question.required && selectedChoices.length <= 0) {
return;
}
const data = {
[question.id]: selectedChoices,
};
onSubmit(data);
setSelectedChoices([]); // reset value
setShowOther(false);
setOtherSpecified("");
handleSubmit();
resetForm();
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -77,7 +113,7 @@ export default function MultipleChoiceMultiQuestion({
<legend className="sr-only">Options</legend>
<div className="xs:max-h-[41vh] relative max-h-[60vh] space-y-2 overflow-y-auto rounded-md py-0.5 pr-2">
{questionChoices.map((choice) => (
<>
<div key={choice.id}>
<label
key={choice.id}
className={cn(
@@ -124,6 +160,7 @@ export default function MultipleChoiceMultiQuestion({
name={question.id}
className="mt-2 bg-white focus:border-slate-300"
placeholder="Please specify"
value={otherSpecified}
onChange={(e) => setOtherSpecified(e.currentTarget.value)}
aria-labelledby={`${choice.id}-label`}
required={question.required}
@@ -132,7 +169,7 @@ export default function MultipleChoiceMultiQuestion({
)}
</span>
</label>
</>
</div>
))}
</div>
</fieldset>
@@ -145,6 +182,19 @@ export default function MultipleChoiceMultiQuestion({
onChange={() => {}}
/>
<div className="mt-4 flex w-full justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
if (otherSpecified.length > 0 && showOther) {
selectedChoices.push(otherSpecified);
}
goToPreviousQuestion({
[question.id]: selectedChoices,
});
resetForm();
}}
/>
)}
<div></div>
<SubmitButton {...{ question, lastQuestion, brandColor }} />
</div>
@@ -1,18 +1,23 @@
import { Input } from "@/../../packages/ui";
import SubmitButton from "@/components/preview/SubmitButton";
import { shuffleArray } from "@/lib/utils";
import { cn } from "@formbricks/lib/cn";
import { Response } from "@formbricks/types/js";
import { MultipleChoiceSingleQuestion } from "@formbricks/types/questions";
import { TSurveyChoice } from "@formbricks/types/v1/surveys";
import { Input } from "@formbricks/ui";
import { useEffect, useRef, useState } from "react";
import Headline from "./Headline";
import Subheader from "./Subheader";
import { BackButton } from "@/components/preview/BackButton";
interface MultipleChoiceSingleProps {
question: MultipleChoiceSingleQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: string | null;
goToNextQuestion: (answer: Response["data"]) => void;
goToPreviousQuestion?: (answer?: Response["data"]) => void;
}
export default function MultipleChoiceSingleQuestion({
@@ -20,8 +25,13 @@ export default function MultipleChoiceSingleQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: MultipleChoiceSingleProps) {
const storedResponseValueValue = question.choices.find((choice) => choice.label === storedResponseValue)?.id;
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const [savedOtherAnswer, setSavedOtherAnswer] = useState<string | null>(null);
const [questionChoices, setQuestionChoices] = useState<TSurveyChoice[]>(
question.choices
? question.shuffleOption && question.shuffleOption !== "none"
@@ -32,10 +42,41 @@ export default function MultipleChoiceSingleQuestion({
const otherSpecify = useRef<HTMLInputElement>(null);
useEffect(() => {
if (selectedChoice === "other") {
otherSpecify.current?.focus();
if (!storedResponseValueValue) {
const otherChoiceId = question.choices.find((choice) => choice.id === "other")?.id;
if (otherChoiceId && storedResponseValue) {
setSelectedChoice(otherChoiceId);
setSavedOtherAnswer(storedResponseValue);
}
} else {
setSelectedChoice(storedResponseValueValue);
}
}, [selectedChoice]);
}, [question.choices, storedResponseValue, storedResponseValueValue]);
useEffect(() => {
if (selectedChoice === "other" && otherSpecify.current) {
otherSpecify.current.value = savedOtherAnswer ?? "";
otherSpecify.current.focus();
}
}, [savedOtherAnswer, selectedChoice]);
const resetForm = () => {
setSelectedChoice(null);
setSavedOtherAnswer(null);
};
const handleSubmit = (value: string) => {
const data = {
[question.id]: value,
};
if (value === storedResponseValue) {
goToNextQuestion(data);
resetForm(); // reset form
return;
}
onSubmit(data);
resetForm(); // reset form
};
useEffect(() => {
setQuestionChoices(
@@ -52,11 +93,7 @@ export default function MultipleChoiceSingleQuestion({
onSubmit={(e) => {
e.preventDefault();
const value = otherSpecify.current?.value || e.currentTarget[question.id].value;
const data = {
[question.id]: value,
};
onSubmit(data);
setSelectedChoice(null); // reset form
handleSubmit(value);
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -108,6 +145,22 @@ export default function MultipleChoiceSingleQuestion({
</fieldset>
</div>
<div className="mt-4 flex w-full justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
goToPreviousQuestion(
selectedChoice === "other"
? {
[question.id]: otherSpecify.current?.value ?? "",
}
: {
[question.id]:
question.choices.find((choice) => choice.id === selectedChoice)?.label ?? "",
}
);
}}
/>
)}
<div></div>
<SubmitButton {...{ question, lastQuestion, brandColor }} />
</div>
+52 -20
View File
@@ -1,27 +1,54 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import type { NPSQuestion } from "@formbricks/types/questions";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "@/components/preview/SubmitButton";
import { Response } from "@formbricks/types/js";
import { BackButton } from "@/components/preview/BackButton";
interface NPSQuestionProps {
question: NPSQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: number | null;
goToNextQuestion: (answer: Response["data"]) => void;
goToPreviousQuestion?: (answer?: Response["data"]) => void;
}
export default function NPSQuestion({ question, onSubmit, lastQuestion, brandColor }: NPSQuestionProps) {
export default function NPSQuestion({
question,
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: NPSQuestionProps) {
const [selectedChoice, setSelectedChoice] = useState<number | null>(null);
useEffect(() => {
setSelectedChoice(storedResponseValue);
}, [storedResponseValue, question]);
const handleSubmit = (value: number | null) => {
const data = {
[question.id]: value ?? null,
};
if (storedResponseValue === value) {
setSelectedChoice(null);
goToNextQuestion(data);
return;
}
setSelectedChoice(null);
onSubmit(data);
};
const handleSelect = (number: number) => {
setSelectedChoice(number);
if (question.required) {
setSelectedChoice(null);
onSubmit({
[question.id]: number,
});
handleSubmit(number);
}
};
@@ -29,14 +56,7 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
<form
onSubmit={(e) => {
e.preventDefault();
const data = {
[question.id]: selectedChoice,
};
setSelectedChoice(null);
onSubmit(data);
// reset form
handleSubmit(selectedChoice);
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -55,6 +75,7 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
type="radio"
name="nps"
value={number}
checked={selectedChoice === number}
className="absolute h-full w-full cursor-pointer opacity-0"
onClick={() => handleSelect(number)}
required={question.required}
@@ -69,12 +90,23 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
</div>
</fieldset>
</div>
{!question.required && (
<div className="mt-4 flex w-full justify-between">
<div></div>
<SubmitButton {...{ question, lastQuestion, brandColor }} />
</div>
)}
<div className="mt-4 flex w-full justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
goToPreviousQuestion(
storedResponseValue !== selectedChoice
? {
[question.id]: selectedChoice,
}
: undefined
);
}}
/>
)}
<div></div>
{(!question.required || storedResponseValue) && <SubmitButton {...{ question, lastQuestion, brandColor }} />}
</div>
</form>
);
}
@@ -1,14 +1,19 @@
import type { OpenTextQuestion } from "@formbricks/types/questions";
import { useState } from "react";
import { useEffect, useState } from "react";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "@/components/preview/SubmitButton";
import { Response } from "@formbricks/types/js";
import { BackButton } from "@/components/preview/BackButton";
interface OpenTextQuestionProps {
question: OpenTextQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: string | null;
goToNextQuestion: (answer: Response["data"]) => void;
goToPreviousQuestion?: (answer: Response["data"]) => void;
autoFocus?: boolean;
}
@@ -17,52 +22,75 @@ export default function OpenTextQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
autoFocus = false,
}: OpenTextQuestionProps) {
const [value, setValue] = useState<string>("");
useEffect(() => {
setValue(storedResponseValue ?? "");
}, [storedResponseValue, question.id, question.longAnswer]);
const handleSubmit = (value: string) => {
const data = {
[question.id]: value,
};
if (storedResponseValue === value) {
goToNextQuestion(data);
return;
}
onSubmit(data);
setValue(""); // reset value
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
const data = {
[question.id]: value,
};
setValue(""); // reset value
onSubmit(data);
handleSubmit(value);
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
<div className="mt-4">
{question.longAnswer === false ? (
<input
autoFocus={autoFocus}
autoFocus={autoFocus && !storedResponseValue}
name={question.id}
id={question.id}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={question.placeholder}
placeholder={!storedResponseValue ? question.placeholder : undefined}
required={question.required}
className="block w-full rounded-md border border-slate-100 bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:outline-none focus:ring-0 sm:text-sm"
/>
) : (
<textarea
autoFocus={autoFocus}
autoFocus={autoFocus && !storedResponseValue}
rows={3}
name={question.id}
id={question.id}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={question.placeholder}
placeholder={!storedResponseValue ? question.placeholder : undefined}
required={question.required}
className="block w-full rounded-md border border-slate-100 bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:ring-0 sm:text-sm"
/>
)}
</div>
<div className="mt-4 flex w-full justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
goToPreviousQuestion({
[question.id]: value,
});
}}
/>
)}
<div></div>
<SubmitButton {...{ question, lastQuestion, brandColor }} />
<SubmitButton {...{ question, lastQuestion, brandColor, storedResponseValue, goToNextQuestion }} />
</div>
</form>
);
@@ -12,6 +12,9 @@ interface QuestionConditionalProps {
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: any;
goToNextQuestion: (answer: any) => void;
goToPreviousQuestion?: (answer: any) => void;
autoFocus: boolean;
}
@@ -20,6 +23,9 @@ export default function QuestionConditional({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
autoFocus,
}: QuestionConditionalProps) {
return question.type === QuestionType.OpenText ? (
@@ -28,6 +34,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
autoFocus={autoFocus}
/>
) : question.type === QuestionType.MultipleChoiceSingle ? (
@@ -36,6 +45,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === QuestionType.MultipleChoiceMulti ? (
<MultipleChoiceMultiQuestion
@@ -43,6 +55,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === QuestionType.NPS ? (
<NPSQuestion
@@ -50,6 +65,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === QuestionType.CTA ? (
<CTAQuestion
@@ -57,6 +75,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === QuestionType.Rating ? (
<RatingQuestion
@@ -64,6 +85,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === "consent" ? (
<ConsentQuestion
@@ -71,6 +95,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : null;
}
+41 -19
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import type { RatingQuestion } from "@formbricks/types/questions";
import Headline from "./Headline";
@@ -19,11 +19,17 @@ import {
} from "../Smileys";
import SubmitButton from "@/components/preview/SubmitButton";
import { Response } from "@formbricks/types/js";
import { BackButton } from "@/components/preview/BackButton";
interface RatingQuestionProps {
question: RatingQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: number | null;
goToNextQuestion: (answer: Response["data"]) => void;
goToPreviousQuestion?: (answer?: Response["data"]) => void;
}
export default function RatingQuestion({
@@ -31,18 +37,35 @@ export default function RatingQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: RatingQuestionProps) {
const [selectedChoice, setSelectedChoice] = useState<number | null>(null);
const [hoveredNumber, setHoveredNumber] = useState(0);
// const icons = RatingSmileyList(question.range);
useEffect(() => {
setSelectedChoice(storedResponseValue);
}, [storedResponseValue, question]);
const handleSubmit = (value: number | null) => {
const data = {
[question.id]: value ?? null,
};
if (storedResponseValue === value) {
goToNextQuestion(data);
setSelectedChoice(null);
return;
}
onSubmit(data);
setSelectedChoice(null);
};
const handleSelect = (number: number) => {
setSelectedChoice(number);
if (question.required) {
onSubmit({
[question.id]: number,
});
setSelectedChoice(null); // reset choice
handleSubmit(number);
}
};
@@ -53,6 +76,7 @@ export default function RatingQuestion({
value={number}
className="absolute left-0 h-full w-full cursor-pointer opacity-0"
onChange={() => handleSelect(number)}
checked={selectedChoice === number}
required={question.required}
/>
);
@@ -61,14 +85,7 @@ export default function RatingQuestion({
<form
onSubmit={(e) => {
e.preventDefault();
const data = {
[question.id]: selectedChoice,
};
setSelectedChoice(null); // reset choice
onSubmit(data);
handleSubmit(selectedChoice);
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -128,12 +145,17 @@ export default function RatingQuestion({
</fieldset>
</div>
{!question.required && (
<div className="mt-4 flex w-full justify-between">
<div></div>
<SubmitButton {...{ question, lastQuestion, brandColor }} />
</div>
)}
<div className="mt-4 flex w-full justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
goToPreviousQuestion({ [question.id]: selectedChoice });
}}
/>
)}
<div></div>
{(!question.required || storedResponseValue) && <SubmitButton {...{ question, lastQuestion, brandColor }} />}
</div>
</form>
);
}
+8 -1
View File
@@ -1,7 +1,14 @@
import { cn } from "@/../../packages/lib/cn";
import { isLight } from "@/lib/utils";
import { Question } from "@formbricks/types/questions";
function SubmitButton({ question, lastQuestion, brandColor }) {
type SubmitButtonProps = {
question: Question;
lastQuestion: boolean;
brandColor: string;
};
function SubmitButton({ question, lastQuestion, brandColor }: SubmitButtonProps) {
return (
<button
type="submit"
+145 -40
View File
@@ -8,6 +8,7 @@ import { useState, useEffect, useCallback } from "react";
import type { Survey } from "@formbricks/types/surveys";
import { useRouter } from "next/navigation";
import { useGetOrCreatePerson } from "../people/people";
import { Response } from "@formbricks/types/js";
export const useLinkSurvey = (surveyId: string) => {
const { data, error, mutate, isLoading } = useSWR(`/api/v1/client/surveys/${surveyId}`, fetcher);
@@ -29,6 +30,7 @@ export const useLinkSurveyUtils = (survey: Survey) => {
const [responseId, setResponseId] = useState<string | null>(null);
const [displayId, setDisplayId] = useState<string | null>(null);
const [initiateCountdown, setinitiateCountdown] = useState<boolean>(false);
const [storedResponseValue, setStoredResponseValue] = useState<string | null>(null);
const router = useRouter();
const URLParams = new URLSearchParams(window.location.search);
const isPreview = URLParams.get("preview") === "true";
@@ -41,9 +43,32 @@ export const useLinkSurveyUtils = (survey: Survey) => {
const { person, isLoadingPerson } = useGetOrCreatePerson(survey.environmentId, isPreview ? null : userId);
const personId = person?.data.person.id ?? null;
useEffect(() => {
const storedResponses = getStoredResponses(survey.id);
const questionKeys = survey.questions.map((question) => question.id);
if (storedResponses) {
const storedResponsesKeys = Object.keys(storedResponses);
// reduce to find the last answered question index
const lastAnsweredQuestionIndex = questionKeys.reduce((acc, key, index) => {
if (storedResponsesKeys.includes(key)) {
return index;
}
return acc;
}, 0);
if (lastAnsweredQuestionIndex > 0 && survey.questions.length > lastAnsweredQuestionIndex + 1) {
const nextQuestion = survey.questions[lastAnsweredQuestionIndex + 1];
setCurrentQuestion(nextQuestion);
setProgress(calculateProgress(nextQuestion, survey));
setStoredResponseValue(getStoredResponseValue(survey.id, nextQuestion.id));
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!isLoadingPerson) {
if (survey) {
const storedResponses = getStoredResponses(survey.id);
if (survey && !storedResponses) {
setCurrentQuestion(survey.questions[0]);
if (isPreview) return;
@@ -70,22 +95,10 @@ export const useLinkSurveyUtils = (survey: Survey) => {
return elementIdx / survey.questions.length;
}, []);
const getNextQuestionId = (answer: any): string => {
const activeQuestionId: string = currentQuestion?.id || "";
const getNextQuestionId = (): string => {
const currentQuestionIndex = survey.questions.findIndex((q) => q.id === currentQuestion?.id);
if (currentQuestionIndex === -1) throw new Error("Question not found");
const answerValue = answer[activeQuestionId];
if (currentQuestion?.logic && currentQuestion?.logic.length > 0) {
for (let logic of currentQuestion.logic) {
if (!logic.destination) continue;
if (evaluateCondition(logic, answerValue)) {
return logic.destination;
}
}
}
if (lastQuestion) return "end";
return survey.questions[currentQuestionIndex + 1].id;
};
@@ -98,8 +111,19 @@ export const useLinkSurveyUtils = (survey: Survey) => {
const submitResponse = async (data: { [x: string]: any }) => {
setLoadingElement(true);
const activeQuestionId: string = currentQuestion?.id || "";
const nextQuestionId = getNextQuestionId();
const responseValue = data[activeQuestionId];
const nextQuestionId = getNextQuestionId(data);
if (currentQuestion?.logic && currentQuestion?.logic.length > 0) {
for (let logic of currentQuestion.logic) {
if (!logic.destination) continue;
if (evaluateCondition(logic, responseValue)) {
return logic.destination;
}
}
}
const finished = nextQuestionId === "end";
// build response
@@ -112,6 +136,7 @@ export const useLinkSurveyUtils = (survey: Survey) => {
url: window.location.href,
},
};
if (!responseId && !isPreview) {
const response = await createResponse(
responseRequest,
@@ -121,12 +146,14 @@ export const useLinkSurveyUtils = (survey: Survey) => {
markDisplayResponded(displayId, `${window.location.protocol}//${window.location.host}`);
}
setResponseId(response.id);
storeResponse(survey.id, response.data);
} else if (responseId && !isPreview) {
await updateResponse(
responseRequest,
responseId,
`${window.location.protocol}//${window.location.host}`
);
storeResponse(survey.id, data);
}
setLoadingElement(false);
@@ -136,11 +163,12 @@ export const useLinkSurveyUtils = (survey: Survey) => {
if (!question) throw new Error("Question not found");
setStoredResponseValue(getStoredResponseValue(survey.id, nextQuestionId));
setCurrentQuestion(question);
// setCurrentQuestion(survey.questions[questionIdx + 1]);
} else {
setProgress(1);
setFinished(true);
clearStoredResponses(survey.id);
if (survey.redirectUrl && Object.values(data)[0] !== "dismissed") {
handleRedirect(survey.redirectUrl);
}
@@ -183,6 +211,49 @@ export const useLinkSurveyUtils = (survey: Survey) => {
handlePrefilling();
}, [handlePrefilling]);
const getPreviousQuestionId = (): string => {
const currentQuestionIndex = survey.questions.findIndex((q) => q.id === currentQuestion?.id);
if (currentQuestionIndex === -1) throw new Error("Question not found");
return survey.questions[currentQuestionIndex - 1].id;
};
const goToPreviousQuestion = (answer: Response["data"]) => {
setLoadingElement(true);
const previousQuestionId = getPreviousQuestionId();
const previousQuestion = survey.questions.find((q) => q.id === previousQuestionId);
if (!previousQuestion) throw new Error("Question not found");
if (answer) {
storeResponse(survey.id, answer);
}
setStoredResponseValue(getStoredResponseValue(survey.id, previousQuestion.id));
setCurrentQuestion(previousQuestion);
setLoadingElement(false);
};
const goToNextQuestion = (answer: Response["data"]) => {
setLoadingElement(true);
const nextQuestionId = getNextQuestionId();
if (nextQuestionId === "end") {
submitResponse(answer);
return;
}
storeResponse(survey.id, answer);
const nextQuestion = survey.questions.find((q) => q.id === nextQuestionId);
if (!nextQuestion) throw new Error("Question not found");
setStoredResponseValue(getStoredResponseValue(survey.id, nextQuestion.id));
setCurrentQuestion(nextQuestion);
setLoadingElement(false);
};
return {
currentQuestion,
progress,
@@ -194,9 +265,43 @@ export const useLinkSurveyUtils = (survey: Survey) => {
initiateCountdown,
submitResponse,
restartSurvey,
goToPreviousQuestion,
goToNextQuestion,
storedResponseValue,
};
};
const storeResponse = (surveyId: string, answer: Response["data"]) => {
const storedResponses = localStorage.getItem(`formbricks-${surveyId}-responses`);
if (storedResponses) {
const parsedResponses = JSON.parse(storedResponses);
localStorage.setItem(`formbricks-${surveyId}-responses`, JSON.stringify({ ...parsedResponses, ...answer }));
} else {
localStorage.setItem(`formbricks-${surveyId}-responses`, JSON.stringify(answer));
}
};
const getStoredResponses = (surveyId: string): Record<string, string> | null => {
const storedResponses = localStorage.getItem(`formbricks-${surveyId}-responses`);
if (storedResponses) {
const parsedResponses = JSON.parse(storedResponses);
return parsedResponses;
}
return null;
};
const getStoredResponseValue = (surveyId: string, questionId: string): string | null => {
const storedResponses = getStoredResponses(surveyId);
if (storedResponses) {
return storedResponses[questionId];
}
return null;
};
const clearStoredResponses = (surveyId: string) => {
localStorage.removeItem(`formbricks-${surveyId}-responses`);
};
const checkValidity = (question: Question, answer: any): boolean => {
if (question.required && (!answer || answer === "")) return false;
try {
@@ -287,54 +392,54 @@ const createAnswer = (question: Question, answer: string): string | number | str
}
};
const evaluateCondition = (logic: Logic, answerValue: any): boolean => {
const evaluateCondition = (logic: Logic, responseValue: any): boolean => {
switch (logic.condition) {
case "equals":
return (
(Array.isArray(answerValue) && answerValue.length === 1 && answerValue.includes(logic.value)) ||
answerValue.toString() === logic.value
(Array.isArray(responseValue) && responseValue.length === 1 && responseValue.includes(logic.value)) ||
responseValue.toString() === logic.value
);
case "notEquals":
return answerValue !== logic.value;
return responseValue !== logic.value;
case "lessThan":
return logic.value !== undefined && answerValue < logic.value;
return logic.value !== undefined && responseValue < logic.value;
case "lessEqual":
return logic.value !== undefined && answerValue <= logic.value;
return logic.value !== undefined && responseValue <= logic.value;
case "greaterThan":
return logic.value !== undefined && answerValue > logic.value;
return logic.value !== undefined && responseValue > logic.value;
case "greaterEqual":
return logic.value !== undefined && answerValue >= logic.value;
return logic.value !== undefined && responseValue >= logic.value;
case "includesAll":
return (
Array.isArray(answerValue) &&
Array.isArray(responseValue) &&
Array.isArray(logic.value) &&
logic.value.every((v) => answerValue.includes(v))
logic.value.every((v) => responseValue.includes(v))
);
case "includesOne":
return (
Array.isArray(answerValue) &&
Array.isArray(responseValue) &&
Array.isArray(logic.value) &&
logic.value.some((v) => answerValue.includes(v))
logic.value.some((v) => responseValue.includes(v))
);
case "accepted":
return answerValue === "accepted";
return responseValue === "accepted";
case "clicked":
return answerValue === "clicked";
return responseValue === "clicked";
case "submitted":
if (typeof answerValue === "string") {
return answerValue !== "dismissed" && answerValue !== "" && answerValue !== null;
} else if (Array.isArray(answerValue)) {
return answerValue.length > 0;
} else if (typeof answerValue === "number") {
return answerValue !== null;
if (typeof responseValue === "string") {
return responseValue !== "dismissed" && responseValue !== "" && responseValue !== null;
} else if (Array.isArray(responseValue)) {
return responseValue.length > 0;
} else if (typeof responseValue === "number") {
return responseValue !== null;
}
return false;
case "skipped":
return (
(Array.isArray(answerValue) && answerValue.length === 0) ||
answerValue === "" ||
answerValue === null ||
answerValue === "dismissed"
(Array.isArray(responseValue) && responseValue.length === 0) ||
responseValue === "" ||
responseValue === null ||
responseValue === "dismissed"
);
default:
return false;
+2
View File
@@ -5,6 +5,7 @@ import { useState } from "preact/hooks";
import Modal from "./components/Modal";
import SurveyView from "./components/SurveyView";
import { IErrorHandler } from "./lib/errors";
import { clearStoredResponse } from "./lib/localStorage";
interface AppProps {
config: TJsConfig;
@@ -18,6 +19,7 @@ export default function App({ config, survey, closeSurvey, errorHandler }: AppPr
const close = () => {
setIsOpen(false);
clearStoredResponse(survey.id);
setTimeout(() => {
closeSurvey();
}, 1000); // wait for animation to finish}
+20
View File
@@ -0,0 +1,20 @@
import { h } from "preact";
import { cn } from "@/../../packages/lib/cn";
interface BackButtonProps {
onClick: () => void;
}
export function BackButton({ onClick }: BackButtonProps) {
return (
<button
type={"button"}
className={cn(
"fb-flex fb-items-center fb-rounded-md fb-border fb-border-transparent fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 fb-shadow-sm hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-slate-500 focus:fb-ring-offset-2"
)}
onClick={onClick}>
Back
</button>
);
}
+43 -35
View File
@@ -4,56 +4,64 @@ import type { TSurveyCTAQuestion } from "../../../types/v1/surveys";
import Headline from "./Headline";
import HtmlBody from "./HtmlBody";
import SubmitButton from "./SubmitButton";
import { BackButton } from "./BackButton";
interface CTAQuestionProps {
question: TSurveyCTAQuestion;
onSubmit: (data: TResponseData) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: number | null;
goToNextQuestion: (answer: TResponseData) => void;
goToPreviousQuestion?: (answer?: TResponseData) => void;
}
export default function CTAQuestion({ question, onSubmit, lastQuestion, brandColor }: CTAQuestionProps) {
export default function CTAQuestion({
question,
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: CTAQuestionProps) {
return (
<div>
<Headline headline={question.headline} questionId={question.id} />
<HtmlBody htmlString={question.html} questionId={question.id} />
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-end">
<div></div>
{!question.required && (
<button
type="button"
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
{goToPreviousQuestion && <BackButton onClick={() => goToPreviousQuestion()} />}
<div className="fb-flex fb-justify-end">
{(!question.required || storedResponseValue) && (
<button
type="button"
onClick={() => {
if (storedResponseValue) {
goToNextQuestion({ [question.id]: "clicked" });
return;
}
onSubmit({ [question.id]: "dismissed" });
}}
className="fb-flex fb-items-center dark:fb-text-slate-400 fb-rounded-md fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 fb-hover:opacity-90 fb-focus:outline-none fb-focus:ring-2 fb-focus:ring-slate-500 fb-focus:ring-offset-2 fb-mr-4">
{typeof storedResponseValue === "string" && storedResponseValue === "clicked"
? "Next"
: question.dismissButtonLabel || "Skip"}
</button>
)}
<SubmitButton
question={question}
lastQuestion={lastQuestion}
brandColor={brandColor}
onClick={() => {
onSubmit({ [question.id]: "dismissed" });
if (question.buttonExternal && question.buttonUrl) {
window?.open(question.buttonUrl, "_blank")?.focus();
}
onSubmit({ [question.id]: "clicked" });
}}
className="fb-flex fb-items-center dark:fb-text-slate-400 fb-rounded-md fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 fb-hover:opacity-90 fb-focus:outline-none fb-focus:ring-2 fb-focus:ring-slate-500 fb-focus:ring-offset-2 fb-mr-4">
{question.dismissButtonLabel || "Skip"}
</button>
)}
{/* <button
type="button"
onClick={() => {
if (question.buttonExternal && question.buttonUrl) {
window?.open(question.buttonUrl, "_blank")?.focus();
}
onSubmit({ [question.id]: "clicked" });
}}
className="fb-flex fb-items-center fb-rounded-md fb-border fb-border-transparent fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 fb-text-white fb-shadow-sm fb-hover:opacity-90 fb-focus:outline-none fb-focus:ring-2 fb-focus:ring-slate-500 fb-focus:ring-offset-2"
style={{ backgroundColor: brandColor }}>
{question.buttonLabel || (lastQuestion ? "Finish" : "Next")}
</button> */}
<SubmitButton
question={question}
lastQuestion={lastQuestion}
brandColor={brandColor}
onClick={() => {
if (question.buttonExternal && question.buttonUrl) {
window?.open(question.buttonUrl, "_blank")?.focus();
}
onSubmit({ [question.id]: "clicked" });
}}
type="button"
/>
type="button"
/>
</div>
</div>
</div>
);
+45 -4
View File
@@ -4,12 +4,17 @@ import type { TSurveyConsentQuestion } from "../../../types/v1/surveys";
import Headline from "./Headline";
import HtmlBody from "./HtmlBody";
import SubmitButton from "./SubmitButton";
import { useEffect, useState } from "preact/hooks";
import { BackButton } from "./BackButton";
interface ConsentQuestionProps {
question: TSurveyConsentQuestion;
onSubmit: (data: TResponseData) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: string | null;
goToNextQuestion: (answer: TResponseData) => void;
goToPreviousQuestion?: (answer?: TResponseData) => void;
}
export default function ConsentQuestion({
@@ -17,7 +22,33 @@ export default function ConsentQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: ConsentQuestionProps) {
const [answer, setAnswer] = useState<string>("dismissed");
useEffect(() => {
setAnswer(storedResponseValue ?? "dismissed");
}, [storedResponseValue, question]);
const handleOnChange = () => {
answer === "accepted" ? setAnswer("dissmissed") : setAnswer("accepted");
};
const handleSumbit = (value: string) => {
const data = {
[question.id]: value,
};
if (storedResponseValue === value) {
goToNextQuestion(data);
setAnswer("dismissed");
return;
}
onSubmit(data);
setAnswer("dismissed");
};
return (
<div>
<Headline headline={question.headline} questionId={question.id} />
@@ -26,9 +57,7 @@ export default function ConsentQuestion({
<form
onSubmit={(e) => {
e.preventDefault();
const checkbox = document.getElementById(question.id) as HTMLInputElement;
onSubmit({ [question.id]: checkbox.checked ? "accepted" : "dismissed" });
handleSumbit(answer);
}}>
<label className="fb-relative fb-z-10 fb-mt-4 fb-flex fb-w-full fb-cursor-pointer fb-items-center fb-rounded-md fb-border fb-border-gray-200 fb-bg-slate-50 fb-p-4 fb-text-sm focus:fb-outline-none">
<input
@@ -36,6 +65,8 @@ export default function ConsentQuestion({
id={question.id}
name={question.id}
value={question.label}
onChange={handleOnChange}
checked={answer === "accepted"}
className="fb-h-4 fb-w-4 fb-border fb-border-slate-300 focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${question.id}-label`}
style={{ borderColor: brandColor, color: brandColor }}
@@ -46,7 +77,17 @@ export default function ConsentQuestion({
</span>
</label>
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-end">
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() =>
goToPreviousQuestion({
[question.id]: answer,
})
}
/>
)}
<div />
<SubmitButton
brandColor={brandColor}
question={question}
@@ -6,12 +6,17 @@ import { cn, shuffleArray } from "../lib/utils";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
import _ from "lodash";
import { BackButton } from "./BackButton";
interface MultipleChoiceMultiProps {
question: TSurveyMultipleChoiceMultiQuestion;
onSubmit: (data: TResponseData) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: string[] | null;
goToNextQuestion: (answer: TResponseData) => void;
goToPreviousQuestion?: (answer: TResponseData) => void;
}
export default function MultipleChoiceMultiQuestion({
@@ -19,6 +24,9 @@ export default function MultipleChoiceMultiQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: MultipleChoiceMultiProps) {
const [selectedChoices, setSelectedChoices] = useState<string[]>([]);
const [showOther, setShowOther] = useState(false);
@@ -36,11 +44,29 @@ export default function MultipleChoiceMultiQuestion({
return selectedChoices.length > 0 || otherSpecified.length > 0;
};
const nonOtherChoiceLabels = question.choices
.filter((label) => label.id !== "other")
.map((choice) => choice.label);
useEffect(() => {
const nonOtherSavedChoices = storedResponseValue?.filter((answer) => nonOtherChoiceLabels.includes(answer));
const savedOtherSpecified = storedResponseValue?.find((answer) => !nonOtherChoiceLabels.includes(answer));
setSelectedChoices(nonOtherSavedChoices ?? []);
if (savedOtherSpecified) {
setOtherSpecified(savedOtherSpecified);
setShowOther(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storedResponseValue, question.id]);
useEffect(() => {
if (showOther && otherInputRef.current) {
otherInputRef.current.value = otherSpecified ?? "";
otherInputRef.current.focus();
}
}, [showOther]);
}, [otherSpecified, showOther]);
useEffect(() => {
setQuestionChoices(
@@ -52,27 +78,38 @@ export default function MultipleChoiceMultiQuestion({
);
}, [question.choices, question.shuffleOption]);
const resetForm = () => {
setSelectedChoices([]); // reset value
setShowOther(false);
setOtherSpecified("");
};
const handleSubmit = () => {
const data = {
[question.id]: selectedChoices,
};
if (_.xor(selectedChoices, storedResponseValue).length === 0) {
goToNextQuestion(data);
return;
}
if (question.required && selectedChoices.length <= 0) {
return;
}
onSubmit(data);
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (otherSpecified.length > 0 && showOther) {
selectedChoices.push(otherSpecified);
}
if (question.required && selectedChoices.length <= 0) {
return;
}
const data = {
[question.id]: selectedChoices,
};
onSubmit(data);
setSelectedChoices([]); // reset value
setShowOther(false);
setOtherSpecified("");
handleSubmit();
resetForm();
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -145,6 +182,19 @@ export default function MultipleChoiceMultiQuestion({
value={isAtLeastOneChecked() ? "checked" : ""}
/>
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
if (otherSpecified.length > 0 && showOther) {
selectedChoices.push(otherSpecified);
}
goToPreviousQuestion({
[question.id]: selectedChoices,
});
resetForm();
}}
/>
)}
<div></div>
<SubmitButton
question={question}
@@ -6,12 +6,16 @@ import { cn, shuffleArray } from "../lib/utils";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
import { BackButton } from "./BackButton";
interface MultipleChoiceSingleProps {
question: TSurveyMultipleChoiceSingleQuestion;
onSubmit: (data: TResponseData) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: string | null;
goToNextQuestion: (answer: TResponseData) => void;
goToPreviousQuestion?: (answer: TResponseData) => void;
}
export default function MultipleChoiceSingleQuestion({
@@ -19,8 +23,13 @@ export default function MultipleChoiceSingleQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: MultipleChoiceSingleProps) {
const storedResponseValueValue = question.choices.find((choice) => choice.label === storedResponseValue)?.id;
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const [savedOtherAnswer, setSavedOtherAnswer] = useState<string | null>(null);
const [questionChoices, setQuestionChoices] = useState<TSurveyChoice[]>(
question.choices
? question.shuffleOption && question.shuffleOption !== "none"
@@ -30,11 +39,24 @@ export default function MultipleChoiceSingleQuestion({
);
const otherSpecify = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!storedResponseValueValue) {
const otherChoiceId = question.choices.find((choice) => choice.id === "other")?.id;
if (otherChoiceId && storedResponseValue) {
setSelectedChoice(otherChoiceId);
setSavedOtherAnswer(storedResponseValue);
}
} else {
setSelectedChoice(storedResponseValueValue);
}
}, [question.choices, storedResponseValue, storedResponseValueValue]);
useEffect(() => {
if (selectedChoice === "other") {
otherSpecify.current.value = savedOtherAnswer ?? "";
otherSpecify.current?.focus();
}
}, [selectedChoice]);
}, [savedOtherAnswer, selectedChoice]);
useEffect(() => {
setQuestionChoices(
@@ -46,18 +68,31 @@ export default function MultipleChoiceSingleQuestion({
);
}, [question.choices, question.shuffleOption]);
const resetForm = () => {
setSelectedChoice(null);
setSavedOtherAnswer(null);
};
const handleSubmit = (value: string) => {
const data = {
[question.id]: value,
};
if (value === storedResponseValue) {
goToNextQuestion(data);
resetForm(); // reset form
return;
}
onSubmit(data);
resetForm(); // reset form
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
const value = otherSpecify.current?.value || e.currentTarget[question.id].value;
const data = {
[question.id]: value,
};
onSubmit(data);
setSelectedChoice(null); // reset form
handleSubmit(value);
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -110,6 +145,21 @@ export default function MultipleChoiceSingleQuestion({
</fieldset>
</div>
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
goToPreviousQuestion(
selectedChoice === "other"
? {
[question.id]: otherSpecify.current?.value,
}
: {
[question.id]: question.choices.find((choice) => choice.id === selectedChoice)?.label,
}
);
}}
/>
)}
<div></div>
<SubmitButton
question={question}
+51 -16
View File
@@ -1,21 +1,49 @@
import { h } from "preact";
import { useState } from "preact/hooks";
import { useEffect, useState } from "preact/hooks";
import { TResponseData } from "../../../types/v1/responses";
import type { TSurveyNPSQuestion } from "../../../types/v1/surveys";
import { cn } from "../lib/utils";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
import { BackButton } from "./BackButton";
interface NPSQuestionProps {
question: TSurveyNPSQuestion;
onSubmit: (data: TResponseData) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: number | null;
goToNextQuestion: (answer: TResponseData) => void;
goToPreviousQuestion?: (answer?: TResponseData) => void;
}
export default function NPSQuestion({ question, onSubmit, lastQuestion, brandColor }: NPSQuestionProps) {
export default function NPSQuestion({
question,
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: NPSQuestionProps) {
const [selectedChoice, setSelectedChoice] = useState<number | null>(null);
useEffect(() => {
setSelectedChoice(storedResponseValue);
}, [storedResponseValue, question]);
const handleSubmit = (value: number | null) => {
const data = {
[question.id]: value,
};
if (storedResponseValue === value) {
setSelectedChoice(null);
goToNextQuestion(data);
return;
}
setSelectedChoice(null);
onSubmit(data);
};
const handleSelect = (number: number) => {
setSelectedChoice(number);
@@ -31,15 +59,7 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
<form
onSubmit={(e) => {
e.preventDefault();
const data = {};
if (selectedChoice !== null) {
data[question.id] = selectedChoice;
}
setSelectedChoice(null);
onSubmit(data);
// reset form
handleSubmit(selectedChoice);
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -58,6 +78,7 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
type="radio"
name="nps"
value={number}
checked={selectedChoice === number}
className="fb-absolute fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onClick={() => handleSelect(number)}
required={question.required}
@@ -72,17 +93,31 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
</div>
</fieldset>
</div>
{!question.required && (
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
<div></div>
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
goToPreviousQuestion(
storedResponseValue !== selectedChoice
? {
[question.id]: selectedChoice,
}
: undefined
);
}}
/>
)}
<div></div>
{(!question.required || storedResponseValue) && (
<SubmitButton
question={question}
lastQuestion={lastQuestion}
brandColor={brandColor}
onClick={() => {}}
/>
</div>
)}
)}
</div>
</form>
);
}
@@ -4,12 +4,17 @@ import type { TSurveyOpenTextQuestion } from "../../../types/v1/surveys";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
import { useEffect, useState } from "preact/hooks";
import { BackButton } from "./BackButton";
interface OpenTextQuestionProps {
question: TSurveyOpenTextQuestion;
onSubmit: (data: TResponseData) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: string | null;
goToNextQuestion: (answer: TResponseData) => void;
goToPreviousQuestion?: (answer: TResponseData) => void;
}
export default function OpenTextQuestion({
@@ -17,17 +22,33 @@ export default function OpenTextQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: OpenTextQuestionProps) {
const [value, setValue] = useState<string>("");
useEffect(() => {
setValue(storedResponseValue ?? "");
}, [storedResponseValue, question.id]);
const handleSubmit = (value: string) => {
const data = {
[question.id]: value,
};
if (storedResponseValue === value) {
goToNextQuestion(data);
return;
}
onSubmit(data);
setValue(""); // reset value
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
const data = {
[question.id]: e.currentTarget[question.id].value,
};
e.currentTarget[question.id].value = ""; // reset value
onSubmit(data);
// reset form
handleSubmit(value);
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -36,8 +57,10 @@ export default function OpenTextQuestion({
<input
name={question.id}
id={question.id}
placeholder={question.placeholder}
placeholder={!storedResponseValue ? question.placeholder : undefined}
required={question.required}
value={value}
onInput={(e) => setValue(e.currentTarget.value)}
className="fb-block fb-w-full fb-rounded-md fb-border fb-p-2 fb-shadow-sm focus:fb-ring-0 sm:fb-text-sm fb-bg-slate-50 fb-border-slate-100 focus:fb-border-slate-500 focus:fb-outline-none"
/>
) : (
@@ -45,12 +68,23 @@ export default function OpenTextQuestion({
rows={3}
name={question.id}
id={question.id}
placeholder={question.placeholder}
placeholder={!storedResponseValue ? question.placeholder : undefined}
required={question.required}
value={value}
onInput={(e) => setValue(e.currentTarget.value)}
className="fb-block fb-w-full fb-rounded-md fb-border fb-p-2 fb-shadow-sm focus:fb-ring-0 sm:fb-text-sm fb-bg-slate-50 fb-border-slate-100 focus:fb-border-slate-500"></textarea>
)}
</div>
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
goToPreviousQuestion({
[question.id]: value,
});
}}
/>
)}
<div></div>
<SubmitButton
question={question}
@@ -14,6 +14,9 @@ interface QuestionConditionalProps {
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: any;
goToNextQuestion: (answer: any) => void;
goToPreviousQuestion?: (answer: any) => void;
}
export default function QuestionConditional({
@@ -21,6 +24,9 @@ export default function QuestionConditional({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: QuestionConditionalProps) {
return question.type === QuestionType.OpenText ? (
<OpenTextQuestion
@@ -28,6 +34,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === QuestionType.MultipleChoiceSingle ? (
<MultipleChoiceSingleQuestion
@@ -35,6 +44,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === QuestionType.MultipleChoiceMulti ? (
<MultipleChoiceMultiQuestion
@@ -42,6 +54,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === QuestionType.NPS ? (
<NPSQuestion
@@ -49,6 +64,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === QuestionType.CTA ? (
<CTAQuestion
@@ -56,6 +74,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === QuestionType.Rating ? (
<RatingQuestion
@@ -63,6 +84,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === "consent" ? (
<ConsentQuestion
@@ -70,6 +94,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : null;
}
+40 -15
View File
@@ -1,5 +1,5 @@
import { h } from "preact";
import { useState } from "preact/hooks";
import { useEffect, useState } from "preact/hooks";
import { TResponseData } from "../../../types/v1/responses";
import type { TSurveyRatingQuestion } from "../../../types/v1/surveys";
import { cn } from "../lib/utils";
@@ -18,12 +18,16 @@ import {
} from "./Smileys";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
import { BackButton } from "./BackButton";
interface RatingQuestionProps {
question: TSurveyRatingQuestion;
onSubmit: (data: TResponseData) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: number | null;
goToNextQuestion: (answer: TResponseData) => void;
goToPreviousQuestion?: (answer?: TResponseData) => void;
}
export default function RatingQuestion({
@@ -31,10 +35,30 @@ export default function RatingQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: RatingQuestionProps) {
const [selectedChoice, setSelectedChoice] = useState<number | null>(null);
const [hoveredNumber, setHoveredNumber] = useState(0);
useEffect(() => {
setSelectedChoice(storedResponseValue);
}, [storedResponseValue, question]);
const handleSubmit = (value: number | null) => {
const data = {
[question.id]: value,
};
if (storedResponseValue === value) {
goToNextQuestion(data);
setSelectedChoice(null);
return;
}
onSubmit(data);
setSelectedChoice(null);
};
const handleSelect = (number: number) => {
setSelectedChoice(number);
if (question.required) {
@@ -53,6 +77,7 @@ export default function RatingQuestion({
className="fb-absolute fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0 fb-left-0"
onChange={() => handleSelect(number)}
required={question.required}
checked={selectedChoice === number}
/>
);
@@ -60,15 +85,7 @@ export default function RatingQuestion({
<form
onSubmit={(e) => {
e.preventDefault();
const data = {};
if (selectedChoice !== null) {
data[question.id] = selectedChoice;
}
setSelectedChoice(null); // reset choice
onSubmit(data);
handleSubmit(selectedChoice);
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -149,17 +166,25 @@ export default function RatingQuestion({
</div>
</fieldset>
</div>
{!question.required && (
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
<div></div>
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
goToPreviousQuestion({ [question.id]: selectedChoice });
}}
/>
)}
<div></div>
{(!question.required || selectedChoice) && (
<SubmitButton
question={question}
lastQuestion={lastQuestion}
brandColor={brandColor}
onClick={() => {}}
/>
</div>
)}
)}
</div>
</form>
);
}
+97 -43
View File
@@ -13,6 +13,7 @@ import QuestionConditional from "./QuestionConditional";
import ThankYouCard from "./ThankYouCard";
import FormbricksSignature from "./FormbricksSignature";
import type { TResponseData, TResponseInput } from "../../../types/v1/responses";
import { clearStoredResponse, getStoredResponse, storeResponse } from "../lib/localStorage";
interface SurveyViewProps {
config: TJsConfig;
@@ -28,12 +29,16 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
const [displayId, setDisplayId] = useState<string | null>(null);
const [loadingElement, setLoadingElement] = useState(false);
const contentRef = useRef(null);
const [finished, setFinished] = useState(false);
const [storedResponseValue, setStoredResponseValue] = useState<any>(null);
const [countdownProgress, setCountdownProgress] = useState(100);
const [countdownStop, setCountdownStop] = useState(false);
const startRef = useRef(performance.now());
const frameRef = useRef<number | null>(null);
const showBackButton = progress !== 0 && !finished;
const handleStopCountdown = () => {
if (frameRef.current !== null) {
setCountdownStop(true);
@@ -101,84 +106,124 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
}
}, [activeQuestionId, survey]);
function evaluateCondition(logic: TSurveyLogic, answerValue: any): boolean {
function evaluateCondition(logic: TSurveyLogic, responseValue: any): boolean {
switch (logic.condition) {
case "equals":
return (
(Array.isArray(answerValue) && answerValue.length === 1 && answerValue.includes(logic.value)) ||
answerValue.toString() === logic.value
(Array.isArray(responseValue) && responseValue.length === 1 && responseValue.includes(logic.value)) ||
responseValue.toString() === logic.value
);
case "notEquals":
return answerValue !== logic.value;
return responseValue !== logic.value;
case "lessThan":
return logic.value !== undefined && answerValue < logic.value;
return logic.value !== undefined && responseValue < logic.value;
case "lessEqual":
return logic.value !== undefined && answerValue <= logic.value;
return logic.value !== undefined && responseValue <= logic.value;
case "greaterThan":
return logic.value !== undefined && answerValue > logic.value;
return logic.value !== undefined && responseValue > logic.value;
case "greaterEqual":
return logic.value !== undefined && answerValue >= logic.value;
return logic.value !== undefined && responseValue >= logic.value;
case "includesAll":
return (
Array.isArray(answerValue) &&
Array.isArray(responseValue) &&
Array.isArray(logic.value) &&
logic.value.every((v) => answerValue.includes(v))
logic.value.every((v) => responseValue.includes(v))
);
case "includesOne":
return (
Array.isArray(answerValue) &&
Array.isArray(responseValue) &&
Array.isArray(logic.value) &&
logic.value.some((v) => answerValue.includes(v))
logic.value.some((v) => responseValue.includes(v))
);
case "accepted":
return answerValue === "accepted";
return responseValue === "accepted";
case "clicked":
return answerValue === "clicked";
return responseValue === "clicked";
case "submitted":
if (typeof answerValue === "string") {
return answerValue !== "dismissed" && answerValue !== "" && answerValue !== null;
} else if (Array.isArray(answerValue)) {
return answerValue.length > 0;
} else if (typeof answerValue === "number") {
return answerValue !== null;
if (typeof responseValue === "string") {
return responseValue !== "dismissed" && responseValue !== "" && responseValue !== null;
} else if (Array.isArray(responseValue)) {
return responseValue.length > 0;
} else if (typeof responseValue === "number") {
return responseValue !== null;
}
return false;
case "skipped":
return (
(Array.isArray(answerValue) && answerValue.length === 0) ||
answerValue === "" ||
answerValue === null ||
answerValue === "dismissed"
(Array.isArray(responseValue) && responseValue.length === 0) ||
responseValue === "" ||
responseValue === null ||
responseValue === "dismissed"
);
default:
return false;
}
}
function getNextQuestion(answer: any): string {
function getNextQuestionId() {
const questions = survey.questions;
const currentQuestionIndex = questions.findIndex((q) => q.id === activeQuestionId);
if (currentQuestionIndex === -1) throw new Error("Question not found");
const answerValue = answer[activeQuestionId];
const currentQuestion = questions[currentQuestionIndex];
if (currentQuestion.logic && currentQuestion.logic.length > 0) {
for (let logic of currentQuestion.logic) {
if (!logic.destination) continue;
if (evaluateCondition(logic, answerValue)) {
return logic.destination;
}
}
}
return questions[currentQuestionIndex + 1]?.id || "end";
}
function goToNextQuestion(answer: TResponseData): string {
setLoadingElement(true);
const questions = survey.questions;
const nextQuestionId = getNextQuestionId();
if (nextQuestionId === "end") {
submitResponse(answer);
return;
}
const nextQuestion = questions.find((q) => q.id === nextQuestionId);
if (!nextQuestion) throw new Error("Question not found");
setStoredResponseValue(getStoredResponse(survey.id, nextQuestionId));
setActiveQuestionId(nextQuestionId);
setLoadingElement(false);
}
function getPreviousQuestionId() {
const questions = survey.questions;
const currentQuestionIndex = questions.findIndex((q) => q.id === activeQuestionId);
if (currentQuestionIndex === -1) throw new Error("Question not found");
return questions[currentQuestionIndex - 1]?.id;
}
function goToPreviousQuestion(answer: TResponseData) {
setLoadingElement(true);
const previousQuestionId = getPreviousQuestionId();
if (!previousQuestionId) throw new Error("Question not found");
if (answer) {
storeResponse(survey.id, answer);
}
setStoredResponseValue(getStoredResponse(survey.id, previousQuestionId));
setActiveQuestionId(previousQuestionId);
setLoadingElement(false);
}
const submitResponse = async (data: TResponseData) => {
setLoadingElement(true);
const nextQuestionId = getNextQuestion(data);
const questions = survey.questions;
const nextQuestionId = getNextQuestionId();
const currentQuestion = questions[activeQuestionId];
const responseValue = data[activeQuestionId];
if (currentQuestion?.logic && currentQuestion?.logic.length > 0) {
for (let logic of currentQuestion.logic) {
if (!logic.destination) continue;
if (evaluateCondition(logic, responseValue)) {
return logic.destination;
}
}
}
const finished = nextQuestionId === "end";
// build response
@@ -196,11 +241,15 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
createResponse(responseRequest, config),
markDisplayResponded(displayId, config),
]);
response.ok === true ? setResponseId(response.value.id) : errorHandler(response.error);
if (response.ok === true) {
setResponseId(response.value.id);
storeResponse(survey.id, data);
} else {
errorHandler(response.error);
}
} else {
const result = await updateResponse(responseRequest, responseId, config);
storeResponse(survey.id, data);
if (result.ok !== true) {
errorHandler(result.error);
} else if (responseRequest.finished) {
@@ -210,10 +259,12 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
setLoadingElement(false);
if (!finished && nextQuestionId !== "end") {
setStoredResponseValue(getStoredResponse(survey.id, nextQuestionId));
setActiveQuestionId(nextQuestionId);
} else {
setProgress(100);
setFinished(true);
clearStoredResponse(survey.id);
if (survey.thankYouCard.enabled) {
setTimeout(() => {
close();
@@ -253,6 +304,9 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
lastQuestion={idx === survey.questions.length - 1}
onSubmit={submitResponse}
question={question}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
/>
)
)
+24
View File
@@ -0,0 +1,24 @@
import type { TResponseData } from "../../../types/v1/responses";
export const storeResponse = (surveyId: string, answer: TResponseData) => {
const storedResponse = localStorage.getItem(`formbricks-${surveyId}-responses`);
if (storedResponse) {
const parsedAnswers = JSON.parse(storedResponse);
localStorage.setItem(`formbricks-${surveyId}-responses`, JSON.stringify({ ...parsedAnswers, ...answer }));
} else {
localStorage.setItem(`formbricks-${surveyId}-responses`, JSON.stringify(answer));
}
};
export const getStoredResponse = (surveyId: string, questionId: string): string | null => {
const storedResponse = localStorage.getItem(`formbricks-${surveyId}-responses`);
if (storedResponse) {
const parsedAnswers = JSON.parse(storedResponse);
return parsedAnswers[questionId] || null;
}
return null;
};
export const clearStoredResponse = (surveyId: string) => {
localStorage.removeItem(`formbricks-${surveyId}-responses`);
};
+1 -1
View File
@@ -34,7 +34,7 @@ export interface Response {
formId: string;
customerId: string;
data: {
[name: string]: string | number | string[] | number[] | undefined;
[name: string]: string | number | string[] | number[] | undefined | null;
};
}
+12780 -5794
View File
File diff suppressed because it is too large Load Diff