From 7eefdd336c786b833c4e94b0c5bd4ea2ed515a46 Mon Sep 17 00:00:00 2001 From: Matti Nannt Date: Mon, 24 Apr 2023 20:13:39 +0200 Subject: [PATCH] Add CTA Question Type (#246) * add CTA question type together with new Text Editor based on Lexical and CTA summary --------- Co-authored-by: moritzrengert --- .../[surveyId]/edit/CTAQuestionForm.tsx | 125 ++++ .../surveys/[surveyId]/edit/QuestionCard.tsx | 8 + .../[surveyId]/responses/SingleResponse.tsx | 2 +- .../surveys/[surveyId]/summary/CTASummary.tsx | 57 ++ .../surveys/[surveyId]/summary/NPSSummary.tsx | 4 +- .../[surveyId]/summary/SummaryList.tsx | 4 + apps/web/components/preview/CTAQuestion.tsx | 45 ++ apps/web/components/preview/HtmlBody.tsx | 10 + .../preview/QuestionConditional.tsx | 8 + apps/web/lib/questions.ts | 17 +- apps/web/package.json | 2 + packages/js/src/components/CTAQuestion.tsx | 47 ++ packages/js/src/components/HtmlBody.tsx | 11 + .../js/src/components/QuestionConditional.tsx | 8 + packages/js/src/components/Subheader.tsx | 2 +- packages/js/src/components/SurveyView.tsx | 1 - packages/js/src/lib/cleanHtml.ts | 79 +++ packages/js/src/lib/styles.ts | 3 +- packages/lib/cleanHtml.ts | 79 +++ packages/lib/markdownIt.ts | 3 + packages/types/questions.ts | 15 +- packages/ui/components/Button.tsx | 3 +- packages/ui/components/ProgressBar.tsx | 3 +- packages/ui/components/editor/Editor.tsx | 106 ++++ packages/ui/components/editor/ExampleTheme.ts | 24 + .../components/editor/images/icons/link.svg | 4 + .../editor/images/icons/list-ol.svg | 4 + .../editor/images/icons/list-ul.svg | 3 + .../editor/images/icons/pencil-fill.svg | 3 + .../editor/images/icons/text-paragraph.svg | 3 + .../editor/images/icons/type-bold.svg | 3 + .../editor/images/icons/type-h1.svg | 3 + .../editor/images/icons/type-h2.svg | 3 + .../editor/images/icons/type-italic.svg | 3 + packages/ui/components/editor/index.ts | 5 + .../editor/plugins/AddVariablesDropdown.tsx | 60 ++ .../editor/plugins/AutoLinkPlugin.tsx | 36 ++ .../editor/plugins/ToolbarPlugin.tsx | 519 ++++++++++++++++ .../ui/components/editor/stylesEditor.css | 337 ++++++++++ .../editor/stylesEditorFrontend.css | 55 ++ packages/ui/index.tsx | 1 + packages/ui/package.json | 7 + pnpm-lock.yaml | 577 ++++++++++++------ 43 files changed, 2084 insertions(+), 208 deletions(-) create mode 100644 apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/CTAQuestionForm.tsx create mode 100644 apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/CTASummary.tsx create mode 100644 apps/web/components/preview/CTAQuestion.tsx create mode 100644 apps/web/components/preview/HtmlBody.tsx create mode 100644 packages/js/src/components/CTAQuestion.tsx create mode 100644 packages/js/src/components/HtmlBody.tsx create mode 100644 packages/js/src/lib/cleanHtml.ts create mode 100644 packages/lib/cleanHtml.ts create mode 100644 packages/lib/markdownIt.ts create mode 100644 packages/ui/components/editor/Editor.tsx create mode 100644 packages/ui/components/editor/ExampleTheme.ts create mode 100644 packages/ui/components/editor/images/icons/link.svg create mode 100644 packages/ui/components/editor/images/icons/list-ol.svg create mode 100644 packages/ui/components/editor/images/icons/list-ul.svg create mode 100644 packages/ui/components/editor/images/icons/pencil-fill.svg create mode 100644 packages/ui/components/editor/images/icons/text-paragraph.svg create mode 100644 packages/ui/components/editor/images/icons/type-bold.svg create mode 100644 packages/ui/components/editor/images/icons/type-h1.svg create mode 100644 packages/ui/components/editor/images/icons/type-h2.svg create mode 100644 packages/ui/components/editor/images/icons/type-italic.svg create mode 100644 packages/ui/components/editor/index.ts create mode 100644 packages/ui/components/editor/plugins/AddVariablesDropdown.tsx create mode 100644 packages/ui/components/editor/plugins/AutoLinkPlugin.tsx create mode 100644 packages/ui/components/editor/plugins/ToolbarPlugin.tsx create mode 100644 packages/ui/components/editor/stylesEditor.css create mode 100644 packages/ui/components/editor/stylesEditorFrontend.css diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/CTAQuestionForm.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/CTAQuestionForm.tsx new file mode 100644 index 0000000000..e7800cfb2b --- /dev/null +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/CTAQuestionForm.tsx @@ -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 ( +
+
+ +
+ updateQuestion(questionIdx, { headline: e.target.value })} + /> +
+
+ +
+ +
+ {/* updateQuestion(questionIdx, { subheader: e.target.value })} + /> */} + md.render(question.html || "")} + setText={(value: string) => { + updateQuestion(questionIdx, { html: value }); + }} + excludedToolbarItems={["blockType"]} + disableLists + firstRender={firstRender} + setFirstRender={setFirstRender} + /> +
+
+ + updateQuestion(questionIdx, { buttonExternal: e === "external" })}> +
+ + +
+
+ + +
+
+ +
+
+ +
+ updateQuestion(questionIdx, { buttonLabel: e.target.value })} + /> +
+
+ {question.buttonExternal && ( +
+ +
+ updateQuestion(questionIdx, { buttonUrl: e.target.value })} + /> +
+
+ )} +
+ +
+ {!question.required && ( +
+ +
+ updateQuestion(questionIdx, { dismissButtonLabel: e.target.value })} + /> +
+
+ )} +
+
+ ); +} diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/QuestionCard.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/QuestionCard.tsx index 0aa91b8acd..0af858efc1 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/QuestionCard.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/QuestionCard.tsx @@ -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" ? ( + ) : null}
diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/SingleResponse.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/SingleResponse.tsx index 2c63bcae8f..a908f8996a 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/SingleResponse.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/responses/SingleResponse.tsx @@ -56,7 +56,7 @@ export default function SingleResponse({ data, environmentId }: OpenTextSummaryP {data.responses.map((response, idx) => (

{response.question}

- {typeof response.answer === "string" ? ( + {typeof response.answer !== "object" ? (

{response.answer}

) : (

{response.answer.join(", ")}

diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/CTASummary.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/CTASummary.tsx new file mode 100644 index 0000000000..2a420a45ca --- /dev/null +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/CTASummary.tsx @@ -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 ( +
+
+
+

{questionSummary.question.headline}

+
+
+
Call-to-Action
+
+ + {ctr.count} responses +
+
+
+
+
+
+

Clickthrough Rate (CTR)

+
+

+ {Math.round(ctr.percentage * 100)}% +

+
+
+

+ {ctr.count} {ctr.count === 1 ? "response" : "responses"} +

+
+ +
+
+ ); +} diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/NPSSummary.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/NPSSummary.tsx index 759ffd9715..e3254c94b4 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/NPSSummary.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/NPSSummary.tsx @@ -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(() => { diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SummaryList.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SummaryList.tsx index 52c7b6bf66..47fef75738 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SummaryList.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SummaryList.tsx @@ -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 ; } + if (questionSummary.question.type === "cta") { + return ; + } return null; })} diff --git a/apps/web/components/preview/CTAQuestion.tsx b/apps/web/components/preview/CTAQuestion.tsx new file mode 100644 index 0000000000..a6e3f2bf43 --- /dev/null +++ b/apps/web/components/preview/CTAQuestion.tsx @@ -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 ( +
+ + + +
+
+ {!question.required && ( + + )} + +
+
+ ); +} diff --git a/apps/web/components/preview/HtmlBody.tsx b/apps/web/components/preview/HtmlBody.tsx new file mode 100644 index 0000000000..e59f0247b4 --- /dev/null +++ b/apps/web/components/preview/HtmlBody.tsx @@ -0,0 +1,10 @@ +import { cleanHtml } from "@formbricks/lib/cleanHtml"; + +export default function HtmlBody({ htmlString, questionId }: { htmlString: string; questionId: string }) { + return ( + + ); +} diff --git a/apps/web/components/preview/QuestionConditional.tsx b/apps/web/components/preview/QuestionConditional.tsx index 9ec6555057..55a8d6c69d 100644 --- a/apps/web/components/preview/QuestionConditional.tsx +++ b/apps/web/components/preview/QuestionConditional.tsx @@ -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" ? ( + ) : null; } diff --git a/apps/web/lib/questions.ts b/apps/web/lib/questions.ts index af5797a425..32cc43216f 100644 --- a/apps/web/lib/questions.ts +++ b/apps/web/lib/questions.ts @@ -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 = { diff --git a/apps/web/package.json b/apps/web/package.json index 735299a499..b77758701e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/packages/js/src/components/CTAQuestion.tsx b/packages/js/src/components/CTAQuestion.tsx new file mode 100644 index 0000000000..b8b2602ffc --- /dev/null +++ b/packages/js/src/components/CTAQuestion.tsx @@ -0,0 +1,47 @@ +import { h } from "preact"; +import type { CTAQuestion } from "@formbricks/types/questions"; +import Headline from "./Headline"; +import Subheader from "./Subheader"; +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 ( +
+ + + +
+
+ {!question.required && ( + + )} + +
+
+ ); +} diff --git a/packages/js/src/components/HtmlBody.tsx b/packages/js/src/components/HtmlBody.tsx new file mode 100644 index 0000000000..5fbc28ee46 --- /dev/null +++ b/packages/js/src/components/HtmlBody.tsx @@ -0,0 +1,11 @@ +import { h } from "preact"; +import { cleanHtml } from "../lib/cleanHtml"; + +export default function HtmlBody({ htmlString, questionId }: { htmlString?: string; questionId: string }) { + return ( + + ); +} diff --git a/packages/js/src/components/QuestionConditional.tsx b/packages/js/src/components/QuestionConditional.tsx index 3df86f9b36..81e88a5392 100644 --- a/packages/js/src/components/QuestionConditional.tsx +++ b/packages/js/src/components/QuestionConditional.tsx @@ -4,6 +4,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; @@ -46,5 +47,12 @@ export default function QuestionConditional({ lastQuestion={lastQuestion} brandColor={brandColor} /> + ) : question.type === "cta" ? ( + ) : null; } diff --git a/packages/js/src/components/Subheader.tsx b/packages/js/src/components/Subheader.tsx index 679684c2ae..edf1c6cad4 100644 --- a/packages/js/src/components/Subheader.tsx +++ b/packages/js/src/components/Subheader.tsx @@ -2,7 +2,7 @@ import { h } from "preact"; export default function Subheader({ subheader, questionId }: { subheader?: string; questionId: string }) { return ( -