feat: Add welcome card (#1073)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
Naitik Kapadia
2023-10-17 14:57:57 +05:30
committed by GitHub
parent 06b9d4f5f9
commit 2c1abf8782
36 changed files with 881 additions and 266 deletions

View File

@@ -23,6 +23,8 @@ import {
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { isLight } from "@/app/lib/utils";
import { CornerDownLeft } from "lucide-react";
import Image from "next/image";
interface EmailTabProps {
survey: TSurvey;
@@ -138,88 +140,129 @@ const getEmailValues = ({ survey, surveyUrl, brandColor, preview }) => {
const getEmailTemplate = (survey: TSurvey, surveyUrl: string, brandColor: string, preview: boolean) => {
const url = preview ? `${surveyUrl}?preview=true` : surveyUrl;
const urlWithPrefilling = preview ? `${surveyUrl}?preview=true&` : `${surveyUrl}?`;
if (survey?.welcomeCard?.enabled) {
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
{survey?.welcomeCard?.fileUrl && (
<Image
src={survey?.welcomeCard?.fileUrl}
className="mb-4"
width={75}
height={75}
alt="Company Logo"
/>
)}
const firstQuestion = survey.questions[0];
switch (firstQuestion.type) {
case QuestionType.OpenText:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Section className="mt-4 block h-20 w-full rounded-lg border border-solid border-gray-200 bg-slate-50" />
<EmailFooter />
</EmailTemplateWrapper>
);
case QuestionType.Consent:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Container className="m-0 text-sm font-normal leading-6 text-slate-500">
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
</Container>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{survey?.welcomeCard?.headline}
</Text>
<Container className="mt-2 text-sm font-normal leading-6 text-slate-500">
<Text
className="m-0 p-0"
dangerouslySetInnerHTML={{ __html: survey?.welcomeCard?.html || "" }}></Text>
</Container>
<Container className="m-0 mt-4 block w-full max-w-none rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 font-medium text-slate-800">
<Text className="m-0 inline-block">{firstQuestion.label}</Text>
</Container>
<Container className="mx-0 mt-4 flex max-w-none justify-end">
{!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
Reject
</EmailButton>
)}
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=accepted`}
className={cn(
"bg-brand-color ml-2 inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
Accept
</EmailButton>
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case QuestionType.NPS:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Section>
<Container className=" mx-0 mt-4 max-w-none">
<EmailButton
href={`${urlWithPrefilling}start=clicked`}
className={cn(
"bg-brand-color inline-flex cursor-pointer appearance-none gap-4 rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
{survey?.welcomeCard?.buttonLabel || "Next"}
</EmailButton>
<Text className="ml-4 inline-flex items-center text-slate-600">
Press Enter
<CornerDownLeft className="h-3" />
</Text>
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
} else {
const firstQuestion = survey.questions[0];
switch (firstQuestion.type) {
case QuestionType.OpenText:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Section className="mt-4 block h-20 w-full rounded-lg border border-solid border-gray-200 bg-slate-50" />
<EmailFooter />
</EmailTemplateWrapper>
);
case QuestionType.Consent:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 mt-4 flex w-max flex-col">
<Section className="block overflow-hidden rounded-md border border-gray-200">
{Array.from({ length: 11 }, (_, i) => (
<EmailButton
key={i}
href={`${urlWithPrefilling}${firstQuestion.id}=${i}`}
className="m-0 inline-flex h-10 w-10 items-center justify-center border-gray-200 p-0 text-slate-800">
{i}
</EmailButton>
))}
</Section>
<Section className="mt-2 px-1.5 text-xs leading-6 text-slate-500">
<Row>
<Column>
<Text className="m-0 inline-block w-max p-0">{firstQuestion.lowerLabel}</Text>
</Column>
<Column className="text-right">
<Text className="m-0 inline-block w-max p-0 text-right">{firstQuestion.upperLabel}</Text>
</Column>
</Row>
</Section>
<Container className="m-0 text-sm font-normal leading-6 text-slate-500">
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
</Container>
{/* {!firstQuestion.required && (
<Container className="m-0 mt-4 block w-full max-w-none rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 font-medium text-slate-800">
<Text className="m-0 inline-block">{firstQuestion.label}</Text>
</Container>
<Container className="mx-0 mt-4 flex max-w-none justify-end">
{!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
Reject
</EmailButton>
)}
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=accepted`}
className={cn(
"bg-brand-color ml-2 inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
Accept
</EmailButton>
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case QuestionType.NPS:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Section>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 mt-4 flex w-max flex-col">
<Section className="block overflow-hidden rounded-md border border-gray-200">
{Array.from({ length: 11 }, (_, i) => (
<EmailButton
key={i}
href={`${urlWithPrefilling}${firstQuestion.id}=${i}`}
className="m-0 inline-flex h-10 w-10 items-center justify-center border-gray-200 p-0 text-slate-800">
{i}
</EmailButton>
))}
</Section>
<Section className="mt-2 px-1.5 text-xs leading-6 text-slate-500">
<Row>
<Column>
<Text className="m-0 inline-block w-max p-0">{firstQuestion.lowerLabel}</Text>
</Column>
<Column className="text-right">
<Text className="m-0 inline-block w-max p-0 text-right">
{firstQuestion.upperLabel}
</Text>
</Column>
</Row>
</Section>
</Container>
{/* {!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className={cn(
@@ -230,83 +273,83 @@ const getEmailTemplate = (survey: TSurvey, surveyUrl: string, brandColor: string
</EmailButton>
)} */}
<EmailFooter />
</Section>
</EmailTemplateWrapper>
);
case QuestionType.CTA:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Container className="mt-2 text-sm font-normal leading-6 text-slate-500">
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
</Container>
<Container className="mx-0 mt-4 max-w-none">
{!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
{firstQuestion.dismissButtonLabel || "Skip"}
</EmailButton>
)}
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}
className={cn(
"bg-brand-color inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
{firstQuestion.buttonLabel}
</EmailButton>
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case QuestionType.Rating:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Section>
<EmailFooter />
</Section>
</EmailTemplateWrapper>
);
case QuestionType.CTA:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 mt-4 flex">
<Section
className={cn("inline-block w-max overflow-hidden rounded-md", {
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
})}>
{Array.from({ length: firstQuestion.range }, (_, i) => (
<EmailButton
key={i}
href={`${urlWithPrefilling}${firstQuestion.id}=${i + 1}`}
className={cn(
"m-0 inline-flex h-16 w-16 items-center justify-center p-0 text-slate-800",
{
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
}
)}>
{firstQuestion.scale === "smiley" && <Text className="text-3xl">😃</Text>}
{firstQuestion.scale === "number" && i + 1}
{firstQuestion.scale === "star" && <Text className="text-3xl"></Text>}
</EmailButton>
))}
</Section>
<Section className="m-0 px-1.5 text-xs leading-6 text-slate-500">
<Row>
<Column>
<Text className="m-0 inline-block p-0">{firstQuestion.lowerLabel}</Text>
</Column>
<Column className="text-right">
<Text className="m-0 inline-block p-0 text-right">{firstQuestion.upperLabel}</Text>
</Column>
</Row>
</Section>
<Container className="mt-2 text-sm font-normal leading-6 text-slate-500">
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
</Container>
{/* {!firstQuestion.required && (
<Container className="mx-0 mt-4 max-w-none">
{!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
{firstQuestion.dismissButtonLabel || "Skip"}
</EmailButton>
)}
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}
className={cn(
"bg-brand-color inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
{firstQuestion.buttonLabel}
</EmailButton>
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case QuestionType.Rating:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Section>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 mt-4 flex">
<Section
className={cn("inline-block w-max overflow-hidden rounded-md", {
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
})}>
{Array.from({ length: firstQuestion.range }, (_, i) => (
<EmailButton
key={i}
href={`${urlWithPrefilling}${firstQuestion.id}=${i + 1}`}
className={cn(
"m-0 inline-flex h-16 w-16 items-center justify-center p-0 text-slate-800",
{
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
}
)}>
{firstQuestion.scale === "smiley" && <Text className="text-3xl">😃</Text>}
{firstQuestion.scale === "number" && i + 1}
{firstQuestion.scale === "star" && <Text className="text-3xl"></Text>}
</EmailButton>
))}
</Section>
<Section className="m-0 px-1.5 text-xs leading-6 text-slate-500">
<Row>
<Column>
<Text className="m-0 inline-block p-0">{firstQuestion.lowerLabel}</Text>
</Column>
<Column className="text-right">
<Text className="m-0 inline-block p-0 text-right">{firstQuestion.upperLabel}</Text>
</Column>
</Row>
</Section>
</Container>
{/* {!firstQuestion.required && (
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className={cn(
@@ -316,55 +359,56 @@ const getEmailTemplate = (survey: TSurvey, surveyUrl: string, brandColor: string
{firstQuestion.buttonLabel || "Skip"}
</EmailButton>
)} */}
<EmailFooter />
</Section>
</EmailTemplateWrapper>
);
case QuestionType.MultipleChoiceMulti:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices.map((choice) => (
<Section
className="mt-2 block w-full rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800"
key={choice.id}>
{choice.label}
</Section>
))}
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case QuestionType.MultipleChoiceSingle:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices
.filter((choice) => choice.id !== "other")
.map((choice) => (
<Link
key={choice.id}
className="mt-2 block rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800 hover:bg-slate-100"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.label}`}>
<EmailFooter />
</Section>
</EmailTemplateWrapper>
);
case QuestionType.MultipleChoiceMulti:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices.map((choice) => (
<Section
className="mt-2 block w-full rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800"
key={choice.id}>
{choice.label}
</Link>
</Section>
))}
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
case QuestionType.MultipleChoiceSingle:
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices
.filter((choice) => choice.id !== "other")
.map((choice) => (
<Link
key={choice.id}
className="mt-2 block rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800 hover:bg-slate-100"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.label}`}>
{choice.label}
</Link>
))}
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
}
}
};

View File

@@ -0,0 +1,183 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import { md } from "@formbricks/lib/markdownIt";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { Editor } from "@formbricks/ui/Editor";
import FileInput from "@formbricks/ui/FileInput";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { Switch } from "@formbricks/ui/Switch";
import * as Collapsible from "@radix-ui/react-collapsible";
import { usePathname } from "next/navigation";
import { useState } from "react";
interface EditWelcomeCardProps {
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
setActiveQuestionId: (id: string | null) => void;
activeQuestionId: string | null;
}
export default function EditWelcomeCard({
localSurvey,
setLocalSurvey,
setActiveQuestionId,
activeQuestionId,
}: EditWelcomeCardProps) {
const [firstRender, setFirstRender] = useState(true);
const path = usePathname();
const environmentId = path?.split("/environments/")[1]?.split("/")[0];
// const [open, setOpen] = useState(false);
let open = activeQuestionId == "start";
const setOpen = (e) => {
if (e) {
setActiveQuestionId("start");
} else {
setActiveQuestionId(null);
}
};
const updateSurvey = (data) => {
setLocalSurvey({
...localSurvey,
welcomeCard: {
...localSurvey.welcomeCard,
...data,
},
});
};
return (
<div
className={cn(
open ? "scale-100 shadow-lg " : "scale-97 shadow-md",
"flex flex-row rounded-lg bg-white transition-transform duration-300 ease-in-out"
)}>
<div
className={cn(
open ? "bg-slate-700" : "bg-slate-400",
"flex w-10 items-center justify-center rounded-l-lg hover:bg-slate-600 group-aria-expanded:rounded-bl-none"
)}>
<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">Welcome Card</p>
{!open && (
<p className="mt-1 truncate text-xs text-slate-500">
{localSurvey?.welcomeCard?.enabled ? "Shown" : "Hidden"}
</p>
)}
</div>
</div>
<div className="flex items-center space-x-2">
<Label htmlFor="welcome-toggle">Enabled</Label>
<Switch
id="welcome-toggle"
checked={localSurvey?.welcomeCard?.enabled}
onClick={(e) => {
e.stopPropagation();
updateSurvey({ enabled: !localSurvey.welcomeCard?.enabled });
}}
/>
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="px-4 pb-6">
<form>
<div className="mt-2">
<Label htmlFor="companyLogo">Company Logo</Label>
</div>
<div className="mt-3 flex w-full items-center justify-center">
<FileInput
allowedFileExtensions={["png", "jpeg", "jpg"]}
environmentId={environmentId}
onFileUpload={(url: string) => {
updateSurvey({ fileUrl: url });
}}
fileUrl={localSurvey?.welcomeCard?.fileUrl}
/>
</div>
<div className="mt-3">
<Label htmlFor="headline">Headline</Label>
<div className="mt-2">
<Input
id="headline"
name="headline"
defaultValue={localSurvey?.welcomeCard?.headline}
onChange={(e) => {
updateSurvey({ headline: e.target.value });
}}
/>
</div>
</div>
<div className="mt-3">
<Label htmlFor="subheader">Welcome Message</Label>
<div className="mt-2">
<Editor
getText={() =>
md.render(
localSurvey?.welcomeCard?.html || "Thanks for providing your feedback - let's go!"
)
}
setText={(value: string) => {
updateSurvey({ html: value });
}}
excludedToolbarItems={["blockType"]}
disableLists
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
</div>
</div>
<div className="mt-3 flex justify-between gap-8">
<div className="flex w-full space-x-2">
<div className="w-full">
<Label htmlFor="buttonLabel">Button Label</Label>
<div className="mt-2">
<Input
id="buttonLabel"
name="buttonLabel"
value={localSurvey?.welcomeCard?.buttonLabel || "Next"}
onChange={(e) => updateSurvey({ buttonLabel: e.target.value })}
/>
</div>
</div>
</div>
</div>
{/* <div className="mt-8 flex items-center">
<div className="mr-2">
<Switch
id="timeToFinish"
name="timeToFinish"
checked={localSurvey?.welcomeCard?.timeToFinish}
onCheckedChange={() =>
updateSurvey({ timeToFinish: !localSurvey.welcomeCard.timeToFinish })
}
/>
</div>
<div className="flex-column">
<Label htmlFor="timeToFinish" className="">
Time to Finish
</Label>
<div className="text-sm text-gray-500 dark:text-gray-400">
Display an estimate of completion time for survey
</div>
</div>
</div> */}
</form>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
</div>
);
}

View File

@@ -299,17 +299,19 @@ export default function QuestionCard({
/>
</div>
)}
<div className="my-4 flex items-center justify-end space-x-2">
<Label htmlFor="required-toggle">Required</Label>
<Switch
id="required-toggle"
checked={question.required}
onClick={(e) => {
e.stopPropagation();
updateQuestion(questionIdx, { required: !question.required });
}}
/>
</div>
{
<div className="my-4 flex items-center justify-end space-x-2">
<Label htmlFor="required-toggle">Required</Label>
<Switch
id="required-toggle"
checked={question.required}
onClick={(e) => {
e.stopPropagation();
updateQuestion(questionIdx, { required: !question.required });
}}
/>
</div>
}
</div>
)}
</Collapsible.Root>

View File

@@ -9,6 +9,7 @@ import { DragDropContext } from "react-beautiful-dnd";
import toast from "react-hot-toast";
import AddQuestionButton from "./AddQuestionButton";
import EditThankYouCard from "./EditThankYouCard";
import EditWelcomeCard from "./EditWelcomeCard";
import QuestionCard from "./QuestionCard";
import { StrictModeDroppable } from "./StrictModeDroppable";
import { validateQuestion } from "./Validation";
@@ -132,6 +133,7 @@ export default function QuestionsView({
const duplicateQuestion = (questionIdx: number) => {
const questionToDuplicate = JSON.parse(JSON.stringify(localSurvey.questions[questionIdx]));
const newQuestionId = createId();
// create a copy of the question with a new id
@@ -156,7 +158,9 @@ export default function QuestionsView({
if (backButtonLabel) {
question.backButtonLabel = backButtonLabel;
}
updatedSurvey.questions.push({ ...question, isDraft: true });
setLocalSurvey(updatedSurvey);
setActiveQuestionId(question.id);
internalQuestionIdMap[question.id] = createId();
@@ -174,7 +178,6 @@ export default function QuestionsView({
};
const moveQuestion = (questionIndex: number, up: boolean) => {
// move the question up or down in the localSurvey questions array
const newQuestions = Array.from(localSurvey.questions);
const [reorderedQuestion] = newQuestions.splice(questionIndex, 1);
const destinationIndex = up ? questionIndex - 1 : questionIndex + 1;
@@ -185,6 +188,14 @@ export default function QuestionsView({
return (
<div className="mt-12 px-5 py-4">
<div className="mb-5 flex flex-col gap-5">
<EditWelcomeCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setActiveQuestionId={setActiveQuestionId}
activeQuestionId={activeQuestionId}
/>
</div>
<DragDropContext onDragEnd={onDragEnd}>
<div className="mb-5 grid grid-cols-1 gap-5 ">
<StrictModeDroppable droppableId="questionsList">

View File

@@ -147,7 +147,7 @@ export default function PreviewSurvey({
setPreviewMode(storePreviewMode);
}, 10);
setActiveQuestionId(survey.questions[0].id);
setActiveQuestionId(survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id);
}
useEffect(() => {

View File

@@ -1,5 +1,5 @@
import { QuestionType } from "@formbricks/types/questions";
import { TSurvey, TSurveyHiddenFields } from "@formbricks/types/v1/surveys";
import { TSurvey, TSurveyHiddenFields, TSurveyWelcomeCard } from "@formbricks/types/v1/surveys";
import { TTemplate } from "@formbricks/types/v1/templates";
import { createId } from "@paralleldrive/cuid2";
@@ -14,6 +14,13 @@ const hiddenFieldsDefault: TSurveyHiddenFields = {
fieldIds: [],
};
const welcomeCardDefault: TSurveyWelcomeCard = {
enabled: true,
headline: "Welcome!",
html: "Thanks for providing your feedback - let's go!",
timeToFinish: false,
};
export const templates: TTemplate[] = [
{
name: "Product Market Fit (Superhuman)",
@@ -22,6 +29,7 @@ export const templates: TTemplate[] = [
description: "Measure PMF by assessing how disappointed users would be if your product disappeared.",
preset: {
name: "Product Market Fit (Superhuman)",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -120,6 +128,7 @@ export const templates: TTemplate[] = [
description: "Learn more about who signed up to your product and why.",
preset: {
name: "Onboarding Segmentation",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -224,6 +233,7 @@ export const templates: TTemplate[] = [
description: "Find out why people cancel their subscriptions. These insights are pure gold!",
preset: {
name: "Churn Survey",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -312,6 +322,7 @@ export const templates: TTemplate[] = [
"The EAS is a riff off the NPS but asking for actual past behaviour instead of lofty intentions.",
preset: {
name: "Earned Advocacy Score (EAS)",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -376,6 +387,7 @@ export const templates: TTemplate[] = [
description: "Find out why people stopped their trial. These insights help you improve your funnel.",
preset: {
name: "Improve Trial Conversion",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -480,6 +492,7 @@ export const templates: TTemplate[] = [
description: "Invite users who love your product to review it publicly.",
preset: {
name: "Review Prompt",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -527,6 +540,7 @@ export const templates: TTemplate[] = [
description: "Invite a specific subset of your users to schedule an interview with your product team.",
preset: {
name: "Interview Prompt",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -550,6 +564,7 @@ export const templates: TTemplate[] = [
description: "Identify weaknesses in your onboarding flow to increase user activation.",
preset: {
name: "Onboarding Drop-Off Reasons",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -646,6 +661,7 @@ export const templates: TTemplate[] = [
description: "Find out what users like and don't like about your product or offering.",
preset: {
name: "Uncover Strengths & Weaknesses",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -695,6 +711,7 @@ export const templates: TTemplate[] = [
description: "Measure PMF by assessing how disappointed users would be if your product disappeared.",
preset: {
name: "Product Market Fit Survey (Short)",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -739,6 +756,7 @@ export const templates: TTemplate[] = [
description: "How did you first hear about us?",
preset: {
name: "Marketing Attribution",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -783,6 +801,7 @@ export const templates: TTemplate[] = [
description: "Find out what goes through peoples minds when changing their subscriptions.",
preset: {
name: "Changing subscription experience",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -849,6 +868,7 @@ export const templates: TTemplate[] = [
"Better understand if your messaging creates the right expectations of the value your product provides.",
preset: {
name: "Identify Customer Goals",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -888,6 +908,7 @@ export const templates: TTemplate[] = [
description: "Follow up with users who just used a specific feature.",
preset: {
name: "Feature Chaser",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -926,6 +947,7 @@ export const templates: TTemplate[] = [
description: "Follow up with users who ran into one of your Fake Door experiments.",
preset: {
name: "Fake Door Follow-Up",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -975,6 +997,7 @@ export const templates: TTemplate[] = [
description: "Give your users the chance to seamlessly share what's on their minds.",
preset: {
name: "Feedback Box",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -1038,6 +1061,7 @@ export const templates: TTemplate[] = [
description: "Evaluate how easily users can add integrations to your product. Find blind spots.",
preset: {
name: "Integration Usage Survey",
welcomeCard: welcomeCardDefault,
questions: [
{
id: "s6ss6znzxdwjod1hv16fow4w",
@@ -1080,6 +1104,7 @@ export const templates: TTemplate[] = [
description: "Find out which integrations your users would like to see next.",
preset: {
name: "New Integration Survey",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -1120,6 +1145,7 @@ export const templates: TTemplate[] = [
description: "Measure how clear each page of your developer documentation is.",
preset: {
name: "{{productName}} Docs Feedback",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -1165,6 +1191,7 @@ export const templates: TTemplate[] = [
description: "Measure the Net Promoter Score of your product.",
preset: {
name: "{{productName}} NPS",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -1194,6 +1221,7 @@ export const templates: TTemplate[] = [
description: "Measure the Customer Satisfaction Score of your product.",
preset: {
name: "{{productName}} CSAT",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -1237,6 +1265,7 @@ export const templates: TTemplate[] = [
description: "Find out how much time your product saves your user. Use it to upsell.",
preset: {
name: "Identify upsell opportunities",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -1277,6 +1306,7 @@ export const templates: TTemplate[] = [
description: "Identify features your users need most and least.",
preset: {
name: "Feature Prioritization",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -1328,6 +1358,7 @@ export const templates: TTemplate[] = [
description: "Evaluate the satisfaction of specific features of your product.",
preset: {
name: "Gauge Feature Satisfaction",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -1359,6 +1390,7 @@ export const templates: TTemplate[] = [
description: "Identify users dropping off your marketing site. Improve your messaging.",
preset: {
name: "Marketing Site Clarity",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -1410,6 +1442,7 @@ export const templates: TTemplate[] = [
description: "Determine how easy it is to use a feature.",
preset: {
name: "Customer Effort Score (CES)",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -1444,6 +1477,7 @@ export const templates: TTemplate[] = [
description: "Let customers rate the checkout experience to tweak conversion.",
preset: {
name: "Rate Checkout Experience",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -1487,6 +1521,7 @@ export const templates: TTemplate[] = [
description: "Measure how relevant your search results are.",
preset: {
name: "Measure Search Experience",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -1530,6 +1565,7 @@ export const templates: TTemplate[] = [
description: "Measure if your content marketing pieces hit right.",
preset: {
name: "Evaluate Content Quality",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -1573,6 +1609,7 @@ export const templates: TTemplate[] = [
description: "See if people get their 'Job To Be Done' done. Successful people are better customers.",
preset: {
name: "Measure Task Accomplishment",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -1648,6 +1685,7 @@ export const templates: TTemplate[] = [
description: "Offer a discount to gather insights about sign up barriers.",
preset: {
name: "{{productName}} Sign Up Barriers",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -1770,6 +1808,7 @@ export const templates: TTemplate[] = [
description: "Identify the ONE thing your users want the most and build it.",
preset: {
name: "{{productName}} Roadmap Input",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -1804,6 +1843,7 @@ export const templates: TTemplate[] = [
description: "Find out how close your visitors are to buy or subscribe.",
preset: {
name: "Purchase Intention Survey",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -1855,6 +1895,7 @@ export const templates: TTemplate[] = [
description: "Find out how your subscribers like your newsletter content.",
preset: {
name: "Improve Newsletter Content",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -1907,6 +1948,7 @@ export const templates: TTemplate[] = [
description: "Survey users about product or feature ideas. Get feedback rapidly.",
preset: {
name: "Evaluate a Product Idea",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -2006,6 +2048,7 @@ export const templates: TTemplate[] = [
description: "Identify reasons for low engagement to improve user adoption.",
preset: {
name: "Reasons for Low Engagement",
welcomeCard: welcomeCardDefault,
questions: [
{
id: "aq9dafe9nxe0kpm67b1os2z9",
@@ -2105,7 +2148,9 @@ export const templates: TTemplate[] = [
description: "X",
preset: {
name: "X",
questions: [
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
type: "X",
@@ -2126,6 +2171,7 @@ export const customSurvey: TTemplate = {
description: "Create a survey without template.",
preset: {
name: "New Survey",
welcomeCard: welcomeCardDefault,
questions: [
{
id: createId(),
@@ -2156,6 +2202,7 @@ export const minimalSurvey: TSurvey = {
triggers: [],
redirectUrl: null,
recontactDays: null,
welcomeCard: welcomeCardDefault,
questions: [],
thankYouCard: {
enabled: false,

View File

@@ -107,6 +107,7 @@ export const getSettings = async (environmentId: string, personId: string): Prom
},
},
thankYouCard: true,
welcomeCard: true,
autoClose: true,
delay: true,
},
@@ -182,6 +183,7 @@ export const getSettings = async (environmentId: string, personId: string): Prom
questions: JSON.parse(JSON.stringify(survey.questions)),
triggers: survey.triggers,
thankYouCard: JSON.parse(JSON.stringify(survey.thankYouCard)),
welcomeCard: JSON.parse(JSON.stringify(survey.welcomeCard)),
autoClose: survey.autoClose,
delay: survey.delay,
};

View File

@@ -41,7 +41,9 @@ export default function LinkSurvey({
const isPreview = searchParams?.get("preview") === "true";
// pass in the responseId if the survey is a single use survey, ensures survey state is updated with the responseId
const [surveyState, setSurveyState] = useState(new SurveyState(survey.id, singleUseId, responseId));
const [activeQuestionId, setActiveQuestionId] = useState<string>(survey.questions[0].id);
const [activeQuestionId, setActiveQuestionId] = useState<string>(
survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id
);
const prefillResponseData: TResponseData | undefined = prefillAnswer
? getPrefillResponseData(survey.questions[0], survey, prefillAnswer)
: undefined;
@@ -122,7 +124,9 @@ export default function LinkSurvey({
Survey Preview 👀
<button
className="flex items-center rounded-full bg-slate-500 px-3 py-1 hover:bg-slate-400"
onClick={() => setActiveQuestionId(survey.questions[0].id)}>
onClick={() =>
setActiveQuestionId(survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id)
}>
Restart <ArrowPathIcon className="ml-2 h-4 w-4" />
</button>
</div>

View File

@@ -21,6 +21,14 @@ const nextConfig = {
protocol: "https",
hostname: "lh3.googleusercontent.com",
},
{
protocol: "http",
hostname: "localhost",
},
{
protocol: "https",
hostname: "app.formbricks.com",
},
],
},
async redirects() {

View File

@@ -2,6 +2,7 @@ import { TActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses";
import { TIntegrationConfig } from "@formbricks/types/v1/integrations";
import { TResponseData, TResponseMeta, TResponsePersonAttributes } from "@formbricks/types/v1/responses";
import {
TSurveyWelcomeCard,
TSurveyClosedMessage,
TSurveyHiddenFields,
TSurveyProductOverwrites,
@@ -20,6 +21,7 @@ declare global {
export type ResponseData = TResponseData;
export type ResponseMeta = TResponseMeta;
export type ResponsePersonAttributes = TResponsePersonAttributes;
export type welcomeCard = TSurveyWelcomeCard;
export type SurveyQuestions = TSurveyQuestions;
export type SurveyThankYouCard = TSurveyThankYouCard;
export type SurveyHiddenFields = TSurveyHiddenFields;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Survey" ADD COLUMN "welcomeCard" JSONB NOT NULL DEFAULT '{"enabled": false}';

View File

@@ -234,6 +234,9 @@ model Survey {
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
status SurveyStatus @default(draft)
/// @zod.custom(imports.ZSurveyWelcomeCard)
/// [SurveyWelcomeCard]
welcomeCard Json @default("{\"enabled\": false}")
/// @zod.custom(imports.ZSurveyQuestions)
/// [SurveyQuestions]
questions Json @default("[]")

View File

@@ -7,6 +7,7 @@ export { ZIntegrationConfig } from "@formbricks/types/v1/integrations";
export { ZResponseData, ZResponsePersonAttributes, ZResponseMeta } from "@formbricks/types/v1/responses";
export {
ZSurveyWelcomeCard,
ZSurveyQuestions,
ZSurveyThankYouCard,
ZSurveyHiddenFields,

View File

@@ -47,13 +47,10 @@ type TransformPersonInput = {
};
export const transformPrismaPerson = (person: TransformPersonInput): TPerson => {
const attributes = person.attributes.reduce(
(acc, attr) => {
acc[attr.attributeClass.name] = attr.value;
return acc;
},
{} as Record<string, string | number>
);
const attributes = person.attributes.reduce((acc, attr) => {
acc[attr.attributeClass.name] = attr.value;
return acc;
}, {} as Record<string, string | number>);
return {
id: person.id,

View File

@@ -36,6 +36,7 @@ export const selectSurvey = {
type: true,
environmentId: true,
status: true,
welcomeCard: true,
questions: true,
thankYouCard: true,
hiddenFields: true,

View File

@@ -46,7 +46,7 @@ export default function CTAQuestion({
</button>
)}
<SubmitButton
question={question}
buttonLabel={question.buttonLabel}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
focus={true}

View File

@@ -75,7 +75,7 @@ export default function ConsentQuestion({
<SubmitButton
tabIndex={2}
brandColor={brandColor}
question={question}
buttonLabel={question.buttonLabel}
isLastQuestion={isLastQuestion}
onClick={() => {}}
/>

View File

@@ -221,7 +221,7 @@ export default function MultipleChoiceSingleQuestion({
<div></div>
<SubmitButton
tabIndex={questionChoices.length + 2}
question={question}
buttonLabel={question.buttonLabel}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
onClick={() => {}}

View File

@@ -181,7 +181,7 @@ export default function MultipleChoiceSingleQuestion({
<div></div>
<SubmitButton
tabIndex={questionChoices.length + 2}
question={question}
buttonLabel={question.buttonLabel}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
onClick={() => {}}

View File

@@ -93,7 +93,7 @@ export default function NPSQuestion({
{!question.required && (
<SubmitButton
tabIndex={12}
question={question}
buttonLabel={question.buttonLabel}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
onClick={() => {}}

View File

@@ -105,7 +105,7 @@ export default function OpenTextQuestion({
)}
<div></div>
<SubmitButton
question={question}
buttonLabel={question.buttonLabel}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
onClick={() => {}}

View File

@@ -187,7 +187,7 @@ export default function RatingQuestion({
{(!question.required || value) && (
<SubmitButton
tabIndex={question.range + 1}
question={question}
buttonLabel={question.buttonLabel}
isLastQuestion={isLastQuestion}
brandColor={brandColor}
onClick={() => {}}

View File

@@ -1,10 +1,9 @@
import { useCallback } from "preact/hooks";
import { cn } from "../../../lib/cn";
import { isLight } from "../lib/utils";
import { TSurveyQuestion } from "../../../types/v1/surveys";
interface SubmitButtonProps {
question: TSurveyQuestion;
buttonLabel: string | undefined;
isLastQuestion: boolean;
brandColor: string;
onClick: () => void;
@@ -14,7 +13,7 @@ interface SubmitButtonProps {
}
function SubmitButton({
question,
buttonLabel,
isLastQuestion,
brandColor,
onClick,
@@ -42,7 +41,7 @@ function SubmitButton({
)}
style={{ backgroundColor: brandColor }}
onClick={onClick}>
{question.buttonLabel || (isLastQuestion ? "Finish" : "Next")}
{buttonLabel || (isLastQuestion ? "Finish" : "Next")}
</button>
);
}

View File

@@ -8,6 +8,7 @@ import FormbricksSignature from "./FormbricksSignature";
import ProgressBar from "./ProgressBar";
import QuestionConditional from "./QuestionConditional";
import ThankYouCard from "./ThankYouCard";
import WelcomeCard from "./WelcomeCard";
export function Survey({
survey,
@@ -22,7 +23,9 @@ export function Survey({
isRedirectDisabled = false,
prefillResponseData,
}: SurveyBaseProps) {
const [questionId, setQuestionId] = useState(activeQuestionId || survey.questions[0]?.id);
const [questionId, setQuestionId] = useState(
activeQuestionId || (survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id)
);
const [loadingElement, setLoadingElement] = useState(false);
const [history, setHistory] = useState<string[]>([]);
const [responseData, setResponseData] = useState<TResponseData>({});
@@ -31,7 +34,7 @@ export function Survey({
const contentRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setQuestionId(activeQuestionId || survey.questions[0].id);
setQuestionId(activeQuestionId || (survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id));
}, [activeQuestionId, survey.questions]);
useEffect(() => {
@@ -48,11 +51,14 @@ export function Survey({
onSubmit(prefillResponseData);
}
}, []);
function getNextQuestionId(data: TResponseData): string {
const questions = survey.questions;
const responseValue = data[questionId];
if (questionId === "start") {
return questions[0]?.id || "end";
}
if (currentQuestionIndex === -1) throw new Error("Question not found");
if (currentQuestion?.logic && currentQuestion?.logic.length > 0) {
@@ -109,7 +115,17 @@ export function Survey({
<AutoCloseWrapper survey={survey} brandColor={brandColor} onClose={onClose}>
<div className="flex h-full w-full flex-col justify-between bg-white px-6 pb-3 pt-6">
<div ref={contentRef} className={cn(loadingElement ? "animate-pulse opacity-60" : "", "my-auto")}>
{questionId === "end" && survey.thankYouCard.enabled ? (
{questionId === "start" && survey.welcomeCard.enabled ? (
<WelcomeCard
headline={survey.welcomeCard.headline}
html={survey.welcomeCard.html}
fileUrl={survey.welcomeCard.fileUrl}
buttonLabel={survey.welcomeCard.buttonLabel}
timeToFinish={survey.welcomeCard.timeToFinish}
brandColor={brandColor}
onSubmit={onSubmit}
/>
) : questionId === "end" && survey.thankYouCard.enabled ? (
<ThankYouCard
headline={survey.thankYouCard.headline}
subheader={survey.thankYouCard.subheader}

View File

@@ -0,0 +1,51 @@
import Headline from "./Headline";
import HtmlBody from "./HtmlBody";
import SubmitButton from "./SubmitButton";
interface WelcomeCardProps {
headline?: string;
html?: string;
fileUrl?: string;
buttonLabel?: string;
timeToFinish?: boolean;
brandColor: string;
onSubmit: (data: { [x: string]: any }) => void;
}
export default function WelcomeCard({
headline,
html,
fileUrl,
buttonLabel,
timeToFinish,
brandColor,
onSubmit,
}: WelcomeCardProps) {
return (
<div>
{fileUrl && (
<img src={fileUrl} className="mb-8 max-h-96 w-1/3 rounded-lg object-contain" alt="Company Logo" />
)}
<Headline headline={headline} questionId="welcomeCard" />
<HtmlBody htmlString={html} questionId="welcomeCard" />
<div className="mt-10 flex w-full justify-between">
<div className="flex w-full justify-start gap-4">
<SubmitButton
buttonLabel={buttonLabel}
isLastQuestion={false}
brandColor={brandColor}
focus={true}
onClick={() => {
onSubmit({ ["welcomeCard"]: "clicked" });
}}
type="button"
/>
<div className="flex items-center text-xs text-slate-600">Press Enter </div>
</div>
</div>
{timeToFinish && <></>}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { Question } from "./questions";
import { ThankYouCard } from "./surveys";
import { WelcomeCard } from "./surveys";
export interface ResponseCreateRequest {
surveyId: string;
@@ -79,6 +80,7 @@ export interface Person {
export interface Survey {
id: string;
welcomeCard: WelcomeCard;
questions: Question[];
triggers: Trigger[];
thankYouCard: ThankYouCard;

View File

@@ -130,6 +130,7 @@ export interface CTALogic extends LogicBase {
condition: "clicked" | "skipped" | undefined;
value?: undefined;
}
export interface RatingLogic extends LogicBase {
condition:
| "submitted"

View File

@@ -7,6 +7,15 @@ export interface ThankYouCard {
subheader?: string;
}
export interface WelcomeCard {
enabled: boolean;
headline?: string;
html?: string;
fileUrl?: string;
buttonLabel?: string;
timeToFinish?: boolean;
}
export interface SurveyClosedMessage {
heading?: string;
subheading?: string;
@@ -41,6 +50,7 @@ export interface Survey {
environmentId: string;
status: "draft" | "inProgress" | "paused" | "completed";
recontactDays: number | null;
welcomeCard: WelcomeCard;
questions: Question[];
thankYouCard: ThankYouCard;
triggers: string[];

View File

@@ -8,6 +8,15 @@ export const ZSurveyThankYouCard = z.object({
subheader: z.optional(z.string()),
});
export const ZSurveyWelcomeCard = z.object({
enabled: z.boolean(),
headline: z.optional(z.string()),
html: z.string().optional(),
fileUrl: z.string().optional(),
buttonLabel: z.string().optional(),
timeToFinish: z.boolean().default(false),
});
export const ZSurveyHiddenFields = z.object({
enabled: z.boolean(),
fieldIds: z.optional(z.array(z.string())),
@@ -52,6 +61,8 @@ export const ZSurveyVerifyEmail = z
export type TSurveyVerifyEmail = z.infer<typeof ZSurveyVerifyEmail>;
export type TSurveyWelcomeCard = z.infer<typeof ZSurveyWelcomeCard>;
export type TSurveyThankYouCard = z.infer<typeof ZSurveyThankYouCard>;
export type TSurveyHiddenFields = z.infer<typeof ZSurveyHiddenFields>;
@@ -230,6 +241,17 @@ export const ZSurveyCTAQuestion = ZSurveyQuestionBase.extend({
export type TSurveyCTAQuestion = z.infer<typeof ZSurveyCTAQuestion>;
// export const ZSurveyWelcomeQuestion = ZSurveyQuestionBase.extend({
// type: z.literal(QuestionType.Welcome),
// html: z.string().optional(),
// fileUrl: z.string().optional(),
// buttonUrl: z.string().optional(),
// timeToFinish: z.boolean().default(false),
// logic: z.array(ZSurveyCTALogic).optional(),
// });
// export type TSurveyWelcomeQuestion = z.infer<typeof ZSurveyWelcomeQuestion>;
export const ZSurveyRatingQuestion = ZSurveyQuestionBase.extend({
type: z.literal(QuestionType.Rating),
scale: z.enum(["number", "smiley", "star"]),
@@ -242,6 +264,7 @@ export const ZSurveyRatingQuestion = ZSurveyQuestionBase.extend({
export type TSurveyRatingQuestion = z.infer<typeof ZSurveyRatingQuestion>;
export const ZSurveyQuestion = z.union([
// ZSurveyWelcomeQuestion,
ZSurveyOpenTextQuestion,
ZSurveyConsentQuestion,
ZSurveyMultipleChoiceSingleQuestion,
@@ -291,6 +314,7 @@ export const ZSurvey = z.object({
triggers: z.array(z.string()),
redirectUrl: z.string().url().nullable(),
recontactDays: z.number().nullable(),
welcomeCard: ZSurveyWelcomeCard,
questions: ZSurveyQuestions,
thankYouCard: ZSurveyThankYouCard,
hiddenFields: ZSurveyHiddenFields,
@@ -312,6 +336,7 @@ export const ZSurveyInput = z.object({
autoClose: z.number().optional(),
redirectUrl: z.string().url().optional(),
recontactDays: z.number().optional(),
welcomeCard: ZSurveyWelcomeCard.optional(),
questions: ZSurveyQuestions.optional(),
thankYouCard: ZSurveyThankYouCard.optional(),
hiddenFields: ZSurveyHiddenFields,

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { ZSurveyHiddenFields, ZSurveyQuestions, ZSurveyThankYouCard } from "./surveys";
import { ZSurveyWelcomeCard, ZSurveyHiddenFields, ZSurveyQuestions, ZSurveyThankYouCard } from "./surveys";
const ZTemplateObjective = z.enum([
"increase_user_adoption",
@@ -20,6 +20,7 @@ export const ZTemplate = z.object({
objectives: z.array(ZTemplateObjective).optional(),
preset: z.object({
name: z.string(),
welcomeCard: ZSurveyWelcomeCard,
questions: ZSurveyQuestions,
thankYouCard: ZSurveyThankYouCard,
hiddenFields: ZSurveyHiddenFields,

View File

@@ -358,6 +358,10 @@ export default function ToolbarPlugin(props: TextEditorProps) {
const dom = parser.parseFromString(props.getText(), "text/html");
const nodes = $generateNodesFromDOM(editor, dom);
const paragraph = $createParagraphNode();
$getRoot().clear().append(paragraph);
paragraph.select();
$getRoot().select();
$insertNodes(nodes);

View File

@@ -0,0 +1,154 @@
"use client";
import { PhotoIcon, TrashIcon } from "@heroicons/react/24/outline";
import { ArrowUpTrayIcon } from "@heroicons/react/24/solid";
import { FileIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { uploadFile } from "./lib/fileUpload";
interface FileInputProps {
allowedFileExtensions: string[];
environmentId: string | undefined;
onFileUpload: (uploadedUrl: string | undefined) => void;
fileUrl: string | undefined;
}
const FileInput: React.FC<FileInputProps> = ({
allowedFileExtensions,
environmentId,
onFileUpload,
fileUrl,
}) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploaded, setIsUploaded] = useState<boolean>(!!fileUrl);
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
};
const handleDrop = async (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files[0];
if (
file &&
file.type &&
allowedFileExtensions.includes(file.type.substring(file.type.lastIndexOf("/") + 1))
) {
setIsUploaded(false);
setSelectedFile(file);
const response = await uploadFile(file, allowedFileExtensions, environmentId);
setIsUploaded(true);
onFileUpload(response.data.url);
} else {
toast.error("File not supported");
}
};
return (
<label
htmlFor="selectedFile"
className="relative flex h-52 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:hover:border-slate-500 dark:hover:bg-slate-600 dark:hover:bg-slate-800"
onDragOver={(e) => handleDragOver(e)}
onDrop={(e) => handleDrop(e)}>
{isUploaded && fileUrl ? (
<>
<div className="absolute inset-0 mr-4 mt-2 flex items-start justify-end gap-4">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-300 hover:bg-slate-200/50 text-slate-800 hover:text-slate-900 bg-opacity-50">
<label htmlFor="modifyFile">
<PhotoIcon className="h-5 text-slate-700 hover:text-slate-900 cursor-pointer" />
<input
type="file"
id="modifyFile"
name="modifyFile"
accept={allowedFileExtensions.map((ext) => `.${ext}`).join(",")}
className="hidden"
onChange={async (e) => {
const selectedFile = e.target?.files?.[0];
if (selectedFile) {
setIsUploaded(false);
setSelectedFile(selectedFile);
const response = await uploadFile(selectedFile, allowedFileExtensions, environmentId);
setIsUploaded(true);
onFileUpload(response.data.url);
}
}}
/>
</label>
</div>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-300 hover:bg-slate-200/50 bg-opacity-50">
<TrashIcon className="h-5 text-slate-700 hover:text-slate-900" onClick={() => onFileUpload(undefined)} />
</div>
</div>
{fileUrl.endsWith("jpg") || fileUrl.endsWith("jpeg") || fileUrl.endsWith("png") ? (
<img
src={fileUrl}
alt="Company Logo"
className="max-h-full max-w-full rounded-lg object-contain"
/>
) : (
<div className="flex flex-col items-center justify-center pb-6 pt-5">
<FileIcon className="h-6 text-slate-500" />
<p className="dark.text-slate-400 mt-2 text-sm text-slate-500">
<span className="font-semibold">{fileUrl.split("/").pop()}</span>
</p>
</div>
)}
</>
) : !isUploaded && selectedFile ? (
<>
{selectedFile.type.startsWith("image/") ? (
<img
src={URL.createObjectURL(selectedFile)}
alt="Company Logo"
className="max-h-full max-w-full rounded-lg object-contain"
/>
) : (
<div className="flex flex-col items-center justify-center pb-6 pt-5">
<FileIcon className="h-6 text-slate-500" />
<p className="dark.text-slate-400 mt-2 text-sm text-slate-500">
<span className="font-semibold">{selectedFile.name}</span>
</p>
</div>
)}
<div className="hover.bg-opacity-60 absolute inset-0 flex items-center justify-center bg-black bg-opacity-20 transition-opacity duration-300">
<label htmlFor="selectedFile" className="cursor-pointer text-sm font-semibold text-white">
Uploading
</label>
</div>
</>
) : (
<div className="flex flex-col items-center justify-center pb-6 pt-5">
<ArrowUpTrayIcon className="h-6 text-slate-500" />
<p className="dark.text-slate-400 mt-2 text-sm text-slate-500">
<span className="font-semibold">Click or drag to upload files.</span>
</p>
<input
type="file"
id="selectedFile"
name="selectedFile"
accept={allowedFileExtensions.map((ext) => `.${ext}`).join(",")}
className="hidden"
onChange={async (e) => {
const selectedFile = e.target?.files?.[0];
if (selectedFile) {
setIsUploaded(false);
setSelectedFile(selectedFile);
const response = await uploadFile(selectedFile, allowedFileExtensions, environmentId);
setIsUploaded(true);
onFileUpload(response.data.url);
}
}}
/>
</div>
)}
</label>
);
};
export default FileInput;

View File

@@ -0,0 +1,40 @@
const uploadFile = async (
file: File | Blob,
allowedFileExtensions: string[],
environmentId: string | undefined
) => {
try {
if (!(file instanceof Blob) || !(file instanceof File)) {
throw new Error(`Invalid file type. Expected Blob or File, but received ${typeof file}`);
}
const fileBuffer = await file.arrayBuffer();
const payload = {
fileBuffer: Array.from(new Uint8Array(fileBuffer)),
fileName: file.name,
contentType: file.type,
allowedFileExtensions: allowedFileExtensions,
environmentId: environmentId,
};
const response = await fetch("/api/v1/management/storage", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Upload failed with status: ${response.status}`);
}
return response.json();
} catch (error) {
console.error("Upload error:", error);
throw error;
}
};
export { uploadFile };

View File

@@ -80,8 +80,8 @@ const TagsCombobox: React.FC<ITagsComboboxProps> = ({
onKeyDown={(e) => {
if (e.key === "Enter" && searchValue !== "") {
if (
!tagsToSearch?.find(
(tag) => tag?.label?.toLowerCase().includes(searchValue?.toLowerCase())
!tagsToSearch?.find((tag) =>
tag?.label?.toLowerCase().includes(searchValue?.toLowerCase())
)
) {
createTag?.(searchValue);

View File

@@ -49,6 +49,7 @@
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-radio-group": "^3.0.3",
"react-use": "^17.4.0"
"react-use": "^17.4.0",
"mime": "^3.0.0"
}
}

58
pnpm-lock.yaml generated
View File

@@ -28,7 +28,7 @@ importers:
version: 3.13.0
turbo:
specifier: latest
version: 1.10.13
version: 1.10.15
apps/demo:
dependencies:
@@ -517,7 +517,7 @@ importers:
version: 9.0.0(eslint@8.51.0)
eslint-config-turbo:
specifier: latest
version: 1.8.8(eslint@8.51.0)
version: 1.10.15(eslint@8.51.0)
eslint-plugin-react:
specifier: 7.33.2
version: 7.33.2(eslint@8.51.0)
@@ -844,6 +844,9 @@ importers:
lucide-react:
specifier: ^0.287.0
version: 0.287.0(react@18.2.0)
mime:
specifier: ^3.0.0
version: 3.0.0
next:
specifier: 13.5.5
version: 13.5.5(react-dom@18.2.0)(react@18.2.0)
@@ -12744,13 +12747,13 @@ packages:
resolution: {integrity: sha512-NB/L/1Y30qyJcG5xZxCJKW/+bqyj+llbcCwo9DEz8bESIP0SLTOQ8T1DWCCFc+wJ61AMEstj4511PSScqMMfCw==}
dev: true
/eslint-config-turbo@1.8.8(eslint@8.51.0):
resolution: {integrity: sha512-+yT22sHOT5iC1sbBXfLIdXfbZuiv9bAyOXsxTxFCWelTeFFnANqmuKB3x274CFvf7WRuZ/vYP/VMjzU9xnFnxA==}
/eslint-config-turbo@1.10.15(eslint@8.51.0):
resolution: {integrity: sha512-76mpx2x818JZE26euen14utYcFDxOahZ9NaWA+6Xa4pY2ezVKVschuOxS96EQz3o3ZRSmcgBOapw/gHbN+EKxQ==}
peerDependencies:
eslint: '>6.6.0'
dependencies:
eslint: 8.51.0
eslint-plugin-turbo: 1.8.8(eslint@8.51.0)
eslint-plugin-turbo: 1.10.15(eslint@8.51.0)
dev: true
/eslint-import-resolver-node@0.3.9:
@@ -12949,11 +12952,12 @@ packages:
- typescript
dev: true
/eslint-plugin-turbo@1.8.8(eslint@8.51.0):
resolution: {integrity: sha512-zqyTIvveOY4YU5jviDWw9GXHd4RiKmfEgwsjBrV/a965w0PpDwJgEUoSMB/C/dU310Sv9mF3DSdEjxjJLaw6rA==}
/eslint-plugin-turbo@1.10.15(eslint@8.51.0):
resolution: {integrity: sha512-Tv4QSKV/U56qGcTqS/UgOvb9HcKFmWOQcVh3HEaj7of94lfaENgfrtK48E2CckQf7amhKs1i+imhCsNCKjkQyA==}
peerDependencies:
eslint: '>6.6.0'
dependencies:
dotenv: 16.0.3
eslint: 8.51.0
dev: true
@@ -22169,64 +22173,64 @@ packages:
dependencies:
safe-buffer: 5.2.1
/turbo-darwin-64@1.10.13:
resolution: {integrity: sha512-vmngGfa2dlYvX7UFVncsNDMuT4X2KPyPJ2Jj+xvf5nvQnZR/3IeDEGleGVuMi/hRzdinoxwXqgk9flEmAYp0Xw==}
/turbo-darwin-64@1.10.15:
resolution: {integrity: sha512-Sik5uogjkRTe1XVP9TC2GryEMOJCaKE2pM/O9uLn4koQDnWKGcLQv+mDU+H+9DXvKLnJnKCD18OVRkwK5tdpoA==}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/turbo-darwin-arm64@1.10.13:
resolution: {integrity: sha512-eMoJC+k7gIS4i2qL6rKmrIQGP6Wr9nN4odzzgHFngLTMimok2cGLK3qbJs5O5F/XAtEeRAmuxeRnzQwTl/iuAw==}
/turbo-darwin-arm64@1.10.15:
resolution: {integrity: sha512-xwqyFDYUcl2xwXyGPmHkmgnNm4Cy0oNzMpMOBGRr5x64SErS7QQLR4VHb0ubiR+VAb8M+ECPklU6vD1Gm+wekg==}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/turbo-linux-64@1.10.13:
resolution: {integrity: sha512-0CyYmnKTs6kcx7+JRH3nPEqCnzWduM0hj8GP/aodhaIkLNSAGAa+RiYZz6C7IXN+xUVh5rrWTnU2f1SkIy7Gdg==}
/turbo-linux-64@1.10.15:
resolution: {integrity: sha512-dM07SiO3RMAJ09Z+uB2LNUSkPp3I1IMF8goH5eLj+d8Kkwoxd/+qbUZOj9RvInyxU/IhlnO9w3PGd3Hp14m/nA==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/turbo-linux-arm64@1.10.13:
resolution: {integrity: sha512-0iBKviSGQQlh2OjZgBsGjkPXoxvRIxrrLLbLObwJo3sOjIH0loGmVIimGS5E323soMfi/o+sidjk2wU1kFfD7Q==}
/turbo-linux-arm64@1.10.15:
resolution: {integrity: sha512-MkzKLkKYKyrz4lwfjNXH8aTny5+Hmiu4SFBZbx+5C0vOlyp6fV5jZANDBvLXWiDDL4DSEAuCEK/2cmN6FVH1ow==}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/turbo-windows-64@1.10.13:
resolution: {integrity: sha512-S5XySRfW2AmnTeY1IT+Jdr6Goq7mxWganVFfrmqU+qqq3Om/nr0GkcUX+KTIo9mPrN0D3p5QViBRzulwB5iuUQ==}
/turbo-windows-64@1.10.15:
resolution: {integrity: sha512-3TdVU+WEH9ThvQGwV3ieX/XHebtYNHv9HARHauPwmVj3kakoALkpGxLclkHFBLdLKkqDvmHmXtcsfs6cXXRHJg==}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/turbo-windows-arm64@1.10.13:
resolution: {integrity: sha512-nKol6+CyiExJIuoIc3exUQPIBjP9nIq5SkMJgJuxsot2hkgGrafAg/izVDRDrRduQcXj2s8LdtxJHvvnbI8hEQ==}
/turbo-windows-arm64@1.10.15:
resolution: {integrity: sha512-l+7UOBCbfadvPMYsX08hyLD+UIoAkg6ojfH+E8aud3gcA1padpjCJTh9gMpm3QdMbKwZteT5uUM+wyi6Rbbyww==}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/turbo@1.10.13:
resolution: {integrity: sha512-vOF5IPytgQPIsgGtT0n2uGZizR2N3kKuPIn4b5p5DdeLoI0BV7uNiydT7eSzdkPRpdXNnO8UwS658VaI4+YSzQ==}
/turbo@1.10.15:
resolution: {integrity: sha512-mKKkqsuDAQy1wCCIjCdG+jOCwUflhckDMSRoeBPcIL/CnCl7c5yRDFe7SyaXloUUkt4tUR0rvNIhVCcT7YeQpg==}
hasBin: true
optionalDependencies:
turbo-darwin-64: 1.10.13
turbo-darwin-arm64: 1.10.13
turbo-linux-64: 1.10.13
turbo-linux-arm64: 1.10.13
turbo-windows-64: 1.10.13
turbo-windows-arm64: 1.10.13
turbo-darwin-64: 1.10.15
turbo-darwin-arm64: 1.10.15
turbo-linux-64: 1.10.15
turbo-linux-arm64: 1.10.15
turbo-windows-64: 1.10.15
turbo-windows-arm64: 1.10.15
dev: true
/tw-to-css@0.0.11: