feat: improve rating and NPS summary UI with aggregated view (#6834)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
Johannes
2025-11-18 00:38:11 -08:00
committed by GitHub
parent c458051839
commit cc8289fa33
23 changed files with 1234 additions and 91 deletions

View File

@@ -0,0 +1,32 @@
"use client";
import { CSSProperties, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface ClickableBarSegmentProps {
children: ReactNode;
onClick: () => void;
className?: string;
style?: CSSProperties;
}
export const ClickableBarSegment = ({
children,
onClick,
className = "",
style,
}: ClickableBarSegmentProps) => {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<button className={className} style={style} onClick={onClick}>
{children}
</button>
</TooltipTrigger>
<TooltipContent>{t("common.click_to_filter")}</TooltipContent>
</Tooltip>
);
};

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,12 @@ 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 { TooltipProvider } from "@/modules/ui/components/tooltip";
import { convertFloatToNDecimal } from "../lib/utils";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
import { SatisfactionIndicator } from "./SatisfactionIndicator";
interface NPSSummaryProps {
questionSummary: TSurveyQuestionSummaryNps;
@@ -24,8 +30,20 @@ interface NPSSummaryProps {
) => void;
}
const calculateNPSOpacity = (rating: number): number => {
if (rating <= 6) {
return 0.3 + (rating / 6) * 0.3;
}
if (rating <= 8) {
return 0.6 + ((rating - 6) / 2) * 0.2;
}
return 0.8 + ((rating - 8) / 2) * 0.2;
};
export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
const applyFilter = (group: string) => {
const filters = {
promoters: {
@@ -61,38 +79,110 @@ 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={
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={questionSummary.promoters.percentage} />
<div>
{t("environments.surveys.summary.promoters")}:{" "}
{convertFloatToNDecimal(questionSummary.promoters.percentage, 2)}%
</div>
</div>
}
/>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("environments.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("environments.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="aggregated" 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>
<ProgressBar
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</button>
))}
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<TooltipProvider delayDuration={200}>
<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) => {
const opacity = calculateNPSOpacity(choice.rating);
return (
<ClickableBarSegment
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-semibold text-slate-700">{choice.count}</div>
<div className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600">
{convertFloatToNDecimal(choice.percentage, 1)}%
</div>
</div>
</div>
</ClickableBarSegment>
);
})}
</div>
<ProgressBar
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</button>
))}
</div>
</TooltipProvider>
</TabsContent>
</Tabs>
<div className="flex justify-center pb-4 pt-4">
<HalfCircle value={questionSummary.score} />

View File

@@ -57,8 +57,8 @@ export const QuestionSummaryHeader = ({
{t("environments.surveys.edit.optional")}
</div>
)}
<IdBadge id={questionSummary.question.id} />
</div>
<IdBadge id={questionSummary.question.id} label={t("common.question_id")} />
</div>
);
};

View File

@@ -0,0 +1,24 @@
"use client";
import { TSurveyRatingQuestion } from "@formbricks/types/surveys/types";
import { RatingResponse } from "@/modules/ui/components/rating-response";
interface RatingScaleLegendProps {
scale: TSurveyRatingQuestion["scale"];
range: number;
}
export const RatingScaleLegend = ({ scale, range }: RatingScaleLegendProps) => {
return (
<div className="mt-3 flex w-full items-start justify-between px-1">
<div className="flex items-center space-x-1">
<RatingResponse scale={scale} answer={1} range={range} addColors={false} variant="scale" />
<span className="text-xs text-slate-500">1</span>
</div>
<div className="flex items-center space-x-1">
<span className="text-xs text-slate-500">{range}</span>
<RatingResponse scale={scale} answer={range} range={range} addColors={false} variant="scale" />
</div>
</div>
);
};

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,12 @@ 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 { TooltipProvider } from "@/modules/ui/components/tooltip";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
import { RatingScaleLegend } from "./RatingScaleLegend";
import { SatisfactionIndicator } from "./SatisfactionIndicator";
interface RatingSummaryProps {
questionSummary: TSurveyQuestionSummaryRating;
@@ -29,6 +34,8 @@ interface RatingSummaryProps {
export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
const getIconBasedOnScale = useMemo(() => {
const scale = questionSummary.question.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
@@ -42,52 +49,174 @@ 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>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={questionSummary.csat.satisfiedPercentage} />
<div>
CSAT: {questionSummary.csat.satisfiedPercentage}%{" "}
{t("environments.surveys.summary.satisfied")}
</div>
</div>
</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)}%
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("environments.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("environments.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="aggregated" 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>
</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>
<RatingScaleLegend
scale={questionSummary.question.scale}
range={questionSummary.question.range}
/>
</>
) : (
<>
<TooltipProvider delayDuration={200}>
<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;
const range = questionSummary.question.range;
const opacity = 0.3 + (result.rating / range) * 0.8;
const isFirst = index === 0;
const isLast = index === questionSummary.choices.length - 1;
return (
<ClickableBarSegment
key={result.rating}
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
style={{
width: `${result.percentage}%`,
borderRight: isLast ? "none" : "1px solid rgb(226, 232, 240)",
}}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div
className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }}
/>
</ClickableBarSegment>
);
})}
</div>
</TooltipProvider>
<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}
variant="aggregated"
/>
</div>
<div className="text-xs font-medium text-slate-600">
{convertFloatToNDecimal(result.percentage, 1)}%
</div>
</div>
);
})}
</div>
<RatingScaleLegend
scale={questionSummary.question.scale}
range={questionSummary.question.range}
/>
</>
)}
</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) => (
<div key={result.rating}>
<button
className="w-full cursor-pointer hover:opacity-80"
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}
variant="individual"
/>
</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>
))}
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
))}
</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,17 @@
interface SatisfactionIndicatorProps {
percentage: number;
}
export const SatisfactionIndicator = ({ percentage }: SatisfactionIndicatorProps) => {
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}`} />;
};

View File

@@ -10,6 +10,7 @@ import {
TSurveyQuestionTypeEnum,
TSurveySummary,
} from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import {
SelectedFilterValue,
@@ -58,7 +59,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
const filterObject: SelectedFilterValue = { ...selectedFilter };
const value = {
id: questionId,
label: getLocalizedValue(label, "default"),
label: getTextContent(getLocalizedValue(label, "default")),
questionType: questionType,
type: OptionsType.QUESTIONS,
};

View File

@@ -2334,6 +2334,147 @@ 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 +2698,549 @@ 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

@@ -532,13 +532,31 @@ export const getQuestionSummary = async (
Object.entries(choiceCountMap).forEach(([label, count]) => {
values.push({
rating: parseInt(label),
rating: Number.parseInt(label),
count,
percentage:
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
});
});
// 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: Number.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

@@ -126,6 +126,7 @@ checksums:
common/clear_filters: 8f40ab5af527e4b190da94e7b6221379
common/clear_selection: af5d720527735d4253e289400d29ec9e
common/click: 9c2744de6b5ac7333d9dae1d5cf4a76d
common/click_to_filter: 527714113ca5fd3504e7d0bd31bca303
common/clicks: f9e154545f87d8ede27b529e5fdf2015
common/close: 2c2e22f8424a1031de89063bd0022e16
common/code: 343bc5386149b97cece2b093c39034b2
@@ -1705,6 +1706,7 @@ checksums:
environments/surveys/share/social_media/title: 1bf4899b063ee8f02f7188576555828b
environments/surveys/summary/added_filter_for_responses_where_answer_to_question: 5bddf0d4f771efd06d58441d11fa5091
environments/surveys/summary/added_filter_for_responses_where_answer_to_question_is_skipped: 74ca713c491cfc33751a5db3de972821
environments/surveys/summary/aggregated: 9d4e77225d5952abed414fffd828c078
environments/surveys/summary/all_responses_csv: 16c0c211853f0839a79f1127ec679ca2
environments/surveys/summary/all_responses_excel: 8bf18916ab127f16bfcf9f38956710b0
environments/surveys/summary/all_time: 62258944e7c2e83f3ebf69074b2c2156
@@ -1759,6 +1761,7 @@ checksums:
environments/surveys/summary/in_app/title: a2d1b633244d0e0504ec6f8f561c7a6b
environments/surveys/summary/includes_all: b0e3679282417c62d511c258362f860e
environments/surveys/summary/includes_either: 186d6923c1693e80d7b664b8367d4221
environments/surveys/summary/individual: 52ebce389ed97a13b6089802055ed667
environments/surveys/summary/is_equal_to: f4aab30ef188eb25dcc0e392cf8e86bb
environments/surveys/summary/is_less_than: 6109d595ba21497c59b1c91d7fd09a13
environments/surveys/summary/last_30_days: a738894cfc5e592052f1e16787744568
@@ -1771,6 +1774,7 @@ checksums:
environments/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
environments/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
environments/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
environments/surveys/summary/promoters: 41fbb8d0439227661253a82fda39f521
environments/surveys/summary/qr_code: 48cb2a8c07a3d1647f766f93bb9e9382
environments/surveys/summary/qr_code_description: 19f48dcf473809f178abf4212657ef14
environments/surveys/summary/qr_code_download_failed: 2764b5615112800da27eecafc21e3472
@@ -1780,6 +1784,7 @@ checksums:
environments/surveys/summary/quotas_completed_tooltip: ec5c4dc67eda27c06764354f695db613
environments/surveys/summary/reset_survey: 8c88ddb81f5f787d183d2e7cb43e7c64
environments/surveys/summary/reset_survey_warning: 6b44be171d7e2716f234387b100b173d
environments/surveys/summary/satisfied: 4d542ba354b85e644acbca5691d2ce45
environments/surveys/summary/selected_responses_csv: 9cef3faccd54d4f24647791e6359db90
environments/surveys/summary/selected_responses_excel: a0ade8b2658e887a4a3f2ad3bdb0c686
environments/surveys/summary/setup_integrations: 70de06d73be671a0cd58a3fd4fa62e53

View File

@@ -153,6 +153,7 @@
"clear_filters": "Filter löschen",
"clear_selection": "Auswahl aufheben",
"click": "Klick",
"click_to_filter": "Klicken zum Filtern",
"clicks": "Klicks",
"close": "Schließen",
"code": "Code",
@@ -1814,6 +1815,7 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "Filter hinzugefügt für Antworten, bei denen die Antwort auf Frage {questionIdx} {filterComboBoxValue} - {filterValue} ist",
"added_filter_for_responses_where_answer_to_question_is_skipped": "Filter hinzugefügt für Antworten, bei denen die Frage {questionIdx} übersprungen wurde",
"aggregated": "Aggregiert",
"all_responses_csv": "Alle Antworten (CSV)",
"all_responses_excel": "Alle Antworten (Excel)",
"all_time": "Gesamt",
@@ -1870,6 +1872,7 @@
},
"includes_all": "Beinhaltet alles",
"includes_either": "Beinhaltet entweder",
"individual": "Individuell",
"is_equal_to": "Ist gleich",
"is_less_than": "ist weniger als",
"last_30_days": "Letzte 30 Tage",
@@ -1882,6 +1885,7 @@
"no_responses_found": "Keine Antworten gefunden",
"other_values_found": "Andere Werte gefunden",
"overall": "Insgesamt",
"promoters": "Promotoren",
"qr_code": "QR-Code",
"qr_code_description": "Antworten, die per QR-Code gesammelt werden, sind anonym.",
"qr_code_download_failed": "QR-Code-Download fehlgeschlagen",
@@ -1891,6 +1895,7 @@
"quotas_completed_tooltip": "Die Anzahl der von den Befragten abgeschlossenen Quoten.",
"reset_survey": "Umfrage zurücksetzen",
"reset_survey_warning": "Das Zurücksetzen einer Umfrage entfernt alle Antworten und Anzeigen, die mit dieser Umfrage verbunden sind. Dies kann nicht rückgängig gemacht werden.",
"satisfied": "Zufrieden",
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
"selected_responses_excel": "Ausgewählte Antworten (Excel)",
"setup_integrations": "Integrationen einrichten",

View File

@@ -153,6 +153,7 @@
"clear_filters": "Clear filters",
"clear_selection": "Clear selection",
"click": "Click",
"click_to_filter": "Click to filter",
"clicks": "Clicks",
"close": "Close",
"code": "Code",
@@ -1814,6 +1815,7 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "Added filter for responses where answer to question {questionIdx} is {filterComboBoxValue} - {filterValue} ",
"added_filter_for_responses_where_answer_to_question_is_skipped": "Added filter for responses where answer to question {questionIdx} is skipped",
"aggregated": "Aggregated",
"all_responses_csv": "All responses (CSV)",
"all_responses_excel": "All responses (Excel)",
"all_time": "All time",
@@ -1870,6 +1872,7 @@
},
"includes_all": "Includes all",
"includes_either": "Includes either",
"individual": "Individual",
"is_equal_to": "Is equal to",
"is_less_than": "Is less than",
"last_30_days": "Last 30 days",
@@ -1882,6 +1885,7 @@
"no_responses_found": "No responses found",
"other_values_found": "Other values found",
"overall": "Overall",
"promoters": "Promoters",
"qr_code": "QR code",
"qr_code_description": "Responses collected via QR code are anonymous.",
"qr_code_download_failed": "QR code download failed",
@@ -1891,6 +1895,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

@@ -153,6 +153,7 @@
"clear_filters": "Borrar filtros",
"clear_selection": "Borrar selección",
"click": "Clic",
"click_to_filter": "Haz clic para filtrar",
"clicks": "Clics",
"close": "Cerrar",
"code": "Código",
@@ -1814,6 +1815,7 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "Filtro añadido para respuestas donde la respuesta a la pregunta {questionIdx} es {filterComboBoxValue} - {filterValue} ",
"added_filter_for_responses_where_answer_to_question_is_skipped": "Filtro añadido para respuestas donde la respuesta a la pregunta {questionIdx} se ha omitido",
"aggregated": "Agregado",
"all_responses_csv": "Todas las respuestas (CSV)",
"all_responses_excel": "Todas las respuestas (Excel)",
"all_time": "Todo el tiempo",
@@ -1870,6 +1872,7 @@
},
"includes_all": "Incluye todo",
"includes_either": "Incluye cualquiera",
"individual": "Individual",
"is_equal_to": "Es igual a",
"is_less_than": "Es menor que",
"last_30_days": "Últimos 30 días",
@@ -1882,6 +1885,7 @@
"no_responses_found": "No se han encontrado respuestas",
"other_values_found": "Otros valores encontrados",
"overall": "General",
"promoters": "Promotores",
"qr_code": "Código QR",
"qr_code_description": "Las respuestas recogidas a través del código QR son anónimas.",
"qr_code_download_failed": "La descarga del código QR ha fallado",
@@ -1891,6 +1895,7 @@
"quotas_completed_tooltip": "El número de cuotas completadas por los encuestados.",
"reset_survey": "Reiniciar encuesta",
"reset_survey_warning": "Reiniciar una encuesta elimina todas las respuestas y visualizaciones asociadas a esta encuesta. Esto no se puede deshacer.",
"satisfied": "Satisfecho",
"selected_responses_csv": "Respuestas seleccionadas (CSV)",
"selected_responses_excel": "Respuestas seleccionadas (Excel)",
"setup_integrations": "Configurar integraciones",

View File

@@ -153,6 +153,7 @@
"clear_filters": "Effacer les filtres",
"clear_selection": "Effacer la sélection",
"click": "Cliquez",
"click_to_filter": "Cliquer pour filtrer",
"clicks": "Clics",
"close": "Fermer",
"code": "Code",
@@ -1814,6 +1815,7 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "Filtre ajouté pour les réponses où la réponse à la question '{'questionIdx'}' est '{'filterComboBoxValue'}' - '{'filterValue'}' ",
"added_filter_for_responses_where_answer_to_question_is_skipped": "Filtre ajouté pour les réponses où la réponse à la question '{'questionIdx'}' est ignorée",
"aggregated": "Agrégé",
"all_responses_csv": "Tous les réponses (CSV)",
"all_responses_excel": "Tous les réponses (Excel)",
"all_time": "Tout le temps",
@@ -1870,6 +1872,7 @@
},
"includes_all": "Comprend tous",
"includes_either": "Comprend soit",
"individual": "Individuel",
"is_equal_to": "Est égal à",
"is_less_than": "est inférieur à",
"last_30_days": "30 derniers jours",
@@ -1882,6 +1885,7 @@
"no_responses_found": "Aucune réponse trouvée",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",
"promoters": "Promoteurs",
"qr_code": "Code QR",
"qr_code_description": "Les réponses collectées via le code QR sont anonymes.",
"qr_code_download_failed": "Échec du téléchargement du code QR",
@@ -1891,6 +1895,7 @@
"quotas_completed_tooltip": "Le nombre de quotas complétés par les répondants.",
"reset_survey": "Réinitialiser l'enquête",
"reset_survey_warning": "Réinitialiser un sondage supprime toutes les réponses et les affichages associés à ce sondage. Cela ne peut pas être annulé.",
"satisfied": "Satisfait",
"selected_responses_csv": "Réponses sélectionnées (CSV)",
"selected_responses_excel": "Réponses sélectionnées (Excel)",
"setup_integrations": "Configurer les intégrations",

View File

@@ -153,6 +153,7 @@
"clear_filters": "フィルターをクリア",
"clear_selection": "選択をクリア",
"click": "クリック",
"click_to_filter": "クリックしてフィルター",
"clicks": "クリック数",
"close": "閉じる",
"code": "コード",
@@ -1814,6 +1815,7 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "質問 {questionIdx} の回答が {filterComboBoxValue} - {filterValue} である回答のフィルターを追加しました",
"added_filter_for_responses_where_answer_to_question_is_skipped": "質問 {questionIdx} の回答がスキップされた回答のフィルターを追加しました",
"aggregated": "集計済み",
"all_responses_csv": "すべての回答 (CSV)",
"all_responses_excel": "すべての回答 (Excel)",
"all_time": "全期間",
@@ -1870,6 +1872,7 @@
},
"includes_all": "すべてを含む",
"includes_either": "どちらかを含む",
"individual": "個人",
"is_equal_to": "と等しい",
"is_less_than": "より小さい",
"last_30_days": "過去30日間",
@@ -1882,6 +1885,7 @@
"no_responses_found": "回答が見つかりません",
"other_values_found": "他の値が見つかりました",
"overall": "全体",
"promoters": "推奨者",
"qr_code": "QRコード",
"qr_code_description": "QRコード経由で収集された回答は匿名です。",
"qr_code_download_failed": "QRコードのダウンロードに失敗しました",
@@ -1891,6 +1895,7 @@
"quotas_completed_tooltip": "回答者 によって 完了 した 定員 の 数。",
"reset_survey": "フォームをリセット",
"reset_survey_warning": "フォームをリセットすると、このフォームに関連付けられているすべての回答と表示が削除されます。この操作は元に戻せません。",
"satisfied": "満足",
"selected_responses_csv": "選択した回答 (CSV)",
"selected_responses_excel": "選択した回答 (Excel)",
"setup_integrations": "連携を設定",

View File

@@ -153,6 +153,7 @@
"clear_filters": "Wis filters",
"clear_selection": "Duidelijke selectie",
"click": "Klik",
"click_to_filter": "Klik om te filteren",
"clicks": "Klikken",
"close": "Dichtbij",
"code": "Code",
@@ -1814,6 +1815,7 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "Filter toegevoegd voor antwoorden waarbij het antwoord op vraag {questionIdx} {filterComboBoxValue} - {filterValue} is",
"added_filter_for_responses_where_answer_to_question_is_skipped": "Filter toegevoegd voor antwoorden waarbij het antwoord op vraag {questionIdx} wordt overgeslagen",
"aggregated": "Geaggregeerd",
"all_responses_csv": "Alle reacties (CSV)",
"all_responses_excel": "Alle reacties (Excel)",
"all_time": "Altijd",
@@ -1870,6 +1872,7 @@
},
"includes_all": "Inclusief alles",
"includes_either": "Inclusief beide",
"individual": "Individueel",
"is_equal_to": "Is gelijk aan",
"is_less_than": "Is minder dan",
"last_30_days": "Laatste 30 dagen",
@@ -1882,6 +1885,7 @@
"no_responses_found": "Geen reacties gevonden",
"other_values_found": "Andere waarden gevonden",
"overall": "Algemeen",
"promoters": "Promoters",
"qr_code": "QR-code",
"qr_code_description": "Reacties verzameld via QR-code zijn anoniem.",
"qr_code_download_failed": "Downloaden van QR-code mislukt",
@@ -1891,6 +1895,7 @@
"quotas_completed_tooltip": "Het aantal quota dat door de respondenten is voltooid.",
"reset_survey": "Enquête opnieuw instellen",
"reset_survey_warning": "Als u een enquête opnieuw instelt, worden alle reacties en weergaven verwijderd die aan deze enquête zijn gekoppeld. Dit kan niet ongedaan worden gemaakt.",
"satisfied": "Tevreden",
"selected_responses_csv": "Geselecteerde reacties (CSV)",
"selected_responses_excel": "Geselecteerde antwoorden (Excel)",
"setup_integrations": "Integraties instellen",

View File

@@ -153,6 +153,7 @@
"clear_filters": "Limpar filtros",
"clear_selection": "Limpar seleção",
"click": "Clica",
"click_to_filter": "Clique para filtrar",
"clicks": "cliques",
"close": "Fechar",
"code": "Código",
@@ -1814,6 +1815,7 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} é {filterComboBoxValue} - {filterValue} ",
"added_filter_for_responses_where_answer_to_question_is_skipped": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} foi pulada",
"aggregated": "Agregado",
"all_responses_csv": "Todas as respostas (CSV)",
"all_responses_excel": "Todas as respostas (Excel)",
"all_time": "Todo o tempo",
@@ -1870,6 +1872,7 @@
},
"includes_all": "Inclui tudo",
"includes_either": "Inclui ou",
"individual": "Individual",
"is_equal_to": "É igual a",
"is_less_than": "É menor que",
"last_30_days": "Últimos 30 dias",
@@ -1882,6 +1885,7 @@
"no_responses_found": "Nenhuma resposta encontrada",
"other_values_found": "Outros valores encontrados",
"overall": "No geral",
"promoters": "Promotores",
"qr_code": "Código QR",
"qr_code_description": "Respostas coletadas via código QR são anônimas.",
"qr_code_download_failed": "falha no download do código QR",
@@ -1891,6 +1895,7 @@
"quotas_completed_tooltip": "Número de cotas preenchidas pelos respondentes.",
"reset_survey": "Redefinir pesquisa",
"reset_survey_warning": "Redefinir uma pesquisa remove todas as respostas e exibições associadas a esta pesquisa. Isto não pode ser desfeito.",
"satisfied": "Satisfeito",
"selected_responses_csv": "Respostas selecionadas (CSV)",
"selected_responses_excel": "Respostas selecionadas (Excel)",
"setup_integrations": "Configurar integrações",

View File

@@ -153,6 +153,7 @@
"clear_filters": "Limpar filtros",
"clear_selection": "Limpar seleção",
"click": "Clique",
"click_to_filter": "Clique para filtrar",
"clicks": "Cliques",
"close": "Fechar",
"code": "Código",
@@ -1814,6 +1815,7 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} é {filterComboBoxValue} - {filterValue} ",
"added_filter_for_responses_where_answer_to_question_is_skipped": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} é ignorada",
"aggregated": "Agregado",
"all_responses_csv": "Todas as respostas (CSV)",
"all_responses_excel": "Todas as respostas (Excel)",
"all_time": "Todo o tempo",
@@ -1870,6 +1872,7 @@
},
"includes_all": "Inclui tudo",
"includes_either": "Inclui qualquer um",
"individual": "Individual",
"is_equal_to": "É igual a",
"is_less_than": "É menos que",
"last_30_days": "Últimos 30 dias",
@@ -1882,6 +1885,7 @@
"no_responses_found": "Nenhuma resposta encontrada",
"other_values_found": "Outros valores encontrados",
"overall": "Geral",
"promoters": "Promotores",
"qr_code": "Código QR",
"qr_code_description": "Respostas recolhidas através de código QR são anónimas.",
"qr_code_download_failed": "Falha ao transferir o código QR",
@@ -1891,6 +1895,7 @@
"quotas_completed_tooltip": "O número de quotas concluídas pelos respondentes.",
"reset_survey": "Reiniciar inquérito",
"reset_survey_warning": "Repor um inquérito remove todas as respostas e visualizações associadas a este inquérito. Isto não pode ser desfeito.",
"satisfied": "Satisfeito",
"selected_responses_csv": "Respostas selecionadas (CSV)",
"selected_responses_excel": "Respostas selecionadas (Excel)",
"setup_integrations": "Configurar integrações",

View File

@@ -153,6 +153,7 @@
"clear_filters": "Curăță filtrele",
"clear_selection": "Șterge selecția",
"click": "Click",
"click_to_filter": "Click pentru a filtra",
"clicks": "Clickuri",
"close": "Închide",
"code": "Cod",
@@ -1814,6 +1815,7 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "Filtru adăugat pentru răspunsuri unde răspunsul la întrebarea {questionIdx} este {filterComboBoxValue} - {filterValue}",
"added_filter_for_responses_where_answer_to_question_is_skipped": "Filtru adăugat pentru răspunsuri unde răspunsul la întrebarea {questionIdx} este omis",
"aggregated": "Agregat",
"all_responses_csv": "Toate răspunsurile (CSV)",
"all_responses_excel": "Toate răspunsurile (Excel)",
"all_time": "Pe parcursul întregii perioade",
@@ -1870,6 +1872,7 @@
},
"includes_all": "Include tot",
"includes_either": "Include fie",
"individual": "Individual",
"is_equal_to": "Este egal cu",
"is_less_than": "Este mai puțin de",
"last_30_days": "Ultimele 30 de zile",
@@ -1882,6 +1885,7 @@
"no_responses_found": "Nu s-au găsit răspunsuri",
"other_values_found": "Alte valori găsite",
"overall": "General",
"promoters": "Promotori",
"qr_code": "Cod QR",
"qr_code_description": "Răspunsurile colectate prin cod QR sunt anonime.",
"qr_code_download_failed": "Descărcarea codului QR a eșuat",
@@ -1891,6 +1895,7 @@
"quotas_completed_tooltip": "Numărul de cote completate de respondenți.",
"reset_survey": "Resetează chestionarul",
"reset_survey_warning": "Resetarea unui sondaj elimină toate răspunsurile și afișajele asociate cu acest sondaj. Aceasta nu poate fi anulată.",
"satisfied": "Mulțumit",
"selected_responses_csv": "Răspunsuri selectate (CSV)",
"selected_responses_excel": "Răspunsuri selectate (Excel)",
"setup_integrations": "Configurare integrare",

View File

@@ -153,6 +153,7 @@
"clear_filters": "清除 过滤器",
"clear_selection": "清除 选择",
"click": "点击",
"click_to_filter": "点击筛选",
"clicks": "点击",
"close": "关闭",
"code": "代码",
@@ -1814,6 +1815,7 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "为 回答 问题 {questionIdx} 的 答复 增加 了 筛选器,筛选条件 是 {filterComboBoxValue} - {filterValue}",
"added_filter_for_responses_where_answer_to_question_is_skipped": "为 回答 问题 {questionIdx} 的 答复 增加 了 筛选器,筛选条件 是 略过",
"aggregated": "汇总",
"all_responses_csv": "所有 反馈 CSV",
"all_responses_excel": "所有 反馈 Excel",
"all_time": "所有 时间",
@@ -1870,6 +1872,7 @@
},
"includes_all": "包括所有 ",
"includes_either": "包含 任意一个",
"individual": "个人",
"is_equal_to": "等于",
"is_less_than": "少于",
"last_30_days": "最近 30 天",
@@ -1882,6 +1885,7 @@
"no_responses_found": "未找到响应",
"other_values_found": "找到其他值",
"overall": "整体",
"promoters": "推荐者",
"qr_code": "二维码",
"qr_code_description": "通过 QR 码 收集 的 响应 是 匿名 的。",
"qr_code_download_failed": "二维码下载失败",
@@ -1891,6 +1895,7 @@
"quotas_completed_tooltip": "受访者完成的配额数量。",
"reset_survey": "重置 调查",
"reset_survey_warning": "重置 一个调查 会移除与 此调查 相关 的 所有响应 和 展示 。此操作 不能 撤销 。",
"satisfied": "满意",
"selected_responses_csv": "选定 反馈 CSV",
"selected_responses_excel": "选定 反馈 Excel",
"setup_integrations": "设置 集成",

View File

@@ -153,6 +153,7 @@
"clear_filters": "清除篩選器",
"clear_selection": "清除選取",
"click": "點擊",
"click_to_filter": "點擊篩選",
"clicks": "點擊數",
"close": "關閉",
"code": "程式碼",
@@ -1814,6 +1815,7 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "已新增回應的篩選器,其中問題 '{'questionIdx'}' 的答案為 '{'filterComboBoxValue'}' - '{'filterValue'}'",
"added_filter_for_responses_where_answer_to_question_is_skipped": "已新增回應的篩選器,其中問題 '{'questionIdx'}' 的答案被跳過",
"aggregated": "匯總",
"all_responses_csv": "所有回應 (CSV)",
"all_responses_excel": "所有回應 (Excel)",
"all_time": "全部時間",
@@ -1870,6 +1872,7 @@
},
"includes_all": "包含全部",
"includes_either": "包含其中一個",
"individual": "個人",
"is_equal_to": "等於",
"is_less_than": "小於",
"last_30_days": "過去 30 天",
@@ -1882,6 +1885,7 @@
"no_responses_found": "找不到回應",
"other_values_found": "找到其他值",
"overall": "整體",
"promoters": "推廣者",
"qr_code": "QR 碼",
"qr_code_description": "透過 QR code 收集的回應都是匿名的。",
"qr_code_download_failed": "QR code 下載失敗",
@@ -1891,6 +1895,7 @@
"quotas_completed_tooltip": "受訪者完成的 配額 數量。",
"reset_survey": "重設問卷",
"reset_survey_warning": "重置 調查 會 移除 與 此 調查 相關 的 所有 回應 和 顯示 。 這 是 不可 撤銷 的 。",
"satisfied": "滿意",
"selected_responses_csv": "選擇的回應 (CSV)",
"selected_responses_excel": "選擇的回應 (Excel)",
"setup_integrations": "設定整合",

View File

@@ -6,32 +6,90 @@ interface RatingResponseProps {
range?: number;
answer: string | number | string[];
addColors?: boolean;
variant?: "default" | "individual" | "aggregated" | "scale";
}
const NumberBox = ({ answer, size }: { answer: number; size: "small" | "medium" | "large" }) => {
const sizeClasses = {
small: "h-6 w-6 text-xs",
medium: "h-7 w-7 text-xs",
large: "h-12 w-12 text-base",
};
return (
<div
className={`flex items-center justify-center rounded-md bg-slate-100 font-semibold text-slate-700 ${sizeClasses[size]}`}>
{answer}
</div>
);
};
const renderStarScale = (answer: number, variant: string, range: number) => {
if (variant === "scale") {
return answer === 1 ? (
<StarIcon className="h-6 w-6 text-slate-300" />
) : (
<StarIcon fill="rgb(250 204 21)" className="h-6 w-6 text-yellow-400" />
);
}
if (variant === "aggregated") {
return <NumberBox answer={answer} size="medium" />;
}
if (variant === "individual" && range > 5) {
return (
<div className="flex items-center space-x-2">
<StarIcon className="h-5 w-5 text-slate-300" />
<span className="text-base font-semibold text-slate-700">{answer}</span>
<StarIcon fill="rgb(250 204 21)" className="h-5 w-5 text-yellow-400" />
</div>
);
}
const stars = Array.from({ length: range }, (_, i) => (
<StarIcon
key={i}
fill={i < answer ? "rgb(250 204 21)" : "none"}
className={`h-7 ${i < answer ? "text-yellow-400" : "text-slate-300"}`}
/>
));
return <div className="flex">{stars}</div>;
};
const renderSmileyScale = (answer: number, variant: string, range: number, addColors: boolean) => {
if (variant === "scale") {
return (
<div className="flex h-6 w-6 items-center justify-center">
<RatingSmiley active={false} idx={answer - 1} range={range} addColors={addColors} />
</div>
);
}
if (variant === "aggregated") {
return <NumberBox answer={answer} size="medium" />;
}
return <RatingSmiley active={false} idx={answer - 1} range={range} addColors={addColors} />;
};
const renderNumberScale = (answer: number, variant: string) => {
if (variant === "scale") return <NumberBox answer={answer} size="small" />;
if (variant === "aggregated") return <NumberBox answer={answer} size="medium" />;
return <NumberBox answer={answer} size="large" />;
};
export const RatingResponse: React.FC<RatingResponseProps> = ({
scale,
range,
answer,
addColors = false,
variant = "default",
}) => {
if (typeof answer !== "number") return null;
if (typeof scale === "undefined" || typeof range === "undefined") return answer;
if (scale === "star") {
// show number of stars according to answer value
const stars: any = [];
for (let i = 0; i < range; i++) {
if (i < parseInt(answer.toString())) {
stars.push(<StarIcon key={i} fill="rgb(250 204 21)" className="h-7 text-yellow-400" />);
} else {
stars.push(<StarIcon key={i} className="h-7 text-slate-300" />);
}
}
return <div className="flex">{stars}</div>;
}
if (scale === "smiley")
return <RatingSmiley active={false} idx={answer - 1} range={range} addColors={addColors} />;
return answer;
if (scale === "star") return renderStarScale(answer, variant, range);
if (scale === "smiley") return renderSmileyScale(answer, variant, range, addColors);
return renderNumberScale(answer, variant);
};

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