Add Rating Question Type (numbers) (#256)

Add Rating Question Type (with numbers)

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Moritz Rengert
2023-04-28 12:05:29 +02:00
committed by GitHub
parent 7e9b336954
commit bcd4f953ee
21 changed files with 667 additions and 50 deletions

View File

@@ -37,12 +37,6 @@ export default function CTAQuestionForm({
<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(

View File

@@ -65,18 +65,20 @@ export default function NPSQuestionForm({
</div>
</div>
<div className="mt-3">
<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 })}
/>
{!question.required && (
<div className="mt-3">
<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>
</div>
)}
</form>
);
}

View File

@@ -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" ? (
<RatingQuestionForm
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">

View File

@@ -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 (
<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 })}
/>
</div>
</div>
<div className="mt-3 flex justify-between gap-8">
<div className="flex-1">
<Label htmlFor="subheader">Scale</Label>
<div className="mt-2">
<Dropdown
options={[
{ label: "Number", value: "number", icon: HashtagIcon },
{ label: "Star", value: "star", icon: StarIcon, disabled: true },
{ label: "Smiley", value: "smiley", icon: FaceSmileIcon, disabled: true },
]}
defaultValue={question.scale || "number"}
onSelect={(option) => updateQuestion(questionIdx, { scale: option.value })}
/>
</div>
</div>
<div className="flex-1">
<Label htmlFor="subheader">Range</Label>
<div className="mt-2">
<Dropdown
options={[
{ label: "5 points (recommended)", value: "5" },
{ label: "3 points", value: "3" },
{ label: "4 points", value: "4" },
{ label: "7 points", value: "7" },
{ label: "10 points", value: "10" },
]}
defaultValue={question.range || "5"}
onSelect={(option) => updateQuestion(questionIdx, { range: option.value })}
/>
</div>
</div>
</div>
<div className="mt-3 flex justify-between gap-8">
<div className="flex-1">
<Label htmlFor="loweLabel">Lower label</Label>
<div className="mt-2">
<Input
id="loweLabel"
name="loweLabel"
placeholder="Very unsatisfied"
value={question.lowerLabel}
onChange={(e) => updateQuestion(questionIdx, { lowerLabel: e.target.value })}
/>
</div>
</div>
<div className="flex-1">
<Label htmlFor="upperLabel">Upper label</Label>
<div className="mt-2">
<Input
id="upperLabel"
name="upperLabel"
placeholder="Very satisfied"
value={question.upperLabel}
onChange={(e) => updateQuestion(questionIdx, { upperLabel: e.target.value })}
/>
</div>
</div>
</div>
<div className="mt-3">
{!question.required && (
<div className="flex-1">
<Label htmlFor="buttonLabel">Dismiss Button Label</Label>
<div className="mt-2">
<Input
id="dismissButtonLabel"
name="dismissButtonLabel"
value={question.buttonLabel}
placeholder={lastQuestion ? "Finish" : "Next"}
onChange={(e) => updateQuestion(questionIdx, { buttonLabel: e.target.value })}
/>
</div>
</div>
)}
</div>
</form>
);
}

View File

@@ -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<Option>(
options.filter((option) => option.value === defaultValue)[0] || options[0]
);
const handleSelect = (option) => {
setSelectedOption(option);
onSelect(option);
};
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300">
<span className="flex flex-1">
{selectedOption.icon && <selectedOption.icon className="mr-3 h-5 w-5" />}
<span>{selectedOption ? selectedOption.label : "Select an option"}</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-gray-500" />
</span>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="min-w-[220px] rounded-md bg-white text-sm text-slate-800 shadow-md"
align="start">
{options.map((option) => (
<DropdownMenu.Item
key={option.value}
className="flex cursor-pointer items-center p-3 hover:bg-gray-100 hover:outline-none data-[disabled]:cursor-default data-[disabled]:opacity-50"
disabled={option.disabled}
onSelect={() => handleSelect(option)}>
{option.icon && <option.icon className="mr-3 h-5 w-5" />}
{option.label}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
};
export default Dropdown;

View File

@@ -51,7 +51,7 @@ export default function MultipleChoiceSummary({ questionSummary }: MultipleChoic
// sort by count and transform to array
const results = Object.values(resultsDict).sort((a: any, b: any) => b.count - a.count);
return results;
}, [questionSummary]);
}, [questionSummary, isSingleChoice]);
const totalResponses = useMemo(() => {
let total = 0;

View File

@@ -16,6 +16,12 @@ interface Result {
score: number;
}
interface ChoiceResult {
label: string;
count: number;
percentage: number;
}
export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
const percentage = (count, total) => {
const result = count / total;
@@ -49,6 +55,23 @@ export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
return data;
}, [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]);
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">
@@ -63,7 +86,7 @@ export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
</div>
</div>
</div>
<div className="space-y-5 rounded-b-lg bg-white px-6 pb-6 pt-4">
<div className="space-y-5 bg-white px-6 pb-6 pt-4">
{["promoters", "passives", "detractors"].map((group) => (
<div key={group}>
<div className="mb-2 flex justify-between">
@@ -83,7 +106,27 @@ export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
</div>
))}
</div>
<div className="mb-4 mt-4 flex justify-center">
{dismissed.count > 0 && (
<div className="border-t bg-white px-6 pb-6 pt-4">
<div key={dismissed.label}>
<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">{dismissed.label}</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(dismissed.percentage * 100)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{dismissed.count} {dismissed.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-slate-600" progress={dismissed.percentage} />
</div>
</div>
)}
<div className="flex justify-center rounded-b-lg bg-white pb-4 pt-4">
<HalfCircle value={result.score} />
</div>
</div>

View File

@@ -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 (
<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">Rating Question</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2 text-sm">
<InboxStackIcon className="mr-2 h-4 w-4 " />
{totalResponses} responses
</div>
</div>
</div>
<div className="space-y-5 bg-white px-6 pb-6 pt-4">
{results.map((result: any) => (
<div key={result.label}>
<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">{result.label}</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(result.percentage * 100)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{result.count} {result.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={result.percentage} />
</div>
))}
</div>
{dismissed.count > 0 && (
<div className="rounded-b-lg border-t bg-white px-6 pb-6 pt-4">
<div key={dismissed.label}>
<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">{dismissed.label}</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(dismissed.percentage * 100)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{dismissed.count} {dismissed.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-slate-600" progress={dismissed.percentage} />
</div>
</div>
)}
</div>
);
}

View File

@@ -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 <CTASummary key={questionSummary.question.id} questionSummary={questionSummary} />;
}
if (questionSummary.question.type === "rating") {
return <RatingSummary key={questionSummary.question.id} questionSummary={questionSummary} />;
}
return null;
})}
</>

View File

@@ -14,6 +14,15 @@ interface NPSQuestionProps {
export default function NPSQuestion({ question, onSubmit, lastQuestion, brandColor }: NPSQuestionProps) {
const [selectedChoice, setSelectedChoice] = useState<number | null>(null);
const handleSelect = (number: number) => {
setSelectedChoice(number);
if (question.required) {
onSubmit({
[question.id]: number,
});
}
};
return (
<form
onSubmit={(e) => {
@@ -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"
)}>
<input
type="radio"
name="nps"
value={number}
className="absolute h-full w-full cursor-pointer opacity-0"
onChange={() => setSelectedChoice(number)}
onChange={() => handleSelect(number)}
required={question.required}
/>
{number}
@@ -57,15 +66,17 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
</div>
</fieldset>
</div>
<div className="mt-4 flex w-full justify-between">
<div></div>
<button
type="submit"
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>
{!question.required && (
<div className="mt-4 flex w-full justify-between">
<div></div>
<button
type="submit"
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>
)}
</form>
);
}

View File

@@ -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" ? (
<RatingQuestion
question={question}
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
/>
) : null;
}

View File

@@ -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<number | null>(null);
const handleSelect = (number: number) => {
setSelectedChoice(number);
if (question.required) {
onSubmit({
[question.id]: number,
});
setSelectedChoice(null); // reset choice
}
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
const data = {
[question.id]: selectedChoice,
};
setSelectedChoice(null); // reset choice
onSubmit(data);
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
<div className="my-4">
<fieldset>
<legend className="sr-only">Choices</legend>
<div className="flex">
{Array.from({ length: question.range }, (_, i) => i + 1).map((number) => (
<label
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 first:rounded-l-md last:rounded-r-md hover:bg-gray-100 focus:outline-none"
)}>
<input
type="radio"
name="rating"
value={number}
className="absolute h-full w-full cursor-pointer opacity-0"
onChange={() => handleSelect(number)}
required={question.required}
/>
{number}
</label>
))}
</div>
<div className="flex justify-between text-sm font-semibold leading-6">
<p>{question.lowerLabel}</p>
<p>{question.upperLabel}</p>
</div>
</fieldset>
</div>
{!question.required && (
<div className="mt-4 flex w-full justify-between">
<div></div>
<button
type="submit"
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>
)}
</form>
);
}

View File

@@ -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 = {

View File

@@ -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",

View File

@@ -92,7 +92,7 @@ export default function MultipleChoiceMultiQuestion({
<div></div>
<button
type="submit"
className="fb-flex fb-items-center fb-rounded-md fb-border fb-border-transparent fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 fb-text-white fb-shadow-sm hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 focus:ring-slate-500"
className="fb-flex fb-items-center fb-rounded-md fb-border fb-border-transparent fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 fb-text-white fb-shadow-sm hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 focus:fb-ring-slate-500"
style={{ backgroundColor: brandColor }}>
{question.buttonLabel || (lastQuestion ? "Finish" : "Next")}
</button>

View File

@@ -15,6 +15,15 @@ interface NPSQuestionProps {
export default function NPSQuestion({ question, onSubmit, lastQuestion, brandColor }: NPSQuestionProps) {
const [selectedChoice, setSelectedChoice] = useState<number | null>(null);
const handleSelect = (number: number) => {
setSelectedChoice(number);
if (question.required) {
onSubmit({
[question.id]: number,
});
}
};
return (
<form
onSubmit={(e) => {
@@ -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"
)}>
<input
type="radio"
name="nps"
value={number}
className="fb-absolute fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onChange={() => setSelectedChoice(number)}
onChange={() => handleSelect(number)}
required={question.required}
/>
{number}
@@ -58,15 +67,17 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
</div>
</fieldset>
</div>
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
<div></div>
<button
type="submit"
className="fb-flex fb-items-center fb-rounded-md fb-border fb-border-transparent fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 fb-text-white fb-shadow-sm fb-hover:opacity-90 fb-focus:outline-none fb-focus:ring-2 fb-focus:ring-slate-500 fb-focus:ring-offset-2"
style={{ backgroundColor: brandColor }}>
{question.buttonLabel || (lastQuestion ? "Finish" : "Next")}
</button>
</div>
{!question.required && (
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
<div></div>
<button
type="submit"
className="fb-flex fb-items-center fb-rounded-md fb-border fb-border-transparent fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 fb-text-white fb-shadow-sm hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 focus:fb-ring-slate-500"
style={{ backgroundColor: brandColor }}>
{question.buttonLabel || (lastQuestion ? "Finish" : "Next")}
</button>
</div>
)}
</form>
);
}

View File

@@ -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" ? (
<RatingQuestion
question={question}
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
/>
) : null;
}

View File

@@ -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<number | null>(null);
const handleSelect = (number: number) => {
setSelectedChoice(number);
if (question.required) {
onSubmit({
[question.id]: number,
});
setSelectedChoice(null); // reset choice
}
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
const data = {
[question.id]: selectedChoice,
};
setSelectedChoice(null); // reset choice
onSubmit(data);
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
<div className="fb-my-4">
<fieldset>
<legend className="fb-sr-only">Choices</legend>
<div className="fb-flex">
{Array.from({ length: question.range }, (_, i) => i + 1).map((number) => (
<label
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 first:fb-rounded-l-md last:fb-rounded-r-md hover:fb-bg-gray-100 focus:fb-outline-none"
)}>
<input
type="radio"
name="nps"
value={number}
className="fb-absolute fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onChange={() => handleSelect(number)}
required={question.required}
/>
{number}
</label>
))}
</div>
<div className="fb-flex fb-justify-between fb-text-sm fb-font-semibold fb-leading-6">
<p>{question.lowerLabel}</p>
<p>{question.upperLabel}</p>
</div>
</fieldset>
</div>
{!question.required && (
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
<div></div>
<button
type="submit"
className="fb-flex fb-items-center fb-rounded-md fb-border fb-border-transparent fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 fb-text-white fb-shadow-sm hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-slate-500 focus:fb-ring-offset-2"
style={{ backgroundColor: brandColor }}>
{question.buttonLabel || (lastQuestion ? "Finish" : "Next")}
</button>
</div>
)}
</form>
);
}

View File

@@ -42,7 +42,7 @@ export default function ThankYouCard({ headline, subheader, brandColor }: ThankY
<p className="fb-text-xs fb-text-slate-500">
Powered by{" "}
<b>
<a href="https://formbricks.com" target="_blank" className="fb-hover:text-slate-700">
<a href="https://formbricks.com" target="_blank" className="hover:text-slate-700">
Formbricks
</a>
</b>

View File

@@ -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;

7
pnpm-lock.yaml generated
View File

@@ -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