Add NPS Question Type (#243)

Add NPS Question Type
---------

Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Moritz Rengert
2023-04-19 14:28:58 +02:00
committed by GitHub
parent d15d062581
commit 48bac2128e
18 changed files with 459 additions and 22 deletions
@@ -1,18 +1,28 @@
"use client";
import { getQuestionDefaults, questionTypes, universalQuestionDefaults } from "@/lib/questions";
import { getQuestionDefaults, questionTypes, universalQuestionPresets } from "@/lib/questions";
import { PlusIcon } from "@heroicons/react/24/solid";
import { createId } from "@paralleldrive/cuid2";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { useProduct } from "@/lib/products/products";
import { ErrorComponent } from "@/../../packages/ui";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
interface AddQuestionButtonProps {
addQuestion: (question: any) => void;
environmentId: string;
}
export default function AddQuestionButton({ addQuestion }: AddQuestionButtonProps) {
export default function AddQuestionButton({ addQuestion, environmentId }: AddQuestionButtonProps) {
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const [open, setOpen] = useState(false);
if (isLoadingProduct) return <LoadingSpinner />;
if (isErrorProduct) return <ErrorComponent />;
return (
<Collapsible.Root
open={open}
@@ -42,8 +52,8 @@ export default function AddQuestionButton({ addQuestion }: AddQuestionButtonProp
addQuestion({
id: createId(),
type: questionType.id,
...universalQuestionDefaults,
...getQuestionDefaults(questionType.id),
...universalQuestionPresets,
...getQuestionDefaults(questionType.id, product),
});
setOpen(false);
}}>
@@ -0,0 +1,82 @@
import type { NPSQuestion } from "@formbricks/types/questions";
import { Input, Label } from "@formbricks/ui";
interface NPSQuestionFormProps {
question: NPSQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;
}
export default function NPSQuestionForm({
question,
questionIdx,
updateQuestion,
lastQuestion,
}: NPSQuestionFormProps) {
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">
<div>
<Label htmlFor="subheader">Lower label</Label>
<div className="mt-2">
<Input
id="subheader"
name="subheader"
value={question.lowerLabel}
onChange={(e) => updateQuestion(questionIdx, { lowerLabel: e.target.value })}
/>
</div>
</div>
<div>
<Label htmlFor="subheader">Upper label</Label>
<div className="mt-2">
<Input
id="subheader"
name="subheader"
value={question.upperLabel}
onChange={(e) => updateQuestion(questionIdx, { upperLabel: e.target.value })}
/>
</div>
</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 })}
/>
</div>
</div>
</form>
);
}
@@ -13,6 +13,7 @@ import MultipleChoiceSingleForm from "./MultipleChoiceSingleForm";
import MultipleChoiceMultiForm from "./MultipleChoiceMultiForm";
import OpenQuestionForm from "./OpenQuestionForm";
import QuestionDropdown from "./QuestionDropdown";
import NPSQuestionForm from "./NPSQuestionForm";
import UpdateQuestionId from "./UpdateQuestionId";
interface QuestionCardProps {
@@ -120,6 +121,13 @@ export default function QuestionCard({
updateQuestion={updateQuestion}
lastQuestion={lastQuestion}
/>
) : question.type === "nps" ? (
<NPSQuestionForm
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">
@@ -14,6 +14,7 @@ interface QuestionsViewProps {
setLocalSurvey: (survey: Survey) => void;
activeQuestionId: string | null;
setActiveQuestionId: (questionId: string | null) => void;
environmentId: string;
}
export default function QuestionsView({
@@ -21,6 +22,7 @@ export default function QuestionsView({
setActiveQuestionId,
localSurvey,
setLocalSurvey,
environmentId,
}: QuestionsViewProps) {
const internalQuestionIdMap = useMemo(() => {
return localSurvey.questions.reduce((acc, question) => {
@@ -99,7 +101,7 @@ export default function QuestionsView({
</StrictModeDroppable>
</div>
</DragDropContext>
<AddQuestionButton addQuestion={addQuestion} />
<AddQuestionButton addQuestion={addQuestion} environmentId={environmentId} />
<div className="mt-5">
<EditThankYouCard
localSurvey={localSurvey}
@@ -71,6 +71,7 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
setLocalSurvey={setLocalSurvey}
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
environmentId={environmentId}
/>
) : (
<AudienceView
@@ -0,0 +1,90 @@
import { ProgressBar } from "@formbricks/ui";
import type { QuestionSummary } from "@formbricks/types/responses";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import { HalfCircle } from "@/../../packages/ui/components/ProgressBar";
interface NPSSummaryProps {
questionSummary: QuestionSummary;
}
interface Result {
promoters: number;
passives: number;
detractors: number;
total: number;
score: number;
}
export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
const percentage = (count, total) => {
return count / total;
};
const result: Result = useMemo(() => {
let data = {
promoters: 0,
passives: 0,
detractors: 0,
total: 0,
score: 0,
};
for (let response of questionSummary.responses) {
const value = response.value;
if (!value || typeof value !== "number") continue;
data.total++;
if (value >= 9) {
data.promoters++;
} else if (value >= 7) {
data.passives++;
} else {
data.detractors++;
}
}
data.score = (percentage(data.promoters, data.total) - percentage(data.detractors, data.total)) * 100;
return data;
}, [questionSummary]);
return (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-6 pb-5 pt-6">
<div>
<h3 className="pb-1 text-xl font-semibold text-slate-900">{questionSummary.question.headline}</h3>
</div>
<div className="flex space-x-2 font-semibold text-slate-600">
<div className="rounded-lg bg-slate-100 p-2 text-sm">Net Promoter Score (NPS)</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2 text-sm">
<InboxStackIcon className="mr-2 h-4 w-4 " />
{result.total} responses
</div>
</div>
</div>
<div className="space-y-5 rounded-b-lg bg-white px-6 pb-6 pt-4">
{["promoters", "passives", "detractors"].map((group) => (
<div key={group}>
<div className="mb-2 flex justify-between">
<div className="mr-8 flex space-x-1">
<p className="font-semibold capitalize text-slate-700">{group}</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(percentage(result[group], result.total) * 100)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{result[group]} {result[group] === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={percentage(result[group], result.total)} />
</div>
))}
</div>
<div className="mb-4 mt-4 flex justify-center">
<HalfCircle value={result.score} />
</div>
</div>
);
}
@@ -9,6 +9,7 @@ import { ErrorComponent } from "@formbricks/ui";
import { useMemo } from "react";
import MultipleChoiceSummary from "./MultipleChoiceSummary";
import OpenTextSummary from "./OpenTextSummary";
import NPSSummary from "./NPSSummary";
export default function SummaryList({ environmentId, surveyId }) {
const { responsesData, isLoadingResponses, isErrorResponses } = useResponses(environmentId, surveyId);
@@ -72,6 +73,9 @@ export default function SummaryList({ environmentId, surveyId }) {
/>
);
}
if (questionSummary.question.type === "nps") {
return <NPSSummary key={questionSummary.question.id} questionSummary={questionSummary} />;
}
return null;
})}
</>
@@ -28,7 +28,9 @@ export default function TemplateList({ environmentId }: { environmentId: string
useEffect(() => {
if (product && templates?.length) {
setActiveTemplate(replacePresetPlaceholders(templates[0], product));
const newTemplate = replacePresetPlaceholders(templates[0], product);
setActiveTemplate(newTemplate);
setActiveQuestionId(newTemplate.preset.questions[0].id);
}
}, [product]);
@@ -63,8 +65,9 @@ export default function TemplateList({ environmentId }: { environmentId: string
<button
type="button"
onClick={() => {
setActiveQuestionId(null);
setActiveTemplate(replacePresetPlaceholders(template, product));
const newTemplate = replacePresetPlaceholders(template, product);
setActiveTemplate(newTemplate);
setActiveQuestionId(newTemplate.preset.questions[0].id);
}}
key={template.name}
className={cn(
@@ -81,7 +84,11 @@ export default function TemplateList({ environmentId }: { environmentId: string
))}
<button
type="button"
onClick={() => setActiveTemplate(customSurvey)}
onClick={() => {
const newTemplate = replacePresetPlaceholders(customSurvey, product);
setActiveTemplate(newTemplate);
setActiveQuestionId(newTemplate.preset.questions[0].id);
}}
className={cn(
activeTemplate?.name === customSurvey.name && "ring-brand ring-2",
"duration-120 hover:border-brand-dark group relative rounded-lg border-2 border-dashed border-slate-300 bg-transparent p-8 transition-colors duration-150"
@@ -0,0 +1,71 @@
import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import type { NPSQuestion } from "@formbricks/types/questions";
import Headline from "./Headline";
import Subheader from "./Subheader";
interface NPSQuestionProps {
question: NPSQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
}
export default function NPSQuestion({ question, onSubmit, lastQuestion, brandColor }: NPSQuestionProps) {
const [selectedChoice, setSelectedChoice] = useState<number | null>(null);
return (
<form
onSubmit={(e) => {
e.preventDefault();
const data = {
[question.id]: selectedChoice,
};
onSubmit(data);
// reset form
}}>
<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: 11 }, (_, i) => i).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 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)}
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>
<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>
);
}
@@ -2,6 +2,7 @@ import type { Question } from "@formbricks/types/questions";
import OpenTextQuestion from "./OpenTextQuestion";
import MultipleChoiceSingleQuestion from "./MultipleChoiceSingleQuestion";
import MultipleChoiceMultiQuestion from "./MultipleChoiceMultiQuestion";
import NPSQuestion from "./NPSQuestion";
interface QuestionConditionalProps {
question: Question;
@@ -37,5 +38,12 @@ export default function QuestionConditional({
lastQuestion={lastQuestion}
brandColor={brandColor}
/>
) : question.type === "nps" ? (
<NPSQuestion
question={question}
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
/>
) : null;
}
+20 -8
View File
@@ -1,12 +1,13 @@
import { Bars3BottomLeftIcon, ListBulletIcon } from "@heroicons/react/24/solid";
import { Bars3BottomLeftIcon, ChartPieIcon, ListBulletIcon } from "@heroicons/react/24/solid";
import { createId } from "@paralleldrive/cuid2";
import { replaceQuestionPresetPlaceholders } from "./templates";
export type QuestionType = {
id: string;
label: string;
description: string;
icon: any;
defaults: any;
preset: any;
};
export const questionTypes: QuestionType[] = [
@@ -15,7 +16,7 @@ export const questionTypes: QuestionType[] = [
label: "Open text",
description: "A single line of text",
icon: Bars3BottomLeftIcon,
defaults: {
preset: {
placeholder: "Type your answer here...",
},
},
@@ -24,7 +25,7 @@ export const questionTypes: QuestionType[] = [
label: "Multiple Choice Single-Select",
description: "A single choice from a list of options (radio buttons)",
icon: ListBulletIcon,
defaults: {
preset: {
choices: [
{ id: createId(), label: "" },
{ id: createId(), label: "" },
@@ -36,22 +37,33 @@ export const questionTypes: QuestionType[] = [
label: "Multiple Choice Multi-Select",
description: "Number of choices from a list of options (checkboxes)",
icon: ListBulletIcon,
defaults: {
preset: {
choices: [
{ id: createId(), label: "" },
{ id: createId(), label: "" },
],
},
},
{
id: "nps",
label: "Net Promoter Score (NPS)",
description: "Rate satisfaction on a 0-10 scale",
icon: ChartPieIcon,
preset: {
headline: "How likely are you to recommend {{productName}} to a friend or colleague?",
lowerLabel: "Not at all likely",
upperLabel: "Extremely likely",
},
},
];
export const universalQuestionDefaults = {
export const universalQuestionPresets = {
required: true,
};
export const getQuestionDefaults = (id: string) => {
export const getQuestionDefaults = (id: string, product: any) => {
const questionType = questionTypes.find((questionType) => questionType.id === id);
return questionType?.defaults;
return replaceQuestionPresetPlaceholders(questionType?.preset, product);
};
export const getQuestionTypeName = (id: string) => {
+12 -3
View File
@@ -1,12 +1,21 @@
import { Question } from "@/../../packages/types/questions";
import type { Template } from "@formbricks/types/templates";
export const replaceQuestionPresetPlaceholders = (question: Question, product) => {
if (!question) return;
if (!product) return question;
const newQuestion = JSON.parse(JSON.stringify(question));
newQuestion.headline = newQuestion.headline.replace("{{productName}}", product.name);
newQuestion.subheader = newQuestion.subheader?.replace("{{productName}}", product.name);
return newQuestion;
};
// replace all occurences of productName with the actual product name in the current template
export const replacePresetPlaceholders = (template: Template, product: any) => {
const preset = JSON.parse(JSON.stringify(template.preset));
preset.name = preset.name.replace("{{productName}}", product.name);
preset.questions.forEach((question) => {
question.headline = question.headline.replace("{{productName}}", product.name);
question.subheader = question.subheader?.replace("{{productName}}", product.name);
preset.questions = preset.questions.map((question) => {
return replaceQuestionPresetPlaceholders(question, product);
});
return { ...template, preset };
};
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Survey" ADD COLUMN "thankYouCard" JSONB NOT NULL DEFAULT '{"enabled": false}';
@@ -0,0 +1,72 @@
import { h } from "preact";
import { useState } from "preact/hooks";
import { cn } from "../lib/utils";
import type { NPSQuestion } from "@formbricks/types/questions";
import Headline from "./Headline";
import Subheader from "./Subheader";
interface NPSQuestionProps {
question: NPSQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
}
export default function NPSQuestion({ question, onSubmit, lastQuestion, brandColor }: NPSQuestionProps) {
const [selectedChoice, setSelectedChoice] = useState<number | null>(null);
return (
<form
onSubmit={(e) => {
e.preventDefault();
const data = {
[question.id]: selectedChoice,
};
onSubmit(data);
// reset form
}}>
<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: 11 }, (_, i) => i).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 fb-hover:bg-gray-100 fb-focus: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)}
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>
<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>
</form>
);
}
@@ -3,6 +3,7 @@ import type { Question } from "@formbricks/types/js";
import OpenTextQuestion from "./OpenTextQuestion";
import MultipleChoiceSingleQuestion from "./MultipleChoiceSingleQuestion";
import MultipleChoiceMultiQuestion from "./MultipleChoiceMultiQuestion";
import NPSQuestion from "./NPSQuestion";
interface QuestionConditionalProps {
question: Question;
@@ -38,5 +39,12 @@ export default function QuestionConditional({
lastQuestion={lastQuestion}
brandColor={brandColor}
/>
) : question.type === "nps" ? (
<NPSQuestion
question={question}
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
/>
) : null;
}
+16 -1
View File
@@ -85,7 +85,11 @@ export interface ThankYouCard {
subheader?: string;
}
export type Question = OpenTextQuestion | MultipleChoiceSingleQuestion | MultipleChoiceMultiQuestion;
export type Question =
| OpenTextQuestion
| MultipleChoiceSingleQuestion
| MultipleChoiceMultiQuestion
| NPSQuestion;
export interface OpenTextQuestion {
id: string;
@@ -117,6 +121,17 @@ export interface MultipleChoiceMultiQuestion {
choices?: Choice[];
}
export interface NPSQuestion {
id: string;
type: "nps";
headline: string;
subheader?: string;
required: boolean;
buttonLabel?: string;
lowerLabel: string;
upperLabel: string;
}
export interface Choice {
id: string;
label: string;
+16 -1
View File
@@ -1,4 +1,8 @@
export type Question = OpenTextQuestion | MultipleChoiceSingleQuestion | MultipleChoiceMultiQuestion;
export type Question =
| OpenTextQuestion
| MultipleChoiceSingleQuestion
| MultipleChoiceMultiQuestion
| NPSQuestion;
export interface OpenTextQuestion {
id: string;
@@ -30,6 +34,17 @@ export interface MultipleChoiceMultiQuestion {
choices: Choice[];
}
export interface NPSQuestion {
id: string;
type: "nps";
headline: string;
subheader?: string;
required: boolean;
buttonLabel?: string;
lowerLabel: string;
upperLabel: string;
}
export interface Choice {
id: string;
label: string;
+21
View File
@@ -11,3 +11,24 @@ export function ProgressBar({ progress, barColor }: { progress: number; barColor
</div>
);
}
export function HalfCircle({ value }: { value: number }) {
const normalizedValue = (value + 100) / 200;
const mappedValue = (normalizedValue * 180 - 180).toString() + "deg";
return (
<div className="w-fit">
<div className="relative flex h-28 w-52 items-end justify-center overflow-hidden">
<div
className="bg-brand absolute h-24 w-48 origin-bottom rounded-tl-full rounded-tr-full"
style={{ rotate: mappedValue }}></div>
<div className="absolute h-20 w-40 rounded-tl-full rounded-tr-full bg-white "></div>
</div>
<div className="flex justify-between text-sm leading-10 text-slate-600">
<p>-100</p>
<p className="text-4xl text-black">{value}</p>
<p>100</p>
</div>
</div>
);
}