mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-01 03:03:28 -05:00
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:
+14
-4
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user