mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-26 10:42:16 -06:00
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:
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
89
apps/web/components/preview/RatingQuestion.tsx
Normal file
89
apps/web/components/preview/RatingQuestion.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
90
packages/js/src/components/RatingQuestion.tsx
Normal file
90
packages/js/src/components/RatingQuestion.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
7
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user