mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-03 03:14:34 -05:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d14805d847 | |||
| a7ec311147 | |||
| 712be0ec71 | |||
| 8330e072dd | |||
| db4bd8966d | |||
| a1c65cc60e | |||
| 2d3a38c818 | |||
| f0252ed30f | |||
| acaeeeff07 | |||
| 8dfdb084aa | |||
| 97a48e5469 | |||
| bb3ff3a2c5 | |||
| 78a6d73ae8 | |||
| 21ff1eb385 | |||
| 71a85c7126 | |||
| 341e2639e1 | |||
| 056470e6f0 | |||
| e965ad4b97 |
+2
-1
@@ -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,
|
||||
}))
|
||||
: [];
|
||||
|
||||
+1
-1
@@ -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) => (
|
||||
|
||||
+120
-30
@@ -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} />
|
||||
|
||||
+177
-42
@@ -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">
|
||||
|
||||
+18
@@ -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 || ""}`} />;
|
||||
};
|
||||
+401
@@ -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", () => {
|
||||
|
||||
+37
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -175,6 +175,7 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
|
||||
"ro-RO",
|
||||
"ja-JP",
|
||||
"zh-Hans-CN",
|
||||
"es-ES",
|
||||
];
|
||||
|
||||
// Billing constants
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -425,6 +425,7 @@
|
||||
"user_id": "ユーザーID",
|
||||
"user_not_found": "ユーザーが見つかりません",
|
||||
"variable": "変数",
|
||||
"variable_ids": "変数ID",
|
||||
"variables": "変数",
|
||||
"verified_email": "認証済みメールアドレス",
|
||||
"video": "動画",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -425,6 +425,7 @@
|
||||
"user_id": "用户 ID",
|
||||
"user_not_found": "用户 不存在",
|
||||
"variable": "变量",
|
||||
"variable_ids": "变量 ID",
|
||||
"variables": "变量",
|
||||
"verified_email": "已验证 电子邮件",
|
||||
"video": "视频",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Generated
+1
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user