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;