Add CTA Question Type (#246)

* add CTA question type together with new Text Editor based on Lexical and CTA summary

---------

Co-authored-by: moritzrengert <moritz@rengert.de>
This commit is contained in:
Matti Nannt
2023-04-24 20:13:39 +02:00
committed by GitHub
parent 501ce56de6
commit 7eefdd336c
43 changed files with 2084 additions and 208 deletions
@@ -0,0 +1,125 @@
"use client";
import type { CTAQuestion } from "@formbricks/types/questions";
import { Editor, Input, Label } from "@formbricks/ui";
import { RadioGroup, RadioGroupItem } from "@formbricks/ui";
import { useState } from "react";
import { md } from "@formbricks/lib/markdownIt";
interface CTAQuestionFormProps {
question: CTAQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;
}
export default function CTAQuestionForm({
question,
questionIdx,
updateQuestion,
lastQuestion,
}: CTAQuestionFormProps) {
const [firstRender, setFirstRender] = useState(true);
return (
<form>
<div className="mt-3">
<Label htmlFor="headline">Question</Label>
<div className="mt-2">
<Input
id="headline"
name="headline"
value={question.headline}
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
/>
</div>
</div>
<div className="mt-3">
<Label htmlFor="subheader">Description</Label>
<div className="mt-2">
{/* <Input
id="subheader"
name="subheader"
value={question.subheader}
onChange={(e) => updateQuestion(questionIdx, { subheader: e.target.value })}
/> */}
<Editor
getText={() => md.render(question.html || "")}
setText={(value: string) => {
updateQuestion(questionIdx, { html: value });
}}
excludedToolbarItems={["blockType"]}
disableLists
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
</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 flex justify-between gap-8">
<div className="flex-1">
<Label htmlFor="buttonLabel">Button Label</Label>
<div className="mt-2">
<Input
id="buttonLabel"
name="buttonLabel"
value={question.buttonLabel}
placeholder={lastQuestion ? "Finish" : "Next"}
onChange={(e) => updateQuestion(questionIdx, { buttonLabel: e.target.value })}
/>
</div>
</div>
{question.buttonExternal && (
<div className="flex-1">
<Label htmlFor="buttonLabel">Button URL</Label>
<div className="mt-2">
<Input
id="buttonUrl"
name="buttonUrl"
value={question.buttonUrl}
placeholder="https://website.com"
onChange={(e) => updateQuestion(questionIdx, { buttonUrl: e.target.value })}
/>
</div>
</div>
)}
</div>
<div className="mt-3">
{!question.required && (
<div className="flex-1">
<Label htmlFor="buttonLabel">Skip Button Label</Label>
<div className="mt-2">
<Input
id="dismissButtonLabel"
name="dismissButtonLabel"
value={question.dismissButtonLabel}
placeholder="Skip"
onChange={(e) => updateQuestion(questionIdx, { dismissButtonLabel: e.target.value })}
/>
</div>
</div>
)}
</div>
</form>
);
}
@@ -15,6 +15,7 @@ import OpenQuestionForm from "./OpenQuestionForm";
import QuestionDropdown from "./QuestionDropdown";
import NPSQuestionForm from "./NPSQuestionForm";
import UpdateQuestionId from "./UpdateQuestionId";
import CTAQuestionForm from "./CTAQuestionForm";
interface QuestionCardProps {
localSurvey: Survey;
@@ -131,6 +132,13 @@ export default function QuestionCard({
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
/>
) : question.type === "cta" ? (
<CTAQuestionForm
question={question}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
/>
) : null}
<div className="mt-4 border-t border-slate-200">
<Collapsible.Root open={openAdvanced} onOpenChange={setOpenAdvanced} className="mt-3">
@@ -56,7 +56,7 @@ export default function SingleResponse({ data, environmentId }: OpenTextSummaryP
{data.responses.map((response, idx) => (
<div key={`${response.id}-${idx}`}>
<p className="text-sm text-slate-500">{response.question}</p>
{typeof response.answer === "string" ? (
{typeof response.answer !== "object" ? (
<p className="ph-no-capture my-1 font-semibold text-slate-700">{response.answer}</p>
) : (
<p className="ph-no-capture my-1 font-semibold text-slate-700">{response.answer.join(", ")}</p>
@@ -0,0 +1,57 @@
import { ProgressBar } from "@formbricks/ui";
import type { QuestionSummary } from "@formbricks/types/responses";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
interface CTASummaryProps {
questionSummary: QuestionSummary;
}
interface ChoiceResult {
count: number;
percentage: number;
}
export default function CTASummary({ questionSummary }: CTASummaryProps) {
const ctr: ChoiceResult = useMemo(() => {
const clickedAbs = questionSummary.responses.filter((response) => response.value === "clicked").length;
return {
count: questionSummary.responses.length,
percentage: clickedAbs / questionSummary.responses.length,
};
}, [questionSummary]);
return (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-6 pb-5 pt-6">
<div>
<h3 className="pb-1 text-xl font-semibold text-slate-900">{questionSummary.question.headline}</h3>
</div>
<div className="flex space-x-2 font-semibold text-slate-600">
<div className="rounded-lg bg-slate-100 p-2 text-sm">Call-to-Action</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2 text-sm">
<InboxStackIcon className="mr-2 h-4 w-4 " />
{ctr.count} responses
</div>
</div>
</div>
<div className="space-y-5 rounded-b-lg bg-white px-6 pb-6 pt-4">
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
<p className="font-semibold text-slate-700">Clickthrough Rate (CTR)</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(ctr.percentage * 100)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{ctr.count} {ctr.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={ctr.percentage} />
</div>
</div>
);
}
@@ -17,9 +17,9 @@ interface Result {
}
export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
console.log(questionSummary);
const percentage = (count, total) => {
return count / total;
const result = count / total;
return result || 0;
};
const result: Result = useMemo(() => {
@@ -10,6 +10,7 @@ import { useMemo } from "react";
import MultipleChoiceSummary from "./MultipleChoiceSummary";
import OpenTextSummary from "./OpenTextSummary";
import NPSSummary from "./NPSSummary";
import CTASummary from "./CTASummary";
export default function SummaryList({ environmentId, surveyId }) {
const { responsesData, isLoadingResponses, isErrorResponses } = useResponses(environmentId, surveyId);
@@ -80,6 +81,9 @@ export default function SummaryList({ environmentId, surveyId }) {
if (questionSummary.question.type === "nps") {
return <NPSSummary key={questionSummary.question.id} questionSummary={questionSummary} />;
}
if (questionSummary.question.type === "cta") {
return <CTASummary key={questionSummary.question.id} questionSummary={questionSummary} />;
}
return null;
})}
</>
@@ -0,0 +1,45 @@
import type { CTAQuestion } from "@formbricks/types/questions";
import Headline from "./Headline";
import HtmlBody from "./HtmlBody";
interface CTAQuestionProps {
question: CTAQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
}
export default function CTAQuestion({ question, onSubmit, lastQuestion, brandColor }: CTAQuestionProps) {
return (
<div>
<Headline headline={question.headline} questionId={question.id} />
<HtmlBody htmlString={question.html || ""} questionId={question.id} />
<div className="mt-4 flex w-full justify-end">
<div></div>
{!question.required && (
<button
type="button"
onClick={() => {
onSubmit({ [question.id]: "dismissed" });
}}
className="mr-4 flex items-center rounded-md px-3 py-3 text-base font-medium leading-4 text-slate-500 hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 dark:border-slate-400 dark:text-slate-400">
{question.dismissButtonLabel || "Skip"}
</button>
)}
<button
type="button"
onClick={() => {
if (question.buttonExternal && question.buttonUrl) {
window?.open(question.buttonUrl, "_blank")?.focus();
}
onSubmit({ [question.id]: "clicked" });
}}
className="flex items-center rounded-md border border-transparent px-3 py-3 text-base font-medium leading-4 text-white shadow-sm hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2"
style={{ backgroundColor: brandColor }}>
{question.buttonLabel || (lastQuestion ? "Finish" : "Next")}
</button>
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { cleanHtml } from "@formbricks/lib/cleanHtml";
export default function HtmlBody({ htmlString, questionId }: { htmlString: string; questionId: string }) {
return (
<label
htmlFor={questionId}
className="fb-block fb-text-sm fb-font-normal fb-leading-6 text-slate-600"
dangerouslySetInnerHTML={{ __html: cleanHtml(htmlString) }}></label>
);
}
@@ -3,6 +3,7 @@ import OpenTextQuestion from "./OpenTextQuestion";
import MultipleChoiceSingleQuestion from "./MultipleChoiceSingleQuestion";
import MultipleChoiceMultiQuestion from "./MultipleChoiceMultiQuestion";
import NPSQuestion from "./NPSQuestion";
import CTAQuestion from "./CTAQuestion";
interface QuestionConditionalProps {
question: Question;
@@ -45,5 +46,12 @@ export default function QuestionConditional({
lastQuestion={lastQuestion}
brandColor={brandColor}
/>
) : question.type === "cta" ? (
<CTAQuestion
question={question}
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
/>
) : null;
}
+16 -1
View File
@@ -1,4 +1,9 @@
import { Bars3BottomLeftIcon, ChartPieIcon, ListBulletIcon } from "@heroicons/react/24/solid";
import {
Bars3BottomLeftIcon,
ChartPieIcon,
ListBulletIcon,
ArrowRightOnRectangleIcon,
} from "@heroicons/react/24/solid";
import { createId } from "@paralleldrive/cuid2";
import { replaceQuestionPresetPlaceholders } from "./templates";
@@ -55,6 +60,16 @@ export const questionTypes: QuestionType[] = [
upperLabel: "Extremely likely",
},
},
{
id: "cta",
label: "Call-to-Action",
description: "Ask your users to perform an action",
icon: ArrowRightOnRectangleIcon,
preset: {
buttonExternal: false,
dismissButtonLabel: "Skip",
},
},
];
export const universalQuestionPresets = {
+2
View File
@@ -27,6 +27,7 @@
"eslint-config-next": "^13.3.0",
"jsonwebtoken": "^9.0.0",
"lucide-react": "^0.161.0",
"markdown-it": "^13.0.1",
"next": "13.2.4",
"next-auth": "^4.22.0",
"nodemailer": "^6.9.1",
@@ -53,6 +54,7 @@
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9",
"@types/bcryptjs": "^2.4.2",
"@types/markdown-it": "^12.2.3",
"autoprefixer": "^10.4.14",
"eslint-config-formbricks": "workspace:*",
"postcss": "^8.4.22",