Compare commits

...

80 Commits

Author SHA1 Message Date
Dhruwang 61d0fa6687 Merge remote-tracking branch 'origin/epic/v5' into chore/merge-v5-into-dashboards
# Conflicts:
#	apps/web/i18n.lock
#	apps/web/locales/de-DE.json
#	apps/web/locales/en-US.json
#	apps/web/locales/es-ES.json
#	apps/web/locales/fr-FR.json
#	apps/web/locales/hu-HU.json
#	apps/web/locales/ja-JP.json
#	apps/web/locales/nl-NL.json
#	apps/web/locales/pt-BR.json
#	apps/web/locales/pt-PT.json
#	apps/web/locales/ro-RO.json
#	apps/web/locales/ru-RU.json
#	apps/web/locales/sv-SE.json
#	apps/web/locales/zh-Hans-CN.json
#	apps/web/locales/zh-Hant-TW.json
#	apps/web/package.json
#	apps/web/playwright/survey.spec.ts
#	pnpm-lock.yaml
2026-04-21 18:15:58 +05:30
Johannes 28103604b4 fix: (Depr Env QA) api v1/me regression (#7761)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-21 13:26:01 +04:00
pandeymangg 175323e7d9 chore: merge with main 2026-04-21 14:07:14 +05:30
Tiago 0303f16db4 feat: BullMQ background jobs + response pipeline (#7779) 2026-04-20 15:30:20 +00:00
Serhat e489c6a346 feat: Add Turkish (tr) translations (#7645)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-20 12:51:25 +00:00
Tiago Farto 6ff5af712f chore: clean tests 2026-04-20 11:35:47 +00:00
Anshuman Pandey 398ba79e7e feat: ces and csat questions (#7688)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-20 15:33:26 +04:00
Tiago Farto 5127de9de0 chore: revert CI action 2026-04-20 11:11:09 +00:00
Tiago Farto ee8122778b chore: address PR comments 2026-04-20 10:43:32 +00:00
Tiago Farto 8aaa7ed9c0 chore: build fix 2026-04-20 10:00:06 +00:00
Dhruwang Jariwala ab1ea7a5ce fix: remove legacy API rewrites from next.config.mjs (#7764)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-20 13:38:30 +04:00
Tiago Farto 4f749355e0 chore: fix coverage test 2026-04-20 09:14:06 +00:00
Tiago Farto 18b60ddd35 chore: fix build 2026-04-20 08:52:59 +00:00
Tiago Farto 87f1b01c7a chore: fix broken tests 2026-04-20 08:40:44 +00:00
Tiago Farto 851ea0deb2 chore: fix broken lock 2026-04-20 08:32:45 +00:00
pandeymangg 9abbbfdd35 chore: merge with main 2026-04-20 13:07:36 +05:30
Johannes cefc2bdf60 fix: show oversized upload error when mime type is missing (#7757)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-04-20 07:00:41 +00:00
dependabot[bot] 78473bf3d0 chore(deps): bump the npm_and_yarn group across 12 directories with 4 updates (#7680)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-04-20 06:59:52 +00:00
Johannes 15403c6a92 fix: add accessible dialog title to project limit modal (#7769)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-20 06:45:21 +00:00
Johannes 35b98863a4 feat: auto-fill safe attribute key from label (#7771)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-20 06:44:10 +00:00
Anshuman Pandey 65f5968fb1 fix: fixes sentry ref issue (#7776) 2026-04-20 06:29:44 +00:00
Bhagya Amarasinghe 2dfea4d72f fix: prevent split offline responses on restore (#7767) 2026-04-20 06:05:13 +00:00
Tiago Farto dedb7389f0 Merge origin/epic/v5 into epic/bullmq 2026-04-17 14:33:21 +00:00
Dhruwang Jariwala ff77118932 fix: response tag UI issues in response modal (#7765) 2026-04-17 11:59:59 +00:00
Johannes 79a773432a feat: extend auto-progress to single-select question types (#7725) 2026-04-17 10:17:00 +00:00
Niels Kaspers d53869f1df fix: fix duplicate block and misleading subheader in trial conversion template (#7560)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-17 10:01:54 +00:00
Balázs Úr fc9ddb2b0d fix: mark Identify Customer Goals survey as translatable (#7566)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-17 09:53:15 +00:00
Bhagya Amarasinghe 6fcb6863bd feat: migrate survey overview to v3 APIs (#7741) 2026-04-17 09:45:12 +00:00
Johannes b1cee91ad9 fix: redirect active project and organization selections (#7724)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-17 09:33:12 +00:00
Bhagya Amarasinghe 9d2e988c59 feat: remove app rate limits for Envoy-covered routes (#7714)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-17 12:43:22 +04:00
Dhruwang Jariwala 60bd5cbeff fix: prevent environment ID leak in API error responses (#7753)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 08:38:32 +00:00
Dhruwang Jariwala b6a3a15379 fix: make other option input field mandatory when sole selection (#7751)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 08:06:00 +00:00
Johannes c68f214eff fix: keep sidebar switcher icons round with long labels (#7756)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-17 08:04:10 +00:00
Harsh Bhat c90ee84483 chore: Add survey to formbricks docs (#7746) 2026-04-16 12:13:55 +00:00
Dhruwang Jariwala dc1ee72594 chore: translation management revamp (scope 1) (#7733)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-16 11:18:48 +00:00
Dhruwang Jariwala 924132287e fix: connect rating/NPS scale labels to label styling settings (#7738)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 10:59:59 +00:00
Dhruwang Jariwala e6f347aa07 fix: remove dark: variant classes from survey-ui to prevent host page style leakage (#7747) 2026-04-16 05:50:46 +00:00
Dhruwang Jariwala 367bc23dd4 fix: prevent offline replay from dropping survey blocks after completion (#7743) 2026-04-15 19:59:15 +00:00
XHamzaX a1a11b2bb8 fix: prevent OIDC button text overlap with 'last used' indicator (#7731)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-15 09:42:20 +00:00
Tiago 31d2ea7444 chore: Move Response Pipeline to BullMQ (#7695) 2026-04-15 10:12:41 +03:00
Marius 0653c6a59f fix: strip @layer properties block to prevent host page CSS pollution (#7685)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 06:58:35 +00:00
Anshuman Pandey b6d793e109 fix: fixes unique constraint error with singleUseId and surveyId (#7737) 2026-04-15 06:50:20 +00:00
Tiago Farto d361c334d3 chore: fixed management snapshot gap 2026-04-13 14:28:31 +03:00
Tiago Farto a4d808b479 chore: build fix 2026-04-13 13:10:33 +03:00
Tiago Farto 18ae1748d3 chore: address PR comments 2026-04-13 12:50:21 +03:00
Tiago Farto 3404e0c494 chore: fix string date convertion error 2026-04-09 17:14:14 +03:00
Tiago Farto 83499ae552 chore: fix build 2026-04-09 15:14:44 +03:00
Tiago Farto 2ac0c1eb07 chore: refactor 2026-04-09 15:04:31 +03:00
Tiago Farto 54ede3015e chore: fix build 2026-04-09 14:09:46 +03:00
Tiago Farto 1b4f05a062 chore: fix linting issue 2026-04-09 13:59:34 +03:00
Tiago Farto 197dbf5aa6 chore: address pr comments 2026-04-09 13:45:32 +03:00
Tiago 7ca52a7a93 feat: Introduce BullMQ setup to Formbricks (#7684) 2026-04-09 11:47:58 +03:00
Tiago Farto 4a48839d17 Merge branch 'feat/background_workers_v1' into chore/response-to-bullmq 2026-04-09 11:43:30 +03:00
Tiago Farto 92bd9bdac7 chore: address PR comments 2026-04-09 11:26:12 +03:00
Tiago Farto ad4b6f8b8c chore: addressing additional PR comments 2026-04-09 10:39:01 +03:00
Tiago Farto 8de5079db3 chore: lint fix 2026-04-09 10:07:29 +03:00
Tiago Farto a60206dd44 chore: fix sonarqube warnings 2026-04-09 09:59:09 +03:00
Tiago Farto d66abdcdaf chore: refactoring 2026-04-09 09:26:38 +03:00
Tiago Farto 03fa41a911 fix: tighten v2 response validation details typing 2026-04-08 23:23:37 +03:00
Tiago Farto cab438e474 chore: refactor 2026-04-08 21:47:15 +03:00
Tiago Farto a6dfe78c81 fix: restore response pipeline safety guards 2026-04-08 20:47:47 +03:00
Tiago Farto e4d96f4379 fix: resolve jobs runtime type import for web build 2026-04-08 17:16:17 +03:00
Tiago Farto 581a66b4a9 chore: fix problems 2026-04-08 17:00:36 +03:00
Tiago Farto 5cf0c15812 chore: response to bullmq 2026-04-08 14:43:50 +03:00
Tiago Farto ebaa2d363c chore: fix flaky test 2026-04-08 10:25:48 +03:00
Tiago Farto 597ea40b75 chore: fix linting issues 2026-04-08 10:16:24 +03:00
Tiago Farto 3c39dcc2de chore: increased test coverage 2026-04-08 09:51:58 +03:00
Tiago Farto e8df1dbb35 chore: fix sonarqube warning 2026-04-07 22:15:10 +03:00
Tiago Farto 84987ce557 chore: linter fixes 2026-04-07 21:42:23 +03:00
Tiago Farto 784ed855d7 chore: additional tests; address PR comments 2026-04-07 21:14:52 +03:00
Tiago Farto 5a17d4144d fix: normalize storage result typing for web build 2026-04-07 19:07:15 +03:00
Tiago Farto 65c9db86c6 fix: separate storage type exports and imports 2026-04-07 18:04:27 +03:00
Tiago Farto bc94d34d1e fix: narrow storage route results by property 2026-04-07 17:41:13 +03:00
Tiago Farto 22be60a0ba fix: align storage type exports for web build 2026-04-07 17:18:53 +03:00
Tiago Farto a384963863 fix: type storage delete wrappers 2026-04-07 16:34:51 +03:00
Tiago Farto c067ae73bb fix: narrow storage delete result in route 2026-04-07 16:25:36 +03:00
Tiago Farto dc78a30cbe fix: repair pnpm lockfile for BullMQ branch 2026-04-07 16:13:17 +03:00
Tiago Farto 9c9ae8a3a2 test: fix env test on v5 branch 2026-04-07 16:01:21 +03:00
Tiago Farto 29a08151aa chore: addressed PR concerns 2026-04-07 15:59:20 +03:00
Tiago Farto f42a8822a9 chore: background workers trough bullMQ 2026-04-07 15:56:12 +03:00
325 changed files with 22254 additions and 7556 deletions
+12
View File
@@ -32,6 +32,18 @@ CRON_SECRET=
# Set the minimum log level(debug, info, warn, error, fatal)
LOG_LEVEL=info
# BullMQ workers require REDIS_URL (for example `redis://localhost:6379`) to be set.
# BullMQ worker startup is enabled by default outside tests. Set to 0 to disable.
# BULLMQ_WORKER_ENABLED=1
# Set to 1 on web/API pods that only enqueue jobs while a separate BullMQ worker deployment consumes them.
# BULLMQ_EXTERNAL_WORKER_ENABLED=0
# Number of BullMQ worker instances started per Formbricks server process.
# BULLMQ_WORKER_COUNT=1
# Number of concurrent jobs each BullMQ worker can process.
# BULLMQ_WORKER_CONCURRENCY=1
##############
# DATABASE #
##############
+1 -1
View File
@@ -23,7 +23,7 @@
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.17",
"storybook": "10.2.17",
"vite": "7.3.1",
"vite": "7.3.2",
"@storybook/addon-docs": "10.2.17"
}
}
@@ -429,16 +429,22 @@ export const MainNavigation = ({
: `/workspaces/${workspace.id}/surveys/`;
const handleWorkspaceChange = (workspaceId: string) => {
if (workspaceId === workspace.id) return;
const targetPath =
workspaceId === workspace.id ? `/workspaces/${workspace.id}/surveys` : `/workspaces/${workspaceId}/`;
startTransition(() => {
router.push(`/workspaces/${workspaceId}/`);
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
});
};
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === organization.id) return;
const targetPath =
organizationId === organization.id
? `/workspaces/${workspace.id}/settings/general`
: `/organizations/${organizationId}/`;
startTransition(() => {
router.push(`/organizations/${organizationId}/`);
setIsOrganizationDropdownOpen(false);
router.push(targetPath);
});
};
@@ -495,7 +501,7 @@ export const MainNavigation = ({
);
const switcherIconClasses =
"flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-600";
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600";
const isInitialWorkspacesLoading =
isWorkspaceDropdownOpen && !hasInitializedWorkspaces && !workspaceLoadError;
@@ -117,8 +117,12 @@ export const OrganizationBreadcrumb = ({
const workspaceBasePath = `/workspaces/${workspace?.id}`;
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === currentOrganizationId) return;
startTransition(() => {
setIsOrganizationDropdownOpen(false);
if (organizationId === currentOrganizationId && currentWorkspaceId) {
router.push(`/workspaces/${currentWorkspaceId}/settings/general`);
return;
}
router.push(`/organizations/${organizationId}/`);
});
};
@@ -158,9 +158,13 @@ export const WorkspaceBreadcrumb = ({
}
const handleWorkspaceChange = (workspaceId: string) => {
if (workspaceId === currentWorkspaceId) return;
const targetPath =
workspaceId === currentWorkspaceId
? `/workspaces/${currentWorkspaceId}/surveys`
: `/workspaces/${workspaceId}/`;
startTransition(() => {
router.push(`/workspaces/${workspaceId}/`);
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
});
};
@@ -0,0 +1,51 @@
"use client";
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryCes } from "@formbricks/types/surveys/types";
import { RatingLikeSummary } from "./RatingLikeSummary";
interface CESSummaryProps {
elementSummary: TSurveyElementSummaryCes;
survey: TSurvey;
setFilter: (
elementId: string,
label: TI18nString,
elementType: TSurveyElementTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
}
export const CESSummary = ({ elementSummary, survey, setFilter }: CESSummaryProps) => {
const { t } = useTranslation();
const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
}, [elementSummary.element.scale]);
return (
<RatingLikeSummary
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
additionalInfo={
<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("workspace.surveys.summary.effort_score")}: {elementSummary.average.toFixed(2)} /{" "}
{elementSummary.element.range}
</div>
</div>
</div>
}
/>
);
};
@@ -0,0 +1,72 @@
"use client";
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryCsat } from "@formbricks/types/surveys/types";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { RatingLikeSummary } from "./RatingLikeSummary";
import { SatisfactionIndicator } from "./SatisfactionIndicator";
interface CSATSummaryProps {
elementSummary: TSurveyElementSummaryCsat;
survey: TSurvey;
setFilter: (
elementId: string,
label: TI18nString,
elementType: TSurveyElementTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
}
export const CSATSummary = ({ elementSummary, survey, setFilter }: CSATSummaryProps) => {
const { t } = useTranslation();
const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
}, [elementSummary.element.scale]);
return (
<RatingLikeSummary
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
additionalInfo={
<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("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
</div>
</div>
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.csat.satisfiedPercentage} />
<div>
{t("workspace.surveys.summary.csat_satisfied", {
percentage: elementSummary.csat.satisfiedPercentage,
})}
</div>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{t("workspace.surveys.summary.csat_satisfied_tooltip", {
percentage: elementSummary.csat.satisfiedPercentage,
})}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
/>
);
};
@@ -8,7 +8,7 @@ import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryNps } from "@formbricks/types/surveys/types";
import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { convertFloatToNDecimal } from "../lib/utils";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
@@ -39,6 +39,7 @@ const calculateNPSOpacity = (rating: number): number => {
export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
const promotersPercentage = convertFloatToNDecimal(elementSummary.promoters.percentage, 2);
const applyFilter = (group: string) => {
const filters = {
@@ -81,13 +82,23 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
elementSummary={elementSummary}
survey={survey}
additionalInfo={
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.promoters.percentage} />
<div>
{t("workspace.surveys.summary.promoters")}:{" "}
{convertFloatToNDecimal(elementSummary.promoters.percentage, 2)}%
</div>
</div>
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.promoters.percentage} />
<div>
{t("workspace.surveys.summary.promoters")}: {promotersPercentage}%
</div>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{t("workspace.surveys.summary.nps_promoters_tooltip", {
percentage: promotersPercentage,
})}
</TooltipContent>
</Tooltip>
</TooltipProvider>
}
/>
@@ -0,0 +1,214 @@
"use client";
import { BarChart, BarChartHorizontal } from "lucide-react";
import { type JSX, useState } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
TSurvey,
TSurveyElementSummaryCes,
TSurveyElementSummaryCsat,
TSurveyElementSummaryRating,
} from "@formbricks/types/surveys/types";
import { convertFloatToNDecimal } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
import { RatingScaleLegend } from "./RatingScaleLegend";
type RatingLikeElementSummary =
| TSurveyElementSummaryCes
| TSurveyElementSummaryCsat
| TSurveyElementSummaryRating;
interface RatingLikeSummaryProps {
elementSummary: RatingLikeElementSummary;
survey: TSurvey;
setFilter: (
elementId: string,
label: TI18nString,
elementType: TSurveyElementTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
additionalInfo: JSX.Element;
}
export const RatingLikeSummary = ({
elementSummary,
survey,
setFilter,
additionalInfo,
}: RatingLikeSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} additionalInfo={additionalInfo} />
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("workspace.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("workspace.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
{elementSummary.responseCount === 0 ? (
<>
<EmptyState text={t("workspace.surveys.summary.no_responses_found")} variant="simple" />
<RatingScaleLegend
scale={elementSummary.element.scale}
range={elementSummary.element.range}
/>
</>
) : (
<>
<TooltipProvider delayDuration={200}>
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
{elementSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
const range = elementSummary.element.range;
const opacity = 0.3 + (result.rating / range) * 0.7;
const isFirst = index === 0;
const isLast = index === elementSummary.choices.length - 1;
return (
<ClickableBarSegment
key={result.rating}
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
style={{
width: `${result.percentage}%`,
borderRight: isLast ? "none" : "1px solid rgb(226, 232, 240)",
}}
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
t("workspace.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }}
/>
</ClickableBarSegment>
);
})}
</div>
</TooltipProvider>
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
{elementSummary.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 < elementSummary.choices.length - 1
? "1px solid rgb(226, 232, 240)"
: "none",
}}>
<div className="mb-1 flex items-center justify-center">
<RatingResponse
scale={elementSummary.element.scale}
answer={result.rating}
range={elementSummary.element.range}
addColors={false}
variant="aggregated"
/>
</div>
<div className="text-xs font-medium text-slate-600">
{convertFloatToNDecimal(result.percentage, 1)}%
</div>
</div>
);
})}
</div>
<RatingScaleLegend
scale={elementSummary.element.scale}
range={elementSummary.element.range}
/>
</>
)}
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base">
{elementSummary.choices.map((result) => (
<div key={result.rating}>
<button
className="w-full cursor-pointer hover:opacity-80"
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
t("workspace.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={elementSummary.element.scale}
answer={result.rating}
range={elementSummary.element.range}
addColors={elementSummary.element.isColorCodingEnabled}
variant="individual"
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: result.count })}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
{elementSummary.dismissed && elementSummary.dismissed.count > 0 && (
<div className="rounded-b-lg border-t bg-white px-6 py-4">
<div key="dismissed">
<div className="text flex justify-between px-2">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.dismissed.count })}
</p>
</div>
</div>
</div>
)}
</div>
);
};
@@ -1,21 +1,12 @@
"use client";
import { BarChart, BarChartHorizontal, CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryRating } from "@formbricks/types/surveys/types";
import { convertFloatToNDecimal } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
import { RatingScaleLegend } from "./RatingScaleLegend";
import { SatisfactionIndicator } from "./SatisfactionIndicator";
import { RatingLikeSummary } from "./RatingLikeSummary";
interface RatingSummaryProps {
elementSummary: TSurveyElementSummaryRating;
@@ -31,196 +22,29 @@ interface RatingSummaryProps {
export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
const getIconBasedOnScale = useMemo(() => {
const scale = elementSummary.element.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
}, [elementSummary]);
}, [elementSummary.element.scale]);
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<ElementSummaryHeader
elementSummary={elementSummary}
survey={survey}
additionalInfo={
<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("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
</div>
</div>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={elementSummary.csat.satisfiedPercentage} />
<div>
CSAT: {elementSummary.csat.satisfiedPercentage}% {t("workspace.surveys.summary.satisfied")}
</div>
</div>
</div>
}
/>
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
<div className="flex justify-end px-4 md:px-6">
<TabsList>
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
{t("workspace.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("workspace.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
{elementSummary.responseCount === 0 ? (
<>
<EmptyState text={t("workspace.surveys.summary.no_responses_found")} variant="simple" />
<RatingScaleLegend
scale={elementSummary.element.scale}
range={elementSummary.element.range}
/>
</>
) : (
<>
<TooltipProvider delayDuration={200}>
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
{elementSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
const range = elementSummary.element.range;
const opacity = 0.3 + (result.rating / range) * 0.8;
const isFirst = index === 0;
const isLast = index === elementSummary.choices.length - 1;
return (
<ClickableBarSegment
key={result.rating}
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
style={{
width: `${result.percentage}%`,
borderRight: isLast ? "none" : "1px solid rgb(226, 232, 240)",
}}
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
t("workspace.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }}
/>
</ClickableBarSegment>
);
})}
</div>
</TooltipProvider>
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
{elementSummary.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 < elementSummary.choices.length - 1
? "1px solid rgb(226, 232, 240)"
: "none",
}}>
<div className="mb-1 flex items-center justify-center">
<RatingResponse
scale={elementSummary.element.scale}
answer={result.rating}
range={elementSummary.element.range}
addColors={false}
variant="aggregated"
/>
</div>
<div className="text-xs font-medium text-slate-600">
{convertFloatToNDecimal(result.percentage, 1)}%
</div>
</div>
);
})}
</div>
<RatingScaleLegend
scale={elementSummary.element.scale}
range={elementSummary.element.range}
/>
</>
)}
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base">
{elementSummary.choices.map((result) => (
<div key={result.rating}>
<button
className="w-full cursor-pointer hover:opacity-80"
onClick={() =>
setFilter(
elementSummary.element.id,
elementSummary.element.headline,
elementSummary.element.type,
t("workspace.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={elementSummary.element.scale}
answer={result.rating}
range={elementSummary.element.range}
addColors={elementSummary.element.isColorCodingEnabled}
variant="individual"
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: result.count })}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
</div>
))}
</div>
</div>
</TabsContent>
</Tabs>
{elementSummary.dismissed && elementSummary.dismissed.count > 0 && (
<div className="rounded-b-lg border-t bg-white px-6 py-4">
<div key="dismissed">
<div className="text flex justify-between px-2">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.dismissed.count })}
</p>
<RatingLikeSummary
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
additionalInfo={
<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("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
</div>
</div>
</div>
)}
</div>
}
/>
);
};
@@ -13,6 +13,8 @@ import {
SelectedFilterValue,
useResponseFilter,
} from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { CESSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CESSummary";
import { CSATSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CSATSummary";
import { CTASummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary";
import { CalSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary";
import { ConsentSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary";
@@ -156,6 +158,26 @@ export const SummaryList = ({ summary, responseCount, survey, locale }: SummaryL
/>
);
}
if (elementSummary.type === TSurveyElementTypeEnum.CSAT) {
return (
<CSATSummary
key={elementSummary.element.id}
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
/>
);
}
if (elementSummary.type === TSurveyElementTypeEnum.CES) {
return (
<CESSummary
key={elementSummary.element.id}
elementSummary={elementSummary}
survey={survey}
setFilter={setFilter}
/>
);
}
if (elementSummary.type === TSurveyElementTypeEnum.Consent) {
return (
<ConsentSummary
@@ -4578,3 +4578,611 @@ describe("Cal question type tests", () => {
expect(summary[0].skipped.count).toBe(1); // Counted as skipped
});
});
describe("CSAT question type tests", () => {
test("getElementSummary correctly processes CSAT question with valid responses", async () => {
const question = {
id: "csat-q1",
type: TSurveyElementTypeEnum.CSAT,
headline: { default: "How satisfied are you?" },
required: true,
scale: "smiley",
range: 5,
lowerLabel: { default: "Very unsatisfied" },
upperLabel: { default: "Very satisfied" },
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "csat-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-2",
data: { "csat-q1": 4 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-3",
data: { "csat-q1": 2 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-4",
data: { "csat-q1": 1 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "csat-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CSAT);
expect(summary[0].responseCount).toBe(4);
// Average = (5 + 4 + 2 + 1) / 4 = 3.0
expect(summary[0].average).toBe(3);
// CSAT: satisfied = ratings 4 + 5 = 2 out of 4
expect(summary[0].csat.satisfiedCount).toBe(2);
expect(summary[0].csat.satisfiedPercentage).toBe(50);
// Verify choice distribution
const rating5 = summary[0].choices.find((c: any) => c.rating === 5);
expect(rating5.count).toBe(1);
expect(rating5.percentage).toBe(25);
const rating4 = summary[0].choices.find((c: any) => c.rating === 4);
expect(rating4.count).toBe(1);
expect(rating4.percentage).toBe(25);
const rating2 = summary[0].choices.find((c: any) => c.rating === 2);
expect(rating2.count).toBe(1);
expect(rating2.percentage).toBe(25);
const rating1 = summary[0].choices.find((c: any) => c.rating === 1);
expect(rating1.count).toBe(1);
expect(rating1.percentage).toBe(25);
expect(summary[0].dismissed.count).toBe(0);
});
test("getElementSummary handles CSAT question with dismissed responses", async () => {
const question = {
id: "csat-q1",
type: TSurveyElementTypeEnum.CSAT,
headline: { default: "How satisfied are you?" },
required: false,
scale: "smiley",
range: 5,
lowerLabel: { default: "Very unsatisfied" },
upperLabel: { default: "Very satisfied" },
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "csat-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "csat-q1": 3 },
finished: true,
},
{
id: "response-2",
data: {},
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "csat-q1": 2 },
finished: true,
},
{
id: "response-3",
data: {},
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "csat-q1": 4 },
finished: true,
},
] as any;
const dropOff = [
{ elementId: "csat-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CSAT);
expect(summary[0].responseCount).toBe(1);
expect(summary[0].average).toBe(5);
expect(summary[0].dismissed.count).toBe(2);
expect(summary[0].csat.satisfiedCount).toBe(1);
expect(summary[0].csat.satisfiedPercentage).toBe(100);
});
test("getElementSummary handles CSAT question with no valid responses", async () => {
const question = {
id: "csat-q1",
type: TSurveyElementTypeEnum.CSAT,
headline: { default: "How satisfied are you?" },
required: true,
scale: "smiley",
range: 5,
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "other-q": "value" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "csat-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CSAT);
expect(summary[0].responseCount).toBe(0);
expect(summary[0].average).toBe(0);
expect(summary[0].csat.satisfiedCount).toBe(0);
expect(summary[0].csat.satisfiedPercentage).toBe(0);
expect(summary[0].choices.every((c: any) => c.count === 0)).toBe(true);
});
test("getElementSummary CSAT correctly identifies satisfied ratings (4 and 5 only)", async () => {
const question = {
id: "csat-q1",
type: TSurveyElementTypeEnum.CSAT,
headline: { default: "How satisfied are you?" },
required: true,
scale: "smiley",
range: 5,
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
// 3 satisfied (4,5,5), 2 not satisfied (1,3)
const responses = [
{
id: "r1",
data: { "csat-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r2",
data: { "csat-q1": 4 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r3",
data: { "csat-q1": 3 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r4",
data: { "csat-q1": 1 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r5",
data: { "csat-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "csat-q1", impressions: 5, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
// satisfied = ratings 4 and 5 = 3 out of 5
expect(summary[0].csat.satisfiedCount).toBe(3);
expect(summary[0].csat.satisfiedPercentage).toBe(60);
});
});
describe("CES question type tests", () => {
test("getElementSummary correctly processes CES question with valid responses (range 5)", async () => {
const question = {
id: "ces-q1",
type: TSurveyElementTypeEnum.CES,
headline: { default: "How easy was it?" },
required: true,
scale: "number",
range: 5,
lowerLabel: { default: "Very difficult" },
upperLabel: { default: "Very easy" },
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "ces-q1": 5 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-2",
data: { "ces-q1": 4 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-3",
data: { "ces-q1": 2 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "response-4",
data: { "ces-q1": 3 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "ces-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
expect(summary[0].responseCount).toBe(4);
// CES = average = (5 + 4 + 2 + 3) / 4 = 3.5
expect(summary[0].average).toBe(3.5);
// Verify choice distribution
const rating5 = summary[0].choices.find((c: any) => c.rating === 5);
expect(rating5.count).toBe(1);
expect(rating5.percentage).toBe(25);
const rating4 = summary[0].choices.find((c: any) => c.rating === 4);
expect(rating4.count).toBe(1);
expect(rating4.percentage).toBe(25);
const rating3 = summary[0].choices.find((c: any) => c.rating === 3);
expect(rating3.count).toBe(1);
expect(rating3.percentage).toBe(25);
const rating2 = summary[0].choices.find((c: any) => c.rating === 2);
expect(rating2.count).toBe(1);
expect(rating2.percentage).toBe(25);
expect(summary[0].dismissed.count).toBe(0);
// CES has no csat field
expect(summary[0].csat).toBeUndefined();
});
test("getElementSummary correctly processes CES question with range 7", async () => {
const question = {
id: "ces-q1",
type: TSurveyElementTypeEnum.CES,
headline: { default: "How easy was it?" },
required: true,
scale: "number",
range: 7,
lowerLabel: { default: "Very difficult" },
upperLabel: { default: "Very easy" },
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "r1",
data: { "ces-q1": 7 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r2",
data: { "ces-q1": 6 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
{
id: "r3",
data: { "ces-q1": 1 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "ces-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
expect(summary[0].responseCount).toBe(3);
// CES average = (7 + 6 + 1) / 3 = 4.67
expect(summary[0].average).toBe(4.67);
// Verify 7 choices exist (range 7)
expect(summary[0].choices).toHaveLength(7);
});
test("getElementSummary handles CES question with dismissed responses", async () => {
const question = {
id: "ces-q1",
type: TSurveyElementTypeEnum.CES,
headline: { default: "How easy was it?" },
required: false,
scale: "number",
range: 5,
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "ces-q1": 3 },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "ces-q1": 5 },
finished: true,
},
{
id: "response-2",
data: {},
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: { "ces-q1": 2 },
finished: true,
},
] as any;
const dropOff = [
{ elementId: "ces-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
expect(summary[0].responseCount).toBe(1);
expect(summary[0].average).toBe(3);
expect(summary[0].dismissed.count).toBe(1);
});
test("getElementSummary handles CES question with no valid responses", async () => {
const question = {
id: "ces-q1",
type: TSurveyElementTypeEnum.CES,
headline: { default: "How easy was it?" },
required: true,
scale: "number",
range: 5,
};
const survey = {
id: "survey-1",
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
questions: [],
languages: [],
welcomeCard: { enabled: false },
} as unknown as TSurvey;
const responses = [
{
id: "response-1",
data: { "other-q": "value" },
updatedAt: new Date(),
contact: null,
contactAttributes: {},
language: null,
ttc: {},
finished: true,
},
];
const dropOff = [
{ elementId: "ces-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
] as unknown as TSurveySummary["dropOff"];
const summary: any = await getElementSummary(
survey,
getElementsFromBlocks(survey.blocks),
responses,
dropOff
);
expect(summary).toHaveLength(1);
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
expect(summary[0].responseCount).toBe(0);
expect(summary[0].average).toBe(0);
expect(summary[0].choices.every((c: any) => c.count === 0)).toBe(true);
});
});
@@ -25,7 +25,6 @@ import {
TSurveyElementSummaryOpenText,
TSurveyElementSummaryPictureSelection,
TSurveyElementSummaryRanking,
TSurveyElementSummaryRating,
TSurveyLanguage,
TSurveySummary,
} from "@formbricks/types/surveys/types";
@@ -272,6 +271,49 @@ const checkForI18n = (
return responseData[id];
};
const computeNumericScaleStats = (
elementId: string,
range: number,
responses: TSurveySummaryResponse[]
): {
choices: { rating: number; count: number; percentage: number }[];
choiceCountMap: Record<number, number>;
totalResponseCount: number;
totalRating: number;
dismissed: number;
average: number;
} => {
const choiceCountMap: Record<number, number> = {};
for (let i = 1; i <= range; i++) {
choiceCountMap[i] = 0;
}
let totalResponseCount = 0;
let totalRating = 0;
let dismissed = 0;
responses.forEach((response) => {
const answer = response.data[elementId];
if (typeof answer === "number") {
totalResponseCount++;
choiceCountMap[answer]++;
totalRating += answer;
} else if (response.ttc && response.ttc[elementId] > 0) {
dismissed++;
}
});
const choices = Object.entries(choiceCountMap).map(([label, count]) => ({
rating: Number.parseInt(label),
count,
percentage: totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
}));
const average = totalResponseCount > 0 ? convertFloatTo2Decimal(totalRating / totalResponseCount) : 0;
return { choices, choiceCountMap, totalResponseCount, totalRating, dismissed, average };
};
export const getElementSummary = async (
survey: TSurvey,
elements: TSurveyElement[],
@@ -472,72 +514,16 @@ export const getElementSummary = async (
break;
}
case TSurveyElementTypeEnum.Rating: {
let values: TSurveyElementSummaryRating["choices"] = [];
const choiceCountMap: Record<number, number> = {};
const range = element.range;
for (let i = 1; i <= range; i++) {
choiceCountMap[i] = 0;
}
let totalResponseCount = 0;
let totalRating = 0;
let dismissed = 0;
responses.forEach((response) => {
const answer = response.data[element.id];
if (typeof answer === "number") {
totalResponseCount++;
choiceCountMap[answer]++;
totalRating += answer;
} else if (response.ttc && response.ttc[element.id] > 0) {
dismissed++;
}
});
Object.entries(choiceCountMap).forEach(([label, count]) => {
values.push({
rating: Number.parseInt(label),
count,
percentage:
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
});
});
// Calculate CSAT based on range
let satisfiedCount = 0;
if (range === 3) {
satisfiedCount = choiceCountMap[3] || 0;
} else if (range === 4) {
satisfiedCount = (choiceCountMap[3] || 0) + (choiceCountMap[4] || 0);
} else if (range === 5) {
satisfiedCount = (choiceCountMap[4] || 0) + (choiceCountMap[5] || 0);
} else if (range === 6) {
satisfiedCount = (choiceCountMap[5] || 0) + (choiceCountMap[6] || 0);
} else if (range === 7) {
satisfiedCount = (choiceCountMap[6] || 0) + (choiceCountMap[7] || 0);
} else if (range === 10) {
satisfiedCount = (choiceCountMap[8] || 0) + (choiceCountMap[9] || 0) + (choiceCountMap[10] || 0);
}
const satisfiedPercentage =
totalResponseCount > 0 ? Math.round((satisfiedCount / totalResponseCount) * 100) : 0;
const stats = computeNumericScaleStats(element.id, element.range, responses);
summary.push({
type: element.type,
element,
average: convertFloatTo2Decimal(totalRating / totalResponseCount) || 0,
responseCount: totalResponseCount,
choices: values,
dismissed: {
count: dismissed,
},
csat: {
satisfiedCount,
satisfiedPercentage,
},
average: stats.average,
responseCount: stats.totalResponseCount,
choices: stats.choices,
dismissed: { count: stats.dismissed },
});
values = [];
break;
}
case TSurveyElementTypeEnum.NPS: {
@@ -612,6 +598,40 @@ export const getElementSummary = async (
});
break;
}
case TSurveyElementTypeEnum.CSAT: {
const stats = computeNumericScaleStats(element.id, element.range, responses);
// CSAT: top 2 ratings out of 5 are "satisfied"
const satisfiedCount = (stats.choiceCountMap[4] || 0) + (stats.choiceCountMap[5] || 0);
const satisfiedPercentage =
stats.totalResponseCount > 0
? convertFloatTo2Decimal((satisfiedCount / stats.totalResponseCount) * 100)
: 0;
summary.push({
type: element.type,
element,
average: stats.average,
responseCount: stats.totalResponseCount,
choices: stats.choices,
dismissed: { count: stats.dismissed },
csat: { satisfiedCount, satisfiedPercentage },
});
break;
}
case TSurveyElementTypeEnum.CES: {
const stats = computeNumericScaleStats(element.id, element.range, responses);
summary.push({
type: element.type,
element,
average: stats.average,
responseCount: stats.totalResponseCount,
choices: stats.choices,
dismissed: { count: stats.dismissed },
});
break;
}
case TSurveyElementTypeEnum.CTA: {
// Only calculate summary for CTA elements with external buttons (CTR tracking is only meaningful for external links)
if (!element.buttonExternal) {
@@ -0,0 +1,8 @@
import { type ReactNode } from "react";
import { SurveysQueryClientProvider } from "./query-client-provider";
const SurveysLayout = ({ children }: { children: ReactNode }) => {
return <SurveysQueryClientProvider>{children}</SurveysQueryClientProvider>;
};
export default SurveysLayout;
@@ -0,0 +1,10 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode, useState } from "react";
export const SurveysQueryClientProvider = ({ children }: { children: ReactNode }) => {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
@@ -4,7 +4,6 @@ import { v7 as uuidv7 } from "uuid";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry";
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
@@ -22,11 +21,12 @@ import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
import { sendResponseFinishedEmail } from "@/modules/email";
import { handleIntegrations } from "@/modules/response-pipeline/lib/handle-integrations";
import { captureSurveyResponsePostHogEvent } from "@/modules/response-pipeline/lib/posthog";
import { sendTelemetryEvents } from "@/modules/response-pipeline/lib/telemetry";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { handleIntegrations } from "./lib/handleIntegrations";
import { captureSurveyResponsePostHogEvent } from "./lib/posthog";
export const POST = async (request: Request) => {
const requestHeaders = await headers();
@@ -0,0 +1,98 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getResponseIdByDisplayId } from "./response";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn((inputs: [unknown, unknown][]) =>
inputs.map((input: [unknown, unknown]) => input[0])
),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
display: {
findFirst: vi.fn(),
},
},
}));
describe("getResponseIdByDisplayId", () => {
const workspaceId = "ws1234567890123456789012";
const displayId = "display1234567890123456789";
beforeEach(() => {
vi.clearAllMocks();
});
test("returns the linked responseId when a response exists", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue({
response: {
id: "response123456789012345678",
},
} as any);
const result = await getResponseIdByDisplayId(workspaceId, displayId);
expect(validateInputs).toHaveBeenCalledWith(
[workspaceId, expect.any(Object)],
[displayId, expect.any(Object)]
);
expect(prisma.display.findFirst).toHaveBeenCalledWith({
where: {
id: displayId,
survey: {
workspaceId,
},
},
select: {
response: {
select: {
id: true,
},
},
},
});
expect(result).toEqual({ responseId: "response123456789012345678" });
});
test("returns null when the display exists but has no response", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue({
response: null,
} as any);
await expect(getResponseIdByDisplayId(workspaceId, displayId)).resolves.toEqual({
responseId: null,
});
});
test("throws ResourceNotFoundError when the display does not exist in the workspace", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue(null);
await expect(getResponseIdByDisplayId(workspaceId, displayId)).rejects.toThrow(
new ResourceNotFoundError("Display", displayId)
);
});
test("throws ValidationError when input validation fails", async () => {
const validationError = new ValidationError("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(getResponseIdByDisplayId(workspaceId, displayId)).rejects.toThrow(ValidationError);
expect(prisma.display.findFirst).not.toHaveBeenCalled();
});
test("throws DatabaseError on Prisma request errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "test",
});
vi.mocked(prisma.display.findFirst).mockRejectedValue(prismaError);
await expect(getResponseIdByDisplayId(workspaceId, displayId)).rejects.toThrow(DatabaseError);
});
});
@@ -0,0 +1,44 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const getResponseIdByDisplayId = async (
workspaceId: string,
displayId: string
): Promise<{ responseId: string | null }> => {
validateInputs([workspaceId, ZId], [displayId, ZId]);
try {
const display = await prisma.display.findFirst({
where: {
id: displayId,
survey: {
workspaceId,
},
},
select: {
response: {
select: {
id: true,
},
},
},
});
if (!display) {
throw new ResourceNotFoundError("Display", displayId);
}
return {
responseId: display.response?.id ?? null,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -0,0 +1,49 @@
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
import { getResponseIdByDisplayId } from "./lib/response";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: THandlerParams<{ params: Promise<{ workspaceId: string; displayId: string }> }>) => {
const params = await props.params;
const resolved = await resolveClientApiIds(params.workspaceId);
if (!resolved) {
return {
response: responses.notFoundResponse("Workspace", params.workspaceId, true),
};
}
const { workspaceId } = resolved;
try {
const response = await getResponseIdByDisplayId(workspaceId, params.displayId);
return {
response: responses.successResponse(response, true),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Display", params.displayId, true),
};
}
logger.error(
{ error, url: req.url, workspaceId, displayId: params.displayId },
"Error in GET /api/v1/client/[workspaceId]/displays/[displayId]/response"
);
return {
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
};
}
},
});
@@ -33,10 +33,13 @@ vi.mock("@formbricks/logger", () => ({
vi.mock("./data");
vi.mock("@/app/lib/api/api-backwards-compat", () => ({
addLegacyProjectOverwritesToList: vi.fn((surveys: unknown[]) =>
surveys.map((s: Record<string, unknown>) => ({
...s,
projectOverwrites: s.workspaceOverwrites ?? null,
}))
surveys.map((survey) => {
const typedSurvey = survey as Record<string, unknown>;
return {
...typedSurvey,
projectOverwrites: typedSurvey.workspaceOverwrites ?? null,
};
})
),
addLegacyProjectToEnvironmentState: vi.fn((data: Record<string, unknown>) => ({
...data,
@@ -126,7 +126,6 @@ export const POST = withV1ApiWrapper({
response: responses.badRequestResponse(
"Survey is part of another workspace",
{
"survey.workspaceId": survey.workspaceId,
workspaceId,
},
true
+22 -31
View File
@@ -15,20 +15,14 @@ const apiKeySelect = {
lastUsedAt: true,
apiKeyWorkspaces: {
select: {
environment: {
workspace: {
select: {
id: true,
type: true,
legacyEnvironmentId: true,
createdAt: true,
updatedAt: true,
workspaceId: true,
name: true,
appSetupCompleted: true,
workspace: {
select: {
id: true,
name: true,
},
},
},
},
permission: true,
@@ -44,17 +38,13 @@ type ApiKeyData = {
lastUsedAt: Date | null;
apiKeyWorkspaces: Array<{
permission: string;
environment: {
workspace: {
id: string;
type: string;
legacyEnvironmentId: string | null;
createdAt: Date;
updatedAt: Date;
workspaceId: string;
name: string;
appSetupCompleted: boolean;
workspace: {
id: string;
name: string;
};
};
}>;
};
@@ -116,21 +106,24 @@ const updateApiKeyUsage = async (apiKeyId: string) => {
});
};
const buildEnvironmentResponse = (apiKeyData: ApiKeyData) => {
const env = apiKeyData.apiKeyWorkspaces[0].environment;
const buildWorkspaceResponse = (apiKeyData: ApiKeyData) => {
const workspace = apiKeyData.apiKeyWorkspaces[0].workspace;
return Response.json({
id: env.id,
type: env.type,
createdAt: env.createdAt,
updatedAt: env.updatedAt,
appSetupCompleted: env.appSetupCompleted,
// Keep v1 payload shape stable while sourcing data from workspace.
id: workspace.legacyEnvironmentId ?? workspace.id,
type: "production",
createdAt: workspace.createdAt,
updatedAt: workspace.updatedAt,
appSetupCompleted: workspace.appSetupCompleted,
workspace: {
id: env.workspaceId,
name: env.workspace.name,
id: workspace.id,
name: workspace.name,
},
// Backwards compat: old consumers expect project fields
projectId: env.workspaceId,
projectName: env.workspace.name,
project: {
id: workspace.id,
name: workspace.name,
},
});
};
@@ -157,14 +150,12 @@ const handleApiKeyAuthentication = async (apiKey: string) => {
});
}
const rateLimitError = await checkRateLimit(apiKeyData.id);
if (rateLimitError) return rateLimitError;
// Rate limiting for apiKey auth is enforced by Envoy in v5 — see envoy-rate-limit-coverage.ts
if (!isValidApiKeyEnvironment(apiKeyData)) {
return responses.badRequestResponse("You can't use this method with this API key");
}
return buildEnvironmentResponse(apiKeyData);
return buildWorkspaceResponse(apiKeyData);
};
const handleSessionAuthentication = async () => {
@@ -73,7 +73,6 @@ const validateSurvey = async (responseInput: TResponseInput, workspaceId: string
error: responses.badRequestResponse(
"Survey is part of another workspace",
{
"survey.workspaceId": survey.workspaceId,
workspaceId,
},
true
@@ -1,43 +1,16 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { deleteSurvey } from "./surveys";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
const { mockDeleteSharedSurvey } = vi.hoisted(() => ({
mockDeleteSharedSurvey: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
delete: vi.fn(),
},
segment: {
delete: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: mockDeleteSharedSurvey,
}));
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
const workspaceId = "clq5n7p1q0000m7z0h5p6g3r3";
const segmentId = "clq5n7p1q0000m7z0h5p6g3r4";
const actionClassId1 = "clq5n7p1q0000m7z0h5p6g3r5";
const actionClassId2 = "clq5n7p1q0000m7z0h5p6g3r6";
const mockDeletedSurveyAppPrivateSegment = {
id: surveyId,
workspaceId,
type: "app",
segment: { id: segmentId, isPrivate: true },
triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }],
};
const mockDeletedSurveyLink = {
id: surveyId,
@@ -56,66 +29,20 @@ describe("deleteSurvey", () => {
vi.clearAllMocks();
});
test("should delete a link survey without a segment and revalidate caches", async () => {
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyLink as any);
test("delegates survey deletion to the shared service", async () => {
mockDeleteSharedSurvey.mockResolvedValue(mockDeletedSurveyLink);
const deletedSurvey = await deleteSurvey(surveyId);
expect(validateInputs).toHaveBeenCalledWith([surveyId, expect.any(Object)]);
expect(prisma.survey.delete).toHaveBeenCalledWith({
where: { id: surveyId },
include: {
segment: true,
triggers: { include: { actionClass: true } },
},
});
expect(prisma.segment.delete).not.toHaveBeenCalled();
expect(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
expect(deletedSurvey).toEqual(mockDeletedSurveyLink);
});
test("should handle PrismaClientKnownRequestError during survey deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should handle PrismaClientKnownRequestError during segment deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Foreign key constraint failed", {
code: "P2003",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyAppPrivateSegment as any);
vi.mocked(prisma.segment.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).toHaveBeenCalledWith({ where: { id: segmentId } });
});
test("should handle generic errors during deletion", async () => {
test("rethrows shared delete service errors", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.survey.delete).mockRejectedValue(genericError);
mockDeleteSharedSurvey.mockRejectedValue(genericError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled();
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should throw validation error for invalid surveyId", async () => {
const invalidSurveyId = "invalid-id";
const validationError = new Error("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(deleteSurvey(invalidSurveyId)).rejects.toThrow(validationError);
expect(prisma.survey.delete).not.toHaveBeenCalled();
expect(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
});
});
@@ -1,43 +1,3 @@
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { deleteSurvey as deleteSharedSurvey } from "@/modules/survey/lib/surveys";
export const deleteSurvey = async (surveyId: string) => {
validateInputs([surveyId, z.cuid2()]);
try {
const deletedSurvey = await prisma.survey.delete({
where: {
id: surveyId,
},
include: {
segment: true,
triggers: {
include: {
actionClass: true,
},
},
},
});
if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) {
await prisma.segment.delete({
where: {
id: deletedSurvey.segment.id,
},
});
}
return deletedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error({ error, surveyId }, "Error deleting survey");
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deleteSurvey = async (surveyId: string) => deleteSharedSurvey(surveyId);
@@ -1,5 +1,6 @@
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
@@ -78,6 +79,12 @@ export const GET = withV1ApiWrapper({
),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Survey", params.surveyId),
};
}
return {
response: handleErrorResponse(error),
};
@@ -1,141 +0,0 @@
import * as Sentry from "@sentry/nextjs";
import { type NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
applyIPRateLimit: vi.fn(),
getWorkspaceState: vi.fn(),
resolveClientApiIds: vi.fn(),
contextualLoggerError: vi.fn(),
}));
vi.mock("@/app/api/v1/client/[workspaceId]/environment/lib/environmentState", () => ({
getWorkspaceState: mocks.getWorkspaceState,
}));
vi.mock("@/lib/utils/resolve-client-id", () => ({
resolveClientApiIds: mocks.resolveClientApiIds,
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: mocks.applyIPRateLimit,
applyRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
client: { windowMs: 60000, max: 100 },
v1: { windowMs: 60000, max: 1000 },
},
},
}));
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: mocks.contextualLoggerError,
warn: vi.fn(),
info: vi.fn(),
})),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
AUDIT_LOG_ENABLED: false,
IS_PRODUCTION: true,
SENTRY_DSN: "test-dsn",
ENCRYPTION_KEY: "test-key",
REDIS_URL: "redis://localhost:6379",
};
});
const createMockRequest = (url: string, headers = new Map<string, string>()): NextRequest => {
const parsedUrl = new URL(url);
return {
method: "GET",
url,
headers: {
get: (key: string) => headers.get(key),
},
nextUrl: {
pathname: parsedUrl.pathname,
},
} as unknown as NextRequest;
};
describe("api/v2 client environment route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.applyIPRateLimit.mockResolvedValue(undefined);
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId: "ck12345678901234567890123" });
});
test("reports v1-backed failures as v2 and keeps the response payload unchanged", async () => {
const underlyingError = new Error("Environment load failed");
mocks.getWorkspaceState.mockRejectedValue(underlyingError);
const request = createMockRequest(
"https://api.test/api/v2/client/ck12345678901234567890123/environment",
new Map([["x-request-id", "req-v2-env"]])
);
const { GET } = await import("../../../../v1/client/[workspaceId]/environment/route");
const response = await GET(request, {
params: Promise.resolve({
workspaceId: "ck12345678901234567890123",
}),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "An error occurred while processing your request.",
details: {},
});
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
underlyingError,
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-v2-env",
method: "GET",
path: "/api/v2/client/ck12345678901234567890123/environment",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
name: "Error",
message: "Environment load failed",
}),
originalError: expect.objectContaining({
name: "Error",
message: "Environment load failed",
}),
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-v2-env",
method: "GET",
path: "/api/v2/client/ck12345678901234567890123/environment",
status: 500,
}),
}),
})
);
});
});
@@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
import { TResponse } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -178,10 +178,34 @@ describe("createResponse V2", () => {
).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
test("should throw UniqueConstraintError on P2002 with singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["surveyId", "singleUseId"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(UniqueConstraintError);
});
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
meta: { target: ["displayId"] },
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
).rejects.toThrow(DatabaseError);
});
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
clientVersion: "test",
});
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
await expect(
@@ -2,7 +2,7 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { TTag } from "@formbricks/types/tags";
@@ -131,6 +131,13 @@ export const createResponse = async (
return response;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
const target = (error.meta?.target as string[]) ?? [];
if (target?.includes("singleUseId")) {
throw new UniqueConstraintError("Response already submitted for this single-use link");
}
}
throw new DatabaseError(error.message);
}
@@ -92,6 +92,7 @@ const mockSurvey: TSurvey = {
isCaptureIpEnabled: false,
metadata: {},
slug: null,
isAutoProgressingEnabled: true,
};
const mockResponseInput: TResponseInputV2 = {
@@ -126,7 +127,6 @@ describe("checkSurveyValidity", () => {
expect(responses.badRequestResponse).toHaveBeenCalledWith(
"Survey is part of another workspace",
{
"survey.workspaceId": "ws-2",
workspaceId: "ws-1",
},
true
@@ -20,7 +20,6 @@ export const checkSurveyValidity = async (
return responses.badRequestResponse(
"Survey is part of another workspace",
{
"survey.workspaceId": survey.workspaceId,
workspaceId,
},
true
@@ -1,5 +1,5 @@
import { UAParser } from "ua-parser-js";
import { InvalidInputError } from "@formbricks/types/errors";
import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[workspaceId]/responses/lib/utils";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
@@ -164,6 +164,10 @@ const createResponseForRequest = async ({
return responses.badRequestResponse(error.message, undefined, true);
}
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message, undefined, true);
}
const response = getUnexpectedPublicErrorResponse();
reportApiError({
request,
+132
View File
@@ -9,6 +9,22 @@ const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
vi.mock("next-auth", () => ({
getServerSession: mockGetServerSession,
}));
@@ -25,6 +41,14 @@ vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
@@ -45,6 +69,114 @@ describe("withV3ApiWrapper", () => {
vi.clearAllMocks();
});
test("passes an audit log to the handler and queues success after the response", async () => {
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1", name: "Test", email: "t@example.com" },
expires: "2026-01-01",
});
const handler = vi.fn(async ({ auditLog }) => {
expect(auditLog).toEqual(
expect.objectContaining({
action: "deleted",
targetType: "survey",
userId: "user_1",
userType: "user",
status: "failure",
})
);
if (auditLog) {
auditLog.targetId = "survey_1";
auditLog.organizationId = "org_1";
auditLog.oldObject = { id: "survey_1" };
}
return Response.json({ ok: true });
});
const wrapped = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys/survey_1", {
method: "DELETE",
headers: { "x-request-id": "req-audit" },
}),
{} as never
);
expect(response.status).toBe(200);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "survey_1",
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: { id: "survey_1" },
})
);
});
test("queues a failure audit log when the handler returns a non-ok response", async () => {
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
mockAuthenticateRequest.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
environmentPermissions: [],
});
const wrapped = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
handler: async ({ auditLog }) => {
if (auditLog) {
auditLog.targetId = "survey_2";
}
return new Response("forbidden", { status: 403 });
},
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys/survey_2", {
method: "DELETE",
headers: {
"x-request-id": "req-failure-audit",
"x-api-key": "fbk_test",
},
}),
{} as never
);
expect(response.status).toBe(403);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "survey_2",
organizationId: "org_1",
userId: "key_1",
userType: "api",
status: "failure",
eventId: "req-failure-audit",
})
);
});
test("uses session auth first in both mode and injects request id into plain responses", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
+76 -2
View File
@@ -4,10 +4,13 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget } from "@/modules/ee/audit-logs/types/audit-log";
import {
type InvalidParam,
problemBadRequest,
@@ -15,7 +18,7 @@ import {
problemTooManyRequests,
problemUnauthorized,
} from "./response";
import type { TV3Authentication } from "./types";
import type { TV3AuditLog, TV3Authentication } from "./types";
type TV3Schema = z.ZodTypeAny;
type MaybePromise<T> = T | Promise<T>;
@@ -38,6 +41,7 @@ export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unkn
req: NextRequest;
props: TProps;
authentication: TV3Authentication;
auditLog?: TV3AuditLog;
parsedInput: TParsedInput;
requestId: string;
instance: string;
@@ -48,6 +52,8 @@ export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = u
schemas?: S;
rateLimit?: boolean;
customRateLimitConfig?: TRateLimitConfig;
action?: TAuditAction;
targetType?: TAuditTarget;
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
};
@@ -293,10 +299,61 @@ async function applyV3RateLimitOrRespond(params: {
return null;
}
function buildV3AuditLog(
authentication: TV3Authentication,
action?: TAuditAction,
targetType?: TAuditTarget,
apiUrl?: string
): TV3AuditLog | undefined {
if (!authentication || !action || !targetType || !apiUrl) {
return undefined;
}
const auditLog = buildAuditLogBaseObject(action, targetType, apiUrl);
if ("user" in authentication && authentication.user?.id) {
auditLog.userId = authentication.user.id;
auditLog.userType = "user";
} else if ("apiKeyId" in authentication) {
auditLog.userId = authentication.apiKeyId;
auditLog.userType = "api";
auditLog.organizationId = authentication.organizationId;
}
return auditLog;
}
async function queueV3AuditLog(
auditLog: TV3AuditLog | undefined,
requestId: string,
log: ReturnType<typeof logger.withContext>
): Promise<void> {
if (!auditLog) {
return;
}
try {
await queueAuditEvent({
...auditLog,
...(auditLog.status === "failure" ? { eventId: auditLog.eventId ?? requestId } : {}),
});
} catch (error) {
log.error({ error }, "Failed to queue V3 audit event");
}
}
export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unknown>(
params: TWithV3ApiWrapperParams<S, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { auth = "both", schemas, rateLimit = true, customRateLimitConfig, handler } = params;
const {
auth = "both",
schemas,
rateLimit = true,
customRateLimitConfig,
handler,
action,
targetType,
} = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
@@ -306,6 +363,7 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
method: req.method,
path: instance,
});
let auditLog: TV3AuditLog | undefined;
try {
const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance);
@@ -331,17 +389,33 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
return rateLimitResponse;
}
auditLog = buildV3AuditLog(authResult.authentication, action, targetType, req.url);
const response = await handler({
req,
props,
authentication: authResult.authentication,
auditLog,
parsedInput: parsedInputResult.parsedInput,
requestId,
instance,
});
if (auditLog) {
if (response.ok) {
auditLog.status = "success";
} else {
auditLog.eventId = requestId;
}
}
await queueV3AuditLog(auditLog, requestId, log);
return ensureRequestIdHeader(response, requestId);
} catch (error) {
if (auditLog) {
auditLog.eventId = requestId;
await queueV3AuditLog(auditLog, requestId, log);
}
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
+25
View File
@@ -7,6 +7,7 @@ import {
problemTooManyRequests,
problemUnauthorized,
successListResponse,
successResponse,
} from "./response";
describe("v3 problem responses", () => {
@@ -93,3 +94,27 @@ describe("successListResponse", () => {
expect(res.headers.get("Cache-Control")).toBe("private, max-age=0");
});
});
describe("successResponse", () => {
test("wraps the payload in a data envelope", async () => {
const res = successResponse({ id: "survey_1" }, { requestId: "req-success" });
expect(res.status).toBe(200);
expect(res.headers.get("X-Request-Id")).toBe("req-success");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toEqual({
data: { id: "survey_1" },
});
});
test("allows custom status and cache headers", async () => {
const res = successResponse(
{ ok: true },
{
cache: "private, max-age=60",
status: 202,
}
);
expect(res.status).toBe(202);
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
});
});
+24
View File
@@ -147,3 +147,27 @@ export function successListResponse<T, TMeta extends Record<string, unknown>>(
}
return Response.json({ data, meta }, { status: 200, headers });
}
export function successResponse<T>(
data: T,
options?: { requestId?: string; cache?: string; status?: number }
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return Response.json(
{
data,
},
{
status: options?.status ?? 200,
headers,
}
);
}
+2
View File
@@ -1,4 +1,6 @@
import type { Session } from "next-auth";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import type { TApiAuditLog } from "@/app/lib/api/with-api-logging";
export type TV3Authentication = TAuthenticationApiKey | Session | null;
export type TV3AuditLog = TApiAuditLog;
@@ -0,0 +1,318 @@
import { ApiKeyPermission } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { DELETE } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const queueAuditEvent = vi.mocked((await import("@/modules/ee/audit-logs/lib/handler")).queueAuditEvent);
const surveyId = "clxx1234567890123456789012";
const workspaceId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) {
headers["x-request-id"] = requestId;
}
return new NextRequest(url, {
method: "DELETE",
headers,
});
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: true },
},
workspacePermissions: [
{
workspaceId,
workspaceName: "W",
permission: ApiKeyPermission.write,
},
],
};
describe("DELETE /api/v3/surveys/[surveyId]", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(getSurvey).mockResolvedValue({
id: surveyId,
name: "Delete me",
workspaceId: workspaceId,
type: "link",
status: "draft",
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
creator: { name: "User" },
singleUse: null,
} as any);
vi.mocked(deleteSurvey).mockResolvedValue({
id: surveyId,
workspaceId,
type: "link",
segment: null,
triggers: [],
} as any);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
workspaceId,
organizationId: "org_1",
});
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(401);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 200 with session auth and deletes the survey", async () => {
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-delete"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
workspaceId,
"readWrite",
"req-delete",
`/api/v3/surveys/${surveyId}`
);
expect(deleteSurvey).toHaveBeenCalledWith(surveyId);
expect(await res.json()).toEqual({
data: {
id: surveyId,
},
});
});
test("returns 200 with x-api-key when the key can delete in the survey workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const res = await DELETE(
createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-api-key", {
"x-api-key": "fbk_test",
}),
{
params: Promise.resolve({ surveyId }),
} as never
);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
workspaceId,
"readWrite",
"req-api-key",
`/api/v3/surveys/${surveyId}`
);
});
test("returns 400 when surveyId is invalid", async () => {
const res = await DELETE(createRequest("http://localhost/api/v3/surveys/not-a-cuid"), {
params: Promise.resolve({ surveyId: "not-a-cuid" }),
} as never);
expect(res.status).toBe(400);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 403 when the survey does not exist", async () => {
vi.mocked(getSurvey).mockResolvedValueOnce(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
});
test("returns 403 when the user lacks readWrite workspace access", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-forbidden",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-forbidden"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "unknown",
organizationId: "unknown",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: undefined,
})
);
});
test("returns 500 when survey deletion fails", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new DatabaseError("db down"));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-db"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 403 when the survey is deleted after authorization succeeds", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new ResourceNotFoundError("Survey", surveyId));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-race"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
test("queues an audit log with target, actor, organization, and old object", async () => {
await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-audit"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: expect.objectContaining({
id: surveyId,
workspaceId: workspaceId,
}),
})
);
});
});
@@ -0,0 +1,72 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
export const DELETE = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
schemas: {
params: z.object({
surveyId: z.cuid2(),
}),
},
handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const survey = await getSurvey(surveyId);
if (!survey) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
survey.workspaceId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
if (auditLog) {
auditLog.targetId = survey.id;
auditLog.organizationId = authResult.organizationId;
auditLog.oldObject = survey;
}
const deletedSurvey = await deleteSurvey(surveyId);
return successResponse(
{
id: deletedSurvey.id,
},
{ requestId }
);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, "V3 survey delete unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
+1 -1
View File
@@ -314,8 +314,8 @@ describe("GET /api/v3/surveys", () => {
const res = await GET(req, {} as any);
const body = await res.json();
expect(body.data[0]).not.toHaveProperty("blocks");
expect(body.data[0]).not.toHaveProperty("singleUse");
expect(body.data[0]).not.toHaveProperty("_count");
expect(body.data[0]).not.toHaveProperty("singleUse");
expect(body.data[0].id).toBe("s1");
expect(body.data[0].workspaceId).toBe("ws_1");
});
+50
View File
@@ -339,6 +339,56 @@ describe("API Response Utilities", () => {
});
});
describe("conflictResponse", () => {
test("should return a conflict response", () => {
const message = "Resource already exists";
const details = { field: "singleUseId" };
const response = responses.conflictResponse(message, details);
expect(response.status).toBe(409);
return response.json().then((body) => {
expect(body).toEqual({
code: "conflict",
message,
details,
});
});
});
test("should handle undefined details", () => {
const message = "Resource already exists";
const response = responses.conflictResponse(message);
expect(response.status).toBe(409);
return response.json().then((body) => {
expect(body).toEqual({
code: "conflict",
message,
details: {},
});
});
});
test("should include CORS headers when cors is true", () => {
const message = "Resource already exists";
const response = responses.conflictResponse(message, undefined, true);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS");
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization");
});
test("should use custom cache control header when provided", () => {
const message = "Resource already exists";
const customCache = "no-cache";
const response = responses.conflictResponse(message, undefined, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("tooManyRequestsResponse", () => {
test("should return a too many requests response", () => {
const message = "Rate limit exceeded";
+27 -1
View File
@@ -16,7 +16,8 @@ interface ApiErrorResponse {
| "method_not_allowed"
| "not_authenticated"
| "forbidden"
| "too_many_requests";
| "too_many_requests"
| "conflict";
message: string;
details: {
[key: string]: string | string[] | number | number[] | boolean | boolean[];
@@ -236,6 +237,30 @@ const internalServerErrorResponse = (
);
};
const conflictResponse = (
message: string,
details?: { [key: string]: string },
cors: boolean = false,
cache: string = "private, no-store"
) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
code: "conflict",
message,
details: details || {},
} as ApiErrorResponse,
{
status: 409,
headers,
}
);
};
const tooManyRequestsResponse = (
message: string,
cors: boolean = false,
@@ -270,4 +295,5 @@ export const responses = {
successResponse,
tooManyRequestsResponse,
forbiddenResponse,
conflictResponse,
};
+140 -19
View File
@@ -3,9 +3,16 @@ import { NextRequest } from "next/server";
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import type { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import { responses } from "./response";
const AuthMethod = {
ApiKey: "apiKey" as AuthenticationMethod,
Session: "session" as AuthenticationMethod,
Both: "both" as AuthenticationMethod,
None: "none" as AuthenticationMethod,
} as const;
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
__esModule: true,
queueAuditEvent: vi.fn(),
@@ -122,7 +129,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -198,7 +205,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -244,7 +251,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -318,7 +325,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -370,7 +377,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -425,7 +432,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
@@ -449,7 +456,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthenticationMethod.None,
authenticationMethod: AuthMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
@@ -473,6 +480,90 @@ describe("withV1ApiWrapper", () => {
});
});
test("skips app rate limiting for Envoy-covered client routes", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ method: "POST", url: "/api/v1/client/env_123/storage" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(200);
expect(applyIPRateLimit).not.toHaveBeenCalled();
});
test("keeps app rate limiting for uncovered client routes", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ method: "GET", url: "/api/v2/client/env_123/environment" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(200);
expect(applyIPRateLimit).toHaveBeenCalled();
});
test("keeps app rate limiting for uncovered verbs on otherwise covered client paths", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: false,
authenticationMethod: AuthMethod.None,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ method: "PATCH", url: "/api/v1/client/env_123/environment" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(200);
expect(applyIPRateLimit).toHaveBeenCalled();
});
test("returns authentication error for non-client routes without auth", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
@@ -481,7 +572,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
@@ -504,7 +595,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Session,
authenticationMethod: AuthMethod.Session,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(getServerSession).mockResolvedValue(null);
@@ -528,7 +619,36 @@ describe("withV1ApiWrapper", () => {
expect(mockContextualLoggerError).toHaveBeenCalled();
});
test("handles rate limiting errors", async () => {
test("keeps app rate limiting for uncovered session-authenticated management routes", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { getServerSession } = await import("next-auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthMethod.Both,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-1" } } as any);
const rateLimitError = new Error("Rate limit exceeded");
rateLimitError.message = "Rate limit exceeded";
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
const handler = vi.fn();
const req = createMockRequest({ method: "POST", url: "https://api.test/api/v1/management/storage" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const customRateLimitConfig = { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" };
const wrapped = withV1ApiWrapper({ handler, customRateLimitConfig });
const res = await wrapped(req, undefined);
expect(res.status).toBe(429);
expect(handler).not.toHaveBeenCalled();
expect(applyRateLimit).toHaveBeenCalledWith(customRateLimitConfig, "user-1");
});
test("skips app rate limiting for Envoy-covered API-key management routes", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
@@ -538,21 +658,22 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const rateLimitError = new Error("Rate limit exceeded");
rateLimitError.message = "Rate limit exceeded";
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
vi.mocked(applyRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const handler = vi.fn();
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(429);
expect(handler).not.toHaveBeenCalled();
expect(res.status).toBe(200);
expect(applyRateLimit).not.toHaveBeenCalled();
});
test("skips audit log creation when no action/targetType provided", async () => {
@@ -566,7 +687,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
authenticationMethod: AuthMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
+44 -6
View File
@@ -13,6 +13,10 @@ import {
} from "@/app/middleware/endpoint-validator";
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
TEnvoyRateLimitAuthType,
isRouteRateLimitedByEnvoy,
} from "@/modules/core/rate-limit/envoy-rate-limit-coverage";
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
@@ -61,29 +65,58 @@ const applyClientRateLimit = async (customRateLimitConfig?: TRateLimitConfig): P
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
};
const getEnvoyRateLimitAuthType = (
authentication: TApiV1Authentication
): TEnvoyRateLimitAuthType | "unknown" => {
if (!authentication) {
return "none";
}
if ("user" in authentication) {
return "session";
}
if ("apiKeyId" in authentication) {
return "apiKey";
}
return "unknown";
};
/**
* Handle rate limiting based on authentication and API type
*/
const handleRateLimiting = async (
req: NextRequest,
authentication: TApiV1Authentication,
routeType: ApiV1RouteTypeEnum,
customRateLimitConfig?: TRateLimitConfig
): Promise<Response | null> => {
const authType = getEnvoyRateLimitAuthType(authentication);
if (authType === "unknown") {
logger.error({ authentication }, "Unknown authentication type");
return responses.internalServerErrorResponse("Invalid authentication configuration");
}
const isEnvoyManagedRateLimit = isRouteRateLimitedByEnvoy({
pathname: req.nextUrl.pathname,
method: req.method,
authType,
});
try {
if (authentication) {
if (authentication && !isEnvoyManagedRateLimit) {
if ("user" in authentication) {
// Session-based authentication for integration routes
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.user.id);
} else if ("apiKeyId" in authentication) {
// API key authentication for general routes
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.apiKeyId);
} else {
logger.error({ authentication }, "Unknown authentication type");
return responses.internalServerErrorResponse("Invalid authentication configuration");
}
}
if (routeType === ApiV1RouteTypeEnum.Client) {
if (routeType === ApiV1RouteTypeEnum.Client && !isEnvoyManagedRateLimit) {
await applyClientRateLimit(customRateLimitConfig);
}
} catch (error) {
@@ -286,7 +319,12 @@ export const withV1ApiWrapper = <TResult extends { response: Response; error?: u
// === Rate Limiting ===
if (isRateLimited) {
const rateLimitResponse = await handleRateLimiting(authentication, routeType, customRateLimitConfig);
const rateLimitResponse = await handleRateLimiting(
req,
authentication,
routeType,
customRateLimitConfig
);
if (rateLimitResponse) return rateLimitResponse;
}
+64 -15
View File
@@ -3,7 +3,9 @@ import type { TFunction } from "i18next";
import type { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import type {
TSurveyCTAElement,
TSurveyCesElement,
TSurveyConsentElement,
TSurveyCsatElement,
TSurveyElement,
TSurveyMultipleChoiceElement,
TSurveyNPSElement,
@@ -96,7 +98,8 @@ export const buildOpenTextElement = ({
};
};
export const buildRatingElement = ({
const buildScaleElement = <T extends TSurveyRatingElement | TSurveyCsatElement | TSurveyCesElement>({
type,
id,
headline,
subheader,
@@ -107,6 +110,32 @@ export const buildRatingElement = ({
required,
isColorCodingEnabled = false,
}: {
type: T["type"];
id?: string;
headline: string;
scale: T["scale"];
range: T["range"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): T => {
return {
id: id ?? createId(),
type,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
scale,
range,
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
} as T;
};
export const buildRatingElement = (params: {
id?: string;
headline: string;
scale: TSurveyRatingElement["scale"];
@@ -116,20 +145,8 @@ export const buildRatingElement = ({
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyRatingElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.Rating,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
scale,
range,
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
};
};
}): TSurveyRatingElement =>
buildScaleElement<TSurveyRatingElement>({ ...params, type: TSurveyElementTypeEnum.Rating });
export const buildConsentElement = ({
id,
@@ -212,6 +229,38 @@ export const buildNPSElement = ({
};
};
export const buildCsatElement = ({
scale = "smiley",
...params
}: {
id?: string;
headline: string;
scale?: TSurveyCsatElement["scale"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyCsatElement =>
buildScaleElement<TSurveyCsatElement>({ ...params, scale, range: 5, type: TSurveyElementTypeEnum.CSAT });
export const buildCesElement = ({
scale = "number",
range = 5,
...params
}: {
id?: string;
headline: string;
scale?: TSurveyCesElement["scale"];
range?: TSurveyCesElement["range"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyCesElement =>
buildScaleElement<TSurveyCesElement>({ ...params, scale, range, type: TSurveyElementTypeEnum.CES });
// Helper function to create block-level jump logic based on operator
export const createBlockJumpLogic = (
sourceElementId: string,
+6
View File
@@ -30,6 +30,8 @@ const conditionOptions: Record<string, string[]> = {
multipleChoiceMulti: ["Includes all", "Includes either"],
nps: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped", "Includes either"],
rating: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
csat: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
ces: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
cta: ["is"],
tags: ["is"],
languages: ["Equals", "Not equals"],
@@ -45,6 +47,8 @@ const filterOptions: Record<string, string[]> = {
openText: ["Filled out", "Skipped"],
rating: ["1", "2", "3", "4", "5"],
nps: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
csat: ["1", "2", "3", "4", "5"],
ces: ["1", "2", "3", "4", "5", "6", "7"],
cta: ["Clicked", "Dismissed"],
tags: ["Applied", "Not applied"],
consent: ["Accepted", "Dismissed"],
@@ -436,6 +440,8 @@ const processElementFilters = (
break;
case TSurveyElementTypeEnum.NPS:
case TSurveyElementTypeEnum.Rating:
case TSurveyElementTypeEnum.CSAT:
case TSurveyElementTypeEnum.CES:
processNPSRatingFilter(filterType, elementId, filters);
break;
case TSurveyElementTypeEnum.CTA:
+13 -14
View File
@@ -7,7 +7,9 @@ import type { TTemplate } from "@formbricks/types/templates";
import {
buildBlock,
buildCTAElement,
buildCesElement,
buildConsentElement,
buildCsatElement,
buildMultipleChoiceElement,
buildNPSElement,
buildOpenTextElement,
@@ -971,13 +973,13 @@ const improveTrialConversion = (t: TFunction): TTemplate => {
elements: [
buildOpenTextElement({
id: reusableElementIds[2],
headline: t("templates.improve_trial_conversion_question_2_headline"),
headline: t("templates.improve_trial_conversion_question_3_headline"),
required: true,
inputType: "text",
}),
],
logic: [createBlockJumpLogic(reusableElementIds[2], block6Id, "isSubmitted")],
buttonLabel: t("templates.improve_trial_conversion_question_2_button_label"),
buttonLabel: t("templates.improve_trial_conversion_question_3_button_label"),
t,
}),
buildBlock({
@@ -1319,8 +1321,7 @@ const employeeSatisfaction = (t: TFunction): TTemplate => {
buildBlock({
name: t("templates.block_1"),
elements: [
buildRatingElement({
range: 5,
buildCsatElement({
scale: "star",
headline: t("templates.employee_satisfaction_question_1_headline"),
required: true,
@@ -1647,14 +1648,14 @@ const identifyCustomerGoals = (t: TFunction): TTemplate => {
elements: [
buildMultipleChoiceElement({
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: "What's your primary goal for using $[workspaceName]?",
headline: t("templates.identify_customer_goals_question_1_headline"),
required: true,
shuffleOption: "none",
choices: [
"Understand my user base deeply",
"Identify upselling opportunities",
"Build the best possible product",
"Rule the world to make everyone breakfast brussels sprouts.",
t("templates.identify_customer_goals_question_1_choice_1"),
t("templates.identify_customer_goals_question_1_choice_2"),
t("templates.identify_customer_goals_question_1_choice_3"),
t("templates.identify_customer_goals_question_1_choice_4"),
],
}),
],
@@ -2723,7 +2724,7 @@ const customerEffortScore = (t: TFunction): TTemplate => {
buildBlock({
name: t("templates.block_1"),
elements: [
buildRatingElement({
buildCesElement({
range: 5,
scale: "number",
headline: t("templates.customer_effort_score_question_1_headline"),
@@ -3828,9 +3829,8 @@ const improveNewsletterContent = (t: TFunction): TTemplate => {
buildBlock({
name: t("templates.block_1"),
elements: [
buildRatingElement({
buildCsatElement({
id: reusableElementIds[0],
range: 5,
scale: "smiley",
headline: t("templates.improve_newsletter_content_question_1_headline"),
required: true,
@@ -4409,8 +4409,7 @@ const longTermRetentionCheckIn = (t: TFunction): TTemplate => {
buildBlock({
name: t("templates.block_9"),
elements: [
buildRatingElement({
range: 5,
buildCsatElement({
scale: "smiley",
headline: t("templates.long_term_retention_check_in_question_9_headline"),
required: true,
@@ -121,13 +121,10 @@ export const DELETE = async (
: responses.notAuthenticatedResponse();
}
if (authResult.ok) {
// Rate limiting for apiKey DELETE is enforced by Envoy in v5 — see envoy-rate-limit-coverage.ts
if (authResult.ok && authResult.data.authType !== "apiKey") {
try {
if (authResult.data.authType === "apiKey") {
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.apiKeyId);
} else {
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.userId);
}
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.userId);
} catch (error) {
return responses.tooManyRequestsResponse(
error instanceof Error ? error.message : "Unknown error occurred"
@@ -142,20 +139,20 @@ export const DELETE = async (
idParam
);
const isSuccess = deleteResult.ok;
if (!deleteResult.ok) {
const { error } = deleteResult;
if (!isSuccess) {
logger.error({ error: deleteResult.error }, "Error deleting file");
logger.error({ error }, "Error deleting file");
await logFileDeletion({
failureReason: deleteResult.error.code,
failureReason: error.code,
accessType,
userId: session?.user?.id,
workspaceId: resolved.workspaceId,
apiUrl: request.url,
});
const errorResponse = getErrorResponseFromStorageError(deleteResult.error, { fileName });
const errorResponse = getErrorResponseFromStorageError(error, { fileName });
return errorResponse;
}
+1
View File
@@ -19,6 +19,7 @@
"ro-RO",
"ru-RU",
"sv-SE",
"tr-TR",
"zh-Hans-CN",
"zh-Hant-TW"
]
+225 -23
View File
@@ -98,6 +98,8 @@ checksums:
common/activity: 1948763de8e531483a798b68195e297e
common/add: 87c4a663507f2bcbbf79934af8164e13
common/add_action: 66fefc4dd6a7b939c2224272cf0d2669
common/add_charts: c377a42e165e8ab67bfbb8ad72026dd8
common/add_existing_chart_description: b1292a1d6df2e03ad7b399689312c37f
common/add_filter: ed5d8e9bfcb05cd1e10e4c403befbae6
common/add_logo: c8665aa9afd0d5a13528bdc96daefa53
common/add_member: 11979625770516ca287e929381778e02
@@ -109,6 +111,7 @@ checksums:
common/allow: 3e39cc5940255e6bff0fea95c817dd43
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
common/analysis: 409bac6215382c47e59f5039cc4cdcdd
common/and: dc75b95c804b16dc617a5f16f7393bca
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
common/api_keys: f961b547cd312cc8b9b79f0c9e0b2cc3
@@ -127,6 +130,9 @@ checksums:
common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507
common/change_organization: 3b2c873962509445ff2cb8cde5ad913b
common/change_workspace: 489cbcf7eef9b9b960e426fbf4da318f
common/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
common/charts: 1da4564d89264c89de4ed28d7451b43e
common/choice_n: ee41eb382bae7289a221d959f3046965
common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d
common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603
common/choose_workspace: f9ed22d76c69cc75aa56cf3da3fa6320
@@ -139,6 +145,7 @@ checksums:
common/close: 2c2e22f8424a1031de89063bd0022e16
common/code: 343bc5386149b97cece2b093c39034b2
common/collapse_rows: 24988527f9180f37aa55d2aa183ccb21
common/column_n: 550955aee6a92d8ccc96989300add693
common/completed: 0e4bbce9985f25eb673d9a054c8d5334
common/configuration: 923ec0502721489202f6222dd4107163
common/confirm: 90930b51154032f119fa75c1bd422d8b
@@ -169,6 +176,8 @@ checksums:
common/created_by: 6775c2fa7d495fea48f1ad816daea93b
common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4
common/dark_overlay: 173e84b526414dbc70dbf9737e443b60
common/dashboard: c9380ea68c8c76ea451bd9613329a07c
common/dashboards: 4bc47e48559a6b688684dcb7ac4babc9
common/date: 56f41c5d30a76295bb087b20b7bee4c3
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
@@ -208,6 +217,7 @@ checksums:
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
common/failed_to_parse_csv: 7a3d675ecbb3d15884faf1006a5752d6
common/field_placeholder: ec26d96643d86da164162204ec6c650f
common/filter: 626325a05e4c8800f7ede7012b0cadaf
common/finish: ffa7a10f71182b48fefed7135bee24fa
common/first_name: cf040a5d6a9fd696be400380cc99f54b
@@ -219,10 +229,13 @@ checksums:
common/generate: 0345bf322c191e70d01fd6607ec5c2f8
common/go_back: b917ea82facb90c88c523b255d29f84b
common/go_to_dashboard: a6efa97d25e36fedc0af794f6ba610f2
common/headline: 0023cbe059bbadcc77312825cbbce5ac
common/hidden: fa290c6ada5869d744ed35e9cca64699
common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972
common/hide: a6088b934651055bb27314d111be510b
common/hide_column: 23ce94db148f2d8e4a0923defead6cf1
common/html: f750870203043349d570d8f5865ca0f8
common/id: c8886d38aeea2ed5f785aba4fc96784b
common/image: 048ba7a239de0fbd883ade8558415830
common/images: 9305827c28694866f49db42b4c51831f
@@ -268,9 +281,9 @@ checksums:
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
common/mobile_overlay_title: 42f52259b7527989fb3a3240f5352a8b
common/months: da74749fbe80394fa0f72973d7b0964a
common/more_options: 53d90eae6a9b0243b5bc043b3d9de169
common/move_down: 4f4de55743043355ad4a839aff2c48ff
common/move_up: 69f25b205c677abdb26cbb69d97cd10b
common/multiple_languages: 7d8ddd4b40d32fcd7bd6f7bac6485b1f
common/my_product: ad022177062f9ef6e9acf33b13e889aa
common/name: 9368b5a047572b6051f334af5aa76819
common/new: 126d036fae5fb6b629728ecb97e6195b
@@ -279,6 +292,7 @@ checksums:
common/no: 8c708225830b06df2d1141c536f2a0d6
common/no_actions_found: 4d92b789eb121fc76cd6868136dcbcd4
common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b
common/no_changes: 17709e3e2fbd133ddb8b3291d13de7f6
common/no_code: f602144ab7d28a5b19a446bf74b4dcc4
common/no_files_uploaded: c97be829e195a41b2f6b6717b87a232b
common/no_overlay: 03cde9e91f08e4dd539d788e1e01407f
@@ -286,6 +300,7 @@ checksums:
common/no_result_found: fedddbc0149972ea072a9e063198a16d
common/no_results: 0e9b73265c6542240f5a3bf6b43e9280
common/no_surveys_found: 7b74706fe4f4aacd7d858e19e444fe85
common/no_text_found: 27350f35bdd57b3701c7ec578a1a0e11
common/none_of_the_above: e007f0b1e046d5ddbbcfbd87940456ee
common/not_authenticated: fed6c62208524ea6782b5f9c07a95a4f
common/not_authorized: 4be80383fe1a6f52c61138f1aa8d01d4
@@ -300,6 +315,7 @@ checksums:
common/on: 1929bcf2fba8003c043b446a851bcb4f
common/only_one_file_allowed: 171be177f2e96c4bb4c4a47b3bf6c8c9
common/only_owners_managers_and_manage_access_members_can_perform_this_action: 3c16fc506e871935f6183793e73b6709
common/open_options: a4578c0afbfdf4a76d5952a53085b72a
common/option_id: ed21d97b8ab035ba89fb3f5f073229bd
common/option_ids: e68c25215ce81ea7ad82ff7be0a0bf2d
common/optional: 396fb9a0472daf401c392bdc3e248943
@@ -309,7 +325,7 @@ checksums:
common/organization_settings: 11528aa89ae9935e55dcb54478058775
common/other: 79acaa6cd481262bea4e743a422529d2
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
common/others: 39160224ce0e35eb4eb252c997edf4d8
common/other_placeholder: f3a0fa2eaaf75aa92b290449c928c081
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
common/password: 223a61cf906ab9c40d22612c588dff48
@@ -327,7 +343,6 @@ checksums:
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
common/privacy: 7459744a63ef8af4e517a09024bd7c08
common/product_manager: dfeadc96e6d3de22a884ee97974b505e
common/production: 226e0ce83b49700bc1b1c08c4c3ed23a
@@ -342,6 +357,7 @@ checksums:
common/quotas_description: a2caa44fa74664b3b6007e813f31a754
common/read_docs: d06513c266fdd9056e0500eab838ebac
common/recipients: f90e7f266be3f5a724858f21a9fd855e
common/refresh: c0aec3f31be4c984bae9a482572d2857
common/remove: dba2fe5fe9f83f8078c687f28cba4b52
common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6
common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069
@@ -355,6 +371,7 @@ checksums:
common/restart: bab6232e89f24e3129f8e48268739d5b
common/retry: 6e44d18639560596569a1278f9c83676
common/role: 53743bbb6ca938f5b893552e839d067f
common/row_n: eb5bb04b244fadd7a6962aa58bf6bd17
common/saas: f01686245bcfb35a3590ab56db677bdb
common/sales: 38758eb50094cd8190a71fe67be4d647
common/save: f7a2929f33bc420195e59ac5a8bcd454
@@ -362,6 +379,7 @@ checksums:
common/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
common/saving: 27ad05746d65e2f3f17d327eb181725d
common/search: 49dd6c21604b5e8d4153ff1aff2177e1
common/search_charts: 51c3934f12f050fb2476d62da335a65c
common/security: 4b34923fef858a2b9a4a914c3e822889
common/segment: e8908115453de180bbda7478ba4c2d50
common/segments: 271db72d5b973fbc5fadab216177eaae
@@ -393,6 +411,7 @@ checksums:
common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6
common/string: 4ddccc1974775ed7357f9beaf9361cec
common/styling: 240fc91eb03c52d46b137f82e7aec2a1
common/subheader: 73a37d57cb9807e574a42bd0c7e334ed
common/submit: 7c91ef5f747eea9f77a9c4f23e19fb2e
common/summary: 13eb7b8a239fb4702dfdaee69100a220
common/survey: b659d270a53dada994d926e0cc6e9a54
@@ -463,7 +482,6 @@ checksums:
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
common/yes: ec580fd11a45779b039466f1e35eed2a
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
common/you_have_reached_your_limit_of_workspace_limit: 506a6ee315d9754da7ea26929bc40f52
@@ -695,6 +713,10 @@ checksums:
templates/career_development_survey_question_6_choice_6: 79acaa6cd481262bea4e743a422529d2
templates/career_development_survey_question_6_headline: 88d2a87cbf2ec21882798890990c2225
templates/career_development_survey_question_6_subheader: b9b478e967930358b0c74324a7c18fc8
templates/ces: 49fc8d0ae7b82f3e7d49922ada7ab7a1
templates/ces_description: 66f4aaa7e76fd87d19c4ec3bf71481e0
templates/ces_lower_label: c2f05d3610d8879ae503a61d49e32e80
templates/ces_upper_label: b88eaddaea17a4f285209c2529a9b8f8
templates/cess_survey_name: dd706043a56d66f2895cad743935c5b4
templates/cess_survey_question_1_headline: 70115a7960746a05acef03f815652fc3
templates/cess_survey_question_1_lower_label: 586eedbc7b53319775e42c7cd4cef4de
@@ -758,7 +780,9 @@ checksums:
templates/consent_description: d76e48fb1e8c291b51e783eaf7fc910d
templates/contact_info: 73913230e8988f5f423e54e0fd43f368
templates/contact_info_description: 0e8962e628bb0a072a4217ae172db43b
templates/csat_description: 4dd35d7fecfa9fdf47765c7108c3d535
templates/csat: 6864fe0caad3b052a4ec0837e7b71cee
templates/csat_description: 0e64d5594f961e5070a95f715594549e
templates/csat_lower_label: 206c68e770b90abd737c8c4cb99aa695
templates/csat_name: f216066cef52693bbaa842a3305377c7
templates/csat_question_10_headline: b6a9ca9c6c20dced146d817c9a1e9be7
templates/csat_question_10_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
@@ -834,6 +858,7 @@ checksums:
templates/csat_survey_question_2_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
templates/csat_survey_question_3_headline: 25974b7f1692cad41908fe305830b6c0
templates/csat_survey_question_3_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
templates/csat_upper_label: a3a49eb9cc86972bce6dc41a107f472d
templates/cta_description: bc94a2ddc965b286a8677b0642696c7e
templates/custom_survey_description: 0492afdea2ef1bd683eaf48a2bad2caa
templates/custom_survey_name: 6fc756927ca9ea22c26368cccd64a67e
@@ -999,6 +1024,11 @@ checksums:
templates/gauge_feature_satisfaction_question_2_headline: 0fcbefbfcf5c21e42de8a36cb2cad854
templates/identify_customer_goals_description: c30d06df9e5c76334e4c3d470ee6e4d8
templates/identify_customer_goals_name: f8123dbfa22e169517a811fae7496595
templates/identify_customer_goals_question_1_choice_1: a6803cfbdbd6208eedf5c691f9e106a5
templates/identify_customer_goals_question_1_choice_2: 7461749517d62030ec2e3915cf1d223b
templates/identify_customer_goals_question_1_choice_3: 725eb3ee0d4f2d229fcf588c21e66a86
templates/identify_customer_goals_question_1_choice_4: 3985521036afaf1cbd2bdc7a4d86d351
templates/identify_customer_goals_question_1_headline: 45a7347cf3ae2d498a30ca1266898cf8
templates/identify_sign_up_barriers_description: 5b2fbee8c425d7a4d0706ec3628cea11
templates/identify_sign_up_barriers_name: 3bbc5352dfa7a9c237bc2c6b21b608dd
templates/identify_sign_up_barriers_question_1_button_label: 080fd22c580f56ffdcea6c3d60448b84
@@ -1073,6 +1103,8 @@ checksums:
templates/improve_trial_conversion_question_1_subheader: 67c7047ba2365d461df14dbed3f9506d
templates/improve_trial_conversion_question_2_button_label: 89ddbcf710eba274963494f312bdc8a9
templates/improve_trial_conversion_question_2_headline: 54324cd652667183dd3cf647ba72dd07
templates/improve_trial_conversion_question_3_button_label: 89ddbcf710eba274963494f312bdc8a9
templates/improve_trial_conversion_question_3_headline: 8dfe1f843c8de64de7e3fa619b961152
templates/improve_trial_conversion_question_4_button_label: d94a6a11cfdf4ebde4c5332e585e2e96
templates/improve_trial_conversion_question_4_headline: 9b07341f65574c4165086ec107cebb45
templates/improve_trial_conversion_question_4_html: 8ce95691eeeae7ad61c4d2f867b918ca
@@ -1555,6 +1587,176 @@ checksums:
workspace/actions/you_can_track_code_action_anywhere_in_your_app_using: 3c0bbf160b8ddbeef142403103b70554
workspace/actions/your_survey_would_be_shown_on_this_url: 766fdeeb52d170c156af5d035a1f8c37
workspace/actions/your_survey_would_not_be_shown: af44fe160f449ff9557ebe5d3686832d
workspace/analysis/charts/OR: 0208d355f231c386b19390f0bea41b95
workspace/analysis/charts/add_chart_to_dashboard: c2a517ada86cdda60e49bec655ca9a6d
workspace/analysis/charts/add_chart_to_dashboard_description: 08980a1849757e9aec21fca5881c6be4
workspace/analysis/charts/add_filter: ed5d8e9bfcb05cd1e10e4c403befbae6
workspace/analysis/charts/add_to_dashboard: 9941c3d30895bb8e25ce8d4e03d33a08
workspace/analysis/charts/advanced_chart_builder_config_prompt: c2fe2c1a076f27d3ae62a4db75474b0a
workspace/analysis/charts/ai_query_placeholder: 24c3d18f514cb3a9953f04c3b04503a2
workspace/analysis/charts/ai_query_section_description: 66d06342f29bf6658793403856521fd7
workspace/analysis/charts/ai_query_section_title: c0e450a47af7c2a516b77f73cf54db1b
workspace/analysis/charts/and_filter_logic: 53e8eb67a396fcb5e419bb4cbf0008df
workspace/analysis/charts/apply_changes: ed3da8072dbd27dc0c959777cdcbebf3
workspace/analysis/charts/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
workspace/analysis/charts/chart_added_to_dashboard: 7bc429ab605cb89a9232c26be008cc00
workspace/analysis/charts/chart_builder_choose_chart_type: 1376de2dcafac573a2df9e4c007b0ec8
workspace/analysis/charts/chart_data: 6739a9576b357a58d73ff0c9bf8db0e4
workspace/analysis/charts/chart_data_tab: b7b46ab6ce9606032c8f81f6f6afbb9b
workspace/analysis/charts/chart_deleted_successfully: 79148f471cd9acc2c8d0d033fb85437e
workspace/analysis/charts/chart_deletion_error: 267eb65c168e726075d7cea678dd32e0
workspace/analysis/charts/chart_duplicated_successfully: 755c4ce5bf533764d549a53c33e32165
workspace/analysis/charts/chart_duplication_error: 90d7166c85188b52f821c9d9f53ff8c4
workspace/analysis/charts/chart_name: cdb36e2f121a7b9c28298e15ab8218dc
workspace/analysis/charts/chart_name_placeholder: 7370d4f88f27aea337ba1c36465c3f8b
workspace/analysis/charts/chart_preview: 1b7faae244d31e43f758f50b94132413
workspace/analysis/charts/chart_render_error: 01e9ece0c86a1fedf301afa0dbbf6aeb
workspace/analysis/charts/chart_saved_successfully: 2489c853c0b36790e3592ac6ea31cc61
workspace/analysis/charts/chart_type_area: 535754c6425f045f17e1dcb551840c93
workspace/analysis/charts/chart_type_bar: c11d460595d3ddfe8efd67ac068574c5
workspace/analysis/charts/chart_type_big_number: 9d17fb96241507c955dca25e143ae67a
workspace/analysis/charts/chart_type_line: f42dd53238ed4d44def306a61d47d5c4
workspace/analysis/charts/chart_type_not_supported: 7ff0afc493b36f3f3c12c7c230df9757
workspace/analysis/charts/chart_type_pie: 068a797404233ccf68d07ad63af7b50c
workspace/analysis/charts/chart_updated_successfully: a2c210523902c726aa1328bbeda0b357
workspace/analysis/charts/configure_description: 2939321f78e4ffbc57b4259ddaddb09d
workspace/analysis/charts/configure_title: ab767b11da1d386b98b3f634f79d3abe
workspace/analysis/charts/configure_type_label: cd13e4b37fb2021af55903e7690a9856
workspace/analysis/charts/contains: 06dd606c0a8f81f9a03b414e9ae89440
workspace/analysis/charts/create_chart: 636969b904e88bef5c93e859dd8a1656
workspace/analysis/charts/create_chart_description: b9680bd8905dea180fa59a86f61de34e
workspace/analysis/charts/create_chart_with_ai: b0092b5616015a29dd51fbab49bcd4c4
workspace/analysis/charts/custom_range: 99f4d72b64621406acc162cceeb1fed7
workspace/analysis/charts/dashboard: c9380ea68c8c76ea451bd9613329a07c
workspace/analysis/charts/dashboard_select_placeholder: 9b875f2f10050d650ae63be53fe0d4e8
workspace/analysis/charts/data_label: b7b46ab6ce9606032c8f81f6f6afbb9b
workspace/analysis/charts/data_source: c29cdd1967a3d1b1a39e91e14469b047
workspace/analysis/charts/date_preset_last_30_days: a738894cfc5e592052f1e16787744568
workspace/analysis/charts/date_preset_last_7_days: 3631df3109bfecfe358ba15dcf8bd6f5
workspace/analysis/charts/date_preset_last_month: 848086395b28875c050d56e3933dae61
workspace/analysis/charts/date_preset_this_month: 50845a38865204a97773c44dcd2ebb90
workspace/analysis/charts/date_preset_this_quarter: 9c77d94783dff2269c069389122cd7bd
workspace/analysis/charts/date_preset_this_year: 1e69651c2ac722f8ce138f43cf2e02f9
workspace/analysis/charts/date_preset_today: 142173f9752e18e92109623a3ee68cad
workspace/analysis/charts/date_preset_yesterday: eeb58908e68ff96c1b7e8f90e389afb7
workspace/analysis/charts/date_range: 9b3aa5954144de586931f60ef9594e99
workspace/analysis/charts/delete_chart_confirmation: f7fd7b0a08e81c9b392b08c9c1ad2147
workspace/analysis/charts/dimensions: f09d837ac25f58986a769bd48ea15022
workspace/analysis/charts/dimensions_toggle_description: 31eb28f3c83c04bbe37799758ca9f595
workspace/analysis/charts/edit_chart_description: 822890e4b6068096e2fe8b7b78b4474f
workspace/analysis/charts/edit_chart_title: fd3e7f8c53280bfad8f4034c055f4c71
workspace/analysis/charts/enable_time_dimension: cfcf0af2d22bccd197319c07680c2cb8
workspace/analysis/charts/end_date: acbea5a9fd7a6fadf5aa1b4f47188203
workspace/analysis/charts/enter_a_name_for_your_chart: b6e992a23d0628136121ebf26eec4a50
workspace/analysis/charts/enter_value: a4554ed67c02872e302b0042724f859d
workspace/analysis/charts/equals: 264ec282f7f5b67da622cc37f2b57b8a
workspace/analysis/charts/failed_to_add_chart_to_dashboard: 355a5606399edcbb3e6d0ba0b66f12a6
workspace/analysis/charts/failed_to_execute_query: d1153133aa4cd3d1cd02e39942413168
workspace/analysis/charts/failed_to_load_chart: abea098fbf8e728f95414d3ae8bb63a4
workspace/analysis/charts/failed_to_load_chart_data: ea980a6d12b1b1efed90d991dd0dd0fd
workspace/analysis/charts/failed_to_save_chart: e237cf1a56a8f9ee30067fdb0757f7c5
workspace/analysis/charts/field: cfd632297d7809a3539e90c9cd4728d9
workspace/analysis/charts/field_label_average_score: 5b5aa7322549521d1e813b1c8312d443
workspace/analysis/charts/field_label_collected_at: b41902ddb4586ba4a4611d726b5014aa
workspace/analysis/charts/field_label_count: 9c5848662eb8024ddf360f7e4001a968
workspace/analysis/charts/field_label_detractor_count: eedb15bc383eb0f14d43043e6666c62a
workspace/analysis/charts/field_label_emotion: eb3a31ead51b5c8a8d365d5f904e9206
workspace/analysis/charts/field_label_field_type: 2581066dc304c853a4a817c20996fa08
workspace/analysis/charts/field_label_nps_score: 9c8d0b0b460f9689bd66e81d45e0a2df
workspace/analysis/charts/field_label_nps_value: cb7404025044400e3d7d5600f3133e4f
workspace/analysis/charts/field_label_passive_count: ceb71da8d1382eb2097089dc3ecf76da
workspace/analysis/charts/field_label_promoter_count: c393131a4bd3a25bf6b297beed20e34f
workspace/analysis/charts/field_label_response_id: 73375099cc976dc7203b8e27f5f709e0
workspace/analysis/charts/field_label_sentiment: 9ba5719c80c0136c2d0644217619aff6
workspace/analysis/charts/field_label_source_name: 157675beca12efcd8ec512c5256b1a61
workspace/analysis/charts/field_label_source_type: d1ff69af76c687eb189db72030717570
workspace/analysis/charts/field_label_topic: 7f542b783cd528f00f4f485e35b48dc1
workspace/analysis/charts/field_label_user_identifier: b0174469c95038766744fb7e64005aec
workspace/analysis/charts/filter_data: 05cc68ed2896feef60bbe3829cd9063d
workspace/analysis/charts/filters: acf5accc113ff3c1992688058576732c
workspace/analysis/charts/filters_toggle_description: ea18bdb212a6a85620125cab89a4b1c1
workspace/analysis/charts/go_to_feedback_record_directories: 1aa0516beef8adbd330cffdcab8b521f
workspace/analysis/charts/granularity: 9eb09aef092e7803ce4acb7965cbbaa9
workspace/analysis/charts/granularity_day: 47648cd60fc313bc3f05b70357a1d675
workspace/analysis/charts/granularity_hour: ec3113f22fc51d01f0c615c5496f8f87
workspace/analysis/charts/granularity_month: ae7bef950efc406ff0980affabc1a64c
workspace/analysis/charts/granularity_quarter: 7a68ec90d7c90b92b7bb873834a00381
workspace/analysis/charts/granularity_week: 436fdd694160827dd6ea4644cdd0a8f8
workspace/analysis/charts/granularity_year: ed86f5f60583f9d8ffdbeed306aa0ec7
workspace/analysis/charts/greater_than: a4c18b3b45fcaf7c83bf489cf2b506d4
workspace/analysis/charts/greater_than_or_equal: d453e26d136847560148168797fece51
workspace/analysis/charts/group_by: 3f1cedea7783018ce83f2fab0051a738
workspace/analysis/charts/group_by_description: a4a85baaca87c172023cbe87e620118b
workspace/analysis/charts/group_data: 55c0035773d8c6b7f4d96363a61cda82
workspace/analysis/charts/is_not_set: 906801489132487ef457652af4835142
workspace/analysis/charts/is_set: 9850468156356f95884bbaf56b6687aa
workspace/analysis/charts/less_than: fb41255dd44bb6de78617b078610c91b
workspace/analysis/charts/less_than_or_equal: da4a2816aadf788d33efcdcc3c61802e
workspace/analysis/charts/measures: b1e6cf0f356dda0052c4fef4ad4957a2
workspace/analysis/charts/no_charts_found: d4a27d5b56e49ebdd38bf28791dbcc42
workspace/analysis/charts/no_dashboards_available: f88389b6c5278cfc4d5b360031205dfe
workspace/analysis/charts/no_dashboards_create_first: 28ded0d72247191eb23f6f77925df539
workspace/analysis/charts/no_data_available: fe1d34a45e22b5611d255b84b2d67232
workspace/analysis/charts/no_data_returned: 683acf7b4f3b32aa85fa26f1bb948d4f
workspace/analysis/charts/no_data_returned_for_chart: b9ff6c85697c683f40b3d0c05eeb2046
workspace/analysis/charts/no_data_source_available: 48179160e288de4a9e00f0bf110a5ced
workspace/analysis/charts/no_grouping: e3a6943e61407600cae057e0833a482d
workspace/analysis/charts/no_valid_data_to_display: d1ba2b0686520c0a2c62ee73daa1c9c9
workspace/analysis/charts/not_contains: 5894f5474271b8902d7892e43500d227
workspace/analysis/charts/not_equals: 427715f1ea349965c36f5c628784eb08
workspace/analysis/charts/open_chart: bc3bed1517ad63c1bcccfbbc430ab333
workspace/analysis/charts/open_options: 2c6a35fec9b9d008e41728594bcd07d7
workspace/analysis/charts/or_filter_logic: 0208d355f231c386b19390f0bea41b95
workspace/analysis/charts/original: 7e55782bdf7cb49f5616b326c003c278
workspace/analysis/charts/please_enter_chart_name: 9258b71b2cb09d22ffe33de1755e7309
workspace/analysis/charts/please_enter_filter_values: ca79dfab463a3836863618fd92f82b3e
workspace/analysis/charts/please_select_at_least_one_dimension: 32ea97a02bb6826947bb70389d1a6231
workspace/analysis/charts/please_select_at_least_one_measure: d4163ede267f71ee65945f453e14ff7b
workspace/analysis/charts/please_select_dashboard: 8f062db96f815ed8268584dd8d292fa6
workspace/analysis/charts/predefined_measures: 7651141f62c991954edcff70899b2a8b
workspace/analysis/charts/preset: a17bb0bf56f3326c9567be3ea896ee19
workspace/analysis/charts/query_executed_successfully: 9d6f9dad526fcfe0161757c2d2fe2c69
workspace/analysis/charts/reset_to_ai_suggestion: 51ced8dd7c0eea8b7fc4e08b35cfbf30
workspace/analysis/charts/save_chart: 2e4505f7bf3d1c35b0b37b1e9d3dc566
workspace/analysis/charts/save_chart_dialog_title: 2e4505f7bf3d1c35b0b37b1e9d3dc566
workspace/analysis/charts/select_data_source: 983394bc0182b65ec68f713a46b97302
workspace/analysis/charts/select_data_source_first: 82a02846de9d6351595c97a0929f3b9a
workspace/analysis/charts/select_dimensions: 6d0d038d027ef9e641bf9b7700edac9f
workspace/analysis/charts/select_field: 45665a44f7d5707506364f17f28db3bf
workspace/analysis/charts/select_measures: c9f101aeb53bf0d4abdd652aaf60a1bf
workspace/analysis/charts/select_preset: e68bad9a209a6ca35c62184f1f1d829c
workspace/analysis/charts/showing_first_n_of: 4dec3215fd3150a16ad5c72f17ae02bc
workspace/analysis/charts/start_date: 881de78c79b56f5ceb9b7103bf23cb2c
workspace/analysis/charts/time_dimension: 5c967f2a6a875b00825068df5cb2ef84
workspace/analysis/charts/time_dimension_title: 9353ce9a075a0cc8c3ba7dfa9ef19a8d
workspace/analysis/charts/time_dimension_toggle_description: 77251d8b3b564390bad8b76f56905190
workspace/analysis/dashboards/add_count_charts: b4ee1f29efce0bb380a060e0bc5d64fa
workspace/analysis/dashboards/charts_add_failed: c4fda79ede798ab6747a989f083a0947
workspace/analysis/dashboards/charts_add_partial_failure: b1a9fc6fe18ab20fe16c16e91a05c195
workspace/analysis/dashboards/charts_added_to_dashboard: 917abab80231adf5af51e812352fc77b
workspace/analysis/dashboards/charts_load_failed: 190bf9c13d3c3cf18126a263591d6757
workspace/analysis/dashboards/create_dashboard: bedb308708fe9c576e161a2fa16d3439
workspace/analysis/dashboards/create_dashboard_description: d29f60615f6d8c96cc4265541e75ec26
workspace/analysis/dashboards/create_failed: 7b58f15568047a35220b3a47cc3b0f71
workspace/analysis/dashboards/create_success: 1fa4dea7702ba03a8a3533295276ff1b
workspace/analysis/dashboards/dashboard: c9380ea68c8c76ea451bd9613329a07c
workspace/analysis/dashboards/dashboard_delete_confirmation: 468a0fb0e24a985cc47a778b50b334ba
workspace/analysis/dashboards/dashboard_name: a2d344bc03f27706b42d7d6a8d0fc752
workspace/analysis/dashboards/dashboard_name_placeholder: 02954eeb5671f1c00e3f69b47319916e
workspace/analysis/dashboards/dashboard_name_required: 4a56c3ce1d73ad915815f5de4bcff566
workspace/analysis/dashboards/dashboard_save_failed: 2b6c7be7947bc7ebb0389b71b5922ba6
workspace/analysis/dashboards/dashboard_saved: 6eb27743b6b12d3d0a20b430319890b8
workspace/analysis/dashboards/delete_confirmation: 468a0fb0e24a985cc47a778b50b334ba
workspace/analysis/dashboards/delete_failed: b108acc28b1f9abcb544a358a958b54b
workspace/analysis/dashboards/delete_success: 9d161634daab9ea9d17fbfb413eeeffa
workspace/analysis/dashboards/duplicate_failed: 6ebaf8ad373b156f88f1ed79a5efd441
workspace/analysis/dashboards/duplicate_success: 37cbb14143776d4c215432673e32ebd9
workspace/analysis/dashboards/failed_to_load_chart_data: ea980a6d12b1b1efed90d991dd0dd0fd
workspace/analysis/dashboards/no_charts_available_description: 796ed01bcb53f770e5f627002839dcb4
workspace/analysis/dashboards/no_charts_to_add_message: ad4cec703aa7d59c407bbb021dce4273
workspace/analysis/dashboards/no_dashboards_found: e049ec0356009c3a0aa2c729d916efc6
workspace/analysis/dashboards/no_data_message: 464d50cf30281a5b6af2726846eb14b4
workspace/analysis/dashboards/please_enter_name: b9211ed8a0882c0e0109beba48685d68
workspace/api_keys/add_api_key: 3c7633bae18a6e19af7a5af12f9bc3da
workspace/api_keys/api_key: ce825fec5b3e1f8e27c45b1a63619985
workspace/api_keys/api_key_copied_to_clipboard: daeeac786ba09ffa650e206609b88f9c
@@ -1865,7 +2067,6 @@ checksums:
workspace/languages/duplicate_language_or_language_id: 0e17e3794b24e2428ca6ffadae0d08f3
workspace/languages/edit_languages: c9d36f6b28557cc7d54e87c37dc18fdd
workspace/languages/identifier: 7d8ade6b85e96216bcd73adeeeeecd8c
workspace/languages/incomplete_translations: d82908b5725f18f5849c7876ad497ebc
workspace/languages/language: 277fd1a41cc237a437cd1d5e4a80463b
workspace/languages/language_deleted_successfully: 4a805d030491f3fe608d2371b0cfcd83
workspace/languages/languages_updated_successfully: 60de474c99c5059c0458cddd0b016c15
@@ -1876,7 +2077,6 @@ checksums:
workspace/languages/remove_language: 1a64563b0f37109f97b78eddd493e381
workspace/languages/remove_language_from_surveys_to_remove_it_from_workspace: 61bc96f9db31a29a649cc9ecd684bc39
workspace/languages/search_items: b54b751c8b075200be579d6c8e58096b
workspace/languages/translate: 59f9803b27e2030ba7323ed239116cf7
workspace/look/add_background_color: 9be512ee1246e32d3958c56097d202d9
workspace/look/add_background_color_description: adb6fcb392862b3d0e9420d9b5405ddb
workspace/look/advanced_styling_field_border_radius: 63b8f3541a9792d705e67d5aca7b6451
@@ -2393,16 +2593,9 @@ checksums:
workspace/settings/teams/you_are_a_member: cf5af638d5371c8fbc337e92519e5150
workspace/surveys/all_set_time_to_create_first_survey: 21d3bb74c3b9642b3195d17c17346399
workspace/surveys/alphabetical: 5fcfeff9c5fd28714f0a390e0ddaaaee
workspace/surveys/copy_survey: de8142b45e7bca61f2dca0069a62b417
workspace/surveys/copy_survey_description: b78f714a4a4baae883210b13fb196bd5
workspace/surveys/copy_survey_error: 74cab7d84ea8b669e106d4c326cac005
workspace/surveys/copy_survey_link_to_clipboard: 77387e3d3de4be07a2a34963f73cd7e8
workspace/surveys/copy_survey_no_workspaces: 6f4547d91b2c14dad83c44b01df365eb
workspace/surveys/copy_survey_partially_success: a436a5fb7167b95c2308794d35aab070
workspace/surveys/copy_survey_success: a829e645fe034b3e712d0b8572a5edc4
workspace/surveys/delete_survey_and_responses_warning: 3320c91c1fd27378b7f3d6abc003f2ae
workspace/surveys/edit/1_choose_the_default_language_for_this_survey: d22759857c1bb3d6b337e8e9d501dad7
workspace/surveys/edit/2_activate_translation_for_specific_languages: 9f23cb81ad301073df45ae36f0d94f9e
workspace/surveys/edit/activate_translations: af127c1bed2b47e2012e3a23e489ecb8
workspace/surveys/edit/add: 5196f5cd4ba3a6ac8edef91345e17f66
workspace/surveys/edit/add_a_delay_or_auto_close_the_survey: b5fa358bf3ff324014060eb0baf6dd2f
workspace/surveys/edit/add_a_four_digit_pin: 953cb3673d2135923e3b4474d33ffb2c
@@ -2452,7 +2645,7 @@ checksums:
workspace/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0
workspace/surveys/edit/auto_close_on_inactivity: 093db516799315ccd4242a3675693012
workspace/surveys/edit/auto_progress_rating_and_nps: 76b98e95a5b850850baa0ccc3c7fbf7c
workspace/surveys/edit/auto_progress_rating_and_nps_description: cbf676789b9f3f47e36bdf35fa58282b
workspace/surveys/edit/auto_progress_rating_and_nps_description: 2a992dd8a5b9532f178f9a21881feb9a
workspace/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
workspace/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
workspace/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
@@ -2498,6 +2691,7 @@ checksums:
workspace/surveys/edit/caution_text: 3291e962c0e4c4656832837ddc512918
workspace/surveys/edit/change_anyway: 6377497d40373f6d0f082670194981ab
workspace/surveys/edit/change_background: fa71a993869f7d3ac553c547c12c3e9b
workspace/surveys/edit/change_default: 6236a6c8a28489ba7c4cad7426806859
workspace/surveys/edit/change_question_type: 2d555ae48df8dbedfc6a4e1ad492f4aa
workspace/surveys/edit/change_survey_type: c26322043a476da6d94adb8b4efe1e93
workspace/surveys/edit/change_the_background_to_a_color_image_or_animation: f1b9c9eb61497dd91b2550dd50c77836
@@ -2510,6 +2704,7 @@ checksums:
workspace/surveys/edit/choose_where_to_run_the_survey: ad87bcae97c445f1fd9ac110ea24f117
workspace/surveys/edit/city: 1831f32e1babbb29af27fac3053504a2
workspace/surveys/edit/close_survey_on_response_limit: 256d0bccdbcbb3d20e39aabc5b376e5e
workspace/surveys/edit/code: 343bc5386149b97cece2b093c39034b2
workspace/surveys/edit/color: 9d53d1d120e8b8954bcae9a322573748
workspace/surveys/edit/column_used_in_logic_error: deffbd3e8f4bd71a5e522682e8ee60dd
workspace/surveys/edit/columns: 14896556dc1535d70198854757f704ec
@@ -2534,6 +2729,7 @@ checksums:
workspace/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2
workspace/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
workspace/surveys/edit/days_before_showing_this_survey_again: 9ee757e5c3a07844b12ceb406dc65b04
workspace/surveys/edit/default_language: 06d01d2598419e36ba97d2d8719f849b
workspace/surveys/edit/delete_anyways: cc8683ab625280eefc9776bd381dc2e8
workspace/surveys/edit/delete_block: c00617cb0724557e486304276063807a
workspace/surveys/edit/delete_choice: fd750208d414b9ad8c980c161a0199e1
@@ -2553,7 +2749,6 @@ checksums:
workspace/surveys/edit/duplicate_question: 910751de01fdd327165968214717711b
workspace/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54
workspace/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318
workspace/surveys/edit/edit_translations: 2b21bea4b53e88342559272701e9fbf3
workspace/surveys/edit/element_not_found: 196777ff6811dd177971ffc8e27a72c1
workspace/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: c70466147d49dcbb3686452f35c46428
workspace/surveys/edit/enable_recaptcha_to_protect_your_survey_from_spam: 4483a5763718d201ac97caa1e1216e13
@@ -2689,11 +2884,13 @@ checksums:
workspace/surveys/edit/long_answer: 3a97f8d2e90aba6e679917a0c5670c53
workspace/surveys/edit/long_answer_toggle_description: 86bcdfeb74d9825c2f2d5a215e92d111
workspace/surveys/edit/lower_label: 45985bca022d4370bd6e013af75d5160
workspace/surveys/edit/manage_languages: 9c56d5afee8a73dfc283a452470f3a10
workspace/surveys/edit/manage_languages: fe82303bc27b55ccfc076b527b185e39
workspace/surveys/edit/manage_translations: 09b01c5c251e6dbc3dc6cd8b33fb6301
workspace/surveys/edit/matrix_all_fields: 187240509163b2f52a400a565e57c67f
workspace/surveys/edit/matrix_rows: 8f41f34e6ca28221cf1ebd948af4c151
workspace/surveys/edit/max_file_size: 3d35a22048f4d22e24da698fb5fb77d7
workspace/surveys/edit/max_file_size_limit_is: 78998639cde3587cecb272ba47e05f9e
workspace/surveys/edit/missing_first: a0c8802636ade7bac86a0dacba00b8d4
workspace/surveys/edit/move_question_to_block: e8d7ef1e2f727921cb7f5788849492ad
workspace/surveys/edit/multiply: 89a0bb629167f97750ae1645a46ced0d
workspace/surveys/edit/needed_for_self_hosted_cal_com_instance: d241e72f0332177d32ce6c35070757dc
@@ -2701,7 +2898,7 @@ checksums:
workspace/surveys/edit/next_button_label: 39f1e82ae1dea5e400e8ed7c98c6ad9c
workspace/surveys/edit/no_hidden_fields_yet_add_first_one_below: 9cc6cab3a6a42dbf835215897b5b8516
workspace/surveys/edit/no_images_found_for: 7dabcbcc7084f59c6ec0971895dfcd29
workspace/surveys/edit/no_languages_found_add_first_one_to_get_started: 22d7782c8504daf693cab3cf7135d6e3
workspace/surveys/edit/no_languages_found_add_first_one_to_get_started: 4e66397232da6a463708220dc020bf42
workspace/surveys/edit/no_option_found: a1a3aa7e6c13b6bb8df20a1a104c7c04
workspace/surveys/edit/no_recall_items_found: 729e2b02e412cdc79f5ad94b1918620c
workspace/surveys/edit/no_variables_yet_add_first_one_below: c8704b9ebc9c26c0e9dd50c099ba88cd
@@ -2728,6 +2925,7 @@ checksums:
workspace/surveys/edit/please_enter_a_valid_url: 25d43dfb802c31cb59dc88453ea72fc4
workspace/surveys/edit/please_set_a_survey_trigger: 0358142df37dd1724f629008a1db453a
workspace/surveys/edit/please_specify: e1faa6cd085144f7339c7e74dc6fb366
workspace/surveys/edit/present_your_survey_in_multiple_languages: 37f28b0a092d68322fedbc2e0c221ef3
workspace/surveys/edit/prevent_double_submission: afc502baa2da81d9c9618da1c3b5a57a
workspace/surveys/edit/prevent_double_submission_description: ef7d2aa22d43bdc6ccebb076c6aa9ce5
workspace/surveys/edit/progress_saved: d7bfc189571f08bbb4d0240cb9363ffa
@@ -2817,6 +3015,7 @@ checksums:
workspace/surveys/edit/seven_points: 4ead50fdfda45e8710767e1b1a84bf42
workspace/surveys/edit/show_block_settings: bad99d99c9908874e45f5c350a88cc79
workspace/surveys/edit/show_button: 6b364aac9d7ac71f34a438607c9693bc
workspace/surveys/edit/show_in_order: 15784a59572eb8a6dba6b918c31a9493
workspace/surveys/edit/show_language_switch: b6915a7f26d7079f2d4d844d74440413
workspace/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60
workspace/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
@@ -2848,7 +3047,6 @@ checksums:
workspace/surveys/edit/survey_preview: 33644451073149383d3ace08be930739
workspace/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
workspace/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
workspace/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
workspace/surveys/edit/target_block_not_found: 0a0c401017ab32364fec2fcbf815d832
workspace/surveys/edit/targeted: ca615f1fc3b490d5a2187b27fb4a2073
workspace/surveys/edit/ten_points: a1317b82003859f77fb3138c55450d63
@@ -2856,9 +3054,11 @@ checksums:
workspace/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: e45beba7ae126775f4966776c982a3b4
workspace/surveys/edit/then: 5e941fb7dd51a18651fcfb865edd5ba6
workspace/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987
workspace/surveys/edit/this_will_remove_the_language_and_all_its_translations: 6a71ae70abbd61f13f15323d825a47f6
workspace/surveys/edit/three_points: d7f299aec752d7d690ef0ab6373327ae
workspace/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a
workspace/surveys/edit/to_keep_the_placement_over_all_surveys_consistent_you_can: 7a078e6a39d4c30b465137d2b6ef3e67
workspace/surveys/edit/translated: 5b9d805410310b726f12bacb06da44e3
workspace/surveys/edit/trigger_survey_when_one_of_the_actions_is_fired: 8570291668ec9879d204f10e861112db
workspace/surveys/edit/try_lollipop_or_mountain: c550a0f07b3ae40a237e30a4314a249c
workspace/surveys/edit/type_field_id: 714b845806236bb8a9d6a09933b836e9
@@ -2931,6 +3131,7 @@ checksums:
workspace/surveys/edit/verify_email_before_submission_description: 434ab3ee6134367513b633a9d4f7d772
workspace/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84
workspace/surveys/edit/visibility_and_recontact_description: 2969ab679e1f6111dd96e95cee26e219
workspace/surveys/edit/visible: 54ea1310fe55664c24a712eb17070fbd
workspace/surveys/edit/wait: 014d18ade977bf08d75b995076596708
workspace/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919
workspace/surveys/edit/waiting_time_across_surveys: 6873c18d51830e2cadef67cce6a2c95c
@@ -3106,6 +3307,8 @@ checksums:
workspace/surveys/summary/configure_alerts: 05cc642cd9398034d7e68589d22d97cf
workspace/surveys/summary/congrats: 378f06fe96289e527153f8201088ff74
workspace/surveys/summary/connect_your_website_or_app_with_formbricks_to_get_started: d24183c86d08b16d58daa8ad887b2837
workspace/surveys/summary/csat_satisfied: 4d6121afdc705a70465a230d6d1f6217
workspace/surveys/summary/csat_satisfied_tooltip: 3a69a76559a40fbbdff14525d83b459c
workspace/surveys/summary/current_count: 6a3e59de8559e88e991e0aeafa9cfeec
workspace/surveys/summary/custom_range: 9bc7e02a890644b13b5c0b0bdd96c165
workspace/surveys/summary/delete_all_existing_responses_and_displays: e346bcbdb1e0dfbce5925e19fdf0cc78
@@ -3113,7 +3316,7 @@ checksums:
workspace/surveys/summary/downloading_qr_code: 3c46bf636e617848a4fca9b6c5b51dac
workspace/surveys/summary/drop_offs: 605ee950f82110132d6c5780926af109
workspace/surveys/summary/drop_offs_tooltip: 2a01683380be45f17636365886cf3452
workspace/surveys/summary/failed_to_copy_link: 4e891c757c80e770674e8e74d1c08487
workspace/surveys/summary/effort_score: b79157d02a8ead85459c158272951ab5
workspace/surveys/summary/filter_added_successfully: e247f65020cd87454bcec0da6f0fd034
workspace/surveys/summary/filter_updated_successfully: 01146bc7e6394e271836be2f1b3a257b
workspace/surveys/summary/filtered_responses_csv: aad66a98be6a09cac8bef9e4db4a75cf
@@ -3163,6 +3366,7 @@ checksums:
workspace/surveys/summary/limit: 347051f1a068e01e8c4e4f6744d8e727
workspace/surveys/summary/no_identified_impressions: c3bc42e6feb9010ced905ded51c5afc4
workspace/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
workspace/surveys/summary/nps_promoters_tooltip: dea6a683c0c36189e325656d5a7596b8
workspace/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
workspace/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
workspace/surveys/summary/promoters: 41fbb8d0439227661253a82fda39f521
@@ -3175,7 +3379,6 @@ checksums:
workspace/surveys/summary/quotas_completed_tooltip: ec5c4dc67eda27c06764354f695db613
workspace/surveys/summary/reset_survey: 8c88ddb81f5f787d183d2e7cb43e7c64
workspace/surveys/summary/reset_survey_warning: 6b44be171d7e2716f234387b100b173d
workspace/surveys/summary/satisfied: 4d542ba354b85e644acbca5691d2ce45
workspace/surveys/summary/selected_responses_csv: 9cef3faccd54d4f24647791e6359db90
workspace/surveys/summary/selected_responses_excel: a0ade8b2658e887a4a3f2ad3bdb0c686
workspace/surveys/summary/setup_integrations: 70de06d73be671a0cd58a3fd4fa62e53
@@ -3198,7 +3401,6 @@ checksums:
workspace/surveys/summary/youre_not_plugged_in_yet: f19da3cd474b9a3cf28e956fd811fb00
workspace/surveys/survey_deleted_successfully: a6b654cc914b344a4475fd2fd4a98cc5
workspace/surveys/survey_duplicated_successfully: 91e244f1e7a33640bb4817166a01ff46
workspace/surveys/survey_duplication_error: 35994330aed844ce37d8b4f09df24581
workspace/surveys/templates/all_channels: 6be67a82fc7326dc2304b23ab3348b87
workspace/surveys/templates/all_industries: c7354412fe34585526ff2232aadace41
workspace/surveys/templates/all_roles: 6582ccd0a2349c162a7ae1574cdf76be
+253
View File
@@ -0,0 +1,253 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const mockStartJobsRuntime = vi.fn();
const mockDebug = vi.fn();
const mockError = vi.fn();
const mockWarn = vi.fn();
const mockGetJobsWorkerBootstrapConfig = vi.fn();
const mockProcessResponsePipelineJob = vi.fn();
const TEST_TIMEOUT_MS = 15_000;
const slowTest = (name: string, fn: () => Promise<void>): void => {
test(name, fn, TEST_TIMEOUT_MS);
};
vi.mock("@formbricks/jobs", () => ({
startJobsRuntime: mockStartJobsRuntime,
}));
vi.mock("@/lib/jobs/config", () => ({
getJobsWorkerBootstrapConfig: mockGetJobsWorkerBootstrapConfig,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
debug: mockDebug,
error: mockError,
info: vi.fn(),
warn: mockWarn,
},
}));
vi.mock("@/modules/response-pipeline/lib/process-response-pipeline-job", () => ({
processResponsePipelineJob: mockProcessResponsePipelineJob,
}));
describe("instrumentation-jobs", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(async () => {
const { resetJobsWorkerRegistrationForTests } = await import("./instrumentation-jobs");
await resetJobsWorkerRegistrationForTests();
vi.useRealTimers();
});
slowTest("skips worker startup when disabled", async () => {
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: false,
runtimeOptions: null,
});
const { registerJobsWorker } = await import("./instrumentation-jobs");
const result = await registerJobsWorker();
expect(result).toBeNull();
expect(mockStartJobsRuntime).not.toHaveBeenCalled();
expect(mockDebug).toHaveBeenCalledWith("BullMQ worker startup skipped");
});
slowTest("starts the worker only once", async () => {
const mockRuntime = {
close: vi.fn().mockResolvedValue(undefined),
};
const mockExistingOverride = vi.fn();
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: true,
runtimeOptions: {
concurrency: 4,
jobHandlerOverrides: {
"test-log.process": mockExistingOverride,
},
redisUrl: "redis://localhost:6379",
workerCount: 2,
},
});
mockStartJobsRuntime.mockResolvedValue(mockRuntime);
const { registerJobsWorker } = await import("./instrumentation-jobs");
const first = await registerJobsWorker();
const second = await registerJobsWorker();
expect(first).toBe(mockRuntime);
expect(second).toBe(mockRuntime);
expect(mockStartJobsRuntime).toHaveBeenCalledTimes(1);
expect(mockStartJobsRuntime).toHaveBeenCalledWith({
concurrency: 4,
jobHandlerOverrides: {
"test-log.process": mockExistingOverride,
"response-pipeline.process": expect.any(Function),
},
redisUrl: "redis://localhost:6379",
workerCount: 2,
});
const overrides = mockStartJobsRuntime.mock.calls[0]?.[0]?.jobHandlerOverrides;
const responsePipelineOverride = overrides?.["response-pipeline.process"];
expect(responsePipelineOverride).toBeTypeOf("function");
await responsePipelineOverride?.(
{
environmentId: "env_123",
event: "responseCreated",
response: { id: "res_123" },
surveyId: "survey_123",
},
{
attempt: 1,
jobId: "job_123",
jobName: "response-pipeline.process",
maxAttempts: 3,
queueName: "background-jobs",
}
);
expect(mockProcessResponsePipelineJob).toHaveBeenCalledWith(
{
environmentId: "env_123",
event: "responseCreated",
response: { id: "res_123" },
surveyId: "survey_123",
},
{
attempt: 1,
jobId: "job_123",
jobName: "response-pipeline.process",
maxAttempts: 3,
queueName: "background-jobs",
}
);
});
slowTest("reuses the in-flight startup promise", async () => {
const mockRuntime = {
close: vi.fn().mockResolvedValue(undefined),
};
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: true,
runtimeOptions: {
concurrency: 2,
redisUrl: "redis://localhost:6379",
workerCount: 1,
},
});
let resolveRuntime: ((value: typeof mockRuntime) => void) | undefined;
mockStartJobsRuntime.mockReturnValue(
new Promise((resolve) => {
resolveRuntime = resolve;
})
);
const { registerJobsWorker } = await import("./instrumentation-jobs");
const firstPromise = registerJobsWorker();
const secondPromise = registerJobsWorker();
expect(mockStartJobsRuntime).toHaveBeenCalledTimes(1);
resolveRuntime?.(mockRuntime);
await expect(firstPromise).resolves.toBe(mockRuntime);
await expect(secondPromise).resolves.toBe(mockRuntime);
});
slowTest("logs and rethrows startup failures", async () => {
const startupError = new Error("startup failed");
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: true,
runtimeOptions: {
concurrency: 1,
redisUrl: "redis://localhost:6379",
workerCount: 1,
},
});
mockStartJobsRuntime.mockRejectedValue(startupError);
const { registerJobsWorker } = await import("./instrumentation-jobs");
await expect(registerJobsWorker()).rejects.toThrow("startup failed");
expect(mockError).toHaveBeenCalledWith({ err: startupError }, "BullMQ worker registration failed");
expect(mockWarn).toHaveBeenCalledWith(
{ retryDelayMs: 30_000 },
"BullMQ worker registration retry scheduled"
);
});
slowTest("retries worker startup after a transient failure", async () => {
const startupError = new Error("startup failed");
const recoveredRuntime = {
close: vi.fn().mockResolvedValue(undefined),
};
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: true,
runtimeOptions: {
concurrency: 1,
redisUrl: "redis://localhost:6379",
workerCount: 1,
},
});
mockStartJobsRuntime.mockRejectedValueOnce(startupError).mockResolvedValueOnce(recoveredRuntime);
const { registerJobsWorker } = await import("./instrumentation-jobs");
await expect(registerJobsWorker()).rejects.toThrow("startup failed");
await vi.advanceTimersByTimeAsync(30_000);
expect(mockStartJobsRuntime).toHaveBeenCalledTimes(2);
await expect(registerJobsWorker()).resolves.toBe(recoveredRuntime);
});
slowTest("clears registration state even when reset close fails", async () => {
const failingRuntime = {
close: vi.fn().mockRejectedValue(new Error("close failed")),
};
const nextRuntime = {
close: vi.fn().mockResolvedValue(undefined),
};
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
enabled: true,
runtimeOptions: {
concurrency: 1,
redisUrl: "redis://localhost:6379",
workerCount: 1,
},
});
mockStartJobsRuntime.mockResolvedValueOnce(failingRuntime).mockResolvedValueOnce(nextRuntime);
const { registerJobsWorker, resetJobsWorkerRegistrationForTests } =
await import("./instrumentation-jobs");
await expect(registerJobsWorker()).resolves.toBe(failingRuntime);
await expect(resetJobsWorkerRegistrationForTests()).resolves.toBeUndefined();
await expect(registerJobsWorker()).resolves.toBe(nextRuntime);
expect(mockStartJobsRuntime).toHaveBeenCalledTimes(2);
expect(mockError).toHaveBeenCalledWith(
{ err: expect.any(Error) },
"BullMQ worker test reset close failed"
);
});
});
+134
View File
@@ -0,0 +1,134 @@
import {
type JobHandlerOverrides,
type JobsRuntimeHandle,
type TResponsePipelineJobData,
startJobsRuntime,
} from "@formbricks/jobs";
import { logger } from "@formbricks/logger";
import { getJobsWorkerBootstrapConfig } from "@/lib/jobs/config";
import { processResponsePipelineJob } from "@/modules/response-pipeline/lib/process-response-pipeline-job";
const WORKER_STARTUP_RETRY_DELAY_MS = 30_000;
type TJobsRuntimeGlobal = typeof globalThis & {
formbricksJobsRuntime: JobsRuntimeHandle | undefined;
formbricksJobsRuntimeInitializing: Promise<JobsRuntimeHandle> | undefined;
formbricksJobsRuntimeRetryTimeout: ReturnType<typeof setTimeout> | undefined;
};
const globalForJobsRuntime = globalThis as TJobsRuntimeGlobal;
const RESPONSE_PIPELINE_JOB_NAME = "response-pipeline.process";
const responsePipelineJobHandler: NonNullable<JobHandlerOverrides[string]> = async (data, context) => {
await processResponsePipelineJob(data as TResponsePipelineJobData, context);
};
const clearJobsWorkerRetryTimeout = (): void => {
if (globalForJobsRuntime.formbricksJobsRuntimeRetryTimeout) {
clearTimeout(globalForJobsRuntime.formbricksJobsRuntimeRetryTimeout);
globalForJobsRuntime.formbricksJobsRuntimeRetryTimeout = undefined;
}
};
const scheduleJobsWorkerRetry = (): void => {
if (
globalForJobsRuntime.formbricksJobsRuntime ||
globalForJobsRuntime.formbricksJobsRuntimeInitializing ||
globalForJobsRuntime.formbricksJobsRuntimeRetryTimeout
) {
return;
}
globalForJobsRuntime.formbricksJobsRuntimeRetryTimeout = setTimeout(() => {
globalForJobsRuntime.formbricksJobsRuntimeRetryTimeout = undefined;
void registerJobsWorker().catch(() => undefined);
}, WORKER_STARTUP_RETRY_DELAY_MS);
logger.warn({ retryDelayMs: WORKER_STARTUP_RETRY_DELAY_MS }, "BullMQ worker registration retry scheduled");
};
export const registerJobsWorker = async (): Promise<JobsRuntimeHandle | null> => {
const jobsWorkerBootstrapConfig = getJobsWorkerBootstrapConfig();
if (!jobsWorkerBootstrapConfig.enabled || !jobsWorkerBootstrapConfig.runtimeOptions) {
clearJobsWorkerRetryTimeout();
logger.debug("BullMQ worker startup skipped");
return null;
}
if (globalForJobsRuntime.formbricksJobsRuntime) {
return globalForJobsRuntime.formbricksJobsRuntime;
}
if (globalForJobsRuntime.formbricksJobsRuntimeInitializing) {
return await globalForJobsRuntime.formbricksJobsRuntimeInitializing;
}
const runtimeOptions = jobsWorkerBootstrapConfig.runtimeOptions;
const jobHandlerOverrides: JobHandlerOverrides = runtimeOptions.jobHandlerOverrides
? {
...runtimeOptions.jobHandlerOverrides,
[RESPONSE_PIPELINE_JOB_NAME]: responsePipelineJobHandler,
}
: {
[RESPONSE_PIPELINE_JOB_NAME]: responsePipelineJobHandler,
};
globalForJobsRuntime.formbricksJobsRuntimeInitializing = startJobsRuntime({
...runtimeOptions,
jobHandlerOverrides,
}).then((runtime) => {
clearJobsWorkerRetryTimeout();
globalForJobsRuntime.formbricksJobsRuntime = runtime;
globalForJobsRuntime.formbricksJobsRuntimeInitializing = undefined;
return runtime;
});
try {
return await globalForJobsRuntime.formbricksJobsRuntimeInitializing;
} catch (error) {
globalForJobsRuntime.formbricksJobsRuntimeInitializing = undefined;
logger.error({ err: error }, "BullMQ worker registration failed");
scheduleJobsWorkerRetry();
throw error;
}
};
export const resetJobsWorkerRegistrationForTests = async (): Promise<void> => {
const runtime = globalForJobsRuntime.formbricksJobsRuntime;
const initializing = globalForJobsRuntime.formbricksJobsRuntimeInitializing;
clearJobsWorkerRetryTimeout();
globalForJobsRuntime.formbricksJobsRuntime = undefined;
globalForJobsRuntime.formbricksJobsRuntimeInitializing = undefined;
const runtimesToClose = new Set<JobsRuntimeHandle>();
if (runtime) {
runtimesToClose.add(runtime);
}
if (initializing) {
try {
const initializedRuntime = await initializing;
runtimesToClose.add(initializedRuntime);
} catch {
// Startup failures are already surfaced by the test that triggered them.
}
}
if (globalForJobsRuntime.formbricksJobsRuntime) {
runtimesToClose.add(globalForJobsRuntime.formbricksJobsRuntime);
}
globalForJobsRuntime.formbricksJobsRuntime = undefined;
globalForJobsRuntime.formbricksJobsRuntimeInitializing = undefined;
await Promise.all(
[...runtimesToClose].map(async (runtimeHandle) => {
try {
await runtimeHandle.close();
} catch (error) {
logger.error({ err: error }, "BullMQ worker test reset close failed");
}
})
);
};
+46
View File
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mockRegisterJobsWorker = vi.fn();
vi.mock("@sentry/nextjs", () => ({
captureRequestError: vi.fn(),
}));
vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
PROMETHEUS_ENABLED: false,
SENTRY_DSN: undefined,
}));
vi.mock("./instrumentation-jobs", () => ({
registerJobsWorker: mockRegisterJobsWorker,
}));
describe("instrumentation register", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
process.env.NEXT_RUNTIME = "nodejs";
delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
});
test("does not block Next.js boot on BullMQ worker startup", async () => {
mockRegisterJobsWorker.mockReturnValue(new Promise(() => undefined));
const { register } = await import("./instrumentation");
await expect(register()).resolves.toBeUndefined();
expect(mockRegisterJobsWorker).toHaveBeenCalledTimes(1);
});
test("swallows BullMQ worker startup rejections after triggering background registration", async () => {
mockRegisterJobsWorker.mockRejectedValue(new Error("startup failed"));
const { register } = await import("./instrumentation");
await expect(register()).resolves.toBeUndefined();
await Promise.resolve();
expect(mockRegisterJobsWorker).toHaveBeenCalledTimes(1);
});
});
+10
View File
@@ -1,5 +1,6 @@
import * as Sentry from "@sentry/nextjs";
import { type Instrumentation } from "next";
import { logger } from "@formbricks/logger";
import { isExpectedError } from "@formbricks/types/errors";
import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@/lib/constants";
@@ -21,6 +22,15 @@ export const register = async () => {
if (PROMETHEUS_ENABLED || process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
await import("./instrumentation-node");
}
try {
const { registerJobsWorker } = await import("./instrumentation-jobs");
void registerJobsWorker().catch((error: unknown) => {
logger.error({ err: error }, "BullMQ worker registration failed during Next.js instrumentation");
});
} catch (error) {
logger.error({ err: error }, "BullMQ worker registration failed during Next.js instrumentation");
}
}
// Sentry init loads after OTEL to avoid TracerProvider conflicts
// Sentry tracing is disabled (tracesSampleRate: 0) -- SigNoz handles distributed tracing
+32 -18
View File
@@ -11,6 +11,8 @@ import {
verifyPassword,
} from "./auth";
const PASSWORD_TEST_TIMEOUT_MS = 30_000;
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -21,26 +23,38 @@ vi.mock("@formbricks/database", () => ({
}));
describe("Password Management", () => {
test("hashPassword should hash a password", async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
expect(hashedPassword).toBeDefined();
expect(hashedPassword).not.toBe(password);
});
test(
"hashPassword should hash a password",
async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
expect(hashedPassword).toBeDefined();
expect(hashedPassword).not.toBe(password);
},
PASSWORD_TEST_TIMEOUT_MS
);
test("verifyPassword should verify a correct password", async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword(password, hashedPassword);
expect(isValid).toBe(true);
});
test(
"verifyPassword should verify a correct password",
async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword(password, hashedPassword);
expect(isValid).toBe(true);
},
PASSWORD_TEST_TIMEOUT_MS
);
test("verifyPassword should reject an incorrect password", async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword("wrongPassword", hashedPassword);
expect(isValid).toBe(false);
});
test(
"verifyPassword should reject an incorrect password",
async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword("wrongPassword", hashedPassword);
expect(isValid).toBe(false);
},
PASSWORD_TEST_TIMEOUT_MS
);
});
describe("Organization Access", () => {
+1
View File
@@ -185,6 +185,7 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"ro-RO",
"ru-RU",
"sv-SE",
"tr-TR",
"zh-Hans-CN",
"zh-Hant-TW",
];
+48 -30
View File
@@ -14,6 +14,8 @@ import {
verifySecret,
} from "./crypto";
const SECRET_HASH_TEST_TIMEOUT_MS = 30_000;
// Unmock crypto for these tests since we want to test the actual crypto functions
vi.unmock("crypto");
@@ -26,45 +28,61 @@ vi.mock("@formbricks/logger", () => ({
describe("Crypto Utils", () => {
describe("hashSecret and verifySecret", () => {
test("should hash and verify secrets correctly", async () => {
const secret = "test-secret-123";
const hash = await hashSecret(secret);
test(
"should hash and verify secrets correctly",
async () => {
const secret = "test-secret-123";
const hash = await hashSecret(secret);
expect(hash).toMatch(/^\$2[aby]\$\d+\$[./A-Za-z0-9]{53}$/);
expect(hash).toMatch(/^\$2[aby]\$\d+\$[./A-Za-z0-9]{53}$/);
const isValid = await verifySecret(secret, hash);
expect(isValid).toBe(true);
});
const isValid = await verifySecret(secret, hash);
expect(isValid).toBe(true);
},
SECRET_HASH_TEST_TIMEOUT_MS
);
test("should reject wrong secrets", async () => {
const secret = "test-secret-123";
const wrongSecret = "wrong-secret";
const hash = await hashSecret(secret);
test(
"should reject wrong secrets",
async () => {
const secret = "test-secret-123";
const wrongSecret = "wrong-secret";
const hash = await hashSecret(secret);
const isValid = await verifySecret(wrongSecret, hash);
expect(isValid).toBe(false);
});
const isValid = await verifySecret(wrongSecret, hash);
expect(isValid).toBe(false);
},
SECRET_HASH_TEST_TIMEOUT_MS
);
test("should generate different hashes for the same secret (due to salt)", async () => {
const secret = "test-secret-123";
const hash1 = await hashSecret(secret);
const hash2 = await hashSecret(secret);
test(
"should generate different hashes for the same secret (due to salt)",
async () => {
const secret = "test-secret-123";
const hash1 = await hashSecret(secret);
const hash2 = await hashSecret(secret);
expect(hash1).not.toBe(hash2);
expect(hash1).not.toBe(hash2);
// But both should verify correctly
expect(await verifySecret(secret, hash1)).toBe(true);
expect(await verifySecret(secret, hash2)).toBe(true);
}, 15000);
// But both should verify correctly
expect(await verifySecret(secret, hash1)).toBe(true);
expect(await verifySecret(secret, hash2)).toBe(true);
},
SECRET_HASH_TEST_TIMEOUT_MS
);
test("should use custom cost factor", async () => {
const secret = "test-secret-123";
const hash = await hashSecret(secret, 10);
test(
"should use custom cost factor",
async () => {
const secret = "test-secret-123";
const hash = await hashSecret(secret, 10);
// Verify the cost factor is in the hash
expect(hash).toMatch(/^\$2[aby]\$10\$/);
expect(await verifySecret(secret, hash)).toBe(true);
});
// Verify the cost factor is in the hash
expect(hash).toMatch(/^\$2[aby]\$10\$/);
expect(await verifySecret(secret, hash)).toBe(true);
},
SECRET_HASH_TEST_TIMEOUT_MS
);
test("should return false for invalid hash format", async () => {
const secret = "test-secret-123";
+11 -1
View File
@@ -8,6 +8,7 @@ const setTestEnv = (overrides: Record<string, string | undefined> = {}) => {
NODE_ENV: "test",
DATABASE_URL: "https://example.com/db",
ENCRYPTION_KEY: "12345678901234567890123456789012",
HUB_API_URL: "https://hub.formbricks.local",
...overrides,
};
};
@@ -21,13 +22,22 @@ describe("env", () => {
process.env = ORIGINAL_ENV;
});
test("allows ambient DEBUG values from external tooling", async () => {
setTestEnv({
DEBUG: "pnpm:*",
});
const { env } = await import("./env");
expect(env.DEBUG).toBe("pnpm:*");
});
test("uses the default password reset token lifetime when env var is not set", async () => {
setTestEnv({
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: undefined,
});
const { env } = await import("./env");
expect(env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES).toBe(30);
});
+11 -1
View File
@@ -124,10 +124,16 @@ const parsedEnv = createEnv({
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(),
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
DEBUG: z.enum(["1", "0"]).optional(),
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
// DEBUG is a common ambient env var in CI/tooling, so we accept arbitrary strings here
// and only treat "1" as enabling Formbricks-specific debug behavior downstream.
DEBUG: z.string().optional(),
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
BULLMQ_WORKER_CONCURRENCY: z.coerce.number().int().min(1).optional(),
BULLMQ_WORKER_COUNT: z.coerce.number().int().min(1).optional(),
BULLMQ_EXTERNAL_WORKER_ENABLED: z.enum(["1", "0"]).optional(),
BULLMQ_WORKER_ENABLED: z.enum(["1", "0"]).optional(),
E2E_TESTING: z.enum(["1", "0"]).optional(),
EMAIL_AUTH_DISABLED: z.enum(["1", "0"]).optional(),
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
@@ -274,6 +280,10 @@ const parsedEnv = createEnv({
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
BULLMQ_EXTERNAL_WORKER_ENABLED: process.env.BULLMQ_EXTERNAL_WORKER_ENABLED,
BULLMQ_WORKER_CONCURRENCY: process.env.BULLMQ_WORKER_CONCURRENCY,
BULLMQ_WORKER_COUNT: process.env.BULLMQ_WORKER_COUNT,
BULLMQ_WORKER_ENABLED: process.env.BULLMQ_WORKER_ENABLED,
E2E_TESTING: process.env.E2E_TESTING,
EMAIL_AUTH_DISABLED: process.env.EMAIL_AUTH_DISABLED,
EMAIL_VERIFICATION_DISABLED: process.env.EMAIL_VERIFICATION_DISABLED,
+7
View File
@@ -213,6 +213,13 @@ export const appLanguages = [
native: "Svenska",
},
},
{
code: "tr-TR",
label: {
"en-US": "Turkish",
native: "Türkçe",
},
},
{
code: "zh-Hans-CN",
label: {
+180
View File
@@ -0,0 +1,180 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const TEST_TIMEOUT_MS = 15_000;
describe("jobs runtime config", () => {
beforeEach(() => {
vi.resetModules();
});
test(
"defaults to one worker with concurrency one outside tests",
async () => {
vi.doMock("@/lib/env", () => ({
env: {
BULLMQ_EXTERNAL_WORKER_ENABLED: undefined,
BULLMQ_WORKER_CONCURRENCY: undefined,
BULLMQ_WORKER_COUNT: undefined,
BULLMQ_WORKER_ENABLED: undefined,
NODE_ENV: "development",
REDIS_URL: "redis://localhost:6379",
},
}));
const { getJobsQueueingConfig, getJobsWorkerBootstrapConfig } = await import("./config");
expect(getJobsWorkerBootstrapConfig()).toEqual({
enabled: true,
runtimeOptions: {
concurrency: 1,
redisUrl: "redis://localhost:6379",
workerCount: 1,
},
});
expect(getJobsQueueingConfig()).toEqual({
enabled: true,
redisUrl: "redis://localhost:6379",
});
},
TEST_TIMEOUT_MS
);
test(
"disables the worker by default in tests",
async () => {
vi.doMock("@/lib/env", () => ({
env: {
BULLMQ_EXTERNAL_WORKER_ENABLED: undefined,
BULLMQ_WORKER_CONCURRENCY: undefined,
BULLMQ_WORKER_COUNT: undefined,
BULLMQ_WORKER_ENABLED: undefined,
NODE_ENV: "test",
REDIS_URL: undefined,
},
}));
const { getJobsQueueingConfig, getJobsWorkerBootstrapConfig } = await import("./config");
expect(getJobsWorkerBootstrapConfig()).toEqual({
enabled: false,
runtimeOptions: null,
});
expect(getJobsQueueingConfig()).toEqual({
enabled: false,
redisUrl: null,
});
},
TEST_TIMEOUT_MS
);
test(
"uses explicit worker tuning overrides",
async () => {
vi.doMock("@/lib/env", () => ({
env: {
BULLMQ_EXTERNAL_WORKER_ENABLED: undefined,
BULLMQ_WORKER_CONCURRENCY: 6,
BULLMQ_WORKER_COUNT: 3,
BULLMQ_WORKER_ENABLED: "1",
NODE_ENV: "production",
REDIS_URL: "redis://cache.internal:6379",
},
}));
const { getJobsQueueingConfig, getJobsWorkerBootstrapConfig } = await import("./config");
expect(getJobsWorkerBootstrapConfig()).toEqual({
enabled: true,
runtimeOptions: {
concurrency: 6,
redisUrl: "redis://cache.internal:6379",
workerCount: 3,
},
});
expect(getJobsQueueingConfig()).toEqual({
enabled: true,
redisUrl: "redis://cache.internal:6379",
});
},
TEST_TIMEOUT_MS
);
test(
"disables queueing when no BullMQ consumer is configured",
async () => {
vi.doMock("@/lib/env", () => ({
env: {
BULLMQ_EXTERNAL_WORKER_ENABLED: undefined,
BULLMQ_WORKER_CONCURRENCY: 6,
BULLMQ_WORKER_COUNT: 3,
BULLMQ_WORKER_ENABLED: "0",
NODE_ENV: "production",
REDIS_URL: "redis://cache.internal:6379",
},
}));
const { getJobsQueueingConfig, getJobsWorkerBootstrapConfig } = await import("./config");
expect(getJobsWorkerBootstrapConfig()).toEqual({
enabled: false,
runtimeOptions: null,
});
expect(getJobsQueueingConfig()).toEqual({
enabled: false,
redisUrl: null,
});
},
TEST_TIMEOUT_MS
);
test(
"keeps queueing enabled when an external BullMQ worker is configured",
async () => {
vi.doMock("@/lib/env", () => ({
env: {
BULLMQ_EXTERNAL_WORKER_ENABLED: "1",
BULLMQ_WORKER_CONCURRENCY: 6,
BULLMQ_WORKER_COUNT: 3,
BULLMQ_WORKER_ENABLED: "0",
NODE_ENV: "production",
REDIS_URL: "redis://cache.internal:6379",
},
}));
const { getJobsQueueingConfig, getJobsWorkerBootstrapConfig } = await import("./config");
expect(getJobsWorkerBootstrapConfig()).toEqual({
enabled: false,
runtimeOptions: null,
});
expect(getJobsQueueingConfig()).toEqual({
enabled: true,
redisUrl: "redis://cache.internal:6379",
});
},
TEST_TIMEOUT_MS
);
test(
"throws when the worker is enabled without a redis url",
async () => {
vi.doMock("@/lib/env", () => ({
env: {
BULLMQ_EXTERNAL_WORKER_ENABLED: undefined,
BULLMQ_WORKER_CONCURRENCY: 2,
BULLMQ_WORKER_COUNT: 1,
BULLMQ_WORKER_ENABLED: "1",
NODE_ENV: "production",
REDIS_URL: undefined,
},
}));
const { getJobsWorkerBootstrapConfig } = await import("./config");
expect(() => getJobsWorkerBootstrapConfig()).toThrow(
"REDIS_URL is required to start the BullMQ worker"
);
},
TEST_TIMEOUT_MS
);
});
+68
View File
@@ -0,0 +1,68 @@
import "server-only";
import type { JobsRuntimeOptions } from "@formbricks/jobs";
import { env } from "@/lib/env";
const DEFAULT_BULLMQ_WORKER_CONCURRENCY = 1;
const DEFAULT_BULLMQ_WORKER_COUNT = 1;
export interface JobsWorkerBootstrapConfig {
enabled: boolean;
runtimeOptions: JobsRuntimeOptions | null;
}
export interface JobsQueueingConfig {
enabled: boolean;
redisUrl: string | null;
}
export const BULLMQ_WORKER_CONCURRENCY = env.BULLMQ_WORKER_CONCURRENCY ?? DEFAULT_BULLMQ_WORKER_CONCURRENCY;
export const BULLMQ_WORKER_COUNT = env.BULLMQ_WORKER_COUNT ?? DEFAULT_BULLMQ_WORKER_COUNT;
const getBullMqWorkerEnabled = (): boolean => {
if (env.BULLMQ_WORKER_ENABLED !== undefined) {
return env.BULLMQ_WORKER_ENABLED === "1";
}
return env.NODE_ENV !== "test";
};
export const BULLMQ_WORKER_ENABLED = getBullMqWorkerEnabled();
export const BULLMQ_EXTERNAL_WORKER_ENABLED = env.BULLMQ_EXTERNAL_WORKER_ENABLED === "1";
const hasBullMqConsumer = (): boolean => BULLMQ_WORKER_ENABLED || BULLMQ_EXTERNAL_WORKER_ENABLED;
export const getJobsQueueingConfig = (): JobsQueueingConfig => {
if (!env.REDIS_URL || !hasBullMqConsumer()) {
return {
enabled: false,
redisUrl: null,
};
}
return {
enabled: true,
redisUrl: env.REDIS_URL,
};
};
export const getJobsWorkerBootstrapConfig = (): JobsWorkerBootstrapConfig => {
if (!BULLMQ_WORKER_ENABLED) {
return {
enabled: false,
runtimeOptions: null,
};
}
if (!env.REDIS_URL) {
throw new Error("REDIS_URL is required to start the BullMQ worker");
}
return {
enabled: true,
runtimeOptions: {
concurrency: BULLMQ_WORKER_CONCURRENCY,
redisUrl: env.REDIS_URL,
workerCount: BULLMQ_WORKER_COUNT,
},
};
};
+35 -14
View File
@@ -93,6 +93,14 @@ export const getResponseContact = (
};
};
const mapResponsePrismaToResponse = (
responsePrisma: Prisma.ResponseGetPayload<{ select: typeof responseSelection }>
): TResponse => ({
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
});
export const getResponsesByContactId = reactCache(
async (contactId: string, page?: number): Promise<TResponseWithQuotas[]> => {
validateInputs([contactId, ZId], [page, ZOptionalNumber]);
@@ -172,13 +180,7 @@ export const getResponseBySingleUseId = reactCache(
return null;
}
const response: TResponse = {
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
return response;
return mapResponsePrismaToResponse(responsePrisma);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -204,13 +206,7 @@ export const getResponse = reactCache(async (responseId: string): Promise<TRespo
return null;
}
const response: TResponse = {
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
return response;
return mapResponsePrismaToResponse(responsePrisma);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -220,6 +216,31 @@ export const getResponse = reactCache(async (responseId: string): Promise<TRespo
}
});
export const getResponseSnapshotForPipeline = async (responseId: string): Promise<TResponse | null> => {
validateInputs([responseId, ZId]);
try {
const responsePrisma = await prisma.response.findUnique({
where: {
id: responseId,
},
select: responseSelection,
});
if (!responsePrisma) {
return null;
}
return mapResponsePrismaToResponse(responsePrisma);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getResponseFilteringValues = reactCache(async (surveyId: string) => {
validateInputs([surveyId, ZId]);
+21 -15
View File
@@ -36,6 +36,8 @@ import {
updateSurveyInternal,
} from "./service";
const SURVEY_SERVICE_TEST_TIMEOUT_MS = 15_000;
// Mock organization service
vi.mock("@/lib/organization/service", () => ({
getOrganizationByWorkspaceId: vi.fn().mockResolvedValue({
@@ -1007,21 +1009,25 @@ describe("updateSurveyDraftAction", () => {
});
describe("Sad Path", () => {
test("should reject publishing survey with incomplete translations", async () => {
// Create a draft with missing translations
const incompleteSurvey = {
...updateSurveyInput,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
// Missing headline
},
],
} as unknown as TSurvey;
test(
"should reject publishing survey with incomplete translations",
async () => {
// Create a draft with missing translations
const incompleteSurvey = {
...updateSurveyInput,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
// Missing headline
},
],
} as unknown as TSurvey;
// Expect validation error (skipValidation = false)
await expect(updateSurveyInternal(incompleteSurvey, false)).rejects.toThrow();
});
// Expect validation error (skipValidation = false)
await expect(updateSurveyInternal(incompleteSurvey, false)).rejects.toThrow();
},
SURVEY_SERVICE_TEST_TIMEOUT_MS
);
});
});
+2 -1
View File
@@ -1,5 +1,5 @@
import { type Locale, formatDistance } from "date-fns";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, tr, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
import { formatDateForDisplay } from "./utils/datetime";
@@ -17,6 +17,7 @@ const TIME_SINCE_LOCALES: Record<TUserLocale, Locale> = {
"ro-RO": ro,
"ru-RU": ru,
"sv-SE": sv,
"tr-TR": tr,
"zh-Hans-CN": zhCN,
"zh-Hant-TW": zhTW,
};
+1 -1
View File
@@ -54,7 +54,7 @@ export const findRecallInfoById = (text: string, id: string): string | null => {
return match ? match[0] : null;
};
const getRecallItemLabel = <T extends TSurvey>(
export const getRecallItemLabel = <T extends TSurvey>(
recallItemId: string,
survey: T,
languageCode: string
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { isSafeIdentifier } from "./safe-identifier";
import { isSafeIdentifier, toSafeIdentifier } from "./safe-identifier";
describe("safe-identifier", () => {
describe("isSafeIdentifier", () => {
@@ -32,4 +32,23 @@ describe("safe-identifier", () => {
expect(isSafeIdentifier("")).toBe(false);
});
});
describe("toSafeIdentifier", () => {
test("normalizes free-form labels into safe identifiers", () => {
expect(toSafeIdentifier("Date of Birth")).toBe("date_of_birth");
expect(toSafeIdentifier("Customer-ID")).toBe("customer_id");
expect(toSafeIdentifier(" Preferred Language ")).toBe("preferred_language");
expect(toSafeIdentifier("city__name")).toBe("city_name");
});
test("strips invalid leading characters until first lowercase letter", () => {
expect(toSafeIdentifier("123 Date")).toBe("date");
expect(toSafeIdentifier("__name")).toBe("name");
expect(toSafeIdentifier("99")).toBe("");
});
test("keeps already safe identifiers unchanged", () => {
expect(toSafeIdentifier("country_code")).toBe("country_code");
});
});
});
+38
View File
@@ -12,6 +12,44 @@ export const isSafeIdentifier = (value: string): boolean => {
return /^[a-z0-9_]+$/.test(value);
};
/**
* Converts a free-form string to a safe identifier candidate.
* The output only contains lowercase letters, numbers, and underscores.
* It also ensures the identifier starts with a lowercase letter by stripping invalid leading chars.
*/
export const toSafeIdentifier = (value: string): string => {
const normalized = value.trim().toLowerCase();
let safeIdentifier = "";
let shouldInsertUnderscore = false;
for (const char of normalized) {
const isLowercaseLetter = char >= "a" && char <= "z";
const isDigit = char >= "0" && char <= "9";
if (isLowercaseLetter || isDigit) {
if (shouldInsertUnderscore && safeIdentifier.length > 0) {
safeIdentifier += "_";
}
safeIdentifier += char;
shouldInsertUnderscore = false;
continue;
}
if (safeIdentifier.length > 0) {
shouldInsertUnderscore = true;
}
}
for (let i = 0; i < safeIdentifier.length; i++) {
const char = safeIdentifier[i];
if (char >= "a" && char <= "z") {
return safeIdentifier.slice(i);
}
}
return "";
};
/**
* Converts a snake_case string to Title Case for display as a label.
* Example: "job_description" -> "Job Description"
+1606 -1587
View File
File diff suppressed because it is too large Load Diff
+44 -25
View File
@@ -159,6 +159,7 @@
"change_workspace": "Change workspace",
"chart": "Chart",
"charts": "Charts",
"choice_n": "Choice {{n}}",
"choices": "Choices",
"choose_organization": "Choose organization",
"choose_workspace": "Choose workspace",
@@ -171,8 +172,9 @@
"close": "Close",
"code": "Code",
"collapse_rows": "Collapse rows",
"column_n": "Column {{n}}",
"completed": "Completed",
"configuration": "Configure",
"configuration": "Configuration",
"confirm": "Confirm",
"connect": "Connect",
"connect_formbricks": "Connect Formbricks",
@@ -242,6 +244,7 @@
"failed_to_load_organizations": "Failed to load organizations",
"failed_to_load_workspaces": "Failed to load workspaces",
"failed_to_parse_csv": "Failed to parse CSV",
"field_placeholder": "{{field}} Placeholder",
"filter": "Filter",
"finish": "Finish",
"first_name": "First Name",
@@ -253,11 +256,13 @@
"generate": "Generate",
"go_back": "Go Back",
"go_to_dashboard": "Go to Dashboard",
"headline": "Headline",
"hidden": "Hidden",
"hidden_field": "Hidden field",
"hidden_fields": "Hidden fields",
"hide": "Hide",
"hide_column": "Hide column",
"html": "HTML",
"id": "ID",
"image": "Image",
"images": "Images",
@@ -306,7 +311,6 @@
"more_options": "More options",
"move_down": "Move down",
"move_up": "Move up",
"multiple_languages": "Multiple languages",
"my_product": "my Product",
"name": "Name",
"new": "New",
@@ -323,6 +327,7 @@
"no_result_found": "No result found",
"no_results": "No results",
"no_surveys_found": "No surveys found.",
"no_text_found": "No text found",
"none_of_the_above": "None of the above",
"not_authenticated": "You are not authenticated to perform this action.",
"not_authorized": "Not authorized",
@@ -347,7 +352,7 @@
"organization_settings": "Organization settings",
"other": "Other",
"other_filters": "Other Filters",
"others": "Others",
"other_placeholder": "Other Placeholder",
"overlay_color": "Overlay color",
"overview": "Overview",
"password": "Password",
@@ -365,7 +370,6 @@
"please_upgrade_your_plan": "Please upgrade your plan",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "Preview",
"preview_survey": "Preview Survey",
"privacy": "Privacy Policy",
"product_manager": "Product Manager",
"production": "Production",
@@ -394,6 +398,7 @@
"restart": "Restart",
"retry": "Retry",
"role": "Role",
"row_n": "Row {{n}}",
"saas": "SaaS",
"sales": "Sales",
"save": "Save",
@@ -433,6 +438,7 @@
"storage_not_configured": "File storage not set up, uploads will likely fail",
"string": "Text",
"styling": "Styling",
"subheader": "Subheader",
"submit": "Submit",
"summary": "Summary",
"survey": "Survey",
@@ -503,7 +509,6 @@
"workspaces": "Workspaces",
"years": "years",
"yes": "Yes",
"you": "You",
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
"you_are_not_authorized_to_perform_this_action": "You are not authorized to perform this action.",
"you_have_reached_your_limit_of_workspace_limit": "You have reached your limit of {workspaceLimit} workspaces.",
@@ -767,6 +772,10 @@
"career_development_survey_question_6_choice_6": "Other",
"career_development_survey_question_6_headline": "Which of the following best describes your current job level?",
"career_development_survey_question_6_subheader": "Please select one of the following options:",
"ces": "Customer Effort (CES)",
"ces_description": "Measure Customer Effort Score (1-5 or 1-7)",
"ces_lower_label": "Very difficult",
"ces_upper_label": "Very easy",
"cess_survey_name": "CES Survey",
"cess_survey_question_1_headline": "$[workspaceName] makes it easy for me to [ADD GOAL]",
"cess_survey_question_1_lower_label": "Strongly disagree",
@@ -830,7 +839,9 @@
"consent_description": "Ask to agree to terms, conditions, or data usage",
"contact_info": "Contact Info",
"contact_info_description": "Ask for name, surname, email, phone number and company jointly",
"csat_description": "Measure the Customer Satisfaction Score of your product or service.",
"csat": "Customer Satisfaction (CSAT)",
"csat_description": "Measure Customer Satisfaction Score (1-5)",
"csat_lower_label": "Very unsatisfied",
"csat_name": "Customer Satisfaction Score (CSAT)",
"csat_question_10_headline": "Do you have any other comments, questions or concerns?",
"csat_question_10_placeholder": "Type your answer here…",
@@ -906,6 +917,7 @@
"csat_survey_question_2_placeholder": "Type your answer here…",
"csat_survey_question_3_headline": "Ugh, sorry! Is there anything we can do to improve your experience?",
"csat_survey_question_3_placeholder": "Type your answer here…",
"csat_upper_label": "Very satisfied",
"cta_description": "Display information and prompt users to take a specific action",
"custom_survey_description": "Create a survey without template.",
"custom_survey_name": "Start from scratch",
@@ -1071,6 +1083,11 @@
"gauge_feature_satisfaction_question_2_headline": "What is one thing we could do better?",
"identify_customer_goals_description": "Better understand if your messaging creates the right expectations of the value your product provides.",
"identify_customer_goals_name": "Identify Customer Goals",
"identify_customer_goals_question_1_choice_1": "Understand my user base deeply",
"identify_customer_goals_question_1_choice_2": "Identify upselling opportunities",
"identify_customer_goals_question_1_choice_3": "Build the best possible product",
"identify_customer_goals_question_1_choice_4": "Rule the world to make everyone breakfast brussels sprouts",
"identify_customer_goals_question_1_headline": "What is your primary goal for using $[workspaceName]?",
"identify_sign_up_barriers_description": "Offer a discount to gather insights about sign up barriers.",
"identify_sign_up_barriers_name": "Identify Sign Up Barriers",
"identify_sign_up_barriers_question_1_button_label": "Get 10% discount",
@@ -1145,6 +1162,8 @@
"improve_trial_conversion_question_1_subheader": "Help us understand you better:",
"improve_trial_conversion_question_2_button_label": "Next",
"improve_trial_conversion_question_2_headline": "Sorry to hear. What was the biggest problem using $[workspaceName]?",
"improve_trial_conversion_question_3_button_label": "Next",
"improve_trial_conversion_question_3_headline": "What did you expect $[workspaceName] to do?",
"improve_trial_conversion_question_4_button_label": "Get 20% off",
"improve_trial_conversion_question_4_headline": "Sorry to hear! Get 20% off the first year.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>We are happy to offer you a 20% discount on a yearly plan.</span></p>",
@@ -2140,7 +2159,6 @@
"duplicate_language_or_language_id": "Duplicate language or language ID",
"edit_languages": "Edit languages",
"identifier": "Identifier (ISO)",
"incomplete_translations": "Incomplete translations",
"language": "Language",
"language_deleted_successfully": "Language deleted successfully",
"languages_updated_successfully": "Languages updated successfully",
@@ -2150,8 +2168,7 @@
"please_select_a_language": "Please select a language",
"remove_language": "Remove Language",
"remove_language_from_surveys_to_remove_it_from_workspace": "Please remove the language from these surveys in order to remove it from the workspace.",
"search_items": "Search items",
"translate": "Translate"
"search_items": "Search items"
},
"look": {
"add_background_color": "Add background color",
@@ -2694,17 +2711,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "You are all set! Time to create your first survey",
"alphabetical": "Alphabetical",
"copy_survey": "Copy survey",
"copy_survey_description": "Copy this survey to another workspace",
"copy_survey_error": "Failed to copy survey",
"copy_survey_link_to_clipboard": "Copy survey link to clipboard",
"copy_survey_no_workspaces": "There are no other workspaces to copy this survey to.",
"copy_survey_partially_success": "{success} surveys copied successfully, {error} failed.",
"copy_survey_success": "Survey copied successfully",
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Choose the default language for this survey:",
"2_activate_translation_for_specific_languages": "2. Activate translation for specific languages:",
"activate_translations": "Activate translations",
"add": "Add +",
"add_a_delay_or_auto_close_the_survey": "Add a delay or auto-close the survey",
"add_a_four_digit_pin": "Add a four digit PIN",
@@ -2754,7 +2764,7 @@
"audience": "Audience",
"auto_close_on_inactivity": "Auto close on inactivity",
"auto_progress_rating_and_nps": "Auto-progress rating and NPS questions",
"auto_progress_rating_and_nps_description": "Automatically advance when respondents select an answer on rating or NPS questions. This only applies to single-question blocks. Required questions hide the Next button; optional questions still show it for skipping.",
"auto_progress_rating_and_nps_description": "Auto-advance in single-question blocks. Required questions hide Next, except when \"Other\" is selected.",
"auto_save_disabled": "Auto-save disabled",
"auto_save_disabled_tooltip": "Your survey is only auto-saved when in draft. This assures public surveys are not unintentionally updated.",
"auto_save_on": "Auto-save on",
@@ -2800,6 +2810,7 @@
"caution_text": "Changes will lead to inconsistencies",
"change_anyway": "Change anyway",
"change_background": "Change background",
"change_default": "Change default",
"change_question_type": "Change question type",
"change_survey_type": "Switching survey type affects existing access",
"change_the_background_to_a_color_image_or_animation": "Change the background to a color, image or animation.",
@@ -2812,6 +2823,7 @@
"choose_where_to_run_the_survey": "Choose where to run the survey.",
"city": "City",
"close_survey_on_response_limit": "Close survey on response limit",
"code": "Code",
"color": "Color",
"column_used_in_logic_error": "This column is used in logic of question {questionIndex}. Please remove it from logic first.",
"columns": "Columns",
@@ -2836,6 +2848,7 @@
"customize_survey_logo": "Customize the survey logo",
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
"days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.",
"default_language": "Default language",
"delete_anyways": "Delete anyways",
"delete_block": "Delete block",
"delete_choice": "Delete choice",
@@ -2855,7 +2868,6 @@
"duplicate_question": "Duplicate question",
"edit_link": "Edit link",
"edit_recall": "Edit Recall",
"edit_translations": "Edit {lang} translations",
"element_not_found": "Question not found",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Allow respondents to switch language at any time. Needs min. 2 active languages.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Spam protection uses reCAPTCHA v3 to filter out the spam responses.",
@@ -2991,11 +3003,13 @@
"long_answer": "Long answer",
"long_answer_toggle_description": "Allow respondents to write longer, multi-line answers.",
"lower_label": "Lower Label",
"manage_languages": "Manage Languages",
"manage_languages": "Manage languages",
"manage_translations": "Manage translations",
"matrix_all_fields": "All fields",
"matrix_rows": "Rows",
"max_file_size": "Max file size",
"max_file_size_limit_is": "Max file size limit is",
"missing_first": "Missing first",
"move_question_to_block": "Move question to block",
"multiply": "Multiply *",
"needed_for_self_hosted_cal_com_instance": "Needed for a self-hosted Cal.com instance",
@@ -3003,7 +3017,7 @@
"next_button_label": "“Next” button label",
"no_hidden_fields_yet_add_first_one_below": "No hidden fields yet. Add the first one below.",
"no_images_found_for": "No images found for “{query}”",
"no_languages_found_add_first_one_to_get_started": "No languages found. Add the first one to get started.",
"no_languages_found_add_first_one_to_get_started": "No survey languages found in this workspace. Please add one to get started.",
"no_option_found": "No option found",
"no_recall_items_found": "No recall items found",
"no_variables_yet_add_first_one_below": "No variables yet. Add the first one below.",
@@ -3030,6 +3044,7 @@
"please_enter_a_valid_url": "Please enter a valid URL (e.g., https://example.com)",
"please_set_a_survey_trigger": "Please set a survey trigger",
"please_specify": "Please specify",
"present_your_survey_in_multiple_languages": "Present your survey in multiple languages",
"prevent_double_submission": "Prevent double submission",
"prevent_double_submission_description": "Only allow 1 response per email address",
"progress_saved": "Progress saved",
@@ -3121,6 +3136,7 @@
"seven_points": "7 points",
"show_block_settings": "Show Block settings",
"show_button": "Show Button",
"show_in_order": "Show in order",
"show_language_switch": "Show language switch",
"show_multiple_times": "Show a limited number of times",
"show_only_once": "Show only once",
@@ -3152,7 +3168,6 @@
"survey_preview": "Survey Preview 👀",
"survey_styling": "Survey styling",
"survey_trigger": "Survey Trigger",
"switch_multi_language_on_to_get_started": "Switch multi-language on to get started 👉",
"target_block_not_found": "Target block not found",
"targeted": "Targeted",
"ten_points": "10 points",
@@ -3160,9 +3175,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Show a single time, even if they do not respond.",
"then": "Then",
"this_action_will_remove_all_the_translations_from_this_survey": "This action will remove all the translations from this survey.",
"this_will_remove_the_language_and_all_its_translations": "This will remove this language and all its translations from this survey. This action cannot be undone.",
"three_points": "3 points",
"times": "times",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "To keep the placement over all surveys consistent, you can",
"translated": "Translated",
"trigger_survey_when_one_of_the_actions_is_fired": "Trigger survey when one of the actions is fired…",
"try_lollipop_or_mountain": "Try “lollipop” or “mountain”…",
"type_field_id": "Type field id",
@@ -3237,6 +3254,7 @@
"verify_email_before_submission_description": "Only let people with a real email respond.",
"visibility_and_recontact": "Visibility & Recontact",
"visibility_and_recontact_description": "Control when this survey can appear and how often it can reappear.",
"visible": "Visible",
"wait": "Wait",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Wait a few seconds after the trigger before showing the survey",
"waiting_time_across_surveys": "Cooldown Period (across surveys)",
@@ -3438,6 +3456,8 @@
"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.",
"csat_satisfied": "CSAT: {percentage}% Satisfied",
"csat_satisfied_tooltip": "{percentage}% of respondents gave a rating of 4 or 5 (CSAT).",
"current_count": "Current count",
"custom_range": "Custom range…",
"delete_all_existing_responses_and_displays": "Delete all existing responses and displays",
@@ -3445,7 +3465,7 @@
"downloading_qr_code": "Downloading QR code",
"drop_offs": "Drop-Offs",
"drop_offs_tooltip": "Number of times the survey has been started but not completed.",
"failed_to_copy_link": "Failed to copy link",
"effort_score": "Effort Score",
"filter_added_successfully": "Filter added successfully",
"filter_updated_successfully": "Filter updated successfully",
"filtered_responses_csv": "Filtered responses (CSV)",
@@ -3497,6 +3517,7 @@
"limit": "Limit",
"no_identified_impressions": "No impressions from identified contacts",
"no_responses_found": "No responses found",
"nps_promoters_tooltip": "{percentage}% of respondents gave a rating of 9 or 10 (NPS promoters).",
"other_values_found": "Other values found",
"overall": "Overall",
"promoters": "Promoters",
@@ -3509,7 +3530,6 @@
"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",
@@ -3533,7 +3553,6 @@
},
"survey_deleted_successfully": "Survey deleted successfully",
"survey_duplicated_successfully": "Survey duplicated successfully",
"survey_duplication_error": "Failed to duplicate the survey.",
"templates": {
"all_channels": "All channels",
"all_industries": "All industries",
+42 -23
View File
@@ -159,6 +159,7 @@
"change_workspace": "Cambiar espacio de trabajo",
"chart": "Gráfico",
"charts": "Gráficos",
"choice_n": "Opción {{n}}",
"choices": "Opciones",
"choose_organization": "Elegir organización",
"choose_workspace": "Elegir proyecto",
@@ -171,6 +172,7 @@
"close": "Cerrar",
"code": "Código",
"collapse_rows": "Contraer filas",
"column_n": "Columna {{n}}",
"completed": "Completado",
"configuration": "Configuración",
"confirm": "Confirmar",
@@ -242,6 +244,7 @@
"failed_to_load_organizations": "Error al cargar organizaciones",
"failed_to_load_workspaces": "Error al cargar los proyectos",
"failed_to_parse_csv": "Error al analizar el CSV",
"field_placeholder": "Marcador de posición de {{field}}",
"filter": "Filtro",
"finish": "Finalizar",
"first_name": "Nombre",
@@ -253,11 +256,13 @@
"generate": "Generar",
"go_back": "Volver",
"go_to_dashboard": "Ir al panel de control",
"headline": "Titular",
"hidden": "Oculto",
"hidden_field": "Campo oculto",
"hidden_fields": "Campos ocultos",
"hide": "Ocultar",
"hide_column": "Ocultar columna",
"html": "HTML",
"id": "ID",
"image": "Imagen",
"images": "Imágenes",
@@ -306,7 +311,6 @@
"more_options": "Más opciones",
"move_down": "Mover hacia abajo",
"move_up": "Mover hacia arriba",
"multiple_languages": "Múltiples idiomas",
"my_product": "mi producto",
"name": "Nombre",
"new": "Nuevo",
@@ -323,6 +327,7 @@
"no_result_found": "No se encontró resultado",
"no_results": "Sin resultados",
"no_surveys_found": "No se encontraron encuestas.",
"no_text_found": "No se encontró texto",
"none_of_the_above": "Ninguna de las anteriores",
"not_authenticated": "No estás autenticado para realizar esta acción.",
"not_authorized": "No autorizado",
@@ -347,7 +352,7 @@
"organization_settings": "Ajustes de la organización",
"other": "Otro",
"other_filters": "Otros Filtros",
"others": "Otros",
"other_placeholder": "Otro marcador de posición",
"overlay_color": "Color de superposición",
"overview": "Resumen",
"password": "Contraseña",
@@ -365,7 +370,6 @@
"please_upgrade_your_plan": "Por favor, actualiza tu plan",
"powered_by_formbricks": "Desarrollado por Formbricks",
"preview": "Vista previa",
"preview_survey": "Vista previa de la encuesta",
"privacy": "Política de privacidad",
"product_manager": "Gestor de producto",
"production": "Producción",
@@ -394,6 +398,7 @@
"restart": "Reiniciar",
"retry": "Reintentar",
"role": "Rol",
"row_n": "Fila {{n}}",
"saas": "SaaS",
"sales": "Ventas",
"save": "Guardar",
@@ -433,6 +438,7 @@
"storage_not_configured": "Almacenamiento de archivos no configurado, es probable que fallen las subidas",
"string": "Texto",
"styling": "Estilo",
"subheader": "Subtítulo",
"submit": "Enviar",
"summary": "Resumen",
"survey": "Encuesta",
@@ -503,7 +509,6 @@
"workspaces": "Proyectos",
"years": "años",
"yes": "Sí",
"you": "Tú",
"you_are_downgraded_to_the_community_edition": "Has sido degradado a la edición Community.",
"you_are_not_authorized_to_perform_this_action": "No tienes autorización para realizar esta acción.",
"you_have_reached_your_limit_of_workspace_limit": "Has alcanzado tu límite de {workspaceLimit} espacios de trabajo.",
@@ -767,6 +772,10 @@
"career_development_survey_question_6_choice_6": "Otro",
"career_development_survey_question_6_headline": "¿Cuál de las siguientes opciones describe mejor tu nivel de trabajo actual?",
"career_development_survey_question_6_subheader": "Por favor, selecciona una de las siguientes opciones",
"ces": "Esfuerzo del Cliente (CES)",
"ces_description": "Mide la Puntuación de Esfuerzo del Cliente (1-5 o 1-7)",
"ces_lower_label": "Muy difícil",
"ces_upper_label": "Muy fácil",
"cess_survey_name": "Encuesta CES",
"cess_survey_question_1_headline": "$[workspaceName] me facilita [AÑADIR OBJETIVO]",
"cess_survey_question_1_lower_label": "Totalmente en desacuerdo",
@@ -830,7 +839,9 @@
"consent_description": "Solicitar aceptación de términos, condiciones o uso de datos",
"contact_info": "Información de contacto",
"contact_info_description": "Solicitar nombre, apellidos, correo electrónico, número de teléfono y empresa conjuntamente",
"csat_description": "Mide el índice de satisfacción del cliente de tu producto o servicio.",
"csat": "Satisfacción del Cliente (CSAT)",
"csat_description": "Mide la Puntuación de Satisfacción del Cliente (1-5)",
"csat_lower_label": "Muy insatisfecho",
"csat_name": "Índice de satisfacción del cliente (CSAT)",
"csat_question_10_headline": "¿Tienes algún otro comentario, pregunta o inquietud?",
"csat_question_10_placeholder": "Escribe tu respuesta aquí...",
@@ -906,6 +917,7 @@
"csat_survey_question_2_placeholder": "Escribe tu respuesta aquí...",
"csat_survey_question_3_headline": "Vaya, ¡lo sentimos! ¿Hay algo que podamos hacer para mejorar tu experiencia?",
"csat_survey_question_3_placeholder": "Escribe tu respuesta aquí...",
"csat_upper_label": "Muy satisfecho",
"cta_description": "Muestra información y anima a los usuarios a realizar una acción específica",
"custom_survey_description": "Crea una encuesta sin plantilla.",
"custom_survey_name": "Empezar desde cero",
@@ -1071,6 +1083,11 @@
"gauge_feature_satisfaction_question_2_headline": "¿Qué es una cosa que podríamos mejorar?",
"identify_customer_goals_description": "Comprende mejor si tus mensajes crean las expectativas correctas sobre el valor que proporciona tu producto.",
"identify_customer_goals_name": "Identificar objetivos del cliente",
"identify_customer_goals_question_1_choice_1": "Conocer a fondo mi base de usuarios",
"identify_customer_goals_question_1_choice_2": "Identificar oportunidades de venta adicional",
"identify_customer_goals_question_1_choice_3": "Construir el mejor producto posible",
"identify_customer_goals_question_1_choice_4": "Dominar el mundo para hacer que todos desayunen coles de Bruselas",
"identify_customer_goals_question_1_headline": "¿Cuál es tu objetivo principal al usar $[workspaceName]?",
"identify_sign_up_barriers_description": "Ofrece un descuento para obtener información sobre las barreras de registro.",
"identify_sign_up_barriers_name": "Identificar barreras de registro",
"identify_sign_up_barriers_question_1_button_label": "Obtener 10 % de descuento",
@@ -1145,6 +1162,8 @@
"improve_trial_conversion_question_1_subheader": "Ayúdanos a entenderte mejor:",
"improve_trial_conversion_question_2_button_label": "Siguiente",
"improve_trial_conversion_question_2_headline": "Lamentamos oírlo. ¿Cuál fue el mayor problema al usar $[workspaceName]?",
"improve_trial_conversion_question_3_button_label": "Siguiente",
"improve_trial_conversion_question_3_headline": "¿Qué esperabas que hiciera $[workspaceName]?",
"improve_trial_conversion_question_4_button_label": "Obtener 20 % de descuento",
"improve_trial_conversion_question_4_headline": "¡Sentimos oírlo! Obtén un 20 % de descuento en el primer año.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Nos complace ofrecerte un 20 % de descuento en un plan anual.</span></p>",
@@ -2140,7 +2159,6 @@
"duplicate_language_or_language_id": "Idioma o ID de idioma duplicado",
"edit_languages": "Editar idiomas",
"identifier": "Identificador (ISO)",
"incomplete_translations": "Traducciones incompletas",
"language": "Idioma",
"language_deleted_successfully": "Idioma eliminado correctamente",
"languages_updated_successfully": "Idiomas actualizados correctamente",
@@ -2150,8 +2168,7 @@
"please_select_a_language": "Por favor, selecciona un idioma",
"remove_language": "Eliminar idioma",
"remove_language_from_surveys_to_remove_it_from_workspace": "Por favor, elimina el idioma de estas encuestas para poder eliminarlo del espacio de trabajo.",
"search_items": "Buscar elementos",
"translate": "Traducir"
"search_items": "Buscar elementos"
},
"look": {
"add_background_color": "Añadir color de fondo",
@@ -2694,17 +2711,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "¡Todo listo! Es hora de crear tu primera encuesta",
"alphabetical": "Alfabético",
"copy_survey": "Copiar encuesta",
"copy_survey_description": "Copia esta encuesta a otro espacio de trabajo",
"copy_survey_error": "Error al copiar la encuesta",
"copy_survey_link_to_clipboard": "Copiar enlace de la encuesta al portapapeles",
"copy_survey_no_workspaces": "No hay otros espacios de trabajo a los que copiar esta encuesta.",
"copy_survey_partially_success": "{success} encuestas copiadas correctamente, {error} fallidas.",
"copy_survey_success": "¡Encuesta copiada correctamente!",
"delete_survey_and_responses_warning": "¿Estás seguro de que quieres eliminar esta encuesta y todas sus respuestas?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Elige el idioma predeterminado para esta encuesta:",
"2_activate_translation_for_specific_languages": "2. Activa la traducción para idiomas específicos:",
"activate_translations": "Activar traducciones",
"add": "Añadir +",
"add_a_delay_or_auto_close_the_survey": "Añadir un retraso o cerrar automáticamente la encuesta",
"add_a_four_digit_pin": "Añadir un PIN de cuatro dígitos",
@@ -2754,7 +2764,7 @@
"audience": "Audiencia",
"auto_close_on_inactivity": "Cierre automático por inactividad",
"auto_progress_rating_and_nps": "Avanzar automáticamente en preguntas de valoración y NPS",
"auto_progress_rating_and_nps_description": "Avanza automáticamente cuando los encuestados seleccionen una respuesta en preguntas de valoración o NPS. Esto solo se aplica a bloques de una sola pregunta. Las preguntas obligatorias ocultan el botón Siguiente; las preguntas opcionales aún lo muestran para omitirlas.",
"auto_progress_rating_and_nps_description": "Avance automático en bloques de una sola pregunta. Las preguntas obligatorias ocultan el botón Siguiente, excepto cuando se selecciona \"Otro\".",
"auto_save_disabled": "Guardado automático desactivado",
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
"auto_save_on": "Guardado automático activado",
@@ -2800,6 +2810,7 @@
"caution_text": "Los cambios provocarán inconsistencias",
"change_anyway": "Cambiar de todos modos",
"change_background": "Cambiar fondo",
"change_default": "Cambiar predeterminado",
"change_question_type": "Cambiar tipo de pregunta",
"change_survey_type": "Cambiar el tipo de encuesta afecta al acceso existente",
"change_the_background_to_a_color_image_or_animation": "Cambiar el fondo a un color, imagen o animación.",
@@ -2812,6 +2823,7 @@
"choose_where_to_run_the_survey": "Elige dónde ejecutar la encuesta.",
"city": "Ciudad",
"close_survey_on_response_limit": "Cerrar encuesta al alcanzar el límite de respuestas",
"code": "Código",
"color": "Color",
"column_used_in_logic_error": "Esta columna se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.",
"columns": "Columnas",
@@ -2836,6 +2848,7 @@
"customize_survey_logo": "Personalizar el logotipo de la encuesta",
"darken_or_lighten_background_of_your_choice": "Oscurece o aclara el fondo de tu elección.",
"days_before_showing_this_survey_again": "o más días deben transcurrir entre la última encuesta mostrada y la visualización de esta encuesta.",
"default_language": "Idioma predeterminado",
"delete_anyways": "Eliminar de todos modos",
"delete_block": "Eliminar bloque",
"delete_choice": "Eliminar opción",
@@ -2855,7 +2868,6 @@
"duplicate_question": "Duplicar pregunta",
"edit_link": "Editar enlace",
"edit_recall": "Editar recuperación",
"edit_translations": "Editar traducciones de {lang}",
"element_not_found": "Pregunta no encontrada",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir a los participantes cambiar el idioma de la encuesta en cualquier momento durante la encuesta.",
"enable_recaptcha_to_protect_your_survey_from_spam": "La protección contra spam utiliza reCAPTCHA v3 para filtrar las respuestas spam.",
@@ -2992,10 +3004,12 @@
"long_answer_toggle_description": "Permitir a los encuestados escribir respuestas más largas y de varias líneas.",
"lower_label": "Etiqueta inferior",
"manage_languages": "Gestionar idiomas",
"manage_translations": "Gestionar traducciones",
"matrix_all_fields": "Todos los campos",
"matrix_rows": "Filas",
"max_file_size": "Tamaño máximo de archivo",
"max_file_size_limit_is": "El límite de tamaño máximo de archivo es",
"missing_first": "Faltantes primero",
"move_question_to_block": "Mover pregunta al bloque",
"multiply": "Multiplicar *",
"needed_for_self_hosted_cal_com_instance": "Necesario para una instancia Cal.com autohospedada",
@@ -3003,7 +3017,7 @@
"next_button_label": "Etiqueta del botón \"Siguiente\"",
"no_hidden_fields_yet_add_first_one_below": "Aún no hay campos ocultos. Añade el primero a continuación.",
"no_images_found_for": "No se encontraron imágenes para ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "No se encontraron idiomas. Añade el primero para comenzar.",
"no_languages_found_add_first_one_to_get_started": "No se encontraron idiomas de encuesta en este espacio de trabajo. Por favor, añade uno para comenzar.",
"no_option_found": "No se encontró ninguna opción",
"no_recall_items_found": "No se encontraron elementos de recuperación",
"no_variables_yet_add_first_one_below": "No hay variables todavía. Añade la primera a continuación.",
@@ -3030,6 +3044,7 @@
"please_enter_a_valid_url": "Por favor, introduce una URL válida (p. ej., https://example.com)",
"please_set_a_survey_trigger": "Establece un disparador de encuesta",
"please_specify": "Por favor, especifica",
"present_your_survey_in_multiple_languages": "Presenta tu encuesta en varios idiomas",
"prevent_double_submission": "Evitar envío duplicado",
"prevent_double_submission_description": "Permitir solo 1 respuesta por dirección de correo electrónico",
"progress_saved": "Progreso guardado",
@@ -3121,6 +3136,7 @@
"seven_points": "7 puntos",
"show_block_settings": "Mostrar ajustes del bloque",
"show_button": "Mostrar botón",
"show_in_order": "Mostrar en orden",
"show_language_switch": "Mostrar cambio de idioma",
"show_multiple_times": "Mostrar un número limitado de veces",
"show_only_once": "Mostrar solo una vez",
@@ -3152,7 +3168,6 @@
"survey_preview": "Vista previa de la encuesta 👀",
"survey_styling": "Estilo del formulario",
"survey_trigger": "Activador de la encuesta",
"switch_multi_language_on_to_get_started": "Activa el modo multiidioma para comenzar 👉",
"target_block_not_found": "Bloque objetivo no encontrado",
"targeted": "Dirigido",
"ten_points": "10 puntos",
@@ -3160,9 +3175,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar una sola vez, incluso si no responden.",
"then": "Entonces",
"this_action_will_remove_all_the_translations_from_this_survey": "Esta acción eliminará todas las traducciones de esta encuesta.",
"this_will_remove_the_language_and_all_its_translations": "Esto eliminará este idioma y todas sus traducciones de esta encuesta. Esta acción no se puede deshacer.",
"three_points": "3 puntos",
"times": "veces",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para mantener la ubicación coherente en todas las encuestas, puedes",
"translated": "Traducido",
"trigger_survey_when_one_of_the_actions_is_fired": "Activar encuesta cuando se dispare una de las acciones...",
"try_lollipop_or_mountain": "Prueba 'piruleta' o 'montaña'...",
"type_field_id": "Escribe el id del campo",
@@ -3237,6 +3254,7 @@
"verify_email_before_submission_description": "Solo permite responder a personas con un correo electrónico real.",
"visibility_and_recontact": "Visibilidad y recontacto",
"visibility_and_recontact_description": "Controla cuándo puede aparecer esta encuesta y con qué frecuencia puede volver a aparecer.",
"visible": "Visible",
"wait": "Esperar",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Esperar unos segundos después del disparador antes de mostrar la encuesta",
"waiting_time_across_surveys": "Periodo de espera (entre encuestas)",
@@ -3438,6 +3456,8 @@
"configure_alerts": "Configurar alertas",
"congrats": "¡Enhorabuena! Tu encuesta está activa.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Conecta tu sitio web o aplicación con Formbricks para comenzar.",
"csat_satisfied": "CSAT: {percentage}% Satisfechos",
"csat_satisfied_tooltip": "El {percentage}% de los encuestados dieron una puntuación de 4 o 5 (CSAT).",
"current_count": "Recuento actual",
"custom_range": "Rango personalizado...",
"delete_all_existing_responses_and_displays": "Eliminar todas las respuestas y visualizaciones existentes",
@@ -3445,7 +3465,7 @@
"downloading_qr_code": "Descargando código QR",
"drop_offs": "Abandonos",
"drop_offs_tooltip": "Número de veces que se ha iniciado la encuesta pero no se ha completado.",
"failed_to_copy_link": "Error al copiar el enlace",
"effort_score": "Puntuación de Esfuerzo",
"filter_added_successfully": "Filtro añadido correctamente",
"filter_updated_successfully": "Filtro actualizado correctamente",
"filtered_responses_csv": "Respuestas filtradas (CSV)",
@@ -3497,6 +3517,7 @@
"limit": "Límite",
"no_identified_impressions": "No hay impresiones de contactos identificados",
"no_responses_found": "No se han encontrado respuestas",
"nps_promoters_tooltip": "El {percentage}% de los encuestados dieron una puntuación de 9 o 10 (promotores NPS).",
"other_values_found": "Otros valores encontrados",
"overall": "General",
"promoters": "Promotores",
@@ -3509,7 +3530,6 @@
"quotas_completed_tooltip": "El número de cuotas completadas por los encuestados.",
"reset_survey": "Reiniciar encuesta",
"reset_survey_warning": "Reiniciar una encuesta elimina todas las respuestas y visualizaciones asociadas a esta encuesta. Esto no se puede deshacer.",
"satisfied": "Satisfecho",
"selected_responses_csv": "Respuestas seleccionadas (CSV)",
"selected_responses_excel": "Respuestas seleccionadas (Excel)",
"setup_integrations": "Configurar integraciones",
@@ -3533,7 +3553,6 @@
},
"survey_deleted_successfully": "¡Encuesta eliminada correctamente!",
"survey_duplicated_successfully": "Encuesta duplicada correctamente.",
"survey_duplication_error": "Error al duplicar la encuesta.",
"templates": {
"all_channels": "Todos los canales",
"all_industries": "Todas las industrias",
+42 -23
View File
@@ -159,6 +159,7 @@
"change_workspace": "Changer d'espace de travail",
"chart": "Graphique",
"charts": "Graphiques",
"choice_n": "Choix {{n}}",
"choices": "Choix",
"choose_organization": "Choisir l'organisation",
"choose_workspace": "Choisir un projet",
@@ -171,6 +172,7 @@
"close": "Fermer",
"code": "Code",
"collapse_rows": "Réduire les lignes",
"column_n": "Colonne {{n}}",
"completed": "Terminé",
"configuration": "Configuration",
"confirm": "Confirmer",
@@ -242,6 +244,7 @@
"failed_to_load_organizations": "Échec du chargement des organisations",
"failed_to_load_workspaces": "Échec du chargement des projets",
"failed_to_parse_csv": "Échec de l'analyse du CSV",
"field_placeholder": "Espace réservé {{field}}",
"filter": "Filtre",
"finish": "Terminer",
"first_name": "Prénom",
@@ -253,11 +256,13 @@
"generate": "Générer",
"go_back": "Retourner",
"go_to_dashboard": "Aller au tableau de bord",
"headline": "Titre principal",
"hidden": "Caché",
"hidden_field": "Champ caché",
"hidden_fields": "Champs cachés",
"hide": "Masquer",
"hide_column": "Cacher la colonne",
"html": "HTML",
"id": "ID",
"image": "Image",
"images": "Images",
@@ -306,7 +311,6 @@
"more_options": "Plus d'options",
"move_down": "Déplacer vers le bas",
"move_up": "Déplacer vers le haut",
"multiple_languages": "Plusieurs langues",
"my_product": "mon produit",
"name": "Nom",
"new": "Nouveau",
@@ -323,6 +327,7 @@
"no_result_found": "Aucun résultat trouvé",
"no_results": "Aucun résultat",
"no_surveys_found": "Aucun sondage trouvé.",
"no_text_found": "Aucun texte trouvé",
"none_of_the_above": "Aucun des éléments ci-dessus",
"not_authenticated": "Vous n'êtes pas authentifié pour effectuer cette action.",
"not_authorized": "Non autorisé",
@@ -347,7 +352,7 @@
"organization_settings": "Paramètres de l'organisation",
"other": "Autre",
"other_filters": "Autres filtres",
"others": "Autres",
"other_placeholder": "Autre espace réservé",
"overlay_color": "Couleur de superposition",
"overview": "Aperçu",
"password": "Mot de passe",
@@ -365,7 +370,6 @@
"please_upgrade_your_plan": "Veuillez mettre à niveau votre plan",
"powered_by_formbricks": "Propulsé par Formbricks",
"preview": "Aperçu",
"preview_survey": "Aperçu de l'enquête",
"privacy": "Politique de confidentialité",
"product_manager": "Chef de produit",
"production": "Production",
@@ -394,6 +398,7 @@
"restart": "Recommencer",
"retry": "Réessayer",
"role": "Rôle",
"row_n": "Ligne {{n}}",
"saas": "SaaS",
"sales": "Ventes",
"save": "Enregistrer",
@@ -433,6 +438,7 @@
"storage_not_configured": "Stockage de fichiers non configuré, les téléchargements risquent d'échouer",
"string": "Texte",
"styling": "Style",
"subheader": "Sous-titre",
"submit": "Soumettre",
"summary": "Résumé",
"survey": "Enquête",
@@ -503,7 +509,6 @@
"workspaces": "Projets",
"years": "années",
"yes": "Oui",
"you": "Vous",
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
"you_are_not_authorized_to_perform_this_action": "Vous n'êtes pas autorisé à effectuer cette action.",
"you_have_reached_your_limit_of_workspace_limit": "Vous avez atteint votre limite de {workspaceLimit} espaces de travail.",
@@ -767,6 +772,10 @@
"career_development_survey_question_6_choice_6": "Autre",
"career_development_survey_question_6_headline": "Lequel des éléments suivants décrit le mieux votre niveau de poste actuel ?",
"career_development_survey_question_6_subheader": "Veuillez sélectionner l'une des options suivantes.",
"ces": "Effort Client (CES)",
"ces_description": "Mesurer le score d'effort client (1-5 ou 1-7)",
"ces_lower_label": "Très difficile",
"ces_upper_label": "Très facile",
"cess_survey_name": "Sondage CES",
"cess_survey_question_1_headline": "$[workspaceName] me facilite la tâche pour [AJOUTER L'OBJECTIF]",
"cess_survey_question_1_lower_label": "Pas du tout d'accord",
@@ -830,7 +839,9 @@
"consent_description": "Demander d'accepter les termes, conditions ou l'utilisation des données",
"contact_info": "Informations de contact",
"contact_info_description": "Demandez le nom, le prénom, l'email, le numéro de téléphone et l'entreprise ensemble.",
"csat_description": "Mesurez le score de satisfaction client de votre produit ou service.",
"csat": "Satisfaction Client (CSAT)",
"csat_description": "Mesurer le score de satisfaction client (1-5)",
"csat_lower_label": "Très insatisfait",
"csat_name": "Score de Satisfaction Client (CSAT)",
"csat_question_10_headline": "Avez-vous d'autres commentaires, questions ou préoccupations ?",
"csat_question_10_placeholder": "Entrez votre réponse ici...",
@@ -906,6 +917,7 @@
"csat_survey_question_2_placeholder": "Entrez votre réponse ici...",
"csat_survey_question_3_headline": "Ah, désolé ! Y a-t-il quelque chose que nous puissions faire pour améliorer votre expérience ?",
"csat_survey_question_3_placeholder": "Entrez votre réponse ici...",
"csat_upper_label": "Très satisfait",
"cta_description": "Afficher des informations et inciter les utilisateurs à effectuer une action spécifique",
"custom_survey_description": "Créez une enquête sans utiliser de modèle.",
"custom_survey_name": "Tout créer moi-même",
@@ -1071,6 +1083,11 @@
"gauge_feature_satisfaction_question_2_headline": "Quelle est une chose que nous pourrions améliorer ?",
"identify_customer_goals_description": "Mieux comprendre si votre message crée les bonnes attentes quant à la valeur que votre produit apporte.",
"identify_customer_goals_name": "Identifier les objectifs des clients",
"identify_customer_goals_question_1_choice_1": "Comprendre ma base d'utilisateurs en profondeur",
"identify_customer_goals_question_1_choice_2": "Identifier les opportunités de vente incitative",
"identify_customer_goals_question_1_choice_3": "Créer le meilleur produit possible",
"identify_customer_goals_question_1_choice_4": "Conquérir le monde pour imposer les choux de Bruxelles au petit-déjeuner",
"identify_customer_goals_question_1_headline": "Quel est ton objectif principal en utilisant $[workspaceName] ?",
"identify_sign_up_barriers_description": "Offrir une remise pour recueillir des informations sur les obstacles à l'inscription.",
"identify_sign_up_barriers_name": "Identifier les obstacles à l'inscription",
"identify_sign_up_barriers_question_1_button_label": "Obtenez 10 % de réduction",
@@ -1145,6 +1162,8 @@
"improve_trial_conversion_question_1_subheader": "Aidez-nous à mieux vous comprendre :",
"improve_trial_conversion_question_2_button_label": "Suivant",
"improve_trial_conversion_question_2_headline": "Désolé d'apprendre ça. Quel a été le plus gros problème lors de l'utilisation de $[workspaceName] ?",
"improve_trial_conversion_question_3_button_label": "Suivant",
"improve_trial_conversion_question_3_headline": "Qu'est-ce que tu t'attendais à ce que $[workspaceName] fasse ?",
"improve_trial_conversion_question_4_button_label": "Obtenez 20 % de réduction",
"improve_trial_conversion_question_4_headline": "Désolé d'apprendre cela ! Bénéficiez de 20 % de réduction sur la première année.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Nous sommes heureux de vous offrir une remise de 20 % sur un plan annuel.</span></p>",
@@ -2140,7 +2159,6 @@
"duplicate_language_or_language_id": "Langue ou identifiant de langue en double",
"edit_languages": "Modifier les langues",
"identifier": "Identifiant (ISO)",
"incomplete_translations": "Traductions incomplètes",
"language": "Langue",
"language_deleted_successfully": "Langue supprimée avec succès",
"languages_updated_successfully": "Langues mises à jour avec succès",
@@ -2150,8 +2168,7 @@
"please_select_a_language": "Veuillez sélectionner une langue",
"remove_language": "Supprimer la langue",
"remove_language_from_surveys_to_remove_it_from_workspace": "Veuillez supprimer la langue de ces sondages afin de la retirer de l'espace de travail.",
"search_items": "Rechercher des éléments",
"translate": "Traduire"
"search_items": "Rechercher des éléments"
},
"look": {
"add_background_color": "Ajouter une couleur d'arrière-plan",
@@ -2694,17 +2711,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "Vous êtes prêt ! Il est temps de créer votre première enquête.",
"alphabetical": "Alphabétique",
"copy_survey": "Copier l'enquête",
"copy_survey_description": "Copier ce sondage vers un autre espace de travail",
"copy_survey_error": "Échec de la copie du sondage",
"copy_survey_link_to_clipboard": "Copier le lien du sondage dans le presse-papiers",
"copy_survey_no_workspaces": "Il n'y a aucun autre espace de travail vers lequel copier ce sondage.",
"copy_survey_partially_success": "{success} enquêtes copiées avec succès, {error} échouées.",
"copy_survey_success": "Enquête copiée avec succès !",
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :",
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques:",
"activate_translations": "Activer les traductions",
"add": "Ajouter +",
"add_a_delay_or_auto_close_the_survey": "Ajouter un délai ou fermer automatiquement l'enquête",
"add_a_four_digit_pin": "Ajoutez un code PIN à quatre chiffres.",
@@ -2754,7 +2764,7 @@
"audience": "Public",
"auto_close_on_inactivity": "Fermeture automatique en cas d'inactivité",
"auto_progress_rating_and_nps": "Progression automatique pour les questions d'évaluation et NPS",
"auto_progress_rating_and_nps_description": "Passe automatiquement à la question suivante lorsque les répondants sélectionnent une réponse aux questions d'évaluation ou NPS. Cela s'applique uniquement aux blocs à question unique. Les questions obligatoires masquent le bouton Suivant ; les questions facultatives l'affichent toujours pour permettre de passer la question.",
"auto_progress_rating_and_nps_description": "Passage automatique pour les blocs à question unique. Les questions obligatoires masquent le bouton Suivant, sauf lorsque « Autre » est sélection.",
"auto_save_disabled": "Sauvegarde automatique désactivée",
"auto_save_disabled_tooltip": "Votre sondage n'est sauvegardé automatiquement que lorsqu'il est en brouillon. Cela garantit que les sondages publics ne sont pas mis à jour involontairement.",
"auto_save_on": "Sauvegarde automatique activée",
@@ -2800,6 +2810,7 @@
"caution_text": "Les changements entraîneront des incohérences.",
"change_anyway": "Changer de toute façon",
"change_background": "Changer l'arrière-plan",
"change_default": "Modifier la langue par défaut",
"change_question_type": "Changer le type de question",
"change_survey_type": "Le changement de type de sondage affecte l'accès existant",
"change_the_background_to_a_color_image_or_animation": "Changez l'arrière-plan en une couleur, une image ou une animation.",
@@ -2812,6 +2823,7 @@
"choose_where_to_run_the_survey": "Choisissez où réaliser l'enquête.",
"city": "Ville",
"close_survey_on_response_limit": "Fermer l'enquête sur la limite de réponse",
"code": "Code",
"color": "Couleur",
"column_used_in_logic_error": "Cette colonne est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",
"columns": "Colonnes",
@@ -2836,6 +2848,7 @@
"customize_survey_logo": "Personnaliser le logo de l'enquête",
"darken_or_lighten_background_of_your_choice": "Assombrir ou éclaircir l'arrière-plan de votre choix.",
"days_before_showing_this_survey_again": "ou plus de jours doivent s'écouler entre le dernier sondage affiché et l'affichage de ce sondage.",
"default_language": "Langue par défaut",
"delete_anyways": "Supprimer quand même",
"delete_block": "Supprimer le bloc",
"delete_choice": "Supprimer l'option",
@@ -2855,7 +2868,6 @@
"duplicate_question": "Dupliquer la question",
"edit_link": "Modifier le lien",
"edit_recall": "Modifier le rappel",
"edit_translations": "Modifier les traductions {lang}",
"element_not_found": "Question non trouvée",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux répondants de changer de langue à tout moment. Nécessite au moins 2 langues actives.",
"enable_recaptcha_to_protect_your_survey_from_spam": "La protection contre le spam utilise reCAPTCHA v3 pour filtrer les réponses indésirables.",
@@ -2992,10 +3004,12 @@
"long_answer_toggle_description": "Permettre aux répondants d'écrire des réponses plus longues et sur plusieurs lignes.",
"lower_label": "Étiquette inférieure",
"manage_languages": "Gérer les langues",
"manage_translations": "Gérer les traductions",
"matrix_all_fields": "Tous les champs",
"matrix_rows": "Lignes",
"max_file_size": "Taille maximale du fichier",
"max_file_size_limit_is": "La limite de taille maximale du fichier est",
"missing_first": "Manquantes en premier",
"move_question_to_block": "Déplacer la question vers le bloc",
"multiply": "Multiplier *",
"needed_for_self_hosted_cal_com_instance": "Nécessaire pour une instance Cal.com auto-hébergée",
@@ -3003,7 +3017,7 @@
"next_button_label": "Libellé du bouton « Suivant »",
"no_hidden_fields_yet_add_first_one_below": "Aucun champ caché pour le moment. Ajoutez le premier ci-dessous.",
"no_images_found_for": "Aucune image trouvée pour ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Aucune langue trouvée. Ajoutez la première pour commencer.",
"no_languages_found_add_first_one_to_get_started": "Aucune langue d'enquête trouvée dans cet espace de travail. Veuillez en ajouter une pour commencer.",
"no_option_found": "Aucune option trouvée",
"no_recall_items_found": "Aucun élément de rappel trouvé",
"no_variables_yet_add_first_one_below": "Aucune variable pour le moment. Ajoutez la première ci-dessous.",
@@ -3030,6 +3044,7 @@
"please_enter_a_valid_url": "Veuillez entrer une URL valide (par exemple, https://example.com)",
"please_set_a_survey_trigger": "Veuillez définir un déclencheur d'enquête.",
"please_specify": "Veuillez préciser",
"present_your_survey_in_multiple_languages": "Présente ton questionnaire dans plusieurs langues",
"prevent_double_submission": "Empêcher la double soumission",
"prevent_double_submission_description": "Autoriser uniquement 1 réponse par adresse e-mail",
"progress_saved": "Progression enregistrée",
@@ -3121,6 +3136,7 @@
"seven_points": "7 points",
"show_block_settings": "Afficher les paramètres du bloc",
"show_button": "Afficher le bouton",
"show_in_order": "Afficher dans l'ordre",
"show_language_switch": "Afficher le changement de langue",
"show_multiple_times": "Afficher un nombre limité de fois",
"show_only_once": "Afficher une seule fois",
@@ -3152,7 +3168,6 @@
"survey_preview": "Aperçu du sondage 👀",
"survey_styling": "Style de formulaire",
"survey_trigger": "Déclencheur d'enquête",
"switch_multi_language_on_to_get_started": "Activez le mode multilingue pour commencer 👉",
"target_block_not_found": "Bloc cible non trouvé",
"targeted": "Ciblé",
"ten_points": "10 points",
@@ -3160,9 +3175,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Afficher une seule fois, même si la personne ne répond pas.",
"then": "Alors",
"this_action_will_remove_all_the_translations_from_this_survey": "Cette action supprimera toutes les traductions de cette enquête.",
"this_will_remove_the_language_and_all_its_translations": "Cela supprimera cette langue et toutes ses traductions de ce questionnaire. Cette action est irréversible.",
"three_points": "3 points",
"times": "fois",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Pour maintenir la cohérence du placement sur tous les sondages, vous pouvez",
"translated": "Traduit",
"trigger_survey_when_one_of_the_actions_is_fired": "Déclencher l'enquête lorsqu'une des actions est déclenchée...",
"try_lollipop_or_mountain": "Essayez 'sucette' ou 'montagne'...",
"type_field_id": "Identifiant de champ de type",
@@ -3237,6 +3254,7 @@
"verify_email_before_submission_description": "Ne laissez répondre que les personnes ayant une véritable adresse e-mail.",
"visibility_and_recontact": "Visibilité et recontact",
"visibility_and_recontact_description": "Contrôlez quand cette enquête peut apparaître et à quelle fréquence elle peut réapparaître.",
"visible": "Visible",
"wait": "Attendre",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Attendez quelques secondes après le déclencheur avant de montrer l'enquête.",
"waiting_time_across_surveys": "Période de refroidissement (entre les sondages)",
@@ -3438,6 +3456,8 @@
"configure_alerts": "Configurer les alertes",
"congrats": "Félicitations ! Votre enquête est en ligne.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Connectez votre site web ou votre application à Formbricks pour commencer.",
"csat_satisfied": "CSAT : {percentage} % Satisfaits",
"csat_satisfied_tooltip": "{percentage} % des répondants ont donné une note de 4 ou 5 (CSAT).",
"current_count": "Nombre actuel",
"custom_range": "Plage personnalisée...",
"delete_all_existing_responses_and_displays": "Supprimer toutes les réponses existantes et les affichages",
@@ -3445,7 +3465,7 @@
"downloading_qr_code": "Téléchargement du code QR",
"drop_offs": "Dépôts",
"drop_offs_tooltip": "Nombre de fois que l'enquête a été commencée mais non terminée.",
"failed_to_copy_link": "Échec de la copie du lien",
"effort_score": "Score d'effort",
"filter_added_successfully": "Filtre ajouté avec succès",
"filter_updated_successfully": "Filtre mis à jour avec succès",
"filtered_responses_csv": "Réponses filtrées (CSV)",
@@ -3497,6 +3517,7 @@
"limit": "Limite",
"no_identified_impressions": "Aucune impression des contacts identifiés",
"no_responses_found": "Aucune réponse trouvée",
"nps_promoters_tooltip": "{percentage} % des répondants ont donné une note de 9 ou 10 (promoteurs NPS).",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",
"promoters": "Promoteurs",
@@ -3509,7 +3530,6 @@
"quotas_completed_tooltip": "Le nombre de quotas complétés par les répondants.",
"reset_survey": "Réinitialiser l'enquête",
"reset_survey_warning": "Réinitialiser un sondage supprime toutes les réponses et les affichages associés à ce sondage. Cela ne peut pas être annulé.",
"satisfied": "Satisfait",
"selected_responses_csv": "Réponses sélectionnées (CSV)",
"selected_responses_excel": "Réponses sélectionnées (Excel)",
"setup_integrations": "Configurer les intégrations",
@@ -3533,7 +3553,6 @@
},
"survey_deleted_successfully": "Enquête supprimée avec succès !",
"survey_duplicated_successfully": "Enquête dupliquée avec succès.",
"survey_duplication_error": "Échec de la duplication de l'enquête.",
"templates": {
"all_channels": "Tous les canaux",
"all_industries": "Tous les secteurs",
+44 -25
View File
@@ -159,6 +159,7 @@
"change_workspace": "Munkaterület módosítása",
"chart": "Diagram",
"charts": "Diagramok",
"choice_n": "{{n}}. választás",
"choices": "Választási lehetőségek",
"choose_organization": "Szervezet kiválasztása",
"choose_workspace": "Munkaterület kiválasztása",
@@ -171,8 +172,9 @@
"close": "Bezárás",
"code": "Kód",
"collapse_rows": "Sorok összecsukása",
"column_n": "{{n}}. oszlop",
"completed": "Befejezve",
"configuration": "Konfiguráció",
"configuration": "Beállítás",
"confirm": "Megerősítés",
"connect": "Kapcsolódás",
"connect_formbricks": "Kapcsolódás a Formbrickshez",
@@ -242,6 +244,7 @@
"failed_to_load_organizations": "Nem sikerült betölteni a szervezeteket",
"failed_to_load_workspaces": "Nem sikerült a munkaterületek betöltése",
"failed_to_parse_csv": "A CSV elemzése sikertelen",
"field_placeholder": "{{field}} helyőrző",
"filter": "Szűrő",
"finish": "Befejezés",
"first_name": "Keresztnév",
@@ -253,11 +256,13 @@
"generate": "Előállítás",
"go_back": "Vissza",
"go_to_dashboard": "Ugrás a vezérlőpultra",
"headline": "Címsor",
"hidden": "Rejtett",
"hidden_field": "Rejtett mező",
"hidden_fields": "Rejtett mezők",
"hide": "Elrejtés",
"hide_column": "Oszlop elrejtése",
"html": "HTML",
"id": "Azonosító",
"image": "Kép",
"images": "Képek",
@@ -306,7 +311,6 @@
"more_options": "További lehetőségek",
"move_down": "Mozgatás le",
"move_up": "Mozgatás fel",
"multiple_languages": "Több nyelv",
"my_product": "saját termék",
"name": "Név",
"new": "Új",
@@ -323,6 +327,7 @@
"no_result_found": "Nem található eredmény",
"no_results": "Nincs találat",
"no_surveys_found": "Nem találhatók kérdőívek.",
"no_text_found": "Nem található szöveg",
"none_of_the_above": "A fentiek közül egyik sem",
"not_authenticated": "Nincs jogosultsága ennek a műveletnek a végrehajtásához.",
"not_authorized": "Nincs felhatalmazva",
@@ -347,7 +352,7 @@
"organization_settings": "Szervezet beállításai",
"other": "Egyéb",
"other_filters": "Egyéb szűrők",
"others": "Mások",
"other_placeholder": "Egyéb helyőrző",
"overlay_color": "Rávetítés színe",
"overview": "Áttekintés",
"password": "Jelszó",
@@ -365,7 +370,6 @@
"please_upgrade_your_plan": "Váltson magasabb csomagra",
"powered_by_formbricks": "A gépházban: Formbricks",
"preview": "Előnézet",
"preview_survey": "Kérdőív előnézete",
"privacy": "Adatvédelmi irányelvek",
"product_manager": "Termékmenedzser",
"production": "Produktív",
@@ -394,6 +398,7 @@
"restart": "Újraindítás",
"retry": "Újra",
"role": "Szerep",
"row_n": "{{n}}. sor",
"saas": "SaaS",
"sales": "Értékesítés",
"save": "Mentés",
@@ -433,6 +438,7 @@
"storage_not_configured": "A fájltároló nincs beállítva, a feltöltések valószínűleg sikertelenek lesznek",
"string": "Szöveg",
"styling": "Stíluskészítés",
"subheader": "Alcím",
"submit": "Elküldés",
"summary": "Összegzés",
"survey": "Kérdőív",
@@ -503,7 +509,6 @@
"workspaces": "Munkaterületek",
"years": "év",
"yes": "Igen",
"you": "Ön",
"you_are_downgraded_to_the_community_edition": "Visszaváltott a közösségi kiadásra.",
"you_are_not_authorized_to_perform_this_action": "Nincs felhatalmazva ennek a műveletnek a végrehajtásához.",
"you_have_reached_your_limit_of_workspace_limit": "Elérte a munkaterületek {workspaceLimit} darabos korlátját.",
@@ -767,6 +772,10 @@
"career_development_survey_question_6_choice_6": "Egyéb",
"career_development_survey_question_6_headline": "Az alábbiak közül melyik írja le legjobban a jelenlegi munkája szintjét?",
"career_development_survey_question_6_subheader": "Válassza ki a következő lehetőségek egyikét:",
"ces": "Ügyfél Erőfeszítés (CES)",
"ces_description": "Ügyfél Erőfeszítési Pontszám mérése (1-5 vagy 1-7)",
"ces_lower_label": "Rendkívül nehéz",
"ces_upper_label": "Rendkívül könnyű",
"cess_survey_name": "Ügyfél-erőfeszítési pontszám kérdőív",
"cess_survey_question_1_headline": "A(z) $[workspaceName] megkönnyíti számomra a következő cél elérését: [CÉL HOZZÁADÁSA]",
"cess_survey_question_1_lower_label": "Egyáltalán nem értek egyet",
@@ -830,7 +839,9 @@
"consent_description": "Felhasználási feltételek vagy adatfelhasználás elfogadásának kérése",
"contact_info": "Kapcsolatfelvételi információk",
"contact_info_description": "Név, vezetéknév, e-mail-cím, telefonszám és vállalat együttes megadásának kérése",
"csat_description": "A termék vagy szolgáltatás ügyfél-elégedettségi pontszámának mérése.",
"csat": gyfél Elégedettség (CSAT)",
"csat_description": "Ügyfél Elégedettségi Pontszám mérése (1-5)",
"csat_lower_label": "Rendkívül elégedetlen",
"csat_name": "Ügyfél-elégedettségi pontszám (CSAT)",
"csat_question_10_headline": "Van még egyéb megjegyzése, kérdése vagy aggálya?",
"csat_question_10_placeholder": "Írja be ide a válaszát…",
@@ -906,6 +917,7 @@
"csat_survey_question_2_placeholder": "Írja be ide a válaszát…",
"csat_survey_question_3_headline": "Jaj, bocsánat! Tehetünk valamit, amivel javíthatnánk az élményén?",
"csat_survey_question_3_placeholder": "Írja be ide a válaszát…",
"csat_upper_label": "Rendkívül elégedett",
"cta_description": "Információk megjelenítése és a felhasználók felkérése egy bizonyos művelet elvégzésére",
"custom_survey_description": "Kérdőív létrehozása sablon nélkül.",
"custom_survey_name": "Kezdés a semmiből",
@@ -1071,6 +1083,11 @@
"gauge_feature_satisfaction_question_2_headline": "Mi az egyetlen dolog, amelyet jobban csinálhatnánk?",
"identify_customer_goals_description": "Jobban megérteni, hogy az üzenetei a termék által nyújtott érték megfelelő elvárásait keltik-e.",
"identify_customer_goals_name": "Ügyfélcélok azonosítása",
"identify_customer_goals_question_1_choice_1": "Felhasználói bázis mélyreható megértése",
"identify_customer_goals_question_1_choice_2": "Felárképzési lehetőségek azonosítása",
"identify_customer_goals_question_1_choice_3": "A lehető legjobb termék kifejlesztése",
"identify_customer_goals_question_1_choice_4": "A világ meghódítása, hogy mindenki reggeli kelbimbót egyék",
"identify_customer_goals_question_1_headline": "Mi az Ön elsődleges célja a(z) $[workspaceName] használatával?",
"identify_sign_up_barriers_description": "Kedvezmény felajánlása a regisztrációs akadályokkal kapcsolatos tapasztalatok gyűjtéséhez.",
"identify_sign_up_barriers_name": "Regisztrációs akadályok azonosítása",
"identify_sign_up_barriers_question_1_button_label": "10% kedvezmény",
@@ -1145,12 +1162,14 @@
"improve_trial_conversion_question_1_subheader": "Segítsen nekünk jobban megérteni Önt:",
"improve_trial_conversion_question_2_button_label": "Következő",
"improve_trial_conversion_question_2_headline": "Sajnáljuk. Mi volt a legnagyobb probléma a $[workspaceName] használata során?",
"improve_trial_conversion_question_3_button_label": "Tovább",
"improve_trial_conversion_question_3_headline": "Mire számított a(z) $[workspaceName] kapcsán?",
"improve_trial_conversion_question_4_button_label": "20% kedvezmény",
"improve_trial_conversion_question_4_headline": "Sajnálattal halljuk! 20% kedvezményt kap az első évre.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Boldogan felajánlunk 20% kedvezményt az éves csomagra.</span></p>",
"improve_trial_conversion_question_5_button_label": "Következő",
"improve_trial_conversion_question_5_headline": "Mit szeretne elérni?",
"improve_trial_conversion_question_5_subheader": "Válassza ki a következő lehetőségek egyikét:",
"improve_trial_conversion_question_5_subheader": "Kérem, válasszon egyet a következő lehetőségek közül:",
"improve_trial_conversion_question_6_headline": "Hogyan oldja meg a problémáját most?",
"improve_trial_conversion_question_6_subheader": "Nevezzen meg alternatív megoldásokat:",
"integration_setup_survey_description": "Annak kiértékelése, hogy a felhasználók mennyire könnyen tudnak integrációkat hozzáadni a termékéhez. A vakfoltok megtalálása.",
@@ -2140,7 +2159,6 @@
"duplicate_language_or_language_id": "Kettőzött nyelv vagy nyelvazonosító",
"edit_languages": "Nyelvek szerkesztése",
"identifier": "Azonosító (ISO)",
"incomplete_translations": "Befejezetlen fordítások",
"language": "Nyelv",
"language_deleted_successfully": "A nyelv sikeresen törölve",
"languages_updated_successfully": "A nyelvek sikeresen frissítve",
@@ -2150,8 +2168,7 @@
"please_select_a_language": "Válasszon egy nyelvet",
"remove_language": "Nyelv eltávolítása",
"remove_language_from_surveys_to_remove_it_from_workspace": "Távolítsa el a nyelvet ezekből a kérdőívekből, hogy eltávolítsa azt a munkaterületről.",
"search_items": "Elemek keresése",
"translate": "Fordítás"
"search_items": "Elemek keresése"
},
"look": {
"add_background_color": "Háttérszín hozzáadása",
@@ -2694,17 +2711,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "Mindent beállított! Ideje létrehozni az első kérdőívet",
"alphabetical": "Ábécé-sorrend",
"copy_survey": "Kérdőív másolása",
"copy_survey_description": "Másolja át ezt a felmérést egy másik munkaterületre",
"copy_survey_error": "Nem sikerült másolni a kérdőívet",
"copy_survey_link_to_clipboard": "Kérdőív hivatkozásának másolása a vágólapra",
"copy_survey_no_workspaces": "Nincsenek más munkaterületek, amelyekre átmásolhatná ezt a felmérést.",
"copy_survey_partially_success": "{success} kérdőív sikeresen másolva, {error} sikertelen.",
"copy_survey_success": "A kérdőív sikeresen másolva",
"delete_survey_and_responses_warning": "Biztosan törölni szeretné ezt a kérdőívet és az összes válaszát?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Válassza ki a kérdőív alapértelmezett nyelvét:",
"2_activate_translation_for_specific_languages": "2. Aktiválja a fordítást bizonyos nyelvekhez:",
"activate_translations": "Fordítások aktiválása",
"add": "Hozzáadás +",
"add_a_delay_or_auto_close_the_survey": "Késleltetés hozzáadása vagy a kérdőív automatikus lezárása",
"add_a_four_digit_pin": "Négy számjegyű PIN-kód hozzáadása",
@@ -2754,7 +2764,7 @@
"audience": "Közönség",
"auto_close_on_inactivity": "Automatikus lezárás tétlenségnél",
"auto_progress_rating_and_nps": "Automatikus továbblépés értékelési és NPS kérdéseknél",
"auto_progress_rating_and_nps_description": "Automatikus továbblépés, amikor a válaszadók kiválasztanak egy választ az értékelési vagy NPS kérdéseknél. Ez csak az egykérdéses blokkokra vonatkozik. A kötelező kérdések elrejtik a Tovább gombot; az opcionális kérdések továbbra is megjelenítik azt a kihagyás lehetősége érdekében.",
"auto_progress_rating_and_nps_description": "Automatikus továbblépés egyetlen kérdést tartalmazó blokkokban. A kötelező kérdések elrejtik a Tovább gombot, kivéve amikor az \"Egyéb\" opció van kiválasztva.",
"auto_save_disabled": "Az automatikus mentés letiltva",
"auto_save_disabled_tooltip": "A kérdőív csak akkor kerül automatikusan mentésre, ha piszkozatban van. Ez biztosítja, hogy a nyilvános kérdőívek ne legyenek véletlenül frissítve.",
"auto_save_on": "Automatikus mentés bekapcsolva",
@@ -2800,6 +2810,7 @@
"caution_text": "A változtatások következetlenségekhez vezetnek",
"change_anyway": "Változtatás mindenképp",
"change_background": "Háttér megváltoztatása",
"change_default": "Alapértelmezett módosítása",
"change_question_type": "Kérdés típusának megváltoztatása",
"change_survey_type": "A kérdőív típusának megváltoztatása befolyásolja a meglévő hozzáférést",
"change_the_background_to_a_color_image_or_animation": "A háttér megváltoztatása színre, képre vagy animációra.",
@@ -2812,6 +2823,7 @@
"choose_where_to_run_the_survey": "Annak kiválasztása, hogy hol fusson a kérdőív.",
"city": "Város",
"close_survey_on_response_limit": "Kérdőív lezárása a válaszkorlátnál",
"code": "Kód",
"color": "Szín",
"column_used_in_logic_error": "Ez az oszlop használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
"columns": "Oszlopok",
@@ -2836,6 +2848,7 @@
"customize_survey_logo": "A kérdőív logójának személyre szabása",
"darken_or_lighten_background_of_your_choice": "A választási lehetőség hátterének sötétítése vagy világosítása.",
"days_before_showing_this_survey_again": "vagy több napnak kell eltelnie az utolsó megjelenített kérdőív és ezen kérdőív megjelenése között.",
"default_language": "Alapértelmezett nyelv",
"delete_anyways": "Törlés mindenképp",
"delete_block": "Blokk törlése",
"delete_choice": "Választási lehetőség törlése",
@@ -2855,7 +2868,6 @@
"duplicate_question": "Kérdés kettőzése",
"edit_link": "Hivatkozás szerkesztése",
"edit_recall": "Visszahívás szerkesztése",
"edit_translations": "{lang} fordítások szerkesztése",
"element_not_found": "A kérdés nem található",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Lehetővé tétel a válaszadóknak, hogy bármikor nyelvet váltsanak. Legalább 2 aktív nyelvet igényel.",
"enable_recaptcha_to_protect_your_survey_from_spam": "A szemét elleni védekezés a reCAPTCHA v3-at használja a kéretlen válaszok kiszűréséhez.",
@@ -2992,10 +3004,12 @@
"long_answer_toggle_description": "Lehetővé tétel a válaszadóknak, hogy hosszabb, többsoros válaszokat írjanak.",
"lower_label": "Alsó címke",
"manage_languages": "Nyelvek kezelése",
"manage_translations": "Fordítások kezelése",
"matrix_all_fields": "Összes mező",
"matrix_rows": "Sorok",
"max_file_size": "Legnagyobb fájlméret",
"max_file_size_limit_is": "A legnagyobb fájlméretkorlát",
"missing_first": "Hiányzók először",
"move_question_to_block": "Kérdés áthelyezése egy blokkba",
"multiply": "Szorzás *",
"needed_for_self_hosted_cal_com_instance": "Saját üzemeltetésű Cal.com-példányhoz szükséges",
@@ -3003,7 +3017,7 @@
"next_button_label": "A „Következő” gomb címkéje",
"no_hidden_fields_yet_add_first_one_below": "Még nincsenek rejtett mezők. Adja hozzá az elsőt lent.",
"no_images_found_for": "Nem találhatók képek a(z) „{query}” lekérdezéshez",
"no_languages_found_add_first_one_to_get_started": "Nem találhatók nyelvek. Adja hozzá az elsőt a kezdéshez.",
"no_languages_found_add_first_one_to_get_started": "Nem található felmérési nyelv ebben a munkaterületen. Kérem, adjon hozzá egyet a kezdéshez.",
"no_option_found": "Nem található lehetőség",
"no_recall_items_found": "Nem találhatók visszahívási elemek",
"no_variables_yet_add_first_one_below": "Még nincsenek változók. Adja hozzá az elsőt lent.",
@@ -3030,6 +3044,7 @@
"please_enter_a_valid_url": "Adjon meg egy érvényes URL-t (például https://example.com)",
"please_set_a_survey_trigger": "Állítson be kérdőív-aktiválót",
"please_specify": "Adja meg",
"present_your_survey_in_multiple_languages": "Mutassa be felmérését több nyelven",
"prevent_double_submission": "Kettős beküldés megakadályozása",
"prevent_double_submission_description": "E-mail-címenként csak 1 válasz engedélyezése",
"progress_saved": "Folyamat elmentve",
@@ -3121,6 +3136,7 @@
"seven_points": "7 pont",
"show_block_settings": "Blokkbeállítások megjelenítése",
"show_button": "Gomb megjelenítése",
"show_in_order": "Sorrendben megjelenítés",
"show_language_switch": "Nyelvválasztó megjelenítése",
"show_multiple_times": "Megjelenítés korlátozott számú alkalommal",
"show_only_once": "Megjelenítés csak egyszer",
@@ -3152,7 +3168,6 @@
"survey_preview": "Kérdőív előnézete 👀",
"survey_styling": "Kérdőív stílusának beállítása",
"survey_trigger": "Kérdőív aktiválója",
"switch_multi_language_on_to_get_started": "Kapcsolja be a többnyelvűséget a kezdéshez 👉",
"target_block_not_found": "A célblokk nem található",
"targeted": "Célzott",
"ten_points": "10 pont",
@@ -3160,9 +3175,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Megjelenítés egyetlen alkalommal, még akkor is, ha nem válaszolnak.",
"then": "Azután",
"this_action_will_remove_all_the_translations_from_this_survey": "Ez a művelet eltávolítja az összes fordítást ebből a kérdőívből.",
"this_will_remove_the_language_and_all_its_translations": "Ez eltávolítja ezt a nyelvet és az összes fordítását ebből a felmérésből. Ez a művelet nem vonható vissza.",
"three_points": "3 pont",
"times": "alkalom",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Ahhoz, hogy következetesen megtartsa az elhelyezést az összes kérdőívnél, az alábbiakat teheti:",
"translated": "Lefordítva",
"trigger_survey_when_one_of_the_actions_is_fired": "A kérdőív aktiválása, ha a műveletek egyikét elindítják…",
"try_lollipop_or_mountain": "A „nyalóka” vagy „hegy” kipróbálása…",
"type_field_id": "Mezőazonosító beírása",
@@ -3237,6 +3254,7 @@
"verify_email_before_submission_description": "Csak valódi e-mail-címmel rendelkező személyek válaszolhassanak.",
"visibility_and_recontact": "Láthatóság és újbóli kapcsolatfelvétel",
"visibility_and_recontact_description": "Annak vezérlése, hogy ez a kérdőív mikor jelenhet meg és milyen gyakran jelenhet meg újra.",
"visible": "Látható",
"wait": "Várakozás",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Várakozás néhány másodpercig az aktiválás után, mielőtt megjelenítené a kérdőívet",
"waiting_time_across_surveys": "Várakozási időszak (kérdőívek között)",
@@ -3438,6 +3456,8 @@
"configure_alerts": "Riasztások beállítása",
"congrats": "Gratulálunk! A kérdőíve élő.",
"connect_your_website_or_app_with_formbricks_to_get_started": "A webhelye vagy alkalmazása csatlakoztatása a Formbrickshez a kezdéshez.",
"csat_satisfied": "CSAT: {percentage}% elégedett",
"csat_satisfied_tooltip": "A válaszadók {percentage}%-a 4-es vagy 5-ös értékelést adott (CSAT).",
"current_count": "Jelenlegi darabszám",
"custom_range": "Egyéni tartomány…",
"delete_all_existing_responses_and_displays": "Az összes meglévő válasz és megjelenítés törlése",
@@ -3445,7 +3465,7 @@
"downloading_qr_code": "QR-kód letöltése",
"drop_offs": "Megszakítások",
"drop_offs_tooltip": "A kérdőív elkezdési, de be nem fejezési alkalmainak száma.",
"failed_to_copy_link": "Nem sikerült a hivatkozás másolása",
"effort_score": "Erőfeszítési Pontszám",
"filter_added_successfully": "A szűrő sikeresen hozzáadva",
"filter_updated_successfully": "A szűrő sikeresen frissítve",
"filtered_responses_csv": "Szűrt válaszok (CSV)",
@@ -3497,6 +3517,7 @@
"limit": "Korlát",
"no_identified_impressions": "Nincsenek azonosított partnerektől származó megtekintések",
"no_responses_found": "Nem találhatók válaszok",
"nps_promoters_tooltip": "A válaszadók {percentage}%-a 9-es vagy 10-es értékelést adott (NPS promoters).",
"other_values_found": "Más értékek találhatók",
"overall": "Összesen",
"promoters": "Népszerűsítők",
@@ -3509,7 +3530,6 @@
"quotas_completed_tooltip": "A válaszadók által teljesített kvóták száma.",
"reset_survey": "Kérdőív visszaállítása",
"reset_survey_warning": "Egy kérdőív visszaállítása eltávolítja a kérdőívhez hozzárendelt összes választ és megjelenítést. Ezt nem lehet visszavonni.",
"satisfied": "Elégedett",
"selected_responses_csv": "Kijelölt válaszok (CSV)",
"selected_responses_excel": "Kijelölt válaszok (Excel)",
"setup_integrations": "Integrációk beállítása",
@@ -3533,7 +3553,6 @@
},
"survey_deleted_successfully": "A kérdőív sikeresen törölve",
"survey_duplicated_successfully": "A kérdőív sikeresen megkettőzve",
"survey_duplication_error": "Nem sikerült megkettőzni a kérdőívet.",
"templates": {
"all_channels": "Összes csatorna",
"all_industries": "Összes iparág",
+43 -24
View File
@@ -159,6 +159,7 @@
"change_workspace": "ワークスペースを変更",
"chart": "チャート",
"charts": "チャート",
"choice_n": "選択肢 {{n}}",
"choices": "選択肢",
"choose_organization": "組織を選択",
"choose_workspace": "ワークスペースを選択",
@@ -171,6 +172,7 @@
"close": "閉じる",
"code": "コード",
"collapse_rows": "行を非表示",
"column_n": "列 {{n}}",
"completed": "完了",
"configuration": "設定",
"confirm": "確認",
@@ -242,6 +244,7 @@
"failed_to_load_organizations": "組織の読み込みに失敗しました",
"failed_to_load_workspaces": "ワークスペースの読み込みに失敗しました",
"failed_to_parse_csv": "CSVの解析に失敗しました",
"field_placeholder": "{{field}} プレースホルダー",
"filter": "フィルター",
"finish": "完了",
"first_name": "名",
@@ -253,11 +256,13 @@
"generate": "生成",
"go_back": "戻る",
"go_to_dashboard": "ダッシュボードへ移動",
"headline": "見出し",
"hidden": "非表示",
"hidden_field": "非表示フィールド",
"hidden_fields": "非表示フィールド",
"hide": "非表示",
"hide_column": "列を非表示",
"html": "HTML",
"id": "ID",
"image": "画像",
"images": "画像",
@@ -306,7 +311,6 @@
"more_options": "その他のオプション",
"move_down": "下に移動",
"move_up": "上に移動",
"multiple_languages": "多言語",
"my_product": "マイプロダクト",
"name": "名前",
"new": "新規",
@@ -323,6 +327,7 @@
"no_result_found": "結果が見つかりません",
"no_results": "結果なし",
"no_surveys_found": "フォームが見つかりません。",
"no_text_found": "テキストが見つかりません",
"none_of_the_above": "いずれも該当しません",
"not_authenticated": "このアクションを実行するための認証がされていません。",
"not_authorized": "権限がありません",
@@ -347,7 +352,7 @@
"organization_settings": "組織設定",
"other": "その他",
"other_filters": "その他のフィルター",
"others": "その他",
"other_placeholder": "その他のプレースホルダー",
"overlay_color": "オーバーレイの色",
"overview": "概要",
"password": "パスワード",
@@ -365,7 +370,6 @@
"please_upgrade_your_plan": "プランをアップグレードしてください",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "プレビュー",
"preview_survey": "フォームをプレビュー",
"privacy": "プライバシーポリシー",
"product_manager": "プロダクトマネージャー",
"production": "本番",
@@ -394,6 +398,7 @@
"restart": "再開",
"retry": "再試行",
"role": "役割",
"row_n": "行 {{n}}",
"saas": "SaaS",
"sales": "セールス",
"save": "保存",
@@ -433,6 +438,7 @@
"storage_not_configured": "ファイルストレージが設定されていないため、アップロードは失敗する可能性があります",
"string": "テキスト",
"styling": "スタイル",
"subheader": "小見出し",
"submit": "送信",
"summary": "概要",
"survey": "フォーム",
@@ -503,7 +509,6 @@
"workspaces": "ワークスペース",
"years": "年",
"yes": "はい",
"you": "あなた",
"you_are_downgraded_to_the_community_edition": "コミュニティ版にダウングレードされました。",
"you_are_not_authorized_to_perform_this_action": "このアクションを実行する権限がありません。",
"you_have_reached_your_limit_of_workspace_limit": "ワークスペースの上限である{workspaceLimit}件に達しました。",
@@ -767,6 +772,10 @@
"career_development_survey_question_6_choice_6": "その他",
"career_development_survey_question_6_headline": "あなたの現在の役職に最も近いものは何ですか?",
"career_development_survey_question_6_subheader": "以下のオプションから一つ選択してください",
"ces": "顧客努力指標(CES)",
"ces_description": "顧客努力スコアを測定します(1-5または1-7)",
"ces_lower_label": "非常に難しい",
"ces_upper_label": "非常に簡単",
"cess_survey_name": "CESアンケート",
"cess_survey_question_1_headline": "$[workspaceName]を使うと、[目標を追加]することが簡単にできます",
"cess_survey_question_1_lower_label": "全くそう思わない",
@@ -830,7 +839,9 @@
"consent_description": "規約、条件、またはデータ使用に同意を求める",
"contact_info": "連絡先情報",
"contact_info_description": "名前、苗字、メール、電話番号、会社をまとめて尋ねる",
"csat_description": "あなたの製品やサービスの顧客満足度CSAT)を測定する。",
"csat": "顧客満足度(CSAT)",
"csat_description": "顧客満足度スコアを測定します(1-5)",
"csat_lower_label": "非常に不満",
"csat_name": "顧客満足度(CSAT",
"csat_question_10_headline": "他に何かコメント、質問、懸念はありますか?",
"csat_question_10_placeholder": "ここに回答を入力してください...",
@@ -906,6 +917,7 @@
"csat_survey_question_2_placeholder": "ここに回答を入力してください...",
"csat_survey_question_3_headline": "申し訳ありません!体験を改善するために何かできることはありますか?",
"csat_survey_question_3_placeholder": "ここに回答を入力してください...",
"csat_upper_label": "非常に満足",
"cta_description": "情報を表示し、特定の行動を促す",
"custom_survey_description": "テンプレートを使わずにアンケートを作成する。",
"custom_survey_name": "最初から始める",
@@ -1071,6 +1083,11 @@
"gauge_feature_satisfaction_question_2_headline": "私たちがもっとうまくできることは何ですか?",
"identify_customer_goals_description": "あなたのメッセージが製品の価値に対する正しい期待を抱かせているかどうかをよりよく理解する。",
"identify_customer_goals_name": "顧客目標の特定",
"identify_customer_goals_question_1_choice_1": "ユーザーベースを深く理解する",
"identify_customer_goals_question_1_choice_2": "アップセルの機会を特定する",
"identify_customer_goals_question_1_choice_3": "最高の製品を構築する",
"identify_customer_goals_question_1_choice_4": "世界を支配してみんなに朝食の芽キャベツを食べさせる",
"identify_customer_goals_question_1_headline": "$[workspaceName]を使用する主な目的は何ですか?",
"identify_sign_up_barriers_description": "サインアップの障壁に関する洞察を得るために割引を提供する。",
"identify_sign_up_barriers_name": "サインアップの障壁を特定する",
"identify_sign_up_barriers_question_1_button_label": "10%割引を取得",
@@ -1145,12 +1162,14 @@
"improve_trial_conversion_question_1_subheader": "私たちをよりよく理解するためにお手伝いください:",
"improve_trial_conversion_question_2_button_label": "次へ",
"improve_trial_conversion_question_2_headline": "残念です。$[workspaceName]を使う上で一番の問題は何でしたか?",
"improve_trial_conversion_question_3_button_label": "次へ",
"improve_trial_conversion_question_3_headline": "$[workspaceName]に何を期待していましたか?",
"improve_trial_conversion_question_4_button_label": "20%オフを取得",
"improve_trial_conversion_question_4_headline": "残念です!初年度20%オフをゲット。",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>年間プランで20%の割引を提供させていただきます。</span></p>",
"improve_trial_conversion_question_5_button_label": "次へ",
"improve_trial_conversion_question_5_headline": "何を達成したいですか?",
"improve_trial_conversion_question_5_subheader": "以下のオプションからつ選択してください:",
"improve_trial_conversion_question_5_subheader": "以下のオプションから1つ選択してください:",
"improve_trial_conversion_question_6_headline": "今、問題をどのように解決していますか?",
"improve_trial_conversion_question_6_subheader": "代替ソリューションを挙げてください:",
"integration_setup_survey_description": "ユーザーが製品に統合を追加するのがどれだけ簡単かを評価する。盲点を見つける。",
@@ -2140,7 +2159,6 @@
"duplicate_language_or_language_id": "重複する言語または言語ID",
"edit_languages": "言語を編集",
"identifier": "識別子(ISO",
"incomplete_translations": "未完了の翻訳",
"language": "言語",
"language_deleted_successfully": "言語を正常に削除しました",
"languages_updated_successfully": "言語を正常に更新しました",
@@ -2150,8 +2168,7 @@
"please_select_a_language": "言語を選択してください",
"remove_language": "言語を削除",
"remove_language_from_surveys_to_remove_it_from_workspace": "ワークスペースから削除するには、これらのフォームから言語を削除してください。",
"search_items": "アイテムを検索",
"translate": "翻訳"
"search_items": "アイテムを検索"
},
"look": {
"add_background_color": "背景色を追加",
@@ -2694,17 +2711,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "すべての準備が整いました!最初のフォームを作成しましょう",
"alphabetical": "アルファベット順",
"copy_survey": "フォームをコピー",
"copy_survey_description": "このアンケートを別のワークスペースにコピー",
"copy_survey_error": "フォームのコピーに失敗しました",
"copy_survey_link_to_clipboard": "フォームのリンクをクリップボードにコピー",
"copy_survey_no_workspaces": "このアンケートをコピーできる他のワークスペースがありません。",
"copy_survey_partially_success": "{success} 個のフォームが正常にコピーされ、{error} 個が失敗しました。",
"copy_survey_success": "フォームを正常にコピーしました!",
"delete_survey_and_responses_warning": "本当にこのフォームとすべての回答を削除しますか?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. このフォームのデフォルト言語を選択してください:",
"2_activate_translation_for_specific_languages": "2. 特定の言語の翻訳を有効にしてください:",
"activate_translations": "翻訳を有効化",
"add": "追加 +",
"add_a_delay_or_auto_close_the_survey": "遅延を追加するか、フォームを自動的に閉じる",
"add_a_four_digit_pin": "4桁のPINを追加",
@@ -2754,7 +2764,7 @@
"audience": "オーディエンス",
"auto_close_on_inactivity": "非アクティブ時に自動閉鎖",
"auto_progress_rating_and_nps": "評価とNPSの質問を自動進行",
"auto_progress_rating_and_nps_description": "評価またはNPSの質問で回答者が選択肢を選んだ際に自動的に次へ進みます。これは単一質問ブロックにのみ適用されます。必須質問では「次へ」ボタンが非表示になり、任意の質問ではスキップ用に引き続き表示されます。",
"auto_progress_rating_and_nps_description": "単一質問ブロックでは自動的に次へ進みます。必須質問では「次へ」ボタンが非表示になりますが、「その他」が選択された場合は表示されます。",
"auto_save_disabled": "自動保存が無効",
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
"auto_save_on": "自動保存オン",
@@ -2800,6 +2810,7 @@
"caution_text": "変更は不整合を引き起こします",
"change_anyway": "とにかく変更",
"change_background": "背景を変更",
"change_default": "デフォルトを変更",
"change_question_type": "質問の種類を変更",
"change_survey_type": "フォームの種類を変更すると、既存のアクセスに影響します",
"change_the_background_to_a_color_image_or_animation": "背景を色、画像、またはアニメーションに変更します。",
@@ -2812,6 +2823,7 @@
"choose_where_to_run_the_survey": "フォームを実行する場所を選択してください。",
"city": "市区町村",
"close_survey_on_response_limit": "回答数の上限でフォームを閉じる",
"code": "コード",
"color": "色",
"column_used_in_logic_error": "この列は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"columns": "列",
@@ -2836,6 +2848,7 @@
"customize_survey_logo": "アンケートのロゴをカスタマイズする",
"darken_or_lighten_background_of_your_choice": "お好みの背景を暗くしたり明るくしたりします。",
"days_before_showing_this_survey_again": "最後に表示されたアンケートとこのアンケートを表示するまでに、この日数以上の期間を空ける必要があります。",
"default_language": "デフォルト言語",
"delete_anyways": "削除する",
"delete_block": "ブロックを削除",
"delete_choice": "選択肢を削除",
@@ -2855,7 +2868,6 @@
"duplicate_question": "質問を複製",
"edit_link": "編集 リンク",
"edit_recall": "リコールを編集",
"edit_translations": "{lang} 翻訳を編集",
"element_not_found": "質問が見つかりません",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "回答者がいつでも言語を切り替えられるようにします。最低2つのアクティブな言語が必要です。",
"enable_recaptcha_to_protect_your_survey_from_spam": "スパム対策はreCAPTCHA v3を使用してスパム回答をフィルタリングします。",
@@ -2992,10 +3004,12 @@
"long_answer_toggle_description": "回答者が長文の複数行の回答を書けるようにします。",
"lower_label": "下限ラベル",
"manage_languages": "言語を管理",
"manage_translations": "翻訳を管理",
"matrix_all_fields": "すべてのフィールド",
"matrix_rows": "行",
"max_file_size": "最大ファイルサイズ",
"max_file_size_limit_is": "最大ファイルサイズの上限は",
"missing_first": "未翻訳を優先",
"move_question_to_block": "質問をブロックに移動",
"multiply": "乗算 *",
"needed_for_self_hosted_cal_com_instance": "セルフホストのCal.comインスタンスに必要",
@@ -3003,7 +3017,7 @@
"next_button_label": "「次へ」ボタンのラベル",
"no_hidden_fields_yet_add_first_one_below": "まだ非表示フィールドがありません。以下で最初のものを追加してください。",
"no_images_found_for": "''{query}'' の画像が見つかりません",
"no_languages_found_add_first_one_to_get_started": "言語が見つかりません。始めるには、最初のものを追加してください。",
"no_languages_found_add_first_one_to_get_started": "このワークスペースにはアンケート言語が見つかりません。開始するには言語を追加してください。",
"no_option_found": "オプションが見つかりません",
"no_recall_items_found": "リコール項目が見つかりません",
"no_variables_yet_add_first_one_below": "まだ変数がありません。以下で最初のものを追加してください。",
@@ -3030,6 +3044,7 @@
"please_enter_a_valid_url": "有効な URL を入力してください (例:https://example.com)",
"please_set_a_survey_trigger": "フォームのトリガーを設定してください",
"please_specify": "具体的に指定してください",
"present_your_survey_in_multiple_languages": "アンケートを複数の言語で表示",
"prevent_double_submission": "二重送信を防ぐ",
"prevent_double_submission_description": "メールアドレスごとに1つの回答のみを許可する",
"progress_saved": "進捗を保存しました",
@@ -3121,6 +3136,7 @@
"seven_points": "7点",
"show_block_settings": "ブロック設定を表示",
"show_button": "ボタンを表示",
"show_in_order": "順番に表示",
"show_language_switch": "言語切り替えを表示",
"show_multiple_times": "限られた回数表示する",
"show_only_once": "一度だけ表示",
@@ -3152,7 +3168,6 @@
"survey_preview": "アンケートプレビュー 👀",
"survey_styling": "フォームのスタイル",
"survey_trigger": "フォームのトリガー",
"switch_multi_language_on_to_get_started": "多言語機能をオンにして開始 👉",
"target_block_not_found": "対象ブロックが見つかりません",
"targeted": "ターゲット",
"ten_points": "10点",
@@ -3160,9 +3175,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "回答がなくても1回だけ表示します。",
"then": "その後",
"this_action_will_remove_all_the_translations_from_this_survey": "このアクションは、このフォームからすべての翻訳を削除します。",
"this_will_remove_the_language_and_all_its_translations": "この言語とすべての翻訳がこのアンケートから削除されます。この操作は元に戻せません。",
"three_points": "3点",
"times": "回",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "すべてのフォームの配置を一貫させるために、",
"translated": "翻訳済み",
"trigger_survey_when_one_of_the_actions_is_fired": "以下のアクションのいずれかが発火したときにフォームをトリガーします...",
"try_lollipop_or_mountain": "「lollipop」や「mountain」を試してみてください...",
"type_field_id": "フィールドIDを入力",
@@ -3237,6 +3254,7 @@
"verify_email_before_submission_description": "有効なメールアドレスを持つ人のみが回答できるようにする",
"visibility_and_recontact": "表示と再接触",
"visibility_and_recontact_description": "このフォームがいつ表示され、どのくらいの頻度で再表示できるかをコントロールします。",
"visible": "表示",
"wait": "待つ",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "トリガーから数秒待ってからフォームを表示します",
"waiting_time_across_surveys": "クールダウン期間(アンケート全体)",
@@ -3438,6 +3456,8 @@
"configure_alerts": "アラートを設定",
"congrats": "おめでとうございます!フォームが公開されました。",
"connect_your_website_or_app_with_formbricks_to_get_started": "始めるには、ウェブサイトやアプリをFormbricksに接続してください。",
"csat_satisfied": "CSAT: 満足度 {percentage}%",
"csat_satisfied_tooltip": "回答者の{percentage}%が4または5の評価をしました(CSAT)。",
"current_count": "現在の件数",
"custom_range": "カスタム範囲...",
"delete_all_existing_responses_and_displays": "既存のすべての回答と表示を削除",
@@ -3445,7 +3465,7 @@
"downloading_qr_code": "QRコードをダウンロード中",
"drop_offs": "離脱",
"drop_offs_tooltip": "フォームが開始されたが完了しなかった回数。",
"failed_to_copy_link": "リンクのコピーに失敗しました",
"effort_score": "エフォートスコア",
"filter_added_successfully": "フィルターを正常に追加しました",
"filter_updated_successfully": "フィルターを正常に更新しました",
"filtered_responses_csv": "フィルター済み回答 (CSV)",
@@ -3497,6 +3517,7 @@
"limit": "制限",
"no_identified_impressions": "識別済みコンタクトからのインプレッションはありません",
"no_responses_found": "回答が見つかりません",
"nps_promoters_tooltip": "回答者の{percentage}%が9または10の評価をしました(NPSプロモーター)。",
"other_values_found": "他の値が見つかりました",
"overall": "全体",
"promoters": "推奨者",
@@ -3509,7 +3530,6 @@
"quotas_completed_tooltip": "回答者 によって 完了 した 定員 の 数。",
"reset_survey": "フォームをリセット",
"reset_survey_warning": "フォームをリセットすると、このフォームに関連付けられているすべての回答と表示が削除されます。この操作は元に戻せません。",
"satisfied": "満足",
"selected_responses_csv": "選択した回答 (CSV)",
"selected_responses_excel": "選択した回答 (Excel)",
"setup_integrations": "連携を設定",
@@ -3533,7 +3553,6 @@
},
"survey_deleted_successfully": "フォームを正常に削除しました!",
"survey_duplicated_successfully": "フォームを正常に複製しました。",
"survey_duplication_error": "フォームの複製に失敗しました。",
"templates": {
"all_channels": "すべてのチャネル",
"all_industries": "すべての業界",
+43 -24
View File
@@ -159,6 +159,7 @@
"change_workspace": "Werkruimte wijzigen",
"chart": "Grafiek",
"charts": "Grafieken",
"choice_n": "Keuze {{n}}",
"choices": "Keuzes",
"choose_organization": "Kies organisatie",
"choose_workspace": "Kies werkruimte",
@@ -171,6 +172,7 @@
"close": "Dichtbij",
"code": "Code",
"collapse_rows": "Rijen samenvouwen",
"column_n": "Kolom {{n}}",
"completed": "Voltooid",
"configuration": "Configuratie",
"confirm": "Bevestigen",
@@ -242,6 +244,7 @@
"failed_to_load_organizations": "Laden van organisaties mislukt",
"failed_to_load_workspaces": "Laden van werkruimtes mislukt",
"failed_to_parse_csv": "Kan CSV niet verwerken",
"field_placeholder": "Tijdelijke aanduiding voor {{field}}",
"filter": "Filter",
"finish": "Finish",
"first_name": "Voornaam",
@@ -253,11 +256,13 @@
"generate": "Genereren",
"go_back": "Ga terug",
"go_to_dashboard": "Ga naar Dashboard",
"headline": "Kop",
"hidden": "Verborgen",
"hidden_field": "Verborgen veld",
"hidden_fields": "Verborgen velden",
"hide": "Verbergen",
"hide_column": "Kolom verbergen",
"html": "HTML",
"id": "ID",
"image": "Afbeelding",
"images": "Afbeeldingen",
@@ -306,7 +311,6 @@
"more_options": "Meer opties",
"move_down": "Ga naar beneden",
"move_up": "Ga omhoog",
"multiple_languages": "Meerdere talen",
"my_product": "mijn product",
"name": "Naam",
"new": "Nieuw",
@@ -323,6 +327,7 @@
"no_result_found": "Geen resultaat gevonden",
"no_results": "Geen resultaten",
"no_surveys_found": "Geen enquêtes gevonden.",
"no_text_found": "Geen tekst gevonden",
"none_of_the_above": "Geen van bovenstaande",
"not_authenticated": "U bent niet geverifieerd om deze actie uit te voeren.",
"not_authorized": "Niet geautoriseerd",
@@ -347,7 +352,7 @@
"organization_settings": "Organisatie-instellingen",
"other": "Ander",
"other_filters": "Overige filters",
"others": "Anderen",
"other_placeholder": "Andere tijdelijke aanduiding",
"overlay_color": "Overlaykleur",
"overview": "Overzicht",
"password": "Wachtwoord",
@@ -365,7 +370,6 @@
"please_upgrade_your_plan": "Upgrade je abonnement",
"powered_by_formbricks": "Mogelijk gemaakt door Formbricks",
"preview": "Voorbeeld",
"preview_survey": "Voorbeeld van enquête",
"privacy": "Privacybeleid",
"product_manager": "Productmanager",
"production": "Productie",
@@ -394,6 +398,7 @@
"restart": "Opnieuw opstarten",
"retry": "Opnieuw proberen",
"role": "Rol",
"row_n": "Rij {{n}}",
"saas": "SaaS",
"sales": "Verkoop",
"save": "Redden",
@@ -433,6 +438,7 @@
"storage_not_configured": "Bestandsopslag is niet ingesteld, uploads zullen waarschijnlijk mislukken",
"string": "Tekst",
"styling": "Styling",
"subheader": "Subkop",
"submit": "Indienen",
"summary": "Samenvatting",
"survey": "Vragenlijst",
@@ -503,7 +509,6 @@
"workspaces": "Werkruimtes",
"years": "jaren",
"yes": "Ja",
"you": "Jij",
"you_are_downgraded_to_the_community_edition": "Je bent gedowngraded naar de Community-editie.",
"you_are_not_authorized_to_perform_this_action": "U bent niet geautoriseerd om deze actie uit te voeren.",
"you_have_reached_your_limit_of_workspace_limit": "Je hebt je limiet van {workspaceLimit} workspaces bereikt.",
@@ -767,6 +772,10 @@
"career_development_survey_question_6_choice_6": "Ander",
"career_development_survey_question_6_headline": "Welke van de volgende omschrijvingen beschrijft het beste uw huidige functieniveau?",
"career_development_survey_question_6_subheader": "Selecteer een van de volgende opties",
"ces": "Klantinspanning (CES)",
"ces_description": "Meet de Customer Effort Score (1-5 of 1-7)",
"ces_lower_label": "Heel moeilijk",
"ces_upper_label": "Heel makkelijk",
"cess_survey_name": "CES-enquête",
"cess_survey_question_1_headline": "$[workspaceName] maakt het makkelijk voor mij om [VOEG DOEL TOE]",
"cess_survey_question_1_lower_label": "Sterk mee oneens",
@@ -830,7 +839,9 @@
"consent_description": "Vraag om akkoord te gaan met de algemene voorwaarden of het datagebruik",
"contact_info": "Contactgegevens",
"contact_info_description": "Vraag gezamenlijk naar naam, achternaam, e-mailadres, telefoonnummer en bedrijf",
"csat_description": "Meet de klanttevredenheidsscore van uw product of dienst.",
"csat": "Klanttevredenheid (CSAT)",
"csat_description": "Meet de Customer Satisfaction Score (1-5)",
"csat_lower_label": "Heel ontevreden",
"csat_name": "Klanttevredenheidsscore (CSAT)",
"csat_question_10_headline": "Heeft u nog andere opmerkingen, vragen of opmerkingen?",
"csat_question_10_placeholder": "Typ hier uw antwoord...",
@@ -906,6 +917,7 @@
"csat_survey_question_2_placeholder": "Typ hier uw antwoord...",
"csat_survey_question_3_headline": "Euh, sorry! Kunnen we iets doen om uw ervaring te verbeteren?",
"csat_survey_question_3_placeholder": "Typ hier uw antwoord...",
"csat_upper_label": "Heel tevreden",
"cta_description": "Geef informatie weer en vraag gebruikers om een specifieke actie te ondernemen",
"custom_survey_description": "Maak een enquête zonder sjabloon.",
"custom_survey_name": "Begin helemaal opnieuw",
@@ -1071,6 +1083,11 @@
"gauge_feature_satisfaction_question_2_headline": "Wat kunnen we beter doen?",
"identify_customer_goals_description": "Begrijp beter of uw boodschap de juiste verwachtingen wekt van de waarde die uw product biedt.",
"identify_customer_goals_name": "Identificeer klantdoelen",
"identify_customer_goals_question_1_choice_1": "Mijn gebruikersbestand grondig begrijpen",
"identify_customer_goals_question_1_choice_2": "Upsellmogelijkheden identificeren",
"identify_customer_goals_question_1_choice_3": "Het best mogelijke product bouwen",
"identify_customer_goals_question_1_choice_4": "De wereld regeren om iedereen spruitjes als ontbijt te laten eten",
"identify_customer_goals_question_1_headline": "Wat is je belangrijkste doel voor het gebruik van $[workspaceName]?",
"identify_sign_up_barriers_description": "Bied een korting aan om inzicht te krijgen in de aanmeldingsbarrières.",
"identify_sign_up_barriers_name": "Identificeer aanmeldingsbarrières",
"identify_sign_up_barriers_question_1_button_label": "Krijg 10% korting",
@@ -1145,6 +1162,8 @@
"improve_trial_conversion_question_1_subheader": "Help ons u beter te begrijpen:",
"improve_trial_conversion_question_2_button_label": "Volgende",
"improve_trial_conversion_question_2_headline": "Wat vervelend om te horen. Wat was het grootste probleem bij het gebruiken van $[workspaceName]?",
"improve_trial_conversion_question_3_button_label": "Volgende",
"improve_trial_conversion_question_3_headline": "Wat verwachtte je dat $[workspaceName] zou doen?",
"improve_trial_conversion_question_4_button_label": "Krijg 20% korting",
"improve_trial_conversion_question_4_headline": "Sorry om te horen! Krijg het eerste jaar 20% korting.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>We bieden u graag 20% korting op een jaarabonnement.</span></p>",
@@ -2140,7 +2159,6 @@
"duplicate_language_or_language_id": "Dubbele taal of taal-ID",
"edit_languages": "Talen bewerken",
"identifier": "Identifier (ISO)",
"incomplete_translations": "Onvolledige vertalingen",
"language": "Taal",
"language_deleted_successfully": "Taal succesvol verwijderd",
"languages_updated_successfully": "Talen succesvol bijgewerkt",
@@ -2150,8 +2168,7 @@
"please_select_a_language": "Selecteer een taal",
"remove_language": "Taal verwijderen",
"remove_language_from_surveys_to_remove_it_from_workspace": "Verwijder de taal uit deze enquêtes om deze uit de werkruimte te verwijderen.",
"search_items": "Items zoeken",
"translate": "Vertalen"
"search_items": "Items zoeken"
},
"look": {
"add_background_color": "Achtergrondkleur toevoegen",
@@ -2694,17 +2711,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "Je bent helemaal klaar! Tijd om uw eerste enquête te maken",
"alphabetical": "Alfabetisch",
"copy_survey": "Kopieer enquête",
"copy_survey_description": "Kopieer deze enquête naar een andere werkruimte",
"copy_survey_error": "Het kopiëren van de enquête is mislukt",
"copy_survey_link_to_clipboard": "Kopieer de enquêtelink naar het klembord",
"copy_survey_no_workspaces": "Er zijn geen andere werkruimtes om deze enquête naartoe te kopiëren.",
"copy_survey_partially_success": "{success} enquêtes zijn succesvol gekopieerd, {error} is mislukt.",
"copy_survey_success": "Enquête succesvol gekopieerd!",
"delete_survey_and_responses_warning": "Weet u zeker dat u deze enquête en alle antwoorden erop wilt verwijderen?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Kies de standaardtaal voor deze enquête:",
"2_activate_translation_for_specific_languages": "2. Activeer vertaling voor specifieke talen:",
"activate_translations": "Vertalingen activeren",
"add": "Voeg + toe",
"add_a_delay_or_auto_close_the_survey": "Voeg een vertraging toe of sluit de enquête automatisch",
"add_a_four_digit_pin": "Voeg een viercijferige pincode toe",
@@ -2754,7 +2764,7 @@
"audience": "Publiek",
"auto_close_on_inactivity": "Automatisch sluiten bij inactiviteit",
"auto_progress_rating_and_nps": "Automatisch doorgaan bij beoordelings- en NPS-vragen",
"auto_progress_rating_and_nps_description": "Ga automatisch verder wanneer respondenten een antwoord selecteren bij beoordelings- of NPS-vragen. Dit geldt alleen voor blokken met één vraag. Bij verplichte vragen wordt de Volgende-knop verborgen; bij optionele vragen blijft deze zichtbaar om de vraag over te slaan.",
"auto_progress_rating_and_nps_description": "Automatisch doorgaan bij blokken met één vraag. Verplichte vragen verbergen Volgende, behalve wanneer \"Anders\" is geselecteerd.",
"auto_save_disabled": "Automatisch opslaan uitgeschakeld",
"auto_save_disabled_tooltip": "Uw enquête wordt alleen automatisch opgeslagen wanneer deze een concept is. Dit zorgt ervoor dat openbare enquêtes niet onbedoeld worden bijgewerkt.",
"auto_save_on": "Automatisch opslaan aan",
@@ -2800,6 +2810,7 @@
"caution_text": "Veranderingen zullen tot inconsistenties leiden",
"change_anyway": "Hoe dan ook veranderen",
"change_background": "Achtergrond wijzigen",
"change_default": "Standaard wijzigen",
"change_question_type": "Vraagtype wijzigen",
"change_survey_type": "Als u van enquêtetype verandert, heeft dit invloed op de bestaande toegang",
"change_the_background_to_a_color_image_or_animation": "Verander de achtergrond in een kleur, afbeelding of animatie.",
@@ -2812,6 +2823,7 @@
"choose_where_to_run_the_survey": "Kies waar u de enquête wilt uitvoeren.",
"city": "Stad",
"close_survey_on_response_limit": "Sluit enquête over responslimiet",
"code": "Code",
"color": "Kleur",
"column_used_in_logic_error": "Deze kolom wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
"columns": "Kolommen",
@@ -2836,6 +2848,7 @@
"customize_survey_logo": "Pas het enquêtelogo aan",
"darken_or_lighten_background_of_your_choice": "Maak de achtergrond naar keuze donkerder of lichter.",
"days_before_showing_this_survey_again": "of meer dagen moeten verstrijken tussen de laatst getoonde enquête en het tonen van deze enquête.",
"default_language": "Standaardtaal",
"delete_anyways": "Toch verwijderen",
"delete_block": "Blok verwijderen",
"delete_choice": "Keuze verwijderen",
@@ -2855,7 +2868,6 @@
"duplicate_question": "Vraag dupliceren",
"edit_link": "Link bewerken",
"edit_recall": "Bewerken Terugroepen",
"edit_translations": "Bewerk {lang} vertalingen",
"element_not_found": "Vraag niet gevonden",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Sta respondenten toe om op elk moment van taal te wisselen. Vereist min. 2 actieve talen.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Spambeveiliging maakt gebruik van reCAPTCHA v3 om de spamreacties eruit te filteren.",
@@ -2991,11 +3003,13 @@
"long_answer": "Lang antwoord",
"long_answer_toggle_description": "Sta respondenten toe om langere antwoorden met meerdere regels te schrijven.",
"lower_label": "Lager etiket",
"manage_languages": "Beheer talen",
"manage_languages": "Talen beheren",
"manage_translations": "Vertalingen beheren",
"matrix_all_fields": "Alle velden",
"matrix_rows": "Rijen",
"max_file_size": "Maximale bestandsgrootte",
"max_file_size_limit_is": "Maximale bestandsgroottelimiet is",
"missing_first": "Ontbrekende eerst",
"move_question_to_block": "Vraag naar blok verplaatsen",
"multiply": "Vermenigvuldig *",
"needed_for_self_hosted_cal_com_instance": "Nodig voor een zelf-gehoste Cal.com-instantie",
@@ -3003,7 +3017,7 @@
"next_button_label": "Knoplabel 'Volgende'",
"no_hidden_fields_yet_add_first_one_below": "Nog geen verborgen velden. Voeg de eerste hieronder toe.",
"no_images_found_for": "Geen afbeeldingen gevonden voor ''{query}'",
"no_languages_found_add_first_one_to_get_started": "Geen talen gevonden. Voeg de eerste toe om aan de slag te gaan.",
"no_languages_found_add_first_one_to_get_started": "Geen enquêtetalen gevonden in deze workspace. Voeg er een toe om te beginnen.",
"no_option_found": "Geen optie gevonden",
"no_recall_items_found": "Geen recall-items gevonden",
"no_variables_yet_add_first_one_below": "Nog geen variabelen. Voeg de eerste hieronder toe.",
@@ -3030,6 +3044,7 @@
"please_enter_a_valid_url": "Voer een geldige URL in (bijvoorbeeld https://example.com)",
"please_set_a_survey_trigger": "Stel een enquêtetrigger in",
"please_specify": "Gelieve te specificeren",
"present_your_survey_in_multiple_languages": "Toon je enquête in meerdere talen",
"prevent_double_submission": "Voorkom dubbele indiening",
"prevent_double_submission_description": "Er is slechts 1 reactie per e-mailadres toegestaan",
"progress_saved": "Voortgang opgeslagen",
@@ -3121,6 +3136,7 @@
"seven_points": "7 punten",
"show_block_settings": "Blokinstellingen tonen",
"show_button": "Toon knop",
"show_in_order": "Toon op volgorde",
"show_language_switch": "Toon taalwissel",
"show_multiple_times": "Toon een beperkt aantal keren",
"show_only_once": "Slechts één keer weergeven",
@@ -3152,7 +3168,6 @@
"survey_preview": "Enquêtevoorbeeld 👀",
"survey_styling": "Vorm styling",
"survey_trigger": "Enquêtetrigger",
"switch_multi_language_on_to_get_started": "Schakel meertaligheid in om te beginnen 👉",
"target_block_not_found": "Doelblok niet gevonden",
"targeted": "Gericht",
"ten_points": "10 punten",
@@ -3160,9 +3175,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Toon één keer, zelfs als ze niet reageren.",
"then": "Dan",
"this_action_will_remove_all_the_translations_from_this_survey": "Met deze actie worden alle vertalingen uit deze enquête verwijderd.",
"this_will_remove_the_language_and_all_its_translations": "Dit verwijdert deze taal en alle vertalingen uit deze enquête. Deze actie kan niet ongedaan worden gemaakt.",
"three_points": "3 punten",
"times": "keer",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Om de plaatsing over alle enquêtes consistent te houden, kunt u dat doen",
"translated": "Vertaald",
"trigger_survey_when_one_of_the_actions_is_fired": "Enquête activeren wanneer een van de acties wordt afgevuurd...",
"try_lollipop_or_mountain": "Probeer 'lollipop' of 'berg'...",
"type_field_id": "Typ veld-ID",
@@ -3237,6 +3254,7 @@
"verify_email_before_submission_description": "Laat alleen mensen met een echte e-mail reageren.",
"visibility_and_recontact": "Zichtbaarheid & opnieuw contact",
"visibility_and_recontact_description": "Bepaal wanneer deze enquête kan verschijnen en hoe vaak deze opnieuw kan verschijnen.",
"visible": "Zichtbaar",
"wait": "Wachten",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Wacht een paar seconden na de trigger voordat u de enquête weergeeft",
"waiting_time_across_surveys": "Afkoelperiode (voor alle enquêtes)",
@@ -3438,6 +3456,8 @@
"configure_alerts": "Configureer waarschuwingen",
"congrats": "Gefeliciteerd! Uw enquête is live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Verbind uw website of app met Formbricks om aan de slag te gaan.",
"csat_satisfied": "CSAT: {percentage}% Tevreden",
"csat_satisfied_tooltip": "{percentage}% van de respondenten gaf een beoordeling van 4 of 5 (CSAT).",
"current_count": "Huidige telling",
"custom_range": "Aangepast bereik...",
"delete_all_existing_responses_and_displays": "Verwijder alle bestaande reacties en displays",
@@ -3445,7 +3465,7 @@
"downloading_qr_code": "QR-code downloaden",
"drop_offs": "Drop-offs",
"drop_offs_tooltip": "Aantal keren dat de enquête is gestart maar niet is voltooid.",
"failed_to_copy_link": "Kan de link niet kopiëren",
"effort_score": "Inspanningsscore",
"filter_added_successfully": "Filter succesvol toegevoegd",
"filter_updated_successfully": "Filter succesvol bijgewerkt",
"filtered_responses_csv": "Gefilterde reacties (CSV)",
@@ -3497,6 +3517,7 @@
"limit": "Beperken",
"no_identified_impressions": "Geen weergaven van geïdentificeerde contacten",
"no_responses_found": "Geen reacties gevonden",
"nps_promoters_tooltip": "{percentage}% van de respondenten gaf een beoordeling van 9 of 10 (NPS promoters).",
"other_values_found": "Andere waarden gevonden",
"overall": "Algemeen",
"promoters": "Promoters",
@@ -3509,7 +3530,6 @@
"quotas_completed_tooltip": "Het aantal quota dat door de respondenten is voltooid.",
"reset_survey": "Enquête opnieuw instellen",
"reset_survey_warning": "Als u een enquête opnieuw instelt, worden alle reacties en weergaven verwijderd die aan deze enquête zijn gekoppeld. Dit kan niet ongedaan worden gemaakt.",
"satisfied": "Tevreden",
"selected_responses_csv": "Geselecteerde reacties (CSV)",
"selected_responses_excel": "Geselecteerde antwoorden (Excel)",
"setup_integrations": "Integraties instellen",
@@ -3533,7 +3553,6 @@
},
"survey_deleted_successfully": "Enquête succesvol verwijderd!",
"survey_duplicated_successfully": "Enquête is succesvol gedupliceerd.",
"survey_duplication_error": "Het is niet gelukt de enquête te dupliceren.",
"templates": {
"all_channels": "Alle kanalen",
"all_industries": "Alle industrieën",
+44 -25
View File
@@ -159,6 +159,7 @@
"change_workspace": "Alterar espaço de trabalho",
"chart": "Gráfico",
"charts": "Gráficos",
"choice_n": "Escolha {{n}}",
"choices": "Escolhas",
"choose_organization": "Escolher organização",
"choose_workspace": "Escolher projeto",
@@ -171,6 +172,7 @@
"close": "Fechar",
"code": "Código",
"collapse_rows": "Recolher linhas",
"column_n": "Coluna {{n}}",
"completed": "Concluído",
"configuration": "Configuração",
"confirm": "Confirmar",
@@ -242,6 +244,7 @@
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_workspaces": "Falha ao carregar projetos",
"failed_to_parse_csv": "Falha ao analisar CSV",
"field_placeholder": "Espaço reservado de {{field}}",
"filter": "Filtro",
"finish": "Terminar",
"first_name": "Primeiro nome",
@@ -253,11 +256,13 @@
"generate": "Gerar",
"go_back": "Voltar",
"go_to_dashboard": "Ir para o Painel",
"headline": "Título",
"hidden": "Escondido",
"hidden_field": "Campo oculto",
"hidden_fields": "Campos ocultos",
"hide": "Ocultar",
"hide_column": "Ocultar coluna",
"html": "HTML",
"id": "ID",
"image": "imagem",
"images": "Imagens",
@@ -306,7 +311,6 @@
"more_options": "Mais opções",
"move_down": "Descer",
"move_up": "Subir",
"multiple_languages": "Vários idiomas",
"my_product": "meu produto",
"name": "Nome",
"new": "Novo",
@@ -323,6 +327,7 @@
"no_result_found": "Nenhum resultado encontrado",
"no_results": "Nenhum resultado",
"no_surveys_found": "Não foram encontradas pesquisas.",
"no_text_found": "Nenhum texto encontrado",
"none_of_the_above": "Nenhuma das opções acima",
"not_authenticated": "Você não está autenticado para realizar essa ação.",
"not_authorized": "Não autorizado",
@@ -347,7 +352,7 @@
"organization_settings": "Configurações da Organização",
"other": "outro",
"other_filters": "Outros Filtros",
"others": "Outros",
"other_placeholder": "Outro espaço reservado",
"overlay_color": "Cor da sobreposição",
"overview": "Visão Geral",
"password": "Senha",
@@ -365,7 +370,6 @@
"please_upgrade_your_plan": "Por favor, atualize seu plano",
"powered_by_formbricks": "Desenvolvido por Formbricks",
"preview": "Prévia",
"preview_survey": "Prévia da Pesquisa",
"privacy": "Política de Privacidade",
"product_manager": "Gerente de Produto",
"production": "Produção",
@@ -394,6 +398,7 @@
"restart": "Reiniciar",
"retry": "Tentar novamente",
"role": "Rolê",
"row_n": "Linha {{n}}",
"saas": "SaaS",
"sales": "vendas",
"save": "Salvar",
@@ -433,6 +438,7 @@
"storage_not_configured": "Armazenamento de arquivos não configurado, uploads provavelmente falharão",
"string": "Texto",
"styling": "Estilização",
"subheader": "Subtítulo",
"submit": "Enviar",
"summary": "Resumo",
"survey": "Pesquisa",
@@ -503,7 +509,6 @@
"workspaces": "Projetos",
"years": "anos",
"yes": "Sim",
"you": "Você",
"you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.",
"you_are_not_authorized_to_perform_this_action": "Você não tem autorização para realizar essa ação.",
"you_have_reached_your_limit_of_workspace_limit": "Você atingiu o limite de {workspaceLimit} espaços de trabalho.",
@@ -767,6 +772,10 @@
"career_development_survey_question_6_choice_6": "Outro",
"career_development_survey_question_6_headline": "O que melhor descreve seu cargo atual?",
"career_development_survey_question_6_subheader": "Por favor, escolha uma das opções a seguir",
"ces": "Esforço do Cliente (CES)",
"ces_description": "Meça o Índice de Esforço do Cliente (1-5 ou 1-7)",
"ces_lower_label": "Muito difícil",
"ces_upper_label": "Muito fácil",
"cess_survey_name": "Pesquisa CES",
"cess_survey_question_1_headline": "$[workspaceName] facilita para eu [ADICIONAR OBJETIVO]",
"cess_survey_question_1_lower_label": "Discordar veementemente",
@@ -830,7 +839,9 @@
"consent_description": "Pedir para concordar com os termos, condições ou uso de dados",
"contact_info": "Informações de Contato",
"contact_info_description": "Peça nome, sobrenome, e-mail, telefone e empresa juntos",
"csat_description": "Mede o Índice de Satisfação do Cliente do seu produto ou serviço.",
"csat": "Satisfação do Cliente (CSAT)",
"csat_description": "Meça o Índice de Satisfação do Cliente (1-5)",
"csat_lower_label": "Muito insatisfeito",
"csat_name": "Pontuação de Satisfação do Cliente (CSAT)",
"csat_question_10_headline": "Você tem mais algum comentário, pergunta ou preocupação?",
"csat_question_10_placeholder": "Digite sua resposta aqui...",
@@ -906,6 +917,7 @@
"csat_survey_question_2_placeholder": "Digite sua resposta aqui...",
"csat_survey_question_3_headline": "Ah, foi mal! Tem algo que a gente possa fazer pra melhorar sua experiência?",
"csat_survey_question_3_placeholder": "Digite sua resposta aqui...",
"csat_upper_label": "Muito satisfeito",
"cta_description": "Mostrar informações e pedir para os usuários tomarem uma ação específica",
"custom_survey_description": "Crie uma pesquisa sem modelo.",
"custom_survey_name": "Começar do zero",
@@ -1071,6 +1083,11 @@
"gauge_feature_satisfaction_question_2_headline": "O que a gente poderia melhorar?",
"identify_customer_goals_description": "Entenda melhor se sua mensagem cria as expectativas certas sobre o valor que seu produto oferece.",
"identify_customer_goals_name": "Identificar Objetivos do Cliente",
"identify_customer_goals_question_1_choice_1": "Entender profundamente minha base de usuários",
"identify_customer_goals_question_1_choice_2": "Identificar oportunidades de upsell",
"identify_customer_goals_question_1_choice_3": "Construir o melhor produto possível",
"identify_customer_goals_question_1_choice_4": "Dominar o mundo para fazer com que todos comam couve de Bruxelas no café da manhã",
"identify_customer_goals_question_1_headline": "Qual é o seu objetivo principal ao usar $[workspaceName]?",
"identify_sign_up_barriers_description": "Ofereça um desconto pra entender melhor as barreiras de cadastro.",
"identify_sign_up_barriers_name": "Identificar Barreiras de Cadastro",
"identify_sign_up_barriers_question_1_button_label": "Ganhe 10% de desconto",
@@ -1145,12 +1162,14 @@
"improve_trial_conversion_question_1_subheader": "Ajuda a gente a te entender melhor:",
"improve_trial_conversion_question_2_button_label": "Próximo",
"improve_trial_conversion_question_2_headline": "Que pena. Qual foi o maior problema ao usar o $[workspaceName]?",
"improve_trial_conversion_question_3_button_label": "Próximo",
"improve_trial_conversion_question_3_headline": "O que você esperava que $[workspaceName] fizesse?",
"improve_trial_conversion_question_4_button_label": "Ganhe 20% de desconto",
"improve_trial_conversion_question_4_headline": "Que pena ouvir isso! Ganhe 20% de desconto no primeiro ano.",
"improve_trial_conversion_question_4_html": "Estamos felizes em te oferecer um desconto de 20% no plano anual.",
"improve_trial_conversion_question_5_button_label": "Próximo",
"improve_trial_conversion_question_5_headline": "O que você gostaria de alcançar?",
"improve_trial_conversion_question_5_subheader": "Por favor, escolha uma das opções a seguir:",
"improve_trial_conversion_question_5_subheader": "Por favor, selecione uma das seguintes opções:",
"improve_trial_conversion_question_6_headline": "Como você tá resolvendo seu problema agora?",
"improve_trial_conversion_question_6_subheader": "Por favor, nomeie soluções alternativas:",
"integration_setup_survey_description": "Avalie quão fácil é para os usuários adicionarem integrações ao seu produto. Encontre pontos cegos.",
@@ -2140,7 +2159,6 @@
"duplicate_language_or_language_id": "Idioma ou ID de idioma duplicado",
"edit_languages": "Editar idiomas",
"identifier": "Identificador (ISO)",
"incomplete_translations": "Traduções incompletas",
"language": "Idioma",
"language_deleted_successfully": "Idioma excluído com sucesso",
"languages_updated_successfully": "Idiomas atualizados com sucesso",
@@ -2150,8 +2168,7 @@
"please_select_a_language": "Por favor, selecione um idioma",
"remove_language": "Remover idioma",
"remove_language_from_surveys_to_remove_it_from_workspace": "Por favor, remova o idioma dessas pesquisas para removê-lo do workspace.",
"search_items": "Buscar itens",
"translate": "Traduzir"
"search_items": "Buscar itens"
},
"look": {
"add_background_color": "Adicionar cor de fundo",
@@ -2694,17 +2711,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "Tá tudo pronto! Hora de criar sua primeira pesquisa",
"alphabetical": "alfabético",
"copy_survey": "Copiar pesquisa",
"copy_survey_description": "Copie esta pesquisa para outro workspace",
"copy_survey_error": "Falha ao copiar pesquisa",
"copy_survey_link_to_clipboard": "Copiar link da pesquisa para a área de transferência",
"copy_survey_no_workspaces": "Não há outros workspaces para copiar esta pesquisa.",
"copy_survey_partially_success": "{success} pesquisas copiadas com sucesso, {error} falharam.",
"copy_survey_success": "Pesquisa copiada com sucesso!",
"delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para essa pesquisa:",
"2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:",
"activate_translations": "Ativar traduções",
"add": "Adicionar +",
"add_a_delay_or_auto_close_the_survey": "Adicione um atraso ou feche a pesquisa automaticamente",
"add_a_four_digit_pin": "Adicione um PIN de quatro dígitos",
@@ -2754,7 +2764,7 @@
"audience": "Público",
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
"auto_progress_rating_and_nps": "Avançar automaticamente em perguntas de avaliação e NPS",
"auto_progress_rating_and_nps_description": "Avança automaticamente quando os respondentes selecionam uma resposta em perguntas de avaliação ou NPS. Isso se aplica apenas a blocos com uma única pergunta. Perguntas obrigatórias ocultam o botão Próximo; perguntas opcionais ainda o exibem para permitir pular.",
"auto_progress_rating_and_nps_description": "Avançar automaticamente em blocos de pergunta única. Perguntas obrigatórias ocultam o botão Próximo, exceto quando \"Outro\" é selecionado.",
"auto_save_disabled": "Salvamento automático desativado",
"auto_save_disabled_tooltip": "Sua pesquisa só é salva automaticamente quando está em rascunho. Isso garante que pesquisas públicas não sejam atualizadas involuntariamente.",
"auto_save_on": "Salvamento automático ativado",
@@ -2800,6 +2810,7 @@
"caution_text": "Mudanças vão levar a inconsistências",
"change_anyway": "Mudar mesmo assim",
"change_background": "Mudar fundo",
"change_default": "Alterar padrão",
"change_question_type": "Mudar tipo de pergunta",
"change_survey_type": "Alterar o tipo de pesquisa afeta o acesso existente",
"change_the_background_to_a_color_image_or_animation": "Mude o fundo para uma cor, imagem ou animação.",
@@ -2812,6 +2823,7 @@
"choose_where_to_run_the_survey": "Escolha onde realizar a pesquisa.",
"city": "cidade",
"close_survey_on_response_limit": "Fechar pesquisa ao atingir limite de respostas",
"code": "Código",
"color": "cor",
"column_used_in_logic_error": "Esta coluna é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"columns": "colunas",
@@ -2836,6 +2848,7 @@
"customize_survey_logo": "Personalizar o logo da pesquisa",
"darken_or_lighten_background_of_your_choice": "Escureça ou clareie o fundo da sua escolha.",
"days_before_showing_this_survey_again": "ou mais dias devem passar entre a última pesquisa exibida e a exibição desta pesquisa.",
"default_language": "Idioma padrão",
"delete_anyways": "Excluir mesmo assim",
"delete_block": "Excluir bloco",
"delete_choice": "Deletar opção",
@@ -2855,7 +2868,6 @@
"duplicate_question": "Duplicar pergunta",
"edit_link": "Editar link",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções de {lang}",
"element_not_found": "Pergunta não encontrada",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os respondentes alterem o idioma a qualquer momento. Necessita de no mínimo 2 idiomas ativos.",
"enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.",
@@ -2991,11 +3003,13 @@
"long_answer": "resposta longa",
"long_answer_toggle_description": "Permitir que os respondentes escrevam respostas mais longas e com várias linhas.",
"lower_label": "Etiqueta Inferior",
"manage_languages": "Gerenciar Idiomas",
"manage_languages": "Gerenciar idiomas",
"manage_translations": "Gerenciar traduções",
"matrix_all_fields": "Todos os campos",
"matrix_rows": "Linhas",
"max_file_size": "Tamanho máximo do arquivo",
"max_file_size_limit_is": "O limite de tamanho máximo do arquivo é",
"missing_first": "Faltantes primeiro",
"move_question_to_block": "Mover pergunta para o bloco",
"multiply": "Multiplicar *",
"needed_for_self_hosted_cal_com_instance": "Necessário para uma instância auto-hospedada do Cal.com",
@@ -3003,7 +3017,7 @@
"next_button_label": "Próximo",
"no_hidden_fields_yet_add_first_one_below": "Ainda não há campos ocultos. Adicione o primeiro abaixo.",
"no_images_found_for": "Nenhuma imagem encontrada para ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Nenhum idioma encontrado. Adicione o primeiro para começar.",
"no_languages_found_add_first_one_to_get_started": "Nenhum idioma de pesquisa encontrado neste workspace. Por favor, adicione um para começar.",
"no_option_found": "Nenhuma opção encontrada",
"no_recall_items_found": "Nenhum item de recuperação encontrado",
"no_variables_yet_add_first_one_below": "Ainda não há variáveis. Adicione a primeira abaixo.",
@@ -3030,6 +3044,7 @@
"please_enter_a_valid_url": "Por favor, insira uma URL válida (ex.: https://example.com)",
"please_set_a_survey_trigger": "Por favor, configure um gatilho para a pesquisa",
"please_specify": "Por favor, especifique",
"present_your_survey_in_multiple_languages": "Apresente sua pesquisa em vários idiomas",
"prevent_double_submission": "Evitar envio duplicado",
"prevent_double_submission_description": "Permitir apenas 1 resposta por endereço de email",
"progress_saved": "Progresso salvo",
@@ -3121,6 +3136,7 @@
"seven_points": "7 pontos",
"show_block_settings": "Mostrar configurações do bloco",
"show_button": "Mostrar Botão",
"show_in_order": "Mostrar em ordem",
"show_language_switch": "Mostrar troca de idioma",
"show_multiple_times": "Mostrar um número limitado de vezes",
"show_only_once": "Mostrar só uma vez",
@@ -3152,7 +3168,6 @@
"survey_preview": "Prévia da pesquisa 👀",
"survey_styling": "Estilização de Formulários",
"survey_trigger": "Gatilho de Pesquisa",
"switch_multi_language_on_to_get_started": "Ative o modo multilíngue para começar 👉",
"target_block_not_found": "Bloco de destino não encontrado",
"targeted": "direcionado",
"ten_points": "10 pontos",
@@ -3160,9 +3175,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar uma única vez, mesmo que não respondam.",
"then": "Então",
"this_action_will_remove_all_the_translations_from_this_survey": "Essa ação vai remover todas as traduções dessa pesquisa.",
"this_will_remove_the_language_and_all_its_translations": "Isso removerá este idioma e todas as suas traduções desta pesquisa. Esta ação não pode ser desfeita.",
"three_points": "3 pontos",
"times": "times",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para manter a colocação consistente em todas as pesquisas, você pode",
"translated": "Traduzido",
"trigger_survey_when_one_of_the_actions_is_fired": "Disparar pesquisa quando uma das ações for executada...",
"try_lollipop_or_mountain": "Tenta 'pirulito' ou 'montanha'...",
"type_field_id": "Digite o id do campo",
@@ -3237,6 +3254,7 @@
"verify_email_before_submission_description": "Deixe só quem tem um email real responder.",
"visibility_and_recontact": "Visibilidade e recontato",
"visibility_and_recontact_description": "Controle quando esta pesquisa pode aparecer e com que frequência pode reaparecer.",
"visible": "Visível",
"wait": "Espera",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Espera alguns segundos depois do gatilho antes de mostrar a pesquisa",
"waiting_time_across_surveys": "Período de espera (entre pesquisas)",
@@ -3438,6 +3456,8 @@
"configure_alerts": "Configurar alertas",
"congrats": "Parabéns! Sua pesquisa está no ar.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Conecte seu site ou app com o Formbricks para começar.",
"csat_satisfied": "CSAT: {percentage}% Satisfeitos",
"csat_satisfied_tooltip": "{percentage}% dos entrevistados deram uma nota de 4 ou 5 (CSAT).",
"current_count": "Contagem Atual",
"custom_range": "Intervalo personalizado...",
"delete_all_existing_responses_and_displays": "Excluir todas as respostas e exibições existentes",
@@ -3445,7 +3465,7 @@
"downloading_qr_code": "Baixando código QR",
"drop_offs": "Pontos de Entrega",
"drop_offs_tooltip": "Número de vezes que a pesquisa foi iniciada mas não concluída.",
"failed_to_copy_link": "Falha ao copiar link",
"effort_score": "Índice de Esforço",
"filter_added_successfully": "Filtro adicionado com sucesso",
"filter_updated_successfully": "Filtro atualizado com sucesso",
"filtered_responses_csv": "Respostas filtradas (CSV)",
@@ -3497,6 +3517,7 @@
"limit": "Limite",
"no_identified_impressions": "Nenhuma impressão de contatos identificados",
"no_responses_found": "Nenhuma resposta encontrada",
"nps_promoters_tooltip": "{percentage}% dos entrevistados deram uma nota de 9 ou 10 (promotores NPS).",
"other_values_found": "Outros valores encontrados",
"overall": "No geral",
"promoters": "Promotores",
@@ -3509,7 +3530,6 @@
"quotas_completed_tooltip": "Número de cotas preenchidas pelos respondentes.",
"reset_survey": "Redefinir pesquisa",
"reset_survey_warning": "Redefinir uma pesquisa remove todas as respostas e exibições associadas a esta pesquisa. Isto não pode ser desfeito.",
"satisfied": "Satisfeito",
"selected_responses_csv": "Respostas selecionadas (CSV)",
"selected_responses_excel": "Respostas selecionadas (Excel)",
"setup_integrations": "Configurar integrações",
@@ -3533,7 +3553,6 @@
},
"survey_deleted_successfully": "Pesquisa deletada com sucesso!",
"survey_duplicated_successfully": "Pesquisa duplicada com sucesso.",
"survey_duplication_error": "Falha ao duplicar a pesquisa.",
"templates": {
"all_channels": "Todos os canais",
"all_industries": "Todas as indústrias",
+43 -24
View File
@@ -159,6 +159,7 @@
"change_workspace": "Alterar espaço de trabalho",
"chart": "Gráfico",
"charts": "Gráficos",
"choice_n": "Escolha {{n}}",
"choices": "Escolhas",
"choose_organization": "Escolher organização",
"choose_workspace": "Escolher projeto",
@@ -171,6 +172,7 @@
"close": "Fechar",
"code": "Código",
"collapse_rows": "Recolher linhas",
"column_n": "Coluna {{n}}",
"completed": "Concluído",
"configuration": "Configuração",
"confirm": "Confirmar",
@@ -242,6 +244,7 @@
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_workspaces": "Falha ao carregar projetos",
"failed_to_parse_csv": "Falha ao analisar o CSV",
"field_placeholder": "Espaço reservado de {{field}}",
"filter": "Filtro",
"finish": "Concluir",
"first_name": "Primeiro nome",
@@ -253,11 +256,13 @@
"generate": "Gerar",
"go_back": "Voltar",
"go_to_dashboard": "Ir para o Painel",
"headline": "Título",
"hidden": "Oculto",
"hidden_field": "Campo oculto",
"hidden_fields": "Campos ocultos",
"hide": "Ocultar",
"hide_column": "Ocultar coluna",
"html": "HTML",
"id": "ID",
"image": "Imagem",
"images": "Imagens",
@@ -306,7 +311,6 @@
"more_options": "Mais opções",
"move_down": "Mover para baixo",
"move_up": "Mover para cima",
"multiple_languages": "Várias línguas",
"my_product": "o meu produto",
"name": "Nome",
"new": "Novo",
@@ -323,6 +327,7 @@
"no_result_found": "Nenhum resultado encontrado",
"no_results": "Nenhum resultado",
"no_surveys_found": "Nenhum inquérito encontrado.",
"no_text_found": "Nenhum texto encontrado",
"none_of_the_above": "Nenhuma das opções acima",
"not_authenticated": "Não está autenticado para realizar esta ação.",
"not_authorized": "Não autorizado",
@@ -347,7 +352,7 @@
"organization_settings": "Configurações da Organização",
"other": "Outro",
"other_filters": "Outros Filtros",
"others": "Outros",
"other_placeholder": "Outro espaço reservado",
"overlay_color": "Cor da sobreposição",
"overview": "Visão geral",
"password": "Palavra-passe",
@@ -365,7 +370,6 @@
"please_upgrade_your_plan": "Por favor, atualize o seu plano",
"powered_by_formbricks": "Desenvolvido por Formbricks",
"preview": "Pré-visualização",
"preview_survey": "Pré-visualização do inquérito",
"privacy": "Política de Privacidade",
"product_manager": "Gestor de Produto",
"production": "Produção",
@@ -394,6 +398,7 @@
"restart": "Reiniciar",
"retry": "Tentar novamente",
"role": "Função",
"row_n": "Linha {{n}}",
"saas": "SaaS",
"sales": "Vendas",
"save": "Guardar",
@@ -433,6 +438,7 @@
"storage_not_configured": "Armazenamento de ficheiros não configurado, uploads provavelmente falharão",
"string": "Texto",
"styling": "Estilo",
"subheader": "Subtítulo",
"submit": "Submeter",
"summary": "Resumo",
"survey": "Inquérito",
@@ -503,7 +509,6 @@
"workspaces": "Projetos",
"years": "anos",
"yes": "Sim",
"you": "Você",
"you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.",
"you_are_not_authorized_to_perform_this_action": "Não está autorizado a realizar esta ação.",
"you_have_reached_your_limit_of_workspace_limit": "Atingiste o teu limite de {workspaceLimit} espaços de trabalho.",
@@ -767,6 +772,10 @@
"career_development_survey_question_6_choice_6": "Outro",
"career_development_survey_question_6_headline": "Qual das seguintes opções descreve melhor o seu nível de emprego atual?",
"career_development_survey_question_6_subheader": "Por favor, selecione uma das seguintes",
"ces": "Esforço do Cliente (CES)",
"ces_description": "Mede a Pontuação de Esforço do Cliente (1-5 ou 1-7)",
"ces_lower_label": "Muito difícil",
"ces_upper_label": "Muito fácil",
"cess_survey_name": "Inquérito CES",
"cess_survey_question_1_headline": "$[workspaceName] facilita-me [ADICIONAR OBJETIVO]",
"cess_survey_question_1_lower_label": "Discordo totalmente",
@@ -830,7 +839,9 @@
"consent_description": "Pedir para concordar com os termos, condições ou uso de dados",
"contact_info": "Informações de Contacto",
"contact_info_description": "Peça nome, apelido, email, número de telefone e empresa em conjunto",
"csat_description": "Meça o Customer Satisfaction Score do seu produto ou serviço.",
"csat": "Satisfação do Cliente (CSAT)",
"csat_description": "Mede a Pontuação de Satisfação do Cliente (1-5)",
"csat_lower_label": "Muito insatisfeito",
"csat_name": "Customer Satisfaction Score (CSAT)",
"csat_question_10_headline": "Tem mais algum comentário, pergunta ou preocupação?",
"csat_question_10_placeholder": "Escreva a sua resposta aqui...",
@@ -906,6 +917,7 @@
"csat_survey_question_2_placeholder": "Escreva a sua resposta aqui...",
"csat_survey_question_3_headline": "Oh, desculpe! Há algo que possamos fazer para melhorar a sua experiência?",
"csat_survey_question_3_placeholder": "Escreva a sua resposta aqui...",
"csat_upper_label": "Muito satisfeito",
"cta_description": "Exibir informações e solicitar aos utilizadores que tomem uma ação específica",
"custom_survey_description": "Crie um inquérito sem modelo.",
"custom_survey_name": "Começar do zero",
@@ -1071,6 +1083,11 @@
"gauge_feature_satisfaction_question_2_headline": "O que é uma coisa que poderíamos fazer melhor?",
"identify_customer_goals_description": "Compreenda melhor se a sua mensagem cria as expectativas certas sobre o valor que o seu produto oferece.",
"identify_customer_goals_name": "Identificar Objetivos do Cliente",
"identify_customer_goals_question_1_choice_1": "Compreender profundamente a minha base de utilizadores",
"identify_customer_goals_question_1_choice_2": "Identificar oportunidades de upselling",
"identify_customer_goals_question_1_choice_3": "Construir o melhor produto possível",
"identify_customer_goals_question_1_choice_4": "Dominar o mundo para fazer couves de Bruxelas ao pequeno-almoço para todos",
"identify_customer_goals_question_1_headline": "Qual é o teu objetivo principal ao usar $[workspaceName]?",
"identify_sign_up_barriers_description": "Ofereça um desconto para obter informações sobre as barreiras de inscrição.",
"identify_sign_up_barriers_name": "Identificar Barreiras de Inscrição",
"identify_sign_up_barriers_question_1_button_label": "Obtenha 10% de desconto",
@@ -1145,6 +1162,8 @@
"improve_trial_conversion_question_1_subheader": "Ajude-nos a compreendê-lo melhor:",
"improve_trial_conversion_question_2_button_label": "Seguinte",
"improve_trial_conversion_question_2_headline": "Lamentamos saber. Qual foi o maior problema ao usar o $[workspaceName]?",
"improve_trial_conversion_question_3_button_label": "Seguinte",
"improve_trial_conversion_question_3_headline": "O que esperavas que $[workspaceName] fizesse?",
"improve_trial_conversion_question_4_button_label": "Obtenha 20% de desconto",
"improve_trial_conversion_question_4_headline": "Lamentamos saber! Obtenha 20% de desconto no primeiro ano.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Estamos felizes por lhe oferecer um desconto de 20% num plano anual.</span></p>",
@@ -2140,7 +2159,6 @@
"duplicate_language_or_language_id": "Idioma ou ID de idioma duplicado",
"edit_languages": "Editar idiomas",
"identifier": "Identificador (ISO)",
"incomplete_translations": "Traduções incompletas",
"language": "Idioma",
"language_deleted_successfully": "Idioma eliminado com sucesso",
"languages_updated_successfully": "Idiomas atualizados com sucesso",
@@ -2150,8 +2168,7 @@
"please_select_a_language": "Por favor, selecione um idioma",
"remove_language": "Remover idioma",
"remove_language_from_surveys_to_remove_it_from_workspace": "Por favor, remova o idioma destes inquéritos para o poder remover do espaço de trabalho.",
"search_items": "Pesquisar itens",
"translate": "Traduzir"
"search_items": "Pesquisar itens"
},
"look": {
"add_background_color": "Adicionar cor de fundo",
@@ -2694,17 +2711,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "Está tudo pronto! Hora de criar o seu primeiro inquérito",
"alphabetical": "Alfabética",
"copy_survey": "Copiar inquérito",
"copy_survey_description": "Copiar este inquérito para outro espaço de trabalho",
"copy_survey_error": "Falha ao copiar inquérito",
"copy_survey_link_to_clipboard": "Copiar link do inquérito para a área de transferência",
"copy_survey_no_workspaces": "Não existem outros espaços de trabalho para onde copiar este inquérito.",
"copy_survey_partially_success": "{success} inquéritos copiados com sucesso, {error} falharam.",
"copy_survey_success": "Inquérito copiado com sucesso!",
"delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para este inquérito:",
"2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:",
"activate_translations": "Ativar traduções",
"add": "Adicionar +",
"add_a_delay_or_auto_close_the_survey": "Adicionar um atraso ou fechar automaticamente o inquérito",
"add_a_four_digit_pin": "Adicione um PIN de quatro dígitos",
@@ -2754,7 +2764,7 @@
"audience": "Público",
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
"auto_progress_rating_and_nps": "Avançar automaticamente em perguntas de classificação e NPS",
"auto_progress_rating_and_nps_description": "Avança automaticamente quando os inquiridos selecionam uma resposta em perguntas de classificação ou NPS. Isto aplica-se apenas a blocos com uma única pergunta. Perguntas obrigatórias ocultam o botão Seguinte; perguntas opcionais continuam a mostrá-lo para permitir saltar.",
"auto_progress_rating_and_nps_description": "Avançar automaticamente em blocos de pergunta única. Perguntas obrigatórias ocultam o botão Seguinte, exceto quando \"Outro\" está selecionado.",
"auto_save_disabled": "Guardar automático desativado",
"auto_save_disabled_tooltip": "O seu inquérito só é guardado automaticamente quando está em rascunho. Isto garante que os inquéritos públicos não sejam atualizados involuntariamente.",
"auto_save_on": "Guardar automático ativado",
@@ -2800,6 +2810,7 @@
"caution_text": "As alterações levarão a inconsistências",
"change_anyway": "Alterar mesmo assim",
"change_background": "Alterar fundo",
"change_default": "Alterar predefinição",
"change_question_type": "Alterar tipo de pergunta",
"change_survey_type": "Alterar o tipo de inquérito afeta o acesso existente",
"change_the_background_to_a_color_image_or_animation": "Altere o fundo para uma cor, imagem ou animação",
@@ -2812,6 +2823,7 @@
"choose_where_to_run_the_survey": "Escolha onde realizar o inquérito.",
"city": "Cidade",
"close_survey_on_response_limit": "Fechar inquérito no limite de respostas",
"code": "Código",
"color": "Cor",
"column_used_in_logic_error": "Esta coluna é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"columns": "Colunas",
@@ -2836,6 +2848,7 @@
"customize_survey_logo": "Personalizar o logótipo do inquérito",
"darken_or_lighten_background_of_your_choice": "Escurecer ou clarear o fundo da sua escolha.",
"days_before_showing_this_survey_again": "ou mais dias a decorrer entre o último inquérito apresentado e a apresentação deste inquérito.",
"default_language": "Idioma predefinido",
"delete_anyways": "Eliminar mesmo assim",
"delete_block": "Eliminar bloco",
"delete_choice": "Eliminar escolha",
@@ -2855,7 +2868,6 @@
"duplicate_question": "Duplicar pergunta",
"edit_link": "Editar link",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções {lang}",
"element_not_found": "Pergunta não encontrada",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os inquiridos mudem de idioma a qualquer momento. Necessita de pelo menos 2 idiomas ativos.",
"enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.",
@@ -2991,11 +3003,13 @@
"long_answer": "Resposta longa",
"long_answer_toggle_description": "Permitir que os inquiridos escrevam respostas mais longas e com várias linhas.",
"lower_label": "Etiqueta Inferior",
"manage_languages": "Gerir Idiomas",
"manage_languages": "Gerir idiomas",
"manage_translations": "Gerir traduções",
"matrix_all_fields": "Todos os campos",
"matrix_rows": "Linhas",
"max_file_size": "Tamanho máximo de ficheiro",
"max_file_size_limit_is": "O limite de tamanho máximo de ficheiro é",
"missing_first": "Em falta primeiro",
"move_question_to_block": "Mover pergunta para o bloco",
"multiply": "Multiplicar *",
"needed_for_self_hosted_cal_com_instance": "Necessário para uma instância auto-hospedada do Cal.com",
@@ -3003,7 +3017,7 @@
"next_button_label": "Rótulo do botão \"Seguinte\"",
"no_hidden_fields_yet_add_first_one_below": "Ainda não há campos ocultos. Adicione o primeiro abaixo.",
"no_images_found_for": "Não foram encontradas imagens para ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Nenhuma língua encontrada. Adicione a primeira para começar.",
"no_languages_found_add_first_one_to_get_started": "Não foram encontrados idiomas de inquérito nesta área de trabalho. Por favor, adicione um para começar.",
"no_option_found": "Nenhuma opção encontrada",
"no_recall_items_found": "Nenhum item de recuperação encontrado",
"no_variables_yet_add_first_one_below": "Ainda não há variáveis. Adicione a primeira abaixo.",
@@ -3030,6 +3044,7 @@
"please_enter_a_valid_url": "Por favor, insira um URL válido (por exemplo, https://example.com)",
"please_set_a_survey_trigger": "Por favor, defina um desencadeador de inquérito",
"please_specify": "Por favor, especifique",
"present_your_survey_in_multiple_languages": "Apresenta o teu inquérito em vários idiomas",
"prevent_double_submission": "Impedir submissão dupla",
"prevent_double_submission_description": "Permitir apenas 1 resposta por endereço de email",
"progress_saved": "Progresso guardado",
@@ -3121,6 +3136,7 @@
"seven_points": "7 pontos",
"show_block_settings": "Mostrar definições do bloco",
"show_button": "Mostrar Botão",
"show_in_order": "Mostrar por ordem",
"show_language_switch": "Mostrar alternador de idioma",
"show_multiple_times": "Mostrar um número limitado de vezes",
"show_only_once": "Mostrar apenas uma vez",
@@ -3152,7 +3168,6 @@
"survey_preview": "Pré-visualização do questionário 👀",
"survey_styling": "Estilo do formulário",
"survey_trigger": "Desencadeador de Inquérito",
"switch_multi_language_on_to_get_started": "Ative o modo multilingue para começar 👉",
"target_block_not_found": "Bloco de destino não encontrado",
"targeted": "Alvo",
"ten_points": "10 pontos",
@@ -3160,9 +3175,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar uma única vez, mesmo que não respondam.",
"then": "Então",
"this_action_will_remove_all_the_translations_from_this_survey": "Esta ação irá remover todas as traduções deste inquérito.",
"this_will_remove_the_language_and_all_its_translations": "Isto irá remover este idioma e todas as suas traduções deste inquérito. Esta ação não pode ser revertida.",
"three_points": "3 pontos",
"times": "tempos",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para manter a colocação consistente em todos os questionários, pode",
"translated": "Traduzido",
"trigger_survey_when_one_of_the_actions_is_fired": "Desencadear inquérito quando uma das ações for disparada...",
"try_lollipop_or_mountain": "Experimente 'cão' ou 'planta'...",
"type_field_id": "Escreva o id do campo",
@@ -3237,6 +3254,7 @@
"verify_email_before_submission_description": "Permitir apenas que pessoas com um email real respondam.",
"visibility_and_recontact": "Visibilidade e Recontacto",
"visibility_and_recontact_description": "Controlar quando este inquérito pode aparecer e com que frequência pode reaparecer.",
"visible": "Visível",
"wait": "Aguardar",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Aguarde alguns segundos após o gatilho antes de mostrar o inquérito",
"waiting_time_across_surveys": "Período de espera (entre inquéritos)",
@@ -3438,6 +3456,8 @@
"configure_alerts": "Configurar alertas",
"congrats": "Parabéns! O seu inquérito está ativo.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Ligue o seu website ou aplicação ao Formbricks para começar.",
"csat_satisfied": "CSAT: {percentage}% Satisfeitos",
"csat_satisfied_tooltip": "{percentage}% dos inquiridos deram uma classificação de 4 ou 5 (CSAT).",
"current_count": "Contagem atual",
"custom_range": "Intervalo personalizado...",
"delete_all_existing_responses_and_displays": "Excluir todas as respostas existentes e exibições",
@@ -3445,7 +3465,7 @@
"downloading_qr_code": "A transferir código QR",
"drop_offs": "Desistências",
"drop_offs_tooltip": "Número de vezes que o inquérito foi iniciado mas não concluído.",
"failed_to_copy_link": "Falha ao copiar link",
"effort_score": "Pontuação de Esforço",
"filter_added_successfully": "Filtro adicionado com sucesso",
"filter_updated_successfully": "Filtro atualizado com sucesso",
"filtered_responses_csv": "Respostas filtradas (CSV)",
@@ -3497,6 +3517,7 @@
"limit": "Limite",
"no_identified_impressions": "Sem impressões de contactos identificados",
"no_responses_found": "Nenhuma resposta encontrada",
"nps_promoters_tooltip": "{percentage}% dos inquiridos deram uma classificação de 9 ou 10 (promotores NPS).",
"other_values_found": "Outros valores encontrados",
"overall": "Geral",
"promoters": "Promotores",
@@ -3509,7 +3530,6 @@
"quotas_completed_tooltip": "O número de quotas concluídas pelos respondentes.",
"reset_survey": "Reiniciar inquérito",
"reset_survey_warning": "Repor um inquérito remove todas as respostas e visualizações associadas a este inquérito. Isto não pode ser desfeito.",
"satisfied": "Satisfeito",
"selected_responses_csv": "Respostas selecionadas (CSV)",
"selected_responses_excel": "Respostas selecionadas (Excel)",
"setup_integrations": "Configurar integrações",
@@ -3533,7 +3553,6 @@
},
"survey_deleted_successfully": "Inquérito eliminado com sucesso!",
"survey_duplicated_successfully": "Inquérito duplicado com sucesso.",
"survey_duplication_error": "Falha ao duplicar o inquérito.",
"templates": {
"all_channels": "Todos os canais",
"all_industries": "Todas as indústrias",
+44 -25
View File
@@ -159,6 +159,7 @@
"change_workspace": "Schimbă spațiul de lucru",
"chart": "Grafic",
"charts": "Grafice",
"choice_n": "Opțiunea {{n}}",
"choices": "Alegeri",
"choose_organization": "Alege organizația",
"choose_workspace": "Alege workspace",
@@ -171,6 +172,7 @@
"close": "Închide",
"code": "Cod",
"collapse_rows": "Restrânge rânduri",
"column_n": "Coloana {{n}}",
"completed": "Completat",
"configuration": "Configurare",
"confirm": "Confirmare",
@@ -242,6 +244,7 @@
"failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor",
"failed_to_load_workspaces": "Nu s-au putut încărca workspaces",
"failed_to_parse_csv": "Nu s-a putut procesa fișierul CSV",
"field_placeholder": "Substituent {{field}}",
"filter": "Filtru",
"finish": "Finalizează",
"first_name": "Prenume",
@@ -253,11 +256,13 @@
"generate": "Generează",
"go_back": "Înapoi",
"go_to_dashboard": "Mergi la Tablou de Bord",
"headline": "Titlu",
"hidden": "Ascuns",
"hidden_field": "Câmp ascuns",
"hidden_fields": "Câmpuri ascunse",
"hide": "Ascunde",
"hide_column": "Ascunde coloana",
"html": "HTML",
"id": "ID",
"image": "Imagine",
"images": "Imagini",
@@ -306,7 +311,6 @@
"more_options": "Mai multe opțiuni",
"move_down": "Mută în jos",
"move_up": "Mută sus",
"multiple_languages": "Mai multe limbi",
"my_product": "produsul meu",
"name": "Nume",
"new": "Nou",
@@ -323,6 +327,7 @@
"no_result_found": "Niciun rezultat găsit",
"no_results": "Nicio rezultat",
"no_surveys_found": "Nu au fost găsite sondaje.",
"no_text_found": "Niciun text găsit",
"none_of_the_above": "Niciuna dintre cele de mai sus",
"not_authenticated": "Nu sunteți autentificat pentru a efectua această acțiune.",
"not_authorized": "Neautorizat",
@@ -347,7 +352,7 @@
"organization_settings": "Setări Organizație",
"other": "Altele",
"other_filters": "Alte Filtre",
"others": "Altele",
"other_placeholder": "Alt substituent",
"overlay_color": "Culoare overlay",
"overview": "Prezentare generală",
"password": "Parolă",
@@ -365,7 +370,6 @@
"please_upgrade_your_plan": "Vă rugăm să faceți upgrade la planul dumneavoastră",
"powered_by_formbricks": "Oferit de Formbricks",
"preview": "Previzualizare",
"preview_survey": "Previzualizare Chestionar",
"privacy": "Politica de Confidențialitate",
"product_manager": "Manager de Produs",
"production": "Producție",
@@ -394,6 +398,7 @@
"restart": "Repornește",
"retry": "Reîncearcă",
"role": "Rolul",
"row_n": "Rândul {{n}}",
"saas": "SaaS",
"sales": "Vânzări",
"save": "Salvează",
@@ -433,6 +438,7 @@
"storage_not_configured": "Stocarea fișierelor neconfigurată, upload-urile vor eșua probabil",
"string": "Text",
"styling": "Stilizare",
"subheader": "Subtitlu",
"submit": "Trimite",
"summary": "Sumar",
"survey": "Chestionar",
@@ -503,7 +509,6 @@
"workspaces": "Workspaces",
"years": "ani",
"yes": "Da",
"you": "Tu",
"you_are_downgraded_to_the_community_edition": "Ai fost retrogradat la ediția Community.",
"you_are_not_authorized_to_perform_this_action": "Nu sunteți autorizat să efectuați această acțiune.",
"you_have_reached_your_limit_of_workspace_limit": "Ai atins limita de {workspaceLimit} spații de lucru.",
@@ -767,6 +772,10 @@
"career_development_survey_question_6_choice_6": "Altele",
"career_development_survey_question_6_headline": "Care dintre următoarele descrie cel mai bine nivelul actual al locului tău de muncă?",
"career_development_survey_question_6_subheader": "Vă rugăm să selectați una dintre următoarele",
"ces": "Efort client (CES)",
"ces_description": "Măsoară scorul de efort al clientului (1-5 sau 1-7)",
"ces_lower_label": "Foarte dificil",
"ces_upper_label": "Foarte ușor",
"cess_survey_name": "Chestionar CES",
"cess_survey_question_1_headline": "$[workspaceName] îmi facilitează [ADAUGĂ OBIECTIV]",
"cess_survey_question_1_lower_label": "Nu sunt deloc de acord",
@@ -830,7 +839,9 @@
"consent_description": "Cereți să fiți de acord cu termenii, condițiile sau utilizarea datelor",
"contact_info": "Informații de contact",
"contact_info_description": "Solicitați numele, prenumele, emailul, numărul de telefon și compania împreună",
"csat_description": "Măsurați Scorul de Satisfacție a Clientului al produsului sau serviciului dumneavoastră.",
"csat": "Satisfacție client (CSAT)",
"csat_description": "Măsoară scorul de satisfacție al clientului (1-5)",
"csat_lower_label": "Foarte nemulțumit",
"csat_name": "Scorul de Satisfacție a Clientului (CSAT)",
"csat_question_10_headline": "Aveți alte comentarii, întrebări sau preocupări?",
"csat_question_10_placeholder": "Tastează răspunsul aici...",
@@ -906,6 +917,7 @@
"csat_survey_question_2_placeholder": "Tastează răspunsul aici...",
"csat_survey_question_3_headline": "Of, îmi pare rău! Există ceva ce putem face pentru a-ți îmbunătăți experiența?",
"csat_survey_question_3_placeholder": "Tastează răspunsul aici...",
"csat_upper_label": "Foarte mulțumit",
"cta_description": "Afișează informații și solicită utilizatorilor să ia o acțiune specifică",
"custom_survey_description": "Creează un sondaj fără șablon.",
"custom_survey_name": "Începe de la zero",
@@ -1071,6 +1083,11 @@
"gauge_feature_satisfaction_question_2_headline": "Care este acel lucru pe care l-am putea îmbunătăți?",
"identify_customer_goals_description": "Înțelegeți mai bine dacă mesajele voastre creează așteptările corecte privind valoarea pe care o oferă produsul vostru.",
"identify_customer_goals_name": "Identifică Obiectivele Clienților",
"identify_customer_goals_question_1_choice_1": "Să înțeleg în profunzime baza mea de utilizatori",
"identify_customer_goals_question_1_choice_2": "Să identific oportunități de upselling",
"identify_customer_goals_question_1_choice_3": "Să construiesc cel mai bun produs posibil",
"identify_customer_goals_question_1_choice_4": "Să cuceresc lumea pentru a face tuturor micul dejun cu varză de Bruxelles",
"identify_customer_goals_question_1_headline": "Care este obiectivul tău principal pentru utilizarea $[workspaceName]?",
"identify_sign_up_barriers_description": "Oferiți o reducere pentru a obține informații despre barierele de înscriere.",
"identify_sign_up_barriers_name": "Identificați barierele de înscriere",
"identify_sign_up_barriers_question_1_button_label": "Obține reducere de 10%",
@@ -1145,12 +1162,14 @@
"improve_trial_conversion_question_1_subheader": "Ajută-ne să te înțelegem mai bine:",
"improve_trial_conversion_question_2_button_label": "Următorul",
"improve_trial_conversion_question_2_headline": "Ne pare rău să auzim asta. Care a fost cea mai mare problemă în folosirea $[workspaceName]?",
"improve_trial_conversion_question_3_button_label": "Următorul",
"improve_trial_conversion_question_3_headline": "Ce te-ai așteptat să facă $[workspaceName]?",
"improve_trial_conversion_question_4_button_label": "Obțineți 20% reducere",
"improve_trial_conversion_question_4_headline": "Ne pare rău să auzim asta! Obțineți 20% reducere în primul an.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Suntem bucuroși să vă oferim o reducere de 20% la un plan anual.</span></p>",
"improve_trial_conversion_question_5_button_label": "Următorul",
"improve_trial_conversion_question_5_headline": "Ce ați dori să obțineți?",
"improve_trial_conversion_question_5_subheader": " rugăm să selecti una dintre următoarele opțiuni:",
"improve_trial_conversion_question_5_subheader": "Te rugăm să selectezi una dintre următoarele opțiuni:",
"improve_trial_conversion_question_6_headline": "Cum rezolvați acum problema dumneavoastră?",
"improve_trial_conversion_question_6_subheader": "Vă rugăm să numiți soluțiile alternative:",
"integration_setup_survey_description": "Evaluați cât de ușor pot utilizatorii să adauge integrări la produsul dvs. Identificați punctele oarbe.",
@@ -2140,7 +2159,6 @@
"duplicate_language_or_language_id": "Limbă sau ID de limbă duplicat",
"edit_languages": "Editați limbile",
"identifier": "Identificator (ISO)",
"incomplete_translations": "Traduceri incomplete",
"language": "Limba",
"language_deleted_successfully": "Limba a fost ștearsă cu succes",
"languages_updated_successfully": "Limbile au fost actualizate cu succes",
@@ -2150,8 +2168,7 @@
"please_select_a_language": "Vă rugăm să selectați o limbă",
"remove_language": "Eliminați limba",
"remove_language_from_surveys_to_remove_it_from_workspace": "Vă rugăm să eliminați limba din aceste sondaje pentru a o elimina din spațiul de lucru.",
"search_items": "Căutați elemente",
"translate": "Traduceți"
"search_items": "Căutați elemente"
},
"look": {
"add_background_color": "Adăugați culoare de fundal",
@@ -2694,17 +2711,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "Ești gata! Este timpul să creezi primul tău chestionar",
"alphabetical": "Alfabetic",
"copy_survey": "Copiază sondajul",
"copy_survey_description": "Copiază acest sondaj într-un alt spațiu de lucru",
"copy_survey_error": "Nu s-a putut copia sondajul",
"copy_survey_link_to_clipboard": "Copiază linkul chestionarului în clipboard",
"copy_survey_no_workspaces": "Nu există alte spații de lucru în care să copiezi acest sondaj.",
"copy_survey_partially_success": "\"{success} sondaje copiate cu succes, {error} eșuate.\"",
"copy_survey_success": "\"Sondaj copiat cu succes!\"",
"delete_survey_and_responses_warning": "Sigur doriți să ștergeți acest sondaj și toate răspunsurile sale?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Alege limba implicită pentru acest sondaj:",
"2_activate_translation_for_specific_languages": "2. Activați traducerea pentru anumite limbi:",
"activate_translations": "Activează traducerile",
"add": "Adaugă +",
"add_a_delay_or_auto_close_the_survey": "Adăugați o întârziere sau închideți automat sondajul",
"add_a_four_digit_pin": "Adăugați un cod PIN din patru cifre",
@@ -2754,7 +2764,7 @@
"audience": "Public",
"auto_close_on_inactivity": "Închidere automată la inactivitate",
"auto_progress_rating_and_nps": "Avansare automată pentru întrebări de rating și NPS",
"auto_progress_rating_and_nps_description": "Avansează automat când respondenții selectează un răspuns la întrebările de rating sau NPS. Aceasta se aplică doar blocurilor cu o singură întrebare. Întrebările obligatorii ascund butonul Următorul; întrebările opționale îl afișează în continuare pentru a permite omiterea.",
"auto_progress_rating_and_nps_description": "Avansare automată în blocurile cu o singură întrebare. Întrebările obligatorii ascund butonul Următorul, cu excepția situației în care este selectată opțiunea „Altele“.",
"auto_save_disabled": "Salvare automată dezactivată",
"auto_save_disabled_tooltip": "Chestionarul dvs. este salvat automat doar când este în ciornă. Acest lucru asigură că sondajele publice nu sunt actualizate neintenționat.",
"auto_save_on": "Salvare automată activată",
@@ -2800,6 +2810,7 @@
"caution_text": "Schimbările vor duce la inconsecvențe",
"change_anyway": "Schimbă oricum",
"change_background": "Schimbați fundalul",
"change_default": "Schimbă implicit",
"change_question_type": "Schimbă tipul întrebării",
"change_survey_type": "Schimbarea tipului chestionarului afectează accesul existent",
"change_the_background_to_a_color_image_or_animation": "Schimbați fundalul cu o culoare, imagine sau animație.",
@@ -2812,6 +2823,7 @@
"choose_where_to_run_the_survey": "Alegeți unde să rulați chestionarul.",
"city": "Oraș",
"close_survey_on_response_limit": "Închideți sondajul la limită de răspunsuri",
"code": "Cod",
"color": "Culoare",
"column_used_in_logic_error": "Această coloană este folosită în logica întrebării {questionIndex}. Vă rugăm să o eliminați din logică mai întâi.",
"columns": "Coloane",
@@ -2836,6 +2848,7 @@
"customize_survey_logo": "Personalizează logo-ul chestionarului",
"darken_or_lighten_background_of_your_choice": "Întunecați sau luminați fundalul după preferințe.",
"days_before_showing_this_survey_again": "sau mai multe zile să treacă între ultima afișare a sondajului și afișarea acestui sondaj.",
"default_language": "Limba implicită",
"delete_anyways": "Șterge oricum",
"delete_block": "Șterge blocul",
"delete_choice": "Șterge alegerea",
@@ -2855,7 +2868,6 @@
"duplicate_question": "Duplică întrebarea",
"edit_link": "Editare legătură",
"edit_recall": "Editează Referințele",
"edit_translations": "Editează traducerile {lang}",
"element_not_found": "Întrebarea nu a fost găsită",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permite respondenților să schimbe limba în orice moment. Necesită minimum 2 limbi active.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Protecția împotriva spamului folosește reCAPTCHA v3 pentru a filtra răspunsurile de spam.",
@@ -2991,11 +3003,13 @@
"long_answer": "Răspuns lung",
"long_answer_toggle_description": "Permite respondenților să scrie răspunsuri mai lungi, pe mai multe rânduri.",
"lower_label": "Etichetă inferioară",
"manage_languages": "Gestionați limbile",
"manage_languages": "Gestionează limbile",
"manage_translations": "Gestionează traducerile",
"matrix_all_fields": "Toate câmpurile",
"matrix_rows": "Rânduri",
"max_file_size": "Dimensiune maximă fișier",
"max_file_size_limit_is": "Limita maximă pentru dimensiunea fișierului este",
"missing_first": "Lipsă întâi",
"move_question_to_block": "Mută întrebarea în bloc",
"multiply": "Multiplicare",
"needed_for_self_hosted_cal_com_instance": "Necesar pentru un exemplu autogăzduit Cal.com",
@@ -3003,7 +3017,7 @@
"next_button_label": "Etichetă buton \"Următorul\"",
"no_hidden_fields_yet_add_first_one_below": "Nu există încă câmpuri ascunse. Adăugați primul mai jos.",
"no_images_found_for": "Nicio imagine găsită pentru ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Nu s-au găsit limbi. Adaugă prima pentru a începe.",
"no_languages_found_add_first_one_to_get_started": "Nu s-au găsit limbi pentru chestionare în acest spațiu de lucru. Te rugăm să adaugi una pentru a începe.",
"no_option_found": "Nicio opțiune găsită",
"no_recall_items_found": "Nu au fost găsite elemente de reamintire",
"no_variables_yet_add_first_one_below": "Nu există variabile încă. Adăugați prima mai jos.",
@@ -3030,6 +3044,7 @@
"please_enter_a_valid_url": "Vă rugăm să introduceți un URL valid (de exemplu, https://example.com)",
"please_set_a_survey_trigger": "Vă rugăm să setați un declanșator sondaj",
"please_specify": "Vă rugăm să specificați",
"present_your_survey_in_multiple_languages": "Prezintă chestionarul tău în mai multe limbi",
"prevent_double_submission": "Prevenire trimitere dublă",
"prevent_double_submission_description": "Permite doar 1 răspuns per adresă de email.",
"progress_saved": "Progres salvat",
@@ -3121,6 +3136,7 @@
"seven_points": "7 puncte",
"show_block_settings": "Afișează setările blocului",
"show_button": "Afișează butonul",
"show_in_order": "Afișează în ordine",
"show_language_switch": "Afișează comutatorul de limbă",
"show_multiple_times": "Afișează de mai multe ori",
"show_only_once": "Afișează doar o dată",
@@ -3152,7 +3168,6 @@
"survey_preview": "Previzualizare chestionar 👀",
"survey_styling": "Stilizare formular",
"survey_trigger": "Declanșator sondaj",
"switch_multi_language_on_to_get_started": "Activați opțiunea multi-limbă pentru a începe 👉",
"target_block_not_found": "Blocul țintă nu a fost găsit",
"targeted": "Ţintite",
"ten_points": "10 puncte",
@@ -3160,9 +3175,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Afișează o singură dată, chiar dacă persoana nu răspunde.",
"then": "Apoi",
"this_action_will_remove_all_the_translations_from_this_survey": "Această acțiune va elimina toate traducerile din acest sondaj.",
"this_will_remove_the_language_and_all_its_translations": "Aceasta va elimina această limbă și toate traducerile ei din acest chestionar. Această acțiune nu poate fi anulată.",
"three_points": "3 puncte",
"times": "ori",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Pentru a menține amplasarea consecventă pentru toate sondajele, puteți",
"translated": "Tradus",
"trigger_survey_when_one_of_the_actions_is_fired": "Declanșați sondajul atunci când una dintre acțiuni este realizată...",
"try_lollipop_or_mountain": "Încercați „lollipop” sau „mountain”...",
"type_field_id": "ID câmp tip",
@@ -3237,6 +3254,7 @@
"verify_email_before_submission_description": "Permite doar persoanelor cu un email real să răspundă.",
"visibility_and_recontact": "Vizibilitate și recontactare",
"visibility_and_recontact_description": "Controlează când poate apărea acest sondaj și cât de des poate reapărea.",
"visible": "Vizibil",
"wait": "Așteptați",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Așteptați câteva secunde după declanșare înainte de a afișa sondajul",
"waiting_time_across_surveys": "Perioadă de răcire (între sondaje)",
@@ -3438,6 +3456,8 @@
"configure_alerts": "Configurează alertele",
"congrats": "Felicitări! Sondajul dumneavoastră este activ.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Conectează-ți site-ul sau aplicația cu Formbricks pentru a începe.",
"csat_satisfied": "CSAT: {percentage}% Mulțumiți",
"csat_satisfied_tooltip": "{percentage}% dintre respondenți au acordat o evaluare de 4 sau 5 (CSAT).",
"current_count": "Număr curent",
"custom_range": "Interval personalizat...",
"delete_all_existing_responses_and_displays": "Șterge toate răspunsurile și afișările existente",
@@ -3445,7 +3465,7 @@
"downloading_qr_code": "Se descarcă codul QR",
"drop_offs": "Renunțări",
"drop_offs_tooltip": "Număr de ori când sondajul a fost început dar nu a fost finalizat.",
"failed_to_copy_link": "Nu s-a putut copia legătura",
"effort_score": "Scor de efort",
"filter_added_successfully": "Filtru adăugat cu succes",
"filter_updated_successfully": "Filtru actualizat cu succes",
"filtered_responses_csv": "Răspunsuri filtrate (CSV)",
@@ -3497,6 +3517,7 @@
"limit": "Limită",
"no_identified_impressions": "Nicio impresie de la contactele identificate",
"no_responses_found": "Nu s-au găsit răspunsuri",
"nps_promoters_tooltip": "{percentage}% dintre respondenți au acordat o evaluare de 9 sau 10 (promotori NPS).",
"other_values_found": "Alte valori găsite",
"overall": "General",
"promoters": "Promotori",
@@ -3509,7 +3530,6 @@
"quotas_completed_tooltip": "Numărul de cote completate de respondenți.",
"reset_survey": "Resetează chestionarul",
"reset_survey_warning": "Resetarea unui sondaj elimină toate răspunsurile și afișajele asociate cu acest sondaj. Aceasta nu poate fi anulată.",
"satisfied": "Mulțumit",
"selected_responses_csv": "Răspunsuri selectate (CSV)",
"selected_responses_excel": "Răspunsuri selectate (Excel)",
"setup_integrations": "Configurare integrare",
@@ -3533,7 +3553,6 @@
},
"survey_deleted_successfully": "\"Sondaj șters cu succes!\"",
"survey_duplicated_successfully": "\"Sondaj duplicat cu succes!\"",
"survey_duplication_error": "Eșec la duplicarea sondajului.",
"templates": {
"all_channels": "Toate canalele",
"all_industries": "Toate industriile",
+43 -24
View File
@@ -159,6 +159,7 @@
"change_workspace": "Сменить рабочее пространство",
"chart": "График",
"charts": "Графики",
"choice_n": "Вариант {{n}}",
"choices": "Варианты",
"choose_organization": "Выберите организацию",
"choose_workspace": "Выбрать рабочее пространство",
@@ -171,6 +172,7 @@
"close": "Закрыть",
"code": "Код",
"collapse_rows": "Свернуть строки",
"column_n": "Колонка {{n}}",
"completed": "Завершено",
"configuration": "Конфигурация",
"confirm": "Подтвердить",
@@ -242,6 +244,7 @@
"failed_to_load_organizations": "Не удалось загрузить организации",
"failed_to_load_workspaces": "Не удалось загрузить рабочие пространства",
"failed_to_parse_csv": "Не удалось обработать CSV",
"field_placeholder": "Заполнитель {{field}}",
"filter": "Фильтр",
"finish": "Завершить",
"first_name": "Имя",
@@ -253,11 +256,13 @@
"generate": "Сгенерировать",
"go_back": "Назад",
"go_to_dashboard": "Перейти к панели управления",
"headline": "Заголовок",
"hidden": "Скрыто",
"hidden_field": "Скрытое поле",
"hidden_fields": "Скрытые поля",
"hide": "Скрыть",
"hide_column": "Скрыть столбец",
"html": "HTML",
"id": "ID",
"image": "Изображение",
"images": "Изображения",
@@ -306,7 +311,6 @@
"more_options": "Дополнительные опции",
"move_down": "Переместить вниз",
"move_up": "Переместить вверх",
"multiple_languages": "Несколько языков",
"my_product": "мой продукт",
"name": "Имя",
"new": "Новый",
@@ -323,6 +327,7 @@
"no_result_found": "Результат не найден",
"no_results": "Нет результатов",
"no_surveys_found": "Опросы не найдены.",
"no_text_found": "Текст не найден",
"none_of_the_above": "Ничего из вышеперечисленного",
"not_authenticated": "У вас нет прав для выполнения этого действия.",
"not_authorized": "Нет доступа",
@@ -347,7 +352,7 @@
"organization_settings": "Настройки организации",
"other": "Другое",
"other_filters": "Другие фильтры",
"others": "Другие",
"other_placeholder": "Другой заполнитель",
"overlay_color": "Цвет наложения",
"overview": "Обзор",
"password": "Пароль",
@@ -365,7 +370,6 @@
"please_upgrade_your_plan": "Пожалуйста, обновите ваш тарифный план",
"powered_by_formbricks": "Работает на Formbricks",
"preview": "Предпросмотр",
"preview_survey": "Предпросмотр опроса",
"privacy": "Политика конфиденциальности",
"product_manager": "Менеджер продукта",
"production": "Продакшн",
@@ -394,6 +398,7 @@
"restart": "Перезапустить",
"retry": "Повторить",
"role": "Роль",
"row_n": "Строка {{n}}",
"saas": "SaaS",
"sales": "Продажи",
"save": "Сохранить",
@@ -433,6 +438,7 @@
"storage_not_configured": "Хранилище файлов не настроено, загрузка, скорее всего, не удастся",
"string": "Текст",
"styling": "Стилизация",
"subheader": "Подзаголовок",
"submit": "Отправить",
"summary": "Сводка",
"survey": "Опрос",
@@ -503,7 +509,6 @@
"workspaces": "Рабочие пространства",
"years": "годы",
"yes": "Да",
"you": "Вы",
"you_are_downgraded_to_the_community_edition": "Ваша версия понижена до Community Edition.",
"you_are_not_authorized_to_perform_this_action": "У вас нет прав для выполнения этого действия.",
"you_have_reached_your_limit_of_workspace_limit": "Вы достигли лимита в {workspaceLimit} рабочих пространств.",
@@ -767,6 +772,10 @@
"career_development_survey_question_6_choice_6": "Другое",
"career_development_survey_question_6_headline": "Какой из следующих вариантов лучше всего описывает ваш текущий уровень должности?",
"career_development_survey_question_6_subheader": "Пожалуйста, выберите один из следующих вариантов",
"ces": "Усилия клиента (CES)",
"ces_description": "Измерьте оценку усилий клиента (1-5 или 1-7)",
"ces_lower_label": "Очень сложно",
"ces_upper_label": "Очень легко",
"cess_survey_name": "Опрос CES",
"cess_survey_question_1_headline": "$[workspaceName] помогает мне легко [ДОБАВИТЬ ЦЕЛЬ]",
"cess_survey_question_1_lower_label": "Совершенно не согласен",
@@ -830,7 +839,9 @@
"consent_description": "Запросить согласие с условиями, правилами или использованием данных",
"contact_info": "Контактная информация",
"contact_info_description": "Запросите имя, фамилию, адрес электронной почты, номер телефона и компанию вместе",
"csat_description": "Измерьте индекс удовлетворённости клиентов вашим продуктом или услугой.",
"csat": "Удовлетворённость клиента (CSAT)",
"csat_description": "Измерьте оценку удовлетворённости клиента (1-5)",
"csat_lower_label": "Очень недоволен",
"csat_name": "Индекс удовлетворённости клиентов (CSAT)",
"csat_question_10_headline": "Есть ли у вас другие комментарии, вопросы или замечания?",
"csat_question_10_placeholder": "Введите ваш ответ здесь...",
@@ -906,6 +917,7 @@
"csat_survey_question_2_placeholder": "Введите ваш ответ здесь...",
"csat_survey_question_3_headline": "Ой, извините! Что мы можем сделать, чтобы улучшить ваш опыт?",
"csat_survey_question_3_placeholder": "Введите ваш ответ здесь...",
"csat_upper_label": "Очень доволен",
"cta_description": "Показывайте информацию и побуждайте пользователей к определённому действию",
"custom_survey_description": "Создайте опрос без шаблона.",
"custom_survey_name": "Начать с нуля",
@@ -1071,6 +1083,11 @@
"gauge_feature_satisfaction_question_2_headline": "Что мы могли бы сделать лучше?",
"identify_customer_goals_description": "Лучше понять, создают ли ваши сообщения правильные ожидания относительно ценности вашего продукта.",
"identify_customer_goals_name": "Определение целей клиента",
"identify_customer_goals_question_1_choice_1": "Глубоко понимать свою аудиторию",
"identify_customer_goals_question_1_choice_2": "Находить возможности для допродаж",
"identify_customer_goals_question_1_choice_3": "Создавать лучший продукт",
"identify_customer_goals_question_1_choice_4": "Захватить мир и заставить всех есть брюссельскую капусту на завтрак",
"identify_customer_goals_question_1_headline": "Какова твоя основная цель использования $[workspaceName]?",
"identify_sign_up_barriers_description": "Предложите скидку, чтобы узнать, что мешает регистрации.",
"identify_sign_up_barriers_name": "Определение барьеров регистрации",
"identify_sign_up_barriers_question_1_button_label": "Получить скидку 10%",
@@ -1145,12 +1162,14 @@
"improve_trial_conversion_question_1_subheader": "Помогите нам лучше вас понять:",
"improve_trial_conversion_question_2_button_label": "Далее",
"improve_trial_conversion_question_2_headline": "Жаль это слышать. Какая была самая большая проблема при использовании $[workspaceName]?",
"improve_trial_conversion_question_3_button_label": "Далее",
"improve_trial_conversion_question_3_headline": "Что ты ожидал от $[workspaceName]?",
"improve_trial_conversion_question_4_button_label": "Получить скидку 20%",
"improve_trial_conversion_question_4_headline": "Жаль это слышать! Получите 20% скидку на первый год.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Мы рады предложить вам скидку 20% на годовой тариф.</span></p>",
"improve_trial_conversion_question_5_button_label": "Далее",
"improve_trial_conversion_question_5_headline": "Чего бы вы хотели достичь?",
"improve_trial_conversion_question_5_subheader": "Пожалуйста, выберите один из следующих вариантов:",
"improve_trial_conversion_question_5_subheader": "Пожалуйста, выбери один из следующих вариантов:",
"improve_trial_conversion_question_6_headline": "Как вы сейчас решаете свою проблему?",
"improve_trial_conversion_question_6_subheader": "Пожалуйста, укажите альтернативные решения:",
"integration_setup_survey_description": "Оцените, насколько легко пользователи могут добавлять интеграции в ваш продукт. Найдите слабые места.",
@@ -2140,7 +2159,6 @@
"duplicate_language_or_language_id": "Дублирующийся язык или идентификатор языка",
"edit_languages": "Редактировать языки",
"identifier": "Идентификатор (ISO)",
"incomplete_translations": "Неполные переводы",
"language": "Язык",
"language_deleted_successfully": "Язык успешно удалён",
"languages_updated_successfully": "Языки успешно обновлены",
@@ -2150,8 +2168,7 @@
"please_select_a_language": "Пожалуйста, выберите язык",
"remove_language": "Удалить язык",
"remove_language_from_surveys_to_remove_it_from_workspace": "Пожалуйста, удалите язык из этих опросов, чтобы удалить его из рабочей области.",
"search_items": "Поиск элементов",
"translate": "Перевести"
"search_items": "Поиск элементов"
},
"look": {
"add_background_color": "Добавить цвет фона",
@@ -2694,17 +2711,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "Всё готово! Пора создать первый опрос",
"alphabetical": "По алфавиту",
"copy_survey": "Копировать опрос",
"copy_survey_description": "Скопировать этот опрос в другое рабочее пространство",
"copy_survey_error": "Не удалось скопировать опрос",
"copy_survey_link_to_clipboard": "Скопировать ссылку на опрос в буфер обмена",
"copy_survey_no_workspaces": "Нет других рабочих пространств, в которые можно скопировать этот опрос.",
"copy_survey_partially_success": "Успешно скопировано опросов: {success}, не удалось: {error}.",
"copy_survey_success": "Опрос успешно скопирован!",
"delete_survey_and_responses_warning": "Вы уверены, что хотите удалить этот опрос и все его ответы?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Выберите язык по умолчанию для этого опроса:",
"2_activate_translation_for_specific_languages": "2. Активируйте перевод для выбранных языков:",
"activate_translations": "Активировать переводы",
"add": "Добавить +",
"add_a_delay_or_auto_close_the_survey": "Добавить задержку или автоматически закрыть опрос",
"add_a_four_digit_pin": "Добавить четырёхзначный PIN-код",
@@ -2754,7 +2764,7 @@
"audience": "Аудитория",
"auto_close_on_inactivity": "Автоматически закрывать при бездействии",
"auto_progress_rating_and_nps": "Автоматический переход для вопросов с оценкой и NPS",
"auto_progress_rating_and_nps_description": "Автоматически переходить к следующему шагу, когда респонденты выбирают ответ в вопросах с оценкой или NPS. Это применяется только к блокам с одним вопросом. В обязательных вопросах кнопка «Далее» скрыта; в необязательных вопросах она остается видимой для пропуска.",
"auto_progress_rating_and_nps_description": "Автопереход в блоках с одним вопросом. Обязательные вопросы скрывают кнопку «Далее», кроме случаев выбора «Другое».",
"auto_save_disabled": "Автосохранение отключено",
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
"auto_save_on": "Автосохранение включено",
@@ -2800,6 +2810,7 @@
"caution_text": "Изменения приведут к несоответствиям",
"change_anyway": "Всё равно изменить",
"change_background": "Изменить фон",
"change_default": "Изменить по умолчанию",
"change_question_type": "Изменить тип вопроса",
"change_survey_type": "Смена типа опроса влияет на существующий доступ",
"change_the_background_to_a_color_image_or_animation": "Изменить фон на цвет, изображение или анимацию.",
@@ -2812,6 +2823,7 @@
"choose_where_to_run_the_survey": "Выберите, где запускать опрос.",
"city": "Город",
"close_survey_on_response_limit": "Закрыть опрос при достижении лимита ответов",
"code": "Код",
"color": "Цвет",
"column_used_in_logic_error": "Этот столбец используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите его из логики.",
"columns": "Столбцы",
@@ -2836,6 +2848,7 @@
"customize_survey_logo": "Настроить логотип опроса",
"darken_or_lighten_background_of_your_choice": "Затемните или осветлите выбранный фон.",
"days_before_showing_this_survey_again": "или больше дней должно пройти между последним показом опроса и показом этого опроса.",
"default_language": "Язык по умолчанию",
"delete_anyways": "Удалить в любом случае",
"delete_block": "Удалить блок",
"delete_choice": "Удалить вариант",
@@ -2855,7 +2868,6 @@
"duplicate_question": "Дублировать вопрос",
"edit_link": "Редактировать ссылку",
"edit_recall": "Редактировать напоминание",
"edit_translations": "Редактировать переводы на {lang}",
"element_not_found": "Вопрос не найден",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Разрешить респондентам менять язык опроса в любое время. Требуется минимум 2 активных языка.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Для защиты от спама используется reCAPTCHA v3, чтобы отфильтровывать спам-ответы.",
@@ -2992,10 +3004,12 @@
"long_answer_toggle_description": "Позволить респондентам писать более длинные, многострочные ответы.",
"lower_label": "Нижняя метка",
"manage_languages": "Управление языками",
"manage_translations": "Управление переводами",
"matrix_all_fields": "Все поля",
"matrix_rows": "Строки",
"max_file_size": "Максимальный размер файла",
"max_file_size_limit_is": "Ограничение максимального размера файла",
"missing_first": "Сначала отсутствующие",
"move_question_to_block": "Переместить вопрос в блок",
"multiply": "Умножить *",
"needed_for_self_hosted_cal_com_instance": "Требуется для самостоятельного размещения Cal.com",
@@ -3003,7 +3017,7 @@
"next_button_label": "Метка кнопки «Далее»",
"no_hidden_fields_yet_add_first_one_below": "Скрытых полей пока нет. Добавьте первое ниже.",
"no_images_found_for": "Изображения не найдены для «{query}»",
"no_languages_found_add_first_one_to_get_started": "Языки не найдены. Добавьте первый, чтобы начать.",
"no_languages_found_add_first_one_to_get_started": "В этом рабочем пространстве не найдено языков опроса. Добавь язык, чтобы начать работу.",
"no_option_found": "Вариант не найден",
"no_recall_items_found": "Не найдено ни одного элемента для напоминания",
"no_variables_yet_add_first_one_below": "Пока нет переменных. Добавьте первую ниже.",
@@ -3030,6 +3044,7 @@
"please_enter_a_valid_url": "Пожалуйста, введите корректный URL (например, https://example.com)",
"please_set_a_survey_trigger": "Пожалуйста, установите триггер опроса",
"please_specify": "Пожалуйста, уточните",
"present_your_survey_in_multiple_languages": "Представьте свой опрос на нескольких языках",
"prevent_double_submission": "Предотвратить повторную отправку",
"prevent_double_submission_description": "Разрешить только 1 ответ на один адрес электронной почты",
"progress_saved": "Прогресс сохранён",
@@ -3121,6 +3136,7 @@
"seven_points": "7 баллов",
"show_block_settings": "Показать настройки блока",
"show_button": "Показать кнопку",
"show_in_order": "Показать по порядку",
"show_language_switch": "Показать переключатель языка",
"show_multiple_times": "Показать ограниченное количество раз",
"show_only_once": "Показать только один раз",
@@ -3152,7 +3168,6 @@
"survey_preview": "Предпросмотр опроса 👀",
"survey_styling": "Оформление формы",
"survey_trigger": "Триггер опроса",
"switch_multi_language_on_to_get_started": "Включите многоязычный режим, чтобы начать 👉",
"target_block_not_found": "Целевой блок не найден",
"targeted": "Нацелен",
"ten_points": "10 баллов",
@@ -3160,9 +3175,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Показать один раз, даже если не будет ответа.",
"then": "Затем",
"this_action_will_remove_all_the_translations_from_this_survey": "Это действие удалит все переводы из этого опроса.",
"this_will_remove_the_language_and_all_its_translations": "Это удалит данный язык и все его переводы из этого опроса. Это действие нельзя отменить.",
"three_points": "3 балла",
"times": "раз",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Чтобы сохранить единое расположение во всех опросах, вы можете",
"translated": "Переведено",
"trigger_survey_when_one_of_the_actions_is_fired": "Запустить опрос при выполнении одного из действий...",
"try_lollipop_or_mountain": "Попробуйте «lollipop» или «mountain»...",
"type_field_id": "Введите id поля",
@@ -3237,6 +3254,7 @@
"verify_email_before_submission_description": "Разрешить отвечать только пользователям с реальным email.",
"visibility_and_recontact": "Видимость и повторный контакт",
"visibility_and_recontact_description": "Управляйте, когда этот опрос может появляться и как часто он может повторяться.",
"visible": "Видимый",
"wait": "Ожидание",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Подождите несколько секунд после срабатывания триггера перед показом опроса",
"waiting_time_across_surveys": "Период ожидания (между опросами)",
@@ -3438,6 +3456,8 @@
"configure_alerts": "Настроить оповещения",
"congrats": "Поздравляем! Ваш опрос опубликован.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Подключите ваш сайт или приложение к Formbricks, чтобы начать.",
"csat_satisfied": "CSAT: {percentage}% удовлетворены",
"csat_satisfied_tooltip": "{percentage}% респондентов дали оценку 4 или 5 (CSAT).",
"current_count": "Текущее количество",
"custom_range": "Произвольный диапазон...",
"delete_all_existing_responses_and_displays": "Удалить все существующие ответы и отображения",
@@ -3445,7 +3465,7 @@
"downloading_qr_code": "Скачивание QR-кода",
"drop_offs": "Прерывания",
"drop_offs_tooltip": "Количество раз, когда опрос был начат, но не завершён.",
"failed_to_copy_link": "Не удалось скопировать ссылку",
"effort_score": "Оценка усилий",
"filter_added_successfully": "Фильтр успешно добавлен",
"filter_updated_successfully": "Фильтр успешно обновлён",
"filtered_responses_csv": "Отфильтрованные ответы (CSV)",
@@ -3497,6 +3517,7 @@
"limit": "Лимит",
"no_identified_impressions": "Нет показов от идентифицированных контактов",
"no_responses_found": "Ответы не найдены",
"nps_promoters_tooltip": "{percentage}% респондентов дали оценку 9 или 10 (промоутеры NPS).",
"other_values_found": "Найдены другие значения",
"overall": "В целом",
"promoters": "Сторонники",
@@ -3509,7 +3530,6 @@
"quotas_completed_tooltip": "Количество квот, выполненных респондентами.",
"reset_survey": "Сбросить опрос",
"reset_survey_warning": "Сброс опроса удаляет все ответы и связанные с этим опросом отображения. Это действие необратимо.",
"satisfied": "Доволен",
"selected_responses_csv": "Выбранные ответы (CSV)",
"selected_responses_excel": "Выбранные ответы (Excel)",
"setup_integrations": "Настроить интеграции",
@@ -3533,7 +3553,6 @@
},
"survey_deleted_successfully": "Опрос успешно удалён!",
"survey_duplicated_successfully": "Опрос успешно продублирован.",
"survey_duplication_error": "Не удалось продублировать опрос.",
"templates": {
"all_channels": "Все каналы",
"all_industries": "Все отрасли",
+42 -23
View File
@@ -159,6 +159,7 @@
"change_workspace": "Byt arbetsyta",
"chart": "Diagram",
"charts": "Diagram",
"choice_n": "Val {{n}}",
"choices": "Val",
"choose_organization": "Välj organisation",
"choose_workspace": "Välj arbetsyta",
@@ -171,6 +172,7 @@
"close": "Stäng",
"code": "Kod",
"collapse_rows": "Dölj rader",
"column_n": "Kolumn {{n}}",
"completed": "Slutförd",
"configuration": "Konfiguration",
"confirm": "Bekräfta",
@@ -242,6 +244,7 @@
"failed_to_load_organizations": "Misslyckades att ladda organisationer",
"failed_to_load_workspaces": "Det gick inte att ladda arbetsytor",
"failed_to_parse_csv": "Det gick inte att tolka CSV-filen",
"field_placeholder": "Platshållare för {{field}}",
"filter": "Filter",
"finish": "Slutför",
"first_name": "Förnamn",
@@ -253,11 +256,13 @@
"generate": "Generera",
"go_back": "Gå tillbaka",
"go_to_dashboard": "Gå till instrumentpanelen",
"headline": "Rubrik",
"hidden": "Dold",
"hidden_field": "Dolt fält",
"hidden_fields": "Dolda fält",
"hide": "Dölj",
"hide_column": "Dölj kolumn",
"html": "HTML",
"id": "ID",
"image": "Bild",
"images": "Bilder",
@@ -306,7 +311,6 @@
"more_options": "Fler alternativ",
"move_down": "Flytta ner",
"move_up": "Flytta upp",
"multiple_languages": "Flera språk",
"my_product": "min produkt",
"name": "Namn",
"new": "Ny",
@@ -323,6 +327,7 @@
"no_result_found": "Inget resultat hittades",
"no_results": "Inga resultat",
"no_surveys_found": "Inga enkäter hittades.",
"no_text_found": "Ingen text hittades",
"none_of_the_above": "Inget av ovanstående",
"not_authenticated": "Du är inte autentiserad för att utföra denna åtgärd.",
"not_authorized": "Ej behörig",
@@ -347,7 +352,7 @@
"organization_settings": "Organisationsinställningar",
"other": "Annat",
"other_filters": "Andra filter",
"others": "Andra",
"other_placeholder": "Annan platshållare",
"overlay_color": "Overlay-färg",
"overview": "Översikt",
"password": "Lösenord",
@@ -365,7 +370,6 @@
"please_upgrade_your_plan": "Vänligen uppgradera din plan",
"powered_by_formbricks": "Drivs av Formbricks",
"preview": "Förhandsgranska",
"preview_survey": "Förhandsgranska enkät",
"privacy": "Integritetspolicy",
"product_manager": "Produktchef",
"production": "Produktion",
@@ -394,6 +398,7 @@
"restart": "Starta om",
"retry": "Försök igen",
"role": "Roll",
"row_n": "Rad {{n}}",
"saas": "SaaS",
"sales": "Försäljning",
"save": "Spara",
@@ -433,6 +438,7 @@
"storage_not_configured": "Fillagring är inte konfigurerad, uppladdningar kommer sannolikt att misslyckas",
"string": "Text",
"styling": "Styling",
"subheader": "Underrubrik",
"submit": "Skicka",
"summary": "Sammanfattning",
"survey": "Enkät",
@@ -503,7 +509,6 @@
"workspaces": "Arbetsytor",
"years": "år",
"yes": "Ja",
"you": "Du",
"you_are_downgraded_to_the_community_edition": "Du har nedgraderats till Community Edition.",
"you_are_not_authorized_to_perform_this_action": "Du har inte behörighet att utföra denna åtgärd.",
"you_have_reached_your_limit_of_workspace_limit": "Du har nått din gräns på {workspaceLimit} arbetsytor.",
@@ -767,6 +772,10 @@
"career_development_survey_question_6_choice_6": "Annat",
"career_development_survey_question_6_headline": "Vilket av följande beskriver bäst din nuvarande jobbnivå?",
"career_development_survey_question_6_subheader": "Vänligen välj ett av följande",
"ces": "Kundansträngning (CES)",
"ces_description": "Mät Customer Effort Score (1-5 eller 1-7)",
"ces_lower_label": "Mycket svårt",
"ces_upper_label": "Mycket lätt",
"cess_survey_name": "CES-enkät",
"cess_survey_question_1_headline": "$[workspaceName] gör det enkelt för mig att [LÄGG TILL MÅL]",
"cess_survey_question_1_lower_label": "Håller inte alls med",
@@ -830,7 +839,9 @@
"consent_description": "Be om godkännande av villkor, bestämmelser eller dataanvändning",
"contact_info": "Kontaktinfo",
"contact_info_description": "Fråga efter namn, efternamn, e-post, telefonnummer och företag tillsammans",
"csat_description": "Mät kundnöjdhetspoängen för din produkt eller tjänst.",
"csat": "Kundnöjdhet (CSAT)",
"csat_description": "Mät Customer Satisfaction Score (1-5)",
"csat_lower_label": "Mycket missnöjd",
"csat_name": "Kundnöjdhetspoäng (CSAT)",
"csat_question_10_headline": "Har du några andra kommentarer, frågor eller funderingar?",
"csat_question_10_placeholder": "Skriv ditt svar här...",
@@ -906,6 +917,7 @@
"csat_survey_question_2_placeholder": "Skriv ditt svar här...",
"csat_survey_question_3_headline": "Aj, förlåt! Finns det något vi kan göra för att förbättra din upplevelse?",
"csat_survey_question_3_placeholder": "Skriv ditt svar här...",
"csat_upper_label": "Mycket nöjd",
"cta_description": "Visa information och uppmana användare att vidta en specifik åtgärd",
"custom_survey_description": "Skapa en enkät utan mall.",
"custom_survey_name": "Börja från början",
@@ -1071,6 +1083,11 @@
"gauge_feature_satisfaction_question_2_headline": "Vad är en sak vi kunde göra bättre?",
"identify_customer_goals_description": "Förstå bättre om din kommunikation skapar rätt förväntningar på värdet din produkt ger.",
"identify_customer_goals_name": "Identifiera kundmål",
"identify_customer_goals_question_1_choice_1": "Förstå min användarbas på djupet",
"identify_customer_goals_question_1_choice_2": "Identifiera möjligheter för merförsäljning",
"identify_customer_goals_question_1_choice_3": "Bygga bästa möjliga produkt",
"identify_customer_goals_question_1_choice_4": "Härska över världen för att få alla att äta brysselkål till frukost",
"identify_customer_goals_question_1_headline": "Vad är ditt främsta mål med att använda $[workspaceName]?",
"identify_sign_up_barriers_description": "Erbjud en rabatt för att samla insikter om registreringshinder.",
"identify_sign_up_barriers_name": "Identifiera registreringshinder",
"identify_sign_up_barriers_question_1_button_label": "Få 10% rabatt",
@@ -1145,6 +1162,8 @@
"improve_trial_conversion_question_1_subheader": "Hjälp oss förstå dig bättre:",
"improve_trial_conversion_question_2_button_label": "Nästa",
"improve_trial_conversion_question_2_headline": "Tråkigt att höra. Vad var det största problemet med att använda $[workspaceName]?",
"improve_trial_conversion_question_3_button_label": "Nästa",
"improve_trial_conversion_question_3_headline": "Vad förväntade du dig att $[workspaceName] skulle göra?",
"improve_trial_conversion_question_4_button_label": "Få 20% rabatt",
"improve_trial_conversion_question_4_headline": "Tråkigt att höra! Få 20% rabatt första året.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Vi erbjuder dig gärna 20% rabatt på en årsplan.</span></p>",
@@ -2140,7 +2159,6 @@
"duplicate_language_or_language_id": "Duplicerat språk eller språk-ID",
"edit_languages": "Redigera språk",
"identifier": "Identifierare (ISO)",
"incomplete_translations": "Ofullständiga översättningar",
"language": "Språk",
"language_deleted_successfully": "Språket har tagits bort",
"languages_updated_successfully": "Språken har uppdaterats",
@@ -2150,8 +2168,7 @@
"please_select_a_language": "Vänligen välj ett språk",
"remove_language": "Ta bort språk",
"remove_language_from_surveys_to_remove_it_from_workspace": "Ta bort språket från dessa enkäter för att kunna ta bort det från arbetsytan.",
"search_items": "Sök objekt",
"translate": "Översätt"
"search_items": "Sök objekt"
},
"look": {
"add_background_color": "Lägg till bakgrundsfärg",
@@ -2694,17 +2711,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "Allt klart! Dags att skapa din första enkät",
"alphabetical": "Alfabetisk",
"copy_survey": "Kopiera enkät",
"copy_survey_description": "Kopiera den här undersökningen till en annan arbetsyta",
"copy_survey_error": "Misslyckades med att kopiera enkät",
"copy_survey_link_to_clipboard": "Kopiera enkätlänk till urklipp",
"copy_survey_no_workspaces": "Det finns inga andra arbetsytor att kopiera den här undersökningen till.",
"copy_survey_partially_success": "{success} enkäter kopierade, {error} misslyckades.",
"copy_survey_success": "Enkät kopierad!",
"delete_survey_and_responses_warning": "Är du säker på att du vill ta bort denna enkät och alla dess svar?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Välj standardspråk för denna enkät:",
"2_activate_translation_for_specific_languages": "2. Aktivera översättning för specifika språk:",
"activate_translations": "Aktivera översättningar",
"add": "Lägg till +",
"add_a_delay_or_auto_close_the_survey": "Lägg till fördröjning eller stäng enkäten automatiskt",
"add_a_four_digit_pin": "Lägg till en fyrsiffrig PIN",
@@ -2754,7 +2764,7 @@
"audience": "Målgrupp",
"auto_close_on_inactivity": "Stäng automatiskt vid inaktivitet",
"auto_progress_rating_and_nps": "Gå vidare automatiskt vid betygs- och NPS-frågor",
"auto_progress_rating_and_nps_description": "Gå automatiskt vidare när respondenter väljer ett svar på betygs- eller NPS-frågor. Detta gäller endast block med en enda fråga. Obligatoriska frågor döljer Nästa-knappen; valfria frågor visar den fortfarande för att kunna hoppas över.",
"auto_progress_rating_and_nps_description": "Gå automatiskt vidare i frågeblock med en enda fråga. Obligatoriska frågor döljer Nästa-knappen, förutom när \"Annat\" är valt.",
"auto_save_disabled": "Automatisk sparning inaktiverad",
"auto_save_disabled_tooltip": "Din enkät sparas endast automatiskt när den är ett utkast. Detta säkerställer att publika enkäter inte uppdateras oavsiktligt.",
"auto_save_on": "Automatisk sparning på",
@@ -2800,6 +2810,7 @@
"caution_text": "Ändringar kommer att leda till inkonsekvenser",
"change_anyway": "Ändra ändå",
"change_background": "Ändra bakgrund",
"change_default": "Ändra standard",
"change_question_type": "Ändra frågetyp",
"change_survey_type": "Byte av enkättyp påverkar befintlig åtkomst",
"change_the_background_to_a_color_image_or_animation": "Ändra bakgrunden till en färg, bild eller animering.",
@@ -2812,6 +2823,7 @@
"choose_where_to_run_the_survey": "Välj var enkäten ska köras.",
"city": "Stad",
"close_survey_on_response_limit": "Stäng enkät vid svarsgräns",
"code": "Kod",
"color": "Färg",
"column_used_in_logic_error": "Denna kolumn används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",
"columns": "Kolumner",
@@ -2836,6 +2848,7 @@
"customize_survey_logo": "Anpassa undersökningens logotyp",
"darken_or_lighten_background_of_your_choice": "Gör bakgrunden mörkare eller ljusare efter eget val.",
"days_before_showing_this_survey_again": "eller fler dagar måste gå mellan den senaste visade enkäten och att visa denna enkät.",
"default_language": "Standardspråk",
"delete_anyways": "Ta bort ändå",
"delete_block": "Ta bort block",
"delete_choice": "Ta bort val",
@@ -2855,7 +2868,6 @@
"duplicate_question": "Duplicera fråga",
"edit_link": "Redigera länk",
"edit_recall": "Redigera återkallning",
"edit_translations": "Redigera {lang} översättningar",
"element_not_found": "Fråga hittades inte",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Tillåt respondenter att byta språk när som helst. Kräver minst 2 aktiva språk.",
"enable_recaptcha_to_protect_your_survey_from_spam": "Spamskydd använder reCAPTCHA v3 för att filtrera bort spam-svar.",
@@ -2992,10 +3004,12 @@
"long_answer_toggle_description": "Tillåt respondenter att skriva längre svar på flera rader.",
"lower_label": "Lägre etikett",
"manage_languages": "Hantera språk",
"manage_translations": "Hantera översättningar",
"matrix_all_fields": "Alla fält",
"matrix_rows": "Rader",
"max_file_size": "Max filstorlek",
"max_file_size_limit_is": "Maximal filstorleksgräns är",
"missing_first": "Saknade först",
"move_question_to_block": "Flytta fråga till block",
"multiply": "Multiplicera *",
"needed_for_self_hosted_cal_com_instance": "Behövs för en självhostad Cal.com-instans",
@@ -3003,7 +3017,7 @@
"next_button_label": "\"Nästa\"-knappetikett",
"no_hidden_fields_yet_add_first_one_below": "Inga dolda fält ännu. Lägg till det första nedan.",
"no_images_found_for": "Inga bilder hittades för ''{query}\"",
"no_languages_found_add_first_one_to_get_started": "Inga språk hittades. Lägg till det första för att komma igång.",
"no_languages_found_add_first_one_to_get_started": "Inga undersökningsspråk hittades i den här arbetsytan. Vänligen lägg till ett för att komma igång.",
"no_option_found": "Inget alternativ hittat",
"no_recall_items_found": "Inga återkallningsobjekt hittades",
"no_variables_yet_add_first_one_below": "Inga variabler ännu. Lägg till den första nedan.",
@@ -3030,6 +3044,7 @@
"please_enter_a_valid_url": "Vänligen ange en giltig URL (t.ex. https://example.com)",
"please_set_a_survey_trigger": "Vänligen ställ in en enkätutlösare",
"please_specify": "Vänligen specificera",
"present_your_survey_in_multiple_languages": "Presentera din enkät på flera språk",
"prevent_double_submission": "Förhindra dubbelinskickning",
"prevent_double_submission_description": "Tillåt endast 1 svar per e-postadress",
"progress_saved": "Framsteg sparade",
@@ -3121,6 +3136,7 @@
"seven_points": "7 poäng",
"show_block_settings": "Visa blockinställningar",
"show_button": "Visa knapp",
"show_in_order": "Visa i ordning",
"show_language_switch": "Visa språkväxlare",
"show_multiple_times": "Visa ett begränsat antal gånger",
"show_only_once": "Visa endast en gång",
@@ -3152,7 +3168,6 @@
"survey_preview": "Enkätförhandsgranskning 👀",
"survey_styling": "Formulärstil",
"survey_trigger": "Enkätutlösare",
"switch_multi_language_on_to_get_started": "Slå på flerspråkighet för att komma igång 👉",
"target_block_not_found": "Målblock hittades inte",
"targeted": "Riktad",
"ten_points": "10 poäng",
@@ -3160,9 +3175,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Visa en enda gång, även om de inte svarar.",
"then": "Sedan",
"this_action_will_remove_all_the_translations_from_this_survey": "Denna åtgärd kommer att ta bort alla översättningar från denna enkät.",
"this_will_remove_the_language_and_all_its_translations": "Detta tar bort språket och alla dess översättningar från denna enkät. Denna åtgärd kan inte ångras.",
"three_points": "3 poäng",
"times": "gånger",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "För att hålla placeringen konsekvent över alla enkäter kan du",
"translated": "Översatt",
"trigger_survey_when_one_of_the_actions_is_fired": "Utlös enkät när en av åtgärderna aktiveras...",
"try_lollipop_or_mountain": "Prova 'lollipop' eller 'mountain'...",
"type_field_id": "Skriv fält-ID",
@@ -3237,6 +3254,7 @@
"verify_email_before_submission_description": "Låt endast personer med en riktig e-post svara.",
"visibility_and_recontact": "Synlighet och återkontakt",
"visibility_and_recontact_description": "Kontrollera när denna enkät kan visas och hur ofta den kan visas igen.",
"visible": "Synlig",
"wait": "Vänta",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Vänta några sekunder efter utlösningen innan enkäten visas",
"waiting_time_across_surveys": "Väntetid (mellan enkäter)",
@@ -3438,6 +3456,8 @@
"configure_alerts": "Konfigurera aviseringar",
"congrats": "Grattis! Din enkät är live.",
"connect_your_website_or_app_with_formbricks_to_get_started": "Anslut din webbplats eller app med Formbricks för att komma igång.",
"csat_satisfied": "CSAT: {percentage}% Nöjda",
"csat_satisfied_tooltip": "{percentage}% av respondenterna gav ett betyg på 4 eller 5 (CSAT).",
"current_count": "Nuvarande antal",
"custom_range": "Anpassat intervall...",
"delete_all_existing_responses_and_displays": "Ta bort alla befintliga svar och visningar",
@@ -3445,7 +3465,7 @@
"downloading_qr_code": "Laddar ner QR-kod",
"drop_offs": "Avhopp",
"drop_offs_tooltip": "Antal gånger enkäten har startats men inte slutförts.",
"failed_to_copy_link": "Misslyckades med att kopiera länk",
"effort_score": "Ansträngningspoäng",
"filter_added_successfully": "Filter tillagt",
"filter_updated_successfully": "Filter uppdaterat",
"filtered_responses_csv": "Filtrerade svar (CSV)",
@@ -3497,6 +3517,7 @@
"limit": "Gräns",
"no_identified_impressions": "Inga visningar från identifierade kontakter",
"no_responses_found": "Inga svar hittades",
"nps_promoters_tooltip": "{percentage}% av respondenterna gav ett betyg på 9 eller 10 (NPS-ambassadörer).",
"other_values_found": "Andra värden hittades",
"overall": "Övergripande",
"promoters": "Ambassadörer",
@@ -3509,7 +3530,6 @@
"quotas_completed_tooltip": "Antalet kvoter som slutförts av respondenterna.",
"reset_survey": "Återställ enkät",
"reset_survey_warning": "Att återställa en enkät tar bort alla svar och visningar kopplade till denna enkät. Detta kan inte ångras.",
"satisfied": "Nöjd",
"selected_responses_csv": "Valda svar (CSV)",
"selected_responses_excel": "Valda svar (Excel)",
"setup_integrations": "Konfigurera integrationer",
@@ -3533,7 +3553,6 @@
},
"survey_deleted_successfully": "Enkät borttagen!",
"survey_duplicated_successfully": "Enkät duplicerad.",
"survey_duplication_error": "Misslyckades med att duplicera enkäten.",
"templates": {
"all_channels": "Alla kanaler",
"all_industries": "Alla branscher",
File diff suppressed because it is too large Load Diff
+44 -25
View File
@@ -159,6 +159,7 @@
"change_workspace": "切换工作区",
"chart": "图表",
"charts": "图表",
"choice_n": "选项 {{n}}",
"choices": "选项",
"choose_organization": "选择 组织",
"choose_workspace": "选择工作区",
@@ -171,6 +172,7 @@
"close": "关闭",
"code": "代码",
"collapse_rows": "折叠 行",
"column_n": "列 {{n}}",
"completed": "完成",
"configuration": "配置",
"confirm": "确认",
@@ -242,6 +244,7 @@
"failed_to_load_organizations": "加载组织失败",
"failed_to_load_workspaces": "加载工作区失败",
"failed_to_parse_csv": "CSV 解析失败",
"field_placeholder": "{{field}} 占位符",
"filter": "筛选",
"finish": "完成",
"first_name": "名字",
@@ -253,11 +256,13 @@
"generate": "生成",
"go_back": "返回 ",
"go_to_dashboard": "转到 Dashboard",
"headline": "标题",
"hidden": "隐藏",
"hidden_field": "隐藏 字段",
"hidden_fields": "隐藏 字段",
"hide": "隐藏",
"hide_column": "隐藏 列",
"html": "HTML",
"id": "ID",
"image": "图片",
"images": "图片",
@@ -306,7 +311,6 @@
"more_options": "更多选项",
"move_down": "下移",
"move_up": "上移",
"multiple_languages": "多种 语言",
"my_product": "我的产品",
"name": "名称",
"new": "新建",
@@ -323,6 +327,7 @@
"no_result_found": "没有 结果",
"no_results": "没有 结果",
"no_surveys_found": "未找到 调查",
"no_text_found": "未找到文本",
"none_of_the_above": "以上 都 不 是",
"not_authenticated": "您 未 认证 以 执行 该 操作。",
"not_authorized": "未授权",
@@ -347,7 +352,7 @@
"organization_settings": "组织 设置",
"other": "其他",
"other_filters": "其他筛选条件",
"others": "其他",
"other_placeholder": "其他占位符",
"overlay_color": "覆盖层颜色",
"overview": "概览",
"password": "密码",
@@ -365,7 +370,6 @@
"please_upgrade_your_plan": "请升级您的计划",
"powered_by_formbricks": "由 Formbricks 提供支持",
"preview": "预览",
"preview_survey": "预览 Survey",
"privacy": "隐私政策",
"product_manager": "产品经理",
"production": "生产环境",
@@ -394,6 +398,7 @@
"restart": "重新启动",
"retry": "重试",
"role": "角色",
"row_n": "行 {{n}}",
"saas": "SaaS",
"sales": "销售",
"save": "保存",
@@ -433,6 +438,7 @@
"storage_not_configured": "文件存储 未设置,上传 可能 失败",
"string": "文本",
"styling": "样式",
"subheader": "副标题",
"submit": "提交",
"summary": "概要",
"survey": "调查",
@@ -503,7 +509,6 @@
"workspaces": "工作区",
"years": "年",
"yes": "是",
"you": "你 ",
"you_are_downgraded_to_the_community_edition": "您已降级到社区版。",
"you_are_not_authorized_to_perform_this_action": "您无权执行此操作。",
"you_have_reached_your_limit_of_workspace_limit": "您已达到 {workspaceLimit} 个工作区的上限。",
@@ -767,6 +772,10 @@
"career_development_survey_question_6_choice_6": "其他",
"career_development_survey_question_6_headline": "以下哪项最能描述你 当前 的职位等级?",
"career_development_survey_question_6_subheader": "请选择以下之一",
"ces": "客户费力度 (CES)",
"ces_description": "测量客户费力度评分 (1-5 或 1-7)",
"ces_lower_label": "非常困难",
"ces_upper_label": "非常容易",
"cess_survey_name": "CES 调查",
"cess_survey_question_1_headline": "$[workspaceName] 让我能够轻松地[添加目标]",
"cess_survey_question_1_lower_label": "强烈 不同意",
@@ -830,7 +839,9 @@
"consent_description": "请求同意 条款、条件 或 数据使用",
"contact_info": "联系信息",
"contact_info_description": "请求提供 姓名、姓氏、电子邮件、电话号码 和 公司 信息",
"csat_description": "衡量 产品 或 服务 的 客户满意度 。",
"csat": "客户满意度 (CSAT)",
"csat_description": "测量客户满意度评分 (1-5)",
"csat_lower_label": "非常不满意",
"csat_name": "客户满意度得分 ( CSAT )",
"csat_question_10_headline": "你还有其他的评论、问题或担忧吗?",
"csat_question_10_placeholder": "在 此 输入 您 的 答案...",
@@ -906,6 +917,7 @@
"csat_survey_question_2_placeholder": "在此输入您的答案...",
"csat_survey_question_3_headline": "糟糕, 对不起!我们可以做些什么来改善您的体验?",
"csat_survey_question_3_placeholder": "在此输入您的答案...",
"csat_upper_label": "非常满意",
"cta_description": "显示 信息 并 提示用户采取 特定行动",
"custom_survey_description": "创建 一个 没有 模板 的 调查。",
"custom_survey_name": "从零开始",
@@ -1071,6 +1083,11 @@
"gauge_feature_satisfaction_question_2_headline": "我们 可以 改进 的 一 件事 是 什么?",
"identify_customer_goals_description": "更好 地 了解 您 的 信息 是否 创造了 您 的 产品 所 提供 价值 的 正确 期望。",
"identify_customer_goals_name": "识别 客户 目标",
"identify_customer_goals_question_1_choice_1": "深入了解我的用户群体",
"identify_customer_goals_question_1_choice_2": "识别追加销售机会",
"identify_customer_goals_question_1_choice_3": "打造最优质的产品",
"identify_customer_goals_question_1_choice_4": "统治世界,让每个人早餐都吃抱子甘蓝",
"identify_customer_goals_question_1_headline": "使用 $[workspaceName] 的主要目标是什么?",
"identify_sign_up_barriers_description": "提供折扣以收集有关 注册障碍 的见解。",
"identify_sign_up_barriers_name": "识别 注册 障碍",
"identify_sign_up_barriers_question_1_button_label": "获取 10% 折扣",
@@ -1145,12 +1162,14 @@
"improve_trial_conversion_question_1_subheader": "帮助 我们 更好 地 了解 你 :",
"improve_trial_conversion_question_2_button_label": "下一步",
"improve_trial_conversion_question_2_headline": "很遗憾听到这个消息。使用 $[workspaceName] 时遇到的最大问题是什么?",
"improve_trial_conversion_question_3_button_label": "下一步",
"improve_trial_conversion_question_3_headline": "你期望 $[workspaceName] 能做什么?",
"improve_trial_conversion_question_4_button_label": "获取 20% 折扣",
"improve_trial_conversion_question_4_headline": "很抱歉 听到!首年 可 获 20% 优惠。",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>我们 很 乐意 为 您 提供 年 度 计划 20% 的 折扣。</span></p>",
"improve_trial_conversion_question_5_button_label": "下一步",
"improve_trial_conversion_question_5_headline": "你 想 实现 什么?",
"improve_trial_conversion_question_5_subheader": "请选择以下选项之一",
"improve_trial_conversion_question_5_subheader": "请选择以下选项之一:",
"improve_trial_conversion_question_6_headline": "你 现在 如何 解决 你的 问题?",
"improve_trial_conversion_question_6_subheader": "请 列举 替代 方案:",
"integration_setup_survey_description": "评估用户 添加 集成 到 产品 的 便捷程度 。 找到 盲点 。",
@@ -2140,7 +2159,6 @@
"duplicate_language_or_language_id": "语言或语言 ID 重复",
"edit_languages": "编辑语言",
"identifier": "标识符(ISO",
"incomplete_translations": "翻译不完整",
"language": "语言",
"language_deleted_successfully": "语言删除成功",
"languages_updated_successfully": "语言更新成功",
@@ -2150,8 +2168,7 @@
"please_select_a_language": "请选择一种语言",
"remove_language": "移除语言",
"remove_language_from_surveys_to_remove_it_from_workspace": "请先从这些调查中移除该语言,然后才能从工作区中删除。",
"search_items": "搜索项目",
"translate": "翻译"
"search_items": "搜索项目"
},
"look": {
"add_background_color": "添加背景色",
@@ -2694,17 +2711,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "一切准备就绪!是时候创建您的第一个调查了",
"alphabetical": "字母顺序",
"copy_survey": "复制 调查",
"copy_survey_description": "将此调查复制到另一个工作区",
"copy_survey_error": "复制 调查 失败",
"copy_survey_link_to_clipboard": "复制 survey 链接 到 剪贴板",
"copy_survey_no_workspaces": "没有其他工作区可以复制此调查。",
"copy_survey_partially_success": "{success} 个调查成功复制,{error} 个失败。",
"copy_survey_success": "调查成功复制!",
"delete_survey_and_responses_warning": "您 确定 要 删除 此 调查 及 所有 回复 吗?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. 选择 此 调查 的 默认 语言:",
"2_activate_translation_for_specific_languages": "2. 激活 特定 语言 的 翻译:",
"activate_translations": "激活翻译",
"add": "添加 +",
"add_a_delay_or_auto_close_the_survey": "添加 延迟 或 自动 关闭 调查",
"add_a_four_digit_pin": "添加 一个 四 位 数 PIN",
@@ -2754,7 +2764,7 @@
"audience": "受众",
"auto_close_on_inactivity": "自动关闭 在 无活动时",
"auto_progress_rating_and_nps": "自动推进评分和 NPS 问题",
"auto_progress_rating_and_nps_description": "当受访者在评分或 NPS 问题上选择答案时自动前进。这仅适用于单问题区块。必填问题会隐藏\"下一步\"按钮;可选问题仍会显示该按钮以便跳过。",
"auto_progress_rating_and_nps_description": "在单问题块中自动前进。必填问题会隐藏\"下一步\"按钮,但选择\"其他\"时除外。",
"auto_save_disabled": "自动保存已禁用",
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
"auto_save_on": "自动保存已启用",
@@ -2800,6 +2810,7 @@
"caution_text": "更改 会导致 不一致",
"change_anyway": "还是更改",
"change_background": "更改 背景",
"change_default": "更改默认语言",
"change_question_type": "更改 问题类型",
"change_survey_type": "更改 调查 类型 会影 响 现有 访问",
"change_the_background_to_a_color_image_or_animation": "将 背景 更改为 颜色 、 图像 或 动画。",
@@ -2812,6 +2823,7 @@
"choose_where_to_run_the_survey": "选择 调查 运行 的 位置 。",
"city": "城市",
"close_survey_on_response_limit": "在响应限制时关闭 调查",
"code": "代码",
"color": "颜色",
"column_used_in_logic_error": "\"这个 列 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
"columns": "列",
@@ -2836,6 +2848,7 @@
"customize_survey_logo": "自定义调查 logo",
"darken_or_lighten_background_of_your_choice": "根据 您 的 选择 暗化 或 亮化 背景。",
"days_before_showing_this_survey_again": "距离上次显示问卷后需间隔不少于指定天数,才能再次显示此问卷。",
"default_language": "默认语言",
"delete_anyways": "仍然删除",
"delete_block": "删除区块",
"delete_choice": "删除 选择",
@@ -2855,7 +2868,6 @@
"duplicate_question": "复制问题",
"edit_link": "编辑 链接",
"edit_recall": "编辑 调用",
"edit_translations": "编辑 {lang} 翻译",
"element_not_found": "未找到问题",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允许受访者在调查过程中随时切换语言。需要至少启用两种语言。",
"enable_recaptcha_to_protect_your_survey_from_spam": "垃圾 邮件 保护 使用 reCAPTCHA v3 来 过滤 掉 垃圾 响应 。",
@@ -2991,11 +3003,13 @@
"long_answer": "长答案",
"long_answer_toggle_description": "允许受访者填写较长的多行答案。",
"lower_label": "下限标签",
"manage_languages": "管理 语言",
"manage_languages": "管理语言",
"manage_translations": "管理翻译",
"matrix_all_fields": "所有字段",
"matrix_rows": "行",
"max_file_size": "最大文件大小",
"max_file_size_limit_is": "最大文件大小限制为",
"missing_first": "缺失优先",
"move_question_to_block": "将问题移动到区块",
"multiply": "乘 *",
"needed_for_self_hosted_cal_com_instance": "需要用于 自建 Cal.com 实例",
@@ -3003,7 +3017,7 @@
"next_button_label": "\"下一步\" 按钮标签",
"no_hidden_fields_yet_add_first_one_below": "还没有隐藏字段。 在下面添加第一个。",
"no_images_found_for": "未找到与 \"{query}\" 相关的图片",
"no_languages_found_add_first_one_to_get_started": "没有找到语言。添加一个以开始。",
"no_languages_found_add_first_one_to_get_started": "此工作区中未找到任何调查语言。添加一个以开始使用。",
"no_option_found": "找不到选择",
"no_recall_items_found": "未找到召回项",
"no_variables_yet_add_first_one_below": "还没有变量。 在下面添加第一个。",
@@ -3030,6 +3044,7 @@
"please_enter_a_valid_url": "请输入有效的 URL(例如, https://example.com ",
"please_set_a_survey_trigger": "请 设置 一个 调查 触发",
"please_specify": "请 指定",
"present_your_survey_in_multiple_languages": "以多种语言展示您的调查问卷",
"prevent_double_submission": "防止 重复 提交",
"prevent_double_submission_description": "只允许每个 email 地址提供 1 个回复",
"progress_saved": "进度已保存",
@@ -3121,6 +3136,7 @@
"seven_points": "7 分",
"show_block_settings": "显示区块设置",
"show_button": "显示 按钮",
"show_in_order": "按顺序显示",
"show_language_switch": "显示 语言 切换",
"show_multiple_times": "显示有限次数",
"show_only_once": "仅 显示 一次",
@@ -3152,7 +3168,6 @@
"survey_preview": "问卷预览 👀",
"survey_styling": "表单 样式",
"survey_trigger": "调查 触发",
"switch_multi_language_on_to_get_started": "开启多语言以开始使用 👉",
"target_block_not_found": "未找到目标区块",
"targeted": "定位",
"ten_points": "10 分",
@@ -3160,9 +3175,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "仅显示一次,即使他们未回应。",
"then": "然后",
"this_action_will_remove_all_the_translations_from_this_survey": "此操作将删除该调查中的所有翻译。",
"this_will_remove_the_language_and_all_its_translations": "这将从此调查问卷中删除该语言及其所有翻译。此操作无法撤销。",
"three_points": "3 分",
"times": "次数",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "为了 保持 所有 调查 的 放置 一致,您 可以",
"translated": "已翻译",
"trigger_survey_when_one_of_the_actions_is_fired": "当 其中 一个 动作 被 触发 时 启动 调查…",
"try_lollipop_or_mountain": "尝试 'lollipop' 或 'mountain' ...",
"type_field_id": "类型 字段 ID",
@@ -3237,6 +3254,7 @@
"verify_email_before_submission_description": "仅允许 拥有 有效 电子邮件 的 人 回应。",
"visibility_and_recontact": "可见性与重新联系",
"visibility_and_recontact_description": "控制此调查何时可以显示以及可以重新显示的频率。",
"visible": "可见",
"wait": "等待",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "触发后等待几秒再显示问卷",
"waiting_time_across_surveys": "冷却期(跨问卷)",
@@ -3438,6 +3456,8 @@
"configure_alerts": "配置 警报",
"congrats": "恭喜!您的调查已上线。",
"connect_your_website_or_app_with_formbricks_to_get_started": "将您 的网站 或应用 与 Formbricks 连接 以开始 使用。",
"csat_satisfied": "CSAT{percentage}% 满意",
"csat_satisfied_tooltip": "{percentage}% 的受访者给出了 4 或 5 分的评价(CSAT)。",
"current_count": "当前数量",
"custom_range": "自定义 范围...",
"delete_all_existing_responses_and_displays": "删除 所有 现有 的 回复 和 显示",
@@ -3445,7 +3465,7 @@
"downloading_qr_code": "正在下载二维码",
"drop_offs": "流失",
"drop_offs_tooltip": "调查 被 开始 但 未 完成 的 次数",
"failed_to_copy_link": "复制链接失败",
"effort_score": "费力度评分",
"filter_added_successfully": "筛选器 添加成功",
"filter_updated_successfully": "筛选器 更新 成功",
"filtered_responses_csv": "过滤 反馈 CSV",
@@ -3497,6 +3517,7 @@
"limit": "限额",
"no_identified_impressions": "没有已识别联系人的展示次数",
"no_responses_found": "未找到响应",
"nps_promoters_tooltip": "{percentage}% 的受访者给出了 9 或 10 分的评价(NPS 推荐者)。",
"other_values_found": "找到其他值",
"overall": "整体",
"promoters": "推荐者",
@@ -3509,7 +3530,6 @@
"quotas_completed_tooltip": "受访者完成的配额数量。",
"reset_survey": "重置 调查",
"reset_survey_warning": "重置 一个调查 会移除与 此调查 相关 的 所有响应 和 展示 。此操作 不能 撤销 。",
"satisfied": "满意",
"selected_responses_csv": "选定 反馈 CSV",
"selected_responses_excel": "选定 反馈 Excel",
"setup_integrations": "设置 集成",
@@ -3533,7 +3553,6 @@
},
"survey_deleted_successfully": "调查 删除 成功",
"survey_duplicated_successfully": "调查成功复制。",
"survey_duplication_error": "无法复制 调查。",
"templates": {
"all_channels": "所有 渠道",
"all_industries": "所有 行业",
+44 -25
View File
@@ -159,6 +159,7 @@
"change_workspace": "變更工作區",
"chart": "圖表",
"charts": "圖表",
"choice_n": "選項 {{n}}",
"choices": "選項",
"choose_organization": "選擇 組織",
"choose_workspace": "選擇工作區",
@@ -171,8 +172,9 @@
"close": "關閉",
"code": "程式碼",
"collapse_rows": "摺疊列",
"column_n": "欄 {{n}}",
"completed": "已完成",
"configuration": "設定",
"configuration": "組態",
"confirm": "確認",
"connect": "連線",
"connect_formbricks": "連線 Formbricks",
@@ -242,6 +244,7 @@
"failed_to_load_organizations": "無法載入組織",
"failed_to_load_workspaces": "載入工作區失敗",
"failed_to_parse_csv": "CSV 解析失敗",
"field_placeholder": "{{field}} 預設文字",
"filter": "篩選",
"finish": "完成",
"first_name": "名字",
@@ -253,11 +256,13 @@
"generate": "產生",
"go_back": "返回",
"go_to_dashboard": "前往儀表板",
"headline": "標題",
"hidden": "隱藏",
"hidden_field": "隱藏欄位",
"hidden_fields": "隱藏欄位",
"hide": "隱藏",
"hide_column": "隱藏欄位",
"html": "HTML",
"id": "ID",
"image": "圖片",
"images": "圖片",
@@ -306,7 +311,6 @@
"more_options": "更多選項",
"move_down": "下移",
"move_up": "上移",
"multiple_languages": "多種語言",
"my_product": "我的產品",
"name": "名稱",
"new": "新增",
@@ -323,6 +327,7 @@
"no_result_found": "找不到結果",
"no_results": "沒有結果",
"no_surveys_found": "找不到問卷。",
"no_text_found": "找不到文字",
"none_of_the_above": "以上皆非",
"not_authenticated": "您未經授權執行此操作。",
"not_authorized": "未授權",
@@ -347,7 +352,7 @@
"organization_settings": "組織設定",
"other": "其他",
"other_filters": "其他篩選條件",
"others": "其他",
"other_placeholder": "其他預設文字",
"overlay_color": "覆蓋層顏色",
"overview": "概覽",
"password": "密碼",
@@ -365,7 +370,6 @@
"please_upgrade_your_plan": "請升級您的方案",
"powered_by_formbricks": "由 Formbricks 提供技術支援",
"preview": "預覽",
"preview_survey": "預覽問卷",
"privacy": "隱私權政策",
"product_manager": "產品經理",
"production": "正式環境",
@@ -394,6 +398,7 @@
"restart": "重新開始",
"retry": "重試",
"role": "角色",
"row_n": "列 {{n}}",
"saas": "SaaS",
"sales": "銷售",
"save": "儲存",
@@ -433,6 +438,7 @@
"storage_not_configured": "檔案儲存未設定,上傳可能會失敗",
"string": "文字",
"styling": "樣式設定",
"subheader": "副標題",
"submit": "提交",
"summary": "摘要",
"survey": "問卷",
@@ -503,7 +509,6 @@
"workspaces": "工作區",
"years": "年",
"yes": "是",
"you": "您",
"you_are_downgraded_to_the_community_edition": "您已降級至社群版。",
"you_are_not_authorized_to_perform_this_action": "您沒有執行此操作的權限。",
"you_have_reached_your_limit_of_workspace_limit": "您已達到 {workspaceLimit} 個工作區的上限。",
@@ -767,6 +772,10 @@
"career_development_survey_question_6_choice_6": "其他",
"career_development_survey_question_6_headline": "以下哪一項最能描述您目前的工作層級?",
"career_development_survey_question_6_subheader": "請選取以下其中一個",
"ces": "顧客費力度 (CES)",
"ces_description": "測量顧客費力度分數 (1-5 或 1-7)",
"ces_lower_label": "非常困難",
"ces_upper_label": "非常容易",
"cess_survey_name": "CES 問卷",
"cess_survey_question_1_headline": "$[workspaceName] 讓我能輕鬆地 [ADD GOAL]",
"cess_survey_question_1_lower_label": "非常不同意",
@@ -830,7 +839,9 @@
"consent_description": "要求同意條款、條件或資料使用",
"contact_info": "聯絡資訊",
"contact_info_description": "要求姓名、電子郵件、電話號碼和公司",
"csat_description": "衡量您的產品或服務的客戶滿意度分數。",
"csat": "顧客滿意度 (CSAT)",
"csat_description": "測量顧客滿意度分數 (1-5)",
"csat_lower_label": "非常不滿意",
"csat_name": "客戶滿意度分數 (CSAT)",
"csat_question_10_headline": "您有任何其他意見、問題或疑慮嗎?",
"csat_question_10_placeholder": "在此輸入您的答案...",
@@ -906,6 +917,7 @@
"csat_survey_question_2_placeholder": "在此輸入您的答案...",
"csat_survey_question_3_headline": "唉,抱歉!我們是否有任何可以改善您體驗的地方?",
"csat_survey_question_3_placeholder": "在此輸入您的答案...",
"csat_upper_label": "非常滿意",
"cta_description": "顯示資訊並提示使用者採取特定操作",
"custom_survey_description": "建立沒有範本的問卷。",
"custom_survey_name": "從頭開始",
@@ -1071,6 +1083,11 @@
"gauge_feature_satisfaction_question_2_headline": "我們可以做哪一件事來改進?",
"identify_customer_goals_description": "更瞭解您的訊息傳遞是否符合您的產品所提供價值的正確期望。",
"identify_customer_goals_name": "識別客戶目標",
"identify_customer_goals_question_1_choice_1": "深入了解我的使用者群",
"identify_customer_goals_question_1_choice_2": "找出向上銷售的機會",
"identify_customer_goals_question_1_choice_3": "打造最優質的產品",
"identify_customer_goals_question_1_choice_4": "統治世界,讓每個人早餐都吃球芽甘藍",
"identify_customer_goals_question_1_headline": "您使用 $[workspaceName] 的主要目標是什麼?",
"identify_sign_up_barriers_description": "提供折扣以收集有關註冊障礙的洞察。",
"identify_sign_up_barriers_name": "識別註冊障礙",
"identify_sign_up_barriers_question_1_button_label": "獲得 10% 折扣",
@@ -1145,12 +1162,14 @@
"improve_trial_conversion_question_1_subheader": "協助我們更瞭解您:",
"improve_trial_conversion_question_2_button_label": "下一步",
"improve_trial_conversion_question_2_headline": "很遺憾聽到這個消息。使用 $[workspaceName] 時最大的問題是什麼?",
"improve_trial_conversion_question_3_button_label": "下一步",
"improve_trial_conversion_question_3_headline": "您期望 $[workspaceName] 做什麼?",
"improve_trial_conversion_question_4_button_label": "獲得 20% 折扣",
"improve_trial_conversion_question_4_headline": "很抱歉聽到!在第一年獲得 20% 的折扣。",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>我們很樂意為您提供年度方案的 20% 折扣。</span></p>",
"improve_trial_conversion_question_5_button_label": "下一步",
"improve_trial_conversion_question_5_headline": "您想要達成什麼?",
"improve_trial_conversion_question_5_subheader": "請選取以下其中一個選項:",
"improve_trial_conversion_question_5_subheader": "請從以下選項中選擇一項:",
"improve_trial_conversion_question_6_headline": "您現在如何解決您的問題?",
"improve_trial_conversion_question_6_subheader": "請列出替代解決方案:",
"integration_setup_survey_description": "評估使用者將整合新增至您的產品的容易程度。找出盲點。",
@@ -2140,7 +2159,6 @@
"duplicate_language_or_language_id": "語言或語言 ID 重複",
"edit_languages": "編輯語言",
"identifier": "識別碼(ISO",
"incomplete_translations": "翻譯不完整",
"language": "語言",
"language_deleted_successfully": "語言已成功刪除",
"languages_updated_successfully": "語言已成功更新",
@@ -2150,8 +2168,7 @@
"please_select_a_language": "請選擇一種語言",
"remove_language": "移除語言",
"remove_language_from_surveys_to_remove_it_from_workspace": "請先從這些問卷中移除此語言,才能從工作區移除。",
"search_items": "搜尋項目",
"translate": "翻譯"
"search_items": "搜尋項目"
},
"look": {
"add_background_color": "新增背景顏色",
@@ -2694,17 +2711,10 @@
"surveys": {
"all_set_time_to_create_first_survey": "您已準備就緒!是時候建立您的第一個問卷",
"alphabetical": "依字母順序",
"copy_survey": "複製問卷",
"copy_survey_description": "將此問卷複製到另一個工作區",
"copy_survey_error": "無法複製問卷",
"copy_survey_link_to_clipboard": "將問卷連結複製到剪貼簿",
"copy_survey_no_workspaces": "沒有其他工作區可以複製此問卷。",
"copy_survey_partially_success": "{success} 個問卷已成功複製,{error} 個失敗。",
"copy_survey_success": "問卷已成功複製!",
"delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. 選擇此問卷的預設語言:",
"2_activate_translation_for_specific_languages": "2. 啟用特定語言的翻譯:",
"activate_translations": "啟用翻譯",
"add": "新增 +",
"add_a_delay_or_auto_close_the_survey": "新增延遲或自動關閉問卷",
"add_a_four_digit_pin": "新增四位數 PIN 碼",
@@ -2754,7 +2764,7 @@
"audience": "受眾",
"auto_close_on_inactivity": "非活動時自動關閉",
"auto_progress_rating_and_nps": "自動前進評分與 NPS 問題",
"auto_progress_rating_and_nps_description": "當受訪者在評分或 NPS 問題中選擇答案時自動前進。此設定僅適用於單一問題區塊。必填問題會隱藏「下一步」按鈕;選填問題仍會顯示該按鈕以便跳過。",
"auto_progress_rating_and_nps_description": "在單一問題區塊中自動前進。必填問題會隱藏「下一步」按鈕,但選擇「其他」時除外。",
"auto_save_disabled": "自動儲存已停用",
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
"auto_save_on": "自動儲存已啟用",
@@ -2800,6 +2810,7 @@
"caution_text": "變更會導致不一致",
"change_anyway": "仍然變更",
"change_background": "變更背景",
"change_default": "變更預設",
"change_question_type": "變更問題類型",
"change_survey_type": "切換問卷類型會影響現有訪問",
"change_the_background_to_a_color_image_or_animation": "將背景變更為顏色、圖片或動畫。",
@@ -2812,6 +2823,7 @@
"choose_where_to_run_the_survey": "選擇在哪裡執行問卷。",
"city": "城市",
"close_survey_on_response_limit": "在回應次數上限關閉問卷",
"code": "代碼",
"color": "顏色",
"column_used_in_logic_error": "此 column 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"columns": "欄位",
@@ -2836,6 +2848,7 @@
"customize_survey_logo": "自訂問卷標誌",
"darken_or_lighten_background_of_your_choice": "變暗或變亮您選擇的背景。",
"days_before_showing_this_survey_again": "距離上次顯示問卷後,需間隔指定天數才能再次顯示此問卷。",
"default_language": "預設語言",
"delete_anyways": "仍要刪除",
"delete_block": "刪除區塊",
"delete_choice": "刪除選項",
@@ -2855,7 +2868,6 @@
"duplicate_question": "複製問題",
"edit_link": "編輯 連結",
"edit_recall": "編輯回憶",
"edit_translations": "編輯 '{'language'}' 翻譯",
"element_not_found": "找不到問題",
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許受訪者隨時切換語言。需要至少啟用兩種語言。",
"enable_recaptcha_to_protect_your_survey_from_spam": "垃圾郵件保護使用 reCAPTCHA v3 過濾垃圾回應。",
@@ -2992,10 +3004,12 @@
"long_answer_toggle_description": "允許受訪者撰寫較長的多行回答。",
"lower_label": "下標籤",
"manage_languages": "管理語言",
"manage_translations": "管理翻譯",
"matrix_all_fields": "所有欄位",
"matrix_rows": "列",
"max_file_size": "最大檔案大小",
"max_file_size_limit_is": "最大檔案大小限制為",
"missing_first": "缺少的優先",
"move_question_to_block": "將問題移至區塊",
"multiply": "乘 *",
"needed_for_self_hosted_cal_com_instance": "自行託管 Cal.com 執行個體時需要",
@@ -3003,7 +3017,7 @@
"next_button_label": "「下一步」按鈕標籤",
"no_hidden_fields_yet_add_first_one_below": "尚無隱藏欄位。在下方新增第一個隱藏欄位。",
"no_images_found_for": "找不到「'{'query'}'」的圖片",
"no_languages_found_add_first_one_to_get_started": "找不到語言。新增一個語言以開始使用。",
"no_languages_found_add_first_one_to_get_started": "此工作區中未找到任何問卷語言。請先新增一個語言以開始使用。",
"no_option_found": "找不到選項",
"no_recall_items_found": "未找到回溯項目",
"no_variables_yet_add_first_one_below": "尚無變數。在下方新增第一個變數。",
@@ -3030,6 +3044,7 @@
"please_enter_a_valid_url": "請輸入有效的 URL(例如:https://example.com",
"please_set_a_survey_trigger": "請設定問卷觸發器",
"please_specify": "請指定",
"present_your_survey_in_multiple_languages": "以多種語言呈現你的問卷",
"prevent_double_submission": "防止重複提交",
"prevent_double_submission_description": "每個電子郵件地址僅允許 1 個回應",
"progress_saved": "進度已儲存",
@@ -3121,6 +3136,7 @@
"seven_points": "7 分",
"show_block_settings": "顯示區塊設定",
"show_button": "顯示按鈕",
"show_in_order": "依序顯示",
"show_language_switch": "顯示語言切換",
"show_multiple_times": "顯示有限次數",
"show_only_once": "僅顯示一次",
@@ -3152,7 +3168,6 @@
"survey_preview": "問卷預覽 👀",
"survey_styling": "表單樣式設定",
"survey_trigger": "問卷觸發器",
"switch_multi_language_on_to_get_started": "請開啟多語言功能以開始使用 👉",
"target_block_not_found": "找不到目標區塊",
"targeted": "目標",
"ten_points": "10 分",
@@ -3160,9 +3175,11 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "僅顯示一次,即使他們未回應。",
"then": "然後",
"this_action_will_remove_all_the_translations_from_this_survey": "此操作將從此問卷中移除所有翻譯。",
"this_will_remove_the_language_and_all_its_translations": "這將會從此問卷中移除該語言及其所有翻譯。此操作無法復原。",
"three_points": "3 分",
"times": "次",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "若要保持所有問卷的位置一致,您可以",
"translated": "已翻譯",
"trigger_survey_when_one_of_the_actions_is_fired": "當觸發其中一個操作時,觸發問卷...",
"try_lollipop_or_mountain": "嘗試「棒棒糖」或「山峰」...",
"type_field_id": "輸入欄位 ID",
@@ -3237,6 +3254,7 @@
"verify_email_before_submission_description": "僅允許擁有真實電子郵件的人員回應。",
"visibility_and_recontact": "可見性與重新聯絡",
"visibility_and_recontact_description": "控制此問卷何時可以顯示以及可以重新顯示的頻率。",
"visible": "可見",
"wait": "等待",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "在觸發後等待幾秒鐘再顯示問卷",
"waiting_time_across_surveys": "冷卻期(跨問卷)",
@@ -3438,6 +3456,8 @@
"configure_alerts": "設定警示",
"congrats": "恭喜!您的問卷已上線。",
"connect_your_website_or_app_with_formbricks_to_get_started": "將您的網站或應用程式與 Formbricks 連線以開始使用。",
"csat_satisfied": "CSAT{percentage}% 滿意",
"csat_satisfied_tooltip": "{percentage}% 的受訪者給予 4 或 5 分評價(CSAT)。",
"current_count": "目前計數",
"custom_range": "自訂範圍...",
"delete_all_existing_responses_and_displays": "刪除 所有 現有 回應 和 顯示",
@@ -3445,7 +3465,7 @@
"downloading_qr_code": "正在下載 QR code",
"drop_offs": "放棄",
"drop_offs_tooltip": "問卷已開始但未完成的次數。",
"failed_to_copy_link": "無法複製連結",
"effort_score": "努力分數",
"filter_added_successfully": "篩選器已成功新增",
"filter_updated_successfully": "篩選器已成功更新",
"filtered_responses_csv": "篩選回應 (CSV)",
@@ -3497,6 +3517,7 @@
"limit": "限制",
"no_identified_impressions": "沒有來自已識別聯絡人的曝光次數",
"no_responses_found": "找不到回應",
"nps_promoters_tooltip": "{percentage}% 的受訪者給予 9 或 10 分評價(NPS 推薦者)。",
"other_values_found": "找到其他值",
"overall": "整體",
"promoters": "推廣者",
@@ -3509,7 +3530,6 @@
"quotas_completed_tooltip": "受訪者完成的 配額 數量。",
"reset_survey": "重設問卷",
"reset_survey_warning": "重置 調查 會 移除 與 此 調查 相關 的 所有 回應 和 顯示 。 這 是 不可 撤銷 的 。",
"satisfied": "滿意",
"selected_responses_csv": "選擇的回應 (CSV)",
"selected_responses_excel": "選擇的回應 (Excel)",
"setup_integrations": "設定整合",
@@ -3533,7 +3553,6 @@
},
"survey_deleted_successfully": "問卷已成功刪除!",
"survey_duplicated_successfully": "問卷已成功複製。",
"survey_duplication_error": "無法複製問卷。",
"templates": {
"all_channels": "所有管道",
"all_industries": "所有產業",
@@ -53,6 +53,8 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
};
switch (element.type) {
case TSurveyElementTypeEnum.Rating:
case TSurveyElementTypeEnum.CSAT:
case TSurveyElementTypeEnum.CES:
if (typeof responseData === "number") {
return (
<RatingResponse
@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TResponse } from "@formbricks/types/responses";
import { TUserLocale } from "@formbricks/types/user";
import { Button } from "@/modules/ui/components/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface InfoIconButtonProps {
@@ -26,9 +25,11 @@ const InfoIconButton = ({
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" aria-label={ariaLabel}>
<button
className="flex h-4 w-4 items-center justify-center rounded text-slate-500 hover:text-slate-700"
aria-label={ariaLabel}>
<Icon className="h-4 w-4" />
</Button>
</button>
</TooltipTrigger>
<TooltipContent avoidCollisions align="start" side="bottom" className={maxWidth}>
{tooltipContent}
@@ -0,0 +1,38 @@
import { describe, expect, test } from "vitest";
import { V3ApiError, getV3ApiErrorMessage, parseV3ApiError } from "@/modules/api/lib/v3-client";
describe("parseV3ApiError", () => {
test("parses RFC 9457 error responses into a typed V3ApiError", async () => {
const response = new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
code: "forbidden",
requestId: "req_1",
invalid_params: [{ name: "surveyId", reason: "Invalid id" }],
}),
{
status: 403,
headers: {
"Content-Type": "application/problem+json",
"X-Request-Id": "req_1",
},
}
);
const error = await parseV3ApiError(response);
expect(error).toBeInstanceOf(V3ApiError);
expect(error.status).toBe(403);
expect(error.detail).toBe("You are not authorized to access this resource");
expect(error.code).toBe("forbidden");
expect(error.requestId).toBe("req_1");
expect(error.invalid_params).toEqual([{ name: "surveyId", reason: "Invalid id" }]);
});
test("falls back to a provided fallback message", () => {
expect(getV3ApiErrorMessage(new Error("boom"), "fallback")).toBe("boom");
expect(getV3ApiErrorMessage("bad", "fallback")).toBe("fallback");
});
});
+74
View File
@@ -0,0 +1,74 @@
export type TV3InvalidParam = {
name: string;
reason: string;
};
type TV3ProblemBody = {
status?: number;
detail?: string;
code?: string;
requestId?: string;
invalid_params?: TV3InvalidParam[];
};
export class V3ApiError extends Error {
status: number;
code?: string;
requestId?: string;
invalid_params?: TV3InvalidParam[];
constructor({
status,
detail,
code,
requestId,
invalid_params,
}: {
status: number;
detail: string;
code?: string;
requestId?: string;
invalid_params?: TV3InvalidParam[];
}) {
super(detail);
this.name = "V3ApiError";
this.status = status;
this.code = code;
this.requestId = requestId;
this.invalid_params = invalid_params;
}
get detail(): string {
return this.message;
}
}
export function getV3ApiErrorMessage(error: unknown, fallbackMessage: string): string {
if (error instanceof V3ApiError) {
return error.detail;
}
if (error instanceof Error && error.message) {
return error.message;
}
return fallbackMessage;
}
export async function parseV3ApiError(response: Response): Promise<V3ApiError> {
let problemBody: TV3ProblemBody | undefined;
try {
problemBody = (await response.json()) as TV3ProblemBody;
} catch {
problemBody = undefined;
}
return new V3ApiError({
status: problemBody?.status ?? response.status,
detail: problemBody?.detail ?? response.statusText ?? "An unexpected error occurred.",
code: problemBody?.code,
requestId: problemBody?.requestId ?? response.headers.get("X-Request-Id") ?? undefined,
invalid_params: problemBody?.invalid_params,
});
}
@@ -1,7 +1,7 @@
import { createId } from "@paralleldrive/cuid2";
import { describe, expect, test, vi } from "vitest";
import { err, ok } from "@formbricks/types/error-handlers";
import { ApiErrorResponseV2 } from "../../types/api-error";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { getWorkspaceId, getWorkspaceIdFromSurveyIds } from "../helper";
import { fetchWorkspaceId, fetchWorkspaceIdFromSurveyIds } from "../services";
@@ -0,0 +1,193 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const {
mockAuthenticatedApiClient,
mockFormatValidationErrorsForV2Api,
mockGetResponse,
mockGetResponseSnapshotForPipeline,
mockGetSurveyQuestions,
mockHandleApiError,
mockHasPermission,
mockGetWorkspaceId,
mockSendToPipeline,
mockSuccessResponse,
mockUpdateResponseWithQuotaEvaluation,
mockValidateFileUploads,
mockValidateOtherOptionLengthForMultipleChoice,
mockValidateResponseData,
} = vi.hoisted(() => ({
mockAuthenticatedApiClient: vi.fn(),
mockFormatValidationErrorsForV2Api: vi.fn(),
mockGetResponse: vi.fn(),
mockGetResponseSnapshotForPipeline: vi.fn(),
mockGetSurveyQuestions: vi.fn(),
mockHandleApiError: vi.fn(),
mockHasPermission: vi.fn(),
mockGetWorkspaceId: vi.fn(),
mockSendToPipeline: vi.fn(),
mockSuccessResponse: vi.fn(),
mockUpdateResponseWithQuotaEvaluation: vi.fn(),
mockValidateFileUploads: vi.fn(),
mockValidateOtherOptionLengthForMultipleChoice: vi.fn(),
mockValidateResponseData: vi.fn(),
}));
vi.mock("@/app/lib/pipelines", () => ({
sendToPipeline: mockSendToPipeline,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV2Api: mockFormatValidationErrorsForV2Api,
validateResponseData: mockValidateResponseData,
}));
vi.mock("@/modules/api/v2/auth/authenticated-api-client", () => ({
authenticatedApiClient: mockAuthenticatedApiClient,
}));
vi.mock("@/modules/api/v2/lib/element", () => ({
validateOtherOptionLengthForMultipleChoice: mockValidateOtherOptionLengthForMultipleChoice,
}));
vi.mock("@/modules/api/v2/lib/response", () => ({
responses: {
successResponse: mockSuccessResponse,
},
}));
vi.mock("@/modules/api/v2/lib/utils", () => ({
handleApiError: mockHandleApiError,
}));
vi.mock("@/modules/api/v2/management/lib/helper", () => ({
getWorkspaceId: mockGetWorkspaceId,
}));
vi.mock("@/modules/organization/settings/api-keys/lib/utils", () => ({
hasPermission: mockHasPermission,
}));
vi.mock("@/modules/storage/utils", () => ({
resolveStorageUrlsInObject: vi.fn((value) => value),
validateFileUploads: mockValidateFileUploads,
}));
vi.mock("./lib/response", () => ({
deleteResponse: vi.fn(),
getResponse: mockGetResponse,
getResponseForPipeline: mockGetResponseSnapshotForPipeline,
updateResponseWithQuotaEvaluation: mockUpdateResponseWithQuotaEvaluation,
}));
vi.mock("./lib/survey", () => ({
getSurveyQuestions: mockGetSurveyQuestions,
}));
const workspaceId = "cm9workspace000108l4abcz12";
const surveyId = "cm9survey000108l4abcz12zz";
const responseId = "cm9response000108l4abcz12";
const updatedAt = new Date("2026-04-13T11:00:00.000Z");
const existingResponse = {
contactAttributes: null,
contactId: null,
createdAt: new Date("2026-04-13T10:00:00.000Z"),
data: {},
displayId: null,
endingId: null,
finished: false,
id: responseId,
language: null,
meta: {},
singleUseId: null,
surveyId,
ttc: {},
updatedAt: new Date("2026-04-13T10:00:00.000Z"),
variables: {},
};
const updatedResponse = {
...existingResponse,
finished: true,
updatedAt,
};
const responseSnapshot = {
contact: null,
contactAttributes: null,
createdAt: existingResponse.createdAt,
data: {},
displayId: null,
endingId: null,
finished: true,
id: responseId,
language: null,
meta: {},
singleUseId: null,
surveyId,
tags: [],
ttc: {},
updatedAt,
variables: {},
};
describe("PUT /modules/api/v2/management/responses/[responseId]", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAuthenticatedApiClient.mockImplementation(
async ({ handler }) =>
await handler({
auditLog: undefined,
authentication: {
workspacePermissions: [{ workspaceId, actions: ["PUT"] }],
},
parsedInput: {
body: {
data: {},
finished: true,
},
params: {
responseId,
},
},
})
);
mockGetWorkspaceId.mockResolvedValue({ data: { workspaceId }, ok: true });
mockHasPermission.mockReturnValue(true);
mockGetResponse.mockResolvedValue({ data: existingResponse, ok: true });
mockGetSurveyQuestions.mockResolvedValue({ data: { blocks: [], questions: [] }, ok: true });
mockValidateFileUploads.mockReturnValue(true);
mockValidateOtherOptionLengthForMultipleChoice.mockReturnValue(undefined);
mockValidateResponseData.mockReturnValue(null);
mockUpdateResponseWithQuotaEvaluation.mockResolvedValue({ data: updatedResponse, ok: true });
mockGetResponseSnapshotForPipeline.mockResolvedValue({ data: responseSnapshot, ok: true });
mockSuccessResponse.mockImplementation((body: unknown) => Response.json(body, { status: 200 }));
mockHandleApiError.mockImplementation((_, error) => Response.json({ error }, { status: 400 }));
});
test("passes the updated response snapshot to the pipeline scheduler", async () => {
const { PUT } = await import("./route");
const response = await PUT(
new Request("http://localhost/api/v2/management/responses/resp_1", { method: "PUT" }),
{
params: Promise.resolve({ responseId }),
}
);
expect(response.status).toBe(200);
expect(mockGetResponseSnapshotForPipeline).toHaveBeenCalledWith(responseId);
expect(mockSendToPipeline).toHaveBeenNthCalledWith(1, {
event: "responseUpdated",
workspaceId,
response: responseSnapshot,
surveyId,
});
expect(mockSendToPipeline).toHaveBeenNthCalledWith(2, {
event: "responseFinished",
workspaceId,
response: responseSnapshot,
surveyId,
});
});
});
@@ -0,0 +1,235 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const {
mockAuthenticatedApiClient,
mockCreateResponseWithQuotaEvaluation,
mockCreatedResponse,
mockFormatValidationErrorsForV2Api,
mockGetResponseSnapshotForPipeline,
mockGetSurveyQuestions,
mockHandleApiError,
mockHasPermission,
mockGetWorkspaceId,
mockSendToPipeline,
mockValidateFileUploads,
mockValidateOtherOptionLengthForMultipleChoice,
mockValidateResponseData,
} = vi.hoisted(() => ({
mockAuthenticatedApiClient: vi.fn(),
mockCreateResponseWithQuotaEvaluation: vi.fn(),
mockCreatedResponse: vi.fn(),
mockFormatValidationErrorsForV2Api: vi.fn(),
mockGetResponseSnapshotForPipeline: vi.fn(),
mockGetSurveyQuestions: vi.fn(),
mockHandleApiError: vi.fn(),
mockHasPermission: vi.fn(),
mockGetWorkspaceId: vi.fn(),
mockSendToPipeline: vi.fn(),
mockValidateFileUploads: vi.fn(),
mockValidateOtherOptionLengthForMultipleChoice: vi.fn(),
mockValidateResponseData: vi.fn(),
}));
vi.mock("@/app/lib/pipelines", () => ({
sendToPipeline: mockSendToPipeline,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV2Api: mockFormatValidationErrorsForV2Api,
validateResponseData: mockValidateResponseData,
}));
vi.mock("@/modules/api/v2/auth/authenticated-api-client", () => ({
authenticatedApiClient: mockAuthenticatedApiClient,
}));
vi.mock("@/modules/api/v2/lib/element", () => ({
validateOtherOptionLengthForMultipleChoice: mockValidateOtherOptionLengthForMultipleChoice,
}));
vi.mock("@/modules/api/v2/lib/response", () => ({
responses: {
createdResponse: mockCreatedResponse,
successResponse: vi.fn(),
},
}));
vi.mock("@/modules/api/v2/lib/utils", () => ({
handleApiError: mockHandleApiError,
}));
vi.mock("@/modules/api/v2/management/lib/helper", () => ({
getWorkspaceId: mockGetWorkspaceId,
}));
vi.mock("@/modules/api/v2/management/responses/[responseId]/lib/survey", () => ({
getSurveyQuestions: mockGetSurveyQuestions,
}));
vi.mock("@/modules/api/v2/management/responses/[responseId]/lib/response", () => ({
getResponseForPipeline: mockGetResponseSnapshotForPipeline,
}));
vi.mock("@/modules/organization/settings/api-keys/lib/utils", () => ({
hasPermission: mockHasPermission,
}));
vi.mock("@/modules/storage/utils", () => ({
resolveStorageUrlsInObject: vi.fn((value) => value),
validateFileUploads: mockValidateFileUploads,
}));
vi.mock("./lib/response", () => ({
createResponseWithQuotaEvaluation: mockCreateResponseWithQuotaEvaluation,
getResponses: vi.fn(),
}));
const workspaceId = "cm9workspace000108l4abcz12";
const surveyId = "cm9survey000108l4abcz12zz";
const responseId = "cm9response000108l4abcz12";
const createdAt = new Date("2026-04-13T10:00:00.000Z");
const createdResponse = {
contactAttributes: null,
contactId: null,
createdAt,
data: {},
displayId: null,
endingId: null,
finished: true,
id: responseId,
language: null,
meta: {},
singleUseId: null,
surveyId,
ttc: {},
updatedAt: createdAt,
variables: {},
};
const responseSnapshot = {
contact: null,
contactAttributes: null,
createdAt,
data: {},
displayId: null,
endingId: null,
finished: true,
id: responseId,
language: null,
meta: {},
singleUseId: null,
surveyId,
tags: [],
ttc: {},
updatedAt: createdAt,
variables: {},
};
describe("POST /modules/api/v2/management/responses", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAuthenticatedApiClient.mockImplementation(
async ({ handler }) =>
await handler({
auditLog: undefined,
authentication: {
workspacePermissions: [{ workspaceId, actions: ["POST"] }],
},
parsedInput: {
body: {
data: {},
finished: true,
surveyId,
},
},
})
);
mockGetWorkspaceId.mockResolvedValue({ data: { workspaceId }, ok: true });
mockHasPermission.mockReturnValue(true);
mockGetSurveyQuestions.mockResolvedValue({ data: { blocks: [], questions: [] }, ok: true });
mockValidateFileUploads.mockReturnValue(true);
mockValidateOtherOptionLengthForMultipleChoice.mockReturnValue(undefined);
mockValidateResponseData.mockReturnValue(null);
mockSendToPipeline.mockResolvedValue(undefined);
mockCreateResponseWithQuotaEvaluation.mockResolvedValue({ data: createdResponse, ok: true });
mockGetResponseSnapshotForPipeline.mockResolvedValue({ data: responseSnapshot, ok: true });
mockCreatedResponse.mockImplementation((body: unknown) => Response.json(body, { status: 201 }));
mockHandleApiError.mockImplementation((_, error) => Response.json({ error }, { status: 400 }));
});
test("passes the freshly hydrated response snapshot to the pipeline scheduler", async () => {
const { POST } = await import("./route");
const response = await POST(
new Request("http://localhost/api/v2/management/responses", { method: "POST" })
);
expect(response.status).toBe(201);
expect(mockGetResponseSnapshotForPipeline).toHaveBeenCalledWith(responseId);
expect(mockSendToPipeline).toHaveBeenNthCalledWith(1, {
event: "responseCreated",
workspaceId,
response: responseSnapshot,
surveyId,
});
expect(mockSendToPipeline).toHaveBeenNthCalledWith(2, {
event: "responseFinished",
workspaceId,
response: responseSnapshot,
surveyId,
});
});
test("returns 201 when loading the pipeline snapshot throws", async () => {
mockGetResponseSnapshotForPipeline.mockRejectedValueOnce(new Error("snapshot failed"));
const { POST } = await import("./route");
const response = await POST(
new Request("http://localhost/api/v2/management/responses", { method: "POST" })
);
expect(response.status).toBe(201);
expect(mockSendToPipeline).not.toHaveBeenCalled();
});
test("returns 201 when pipeline dispatch rejects", async () => {
mockSendToPipeline.mockRejectedValueOnce(new Error("pipeline failed"));
const { POST } = await import("./route");
const response = await POST(
new Request("http://localhost/api/v2/management/responses", { method: "POST" })
);
await Promise.resolve();
expect(response.status).toBe(201);
expect(mockSendToPipeline).toHaveBeenCalledTimes(2);
});
test("returns the create-response error payload when response creation fails", async () => {
mockCreateResponseWithQuotaEvaluation.mockResolvedValueOnce({
ok: false,
error: {
type: "bad_request",
details: [{ field: "surveyId", issue: "invalid" }],
},
});
const { POST } = await import("./route");
const response = await POST(
new Request("http://localhost/api/v2/management/responses", { method: "POST" })
);
expect(response.status).toBe(400);
expect(mockHandleApiError).toHaveBeenCalledWith(
expect.any(Request),
{
type: "bad_request",
details: [{ field: "surveyId", issue: "invalid" }],
},
undefined
);
expect(mockSendToPipeline).not.toHaveBeenCalled();
});
});
@@ -1,5 +1,6 @@
import { Response } from "@prisma/client";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { sendToPipeline } from "@/app/lib/pipelines";
import { formatValidationErrorsForV2Api, validateResponseData } from "@/modules/api/lib/validation";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
@@ -15,6 +16,31 @@ import { hasPermission } from "@/modules/organization/settings/api-keys/lib/util
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
const queueResponsePipelineEvent = ({
event,
response,
surveyId,
workspaceId,
}: Parameters<typeof sendToPipeline>[0]): void => {
void sendToPipeline({
event,
response,
surveyId,
workspaceId,
}).catch((error: unknown) => {
logger.error(
{
err: error,
event,
responseId: response.id,
surveyId,
workspaceId,
},
"Failed to send response event to pipeline"
);
});
};
export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
@@ -156,23 +182,35 @@ export const POST = async (request: Request) =>
}
// Fetch created response with relations for pipeline
const createdResponseForPipeline = await getResponseForPipeline(createResponseResult.data.id);
if (createdResponseForPipeline.ok) {
sendToPipeline({
event: "responseCreated",
workspaceId,
surveyId: body.surveyId,
response: createdResponseForPipeline.data,
});
if (createResponseResult.data.finished) {
sendToPipeline({
event: "responseFinished",
try {
const createdResponseForPipeline = await getResponseForPipeline(createResponseResult.data.id);
if (createdResponseForPipeline.ok) {
queueResponsePipelineEvent({
event: "responseCreated",
workspaceId,
surveyId: body.surveyId,
response: createdResponseForPipeline.data,
});
if (createResponseResult.data.finished) {
queueResponsePipelineEvent({
event: "responseFinished",
workspaceId,
surveyId: body.surveyId,
response: createdResponseForPipeline.data,
});
}
}
} catch (error) {
logger.error(
{
err: error,
responseId: createResponseResult.data.id,
surveyId: body.surveyId,
workspaceId,
},
"Failed to load response data for pipeline dispatch"
);
}
if (auditLog) {
+32 -5
View File
@@ -1,4 +1,5 @@
import { NextRequest } from "next/server";
import { prisma } from "@formbricks/database";
import { OrganizationAccessType } from "@formbricks/types/api-key";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
@@ -16,13 +17,39 @@ export const GET = async (request: NextRequest) =>
});
}
const workspaceIds = authentication.workspacePermissions.map((p) => p.workspaceId);
const workspaces = await prisma.workspace.findMany({
where: { id: { in: workspaceIds } },
select: { id: true, legacyEnvironmentId: true },
});
const legacyEnvIdByWorkspaceId = new Map(workspaces.map((w) => [w.id, w.legacyEnvironmentId]));
const workspacePermissions = authentication.workspacePermissions.map((permission) => ({
permissions: permission.permission,
workspaceId: permission.workspaceId,
workspaceName: permission.workspaceName,
}));
// Backwards compat: expose environment-shaped permissions for consumers
// from before the Environment model was removed.
const environmentPermissions = authentication.workspacePermissions.flatMap((permission) => {
const legacyEnvironmentId = legacyEnvIdByWorkspaceId.get(permission.workspaceId);
if (!legacyEnvironmentId) return [];
return [
{
environmentId: legacyEnvironmentId,
environmentType: "production" as const,
permissions: permission.permission,
projectId: permission.workspaceId,
projectName: permission.workspaceName,
},
];
});
return responses.successResponse({
data: {
workspacePermissions: authentication.workspacePermissions.map((permission) => ({
permissions: permission.permission,
workspaceId: permission.workspaceId,
workspaceName: permission.workspaceName,
})),
workspacePermissions,
environmentPermissions,
organizationId: authentication.organizationId,
organizationAccess: authentication.organizationAccess,
},
@@ -7,6 +7,8 @@ import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { forgotPasswordAction } from "./actions";
const allowedRateLimitResponse = { allowed: true };
// Mock dependencies
vi.mock("@/lib/constants", () => ({
PASSWORD_RESET_DISABLED: false,
@@ -111,7 +113,7 @@ describe("forgotPasswordAction", () => {
describe("Password Reset Flow", () => {
test("should send password reset email when user exists with email identity provider", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(applyIPRateLimit).mockResolvedValue(allowedRateLimitResponse);
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
@@ -123,7 +125,7 @@ describe("forgotPasswordAction", () => {
});
test("should not send email when user doesn't exist", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(applyIPRateLimit).mockResolvedValue(allowedRateLimitResponse);
vi.mocked(getUserByEmail).mockResolvedValue(null);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
@@ -136,7 +138,7 @@ describe("forgotPasswordAction", () => {
test("should not send email when user has non-email identity provider", async () => {
const ssoUser = { ...mockUser, identityProvider: "google" };
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(applyIPRateLimit).mockResolvedValue(allowedRateLimitResponse);
vi.mocked(getUserByEmail).mockResolvedValue(ssoUser as any);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
@@ -153,7 +155,7 @@ describe("forgotPasswordAction", () => {
// This test verifies that password reset is enabled by default
// The actual PASSWORD_RESET_DISABLED check is part of the implementation
// and we've mocked it as false, so rate limiting should work normally
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(applyIPRateLimit).mockResolvedValue(allowedRateLimitResponse);
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
@@ -175,7 +177,7 @@ describe("forgotPasswordAction", () => {
});
test("should handle user lookup errors after rate limiting", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(applyIPRateLimit).mockResolvedValue(allowedRateLimitResponse);
vi.mocked(getUserByEmail).mockRejectedValue(new Error("Database error"));
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow(
@@ -186,7 +188,7 @@ describe("forgotPasswordAction", () => {
});
test("should propagate unexpected password reset request errors after rate limiting", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(applyIPRateLimit).mockResolvedValue(allowedRateLimitResponse);
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
vi.mocked(requestPasswordReset).mockRejectedValue(new Error("Password reset request failed"));
@@ -208,7 +210,7 @@ describe("forgotPasswordAction", () => {
describe("Security Considerations", () => {
test("should always return success even for non-existent users to prevent email enumeration", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(applyIPRateLimit).mockResolvedValue(allowedRateLimitResponse);
vi.mocked(getUserByEmail).mockResolvedValue(null);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
@@ -218,7 +220,7 @@ describe("forgotPasswordAction", () => {
test("should always return success even for SSO users to prevent identity provider enumeration", async () => {
const ssoUser = { ...mockUser, identityProvider: "github" };
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(applyIPRateLimit).mockResolvedValue(allowedRateLimitResponse);
vi.mocked(getUserByEmail).mockResolvedValue(ssoUser as any);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
@@ -228,7 +230,7 @@ describe("forgotPasswordAction", () => {
});
test("should rate limit all requests regardless of user existence", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(applyIPRateLimit).mockResolvedValue(allowedRateLimitResponse);
// Test with existing user
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
@@ -252,7 +252,7 @@ describe("password-reset-service", () => {
const parseTokenFromResetLink = (): string => {
const lastCall = vi.mocked(sendPasswordResetLinkEmail).mock.calls.at(-1);
const verifyLink = lastCall?.[0]?.verifyLink;
const verifyLink = (lastCall?.[0] as { verifyLink?: string } | undefined)?.verifyLink;
if (!verifyLink) {
throw new Error("No verify link found");
@@ -271,7 +271,7 @@ describe("password-reset-service", () => {
const parseTokenFromDebugLog = (): string => {
const verifyLink = vi
.mocked(logger.info)
.mock.calls.map(([payload]) => payload?.verifyLink)
.mock.calls.map(([payload]) => (payload as { verifyLink?: string } | undefined)?.verifyLink)
.find((loggedVerifyLink): loggedVerifyLink is string => typeof loggedVerifyLink === "string");
if (!verifyLink) {
+17 -32
View File
@@ -6,7 +6,6 @@ import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { capturePostHogEvent } from "@/lib/posthog";
// Import mocked rate limiting functions
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { authOptions } from "./authOptions";
import { mockUser } from "./mock-data";
import { hashPassword } from "./utils";
@@ -220,8 +219,8 @@ describe("authOptions", () => {
});
}, 15000);
describe("Rate Limiting", () => {
test("should apply rate limiting before credential validation", async () => {
describe("Envoy-managed callback behavior", () => {
test("should not apply in-app rate limiting before credential validation", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
@@ -235,27 +234,14 @@ describe("authOptions", () => {
await credentialsProvider.options.authorize(credentials, {});
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.login);
expect(applyIPRateLimit).toHaveBeenCalledBefore(prisma.user.findUnique as any);
expect(applyIPRateLimit).not.toHaveBeenCalled();
expect(prisma.user.findUnique).toHaveBeenCalled();
});
test("should block login when rate limit exceeded", async () => {
test("should ignore app limiter errors because login is Envoy-managed", async () => {
vi.mocked(applyIPRateLimit).mockRejectedValue(
new Error("Maximum number of requests reached. Please try again later.")
);
const findUniqueSpy = vi.spyOn(prisma.user, "findUnique");
const credentials = { email: mockUser.email, password: mockPassword };
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
expect(findUniqueSpy).not.toHaveBeenCalled();
});
test("should use correct rate limit configuration", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
@@ -266,13 +252,14 @@ describe("authOptions", () => {
const credentials = { email: mockUser.email, password: mockPassword };
await credentialsProvider.options.authorize(credentials, {});
const result = await credentialsProvider.options.authorize(credentials, {});
expect(applyIPRateLimit).toHaveBeenCalledWith({
interval: 900,
allowedPerInterval: 30,
namespace: "auth:login",
expect(result).toEqual({
id: mockUserId,
email: mockUser.email,
emailVerified: expect.any(Date),
});
expect(applyIPRateLimit).not.toHaveBeenCalled();
});
});
@@ -315,30 +302,28 @@ describe("authOptions", () => {
);
});
describe("Rate Limiting", () => {
test("should apply rate limiting before token verification", async () => {
describe("Envoy-managed callback behavior", () => {
test("should not apply in-app rate limiting before token verification", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const credentials = { token: "sometoken" };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow();
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail);
expect(applyIPRateLimit).not.toHaveBeenCalled();
});
test("should block verification when rate limit exceeded", async () => {
test("should ignore app limiter errors because token verification is Envoy-managed", async () => {
vi.mocked(applyIPRateLimit).mockRejectedValue(
new Error("Maximum number of requests reached. Please try again later.")
);
const findUniqueSpy = vi.spyOn(prisma.user, "findUnique");
const credentials = { token: "sometoken" };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
"Either a user does not match the provided token or the token is invalid"
);
expect(findUniqueSpy).not.toHaveBeenCalled();
expect(applyIPRateLimit).not.toHaveBeenCalled();
});
});
});
-6
View File
@@ -29,8 +29,6 @@ import {
shouldLogAuthFailure,
verifyPassword,
} from "@/modules/auth/lib/utils";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { getSSOProviders } from "@/modules/ee/sso/lib/providers";
import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
@@ -62,8 +60,6 @@ export const authOptions: NextAuthOptions = {
backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" },
},
async authorize(credentials, _req) {
await applyIPRateLimit(rateLimitConfigs.auth.login);
// Use email for rate limiting when available, fall back to "unknown_user" for credential validation
const identifier = credentials?.email || "unknown_user"; // NOSONAR // We want to check for empty strings
@@ -252,8 +248,6 @@ export const authOptions: NextAuthOptions = {
},
},
async authorize(credentials, _req) {
await applyIPRateLimit(rateLimitConfigs.auth.verifyEmail);
// For token verification, we can't rate limit effectively by token (single-use)
// So we use a generic identifier for token abuse attempts
const identifier = "email_verification_attempts";
@@ -0,0 +1,246 @@
import { describe, expect, test } from "vitest";
import { isRouteRateLimitedByEnvoy } from "./envoy-rate-limit-coverage";
describe("isRouteRateLimitedByEnvoy", () => {
test("matches covered auth callback routes", () => {
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/auth/callback/credentials",
method: "POST",
authType: "none",
})
).toBe(true);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/auth/callback/token",
method: "POST",
authType: "none",
})
).toBe(true);
});
test("matches covered api-key management and webhook routes", () => {
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v1/management/surveys",
method: "GET",
authType: "apiKey",
})
).toBe(true);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v1/management/storage",
method: "POST",
authType: "apiKey",
})
).toBe(true);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v1/webhooks/webhook-id",
method: "DELETE",
authType: "apiKey",
})
).toBe(true);
});
test("matches covered client routes", () => {
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v1/client/env_123/environment",
method: "GET",
authType: "none",
})
).toBe(true);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v1/client/env_123/responses",
method: "POST",
authType: "none",
})
).toBe(true);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v1/client/env_123/responses/response_123",
method: "PUT",
authType: "none",
})
).toBe(true);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v1/client/env_123/displays",
method: "POST",
authType: "none",
})
).toBe(true);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v1/client/env_123/user",
method: "POST",
authType: "none",
})
).toBe(true);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v1/client/env_123/storage",
method: "POST",
authType: "none",
})
).toBe(true);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v2/client/env_123/responses",
method: "POST",
authType: "none",
})
).toBe(true);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v2/client/env_123/responses/response_123",
method: "PUT",
authType: "none",
})
).toBe(true);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v2/client/env_123/displays",
method: "POST",
authType: "none",
})
).toBe(true);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v2/client/env_123/storage",
method: "POST",
authType: "none",
})
).toBe(true);
});
test("matches covered api-key storage delete route", () => {
expect(
isRouteRateLimitedByEnvoy({
pathname: "/storage/env_123/private/file.pdf",
method: "DELETE",
authType: "apiKey",
})
).toBe(true);
});
test("does not match excluded or uncovered routes", () => {
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v1/client/og",
method: "GET",
authType: "none",
})
).toBe(false);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v2/health",
method: "GET",
authType: "none",
})
).toBe(false);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v1/client/env_123/environment",
method: "PATCH",
authType: "none",
})
).toBe(false);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v1/client/env_123/displays",
method: "GET",
authType: "none",
})
).toBe(false);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v2/client/env_123/responses",
method: "PUT",
authType: "none",
})
).toBe(false);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v2/client/env_123/responses/response_123",
method: "POST",
authType: "none",
})
).toBe(false);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v2/client/env_123/environment",
method: "GET",
authType: "none",
})
).toBe(false);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v2/client/env_123/user",
method: "POST",
authType: "none",
})
).toBe(false);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v1/management/me",
method: "GET",
authType: "session",
})
).toBe(false);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v1/management/storage",
method: "POST",
authType: "session",
})
).toBe(false);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/storage/env_123/private/file.pdf",
method: "DELETE",
authType: "session",
})
).toBe(false);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v1/webhooks",
method: "GET",
authType: "apiKey",
})
).toBe(false);
expect(
isRouteRateLimitedByEnvoy({
pathname: "/api/v1/client/env_123/environment",
method: "OPTIONS",
authType: "none",
})
).toBe(false);
});
});
@@ -0,0 +1,117 @@
export type TEnvoyRateLimitAuthType = "none" | "apiKey" | "session";
type TEnvoyRateLimitRequest = {
pathname: string;
method: string;
authType: TEnvoyRateLimitAuthType;
};
const V1_CLIENT_STORAGE_PATTERN = /^\/api\/v1\/client\/[^/]+\/storage$/;
const V1_CLIENT_ENVIRONMENT_PATTERN = /^\/api\/v1\/client\/[^/]+\/environment$/;
const V1_CLIENT_RESPONSES_PATTERN = /^\/api\/v1\/client\/[^/]+\/responses$/;
const V1_CLIENT_RESPONSE_PATTERN = /^\/api\/v1\/client\/[^/]+\/responses\/[^/]+$/;
const V1_CLIENT_DISPLAYS_PATTERN = /^\/api\/v1\/client\/[^/]+\/displays$/;
const V1_CLIENT_USER_PATTERN = /^\/api\/v1\/client\/[^/]+\/user$/;
const V2_CLIENT_RESPONSES_PATTERN = /^\/api\/v2\/client\/[^/]+\/responses$/;
const V2_CLIENT_RESPONSE_PATTERN = /^\/api\/v2\/client\/[^/]+\/responses\/[^/]+$/;
const V2_CLIENT_DISPLAYS_PATTERN = /^\/api\/v2\/client\/[^/]+\/displays$/;
const V2_CLIENT_STORAGE_PATTERN = /^\/api\/v2\/client\/[^/]+\/storage$/;
const STORAGE_DELETE_PATTERN = /^\/storage\/[^/]+\/(public|private)\/.+$/;
const V1_MANAGEMENT_PREFIX = "/api/v1/management/";
const V1_WEBHOOKS_PREFIX = "/api/v1/webhooks/";
const V1_GENERAL_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
const normalizeMethod = (method: string): string => method.toUpperCase();
const matchesPrefixedPath = (pathname: string, prefix: string): boolean => pathname.startsWith(prefix);
/**
* Mirrors the live Envoy rate-limit policy set.
* Keep this matcher aligned with the Gateway policies when coverage changes.
*/
export const isRouteRateLimitedByEnvoy = ({
pathname,
method,
authType,
}: TEnvoyRateLimitRequest): boolean => {
const normalizedMethod = normalizeMethod(method);
if (normalizedMethod === "OPTIONS") {
return false;
}
if (authType === "none" && normalizedMethod === "POST" && pathname === "/api/auth/callback/credentials") {
return true;
}
if (authType === "none" && normalizedMethod === "POST" && pathname === "/api/auth/callback/token") {
return true;
}
if (
authType === "apiKey" &&
V1_GENERAL_METHODS.has(normalizedMethod) &&
matchesPrefixedPath(pathname, V1_MANAGEMENT_PREFIX)
) {
return true;
}
if (
authType === "apiKey" &&
V1_GENERAL_METHODS.has(normalizedMethod) &&
matchesPrefixedPath(pathname, V1_WEBHOOKS_PREFIX)
) {
return true;
}
if (authType === "apiKey" && normalizedMethod === "DELETE" && STORAGE_DELETE_PATTERN.test(pathname)) {
return true;
}
if (authType !== "none") {
return false;
}
if (normalizedMethod === "POST" && V1_CLIENT_STORAGE_PATTERN.test(pathname)) {
return true;
}
if (normalizedMethod === "GET" && V1_CLIENT_ENVIRONMENT_PATTERN.test(pathname)) {
return true;
}
if (normalizedMethod === "POST" && V1_CLIENT_RESPONSES_PATTERN.test(pathname)) {
return true;
}
if (normalizedMethod === "PUT" && V1_CLIENT_RESPONSE_PATTERN.test(pathname)) {
return true;
}
if (normalizedMethod === "POST" && V1_CLIENT_DISPLAYS_PATTERN.test(pathname)) {
return true;
}
if (normalizedMethod === "POST" && V1_CLIENT_USER_PATTERN.test(pathname)) {
return true;
}
if (normalizedMethod === "POST" && V2_CLIENT_RESPONSES_PATTERN.test(pathname)) {
return true;
}
if (normalizedMethod === "PUT" && V2_CLIENT_RESPONSE_PATTERN.test(pathname)) {
return true;
}
if (normalizedMethod === "POST" && V2_CLIENT_DISPLAYS_PATTERN.test(pathname)) {
return true;
}
if (normalizedMethod === "POST" && V2_CLIENT_STORAGE_PATTERN.test(pathname)) {
return true;
}
return false;
};
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { TActor, TAuditAction, TAuditStatus, TAuditTarget } from "../types/audit-log";
// Import original module to access its original exports for the mock factory
import * as OriginalHandler from "./handler";
@@ -131,6 +132,7 @@ const mockCtxBase = {
function clearAllMockHandles() {
if (serviceLogAuditEventMockHandle) serviceLogAuditEventMockHandle.mockClear().mockResolvedValue(undefined);
if (loggerErrorMockHandle) loggerErrorMockHandle.mockClear();
vi.mocked(getClientIpFromHeaders).mockClear();
if (mutableConstants) {
// Check because it's a var and could be re-assigned (though not in this code)
mutableConstants.AUDIT_LOG_ENABLED = true;
@@ -186,6 +188,29 @@ describe("queueAuditEventBackground", () => {
});
});
describe("queueAuditEventWithoutRequest", () => {
beforeEach(() => {
clearAllMockHandles();
});
afterEach(() => {
vi.resetModules();
});
test("logs audit events without reading request headers", async () => {
await OriginalHandler.queueAuditEventWithoutRequest({
...baseEventParams,
ipAddress: "worker-ip",
});
expect(serviceLogAuditEventMockHandle).toHaveBeenCalledWith(
expect.objectContaining({
ipAddress: "worker-ip",
})
);
expect(vi.mocked(getClientIpFromHeaders)).not.toHaveBeenCalled();
});
});
describe("withAuditLogging", () => {
beforeEach(() => {
clearAllMockHandles();
+55 -40
View File
@@ -15,6 +15,24 @@ import {
} from "@/modules/ee/audit-logs/types/audit-log";
import { getIsAuditLogsEnabled } from "@/modules/ee/license-check/lib/utils";
export type TAuditEventInput = {
action: TAuditAction;
targetType: TAuditTarget;
userId: string;
userType: TActor;
targetId: string;
organizationId: string;
status: TAuditStatus;
oldObject?: Record<string, unknown> | null;
newObject?: Record<string, unknown> | null;
eventId?: string;
apiUrl?: string;
};
type TBuildAuditEventInput = TAuditEventInput & {
ipAddress: string;
};
/**
* Builds an audit event and logs it.
* Redacts sensitive data from the old and new objects before logging.
@@ -32,20 +50,7 @@ export const buildAndLogAuditEvent = async ({
newObject,
eventId,
apiUrl,
}: {
action: TAuditAction;
targetType: TAuditTarget;
userId: string;
userType: TActor;
targetId: string;
organizationId: string;
ipAddress: string;
status: TAuditStatus;
oldObject?: Record<string, unknown> | null;
newObject?: Record<string, unknown> | null;
eventId?: string;
apiUrl?: string;
}) => {
}: TBuildAuditEventInput) => {
if (!AUDIT_LOG_ENABLED && !(await getIsAuditLogsEnabled())) {
return;
}
@@ -97,19 +102,7 @@ export const queueAuditEventBackground = async ({
status,
eventId,
apiUrl,
}: {
action: TAuditAction;
targetType: TAuditTarget;
userId: string;
userType: TActor;
targetId: string;
organizationId: string;
oldObject?: Record<string, unknown> | null;
newObject?: Record<string, unknown> | null;
status: TAuditStatus;
eventId?: string;
apiUrl?: string;
}) => {
}: TAuditEventInput) => {
setImmediate(async () => {
const ipAddress = await getClientIpFromHeaders();
await buildAndLogAuditEvent({
@@ -145,19 +138,7 @@ export const queueAuditEvent = async ({
status,
eventId,
apiUrl,
}: {
action: TAuditAction;
targetType: TAuditTarget;
userId: string;
userType: TActor;
targetId: string;
organizationId: string;
oldObject?: Record<string, unknown> | null;
newObject?: Record<string, unknown> | null;
status: TAuditStatus;
eventId?: string;
apiUrl?: string;
}) => {
}: TAuditEventInput) => {
const ipAddress = await getClientIpFromHeaders();
await buildAndLogAuditEvent({
@@ -176,6 +157,40 @@ export const queueAuditEvent = async ({
});
};
/**
* Logs an audit event without reading request headers.
* Use this from background workers or other contexts without a request lifecycle.
*/
export const queueAuditEventWithoutRequest = async ({
action,
targetType,
userId,
userType,
targetId,
organizationId,
oldObject,
newObject,
status,
eventId,
apiUrl,
ipAddress = UNKNOWN_DATA,
}: TAuditEventInput & { ipAddress?: string }) => {
await buildAndLogAuditEvent({
action,
targetType,
userId,
userType,
targetId,
organizationId,
ipAddress,
status,
oldObject,
newObject,
eventId,
apiUrl,
});
};
/**
* Wraps a handler function with audit logging.
* Logs audit events for server actions. Specifically for server actions that use next-server-action library middleware and its context.

Some files were not shown because too many files have changed in this diff Show More