From cc8289fa33fd559b73a359912b64519f2d77ec22 Mon Sep 17 00:00:00 2001 From: Johannes <72809645+jobenjada@users.noreply.github.com> Date: Tue, 18 Nov 2025 00:38:11 -0800 Subject: [PATCH] feat: improve rating and NPS summary UI with aggregated view (#6834) Co-authored-by: Dhruwang --- .../components/ClickableBarSegment.tsx | 32 + .../summary/components/NPSSummary.tsx | 148 +++- .../components/QuestionSummaryHeader.tsx | 2 +- .../summary/components/RatingScaleLegend.tsx | 24 + .../summary/components/RatingSummary.tsx | 211 ++++-- .../components/SatisfactionIndicator.tsx | 17 + .../summary/components/SummaryList.tsx | 3 +- .../summary/lib/surveySummary.test.ts | 684 ++++++++++++++++++ .../(analysis)/summary/lib/surveySummary.ts | 39 +- apps/web/i18n.lock | 5 + apps/web/locales/de-DE.json | 5 + apps/web/locales/en-US.json | 5 + apps/web/locales/es-ES.json | 5 + apps/web/locales/fr-FR.json | 5 + apps/web/locales/ja-JP.json | 5 + apps/web/locales/nl-NL.json | 5 + apps/web/locales/pt-BR.json | 5 + apps/web/locales/pt-PT.json | 5 + apps/web/locales/ro-RO.json | 5 + apps/web/locales/zh-Hans-CN.json | 5 + apps/web/locales/zh-Hant-TW.json | 5 + .../ui/components/rating-response/index.tsx | 94 ++- packages/types/surveys/types.ts | 11 + 23 files changed, 1234 insertions(+), 91 deletions(-) create mode 100644 apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ClickableBarSegment.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingScaleLegend.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SatisfactionIndicator.tsx diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ClickableBarSegment.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ClickableBarSegment.tsx new file mode 100644 index 0000000000..edea21666d --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ClickableBarSegment.tsx @@ -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 ( + + + + + {t("common.click_to_filter")} + + ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx index f4853f8b21..d6462d1833 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx @@ -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 (
- -
- {["promoters", "passives", "detractors", "dismissed"].map((group) => ( -
-

- {questionSummary[group]?.count}{" "} - {questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")} -

+ + + ))} +
+ + + + +
+ {questionSummary.choices.map((choice) => { + const opacity = calculateNPSOpacity(choice.rating); + + return ( + + setFilter( + questionSummary.question.id, + questionSummary.question.headline, + questionSummary.question.type, + t("environments.surveys.summary.is_equal_to"), + choice.rating.toString() + ) + }> +
+
+
+
+
{choice.rating}
+
+
{choice.count}
+
+ {convertFloatToNDecimal(choice.percentage, 1)}% +
+
+
+ + ); + })}
- - - ))} -
+
+
+
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader.tsx index a749f3c812..466b02be7e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader.tsx @@ -57,8 +57,8 @@ export const QuestionSummaryHeader = ({ {t("environments.surveys.edit.optional")}
)} + - ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingScaleLegend.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingScaleLegend.tsx new file mode 100644 index 0000000000..30337dbaff --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingScaleLegend.tsx @@ -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 ( +
+
+ + 1 +
+
+ {range} + +
+
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx index f0d94e7505..146f929575 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx @@ -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 ; @@ -42,52 +49,174 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm questionSummary={questionSummary} survey={survey} additionalInfo={ -
- {getIconBasedOnScale} -
- {t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)} +
+
+ {getIconBasedOnScale} +
+ {t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)} +
+
+ +
+ +
+ CSAT: {questionSummary.csat.satisfiedPercentage}%{" "} + {t("environments.surveys.summary.satisfied")} +
} /> -
- {questionSummary.choices.map((result) => ( - +
+ ))}
- - - ))} -
+ + + {questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SatisfactionIndicator.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SatisfactionIndicator.tsx new file mode 100644 index 0000000000..800d221fbd --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SatisfactionIndicator.tsx @@ -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
; +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx index cf5df241ce..1a96f43071 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx @@ -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, }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.test.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.test.ts index ff95678b15..94d1dc5fa3 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.test.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.test.ts @@ -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", () => { diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts index d0a3f2d294..95a7ac4787 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts @@ -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 = {}; + 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; } diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index f17e3e395b..ac078cd056 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -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 diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 06f7c1fd5a..b3c5e83089 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -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", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index ed290035c2..9f7fec212b 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -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", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index feb68d6303..c9158c5d0f 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -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", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 1f9ceb9552..4471e787ea 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -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", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 472b3f702e..27594632b5 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -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": "連携を設定", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 2c09a907e7..4e7e41a0b1 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -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", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index a04bd43802..045f5b2e6f 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -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", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index bc0cc29074..4c0446cf43 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -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", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 297307f7b1..c6d9739825 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -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", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index c1a7df0e62..259022e604 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -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": "设置 集成", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index be71d9fdce..ecd476df04 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -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": "設定整合", diff --git a/apps/web/modules/ui/components/rating-response/index.tsx b/apps/web/modules/ui/components/rating-response/index.tsx index e9204c8119..b1abe135da 100644 --- a/apps/web/modules/ui/components/rating-response/index.tsx +++ b/apps/web/modules/ui/components/rating-response/index.tsx @@ -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 ( +
+ {answer} +
+ ); +}; + +const renderStarScale = (answer: number, variant: string, range: number) => { + if (variant === "scale") { + return answer === 1 ? ( + + ) : ( + + ); + } + + if (variant === "aggregated") { + return ; + } + + if (variant === "individual" && range > 5) { + return ( +
+ + {answer} + +
+ ); + } + + const stars = Array.from({ length: range }, (_, i) => ( + + )); + + return
{stars}
; +}; + +const renderSmileyScale = (answer: number, variant: string, range: number, addColors: boolean) => { + if (variant === "scale") { + return ( +
+ +
+ ); + } + + if (variant === "aggregated") { + return ; + } + + return ; +}; + +const renderNumberScale = (answer: number, variant: string) => { + if (variant === "scale") return ; + if (variant === "aggregated") return ; + return ; +}; + export const RatingResponse: React.FC = ({ 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(); - } else { - stars.push(); - } - } - return
{stars}
; - } - - if (scale === "smiley") - return ; - - return answer; + if (scale === "star") return renderStarScale(answer, variant, range); + if (scale === "smiley") return renderSmileyScale(answer, variant, range, addColors); + return renderNumberScale(answer, variant); }; diff --git a/packages/types/surveys/types.ts b/packages/types/surveys/types.ts index 8313fcdc51..35954c5765 100644 --- a/packages/types/surveys/types.ts +++ b/packages/types/surveys/types.ts @@ -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; @@ -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;