Compare commits

...

14 Commits

Author SHA1 Message Date
Cursor Agent
d14805d847 Refactor: Adjust font weight for NPS summary counts and percentages
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 12:36:32 +00:00
Cursor Agent
a7ec311147 Refactor rating summary component to handle no responses and improve UI
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 11:59:40 +00:00
Cursor Agent
712be0ec71 Refactor rating summary component for better UI
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 11:03:18 +00:00
Cursor Agent
8330e072dd Refactor: Improve rating display and styling
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 10:53:18 +00:00
Cursor Agent
db4bd8966d Refactor: Update rating summary text color to slate-900
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 10:39:36 +00:00
Cursor Agent
a1c65cc60e Refactor: Improve survey summary component styling and layout
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 10:37:58 +00:00
Cursor Agent
2d3a38c818 Refactor NPS and Rating summary components for better UI
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 09:48:28 +00:00
Cursor Agent
f0252ed30f Refactor: Improve visual feedback for survey analysis components
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 09:43:44 +00:00
Cursor Agent
acaeeeff07 Refactor SatisfactionSmiley to use a simple div with color classes
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 09:41:58 +00:00
Cursor Agent
8dfdb084aa feat: Add tabs to survey summary components
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 09:27:35 +00:00
Cursor Agent
97a48e5469 Refactor NPS summary and update smiley icons and translations
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 09:21:35 +00:00
Cursor Agent
bb3ff3a2c5 Refactor: Add tbody to RatingSmiley table for better structure
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 09:16:46 +00:00
Cursor Agent
78a6d73ae8 Refactor: Remove unused range variable in survey summary
Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 09:13:34 +00:00
Cursor Agent
21ff1eb385 feat: Add NPS and Rating summary view improvements
This commit introduces several enhancements to the NPS and Rating survey summary views.

**NPS Summary:**
- Implemented a tabbed interface to switch between "grouped" and "individual" views.
- The "grouped" view displays the breakdown of promoters, passives, and detractors.
- The "individual" view shows a bar chart of responses for each NPS score (0-10).
- Added tooltips for better user guidance.

**Rating Summary:**
- Integrated CSAT (Customer Satisfaction) score calculation and display.
- Added tooltips for CSAT information.

**Backend/Logic Changes:**
- Updated `getQuestionSummary` to calculate and include CSAT data for Rating questions.
- Modified `getQuestionSummary` to include an array of `choices` for NPS questions, detailing individual score counts and percentages.
- Added new tests to cover CSAT calculations for various rating scales and NPS individual score breakdowns.
- Updated type definitions for `TSurveyQuestionSummaryRating` and `TSurveyQuestionSummaryNps` to accommodate the new data structures.

Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 08:43:56 +00:00
10 changed files with 784 additions and 80 deletions

View File

@@ -1,5 +1,7 @@
"use client";
import { BarChart, BarChartHorizontal } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
TI18nString,
@@ -9,8 +11,11 @@ import {
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { convertFloatToNDecimal } from "../lib/utils";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
import { SatisfactionSmiley } from "./SatisfactionSmiley";
interface NPSSummaryProps {
questionSummary: TSurveyQuestionSummaryNps;
@@ -26,6 +31,8 @@ interface NPSSummaryProps {
export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"grouped" | "individual">("grouped");
const applyFilter = (group: string) => {
const filters = {
promoters: {
@@ -61,38 +68,121 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={group}
onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
<p
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
additionalInfo={
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionSmiley percentage={questionSummary.promoters.percentage} />
<div>
{t("environments.surveys.summary.promoters")}:{" "}
{convertFloatToNDecimal(questionSummary.promoters.percentage, 2)}%
</div>
</div>
</TooltipTrigger>
<TooltipContent>{t("environments.surveys.summary.promotersTooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
}
/>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "grouped" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="grouped" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("environments.surveys.summary.grouped")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("environments.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="grouped" className="mt-4">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={group}
onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
<p
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary[group]?.count}{" "}
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary[group]?.count}{" "}
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</button>
))}
</div>
<ProgressBar
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</button>
))}
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<div className="grid grid-cols-11 gap-2 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((choice) => {
// Calculate opacity: 0-6 detractors (low opacity), 7-8 passives (medium), 9-10 promoters (high)
const opacity =
choice.rating <= 6
? 0.3 + (choice.rating / 6) * 0.3 // 0.3 to 0.6
: choice.rating <= 8
? 0.6 + ((choice.rating - 6) / 2) * 0.2 // 0.6 to 0.8
: 0.8 + ((choice.rating - 8) / 2) * 0.2; // 0.8 to 1.0
return (
<button
key={choice.rating}
className="group flex cursor-pointer flex-col items-center"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
choice.rating.toString()
)
}>
<div className="flex h-32 w-full flex-col items-center justify-end">
<div
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
style={{
height: `${Math.max(choice.percentage, 2)}%`,
opacity,
}}
/>
</div>
<div className="flex w-full flex-col items-center rounded-b-lg border border-t-0 border-slate-200 bg-slate-50 px-1 py-2">
<div className="mb-1.5 text-xs font-medium text-slate-500">{choice.rating}</div>
<div className="mb-1 flex items-center space-x-1">
<div className="text-base font-medium text-slate-700">{choice.count}</div>
<div className="rounded bg-slate-100 px-1.5 py-0.5 text-xs font-medium text-slate-600">
{convertFloatToNDecimal(choice.percentage, 1)}%
</div>
</div>
</div>
</button>
);
})}
</div>
</TabsContent>
</Tabs>
<div className="flex justify-center pb-4 pt-4">
<HalfCircle value={questionSummary.score} />

View File

@@ -1,7 +1,7 @@
"use client";
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo } from "react";
import { BarChart, BarChartHorizontal, CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
TI18nString,
@@ -13,7 +13,10 @@ import {
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
import { SatisfactionSmiley } from "./SatisfactionSmiley";
interface RatingSummaryProps {
questionSummary: TSurveyQuestionSummaryRating;
@@ -29,6 +32,8 @@ interface RatingSummaryProps {
export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"grouped" | "individual">("grouped");
const getIconBasedOnScale = useMemo(() => {
const scale = questionSummary.question.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
@@ -42,52 +47,182 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
questionSummary={questionSummary}
survey={survey}
additionalInfo={
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
</div>
</div>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionSmiley percentage={questionSummary.csat.satisfiedPercentage} />
<div>
CSAT: {questionSummary.csat.satisfiedPercentage}%{" "}
{t("environments.surveys.summary.satisfied")}
</div>
</div>
</TooltipTrigger>
<TooltipContent>{t("environments.surveys.summary.csatTooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((result) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={result.rating}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={questionSummary.question.scale}
answer={result.rating}
range={questionSummary.question.range}
addColors={questionSummary.question.isColorCodingEnabled}
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "grouped" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="grouped" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("environments.surveys.summary.grouped")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("environments.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="grouped" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
{questionSummary.responseCount === 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-8 text-center">
<p className="text-sm text-slate-500">
{t("environments.surveys.summary.no_responses_found")}
</p>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
</p>
) : (
<>
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
{questionSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
// Calculate opacity based on rating position (higher rating = higher opacity)
const range = questionSummary.question.range;
const opacity = 0.3 + (result.rating / range) * 0.7; // Range from 30% to 100%
return (
<button
key={result.rating}
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
style={{
width: `${result.percentage}%`,
borderRight:
index < questionSummary.choices.length - 1
? "1px solid rgb(226, 232, 240)"
: "none",
}}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div
className={`h-full ${index === 0 ? "rounded-tl-lg" : ""} ${
index === questionSummary.choices.length - 1 ? "rounded-tr-lg" : ""
} bg-brand-dark`}
style={{ opacity }}
/>
</button>
);
})}
</div>
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
{questionSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
return (
<div
key={result.rating}
className="flex flex-col items-center justify-center py-2"
style={{
width: `${result.percentage}%`,
borderRight:
index < questionSummary.choices.length - 1
? "1px solid rgb(226, 232, 240)"
: "none",
}}>
<div className="mb-1 flex items-center justify-center">
<RatingResponse
scale={questionSummary.question.scale}
answer={result.rating}
range={questionSummary.question.range}
addColors={false}
/>
</div>
<div className="text-xs font-medium text-slate-600">
{convertFloatToNDecimal(result.percentage, 1)}%
</div>
</div>
);
})}
</div>
{questionSummary.question.scale === "star" && (
<div className="mt-3 flex w-full items-center justify-center space-x-3 px-1">
<StarIcon className="h-6 w-6 text-slate-300" />
<span className="text-xs text-slate-500">1 - {questionSummary.question.range}</span>
<StarIcon fill="rgb(250 204 21)" className="h-6 w-6 text-yellow-400" />
</div>
)}
</>
)}
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base">
{questionSummary.choices.map((result) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={result.rating}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={questionSummary.question.scale}
answer={result.rating}
range={questionSummary.question.range}
addColors={questionSummary.question.isColorCodingEnabled}
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
))}
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
))}
</div>
{questionSummary.question.scale === "star" && (
<div className="mt-5 flex w-full items-center justify-center space-x-3 px-1">
<StarIcon className="h-6 w-6 text-slate-300" />
<span className="text-xs text-slate-500">1 - {questionSummary.question.range}</span>
<StarIcon fill="rgb(250 204 21)" className="h-6 w-6 text-yellow-400" />
</div>
)}
</div>
</TabsContent>
</Tabs>
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
<div className="rounded-b-lg border-t bg-white px-6 py-4">
<div key="dismissed">

View File

@@ -0,0 +1,18 @@
interface SatisfactionSmileyProps {
percentage: number;
className?: string;
}
export const SatisfactionSmiley = ({ percentage, className }: SatisfactionSmileyProps) => {
let colorClass = "";
if (percentage > 80) {
colorClass = "bg-emerald-500";
} else if (percentage >= 55) {
colorClass = "bg-orange-500";
} else {
colorClass = "bg-rose-500";
}
return <div className={`h-3 w-3 rounded-full ${colorClass} ${className || ""}`} />;
};

View File

@@ -2334,6 +2334,98 @@ describe("NPS question type tests", () => {
// Score should be -100 since all valid responses are detractors
expect(summary[0].score).toBe(-100);
});
test("getQuestionSummary includes individual score breakdown in choices array for NPS", async () => {
const question = {
id: "nps-q1",
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "How likely are you to recommend us?" },
required: true,
lowerLabel: { default: "Not likely" },
upperLabel: { default: "Very likely" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "nps-q1": 0 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "nps-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "nps-q1": 7 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r4", data: { "nps-q1": 9 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r5", data: { "nps-q1": 10 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "nps-q1", impressions: 5, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
expect(summary[0].choices).toBeDefined();
expect(summary[0].choices).toHaveLength(11); // Scores 0-10
// Verify specific scores
const score0 = summary[0].choices.find((c: any) => c.rating === 0);
expect(score0.count).toBe(1);
expect(score0.percentage).toBe(20); // 1/5 * 100
const score5 = summary[0].choices.find((c: any) => c.rating === 5);
expect(score5.count).toBe(1);
expect(score5.percentage).toBe(20);
const score7 = summary[0].choices.find((c: any) => c.rating === 7);
expect(score7.count).toBe(1);
expect(score7.percentage).toBe(20);
const score9 = summary[0].choices.find((c: any) => c.rating === 9);
expect(score9.count).toBe(1);
expect(score9.percentage).toBe(20);
const score10 = summary[0].choices.find((c: any) => c.rating === 10);
expect(score10.count).toBe(1);
expect(score10.percentage).toBe(20);
// Verify scores with no responses have 0 count
const score1 = summary[0].choices.find((c: any) => c.rating === 1);
expect(score1.count).toBe(0);
expect(score1.percentage).toBe(0);
});
test("getQuestionSummary handles NPS individual score breakdown with no responses", async () => {
const question = {
id: "nps-q1",
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "How likely are you to recommend us?" },
required: true,
lowerLabel: { default: "Not likely" },
upperLabel: { default: "Very likely" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses: any[] = [];
const dropOff = [{ questionId: "nps-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
expect(summary[0].choices).toBeDefined();
expect(summary[0].choices).toHaveLength(11); // Scores 0-10
// All scores should have 0 count and percentage
summary[0].choices.forEach((choice: any) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
});
});
});
describe("Rating question type tests", () => {
@@ -2557,6 +2649,315 @@ describe("Rating question type tests", () => {
// Verify dismissed is 0
expect(summary[0].dismissed.count).toBe(0);
});
test("getQuestionSummary calculates CSAT for Rating question with range 3", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 3,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 2 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 3: satisfied = score 3
// 2 out of 3 responses are satisfied (score 3)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67); // Math.round((2/3) * 100)
});
test("getQuestionSummary calculates CSAT for Rating question with range 4", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 4,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 2 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 4: satisfied = scores 3-4
// 2 out of 3 responses are satisfied (scores 3 and 4)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67);
});
test("getQuestionSummary calculates CSAT for Rating question with range 5", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 5: satisfied = scores 4-5
// 2 out of 3 responses are satisfied (scores 4 and 5)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67);
});
test("getQuestionSummary calculates CSAT for Rating question with range 6", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 6,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 6 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 6: satisfied = scores 5-6
// 2 out of 3 responses are satisfied (scores 5 and 6)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67);
});
test("getQuestionSummary calculates CSAT for Rating question with range 7", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 7,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 6 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 7 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 7: satisfied = scores 6-7
// 2 out of 3 responses are satisfied (scores 6 and 7)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67);
});
test("getQuestionSummary calculates CSAT for Rating question with range 10", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 10,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 8 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 9 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 10 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r4", data: { "rating-q1": 7 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 10: satisfied = scores 8-10
// 3 out of 4 responses are satisfied (scores 8, 9, 10)
expect(summary[0].csat.satisfiedCount).toBe(3);
expect(summary[0].csat.satisfiedPercentage).toBe(75);
});
test("getQuestionSummary calculates CSAT for Rating question with all satisfied", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 5: satisfied = scores 4-5
// All 2 responses are satisfied
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(100);
});
test("getQuestionSummary calculates CSAT for Rating question with none satisfied", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 1 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 2 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 5: satisfied = scores 4-5
// None of the responses are satisfied (all are 1, 2, or 3)
expect(summary[0].csat.satisfiedCount).toBe(0);
expect(summary[0].csat.satisfiedPercentage).toBe(0);
});
test("getQuestionSummary calculates CSAT for Rating question with no responses", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses: any[] = [];
const dropOff = [{ questionId: "rating-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
expect(summary[0].csat.satisfiedCount).toBe(0);
expect(summary[0].csat.satisfiedPercentage).toBe(0);
});
});
describe("PictureSelection question type tests", () => {

View File

@@ -539,6 +539,24 @@ export const getQuestionSummary = async (
});
});
// Calculate CSAT based on range
let satisfiedCount = 0;
if (range === 3) {
satisfiedCount = choiceCountMap[3] || 0;
} else if (range === 4) {
satisfiedCount = (choiceCountMap[3] || 0) + (choiceCountMap[4] || 0);
} else if (range === 5) {
satisfiedCount = (choiceCountMap[4] || 0) + (choiceCountMap[5] || 0);
} else if (range === 6) {
satisfiedCount = (choiceCountMap[5] || 0) + (choiceCountMap[6] || 0);
} else if (range === 7) {
satisfiedCount = (choiceCountMap[6] || 0) + (choiceCountMap[7] || 0);
} else if (range === 10) {
satisfiedCount = (choiceCountMap[8] || 0) + (choiceCountMap[9] || 0) + (choiceCountMap[10] || 0);
}
const satisfiedPercentage =
totalResponseCount > 0 ? Math.round((satisfiedCount / totalResponseCount) * 100) : 0;
summary.push({
type: question.type,
question,
@@ -548,6 +566,10 @@ export const getQuestionSummary = async (
dismissed: {
count: dismissed,
},
csat: {
satisfiedCount,
satisfiedPercentage,
},
});
values = [];
@@ -563,10 +585,17 @@ export const getQuestionSummary = async (
score: 0,
};
// Track individual score counts (0-10)
const scoreCountMap: Record<number, number> = {};
for (let i = 0; i <= 10; i++) {
scoreCountMap[i] = 0;
}
responses.forEach((response) => {
const value = response.data[question.id];
if (typeof value === "number") {
data.total++;
scoreCountMap[value]++;
if (value >= 9) {
data.promoters++;
} else if (value >= 7) {
@@ -585,6 +614,13 @@ export const getQuestionSummary = async (
? convertFloatTo2Decimal(((data.promoters - data.detractors) / data.total) * 100)
: 0;
// Build choices array with individual score breakdown
const choices = Object.entries(scoreCountMap).map(([rating, count]) => ({
rating: parseInt(rating),
count,
percentage: data.total > 0 ? convertFloatTo2Decimal((count / data.total) * 100) : 0,
}));
summary.push({
type: question.type,
question,
@@ -607,6 +643,7 @@ export const getQuestionSummary = async (
count: data.dismissed,
percentage: data.total > 0 ? convertFloatTo2Decimal((data.dismissed / data.total) * 100) : 0,
},
choices,
});
break;
}

View File

@@ -1812,6 +1812,7 @@
"configure_alerts": "Configure alerts",
"congrats": "Congrats! Your survey is live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.",
"csatTooltip": "Customer Satisfaction Score measures the percentage of respondents who gave top ratings (based on the rating scale).",
"current_count": "Current count",
"custom_range": "Custom range...",
"delete_all_existing_responses_and_displays": "Delete all existing responses and displays",
@@ -1826,6 +1827,7 @@
"filtered_responses_excel": "Filtered responses (Excel)",
"generating_qr_code": "Generating QR code",
"go_to_setup_checklist": "Go to Setup Checklist \uD83D\uDC49",
"grouped": "Grouped",
"impressions": "Impressions",
"impressions_tooltip": "Number of times the survey has been viewed.",
"in_app": {
@@ -1859,6 +1861,7 @@
},
"includes_all": "Includes all",
"includes_either": "Includes either",
"individual": "Individual",
"install_widget": "Install Formbricks Widget",
"is_equal_to": "Is equal to",
"is_less_than": "Is less than",
@@ -1872,6 +1875,8 @@
"no_responses_found": "No responses found",
"other_values_found": "Other values found",
"overall": "Overall",
"promoters": "Promoters",
"promotersTooltip": "Percentage of respondents who scored 9-10, indicating high likelihood to recommend.",
"qr_code": "QR code",
"qr_code_description": "Responses collected via QR code are anonymous.",
"qr_code_download_failed": "QR code download failed",
@@ -1881,6 +1886,7 @@
"quotas_completed_tooltip": "The number of quotas completed by the respondents.",
"reset_survey": "Reset survey",
"reset_survey_warning": "Resetting a survey removes all responses and displays associated with this survey. This cannot be undone.",
"satisfied": "Satisfied",
"selected_responses_csv": "Selected responses (CSV)",
"selected_responses_excel": "Selected responses (Excel)",
"setup_integrations": "Setup integrations",

View File

@@ -66,13 +66,13 @@ const getSmiley = (
return (
<table style={{ width: "48px", height: "48px" }}>
{" "}
{/* NOSONAR S5256 - Need table layout for email compatibility (gmail) */}
<tr>
<td align="center" valign="middle">
{icon}
</td>
</tr>
<tbody>
<tr>
<td align="center" valign="middle">
{icon}
</td>
</tr>
</tbody>
</table>
);
};

View File

@@ -33,5 +33,10 @@ export const RatingResponse: React.FC<RatingResponseProps> = ({
if (scale === "smiley")
return <RatingSmiley active={false} idx={answer - 1} range={range} addColors={addColors} />;
return answer;
// For number scale, render in a gray box for consistent styling
return (
<div className="flex h-12 w-12 items-center justify-center rounded-md bg-slate-100 text-base font-semibold text-slate-700">
{answer}
</div>
);
};

View File

@@ -2587,6 +2587,10 @@ export const ZSurveyQuestionSummaryRating = z.object({
dismissed: z.object({
count: z.number(),
}),
csat: z.object({
satisfiedCount: z.number(),
satisfiedPercentage: z.number(),
}),
});
export type TSurveyQuestionSummaryRating = z.infer<typeof ZSurveyQuestionSummaryRating>;
@@ -2613,6 +2617,13 @@ export const ZSurveyQuestionSummaryNps = z.object({
count: z.number(),
percentage: z.number(),
}),
choices: z.array(
z.object({
rating: z.number(),
count: z.number(),
percentage: z.number(),
})
),
});
export type TSurveyQuestionSummaryNps = z.infer<typeof ZSurveyQuestionSummaryNps>;

1
pnpm-lock.yaml generated
View File

@@ -1374,6 +1374,7 @@ packages:
'@azure/microsoft-playwright-testing@1.0.0-beta.7':
resolution: {integrity: sha512-Y6C35LWUfLevHu5NG+7vvFfhpmUrGWKRumcz7/CSCmWlx8RVfWgP6NuL8rIPDuTeJyjaTczNfeg1ppGW26TjBw==}
engines: {node: '>=18.0.0'}
deprecated: This package has been deprecated and will no longer be maintained after March 8, 2026. Upgrade to the replacement package, @azure/playwright, to continue receiving updates.
peerDependencies:
'@playwright/test': ^1.43.1