mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-26 11:48:27 -05:00
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:
+125
@@ -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">
|
||||
|
||||
+1
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user