diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/CTAQuestionForm.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/CTAQuestionForm.tsx index c1fc734283..202e680735 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/CTAQuestionForm.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/CTAQuestionForm.tsx @@ -37,12 +37,6 @@ export default function CTAQuestionForm({
- {/* updateQuestion(questionIdx, { subheader: e.target.value })} - /> */} md.render( diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/NPSQuestionForm.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/NPSQuestionForm.tsx index e5023f17b0..865556df05 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/NPSQuestionForm.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/NPSQuestionForm.tsx @@ -65,18 +65,20 @@ export default function NPSQuestionForm({
-
- -
- updateQuestion(questionIdx, { buttonLabel: e.target.value })} - /> + {!question.required && ( +
+ +
+ updateQuestion(questionIdx, { buttonLabel: 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 0432002d1d..9cba974ee4 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/QuestionCard.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/QuestionCard.tsx @@ -22,6 +22,7 @@ import QuestionDropdown from "./QuestionDropdown"; import NPSQuestionForm from "./NPSQuestionForm"; import UpdateQuestionId from "./UpdateQuestionId"; import CTAQuestionForm from "./CTAQuestionForm"; +import RatingQuestionForm from "./RatingQuestionForm"; interface QuestionCardProps { localSurvey: Survey; @@ -157,6 +158,13 @@ export default function QuestionCard({ updateQuestion={updateQuestion} lastQuestion={lastQuestion} /> + ) : question.type === "rating" ? ( + ) : null}
diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/RatingQuestionForm.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/RatingQuestionForm.tsx new file mode 100644 index 0000000000..7719b2ceb7 --- /dev/null +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/RatingQuestionForm.tsx @@ -0,0 +1,124 @@ +import type { RatingQuestion } from "@formbricks/types/questions"; +import { Input, Label } from "@formbricks/ui"; +import { HashtagIcon, StarIcon, FaceSmileIcon } from "@heroicons/react/24/outline"; + +import Dropdown from "./RatingTypeDropdown"; + +interface RatingQuestionFormProps { + question: RatingQuestion; + questionIdx: number; + updateQuestion: (questionIdx: number, updatedAttributes: any) => void; + lastQuestion: boolean; +} + +export default function RatingQuestionForm({ + question, + questionIdx, + updateQuestion, + lastQuestion, +}: RatingQuestionFormProps) { + return ( +
+
+ +
+ updateQuestion(questionIdx, { headline: e.target.value })} + /> +
+
+ +
+ +
+ updateQuestion(questionIdx, { subheader: e.target.value })} + /> +
+
+ +
+
+ +
+ updateQuestion(questionIdx, { scale: option.value })} + /> +
+
+
+ +
+ updateQuestion(questionIdx, { range: option.value })} + /> +
+
+
+ +
+
+ +
+ updateQuestion(questionIdx, { lowerLabel: e.target.value })} + /> +
+
+
+ +
+ updateQuestion(questionIdx, { upperLabel: e.target.value })} + /> +
+
+
+ +
+ {!question.required && ( +
+ +
+ updateQuestion(questionIdx, { buttonLabel: e.target.value })} + /> +
+
+ )} +
+
+ ); +} diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/RatingTypeDropdown.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/RatingTypeDropdown.tsx new file mode 100644 index 0000000000..7720ddaf63 --- /dev/null +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/edit/RatingTypeDropdown.tsx @@ -0,0 +1,64 @@ +import React, { useState } from "react"; +import { ChevronDownIcon } from "@heroicons/react/24/solid"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; + +type Option = { + label: string; + icon?: any; + value: string | number; + disabled?: boolean; +}; + +type DropdownProps = { + options: Option[]; + defaultValue: string | number; + onSelect: (option: Option) => any; +}; + +const Dropdown = ({ options, defaultValue, onSelect }: DropdownProps) => { + const [selectedOption, setSelectedOption] = useState
-
+
{["promoters", "passives", "detractors"].map((group) => (
@@ -83,7 +106,27 @@ export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
))}
-
+ {dismissed.count > 0 && ( +
+
+
+
+

{dismissed.label}

+
+

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

+
+
+

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

+
+ +
+
+ )} +
diff --git a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/RatingSummary.tsx b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/RatingSummary.tsx new file mode 100644 index 0000000000..cc604a75ff --- /dev/null +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/RatingSummary.tsx @@ -0,0 +1,130 @@ +import { ProgressBar } from "@formbricks/ui"; +import type { QuestionSummary } from "@formbricks/types/responses"; +import { InboxStackIcon } from "@heroicons/react/24/solid"; +import { useMemo } from "react"; + +interface RatingSummaryProps { + questionSummary: QuestionSummary; +} + +interface ChoiceResult { + label: string; + count: number; + percentage: number; +} + +export default function RatingSummary({ questionSummary }: RatingSummaryProps) { + const results: ChoiceResult[] = useMemo(() => { + if (questionSummary.question.type !== "rating") return []; + // build a dictionary of choices + const resultsDict: { [key: string]: ChoiceResult } = {}; + for (let i = 1; i <= questionSummary.question.range; i++) { + resultsDict[i.toString()] = { + count: 0, + label: i.toString(), + percentage: 0, + }; + } + // count the responses + for (const response of questionSummary.responses) { + // if single choice, only add responses that are in the choices + if (response.value in resultsDict) { + resultsDict[response.value].count += 1; + } + } + // add the percentage + const total = questionSummary.responses.length; + for (const key of Object.keys(resultsDict)) { + if (resultsDict[key].count) { + resultsDict[key].percentage = resultsDict[key].count / total; + } + } + + // sort by count and transform to array + const results = Object.values(resultsDict).sort((a: any, b: any) => a.label - b.label); + + return results; + }, [questionSummary]); + + const dismissed: ChoiceResult = useMemo(() => { + if (questionSummary.question.required) return { count: 0, label: "Dismissed", percentage: 0 }; + + const total = questionSummary.responses.length; + let count = 0; + for (const response of questionSummary.responses) { + if (!response.value) { + count += 1; + } + } + return { + count, + label: "Dismissed", + percentage: count / total, + }; + }, [questionSummary]); + + const totalResponses = useMemo(() => { + let total = 0; + for (const result of results) { + total += result.count; + } + return total; + }, [results]); + + return ( +
+
+
+

{questionSummary.question.headline}

+
+
+
Rating Question
+
+ + {totalResponses} responses +
+
+
+
+ {results.map((result: any) => ( +
+
+
+

{result.label}

+
+

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

+
+
+

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

+
+ +
+ ))} +
+ {dismissed.count > 0 && ( +
+
+
+
+

{dismissed.label}

+
+

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

+
+
+

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

+
+ +
+
+ )} +
+ ); +} 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 ac475c3a7e..8a3eafb562 100644 --- a/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SummaryList.tsx +++ b/apps/web/app/environments/[environmentId]/surveys/[surveyId]/summary/SummaryList.tsx @@ -11,6 +11,7 @@ import MultipleChoiceSummary from "./MultipleChoiceSummary"; import OpenTextSummary from "./OpenTextSummary"; import NPSSummary from "./NPSSummary"; import CTASummary from "./CTASummary"; +import RatingSummary from "./RatingSummary"; export default function SummaryList({ environmentId, surveyId }) { const { responsesData, isLoadingResponses, isErrorResponses } = useResponses(environmentId, surveyId); @@ -87,6 +88,9 @@ export default function SummaryList({ environmentId, surveyId }) { if (questionSummary.question.type === "cta") { return ; } + if (questionSummary.question.type === "rating") { + return ; + } return null; })} diff --git a/apps/web/components/preview/NPSQuestion.tsx b/apps/web/components/preview/NPSQuestion.tsx index 5ee22b891f..e583a3cc3f 100644 --- a/apps/web/components/preview/NPSQuestion.tsx +++ b/apps/web/components/preview/NPSQuestion.tsx @@ -14,6 +14,15 @@ interface NPSQuestionProps { export default function NPSQuestion({ question, onSubmit, lastQuestion, brandColor }: NPSQuestionProps) { const [selectedChoice, setSelectedChoice] = useState(null); + const handleSelect = (number: number) => { + setSelectedChoice(number); + if (question.required) { + onSubmit({ + [question.id]: number, + }); + } + }; + return (
{ @@ -37,14 +46,14 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol key={number} className={cn( selectedChoice === number ? "z-10 border-slate-400 bg-slate-50" : "", - "relative h-10 flex-1 cursor-pointer border bg-white text-center text-sm leading-10 hover:bg-gray-100 focus:outline-none" + "relative h-10 flex-1 cursor-pointer border bg-white text-center text-sm leading-10 first:rounded-l-md last:rounded-r-md hover:bg-gray-100 focus:outline-none" )}> setSelectedChoice(number)} + onChange={() => handleSelect(number)} required={question.required} /> {number} @@ -57,15 +66,17 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
-
-
- -
+ {!question.required && ( +
+
+ +
+ )} ); } diff --git a/apps/web/components/preview/QuestionConditional.tsx b/apps/web/components/preview/QuestionConditional.tsx index 55a8d6c69d..3cd9f3613d 100644 --- a/apps/web/components/preview/QuestionConditional.tsx +++ b/apps/web/components/preview/QuestionConditional.tsx @@ -4,6 +4,7 @@ import MultipleChoiceSingleQuestion from "./MultipleChoiceSingleQuestion"; import MultipleChoiceMultiQuestion from "./MultipleChoiceMultiQuestion"; import NPSQuestion from "./NPSQuestion"; import CTAQuestion from "./CTAQuestion"; +import RatingQuestion from "./RatingQuestion"; interface QuestionConditionalProps { question: Question; @@ -53,5 +54,12 @@ export default function QuestionConditional({ lastQuestion={lastQuestion} brandColor={brandColor} /> + ) : question.type === "rating" ? ( + ) : null; } diff --git a/apps/web/components/preview/RatingQuestion.tsx b/apps/web/components/preview/RatingQuestion.tsx new file mode 100644 index 0000000000..235cfbae40 --- /dev/null +++ b/apps/web/components/preview/RatingQuestion.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { cn } from "@formbricks/lib/cn"; +import type { RatingQuestion } from "@formbricks/types/questions"; +import Headline from "./Headline"; +import Subheader from "./Subheader"; + +interface RatingQuestionProps { + question: RatingQuestion; + onSubmit: (data: { [x: string]: any }) => void; + lastQuestion: boolean; + brandColor: string; +} + +export default function RatingQuestion({ + question, + onSubmit, + lastQuestion, + brandColor, +}: RatingQuestionProps) { + const [selectedChoice, setSelectedChoice] = useState(null); + + const handleSelect = (number: number) => { + setSelectedChoice(number); + if (question.required) { + onSubmit({ + [question.id]: number, + }); + setSelectedChoice(null); // reset choice + } + }; + + return ( +
{ + e.preventDefault(); + + const data = { + [question.id]: selectedChoice, + }; + + setSelectedChoice(null); // reset choice + + onSubmit(data); + }}> + + +
+
+ Choices +
+ {Array.from({ length: question.range }, (_, i) => i + 1).map((number) => ( + + ))} +
+
+

{question.lowerLabel}

+

{question.upperLabel}

+
+
+
+ {!question.required && ( +
+
+ +
+ )} + + ); +} diff --git a/apps/web/lib/questions.ts b/apps/web/lib/questions.ts index 23dae079ef..1190dd26d5 100644 --- a/apps/web/lib/questions.ts +++ b/apps/web/lib/questions.ts @@ -1,9 +1,10 @@ import { - ListBulletIcon, + ArrowRightOnRectangleIcon, ChatBubbleBottomCenterTextIcon, - CursorArrowRippleIcon, + ListBulletIcon, PresentationChartBarIcon, QueueListIcon, + StarIcon, } from "@heroicons/react/24/solid"; import { createId } from "@paralleldrive/cuid2"; import { replaceQuestionPresetPlaceholders } from "./templates"; @@ -69,7 +70,7 @@ export const questionTypes: QuestionType[] = [ id: "cta", label: "Call-to-Action", description: "Ask your users to perform an action", - icon: CursorArrowRippleIcon, + icon: ArrowRightOnRectangleIcon, preset: { headline: "You are one of our power users!", buttonLabel: "Book interview", @@ -77,6 +78,18 @@ export const questionTypes: QuestionType[] = [ dismissButtonLabel: "Skip", }, }, + { + id: "rating", + label: "Rating", + description: "Ask your users to rate something", + icon: StarIcon, + preset: { + scale: "number", + range: 5, + lowerLabel: "Very unsatisfied", + upperLabel: "Very satisfied", + }, + }, ]; export const universalQuestionPresets = { diff --git a/apps/web/package.json b/apps/web/package.json index b77758701e..8efb083f67 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@paralleldrive/cuid2": "^2.2.0", "@radix-ui/react-alert-dialog": "^1.0.3", "@radix-ui/react-collapsible": "^1.0.2", + "@radix-ui/react-dropdown-menu": "^2.0.4", "@types/node": "18.15.11", "@types/react": "18.0.35", "@types/react-dom": "18.0.11", diff --git a/packages/js/src/components/MultipleChoiceMultiQuestion.tsx b/packages/js/src/components/MultipleChoiceMultiQuestion.tsx index f2459462f1..1a3aa1657f 100644 --- a/packages/js/src/components/MultipleChoiceMultiQuestion.tsx +++ b/packages/js/src/components/MultipleChoiceMultiQuestion.tsx @@ -92,7 +92,7 @@ export default function MultipleChoiceMultiQuestion({
diff --git a/packages/js/src/components/NPSQuestion.tsx b/packages/js/src/components/NPSQuestion.tsx index 8512cb1530..57915bbf12 100644 --- a/packages/js/src/components/NPSQuestion.tsx +++ b/packages/js/src/components/NPSQuestion.tsx @@ -15,6 +15,15 @@ interface NPSQuestionProps { export default function NPSQuestion({ question, onSubmit, lastQuestion, brandColor }: NPSQuestionProps) { const [selectedChoice, setSelectedChoice] = useState(null); + const handleSelect = (number: number) => { + setSelectedChoice(number); + if (question.required) { + onSubmit({ + [question.id]: number, + }); + } + }; + return (
{ @@ -38,14 +47,14 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol key={number} className={cn( selectedChoice === number ? "fb-z-10 fb-border-slate-400 fb-bg-slate-50" : "", - "fb-relative fb-h-10 fb-flex-1 fb-cursor-pointer fb-border fb-bg-white fb-text-center fb-text-sm fb-leading-10 fb-hover:bg-gray-100 fb-focus:outline-none" + "fb-relative fb-h-10 fb-flex-1 fb-cursor-pointer fb-border fb-bg-white fb-text-center fb-text-sm fb-leading-10 first:fb-rounded-l-md last:fb-rounded-r-md hover:fb-bg-gray-100 focus:fb-outline-none" )}> setSelectedChoice(number)} + onChange={() => handleSelect(number)} required={question.required} /> {number} @@ -58,15 +67,17 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
-
-
- -
+ {!question.required && ( +
+
+ +
+ )} ); } diff --git a/packages/js/src/components/QuestionConditional.tsx b/packages/js/src/components/QuestionConditional.tsx index 81e88a5392..72f6b25124 100644 --- a/packages/js/src/components/QuestionConditional.tsx +++ b/packages/js/src/components/QuestionConditional.tsx @@ -5,6 +5,7 @@ import MultipleChoiceSingleQuestion from "./MultipleChoiceSingleQuestion"; import MultipleChoiceMultiQuestion from "./MultipleChoiceMultiQuestion"; import NPSQuestion from "./NPSQuestion"; import CTAQuestion from "./CTAQuestion"; +import RatingQuestion from "./RatingQuestion"; interface QuestionConditionalProps { question: Question; @@ -54,5 +55,12 @@ export default function QuestionConditional({ lastQuestion={lastQuestion} brandColor={brandColor} /> + ) : question.type === "rating" ? ( + ) : null; } diff --git a/packages/js/src/components/RatingQuestion.tsx b/packages/js/src/components/RatingQuestion.tsx new file mode 100644 index 0000000000..d31b966617 --- /dev/null +++ b/packages/js/src/components/RatingQuestion.tsx @@ -0,0 +1,90 @@ +import { h } from "preact"; +import { useState } from "preact/hooks"; +import { cn } from "../lib/utils"; +import type { RatingQuestion } from "@formbricks/types/questions"; +import Headline from "./Headline"; +import Subheader from "./Subheader"; + +interface RatingQuestionProps { + question: RatingQuestion; + onSubmit: (data: { [x: string]: any }) => void; + lastQuestion: boolean; + brandColor: string; +} + +export default function RatingQuestion({ + question, + onSubmit, + lastQuestion, + brandColor, +}: RatingQuestionProps) { + const [selectedChoice, setSelectedChoice] = useState(null); + + const handleSelect = (number: number) => { + setSelectedChoice(number); + if (question.required) { + onSubmit({ + [question.id]: number, + }); + setSelectedChoice(null); // reset choice + } + }; + + return ( +
{ + e.preventDefault(); + + const data = { + [question.id]: selectedChoice, + }; + + setSelectedChoice(null); // reset choice + + onSubmit(data); + }}> + + +
+
+ Choices +
+ {Array.from({ length: question.range }, (_, i) => i + 1).map((number) => ( + + ))} +
+
+

{question.lowerLabel}

+

{question.upperLabel}

+
+
+
+ {!question.required && ( +
+
+ +
+ )} + + ); +} diff --git a/packages/js/src/components/ThankYouCard.tsx b/packages/js/src/components/ThankYouCard.tsx index bdac2f10d4..2eb693a49e 100644 --- a/packages/js/src/components/ThankYouCard.tsx +++ b/packages/js/src/components/ThankYouCard.tsx @@ -42,7 +42,7 @@ export default function ThankYouCard({ headline, subheader, brandColor }: ThankY

Powered by{" "} - + Formbricks diff --git a/packages/types/questions.ts b/packages/types/questions.ts index 5085f9f8df..16019e51b8 100644 --- a/packages/types/questions.ts +++ b/packages/types/questions.ts @@ -3,7 +3,8 @@ export type Question = | MultipleChoiceSingleQuestion | MultipleChoiceMultiQuestion | NPSQuestion - | CTAQuestion; + | CTAQuestion + | RatingQuestion; export interface OpenTextQuestion { id: string; @@ -58,6 +59,19 @@ export interface CTAQuestion { dismissButtonLabel?: string; } +export interface RatingQuestion { + id: string; + type: "rating"; + headline: string; + subheader?: string; + required: boolean; + scale: "number" | "smiley" | "star"; + range: 5 | 3 | 4 | 7 | 10; + lowerLabel: string; + upperLabel: string; + buttonLabel?: string; +} + export interface Choice { id: string; label: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 608124d43e..70b3760551 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,9 @@ importers: '@radix-ui/react-collapsible': specifier: ^1.0.2 version: 1.0.2(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-dropdown-menu': + specifier: ^2.0.4 + version: 2.0.4(@types/react@18.0.35)(react-dom@18.2.0)(react@18.2.0) '@types/node': specifier: 18.15.11 version: 18.15.11 @@ -4642,7 +4645,7 @@ packages: tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1' dependencies: mini-svg-data-uri: 1.4.4 - tailwindcss: 3.3.1(postcss@8.4.22) + tailwindcss: 3.3.1(postcss@8.4.21) dev: true /@tailwindcss/typography@0.5.9(tailwindcss@3.3.1): @@ -10196,7 +10199,7 @@ packages: resolution: {integrity: sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==} engines: {node: '>= 4.0'} os: [darwin] - deprecated: fsevents 1 will break on node v14+ and could be using insecure binaries. Upgrade to fsevents 2. + deprecated: The v1 package contains DANGEROUS / INSECURE binaries. Upgrade to safe fsevents v2 requiresBuild: true dependencies: bindings: 1.5.0