mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-06 09:00:18 -06:00
feat: multiple end screens (#2863)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com> Co-authored-by: Johannes <johannes@formbricks.com> Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
75ade97805
commit
5d347096cf
@@ -1,4 +1 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm lint-staged
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface AddEndingCardButtonProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
addEndingCard: (index: number) => void;
|
||||
}
|
||||
|
||||
export const AddEndingCardButton = ({ localSurvey, addEndingCard }: AddEndingCardButtonProps) => {
|
||||
return (
|
||||
<div
|
||||
className="group inline-flex rounded-lg border border-slate-300 bg-slate-50 hover:cursor-pointer hover:bg-white"
|
||||
onClick={() => addEndingCard(localSurvey.endings.length)}>
|
||||
<div className="flex w-10 items-center justify-center rounded-l-lg bg-slate-400 transition-all duration-300 ease-in-out group-hover:bg-slate-500 group-aria-expanded:rounded-bl-none group-aria-expanded:rounded-br">
|
||||
<PlusIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="px-4 py-3 text-sm">
|
||||
<p className="font-semibold">Add Ending</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -21,17 +21,17 @@ export const AddQuestionButton = ({ addQuestion, product }: AddQuestionButtonPro
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className={cn(
|
||||
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
|
||||
"group w-full space-y-2 rounded-lg border border-slate-300 bg-white transition-all duration-300 ease-in-out hover:scale-100 hover:cursor-pointer hover:bg-slate-50"
|
||||
open ? "shadow-lg" : "shadow-md",
|
||||
"group w-full space-y-2 rounded-lg border border-slate-300 bg-white duration-300 hover:cursor-pointer hover:bg-slate-50"
|
||||
)}>
|
||||
<Collapsible.CollapsibleTrigger asChild className="group h-full w-full">
|
||||
<div className="inline-flex">
|
||||
<div className="bg-brand-dark flex w-10 items-center justify-center rounded-l-lg group-aria-expanded:rounded-bl-none group-aria-expanded:rounded-br">
|
||||
<PlusIcon className="h-6 w-6 text-white" />
|
||||
<PlusIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<p className="font-semibold">Add Question</p>
|
||||
<p className="mt-1 text-sm text-slate-500">Add a new question to your survey</p>
|
||||
<p className="text-sm font-semibold">Add Question</p>
|
||||
<p className="mt-1 text-xs text-slate-500">Add a new question to your survey</p>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
@@ -41,7 +41,7 @@ export const AddQuestionButton = ({ addQuestion, product }: AddQuestionButtonPro
|
||||
<button
|
||||
type="button"
|
||||
key={questionType.id}
|
||||
className="mx-2 inline-flex items-center rounded p-0.5 px-4 py-2 font-medium text-slate-700 last:mb-2 hover:bg-slate-100 hover:text-slate-800"
|
||||
className="mx-2 inline-flex items-center rounded p-0.5 px-4 py-2 text-sm font-medium text-slate-700 last:mb-2 hover:bg-slate-100 hover:text-slate-800"
|
||||
onClick={() => {
|
||||
addQuestion({
|
||||
...universalQuestionPresets,
|
||||
@@ -51,7 +51,7 @@ export const AddQuestionButton = ({ addQuestion, product }: AddQuestionButtonPro
|
||||
});
|
||||
setOpen(false);
|
||||
}}>
|
||||
<questionType.icon className="text-brand-dark -ml-0.5 mr-2 h-5 w-5" aria-hidden="true" />
|
||||
<questionType.icon className="text-brand-dark -ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{questionType.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -6,8 +6,16 @@ import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys/types";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { OptionsSwitch } from "@formbricks/ui/OptionsSwitch";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: "internal",
|
||||
label: "Button to continue in survey",
|
||||
},
|
||||
{ value: "external", label: "Button to link to external URL" },
|
||||
];
|
||||
|
||||
interface CTAQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -66,25 +74,13 @@ export const CTAQuestionForm = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RadioGroup
|
||||
className="mt-3 flex"
|
||||
defaultValue="internal"
|
||||
value={question.buttonExternal ? "external" : "internal"}
|
||||
onValueChange={(e) => updateQuestion(questionIdx, { buttonExternal: e === "external" })}>
|
||||
<div className="flex items-center space-x-2 rounded-lg border border-slate-200 p-3 dark:border-slate-500">
|
||||
<RadioGroupItem value="internal" id="internal" className="bg-slate-50" />
|
||||
<Label htmlFor="internal" className="cursor-pointer dark:text-slate-200">
|
||||
Button to continue in survey
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-lg border border-slate-200 p-3 dark:border-slate-500">
|
||||
<RadioGroupItem value="external" id="external" className="bg-slate-50" />
|
||||
<Label htmlFor="external" className="cursor-pointer dark:text-slate-200">
|
||||
Button to link to external URL
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<div className="mt-3">
|
||||
<OptionsSwitch
|
||||
options={options}
|
||||
currentOption={question.buttonExternal ? "external" : "internal"}
|
||||
handleOptionChange={(e) => updateQuestion(questionIdx, { buttonExternal: e === "external" })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-between gap-8">
|
||||
<div className="flex w-full space-x-2">
|
||||
|
||||
@@ -4,8 +4,8 @@ import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyDateQuestion } from "@formbricks/types/surveys/types";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { OptionsSwitch } from "@formbricks/ui/OptionsSwitch";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
import { OptionsSwitcher } from "@formbricks/ui/QuestionTypeSelector";
|
||||
|
||||
interface IDateQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -100,10 +100,10 @@ export const DateQuestionForm = ({
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="questionType">Date Format</Label>
|
||||
<div className="mt-2 flex items-center">
|
||||
<OptionsSwitcher
|
||||
<OptionsSwitch
|
||||
options={dateOptions}
|
||||
currentOption={question.format}
|
||||
handleTypeChange={(value: "M-d-y" | "d-M-y" | "y-M-d") =>
|
||||
handleOptionChange={(value: "M-d-y" | "d-M-y" | "y-M-d") =>
|
||||
updateQuestion(questionIdx, { format: value })
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { EditorCardMenu } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu";
|
||||
import { EndScreenForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EndScreenForm";
|
||||
import { RedirectUrlForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RedirectUrlForm";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { GripIcon } from "lucide-react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
||||
import { TSurvey, TSurveyEndScreenCard, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
|
||||
import { OptionsSwitch } from "@formbricks/ui/OptionsSwitch";
|
||||
import { TooltipRenderer } from "@formbricks/ui/Tooltip";
|
||||
|
||||
interface EditEndingCardProps {
|
||||
localSurvey: TSurvey;
|
||||
endingCardIndex: number;
|
||||
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
setActiveQuestionId: (id: string | null) => void;
|
||||
activeQuestionId: string | null;
|
||||
isInvalid: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
attributeClasses: TAttributeClass[];
|
||||
plan: TOrganizationBillingPlan;
|
||||
addEndingCard: (index: number) => void;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
const endingCardTypes = [
|
||||
{ value: "endScreen", label: "Ending card" },
|
||||
{ value: "redirectToUrl", label: "Redirect to Url" },
|
||||
];
|
||||
|
||||
export const EditEndingCard = ({
|
||||
localSurvey,
|
||||
endingCardIndex,
|
||||
setLocalSurvey,
|
||||
setActiveQuestionId,
|
||||
activeQuestionId,
|
||||
isInvalid,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
attributeClasses,
|
||||
plan,
|
||||
addEndingCard,
|
||||
isFormbricksCloud,
|
||||
}: EditEndingCardProps) => {
|
||||
const endingCard = localSurvey.endings[endingCardIndex];
|
||||
const isRedirectToUrlDisabled = isFormbricksCloud
|
||||
? plan === "free" && endingCard.type !== "redirectToUrl"
|
||||
: false;
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: endingCard.id,
|
||||
});
|
||||
let open = activeQuestionId === endingCard.id;
|
||||
|
||||
const setOpen = (e) => {
|
||||
if (e) {
|
||||
setActiveQuestionId(endingCard.id);
|
||||
} else {
|
||||
setActiveQuestionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSurvey = (data: Partial<TSurveyEndScreenCard> | Partial<TSurveyRedirectUrlCard>) => {
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
const updatedEndings = prevSurvey.endings.map((ending, idx) =>
|
||||
idx === endingCardIndex ? { ...ending, ...data } : ending
|
||||
);
|
||||
return { ...prevSurvey, endings: updatedEndings };
|
||||
});
|
||||
};
|
||||
|
||||
const deleteEndingCard = () => {
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
const updatedEndings = prevSurvey.endings.filter((_, index) => index !== endingCardIndex);
|
||||
return { ...prevSurvey, endings: updatedEndings };
|
||||
});
|
||||
};
|
||||
|
||||
const style = {
|
||||
transition: transition ?? "transform 100ms ease",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
zIndex: isDragging ? 10 : 1,
|
||||
};
|
||||
|
||||
const duplicateEndingCard = () => {
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
const endingToDuplicate = prevSurvey.endings[endingCardIndex];
|
||||
const duplicatedEndingCard = {
|
||||
...endingToDuplicate,
|
||||
id: createId(),
|
||||
};
|
||||
const updatedEndings = [
|
||||
...prevSurvey.endings.slice(0, endingCardIndex + 1),
|
||||
duplicatedEndingCard,
|
||||
...prevSurvey.endings.slice(endingCardIndex + 1),
|
||||
];
|
||||
return { ...prevSurvey, endings: updatedEndings };
|
||||
});
|
||||
};
|
||||
|
||||
const moveEndingCard = (index: number, up: boolean) => {
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
const newEndings = [...prevSurvey.endings];
|
||||
const [movedEnding] = newEndings.splice(index, 1);
|
||||
newEndings.splice(up ? index - 1 : index + 1, 0, movedEnding);
|
||||
return { ...prevSurvey, endings: newEndings };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(open ? "shadow-lg" : "shadow-md", "group z-20 flex flex-row rounded-lg bg-white")}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
id={endingCard.id}>
|
||||
<div
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "",
|
||||
"flex w-10 flex-col items-center justify-between rounded-l-lg border-b border-l border-t py-2 group-aria-expanded:rounded-bl-none",
|
||||
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
|
||||
)}>
|
||||
<p className="mt-3">{endingCard.type === "endScreen" ? "🙏" : "↪️"}</p>
|
||||
<button className="opacity-0 transition-all duration-300 hover:cursor-move group-hover:opacity-100">
|
||||
<GripIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="flex-1 rounded-r-lg border border-slate-200 transition-all duration-300 ease-in-out">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className="flex cursor-pointer justify-between rounded-r-lg p-5 hover:bg-slate-50">
|
||||
<div>
|
||||
<div className="inline-flex">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">
|
||||
{endingCard.type === "endScreen" &&
|
||||
(endingCard.headline &&
|
||||
recallToHeadline(
|
||||
endingCard.headline,
|
||||
localSurvey,
|
||||
true,
|
||||
selectedLanguageCode,
|
||||
attributeClasses
|
||||
)[selectedLanguageCode]
|
||||
? formatTextWithSlashes(
|
||||
recallToHeadline(
|
||||
endingCard.headline,
|
||||
localSurvey,
|
||||
true,
|
||||
selectedLanguageCode,
|
||||
attributeClasses
|
||||
)[selectedLanguageCode]
|
||||
)
|
||||
: "Ending card")}
|
||||
{endingCard.type === "redirectToUrl" && (endingCard.label || "Redirect to Url")}
|
||||
</p>
|
||||
{!open && (
|
||||
<p className="mt-1 truncate text-xs text-slate-500">
|
||||
{endingCard.type === "endScreen" ? "Ending card" : "Redirect to Url"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<EditorCardMenu
|
||||
survey={localSurvey}
|
||||
cardIdx={endingCardIndex}
|
||||
lastCard={endingCardIndex === localSurvey.endings.length - 1}
|
||||
duplicateCard={duplicateEndingCard}
|
||||
deleteCard={deleteEndingCard}
|
||||
moveCard={moveEndingCard}
|
||||
card={endingCard}
|
||||
updateCard={() => {}}
|
||||
addCard={addEndingCard}
|
||||
cardType="ending"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="mt-3 px-4 pb-6">
|
||||
<TooltipRenderer
|
||||
shouldRender={endingCard.type === "endScreen" && isRedirectToUrlDisabled}
|
||||
tooltipContent={"Redirect To Url is not available on free plan"}
|
||||
triggerClass="w-full">
|
||||
<OptionsSwitch
|
||||
options={endingCardTypes}
|
||||
currentOption={endingCard.type}
|
||||
handleOptionChange={() => {
|
||||
if (endingCard.type === "endScreen") {
|
||||
updateSurvey({ type: "redirectToUrl" });
|
||||
} else {
|
||||
updateSurvey({ type: "endScreen" });
|
||||
}
|
||||
}}
|
||||
disabled={isRedirectToUrlDisabled}
|
||||
/>
|
||||
</TooltipRenderer>
|
||||
{endingCard.type === "endScreen" && (
|
||||
<EndScreenForm
|
||||
localSurvey={localSurvey}
|
||||
endingCardIndex={endingCardIndex}
|
||||
isInvalid={isInvalid}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
updateSurvey={updateSurvey}
|
||||
endingCard={endingCard}
|
||||
/>
|
||||
)}
|
||||
{endingCard.type === "redirectToUrl" && (
|
||||
<RedirectUrlForm endingCard={endingCard} updateSurvey={updateSurvey} />
|
||||
)}
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,200 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
|
||||
interface EditThankYouCardProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
setActiveQuestionId: (id: string | null) => void;
|
||||
activeQuestionId: string | null;
|
||||
isInvalid: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
attributeClasses: TAttributeClass[];
|
||||
}
|
||||
|
||||
export const EditThankYouCard = ({
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
setActiveQuestionId,
|
||||
activeQuestionId,
|
||||
isInvalid,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
attributeClasses,
|
||||
}: EditThankYouCardProps) => {
|
||||
// const [open, setOpen] = useState(false);
|
||||
let open = activeQuestionId == "end";
|
||||
const [showThankYouCardCTA, setshowThankYouCardCTA] = useState<boolean>(
|
||||
getLocalizedValue(localSurvey.thankYouCard.buttonLabel, "default") || localSurvey.thankYouCard.buttonLink
|
||||
? true
|
||||
: false
|
||||
);
|
||||
const setOpen = (e) => {
|
||||
if (e) {
|
||||
setActiveQuestionId("end");
|
||||
} else {
|
||||
setActiveQuestionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSurvey = (data) => {
|
||||
const updatedSurvey = {
|
||||
...localSurvey,
|
||||
thankYouCard: {
|
||||
...localSurvey.thankYouCard,
|
||||
...data,
|
||||
},
|
||||
};
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
|
||||
"group z-20 flex flex-row rounded-lg bg-white transition-transform duration-300 ease-in-out"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "",
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none",
|
||||
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
|
||||
)}>
|
||||
<p>🙏</p>
|
||||
</div>
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="flex-1 rounded-r-lg border border-slate-200 transition-all duration-300 ease-in-out">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className="flex cursor-pointer justify-between p-4 hover:bg-slate-50">
|
||||
<div>
|
||||
<div className="inline-flex">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Thank You Card</p>
|
||||
{!open && (
|
||||
<p className="mt-1 truncate text-xs text-slate-500">
|
||||
{localSurvey?.thankYouCard?.enabled ? "Shown" : "Hidden"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localSurvey.type !== "link" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label htmlFor="thank-you-toggle">Show</Label>
|
||||
|
||||
<Switch
|
||||
id="thank-you-toggle"
|
||||
checked={localSurvey?.thankYouCard?.enabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateSurvey({ enabled: !localSurvey.thankYouCard?.enabled });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="px-4 pb-6">
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
label="Note*"
|
||||
value={localSurvey?.thankYouCard?.headline}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={localSurvey.questions.length}
|
||||
isInvalid={isInvalid}
|
||||
updateSurvey={updateSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={localSurvey.thankYouCard.subheader}
|
||||
label={"Description"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={localSurvey.questions.length}
|
||||
isInvalid={isInvalid}
|
||||
updateSurvey={updateSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Switch
|
||||
id="showButton"
|
||||
checked={showThankYouCardCTA}
|
||||
onCheckedChange={() => {
|
||||
if (showThankYouCardCTA) {
|
||||
updateSurvey({ buttonLabel: undefined, buttonLink: undefined });
|
||||
} else {
|
||||
updateSurvey({
|
||||
buttonLabel: { default: "Create your own Survey" },
|
||||
buttonLink: "https://formbricks.com/signup",
|
||||
});
|
||||
}
|
||||
setshowThankYouCardCTA(!showThankYouCardCTA);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="showButton" className="cursor-pointer">
|
||||
<div className="ml-2">
|
||||
<h3 className="text-sm font-semibold text-slate-700">Show Button</h3>
|
||||
<p className="text-xs font-normal text-slate-500">
|
||||
Send your respondents to a page of your choice.
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{showThankYouCardCTA && (
|
||||
<div className="border-1 mt-4 space-y-4 rounded-md border bg-slate-100 p-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
label="Button Label"
|
||||
placeholder="Create your own Survey"
|
||||
className="bg-white"
|
||||
value={localSurvey.thankYouCard.buttonLabel}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={localSurvey.questions.length}
|
||||
isInvalid={isInvalid}
|
||||
updateSurvey={updateSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Button Link</Label>
|
||||
<Input
|
||||
id="buttonLink"
|
||||
name="buttonLink"
|
||||
className="bg-white"
|
||||
placeholder="https://formbricks.com/signup"
|
||||
value={localSurvey.thankYouCard.buttonLink}
|
||||
onChange={(e) => updateSurvey({ buttonLink: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import { useState } from "react";
|
||||
import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
|
||||
import { FileInput } from "@formbricks/ui/FileInput";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
@@ -48,7 +48,7 @@ export const EditWelcomeCard = ({
|
||||
}
|
||||
};
|
||||
|
||||
const updateSurvey = (data: Partial<TSurvey["welcomeCard"]>) => {
|
||||
const updateSurvey = (data: Partial<TSurveyWelcomeCard>) => {
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
welcomeCard: {
|
||||
@@ -59,11 +59,7 @@ export const EditWelcomeCard = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
|
||||
"group flex flex-row rounded-lg bg-white transition-transform duration-300 ease-in-out"
|
||||
)}>
|
||||
<div className={cn(open ? "shadow-lg" : "shadow-md", "group flex flex-row rounded-lg bg-white")}>
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "",
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import { QUESTIONS_ICON_MAP, QUESTIONS_NAME_MAP, getQuestionDefaults } from "@/app/lib/questions";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyRedirectUrlCard,
|
||||
ZSurveyQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { ConfirmationModal } from "@formbricks/ui/ConfirmationModal";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
|
||||
interface EditorCardMenuProps {
|
||||
survey: TSurvey;
|
||||
cardIdx: number;
|
||||
lastCard: boolean;
|
||||
duplicateCard: (cardIdx: number) => void;
|
||||
deleteCard: (cardIdx: number) => void;
|
||||
moveCard: (cardIdx: number, up: boolean) => void;
|
||||
card: TSurveyQuestion | TSurveyEndScreenCard | TSurveyRedirectUrlCard;
|
||||
updateCard: (cardIdx: number, updatedAttributes: any) => void;
|
||||
addCard: (question: any, index?: number) => void;
|
||||
cardType: "question" | "ending";
|
||||
product?: TProduct;
|
||||
}
|
||||
|
||||
export const EditorCardMenu = ({
|
||||
survey,
|
||||
cardIdx,
|
||||
lastCard,
|
||||
duplicateCard,
|
||||
deleteCard,
|
||||
moveCard,
|
||||
product,
|
||||
card,
|
||||
updateCard,
|
||||
addCard,
|
||||
cardType,
|
||||
}: EditorCardMenuProps) => {
|
||||
const [logicWarningModal, setLogicWarningModal] = useState(false);
|
||||
const [changeToType, setChangeToType] = useState(
|
||||
card.type !== "endScreen" && card.type !== "redirectToUrl" ? card.type : undefined
|
||||
);
|
||||
const isDeleteDisabled =
|
||||
cardType === "question"
|
||||
? survey.questions.length === 1
|
||||
: survey.type === "link" && survey.endings.length === 1;
|
||||
|
||||
const changeQuestionType = (type?: TSurveyQuestionTypeEnum) => {
|
||||
const parseResult = ZSurveyQuestion.safeParse(card);
|
||||
if (parseResult.success && type) {
|
||||
const question = parseResult.data;
|
||||
const { headline, required, subheader, imageUrl, videoUrl, buttonLabel, backButtonLabel } = question;
|
||||
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
|
||||
// if going from single select to multi select or vice versa, we need to keep the choices as well
|
||||
|
||||
if (
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
|
||||
) {
|
||||
updateCard(cardIdx, {
|
||||
choices: question.choices,
|
||||
type,
|
||||
logic: undefined,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
updateCard(cardIdx, {
|
||||
...questionDefaults,
|
||||
type,
|
||||
headline,
|
||||
subheader,
|
||||
required,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
logic: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addQuestionCardBelow = (type: TSurveyQuestionTypeEnum) => {
|
||||
const parseResult = ZSurveyQuestion.safeParse(card);
|
||||
if (parseResult.success) {
|
||||
const question = parseResult.data;
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
|
||||
addCard(
|
||||
{
|
||||
...questionDefaults,
|
||||
type,
|
||||
id: createId(),
|
||||
required: true,
|
||||
},
|
||||
cardIdx + 1
|
||||
);
|
||||
|
||||
// scroll to the new question
|
||||
const section = document.getElementById(`${question.id}`);
|
||||
section?.scrollIntoView({ behavior: "smooth", block: "end", inline: "end" });
|
||||
}
|
||||
};
|
||||
|
||||
const addEndingCardBelow = () => {
|
||||
addCard(cardIdx + 1);
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
changeQuestionType(changeToType);
|
||||
setLogicWarningModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
<CopyIcon
|
||||
className="h-4 cursor-pointer text-slate-500 hover:text-slate-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicateCard(cardIdx);
|
||||
}}
|
||||
/>
|
||||
<TrashIcon
|
||||
className={cn(
|
||||
"h-4 cursor-pointer text-slate-500",
|
||||
isDeleteDisabled ? "cursor-not-allowed opacity-50" : "hover:text-slate-600"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isDeleteDisabled) return;
|
||||
deleteCard(cardIdx);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<EllipsisIcon className="h-4 w-4 text-slate-500 hover:text-slate-600" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="border border-slate-200">
|
||||
<div className="flex flex-col">
|
||||
{cardType === "question" && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="cursor-pointer text-sm text-slate-600 hover:text-slate-700">
|
||||
Change question type
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent className="ml-2 border border-slate-200 text-slate-600 hover:text-slate-700">
|
||||
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
|
||||
const parsedResult = ZSurveyQuestion.safeParse(card);
|
||||
if (parsedResult.success) {
|
||||
const question = parsedResult.data;
|
||||
if (type === question.type) return null;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
className="min-h-8 cursor-pointer"
|
||||
onClick={() => {
|
||||
setChangeToType(type as TSurveyQuestionTypeEnum);
|
||||
if (question.logic) {
|
||||
setLogicWarningModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
changeQuestionType(type as TSurveyQuestionTypeEnum);
|
||||
}}>
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{cardType === "ending" && (
|
||||
<DropdownMenuItem
|
||||
className="flex min-h-8 cursor-pointer justify-between text-slate-600 hover:text-slate-700"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
addEndingCardBelow();
|
||||
}}>
|
||||
<span className="text-sm">Add ending below</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{cardType === "question" && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="cursor-pointer text-sm text-slate-600 hover:text-slate-700">
|
||||
Add question below
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent className="ml-4 border border-slate-200">
|
||||
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
|
||||
if (type === card.type) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
className="min-h-8 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (cardType === "question") {
|
||||
addQuestionCardBelow(type as TSurveyQuestionTypeEnum);
|
||||
}
|
||||
}}>
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className={`flex min-h-8 cursor-pointer justify-between text-slate-600 hover:text-slate-700 ${
|
||||
cardIdx === 0 ? "opacity-50" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (cardIdx !== 0) {
|
||||
e.stopPropagation();
|
||||
moveCard(cardIdx, true);
|
||||
}
|
||||
}}
|
||||
disabled={cardIdx === 0}>
|
||||
<span className="text-sm">Move up</span>
|
||||
<ArrowUpIcon className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className={`flex min-h-8 cursor-pointer justify-between text-slate-600 hover:text-slate-700 ${
|
||||
lastCard ? "opacity-50" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (!lastCard) {
|
||||
e.stopPropagation();
|
||||
moveCard(cardIdx, false);
|
||||
}
|
||||
}}
|
||||
disabled={lastCard}>
|
||||
<span className="text-sm text-slate-600 hover:text-slate-700">Move down</span>
|
||||
<ArrowDownIcon className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<ConfirmationModal
|
||||
open={logicWarningModal}
|
||||
setOpen={setLogicWarningModal}
|
||||
title="Changing will cause logic errors"
|
||||
text="Changing the question type will remove the logic conditions from this question"
|
||||
buttonText="Change anyway"
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyEndScreenCard } from "@formbricks/types/surveys/types";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
|
||||
interface EndScreenFormProps {
|
||||
localSurvey: TSurvey;
|
||||
endingCardIndex: number;
|
||||
isInvalid: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
attributeClasses: TAttributeClass[];
|
||||
updateSurvey: (input: Partial<TSurveyEndScreenCard>) => void;
|
||||
endingCard: TSurveyEndScreenCard;
|
||||
}
|
||||
|
||||
export const EndScreenForm = ({
|
||||
localSurvey,
|
||||
endingCardIndex,
|
||||
isInvalid,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
attributeClasses,
|
||||
updateSurvey,
|
||||
endingCard,
|
||||
}: EndScreenFormProps) => {
|
||||
const [showEndingCardCTA, setshowEndingCardCTA] = useState<boolean>(
|
||||
endingCard.type === "endScreen" &&
|
||||
(!!getLocalizedValue(endingCard.buttonLabel, selectedLanguageCode) || !!endingCard.buttonLink)
|
||||
);
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
label="Note*"
|
||||
value={endingCard.headline}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={localSurvey.questions.length + endingCardIndex}
|
||||
isInvalid={isInvalid}
|
||||
updateSurvey={updateSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={endingCard.subheader}
|
||||
label={"Description"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={localSurvey.questions.length + endingCardIndex}
|
||||
isInvalid={isInvalid}
|
||||
updateSurvey={updateSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Switch
|
||||
id="showButton"
|
||||
checked={showEndingCardCTA}
|
||||
onCheckedChange={() => {
|
||||
if (showEndingCardCTA) {
|
||||
updateSurvey({ buttonLabel: undefined, buttonLink: undefined });
|
||||
} else {
|
||||
updateSurvey({
|
||||
buttonLabel: { default: "Create your own Survey" },
|
||||
buttonLink: "https://formbricks.com/signup",
|
||||
});
|
||||
}
|
||||
setshowEndingCardCTA(!showEndingCardCTA);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="showButton" className="cursor-pointer">
|
||||
<div className="ml-2">
|
||||
<h3 className="text-sm font-semibold text-slate-700">Show Button</h3>
|
||||
<p className="text-xs font-normal text-slate-500">
|
||||
Send your respondents to a page of your choice.
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{showEndingCardCTA && (
|
||||
<div className="border-1 mt-4 space-y-4 rounded-md border bg-slate-100 p-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
label="Button Label"
|
||||
placeholder="Create your own Survey"
|
||||
className="bg-white"
|
||||
value={endingCard.buttonLabel}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={localSurvey.questions.length + endingCardIndex}
|
||||
isInvalid={isInvalid}
|
||||
updateSurvey={updateSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Button Link</Label>
|
||||
<Input
|
||||
id="buttonLink"
|
||||
name="buttonLink"
|
||||
className="bg-white"
|
||||
placeholder="https://formbricks.com/signup"
|
||||
value={endingCard.buttonLink}
|
||||
onChange={(e) => updateSurvey({ buttonLink: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -47,11 +47,7 @@ export const HiddenFieldsCard = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
|
||||
"group z-10 flex flex-row rounded-lg bg-white transition-transform duration-300 ease-in-out"
|
||||
)}>
|
||||
<div className={cn(open ? "shadow-lg" : "shadow-md", "group z-10 flex flex-row rounded-lg bg-white")}>
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
|
||||
@@ -118,11 +114,13 @@ export const HiddenFieldsCard = ({
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const existingQuestionIds = localSurvey.questions.map((question) => question.id);
|
||||
const existingEndingCardIds = localSurvey.endings.map((ending) => ending.id);
|
||||
const existingHiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
|
||||
const validateIdError = validateId(
|
||||
"Hidden field",
|
||||
hiddenField,
|
||||
existingQuestionIds,
|
||||
existingEndingCardIds,
|
||||
existingHiddenFieldIds
|
||||
);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AlertCircleIcon, BlocksIcon, CheckIcon, EarthIcon, LinkIcon, MonitorIco
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { getDefaultEndingCard } from "@formbricks/lib/templates";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
@@ -40,13 +41,14 @@ export const HowToSendCard = ({
|
||||
}, [environment]);
|
||||
|
||||
const setSurveyType = (type: TSurveyType) => {
|
||||
const endingsTemp = localSurvey.endings;
|
||||
if (type === "link" && localSurvey.endings.length === 0) {
|
||||
endingsTemp.push(getDefaultEndingCard(localSurvey.languages));
|
||||
}
|
||||
setLocalSurvey((prevSurvey) => ({
|
||||
...prevSurvey,
|
||||
type,
|
||||
thankYouCard: {
|
||||
...prevSurvey.thankYouCard,
|
||||
enabled: type === "link" ? true : prevSurvey.thankYouCard.enabled,
|
||||
},
|
||||
endings: endingsTemp,
|
||||
}));
|
||||
|
||||
// if the type is "app" and the local survey does not already have a segment, we create a new temporary segment
|
||||
|
||||
@@ -423,7 +423,15 @@ export const LogicEditor = ({
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
<SelectItem value="end">End of survey</SelectItem>
|
||||
{localSurvey.endings.map((ending) => {
|
||||
return (
|
||||
<SelectItem value={ending.id}>
|
||||
{ending.type === "endScreen"
|
||||
? getLocalizedValue(ending.headline, "default")
|
||||
: ending.label}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div>
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { OptionsSwitch } from "@formbricks/ui/OptionsSwitch";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
import { OptionsSwitcher } from "@formbricks/ui/QuestionTypeSelector";
|
||||
|
||||
const questionTypes = [
|
||||
{ value: "text", label: "Text", icon: <MessageSquareTextIcon className="h-4 w-4" /> },
|
||||
@@ -127,10 +127,10 @@ export const OpenQuestionForm = ({
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="questionType">Input Type</Label>
|
||||
<div className="mt-2 flex items-center">
|
||||
<OptionsSwitcher
|
||||
<OptionsSwitch
|
||||
options={questionTypes}
|
||||
currentOption={question.inputType}
|
||||
handleTypeChange={handleInputChange} // Use the merged function
|
||||
handleOptionChange={handleInputChange} // Use the merged function
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
|
||||
import { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeEnumName } from "@/app/lib/questions";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
@@ -25,13 +26,13 @@ import { CTAQuestionForm } from "./CTAQuestionForm";
|
||||
import { CalQuestionForm } from "./CalQuestionForm";
|
||||
import { ConsentQuestionForm } from "./ConsentQuestionForm";
|
||||
import { DateQuestionForm } from "./DateQuestionForm";
|
||||
import { EditorCardMenu } from "./EditorCardMenu";
|
||||
import { FileUploadQuestionForm } from "./FileUploadQuestionForm";
|
||||
import { MatrixQuestionForm } from "./MatrixQuestionForm";
|
||||
import { MultipleChoiceQuestionForm } from "./MultipleChoiceQuestionForm";
|
||||
import { NPSQuestionForm } from "./NPSQuestionForm";
|
||||
import { OpenQuestionForm } from "./OpenQuestionForm";
|
||||
import { PictureSelectionForm } from "./PictureSelectionForm";
|
||||
import { QuestionMenu } from "./QuestionMenu";
|
||||
import { RatingQuestionForm } from "./RatingQuestionForm";
|
||||
|
||||
interface QuestionCardProps {
|
||||
@@ -80,25 +81,6 @@ export const QuestionCard = ({
|
||||
const open = activeQuestionId === question.id;
|
||||
const [openAdvanced, setOpenAdvanced] = useState(question.logic && question.logic.length > 0);
|
||||
|
||||
// formats the text to highlight specific parts of the text with slashes
|
||||
const formatTextWithSlashes = (text) => {
|
||||
const regex = /\/(.*?)\\/g;
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
// Check if the part was inside slashes
|
||||
if (index % 2 !== 0) {
|
||||
return (
|
||||
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-xs">
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return part;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateEmptyNextButtonLabels = (labelValue: TI18nString) => {
|
||||
localSurvey.questions.forEach((q, index) => {
|
||||
if (index === localSurvey.questions.length - 1) return;
|
||||
@@ -140,8 +122,8 @@ export const QuestionCard = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
|
||||
"flex w-full flex-row rounded-lg bg-white transition-all duration-300 ease-in-out"
|
||||
open ? "shadow-lg" : "shadow-md",
|
||||
"flex w-full flex-row rounded-lg bg-white duration-300"
|
||||
)}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
@@ -151,11 +133,11 @@ export const QuestionCard = ({
|
||||
{...attributes}
|
||||
className={cn(
|
||||
open ? "bg-slate-700" : "bg-slate-400",
|
||||
"top-0 w-[5%] rounded-l-lg p-2 text-center text-sm text-white hover:cursor-grab hover:bg-slate-600",
|
||||
"top-0 w-10 rounded-l-lg p-2 text-center text-sm text-white hover:cursor-grab hover:bg-slate-600",
|
||||
isInvalid && "bg-red-400 hover:bg-red-600",
|
||||
"flex flex-col items-center justify-between"
|
||||
)}>
|
||||
<span>{questionIdx + 1}</span>
|
||||
<div className="mt-3 flex w-full justify-center">{QUESTIONS_ICON_MAP[question.type]}</div>
|
||||
|
||||
<button className="opacity-0 hover:cursor-move group-hover:opacity-100">
|
||||
<GripIcon className="h-4 w-4" />
|
||||
@@ -173,12 +155,15 @@ export const QuestionCard = ({
|
||||
className="w-[95%] flex-1 rounded-r-lg border border-slate-200">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className={cn(open ? "" : " ", "flex cursor-pointer justify-between gap-4 p-4 hover:bg-slate-50")}>
|
||||
className={cn(
|
||||
open ? "" : " ",
|
||||
"flex cursor-pointer justify-between gap-4 rounded-r-lg p-4 hover:bg-slate-50"
|
||||
)}>
|
||||
<div>
|
||||
<div className="flex grow">
|
||||
<div className="-ml-0.5 mr-3 h-6 min-w-[1.5rem] text-slate-400">
|
||||
{/* <div className="-ml-0.5 mr-3 h-6 min-w-[1.5rem] text-slate-400">
|
||||
{QUESTIONS_ICON_MAP[question.type]}
|
||||
</div>
|
||||
</div> */}
|
||||
<div className="grow" dir="auto">
|
||||
<p className="text-sm font-semibold">
|
||||
{recallToHeadline(
|
||||
@@ -199,23 +184,27 @@ export const QuestionCard = ({
|
||||
)
|
||||
: getTSurveyQuestionTypeEnumName(question.type)}
|
||||
</p>
|
||||
{!open && question?.required && (
|
||||
<p className="mt-1 truncate text-xs text-slate-500">{question?.required && "Required"}</p>
|
||||
{!open && (
|
||||
<p className="mt-1 truncate text-xs text-slate-500">
|
||||
{question?.required ? "Required" : "Optional"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<QuestionMenu
|
||||
questionIdx={questionIdx}
|
||||
lastQuestion={lastQuestion}
|
||||
duplicateQuestion={duplicateQuestion}
|
||||
deleteQuestion={deleteQuestion}
|
||||
moveQuestion={moveQuestion}
|
||||
question={question}
|
||||
<EditorCardMenu
|
||||
survey={localSurvey}
|
||||
cardIdx={questionIdx}
|
||||
lastCard={lastQuestion}
|
||||
duplicateCard={duplicateQuestion}
|
||||
deleteCard={deleteQuestion}
|
||||
moveCard={moveQuestion}
|
||||
card={question}
|
||||
product={product}
|
||||
updateQuestion={updateQuestion}
|
||||
addQuestion={addQuestion}
|
||||
updateCard={updateQuestion}
|
||||
addCard={addQuestion}
|
||||
cardType="question"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { QUESTIONS_ICON_MAP, QUESTIONS_NAME_MAP, getQuestionDefaults } from "@/app/lib/questions";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { ConfirmationModal } from "@formbricks/ui/ConfirmationModal";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
|
||||
interface QuestionMenuProps {
|
||||
questionIdx: number;
|
||||
lastQuestion: boolean;
|
||||
duplicateQuestion: (questionIdx: number) => void;
|
||||
deleteQuestion: (questionIdx: number) => void;
|
||||
moveQuestion: (questionIdx: number, up: boolean) => void;
|
||||
question: TSurveyQuestion;
|
||||
product: TProduct;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
addQuestion: (question: any, index?: number) => void;
|
||||
}
|
||||
|
||||
export const QuestionMenu = ({
|
||||
questionIdx,
|
||||
lastQuestion,
|
||||
duplicateQuestion,
|
||||
deleteQuestion,
|
||||
moveQuestion,
|
||||
product,
|
||||
question,
|
||||
updateQuestion,
|
||||
addQuestion,
|
||||
}: QuestionMenuProps) => {
|
||||
const [logicWarningModal, setLogicWarningModal] = useState(false);
|
||||
const [changeToType, setChangeToType] = useState(question.type);
|
||||
|
||||
const changeQuestionType = (type: TSurveyQuestionTypeEnum) => {
|
||||
const { headline, required, subheader, imageUrl, videoUrl, buttonLabel, backButtonLabel } = question;
|
||||
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
|
||||
// if going from single select to multi select or vice versa, we need to keep the choices as well
|
||||
|
||||
if (
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
|
||||
) {
|
||||
updateQuestion(questionIdx, {
|
||||
choices: question.choices,
|
||||
type,
|
||||
logic: undefined,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
...questionDefaults,
|
||||
type,
|
||||
headline,
|
||||
subheader,
|
||||
required,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
logic: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const addQuestionBelow = (type: TSurveyQuestionTypeEnum) => {
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
|
||||
addQuestion(
|
||||
{
|
||||
...questionDefaults,
|
||||
type,
|
||||
id: createId(),
|
||||
required: true,
|
||||
},
|
||||
questionIdx + 1
|
||||
);
|
||||
|
||||
// scroll to the new question
|
||||
const section = document.getElementById(`${question.id}`);
|
||||
section?.scrollIntoView({ behavior: "smooth", block: "end", inline: "end" });
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
changeQuestionType(changeToType);
|
||||
setLogicWarningModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
<CopyIcon
|
||||
className="h-4 cursor-pointer text-slate-500 hover:text-slate-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicateQuestion(questionIdx);
|
||||
}}
|
||||
/>
|
||||
<TrashIcon
|
||||
className="h-4 cursor-pointer text-slate-500 hover:text-slate-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteQuestion(questionIdx);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<EllipsisIcon className="h-4 w-4 text-slate-500 hover:text-slate-600" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<div className="flex flex-col">
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<div className="cursor-pointer text-slate-500 hover:text-slate-600">
|
||||
<span className="text-xs text-slate-500">Change question type</span>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent className="ml-4 border border-slate-200">
|
||||
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
|
||||
if (type === question.type) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
className="min-h-8 cursor-pointer text-slate-500"
|
||||
onClick={() => {
|
||||
setChangeToType(type as TSurveyQuestionTypeEnum);
|
||||
if (question.logic) {
|
||||
setLogicWarningModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
changeQuestionType(type as TSurveyQuestionTypeEnum);
|
||||
}}>
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<div className="cursor-pointer text-slate-500 hover:text-slate-600">
|
||||
<span className="text-xs text-slate-500">Add question below</span>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent className="ml-4 border border-slate-200">
|
||||
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
|
||||
if (type === question.type) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
className="min-h-8 cursor-pointer text-slate-500"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
addQuestionBelow(type as TSurveyQuestionTypeEnum);
|
||||
}}>
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem
|
||||
className={`flex min-h-8 cursor-pointer justify-between text-slate-500 hover:text-slate-600 ${
|
||||
questionIdx === 0 ? "opacity-50" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (questionIdx !== 0) {
|
||||
e.stopPropagation();
|
||||
moveQuestion(questionIdx, true);
|
||||
}
|
||||
}}
|
||||
disabled={questionIdx === 0}>
|
||||
<span className="text-xs text-slate-500">Move up</span>
|
||||
<ArrowUpIcon className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className={`flex min-h-8 cursor-pointer justify-between text-slate-500 hover:text-slate-600 ${
|
||||
lastQuestion ? "opacity-50" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (!lastQuestion) {
|
||||
e.stopPropagation();
|
||||
moveQuestion(questionIdx, false);
|
||||
}
|
||||
}}
|
||||
disabled={lastQuestion}>
|
||||
<span className="text-xs text-slate-500">Move down</span>
|
||||
<ArrowDownIcon className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<ConfirmationModal
|
||||
open={logicWarningModal}
|
||||
setOpen={setLogicWarningModal}
|
||||
title="Changing will cause logic errors"
|
||||
text="Changing the question type will remove the logic conditions from this question"
|
||||
buttonText="Change anyway"
|
||||
onConfirm={onConfirm}
|
||||
buttonVariant="primary"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AddEndingCardButton } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddEndingCardButton";
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
@@ -8,20 +9,28 @@ import {
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import React, { SetStateAction, useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { MultiLanguageCard } from "@formbricks/ee/multi-language/components/multi-language-card";
|
||||
import { extractLanguageCodes, getLocalizedValue, translateQuestion } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { getDefaultEndingCard } from "@formbricks/lib/templates";
|
||||
import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { findQuestionsWithCyclicLogic } from "@formbricks/types/surveys/validation";
|
||||
import { isCardValid, validateQuestion, validateSurveyQuestionsInBatch } from "../lib/validation";
|
||||
import {
|
||||
isEndingCardValid,
|
||||
isWelcomeCardValid,
|
||||
validateQuestion,
|
||||
validateSurveyQuestionsInBatch,
|
||||
} from "../lib/validation";
|
||||
import { AddQuestionButton } from "./AddQuestionButton";
|
||||
import { EditThankYouCard } from "./EditThankYouCard";
|
||||
import { EditEndingCard } from "./EditEndingCard";
|
||||
import { EditWelcomeCard } from "./EditWelcomeCard";
|
||||
import { HiddenFieldsCard } from "./HiddenFieldsCard";
|
||||
import { QuestionsDroppable } from "./QuestionsDroppable";
|
||||
@@ -39,6 +48,7 @@ interface QuestionsViewProps {
|
||||
isMultiLanguageAllowed?: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
attributeClasses: TAttributeClass[];
|
||||
plan: TOrganizationBillingPlan;
|
||||
}
|
||||
|
||||
export const QuestionsView = ({
|
||||
@@ -54,6 +64,7 @@ export const QuestionsView = ({
|
||||
isMultiLanguageAllowed,
|
||||
isFormbricksCloud,
|
||||
attributeClasses,
|
||||
plan,
|
||||
}: QuestionsViewProps) => {
|
||||
const internalQuestionIdMap = useMemo(() => {
|
||||
return localSurvey.questions.reduce((acc, question) => {
|
||||
@@ -82,6 +93,36 @@ export const QuestionsView = ({
|
||||
return survey;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!invalidQuestions) return;
|
||||
let updatedInvalidQuestions: string[] = invalidQuestions;
|
||||
|
||||
// Check welcome card
|
||||
if (localSurvey.welcomeCard.enabled && !isWelcomeCardValid(localSurvey.welcomeCard, surveyLanguages)) {
|
||||
if (!updatedInvalidQuestions.includes("start")) {
|
||||
updatedInvalidQuestions.push("start");
|
||||
}
|
||||
} else {
|
||||
updatedInvalidQuestions = updatedInvalidQuestions.filter((questionId) => questionId !== "start");
|
||||
}
|
||||
|
||||
// Check thank you card
|
||||
localSurvey.endings.forEach((ending) => {
|
||||
if (!isEndingCardValid(ending, surveyLanguages)) {
|
||||
if (!updatedInvalidQuestions.includes(ending.id)) {
|
||||
updatedInvalidQuestions.push(ending.id);
|
||||
}
|
||||
} else {
|
||||
updatedInvalidQuestions = updatedInvalidQuestions.filter((questionId) => questionId !== ending.id);
|
||||
}
|
||||
});
|
||||
|
||||
if (JSON.stringify(updatedInvalidQuestions) !== JSON.stringify(invalidQuestions)) {
|
||||
setInvalidQuestions(updatedInvalidQuestions);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSurvey.languages, localSurvey.endings, localSurvey.welcomeCard]);
|
||||
|
||||
// function to validate individual questions
|
||||
const validateSurveyQuestion = (question: TSurveyQuestion) => {
|
||||
// prevent this function to execute further if user hasnt still tried to save the survey
|
||||
@@ -111,7 +152,7 @@ export const QuestionsView = ({
|
||||
const updateQuestion = (questionIdx: number, updatedAttributes: any) => {
|
||||
let updatedSurvey = { ...localSurvey };
|
||||
if ("id" in updatedAttributes) {
|
||||
// if the survey whose id is to be changed is linked to logic of any other survey then changing it
|
||||
// if the survey question whose id is to be changed is linked to logic of any other survey then changing it
|
||||
const initialQuestionId = updatedSurvey.questions[questionIdx].id;
|
||||
updatedSurvey = handleQuestionLogicChange(updatedSurvey, initialQuestionId, updatedAttributes.id);
|
||||
if (invalidQuestions?.includes(initialQuestionId)) {
|
||||
@@ -181,14 +222,15 @@ export const QuestionsView = ({
|
||||
}
|
||||
});
|
||||
updatedSurvey.questions.splice(questionIdx, 1);
|
||||
updatedSurvey = handleQuestionLogicChange(updatedSurvey, questionId, "end");
|
||||
updatedSurvey = handleQuestionLogicChange(updatedSurvey, questionId, "");
|
||||
const firstEndingCard = localSurvey.endings[0];
|
||||
setLocalSurvey(updatedSurvey);
|
||||
delete internalQuestionIdMap[questionId];
|
||||
if (questionId === activeQuestionIdTemp) {
|
||||
if (questionIdx <= localSurvey.questions.length && localSurvey.questions.length > 0) {
|
||||
setActiveQuestionId(localSurvey.questions[questionIdx % localSurvey.questions.length].id);
|
||||
} else if (localSurvey.thankYouCard.enabled) {
|
||||
setActiveQuestionId("end");
|
||||
} else if (firstEndingCard) {
|
||||
setActiveQuestionId(firstEndingCard.id);
|
||||
}
|
||||
}
|
||||
toast.success("Question deleted.");
|
||||
@@ -235,6 +277,15 @@ export const QuestionsView = ({
|
||||
internalQuestionIdMap[question.id] = createId();
|
||||
};
|
||||
|
||||
const addEndingCard = (index: number) => {
|
||||
const updatedSurvey = structuredClone(localSurvey);
|
||||
const newEndingCard = getDefaultEndingCard(localSurvey.languages);
|
||||
|
||||
updatedSurvey.endings.splice(index, 0, newEndingCard);
|
||||
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
const moveQuestion = (questionIndex: number, up: boolean) => {
|
||||
const newQuestions = Array.from(localSurvey.questions);
|
||||
const [reorderedQuestion] = newQuestions.splice(questionIndex, 1);
|
||||
@@ -244,29 +295,6 @@ export const QuestionsView = ({
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (invalidQuestions === null) return;
|
||||
|
||||
const updateInvalidQuestions = (card, cardId, currentInvalidQuestions) => {
|
||||
if (card.enabled && !isCardValid(card, cardId, surveyLanguages)) {
|
||||
return currentInvalidQuestions.includes(cardId)
|
||||
? currentInvalidQuestions
|
||||
: [...currentInvalidQuestions, cardId];
|
||||
}
|
||||
return currentInvalidQuestions.filter((id) => id !== cardId);
|
||||
};
|
||||
|
||||
const updatedQuestionsStart = updateInvalidQuestions(localSurvey.welcomeCard, "start", invalidQuestions);
|
||||
const updatedQuestionsEnd = updateInvalidQuestions(
|
||||
localSurvey.thankYouCard,
|
||||
"end",
|
||||
updatedQuestionsStart
|
||||
);
|
||||
|
||||
setInvalidQuestions(updatedQuestionsEnd);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSurvey.welcomeCard, localSurvey.thankYouCard]);
|
||||
|
||||
//useEffect to validate survey when changes are made to languages
|
||||
useEffect(() => {
|
||||
if (!invalidQuestions) return;
|
||||
@@ -281,29 +309,11 @@ export const QuestionsView = ({
|
||||
);
|
||||
});
|
||||
|
||||
// Check welcome card
|
||||
if (localSurvey.welcomeCard.enabled && !isCardValid(localSurvey.welcomeCard, "start", surveyLanguages)) {
|
||||
if (!updatedInvalidQuestions.includes("start")) {
|
||||
updatedInvalidQuestions.push("start");
|
||||
}
|
||||
} else {
|
||||
updatedInvalidQuestions = updatedInvalidQuestions.filter((questionId) => questionId !== "start");
|
||||
}
|
||||
|
||||
// Check thank you card
|
||||
if (localSurvey.thankYouCard.enabled && !isCardValid(localSurvey.thankYouCard, "end", surveyLanguages)) {
|
||||
if (!updatedInvalidQuestions.includes("end")) {
|
||||
updatedInvalidQuestions.push("end");
|
||||
}
|
||||
} else {
|
||||
updatedInvalidQuestions = updatedInvalidQuestions.filter((questionId) => questionId !== "end");
|
||||
}
|
||||
|
||||
if (JSON.stringify(updatedInvalidQuestions) !== JSON.stringify(invalidQuestions)) {
|
||||
setInvalidQuestions(updatedInvalidQuestions);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSurvey.languages, localSurvey.questions]);
|
||||
}, [localSurvey.languages, localSurvey.questions, localSurvey.endings, localSurvey.welcomeCard]);
|
||||
|
||||
useEffect(() => {
|
||||
const questionWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, selectedLanguageCode);
|
||||
@@ -324,7 +334,7 @@ export const QuestionsView = ({
|
||||
})
|
||||
);
|
||||
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
const onQuestionCardDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
const newQuestions = Array.from(localSurvey.questions);
|
||||
@@ -336,6 +346,17 @@ export const QuestionsView = ({
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
const onEndingCardDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
const newEndings = Array.from(localSurvey.endings);
|
||||
const sourceIndex = newEndings.findIndex((ending) => ending.id === active.id);
|
||||
const destinationIndex = newEndings.findIndex((ending) => ending.id === over?.id);
|
||||
const [reorderedEndings] = newEndings.splice(sourceIndex, 1);
|
||||
newEndings.splice(destinationIndex, 0, reorderedEndings);
|
||||
const updatedSurvey = { ...localSurvey, endings: newEndings };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-16 w-full px-5 py-4">
|
||||
<div className="mb-5 flex w-full flex-col gap-5">
|
||||
@@ -351,7 +372,7 @@ export const QuestionsView = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DndContext sensors={sensors} onDragEnd={onDragEnd} collisionDetection={closestCorners}>
|
||||
<DndContext sensors={sensors} onDragEnd={onQuestionCardDragEnd} collisionDetection={closestCorners}>
|
||||
<QuestionsDroppable
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
@@ -373,16 +394,37 @@ export const QuestionsView = ({
|
||||
|
||||
<AddQuestionButton addQuestion={addQuestion} product={product} />
|
||||
<div className="mt-5 flex flex-col gap-5">
|
||||
<EditThankYouCard
|
||||
<hr className="border-t border-dashed" />
|
||||
<DndContext sensors={sensors} onDragEnd={onEndingCardDragEnd} collisionDetection={closestCorners}>
|
||||
<SortableContext items={localSurvey.endings} strategy={verticalListSortingStrategy}>
|
||||
{localSurvey.endings.map((ending, index) => {
|
||||
return (
|
||||
<EditEndingCard
|
||||
key={ending.id}
|
||||
localSurvey={localSurvey}
|
||||
endingCardIndex={index}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(ending.id) : false}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
plan={plan}
|
||||
addEndingCard={addEndingCard}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<AddEndingCardButton
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes("end") : false}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
addEndingCard={addEndingCard}
|
||||
/>
|
||||
<hr />
|
||||
|
||||
<HiddenFieldsCard
|
||||
localSurvey={localSurvey}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import { TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
interface RedirectUrlFormProps {
|
||||
endingCard: TSurveyRedirectUrlCard;
|
||||
updateSurvey: (input: Partial<TSurveyRedirectUrlCard>) => void;
|
||||
}
|
||||
|
||||
export const RedirectUrlForm = ({ endingCard, updateSurvey }: RedirectUrlFormProps) => {
|
||||
return (
|
||||
<form className="mt-3 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>URL</Label>
|
||||
<Input
|
||||
id="redirectUrl"
|
||||
name="redirectUrl"
|
||||
className="bg-white"
|
||||
placeholder="https://formbricks.com/signup"
|
||||
value={endingCard.url}
|
||||
onChange={(e) => updateSurvey({ url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Label</Label>
|
||||
<Input
|
||||
id="redirectUrlLabel"
|
||||
name="redirectUrlLabel"
|
||||
className="bg-white"
|
||||
placeholder="Formbricks App"
|
||||
value={endingCard.label}
|
||||
onChange={(e) => updateSurvey({ label: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -26,11 +26,9 @@ export const ResponseOptionsCard = ({
|
||||
}: ResponseOptionsCardProps) => {
|
||||
const [open, setOpen] = useState(localSurvey.type === "link" ? true : false);
|
||||
const autoComplete = localSurvey.autoComplete !== null;
|
||||
const [redirectToggle, setRedirectToggle] = useState(false);
|
||||
const [runOnDateToggle, setRunOnDateToggle] = useState(false);
|
||||
const [closeOnDateToggle, setCloseOnDateToggle] = useState(false);
|
||||
useState;
|
||||
const [redirectUrl, setRedirectUrl] = useState<string | null>("");
|
||||
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
|
||||
const [verifyEmailToggle, setVerifyEmailToggle] = useState(false);
|
||||
|
||||
@@ -56,15 +54,6 @@ export const ResponseOptionsCard = ({
|
||||
|
||||
const [verifyProtectWithPinError, setVerifyProtectWithPinError] = useState<string | null>(null);
|
||||
|
||||
const handleRedirectCheckMark = () => {
|
||||
setRedirectToggle((prev) => !prev);
|
||||
|
||||
if (redirectToggle && localSurvey.redirectUrl) {
|
||||
setRedirectUrl(null);
|
||||
setLocalSurvey({ ...localSurvey, redirectUrl: null });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunOnDateToggle = () => {
|
||||
if (runOnDateToggle) {
|
||||
setRunOnDateToggle(false);
|
||||
@@ -116,11 +105,6 @@ export const ResponseOptionsCard = ({
|
||||
if (exceptThisSymbols.includes(e.key)) e.preventDefault();
|
||||
};
|
||||
|
||||
const handleRedirectUrlChange = (link: string) => {
|
||||
setRedirectUrl(link);
|
||||
setLocalSurvey({ ...localSurvey, redirectUrl: link });
|
||||
};
|
||||
|
||||
const handleCloseSurveyMessageToggle = () => {
|
||||
setSurveyClosedMessageToggle((prev) => !prev);
|
||||
|
||||
@@ -236,11 +220,6 @@ export const ResponseOptionsCard = ({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (localSurvey.redirectUrl) {
|
||||
setRedirectUrl(localSurvey.redirectUrl);
|
||||
setRedirectToggle(true);
|
||||
}
|
||||
|
||||
if (!!localSurvey.surveyClosedMessage) {
|
||||
setSurveyClosedMessage({
|
||||
heading: localSurvey.surveyClosedMessage.heading ?? surveyClosedMessage.heading,
|
||||
@@ -389,26 +368,6 @@ export const ResponseOptionsCard = ({
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
{/* Redirect on completion */}
|
||||
<AdvancedOptionToggle
|
||||
htmlId="redirectUrl"
|
||||
isChecked={redirectToggle}
|
||||
onToggle={handleRedirectCheckMark}
|
||||
title="Redirect on completion"
|
||||
description="Redirect user to link destination when they completed the survey"
|
||||
childBorder={true}>
|
||||
<div className="w-full p-4">
|
||||
<Input
|
||||
autoFocus
|
||||
className="w-full bg-white"
|
||||
type="url"
|
||||
placeholder="https://www.example.com"
|
||||
value={redirectUrl ? redirectUrl : ""}
|
||||
onChange={(e) => handleRedirectUrlChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
{localSurvey.type === "link" && (
|
||||
<>
|
||||
{/* Adjust Survey Closed Message */}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey, TSurveyEditorTabs, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
@@ -35,6 +36,7 @@ interface SurveyEditorProps {
|
||||
isMultiLanguageAllowed?: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
isUnsplashConfigured: boolean;
|
||||
plan: TOrganizationBillingPlan;
|
||||
}
|
||||
|
||||
export const SurveyEditor = ({
|
||||
@@ -52,6 +54,7 @@ export const SurveyEditor = ({
|
||||
isUserTargetingAllowed = false,
|
||||
isFormbricksCloud,
|
||||
isUnsplashConfigured,
|
||||
plan,
|
||||
}: SurveyEditorProps) => {
|
||||
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("questions");
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
|
||||
@@ -144,7 +147,7 @@ export const SurveyEditor = ({
|
||||
/>
|
||||
<div className="relative z-0 flex flex-1 overflow-hidden">
|
||||
<main
|
||||
className="relative z-0 w-1/2 flex-1 overflow-y-auto focus:outline-none"
|
||||
className="relative z-0 w-1/2 flex-1 overflow-y-auto bg-slate-50 focus:outline-none"
|
||||
ref={surveyEditorRef}>
|
||||
<QuestionsAudienceTabs
|
||||
activeId={activeView}
|
||||
@@ -166,6 +169,7 @@ export const SurveyEditor = ({
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
attributeClasses={attributeClasses}
|
||||
plan={plan}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -202,7 +206,7 @@ export const SurveyEditor = ({
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 py-6 md:flex md:flex-col">
|
||||
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-200 bg-slate-100 py-6 shadow-inner md:flex md:flex-col">
|
||||
<PreviewSurvey
|
||||
survey={localSurvey}
|
||||
questionId={activeQuestionId}
|
||||
|
||||
@@ -11,7 +11,14 @@ import { getLanguageLabel } from "@formbricks/lib/i18n/utils";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey, TSurveyEditorTabs, TSurveyQuestion, ZSurvey } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyEditorTabs,
|
||||
TSurveyQuestion,
|
||||
ZSurvey,
|
||||
ZSurveyEndScreenCard,
|
||||
ZSurveyRedirectUrlCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { AlertDialog } from "@formbricks/ui/AlertDialog";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
@@ -142,6 +149,7 @@ export const SurveyMenuBar = ({
|
||||
const localSurveyValidation = ZSurvey.safeParse(localSurvey);
|
||||
if (!localSurveyValidation.success) {
|
||||
const currentError = localSurveyValidation.error.errors[0];
|
||||
|
||||
if (currentError.path[0] === "questions") {
|
||||
const questionIdx = currentError.path[1];
|
||||
const question: TSurveyQuestion = localSurvey.questions[questionIdx];
|
||||
@@ -154,9 +162,12 @@ export const SurveyMenuBar = ({
|
||||
setInvalidQuestions((prevInvalidQuestions) =>
|
||||
prevInvalidQuestions ? [...prevInvalidQuestions, "start"] : ["start"]
|
||||
);
|
||||
} else if (currentError.path[0] === "thankYouCard") {
|
||||
} else if (currentError.path[0] === "endings") {
|
||||
const endingIdx = typeof currentError.path[1] === "number" ? currentError.path[1] : -1;
|
||||
setInvalidQuestions((prevInvalidQuestions) =>
|
||||
prevInvalidQuestions ? [...prevInvalidQuestions, "end"] : ["end"]
|
||||
prevInvalidQuestions
|
||||
? [...prevInvalidQuestions, localSurvey.endings[endingIdx].id]
|
||||
: [localSurvey.endings[endingIdx].id]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -204,6 +215,14 @@ export const SurveyMenuBar = ({
|
||||
return rest;
|
||||
});
|
||||
|
||||
localSurvey.endings = localSurvey.endings.map((ending) => {
|
||||
if (ending.type === "redirectToUrl") {
|
||||
return ZSurveyRedirectUrlCard.parse(ending);
|
||||
} else {
|
||||
return ZSurveyEndScreenCard.parse(ending);
|
||||
}
|
||||
});
|
||||
|
||||
const segment = await handleSegmentUpdate();
|
||||
const updatedSurvey = await updateSurveyAction({ ...localSurvey, segment });
|
||||
|
||||
|
||||
@@ -34,9 +34,10 @@ export const UpdateQuestionId = ({
|
||||
}
|
||||
|
||||
const questionIds = localSurvey.questions.map((q) => q.id);
|
||||
const endingCardIds = localSurvey.endings.map((e) => e.id);
|
||||
const hiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
|
||||
|
||||
const validateIdError = validateId("Question", currentValue, questionIds, hiddenFieldIds);
|
||||
const validateIdError = validateId("Question", currentValue, questionIds, endingCardIds, hiddenFieldIds);
|
||||
|
||||
if (validateIdError) {
|
||||
setIsInputInvalid(true);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// formats the text to highlight specific parts of the text with slashes
|
||||
export const formatTextWithSlashes = (text: string) => {
|
||||
const regex = /\/(.*?)\\/g;
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
// Check if the part was inside slashes
|
||||
if (index % 2 !== 0) {
|
||||
return (
|
||||
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-xs">
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return part;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
// extend this object in order to add more validation rules
|
||||
import { toast } from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { checkForEmptyFallBackValue } from "@formbricks/lib/utils/recall";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
@@ -8,13 +9,14 @@ import {
|
||||
TSurvey,
|
||||
TSurveyCTAQuestion,
|
||||
TSurveyConsentQuestion,
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyLanguage,
|
||||
TSurveyMatrixQuestion,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyPictureSelectionQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyThankYouCard,
|
||||
TSurveyRedirectUrlCard,
|
||||
TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { findLanguageCodesForDuplicateLabels } from "@formbricks/types/surveys/validation";
|
||||
@@ -163,25 +165,33 @@ export const validateSurveyQuestionsInBatch = (
|
||||
return invalidQuestions;
|
||||
};
|
||||
|
||||
export const isCardValid = (
|
||||
card: TSurveyWelcomeCard | TSurveyThankYouCard,
|
||||
cardType: "start" | "end",
|
||||
surveyLanguages: TSurveyLanguage[]
|
||||
): boolean => {
|
||||
const defaultLanguageCode = "default";
|
||||
const isContentValid = (content: Record<string, string> | undefined) => {
|
||||
return (
|
||||
!content || content[defaultLanguageCode] === "" || isLabelValidForAllLanguages(content, surveyLanguages)
|
||||
);
|
||||
};
|
||||
const isContentValid = (content: Record<string, string> | undefined, surveyLanguages: TSurveyLanguage[]) => {
|
||||
return !content || isLabelValidForAllLanguages(content, surveyLanguages);
|
||||
};
|
||||
|
||||
return (
|
||||
(card.headline ? isLabelValidForAllLanguages(card.headline, surveyLanguages) : true) &&
|
||||
isContentValid(
|
||||
cardType === "start" ? (card as TSurveyWelcomeCard).html : (card as TSurveyThankYouCard).subheader
|
||||
) &&
|
||||
isContentValid(card.buttonLabel)
|
||||
);
|
||||
export const isWelcomeCardValid = (card: TSurveyWelcomeCard, surveyLanguages: TSurveyLanguage[]): boolean => {
|
||||
return isContentValid(card.headline, surveyLanguages) && isContentValid(card.html, surveyLanguages);
|
||||
};
|
||||
|
||||
export const isEndingCardValid = (
|
||||
card: TSurveyEndScreenCard | TSurveyRedirectUrlCard,
|
||||
surveyLanguages: TSurveyLanguage[]
|
||||
) => {
|
||||
if (card.type === "endScreen") {
|
||||
return (
|
||||
isContentValid(card.headline, surveyLanguages) &&
|
||||
isContentValid(card.subheader, surveyLanguages) &&
|
||||
isContentValid(card.buttonLabel, surveyLanguages)
|
||||
);
|
||||
} else {
|
||||
const parseResult = z.string().url().safeParse(card.url);
|
||||
if (parseResult.success) {
|
||||
return card.label?.trim() !== "";
|
||||
} else {
|
||||
toast.error("Invalid Redirect Url in Ending card");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const isSurveyValid = (survey: TSurvey, selectedLanguageCode: string) => {
|
||||
|
||||
@@ -85,6 +85,7 @@ const Page = async ({ params }) => {
|
||||
segments={segments}
|
||||
isUserTargetingAllowed={isUserTargetingAllowed}
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
plan={organization.billing.plan}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isUnsplashConfigured={UNSPLASH_ACCESS_KEY ? true : false}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getDefaultEndingCard, welcomeCardDefault } from "@formbricks/lib/templates";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const minimalSurvey: TSurvey = {
|
||||
@@ -12,20 +13,11 @@ export const minimalSurvey: TSurvey = {
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
triggers: [],
|
||||
redirectUrl: null,
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
headline: { default: "Welcome!" },
|
||||
html: { default: "Thanks for providing your feedback - let's go!" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [],
|
||||
thankYouCard: {
|
||||
enabled: false,
|
||||
},
|
||||
endings: [getDefaultEndingCard([])],
|
||||
hiddenFields: {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
@@ -26,28 +26,19 @@ export const replaceAttributeRecall = (survey: TSurvey, attributes: TAttributes)
|
||||
}
|
||||
});
|
||||
}
|
||||
if (surveyTemp.thankYouCard.enabled) {
|
||||
languages.forEach((language) => {
|
||||
if (
|
||||
surveyTemp.thankYouCard.headline &&
|
||||
surveyTemp.thankYouCard.headline[language].includes("recall:")
|
||||
) {
|
||||
surveyTemp.thankYouCard.headline[language] = parseRecallInfo(
|
||||
surveyTemp.thankYouCard.headline[language],
|
||||
attributes
|
||||
);
|
||||
if (
|
||||
surveyTemp.thankYouCard.subheader &&
|
||||
surveyTemp.thankYouCard.subheader[language].includes("recall:")
|
||||
) {
|
||||
surveyTemp.thankYouCard.subheader[language] = parseRecallInfo(
|
||||
surveyTemp.thankYouCard.subheader[language],
|
||||
attributes
|
||||
);
|
||||
surveyTemp.endings.forEach((ending) => {
|
||||
if (ending.type === "endScreen") {
|
||||
languages.forEach((language) => {
|
||||
if (ending.headline && ending.headline[language].includes("recall:")) {
|
||||
ending.headline[language] = parseRecallInfo(ending.headline[language], attributes);
|
||||
if (ending.subheader && ending.subheader[language].includes("recall:")) {
|
||||
ending.subheader[language] = parseRecallInfo(ending.subheader[language], attributes);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return surveyTemp;
|
||||
};
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ export const questionTypes: TQuestion[] = [
|
||||
export const QUESTIONS_ICON_MAP = questionTypes.reduce(
|
||||
(prev, curr) => ({
|
||||
...prev,
|
||||
[curr.id]: <curr.icon className="h-5 w-5" />,
|
||||
[curr.id]: <curr.icon className="h-4 w-4" />,
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
@@ -411,11 +411,7 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
.fill(surveys.germanCreate.addressQuestion.question);
|
||||
|
||||
// Fill Thank you card in german
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: /^Thank You CardShown$/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByText("Ending card").first().click();
|
||||
await page.getByPlaceholder("Your question here. Recall").click();
|
||||
await page
|
||||
.getByPlaceholder("Your question here. Recall")
|
||||
|
||||
@@ -282,7 +282,11 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => {
|
||||
await page.getByLabel("Question*").fill(params.address.question);
|
||||
|
||||
// Thank You Card
|
||||
await page.getByText("Thank You CardShown").click();
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: /^Thank you!Ending card$/ })
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.getByLabel("Note*").fill(params.thankYouCard.headline);
|
||||
await page.getByLabel("Description").fill(params.thankYouCard.description);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable no-console -- logging is allowed in migration scripts */
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
async function runMigration(): Promise<void> {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
console.log("starting migration");
|
||||
@@ -24,7 +25,7 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Deleted ${segmentsWithNoSurveys.length} segments with no surveys`);
|
||||
console.log(`Deleted ${segmentsWithNoSurveys.length.toString()} segments with no surveys`);
|
||||
|
||||
const appSurveysWithoutSegment = await tx.survey.findMany({
|
||||
where: {
|
||||
@@ -33,34 +34,42 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Found ${appSurveysWithoutSegment.length} app surveys without a segment`);
|
||||
console.log(`Found ${appSurveysWithoutSegment.length.toString()} app surveys without a segment`);
|
||||
|
||||
const segmentPromises = [];
|
||||
|
||||
for (const appSurvey of appSurveysWithoutSegment) {
|
||||
// create a new private segment for each app survey
|
||||
|
||||
segmentPromises.push(
|
||||
tx.segment.create({
|
||||
data: {
|
||||
title: appSurvey.id,
|
||||
isPrivate: true,
|
||||
environment: { connect: { id: appSurvey.environmentId } },
|
||||
surveys: { connect: { id: appSurvey.id } },
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
const segmentPromises = appSurveysWithoutSegment.map((appSurvey) =>
|
||||
tx.segment.create({
|
||||
data: {
|
||||
title: appSurvey.id,
|
||||
isPrivate: true,
|
||||
environment: { connect: { id: appSurvey.environmentId } },
|
||||
surveys: { connect: { id: appSurvey.id } },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(segmentPromises);
|
||||
console.log("Migration completed");
|
||||
},
|
||||
{ timeout: 50000 }
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(async (e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => await prisma.$disconnect());
|
||||
function handleError(error: unknown): void {
|
||||
console.error("An error occurred during migration:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function handleDisconnectError(): void {
|
||||
console.error("Failed to disconnect Prisma client");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
runMigration()
|
||||
.catch(handleError)
|
||||
.finally(() => {
|
||||
prisma.$disconnect().catch(handleDisconnectError);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/* eslint-disable no-console -- logging is allowed in migration scripts */
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { type TSurveyEndings } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface Survey {
|
||||
id: string;
|
||||
thankYouCard: {
|
||||
enabled: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
} | null;
|
||||
redirectUrl: string | null;
|
||||
}
|
||||
interface UpdatedSurvey extends Survey {
|
||||
endings?: TSurveyEndings;
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function runMigration(): Promise<void> {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const startTime = Date.now();
|
||||
console.log("Starting data migration...");
|
||||
|
||||
// Fetch all surveys
|
||||
const surveys: Survey[] = await tx.survey.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
thankYouCard: true,
|
||||
redirectUrl: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (surveys.length === 0) {
|
||||
// Stop the migration if there are no surveys
|
||||
console.log("No Surveys found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Total surveys found: ${surveys.length.toString()}`);
|
||||
let transformedSurveyCount = 0;
|
||||
|
||||
const updatePromises = surveys
|
||||
.filter((s) => s.thankYouCard !== null)
|
||||
.map((survey) => {
|
||||
transformedSurveyCount++;
|
||||
const updatedSurvey: UpdatedSurvey = structuredClone(survey);
|
||||
|
||||
if (survey.redirectUrl) {
|
||||
updatedSurvey.endings = [
|
||||
{
|
||||
type: "redirectToUrl",
|
||||
label: "Redirect Url",
|
||||
id: createId(),
|
||||
url: survey.redirectUrl,
|
||||
},
|
||||
];
|
||||
} else if (survey.thankYouCard?.enabled) {
|
||||
updatedSurvey.endings = [
|
||||
{
|
||||
...survey.thankYouCard,
|
||||
type: "endScreen",
|
||||
id: createId(),
|
||||
},
|
||||
];
|
||||
} else {
|
||||
updatedSurvey.endings = [];
|
||||
}
|
||||
|
||||
// Return the update promise
|
||||
return tx.survey.update({
|
||||
where: { id: survey.id },
|
||||
data: {
|
||||
endings: updatedSurvey.endings,
|
||||
thankYouCard: null,
|
||||
redirectUrl: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
console.log(`${transformedSurveyCount.toString()} surveys transformed`);
|
||||
const endTime = Date.now();
|
||||
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toString()}s`);
|
||||
},
|
||||
{
|
||||
timeout: 180000, // 3 minutes
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleError(error: unknown): void {
|
||||
console.error("An error occurred during migration:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function handleDisconnectError(): void {
|
||||
console.error("Failed to disconnect Prisma client");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
runMigration()
|
||||
.catch(handleError)
|
||||
.finally(() => {
|
||||
prisma.$disconnect().catch(handleDisconnectError);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -6,12 +6,12 @@ import { TResponseData, TResponseMeta, TResponsePersonAttributes } from "@formbr
|
||||
import { TBaseFilters } from "@formbricks/types/segment";
|
||||
import {
|
||||
TSurveyClosedMessage,
|
||||
TSurveyEndings,
|
||||
TSurveyHiddenFields,
|
||||
TSurveyProductOverwrites,
|
||||
TSurveyQuestions,
|
||||
TSurveySingleUse,
|
||||
TSurveyStyling,
|
||||
TSurveyThankYouCard,
|
||||
TSurveyVerifyEmail,
|
||||
TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
@@ -28,7 +28,7 @@ declare global {
|
||||
export type ResponsePersonAttributes = TResponsePersonAttributes;
|
||||
export type welcomeCard = TSurveyWelcomeCard;
|
||||
export type SurveyQuestions = TSurveyQuestions;
|
||||
export type SurveyThankYouCard = TSurveyThankYouCard;
|
||||
export type SurveyEndings = TSurveyEndings;
|
||||
export type SurveyHiddenFields = TSurveyHiddenFields;
|
||||
export type SurveyProductOverwrites = TSurveyProductOverwrites;
|
||||
export type SurveyStyling = TSurveyStyling;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Survey" ADD COLUMN "endings" JSONB[] DEFAULT ARRAY[]::JSONB[],
|
||||
ALTER COLUMN "thankYouCard" DROP NOT NULL,
|
||||
ALTER COLUMN "thankYouCard" DROP DEFAULT;
|
||||
@@ -41,7 +41,8 @@
|
||||
"data-migration:v2.2": "pnpm data-migration:adds_app_and_website_status_indicator && pnpm data-migration:product-config && pnpm data-migration:pricing-v2",
|
||||
"data-migration:zh-to-zh-Hans": "ts-node ./data-migrations/20240625101352_update_zh_to_zh-Hans/data-migration.ts",
|
||||
"data-migration:v2.3": "pnpm data-migration:zh-to-zh-Hans",
|
||||
"data-migration:segments-cleanup": "ts-node ./data-migrations/20240712123456_segments_cleanup/data-migration.ts"
|
||||
"data-migration:segments-cleanup": "ts-node ./data-migrations/20240712123456_segments_cleanup/data-migration.ts",
|
||||
"data-migration:multiple-endings": "ts-node ./data-migrations/20240801120500_thankYouCard_to_endings/data-migration.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.17.0",
|
||||
|
||||
@@ -271,9 +271,10 @@ model Survey {
|
||||
/// @zod.custom(imports.ZSurveyQuestions)
|
||||
/// [SurveyQuestions]
|
||||
questions Json @default("[]")
|
||||
endings Json[] @default([])
|
||||
/// @zod.custom(imports.ZSurveyThankYouCard)
|
||||
/// [SurveyThankYouCard]
|
||||
thankYouCard Json @default("{\"enabled\": false}")
|
||||
thankYouCard Json? //deprecated
|
||||
/// @zod.custom(imports.ZSurveyHiddenFields)
|
||||
/// [SurveyHiddenFields]
|
||||
hiddenFields Json @default("{\"enabled\": false}")
|
||||
|
||||
@@ -169,8 +169,8 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
|
||||
"group z-10 flex flex-row rounded-lg bg-white text-slate-900 transition-transform duration-300 ease-in-out"
|
||||
open ? "shadow-lg" : "shadow-md",
|
||||
"group z-10 flex flex-row rounded-lg bg-white text-slate-900"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { mockSegment } from "segment/tests/__mocks__/segment.mock";
|
||||
import { mockSurveyLanguages } from "survey/tests/__mock__/survey.mock";
|
||||
import { TLegacySurveyThankYouCard } from "@formbricks/types/legacySurveys";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyCTAQuestion,
|
||||
TSurveyCalQuestion,
|
||||
TSurveyConsentQuestion,
|
||||
TSurveyDateQuestion,
|
||||
TSurveyEndings,
|
||||
TSurveyFileUploadQuestion,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyNPSQuestion,
|
||||
@@ -13,7 +15,6 @@ import {
|
||||
TSurveyPictureSelectionQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyRatingQuestion,
|
||||
TSurveyThankYouCard,
|
||||
TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
|
||||
@@ -234,17 +235,20 @@ export const mockCalQuestion: TSurveyCalQuestion = {
|
||||
isDraft: true,
|
||||
};
|
||||
|
||||
export const mockThankYouCard: TSurveyThankYouCard = {
|
||||
enabled: true,
|
||||
headline: {
|
||||
default: "Thank you!",
|
||||
export const mockEndings: TSurveyEndings = [
|
||||
{
|
||||
type: "endScreen",
|
||||
id: "umyknohldc7w26ocjdhaa62c",
|
||||
headline: {
|
||||
default: "Thank you!",
|
||||
},
|
||||
subheader: {
|
||||
default: "We appreciate your feedback.",
|
||||
},
|
||||
buttonLink: "https://formbricks.com/signup",
|
||||
buttonLabel: { default: "Create your own Survey" },
|
||||
},
|
||||
subheader: {
|
||||
default: "We appreciate your feedback.",
|
||||
},
|
||||
buttonLink: "https://formbricks.com/signup",
|
||||
buttonLabel: { default: "Create your own Survey" },
|
||||
} as unknown as TSurveyThankYouCard;
|
||||
];
|
||||
|
||||
export const mockSurvey: TSurvey = {
|
||||
id: "eddb4fbgaml6z52eomejy77w",
|
||||
@@ -269,17 +273,21 @@ export const mockSurvey: TSurvey = {
|
||||
mockFileUploadQuestion,
|
||||
mockCalQuestion,
|
||||
],
|
||||
thankYouCard: {
|
||||
enabled: true,
|
||||
headline: {
|
||||
default: "Thank you!",
|
||||
endings: [
|
||||
{
|
||||
type: "endScreen",
|
||||
id: "umyknohldc7w26ocjdhaa62c",
|
||||
enabled: true,
|
||||
headline: {
|
||||
default: "Thank you!",
|
||||
},
|
||||
subheader: {
|
||||
default: "We appreciate your feedback.",
|
||||
},
|
||||
buttonLink: "https://formbricks.com/signup",
|
||||
buttonLabel: { default: "Create your own Survey" },
|
||||
},
|
||||
subheader: {
|
||||
default: "We appreciate your feedback.",
|
||||
},
|
||||
buttonLink: "https://formbricks.com/signup",
|
||||
buttonLabel: { default: "Create your own Survey" },
|
||||
},
|
||||
],
|
||||
hiddenFields: {
|
||||
enabled: true,
|
||||
fieldIds: [],
|
||||
@@ -294,7 +302,6 @@ export const mockSurvey: TSurvey = {
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
verifyEmail: null,
|
||||
redirectUrl: null,
|
||||
productOverwrites: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
@@ -490,15 +497,18 @@ export const mockLegacyCalQuestion = {
|
||||
buttonLabel: "Skip",
|
||||
};
|
||||
|
||||
export const mockTranslatedThankYouCard = {
|
||||
...mockThankYouCard,
|
||||
headline: { default: "Thank you!", de: "" },
|
||||
subheader: { default: "We appreciate your feedback.", de: "" },
|
||||
buttonLabel: { default: "Create your own Survey", de: "" },
|
||||
};
|
||||
export const mockTranslatedEndings = [
|
||||
{
|
||||
...mockEndings[0],
|
||||
headline: { default: "Thank you!", de: "" },
|
||||
subheader: { default: "We appreciate your feedback.", de: "" },
|
||||
buttonLabel: { default: "Create your own Survey", de: "" },
|
||||
},
|
||||
];
|
||||
|
||||
export const mockLegacyThankYouCard = {
|
||||
...mockThankYouCard,
|
||||
export const mockLegacyThankYouCard: TLegacySurveyThankYouCard = {
|
||||
buttonLink: "https://formbricks.com/signup",
|
||||
enabled: true,
|
||||
headline: "Thank you!",
|
||||
subheader: "We appreciate your feedback.",
|
||||
buttonLabel: "Create your own Survey",
|
||||
@@ -520,7 +530,7 @@ export const mockTranslatedSurvey = {
|
||||
mockTranslatedCalQuestion,
|
||||
],
|
||||
welcomeCard: mockTranslatedWelcomeCard,
|
||||
thankYouCard: mockTranslatedThankYouCard,
|
||||
endings: mockTranslatedEndings,
|
||||
};
|
||||
|
||||
export const mockLegacySurvey = {
|
||||
@@ -542,4 +552,5 @@ export const mockLegacySurvey = {
|
||||
],
|
||||
welcomeCard: mockLegacyWelcomeCard,
|
||||
thankYouCard: mockLegacyThankYouCard,
|
||||
endings: undefined,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mockEndings,
|
||||
mockLegacySurvey,
|
||||
mockSurvey,
|
||||
mockThankYouCard,
|
||||
mockTranslatedEndings,
|
||||
mockTranslatedSurvey,
|
||||
mockTranslatedThankYouCard,
|
||||
mockTranslatedWelcomeCard,
|
||||
mockWelcomeCard,
|
||||
} from "./i18n.mock";
|
||||
import { reverseTranslateSurvey } from "./reverseTranslation";
|
||||
import { createI18nString, translateSurvey, translateThankYouCard, translateWelcomeCard } from "./utils";
|
||||
import { createI18nString, translateEndings, translateSurvey, translateWelcomeCard } from "./utils";
|
||||
|
||||
describe("createI18nString", () => {
|
||||
it("should create an i18n string from a regular string", () => {
|
||||
@@ -51,11 +51,11 @@ describe("translateWelcomeCard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("translateThankYouCard", () => {
|
||||
it("should translate all text fields of a Thank you card", () => {
|
||||
describe("translateEndings", () => {
|
||||
it("should translate all text fields of first endingCard", () => {
|
||||
const languages = ["default", "de"];
|
||||
const translatedThankYouCard = translateThankYouCard(mockThankYouCard, languages);
|
||||
expect(translatedThankYouCard).toEqual(mockTranslatedThankYouCard);
|
||||
const translatedEndings = translateEndings(mockEndings, languages);
|
||||
expect(translatedEndings).toEqual(mockTranslatedEndings);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import "server-only";
|
||||
import { TLegacySurvey, ZLegacySurvey } from "@formbricks/types/legacy-surveys";
|
||||
import { TLegacySurvey, TLegacySurveyThankYouCard, ZLegacySurvey } from "@formbricks/types/legacy-surveys";
|
||||
import { TI18nString, TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { structuredClone } from "../pollyfills/structuredClone";
|
||||
import { isI18nObject } from "./utils";
|
||||
import { getLocalizedValue, isI18nObject } from "./utils";
|
||||
|
||||
// Helper function to extract a regular string from an i18nString.
|
||||
const extractStringFromI18n = (i18nString: TI18nString, languageCode: string): string => {
|
||||
@@ -28,6 +28,21 @@ const reverseTranslateObject = <T extends Record<string, any>>(obj: T, languageC
|
||||
return clonedObj;
|
||||
};
|
||||
|
||||
const reverseTranslateEndings = (survey: TSurvey, languageCode: string): TLegacySurveyThankYouCard => {
|
||||
const firstEndingCard = survey.endings[0];
|
||||
if (firstEndingCard && firstEndingCard.type === "endScreen") {
|
||||
return {
|
||||
headline: getLocalizedValue(firstEndingCard.headline, languageCode),
|
||||
subheader: getLocalizedValue(firstEndingCard.subheader, languageCode),
|
||||
buttonLabel: getLocalizedValue(firstEndingCard.buttonLabel, languageCode),
|
||||
buttonLink: firstEndingCard.buttonLink,
|
||||
enabled: true,
|
||||
};
|
||||
} else {
|
||||
return { enabled: false };
|
||||
}
|
||||
};
|
||||
|
||||
export const reverseTranslateSurvey = (survey: TSurvey, languageCode: string = "default"): TLegacySurvey => {
|
||||
const reversedSurvey = structuredClone(survey);
|
||||
reversedSurvey.questions = reversedSurvey.questions.map((question) =>
|
||||
@@ -41,7 +56,16 @@ export const reverseTranslateSurvey = (survey: TSurvey, languageCode: string = "
|
||||
}
|
||||
|
||||
reversedSurvey.welcomeCard = reverseTranslateObject(reversedSurvey.welcomeCard, languageCode);
|
||||
reversedSurvey.thankYouCard = reverseTranslateObject(reversedSurvey.thankYouCard, languageCode);
|
||||
// validate the type with zod
|
||||
// @ts-expect-error
|
||||
reversedSurvey.thankYouCard = reverseTranslateEndings(reversedSurvey, languageCode);
|
||||
const firstEndingCard = survey.endings[0];
|
||||
// @ts-expect-error
|
||||
reversedSurvey.redirectUrl = null;
|
||||
if (firstEndingCard?.type === "redirectToUrl") {
|
||||
// @ts-expect-error
|
||||
reversedSurvey.redirectUrl = firstEnabledEnding.url;
|
||||
}
|
||||
// @ts-expect-error
|
||||
reversedSurvey.endings = undefined;
|
||||
return ZLegacySurvey.parse(reversedSurvey);
|
||||
};
|
||||
|
||||
@@ -11,17 +11,19 @@ import {
|
||||
TSurveyCTAQuestion,
|
||||
TSurveyChoice,
|
||||
TSurveyConsentQuestion,
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyEndings,
|
||||
TSurveyLanguage,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyNPSQuestion,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyRatingQuestion,
|
||||
TSurveyThankYouCard,
|
||||
TSurveyWelcomeCard,
|
||||
ZSurveyCTAQuestion,
|
||||
ZSurveyCalQuestion,
|
||||
ZSurveyConsentQuestion,
|
||||
ZSurveyEndScreenCard,
|
||||
ZSurveyFileUploadQuestion,
|
||||
ZSurveyMultipleChoiceQuestion,
|
||||
ZSurveyNPSQuestion,
|
||||
@@ -29,7 +31,6 @@ import {
|
||||
ZSurveyPictureSelectionQuestion,
|
||||
ZSurveyQuestion,
|
||||
ZSurveyRatingQuestion,
|
||||
ZSurveyThankYouCard,
|
||||
ZSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { structuredClone } from "../pollyfills/structuredClone";
|
||||
@@ -144,24 +145,47 @@ export const translateWelcomeCard = (
|
||||
|
||||
// LGEGACY
|
||||
// Helper function to maintain backwards compatibility for old survey objects before Multi Language
|
||||
export const translateThankYouCard = (
|
||||
thankYouCard: TSurveyThankYouCard | TLegacySurveyThankYouCard,
|
||||
export const translateEndings = (
|
||||
endings: TSurveyEndings | TLegacySurveyThankYouCard,
|
||||
languages: string[]
|
||||
): TSurveyThankYouCard => {
|
||||
const clonedThankYouCard = structuredClone(thankYouCard);
|
||||
): TSurveyEndings => {
|
||||
const isEndingsArray = Array.isArray(endings);
|
||||
if (isEndingsArray) {
|
||||
return endings.map((ending) => {
|
||||
if (ending.type === "redirectToUrl") return ending;
|
||||
else {
|
||||
const clonedEndingCard = structuredClone(ending);
|
||||
|
||||
if (typeof thankYouCard.headline !== "undefined") {
|
||||
clonedThankYouCard.headline = createI18nString(thankYouCard.headline ?? "", languages);
|
||||
}
|
||||
if (typeof ending.headline !== "undefined") {
|
||||
clonedEndingCard.headline = createI18nString(ending.headline ?? "", languages);
|
||||
}
|
||||
|
||||
if (typeof thankYouCard.subheader !== "undefined") {
|
||||
clonedThankYouCard.subheader = createI18nString(thankYouCard.subheader ?? "", languages);
|
||||
}
|
||||
if (typeof ending.subheader !== "undefined") {
|
||||
clonedEndingCard.subheader = createI18nString(ending.subheader ?? "", languages);
|
||||
}
|
||||
|
||||
if (typeof clonedThankYouCard.buttonLabel !== "undefined") {
|
||||
clonedThankYouCard.buttonLabel = createI18nString(thankYouCard.buttonLabel ?? "", languages);
|
||||
if (typeof ending.buttonLabel !== "undefined") {
|
||||
clonedEndingCard.buttonLabel = createI18nString(ending.buttonLabel ?? "", languages);
|
||||
}
|
||||
return ZSurveyEndScreenCard.parse(clonedEndingCard);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const clonedEndingCard = structuredClone(endings) as unknown as TSurveyEndScreenCard;
|
||||
|
||||
if (typeof clonedEndingCard.headline !== "undefined") {
|
||||
clonedEndingCard.headline = createI18nString(clonedEndingCard.headline ?? "", languages);
|
||||
}
|
||||
|
||||
if (typeof clonedEndingCard.subheader !== "undefined") {
|
||||
clonedEndingCard.subheader = createI18nString(clonedEndingCard.subheader ?? "", languages);
|
||||
}
|
||||
|
||||
if (typeof clonedEndingCard.buttonLabel !== "undefined") {
|
||||
clonedEndingCard.buttonLabel = createI18nString(clonedEndingCard.buttonLabel ?? "", languages);
|
||||
}
|
||||
return [ZSurveyEndScreenCard.parse(clonedEndingCard)];
|
||||
}
|
||||
return ZSurveyThankYouCard.parse(clonedThankYouCard);
|
||||
};
|
||||
|
||||
// LGEGACY
|
||||
@@ -287,20 +311,20 @@ export const extractLanguageIds = (languages: TLanguage[]): string[] => {
|
||||
// LGEGACY
|
||||
// Helper function to maintain backwards compatibility for old survey objects before Multi Language
|
||||
export const translateSurvey = (
|
||||
survey: Pick<TSurvey, "questions" | "welcomeCard" | "thankYouCard">,
|
||||
survey: Pick<TSurvey, "questions" | "welcomeCard" | "endings">,
|
||||
languageCodes: string[]
|
||||
): Pick<TSurvey, "questions" | "welcomeCard" | "thankYouCard"> => {
|
||||
): Pick<TSurvey, "questions" | "welcomeCard" | "endings"> => {
|
||||
const translatedQuestions = survey.questions.map((question) => {
|
||||
return translateQuestion(question, languageCodes);
|
||||
});
|
||||
const translatedWelcomeCard = translateWelcomeCard(survey.welcomeCard, languageCodes);
|
||||
const translatedThankYouCard = translateThankYouCard(survey.thankYouCard, languageCodes);
|
||||
const translatedEndings = translateEndings(survey.endings, languageCodes);
|
||||
const translatedSurvey = structuredClone(survey);
|
||||
return {
|
||||
...translatedSurvey,
|
||||
questions: translatedQuestions,
|
||||
welcomeCard: translatedWelcomeCard,
|
||||
thankYouCard: translatedThankYouCard,
|
||||
endings: translatedEndings,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// https://github.com/airbnb/javascript/#naming--uppercase
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getDefaultEndingCard } from "../templates";
|
||||
|
||||
export const COLOR_DEFAULTS = {
|
||||
brandColor: "#64748b",
|
||||
@@ -98,15 +99,7 @@ export const PREVIEW_SURVEY = {
|
||||
shuffleOption: "none",
|
||||
},
|
||||
],
|
||||
thankYouCard: {
|
||||
enabled: true,
|
||||
headline: {
|
||||
default: "Thank you!",
|
||||
},
|
||||
subheader: {
|
||||
default: "We appreciate your feedback.",
|
||||
},
|
||||
},
|
||||
endings: [getDefaultEndingCard([])],
|
||||
hiddenFields: {
|
||||
enabled: true,
|
||||
fieldIds: [],
|
||||
|
||||
@@ -54,7 +54,7 @@ export const selectSurvey = {
|
||||
status: true,
|
||||
welcomeCard: true,
|
||||
questions: true,
|
||||
thankYouCard: true,
|
||||
endings: true,
|
||||
hiddenFields: true,
|
||||
displayOption: true,
|
||||
recontactDays: true,
|
||||
@@ -360,7 +360,6 @@ export const getSurveyCount = reactCache(
|
||||
|
||||
export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
|
||||
validateInputs([updatedSurvey, ZSurvey]);
|
||||
|
||||
try {
|
||||
const surveyId = updatedSurvey.id;
|
||||
let data: any = {};
|
||||
@@ -489,6 +488,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
data.status = "scheduled";
|
||||
}
|
||||
|
||||
delete data.createdBy;
|
||||
const prismaSurvey = await prisma.survey.update({
|
||||
where: { id: surveyId },
|
||||
data,
|
||||
@@ -607,11 +607,6 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
|
||||
attributeFilters: undefined,
|
||||
};
|
||||
|
||||
if ((surveyBody.type === "website" || surveyBody.type === "app") && data.thankYouCard) {
|
||||
data.thankYouCard.buttonLabel = undefined;
|
||||
data.thankYouCard.buttonLink = undefined;
|
||||
}
|
||||
|
||||
if (createdBy) {
|
||||
data.creator = {
|
||||
connect: {
|
||||
@@ -718,7 +713,7 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string, u
|
||||
name: `${existingSurvey.name} (copy)`,
|
||||
status: "draft",
|
||||
questions: structuredClone(existingSurvey.questions),
|
||||
thankYouCard: structuredClone(existingSurvey.thankYouCard),
|
||||
endings: structuredClone(existingSurvey.endings),
|
||||
languages: {
|
||||
create: existingSurvey.languages?.map((surveyLanguage) => ({
|
||||
languageId: surveyLanguage.language.id,
|
||||
|
||||
@@ -170,7 +170,13 @@ const baseSurveyProperties = {
|
||||
displayLimit: 3,
|
||||
welcomeCard: mockWelcomeCard,
|
||||
questions: [mockQuestion],
|
||||
thankYouCard: { enabled: false },
|
||||
endings: [
|
||||
{
|
||||
id: "umyknohldc7w26ocjdhaa62c",
|
||||
type: "endScreen",
|
||||
headline: { default: "Thank You!", de: "Danke!" },
|
||||
},
|
||||
],
|
||||
hiddenFields: { enabled: false },
|
||||
surveyClosedMessage: {
|
||||
enabled: false,
|
||||
|
||||
@@ -3,23 +3,29 @@ import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import {
|
||||
TSurveyCTAQuestion,
|
||||
TSurveyDisplayOption,
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyHiddenFields,
|
||||
TSurveyInput,
|
||||
TSurveyLanguage,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyStatus,
|
||||
TSurveyThankYouCard,
|
||||
TSurveyType,
|
||||
TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TTemplate } from "@formbricks/types/templates";
|
||||
import { createI18nString, extractLanguageCodes } from "./i18n/utils";
|
||||
|
||||
const thankYouCardDefault: TSurveyThankYouCard = {
|
||||
enabled: true,
|
||||
headline: { default: "Thank you!" },
|
||||
subheader: { default: "We appreciate your feedback." },
|
||||
buttonLabel: { default: "Create your own Survey" },
|
||||
buttonLink: "https://formbricks.com/signup",
|
||||
export const getDefaultEndingCard = (languages: TSurveyLanguage[]): TSurveyEndScreenCard => {
|
||||
const languageCodes = extractLanguageCodes(languages);
|
||||
return {
|
||||
id: createId(),
|
||||
type: "endScreen",
|
||||
headline: createI18nString("Thank you!", languageCodes),
|
||||
subheader: createI18nString("We appreciate your feedback.", languageCodes),
|
||||
buttonLabel: createI18nString("Create your own Survey", languageCodes),
|
||||
buttonLink: "https://formbricks.com/signup",
|
||||
};
|
||||
};
|
||||
|
||||
const hiddenFieldsDefault: TSurveyHiddenFields = {
|
||||
@@ -27,7 +33,7 @@ const hiddenFieldsDefault: TSurveyHiddenFields = {
|
||||
fieldIds: [],
|
||||
};
|
||||
|
||||
const welcomeCardDefault: TSurveyWelcomeCard = {
|
||||
export const welcomeCardDefault: TSurveyWelcomeCard = {
|
||||
enabled: false,
|
||||
headline: { default: "Welcome!" },
|
||||
html: { default: "Thanks for providing your feedback - let's go!" },
|
||||
@@ -38,318 +44,11 @@ const welcomeCardDefault: TSurveyWelcomeCard = {
|
||||
const surveyDefault: TTemplate["preset"] = {
|
||||
name: "New Survey",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
thankYouCard: thankYouCardDefault,
|
||||
endings: [getDefaultEndingCard([])],
|
||||
hiddenFields: hiddenFieldsDefault,
|
||||
questions: [],
|
||||
};
|
||||
|
||||
/* export const testTemplate: TTemplate = {
|
||||
name: "Test template",
|
||||
role: "productManager",
|
||||
industries: ["other"],
|
||||
description: "Test template consisting of all questions",
|
||||
preset: {
|
||||
...surveyDefault,
|
||||
name: "Test template",
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "This is an open text question" },
|
||||
subheader: { default: "Please enter some text:" },
|
||||
required: true,
|
||||
inputType: "text",
|
||||
longAnswer: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "This is an open text question" },
|
||||
subheader: { default: "Please enter some text:" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
longAnswer: false,
|
||||
},
|
||||
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "This is an open text question" },
|
||||
subheader: { default: "Please enter an email" },
|
||||
required: true,
|
||||
inputType: "email",
|
||||
longAnswer: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "This is an open text question" },
|
||||
subheader: { default: "Please enter an email" },
|
||||
required: false,
|
||||
inputType: "email",
|
||||
longAnswer: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "This is an open text question" },
|
||||
subheader: { default: "Please enter a number" },
|
||||
required: true,
|
||||
inputType: "number",
|
||||
longAnswer: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "This is an open text question" },
|
||||
subheader: { default: "Please enter a number" },
|
||||
required: false,
|
||||
inputType: "number",
|
||||
longAnswer: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "This is an open text question" },
|
||||
subheader: { default: "Please enter a phone number" },
|
||||
required: true,
|
||||
inputType: "phone",
|
||||
longAnswer: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "This is an open text question" },
|
||||
subheader: { default: "Please enter a phone number" },
|
||||
required: false,
|
||||
inputType: "phone",
|
||||
longAnswer: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "This is an open text question" },
|
||||
subheader: { default: "Please enter a url" },
|
||||
required: true,
|
||||
inputType: "url",
|
||||
longAnswer: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "This is an open text question" },
|
||||
subheader: { default: "Please enter a url" },
|
||||
required: false,
|
||||
inputType: "url",
|
||||
longAnswer: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "This ia a Multiple choice Single question" },
|
||||
subheader: { default: "Please select one of the following" },
|
||||
required: true,
|
||||
shuffleOption: "none",
|
||||
choices: [
|
||||
{
|
||||
id: createId(),
|
||||
label: { default: "Option1" },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
label: { default: "Option2" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
headline: { default: "This ia a Multiple choice Single question" },
|
||||
subheader: { default: "Please select one of the following" },
|
||||
required: false,
|
||||
shuffleOption: "none",
|
||||
choices: [
|
||||
{
|
||||
id: createId(),
|
||||
label: { default: "Option 1" },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
label: { default: "Option 2" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "This ia a Multiple choice Multiple question" },
|
||||
subheader: { default: "Please select some from the following" },
|
||||
required: true,
|
||||
shuffleOption: "none",
|
||||
choices: [
|
||||
{
|
||||
id: createId(),
|
||||
label: { default: "Option1" },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
label: { default: "Option2" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
headline: { default: "This ia a Multiple choice Multiple question" },
|
||||
subheader: { default: "Please select some from the following" },
|
||||
required: false,
|
||||
shuffleOption: "none",
|
||||
choices: [
|
||||
{
|
||||
id: createId(),
|
||||
label: { default: "Option1" },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
label: { default: "Option2" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "This is a rating question" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Low" },
|
||||
upperLabel: { default: "High" },
|
||||
range: 5,
|
||||
scale: "number",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "This is a rating question" },
|
||||
required: false,
|
||||
lowerLabel: { default: "Low" },
|
||||
upperLabel: { default: "High" },
|
||||
range: 5,
|
||||
scale: "number",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "This is a rating question" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Low" },
|
||||
upperLabel: { default: "High" },
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "This is a rating question" },
|
||||
required: false,
|
||||
lowerLabel: { default: "Low" },
|
||||
upperLabel: { default: "High" },
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "This is a rating question" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Low" },
|
||||
upperLabel: { default: "High" },
|
||||
range: 5,
|
||||
scale: "star",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
headline: { default: "This is a rating question" },
|
||||
required: false,
|
||||
lowerLabel: { default: "Low" },
|
||||
upperLabel: { default: "High" },
|
||||
range: 5,
|
||||
scale: "star",
|
||||
},
|
||||
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "This is a CTA question" },
|
||||
html: { default: "This is a test CTA" },
|
||||
buttonLabel: { default: "Click" },
|
||||
buttonUrl: "https://formbricks.com",
|
||||
buttonExternal: true,
|
||||
required: true,
|
||||
dismissButtonLabel: { default: "Maybe later" },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
headline: { default: "This is a CTA question" },
|
||||
html: { default: "This is a test CTA" },
|
||||
buttonLabel: { default: "Click" },
|
||||
buttonUrl: "https://formbricks.com",
|
||||
buttonExternal: true,
|
||||
required: false,
|
||||
dismissButtonLabel: { default: "Maybe later" },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
headline: { default: "This is a Picture select" },
|
||||
allowMulti: true,
|
||||
required: true,
|
||||
choices: [
|
||||
{
|
||||
id: createId(),
|
||||
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-2-small.jpg",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
headline: { default: "This is a Picture select" },
|
||||
allowMulti: true,
|
||||
required: false,
|
||||
choices: [
|
||||
{
|
||||
id: createId(),
|
||||
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-2-small.jpg",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
headline: { default: "This is a Consent question" },
|
||||
required: true,
|
||||
label: { default: "I agree to the terms and conditions" },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
headline: { default: "This is a Consent question" },
|
||||
required: false,
|
||||
label: { default: "I agree to the terms and conditions" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}; */
|
||||
|
||||
export const templates: TTemplate[] = [
|
||||
{
|
||||
name: "Cart Abandonment Survey",
|
||||
@@ -368,7 +67,7 @@ export const templates: TTemplate[] = [
|
||||
'<p class="fb-editor-paragraph" dir="ltr"><span>We noticed you left some items in your cart. We would love to understand why.</span></p>',
|
||||
},
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [{ condition: "skipped", destination: "end" }],
|
||||
logic: [{ condition: "skipped", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "Do you have 2 minutes to help us improve?" },
|
||||
required: false,
|
||||
buttonLabel: { default: "Sure!" },
|
||||
@@ -502,7 +201,7 @@ export const templates: TTemplate[] = [
|
||||
"<p class='fb-editor-paragraph' dir='ltr'><span>We noticed you're leaving our site without making a purchase. We would love to understand why.</span></p>",
|
||||
},
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [{ condition: "skipped", destination: "end" }],
|
||||
logic: [{ condition: "skipped", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "Do you have a minute?" },
|
||||
required: false,
|
||||
buttonLabel: { default: "Sure!" },
|
||||
@@ -636,7 +335,7 @@ export const templates: TTemplate[] = [
|
||||
'<p class="fb-editor-paragraph" dir="ltr"><span>We would love to understand your user experience better. Sharing your insight helps a lot.</span></p>',
|
||||
},
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [{ condition: "skipped", destination: "end" }],
|
||||
logic: [{ condition: "skipped", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "You are one of our power users! Do you have 5 minutes?" },
|
||||
required: false,
|
||||
buttonLabel: { default: "Happy to help!" },
|
||||
@@ -842,7 +541,11 @@ export const templates: TTemplate[] = [
|
||||
{ value: "It's too expensive", condition: "equals", destination: "mao94214zoo6c1at5rpuz7io" },
|
||||
{ value: "I am missing features", condition: "equals", destination: "l054desub14syoie7n202vq4" },
|
||||
{ value: "Poor customer service", condition: "equals", destination: "hdftsos1odzjllr7flj4m3j9" },
|
||||
{ value: "I just didn't need it anymore", condition: "equals", destination: "end" },
|
||||
{
|
||||
value: "I just didn't need it anymore",
|
||||
condition: "equals",
|
||||
destination: surveyDefault.endings[0].id,
|
||||
},
|
||||
],
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "Difficult to use" } },
|
||||
@@ -858,7 +561,7 @@ export const templates: TTemplate[] = [
|
||||
{
|
||||
id: "sxwpskjgzzpmkgfxzi15inif",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [{ condition: "submitted", destination: "end" }],
|
||||
logic: [{ condition: "submitted", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "What would have made {{productName}} easier to use?" },
|
||||
required: true,
|
||||
buttonLabel: { default: "Send" },
|
||||
@@ -871,7 +574,7 @@ export const templates: TTemplate[] = [
|
||||
'<p class="fb-editor-paragraph" dir="ltr"><span>We\'d love to keep you as a customer. Happy to offer a 30% discount for the next year.</span></p>',
|
||||
},
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [{ condition: "clicked", destination: "end" }],
|
||||
logic: [{ condition: "clicked", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "Get 30% off for the next year!" },
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com",
|
||||
@@ -882,7 +585,7 @@ export const templates: TTemplate[] = [
|
||||
{
|
||||
id: "l054desub14syoie7n202vq4",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [{ condition: "submitted", destination: "end" }],
|
||||
logic: [{ condition: "submitted", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "What features are you missing?" },
|
||||
required: true,
|
||||
inputType: "text",
|
||||
@@ -894,7 +597,7 @@ export const templates: TTemplate[] = [
|
||||
'<p class="fb-editor-paragraph" dir="ltr"><span>We aim to provide the best possible customer service. Please email our CEO and she will personally handle your issue.</span></p>',
|
||||
},
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [{ condition: "clicked", destination: "end" }],
|
||||
logic: [{ condition: "clicked", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "So sorry to hear 😔 Talk to our CEO directly!" },
|
||||
required: true,
|
||||
buttonUrl: "mailto:ceo@company.com",
|
||||
@@ -948,7 +651,7 @@ export const templates: TTemplate[] = [
|
||||
{
|
||||
id: "yhfew1j3ng6luy7t7qynwj79",
|
||||
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
|
||||
logic: [{ value: "No", condition: "equals", destination: "end" }],
|
||||
logic: [{ value: "No", condition: "equals", destination: surveyDefault.endings[0].id }],
|
||||
shuffleOption: "none",
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "Yes" } },
|
||||
@@ -1003,7 +706,11 @@ export const templates: TTemplate[] = [
|
||||
condition: "equals",
|
||||
destination: "rbhww1pix03r6sl4xc511wqg",
|
||||
},
|
||||
{ value: "I was just looking around", condition: "equals", destination: "end" },
|
||||
{
|
||||
value: "I was just looking around",
|
||||
condition: "equals",
|
||||
destination: surveyDefault.endings[0].id,
|
||||
},
|
||||
],
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "I didn't get much value out of it" } },
|
||||
@@ -1041,7 +748,7 @@ export const templates: TTemplate[] = [
|
||||
'<p class="fb-editor-paragraph" dir="ltr"><span>We\'re happy to offer you a 20% discount on a yearly plan.</span></p>',
|
||||
},
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [{ condition: "clicked", destination: "end" }],
|
||||
logic: [{ condition: "clicked", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "Sorry to hear! Get 20% off the first year." },
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
@@ -1063,8 +770,8 @@ export const templates: TTemplate[] = [
|
||||
id: "bqiyml1ym74ggx6htwdo7rlu",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [
|
||||
{ condition: "submitted", destination: "end" },
|
||||
{ condition: "skipped", destination: "end" },
|
||||
{ condition: "submitted", destination: surveyDefault.endings[0].id },
|
||||
{ condition: "skipped", destination: surveyDefault.endings[0].id },
|
||||
],
|
||||
headline: { default: "How are you solving your problem now?" },
|
||||
required: false,
|
||||
@@ -1100,7 +807,7 @@ export const templates: TTemplate[] = [
|
||||
id: createId(),
|
||||
html: { default: '<p class="fb-editor-paragraph" dir="ltr"><span>This helps us a lot.</span></p>' },
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [{ condition: "clicked", destination: "end" }],
|
||||
logic: [{ condition: "clicked", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "Happy to hear 🙏 Please write a review for us!" },
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
@@ -1194,7 +901,7 @@ export const templates: TTemplate[] = [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [{ condition: "submitted", destination: "end" }],
|
||||
logic: [{ condition: "submitted", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "What made you think {{productName}} wouldn't be useful?" },
|
||||
required: true,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
@@ -1203,7 +910,7 @@ export const templates: TTemplate[] = [
|
||||
{
|
||||
id: "r0zvi3vburf4hm7qewimzjux",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [{ condition: "submitted", destination: "end" }],
|
||||
logic: [{ condition: "submitted", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "What was difficult about setting up or using {{productName}}?" },
|
||||
required: true,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
@@ -1212,7 +919,7 @@ export const templates: TTemplate[] = [
|
||||
{
|
||||
id: "rbwz3y6y9avzqcfj30nu0qj4",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [{ condition: "submitted", destination: "end" }],
|
||||
logic: [{ condition: "submitted", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "What features or functionality were missing?" },
|
||||
required: true,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
@@ -1221,7 +928,7 @@ export const templates: TTemplate[] = [
|
||||
{
|
||||
id: "gn6298zogd2ipdz7js17qy5i",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [{ condition: "submitted", destination: "end" }],
|
||||
logic: [{ condition: "submitted", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "How could we make it easier for you to get started?" },
|
||||
required: true,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
@@ -1610,8 +1317,8 @@ export const templates: TTemplate[] = [
|
||||
},
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [
|
||||
{ condition: "clicked", destination: "end" },
|
||||
{ condition: "skipped", destination: "end" },
|
||||
{ condition: "clicked", destination: surveyDefault.endings[0].id },
|
||||
{ condition: "skipped", destination: surveyDefault.endings[0].id },
|
||||
],
|
||||
headline: { default: "Want to stay in the loop?" },
|
||||
required: false,
|
||||
@@ -1819,7 +1526,7 @@ export const templates: TTemplate[] = [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [{ condition: "submitted", destination: "end" }],
|
||||
logic: [{ condition: "submitted", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "Lovely! Is there anything we can do to improve your experience?" },
|
||||
required: false,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
@@ -2044,7 +1751,7 @@ export const templates: TTemplate[] = [
|
||||
inputType: "text",
|
||||
},
|
||||
],
|
||||
thankYouCard: thankYouCardDefault,
|
||||
endings: [getDefaultEndingCard([])],
|
||||
hiddenFields: hiddenFieldsDefault,
|
||||
},
|
||||
},
|
||||
@@ -2158,7 +1865,7 @@ export const templates: TTemplate[] = [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [{ condition: "submitted", destination: "end" }],
|
||||
logic: [{ condition: "submitted", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "Sorry about that! What would have made it easier for you?" },
|
||||
required: true,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
@@ -2201,7 +1908,7 @@ export const templates: TTemplate[] = [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [{ condition: "submitted", destination: "end" }],
|
||||
logic: [{ condition: "submitted", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "Ugh! What makes the results irrelevant for you?" },
|
||||
required: true,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
@@ -2244,7 +1951,7 @@ export const templates: TTemplate[] = [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [{ condition: "submitted", destination: "end" }],
|
||||
logic: [{ condition: "submitted", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "Hmpft! What were you hoping for?" },
|
||||
required: true,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
@@ -2305,8 +2012,8 @@ export const templates: TTemplate[] = [
|
||||
id: "s0999bhpaz8vgf7ps264piek",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [
|
||||
{ condition: "submitted", destination: "end" },
|
||||
{ condition: "skipped", destination: "end" },
|
||||
{ condition: "submitted", destination: surveyDefault.endings[0].id },
|
||||
{ condition: "skipped", destination: surveyDefault.endings[0].id },
|
||||
],
|
||||
headline: { default: "What made it hard?" },
|
||||
required: false,
|
||||
@@ -2317,8 +2024,8 @@ export const templates: TTemplate[] = [
|
||||
id: "nq88udm0jjtylr16ax87xlyc",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [
|
||||
{ condition: "skipped", destination: "end" },
|
||||
{ condition: "submitted", destination: "end" },
|
||||
{ condition: "skipped", destination: surveyDefault.endings[0].id },
|
||||
{ condition: "submitted", destination: surveyDefault.endings[0].id },
|
||||
],
|
||||
headline: { default: "Great! What did you come here to do today?" },
|
||||
required: false,
|
||||
@@ -2355,7 +2062,7 @@ export const templates: TTemplate[] = [
|
||||
'<p class="fb-editor-paragraph" dir="ltr"><span>You seem to be considering signing up. Answer four questions and get 10% on any plan.</span></p>',
|
||||
},
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
logic: [{ condition: "skipped", destination: "end" }],
|
||||
logic: [{ condition: "skipped", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "Answer this short survey, get 10% off!" },
|
||||
required: false,
|
||||
buttonLabel: { default: "Get 10% discount" },
|
||||
@@ -2365,7 +2072,7 @@ export const templates: TTemplate[] = [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [{ value: "5", condition: "equals", destination: "end" }],
|
||||
logic: [{ value: "5", condition: "equals", destination: surveyDefault.endings[0].id }],
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: { default: "How likely are you to sign up for {{productName}}?" },
|
||||
@@ -2519,7 +2226,7 @@ export const templates: TTemplate[] = [
|
||||
{ value: "2", condition: "lessEqual", destination: "y19mwcmstlc7pi7s4izxk1ll" },
|
||||
{ value: "3", condition: "equals", destination: "zm1hs8qkeuidh3qm0hx8pnw7" },
|
||||
{ value: "4", condition: "equals", destination: "zm1hs8qkeuidh3qm0hx8pnw7" },
|
||||
{ value: "5", condition: "equals", destination: "end" },
|
||||
{ value: "5", condition: "equals", destination: surveyDefault.endings[0].id },
|
||||
],
|
||||
range: 5,
|
||||
scale: "number",
|
||||
@@ -2533,8 +2240,8 @@ export const templates: TTemplate[] = [
|
||||
id: "y19mwcmstlc7pi7s4izxk1ll",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [
|
||||
{ condition: "submitted", destination: "end" },
|
||||
{ condition: "skipped", destination: "end" },
|
||||
{ condition: "submitted", destination: surveyDefault.endings[0].id },
|
||||
{ condition: "skipped", destination: surveyDefault.endings[0].id },
|
||||
],
|
||||
headline: { default: "Got it. What's your primary reason for visiting today?" },
|
||||
required: false,
|
||||
@@ -2582,8 +2289,8 @@ export const templates: TTemplate[] = [
|
||||
id: "k3s6gm5ivkc5crpycdbpzkpa",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [
|
||||
{ condition: "submitted", destination: "end" },
|
||||
{ condition: "skipped", destination: "end" },
|
||||
{ condition: "submitted", destination: surveyDefault.endings[0].id },
|
||||
{ condition: "skipped", destination: surveyDefault.endings[0].id },
|
||||
],
|
||||
headline: { default: "What would have made this weeks newsletter more helpful?" },
|
||||
required: false,
|
||||
@@ -2761,7 +2468,7 @@ export const templates: TTemplate[] = [
|
||||
{
|
||||
id: "r0zvi3vburf4hm7qewimzjux",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [{ condition: "submitted", destination: "end" }],
|
||||
logic: [{ condition: "submitted", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "What's difficult about using {{productName}}?" },
|
||||
required: true,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
@@ -2770,7 +2477,7 @@ export const templates: TTemplate[] = [
|
||||
{
|
||||
id: "g92s5wetp51ps6afmc6y7609",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [{ condition: "submitted", destination: "end" }],
|
||||
logic: [{ condition: "submitted", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "Got it. Which alternative are you using instead?" },
|
||||
required: true,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
@@ -2779,7 +2486,7 @@ export const templates: TTemplate[] = [
|
||||
{
|
||||
id: "gn6298zogd2ipdz7js17qy5i",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [{ condition: "submitted", destination: "end" }],
|
||||
logic: [{ condition: "submitted", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "Got it. How could we make it easier for you to get started?" },
|
||||
required: true,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
@@ -2788,7 +2495,7 @@ export const templates: TTemplate[] = [
|
||||
{
|
||||
id: "rbwz3y6y9avzqcfj30nu0qj4",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [{ condition: "submitted", destination: "end" }],
|
||||
logic: [{ condition: "submitted", destination: surveyDefault.endings[0].id }],
|
||||
headline: { default: "Got it. What features or functionality were missing?" },
|
||||
required: true,
|
||||
placeholder: { default: "Type your answer here..." },
|
||||
|
||||
138
packages/surveys/src/components/general/EndingCard.tsx
Normal file
138
packages/surveys/src/components/general/EndingCard.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { SubmitButton } from "@/components/buttons/SubmitButton";
|
||||
import { Headline } from "@/components/general/Headline";
|
||||
import { LoadingSpinner } from "@/components/general/LoadingSpinner";
|
||||
import { QuestionMedia } from "@/components/general/QuestionMedia";
|
||||
import { Subheader } from "@/components/general/Subheader";
|
||||
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
|
||||
import { replaceRecallInfo } from "@/lib/recall";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyEndScreenCard, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface EndingCardProps {
|
||||
survey: TSurvey;
|
||||
endingCard: TSurveyEndScreenCard | TSurveyRedirectUrlCard;
|
||||
isRedirectDisabled: boolean;
|
||||
isResponseSendingFinished: boolean;
|
||||
autoFocusEnabled: boolean;
|
||||
isCurrent: boolean;
|
||||
languageCode: string;
|
||||
responseData: TResponseData;
|
||||
}
|
||||
|
||||
export const EndingCard = ({
|
||||
survey,
|
||||
endingCard,
|
||||
isRedirectDisabled,
|
||||
isResponseSendingFinished,
|
||||
autoFocusEnabled,
|
||||
isCurrent,
|
||||
languageCode,
|
||||
responseData,
|
||||
}: EndingCardProps) => {
|
||||
const media =
|
||||
endingCard.type === "endScreen" && (endingCard.imageUrl || endingCard.videoUrl) ? (
|
||||
<QuestionMedia imgUrl={endingCard.imageUrl} videoUrl={endingCard.videoUrl} />
|
||||
) : null;
|
||||
const checkmark = (
|
||||
<div className="fb-text-brand fb-flex fb-flex-col fb-items-center fb-justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
class="fb-h-24 fb-w-24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="fb-bg-brand fb-mb-[10px] fb-inline-block fb-h-1 fb-w-16 fb-rounded-[100%]"></span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isRedirectDisabled && endingCard.type === "endScreen" && endingCard.buttonLink) {
|
||||
window.top?.location.replace(endingCard.buttonLink);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isCurrent) {
|
||||
if (!isRedirectDisabled && endingCard.type === "redirectToUrl" && endingCard.url) {
|
||||
window.top?.location.replace(endingCard.url);
|
||||
}
|
||||
}
|
||||
const handleEnter = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
if (isCurrent && survey.type === "link") {
|
||||
document.addEventListener("keydown", handleEnter);
|
||||
} else {
|
||||
document.removeEventListener("keydown", handleEnter);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEnter);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isCurrent]);
|
||||
|
||||
return (
|
||||
<ScrollableContainer>
|
||||
<div className="fb-text-center">
|
||||
{isResponseSendingFinished ? (
|
||||
<>
|
||||
{endingCard.type === "endScreen" && (media || checkmark)}
|
||||
<div>
|
||||
<Headline
|
||||
alignTextCenter={true}
|
||||
headline={
|
||||
endingCard.type === "endScreen"
|
||||
? replaceRecallInfo(getLocalizedValue(endingCard.headline, languageCode), responseData)
|
||||
: "Respondants will not see this card"
|
||||
}
|
||||
questionId="EndingCard"
|
||||
/>
|
||||
<Subheader
|
||||
subheader={
|
||||
endingCard.type === "endScreen"
|
||||
? getLocalizedValue(endingCard.subheader, languageCode)
|
||||
: "They will be forwarded immediately"
|
||||
}
|
||||
questionId="EndingCard"
|
||||
/>
|
||||
{endingCard.type === "endScreen" && endingCard.buttonLabel && (
|
||||
<div className="fb-mt-6 fb-flex fb-w-full fb-flex-col fb-items-center fb-justify-center fb-space-y-4">
|
||||
<SubmitButton
|
||||
buttonLabel={replaceRecallInfo(
|
||||
getLocalizedValue(endingCard.buttonLabel, languageCode),
|
||||
responseData
|
||||
)}
|
||||
isLastQuestion={false}
|
||||
focus={autoFocusEnabled}
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="fb-my-3">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<h1 className="fb-text-brand">Sending responses...</h1>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,7 @@ export const ProgressBar = ({ survey, questionId }: ProgressBarProps) => {
|
||||
() => survey.questions.findIndex((q) => q.id === questionId),
|
||||
[survey, questionId]
|
||||
);
|
||||
const questionIds = useMemo(() => survey.questions.map((question) => question.id), [survey.questions]);
|
||||
|
||||
const calculateProgress = useCallback(
|
||||
(index: number, questionsLength: number) => {
|
||||
@@ -31,7 +32,9 @@ export const ProgressBar = ({ survey, questionId }: ProgressBarProps) => {
|
||||
|
||||
return (
|
||||
<Progress
|
||||
progress={questionId === "end" ? 1 : questionId === "start" ? 0 : progressArray[currentQuestionIdx]}
|
||||
progress={
|
||||
!questionIds.includes(questionId) ? 1 : questionId === "start" ? 0 : progressArray[currentQuestionIdx]
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { EndingCard } from "@/components/general/EndingCard";
|
||||
import { FormbricksBranding } from "@/components/general/FormbricksBranding";
|
||||
import { LanguageSwitch } from "@/components/general/LanguageSwitch";
|
||||
import { ProgressBar } from "@/components/general/ProgressBar";
|
||||
import { QuestionConditional } from "@/components/general/QuestionConditional";
|
||||
import { ResponseErrorComponent } from "@/components/general/ResponseErrorComponent";
|
||||
import { SurveyCloseButton } from "@/components/general/SurveyCloseButton";
|
||||
import { ThankYouCard } from "@/components/general/ThankYouCard";
|
||||
import { WelcomeCard } from "@/components/general/WelcomeCard";
|
||||
import { AutoCloseWrapper } from "@/components/wrappers/AutoCloseWrapper";
|
||||
import { StackedCardsContainer } from "@/components/wrappers/StackedCardsContainer";
|
||||
@@ -62,6 +62,7 @@ export const Survey = ({
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [responseData, setResponseData] = useState<TResponseData>(hiddenFieldsRecord ?? {});
|
||||
const [ttc, setTtc] = useState<TResponseTtc>({});
|
||||
const questionIds = useMemo(() => survey.questions.map((question) => question.id), [survey.questions]);
|
||||
const cardArrangement = useMemo(() => {
|
||||
if (survey.type === "link") {
|
||||
return styling.cardArrangement?.linkSurveys ?? "straight";
|
||||
@@ -72,7 +73,7 @@ export const Survey = ({
|
||||
|
||||
const currentQuestionIndex = survey.questions.findIndex((q) => q.id === questionId);
|
||||
const currentQuestion = useMemo(() => {
|
||||
if (questionId === "end" && !survey.thankYouCard.enabled) {
|
||||
if (!questionIds.includes(questionId)) {
|
||||
const newHistory = [...history];
|
||||
const prevQuestionId = newHistory.pop();
|
||||
return survey.questions.find((q) => q.id === prevQuestionId);
|
||||
@@ -134,12 +135,11 @@ export const Survey = ({
|
||||
let currIdxTemp = currentQuestionIndex;
|
||||
let currQuesTemp = currentQuestion;
|
||||
|
||||
const getNextQuestionId = (data: TResponseData): string => {
|
||||
const getNextQuestionId = (data: TResponseData): string | undefined => {
|
||||
const questions = survey.questions;
|
||||
const responseValue = data[questionId];
|
||||
|
||||
if (questionId === "start") return questions[0]?.id || "end";
|
||||
|
||||
const firstEndingId = survey.endings.length > 0 ? survey.endings[0].id : undefined;
|
||||
if (questionId === "start") return questions[0]?.id || firstEndingId;
|
||||
if (currIdxTemp === -1) throw new Error("Question not found");
|
||||
if (currQuesTemp?.logic && currQuesTemp?.logic.length > 0 && currentQuestion) {
|
||||
for (let logic of currQuesTemp.logic) {
|
||||
@@ -194,7 +194,7 @@ export const Survey = ({
|
||||
}
|
||||
}
|
||||
|
||||
return questions[currIdxTemp + 1]?.id || "end";
|
||||
return questions[currIdxTemp + 1]?.id || firstEndingId;
|
||||
};
|
||||
|
||||
const onChange = (responseDataUpdate: TResponseData) => {
|
||||
@@ -206,7 +206,9 @@ export const Survey = ({
|
||||
const questionId = Object.keys(responseData)[0];
|
||||
setLoadingElement(true);
|
||||
const nextQuestionId = getNextQuestionId(responseData);
|
||||
const finished = nextQuestionId === "end";
|
||||
const finished =
|
||||
nextQuestionId === undefined ||
|
||||
!survey.questions.map((question) => question.id).includes(nextQuestionId);
|
||||
onChange(responseData);
|
||||
onResponse({ data: responseData, ttc, finished, language: selectedLanguage });
|
||||
if (finished) {
|
||||
@@ -214,7 +216,9 @@ export const Survey = ({
|
||||
window.parent.postMessage("formbricksSurveyCompleted", "*");
|
||||
onFinished();
|
||||
}
|
||||
setQuestionId(nextQuestionId);
|
||||
if (nextQuestionId) {
|
||||
setQuestionId(nextQuestionId);
|
||||
}
|
||||
// add to history
|
||||
setHistory([...history, questionId]);
|
||||
setLoadingElement(false);
|
||||
@@ -268,30 +272,24 @@ export const Survey = ({
|
||||
responseData={responseData}
|
||||
/>
|
||||
);
|
||||
} else if (questionIdx === survey.questions.length) {
|
||||
return (
|
||||
<ThankYouCard
|
||||
key="end"
|
||||
headline={replaceRecallInfo(
|
||||
getLocalizedValue(survey.thankYouCard.headline, selectedLanguage),
|
||||
responseData
|
||||
)}
|
||||
subheader={replaceRecallInfo(
|
||||
getLocalizedValue(survey.thankYouCard.subheader, selectedLanguage),
|
||||
responseData
|
||||
)}
|
||||
isResponseSendingFinished={isResponseSendingFinished}
|
||||
buttonLabel={getLocalizedValue(survey.thankYouCard.buttonLabel, selectedLanguage)}
|
||||
buttonLink={survey.thankYouCard.buttonLink}
|
||||
survey={survey}
|
||||
imageUrl={survey.thankYouCard.imageUrl}
|
||||
videoUrl={survey.thankYouCard.videoUrl}
|
||||
redirectUrl={survey.redirectUrl}
|
||||
isRedirectDisabled={isRedirectDisabled}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
isCurrent={offset === 0}
|
||||
/>
|
||||
);
|
||||
} else if (questionIdx >= survey.questions.length) {
|
||||
const endingCard = survey.endings.find((ending) => {
|
||||
return ending.id === questionId;
|
||||
});
|
||||
if (endingCard) {
|
||||
return (
|
||||
<EndingCard
|
||||
survey={survey}
|
||||
endingCard={endingCard}
|
||||
isRedirectDisabled={isRedirectDisabled}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
isCurrent={offset === 0}
|
||||
languageCode={selectedLanguage}
|
||||
isResponseSendingFinished={isResponseSendingFinished}
|
||||
responseData={responseData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const question = survey.questions[questionIdx];
|
||||
return (
|
||||
|
||||
@@ -55,11 +55,15 @@ export const SurveyModal = ({
|
||||
onClose={close}
|
||||
onFinished={() => {
|
||||
onFinished();
|
||||
setTimeout(() => {
|
||||
if (!survey.redirectUrl) {
|
||||
close();
|
||||
}
|
||||
}, 3000); // close modal automatically after 3 seconds
|
||||
setTimeout(
|
||||
() => {
|
||||
const firstEnabledEnding = survey.endings[0];
|
||||
if (firstEnabledEnding?.type !== "redirectToUrl") {
|
||||
close();
|
||||
}
|
||||
},
|
||||
survey.endings.length ? 3000 : 0 // close modal automatically after 3 seconds if no ending is enabled; otherwise, close immediately
|
||||
);
|
||||
}}
|
||||
onRetry={onRetry}
|
||||
getSetIsError={getSetIsError}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { SubmitButton } from "@/components/buttons/SubmitButton";
|
||||
import { Headline } from "@/components/general/Headline";
|
||||
import { LoadingSpinner } from "@/components/general/LoadingSpinner";
|
||||
import { QuestionMedia } from "@/components/general/QuestionMedia";
|
||||
import { RedirectCountDown } from "@/components/general/RedirectCountdown";
|
||||
import { Subheader } from "@/components/general/Subheader";
|
||||
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface ThankYouCardProps {
|
||||
headline?: string;
|
||||
subheader?: string;
|
||||
redirectUrl: string | null;
|
||||
isRedirectDisabled: boolean;
|
||||
buttonLabel?: string;
|
||||
buttonLink?: string;
|
||||
imageUrl?: string;
|
||||
videoUrl?: string;
|
||||
isResponseSendingFinished: boolean;
|
||||
autoFocusEnabled: boolean;
|
||||
isCurrent: boolean;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export const ThankYouCard = ({
|
||||
headline,
|
||||
subheader,
|
||||
redirectUrl,
|
||||
isRedirectDisabled,
|
||||
buttonLabel,
|
||||
buttonLink,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
isResponseSendingFinished,
|
||||
autoFocusEnabled,
|
||||
isCurrent,
|
||||
survey,
|
||||
}: ThankYouCardProps) => {
|
||||
const media = imageUrl || videoUrl ? <QuestionMedia imgUrl={imageUrl} videoUrl={videoUrl} /> : null;
|
||||
const checkmark = (
|
||||
<div className="fb-text-brand fb-flex fb-flex-col fb-items-center fb-justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
class="fb-h-24 fb-w-24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="fb-bg-brand fb-mb-[10px] fb-inline-block fb-h-1 fb-w-16 fb-rounded-[100%]"></span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (buttonLink) window.location.replace(buttonLink);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleEnter = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
if (isCurrent && survey.type === "link") {
|
||||
document.addEventListener("keydown", handleEnter);
|
||||
} else {
|
||||
document.removeEventListener("keydown", handleEnter);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEnter);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isCurrent]);
|
||||
|
||||
return (
|
||||
<ScrollableContainer>
|
||||
<div className="fb-text-center">
|
||||
{isResponseSendingFinished ? (
|
||||
<>
|
||||
{media || checkmark}
|
||||
<Headline alignTextCenter={true} headline={headline} questionId="thankYouCard" />
|
||||
<Subheader subheader={subheader} questionId="thankYouCard" />
|
||||
<RedirectCountDown redirectUrl={redirectUrl} isRedirectDisabled={isRedirectDisabled} />
|
||||
{buttonLabel && (
|
||||
<div className="fb-mt-6 fb-flex fb-w-full fb-flex-col fb-items-center fb-justify-center fb-space-y-4">
|
||||
<SubmitButton
|
||||
buttonLabel={buttonLabel}
|
||||
isLastQuestion={false}
|
||||
focus={autoFocusEnabled}
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="fb-my-3">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<h1 className="fb-text-brand">Sending responses...</h1>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
};
|
||||
@@ -39,9 +39,11 @@ export const StackedCardsContainer = ({
|
||||
|
||||
const questionIdxTemp = useMemo(() => {
|
||||
if (currentQuestionId === "start") return survey.welcomeCard.enabled ? -1 : 0;
|
||||
if (currentQuestionId === "end") return survey.thankYouCard.enabled ? survey.questions.length : 0;
|
||||
if (!survey.questions.map((question) => question.id).includes(currentQuestionId)) {
|
||||
return survey.questions.length;
|
||||
}
|
||||
return survey.questions.findIndex((question) => question.id === currentQuestionId);
|
||||
}, [currentQuestionId, survey.welcomeCard.enabled, survey.thankYouCard.enabled, survey.questions]);
|
||||
}, [currentQuestionId, survey.welcomeCard.enabled, survey.questions]);
|
||||
|
||||
const [prevQuestionIdx, setPrevQuestionIdx] = useState(questionIdxTemp - 1);
|
||||
const [currentQuestionIdx, setCurrentQuestionIdx] = useState(questionIdxTemp);
|
||||
@@ -180,12 +182,9 @@ export const StackedCardsContainer = ({
|
||||
questionIdxTemp !== undefined &&
|
||||
[prevQuestionIdx, currentQuestionIdx, nextQuestionIdx, nextQuestionIdx + 1].map(
|
||||
(questionIdxTemp, index) => {
|
||||
//Check for hiding extra card
|
||||
if (survey.thankYouCard.enabled) {
|
||||
if (questionIdxTemp > survey.questions.length) return;
|
||||
} else {
|
||||
if (questionIdxTemp > survey.questions.length - 1) return;
|
||||
}
|
||||
const hasEndingCard = survey.endings.length > 0;
|
||||
// Check for hiding extra card
|
||||
if (questionIdxTemp > survey.questions.length + (hasEndingCard ? 0 : -1)) return;
|
||||
const offset = index - 1;
|
||||
const isHidden = offset < 0;
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ZSurveyCTALogic,
|
||||
ZSurveyCalLogic,
|
||||
ZSurveyConsentLogic,
|
||||
ZSurveyEndings,
|
||||
ZSurveyFileUploadLogic,
|
||||
ZSurveyMultipleChoiceLogic,
|
||||
ZSurveyNPSLogic,
|
||||
@@ -189,6 +190,7 @@ export const ZLegacySurvey = ZSurvey.innerType().extend({
|
||||
thankYouCard: ZLegacySurveyThankYouCard,
|
||||
welcomeCard: ZLegacySurveyWelcomeCard,
|
||||
triggers: z.array(z.string()),
|
||||
endings: ZSurveyEndings.optional(),
|
||||
});
|
||||
|
||||
export type TLegacySurvey = z.infer<typeof ZLegacySurvey>;
|
||||
|
||||
@@ -20,8 +20,15 @@ export const ZI18nString = z.record(z.string()).refine((obj) => "default" in obj
|
||||
|
||||
export type TI18nString = z.infer<typeof ZI18nString>;
|
||||
|
||||
export const ZSurveyThankYouCard = z.object({
|
||||
enabled: z.boolean(),
|
||||
const ZEndScreenType = z.union([z.literal("endScreen"), z.literal("redirectToUrl")]);
|
||||
|
||||
const ZSurveyEndingBase = z.object({
|
||||
id: z.string().cuid2(),
|
||||
type: ZEndScreenType,
|
||||
});
|
||||
|
||||
export const ZSurveyEndScreenCard = ZSurveyEndingBase.extend({
|
||||
type: z.literal("endScreen"),
|
||||
headline: ZI18nString.optional(),
|
||||
subheader: ZI18nString.optional(),
|
||||
buttonLabel: ZI18nString.optional(),
|
||||
@@ -29,6 +36,16 @@ export const ZSurveyThankYouCard = z.object({
|
||||
imageUrl: z.string().optional(),
|
||||
videoUrl: z.string().optional(),
|
||||
});
|
||||
export type TSurveyEndScreenCard = z.infer<typeof ZSurveyEndScreenCard>;
|
||||
|
||||
export const ZSurveyRedirectUrlCard = ZSurveyEndingBase.extend({
|
||||
type: z.literal("redirectToUrl"),
|
||||
url: z.string().url("Invalid redirect Url in Ending card").optional(),
|
||||
label: z.string().optional(),
|
||||
});
|
||||
export type TSurveyRedirectUrlCard = z.infer<typeof ZSurveyRedirectUrlCard>;
|
||||
|
||||
export const ZSurveyEndings = z.array(z.union([ZSurveyEndScreenCard, ZSurveyRedirectUrlCard]));
|
||||
|
||||
export enum TSurveyQuestionTypeEnum {
|
||||
FileUpload = "fileUpload",
|
||||
@@ -143,7 +160,7 @@ export type TSurveyVerifyEmail = z.infer<typeof ZSurveyVerifyEmail>;
|
||||
|
||||
export type TSurveyWelcomeCard = z.infer<typeof ZSurveyWelcomeCard>;
|
||||
|
||||
export type TSurveyThankYouCard = z.infer<typeof ZSurveyThankYouCard>;
|
||||
export type TSurveyEndings = z.infer<typeof ZSurveyEndings>;
|
||||
|
||||
export type TSurveyHiddenFields = z.infer<typeof ZSurveyHiddenFields>;
|
||||
|
||||
@@ -186,7 +203,7 @@ export type TSurveyLogicCondition = z.infer<typeof ZSurveyLogicCondition>;
|
||||
export const ZSurveyLogicBase = z.object({
|
||||
condition: ZSurveyLogicCondition.optional(),
|
||||
value: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
destination: z.union([z.string(), z.literal("end")]).optional(),
|
||||
destination: z.string().cuid2().optional(),
|
||||
});
|
||||
|
||||
export const ZSurveyFileUploadLogic = ZSurveyLogicBase.extend({
|
||||
@@ -561,7 +578,6 @@ export const ZSurvey = z
|
||||
displayOption: ZSurveyDisplayOption,
|
||||
autoClose: z.number().nullable(),
|
||||
triggers: z.array(z.object({ actionClass: ZActionClass })),
|
||||
redirectUrl: z.string().url({ message: "Invalid redirect URL" }).nullable(),
|
||||
recontactDays: z.number().nullable(),
|
||||
displayLimit: z.number().nullable(),
|
||||
welcomeCard: ZSurveyWelcomeCard,
|
||||
@@ -578,7 +594,17 @@ export const ZSurvey = z
|
||||
});
|
||||
}
|
||||
}),
|
||||
thankYouCard: ZSurveyThankYouCard,
|
||||
endings: ZSurveyEndings.superRefine((endings, ctx) => {
|
||||
const endingIds = endings.map((q) => q.id);
|
||||
const uniqueEndingIds = new Set(endingIds);
|
||||
if (uniqueEndingIds.size !== endingIds.length) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Ending IDs must be unique",
|
||||
path: [endingIds.findIndex((id, index) => endingIds.indexOf(id) !== index), "id"],
|
||||
});
|
||||
}
|
||||
}),
|
||||
hiddenFields: ZSurveyHiddenFields,
|
||||
delay: z.number(),
|
||||
autoComplete: z.number().min(1, { message: "Response limit must be greater than 0" }).nullable(),
|
||||
@@ -597,7 +623,7 @@ export const ZSurvey = z
|
||||
languages: z.array(ZSurveyLanguage),
|
||||
})
|
||||
.superRefine((survey, ctx) => {
|
||||
const { questions, languages, welcomeCard, thankYouCard } = survey;
|
||||
const { questions, languages, welcomeCard, endings } = survey;
|
||||
|
||||
let multiLangIssue: z.IssueData | null;
|
||||
|
||||
@@ -730,7 +756,7 @@ export const ZSurvey = z
|
||||
if (duplicateChoicesLanguageCodes.length > 0) {
|
||||
const invalidLanguageCodes = duplicateChoicesLanguageCodes.map((invalidLanguageCode) =>
|
||||
invalidLanguageCode === "default"
|
||||
? languages.find((lang) => lang.default)?.language.code ?? "default"
|
||||
? (languages.find((lang) => lang.default)?.language.code ?? "default")
|
||||
: invalidLanguageCode
|
||||
);
|
||||
|
||||
@@ -811,7 +837,7 @@ export const ZSurvey = z
|
||||
if (duplicateRowsLanguageCodes.length > 0) {
|
||||
const invalidLanguageCodes = duplicateRowsLanguageCodes.map((invalidLanguageCode) =>
|
||||
invalidLanguageCode === "default"
|
||||
? languages.find((lang) => lang.default)?.language.code ?? "default"
|
||||
? (languages.find((lang) => lang.default)?.language.code ?? "default")
|
||||
: invalidLanguageCode
|
||||
);
|
||||
|
||||
@@ -828,7 +854,7 @@ export const ZSurvey = z
|
||||
if (duplicateColumnLanguageCodes.length > 0) {
|
||||
const invalidLanguageCodes = duplicateColumnLanguageCodes.map((invalidLanguageCode) =>
|
||||
invalidLanguageCode === "default"
|
||||
? languages.find((lang) => lang.default)?.language.code ?? "default"
|
||||
? (languages.find((lang) => lang.default)?.language.code ?? "default")
|
||||
: invalidLanguageCode
|
||||
);
|
||||
|
||||
@@ -914,58 +940,58 @@ export const ZSurvey = z
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// thank you card validations
|
||||
if (thankYouCard.enabled) {
|
||||
if (thankYouCard.headline) {
|
||||
multiLangIssue = validateCardFieldsForAllLanguages(
|
||||
endings.forEach((ending, index) => {
|
||||
// thank you card validations
|
||||
if (ending.type === "endScreen") {
|
||||
const multiLangIssueInHeadline = validateCardFieldsForAllLanguages(
|
||||
"cardHeadline",
|
||||
thankYouCard.headline,
|
||||
ending.headline ?? {},
|
||||
languages,
|
||||
"thankYou"
|
||||
"end",
|
||||
index
|
||||
);
|
||||
|
||||
if (multiLangIssue) {
|
||||
ctx.addIssue(multiLangIssue);
|
||||
}
|
||||
}
|
||||
|
||||
if (thankYouCard.subheader && thankYouCard.subheader.default.trim() !== "") {
|
||||
multiLangIssue = validateCardFieldsForAllLanguages(
|
||||
"subheader",
|
||||
thankYouCard.subheader,
|
||||
languages,
|
||||
"thankYou"
|
||||
);
|
||||
|
||||
if (multiLangIssue) {
|
||||
ctx.addIssue(multiLangIssue);
|
||||
}
|
||||
}
|
||||
|
||||
if (thankYouCard.buttonLabel && thankYouCard.buttonLabel.default.trim() !== "") {
|
||||
multiLangIssue = validateCardFieldsForAllLanguages(
|
||||
"thankYouCardButtonLabel",
|
||||
thankYouCard.buttonLabel,
|
||||
languages,
|
||||
"thankYou"
|
||||
);
|
||||
if (multiLangIssue) {
|
||||
ctx.addIssue(multiLangIssue);
|
||||
if (multiLangIssueInHeadline) {
|
||||
ctx.addIssue(multiLangIssueInHeadline);
|
||||
}
|
||||
|
||||
if (thankYouCard.buttonLink) {
|
||||
const parsedButtonLink = z.string().url().safeParse(thankYouCard.buttonLink);
|
||||
if (!parsedButtonLink.success) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Invalid URL for the button link in thank you card.`,
|
||||
path: ["thankYouCard", "buttonLink"],
|
||||
});
|
||||
if (ending.subheader) {
|
||||
const multiLangIssueInSubheader = validateCardFieldsForAllLanguages(
|
||||
"subheader",
|
||||
ending.subheader,
|
||||
languages,
|
||||
"end",
|
||||
index
|
||||
);
|
||||
|
||||
if (multiLangIssueInSubheader) {
|
||||
ctx.addIssue(multiLangIssueInSubheader);
|
||||
}
|
||||
}
|
||||
|
||||
if (ending.buttonLabel) {
|
||||
const multiLangIssueInButtonLabel = validateCardFieldsForAllLanguages(
|
||||
"endingCardButtonLabel",
|
||||
ending.buttonLabel,
|
||||
languages,
|
||||
"end",
|
||||
index
|
||||
);
|
||||
if (multiLangIssueInButtonLabel) {
|
||||
ctx.addIssue(multiLangIssueInButtonLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ending.type === "redirectToUrl") {
|
||||
if (!ending.label || ending.label.trim() === "") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Redirect Url label cannot be empty for ending Card ${String(index + 1)}.`,
|
||||
path: ["endings", index, "label"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ZSurvey is a refinement, so to extend it to ZSurveyUpdateInput, we need to transform the innerType and then apply the same refinements.
|
||||
@@ -990,7 +1016,7 @@ export const ZSurveyInput = z.object({
|
||||
recontactDays: z.number().nullish(),
|
||||
welcomeCard: ZSurveyWelcomeCard.optional(),
|
||||
questions: ZSurveyQuestions.optional(),
|
||||
thankYouCard: ZSurveyThankYouCard.optional(),
|
||||
endings: ZSurveyEndings.optional(),
|
||||
hiddenFields: ZSurveyHiddenFields.optional(),
|
||||
delay: z.number().optional(),
|
||||
autoComplete: z.number().nullish(),
|
||||
|
||||
@@ -27,7 +27,7 @@ const FIELD_TO_LABEL_MAP: Record<string, string> = {
|
||||
html: "description",
|
||||
cardHeadline: "note",
|
||||
welcomeCardHtml: "welcome message",
|
||||
thankYouCardButtonLabel: "button label",
|
||||
endingCardButtonLabel: "button label",
|
||||
};
|
||||
|
||||
const extractLanguageCodes = (surveyLanguages?: TSurveyLanguage[]): string[] => {
|
||||
@@ -85,7 +85,8 @@ export const validateCardFieldsForAllLanguages = (
|
||||
field: string,
|
||||
fieldLabel: TI18nString,
|
||||
languages: TSurveyLanguage[],
|
||||
cardType: "welcome" | "thankYou",
|
||||
cardType: "welcome" | "end",
|
||||
endingCardIndex?: number,
|
||||
skipArticle = false
|
||||
): z.IssueData | null => {
|
||||
const invalidLanguageCodes = validateLabelForAllLanguages(fieldLabel, languages);
|
||||
@@ -99,9 +100,11 @@ export const validateCardFieldsForAllLanguages = (
|
||||
return {
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `${messagePrefix}${messageField} on the ${
|
||||
cardType === "welcome" ? "Welcome" : "Thank You"
|
||||
} card${messageSuffix}`,
|
||||
path: [cardType === "welcome" ? "welcomeCard" : "thankYouCard", field],
|
||||
cardType === "welcome"
|
||||
? "Welcome card"
|
||||
: `Redirect to Url ${((endingCardIndex ?? -1) + 1).toString()}`
|
||||
} ${messageSuffix}`,
|
||||
path: cardType === "welcome" ? ["welcomeCard", field] : ["endings", endingCardIndex ?? -1, field],
|
||||
params: isDefaultOnly ? undefined : { invalidLanguageCodes },
|
||||
};
|
||||
}
|
||||
@@ -185,13 +188,14 @@ export const validateId = (
|
||||
type: "Hidden field" | "Question",
|
||||
field: string,
|
||||
existingQuestionIds: string[],
|
||||
existingEndingCardIds: string[],
|
||||
existingHiddenFieldIds: string[]
|
||||
): string | null => {
|
||||
if (field.trim() === "") {
|
||||
return `Please enter a ${type} Id.`;
|
||||
}
|
||||
|
||||
const combinedIds = [...existingQuestionIds, ...existingHiddenFieldIds];
|
||||
const combinedIds = [...existingQuestionIds, ...existingHiddenFieldIds, ...existingEndingCardIds];
|
||||
|
||||
if (combinedIds.findIndex((id) => id.toLowerCase() === field.toLowerCase()) !== -1) {
|
||||
return `${type} ID already exists in questions or hidden fields.`;
|
||||
|
||||
@@ -5,12 +5,7 @@ import {
|
||||
ZLegacySurveyWelcomeCard,
|
||||
} from "./legacy-surveys";
|
||||
import { ZProductConfigChannel, ZProductConfigIndustry } from "./product";
|
||||
import {
|
||||
ZSurveyHiddenFields,
|
||||
ZSurveyQuestions,
|
||||
ZSurveyThankYouCard,
|
||||
ZSurveyWelcomeCard,
|
||||
} from "./surveys/types";
|
||||
import { ZSurveyEndings, ZSurveyHiddenFields, ZSurveyQuestions, ZSurveyWelcomeCard } from "./surveys/types";
|
||||
import { ZUserObjective } from "./user";
|
||||
|
||||
export const ZTemplateRole = z.enum(["productManager", "customerSuccess", "marketing", "sales"]);
|
||||
@@ -28,7 +23,7 @@ export const ZTemplate = z.object({
|
||||
name: z.string(),
|
||||
welcomeCard: ZSurveyWelcomeCard,
|
||||
questions: ZSurveyQuestions,
|
||||
thankYouCard: ZSurveyThankYouCard,
|
||||
endings: ZSurveyEndings,
|
||||
hiddenFields: ZSurveyHiddenFields,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ const DropdownMenuSubTrigger: React.ComponentType<
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref as any}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none focus:bg-slate-100 data-[state=open]:bg-slate-100",
|
||||
"flex cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm font-medium outline-none focus:bg-slate-100 data-[state=open]:bg-slate-100",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
@@ -90,7 +90,7 @@ const DropdownMenuItem: React.ForwardRefExoticComponent<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex cursor-pointer select-none items-center rounded-md px-2 py-1.5 text-sm font-medium outline-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import toast from "react-hot-toast";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TAllowedFileExtension } from "@formbricks/types/common";
|
||||
import { LoadingSpinner } from "../LoadingSpinner";
|
||||
import { TabBar } from "../TabBar";
|
||||
import { OptionsSwitch } from "../OptionsSwitch";
|
||||
import { Uploader } from "./components/Uploader";
|
||||
import { VideoSettings } from "./components/VideoSettings";
|
||||
import { getAllowedFiles, uploadFile } from "./lib/utils";
|
||||
@@ -16,9 +16,9 @@ const allowedFileTypesForPreview = ["png", "jpeg", "jpg", "webp"];
|
||||
const isImage = (name: string) => {
|
||||
return allowedFileTypesForPreview.includes(name.split(".").pop() as TAllowedFileExtension);
|
||||
};
|
||||
const tabs = [
|
||||
{ id: "image", label: "Image" },
|
||||
{ id: "video", label: "Video" },
|
||||
const options = [
|
||||
{ value: "image", label: "Image" },
|
||||
{ value: "video", label: "Video" },
|
||||
];
|
||||
|
||||
interface FileInputProps {
|
||||
@@ -203,7 +203,7 @@ export const FileInput = ({
|
||||
<div className="w-full cursor-default">
|
||||
<div>
|
||||
{isVideoAllowed && (
|
||||
<TabBar tabs={tabs} activeId={activeTab} setActiveId={setActiveTab} tabStyle="button" />
|
||||
<OptionsSwitch options={options} currentOption={activeTab} handleOptionChange={setActiveTab} />
|
||||
)}
|
||||
<div>
|
||||
{activeTab === "video" && (
|
||||
|
||||
@@ -6,28 +6,30 @@ interface TOption {
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface QuestionTypeSelectorProps {
|
||||
interface OptionsSwitchProps {
|
||||
options: TOption[];
|
||||
currentOption: string | undefined;
|
||||
handleTypeChange: (value: string) => void;
|
||||
handleOptionChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const OptionsSwitcher = ({
|
||||
export const OptionsSwitch = ({
|
||||
options: questionTypes,
|
||||
currentOption,
|
||||
handleTypeChange,
|
||||
}: QuestionTypeSelectorProps) => {
|
||||
handleOptionChange,
|
||||
disabled = false,
|
||||
}: OptionsSwitchProps) => {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between rounded-md border p-1">
|
||||
{questionTypes.map((type) => (
|
||||
<div
|
||||
key={type.value}
|
||||
onClick={() => handleTypeChange(type.value)}
|
||||
onClick={() => !disabled && handleOptionChange(type.value)}
|
||||
className={`flex-grow cursor-pointer rounded-md bg-${
|
||||
(currentOption === undefined && type.value === "text") || currentOption === type.value
|
||||
? "slate-100"
|
||||
: "white"
|
||||
} p-2 text-center`}>
|
||||
} p-2 text-center ${disabled ? "cursor-not-allowed" : ""}`}>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<span className="text-sm text-slate-900">{type.label}</span>
|
||||
{type.icon ? (
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TPlacement } from "@formbricks/types/common";
|
||||
|
||||
@@ -160,7 +160,7 @@ export const PreviewSurvey = ({
|
||||
|
||||
const onFinished = () => {
|
||||
// close modal if there are no questions left
|
||||
if ((survey.type === "website" || survey.type === "app") && !survey.thankYouCard.enabled) {
|
||||
if ((survey.type === "website" || survey.type === "app") && survey.endings.length === 0) {
|
||||
setIsModalOpen(false);
|
||||
setTimeout(() => {
|
||||
setQuestionId(survey.questions[0]?.id);
|
||||
|
||||
@@ -99,10 +99,10 @@ export const RecallItemSelect = ({
|
||||
}, [attributeClasses]);
|
||||
|
||||
const surveyQuestionRecallItems = useMemo(() => {
|
||||
const idx =
|
||||
questionId === "end"
|
||||
? localSurvey.questions.length
|
||||
: localSurvey.questions.findIndex((recallQuestion) => recallQuestion.id === questionId);
|
||||
const isEndingCard = !localSurvey.questions.map((question) => question.id).includes(questionId);
|
||||
const idx = isEndingCard
|
||||
? localSurvey.questions.length
|
||||
: localSurvey.questions.findIndex((recallQuestion) => recallQuestion.id === questionId);
|
||||
const filteredQuestions = localSurvey.questions
|
||||
.filter((question, index) => {
|
||||
const notAllowed = isNotAllowedQuestionType(question);
|
||||
|
||||
@@ -21,8 +21,10 @@ import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyChoice,
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyQuestion,
|
||||
TSurveyRecallItem,
|
||||
TSurveyRedirectUrlCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { LanguageIndicator } from "../../ee/multi-language/components/language-indicator";
|
||||
import { createI18nString } from "../../lib/i18n/utils";
|
||||
@@ -33,11 +35,12 @@ import { FallbackInput } from "./components/FallbackInput";
|
||||
import { RecallItemSelect } from "./components/RecallItemSelect";
|
||||
import {
|
||||
determineImageUploaderVisibility,
|
||||
getCardText,
|
||||
getChoiceLabel,
|
||||
getEndingCardText,
|
||||
getIndex,
|
||||
getMatrixLabel,
|
||||
getPlaceHolderById,
|
||||
getWelcomeCardText,
|
||||
isValueIncomplete,
|
||||
} from "./utils";
|
||||
|
||||
@@ -47,7 +50,7 @@ interface QuestionFormInputProps {
|
||||
localSurvey: TSurvey;
|
||||
questionIdx: number;
|
||||
updateQuestion?: (questionIdx: number, data: Partial<TSurveyQuestion>) => void;
|
||||
updateSurvey?: (data: Partial<TSurveyQuestion>) => void;
|
||||
updateSurvey?: (data: Partial<TSurveyEndScreenCard> | Partial<TSurveyRedirectUrlCard>) => void;
|
||||
updateChoice?: (choiceIdx: number, data: Partial<TSurveyChoice>) => void;
|
||||
updateMatrixLabel?: (index: number, type: "row" | "column", data: Partial<TSurveyQuestion>) => void;
|
||||
isInvalid: boolean;
|
||||
@@ -84,18 +87,21 @@ export const QuestionFormInput = ({
|
||||
const defaultLanguageCode =
|
||||
localSurvey.languages.filter((lang) => lang.default)[0]?.language.code ?? "default";
|
||||
const usedLanguageCode = selectedLanguageCode === defaultLanguageCode ? "default" : selectedLanguageCode;
|
||||
|
||||
const question: TSurveyQuestion = localSurvey.questions[questionIdx];
|
||||
const isChoice = id.includes("choice");
|
||||
const isMatrixLabelRow = id.includes("row");
|
||||
const isMatrixLabelColumn = id.includes("column");
|
||||
const isThankYouCard = questionIdx === localSurvey.questions.length;
|
||||
const isEndingCard = questionIdx >= localSurvey.questions.length;
|
||||
const isWelcomeCard = questionIdx === -1;
|
||||
const index = getIndex(id, isChoice || isMatrixLabelColumn || isMatrixLabelRow);
|
||||
|
||||
const questionId = useMemo(() => {
|
||||
return isWelcomeCard ? "start" : isThankYouCard ? "end" : question.id;
|
||||
}, [isWelcomeCard, isThankYouCard, question?.id]);
|
||||
return isWelcomeCard
|
||||
? "start"
|
||||
: isEndingCard
|
||||
? localSurvey.endings[questionIdx - localSurvey.questions.length].id
|
||||
: question.id;
|
||||
}, [isWelcomeCard, isEndingCard, question?.id]);
|
||||
|
||||
const enabledLanguages = useMemo(
|
||||
() => getEnabledLanguages(localSurvey.languages ?? []),
|
||||
@@ -116,8 +122,12 @@ export const QuestionFormInput = ({
|
||||
return getChoiceLabel(question, index, surveyLanguageCodes);
|
||||
}
|
||||
|
||||
if (isThankYouCard || isWelcomeCard) {
|
||||
return getCardText(localSurvey, id, isThankYouCard, surveyLanguageCodes);
|
||||
if (isWelcomeCard) {
|
||||
return getWelcomeCardText(localSurvey, id, surveyLanguageCodes);
|
||||
}
|
||||
|
||||
if (isEndingCard) {
|
||||
return getEndingCardText(localSurvey, id, surveyLanguageCodes, questionIdx);
|
||||
}
|
||||
|
||||
if ((isMatrixLabelColumn || isMatrixLabelRow) && typeof index === "number") {
|
||||
@@ -351,7 +361,7 @@ export const QuestionFormInput = ({
|
||||
|
||||
if (isChoice) {
|
||||
updateChoiceDetails(translatedText);
|
||||
} else if (isThankYouCard || isWelcomeCard) {
|
||||
} else if (isEndingCard || isWelcomeCard) {
|
||||
updateSurveyDetails(translatedText);
|
||||
} else if (isMatrixLabelRow || isMatrixLabelColumn) {
|
||||
updateMatrixLabelDetails(translatedText);
|
||||
@@ -391,16 +401,20 @@ export const QuestionFormInput = ({
|
||||
}
|
||||
};
|
||||
|
||||
const getFileUrl = () => {
|
||||
if (isThankYouCard) return localSurvey.thankYouCard.imageUrl;
|
||||
else if (isWelcomeCard) return localSurvey.welcomeCard.fileUrl;
|
||||
else return question.imageUrl;
|
||||
const getFileUrl = (): string | undefined => {
|
||||
if (isWelcomeCard) return localSurvey.welcomeCard.fileUrl;
|
||||
if (isEndingCard) {
|
||||
const endingCard = localSurvey.endings.find((ending) => ending.id === questionId);
|
||||
if (endingCard && endingCard.type === "endScreen") return endingCard.imageUrl;
|
||||
} else return question.imageUrl;
|
||||
};
|
||||
|
||||
const getVideoUrl = () => {
|
||||
if (isThankYouCard) return localSurvey.thankYouCard.videoUrl;
|
||||
else if (isWelcomeCard) return localSurvey.welcomeCard.videoUrl;
|
||||
else return question.videoUrl;
|
||||
const getVideoUrl = (): string | undefined => {
|
||||
if (isWelcomeCard) return localSurvey.welcomeCard.videoUrl;
|
||||
if (isEndingCard) {
|
||||
const endingCard = localSurvey.endings.find((ending) => ending.id === questionId);
|
||||
if (endingCard && endingCard.type === "endScreen") return endingCard.videoUrl;
|
||||
} else return question.videoUrl;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -422,7 +436,7 @@ export const QuestionFormInput = ({
|
||||
fileType === "video"
|
||||
? { videoUrl: url[0], imageUrl: "" }
|
||||
: { imageUrl: url[0], videoUrl: "" };
|
||||
if (isThankYouCard && updateSurvey) {
|
||||
if (isEndingCard && updateSurvey) {
|
||||
updateSurvey(update);
|
||||
} else if (updateQuestion) {
|
||||
updateQuestion(questionIdx, update);
|
||||
|
||||
@@ -39,20 +39,32 @@ export const getMatrixLabel = (
|
||||
return labels[idx] || createI18nString("", surveyLanguageCodes);
|
||||
};
|
||||
|
||||
export const getCardText = (
|
||||
export const getWelcomeCardText = (
|
||||
survey: TSurvey,
|
||||
id: string,
|
||||
isThankYouCard: boolean,
|
||||
surveyLanguageCodes: string[]
|
||||
): TI18nString => {
|
||||
const card = isThankYouCard ? survey.thankYouCard : survey.welcomeCard;
|
||||
const card = survey.welcomeCard;
|
||||
return (card[id as keyof typeof card] as TI18nString) || createI18nString("", surveyLanguageCodes);
|
||||
};
|
||||
|
||||
export const getEndingCardText = (
|
||||
survey: TSurvey,
|
||||
id: string,
|
||||
surveyLanguageCodes: string[],
|
||||
questionIdx: number
|
||||
): TI18nString => {
|
||||
const endingCardIndex = questionIdx - survey.questions.length;
|
||||
const card = survey.endings[endingCardIndex];
|
||||
if (card.type === "endScreen") {
|
||||
return (card[id as keyof typeof card] as TI18nString) || createI18nString("", surveyLanguageCodes);
|
||||
} else {
|
||||
return createI18nString("", surveyLanguageCodes);
|
||||
}
|
||||
};
|
||||
|
||||
export const determineImageUploaderVisibility = (questionIdx: number, localSurvey: TSurvey) => {
|
||||
switch (questionIdx) {
|
||||
case localSurvey.questions.length: // Thank You Card
|
||||
return !!localSurvey.thankYouCard.imageUrl || !!localSurvey.thankYouCard.videoUrl;
|
||||
case -1: // Welcome Card
|
||||
return false;
|
||||
default:
|
||||
|
||||
@@ -181,7 +181,7 @@ export const copyToOtherEnvironmentAction = authenticatedActionClient
|
||||
name: `${existingSurvey.name} (copy)`,
|
||||
status: "draft",
|
||||
questions: structuredClone(existingSurvey.questions),
|
||||
thankYouCard: structuredClone(existingSurvey.thankYouCard),
|
||||
endings: structuredClone(existingSurvey.endings),
|
||||
languages: {
|
||||
create: existingSurvey.languages?.map((surveyLanguage) => ({
|
||||
languageId: surveyLanguage.language.id,
|
||||
|
||||
@@ -37,14 +37,15 @@ interface TooltipRendererProps {
|
||||
tooltipContent: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
triggerClass?: string;
|
||||
}
|
||||
export const TooltipRenderer = (props: TooltipRendererProps) => {
|
||||
const { children, shouldRender, tooltipContent, className } = props;
|
||||
const { children, shouldRender, tooltipContent, className, triggerClass } = props;
|
||||
if (shouldRender) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>{children}</TooltipTrigger>
|
||||
<TooltipTrigger className={triggerClass}>{children}</TooltipTrigger>
|
||||
<TooltipContent className={className}>{tooltipContent}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
Reference in New Issue
Block a user