mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-22 02:55:04 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Generated
+12780
-5794
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user