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:
Dhruwang Jariwala
2024-08-02 10:18:41 +05:30
committed by GitHub
parent 75ade97805
commit 5d347096cf
65 changed files with 1685 additions and 1424 deletions

View File

@@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged

View File

@@ -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>
);
};

View File

@@ -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>
))}

View File

@@ -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">

View File

@@ -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 })
}
/>

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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" : "",

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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
);

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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}

View File

@@ -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>
);
};

View File

@@ -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 */}

View File

@@ -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}

View File

@@ -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 });

View File

@@ -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);

View File

@@ -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;
}
});
};

View File

@@ -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) => {

View File

@@ -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}
/>

View File

@@ -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,
},

View File

@@ -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;
};

View File

@@ -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" />,
}),
{}
);

View File

@@ -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")

View File

@@ -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);
};

View File

@@ -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();

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;

View File

@@ -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",

View File

@@ -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}")

View File

@@ -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(

View File

@@ -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,
};

View File

@@ -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);
});
});

View File

@@ -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);
};

View File

@@ -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,
};
};

View File

@@ -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: [],

View File

@@ -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,

View File

@@ -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,

View File

@@ -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..." },

View 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>
);
};

View File

@@ -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]
}
/>
);
};

View File

@@ -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 (

View File

@@ -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}

View File

@@ -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>
);
};

View File

@@ -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 (

View File

@@ -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>;

View File

@@ -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(),

View File

@@ -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.`;

View File

@@ -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,
}),
});

View File

@@ -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
)}

View File

@@ -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" && (

View File

@@ -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 ? (

View File

@@ -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";

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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:

View File

@@ -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,

View File

@@ -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>