Compare commits

...

18 Commits

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

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

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

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

Co-authored-by: johannes <johannes@formbricks.com>
2025-11-17 08:43:56 +00:00
Matti Nannt 71a85c7126 feat: add CUID v1 validation for environment ID endpoints (#6827)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-17 07:33:52 +00:00
Dhruwang Jariwala 341e2639e1 feat: spanish translations (#6817)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-11-13 14:48:37 +00:00
Dhruwang Jariwala 056470e6f0 fix: added variable key id mapping UI (#6814) 2025-11-13 09:56:42 +00:00
Dhruwang Jariwala e965ad4b97 fix: raw html issues (#6813) 2025-11-13 09:12:39 +00:00
43 changed files with 4069 additions and 121 deletions
@@ -13,6 +13,7 @@ import {
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import {
ERRORS,
@@ -122,7 +123,7 @@ export const AddIntegrationModal = ({
const questions = selectedSurvey
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
id: q.id,
name: getLocalizedValue(q.headline, "default"),
name: getTextContent(getLocalizedValue(q.headline, "default")),
type: q.type,
}))
: [];
@@ -215,7 +215,7 @@ export const EditProfileDetailsForm = ({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-slate-50 text-slate-700"
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{appLanguages.map((lang) => (
@@ -1,5 +1,7 @@
"use client";
import { BarChart, BarChartHorizontal } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
TI18nString,
@@ -9,8 +11,11 @@ import {
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { convertFloatToNDecimal } from "../lib/utils";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
import { SatisfactionSmiley } from "./SatisfactionSmiley";
interface NPSSummaryProps {
questionSummary: TSurveyQuestionSummaryNps;
@@ -26,6 +31,8 @@ interface NPSSummaryProps {
export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"grouped" | "individual">("grouped");
const applyFilter = (group: string) => {
const filters = {
promoters: {
@@ -61,38 +68,121 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={group}
onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
<p
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
additionalInfo={
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionSmiley percentage={questionSummary.promoters.percentage} />
<div>
{t("environments.surveys.summary.promoters")}:{" "}
{convertFloatToNDecimal(questionSummary.promoters.percentage, 2)}%
</div>
</div>
</TooltipTrigger>
<TooltipContent>{t("environments.surveys.summary.promotersTooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
}
/>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "grouped" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="grouped" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("environments.surveys.summary.grouped")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("environments.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="grouped" className="mt-4">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={group}
onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
<p
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary[group]?.count}{" "}
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary[group]?.count}{" "}
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</button>
))}
</div>
<ProgressBar
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</button>
))}
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<div className="grid grid-cols-11 gap-2 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((choice) => {
// Calculate opacity: 0-6 detractors (low opacity), 7-8 passives (medium), 9-10 promoters (high)
const opacity =
choice.rating <= 6
? 0.3 + (choice.rating / 6) * 0.3 // 0.3 to 0.6
: choice.rating <= 8
? 0.6 + ((choice.rating - 6) / 2) * 0.2 // 0.6 to 0.8
: 0.8 + ((choice.rating - 8) / 2) * 0.2; // 0.8 to 1.0
return (
<button
key={choice.rating}
className="group flex cursor-pointer flex-col items-center"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
choice.rating.toString()
)
}>
<div className="flex h-32 w-full flex-col items-center justify-end">
<div
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
style={{
height: `${Math.max(choice.percentage, 2)}%`,
opacity,
}}
/>
</div>
<div className="flex w-full flex-col items-center rounded-b-lg border border-t-0 border-slate-200 bg-slate-50 px-1 py-2">
<div className="mb-1.5 text-xs font-medium text-slate-500">{choice.rating}</div>
<div className="mb-1 flex items-center space-x-1">
<div className="text-base font-medium text-slate-700">{choice.count}</div>
<div className="rounded bg-slate-100 px-1.5 py-0.5 text-xs font-medium text-slate-600">
{convertFloatToNDecimal(choice.percentage, 1)}%
</div>
</div>
</div>
</button>
);
})}
</div>
</TabsContent>
</Tabs>
<div className="flex justify-center pb-4 pt-4">
<HalfCircle value={questionSummary.score} />
@@ -1,7 +1,7 @@
"use client";
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo } from "react";
import { BarChart, BarChartHorizontal, CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
TI18nString,
@@ -13,7 +13,10 @@ import {
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
import { SatisfactionSmiley } from "./SatisfactionSmiley";
interface RatingSummaryProps {
questionSummary: TSurveyQuestionSummaryRating;
@@ -29,6 +32,8 @@ interface RatingSummaryProps {
export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"grouped" | "individual">("grouped");
const getIconBasedOnScale = useMemo(() => {
const scale = questionSummary.question.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
@@ -42,52 +47,182 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
questionSummary={questionSummary}
survey={survey}
additionalInfo={
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
</div>
</div>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionSmiley percentage={questionSummary.csat.satisfiedPercentage} />
<div>
CSAT: {questionSummary.csat.satisfiedPercentage}%{" "}
{t("environments.surveys.summary.satisfied")}
</div>
</div>
</TooltipTrigger>
<TooltipContent>{t("environments.surveys.summary.csatTooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((result) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={result.rating}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={questionSummary.question.scale}
answer={result.rating}
range={questionSummary.question.range}
addColors={questionSummary.question.isColorCodingEnabled}
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "grouped" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="grouped" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("environments.surveys.summary.grouped")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("environments.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="grouped" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
{questionSummary.responseCount === 0 ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-8 text-center">
<p className="text-sm text-slate-500">
{t("environments.surveys.summary.no_responses_found")}
</p>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
</p>
) : (
<>
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
{questionSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
// Calculate opacity based on rating position (higher rating = higher opacity)
const range = questionSummary.question.range;
const opacity = 0.3 + (result.rating / range) * 0.7; // Range from 30% to 100%
return (
<button
key={result.rating}
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
style={{
width: `${result.percentage}%`,
borderRight:
index < questionSummary.choices.length - 1
? "1px solid rgb(226, 232, 240)"
: "none",
}}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div
className={`h-full ${index === 0 ? "rounded-tl-lg" : ""} ${
index === questionSummary.choices.length - 1 ? "rounded-tr-lg" : ""
} bg-brand-dark`}
style={{ opacity }}
/>
</button>
);
})}
</div>
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
{questionSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
return (
<div
key={result.rating}
className="flex flex-col items-center justify-center py-2"
style={{
width: `${result.percentage}%`,
borderRight:
index < questionSummary.choices.length - 1
? "1px solid rgb(226, 232, 240)"
: "none",
}}>
<div className="mb-1 flex items-center justify-center">
<RatingResponse
scale={questionSummary.question.scale}
answer={result.rating}
range={questionSummary.question.range}
addColors={false}
/>
</div>
<div className="text-xs font-medium text-slate-600">
{convertFloatToNDecimal(result.percentage, 1)}%
</div>
</div>
);
})}
</div>
{questionSummary.question.scale === "star" && (
<div className="mt-3 flex w-full items-center justify-center space-x-3 px-1">
<StarIcon className="h-6 w-6 text-slate-300" />
<span className="text-xs text-slate-500">1 - {questionSummary.question.range}</span>
<StarIcon fill="rgb(250 204 21)" className="h-6 w-6 text-yellow-400" />
</div>
)}
</>
)}
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base">
{questionSummary.choices.map((result) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={result.rating}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={questionSummary.question.scale}
answer={result.rating}
range={questionSummary.question.range}
addColors={questionSummary.question.isColorCodingEnabled}
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
))}
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
))}
</div>
{questionSummary.question.scale === "star" && (
<div className="mt-5 flex w-full items-center justify-center space-x-3 px-1">
<StarIcon className="h-6 w-6 text-slate-300" />
<span className="text-xs text-slate-500">1 - {questionSummary.question.range}</span>
<StarIcon fill="rgb(250 204 21)" className="h-6 w-6 text-yellow-400" />
</div>
)}
</div>
</TabsContent>
</Tabs>
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
<div className="rounded-b-lg border-t bg-white px-6 py-4">
<div key="dismissed">
@@ -0,0 +1,18 @@
interface SatisfactionSmileyProps {
percentage: number;
className?: string;
}
export const SatisfactionSmiley = ({ percentage, className }: SatisfactionSmileyProps) => {
let colorClass = "";
if (percentage > 80) {
colorClass = "bg-emerald-500";
} else if (percentage >= 55) {
colorClass = "bg-orange-500";
} else {
colorClass = "bg-rose-500";
}
return <div className={`h-3 w-3 rounded-full ${colorClass} ${className || ""}`} />;
};
@@ -2334,6 +2334,98 @@ describe("NPS question type tests", () => {
// Score should be -100 since all valid responses are detractors
expect(summary[0].score).toBe(-100);
});
test("getQuestionSummary includes individual score breakdown in choices array for NPS", async () => {
const question = {
id: "nps-q1",
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "How likely are you to recommend us?" },
required: true,
lowerLabel: { default: "Not likely" },
upperLabel: { default: "Very likely" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "nps-q1": 0 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "nps-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "nps-q1": 7 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r4", data: { "nps-q1": 9 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r5", data: { "nps-q1": 10 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "nps-q1", impressions: 5, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
expect(summary[0].choices).toBeDefined();
expect(summary[0].choices).toHaveLength(11); // Scores 0-10
// Verify specific scores
const score0 = summary[0].choices.find((c: any) => c.rating === 0);
expect(score0.count).toBe(1);
expect(score0.percentage).toBe(20); // 1/5 * 100
const score5 = summary[0].choices.find((c: any) => c.rating === 5);
expect(score5.count).toBe(1);
expect(score5.percentage).toBe(20);
const score7 = summary[0].choices.find((c: any) => c.rating === 7);
expect(score7.count).toBe(1);
expect(score7.percentage).toBe(20);
const score9 = summary[0].choices.find((c: any) => c.rating === 9);
expect(score9.count).toBe(1);
expect(score9.percentage).toBe(20);
const score10 = summary[0].choices.find((c: any) => c.rating === 10);
expect(score10.count).toBe(1);
expect(score10.percentage).toBe(20);
// Verify scores with no responses have 0 count
const score1 = summary[0].choices.find((c: any) => c.rating === 1);
expect(score1.count).toBe(0);
expect(score1.percentage).toBe(0);
});
test("getQuestionSummary handles NPS individual score breakdown with no responses", async () => {
const question = {
id: "nps-q1",
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "How likely are you to recommend us?" },
required: true,
lowerLabel: { default: "Not likely" },
upperLabel: { default: "Very likely" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses: any[] = [];
const dropOff = [{ questionId: "nps-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
expect(summary[0].choices).toBeDefined();
expect(summary[0].choices).toHaveLength(11); // Scores 0-10
// All scores should have 0 count and percentage
summary[0].choices.forEach((choice: any) => {
expect(choice.count).toBe(0);
expect(choice.percentage).toBe(0);
});
});
});
describe("Rating question type tests", () => {
@@ -2557,6 +2649,315 @@ describe("Rating question type tests", () => {
// Verify dismissed is 0
expect(summary[0].dismissed.count).toBe(0);
});
test("getQuestionSummary calculates CSAT for Rating question with range 3", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 3,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 2 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 3: satisfied = score 3
// 2 out of 3 responses are satisfied (score 3)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67); // Math.round((2/3) * 100)
});
test("getQuestionSummary calculates CSAT for Rating question with range 4", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 4,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 2 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 4: satisfied = scores 3-4
// 2 out of 3 responses are satisfied (scores 3 and 4)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67);
});
test("getQuestionSummary calculates CSAT for Rating question with range 5", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 5: satisfied = scores 4-5
// 2 out of 3 responses are satisfied (scores 4 and 5)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67);
});
test("getQuestionSummary calculates CSAT for Rating question with range 6", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 6,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 6 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 6: satisfied = scores 5-6
// 2 out of 3 responses are satisfied (scores 5 and 6)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67);
});
test("getQuestionSummary calculates CSAT for Rating question with range 7", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 7,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 6 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 7 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 7: satisfied = scores 6-7
// 2 out of 3 responses are satisfied (scores 6 and 7)
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(67);
});
test("getQuestionSummary calculates CSAT for Rating question with range 10", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 10,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 8 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 9 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 10 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r4", data: { "rating-q1": 7 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 10: satisfied = scores 8-10
// 3 out of 4 responses are satisfied (scores 8, 9, 10)
expect(summary[0].csat.satisfiedCount).toBe(3);
expect(summary[0].csat.satisfiedPercentage).toBe(75);
});
test("getQuestionSummary calculates CSAT for Rating question with all satisfied", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 4 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 5 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 5: satisfied = scores 4-5
// All 2 responses are satisfied
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(100);
});
test("getQuestionSummary calculates CSAT for Rating question with none satisfied", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{ id: "r1", data: { "rating-q1": 1 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r2", data: { "rating-q1": 2 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
{ id: "r3", data: { "rating-q1": 3 }, updatedAt: new Date(), contact: null, contactAttributes: {}, language: null, ttc: {}, finished: true },
];
const dropOff = [{ questionId: "rating-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
// Range 5: satisfied = scores 4-5
// None of the responses are satisfied (all are 1, 2, or 3)
expect(summary[0].csat.satisfiedCount).toBe(0);
expect(summary[0].csat.satisfiedPercentage).toBe(0);
});
test("getQuestionSummary calculates CSAT for Rating question with no responses", async () => {
const question = {
id: "rating-q1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rate our service" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Poor" },
upperLabel: { default: "Excellent" },
};
const survey = {
id: "survey-1",
questions: [question],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses: any[] = [];
const dropOff = [{ questionId: "rating-q1", impressions: 0, dropOffCount: 0, dropOffPercentage: 0 }] as unknown as TSurveySummary["dropOff"];
const summary: any = await getQuestionSummary(survey, responses, dropOff);
expect(summary[0].csat.satisfiedCount).toBe(0);
expect(summary[0].csat.satisfiedPercentage).toBe(0);
});
});
describe("PictureSelection question type tests", () => {
@@ -539,6 +539,24 @@ export const getQuestionSummary = async (
});
});
// Calculate CSAT based on range
let satisfiedCount = 0;
if (range === 3) {
satisfiedCount = choiceCountMap[3] || 0;
} else if (range === 4) {
satisfiedCount = (choiceCountMap[3] || 0) + (choiceCountMap[4] || 0);
} else if (range === 5) {
satisfiedCount = (choiceCountMap[4] || 0) + (choiceCountMap[5] || 0);
} else if (range === 6) {
satisfiedCount = (choiceCountMap[5] || 0) + (choiceCountMap[6] || 0);
} else if (range === 7) {
satisfiedCount = (choiceCountMap[6] || 0) + (choiceCountMap[7] || 0);
} else if (range === 10) {
satisfiedCount = (choiceCountMap[8] || 0) + (choiceCountMap[9] || 0) + (choiceCountMap[10] || 0);
}
const satisfiedPercentage =
totalResponseCount > 0 ? Math.round((satisfiedCount / totalResponseCount) * 100) : 0;
summary.push({
type: question.type,
question,
@@ -548,6 +566,10 @@ export const getQuestionSummary = async (
dismissed: {
count: dismissed,
},
csat: {
satisfiedCount,
satisfiedPercentage,
},
});
values = [];
@@ -563,10 +585,17 @@ export const getQuestionSummary = async (
score: 0,
};
// Track individual score counts (0-10)
const scoreCountMap: Record<number, number> = {};
for (let i = 0; i <= 10; i++) {
scoreCountMap[i] = 0;
}
responses.forEach((response) => {
const value = response.data[question.id];
if (typeof value === "number") {
data.total++;
scoreCountMap[value]++;
if (value >= 9) {
data.promoters++;
} else if (value >= 7) {
@@ -585,6 +614,13 @@ export const getQuestionSummary = async (
? convertFloatTo2Decimal(((data.promoters - data.detractors) / data.total) * 100)
: 0;
// Build choices array with individual score breakdown
const choices = Object.entries(scoreCountMap).map(([rating, count]) => ({
rating: parseInt(rating),
count,
percentage: data.total > 0 ? convertFloatTo2Decimal((count / data.total) * 100) : 0,
}));
summary.push({
type: question.type,
question,
@@ -607,6 +643,7 @@ export const getQuestionSummary = async (
count: data.dismissed,
percentage: data.total > 0 ? convertFloatTo2Decimal((data.dismissed / data.total) * 100) : 0,
},
choices,
});
break;
}
@@ -1,5 +1,6 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
import { responses } from "@/app/lib/api/response";
@@ -28,15 +29,38 @@ export const GET = withV1ApiWrapper({
const params = await props.params;
try {
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
// Basic type check for environmentId
if (typeof params.environmentId !== "string") {
return {
response: responses.badRequestResponse("Environment ID is required", undefined, true),
};
}
const environmentId = params.environmentId.trim();
// Validate CUID v1 format using Zod (matches Prisma schema @default(cuid()))
// This catches all invalid formats including:
// - null/undefined passed as string "null" or "undefined"
// - HTML-encoded placeholders like <environmentId> or %3C...%3E
// - Empty or whitespace-only IDs
// - Any other invalid CUID v1 format
const cuidValidation = ZEnvironmentId.safeParse(environmentId);
if (!cuidValidation.success) {
logger.warn(
{
environmentId: params.environmentId,
url: req.url,
validationError: cuidValidation.error.errors[0]?.message,
},
"Invalid CUID v1 format detected"
);
return {
response: responses.badRequestResponse("Invalid environment ID format", undefined, true),
};
}
// Use optimized environment state fetcher with new caching approach
const environmentState = await getEnvironmentState(params.environmentId);
const environmentState = await getEnvironmentState(environmentId);
const { data } = environmentState;
return {
@@ -2,7 +2,7 @@ import { headers } from "next/headers";
import { NextRequest } from "next/server";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
@@ -51,7 +51,7 @@ export const POST = withV1ApiWrapper({
}
const { environmentId } = params;
const environmentIdValidation = ZId.safeParse(environmentId);
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
@@ -1,7 +1,7 @@
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
@@ -43,7 +43,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
}
const { environmentId } = params;
const environmentIdValidation = ZId.safeParse(environmentId);
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
+12 -1
View File
@@ -7,7 +7,18 @@
},
"locale": {
"source": "en-US",
"targets": ["de-DE", "fr-FR", "ja-JP", "pt-BR", "pt-PT", "ro-RO", "zh-Hans-CN", "zh-Hant-TW", "nl-NL"]
"targets": [
"de-DE",
"fr-FR",
"ja-JP",
"pt-BR",
"pt-PT",
"ro-RO",
"zh-Hans-CN",
"zh-Hant-TW",
"nl-NL",
"es-ES"
]
},
"version": 1.8
}
+1
View File
@@ -398,6 +398,7 @@ checksums:
common/user_id: 37f5ba37f71cb50607af32a6a203b1d4
common/user_not_found: 5903581136ac6c1c1351a482a6d8fdf7
common/variable: c13db5775ba9791b1522cc55c9c7acce
common/variable_ids: 44bf93b70703b7699fa9f21bc6c8eed4
common/variables: ffd3eec5497af36d7b4e4185bad1313a
common/verified_email: d4a9e5e47d622c6ef2fede44233076c7
common/video: 8050c90e4289b105a0780f0fdda6ff66
+1
View File
@@ -175,6 +175,7 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"ro-RO",
"ja-JP",
"zh-Hans-CN",
"es-ES",
];
// Billing constants
+26
View File
@@ -138,6 +138,7 @@ export const appLanguages = [
"ja-JP": "英語(米国)",
"zh-Hans-CN": "英语(美国)",
"nl-NL": "Engels (VS)",
"es-ES": "Inglés (EE.UU.)",
},
},
{
@@ -153,6 +154,7 @@ export const appLanguages = [
"ja-JP": "ドイツ語",
"zh-Hans-CN": "德语",
"nl-NL": "Duits",
"es-ES": "Alemán",
},
},
{
@@ -168,6 +170,7 @@ export const appLanguages = [
"ja-JP": "ポルトガル語(ブラジル)",
"zh-Hans-CN": "葡萄牙语(巴西)",
"nl-NL": "Portugees (Brazilië)",
"es-ES": "Portugués (Brasil)",
},
},
{
@@ -183,6 +186,7 @@ export const appLanguages = [
"ja-JP": "フランス語",
"zh-Hans-CN": "法语",
"nl-NL": "Frans",
"es-ES": "Francés",
},
},
{
@@ -198,6 +202,7 @@ export const appLanguages = [
"ja-JP": "中国語(繁体字)",
"zh-Hans-CN": "繁体中文",
"nl-NL": "Chinees (Traditioneel)",
"es-ES": "Chino (Tradicional)",
},
},
{
@@ -213,6 +218,7 @@ export const appLanguages = [
"ja-JP": "ポルトガル語(ポルトガル)",
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
"nl-NL": "Portugees (Portugal)",
"es-ES": "Portugués (Portugal)",
},
},
{
@@ -228,6 +234,7 @@ export const appLanguages = [
"ja-JP": "ルーマニア語",
"zh-Hans-CN": "罗马尼亚语",
"nl-NL": "Roemeens",
"es-ES": "Rumano",
},
},
{
@@ -243,6 +250,7 @@ export const appLanguages = [
"ja-JP": "日本語",
"zh-Hans-CN": "日语",
"nl-NL": "Japans",
"es-ES": "Japonés",
},
},
{
@@ -258,6 +266,7 @@ export const appLanguages = [
"ja-JP": "中国語(簡体字)",
"zh-Hans-CN": "简体中文",
"nl-NL": "Chinees (Vereenvoudigd)",
"es-ES": "Chino (Simplificado)",
},
},
{
@@ -273,6 +282,23 @@ export const appLanguages = [
"ja-JP": "オランダ語",
"zh-Hans-CN": "荷兰语",
"nl-NL": "Nederlands",
"es-ES": "Neerlandés",
},
},
{
code: "es-ES",
label: {
"en-US": "Spanish",
"de-DE": "Spanisch",
"pt-BR": "Espanhol",
"fr-FR": "Espagnol",
"zh-Hant-TW": "西班牙語",
"pt-PT": "Espanhol",
"ro-RO": "Spaniol",
"ja-JP": "スペイン語",
"zh-Hans-CN": "西班牙语",
"nl-NL": "Spaans",
"es-ES": "Español",
},
},
];
+3 -1
View File
@@ -1,5 +1,5 @@
import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, fr, ja, nl, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
export const convertDateString = (dateString: string | null) => {
@@ -103,6 +103,8 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
return ja;
case "zh-Hans-CN":
return zhCN;
case "es-ES":
return es;
}
};
+1
View File
@@ -425,6 +425,7 @@
"user_id": "Benutzer-ID",
"user_not_found": "Benutzer nicht gefunden",
"variable": "Variable",
"variable_ids": "Variablen-IDs",
"variables": "Variablen",
"verified_email": "Verifizierte E-Mail",
"video": "Video",
+7
View File
@@ -425,6 +425,7 @@
"user_id": "User ID",
"user_not_found": "User not found",
"variable": "Variable",
"variable_ids": "Variable IDs",
"variables": "Variables",
"verified_email": "Verified Email",
"video": "Video",
@@ -1811,6 +1812,7 @@
"configure_alerts": "Configure alerts",
"congrats": "Congrats! Your survey is live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.",
"csatTooltip": "Customer Satisfaction Score measures the percentage of respondents who gave top ratings (based on the rating scale).",
"current_count": "Current count",
"custom_range": "Custom range...",
"delete_all_existing_responses_and_displays": "Delete all existing responses and displays",
@@ -1825,6 +1827,7 @@
"filtered_responses_excel": "Filtered responses (Excel)",
"generating_qr_code": "Generating QR code",
"go_to_setup_checklist": "Go to Setup Checklist \uD83D\uDC49",
"grouped": "Grouped",
"impressions": "Impressions",
"impressions_tooltip": "Number of times the survey has been viewed.",
"in_app": {
@@ -1858,6 +1861,7 @@
},
"includes_all": "Includes all",
"includes_either": "Includes either",
"individual": "Individual",
"install_widget": "Install Formbricks Widget",
"is_equal_to": "Is equal to",
"is_less_than": "Is less than",
@@ -1871,6 +1875,8 @@
"no_responses_found": "No responses found",
"other_values_found": "Other values found",
"overall": "Overall",
"promoters": "Promoters",
"promotersTooltip": "Percentage of respondents who scored 9-10, indicating high likelihood to recommend.",
"qr_code": "QR code",
"qr_code_description": "Responses collected via QR code are anonymous.",
"qr_code_download_failed": "QR code download failed",
@@ -1880,6 +1886,7 @@
"quotas_completed_tooltip": "The number of quotas completed by the respondents.",
"reset_survey": "Reset survey",
"reset_survey_warning": "Resetting a survey removes all responses and displays associated with this survey. This cannot be undone.",
"satisfied": "Satisfied",
"selected_responses_csv": "Selected responses (CSV)",
"selected_responses_excel": "Selected responses (Excel)",
"setup_integrations": "Setup integrations",
File diff suppressed because it is too large Load Diff
+1
View File
@@ -425,6 +425,7 @@
"user_id": "Identifiant d'utilisateur",
"user_not_found": "Utilisateur non trouvé",
"variable": "Variable",
"variable_ids": "Identifiants variables",
"variables": "Variables",
"verified_email": "Email vérifié",
"video": "Vidéo",
+1
View File
@@ -425,6 +425,7 @@
"user_id": "ユーザーID",
"user_not_found": "ユーザーが見つかりません",
"variable": "変数",
"variable_ids": "変数ID",
"variables": "変数",
"verified_email": "認証済みメールアドレス",
"video": "動画",
+1
View File
@@ -425,6 +425,7 @@
"user_id": "Gebruikers-ID",
"user_not_found": "Gebruiker niet gevonden",
"variable": "Variabel",
"variable_ids": "Variabele ID's",
"variables": "Variabelen",
"verified_email": "Geverifieerde e-mail",
"video": "Video",
+1
View File
@@ -425,6 +425,7 @@
"user_id": "ID do usuário",
"user_not_found": "Usuário não encontrado",
"variable": "variável",
"variable_ids": "IDs de variáveis",
"variables": "Variáveis",
"verified_email": "Email Verificado",
"video": "vídeo",
+1
View File
@@ -425,6 +425,7 @@
"user_id": "ID do Utilizador",
"user_not_found": "Utilizador não encontrado",
"variable": "Variável",
"variable_ids": "IDs de variáveis",
"variables": "Variáveis",
"verified_email": "Email verificado",
"video": "Vídeo",
+1
View File
@@ -425,6 +425,7 @@
"user_id": "ID Utilizator",
"user_not_found": "Utilizatorul nu a fost găsit",
"variable": "Variabilă",
"variable_ids": "ID-uri variabile",
"variables": "Variante",
"verified_email": "Email verificat",
"video": "Video",
+1
View File
@@ -425,6 +425,7 @@
"user_id": "用户 ID",
"user_not_found": "用户 不存在",
"variable": "变量",
"variable_ids": "变量 ID",
"variables": "变量",
"verified_email": "已验证 电子邮件",
"video": "视频",
+1
View File
@@ -425,6 +425,7 @@
"user_id": "使用者 ID",
"user_not_found": "找不到使用者",
"variable": "變數",
"variable_ids": "變數 ID",
"variables": "變數",
"verified_email": "已驗證的電子郵件",
"video": "影片",
@@ -66,13 +66,13 @@ const getSmiley = (
return (
<table style={{ width: "48px", height: "48px" }}>
{" "}
{/* NOSONAR S5256 - Need table layout for email compatibility (gmail) */}
<tr>
<td align="center" valign="middle">
{icon}
</td>
</tr>
<tbody>
<tr>
<td align="center" valign="middle">
{icon}
</td>
</tr>
</tbody>
</table>
);
};
@@ -1,6 +1,7 @@
import { NextRequest, userAgent } from "next/server";
import { logger } from "@formbricks/logger";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TJsPersonState } from "@formbricks/types/js";
import { responses } from "@/app/lib/api/response";
@@ -29,15 +30,36 @@ export const POST = withV1ApiWrapper({
const params = await props.params;
try {
const { environmentId } = params;
// Simple validation (faster than Zod for high-frequency endpoint)
if (!environmentId || typeof environmentId !== "string") {
// Basic type check for environmentId
if (typeof params.environmentId !== "string") {
return {
response: responses.badRequestResponse("Environment ID is required", undefined, true),
};
}
const environmentId = params.environmentId.trim();
// Validate CUID v1 format using Zod (matches Prisma schema @default(cuid()))
// This catches all invalid formats including:
// - null/undefined passed as string "null" or "undefined"
// - HTML-encoded placeholders like <environmentId> or %3C...%3E
// - Empty or whitespace-only IDs
// - Any other invalid CUID v1 format
const cuidValidation = ZEnvironmentId.safeParse(environmentId);
if (!cuidValidation.success) {
logger.warn(
{
environmentId: params.environmentId,
url: req.url,
validationError: cuidValidation.error.errors[0]?.message,
},
"Invalid CUID v1 format detected"
);
return {
response: responses.badRequestResponse("Invalid environment ID format", undefined, true),
};
}
const jsonInput = await req.json();
// Basic input validation without Zod overhead
@@ -3,6 +3,7 @@
import { HandshakeIcon, Undo2Icon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TSurveyEndings } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import {
Select,
@@ -41,7 +42,7 @@ export const EndingCardSelector = ({ endings, value, onChange }: EndingCardSelec
{/* Custom endings */}
{endingCards.map((ending) => (
<SelectItem key={ending.id} value={ending.id}>
{getLocalizedValue(ending.headline, "default")}
{getTextContent(getLocalizedValue(ending.headline, "default"))}
</SelectItem>
))}
</SelectGroup>
@@ -26,7 +26,7 @@ const baseProject = {
darkOverlay: false,
environments: [
{
id: "prodenv",
id: "cmi2sra0j000004l73fvh7lhe",
createdAt: new Date(),
updatedAt: new Date(),
type: "production" as TEnvironment["type"],
@@ -34,7 +34,7 @@ const baseProject = {
appSetupCompleted: false,
},
{
id: "devenv",
id: "cmi2srt9q000104l7127e67v7",
createdAt: new Date(),
updatedAt: new Date(),
type: "development" as TEnvironment["type"],
@@ -155,7 +155,7 @@ describe("project lib", () => {
vi.mocked(deleteFilesByEnvironmentId).mockResolvedValue({ ok: true, data: undefined });
const result = await deleteProject("p1");
expect(result).toEqual(baseProject);
expect(deleteFilesByEnvironmentId).toHaveBeenCalledWith("prodenv");
expect(deleteFilesByEnvironmentId).toHaveBeenCalledWith("cmi2sra0j000004l73fvh7lhe");
});
test("logs error if file deletion fails", async () => {
@@ -40,7 +40,9 @@ export const AdvancedSettings = ({
updateQuestion={updateQuestion}
/>
{showOptionIds && <OptionIds question={question} selectedLanguageCode={selectedLanguageCode} />}
{showOptionIds && (
<OptionIds type="question" question={question} selectedLanguageCode={selectedLanguageCode} />
)}
</div>
);
};
@@ -4,6 +4,7 @@ import { ArrowRightIcon } from "lucide-react";
import { ReactElement, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyLogic, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
@@ -48,7 +49,7 @@ export function LogicEditor({
const ques = localSurvey.questions[i];
options.push({
icon: QUESTIONS_ICON_MAP[ques.type],
label: getLocalizedValue(ques.headline, "default"),
label: getTextContent(getLocalizedValue(ques.headline, "default")),
value: ques.id,
});
}
@@ -57,7 +58,8 @@ export function LogicEditor({
options.push({
label:
ending.type === "endScreen"
? getLocalizedValue(ending.headline, "default") || t("environments.surveys.edit.end_screen_card")
? getTextContent(getLocalizedValue(ending.headline, "default")) ||
t("environments.surveys.edit.end_screen_card")
: ending.label || t("environments.surveys.edit.redirect_thank_you_card"),
value: ending.id,
});
@@ -1,19 +1,27 @@
import Image from "next/image";
import { useTranslation } from "react-i18next";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurveyQuestion, TSurveyQuestionTypeEnum, TSurveyVariable } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Label } from "@/modules/ui/components/label";
interface OptionIdsProps {
interface OptionIdsQuestionProps {
type: "question";
question: TSurveyQuestion;
selectedLanguageCode: string;
}
export const OptionIds = ({ question, selectedLanguageCode }: OptionIdsProps) => {
interface OptionIdsVariablesProps {
type: "variables";
variables: TSurveyVariable[];
}
type OptionIdsProps = OptionIdsQuestionProps | OptionIdsVariablesProps;
export const OptionIds = (props: OptionIdsProps) => {
const { t } = useTranslation();
const renderChoiceIds = () => {
const renderChoiceIds = (question: TSurveyQuestion, selectedLanguageCode: string) => {
switch (question.type) {
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
@@ -59,10 +67,31 @@ export const OptionIds = ({ question, selectedLanguageCode }: OptionIdsProps) =>
}
};
const renderVariableIds = (variables: TSurveyVariable[]) => {
return (
<div className="flex flex-col gap-2">
{variables.map((variable) => (
<div key={variable.id}>
<IdBadge id={variable.id} label={variable.name} />
</div>
))}
</div>
);
};
if (props.type === "variables") {
return (
<div className="space-y-3">
<Label className="text-sm font-medium text-gray-700">{t("common.variable_ids")}</Label>
<div className="w-full">{renderVariableIds(props.variables)}</div>
</div>
);
}
return (
<div className="space-y-3">
<Label className="text-sm font-medium text-gray-700">{t("common.option_ids")}</Label>
<div className="w-full">{renderChoiceIds()}</div>
<div className="w-full">{renderChoiceIds(props.question, props.selectedLanguageCode)}</div>
</div>
);
};
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { OptionIds } from "@/modules/survey/editor/components/option-ids";
import { SurveyVariablesCardItem } from "@/modules/survey/editor/components/survey-variables-card-item";
interface SurveyVariablesCardProps {
@@ -91,6 +92,12 @@ export const SurveyVariablesCard = ({
setLocalSurvey={setLocalSurvey}
quotas={quotas}
/>
{localSurvey.variables.length > 0 && (
<div className="mt-6">
<OptionIds type="variables" variables={localSurvey.variables} />
</div>
)}
</Collapsible.CollapsibleContent>
</Collapsible.Root>
</div>
+4 -4
View File
@@ -594,7 +594,7 @@ export const getMatchValueProps = (
const questionOptions = openTextQuestions.map((question) => {
return {
icon: getQuestionIconMapping(t)[question.type],
label: getLocalizedValue(question.headline, "default"),
label: getTextContent(getLocalizedValue(question.headline, "default")),
value: question.id,
meta: {
type: "question",
@@ -691,7 +691,7 @@ export const getMatchValueProps = (
const questionOptions = allowedQuestions.map((question) => {
return {
icon: getQuestionIconMapping(t)[question.type],
label: getLocalizedValue(question.headline, "default"),
label: getTextContent(getLocalizedValue(question.headline, "default")),
value: question.id,
meta: {
type: "question",
@@ -765,7 +765,7 @@ export const getMatchValueProps = (
const questionOptions = allowedQuestions.map((question) => {
return {
icon: getQuestionIconMapping(t)[question.type],
label: getLocalizedValue(question.headline, "default"),
label: getTextContent(getLocalizedValue(question.headline, "default")),
value: question.id,
meta: {
type: "question",
@@ -845,7 +845,7 @@ export const getMatchValueProps = (
const questionOptions = allowedQuestions.map((question) => {
return {
icon: getQuestionIconMapping(t)[question.type],
label: getLocalizedValue(question.headline, "default"),
label: getTextContent(getLocalizedValue(question.headline, "default")),
value: question.id,
meta: {
type: "question",
@@ -18,6 +18,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurveyFollowUpAction, TSurveyFollowUpTrigger } from "@formbricks/database/types/survey-follow-up";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -140,9 +141,9 @@ export const FollowUpModal = ({
return [
...openTextAndContactQuestions.map((question) => ({
label: recallToHeadline(question.headline, localSurvey, false, selectedLanguageCode)[
selectedLanguageCode
],
label: getTextContent(
recallToHeadline(question.headline, localSurvey, false, selectedLanguageCode)[selectedLanguageCode]
),
id: question.id,
type:
question.type === TSurveyQuestionTypeEnum.OpenText
@@ -517,7 +518,9 @@ export const FollowUpModal = ({
const getEndingLabel = (): string => {
if (ending.type === "endScreen") {
return (
getLocalizedValue(ending.headline, selectedLanguageCode) || "Ending"
getTextContent(
getLocalizedValue(ending.headline, selectedLanguageCode)
) || "Ending"
);
}
@@ -33,5 +33,10 @@ export const RatingResponse: React.FC<RatingResponseProps> = ({
if (scale === "smiley")
return <RatingSmiley active={false} idx={answer - 1} range={range} addColors={addColors} />;
return answer;
// For number scale, render in a gray box for consistent styling
return (
<div className="flex h-12 w-12 items-center justify-center rounded-md bg-slate-100 text-base font-semibold text-slate-700">
{answer}
</div>
);
};
+1
View File
@@ -222,6 +222,7 @@ vi.mock("@/lib/constants", () => ({
"ro-RO",
"ja-JP",
"zh-Hans-CN",
"es-ES",
],
DEFAULT_LOCALE: "en-US",
BREVO_API_KEY: "mock-brevo-api-key",
File diff suppressed because it is too large Load Diff
+5 -7
View File
@@ -1,7 +1,11 @@
import { z } from "zod";
export const ZEnvironmentId = z.string().cuid();
export type TEnvironmentId = z.infer<typeof ZEnvironmentId>;
export const ZEnvironment = z.object({
id: z.string().cuid2(),
id: ZEnvironmentId,
createdAt: z.date(),
updatedAt: z.date(),
type: z.enum(["development", "production"]),
@@ -11,12 +15,6 @@ export const ZEnvironment = z.object({
export type TEnvironment = z.infer<typeof ZEnvironment>;
export const ZEnvironmentId = z.object({
id: z.string(),
});
export type TEnvironmentId = z.infer<typeof ZEnvironmentId>;
export const ZEnvironmentUpdateInput = z.object({
type: z.enum(["development", "production"]),
projectId: z.string(),
+11
View File
@@ -2587,6 +2587,10 @@ export const ZSurveyQuestionSummaryRating = z.object({
dismissed: z.object({
count: z.number(),
}),
csat: z.object({
satisfiedCount: z.number(),
satisfiedPercentage: z.number(),
}),
});
export type TSurveyQuestionSummaryRating = z.infer<typeof ZSurveyQuestionSummaryRating>;
@@ -2613,6 +2617,13 @@ export const ZSurveyQuestionSummaryNps = z.object({
count: z.number(),
percentage: z.number(),
}),
choices: z.array(
z.object({
rating: z.number(),
count: z.number(),
percentage: z.number(),
})
),
});
export type TSurveyQuestionSummaryNps = z.infer<typeof ZSurveyQuestionSummaryNps>;
+1
View File
@@ -11,6 +11,7 @@ export const ZUserLocale = z.enum([
"ro-RO",
"ja-JP",
"zh-Hans-CN",
"es-ES",
]);
export type TUserLocale = z.infer<typeof ZUserLocale>;
+1
View File
@@ -1374,6 +1374,7 @@ packages:
'@azure/microsoft-playwright-testing@1.0.0-beta.7':
resolution: {integrity: sha512-Y6C35LWUfLevHu5NG+7vvFfhpmUrGWKRumcz7/CSCmWlx8RVfWgP6NuL8rIPDuTeJyjaTczNfeg1ppGW26TjBw==}
engines: {node: '>=18.0.0'}
deprecated: This package has been deprecated and will no longer be maintained after March 8, 2026. Upgrade to the replacement package, @azure/playwright, to continue receiving updates.
peerDependencies:
'@playwright/test': ^1.43.1