Compare commits

..

50 Commits

Author SHA1 Message Date
pandeymangg f3d679d087 Merge branch 'epic/survey-mqp' into feat/survey-editor-blocks-ui 2025-11-12 22:37:12 +05:30
pandeymangg c79a600efc initial UI changes for the PoC 2025-11-12 22:23:47 +05:30
Anshuman Pandey 7a8da3b84b feat: migrate all templates from questions to blocks structure (#6798) 2025-11-12 16:15:51 +05:30
Matti Nannt 4b2d48397d chore: fix tests 2025-11-12 11:16:31 +01:00
Matti Nannt 3ea81dc7c1 chore: remove unused templates functions for questions, fix linting issues 2025-11-12 10:54:59 +01:00
pandeymangg d9b6b550a9 Merge branch 'epic/survey-mqp' into feat/migrate-templates-to-blocks 2025-11-12 10:32:45 +05:30
Anshuman Pandey 56a6ba08ba fix: moves the integrations code to blocks schema (#6800) 2025-11-11 23:23:07 +05:30
pandeymangg 1ba55ff66c fixes tests 2025-11-11 22:22:53 +05:30
pandeymangg 0cf621d76c chore: merge with the epic branch 2025-11-11 22:12:41 +05:30
pandeymangg 3dc615fdc0 chore: merge with the epic branch 2025-11-11 22:08:12 +05:30
Anshuman Pandey 7157b17901 feat: survey summary blocks (#6795) 2025-11-11 22:06:43 +05:30
pandeymangg 82c26941e4 fixes coderabbit feedback 2025-11-11 22:05:45 +05:30
pandeymangg 591d5fa3d4 fixed tests for the xm templates 2025-11-11 16:47:42 +05:30
pandeymangg 211bca1bd8 moves the xm-templates to blocks 2025-11-11 16:13:46 +05:30
pandeymangg 5a20839c5b fixes template logic 2025-11-11 14:35:35 +05:30
pandeymangg 85743bd3d0 fixes feedback 2025-11-11 11:58:59 +05:30
pandeymangg 335ec02361 moves the integrations code over to the blocks schema 2025-11-11 11:16:08 +05:30
Matti Nannt 7918523957 feat: migrate all templates from questions to blocks structure 2025-11-10 16:44:55 +01:00
pandeymangg 3b5fe4cb94 some build fixes 2025-11-10 16:48:32 +05:30
pandeymangg 6bbd5ec7ef Merge branch 'epic/survey-mqp' into feat/survey-summary-blocks 2025-11-10 16:18:43 +05:30
pandeymangg c9542dcf79 moving survey summary, responses, follow ups to blocks 2025-11-10 16:18:00 +05:30
Anshuman Pandey 4277a9dc34 feat: Moving surveys package logic to blocks (#6785) 2025-11-10 09:47:46 +05:30
pandeymangg b1da63e47d fixes description issue 2025-11-07 14:22:13 +05:30
pandeymangg 8c05154a86 fixes feedback 2025-11-07 12:30:25 +05:30
pandeymangg 45122de652 surveys package changes for supporting blocks 2025-11-06 19:10:58 +05:30
Anshuman Pandey 2180bf98ba feat: refactor survey editor logic to use blocks model (#6778) 2025-11-06 15:45:15 +05:30
pandeymangg 2d4a94721b removes log 2025-11-06 15:11:16 +05:30
pandeymangg b2b97c8bed fixes feedback comments 2025-11-06 12:02:25 +05:30
pandeymangg f349f7199d fixes unit tests 2025-11-05 11:57:35 +05:30
pandeymangg e7d8803a13 fixes coderabbit feedback 2025-11-05 11:04:17 +05:30
pandeymangg 53a9b218bc fixes coderabbit feedback 2025-11-05 10:26:20 +05:30
pandeymangg c618e7d473 survey mqp survey editor logic 2025-11-04 22:31:35 +05:30
Anshuman Pandey 3d0f703ae1 feat(blocks): add editor utilities, validation, and unit tests for bl… (#6768) 2025-11-03 20:40:52 +05:30
pandeymangg 33eadaaa7b feedback 2025-11-03 16:37:24 +05:30
pandeymangg 452617529c updates error message 2025-11-03 14:11:53 +05:30
pandeymangg 5951eea618 feedback 2025-11-03 13:10:30 +05:30
pandeymangg e314feb416 fix 2025-11-03 11:18:11 +05:30
pandeymangg 0910b0f1a7 fix: sonar issues 2025-11-03 10:59:58 +05:30
pandeymangg 10ba42eb31 fix: code duplication 2025-11-03 10:28:02 +05:30
pandeymangg 04f1e17e23 fix: tests 2025-11-03 10:13:19 +05:30
pandeymangg 4642cc60c9 fix: coderabbit feedback 2025-11-02 17:59:16 +05:30
pandeymangg 49fa5c587c feat(blocks): add editor utilities, validation, and unit tests for blocks support 2025-10-31 17:32:47 +05:30
Anshuman Pandey 4f9b48b5e5 feat: add blocks model to support multi-question pages (schema only) (#6754) 2025-10-31 11:52:35 +05:30
pandeymangg 80789327d0 fix: feedback 2025-10-31 11:32:01 +05:30
pandeymangg 38108a32d1 fix: feedback 2025-10-31 09:18:18 +05:30
pandeymangg ce4b64da0e fix(validation): fix cyclic logic detection and add choice ID validation in block logic 2025-10-30 15:33:46 +05:30
pandeymangg 9790b071d7 fix(validation): correct operator names to match enum definition
Update comparison operators from 'lessThan/lessEqual/greaterThan/greaterEqual'
to 'isLessThan/isLessThanOrEqual/isGreaterThan/isGreaterThanOrEqual' to match
ZSurveyLogicConditionsOperator enum in OpenText number, NPS, and Rating validation.
2025-10-30 15:15:20 +05:30
pandeymangg 1f5ba0e60e fix: sonar duplicate import issue 2025-10-30 14:07:05 +05:30
pandeymangg b502bbc91e refactor(types): extract i18n and logic types to resolve circular dependencies
Moves TI18nString to packages/types/i18n.ts and all logic types to packages/types/surveys/logic.ts, updating imports across codebase.
2025-10-30 13:58:00 +05:30
pandeymangg 6772ac7c20 feat: add blocks model to support multi-question pages (schema only) 2025-10-30 00:00:53 +05:30
318 changed files with 18508 additions and 20131 deletions
+4 -4
View File
@@ -179,14 +179,14 @@ For endpoints serving client SDKs, coordinate TTLs across layers:
```typescript
// Client SDK cache (expiresAt) - longest TTL for fewer requests
const CLIENT_TTL = 60; // 1 minute (seconds for client)
const CLIENT_TTL = 60 * 60; // 1 hour (seconds for client)
// Server Redis cache - shorter TTL ensures fresh data for clients
const SERVER_TTL = 60 * 1000; // 1 minutes in milliseconds
const SERVER_TTL = 60 * 30 * 1000; // 30 minutes in milliseconds
// HTTP cache headers (seconds)
const BROWSER_TTL = 60; // 1 minute (max-age)
const CDN_TTL = 60; // 1 minute (s-maxage)
const BROWSER_TTL = 60 * 60; // 1 hour (max-age)
const CDN_TTL = 60 * 30; // 30 minutes (s-maxage)
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
```
@@ -32,14 +32,22 @@ const mockProject: TProject = {
};
const mockTemplate: TXMTemplate = {
name: "$[projectName] Survey",
questions: [
blocks: [
{
id: "q1",
inputType: "text",
type: "email" as any,
headline: { default: "$[projectName] Question" },
required: false,
charLimit: { enabled: true, min: 400, max: 1000 },
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: "openText" as const,
inputType: "text" as const,
headline: { default: "$[projectName] Question" },
subheader: { default: "" },
required: false,
placeholder: { default: "" },
charLimit: 1000,
},
],
},
],
endings: [
@@ -66,9 +74,9 @@ describe("replacePresetPlaceholders", () => {
expect(result.name).toBe("Test Project Survey");
});
test("replaces projectName placeholder in question headline", () => {
test("replaces projectName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.questions[0].headline.default).toBe("Test Project Question");
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
});
test("returns a new object without mutating the original template", () => {
@@ -1,13 +1,16 @@
import { TProject } from "@formbricks/types/project";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TXMTemplate } from "@formbricks/types/templates";
import { replaceQuestionPresetPlaceholders } from "@/lib/utils/templates";
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
// replace all occurences of projectName with the actual project name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject) => {
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
const survey = structuredClone(template);
survey.name = survey.name.replace("$[projectName]", project.name);
survey.questions = survey.questions.map((question) => {
return replaceQuestionPresetPlaceholders(question, project);
});
return { ...template, ...survey };
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
...block,
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
}));
return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
};
@@ -20,7 +20,7 @@ describe("xm-templates", () => {
expect(result).toEqual({
name: "",
endings: expect.any(Array),
questions: [],
blocks: [],
styling: {
overwriteThemeStyling: true,
},
@@ -3,19 +3,21 @@ import { TFunction } from "i18next";
import { logger } from "@formbricks/logger";
import { TXMTemplate } from "@formbricks/types/templates";
import {
buildCTAQuestion,
buildNPSQuestion,
buildOpenTextQuestion,
buildRatingQuestion,
getDefaultEndingCard,
} from "@/app/lib/survey-builder";
buildBlock,
buildCTAElement,
buildNPSElement,
buildOpenTextElement,
buildRatingElement,
createBlockJumpLogic,
} from "@/app/lib/survey-block-builder";
import { getDefaultEndingCard } from "@/app/lib/survey-builder";
export const getXMSurveyDefault = (t: TFunction): TXMTemplate => {
try {
return {
name: "",
endings: [getDefaultEndingCard([], t)],
questions: [],
blocks: [],
styling: {
overwriteThemeStyling: true,
},
@@ -30,25 +32,40 @@ const npsSurvey = (t: TFunction): TXMTemplate => {
return {
...getXMSurveyDefault(t),
name: t("templates.nps_survey_name"),
questions: [
buildNPSQuestion({
headline: t("templates.nps_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
upperLabel: t("templates.nps_survey_question_1_upper_label"),
isColorCodingEnabled: true,
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildNPSElement({
headline: t("templates.nps_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
upperLabel: t("templates.nps_survey_question_1_upper_label"),
isColorCodingEnabled: true,
}),
],
t,
}),
buildOpenTextQuestion({
headline: t("templates.nps_survey_question_2_headline"),
required: false,
inputType: "text",
buildBlock({
name: "Block 2",
elements: [
buildOpenTextElement({
headline: t("templates.nps_survey_question_2_headline"),
required: false,
inputType: "text",
}),
],
t,
}),
buildOpenTextQuestion({
headline: t("templates.nps_survey_question_3_headline"),
required: false,
inputType: "text",
buildBlock({
name: "Block 3",
elements: [
buildOpenTextElement({
headline: t("templates.nps_survey_question_3_headline"),
required: false,
inputType: "text",
}),
],
t,
}),
],
@@ -56,15 +73,27 @@ const npsSurvey = (t: TFunction): TXMTemplate => {
};
const starRatingSurvey = (t: TFunction): TXMTemplate => {
const reusableQuestionIds = [createId(), createId(), createId()];
const reusableElementIds = [createId(), createId(), createId()];
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
const defaultSurvey = getXMSurveyDefault(t);
return {
...defaultSurvey,
name: t("templates.star_rating_survey_name"),
questions: [
buildRatingQuestion({
id: reusableQuestionIds[0],
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildRatingElement({
id: reusableElementIds[0],
range: 5,
scale: "number",
headline: t("templates.star_rating_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
}),
],
logic: [
{
id: createId(),
@@ -75,7 +104,7 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[0],
value: reusableElementIds[0],
type: "question",
},
operator: "isLessThanOrEqual",
@@ -89,64 +118,44 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: reusableQuestionIds[2],
objective: "jumpToBlock",
target: block3Id,
},
],
},
],
range: 5,
scale: "number",
headline: t("templates.star_rating_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
t,
}),
buildCTAQuestion({
id: reusableQuestionIds[1],
subheader: t("templates.star_rating_survey_question_2_html"),
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isClicked",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: defaultSurvey.endings[0].id,
},
],
},
buildBlock({
name: "Block 2",
elements: [
buildCTAElement({
id: reusableElementIds[1],
subheader: t("templates.star_rating_survey_question_2_html"),
headline: t("templates.star_rating_survey_question_2_headline"),
required: true,
buttonUrl: "https://formbricks.com/github",
buttonExternal: true,
}),
],
headline: t("templates.star_rating_survey_question_2_headline"),
required: true,
buttonUrl: "https://formbricks.com/github",
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
buttonLabel: t("templates.star_rating_survey_question_2_button_label"),
buttonExternal: true,
t,
}),
buildOpenTextQuestion({
id: reusableQuestionIds[2],
headline: t("templates.star_rating_survey_question_3_headline"),
required: true,
subheader: t("templates.star_rating_survey_question_3_subheader"),
buildBlock({
id: block3Id,
name: "Block 3",
elements: [
buildOpenTextElement({
id: reusableElementIds[2],
headline: t("templates.star_rating_survey_question_3_headline"),
required: true,
subheader: t("templates.star_rating_survey_question_3_subheader"),
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
inputType: "text",
}),
],
buttonLabel: t("templates.star_rating_survey_question_3_button_label"),
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
inputType: "text",
t,
}),
],
@@ -154,15 +163,27 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
};
const csatSurvey = (t: TFunction): TXMTemplate => {
const reusableQuestionIds = [createId(), createId(), createId()];
const reusableElementIds = [createId(), createId(), createId()];
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
const defaultSurvey = getXMSurveyDefault(t);
return {
...defaultSurvey,
name: t("templates.csat_survey_name"),
questions: [
buildRatingQuestion({
id: reusableQuestionIds[0],
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildRatingElement({
id: reusableElementIds[0],
range: 5,
scale: "smiley",
headline: t("templates.csat_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
upperLabel: t("templates.csat_survey_question_1_upper_label"),
}),
],
logic: [
{
id: createId(),
@@ -173,7 +194,7 @@ const csatSurvey = (t: TFunction): TXMTemplate => {
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[0],
value: reusableElementIds[0],
type: "question",
},
operator: "isLessThanOrEqual",
@@ -187,60 +208,40 @@ const csatSurvey = (t: TFunction): TXMTemplate => {
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: reusableQuestionIds[2],
objective: "jumpToBlock",
target: block3Id,
},
],
},
],
range: 5,
scale: "smiley",
headline: t("templates.csat_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
upperLabel: t("templates.csat_survey_question_1_upper_label"),
t,
}),
buildOpenTextQuestion({
id: reusableQuestionIds[1],
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isSubmitted",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: defaultSurvey.endings[0].id,
},
],
},
buildBlock({
name: "Block 2",
elements: [
buildOpenTextElement({
id: reusableElementIds[1],
headline: t("templates.csat_survey_question_2_headline"),
required: false,
placeholder: t("templates.csat_survey_question_2_placeholder"),
inputType: "text",
}),
],
headline: t("templates.csat_survey_question_2_headline"),
required: false,
placeholder: t("templates.csat_survey_question_2_placeholder"),
inputType: "text",
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isSubmitted")],
t,
}),
buildOpenTextQuestion({
id: reusableQuestionIds[2],
headline: t("templates.csat_survey_question_3_headline"),
required: false,
placeholder: t("templates.csat_survey_question_3_placeholder"),
inputType: "text",
buildBlock({
id: block3Id,
name: "Block 3",
elements: [
buildOpenTextElement({
id: reusableElementIds[2],
headline: t("templates.csat_survey_question_3_headline"),
required: false,
placeholder: t("templates.csat_survey_question_3_placeholder"),
inputType: "text",
}),
],
t,
}),
],
@@ -251,21 +252,31 @@ const cessSurvey = (t: TFunction): TXMTemplate => {
return {
...getXMSurveyDefault(t),
name: t("templates.cess_survey_name"),
questions: [
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.cess_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
upperLabel: t("templates.cess_survey_question_1_upper_label"),
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildRatingElement({
range: 5,
scale: "number",
headline: t("templates.cess_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
upperLabel: t("templates.cess_survey_question_1_upper_label"),
}),
],
t,
}),
buildOpenTextQuestion({
headline: t("templates.cess_survey_question_2_headline"),
required: true,
placeholder: t("templates.cess_survey_question_2_placeholder"),
inputType: "text",
buildBlock({
name: "Block 2",
elements: [
buildOpenTextElement({
headline: t("templates.cess_survey_question_2_headline"),
required: true,
placeholder: t("templates.cess_survey_question_2_placeholder"),
inputType: "text",
}),
],
t,
}),
],
@@ -273,15 +284,27 @@ const cessSurvey = (t: TFunction): TXMTemplate => {
};
const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
const reusableQuestionIds = [createId(), createId(), createId()];
const reusableElementIds = [createId(), createId(), createId()];
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
const defaultSurvey = getXMSurveyDefault(t);
return {
...defaultSurvey,
name: t("templates.smileys_survey_name"),
questions: [
buildRatingQuestion({
id: reusableQuestionIds[0],
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildRatingElement({
id: reusableElementIds[0],
range: 5,
scale: "smiley",
headline: t("templates.smileys_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
}),
],
logic: [
{
id: createId(),
@@ -292,7 +315,7 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[0],
value: reusableElementIds[0],
type: "question",
},
operator: "isLessThanOrEqual",
@@ -306,64 +329,44 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: reusableQuestionIds[2],
objective: "jumpToBlock",
target: block3Id,
},
],
},
],
range: 5,
scale: "smiley",
headline: t("templates.smileys_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
t,
}),
buildCTAQuestion({
id: reusableQuestionIds[1],
subheader: t("templates.smileys_survey_question_2_html"),
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isClicked",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: defaultSurvey.endings[0].id,
},
],
},
buildBlock({
name: "Block 2",
elements: [
buildCTAElement({
id: reusableElementIds[1],
subheader: t("templates.smileys_survey_question_2_html"),
headline: t("templates.smileys_survey_question_2_headline"),
required: true,
buttonUrl: "https://formbricks.com/github",
buttonExternal: true,
}),
],
headline: t("templates.smileys_survey_question_2_headline"),
required: true,
buttonUrl: "https://formbricks.com/github",
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
buttonLabel: t("templates.smileys_survey_question_2_button_label"),
buttonExternal: true,
t,
}),
buildOpenTextQuestion({
id: reusableQuestionIds[2],
headline: t("templates.smileys_survey_question_3_headline"),
required: true,
subheader: t("templates.smileys_survey_question_3_subheader"),
buildBlock({
id: block3Id,
name: "Block 3",
elements: [
buildOpenTextElement({
id: reusableElementIds[2],
headline: t("templates.smileys_survey_question_3_headline"),
required: true,
subheader: t("templates.smileys_survey_question_3_subheader"),
placeholder: t("templates.smileys_survey_question_3_placeholder"),
inputType: "text",
}),
],
buttonLabel: t("templates.smileys_survey_question_3_button_label"),
placeholder: t("templates.smileys_survey_question_3_placeholder"),
inputType: "text",
t,
}),
],
@@ -374,25 +377,40 @@ const enpsSurvey = (t: TFunction): TXMTemplate => {
return {
...getXMSurveyDefault(t),
name: t("templates.enps_survey_name"),
questions: [
buildNPSQuestion({
headline: t("templates.enps_survey_question_1_headline"),
required: false,
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
upperLabel: t("templates.enps_survey_question_1_upper_label"),
isColorCodingEnabled: true,
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildNPSElement({
headline: t("templates.enps_survey_question_1_headline"),
required: false,
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
upperLabel: t("templates.enps_survey_question_1_upper_label"),
isColorCodingEnabled: true,
}),
],
t,
}),
buildOpenTextQuestion({
headline: t("templates.enps_survey_question_2_headline"),
required: false,
inputType: "text",
buildBlock({
name: "Block 2",
elements: [
buildOpenTextElement({
headline: t("templates.enps_survey_question_2_headline"),
required: false,
inputType: "text",
}),
],
t,
}),
buildOpenTextQuestion({
headline: t("templates.enps_survey_question_3_headline"),
required: false,
inputType: "text",
buildBlock({
name: "Block 3",
elements: [
buildOpenTextElement({
headline: t("templates.enps_survey_question_3_headline"),
required: false,
inputType: "text",
}),
],
t,
}),
],
@@ -3,7 +3,7 @@
import { TFunction } from "i18next";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Control, Controller, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -14,14 +14,15 @@ import {
TIntegrationAirtableInput,
TIntegrationAirtableTables,
} from "@formbricks/types/integration/airtable";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
import AirtableLogo from "@/images/airtableLogo.svg";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
@@ -71,6 +72,7 @@ const NoBaseFoundError = () => {
const renderQuestionSelection = ({
t,
selectedSurvey,
questions,
control,
includeVariables,
setIncludeVariables,
@@ -83,6 +85,7 @@ const renderQuestionSelection = ({
}: {
t: TFunction;
selectedSurvey: TSurvey;
questions: TSurveyElement[];
control: Control<IntegrationModalInputs>;
includeVariables: boolean;
setIncludeVariables: (value: boolean) => void;
@@ -99,7 +102,7 @@ const renderQuestionSelection = ({
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
{questions.map((question) => (
<Controller
key={question.id}
control={control}
@@ -120,7 +123,9 @@ const renderQuestionSelection = ({
}}
/>
<span className="ml-2">
{getTextContent(getLocalizedValue(question.headline, "default"))}
{getTextContent(
recallToHeadline(question.headline, selectedSurvey, false, "default")["default"]
)}
</span>
</label>
</div>
@@ -194,6 +199,11 @@ export const AddIntegrationModal = ({
};
const selectedSurvey = surveys.find((item) => item.id === survey);
const questions = useMemo(
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
[selectedSurvey]
);
const submitHandler = async (data: IntegrationModalInputs) => {
try {
if (!data.base || data.base === "") {
@@ -218,7 +228,7 @@ export const AddIntegrationModal = ({
surveyName: selectedSurvey.name,
questionIds: data.questions,
questions:
data.questions.length === selectedSurvey.questions.length
data.questions.length === questions.length
? t("common.all_questions")
: t("common.selected_questions"),
createdAt: new Date(),
@@ -395,6 +405,7 @@ export const AddIntegrationModal = ({
renderQuestionSelection({
t,
selectedSurvey,
questions,
control,
includeVariables,
setIncludeVariables,
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -15,6 +16,7 @@ interface AirtableWrapperProps {
airtableArray: TIntegrationItem[];
airtableIntegration?: TIntegrationAirtable;
surveys: TSurvey[];
environment: TEnvironment;
isEnabled: boolean;
webAppUrl: string;
locale: TUserLocale;
@@ -25,6 +27,7 @@ export const AirtableWrapper = ({
airtableArray,
airtableIntegration,
surveys,
environment,
isEnabled,
webAppUrl,
locale,
@@ -45,6 +48,7 @@ export const AirtableWrapper = ({
<ManageIntegration
airtableArray={airtableArray}
environmentId={environmentId}
environment={environment}
airtableIntegration={airtableIntegration}
setIsConnected={setIsConnected}
surveys={surveys}
@@ -4,6 +4,7 @@ import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -14,11 +15,12 @@ import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { IntegrationModalInputs } from "../lib/types";
interface ManageIntegrationProps {
airtableIntegration: TIntegrationAirtable;
environment: TEnvironment;
environmentId: string;
setIsConnected: (data: boolean) => void;
surveys: TSurvey[];
@@ -27,7 +29,7 @@ interface ManageIntegrationProps {
}
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
const { t } = useTranslation();
const tableHeaders = [
@@ -130,7 +132,12 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
</div>
) : (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.airtable.no_integrations_yet")} />
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={t("environments.integrations.airtable.no_integrations_yet")}
/>
</div>
)}
@@ -51,6 +51,7 @@ const Page = async (props) => {
airtableArray={airtableArray}
environmentId={environment.id}
surveys={surveys}
environment={environment}
webAppUrl={WEBAPP_URL}
locale={locale}
/>
@@ -1,7 +1,7 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -20,9 +20,9 @@ import {
isValidGoogleSheetsUrl,
} from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/util";
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
@@ -86,12 +86,17 @@ export const AddIntegrationModal = ({
},
};
const questions = useMemo(
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
[selectedSurvey]
);
useEffect(() => {
if (selectedSurvey && !selectedIntegration) {
const questionIds = selectedSurvey.questions.map((question) => question.id);
const questionIds = questions.map((question) => question.id);
setSelectedQuestions(questionIds);
}
}, [selectedIntegration, selectedSurvey]);
}, [questions, selectedIntegration, selectedSurvey]);
useEffect(() => {
if (selectedIntegration) {
@@ -145,7 +150,7 @@ export const AddIntegrationModal = ({
integrationData.surveyName = selectedSurvey.name;
integrationData.questionIds = selectedQuestions;
integrationData.questions =
selectedQuestions.length === selectedSurvey?.questions.length
selectedQuestions.length === questions.length
? t("common.all_questions")
: t("common.selected_questions");
integrationData.createdAt = new Date();
@@ -263,7 +268,7 @@ export const AddIntegrationModal = ({
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
{questions.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
@@ -277,7 +282,11 @@ export const AddIntegrationModal = ({
}}
/>
<span className="ml-2 w-[30rem] truncate">
{getTextContent(getLocalizedValue(question.headline, "default"))}
{getTextContent(
recallToHeadline(question.headline, selectedSurvey, false, "default")[
"default"
]
)}
</span>
</label>
</div>
@@ -60,6 +60,7 @@ export const GoogleSheetWrapper = ({
selectedIntegration={selectedIntegration}
/>
<ManageIntegration
environment={environment}
googleSheetIntegration={googleSheetIntegration}
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}
@@ -4,6 +4,7 @@ import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsConfigData,
@@ -14,9 +15,10 @@ import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
interface ManageIntegrationProps {
environment: TEnvironment;
googleSheetIntegration: TIntegrationGoogleSheets;
setOpenAddIntegrationModal: (v: boolean) => void;
setIsConnected: (v: boolean) => void;
@@ -25,6 +27,7 @@ interface ManageIntegrationProps {
}
export const ManageIntegration = ({
environment,
googleSheetIntegration,
setOpenAddIntegrationModal,
setIsConnected,
@@ -87,7 +90,12 @@ export const ManageIntegration = ({
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.google_sheets.no_integrations_yet")} />
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={t("environments.integrations.google_sheets.no_integrations_yet")}
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
@@ -21,9 +21,9 @@ import {
UNSUPPORTED_TYPES_BY_NOTION,
} from "@/app/(app)/environments/[environmentId]/project/integrations/notion/constants";
import NotionLogo from "@/images/notion.png";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { Button } from "@/modules/ui/components/button";
import {
@@ -92,6 +92,11 @@ export const AddIntegrationModal = ({
createdAt: new Date(),
};
const questions = useMemo(
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
[selectedSurvey]
);
const notionIntegrationData: TIntegrationInput = {
type: "notion",
config: {
@@ -120,10 +125,10 @@ export const AddIntegrationModal = ({
}, [selectedDatabase?.id]);
const questionItems = useMemo(() => {
const questions = selectedSurvey
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
const mappedQuestions = selectedSurvey
? questions.map((q) => ({
id: q.id,
name: getTextContent(getLocalizedValue(q.headline, "default")),
name: getTextContent(recallToHeadline(q.headline, selectedSurvey, false, "default")["default"]),
type: q.type,
}))
: [];
@@ -156,7 +161,7 @@ export const AddIntegrationModal = ({
},
];
return [...questions, ...variables, ...hiddenFields, ...Metadata, ...createdAt];
return [...mappedQuestions, ...variables, ...hiddenFields, ...Metadata, ...createdAt];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSurvey?.id]);
@@ -4,6 +4,7 @@ import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import React, { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
@@ -11,10 +12,11 @@ import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface ManageIntegrationProps {
environment: TEnvironment;
notionIntegration: TIntegrationNotion;
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
@@ -26,6 +28,7 @@ interface ManageIntegrationProps {
}
export const ManageIntegration = ({
environment,
notionIntegration,
setOpenAddIntegrationModal,
setIsConnected,
@@ -98,7 +101,12 @@ export const ManageIntegration = ({
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.notion.no_databases_found")} />
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={t("environments.integrations.notion.no_databases_found")}
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
@@ -64,6 +64,7 @@ export const NotionWrapper = ({
selectedIntegration={selectedIntegration}
/>
<ManageIntegration
environment={environment}
notionIntegration={notionIntegration}
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}
@@ -17,8 +17,8 @@ import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import SlackLogo from "@/images/slacklogo.png";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
@@ -73,14 +73,19 @@ export const AddChannelMappingModal = ({
},
};
const questions = useMemo(
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
[selectedSurvey]
);
useEffect(() => {
if (selectedSurvey) {
const questionIds = selectedSurvey.questions.map((question) => question.id);
const questionIds = questions.map((question) => question.id);
if (!selectedIntegration) {
setSelectedQuestions(questionIds);
}
}
}, [selectedIntegration, selectedSurvey]);
}, [questions, selectedIntegration, selectedSurvey]);
useEffect(() => {
if (selectedIntegration) {
@@ -269,7 +274,7 @@ export const AddChannelMappingModal = ({
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default")?.questions?.map((question) => (
{questions.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
@@ -283,7 +288,11 @@ export const AddChannelMappingModal = ({
}}
/>
<span className="ml-2">
{getTextContent(getLocalizedValue(question.headline, "default"))}
{getTextContent(
recallToHeadline(question.headline, selectedSurvey, false, "default")[
"default"
]
)}
</span>
</label>
</div>
@@ -4,6 +4,7 @@ import { Trash2Icon } from "lucide-react";
import React, { useState } from "react";
import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
@@ -11,9 +12,10 @@ import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
interface ManageIntegrationProps {
environment: TEnvironment;
slackIntegration: TIntegrationSlack;
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
@@ -27,6 +29,7 @@ interface ManageIntegrationProps {
}
export const ManageIntegration = ({
environment,
slackIntegration,
setOpenAddIntegrationModal,
setIsConnected,
@@ -103,7 +106,12 @@ export const ManageIntegration = ({
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.slack.connect_your_first_slack_channel")} />
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={t("environments.integrations.slack.connect_your_first_slack_channel")}
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
@@ -78,6 +78,7 @@ export const SlackWrapper = ({
selectedIntegration={selectedIntegration}
/>
<ManageIntegration
environment={environment}
slackIntegration={slackIntegration}
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}
@@ -215,7 +215,7 @@ export const EditProfileDetailsForm = ({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-slate-50 text-slate-700"
align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{appLanguages.map((lang) => (
@@ -10,6 +10,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
interface ResponseDataViewProps {
survey: TSurvey;
@@ -55,7 +56,10 @@ export const formatContactInfoData = (responseValue: TResponseDataValue): Record
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
const responseData: Record<string, any> = {};
for (const question of survey.questions) {
// Derive questions from blocks
const questions = getElementsFromBlocks(survey.blocks);
for (const question of questions) {
const responseValue = response.data[question.id];
switch (question.type) {
case "matrix":
@@ -5,7 +5,8 @@ import { TFunction } from "i18next";
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
import Link from "next/link";
import { TResponseTableData } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
@@ -13,6 +14,7 @@ import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { IdBadge } from "@/modules/ui/components/id-badge";
@@ -29,7 +31,7 @@ import {
} from "../lib/utils";
const getQuestionColumnsData = (
question: TSurveyQuestion,
question: TSurveyElement,
survey: TSurvey,
isExpanded: boolean,
t: TFunction
@@ -54,7 +56,7 @@ const getQuestionColumnsData = (
};
// Helper function to get localized question headline
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
const getQuestionHeadline = (question: TSurveyElement, survey: TSurvey) => {
return getTextContent(
getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default")
);
@@ -265,7 +267,8 @@ export const generateResponseTableColumns = (
t: TFunction,
showQuotasColumn: boolean
): ColumnDef<TResponseTableData>[] => {
const questionColumns = survey.questions.flatMap((question) =>
const questions = getElementsFromBlocks(survey.blocks);
const questionColumns = questions.flatMap((question) =>
getQuestionColumnsData(question, survey, isExpanded, t)
);
@@ -2,6 +2,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -13,6 +14,7 @@ import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -21,39 +23,43 @@ const Page = async (props) => {
const params = await props.params;
const t = await getTranslate();
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const { session, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
getSurvey(params.surveyId),
getUser(session.user.id),
getTagsByEnvironmentId(params.environmentId),
getIsContactsEnabled(),
getResponseCountBySurveyId(params.surveyId),
findMatchingLocale(),
]);
const survey = await getSurvey(params.surveyId);
if (!survey) {
throw new Error(t("common.survey_not_found"));
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const tags = await getTagsByEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled();
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
// Get response count for the CTA component
const responseCount = await getResponseCountBySurveyId(params.surveyId);
const displayCount = await getDisplayCountBySurveyId(params.surveyId);
const locale = await findMatchingLocale();
const publicDomain = getPublicDomain();
const organizationBilling = await getOrganizationBilling(organization.id);
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
if (!organizationId) {
throw new Error(t("common.organization_not_found"));
}
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error(t("common.organization_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
return (
@@ -68,6 +74,7 @@ const Page = async (props) => {
user={user}
publicDomain={publicDomain}
responseCount={responseCount}
displayCount={displayCount}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
@@ -2,7 +2,7 @@
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyQuestionSummaryAddress } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyElementSummaryAddress } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
@@ -11,7 +11,7 @@ import { PersonAvatar } from "@/modules/ui/components/avatars";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface AddressSummaryProps {
questionSummary: TSurveyQuestionSummaryAddress;
questionSummary: TSurveyElementSummaryAddress;
environmentId: string;
survey: TSurvey;
locale: TUserLocale;
@@ -2,13 +2,13 @@
import { InboxIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyQuestionSummaryCta } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyElementSummaryCta } from "@formbricks/types/surveys/types";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { convertFloatToNDecimal } from "../lib/utils";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface CTASummaryProps {
questionSummary: TSurveyQuestionSummaryCta;
questionSummary: TSurveyElementSummaryCta;
survey: TSurvey;
}
@@ -1,13 +1,13 @@
"use client";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyQuestionSummaryCal } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyElementSummaryCal } from "@formbricks/types/surveys/types";
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface CalSummaryProps {
questionSummary: TSurveyQuestionSummaryCal;
questionSummary: TSurveyElementSummaryCal;
environmentId: string;
survey: TSurvey;
}
@@ -1,32 +0,0 @@
"use client";
import { CSSProperties, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface ClickableBarSegmentProps {
children: ReactNode;
onClick: () => void;
className?: string;
style?: CSSProperties;
}
export const ClickableBarSegment = ({
children,
onClick,
className = "",
style,
}: ClickableBarSegmentProps) => {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<button className={className} style={style} onClick={onClick}>
{children}
</button>
</TooltipTrigger>
<TooltipContent>{t("common.click_to_filter")}</TooltipContent>
</Tooltip>
);
};
@@ -1,24 +1,20 @@
"use client";
import { useTranslation } from "react-i18next";
import {
TI18nString,
TSurvey,
TSurveyQuestionId,
TSurveyQuestionSummaryConsent,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryConsent, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { convertFloatToNDecimal } from "../lib/utils";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface ConsentSummaryProps {
questionSummary: TSurveyQuestionSummaryConsent;
questionSummary: TSurveyElementSummaryConsent;
survey: TSurvey;
setFilter: (
questionId: TSurveyQuestionId,
label: TI18nString,
questionType: TSurveyQuestionTypeEnum,
questionType: TSurveyElementTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
@@ -2,7 +2,7 @@
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyQuestionSummaryContactInfo } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyElementSummaryContactInfo } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
@@ -11,7 +11,7 @@ import { PersonAvatar } from "@/modules/ui/components/avatars";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface ContactInfoSummaryProps {
questionSummary: TSurveyQuestionSummaryContactInfo;
questionSummary: TSurveyElementSummaryContactInfo;
environmentId: string;
survey: TSurvey;
locale: TUserLocale;
@@ -3,7 +3,7 @@
import Link from "next/link";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyQuestionSummaryDate } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
@@ -13,7 +13,7 @@ import { Button } from "@/modules/ui/components/button";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface DateQuestionSummary {
questionSummary: TSurveyQuestionSummaryDate;
questionSummary: TSurveyElementSummaryDate;
environmentId: string;
survey: TSurvey;
locale: TUserLocale;
@@ -4,7 +4,7 @@ import { DownloadIcon, FileIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyQuestionSummaryFileUpload } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyElementSummaryFileUpload } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
@@ -14,7 +14,7 @@ import { Button } from "@/modules/ui/components/button";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface FileUploadSummaryProps {
questionSummary: TSurveyQuestionSummaryFileUpload;
questionSummary: TSurveyElementSummaryFileUpload;
environmentId: string;
survey: TSurvey;
locale: TUserLocale;
@@ -4,7 +4,7 @@ import { InboxIcon, Link, MessageSquareTextIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurveyQuestionSummaryHiddenFields } from "@formbricks/types/surveys/types";
import { TSurveyElementSummaryHiddenFields } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
@@ -13,7 +13,7 @@ import { Button } from "@/modules/ui/components/button";
interface HiddenFieldsSummaryProps {
environment: TEnvironment;
questionSummary: TSurveyQuestionSummaryHiddenFields;
questionSummary: TSurveyElementSummaryHiddenFields;
locale: TUserLocale;
}
@@ -1,23 +1,19 @@
"use client";
import { useTranslation } from "react-i18next";
import {
TI18nString,
TSurvey,
TSurveyQuestionId,
TSurveyQuestionSummaryMatrix,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryMatrix, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface MatrixQuestionSummaryProps {
questionSummary: TSurveyQuestionSummaryMatrix;
questionSummary: TSurveyElementSummaryMatrix;
survey: TSurvey;
setFilter: (
questionId: TSurveyQuestionId,
label: TI18nString,
questionType: TSurveyQuestionTypeEnum,
questionType: TSurveyElementTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
@@ -4,12 +4,12 @@ import { InboxIcon } from "lucide-react";
import Link from "next/link";
import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
TI18nString,
TSurvey,
TSurveyElementSummaryMultipleChoice,
TSurveyQuestionId,
TSurveyQuestionSummaryMultipleChoice,
TSurveyQuestionTypeEnum,
TSurveyType,
} from "@formbricks/types/surveys/types";
import { getChoiceIdByValue } from "@/lib/response/utils";
@@ -22,14 +22,14 @@ import { convertFloatToNDecimal } from "../lib/utils";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface MultipleChoiceSummaryProps {
questionSummary: TSurveyQuestionSummaryMultipleChoice;
questionSummary: TSurveyElementSummaryMultipleChoice;
environmentId: string;
surveyType: TSurveyType;
survey: TSurvey;
setFilter: (
questionId: TSurveyQuestionId,
label: TI18nString,
questionType: TSurveyQuestionTypeEnum,
questionType: TSurveyElementTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
@@ -1,49 +1,27 @@
"use client";
import { BarChart, BarChartHorizontal } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
TI18nString,
TSurvey,
TSurveyQuestionId,
TSurveyQuestionSummaryNps,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryNps, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { convertFloatToNDecimal } from "../lib/utils";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
import { SatisfactionIndicator } from "./SatisfactionIndicator";
interface NPSSummaryProps {
questionSummary: TSurveyQuestionSummaryNps;
questionSummary: TSurveyElementSummaryNps;
survey: TSurvey;
setFilter: (
questionId: TSurveyQuestionId,
label: TI18nString,
questionType: TSurveyQuestionTypeEnum,
questionType: TSurveyElementTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
}
const calculateNPSOpacity = (rating: number): number => {
if (rating <= 6) {
return 0.3 + (rating / 6) * 0.3;
}
if (rating <= 8) {
return 0.6 + ((rating - 6) / 2) * 0.2;
}
return 0.8 + ((rating - 8) / 2) * 0.2;
};
export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
const applyFilter = (group: string) => {
const filters = {
promoters: {
@@ -79,110 +57,38 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader
questionSummary={questionSummary}
survey={survey}
additionalInfo={
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={questionSummary.promoters.percentage} />
<div>
{t("environments.surveys.summary.promoters")}:{" "}
{convertFloatToNDecimal(questionSummary.promoters.percentage, 2)}%
</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("environments.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("environments.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="aggregated" className="mt-4">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={group}
onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
<p
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary[group]?.count}{" "}
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={group}
onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
<p
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
</p>
</div>
<ProgressBar
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</button>
))}
</div>
</TabsContent>
<TabsContent value="individual" className="mt-4">
<TooltipProvider delayDuration={200}>
<div className="grid grid-cols-11 gap-2 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((choice) => {
const opacity = calculateNPSOpacity(choice.rating);
return (
<ClickableBarSegment
key={choice.rating}
className="group flex cursor-pointer flex-col items-center"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
choice.rating.toString()
)
}>
<div className="flex h-32 w-full flex-col items-center justify-end">
<div
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
style={{
height: `${Math.max(choice.percentage, 2)}%`,
opacity,
}}
/>
</div>
<div className="flex w-full flex-col items-center rounded-b-lg border border-t-0 border-slate-200 bg-slate-50 px-1 py-2">
<div className="mb-1.5 text-xs font-medium text-slate-500">{choice.rating}</div>
<div className="mb-1 flex items-center space-x-1">
<div className="text-base font-semibold text-slate-700">{choice.count}</div>
<div className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600">
{convertFloatToNDecimal(choice.percentage, 1)}%
</div>
</div>
</div>
</ClickableBarSegment>
);
})}
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary[group]?.count}{" "}
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
</TooltipProvider>
</TabsContent>
</Tabs>
<ProgressBar
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</button>
))}
</div>
<div className="flex justify-center pb-4 pt-4">
<HalfCircle value={questionSummary.score} />
@@ -3,7 +3,7 @@
import Link from "next/link";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyElementSummaryOpenText } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
@@ -14,7 +14,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface OpenTextSummaryProps {
questionSummary: TSurveyQuestionSummaryOpenText;
questionSummary: TSurveyElementSummaryOpenText;
environmentId: string;
survey: TSurvey;
locale: TUserLocale;
@@ -3,12 +3,12 @@
import { InboxIcon } from "lucide-react";
import Image from "next/image";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
TI18nString,
TSurvey,
TSurveyElementSummaryPictureSelection,
TSurveyQuestionId,
TSurveyQuestionSummaryPictureSelection,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
@@ -17,12 +17,12 @@ import { convertFloatToNDecimal } from "../lib/utils";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface PictureChoiceSummaryProps {
questionSummary: TSurveyQuestionSummaryPictureSelection;
questionSummary: TSurveyElementSummaryPictureSelection;
survey: TSurvey;
setFilter: (
questionId: TSurveyQuestionId,
label: TI18nString,
questionType: TSurveyQuestionTypeEnum,
questionType: TSurveyElementTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
@@ -3,7 +3,7 @@
import { InboxIcon } from "lucide-react";
import type { JSX } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyQuestionSummary } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyElementSummary } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { recallToHeadline } from "@/lib/utils/recall";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
@@ -11,7 +11,7 @@ import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { IdBadge } from "@/modules/ui/components/id-badge";
interface HeadProps {
questionSummary: TSurveyQuestionSummary;
questionSummary: TSurveyElementSummary;
showResponses?: boolean;
additionalInfo?: JSX.Element;
survey: TSurvey;
@@ -57,8 +57,8 @@ export const QuestionSummaryHeader = ({
{t("environments.surveys.edit.optional")}
</div>
)}
<IdBadge id={questionSummary.question.id} />
</div>
<IdBadge id={questionSummary.question.id} label={t("common.question_id")} />
</div>
);
};
@@ -1,12 +1,12 @@
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyQuestionSummaryRanking } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyElementSummaryRanking } from "@formbricks/types/surveys/types";
import { getChoiceIdByValue } from "@/lib/response/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { convertFloatToNDecimal } from "../lib/utils";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface RankingSummaryProps {
questionSummary: TSurveyQuestionSummaryRanking;
questionSummary: TSurveyElementSummaryRanking;
survey: TSurvey;
}
@@ -1,24 +0,0 @@
"use client";
import { TSurveyRatingQuestion } from "@formbricks/types/surveys/types";
import { RatingResponse } from "@/modules/ui/components/rating-response";
interface RatingScaleLegendProps {
scale: TSurveyRatingQuestion["scale"];
range: number;
}
export const RatingScaleLegend = ({ scale, range }: RatingScaleLegendProps) => {
return (
<div className="mt-3 flex w-full items-start justify-between px-1">
<div className="flex items-center space-x-1">
<RatingResponse scale={scale} answer={1} range={range} addColors={false} variant="scale" />
<span className="text-xs text-slate-500">1</span>
</div>
<div className="flex items-center space-x-1">
<span className="text-xs text-slate-500">{range}</span>
<RatingResponse scale={scale} answer={range} range={range} addColors={false} variant="scale" />
</div>
</div>
);
};
@@ -1,32 +1,23 @@
"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 {
TI18nString,
TSurvey,
TSurveyQuestionId,
TSurveyQuestionSummaryRating,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { type TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyElementSummaryRating, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
import { TooltipProvider } from "@/modules/ui/components/tooltip";
import { ClickableBarSegment } from "./ClickableBarSegment";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
import { RatingScaleLegend } from "./RatingScaleLegend";
import { SatisfactionIndicator } from "./SatisfactionIndicator";
interface RatingSummaryProps {
questionSummary: TSurveyQuestionSummaryRating;
questionSummary: TSurveyElementSummaryRating;
survey: TSurvey;
setFilter: (
questionId: TSurveyQuestionId,
label: TI18nString,
questionType: TSurveyQuestionTypeEnum,
questionType: TSurveyElementTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => void;
@@ -34,8 +25,6 @@ interface RatingSummaryProps {
export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSummaryProps) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
const getIconBasedOnScale = useMemo(() => {
const scale = questionSummary.question.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
@@ -49,174 +38,52 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
questionSummary={questionSummary}
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("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
</div>
</div>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<SatisfactionIndicator percentage={questionSummary.csat.satisfiedPercentage} />
<div>
CSAT: {questionSummary.csat.satisfiedPercentage}%{" "}
{t("environments.surveys.summary.satisfied")}
</div>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>
{t("environments.surveys.summary.overall")}: {questionSummary.average.toFixed(2)}
</div>
</div>
}
/>
<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("environments.surveys.summary.aggregated")}
</TabsTrigger>
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
{t("environments.surveys.summary.individual")}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="aggregated" className="mt-4">
<div className="px-4 pb-6 pt-4 md:px-6">
{questionSummary.responseCount === 0 ? (
<>
<div className="rounded-lg border border-slate-200 bg-slate-50 p-8 text-center">
<p className="text-sm text-slate-500">
{t("environments.surveys.summary.no_responses_found")}
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((result) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={result.rating}
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={questionSummary.question.scale}
answer={result.rating}
range={questionSummary.question.range}
addColors={questionSummary.question.isColorCodingEnabled}
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
<RatingScaleLegend
scale={questionSummary.question.scale}
range={questionSummary.question.range}
/>
</>
) : (
<>
<TooltipProvider delayDuration={200}>
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
{questionSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
const range = questionSummary.question.range;
const opacity = 0.3 + (result.rating / range) * 0.8;
const isFirst = index === 0;
const isLast = index === questionSummary.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(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div
className={`bg-brand-dark h-full ${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">
{questionSummary.choices.map((result, index) => {
if (result.percentage === 0) return null;
return (
<div
key={result.rating}
className="flex flex-col items-center justify-center py-2"
style={{
width: `${result.percentage}%`,
borderRight:
index < questionSummary.choices.length - 1
? "1px solid rgb(226, 232, 240)"
: "none",
}}>
<div className="mb-1 flex items-center justify-center">
<RatingResponse
scale={questionSummary.question.scale}
answer={result.rating}
range={questionSummary.question.range}
addColors={false}
variant="aggregated"
/>
</div>
<div className="text-xs font-medium text-slate-600">
{convertFloatToNDecimal(result.percentage, 1)}%
</div>
</div>
);
})}
</div>
<RatingScaleLegend
scale={questionSummary.question.scale}
range={questionSummary.question.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">
{questionSummary.choices.map((result) => (
<div key={result.rating}>
<button
className="w-full cursor-pointer hover:opacity-80"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
t("environments.surveys.summary.is_equal_to"),
result.rating.toString()
)
}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={questionSummary.question.scale}
answer={result.rating}
range={questionSummary.question.range}
addColors={questionSummary.question.isColorCodingEnabled}
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">
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
</div>
))}
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
</div>
</TabsContent>
</Tabs>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</button>
))}
</div>
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
<div className="rounded-b-lg border-t bg-white px-6 py-4">
<div key="dismissed">
@@ -1,17 +0,0 @@
interface SatisfactionIndicatorProps {
percentage: number;
}
export const SatisfactionIndicator = ({ percentage }: SatisfactionIndicatorProps) => {
let colorClass = "";
if (percentage > 80) {
colorClass = "bg-emerald-500";
} else if (percentage >= 55) {
colorClass = "bg-orange-500";
} else {
colorClass = "bg-rose-500";
}
return <div className={`h-3 w-3 rounded-full ${colorClass}`} />;
};
@@ -2,7 +2,8 @@
import { TimerIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyQuestionType, TSurveySummary } from "@formbricks/types/surveys/types";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { recallToHeadline } from "@/lib/utils/recall";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getQuestionIcon } from "@/modules/survey/lib/questions";
@@ -15,7 +16,7 @@ interface SummaryDropOffsProps {
export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
const { t } = useTranslation();
const getIcon = (questionType: TSurveyQuestionType) => {
const getIcon = (questionType: TSurveyElementTypeEnum) => {
const Icon = getQuestionIcon(questionType, t);
return <Icon className="mt-[3px] h-5 w-5 shrink-0 text-slate-600" />;
};
@@ -3,14 +3,10 @@
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import {
TI18nString,
TSurvey,
TSurveyQuestionId,
TSurveyQuestionTypeEnum,
TSurveySummary,
} from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurveySummary } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import {
SelectedFilterValue,
@@ -34,7 +30,7 @@ import { RatingSummary } from "@/app/(app)/environments/[environmentId]/surveys/
import { constructToastMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
import { AddressSummary } from "./AddressSummary";
@@ -50,16 +46,16 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
const { setSelectedFilter, selectedFilter } = useResponseFilter();
const { t } = useTranslation();
const setFilter = (
questionId: TSurveyQuestionId,
questionId: string,
label: TI18nString,
questionType: TSurveyQuestionTypeEnum,
questionType: TSurveyElementTypeEnum,
filterValue: string,
filterComboBoxValue?: string | string[]
) => {
const filterObject: SelectedFilterValue = { ...selectedFilter };
const value = {
id: questionId,
label: getTextContent(getLocalizedValue(label, "default")),
label: getLocalizedValue(label, "default"),
questionType: questionType,
type: OptionsType.QUESTIONS,
};
@@ -108,10 +104,15 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
) : summary.length === 0 ? (
<SkeletonLoader type="summary" />
) : responseCount === 0 ? (
<EmptyState text={t("environments.surveys.summary.no_responses_found")} />
<EmptySpaceFiller
type="response"
environment={environment}
noWidgetRequired={survey.type === "link"}
emptyMessage={t("environments.surveys.summary.no_responses_found")}
/>
) : (
summary.map((questionSummary) => {
if (questionSummary.type === TSurveyQuestionTypeEnum.OpenText) {
if (questionSummary.type === TSurveyElementTypeEnum.OpenText) {
return (
<OpenTextSummary
key={questionSummary.question.id}
@@ -123,8 +124,8 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
);
}
if (
questionSummary.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ||
questionSummary.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti
questionSummary.type === TSurveyElementTypeEnum.MultipleChoiceSingle ||
questionSummary.type === TSurveyElementTypeEnum.MultipleChoiceMulti
) {
return (
<MultipleChoiceSummary
@@ -137,7 +138,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
/>
);
}
if (questionSummary.type === TSurveyQuestionTypeEnum.NPS) {
if (questionSummary.type === TSurveyElementTypeEnum.NPS) {
return (
<NPSSummary
key={questionSummary.question.id}
@@ -147,7 +148,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
/>
);
}
if (questionSummary.type === TSurveyQuestionTypeEnum.CTA) {
if (questionSummary.type === TSurveyElementTypeEnum.CTA) {
return (
<CTASummary
key={questionSummary.question.id}
@@ -156,7 +157,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
/>
);
}
if (questionSummary.type === TSurveyQuestionTypeEnum.Rating) {
if (questionSummary.type === TSurveyElementTypeEnum.Rating) {
return (
<RatingSummary
key={questionSummary.question.id}
@@ -166,7 +167,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
/>
);
}
if (questionSummary.type === TSurveyQuestionTypeEnum.Consent) {
if (questionSummary.type === TSurveyElementTypeEnum.Consent) {
return (
<ConsentSummary
key={questionSummary.question.id}
@@ -176,7 +177,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
/>
);
}
if (questionSummary.type === TSurveyQuestionTypeEnum.PictureSelection) {
if (questionSummary.type === TSurveyElementTypeEnum.PictureSelection) {
return (
<PictureChoiceSummary
key={questionSummary.question.id}
@@ -186,7 +187,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
/>
);
}
if (questionSummary.type === TSurveyQuestionTypeEnum.Date) {
if (questionSummary.type === TSurveyElementTypeEnum.Date) {
return (
<DateQuestionSummary
key={questionSummary.question.id}
@@ -197,7 +198,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
/>
);
}
if (questionSummary.type === TSurveyQuestionTypeEnum.FileUpload) {
if (questionSummary.type === TSurveyElementTypeEnum.FileUpload) {
return (
<FileUploadSummary
key={questionSummary.question.id}
@@ -208,7 +209,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
/>
);
}
if (questionSummary.type === TSurveyQuestionTypeEnum.Cal) {
if (questionSummary.type === TSurveyElementTypeEnum.Cal) {
return (
<CalSummary
key={questionSummary.question.id}
@@ -218,7 +219,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
/>
);
}
if (questionSummary.type === TSurveyQuestionTypeEnum.Matrix) {
if (questionSummary.type === TSurveyElementTypeEnum.Matrix) {
return (
<MatrixQuestionSummary
key={questionSummary.question.id}
@@ -228,7 +229,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
/>
);
}
if (questionSummary.type === TSurveyQuestionTypeEnum.Address) {
if (questionSummary.type === TSurveyElementTypeEnum.Address) {
return (
<AddressSummary
key={questionSummary.question.id}
@@ -239,7 +240,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
/>
);
}
if (questionSummary.type === TSurveyQuestionTypeEnum.Ranking) {
if (questionSummary.type === TSurveyElementTypeEnum.Ranking) {
return (
<RankingSummary
key={questionSummary.question.id}
@@ -258,7 +259,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
/>
);
}
if (questionSummary.type === TSurveyQuestionTypeEnum.ContactInfo) {
if (questionSummary.type === TSurveyElementTypeEnum.ContactInfo) {
return (
<ContactInfoSummary
key={questionSummary.question.id}
@@ -29,6 +29,7 @@ interface SurveyAnalysisCTAProps {
user: TUser;
publicDomain: string;
responseCount: number;
displayCount: number;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
@@ -47,6 +48,7 @@ export const SurveyAnalysisCTA = ({
user,
publicDomain,
responseCount,
displayCount,
segments,
isContactsEnabled,
isFormbricksCloud,
@@ -94,6 +96,7 @@ export const SurveyAnalysisCTA = ({
const duplicateSurveyAndRoute = async (surveyId: string) => {
setLoading(true);
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
environmentId: environment.id,
surveyId: surveyId,
targetEnvironmentId: environment.id,
});
@@ -167,7 +170,7 @@ export const SurveyAnalysisCTA = ({
icon: ListRestart,
tooltip: t("environments.surveys.summary.reset_survey"),
onClick: () => setIsResetModalOpen(true),
isVisible: !isReadOnly,
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0),
},
{
icon: SquarePenIcon,
@@ -5,7 +5,8 @@ import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TI18nString, TSurvey, TSurveyMetadata } from "@formbricks/types/surveys/types";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurvey, TSurveyMetadata } from "@formbricks/types/surveys/types";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { createI18nString, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
@@ -14,23 +14,26 @@ import {
TResponseVariables,
ZResponseFilterCriteria,
} from "@formbricks/types/responses";
import {
TSurveyAddressElement,
TSurveyContactInfoElement,
TSurveyElement,
TSurveyElementTypeEnum,
} from "@formbricks/types/surveys/elements";
import {
TSurvey,
TSurveyContactInfoQuestion,
TSurveyElementSummaryAddress,
TSurveyElementSummaryContactInfo,
TSurveyElementSummaryDate,
TSurveyElementSummaryFileUpload,
TSurveyElementSummaryHiddenFields,
TSurveyElementSummaryMultipleChoice,
TSurveyElementSummaryOpenText,
TSurveyElementSummaryPictureSelection,
TSurveyElementSummaryRanking,
TSurveyElementSummaryRating,
TSurveyLanguage,
TSurveyMultipleChoiceQuestion,
TSurveyQuestion,
TSurveyQuestionId,
TSurveyQuestionSummaryAddress,
TSurveyQuestionSummaryDate,
TSurveyQuestionSummaryFileUpload,
TSurveyQuestionSummaryHiddenFields,
TSurveyQuestionSummaryMultipleChoice,
TSurveyQuestionSummaryOpenText,
TSurveyQuestionSummaryPictureSelection,
TSurveyQuestionSummaryRanking,
TSurveyQuestionSummaryRating,
TSurveyQuestionTypeEnum,
TSurveyQuestionChoice,
TSurveySummary,
} from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
@@ -40,6 +43,7 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { buildWhereClause } from "@/lib/response/utils";
import { getSurvey } from "@/lib/survey/service";
import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils";
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
import { validateInputs } from "@/lib/utils/validate";
import { convertFloatTo2Decimal } from "./utils";
@@ -97,25 +101,26 @@ export const getSurveySummaryMeta = (
const evaluateLogicAndGetNextQuestionId = (
localSurvey: TSurvey,
questions: TSurveyElement[],
data: TResponseData,
localVariables: TResponseVariables,
currentQuestionIndex: number,
currQuesTemp: TSurveyQuestion,
currQuesTemp: TSurveyElement,
selectedLanguage: string | null
): {
nextQuestionId: TSurveyQuestionId | undefined;
nextQuestionId: string | undefined;
updatedSurvey: TSurvey;
updatedVariables: TResponseVariables;
} => {
const questions = localSurvey.questions;
let updatedSurvey = { ...localSurvey };
let updatedVariables = { ...localVariables };
let firstJumpTarget: string | undefined;
if (currQuesTemp.logic && currQuesTemp.logic.length > 0) {
for (const logic of currQuesTemp.logic) {
const { block: currentBlock } = findElementLocation(localSurvey, currQuesTemp.id);
if (currentBlock?.logic && currentBlock.logic.length > 0) {
for (const logic of currentBlock.logic) {
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
const { jumpTarget, requiredQuestionIds, calculations } = performActions(
updatedSurvey,
@@ -125,9 +130,13 @@ const evaluateLogicAndGetNextQuestionId = (
);
if (requiredQuestionIds.length > 0) {
updatedSurvey.questions = updatedSurvey.questions.map((q) =>
requiredQuestionIds.includes(q.id) ? { ...q, required: true } : q
);
// Update blocks to mark elements as required
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
...block,
elements: block.elements.map((e) =>
requiredQuestionIds.includes(e.id) ? { ...e, required: true } : e
),
}));
}
updatedVariables = { ...updatedVariables, ...calculations };
@@ -139,8 +148,8 @@ const evaluateLogicAndGetNextQuestionId = (
}
// If no jump target was set, check for a fallback logic
if (!firstJumpTarget && currQuesTemp.logicFallback) {
firstJumpTarget = currQuesTemp.logicFallback;
if (!firstJumpTarget && currentBlock?.logicFallback) {
firstJumpTarget = currentBlock.logicFallback;
}
// Return the first jump target if found, otherwise go to the next question
@@ -151,10 +160,11 @@ const evaluateLogicAndGetNextQuestionId = (
export const getSurveySummaryDropOff = (
survey: TSurvey,
questions: TSurveyElement[],
responses: TSurveySummaryResponse[],
displayCount: number
): TSurveySummary["dropOff"] => {
const initialTtc = survey.questions.reduce((acc: Record<string, number>, question) => {
const initialTtc = questions.reduce((acc: Record<string, number>, question) => {
acc[question.id] = 0;
return acc;
}, {});
@@ -162,9 +172,9 @@ export const getSurveySummaryDropOff = (
let totalTtc = { ...initialTtc };
let responseCounts = { ...initialTtc };
let dropOffArr = new Array(survey.questions.length).fill(0) as number[];
let impressionsArr = new Array(survey.questions.length).fill(0) as number[];
let dropOffPercentageArr = new Array(survey.questions.length).fill(0) as number[];
let dropOffArr = new Array(questions.length).fill(0) as number[];
let impressionsArr = new Array(questions.length).fill(0) as number[];
let dropOffPercentageArr = new Array(questions.length).fill(0) as number[];
const surveyVariablesData = survey.variables?.reduce(
(acc, variable) => {
@@ -191,8 +201,8 @@ export const getSurveySummaryDropOff = (
let currQuesIdx = 0;
while (currQuesIdx < localSurvey.questions.length) {
const currQues = localSurvey.questions[currQuesIdx];
while (currQuesIdx < questions.length) {
const currQues = questions[currQuesIdx];
if (!currQues) break;
// question is not answered and required
@@ -206,6 +216,7 @@ export const getSurveySummaryDropOff = (
const { nextQuestionId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextQuestionId(
localSurvey,
questions,
localResponseData,
localVariables,
currQuesIdx,
@@ -217,7 +228,7 @@ export const getSurveySummaryDropOff = (
localVariables = updatedVariables;
if (nextQuestionId) {
const nextQuesIdx = survey.questions.findIndex((q) => q.id === nextQuestionId);
const nextQuesIdx = questions.findIndex((q) => q.id === nextQuestionId);
if (!response.data[nextQuestionId] && !response.finished) {
dropOffArr[nextQuesIdx]++;
impressionsArr[nextQuesIdx]++;
@@ -250,13 +261,13 @@ export const getSurveySummaryDropOff = (
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
}
for (let i = 1; i < survey.questions.length; i++) {
for (let i = 1; i < questions.length; i++) {
if (impressionsArr[i] !== 0) {
dropOffPercentageArr[i] = (dropOffArr[i] / impressionsArr[i]) * 100;
}
}
const dropOff = survey.questions.map((question, index) => {
const dropOff = questions.map((question, index) => {
return {
questionId: question.id,
questionType: question.type,
@@ -277,13 +288,22 @@ const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: strin
return language?.default ? "default" : language?.language.code || "default";
};
const checkForI18n = (responseData: TResponseData, id: string, survey: TSurvey, languageCode: string) => {
const question = survey.questions.find((question) => question.id === id);
const checkForI18n = (
responseData: TResponseData,
id: string,
questions: TSurveyElement[],
languageCode: string
) => {
const question = questions.find((question) => question.id === id);
if (question?.type === "multipleChoiceMulti" || question?.type === "ranking") {
// Initialize an array to hold the choice values
let choiceValues = [] as string[];
// Type guard: both question types have choices property
const hasChoices = "choices" in question;
if (!hasChoices) return [];
(typeof responseData[id] === "string"
? ([responseData[id]] as string[])
: (responseData[id] as string[])
@@ -301,25 +321,31 @@ const checkForI18n = (responseData: TResponseData, id: string, survey: TSurvey,
}
// Return the localized value of the choice fo multiSelect single question
const choice = (question as TSurveyMultipleChoiceQuestion)?.choices.find(
(choice) => choice.label[languageCode] === responseData[id]
);
if (question && "choices" in question) {
const choice = question.choices?.find(
(choice: TSurveyQuestionChoice) => choice.label?.[languageCode] === responseData[id]
);
return choice && "label" in choice
? getLocalizedValue(choice.label, "default") || responseData[id]
: responseData[id];
}
return getLocalizedValue(choice?.label, "default") || responseData[id];
return responseData[id];
};
export const getQuestionSummary = async (
survey: TSurvey,
questions: TSurveyElement[],
responses: TSurveySummaryResponse[],
dropOff: TSurveySummary["dropOff"]
): Promise<TSurveySummary["summary"]> => {
const VALUES_LIMIT = 50;
let summary: TSurveySummary["summary"] = [];
for (const question of survey.questions) {
for (const question of questions) {
switch (question.type) {
case TSurveyQuestionTypeEnum.OpenText: {
let values: TSurveyQuestionSummaryOpenText["samples"] = [];
case TSurveyElementTypeEnum.OpenText: {
let values: TSurveyElementSummaryOpenText["samples"] = [];
responses.forEach((response) => {
const answer = response.data[question.id];
if (answer && typeof answer === "string") {
@@ -335,7 +361,7 @@ export const getQuestionSummary = async (
summary.push({
type: question.type,
question,
question: question,
responseCount: values.length,
samples: values.slice(0, VALUES_LIMIT),
});
@@ -343,9 +369,9 @@ export const getQuestionSummary = async (
values = [];
break;
}
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
let values: TSurveyQuestionSummaryMultipleChoice["choices"] = [];
case TSurveyElementTypeEnum.MultipleChoiceSingle:
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
let values: TSurveyElementSummaryMultipleChoice["choices"] = [];
const otherOption = question.choices.find((choice) => choice.id === "other");
const noneOption = question.choices.find((choice) => choice.id === "none");
@@ -363,7 +389,7 @@ export const getQuestionSummary = async (
const noneLabel = noneOption ? getLocalizedValue(noneOption.label, "default") : null;
let noneCount = 0;
const otherValues: TSurveyQuestionSummaryMultipleChoice["choices"][number]["others"] = [];
const otherValues: TSurveyElementSummaryMultipleChoice["choices"][number]["others"] = [];
let totalSelectionCount = 0;
let totalResponseCount = 0;
responses.forEach((response) => {
@@ -372,11 +398,11 @@ export const getQuestionSummary = async (
const answer =
responseLanguageCode === "default"
? response.data[question.id]
: checkForI18n(response.data, question.id, survey, responseLanguageCode);
: checkForI18n(response.data, question.id, questions, responseLanguageCode);
let hasValidAnswer = false;
if (Array.isArray(answer) && question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
if (Array.isArray(answer) && question.type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
answer.forEach((value) => {
if (value) {
totalSelectionCount++;
@@ -396,7 +422,7 @@ export const getQuestionSummary = async (
});
} else if (
typeof answer === "string" &&
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle
question.type === TSurveyElementTypeEnum.MultipleChoiceSingle
) {
if (answer) {
totalSelectionCount++;
@@ -462,8 +488,8 @@ export const getQuestionSummary = async (
values = [];
break;
}
case TSurveyQuestionTypeEnum.PictureSelection: {
let values: TSurveyQuestionSummaryPictureSelection["choices"] = [];
case TSurveyElementTypeEnum.PictureSelection: {
let values: TSurveyElementSummaryPictureSelection["choices"] = [];
const choiceCountMap: Record<string, number> = {};
question.choices.forEach((choice) => {
@@ -506,8 +532,8 @@ export const getQuestionSummary = async (
values = [];
break;
}
case TSurveyQuestionTypeEnum.Rating: {
let values: TSurveyQuestionSummaryRating["choices"] = [];
case TSurveyElementTypeEnum.Rating: {
let values: TSurveyElementSummaryRating["choices"] = [];
const choiceCountMap: Record<number, number> = {};
const range = question.range;
@@ -532,31 +558,13 @@ export const getQuestionSummary = async (
Object.entries(choiceCountMap).forEach(([label, count]) => {
values.push({
rating: Number.parseInt(label),
rating: parseInt(label),
count,
percentage:
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
});
});
// Calculate CSAT based on range
let satisfiedCount = 0;
if (range === 3) {
satisfiedCount = choiceCountMap[3] || 0;
} else if (range === 4) {
satisfiedCount = (choiceCountMap[3] || 0) + (choiceCountMap[4] || 0);
} else if (range === 5) {
satisfiedCount = (choiceCountMap[4] || 0) + (choiceCountMap[5] || 0);
} else if (range === 6) {
satisfiedCount = (choiceCountMap[5] || 0) + (choiceCountMap[6] || 0);
} else if (range === 7) {
satisfiedCount = (choiceCountMap[6] || 0) + (choiceCountMap[7] || 0);
} else if (range === 10) {
satisfiedCount = (choiceCountMap[8] || 0) + (choiceCountMap[9] || 0) + (choiceCountMap[10] || 0);
}
const satisfiedPercentage =
totalResponseCount > 0 ? Math.round((satisfiedCount / totalResponseCount) * 100) : 0;
summary.push({
type: question.type,
question,
@@ -566,16 +574,12 @@ export const getQuestionSummary = async (
dismissed: {
count: dismissed,
},
csat: {
satisfiedCount,
satisfiedPercentage,
},
});
values = [];
break;
}
case TSurveyQuestionTypeEnum.NPS: {
case TSurveyElementTypeEnum.NPS: {
const data = {
promoters: 0,
passives: 0,
@@ -585,17 +589,10 @@ export const getQuestionSummary = async (
score: 0,
};
// Track individual score counts (0-10)
const scoreCountMap: Record<number, number> = {};
for (let i = 0; i <= 10; i++) {
scoreCountMap[i] = 0;
}
responses.forEach((response) => {
const value = response.data[question.id];
if (typeof value === "number") {
data.total++;
scoreCountMap[value]++;
if (value >= 9) {
data.promoters++;
} else if (value >= 7) {
@@ -614,13 +611,6 @@ export const getQuestionSummary = async (
? convertFloatTo2Decimal(((data.promoters - data.detractors) / data.total) * 100)
: 0;
// Build choices array with individual score breakdown
const choices = Object.entries(scoreCountMap).map(([rating, count]) => ({
rating: Number.parseInt(rating),
count,
percentage: data.total > 0 ? convertFloatTo2Decimal((count / data.total) * 100) : 0,
}));
summary.push({
type: question.type,
question,
@@ -643,11 +633,10 @@ export const getQuestionSummary = async (
count: data.dismissed,
percentage: data.total > 0 ? convertFloatTo2Decimal((data.dismissed / data.total) * 100) : 0,
},
choices,
});
break;
}
case TSurveyQuestionTypeEnum.CTA: {
case TSurveyElementTypeEnum.CTA: {
const data = {
clicked: 0,
dismissed: 0,
@@ -663,7 +652,7 @@ export const getQuestionSummary = async (
});
const totalResponses = data.clicked + data.dismissed;
const idx = survey.questions.findIndex((q) => q.id === question.id);
const idx = questions.findIndex((q) => q.id === question.id);
const impressions = dropOff[idx].impressions;
summary.push({
@@ -680,7 +669,7 @@ export const getQuestionSummary = async (
});
break;
}
case TSurveyQuestionTypeEnum.Consent: {
case TSurveyElementTypeEnum.Consent: {
const data = {
accepted: 0,
dismissed: 0,
@@ -715,8 +704,8 @@ export const getQuestionSummary = async (
break;
}
case TSurveyQuestionTypeEnum.Date: {
let values: TSurveyQuestionSummaryDate["samples"] = [];
case TSurveyElementTypeEnum.Date: {
let values: TSurveyElementSummaryDate["samples"] = [];
responses.forEach((response) => {
const answer = response.data[question.id];
if (answer && typeof answer === "string") {
@@ -740,8 +729,8 @@ export const getQuestionSummary = async (
values = [];
break;
}
case TSurveyQuestionTypeEnum.FileUpload: {
let values: TSurveyQuestionSummaryFileUpload["files"] = [];
case TSurveyElementTypeEnum.FileUpload: {
let values: TSurveyElementSummaryFileUpload["files"] = [];
responses.forEach((response) => {
const answer = response.data[question.id];
if (Array.isArray(answer)) {
@@ -765,7 +754,7 @@ export const getQuestionSummary = async (
values = [];
break;
}
case TSurveyQuestionTypeEnum.Cal: {
case TSurveyElementTypeEnum.Cal: {
const data = {
booked: 0,
skipped: 0,
@@ -798,7 +787,7 @@ export const getQuestionSummary = async (
break;
}
case TSurveyQuestionTypeEnum.Matrix: {
case TSurveyElementTypeEnum.Matrix: {
const rows = question.rows.map((row) => getLocalizedValue(row.label, "default"));
const columns = question.columns.map((column) => getLocalizedValue(column.label, "default"));
let totalResponseCount = 0;
@@ -859,9 +848,8 @@ export const getQuestionSummary = async (
});
break;
}
case TSurveyQuestionTypeEnum.Address:
case TSurveyQuestionTypeEnum.ContactInfo: {
let values: TSurveyQuestionSummaryAddress["samples"] = [];
case TSurveyElementTypeEnum.Address: {
let values: TSurveyElementSummaryAddress["samples"] = [];
responses.forEach((response) => {
const answer = response.data[question.id];
if (Array.isArray(answer) && answer.length > 0) {
@@ -876,8 +864,8 @@ export const getQuestionSummary = async (
});
summary.push({
type: question.type as TSurveyQuestionTypeEnum.ContactInfo,
question: question as TSurveyContactInfoQuestion,
type: TSurveyElementTypeEnum.Address,
question: question as TSurveyAddressElement,
responseCount: values.length,
samples: values.slice(0, VALUES_LIMIT),
});
@@ -885,13 +873,38 @@ export const getQuestionSummary = async (
values = [];
break;
}
case TSurveyQuestionTypeEnum.Ranking: {
let values: TSurveyQuestionSummaryRanking["choices"] = [];
case TSurveyElementTypeEnum.ContactInfo: {
let values: TSurveyElementSummaryContactInfo["samples"] = [];
responses.forEach((response) => {
const answer = response.data[question.id];
if (Array.isArray(answer) && answer.length > 0) {
values.push({
id: response.id,
updatedAt: response.updatedAt,
value: answer,
contact: response.contact,
contactAttributes: response.contactAttributes,
});
}
});
summary.push({
type: TSurveyElementTypeEnum.ContactInfo,
question: question as TSurveyContactInfoElement,
responseCount: values.length,
samples: values.slice(0, VALUES_LIMIT),
});
values = [];
break;
}
case TSurveyElementTypeEnum.Ranking: {
let values: TSurveyElementSummaryRanking["choices"] = [];
const questionChoices = question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
let totalResponseCount = 0;
const choiceRankSums: Record<string, number> = {};
const choiceCountMap: Record<string, number> = {};
questionChoices.forEach((choice) => {
questionChoices.forEach((choice: string) => {
choiceRankSums[choice] = 0;
choiceCountMap[choice] = 0;
});
@@ -902,7 +915,7 @@ export const getQuestionSummary = async (
const answer =
responseLanguageCode === "default"
? response.data[question.id]
: checkForI18n(response.data, question.id, survey, responseLanguageCode);
: checkForI18n(response.data, question.id, questions, responseLanguageCode);
if (Array.isArray(answer)) {
totalResponseCount++;
@@ -916,7 +929,7 @@ export const getQuestionSummary = async (
}
});
questionChoices.forEach((choice) => {
questionChoices.forEach((choice: string) => {
const count = choiceCountMap[choice];
const avgRanking = count > 0 ? choiceRankSums[choice] / count : 0;
values.push({
@@ -939,7 +952,7 @@ export const getQuestionSummary = async (
}
survey.hiddenFields?.fieldIds?.forEach((hiddenFieldId) => {
let values: TSurveyQuestionSummaryHiddenFields["samples"] = [];
let values: TSurveyElementSummaryHiddenFields["samples"] = [];
responses.forEach((response) => {
const answer = response.data[hiddenFieldId];
if (answer && typeof answer === "string") {
@@ -975,6 +988,9 @@ export const getSurveySummary = reactCache(
throw new ResourceNotFoundError("Survey", surveyId);
}
// Derive questions once from blocks
const questions = getElementsFromBlocks(survey.blocks);
const batchSize = 5000;
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
@@ -1005,10 +1021,10 @@ export const getSurveySummary = reactCache(
getQuotasSummary(surveyId),
]);
const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
const dropOff = getSurveySummaryDropOff(survey, questions, responses, displayCount);
const [meta, questionWiseSummary] = await Promise.all([
getSurveySummaryMeta(responses, displayCount, quotas),
getQuestionSummary(survey, responses, dropOff),
getQuestionSummary(survey, questions, responses, dropOff),
]);
return {
@@ -1,5 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { constructToastMessage, convertFloatTo2Decimal, convertFloatToNDecimal } from "./utils";
describe("Utils Tests", () => {
@@ -34,29 +35,40 @@ describe("Utils Tests", () => {
type: "app",
environmentId: "env1",
status: "draft",
questions: [
blocks: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Q2" },
required: false,
choices: [{ id: "c1", label: { default: "Choice 1" } }],
},
{
id: "q3",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Q3" },
required: false,
rows: [{ id: "r1", label: { default: "Row 1" } }],
columns: [{ id: "col1", label: { default: "Col 1" } }],
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
charLimit: { enabled: false },
},
{
id: "q2",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Q2" },
required: false,
choices: [{ id: "c1", label: { default: "Choice 1" } }],
buttonLabel: { default: "Next" },
shuffleOption: "none",
},
{
id: "q3",
type: TSurveyElementTypeEnum.Matrix,
headline: { default: "Q3" },
required: false,
rows: [{ id: "r1", label: { default: "Row 1" } }],
columns: [{ id: "col1", label: { default: "Col 1" } }],
buttonLabel: { default: "Next" },
},
],
},
],
questions: [],
triggers: [],
recontactDays: null,
autoClose: null,
@@ -74,7 +86,7 @@ describe("Utils Tests", () => {
test("should construct message for matrix question type", () => {
const message = constructToastMessage(
TSurveyQuestionTypeEnum.Matrix,
TSurveyElementTypeEnum.Matrix,
"is",
mockSurvey,
"q3",
@@ -95,7 +107,7 @@ describe("Utils Tests", () => {
});
test("should construct message for matrix question type with array filterComboBoxValue", () => {
const message = constructToastMessage(TSurveyQuestionTypeEnum.Matrix, "is", mockSurvey, "q3", mockT, [
const message = constructToastMessage(TSurveyElementTypeEnum.Matrix, "is", mockSurvey, "q3", mockT, [
"MatrixValue1",
"MatrixValue2",
]);
@@ -114,7 +126,7 @@ describe("Utils Tests", () => {
test("should construct message when filterComboBoxValue is undefined (skipped)", () => {
const message = constructToastMessage(
TSurveyQuestionTypeEnum.OpenText,
TSurveyElementTypeEnum.OpenText,
"is skipped",
mockSurvey,
"q1",
@@ -134,7 +146,7 @@ describe("Utils Tests", () => {
test("should construct message for non-matrix question with string filterComboBoxValue", () => {
const message = constructToastMessage(
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
TSurveyElementTypeEnum.MultipleChoiceSingle,
"is",
mockSurvey,
"q2",
@@ -156,7 +168,7 @@ describe("Utils Tests", () => {
test("should construct message for non-matrix question with array filterComboBoxValue", () => {
const message = constructToastMessage(
TSurveyQuestionTypeEnum.MultipleChoiceMulti,
TSurveyElementTypeEnum.MultipleChoiceMulti,
"includes all of",
mockSurvey,
"q2", // Assuming q2 can be multi for this test case logic
@@ -178,7 +190,7 @@ describe("Utils Tests", () => {
test("should handle questionId not found in survey", () => {
const message = constructToastMessage(
TSurveyQuestionTypeEnum.OpenText,
TSurveyElementTypeEnum.OpenText,
"is",
mockSurvey,
"qNonExistent",
@@ -1,5 +1,7 @@
import { TFunction } from "i18next";
import { TSurvey, TSurveyQuestionId, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
export const convertFloatToNDecimal = (num: number, N: number = 2) => {
return Math.round(num * Math.pow(10, N)) / Math.pow(10, N);
@@ -10,14 +12,16 @@ export const convertFloatTo2Decimal = (num: number) => {
};
export const constructToastMessage = (
questionType: TSurveyQuestionTypeEnum,
questionType: TSurveyElementTypeEnum,
filterValue: string,
survey: TSurvey,
questionId: TSurveyQuestionId,
t: TFunction,
filterComboBoxValue?: string | string[]
) => {
const questionIdx = survey.questions.findIndex((question) => question.id === questionId);
// Derive questions from blocks
const questions = getElementsFromBlocks(survey.blocks);
const questionIdx = questions.findIndex((question) => question.id === questionId);
if (questionType === "matrix") {
return t("environments.surveys.summary.added_filter_for_responses_where_answer_to_question", {
questionIdx: questionIdx + 1,
@@ -70,6 +70,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
user={user}
publicDomain={publicDomain}
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
displayCount={initialSurveySummary?.meta.displayCount ?? 0}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
@@ -29,7 +29,7 @@ import {
} from "lucide-react";
import { Fragment, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
import { Button } from "@/modules/ui/components/button";
@@ -55,7 +55,7 @@ export enum OptionsType {
export type QuestionOption = {
label: string;
questionType?: TSurveyQuestionTypeEnum;
questionType?: TSurveyElementTypeEnum;
type: OptionsType;
id: string;
};
@@ -72,18 +72,18 @@ interface QuestionComboBoxProps {
const questionIcons = {
// questions
[TSurveyQuestionTypeEnum.OpenText]: MessageSquareTextIcon,
[TSurveyQuestionTypeEnum.Rating]: StarIcon,
[TSurveyQuestionTypeEnum.CTA]: MousePointerClickIcon,
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ListIcon,
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: Rows3Icon,
[TSurveyQuestionTypeEnum.NPS]: NetPromoterScoreIcon,
[TSurveyQuestionTypeEnum.Consent]: CheckIcon,
[TSurveyQuestionTypeEnum.PictureSelection]: ImageIcon,
[TSurveyQuestionTypeEnum.Matrix]: GridIcon,
[TSurveyQuestionTypeEnum.Ranking]: ListOrderedIcon,
[TSurveyQuestionTypeEnum.Address]: HomeIcon,
[TSurveyQuestionTypeEnum.ContactInfo]: ContactIcon,
[TSurveyElementTypeEnum.OpenText]: MessageSquareTextIcon,
[TSurveyElementTypeEnum.Rating]: StarIcon,
[TSurveyElementTypeEnum.CTA]: MousePointerClickIcon,
[TSurveyElementTypeEnum.MultipleChoiceMulti]: ListIcon,
[TSurveyElementTypeEnum.MultipleChoiceSingle]: Rows3Icon,
[TSurveyElementTypeEnum.NPS]: NetPromoterScoreIcon,
[TSurveyElementTypeEnum.Consent]: CheckIcon,
[TSurveyElementTypeEnum.PictureSelection]: ImageIcon,
[TSurveyElementTypeEnum.Matrix]: GridIcon,
[TSurveyElementTypeEnum.Ranking]: ListOrderedIcon,
[TSurveyElementTypeEnum.Address]: HomeIcon,
[TSurveyElementTypeEnum.ContactInfo]: ContactIcon,
// attributes
[OptionsType.ATTRIBUTES]: User,
@@ -4,7 +4,8 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import {
SelectedFilterValue,
TResponseStatus,
@@ -25,7 +26,7 @@ import {
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
export type QuestionFilterOptions = {
type: TSurveyQuestionTypeEnum | "Attributes" | "Tags" | "Languages" | "Quotas";
type: TSurveyElementTypeEnum | "Attributes" | "Tags" | "Languages" | "Quotas";
filterOptions: string[];
filterComboBoxOptions: string[];
id: string;
@@ -217,13 +218,11 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
setFilterValue(selectedFilter);
}, [selectedFilter]);
const activeFilterCount = filterValue.filter.length + (filterValue.responseStatus === "all" ? 0 : 1);
return (
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<PopoverTriggerButton isOpen={isOpen}>
Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b>
</PopoverTriggerButton>
</PopoverTrigger>
<PopoverContent
@@ -23,12 +23,8 @@ import {
TIntegrationSlackCredential,
} from "@formbricks/types/integration/slack";
import { TResponse, TResponseMeta } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyOpenTextQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { writeData as airtableWriteData } from "@/lib/airtable/service";
import { writeData as googleSheetWriteData } from "@/lib/googleSheet/service";
@@ -101,33 +97,47 @@ const mockPipelineInput = {
const mockSurvey = {
id: surveyId,
name: "Test Survey",
questions: [
blocks: [
{
id: questionId1,
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1 {{recall:q2}}" },
required: true,
} as unknown as TSurveyOpenTextQuestion,
{
id: questionId2,
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Question 2" },
required: true,
choices: [
{ id: "choice1", label: { default: "Choice 1" } },
{ id: "choice2", label: { default: "Choice 2" } },
id: "block1",
name: "Block 1",
elements: [
{
id: questionId1,
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Question 1 {{recall:q2}}" },
required: true,
inputType: "text",
charLimit: 1000,
subheader: { default: "" },
placeholder: { default: "" },
},
{
id: questionId2,
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Question 2" },
required: true,
choices: [
{ id: "choice1", label: { default: "Choice 1" } },
{ id: "choice2", label: { default: "Choice 2" } },
],
shuffleOption: "none",
subheader: { default: "" },
},
{
id: questionId3,
type: TSurveyElementTypeEnum.PictureSelection,
headline: { default: "Question 3" },
required: true,
choices: [
{ id: "picChoice1", imageUrl: "http://image.com/1" },
{ id: "picChoice2", imageUrl: "http://image.com/2" },
],
allowMultiple: false,
subheader: { default: "" },
},
],
},
{
id: questionId3,
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: { default: "Question 3" },
required: true,
choices: [
{ id: "picChoice1", imageUrl: "http://image.com/1" },
{ id: "picChoice2", imageUrl: "http://image.com/2" },
],
} as unknown as TSurveyPictureSelectionQuestion,
],
hiddenFields: {
enabled: true,
@@ -6,7 +6,8 @@ import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-s
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { TResponseMeta } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { writeData as airtableWriteData } from "@/lib/airtable/service";
@@ -16,6 +17,7 @@ import { getLocalizedValue } from "@/lib/i18n/utils";
import { writeData as writeNotionData } from "@/lib/notion/service";
import { processResponseData } from "@/lib/responses";
import { writeDataToSlack } from "@/lib/slack/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { parseRecallInfo } from "@/lib/utils/recall";
import { truncateText } from "@/lib/utils/strings";
@@ -236,6 +238,9 @@ const extractResponses = async (
const responses: string[] = [];
const questions: string[] = [];
// Derive questions from blocks
const surveyQuestions = getElementsFromBlocks(survey.blocks);
for (const questionId of questionIds) {
//check for hidden field Ids
if (survey.hiddenFields.fieldIds?.includes(questionId)) {
@@ -243,7 +248,7 @@ const extractResponses = async (
questions.push(questionId);
continue;
}
const question = survey?.questions.find((q) => q.id === questionId);
const question = surveyQuestions.find((q) => q.id === questionId);
if (!question) {
continue;
}
@@ -252,7 +257,7 @@ const extractResponses = async (
if (responseValue !== undefined) {
let answer: typeof responseValue;
if (question.type === TSurveyQuestionTypeEnum.PictureSelection) {
if (question.type === TSurveyElementTypeEnum.PictureSelection) {
const selectedChoiceIds = responseValue as string[];
answer = question?.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
@@ -321,14 +326,17 @@ const buildNotionPayloadProperties = (
const properties: any = {};
const responses = data.response.data;
// Derive questions from blocks
const surveyQuestions = getElementsFromBlocks(surveyData.blocks);
const mappingQIds = mapping
.filter((m) => m.question.type === TSurveyQuestionTypeEnum.PictureSelection)
.filter((m) => m.question.type === TSurveyElementTypeEnum.PictureSelection)
.map((m) => m.question.id);
Object.keys(responses).forEach((resp) => {
if (mappingQIds.find((qId) => qId === resp)) {
const selectedChoiceIds = responses[resp] as string[];
const pictureQuestion = surveyData.questions.find((q) => q.id === resp);
const pictureQuestion = surveyQuestions.find((q) => q.id === resp);
responses[resp] = (pictureQuestion as any)?.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
@@ -92,6 +92,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
welcomeCard: true,
name: true,
questions: true,
blocks: true,
variables: true,
type: true,
showLanguageSwitch: true,
@@ -1,6 +1,5 @@
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironmentState } from "@/app/api/v1/client/[environmentId]/environment/lib/environmentState";
import { responses } from "@/app/lib/api/response";
@@ -29,38 +28,15 @@ export const GET = withV1ApiWrapper({
const params = await props.params;
try {
// Basic type check for environmentId
// Simple validation for environmentId (faster than Zod for high-frequency endpoint)
if (typeof params.environmentId !== "string") {
return {
response: responses.badRequestResponse("Environment ID is required", undefined, true),
};
}
const environmentId = params.environmentId.trim();
// Validate CUID v1 format using Zod (matches Prisma schema @default(cuid()))
// This catches all invalid formats including:
// - null/undefined passed as string "null" or "undefined"
// - HTML-encoded placeholders like <environmentId> or %3C...%3E
// - Empty or whitespace-only IDs
// - Any other invalid CUID v1 format
const cuidValidation = ZEnvironmentId.safeParse(environmentId);
if (!cuidValidation.success) {
logger.warn(
{
environmentId: params.environmentId,
url: req.url,
validationError: cuidValidation.error.errors[0]?.message,
},
"Invalid CUID v1 format detected"
);
return {
response: responses.badRequestResponse("Invalid environment ID format", undefined, true),
};
}
// Use optimized environment state fetcher with new caching approach
const environmentState = await getEnvironmentState(environmentId);
const environmentState = await getEnvironmentState(params.environmentId);
const { data } = environmentState;
return {
@@ -70,12 +46,12 @@ export const GET = withV1ApiWrapper({
expiresAt: new Date(Date.now() + 1000 * 60 * 60), // 1 hour for SDK to recheck
},
true,
// Cache headers aligned with Redis cache TTL (1 minute)
// max-age=60: 1min browser cache
// s-maxage=60: 1min Cloudflare CDN cache
// stale-while-revalidate=60: 1min stale serving during revalidation
// stale-if-error=60: 1min stale serving on origin errors
"public, s-maxage=60, max-age=60, stale-while-revalidate=60, stale-if-error=60"
// Optimized cache headers for Cloudflare CDN and browser caching
// max-age=3600: 1hr browser cache (per guidelines)
// s-maxage=1800: 30min Cloudflare cache (per guidelines)
// stale-while-revalidate=1800: 30min stale serving during revalidation
// stale-if-error=3600: 1hr stale serving on origin errors
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
),
};
} catch (err) {
@@ -2,7 +2,7 @@ import { headers } from "next/headers";
import { NextRequest } from "next/server";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
@@ -51,7 +51,7 @@ export const POST = withV1ApiWrapper({
}
const { environmentId } = params;
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
const environmentIdValidation = ZId.safeParse(environmentId);
const responseInputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
@@ -1,7 +1,7 @@
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { ZId } from "@formbricks/types/common";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
@@ -10,6 +10,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/question";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
@@ -43,7 +44,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
}
const { environmentId } = params;
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
const environmentIdValidation = ZId.safeParse(environmentId);
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
@@ -91,7 +92,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: responseInputData.data,
surveyQuestions: survey.questions,
surveyQuestions: getElementsFromBlocks(survey.blocks),
responseLanguage: responseInputData.language,
});
+308
View File
@@ -0,0 +1,308 @@
import { createId } from "@paralleldrive/cuid2";
import type { TFunction } from "i18next";
import type { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import type {
TSurveyCTAElement,
TSurveyConsentElement,
TSurveyElement,
TSurveyMultipleChoiceElement,
TSurveyNPSElement,
TSurveyOpenTextElement,
TSurveyOpenTextElementInputType,
TSurveyRatingElement,
} from "@formbricks/types/surveys/elements";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import type { TShuffleOption } from "@formbricks/types/surveys/types";
import { createI18nString } from "@/lib/i18n/utils";
const getDefaultButtonLabel = (label: string | undefined, t: TFunction) =>
createI18nString(label || t("common.next"), []);
const getDefaultBackButtonLabel = (label: string | undefined, t: TFunction) =>
createI18nString(label || t("common.back"), []);
export const buildMultipleChoiceElement = ({
id,
headline,
type,
subheader,
choices,
choiceIds,
shuffleOption,
required,
containsOther = false,
}: {
id?: string;
headline: string;
type: TSurveyElementTypeEnum.MultipleChoiceMulti | TSurveyElementTypeEnum.MultipleChoiceSingle;
subheader?: string;
choices: string[];
choiceIds?: string[];
shuffleOption?: TShuffleOption;
required?: boolean;
containsOther?: boolean;
}): TSurveyMultipleChoiceElement => {
return {
id: id ?? createId(),
type,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
choices: choices.map((choice, index) => {
const isLastIndex = index === choices.length - 1;
let choiceId: string;
if (containsOther && isLastIndex) {
choiceId = "other";
} else if (choiceIds) {
choiceId = choiceIds[index];
} else {
choiceId = createId();
}
return { id: choiceId, label: createI18nString(choice, []) };
}),
shuffleOption: shuffleOption || "none",
required: required ?? false,
};
};
export const buildOpenTextElement = ({
id,
headline,
subheader,
placeholder,
inputType,
required,
longAnswer,
}: {
id?: string;
headline: string;
subheader?: string;
placeholder?: string;
required?: boolean;
inputType: TSurveyOpenTextElementInputType;
longAnswer?: boolean;
}): TSurveyOpenTextElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.OpenText,
inputType,
subheader: subheader ? createI18nString(subheader, []) : undefined,
placeholder: placeholder ? createI18nString(placeholder, []) : undefined,
headline: createI18nString(headline, []),
required: required ?? false,
longAnswer,
charLimit: {
enabled: false,
},
};
};
export const buildRatingElement = ({
id,
headline,
subheader,
scale,
range,
lowerLabel,
upperLabel,
required,
isColorCodingEnabled = false,
}: {
id?: string;
headline: string;
scale: TSurveyRatingElement["scale"];
range: TSurveyRatingElement["range"];
lowerLabel?: string;
upperLabel?: string;
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,
};
};
export const buildConsentElement = ({
id,
headline,
subheader,
label,
required,
}: {
id?: string;
headline: string;
subheader: string;
required?: boolean;
label: string;
}): TSurveyConsentElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.Consent,
subheader: createI18nString(subheader, []),
headline: createI18nString(headline, []),
required: required ?? false,
label: createI18nString(label, []),
};
};
export const buildCTAElement = ({
id,
headline,
subheader,
buttonExternal,
required,
dismissButtonLabel,
buttonUrl,
}: {
id?: string;
headline: string;
buttonExternal: boolean;
subheader: string;
required?: boolean;
dismissButtonLabel?: string;
buttonUrl?: string;
}): TSurveyCTAElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.CTA,
subheader: createI18nString(subheader, []),
headline: createI18nString(headline, []),
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
required: required ?? false,
buttonExternal,
buttonUrl,
};
};
export const buildNPSElement = ({
id,
headline,
subheader,
lowerLabel,
upperLabel,
required,
isColorCodingEnabled = false,
}: {
id?: string;
headline: string;
subheader?: string;
lowerLabel?: string;
upperLabel?: string;
required?: boolean;
isColorCodingEnabled?: boolean;
}): TSurveyNPSElement => {
return {
id: id ?? createId(),
type: TSurveyElementTypeEnum.NPS,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
};
};
// Helper function to create block-level jump logic based on operator
export const createBlockJumpLogic = (
sourceElementId: string,
targetBlockId: string,
operator: "isSkipped" | "isSubmitted" | "isClicked"
): TSurveyBlockLogic => ({
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: sourceElementId,
type: "question",
},
operator: operator,
},
],
},
actions: [
{
id: createId(),
objective: "jumpToBlock",
target: targetBlockId,
},
],
});
// Helper function to create block-level jump logic based on choice selection
export const createBlockChoiceJumpLogic = (
sourceElementId: string,
choiceId: string | number,
targetBlockId: string
): TSurveyBlockLogic => ({
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: sourceElementId,
type: "question",
},
operator: "equals",
rightOperand: {
type: "static",
value: choiceId,
},
},
],
},
actions: [
{
id: createId(),
objective: "jumpToBlock",
target: targetBlockId,
},
],
});
// Block builder function
export const buildBlock = ({
id,
name,
elements,
logic,
logicFallback,
buttonLabel,
backButtonLabel,
t,
}: {
id?: string;
name: string;
elements: TSurveyElement[];
logic?: TSurveyBlockLogic[];
logicFallback?: string;
buttonLabel?: string;
backButtonLabel?: string;
t: TFunction;
}): TSurveyBlock => {
return {
id: id ?? createId(),
name,
elements,
logic,
logicFallback,
buttonLabel: buttonLabel ? getDefaultButtonLabel(buttonLabel, t) : undefined,
backButtonLabel: backButtonLabel ? getDefaultBackButtonLabel(backButtonLabel, t) : undefined,
};
};
+60 -583
View File
@@ -1,15 +1,6 @@
import { describe, expect, test } from "vitest";
import { TShuffleOption, TSurveyLogic, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
buildCTAQuestion,
buildConsentQuestion,
buildMultipleChoiceQuestion,
buildNPSQuestion,
buildOpenTextQuestion,
buildRatingQuestion,
buildSurvey,
createChoiceJumpLogic,
createJumpLogic,
getDefaultEndingCard,
getDefaultSurveyPreset,
getDefaultWelcomeCard,
@@ -19,595 +10,81 @@ import {
const mockT = (props: any): string => (typeof props === "string" ? props : props.key);
describe("Survey Builder", () => {
describe("buildMultipleChoiceQuestion", () => {
test("creates a single choice question with required fields", () => {
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: ["Option 1", "Option 2", "Option 3"],
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Test Question" },
choices: expect.arrayContaining([
expect.objectContaining({ label: { default: "Option 1" } }),
expect.objectContaining({ label: { default: "Option 2" } }),
expect.objectContaining({ label: { default: "Option 3" } }),
]),
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
shuffleOption: "none",
required: false,
});
expect(question.choices.length).toBe(3);
expect(question.id).toBeDefined();
describe("Helper Functions", () => {
test("getDefaultSurveyPreset returns expected default survey preset", () => {
const preset = getDefaultSurveyPreset(mockT);
expect(preset.name).toBe("New Survey");
// test welcomeCard and endings
expect(preset.welcomeCard).toHaveProperty("headline");
expect(preset.endings).toHaveLength(1);
expect(preset.endings[0]).toHaveProperty("headline");
expect(preset.hiddenFields).toEqual(hiddenFieldsDefault);
expect(preset.blocks).toEqual([]);
});
test("creates a multiple choice question with provided ID", () => {
const customId = "custom-id-123";
const question = buildMultipleChoiceQuestion({
id: customId,
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
choices: ["Option 1", "Option 2"],
t: mockT,
test("getDefaultWelcomeCard returns expected welcome card", () => {
const welcomeCard = getDefaultWelcomeCard(mockT);
expect(welcomeCard).toMatchObject({
enabled: false,
headline: { default: "templates.default_welcome_card_headline" },
timeToFinish: false,
showResponseCount: false,
});
expect(question.id).toBe(customId);
expect(question.type).toBe(TSurveyQuestionTypeEnum.MultipleChoiceMulti);
// Check that the welcome card is properly structured
expect(welcomeCard).toHaveProperty("enabled");
expect(welcomeCard).toHaveProperty("headline");
expect(welcomeCard).toHaveProperty("showResponseCount");
expect(welcomeCard).toHaveProperty("timeToFinish");
});
test("handles 'other' option correctly", () => {
const choices = ["Option 1", "Option 2", "Other"];
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices,
containsOther: true,
t: mockT,
test("getDefaultEndingCard returns expected ending card", () => {
const languages: string[] = [];
const endingCard = getDefaultEndingCard(languages, mockT);
expect(endingCard).toMatchObject({
type: "endScreen",
headline: { default: "templates.default_ending_card_headline" },
subheader: { default: "templates.default_ending_card_subheader" },
});
expect(question.choices.length).toBe(3);
expect(question.choices[2].id).toBe("other");
expect(endingCard.id).toBeDefined();
expect(endingCard).toHaveProperty("buttonLabel");
expect(endingCard).toHaveProperty("buttonLink");
});
test("uses provided choice IDs when available", () => {
const choiceIds = ["id1", "id2", "id3"];
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: ["Option 1", "Option 2", "Option 3"],
choiceIds,
t: mockT,
test("hiddenFieldsDefault has expected structure", () => {
expect(hiddenFieldsDefault).toMatchObject({
enabled: true,
fieldIds: [],
});
expect(question.choices[0].id).toBe(choiceIds[0]);
expect(question.choices[1].id).toBe(choiceIds[1]);
expect(question.choices[2].id).toBe(choiceIds[2]);
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
test("buildSurvey returns built survey with overridden preset properties", () => {
const config = {
name: "Custom Survey",
role: "productManager" as const,
industries: ["saas" as const],
channels: ["link" as const],
description: "A custom survey description",
blocks: [],
endings: [getDefaultEndingCard([], mockT)],
hiddenFields: hiddenFieldsDefault,
};
const shuffleOption: TShuffleOption = "all";
const survey = buildSurvey(config, mockT);
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
subheader: "This is a subheader",
choices: ["Option 1", "Option 2"],
buttonLabel: "Custom Next",
backButtonLabel: "Custom Back",
shuffleOption,
required: false,
logic,
t: mockT,
});
// role, industries, channels, description
expect(survey.role).toBe(config.role);
expect(survey.industries).toEqual(config.industries);
expect(survey.channels).toEqual(config.channels);
expect(survey.description).toBe(config.description);
expect(question.subheader).toEqual({ default: "This is a subheader" });
expect(question.buttonLabel).toEqual({ default: "Custom Next" });
expect(question.backButtonLabel).toEqual({ default: "Custom Back" });
expect(question.shuffleOption).toBe("all");
expect(question.required).toBe(false);
expect(question.logic).toBe(logic);
});
});
// preset overrides
expect(survey.preset.name).toBe(config.name);
expect(survey.preset.endings).toEqual(config.endings);
expect(survey.preset.hiddenFields).toEqual(config.hiddenFields);
expect(survey.preset.blocks).toEqual(config.blocks);
describe("buildOpenTextQuestion", () => {
test("creates an open text question with required fields", () => {
const question = buildOpenTextQuestion({
headline: "Open Question",
inputType: "text",
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Question" },
inputType: "text",
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
charLimit: {
enabled: false,
},
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildOpenTextQuestion({
id: "custom-id",
headline: "Open Question",
subheader: "Answer this question",
placeholder: "Type here",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
longAnswer: true,
inputType: "email",
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "Answer this question" });
expect(question.placeholder).toEqual({ default: "Type here" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.longAnswer).toBe(true);
expect(question.inputType).toBe("email");
expect(question.logic).toBe(logic);
});
});
describe("buildRatingQuestion", () => {
test("creates a rating question with required fields", () => {
const question = buildRatingQuestion({
headline: "Rating Question",
scale: "number",
range: 5,
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rating Question" },
scale: "number",
range: 5,
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
isColorCodingEnabled: false,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildRatingQuestion({
id: "custom-id",
headline: "Rating Question",
subheader: "Rate us",
scale: "star",
range: 10,
lowerLabel: "Poor",
upperLabel: "Excellent",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
isColorCodingEnabled: true,
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "Rate us" });
expect(question.scale).toBe("star");
expect(question.range).toBe(10);
expect(question.lowerLabel).toEqual({ default: "Poor" });
expect(question.upperLabel).toEqual({ default: "Excellent" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.isColorCodingEnabled).toBe(true);
expect(question.logic).toBe(logic);
});
});
describe("buildNPSQuestion", () => {
test("creates an NPS question with required fields", () => {
const question = buildNPSQuestion({
headline: "NPS Question",
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "NPS Question" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
isColorCodingEnabled: false,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildNPSQuestion({
id: "custom-id",
headline: "NPS Question",
subheader: "How likely are you to recommend us?",
lowerLabel: "Not likely",
upperLabel: "Very likely",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
isColorCodingEnabled: true,
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "How likely are you to recommend us?" });
expect(question.lowerLabel).toEqual({ default: "Not likely" });
expect(question.upperLabel).toEqual({ default: "Very likely" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.isColorCodingEnabled).toBe(true);
expect(question.logic).toBe(logic);
});
});
describe("buildConsentQuestion", () => {
test("creates a consent question with required fields", () => {
const question = buildConsentQuestion({
headline: "Consent Question",
subheader: "",
label: "I agree to terms",
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.Consent,
headline: { default: "Consent Question" },
subheader: { default: "" },
label: { default: "I agree to terms" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildConsentQuestion({
id: "custom-id",
headline: "Consent Question",
subheader: "Please read the terms",
label: "I agree to terms",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "Please read the terms" });
expect(question.label).toEqual({ default: "I agree to terms" });
expect(question.buttonLabel).toEqual({ default: "Submit" });
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.logic).toBe(logic);
});
});
describe("buildCTAQuestion", () => {
test("creates a CTA question with required fields", () => {
const question = buildCTAQuestion({
headline: "CTA Question",
subheader: "",
buttonExternal: false,
t: mockT,
});
expect(question).toMatchObject({
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA Question" },
subheader: { default: "" },
buttonLabel: { default: "common.next" },
backButtonLabel: { default: "common.back" },
required: false,
buttonExternal: false,
});
expect(question.id).toBeDefined();
});
test("applies all optional parameters correctly", () => {
const logic: TSurveyLogic[] = [
{
id: "logic-1",
conditions: {
id: "cond-1",
connector: "and",
conditions: [],
},
actions: [],
},
];
const question = buildCTAQuestion({
id: "custom-id",
headline: "CTA Question",
subheader: "<p>Click the button</p>",
buttonLabel: "Click me",
buttonExternal: true,
buttonUrl: "https://example.com",
backButtonLabel: "Previous",
required: false,
dismissButtonLabel: "No thanks",
logic,
t: mockT,
});
expect(question.id).toBe("custom-id");
expect(question.subheader).toEqual({ default: "<p>Click the button</p>" });
expect(question.buttonLabel).toEqual({ default: "Click me" });
expect(question.buttonExternal).toBe(true);
expect(question.buttonUrl).toBe("https://example.com");
expect(question.backButtonLabel).toEqual({ default: "Previous" });
expect(question.required).toBe(false);
expect(question.dismissButtonLabel).toEqual({ default: "No thanks" });
expect(question.logic).toBe(logic);
});
test("handles external button with URL", () => {
const question = buildCTAQuestion({
headline: "CTA Question",
subheader: "",
buttonExternal: true,
buttonUrl: "https://formbricks.com",
t: mockT,
});
expect(question.buttonExternal).toBe(true);
expect(question.buttonUrl).toBe("https://formbricks.com");
});
});
// Test combinations of parameters for edge cases
describe("Edge cases", () => {
test("multiple choice question with empty choices array", () => {
const question = buildMultipleChoiceQuestion({
headline: "Test Question",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [],
t: mockT,
});
expect(question.choices).toEqual([]);
});
test("open text question with all parameters", () => {
const question = buildOpenTextQuestion({
id: "custom-id",
headline: "Open Question",
subheader: "Answer this question",
placeholder: "Type here",
buttonLabel: "Submit",
backButtonLabel: "Previous",
required: false,
longAnswer: true,
inputType: "email",
logic: [],
t: mockT,
});
expect(question).toMatchObject({
id: "custom-id",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Question" },
subheader: { default: "Answer this question" },
placeholder: { default: "Type here" },
buttonLabel: { default: "Submit" },
backButtonLabel: { default: "Previous" },
required: false,
longAnswer: true,
inputType: "email",
logic: [],
});
// default values from getDefaultSurveyPreset
expect(survey.preset.welcomeCard).toHaveProperty("headline");
});
});
});
describe("Helper Functions", () => {
test("createJumpLogic returns valid jump logic", () => {
const sourceId = "q1";
const targetId = "q2";
const operator: "isClicked" = "isClicked";
const logic = createJumpLogic(sourceId, targetId, operator);
// Check structure
expect(logic).toHaveProperty("id");
expect(logic).toHaveProperty("conditions");
expect(logic.conditions).toHaveProperty("conditions");
expect(Array.isArray(logic.conditions.conditions)).toBe(true);
// Check one of the inner conditions
const condition = logic.conditions.conditions[0];
// Need to use type checking to ensure condition is a TSingleCondition not a TConditionGroup
if (!("connector" in condition)) {
expect(condition.leftOperand.value).toBe(sourceId);
expect(condition.operator).toBe(operator);
}
// Check actions
expect(Array.isArray(logic.actions)).toBe(true);
const action = logic.actions[0];
if (action.objective === "jumpToQuestion") {
expect(action.target).toBe(targetId);
}
});
test("createChoiceJumpLogic returns valid jump logic based on choice selection", () => {
const sourceId = "q1";
const choiceId = "choice1";
const targetId = "q2";
const logic = createChoiceJumpLogic(sourceId, choiceId, targetId);
expect(logic).toHaveProperty("id");
expect(logic.conditions).toHaveProperty("conditions");
const condition = logic.conditions.conditions[0];
if (!("connector" in condition)) {
expect(condition.leftOperand.value).toBe(sourceId);
expect(condition.operator).toBe("equals");
expect(condition.rightOperand?.value).toBe(choiceId);
}
const action = logic.actions[0];
if (action.objective === "jumpToQuestion") {
expect(action.target).toBe(targetId);
}
});
test("getDefaultWelcomeCard returns expected welcome card", () => {
const card = getDefaultWelcomeCard(mockT);
expect(card.enabled).toBe(false);
expect(card.headline).toEqual({ default: "templates.default_welcome_card_headline" });
expect(card.subheader).toEqual({ default: "templates.default_welcome_card_html" });
expect(card.buttonLabel).toEqual({ default: "templates.default_welcome_card_button_label" });
// boolean flags
expect(card.timeToFinish).toBe(false);
expect(card.showResponseCount).toBe(false);
});
test("getDefaultEndingCard returns expected end screen card", () => {
// Pass empty languages array to simulate no languages
const card = getDefaultEndingCard([], mockT);
expect(card).toHaveProperty("id");
expect(card.type).toBe("endScreen");
expect(card.headline).toEqual({ default: "templates.default_ending_card_headline" });
expect(card.subheader).toEqual({ default: "templates.default_ending_card_subheader" });
expect(card.buttonLabel).toEqual({ default: "templates.default_ending_card_button_label" });
expect(card.buttonLink).toBe("https://formbricks.com");
});
test("getDefaultSurveyPreset returns expected default survey preset", () => {
const preset = getDefaultSurveyPreset(mockT);
expect(preset.name).toBe("New Survey");
expect(preset.questions).toEqual([]);
// test welcomeCard and endings
expect(preset.welcomeCard).toHaveProperty("headline");
expect(Array.isArray(preset.endings)).toBe(true);
expect(preset.hiddenFields).toEqual(hiddenFieldsDefault);
});
test("buildSurvey returns built survey with overridden preset properties", () => {
const config = {
name: "Custom Survey",
industries: ["eCommerce"] as string[],
channels: ["link"],
description: "Test survey",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText, // changed from "OpenText"
headline: { default: "Question 1" },
inputType: "text",
buttonLabel: { default: "Next" },
backButtonLabel: { default: "Back" },
required: true,
},
],
endings: [
{
id: "end1",
type: "endScreen",
headline: { default: "End Screen" },
subheader: { default: "Thanks" },
buttonLabel: { default: "Finish" },
buttonLink: "https://formbricks.com",
},
],
hiddenFields: { enabled: false, fieldIds: ["f1"] },
};
const survey = buildSurvey(config as any, mockT);
expect(survey.name).toBe(config.name);
expect(survey.industries).toEqual(config.industries);
expect(survey.channels).toEqual(config.channels);
expect(survey.description).toBe(config.description);
// preset overrides
expect(survey.preset.name).toBe(config.name);
expect(survey.preset.questions).toEqual(config.questions);
expect(survey.preset.endings).toEqual(config.endings);
expect(survey.preset.hiddenFields).toEqual(config.hiddenFields);
});
test("hiddenFieldsDefault has expected default configuration", () => {
expect(hiddenFieldsDefault).toEqual({ enabled: true, fieldIds: [] });
});
});
+10 -277
View File
@@ -1,284 +1,17 @@
import { createId } from "@paralleldrive/cuid2";
import { TFunction } from "i18next";
import {
TShuffleOption,
TSurveyCTAQuestion,
TSurveyConsentQuestion,
import type { TFunction } from "i18next";
import type { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import type {
TSurveyEndScreenCard,
TSurveyEnding,
TSurveyHiddenFields,
TSurveyLanguage,
TSurveyLogic,
TSurveyMultipleChoiceQuestion,
TSurveyNPSQuestion,
TSurveyOpenTextQuestion,
TSurveyOpenTextQuestionInputType,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyRatingQuestion,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
import type { TTemplate, TTemplateRole } from "@formbricks/types/templates";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
const getDefaultButtonLabel = (label: string | undefined, t: TFunction) =>
createI18nString(label || t("common.next"), []);
const getDefaultBackButtonLabel = (label: string | undefined, t: TFunction) =>
createI18nString(label || t("common.back"), []);
export const buildMultipleChoiceQuestion = ({
id,
headline,
type,
subheader,
choices,
choiceIds,
buttonLabel,
backButtonLabel,
shuffleOption,
required,
logic,
containsOther = false,
t,
}: {
id?: string;
headline: string;
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti | TSurveyQuestionTypeEnum.MultipleChoiceSingle;
subheader?: string;
choices: string[];
choiceIds?: string[];
buttonLabel?: string;
backButtonLabel?: string;
shuffleOption?: TShuffleOption;
required?: boolean;
logic?: TSurveyLogic[];
containsOther?: boolean;
t: TFunction;
}): TSurveyMultipleChoiceQuestion => {
return {
id: id ?? createId(),
type,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
choices: choices.map((choice, index) => {
const isLastIndex = index === choices.length - 1;
const id = containsOther && isLastIndex ? "other" : choiceIds ? choiceIds[index] : createId();
return { id, label: createI18nString(choice, []) };
}),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
shuffleOption: shuffleOption || "none",
required: required ?? false,
logic,
};
};
export const buildOpenTextQuestion = ({
id,
headline,
subheader,
placeholder,
inputType,
buttonLabel,
backButtonLabel,
required,
logic,
longAnswer,
t,
}: {
id?: string;
headline: string;
subheader?: string;
placeholder?: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
inputType: TSurveyOpenTextQuestionInputType;
longAnswer?: boolean;
t: TFunction;
}): TSurveyOpenTextQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.OpenText,
inputType,
subheader: subheader ? createI18nString(subheader, []) : undefined,
placeholder: placeholder ? createI18nString(placeholder, []) : undefined,
headline: createI18nString(headline, []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
longAnswer,
logic,
charLimit: {
enabled: false,
},
};
};
export const buildRatingQuestion = ({
id,
headline,
subheader,
scale,
range,
lowerLabel,
upperLabel,
buttonLabel,
backButtonLabel,
required,
logic,
isColorCodingEnabled = false,
t,
}: {
id?: string;
headline: string;
scale: TSurveyRatingQuestion["scale"];
range: TSurveyRatingQuestion["range"];
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
placeholder?: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
isColorCodingEnabled?: boolean;
t: TFunction;
}): TSurveyRatingQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.Rating,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
scale,
range,
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
logic,
};
};
export const buildNPSQuestion = ({
id,
headline,
subheader,
lowerLabel,
upperLabel,
buttonLabel,
backButtonLabel,
required,
logic,
isColorCodingEnabled = false,
t,
}: {
id?: string;
headline: string;
lowerLabel?: string;
upperLabel?: string;
subheader?: string;
placeholder?: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
isColorCodingEnabled?: boolean;
t: TFunction;
}): TSurveyNPSQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.NPS,
subheader: subheader ? createI18nString(subheader, []) : undefined,
headline: createI18nString(headline, []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
isColorCodingEnabled,
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
logic,
};
};
export const buildConsentQuestion = ({
id,
headline,
subheader,
label,
buttonLabel,
backButtonLabel,
required,
logic,
t,
}: {
id?: string;
headline: string;
subheader: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
label: string;
t: TFunction;
}): TSurveyConsentQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.Consent,
subheader: createI18nString(subheader, []),
headline: createI18nString(headline, []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
required: required ?? false,
label: createI18nString(label, []),
logic,
};
};
export const buildCTAQuestion = ({
id,
headline,
subheader,
buttonLabel,
buttonExternal,
backButtonLabel,
required,
logic,
dismissButtonLabel,
buttonUrl,
t,
}: {
id?: string;
headline: string;
buttonExternal: boolean;
subheader: string;
buttonLabel?: string;
backButtonLabel?: string;
required?: boolean;
logic?: TSurveyLogic[];
dismissButtonLabel?: string;
buttonUrl?: string;
t: TFunction;
}): TSurveyCTAQuestion => {
return {
id: id ?? createId(),
type: TSurveyQuestionTypeEnum.CTA,
subheader: createI18nString(subheader, []),
headline: createI18nString(headline, []),
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
required: required ?? false,
buttonExternal,
buttonUrl,
logic,
};
};
// Helper function to create standard jump logic based on operator
export const createJumpLogic = (
sourceQuestionId: string,
@@ -377,13 +110,13 @@ export const getDefaultSurveyPreset = (t: TFunction): TTemplate["preset"] => {
welcomeCard: getDefaultWelcomeCard(t),
endings: [getDefaultEndingCard([], t)],
hiddenFields: hiddenFieldsDefault,
questions: [],
blocks: [],
};
};
/**
* Generic builder for survey.
* @param config - The configuration for survey settings and questions.
* @param config - The configuration for survey settings and blocks.
* @param t - The translation function.
*/
export const buildSurvey = (
@@ -393,9 +126,9 @@ export const buildSurvey = (
channels: ("link" | "app" | "website")[];
role: TTemplateRole;
description: string;
questions: TSurveyQuestion[];
endings?: TSurveyEnding[];
hiddenFields?: TSurveyHiddenFields;
blocks: TSurveyBlock[];
endings: TSurveyEnding[];
hiddenFields: TSurveyHiddenFields;
},
t: TFunction
): TTemplate => {
@@ -409,7 +142,7 @@ export const buildSurvey = (
preset: {
...localSurvey,
name: config.name,
questions: config.questions,
blocks: config.blocks ?? [],
endings: config.endings ?? localSurvey.endings,
hiddenFields: config.hiddenFields ?? hiddenFieldsDefault,
},
+228 -143
View File
@@ -2,12 +2,8 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TLanguage } from "@formbricks/types/project";
import {
TSurvey,
TSurveyLanguage,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { type TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import {
DateRange,
@@ -26,13 +22,23 @@ describe("surveys", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [
blocks: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Text Question" },
} as unknown as TSurveyQuestion,
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Open Text Question" },
required: false,
inputType: "text",
charLimit: { enabled: false },
} as TSurveyElement,
],
},
],
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
@@ -51,6 +57,7 @@ describe("surveys", () => {
const survey = {
id: "survey1",
name: "Test Survey",
blocks: [],
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
@@ -74,6 +81,7 @@ describe("surveys", () => {
const survey = {
id: "survey1",
name: "Test Survey",
blocks: [],
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
@@ -97,6 +105,7 @@ describe("surveys", () => {
const survey = {
id: "survey1",
name: "Test Survey",
blocks: [],
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
@@ -120,6 +129,7 @@ describe("surveys", () => {
const survey = {
id: "survey1",
name: "Test Survey",
blocks: [],
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
@@ -145,6 +155,7 @@ describe("surveys", () => {
const survey = {
id: "survey1",
name: "Test Survey",
blocks: [],
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
@@ -164,59 +175,87 @@ describe("surveys", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [
blocks: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Text" },
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Multiple Choice Single" },
choices: [{ id: "c1", label: "Choice 1" }],
} as unknown as TSurveyQuestion,
{
id: "q3",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Multiple Choice Multi" },
choices: [
{ id: "c1", label: "Choice 1" },
{ id: "other", label: "Other" },
],
} as unknown as TSurveyQuestion,
{
id: "q4",
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "NPS" },
} as unknown as TSurveyQuestion,
{
id: "q5",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rating" },
} as unknown as TSurveyQuestion,
{
id: "q6",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
} as unknown as TSurveyQuestion,
{
id: "q7",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: { default: "Picture Selection" },
choices: [
{ id: "p1", imageUrl: "url1" },
{ id: "p2", imageUrl: "url2" },
],
} as unknown as TSurveyQuestion,
{
id: "q8",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix" },
rows: [{ id: "r1", label: "Row 1" }],
columns: [{ id: "c1", label: "Column 1" }],
} as unknown as TSurveyQuestion,
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Open Text" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
{
id: "q2",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Multiple Choice Single" },
required: false,
choices: [{ id: "c1", label: { default: "Choice 1" } }],
shuffleOption: "none",
},
{
id: "q3",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Multiple Choice Multi" },
required: false,
choices: [
{ id: "c1", label: { default: "Choice 1" } },
{ id: "other", label: { default: "Other" } },
],
shuffleOption: "none",
},
{
id: "q4",
type: TSurveyElementTypeEnum.NPS,
headline: { default: "NPS" },
required: false,
lowerLabel: { default: "Not likely" },
upperLabel: { default: "Very likely" },
},
{
id: "q5",
type: TSurveyElementTypeEnum.Rating,
headline: { default: "Rating" },
required: false,
scale: "number",
range: 5,
lowerLabel: { default: "Low" },
upperLabel: { default: "High" },
},
{
id: "q6",
type: TSurveyElementTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonLabel: { default: "Click me" },
buttonExternal: false,
},
{
id: "q7",
type: TSurveyElementTypeEnum.PictureSelection,
headline: { default: "Picture Selection" },
required: false,
allowMultiple: false,
choices: [
{ id: "p1", imageUrl: "url1" },
{ id: "p2", imageUrl: "url2" },
],
},
{
id: "q8",
type: TSurveyElementTypeEnum.Matrix,
headline: { default: "Matrix" },
required: false,
rows: [{ id: "r1", label: { default: "Row 1" } }],
columns: [{ id: "c1", label: { default: "Column 1" } }],
},
] as TSurveyElement[],
},
],
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
@@ -236,6 +275,7 @@ describe("surveys", () => {
const survey = {
id: "survey1",
name: "Test Survey",
blocks: [],
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
@@ -274,76 +314,121 @@ describe("surveys", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [
blocks: [
{
id: "openTextQ",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Text" },
} as unknown as TSurveyQuestion,
{
id: "mcSingleQ",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Multiple Choice Single" },
choices: [{ id: "c1", label: "Choice 1" }],
} as unknown as TSurveyQuestion,
{
id: "mcMultiQ",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Multiple Choice Multi" },
choices: [{ id: "c1", label: "Choice 1" }],
} as unknown as TSurveyQuestion,
{
id: "npsQ",
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "NPS" },
} as unknown as TSurveyQuestion,
{
id: "ratingQ",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rating" },
} as unknown as TSurveyQuestion,
{
id: "ctaQ",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
} as unknown as TSurveyQuestion,
{
id: "consentQ",
type: TSurveyQuestionTypeEnum.Consent,
headline: { default: "Consent" },
} as unknown as TSurveyQuestion,
{
id: "pictureQ",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: { default: "Picture Selection" },
choices: [
{ id: "p1", imageUrl: "url1" },
{ id: "p2", imageUrl: "url2" },
],
} as unknown as TSurveyQuestion,
{
id: "matrixQ",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix" },
rows: [{ id: "r1", label: "Row 1" }],
columns: [{ id: "c1", label: "Column 1" }],
} as unknown as TSurveyQuestion,
{
id: "addressQ",
type: TSurveyQuestionTypeEnum.Address,
headline: { default: "Address" },
} as unknown as TSurveyQuestion,
{
id: "contactQ",
type: TSurveyQuestionTypeEnum.ContactInfo,
headline: { default: "Contact Info" },
} as unknown as TSurveyQuestion,
{
id: "rankingQ",
type: TSurveyQuestionTypeEnum.Ranking,
headline: { default: "Ranking" },
} as unknown as TSurveyQuestion,
id: "block1",
name: "Block 1",
elements: [
{
id: "openTextQ",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Open Text" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
{
id: "mcSingleQ",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Multiple Choice Single" },
required: false,
choices: [{ id: "c1", label: { default: "Choice 1" } }],
shuffleOption: "none",
},
{
id: "mcMultiQ",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Multiple Choice Multi" },
required: false,
choices: [{ id: "c1", label: { default: "Choice 1" } }],
shuffleOption: "none",
},
{
id: "npsQ",
type: TSurveyElementTypeEnum.NPS,
headline: { default: "NPS" },
required: false,
lowerLabel: { default: "Not likely" },
upperLabel: { default: "Very likely" },
},
{
id: "ratingQ",
type: TSurveyElementTypeEnum.Rating,
headline: { default: "Rating" },
required: false,
scale: "number",
range: 5,
lowerLabel: { default: "Low" },
upperLabel: { default: "High" },
},
{
id: "ctaQ",
type: TSurveyElementTypeEnum.CTA,
headline: { default: "CTA" },
required: false,
buttonLabel: { default: "Click me" },
buttonExternal: false,
},
{
id: "consentQ",
type: TSurveyElementTypeEnum.Consent,
headline: { default: "Consent" },
required: false,
label: { default: "I agree" },
},
{
id: "pictureQ",
type: TSurveyElementTypeEnum.PictureSelection,
headline: { default: "Picture Selection" },
required: false,
allowMultiple: false,
choices: [
{ id: "p1", imageUrl: "url1" },
{ id: "p2", imageUrl: "url2" },
],
},
{
id: "matrixQ",
type: TSurveyElementTypeEnum.Matrix,
headline: { default: "Matrix" },
required: false,
rows: [{ id: "r1", label: { default: "Row 1" } }],
columns: [{ id: "c1", label: { default: "Column 1" } }],
},
{
id: "addressQ",
type: TSurveyElementTypeEnum.Address,
headline: { default: "Address" },
required: false,
zip: { show: true, required: false, placeholder: { default: "Zip" } },
city: { show: true, required: false, placeholder: { default: "City" } },
state: { show: true, required: false, placeholder: { default: "State" } },
country: { show: true, required: false, placeholder: { default: "Country" } },
addressLine1: { show: true, required: false, placeholder: { default: "Address Line 1" } },
addressLine2: { show: true, required: false, placeholder: { default: "Address Line 2" } },
},
{
id: "contactQ",
type: TSurveyElementTypeEnum.ContactInfo,
headline: { default: "Contact Info" },
required: false,
firstName: { show: true, required: false, placeholder: { default: "First Name" } },
lastName: { show: true, required: false, placeholder: { default: "Last Name" } },
email: { show: true, required: false, placeholder: { default: "Email" } },
phone: { show: true, required: false, placeholder: { default: "Phone" } },
company: { show: true, required: false, placeholder: { default: "Company" } },
},
{
id: "rankingQ",
type: TSurveyElementTypeEnum.Ranking,
headline: { default: "Ranking" },
required: false,
choices: [{ id: "r1", label: { default: "Option 1" } }],
},
] as TSurveyElement[],
},
],
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
@@ -420,7 +505,7 @@ describe("surveys", () => {
type: "Questions",
label: "Open Text",
id: "openTextQ",
questionType: TSurveyQuestionTypeEnum.OpenText,
questionType: TSurveyElementTypeEnum.OpenText,
},
filterType: { filterComboBoxValue: "Filled out" },
},
@@ -441,7 +526,7 @@ describe("surveys", () => {
type: "Questions",
label: "Address",
id: "addressQ",
questionType: TSurveyQuestionTypeEnum.Address,
questionType: TSurveyElementTypeEnum.Address,
},
filterType: { filterComboBoxValue: "Skipped" },
},
@@ -462,7 +547,7 @@ describe("surveys", () => {
type: "Questions",
label: "Contact Info",
id: "contactQ",
questionType: TSurveyQuestionTypeEnum.ContactInfo,
questionType: TSurveyElementTypeEnum.ContactInfo,
},
filterType: { filterComboBoxValue: "Filled out" },
},
@@ -483,7 +568,7 @@ describe("surveys", () => {
type: "Questions",
label: "Ranking",
id: "rankingQ",
questionType: TSurveyQuestionTypeEnum.Ranking,
questionType: TSurveyElementTypeEnum.Ranking,
},
filterType: { filterComboBoxValue: "Filled out" },
},
@@ -504,7 +589,7 @@ describe("surveys", () => {
type: "Questions",
label: "MC Single",
id: "mcSingleQ",
questionType: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
questionType: TSurveyElementTypeEnum.MultipleChoiceSingle,
},
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Choice 1"] },
},
@@ -525,7 +610,7 @@ describe("surveys", () => {
type: "Questions",
label: "MC Multi",
id: "mcMultiQ",
questionType: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
questionType: TSurveyElementTypeEnum.MultipleChoiceMulti,
},
filterType: { filterValue: "Includes all", filterComboBoxValue: ["Choice 1", "Choice 2"] },
},
@@ -546,7 +631,7 @@ describe("surveys", () => {
type: "Questions",
label: "NPS",
id: "npsQ",
questionType: TSurveyQuestionTypeEnum.NPS,
questionType: TSurveyElementTypeEnum.NPS,
},
filterType: { filterValue: "Is equal to", filterComboBoxValue: "7" },
},
@@ -567,7 +652,7 @@ describe("surveys", () => {
type: "Questions",
label: "Rating",
id: "ratingQ",
questionType: TSurveyQuestionTypeEnum.Rating,
questionType: TSurveyElementTypeEnum.Rating,
},
filterType: { filterValue: "Is less than", filterComboBoxValue: "4" },
},
@@ -588,7 +673,7 @@ describe("surveys", () => {
type: "Questions",
label: "CTA",
id: "ctaQ",
questionType: TSurveyQuestionTypeEnum.CTA,
questionType: TSurveyElementTypeEnum.CTA,
},
filterType: { filterComboBoxValue: "Clicked" },
},
@@ -609,7 +694,7 @@ describe("surveys", () => {
type: "Questions",
label: "Consent",
id: "consentQ",
questionType: TSurveyQuestionTypeEnum.Consent,
questionType: TSurveyElementTypeEnum.Consent,
},
filterType: { filterComboBoxValue: "Accepted" },
},
@@ -630,7 +715,7 @@ describe("surveys", () => {
type: "Questions",
label: "Picture",
id: "pictureQ",
questionType: TSurveyQuestionTypeEnum.PictureSelection,
questionType: TSurveyElementTypeEnum.PictureSelection,
},
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Picture 1"] },
},
@@ -651,7 +736,7 @@ describe("surveys", () => {
type: "Questions",
label: "Matrix",
id: "matrixQ",
questionType: TSurveyQuestionTypeEnum.Matrix,
questionType: TSurveyElementTypeEnum.Matrix,
},
filterType: { filterValue: "Row 1", filterComboBoxValue: "Column 1" },
},
@@ -736,7 +821,7 @@ describe("surveys", () => {
type: "Questions",
label: "NPS",
id: "npsQ",
questionType: TSurveyQuestionTypeEnum.NPS,
questionType: TSurveyElementTypeEnum.NPS,
},
filterType: { filterValue: "Is more than", filterComboBoxValue: "7" },
},
+25 -21
View File
@@ -5,7 +5,8 @@ import {
TSurveyContactAttributes,
TSurveyMetaFieldFilter,
} from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TTag } from "@formbricks/types/tags";
import {
@@ -21,6 +22,7 @@ import {
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
const conditionOptions = {
openText: ["is"],
@@ -79,8 +81,9 @@ export const generateQuestionAndFilterOptions = (
let questionFilterOptions: any = [];
let questionsOptions: any = [];
const questions = getElementsFromBlocks(survey.blocks);
survey.questions.forEach((q) => {
questions.forEach((q) => {
if (Object.keys(conditionOptions).includes(q.type)) {
questionsOptions.push({
label: getTextContent(
@@ -93,16 +96,16 @@ export const generateQuestionAndFilterOptions = (
}
});
questionOptions = [...questionOptions, { header: OptionsType.QUESTIONS, option: questionsOptions }];
survey.questions.forEach((q) => {
questions.forEach((q) => {
if (Object.keys(conditionOptions).includes(q.type)) {
if (q.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle) {
if (q.type === TSurveyElementTypeEnum.MultipleChoiceSingle) {
questionFilterOptions.push({
type: q.type,
filterOptions: conditionOptions[q.type],
filterComboBoxOptions: q?.choices ? q?.choices?.map((c) => c?.label) : [""],
id: q.id,
});
} else if (q.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
} else if (q.type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
questionFilterOptions.push({
type: q.type,
filterOptions: conditionOptions[q.type],
@@ -111,14 +114,14 @@ export const generateQuestionAndFilterOptions = (
: [""],
id: q.id,
});
} else if (q.type === TSurveyQuestionTypeEnum.PictureSelection) {
} else if (q.type === TSurveyElementTypeEnum.PictureSelection) {
questionFilterOptions.push({
type: q.type,
filterOptions: conditionOptions[q.type],
filterComboBoxOptions: q?.choices ? q?.choices?.map((_, idx) => `Picture ${idx + 1}`) : [""],
id: q.id,
});
} else if (q.type === TSurveyQuestionTypeEnum.Matrix) {
} else if (q.type === TSurveyElementTypeEnum.Matrix) {
questionFilterOptions.push({
type: q.type,
filterOptions: q.rows.flatMap((row) => Object.values(row)),
@@ -311,12 +314,13 @@ export const getFormattedFilters = (
// for questions
if (questions.length) {
const surveyQuestions = getElementsFromBlocks(survey.blocks);
questions.forEach(({ filterType, questionType }) => {
if (!filters.data) filters.data = {};
switch (questionType.questionType) {
case TSurveyQuestionTypeEnum.OpenText:
case TSurveyQuestionTypeEnum.Address:
case TSurveyQuestionTypeEnum.ContactInfo: {
case TSurveyElementTypeEnum.OpenText:
case TSurveyElementTypeEnum.Address:
case TSurveyElementTypeEnum.ContactInfo: {
if (filterType.filterComboBoxValue === "Filled out") {
filters.data[questionType.id ?? ""] = {
op: "filledOut",
@@ -328,7 +332,7 @@ export const getFormattedFilters = (
}
break;
}
case TSurveyQuestionTypeEnum.Ranking: {
case TSurveyElementTypeEnum.Ranking: {
if (filterType.filterComboBoxValue === "Filled out") {
filters.data[questionType.id ?? ""] = {
op: "submitted",
@@ -340,8 +344,8 @@ export const getFormattedFilters = (
}
break;
}
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
case TSurveyQuestionTypeEnum.MultipleChoiceMulti: {
case TSurveyElementTypeEnum.MultipleChoiceSingle:
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
if (filterType.filterValue === "Includes either") {
filters.data[questionType.id ?? ""] = {
op: "includesOne",
@@ -355,8 +359,8 @@ export const getFormattedFilters = (
}
break;
}
case TSurveyQuestionTypeEnum.NPS:
case TSurveyQuestionTypeEnum.Rating: {
case TSurveyElementTypeEnum.NPS:
case TSurveyElementTypeEnum.Rating: {
if (filterType.filterValue === "Is equal to") {
filters.data[questionType.id ?? ""] = {
op: "equals",
@@ -388,7 +392,7 @@ export const getFormattedFilters = (
}
break;
}
case TSurveyQuestionTypeEnum.CTA: {
case TSurveyElementTypeEnum.CTA: {
if (filterType.filterComboBoxValue === "Clicked") {
filters.data[questionType.id ?? ""] = {
op: "clicked",
@@ -400,7 +404,7 @@ export const getFormattedFilters = (
}
break;
}
case TSurveyQuestionTypeEnum.Consent: {
case TSurveyElementTypeEnum.Consent: {
if (filterType.filterComboBoxValue === "Accepted") {
filters.data[questionType.id ?? ""] = {
op: "accepted",
@@ -412,12 +416,12 @@ export const getFormattedFilters = (
}
break;
}
case TSurveyQuestionTypeEnum.PictureSelection: {
case TSurveyElementTypeEnum.PictureSelection: {
const questionId = questionType.id ?? "";
const question = survey.questions.find((q) => q.id === questionId);
const question = surveyQuestions.find((q) => q.id === questionId);
if (
question?.type !== TSurveyQuestionTypeEnum.PictureSelection ||
question?.type !== TSurveyElementTypeEnum.PictureSelection ||
!Array.isArray(filterType.filterComboBoxValue)
) {
return;
@@ -441,7 +445,7 @@ export const getFormattedFilters = (
}
break;
}
case TSurveyQuestionTypeEnum.Matrix: {
case TSurveyElementTypeEnum.Matrix: {
if (
filterType.filterValue &&
filterType.filterComboBoxValue &&
+3246 -2013
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
import { LinkSurveyLoading } from "@/modules/survey/link/loading";
export default LinkSurveyLoading;
+1 -12
View File
@@ -7,18 +7,7 @@
},
"locale": {
"source": "en-US",
"targets": [
"de-DE",
"fr-FR",
"ja-JP",
"pt-BR",
"pt-PT",
"ro-RO",
"zh-Hans-CN",
"zh-Hant-TW",
"nl-NL",
"es-ES"
]
"targets": ["de-DE", "fr-FR", "ja-JP", "pt-BR", "pt-PT", "ro-RO", "zh-Hans-CN", "zh-Hant-TW"]
},
"version": 1.8
}
+28 -45
View File
@@ -126,7 +126,6 @@ checksums:
common/clear_filters: 8f40ab5af527e4b190da94e7b6221379
common/clear_selection: af5d720527735d4253e289400d29ec9e
common/click: 9c2744de6b5ac7333d9dae1d5cf4a76d
common/click_to_filter: 527714113ca5fd3504e7d0bd31bca303
common/clicks: f9e154545f87d8ede27b529e5fdf2015
common/close: 2c2e22f8424a1031de89063bd0022e16
common/code: 343bc5386149b97cece2b093c39034b2
@@ -184,7 +183,6 @@ checksums:
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
common/failed_to_load_projects: 0bba9f9b2b38c189706a486a1bb134c3
common/finish: ffa7a10f71182b48fefed7135bee24fa
@@ -193,7 +191,6 @@ checksums:
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
common/gathering_responses: c5914490ed81bd77f13d411739f0c9ef
common/general: b891e8f15579fc5d97bcaf3637f5ae59
common/generate: 0345bf322c191e70d01fd6607ec5c2f8
common/go_back: b917ea82facb90c88c523b255d29f84b
common/go_to_dashboard: a6efa97d25e36fedc0af794f6ba610f2
common/hidden: fa290c6ada5869d744ed35e9cca64699
@@ -401,7 +398,6 @@ checksums:
common/user_id: 37f5ba37f71cb50607af32a6a203b1d4
common/user_not_found: 5903581136ac6c1c1351a482a6d8fdf7
common/variable: c13db5775ba9791b1522cc55c9c7acce
common/variable_ids: 44bf93b70703b7699fa9f21bc6c8eed4
common/variables: ffd3eec5497af36d7b4e4185bad1313a
common/verified_email: d4a9e5e47d622c6ef2fede44233076c7
common/video: 8050c90e4289b105a0780f0fdda6ff66
@@ -495,7 +491,6 @@ checksums:
environments/actions/add_css_class_or_id: cfc4d88412c5b9ef1157e28db4afdcc5
environments/actions/add_regular_expression_here: 797fde3681996b85bc63c3550dec1fd4
environments/actions/add_url: 8eba7972136a42da78a8fa4798da8e87
environments/actions/and: 53e8eb67a396fcb5e419bb4cbf0008df
environments/actions/click: 9c2744de6b5ac7333d9dae1d5cf4a76d
environments/actions/contains: 41c8c25407527a5336404313f4c8d650
environments/actions/create_action: 3abcc6dbbca18d3218ba49f90c4a66fd
@@ -526,7 +521,6 @@ checksums:
environments/actions/limit_to_specific_pages: f8ba95b2fc68d965689594b8a545417c
environments/actions/matches_regex: 208b4d02b38714b4523923239e4a66b0
environments/actions/on_all_pages: ccb8ee531a55e21eb8157c36fa75ad9a
environments/actions/or: 0208d355f231c386b19390f0bea41b95
environments/actions/page_filter: fe98a0bcbedb938e58cc3730589caa95
environments/actions/page_view: 019c12b6739f6f7b1500f96ee275d47c
environments/actions/select_match_type: b555dce1cb5c61538d3fbd792b2c71a2
@@ -563,18 +557,9 @@ checksums:
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
environments/contacts/delete_contact_confirmation: 4304d36277daa205b4aa09f5e0d494ab
environments/contacts/delete_contact_confirmation_with_quotas: 7c0e2e223ca55101270ac2988c53e616
environments/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
environments/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
environments/contacts/no_published_link_surveys_available: 9c1abc5b21aba827443cdf87dd6c8bfe
environments/contacts/no_published_surveys: bd945b0e2e2328c17615c94143bdd62b
environments/contacts/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
environments/contacts/not_provided: a09e4d61bbeb04b927406a50116445e2
environments/contacts/personal_link_generated: efb7a0420bd459847eb57bca41a4ab0d
environments/contacts/personal_link_generated_but_clipboard_failed: 4eb1e208e729bd5ac00c33f72fc38d53
environments/contacts/personal_survey_link: 5b3f1afc53733718c4ed5b1443b6a604
environments/contacts/please_select_a_survey: 465aa7048773079c8ffdde8b333b78eb
environments/contacts/search_contact: 020205a93846ab3e12c203ac4fa97c12
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d
environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb
@@ -736,14 +721,14 @@ checksums:
environments/project/api_keys/secret: f041e5eb96121c8b4f2b8af7e0f83a9b
environments/project/api_keys/unable_to_delete_api_key: 1fd76d9a22c5f5f8c241c4891fca8295
environments/project/app-connection/app_connection: 778d2305e1a9c8efe91c2c7b4af37ae4
environments/project/app-connection/app_connection_description: dde226414bd2265cbd0daf6635efcfdd
environments/project/app-connection/cache_update_delay_description: 3368e4a8090b7684117a16c94f0c409c
environments/project/app-connection/cache_update_delay_title: 60e4a0fcfbd8850bddf29b5c3f59550c
environments/project/app-connection/app_connection_description: 01327bfae3da950d796890b6605afed2
environments/project/app-connection/cache_update_delay_description: 1cb2c46fdb6762ccb348d21086063a4f
environments/project/app-connection/cache_update_delay_title: fef7f99f0228f9e30093574ac7770e7e
environments/project/app-connection/environment_id: 3dba898b081c18cd4cae131765ef411f
environments/project/app-connection/environment_id_description: 8b4a763d069b000cfa1a2025a13df80c
environments/project/app-connection/formbricks_sdk_connected: 29e8a40ad6a7fdb5af5ee9451a70a9aa
environments/project/app-connection/formbricks_sdk_not_connected: 557c534e665750978ba6edb0eacb428e
environments/project/app-connection/formbricks_sdk_not_connected_description: 4ddbacae084238bd0cefeded0fe9dbb9
environments/project/app-connection/formbricks_sdk_not_connected_description: 666b2b25f06e76554cc2d60f925bcd4b
environments/project/app-connection/how_to_setup: 3bad40037f280b47fe6418fcbeb4c717
environments/project/app-connection/how_to_setup_description: 2ae5cd9456a8acd3986e3d3678e70ed2
environments/project/app-connection/receiving_data: 9f2a48c0b0278861add70b526061264c
@@ -760,7 +745,7 @@ checksums:
environments/project/general/project_deleted_successfully: dbedf0f0739b822f3951de4aeb2fc26f
environments/project/general/project_name_settings_description: 079c6380ad539543a9aa8772bc1b0fa2
environments/project/general/project_name_updated_successfully: f95f70f4a49d451dc0441a51d05a3aa3
environments/project/general/recontact_waiting_time: 0566dc710b4b9644e276e311b419c4c0
environments/project/general/recontact_waiting_time: 9c5ebb18960dec73def053de89e63272
environments/project/general/recontact_waiting_time_settings_description: 8922cde1f95777f9a2747fb4bed57ab5
environments/project/general/this_action_cannot_be_undone: 3d8b13374ffd3cefc0f3f7ce077bd9c9
environments/project/general/wait_x_days_before_showing_next_survey: d96228788d32ec23dc0d8c8ba77150a6
@@ -823,6 +808,7 @@ checksums:
environments/project/tags/add_tag: 2cfa04ceea966149f2b5d40d9c131141
environments/project/tags/count: 9c5848662eb8024ddf360f7e4001a968
environments/project/tags/delete_tag_confirmation: a9fb98064cd156242899643f3d2ef032
environments/project/tags/empty_message: da71bd7c7b5bf634469d20e010d25503
environments/project/tags/manage_tags: 2761d558b82b6104befbc240ae2379c6
environments/project/tags/manage_tags_description: ce7cc42da3646fba960502d7e4e49cd2
environments/project/tags/merge: 95051c859b8778be51226b43be6f1075
@@ -1155,6 +1141,7 @@ checksums:
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
environments/surveys/edit/allow_users_to_select_more_than_one_image: d683e0b538d1366400292a771f3fbd08
environments/surveys/edit/always_show_survey: b0ae6a873ce2eeb0aea2e6d4cb04c540
environments/surveys/edit/and_launch_surveys_in_your_website_or_app: a3edcdb4aea792a27d90aad1930f001a
environments/surveys/edit/animation: 66a18eacfb92fc9fc9db188d2dde4f81
environments/surveys/edit/app_survey_description: bdfacfce478e97f70b700a1382dfa687
@@ -1237,7 +1224,8 @@ checksums:
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
environments/surveys/edit/days_before_showing_this_survey_again: 354fb28c5ff076f022d82a20c749ee46
environments/surveys/edit/days_before_showing_this_survey_again: 8b4623eab862615fa60064400008eb23
environments/surveys/edit/decide_how_often_people_can_answer_this_survey: 58427b0f0a7a258c24fa2acd9913e95e
environments/surveys/edit/delete_choice: fd750208d414b9ad8c980c161a0199e1
environments/surveys/edit/disable_the_visibility_of_survey_progress: 2af631010114307ac2a91612559c9618
environments/surveys/edit/display_an_estimate_of_completion_time_for_survey: 03f0a816569399c1c61d08dbc913de06
@@ -1265,7 +1253,7 @@ checksums:
environments/surveys/edit/equals_one_of: 369a451add4b79bc003f952f0e1bfcc9
environments/surveys/edit/error_publishing_survey: bf9fab1d8ea7132a2e9b4b7b09f18b1f
environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: c6668f9cf127fd922bec695dc548fe12
environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413
environments/surveys/edit/external_urls_paywall_tooltip: 0dbb62557e8a6fa817f0e74709eeb3d2
environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722
@@ -1336,9 +1324,8 @@ checksums:
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
environments/surveys/edit/how_funky_do_you_want_your_cards_in_survey_type_derived_surveys: 3cb16b37510c01af20a80f51b598346e
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 31c18a8c7c578db2ba49eed663d1739f
environments/surveys/edit/ignore_global_waiting_time: 1e7f1465aeb6d26c325ad7f135b207a8
environments/surveys/edit/ignore_global_waiting_time_description: 37d173a4d537622de40677389238d859
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 33f0320ec85067a06198a841348e9fc6
environments/surveys/edit/ignore_waiting_time_between_surveys: 8145b6aef535fde5ee54dea63e66f64a
environments/surveys/edit/image: 048ba7a239de0fbd883ade8558415830
environments/surveys/edit/includes_all_of: ec72f90c0839d4c3bb518deb03894031
environments/surveys/edit/includes_one_of: 6d5be5d7c2494179e88bd7302b247884
@@ -1405,10 +1392,9 @@ checksums:
environments/surveys/edit/optional: 396fb9a0472daf401c392bdc3e248943
environments/surveys/edit/options: 59156082418d80acb211f973b1218f11
environments/surveys/edit/override_theme_with_individual_styles_for_this_survey: edffc97f5d3372419fe0444de0a5aa3f
environments/surveys/edit/overwrite_global_waiting_time: 7bc23bd502b6bd048356b67acd956d9d
environments/surveys/edit/overwrite_global_waiting_time_description: 795cf6e93d4c01d2e43aa0ebab601c6e
environments/surveys/edit/overwrite_placement: d7278be243e52c5091974e0fc4a7c342
environments/surveys/edit/overwrite_the_global_placement_of_the_survey: 874075712254b1ce92e099d89f675a48
environments/surveys/edit/overwrites_waiting_period_between_surveys_to_x_days: 8d5596b024cbe8c82b021dcf6c73ba05
environments/surveys/edit/pick_a_background_from_our_library_or_upload_your_own: b83bcbdc8131fc9524d272ff5dede754
environments/surveys/edit/picture_idx: 55e053ad1ade5d17c582406706036028
environments/surveys/edit/pin_can_only_contain_numbers: 417c854d44620a7229ebd9ab8cbb3613
@@ -1465,8 +1451,7 @@ checksums:
environments/surveys/edit/range: 1fad969ecf3de1c21df046b93053c422
environments/surveys/edit/recall_data: 39beabd626c0af15316885cff5d5d9b8
environments/surveys/edit/recall_information_from: 884cfd143456fab1a91f0744cc92f0c8
environments/surveys/edit/recontact_options_section: 57a23e1bcab6baa484b27b615e6c906a
environments/surveys/edit/recontact_options_section_description: 1e04011440c339a3b5cfff12d55b7f12
environments/surveys/edit/recontact_options: 0f570378a531da60448fde37abd50214
environments/surveys/edit/redirect_thank_you_card: 09f721c4b62e2584e40a53507092ea83
environments/surveys/edit/redirect_to_url: f17d726bbc3391561447b3f4010635cf
environments/surveys/edit/remove_description: b52de820b4bbcb354eb62246c4112a9a
@@ -1475,8 +1460,6 @@ checksums:
environments/surveys/edit/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
environments/surveys/edit/reset_to_theme_styles: f9edc3970ec23d6c4d2d7accc292ef3a
environments/surveys/edit/reset_to_theme_styles_main_text: d86fb2213d3b2efbd0361526dc6cb27b
environments/surveys/edit/respect_global_waiting_time: 850e7e64ec890c591b2d07741ef26e11
environments/surveys/edit/respect_global_waiting_time_description: 5235fee102d619cb391c5aa2c75b61be
environments/surveys/edit/response_limit_can_t_be_set_to_0: 278664873ee3b1046dbcb58848efc12a
environments/surveys/edit/response_limit_needs_to_exceed_number_of_received_responses: 9a9c223c0918ded716ddfaa84fbaa8d9
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
@@ -1501,7 +1484,7 @@ checksums:
environments/surveys/edit/show_advanced_settings: b6f5bbbb84f34e51cd72ccd332e9613e
environments/surveys/edit/show_button: 6b364aac9d7ac71f34a438607c9693bc
environments/surveys/edit/show_language_switch: b6915a7f26d7079f2d4d844d74440413
environments/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60
environments/surveys/edit/show_multiple_times: 5e6e0244c20feca78723c79aa1ddcf62
environments/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
@@ -1531,12 +1514,13 @@ checksums:
environments/surveys/edit/switch_multi_lanugage_on_to_get_started: d2ca06684af26bd6b5121a4656bb6458
environments/surveys/edit/targeted: ca615f1fc3b490d5a2187b27fb4a2073
environments/surveys/edit/ten_points: a1317b82003859f77fb3138c55450d63
environments/surveys/edit/the_survey_will_be_shown_multiple_times_until_they_respond: 2d8d7d2351bd7533eb3788cce228c654
environments/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: 6062aaa5cf8e58e79b75b6b588ae9598
environments/surveys/edit/the_survey_will_be_shown_multiple_times_until_they_respond: 219b15081cbafaa391e266bd2cc4c9d4
environments/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: c145b7be481ae1fe6f66298d9a5cf838
environments/surveys/edit/then: 5e941fb7dd51a18651fcfb865edd5ba6
environments/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987
environments/surveys/edit/this_extension_is_already_added: 201d636539836c95958e28cecd8f3240
environments/surveys/edit/this_file_type_is_not_supported: f365b9a2e05aa062ab0bc1af61f642e2
environments/surveys/edit/this_setting_overwrites_your: 6f980149a5a4adc2cfe3dac4f367e7e5
environments/surveys/edit/three_points: d7f299aec752d7d690ef0ab6373327ae
environments/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a
environments/surveys/edit/to_keep_the_placement_over_all_surveys_consistent_you_can: 7a078e6a39d4c30b465137d2b6ef3e67
@@ -1547,7 +1531,8 @@ checksums:
environments/surveys/edit/unlock_targeting_description: 8e315dc41c2849754839a1460643c5fb
environments/surveys/edit/unlock_targeting_title: 6098caf969cac64cd54e217471ae42d4
environments/surveys/edit/unsaved_changes_warning: a164f276c9f7344022aa4640b32abcf9
environments/surveys/edit/until_they_submit_a_response: 2a0fd5dcc6cc40a72ed9b974f22eaf68
environments/surveys/edit/until_they_submit_a_response: c980c520f5b5883ed46f2e1c006082b5
environments/surveys/edit/untitled_block: fdaa045139deff5cc65fa027df0cc22e
environments/surveys/edit/upgrade_notice_description: 32b66a4f257ad8d38bc38dcc95fe23c4
environments/surveys/edit/upgrade_notice_title: 40866066ebc558ad0c92a4f19f12090c
environments/surveys/edit/upload: 4a6c84aa16db0f4e5697f49b45257bc7
@@ -1555,6 +1540,7 @@ checksums:
environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17
environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421
environments/surveys/edit/url_not_supported: af8a753467c617b596aadef1aaaed664
environments/surveys/edit/use_with_caution: 7c35d3ad68dd001e53cbd9d57c96af91
environments/surveys/edit/variable_is_used_in_logic_of_question_please_remove_it_from_logic_first: bd9d9c7cf0be671c4e8cf67e2ae6659e
environments/surveys/edit/variable_is_used_in_quota_please_remove_it_from_quota_first: 0d36e5b2713f5450fe346e0af0aaa29c
environments/surveys/edit/variable_name_is_already_taken_please_choose_another: 6da42fe8733c6379158bce9a176f76d7
@@ -1564,13 +1550,11 @@ checksums:
environments/surveys/edit/variable_used_in_recall_welcome: 60321b2f40ae01cd10f99ed77bb986ba
environments/surveys/edit/verify_email_before_submission: c05d345dc35f2d33839e4cfd72d11eb2
environments/surveys/edit/verify_email_before_submission_description: 434ab3ee6134367513b633a9d4f7d772
environments/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84
environments/surveys/edit/visibility_and_recontact_description: 2969ab679e1f6111dd96e95cee26e219
environments/surveys/edit/wait: 014d18ade977bf08d75b995076596708
environments/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919
environments/surveys/edit/waiting_time_across_surveys: 5c5a7653d797c86c4008f13a40434ad8
environments/surveys/edit/waiting_time_across_surveys_description: 1bbee2fee49f842056547c336f8fd788
environments/surveys/edit/waiting_period: 21775d12b2cb831134b1f47450eaf1f3
environments/surveys/edit/welcome_message: 986a434e3895c8ee0b267df95cc40051
environments/surveys/edit/when_conditions_match_waiting_time_will_be_ignored_and_survey_shown: e7fe9c56664da4670e52e38656d8705d
environments/surveys/edit/without_a_filter_all_of_your_users_can_be_surveyed: 451990569c61f25d01044cc45b1ce122
environments/surveys/edit/you_have_not_created_a_segment_yet: c6658bd1cee9c5c957c675db044708dd
environments/surveys/edit/you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations: b12b28699e02ff9ba69bcbae838ba5da
@@ -1611,7 +1595,7 @@ checksums:
environments/surveys/responses/last_name: 2c9a7de7738ca007ba9023c385149c26
environments/surveys/responses/not_completed: df34eab65a6291f2c5e15a0e349c4eba
environments/surveys/responses/os: a4c753bb2c004a58d02faeed6b4da476
environments/surveys/responses/person_attributes: 07ae67ae73d7a2a7c67008694a83f0a3
environments/surveys/responses/person_attributes: 8f7f8a9040ce8efb3cb54ce33b590866
environments/surveys/responses/phone: b9537ee90fc5b0116942e0af29d926cc
environments/surveys/responses/respondent_skipped_questions: d85daf579ade534dc7e639689156fcd5
environments/surveys/responses/response_deleted_successfully: 6cec5427c271800619fee8c812d7db18
@@ -1706,7 +1690,6 @@ checksums:
environments/surveys/share/social_media/title: 1bf4899b063ee8f02f7188576555828b
environments/surveys/summary/added_filter_for_responses_where_answer_to_question: 5bddf0d4f771efd06d58441d11fa5091
environments/surveys/summary/added_filter_for_responses_where_answer_to_question_is_skipped: 74ca713c491cfc33751a5db3de972821
environments/surveys/summary/aggregated: 9d4e77225d5952abed414fffd828c078
environments/surveys/summary/all_responses_csv: 16c0c211853f0839a79f1127ec679ca2
environments/surveys/summary/all_responses_excel: 8bf18916ab127f16bfcf9f38956710b0
environments/surveys/summary/all_time: 62258944e7c2e83f3ebf69074b2c2156
@@ -1730,6 +1713,7 @@ checksums:
environments/surveys/summary/filtered_responses_csv: aad66a98be6a09cac8bef9e4db4a75cf
environments/surveys/summary/filtered_responses_excel: 06e57bae9e41979fd7fc4b8bfe3466f9
environments/surveys/summary/generating_qr_code: 5026d4a76f995db458195e5215d9bbd9
environments/surveys/summary/go_to_setup_checklist: d70bd018d651d01c41ae10370e71d0be
environments/surveys/summary/impressions: 7fe38d42d68a64d3fd8436a063751584
environments/surveys/summary/impressions_tooltip: 4d0823cbf360304770c7c5913e33fdc8
environments/surveys/summary/in_app/connection_description: 9710bbf8048a8a5c3b2b56db9d946b73
@@ -1761,7 +1745,7 @@ checksums:
environments/surveys/summary/in_app/title: a2d1b633244d0e0504ec6f8f561c7a6b
environments/surveys/summary/includes_all: b0e3679282417c62d511c258362f860e
environments/surveys/summary/includes_either: 186d6923c1693e80d7b664b8367d4221
environments/surveys/summary/individual: 52ebce389ed97a13b6089802055ed667
environments/surveys/summary/install_widget: 55d403de32e3d0da7513ab199f1d1934
environments/surveys/summary/is_equal_to: f4aab30ef188eb25dcc0e392cf8e86bb
environments/surveys/summary/is_less_than: 6109d595ba21497c59b1c91d7fd09a13
environments/surveys/summary/last_30_days: a738894cfc5e592052f1e16787744568
@@ -1774,7 +1758,6 @@ checksums:
environments/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
environments/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
environments/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
environments/surveys/summary/promoters: 41fbb8d0439227661253a82fda39f521
environments/surveys/summary/qr_code: 48cb2a8c07a3d1647f766f93bb9e9382
environments/surveys/summary/qr_code_description: 19f48dcf473809f178abf4212657ef14
environments/surveys/summary/qr_code_download_failed: 2764b5615112800da27eecafc21e3472
@@ -1784,7 +1767,6 @@ checksums:
environments/surveys/summary/quotas_completed_tooltip: ec5c4dc67eda27c06764354f695db613
environments/surveys/summary/reset_survey: 8c88ddb81f5f787d183d2e7cb43e7c64
environments/surveys/summary/reset_survey_warning: 6b44be171d7e2716f234387b100b173d
environments/surveys/summary/satisfied: 4d542ba354b85e644acbca5691d2ce45
environments/surveys/summary/selected_responses_csv: 9cef3faccd54d4f24647791e6359db90
environments/surveys/summary/selected_responses_excel: a0ade8b2658e887a4a3f2ad3bdb0c686
environments/surveys/summary/setup_integrations: 70de06d73be671a0cd58a3fd4fa62e53
@@ -1800,6 +1782,7 @@ checksums:
environments/surveys/summary/ttc_tooltip: 9b1cbe32cc81111314bd3b6fd050c2e7
environments/surveys/summary/unknown_question_type: e4152a7457d2b94f48dcc70aaba9922f
environments/surveys/summary/use_personal_links: da2b3e7e1aaf2ea2bd4efed2dda4247c
environments/surveys/summary/waiting_for_response: 0194a84e0850b8e98435632d5331a916
environments/surveys/summary/whats_next: d920145bfa2147014062f6f2d1d451a4
environments/surveys/summary/your_survey_is_public: 3f5cb5949a5f4020a3d4d74fdfc95e83
environments/surveys/summary/youre_not_plugged_in_yet: 9217467742cdcf7edf8d59cc1472ede6
@@ -2118,6 +2101,7 @@ checksums:
templates/csat_survey_question_3_headline: 25974b7f1692cad41908fe305830b6c0
templates/csat_survey_question_3_placeholder: 3ec4d0b6bb1f26bb2c32e9e8bd377ae3
templates/cta_description: bc94a2ddc965b286a8677b0642696c7e
templates/custom_survey_block_1_name: 5e1b4dce0cb70662441b663507a69454
templates/custom_survey_description: 0492afdea2ef1bd683eaf48a2bad2caa
templates/custom_survey_name: 6fc756927ca9ea22c26368cccd64a67e
templates/custom_survey_question_1_headline: 0abf9d41e0b5c5567c3833fd63048398
@@ -2527,7 +2511,6 @@ checksums:
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
templates/preview_survey_welcome_card_html: 5fc24f7cfeba1af9a3fc3ddb6fb67de4
templates/prioritize_features_description: 1eae41fad0e3947f803d8539081e59ec
templates/prioritize_features_name: 4ca59ff1f9c319aaa68c3106d820fd6a
templates/prioritize_features_question_1_choice_1: 7c0b2da44eacc271073d4f15caaa86c8
+2 -3
View File
@@ -19,7 +19,8 @@ export const ENCRYPTION_KEY = env.ENCRYPTION_KEY;
// Other
export const CRON_SECRET = env.CRON_SECRET;
export const DEFAULT_BRAND_COLOR = "#64748b";
export const FB_LOGO_URL = `${WEBAPP_URL}/logo-transparent.png`;
export const FB_LOGO_URL =
"https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Formbricks-Light-transparent.png";
export const PRIVACY_URL = env.PRIVACY_URL;
export const TERMS_URL = env.TERMS_URL;
@@ -169,13 +170,11 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"de-DE",
"pt-BR",
"fr-FR",
"nl-NL",
"zh-Hant-TW",
"pt-PT",
"ro-RO",
"ja-JP",
"zh-Hans-CN",
"es-ES",
];
// Billing constants
+2 -51
View File
@@ -1,6 +1,7 @@
import { iso639Languages } from "@formbricks/i18n-utils/src/utils";
import { TI18nString } from "@formbricks/types/i18n";
import { TLanguage } from "@formbricks/types/project";
import { TI18nString, TSurveyLanguage } from "@formbricks/types/surveys/types";
import { TSurveyLanguage } from "@formbricks/types/surveys/types";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
// Helper function to create an i18nString from a regular string.
@@ -137,8 +138,6 @@ export const appLanguages = [
"ro-RO": "Engleză (SUA)",
"ja-JP": "英語(米国)",
"zh-Hans-CN": "英语(美国)",
"nl-NL": "Engels (VS)",
"es-ES": "Inglés (EE.UU.)",
},
},
{
@@ -153,8 +152,6 @@ export const appLanguages = [
"ro-RO": "Germană",
"ja-JP": "ドイツ語",
"zh-Hans-CN": "德语",
"nl-NL": "Duits",
"es-ES": "Alemán",
},
},
{
@@ -169,8 +166,6 @@ export const appLanguages = [
"ro-RO": "Portugheză (Brazilia)",
"ja-JP": "ポルトガル語(ブラジル)",
"zh-Hans-CN": "葡萄牙语(巴西)",
"nl-NL": "Portugees (Brazilië)",
"es-ES": "Portugués (Brasil)",
},
},
{
@@ -185,8 +180,6 @@ export const appLanguages = [
"ro-RO": "Franceză",
"ja-JP": "フランス語",
"zh-Hans-CN": "法语",
"nl-NL": "Frans",
"es-ES": "Francés",
},
},
{
@@ -201,8 +194,6 @@ export const appLanguages = [
"ro-RO": "Chineză (Tradicională)",
"ja-JP": "中国語(繁体字)",
"zh-Hans-CN": "繁体中文",
"nl-NL": "Chinees (Traditioneel)",
"es-ES": "Chino (Tradicional)",
},
},
{
@@ -217,8 +208,6 @@ export const appLanguages = [
"ro-RO": "Portugheză (Portugalia)",
"ja-JP": "ポルトガル語(ポルトガル)",
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
"nl-NL": "Portugees (Portugal)",
"es-ES": "Portugués (Portugal)",
},
},
{
@@ -233,8 +222,6 @@ export const appLanguages = [
"ro-RO": "Română",
"ja-JP": "ルーマニア語",
"zh-Hans-CN": "罗马尼亚语",
"nl-NL": "Roemeens",
"es-ES": "Rumano",
},
},
{
@@ -249,8 +236,6 @@ export const appLanguages = [
"ro-RO": "Japoneză",
"ja-JP": "日本語",
"zh-Hans-CN": "日语",
"nl-NL": "Japans",
"es-ES": "Japonés",
},
},
{
@@ -265,40 +250,6 @@ export const appLanguages = [
"ro-RO": "Chineză (Simplificată)",
"ja-JP": "中国語(簡体字)",
"zh-Hans-CN": "简体中文",
"nl-NL": "Chinees (Vereenvoudigd)",
"es-ES": "Chino (Simplificado)",
},
},
{
code: "nl-NL",
label: {
"en-US": "Dutch",
"de-DE": "Niederländisch",
"pt-BR": "Holandês",
"fr-FR": "Néerlandais",
"zh-Hant-TW": "荷蘭語",
"pt-PT": "Holandês",
"ro-RO": "Olandeză",
"ja-JP": "オランダ語",
"zh-Hans-CN": "荷兰语",
"nl-NL": "Nederlands",
"es-ES": "Neerlandés",
},
},
{
code: "es-ES",
label: {
"en-US": "Spanish",
"de-DE": "Spanisch",
"pt-BR": "Espanhol",
"fr-FR": "Espagnol",
"zh-Hant-TW": "西班牙語",
"pt-PT": "Espanhol",
"ro-RO": "Spaniol",
"ja-JP": "スペイン語",
"zh-Hans-CN": "西班牙语",
"nl-NL": "Spaans",
"es-ES": "Español",
},
},
];
+6 -4
View File
@@ -15,8 +15,10 @@ import {
ZResponseFilterCriteria,
ZResponseUpdateInput,
} from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
import { deleteFile } from "@/modules/storage/service";
@@ -548,10 +550,10 @@ export const updateResponse = async (
};
const findAndDeleteUploadedFilesInResponse = async (response: TResponse, survey: TSurvey): Promise<void> => {
const questions = getElementsFromBlocks(survey.blocks);
const fileUploadQuestions = new Set(
survey.questions
.filter((question) => question.type === TSurveyQuestionTypeEnum.FileUpload)
.map((q) => q.id)
questions.filter((question) => question.type === TSurveyElementTypeEnum.FileUpload).map((q) => q.id)
);
const fileUrls = Object.entries(response.data)
+106 -84
View File
@@ -1,12 +1,8 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test } from "vitest";
import { TResponse } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyOpenTextQuestion,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import {
buildWhereClause,
calculateTtcTotal,
@@ -44,20 +40,8 @@ describe("Response Utils", () => {
const mockSurvey: Partial<TSurvey> = {
id: "survey1",
name: "Test Survey",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 1" },
required: true,
choices: [
{ id: "1", label: { default: "Option 1" } },
{ id: "other", label: { default: "Other" } },
],
shuffleOption: "none",
isDraft: false,
},
],
blocks: [],
questions: [],
type: "app",
hiddenFields: { enabled: true, fieldIds: [] },
createdAt: new Date(),
@@ -115,6 +99,7 @@ describe("Response Utils", () => {
const baseSurvey: Partial<TSurvey> = {
id: "s1",
name: "Survey",
blocks: [],
questions: [],
type: "app",
hiddenFields: { enabled: false, fieldIds: [] },
@@ -203,26 +188,33 @@ describe("Response Utils", () => {
const textSurvey: Partial<TSurvey> = {
id: "s2",
name: "TextSurvey",
questions: [
blocks: [
{
id: "qText",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Text Q" },
required: false,
isDraft: false,
charLimit: {},
inputType: "text",
},
{
id: "qNum",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Num Q" },
required: false,
isDraft: false,
charLimit: {},
inputType: "number",
id: "block1",
name: "Block 1",
elements: [
{
id: "qText",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Text Q" },
required: false,
isDraft: false,
charLimit: {},
inputType: "text",
},
{
id: "qNum",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Num Q" },
required: false,
isDraft: false,
charLimit: {},
inputType: "number",
},
],
},
],
questions: [],
type: "app",
hiddenFields: { enabled: false, fieldIds: [] },
createdAt: new Date(),
@@ -232,7 +224,7 @@ describe("Response Utils", () => {
status: "inProgress",
};
const ops: Array<[keyof TSurveyQuestionTypeEnum | string, any, any]> = [
const ops: Array<[keyof TSurveyElementTypeEnum | string, any, any]> = [
["submitted", { op: "submitted" }, { path: ["qText"], not: Prisma.DbNull }],
["filledOut", { op: "filledOut" }, { path: ["qText"], not: [] }],
["skipped", { op: "skipped" }, "OR"],
@@ -295,18 +287,25 @@ describe("Response Utils", () => {
const matrixSurvey: Partial<TSurvey> = {
id: "s3",
name: "MatrixSurvey",
questions: [
blocks: [
{
id: "qM",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix" },
required: false,
rows: [{ default: "R1" }],
columns: [{ default: "C1" }],
shuffleOption: "none",
isDraft: false,
id: "block1",
name: "Block 1",
elements: [
{
id: "qM",
type: TSurveyElementTypeEnum.Matrix,
headline: { default: "Matrix" },
required: false,
rows: [{ id: "r1", label: { default: "R1" } }],
columns: [{ id: "c1", label: { default: "C1" } }],
shuffleOption: "none",
isDraft: false,
},
],
},
],
questions: [],
type: "app",
hiddenFields: { enabled: false, fieldIds: [] },
createdAt: new Date(),
@@ -360,34 +359,48 @@ describe("Response Utils", () => {
});
});
// TODO: Fix this test after the survey editor poc is merged
describe("extractSurveyDetails", () => {
const mockSurvey: Partial<TSurvey> = {
id: "survey1",
name: "Test Survey",
questions: [
blocks: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 1" },
required: true,
choices: [
{ id: "1", label: { default: "Option 1" } },
{ id: "2", label: { default: "Option 2" } },
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 1" },
required: true,
choices: [
{ id: "1", label: { default: "Option 1" } },
{ id: "2", label: { default: "Option 2" } },
],
shuffleOption: "none",
isDraft: false,
},
{
id: "q2",
type: TSurveyElementTypeEnum.Matrix,
headline: { default: "Matrix Question" },
required: true,
rows: [
{ id: "r1", label: { default: "Row 1" } },
{ id: "r2", label: { default: "Row 2" } },
],
columns: [
{ id: "c1", label: { default: "Column 1" } },
{ id: "c2", label: { default: "Column 2" } },
],
shuffleOption: "none",
isDraft: false,
},
],
shuffleOption: "none",
isDraft: false,
},
{
id: "q2",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix Question" },
required: true,
rows: [{ default: "Row 1" }, { default: "Row 2" }],
columns: [{ default: "Column 1" }, { default: "Column 2" }],
shuffleOption: "none",
isDraft: false,
},
],
questions: [],
type: "app",
hiddenFields: { enabled: true, fieldIds: ["hidden1"] },
createdAt: new Date(),
@@ -424,20 +437,27 @@ describe("Response Utils", () => {
const mockSurvey: Partial<TSurvey> = {
id: "survey1",
name: "Test Survey",
questions: [
blocks: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 1" },
required: true,
choices: [
{ id: "1", label: { default: "Option 1" } },
{ id: "2", label: { default: "Option 2" } },
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 1" },
required: true,
choices: [
{ id: "1", label: { default: "Option 1" } },
{ id: "2", label: { default: "Option 2" } },
],
shuffleOption: "none",
isDraft: false,
},
],
shuffleOption: "none",
isDraft: false,
},
],
questions: [],
type: "app",
hiddenFields: { enabled: true, fieldIds: [] },
createdAt: new Date(),
@@ -690,9 +710,9 @@ describe("Response Utils", () => {
});
describe("extractChoiceIdsFromResponse", () => {
const multipleChoiceMultiQuestion: TSurveyQuestion = {
const multipleChoiceMultiQuestion = {
id: "multi-choice-id",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
type: TSurveyElementTypeEnum.MultipleChoiceMulti as typeof TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Select multiple options" },
required: false,
choices: [
@@ -709,11 +729,12 @@ describe("extractChoiceIdsFromResponse", () => {
label: { default: "Option 3", es: "Opción 3" },
},
],
shuffleOption: "none" as const,
};
const multipleChoiceSingleQuestion: TSurveyQuestion = {
const multipleChoiceSingleQuestion = {
id: "single-choice-id",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
type: TSurveyElementTypeEnum.MultipleChoiceSingle as typeof TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Select one option" },
required: false,
choices: [
@@ -726,14 +747,15 @@ describe("extractChoiceIdsFromResponse", () => {
label: { default: "Choice B", fr: "Choix B" },
},
],
shuffleOption: "none" as const,
};
const textQuestion: TSurveyOpenTextQuestion = {
const textQuestion = {
id: "text-id",
type: TSurveyQuestionTypeEnum.OpenText,
type: TSurveyElementTypeEnum.OpenText as typeof TSurveyElementTypeEnum.OpenText,
headline: { default: "What do you think?" },
required: false,
inputType: "text",
inputType: "text" as const,
charLimit: { enabled: false, min: 0, max: 0 },
};
+16 -11
View File
@@ -10,15 +10,16 @@ import {
TSurveyMetaFieldFilter,
} from "@formbricks/types/responses";
import {
TSurvey,
TSurveyMultipleChoiceQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestion,
TSurveyRankingQuestion,
} from "@formbricks/types/surveys/types";
TSurveyElement,
TSurveyMultipleChoiceElement,
TSurveyPictureSelectionElement,
TSurveyRankingElement,
} from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { processResponseData } from "../responses";
import { getTodaysDateTimeFormatted } from "../time";
import { getFormattedDateTimeString } from "../utils/datetime";
@@ -33,7 +34,7 @@ import { sanitizeString } from "../utils/strings";
*/
export const extractChoiceIdsFromResponse = (
responseValue: TResponseDataValue,
question: TSurveyQuestion,
question: TSurveyElement,
language: string = "default"
): string[] => {
// Type guard to ensure the question has choices
@@ -92,7 +93,7 @@ export const extractChoiceIdsFromResponse = (
export const getChoiceIdByValue = (
value: string,
question: TSurveyMultipleChoiceQuestion | TSurveyRankingQuestion | TSurveyPictureSelectionQuestion
question: TSurveyMultipleChoiceElement | TSurveyRankingElement | TSurveyPictureSelectionElement
) => {
if (question.type === "pictureSelection") {
return question.choices.find((choice) => choice.imageUrl === value)?.id ?? "other";
@@ -329,7 +330,8 @@ export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilt
const data: Prisma.ResponseWhereInput[] = [];
Object.entries(filterCriteria.data).forEach(([key, val]) => {
const question = survey.questions.find((question) => question.id === key);
const questions = getElementsFromBlocks(survey.blocks);
const question = questions.find((question) => question.id === key);
switch (val.op) {
case "submitted":
@@ -663,7 +665,9 @@ export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) =>
const metaDataFields = responses.length > 0 ? extracMetadataKeys(responses[0].meta) : [];
const modifiedSurvey = replaceHeadlineRecall(survey, "default");
const questions = modifiedSurvey.questions.map((question, idx) => {
const modifiedQuestions = getElementsFromBlocks(modifiedSurvey.blocks);
const questions = modifiedQuestions.map((question, idx) => {
const headline = getTextContent(getLocalizedValue(question.headline, "default")) ?? question.id;
if (question.type === "matrix") {
return question.rows.map((row) => {
@@ -731,7 +735,8 @@ export const getResponsesJson = (
// survey response data
questionsHeadlines.forEach((questionHeadline) => {
const questionIndex = parseInt(questionHeadline[0]) - 1;
const question = survey?.questions[questionIndex];
const questions = getElementsFromBlocks(survey.blocks);
const question = questions[questionIndex];
const answer = response.data[question.id];
if (question.type === "matrix") {
+49 -33
View File
@@ -1,5 +1,5 @@
import { describe, expect, test, vi } from "vitest";
import { TSurveyQuestionType, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { convertResponseValue, getQuestionResponseMapping, processResponseData } from "./responses";
// Mock the recall and i18n utils
@@ -63,7 +63,7 @@ describe("Response Processing", () => {
describe("convertResponseValue", () => {
const mockOpenTextQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText as const,
type: TSurveyElementTypeEnum.OpenText as const,
headline: { default: "Test Question" },
required: true,
inputType: "text" as const,
@@ -73,7 +73,7 @@ describe("Response Processing", () => {
const mockRankingQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.Ranking as const,
type: TSurveyElementTypeEnum.Ranking as const,
headline: { default: "Test Question" },
required: true,
choices: [
@@ -85,7 +85,7 @@ describe("Response Processing", () => {
const mockFileUploadQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.FileUpload as const,
type: TSurveyElementTypeEnum.FileUpload as const,
headline: { default: "Test Question" },
required: true,
allowMultipleFiles: true,
@@ -93,7 +93,7 @@ describe("Response Processing", () => {
const mockPictureSelectionQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.PictureSelection as const,
type: TSurveyElementTypeEnum.PictureSelection as const,
headline: { default: "Test Question" },
required: true,
allowMulti: false,
@@ -184,28 +184,36 @@ describe("Response Processing", () => {
name: "Test Survey",
environmentId: "env1",
createdBy: null,
questions: [
blocks: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText as const,
headline: { default: "Question 1" },
required: true,
inputType: "text" as const,
longAnswer: false,
charLimit: { enabled: false },
},
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti as const,
headline: { default: "Question 2" },
required: true,
choices: [
{ id: "1", label: { default: "Option 1" } },
{ id: "2", label: { default: "Option 2" } },
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: TSurveyElementTypeEnum.OpenText as const,
headline: { default: "Question 1" },
required: true,
inputType: "text" as const,
longAnswer: false,
charLimit: { enabled: false },
},
{
id: "q2",
type: TSurveyElementTypeEnum.MultipleChoiceMulti as const,
headline: { default: "Question 2" },
required: true,
choices: [
{ id: "1", label: { default: "Option 1" } },
{ id: "2", label: { default: "Option 2" } },
],
shuffleOption: "none" as const,
buttonLabel: { default: "Next" },
},
],
shuffleOption: "none" as const,
},
],
questions: [],
hiddenFields: {
enabled: false,
fieldIds: [],
@@ -255,6 +263,7 @@ describe("Response Processing", () => {
enabled: false,
isEncrypted: false,
},
metadata: {},
};
const mockResponse = {
@@ -291,12 +300,12 @@ describe("Response Processing", () => {
expect(mapping[0]).toEqual({
question: "Question 1",
response: "Answer 1",
type: TSurveyQuestionTypeEnum.OpenText,
type: TSurveyElementTypeEnum.OpenText,
});
expect(mapping[1]).toEqual({
question: "Question 2",
response: "Option 1; Option 2",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
});
});
@@ -334,17 +343,24 @@ describe("Response Processing", () => {
test("should handle different language", () => {
const survey = {
...mockSurvey,
questions: [
blocks: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText as const,
headline: { default: "Question 1", en: "Question 1 EN" },
required: true,
inputType: "text" as const,
longAnswer: false,
charLimit: { enabled: false },
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: TSurveyElementTypeEnum.OpenText as const,
headline: { default: "Question 1", en: "Question 1 EN" },
required: true,
inputType: "text" as const,
longAnswer: false,
charLimit: { enabled: false },
},
],
},
],
questions: [],
languages: [
{
language: {
+9 -5
View File
@@ -1,13 +1,15 @@
import { TResponse, TResponseDataValue } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys/types";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { parseRecallInfo } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { getLanguageCode, getLocalizedValue } from "./i18n/utils";
// function to convert response value of type string | number | string[] or Record<string, string> to string | string[]
export const convertResponseValue = (
answer: TResponseDataValue,
question: TSurveyQuestion
question: TSurveyElement
): string | string[] => {
switch (question.type) {
case "ranking":
@@ -34,15 +36,17 @@ export const convertResponseValue = (
export const getQuestionResponseMapping = (
survey: TSurvey,
response: TResponse
): { question: string; response: string | string[]; type: TSurveyQuestionType }[] => {
): { question: string; response: string | string[]; type: TSurveyElementTypeEnum }[] => {
const questionResponseMapping: {
question: string;
response: string | string[];
type: TSurveyQuestionType;
type: TSurveyElementTypeEnum;
}[] = [];
const responseLanguageCode = getLanguageCode(survey.languages, response.language);
for (const question of survey.questions) {
const questions = getElementsFromBlocks(survey.blocks);
for (const question of questions) {
const answer = response.data[question.id];
questionResponseMapping.push({
+90 -85
View File
@@ -4,12 +4,11 @@ import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
TSurvey,
TSurveyCreateInput,
TSurveyLanguage,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
@@ -172,12 +171,12 @@ export const mockContactAttributeKey: TContactAttributeKey = {
...commonMockProperties,
};
const mockQuestion: TSurveyQuestion = {
const mockQuestion = {
id: mockId,
type: TSurveyQuestionTypeEnum.OpenText,
type: TSurveyElementTypeEnum.OpenText as typeof TSurveyElementTypeEnum.OpenText,
headline: { default: "Question Text", de: "Fragetext" },
required: false,
inputType: "text",
inputType: "text" as const,
charLimit: {
enabled: false,
},
@@ -200,7 +199,14 @@ const baseSurveyProperties = {
recontactDays: 3,
displayLimit: 3,
welcomeCard: mockWelcomeCard,
questions: [mockQuestion],
questions: [],
blocks: [
{
id: "block1",
name: "Block 1",
elements: [mockQuestion],
},
],
isBackButtonHidden: false,
endings: [
{
@@ -297,22 +303,22 @@ export const updateSurveyInput: TSurvey = {
type: "link",
status: "inProgress",
displayOption: "respondMultiple",
metadata: {},
triggers: [{ actionClass: mockActionClass }],
projectOverwrites: null,
styling: null,
recaptcha: null,
singleUse: null,
styling: null,
displayPercentage: null,
createdBy: null,
pin: null,
recaptcha: null,
segment: null,
languages: [],
showLanguageSwitch: null,
variables: [],
followUps: [],
metadata: {},
...commonMockProperties,
...baseSurveyProperties,
...commonMockProperties,
};
export const mockTransformedSurveyOutput = {
@@ -331,16 +337,78 @@ export const mockSurveyWithLogic: TSurvey = {
type: "link",
endings: [],
hiddenFields: { enabled: true, fieldIds: ["name"] },
questions: [
blocks: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
inputType: "text",
headline: { default: "What is your favorite color?" },
required: true,
charLimit: {
enabled: false,
},
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: TSurveyElementTypeEnum.OpenText,
inputType: "text" as const,
headline: { default: "What is your favorite color?" },
required: true,
charLimit: {
enabled: false,
},
},
{
id: "q2",
type: TSurveyElementTypeEnum.OpenText,
inputType: "text" as const,
headline: { default: "What is your favorite food?" },
required: true,
charLimit: {
enabled: false,
},
},
{
id: "q3",
type: TSurveyElementTypeEnum.OpenText,
inputType: "text" as const,
headline: { default: "What is your favorite movie?" },
required: true,
charLimit: {
enabled: false,
},
},
{
id: "q4",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "Select a number:" },
choices: [
{ id: "mvedaklp0gxxycprpyhhwen7", label: { default: "lol" } },
{ id: "i7ws8uqyj66q5x086vbqtm8n", label: { default: "lmao" } },
{ id: "cy8hbbr9e2q6ywbfjbzwdsqn", label: { default: "XD" } },
{ id: "sojc5wwxc5gxrnuib30w7t6s", label: { default: "hehe" } },
],
required: true,
shuffleOption: "none" as const,
},
{
id: "q5",
type: TSurveyElementTypeEnum.OpenText,
inputType: "number" as const,
headline: { default: "Select your age group:" },
required: true,
charLimit: {
enabled: false,
},
},
{
id: "q6",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "Select your age group:" },
required: true,
choices: [
{ id: "mvedaklp0gxxycprpyhhwen7", label: { default: "lol" } },
{ id: "i7ws8uqyj66q5x086vbqtm8n", label: { default: "lmao" } },
{ id: "cy8hbbr9e2q6ywbfjbzwdsqn", label: { default: "XD" } },
{ id: "sojc5wwxc5gxrnuib30w7t6s", label: { default: "hehe" } },
],
shuffleOption: "none" as const,
},
],
logic: [
{
id: "cdu9vgtmmd9b24l35pp9bodk",
@@ -358,18 +426,6 @@ export const mockSurveyWithLogic: TSurvey = {
},
actions: [],
},
],
},
{
id: "q2",
type: TSurveyQuestionTypeEnum.OpenText,
inputType: "text",
headline: { default: "What is your favorite food?" },
required: true,
charLimit: {
enabled: false,
},
logic: [
{
id: "uwlm6kazj5pbt6licpa1hw5c",
conditions: {
@@ -392,18 +448,6 @@ export const mockSurveyWithLogic: TSurvey = {
},
actions: [],
},
],
},
{
id: "q3",
type: TSurveyQuestionTypeEnum.OpenText,
inputType: "text",
headline: { default: "What is your favorite movie?" },
required: true,
charLimit: {
enabled: false,
},
logic: [
{
id: "dpi3zipezuo1idplztb1abes",
conditions: {
@@ -426,20 +470,6 @@ export const mockSurveyWithLogic: TSurvey = {
},
actions: [],
},
],
},
{
id: "q4",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Select a number:" },
choices: [
{ id: "mvedaklp0gxxycprpyhhwen7", label: { default: "lol" } },
{ id: "i7ws8uqyj66q5x086vbqtm8n", label: { default: "lmao" } },
{ id: "cy8hbbr9e2q6ywbfjbzwdsqn", label: { default: "XD" } },
{ id: "sojc5wwxc5gxrnuib30w7t6s", label: { default: "hehe" } },
],
required: true,
logic: [
{
id: "fbim31ttxe1s7qkrjzkj1mtc",
conditions: {
@@ -456,18 +486,6 @@ export const mockSurveyWithLogic: TSurvey = {
},
actions: [],
},
],
},
{
id: "q5",
type: TSurveyQuestionTypeEnum.OpenText,
inputType: "number",
headline: { default: "Select your age group:" },
required: true,
charLimit: {
enabled: false,
},
logic: [
{
id: "o6n73uq9rysih9mpcbzlehfs",
conditions: {
@@ -490,24 +508,10 @@ export const mockSurveyWithLogic: TSurvey = {
},
actions: [],
},
],
},
{
id: "q6",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Select your age group:" },
required: true,
choices: [
{ id: "mvedaklp0gxxycprpyhhwen7", label: { default: "lol" } },
{ id: "i7ws8uqyj66q5x086vbqtm8n", label: { default: "lmao" } },
{ id: "cy8hbbr9e2q6ywbfjbzwdsqn", label: { default: "XD" } },
{ id: "sojc5wwxc5gxrnuib30w7t6s", label: { default: "hehe" } },
],
logic: [
{
id: "o6n73uq9rysih9mpcbzlehfs",
id: "o6n73uq9rysih9mpcbzlehfs2",
conditions: {
id: "szdkmtz17j9008n4i2d1t040",
id: "szdkmtz17j9008n4i2d1t041",
connector: "and",
conditions: [
{
@@ -562,6 +566,7 @@ export const mockSurveyWithLogic: TSurvey = {
],
},
],
questions: [],
variables: [
{ id: "siog1dabtpo3l0a3xoxw2922", type: "text", name: "var1", value: "lmao" },
{ id: "km1srr55owtn2r7lkoh5ny1u", type: "number", name: "var2", value: 32 },
+10 -10
View File
@@ -67,7 +67,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[0].logic![0].conditions,
mockSurveyWithLogic.blocks[0].logic![0].conditions,
"default"
);
expect(result).toBe(true);
@@ -81,7 +81,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[0].logic![0].conditions,
mockSurveyWithLogic.blocks[0].logic![0].conditions,
"default"
);
expect(result).toBe(false);
@@ -95,7 +95,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[1].logic![0].conditions,
mockSurveyWithLogic.blocks[0].logic![1].conditions,
"default"
);
expect(result).toBe(true);
@@ -109,7 +109,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[1].logic![0].conditions,
mockSurveyWithLogic.blocks[0].logic![1].conditions,
"default"
);
expect(result).toBe(false);
@@ -123,7 +123,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[2].logic![0].conditions,
mockSurveyWithLogic.blocks[0].logic![2].conditions,
"default"
);
expect(result).toBe(true);
@@ -137,7 +137,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[3].logic![0].conditions,
mockSurveyWithLogic.blocks[0].logic![3].conditions,
"default"
);
expect(result).toBe(true);
@@ -151,7 +151,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[3].logic![0].conditions,
mockSurveyWithLogic.blocks[0].logic![3].conditions,
"default"
);
expect(result).toBe(false);
@@ -165,7 +165,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[4].logic![0].conditions,
mockSurveyWithLogic.blocks[0].logic![4].conditions,
"default"
);
expect(result).toBe(true);
@@ -179,7 +179,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[4].logic![0].conditions,
mockSurveyWithLogic.blocks[0].logic![4].conditions,
"default"
);
expect(result).toBe(false);
@@ -193,7 +193,7 @@ describe("evaluateLogic with mockSurveyWithLogic", () => {
mockSurveyWithLogic,
data,
variablesData,
mockSurveyWithLogic.questions[5].logic![0].conditions,
mockSurveyWithLogic.blocks[0].logic![5].conditions,
"default"
);
expect(result).toBe(true);
+26 -9
View File
@@ -15,7 +15,13 @@ import { getActionClasses } from "../actionClass/service";
import { ITEMS_PER_PAGE } from "../constants";
import { capturePosthogEnvironmentEvent } from "../posthogServer";
import { validateInputs } from "../utils/validate";
import { checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
import {
checkForInvalidImagesInQuestions,
checkForInvalidMediaInBlocks,
stripIsDraftFromBlocks,
transformPrismaSurvey,
validateMediaAndPrepareBlocks,
} from "./utils";
interface TriggerUpdate {
create?: Array<{ actionClassId: string }>;
@@ -37,6 +43,7 @@ export const selectSurvey = {
status: true,
welcomeCard: true,
questions: true,
blocks: true,
endings: true,
hiddenFields: true,
variables: true,
@@ -297,6 +304,14 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
checkForInvalidImagesInQuestions(questions);
// Add blocks media validation
if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
const blocksValidation = checkForInvalidMediaInBlocks(updatedSurvey.blocks);
if (!blocksValidation.ok) {
throw new InvalidInputError(blocksValidation.error.message);
}
}
if (languages) {
// Process languages update logic here
// Extract currentLanguageIds and updatedLanguageIds
@@ -504,6 +519,11 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
return rest;
});
// Strip isDraft from elements before saving
if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
data.blocks = stripIsDraftFromBlocks(updatedSurvey.blocks);
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
@@ -608,6 +628,11 @@ export const createSurvey = async (
checkForInvalidImagesInQuestions(data.questions);
}
// Validate and prepare blocks for persistence
if (data.blocks && data.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(data.blocks);
}
const survey = await prisma.survey.create({
data: {
...data,
@@ -622,14 +647,6 @@ export const createSurvey = async (
// if the survey created is an "app" survey, we also create a private segment for it.
if (survey.type === "app") {
// const newSegment = await createSegment({
// environmentId: parsedEnvironmentId,
// surveyId: survey.id,
// filters: [],
// title: survey.id,
// isPrivate: true,
// });
const newSegment = await prisma.segment.create({
data: {
title: survey.id,
+424 -1
View File
@@ -2,9 +2,17 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { InvalidInputError } from "@formbricks/types/errors";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSegment } from "@formbricks/types/segment";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import * as videoValidation from "@/lib/utils/video-upload";
import * as fileValidation from "@/modules/storage/utils";
import { anySurveyHasFilters, checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
import {
anySurveyHasFilters,
checkForInvalidImagesInQuestions,
checkForInvalidMediaInBlocks,
transformPrismaSurvey,
} from "./utils";
describe("transformPrismaSurvey", () => {
test("transforms prisma survey without segment", () => {
@@ -252,3 +260,418 @@ describe("checkForInvalidImagesInQuestions", () => {
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(3, "image3.jpg");
});
});
describe("checkForInvalidMediaInBlocks", () => {
beforeEach(() => {
vi.resetAllMocks();
});
test("returns ok when blocks array is empty", () => {
const blocks: TSurveyBlock[] = [];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(true);
});
test("returns ok when blocks have no images", () => {
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Block 1",
elements: [
{
id: "elem-1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Question" },
required: false,
inputType: "text",
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(true);
});
test("returns ok when all element images are valid", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Block 1",
elements: [
{
id: "elem-1",
type: TSurveyElementTypeEnum.PictureSelection,
headline: { default: "Question" },
required: false,
choices: [
{ id: "c1", label: { default: "Option 1" }, imageUrl: "image1.jpg" },
{ id: "c2", label: { default: "Option 2" }, imageUrl: "image2.jpg" },
],
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(true);
expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image1.jpg");
expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image2.jpg");
});
test("returns error when element image is invalid", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(false);
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Welcome Block",
elements: [
{
id: "welcome",
type: TSurveyElementTypeEnum.PictureSelection,
headline: { default: "Welcome" },
required: false,
choices: [
{ id: "c1", label: { default: "Option 1" }, imageUrl: "image1.jpg" },
{ id: "c2", label: { default: "Option 2" }, imageUrl: "image2.jpg" },
],
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(false);
if (!result.ok) {
console.log(result.error);
expect(result.error.message).toBe(
'Invalid image URL in choice 1 of question 1 of block "Welcome Block"'
);
}
expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image1.jpg");
});
test("returns ok when all choice images are valid", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Choice Block",
elements: [
{
id: "choice-q",
type: TSurveyElementTypeEnum.PictureSelection,
headline: { default: "Pick one" },
required: true,
choices: [
{ id: "c1", imageUrl: "image1.jpg" },
{ id: "c2", imageUrl: "image2.jpg" },
],
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(true);
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2);
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "image1.jpg");
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "image2.jpg");
});
test("returns error when choice image is invalid", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockImplementation((url) => url === "valid.jpg");
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Picture Selection",
elements: [
{
id: "pic-select",
type: TSurveyElementTypeEnum.PictureSelection,
headline: { default: "Select a picture" },
required: true,
choices: [
{ id: "c1", imageUrl: "valid.jpg" },
{ id: "c2", imageUrl: "invalid.txt" },
],
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe(
'Invalid image URL in choice 2 of question 1 of block "Picture Selection"'
);
}
});
test("returns ok when video URL is valid (YouTube)", () => {
vi.spyOn(videoValidation, "isValidVideoUrl").mockReturnValue(true);
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Video Block",
elements: [
{
id: "video-q",
type: TSurveyElementTypeEnum.CTA,
headline: { default: "Watch this" },
required: false,
videoUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(true);
expect(videoValidation.isValidVideoUrl).toHaveBeenCalledWith(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ"
);
});
test("returns error when video URL is invalid (not YouTube/Vimeo/Loom)", () => {
vi.spyOn(videoValidation, "isValidVideoUrl").mockReturnValue(false);
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Video Block",
elements: [
{
id: "video-q",
type: TSurveyElementTypeEnum.CTA,
headline: { default: "Watch this" },
required: false,
videoUrl: "https://example.com/video.mp4",
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("Invalid video URL");
expect(result.error.message).toContain("question 1");
expect(result.error.message).toContain("YouTube, Vimeo, and Loom");
}
});
test("validates images across multiple blocks", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Block 1",
elements: [
{
id: "elem-1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
inputType: "text",
imageUrl: "image1.jpg",
} as unknown as TSurveyElement,
],
},
{
id: "block-2",
name: "Block 2",
elements: [
{
id: "elem-2",
type: TSurveyElementTypeEnum.Rating,
headline: { default: "Q2" },
required: true,
range: 5,
scale: "star",
imageUrl: "image2.jpg",
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(true);
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2);
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "image1.jpg");
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "image2.jpg");
});
test("stops at first invalid image and returns specific error", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockImplementation((url) => url !== "bad-image.gif");
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Block 1",
elements: [
{
id: "elem-1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
inputType: "text",
imageUrl: "good.jpg",
} as unknown as TSurveyElement,
],
},
{
id: "block-2",
name: "Block 2",
elements: [
{
id: "elem-2",
type: TSurveyElementTypeEnum.CTA,
headline: { default: "Q2" },
required: false,
imageUrl: "bad-image.gif",
} as unknown as TSurveyElement,
{
id: "elem-3",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q3" },
required: false,
inputType: "text",
imageUrl: "another.jpg",
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe('Invalid image URL in question 1 of block "Block 2" (block 2)');
}
// Should stop after finding first invalid image
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2);
});
test("validates choices without imageUrl (skips gracefully)", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Choice Block",
elements: [
{
id: "mc-q",
type: TSurveyElementTypeEnum.PictureSelection,
headline: { default: "Pick one" },
required: true,
choices: [{ id: "c1", imageUrl: "image.jpg" }],
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(true);
// Only validates the one with imageUrl
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(1);
expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image.jpg");
});
test("handles multiple elements in single block", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Multi-Element Block",
elements: [
{
id: "elem-1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
inputType: "text",
imageUrl: "img1.jpg",
} as unknown as TSurveyElement,
{
id: "elem-2",
type: TSurveyElementTypeEnum.Rating,
headline: { default: "Q2" },
required: true,
range: 5,
scale: "number",
imageUrl: "img2.jpg",
} as unknown as TSurveyElement,
{
id: "elem-3",
type: TSurveyElementTypeEnum.CTA,
headline: { default: "Q3" },
required: false,
imageUrl: "img3.jpg",
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(true);
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(3);
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "img1.jpg");
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "img2.jpg");
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(3, "img3.jpg");
});
test("validates both element imageUrl and choice imageUrls", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Complex Block",
elements: [
{
id: "elem-1",
type: TSurveyElementTypeEnum.PictureSelection,
headline: { default: "Choose" },
required: true,
imageUrl: "element-image.jpg",
choices: [
{ id: "c1", imageUrl: "choice1.jpg" },
{ id: "c2", imageUrl: "choice2.jpg" },
],
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(true);
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(3);
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "element-image.jpg");
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "choice1.jpg");
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(3, "choice2.jpg");
});
});
+193
View File
@@ -1,8 +1,16 @@
import "server-only";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { InvalidInputError } from "@formbricks/types/errors";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSegment } from "@formbricks/types/segment";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import {
TSurveyElement,
TSurveyElementTypeEnum,
TSurveyPictureChoice,
} from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { isValidVideoUrl } from "@/lib/utils/video-upload";
import { isValidImageFile } from "@/modules/storage/utils";
export const transformPrismaSurvey = <T extends TSurvey | TJsEnvironmentStateSurvey>(
@@ -56,3 +64,188 @@ export const checkForInvalidImagesInQuestions = (questions: TSurveyQuestion[]) =
}
});
};
/**
* Validates a single choice's image URL
* @param choice - Choice to validate
* @param choiceIdx - Index of the choice for error reporting
* @param questionIdx - Index of the question for error reporting
* @param blockName - Block name for error reporting
* @returns Result with void data on success or Error on failure
*/
const validateChoiceImage = (
choice: TSurveyPictureChoice,
choiceIdx: number,
questionIdx: number,
blockName: string
): Result<void, Error> => {
if (choice.imageUrl && !isValidImageFile(choice.imageUrl)) {
return err(
new Error(
`Invalid image URL in choice ${choiceIdx + 1} of question ${questionIdx + 1} of block "${blockName}"`
)
);
}
return ok(undefined);
};
/**
* Validates choice images for picture selection elements
* Only picture selection questions have imageUrl in choices
* @param element - Element with choices to validate
* @param questionIdx - Index of the question for error reporting
* @param blockName - Block name for error reporting
* @returns Result with void data on success or Error on failure
*/
const validatePictureSelectionChoiceImages = (
element: TSurveyElement,
questionIdx: number,
blockName: string
): Result<void, Error> => {
// Only validate choices for picture selection questions
if (element.type !== TSurveyElementTypeEnum.PictureSelection) {
return ok(undefined);
}
if (!("choices" in element) || !Array.isArray(element.choices)) {
return ok(undefined);
}
for (let choiceIdx = 0; choiceIdx < element.choices.length; choiceIdx++) {
const result = validateChoiceImage(element.choices[choiceIdx], choiceIdx, questionIdx, blockName);
if (!result.ok) {
return result;
}
}
return ok(undefined);
};
/**
* Validates a single element's image URL, video URL, and picture selection choice images
* @param element - Element to validate
* @param elementIdx - Index of the element for error reporting
* @param blockIdx - Index of the block for error reporting
* @param blockName - Block name for error reporting
* @returns Result with void data on success or Error on failure
*/
const validateElement = (
element: TSurveyElement,
elementIdx: number,
blockIdx: number,
blockName: string
): Result<void, Error> => {
// Check element imageUrl
if (element.imageUrl && !isValidImageFile(element.imageUrl)) {
return err(
new Error(
`Invalid image URL in question ${elementIdx + 1} of block "${blockName}" (block ${blockIdx + 1})`
)
);
}
// Check element videoUrl
if (element.videoUrl && !isValidVideoUrl(element.videoUrl)) {
return err(
new Error(
`Invalid video URL in question ${elementIdx + 1} of block "${blockName}" (block ${blockIdx + 1}). Only YouTube, Vimeo, and Loom URLs are supported.`
)
);
}
// Check choices for picture selection
return validatePictureSelectionChoiceImages(element, elementIdx, blockName);
};
/**
* Validates that all media URLs (images and videos) in blocks are valid
* - Validates element imageUrl
* - Validates element videoUrl
* - Validates choice imageUrl for picture selection elements
* @param blocks - Array of survey blocks to validate
* @returns Result with void data on success or Error on failure
*/
export const checkForInvalidMediaInBlocks = (blocks: TSurveyBlock[]): Result<void, Error> => {
for (let blockIdx = 0; blockIdx < blocks.length; blockIdx++) {
const block = blocks[blockIdx];
for (let elementIdx = 0; elementIdx < block.elements.length; elementIdx++) {
const result = validateElement(block.elements[elementIdx], elementIdx, blockIdx, block.name);
if (!result.ok) {
return result;
}
}
}
return ok(undefined);
};
/**
* Strips isDraft field from elements before saving to database
* Note: Blocks don't have isDraft since block IDs are CUIDs (not user-editable)
* Only element IDs need protection as they're user-editable and used in responses
* @param blocks - Array of survey blocks
* @returns New array with isDraft stripped from all elements
*/
export const stripIsDraftFromBlocks = (blocks: TSurveyBlock[]): TSurveyBlock[] => {
return blocks.map((block) => ({
...block,
elements: block.elements.map((element) => {
const { isDraft, ...elementRest } = element;
return elementRest;
}),
}));
};
/**
* Validates and prepares blocks for persistence
* - Validates all media URLs (images and videos) in blocks
* - Strips isDraft flags from elements
* @param blocks - Array of survey blocks to validate and prepare
* @returns Prepared blocks ready for database persistence
* @throws Error if any media validation fails
*/
export const validateMediaAndPrepareBlocks = (blocks: TSurveyBlock[]): TSurveyBlock[] => {
// Validate media (images and videos)
const validation = checkForInvalidMediaInBlocks(blocks);
if (!validation.ok) {
throw validation.error;
}
// Strip isDraft
return stripIsDraftFromBlocks(blocks);
};
/**
* Derives a flat array of elements from the survey's blocks structure
* Useful for server-side processing where we need to iterate over all questions
* Note: This is duplicated from the client-side survey utils since this file is server-only
* @param blocks - Array of survey blocks
* @returns Flat array of all elements across all blocks
*/
export const getElementsFromBlocks = (blocks: TSurveyBlock[]): TSurveyElement[] => {
return blocks.flatMap((block) => block.elements);
};
/**
* Find the location of an element within the survey blocks
* @param survey - The survey object
* @param elementId - The ID of the element to find
* @returns Object containing blockId, blockIndex, elementIndex and the block
*/
export const findElementLocation = (
survey: TSurvey,
elementId: string
): { blockId: string | null; blockIndex: number; elementIndex: number; block: TSurveyBlock | null } => {
const blocks = survey.blocks;
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
const block = blocks[blockIndex];
const elementIndex = block.elements.findIndex((e) => e.id === elementId);
if (elementIndex !== -1) {
return { blockId: block.id, blockIndex, elementIndex, block };
}
}
return { blockId: null, blockIndex: -1, elementIndex: -1, block: null };
};
+137 -125
View File
@@ -1,13 +1,10 @@
import { describe, expect, test, vi } from "vitest";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
import {
TConditionGroup,
TSingleCondition,
TSurveyLogic,
TSurveyLogicAction,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TSurveyBlockLogic, TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic";
import { TSurveyLogicAction } from "@formbricks/types/surveys/types";
import {
addConditionBelow,
createGroupFromResource,
@@ -36,9 +33,6 @@ describe("surveyLogic", () => {
type: "link",
status: "inProgress",
welcomeCard: {
html: {
default: "Thanks for providing your feedback - let's go!",
},
enabled: false,
headline: {
default: "Welcome!",
@@ -49,25 +43,28 @@ describe("surveyLogic", () => {
timeToFinish: false,
showResponseCount: false,
},
questions: [
blocks: [
{
id: "vjniuob08ggl8dewl0hwed41",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "What would you like to know?",
},
required: true,
charLimit: {},
inputType: "email",
longAnswer: false,
buttonLabel: {
default: "Next",
},
placeholder: {
default: "example@email.com",
},
id: "block1",
name: "Block 1",
elements: [
{
id: "vjniuob08ggl8dewl0hwed41",
type: TSurveyElementTypeEnum.OpenText,
headline: {
default: "What would you like to know?",
},
required: true,
charLimit: { enabled: false },
inputType: "email",
placeholder: {
default: "example@email.com",
},
},
],
},
],
questions: [],
endings: [
{
id: "gt1yoaeb5a3istszxqbl08mk",
@@ -132,7 +129,7 @@ describe("surveyLogic", () => {
});
test("duplicateLogicItem duplicates IDs recursively", () => {
const logic: TSurveyLogic = {
const logic: TSurveyBlockLogic = {
id: "L1",
conditions: simpleGroup(),
actions: [{ id: "A1", objective: "requireAnswer", target: "q1" }],
@@ -211,13 +208,13 @@ describe("surveyLogic", () => {
});
test("getUpdatedActionBody returns new action bodies correctly", () => {
const base: TSurveyLogicAction = { id: "A", objective: "requireAnswer", target: "q" };
const base: TSurveyBlockLogicAction = { id: "A", objective: "requireAnswer", target: "q" };
const calc = getUpdatedActionBody(base, "calculate");
expect(calc.objective).toBe("calculate");
const req = getUpdatedActionBody(calc, "requireAnswer");
expect(req.objective).toBe("requireAnswer");
const jump = getUpdatedActionBody(req, "jumpToQuestion");
expect(jump.objective).toBe("jumpToQuestion");
const jump = getUpdatedActionBody(req, "jumpToBlock");
expect(jump.objective).toBe("jumpToBlock");
});
test("evaluateLogic handles AND/OR groups and single conditions", () => {
@@ -249,7 +246,7 @@ describe("surveyLogic", () => {
test("performActions calculates, requires, and jumps correctly", () => {
const data: TResponseData = { q: "5" };
const initialVars: TResponseVariables = {};
const actions: TSurveyLogicAction[] = [
const actions: TSurveyBlockLogicAction[] = [
{
id: "a1",
objective: "calculate",
@@ -258,7 +255,7 @@ describe("surveyLogic", () => {
value: { type: "static", value: 3 },
},
{ id: "a2", objective: "requireAnswer", target: "q2" },
{ id: "a3", objective: "jumpToQuestion", target: "q3" },
{ id: "a3", objective: "jumpToBlock", target: "q3" },
];
const result = performActions(mockSurvey, actions, data, initialVars);
expect(result.calculations.v).toBe(3);
@@ -463,7 +460,7 @@ describe("surveyLogic", () => {
variables: [{ id: "v", name: "num", type: "number", value: 0 }],
};
const data: TResponseData = { q: 2 };
const actions: TSurveyLogicAction[] = [
const actions: TSurveyBlockLogicAction[] = [
{
id: "a1",
objective: "calculate",
@@ -750,84 +747,87 @@ describe("surveyLogic", () => {
test("getLeftOperandValue handles different question types", () => {
const surveyWithQuestions: TJsEnvironmentStateSurvey = {
...mockSurvey,
questions: [
...mockSurvey.questions,
blocks: [
{
id: "numQuestion",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Number question" },
required: true,
inputType: "number",
charLimit: { enabled: false },
},
{
id: "mcSingle",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "MC Single" },
required: true,
choices: [
{ id: "choice1", label: { default: "Choice 1" } },
{ id: "choice2", label: { default: "Choice 2" } },
{ id: "other", label: { default: "Other" } },
id: "block1",
name: "Block 1",
elements: [
...mockSurvey.blocks[0].elements,
{
id: "numQuestion",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Number question" },
required: true,
inputType: "number",
charLimit: { enabled: false },
},
{
id: "mcSingle",
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: { default: "MC Single" },
required: true,
choices: [
{ id: "choice1", label: { default: "Choice 1" } },
{ id: "choice2", label: { default: "Choice 2" } },
{ id: "other", label: { default: "Other" } },
],
shuffleOption: "none",
},
{
id: "mcMulti",
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
headline: { default: "MC Multi" },
required: true,
choices: [
{ id: "choice1", label: { default: "Choice 1" } },
{ id: "choice2", label: { default: "Choice 2" } },
],
shuffleOption: "none",
},
{
id: "matrixQ",
type: TSurveyElementTypeEnum.Matrix,
headline: { default: "Matrix Question" },
required: true,
rows: [
{ id: "row-1", label: { default: "Row 1" } },
{ id: "row-2", label: { default: "Row 2" } },
],
columns: [
{ id: "col-1", label: { default: "Column 1" } },
{ id: "col-2", label: { default: "Column 2" } },
],
shuffleOption: "none",
},
{
id: "pictureQ",
type: TSurveyElementTypeEnum.PictureSelection,
allowMulti: false,
headline: { default: "Picture Selection" },
required: true,
choices: [
{ id: "pic1", imageUrl: "url1" },
{ id: "pic2", imageUrl: "url2" },
],
},
{
id: "dateQ",
type: TSurveyElementTypeEnum.Date,
format: "M-d-y",
headline: { default: "Date Question" },
required: true,
},
{
id: "fileQ",
type: TSurveyElementTypeEnum.FileUpload,
allowMultipleFiles: false,
headline: { default: "File Upload" },
required: true,
},
],
buttonLabel: { default: "Next" },
},
{
id: "mcMulti",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "MC Multi" },
required: true,
choices: [
{ id: "choice1", label: { default: "Choice 1" } },
{ id: "choice2", label: { default: "Choice 2" } },
],
buttonLabel: { default: "Next" },
},
{
id: "matrixQ",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix Question" },
required: true,
rows: [
{ id: "row-1", label: { default: "Row 1" } },
{ id: "row-2", label: { default: "Row 2" } },
],
columns: [
{ id: "col-1", label: { default: "Column 1" } },
{ id: "col-2", label: { default: "Column 2" } },
],
buttonLabel: { default: "Next" },
shuffleOption: "none",
},
{
id: "pictureQ",
type: TSurveyQuestionTypeEnum.PictureSelection,
allowMulti: false,
headline: { default: "Picture Selection" },
required: true,
choices: [
{ id: "pic1", imageUrl: "url1" },
{ id: "pic2", imageUrl: "url2" },
],
buttonLabel: { default: "Next" },
},
{
id: "dateQ",
type: TSurveyQuestionTypeEnum.Date,
format: "M-d-y",
headline: { default: "Date Question" },
required: true,
buttonLabel: { default: "Next" },
},
{
id: "fileQ",
type: TSurveyQuestionTypeEnum.FileUpload,
allowMultipleFiles: false,
headline: { default: "File Upload" },
required: true,
buttonLabel: { default: "Next" },
},
],
questions: [],
variables: [
{ id: "numVar", name: "numberVar", type: "number", value: 5 },
{ id: "textVar", name: "textVar", type: "text", value: "hello" },
@@ -1008,17 +1008,24 @@ describe("surveyLogic", () => {
test("getRightOperandValue handles different data types and sources", () => {
const surveyWithVars: TJsEnvironmentStateSurvey = {
...mockSurvey,
questions: [
...mockSurvey.questions,
blocks: [
{
id: "question1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
inputType: "text",
charLimit: { enabled: false },
id: "block1",
name: "Block 1",
elements: [
...mockSurvey.blocks[0].elements,
{
id: "question1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
inputType: "text",
charLimit: { enabled: false },
},
],
},
],
questions: [],
variables: [
{ id: "numVar", name: "numberVar", type: "number", value: 5 },
{ id: "textVar", name: "textVar", type: "text", value: "hello" },
@@ -1319,19 +1326,24 @@ describe("surveyLogic", () => {
test("getLeftOperandValue handles number input type with non-number value", () => {
const surveyWithNumberInput: TJsEnvironmentStateSurvey = {
...mockSurvey,
questions: [
blocks: [
{
id: "numQuestion",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Number question" },
required: true,
inputType: "number",
placeholder: { default: "Enter a number" },
buttonLabel: { default: "Next" },
longAnswer: false,
charLimit: {},
id: "block1",
name: "Block 1",
elements: [
{
id: "numQuestion",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Number question" },
required: true,
inputType: "number",
placeholder: { default: "Enter a number" },
charLimit: { enabled: false },
},
],
},
],
questions: [],
};
const condition: TSingleCondition = {
+36 -34
View File
@@ -2,17 +2,15 @@ import { createId } from "@paralleldrive/cuid2";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TResponseData, TResponseVariables } from "@formbricks/types/responses";
import {
TActionCalculate,
TActionObjective,
TConditionGroup,
TSingleCondition,
TSurveyLogic,
TSurveyLogicAction,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyVariable,
} from "@formbricks/types/surveys/types";
TSurveyBlockLogic,
TSurveyBlockLogicAction,
TSurveyBlockLogicActionObjective,
} from "@formbricks/types/surveys/blocks";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic";
import { TActionCalculate, TSurveyLogicAction, TSurveyVariable } from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
type TCondition = TSingleCondition | TConditionGroup;
@@ -20,7 +18,7 @@ export const isConditionGroup = (condition: TCondition): condition is TCondition
return (condition as TConditionGroup).connector !== undefined;
};
export const duplicateLogicItem = (logicItem: TSurveyLogic): TSurveyLogic => {
export const duplicateLogicItem = (logicItem: TSurveyBlockLogic): TSurveyBlockLogic => {
const duplicateConditionGroup = (group: TConditionGroup): TConditionGroup => {
return {
...group,
@@ -42,7 +40,7 @@ export const duplicateLogicItem = (logicItem: TSurveyLogic): TSurveyLogic => {
};
};
const duplicateAction = (action: TSurveyLogicAction): TSurveyLogicAction => {
const duplicateAction = (action: TSurveyBlockLogicAction): TSurveyBlockLogicAction => {
return {
...action,
id: createId(),
@@ -198,9 +196,9 @@ export const updateCondition = (
};
export const getUpdatedActionBody = (
action: TSurveyLogicAction,
objective: TActionObjective
): TSurveyLogicAction => {
action: TSurveyBlockLogicAction,
objective: TSurveyBlockLogicActionObjective
): TSurveyBlockLogicAction => {
if (objective === action.objective) return action;
switch (objective) {
case "calculate":
@@ -217,12 +215,14 @@ export const getUpdatedActionBody = (
objective: "requireAnswer",
target: "",
};
case "jumpToQuestion":
case "jumpToBlock":
return {
id: action.id,
objective: "jumpToQuestion",
objective: "jumpToBlock",
target: "",
};
default:
return action;
}
};
@@ -263,14 +263,17 @@ const evaluateSingleCondition = (
condition.leftOperand,
selectedLanguage
);
let rightValue = condition.rightOperand
? getRightOperandValue(localSurvey, data, variablesData, condition.rightOperand)
: undefined;
let leftField: TSurveyQuestion | TSurveyVariable | string;
const questions = getElementsFromBlocks(localSurvey.blocks);
let leftField: TSurveyElement | TSurveyVariable | string;
if (condition.leftOperand?.type === "question") {
leftField = localSurvey.questions.find((q) => q.id === condition.leftOperand?.value) as TSurveyQuestion;
leftField = questions.find((q) => q.id === condition.leftOperand?.value) ?? "";
} else if (condition.leftOperand?.type === "variable") {
leftField = localSurvey.variables.find((v) => v.id === condition.leftOperand?.value) as TSurveyVariable;
} else if (condition.leftOperand?.type === "hiddenField") {
@@ -279,12 +282,10 @@ const evaluateSingleCondition = (
leftField = "";
}
let rightField: TSurveyQuestion | TSurveyVariable | string;
let rightField: TSurveyElement | TSurveyVariable | string;
if (condition.rightOperand?.type === "question") {
rightField = localSurvey.questions.find(
(q) => q.id === condition.rightOperand?.value
) as TSurveyQuestion;
rightField = questions.find((q) => q.id === condition.rightOperand?.value) ?? "";
} else if (condition.rightOperand?.type === "variable") {
rightField = localSurvey.variables.find(
(v) => v.id === condition.rightOperand?.value
@@ -307,7 +308,7 @@ const evaluateSingleCondition = (
case "equals":
if (condition.leftOperand.type === "question") {
if (
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
(leftField as TSurveyElement).type === TSurveyElementTypeEnum.Date &&
typeof leftValue === "string" &&
typeof rightValue === "string"
) {
@@ -318,12 +319,12 @@ const evaluateSingleCondition = (
// when left value is of openText, hiddenField, variable and right value is of multichoice
if (condition.rightOperand?.type === "question") {
if ((rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
if ((rightField as TSurveyElement).type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) {
return rightValue.includes(leftValue as string);
} else return false;
} else if (
(rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
(rightField as TSurveyElement).type === TSurveyElementTypeEnum.Date &&
typeof leftValue === "string" &&
typeof rightValue === "string"
) {
@@ -342,7 +343,7 @@ const evaluateSingleCondition = (
// when left value is of picture selection question and right value is its option
if (
condition.leftOperand.type === "question" &&
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.PictureSelection &&
(leftField as TSurveyElement).type === TSurveyElementTypeEnum.PictureSelection &&
Array.isArray(leftValue) &&
leftValue.length > 0 &&
typeof rightValue === "string"
@@ -353,7 +354,7 @@ const evaluateSingleCondition = (
// when left value is of date question and right value is string
if (
condition.leftOperand.type === "question" &&
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
(leftField as TSurveyElement).type === TSurveyElementTypeEnum.Date &&
typeof leftValue === "string" &&
typeof rightValue === "string"
) {
@@ -362,12 +363,12 @@ const evaluateSingleCondition = (
// when left value is of openText, hiddenField, variable and right value is of multichoice
if (condition.rightOperand?.type === "question") {
if ((rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
if ((rightField as TSurveyElement).type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
if (Array.isArray(rightValue) && typeof leftValue === "string" && rightValue.length === 1) {
return !rightValue.includes(leftValue as string);
} else return false;
} else if (
(rightField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.Date &&
(rightField as TSurveyElement).type === TSurveyElementTypeEnum.Date &&
typeof leftValue === "string" &&
typeof rightValue === "string"
) {
@@ -398,7 +399,7 @@ const evaluateSingleCondition = (
if (typeof leftValue === "string") {
if (
condition.leftOperand.type === "question" &&
(leftField as TSurveyQuestion).type === TSurveyQuestionTypeEnum.FileUpload &&
(leftField as TSurveyElement).type === TSurveyElementTypeEnum.FileUpload &&
leftValue
) {
return leftValue !== "skipped";
@@ -511,7 +512,8 @@ const getLeftOperandValue = (
) => {
switch (leftOperand.type) {
case "question":
const currentQuestion = localSurvey.questions.find((q) => q.id === leftOperand.value);
const questions = getElementsFromBlocks(localSurvey.blocks);
const currentQuestion = questions.find((q) => q.id === leftOperand.value);
if (!currentQuestion) return undefined;
const responseValue = data[leftOperand.value];
@@ -623,7 +625,7 @@ const getRightOperandValue = (
export const performActions = (
survey: TJsEnvironmentStateSurvey,
actions: TSurveyLogicAction[],
actions: TSurveyBlockLogicAction[] | TSurveyLogicAction[],
data: TResponseData,
calculationResults: TResponseVariables
): {
@@ -644,7 +646,7 @@ export const performActions = (
case "requireAnswer":
requiredQuestionIds.push(action.target);
break;
case "jumpToQuestion":
case "jumpToBlock":
if (!jumpTarget) {
jumpTarget = action.target;
}
+1 -5
View File
@@ -1,5 +1,5 @@
import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
import { de, enUS, fr, ja, pt, ptBR, ro, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
export const convertDateString = (dateString: string | null) => {
@@ -91,8 +91,6 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
return ptBR;
case "fr-FR":
return fr;
case "nl-NL":
return nl;
case "zh-Hant-TW":
return zhTW;
case "pt-PT":
@@ -103,8 +101,6 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
return ja;
case "zh-Hans-CN":
return zhCN;
case "es-ES":
return es;
}
};
+58 -33
View File
@@ -145,7 +145,7 @@ describe("recall utility functions", () => {
const headline = { en: "How do you like #recall:product/fallback:ournbspproduct#?" };
const survey = {
id: "test-survey",
questions: [{ id: "product", headline: { en: "Product Question" } }],
blocks: [{ id: "b1", elements: [{ id: "product", headline: { en: "Product Question" } }] }],
hiddenFields: { fieldIds: [] },
variables: [],
} as any;
@@ -158,7 +158,7 @@ describe("recall utility functions", () => {
const headline = { en: "Rate #recall:product/fallback:ournbspproduct#" };
const survey = {
id: "test-survey",
questions: [{ id: "product", headline: { en: "Product Question" } }],
blocks: [{ id: "b1", elements: [{ id: "product", headline: { en: "Product Question" } }] }],
hiddenFields: { fieldIds: [] },
variables: [],
} as any;
@@ -171,7 +171,7 @@ describe("recall utility functions", () => {
const headline = { en: "Your email is #recall:email/fallback:notnbspprovided#" };
const survey: TSurvey = {
id: "test-survey",
questions: [],
blocks: [],
hiddenFields: { fieldIds: ["email"] },
variables: [],
} as unknown as TSurvey;
@@ -184,7 +184,7 @@ describe("recall utility functions", () => {
const headline = { en: "Your plan is #recall:plan/fallback:unknown#" };
const survey: TSurvey = {
id: "test-survey",
questions: [],
blocks: [],
hiddenFields: { fieldIds: [] },
variables: [{ id: "plan", name: "Subscription Plan" }],
} as unknown as TSurvey;
@@ -207,7 +207,7 @@ describe("recall utility functions", () => {
};
const survey = {
id: "test-survey",
questions: [{ id: "inner", headline: { en: "Inner with @outer" } }],
blocks: [{ id: "b1", elements: [{ id: "inner", headline: { en: "Inner with @outer" } }] }],
hiddenFields: { fieldIds: [] },
variables: [],
} as any;
@@ -241,41 +241,56 @@ describe("recall utility functions", () => {
test("identifies question with empty fallback value", () => {
const questionHeadline = { en: "Question with #recall:id1/fallback:# empty fallback" };
const survey = {
questions: [
blocks: [
{
id: "q1",
headline: questionHeadline,
id: "b1",
elements: [
{
id: "q1",
headline: questionHeadline,
},
],
},
],
} as any;
const result = checkForEmptyFallBackValue(survey, "en");
expect(result).toBe(survey.questions[0]);
expect(result).toBe(survey.blocks[0].elements[0]);
});
test("identifies question with empty fallback in subheader", () => {
const questionSubheader = { en: "Subheader with #recall:id1/fallback:# empty fallback" };
const survey = {
questions: [
blocks: [
{
id: "q1",
headline: { en: "Normal question" },
subheader: questionSubheader,
id: "b1",
elements: [
{
id: "q1",
headline: { en: "Normal question" },
subheader: questionSubheader,
},
],
},
],
} as any;
const result = checkForEmptyFallBackValue(survey, "en");
expect(result).toBe(survey.questions[0]);
expect(result).toBe(survey.blocks[0].elements[0]);
});
test("returns null when no empty fallback values are found", () => {
const questionHeadline = { en: "Question with #recall:id1/fallback:default# valid fallback" };
const survey = {
questions: [
blocks: [
{
id: "q1",
headline: questionHeadline,
id: "b1",
elements: [
{
id: "q1",
headline: questionHeadline,
},
],
},
],
} as any;
@@ -288,16 +303,21 @@ describe("recall utility functions", () => {
describe("replaceHeadlineRecall", () => {
test("processes all questions in a survey", () => {
const survey: TSurvey = {
questions: [
blocks: [
{
id: "q1",
headline: { en: "Question with #recall:id1/fallback:default#" },
id: "b1",
elements: [
{
id: "q1",
headline: { en: "Question with #recall:id1/fallback:default#" },
},
{
id: "q2",
headline: { en: "Another with #recall:id2/fallback:other#" },
},
],
},
{
id: "q2",
headline: { en: "Another with #recall:id2/fallback:other#" },
},
] as unknown as TSurveyQuestion[],
],
hiddenFields: { fieldIds: [] },
variables: [],
} as unknown as TSurvey;
@@ -308,8 +328,8 @@ describe("recall utility functions", () => {
// Verify recallToHeadline was called for each question
expect(result).not.toBe(survey); // Should be a clone
expect(result.questions[0].headline).not.toEqual(survey.questions[0].headline);
expect(result.questions[1].headline).not.toEqual(survey.questions[1].headline);
expect(result.blocks[0].elements[0].headline).not.toEqual(survey.blocks[0].elements[0].headline);
expect(result.blocks[0].elements[1].headline).not.toEqual(survey.blocks[0].elements[1].headline);
});
});
@@ -317,10 +337,15 @@ describe("recall utility functions", () => {
test("extracts recall items from text", () => {
const text = "Text with #recall:id1/fallback:val1# and #recall:id2/fallback:val2#";
const survey: TSurvey = {
questions: [
{ id: "id1", headline: { en: "Question One" } },
{ id: "id2", headline: { en: "Question Two" } },
] as unknown as TSurveyQuestion[],
blocks: [
{
id: "b1",
elements: [
{ id: "id1", headline: { en: "Question One" } },
{ id: "id2", headline: { en: "Question Two" } },
],
},
],
hiddenFields: { fieldIds: [] },
variables: [],
} as unknown as TSurvey;
@@ -339,7 +364,7 @@ describe("recall utility functions", () => {
test("handles hidden fields in recall items", () => {
const text = "Text with #recall:hidden1/fallback:val1#";
const survey: TSurvey = {
questions: [],
blocks: [],
hiddenFields: { fieldIds: ["hidden1"] },
variables: [],
} as unknown as TSurvey;
@@ -354,7 +379,7 @@ describe("recall utility functions", () => {
test("handles variables in recall items", () => {
const text = "Text with #recall:var1/fallback:val1#";
const survey: TSurvey = {
questions: [],
blocks: [],
hiddenFields: { fieldIds: [] },
variables: [{ id: "var1", name: "Variable One" }],
} as unknown as TSurvey;
+13 -6
View File
@@ -1,8 +1,11 @@
import { type TI18nString } from "@formbricks/types/i18n";
import { TResponseData, TResponseDataValue, TResponseVariables } from "@formbricks/types/responses";
import { TI18nString, TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
export interface fallbacks {
@@ -59,7 +62,8 @@ const getRecallItemLabel = <T extends TSurvey>(
const isHiddenField = survey.hiddenFields.fieldIds?.includes(recallItemId);
if (isHiddenField) return recallItemId;
const surveyQuestion = survey.questions.find((question) => question.id === recallItemId);
const questions = getElementsFromBlocks(survey.blocks);
const surveyQuestion = questions.find((question) => question.id === recallItemId);
if (surveyQuestion) {
const headline = getLocalizedValue(surveyQuestion.headline, languageCode);
// Strip HTML tags to prevent raw HTML from showing in nested recalls
@@ -122,13 +126,14 @@ export const replaceRecallInfoWithUnderline = (label: string): string => {
};
// Checks for survey questions with a "recall" pattern but no fallback value.
export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): TSurveyQuestion | null => {
export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): TSurveyElement | null => {
const doesTextHaveRecall = (text: string) => {
const recalls = text.match(/#recall:[^ ]+/g);
return recalls?.some((recall) => !extractFallbackValue(recall));
};
for (const question of survey.questions) {
const questions = getElementsFromBlocks(survey.blocks);
for (const question of questions) {
if (
doesTextHaveRecall(getLocalizedValue(question.headline, language)) ||
(question.subheader && doesTextHaveRecall(getLocalizedValue(question.subheader, language)))
@@ -142,7 +147,8 @@ export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): T
// Processes each question in a survey to ensure headlines are formatted correctly for recall and return the modified survey.
export const replaceHeadlineRecall = <T extends TSurvey>(survey: T, language: string): T => {
const modifiedSurvey = structuredClone(survey);
modifiedSurvey.questions.forEach((question) => {
const questions = getElementsFromBlocks(modifiedSurvey.blocks);
questions.forEach((question) => {
question.headline = recallToHeadline(question.headline, modifiedSurvey, false, language);
});
return modifiedSurvey;
@@ -156,7 +162,8 @@ export const getRecallItems = (text: string, survey: TSurvey, languageCode: stri
let recallItems: TSurveyRecallItem[] = [];
ids.forEach((recallItemId) => {
const isHiddenField = survey.hiddenFields.fieldIds?.includes(recallItemId);
const isSurveyQuestion = survey.questions.find((question) => question.id === recallItemId);
const questions = getElementsFromBlocks(survey.blocks);
const isSurveyQuestion = questions.find((question) => question.id === recallItemId);
const isVariable = survey.variables.find((variable) => variable.id === recallItemId);
const recallItemLabel = getRecallItemLabel(recallItemId, survey, languageCode);
+78 -123
View File
@@ -1,164 +1,119 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TTemplate } from "@formbricks/types/templates";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { type TProject } from "@formbricks/types/project";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TTemplate } from "@formbricks/types/templates";
import * as i18nUtils from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { replacePresetPlaceholders, replaceQuestionPresetPlaceholders } from "./templates";
import { replaceElementPresetPlaceholders, replacePresetPlaceholders } from "./templates";
// Mock the imported functions
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: vi.fn(),
}));
vi.mock("@/lib/i18n/utils");
vi.mock("@/lib/pollyfills/structuredClone");
vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: vi.fn((obj) => JSON.parse(JSON.stringify(obj))),
}));
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(structuredClone).mockImplementation((obj) => JSON.parse(JSON.stringify(obj)));
// Mock getLocalizedValue to return the value from the object
vi.mocked(i18nUtils.getLocalizedValue).mockImplementation((obj: any, lang: string) => obj?.[lang] || "");
});
describe("Template Utilities", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("replaceElementPresetPlaceholders", () => {
test("returns original element when project is not provided", () => {
const element = {
type: "openText",
headline: { default: "Question about $[projectName]?" },
} as unknown as TSurveyElement;
describe("replaceQuestionPresetPlaceholders", () => {
test("returns original question when project is not provided", () => {
const question: TSurveyQuestion = {
id: "test-id",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "Test Question $[projectName]",
},
} as unknown as TSurveyQuestion;
const result = replaceElementPresetPlaceholders(element, undefined as any);
const result = replaceQuestionPresetPlaceholders(question, undefined as unknown as TProject);
expect(result).toEqual(element);
});
expect(result).toEqual(question);
expect(structuredClone).not.toHaveBeenCalled();
test("replaces projectName placeholder in headline", () => {
const element = {
type: "openText",
headline: { default: "How do you like $[projectName]?" },
} as unknown as TSurveyElement;
const project = {
name: "TestProject",
} as unknown as TProject;
const result = replaceElementPresetPlaceholders(element, project);
// The function directly replaces without calling getLocalizedValue in the test scenario
expect(result.headline?.default).toBe("How do you like TestProject?");
});
test("replaces projectName placeholder in subheader", () => {
const question: TSurveyQuestion = {
id: "test-id",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "Test Question",
},
subheader: {
default: "Subheader for $[projectName]",
},
} as unknown as TSurveyQuestion;
const element = {
type: "openText",
headline: { default: "Question" },
subheader: { default: "Subheader for $[projectName]" },
} as unknown as TSurveyElement;
const project: TProject = {
id: "project-id",
name: "Test Project",
organizationId: "org-id",
const project = {
name: "TestProject",
} as unknown as TProject;
// Mock for headline and subheader with correct return values
vi.mocked(getLocalizedValue).mockReturnValueOnce("Test Question");
vi.mocked(getLocalizedValue).mockReturnValueOnce("Subheader for $[projectName]");
const result = replaceElementPresetPlaceholders(element, project);
const result = replaceQuestionPresetPlaceholders(question, project);
expect(vi.mocked(getLocalizedValue)).toHaveBeenCalledTimes(2);
expect(result.subheader?.default).toBe("Subheader for Test Project");
expect(result.headline?.default).toBe("Question");
expect(result.subheader?.default).toBe("Subheader for TestProject");
});
test("handles missing headline and subheader", () => {
const question: TSurveyQuestion = {
id: "test-id",
type: TSurveyQuestionTypeEnum.OpenText,
} as unknown as TSurveyQuestion;
const element = {
type: "openText",
} as unknown as TSurveyElement;
const project: TProject = {
id: "project-id",
name: "Test Project",
organizationId: "org-id",
const project = {
name: "TestProject",
} as unknown as TProject;
const result = replaceQuestionPresetPlaceholders(question, project);
const result = replaceElementPresetPlaceholders(element, project);
expect(structuredClone).toHaveBeenCalledWith(question);
expect(result).toEqual(question);
expect(getLocalizedValue).not.toHaveBeenCalled();
expect(structuredClone).toHaveBeenCalledWith(element);
expect(result).toEqual(element);
});
});
describe("replacePresetPlaceholders", () => {
test("replaces projectName placeholder in template name and questions", () => {
const template: TTemplate = {
id: "template-1",
name: "Test Template",
description: "Template Description",
test("replaces projectName placeholder in template name and blocks", () => {
const mockTemplate = {
name: "Template 1",
preset: {
name: "$[projectName] Feedback",
questions: [
welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false },
blocks: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "How do you like $[projectName]?",
},
},
{
id: "q2",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "Another question",
},
subheader: {
default: "About $[projectName]",
},
id: "block1",
name: "Block 1",
elements: [
{
id: "elem1",
type: "openText",
headline: { default: "How would you rate $[projectName]?" },
required: true,
inputType: "text",
},
],
},
],
endings: [],
hiddenFields: { enabled: true, fieldIds: [] },
},
category: "product",
} as unknown as TTemplate;
const project = {
name: "Awesome App",
};
name: "TestProject",
} as TProject;
// Mock getLocalizedValue to return the original strings with placeholders
vi.mocked(getLocalizedValue)
.mockReturnValueOnce("How do you like $[projectName]?")
.mockReturnValueOnce("Another question")
.mockReturnValueOnce("About $[projectName]");
const result = replacePresetPlaceholders(mockTemplate, project);
const result = replacePresetPlaceholders(template, project);
expect(result.preset.name).toBe("Awesome App Feedback");
expect(structuredClone).toHaveBeenCalledWith(template.preset);
// Verify that replaceQuestionPresetPlaceholders was applied to both questions
expect(vi.mocked(getLocalizedValue)).toHaveBeenCalledTimes(3);
expect(result.preset.questions[0].headline?.default).toBe("How do you like Awesome App?");
expect(result.preset.questions[1].subheader?.default).toBe("About Awesome App");
});
test("maintains other template properties", () => {
const template: TTemplate = {
id: "template-1",
name: "Test Template",
description: "Template Description",
preset: {
name: "$[projectName] Feedback",
questions: [],
},
category: "product",
} as unknown as TTemplate;
const project = {
name: "Awesome App",
};
const result = replacePresetPlaceholders(template, project) as unknown as {
name: string;
description: string;
};
expect(result.name).toBe(template.name);
expect(result.description).toBe(template.description);
expect(structuredClone).toHaveBeenCalledWith(mockTemplate.preset);
expect(result.preset.name).toBe("TestProject Feedback");
expect(result.preset.blocks[0].elements[0].headline?.default).toBe("How would you rate TestProject?");
});
});
});
+27 -18
View File
@@ -1,37 +1,46 @@
import { TProject } from "@formbricks/types/project";
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
import { TTemplate } from "@formbricks/types/templates";
import type { TProject } from "@formbricks/types/project";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TTemplate } from "@formbricks/types/templates";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
export const replaceQuestionPresetPlaceholders = (
question: TSurveyQuestion,
export const replaceElementPresetPlaceholders = (
element: TSurveyElement,
project: TProject
): TSurveyQuestion => {
if (!project) return question;
const newQuestion = structuredClone(question);
): TSurveyElement => {
if (!project) return element;
const newElement = structuredClone(element);
const defaultLanguageCode = "default";
if (newQuestion.headline) {
newQuestion.headline[defaultLanguageCode] = getLocalizedValue(
newQuestion.headline,
if (newElement.headline) {
newElement.headline[defaultLanguageCode] = getLocalizedValue(
newElement.headline,
defaultLanguageCode
).replace("$[projectName]", project.name);
}
if (newQuestion.subheader) {
newQuestion.subheader[defaultLanguageCode] = getLocalizedValue(
newQuestion.subheader,
if (newElement.subheader) {
newElement.subheader[defaultLanguageCode] = getLocalizedValue(
newElement.subheader,
defaultLanguageCode
)?.replace("$[projectName]", project.name);
}
return newQuestion;
return newElement;
};
// replace all occurences of projectName with the actual project name in the current template
export const replacePresetPlaceholders = (template: TTemplate, project: any) => {
const preset = structuredClone(template.preset);
preset.name = preset.name.replace("$[projectName]", project.name);
preset.questions = preset.questions.map((question) => {
return replaceQuestionPresetPlaceholders(question, project);
});
// Handle blocks if present
if (preset.blocks && preset.blocks.length > 0) {
preset.blocks = preset.blocks.map((block) => ({
...block,
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
}));
}
return { ...template, preset };
};
+9
View File
@@ -124,3 +124,12 @@ export const convertToEmbedUrl = (url: string): string | undefined => {
// If no supported platform found, return undefined
return undefined;
};
/**
* Validates if a URL is from a supported video platform (YouTube, Vimeo, or Loom)
* @param url - URL to validate
* @returns true if URL is from a supported platform, false otherwise
*/
export const isValidVideoUrl = (url: string): boolean => {
return checkForYoutubeUrl(url) || checkForVimeoUrl(url) || checkForLoomUrl(url);
};
+2 -2
View File
@@ -53,9 +53,9 @@ export const I18nProvider = ({ children, language, defaultLanguage }: I18nProvid
initializeI18n();
}, [locale, defaultLanguage]);
// Don't render children until i18n is ready to prevent race conditions
// Don't render children until i18n is ready to prevent hydration issues
if (!isReady) {
return null;
return <div style={{ visibility: "hidden" }}>{children}</div>;
}
return (
+34 -47
View File
@@ -153,7 +153,6 @@
"clear_filters": "Filter löschen",
"clear_selection": "Auswahl aufheben",
"click": "Klick",
"click_to_filter": "Klicken zum Filtern",
"clicks": "Klicks",
"close": "Schließen",
"code": "Code",
@@ -211,7 +210,6 @@
"error_rate_limit_description": "Maximale Anzahl an Anfragen erreicht. Bitte später erneut versuchen.",
"error_rate_limit_title": "Rate Limit Überschritten",
"expand_rows": "Zeilen erweitern",
"failed_to_copy_to_clipboard": "Fehler beim Kopieren in die Zwischenablage",
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
"failed_to_load_projects": "Fehler beim Laden der Projekte",
"finish": "Fertigstellen",
@@ -220,7 +218,6 @@
"full_name": "Name",
"gathering_responses": "Antworten sammeln",
"general": "Allgemein",
"generate": "Generieren",
"go_back": "Geh zurück",
"go_to_dashboard": "Zum Dashboard gehen",
"hidden": "Versteckt",
@@ -428,7 +425,6 @@
"user_id": "Benutzer-ID",
"user_not_found": "Benutzer nicht gefunden",
"variable": "Variable",
"variable_ids": "Variablen-IDs",
"variables": "Variablen",
"verified_email": "Verifizierte E-Mail",
"video": "Video",
@@ -527,7 +523,6 @@
"add_css_class_or_id": "CSS-Klasse oder ID hinzufügen",
"add_regular_expression_here": "Fügen Sie hier einen regulären Ausdruck hinzu",
"add_url": "URL hinzufügen",
"and": "UND",
"click": "Klicken",
"contains": "enthält",
"create_action": "Aktion erstellen",
@@ -558,7 +553,6 @@
"limit_to_specific_pages": "Auf bestimmte Seiten beschränken",
"matches_regex": "Entspricht Regex",
"on_all_pages": "Auf allen Seiten",
"or": "ODER",
"page_filter": "Seitenfilter",
"page_view": "Seitenansicht",
"select_match_type": "Wähle den Spieltyp aus",
@@ -599,18 +593,9 @@
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
"delete_contact_confirmation_with_quotas": "{value, plural, other {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn dieser Kontakt Antworten hat, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.}}",
"generate_personal_link": "Persönlichen Link generieren",
"generate_personal_link_description": "Wähle eine veröffentlichte Umfrage aus, um einen personalisierten Link für diesen Kontakt zu generieren.",
"no_published_link_surveys_available": "Keine veröffentlichten Link-Umfragen verfügbar. Bitte veröffentliche zuerst eine Link-Umfrage.",
"no_published_surveys": "Keine veröffentlichten Umfragen",
"no_responses_found": "Keine Antworten gefunden",
"not_provided": "Nicht angegeben",
"personal_link_generated": "Persönlicher Link erfolgreich generiert",
"personal_link_generated_but_clipboard_failed": "Persönlicher Link wurde generiert, konnte aber nicht in die Zwischenablage kopiert werden: {url}",
"personal_survey_link": "Link zur persönlichen Umfrage",
"please_select_a_survey": "Bitte wähle eine Umfrage aus",
"search_contact": "Kontakt suchen",
"select_a_survey": "Wähle eine Umfrage aus",
"select_attribute": "Attribut auswählen",
"unlock_contacts_description": "Verwalte Kontakte und sende gezielte Umfragen",
"unlock_contacts_title": "Kontakte mit einem höheren Plan freischalten",
@@ -789,14 +774,14 @@
},
"app-connection": {
"app_connection": "App-Verbindung",
"app_connection_description": "Verbinde deine App oder Website mit Formbricks.",
"cache_update_delay_description": "Wenn Sie Aktualisierungen an Umfragen, Kontakten, Aktionen oder anderen Daten vornehmen, kann es bis zu 1 Minute dauern, bis diese Änderungen in Ihrer lokalen App, die das Formbricks SDK ausführt, erscheinen.",
"cache_update_delay_title": "Änderungen werden aufgrund von Caching nach etwa 1 Minute angezeigt",
"app_connection_description": "Verbinde deine App mit Formbricks.",
"cache_update_delay_description": "Wenn du Aktualisierungen an Umfragen, Kontakten, Aktionen oder anderen Daten vornimmst, kann es bis zu 5 Minuten dauern, bis diese Änderungen in deiner lokalen App, die das Formbricks SDK verwendet, angezeigt werden. Diese Verzögerung ist auf eine Einschränkung unseres aktuellen Caching-Systems zurückzuführen. Wir arbeiten aktiv an einer Überarbeitung des Cache und werden in Formbricks 4.0 eine Lösung veröffentlichen.",
"cache_update_delay_title": "Änderungen werden aufgrund von Caching nach 5 Minuten angezeigt",
"environment_id": "Deine Umgebungs-ID",
"environment_id_description": "Diese ID identifiziert eindeutig diese Formbricks Umgebung.",
"formbricks_sdk_connected": "Formbricks SDK ist verbunden",
"formbricks_sdk_not_connected": "Formbricks SDK ist noch nicht verbunden.",
"formbricks_sdk_not_connected_description": "Füge das Formbricks SDK zu deiner Website oder App hinzu, um sie mit Formbricks zu verbinden",
"formbricks_sdk_not_connected_description": "Verbinde deine Website oder App mit Formbricks",
"how_to_setup": "Wie einrichten",
"how_to_setup_description": "Befolge diese Schritte, um das Formbricks Widget in deiner App einzurichten.",
"receiving_data": "Daten werden empfangen 💃🕺",
@@ -815,7 +800,7 @@
"project_deleted_successfully": "Projekt erfolgreich gelöscht",
"project_name_settings_description": "Ändere den Namen deines Projekts.",
"project_name_updated_successfully": "Projektname erfolgreich aktualisiert",
"recontact_waiting_time": "Projektweite Wartezeit zwischen Umfragen",
"recontact_waiting_time": "Wartezeit für erneuten Kontakt",
"recontact_waiting_time_settings_description": "Steuere, wie oft Nutzer in allen App-Umfragen eine Umfrage angezeigt bekommen können.",
"this_action_cannot_be_undone": "Diese Aktion kann nicht rückgängig gemacht werden.",
"wait_x_days_before_showing_next_survey": "Warte X Tage, bevor die nächste Umfrage angezeigt wird:",
@@ -884,6 +869,7 @@
"add_tag": "Tag hinzufügen",
"count": "zählen",
"delete_tag_confirmation": "Bist Du sicher, dass Du diesen Tag löschen möchtest?",
"empty_message": "Markiere eine Antwort, um deine Liste der Tags hier zu finden.",
"manage_tags": "Tags verwalten",
"manage_tags_description": "Zusammenführen und Antwort-Tags entfernen.",
"merge": "Zusammenführen",
@@ -1202,9 +1188,9 @@
"add": "+ hinzufügen",
"add_a_delay_or_auto_close_the_survey": "Füge eine Verzögerung hinzu oder schließe die Umfrage automatisch.",
"add_a_four_digit_pin": "Füge eine vierstellige PIN hinzu",
"add_a_new_question_to_your_survey": "Neue Frage hinzufügen",
"add_a_variable_to_calculate": "Variable hinzufügen",
"add_action_below": "Aktion unten hinzufügen",
"add_block": "Block hinzufügen",
"add_choice_below": "Auswahl unten hinzufügen",
"add_color_coding": "Farbkodierung hinzufügen",
"add_color_coding_description": "Füge rote, orange und grüne Farbcodes zu den Optionen hinzu.",
@@ -1225,7 +1211,6 @@
"add_other": "Anderes hinzufügen",
"add_photo_or_video": "Foto oder Video hinzufügen",
"add_pin": "PIN hinzufügen",
"add_question": "Frage hinzufügen",
"add_question_below": "Frage unten hinzufügen",
"add_row": "Zeile hinzufügen",
"add_variable": "Variable hinzufügen",
@@ -1240,6 +1225,7 @@
"allow_multi_select": "Mehrfachauswahl erlauben",
"allow_multiple_files": "Mehrere Dateien zulassen",
"allow_users_to_select_more_than_one_image": "Erlaube Nutzern, mehr als ein Bild auszuwählen",
"always_show_survey": "Umfrage immer anzeigen",
"and_launch_surveys_in_your_website_or_app": "und Umfragen auf deiner Website oder App starten.",
"animation": "Animation",
"app_survey_description": "Bette eine Umfrage in deine Web-App oder Website ein, um Antworten zu sammeln.",
@@ -1252,6 +1238,8 @@
"automatically_mark_the_survey_as_complete_after": "Umfrage automatisch als abgeschlossen markieren nach",
"back_button_label": "Zurück\"- Button ",
"background_styling": "Hintergründe",
"block_deleted": "Block gelöscht.",
"block_duplicated": "Block dupliziert.",
"bold": "Fett",
"brand_color": "Markenfarbe",
"brightness": "Helligkeit",
@@ -1296,6 +1284,7 @@
"character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu",
"checkbox_label": "Checkbox-Beschriftung",
"choose_the_actions_which_trigger_the_survey": "Aktionen auswählen, die die Umfrage auslösen.",
"choose_the_first_question_on_your_block": "Wählen sie die erste frage in ihrem block",
"choose_where_to_run_the_survey": "Wähle, wo die Umfrage durchgeführt werden soll.",
"city": "Stadt",
"close_survey_on_response_limit": "Umfrage bei Erreichen des Antwortlimits schließen",
@@ -1322,7 +1311,9 @@
"custom_hostname": "Benutzerdefinierter Hostname",
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.",
"date_format": "Datumsformat",
"days_before_showing_this_survey_again": "Tage nachdem eine beliebige Umfrage angezeigt wurde, bevor diese Umfrage erscheinen kann.",
"days_before_showing_this_survey_again": "Tage, bevor diese Umfrage erneut angezeigt wird.",
"decide_how_often_people_can_answer_this_survey": "Entscheide, wie oft Leute diese Umfrage beantworten können.",
"delete_block": "Block löschen",
"delete_choice": "Auswahl löschen",
"disable_the_visibility_of_survey_progress": "Deaktiviere die Sichtbarkeit des Umfragefortschritts.",
"display_an_estimate_of_completion_time_for_survey": "Zeige eine Schätzung der Fertigstellungszeit für die Umfrage an",
@@ -1334,6 +1325,7 @@
"does_not_include_all_of": "Enthält nicht alle von",
"does_not_include_one_of": "Enthält nicht eines von",
"does_not_start_with": "Fängt nicht an mit",
"duplicate_block": "Block duplizieren",
"edit_link": "Bearbeitungslink",
"edit_recall": "Erinnerung bearbeiten",
"edit_translations": "{lang} -Übersetzungen bearbeiten",
@@ -1350,7 +1342,7 @@
"equals_one_of": "Entspricht einem von",
"error_publishing_survey": "Beim Veröffentlichen der Umfrage ist ein Fehler aufgetreten.",
"error_saving_changes": "Fehler beim Speichern der Änderungen",
"even_after_they_submitted_a_response_e_g_feedback_box": "Mehrfachantworten erlauben; weiterhin anzeigen, auch nach einer Antwort (z.B. Feedback-Box).",
"even_after_they_submitted_a_response_e_g_feedback_box": "Sogar nachdem sie eine Antwort eingereicht haben (z.B. Feedback-Box)",
"everyone": "Jeder",
"external_urls_paywall_tooltip": "Bitte aktualisieren, um die externe URL anzupassen. Phishing-Prävention.",
"fallback_missing": "Fehlender Fallback",
@@ -1421,9 +1413,8 @@
"hostname": "Hostname",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Wie funky sollen deine Karten in {surveyTypeDerived} Umfragen sein",
"if_you_need_more_please": "Wenn Du mehr brauchst, bitte",
"if_you_really_want_that_answer_ask_until_you_get_it": "Weiterhin anzeigen, wenn ausgelöst, bis eine Antwort abgegeben wird.",
"ignore_global_waiting_time": "Projektweite Wartezeit ignorieren",
"ignore_global_waiting_time_description": "Diese Umfrage kann angezeigt werden, wenn ihre Bedingungen erfüllt sind, auch wenn kürzlich eine andere Umfrage angezeigt wurde.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Wenn Du diese Antwort brauchst, frag so lange, bis Du sie bekommst.",
"ignore_waiting_time_between_surveys": "Wartezeit zwischen Umfragen ignorieren",
"image": "Bild",
"includes_all_of": "Enthält alles von",
"includes_one_of": "Enthält eines von",
@@ -1490,10 +1481,9 @@
"optional": "Optional",
"options": "Optionen",
"override_theme_with_individual_styles_for_this_survey": "Styling für diese Umfrage überschreiben.",
"overwrite_global_waiting_time": "Benutzerdefinierte Wartezeit festlegen",
"overwrite_global_waiting_time_description": "Die Projektkonfiguration nur für diese Umfrage überschreiben.",
"overwrite_placement": "Platzierung überschreiben",
"overwrite_the_global_placement_of_the_survey": "Platzierung für diese Umfrage überschreiben",
"overwrites_waiting_period_between_surveys_to_x_days": "Überschreibt die Wartezeit zwischen Umfragen auf {days} Tag(e).",
"pick_a_background_from_our_library_or_upload_your_own": "Wähle einen Hintergrund aus oder lade deinen eigenen hoch.",
"picture_idx": "Bild {idx}",
"pin_can_only_contain_numbers": "PIN darf nur Zahlen enthalten.",
@@ -1552,8 +1542,7 @@
"range": "Reichweite",
"recall_data": "Daten abrufen",
"recall_information_from": "Information abrufen von ...",
"recontact_options_section": "Optionen zur erneuten Kontaktaufnahme",
"recontact_options_section_description": "Wenn die Wartezeit es erlaubt, wählen Sie aus, wie oft diese Umfrage einer Person angezeigt werden kann.",
"recontact_options": "Optionen zur erneuten Kontaktaufnahme",
"redirect_thank_you_card": "Weiterleitung anlegen",
"redirect_to_url": "Zu URL weiterleiten",
"remove_description": "Beschreibung entfernen",
@@ -1562,8 +1551,6 @@
"required": "Erforderlich",
"reset_to_theme_styles": "Styling zurücksetzen",
"reset_to_theme_styles_main_text": "Bist Du sicher, dass Du das Styling auf die Themenstile zurücksetzen möchtest? Dadurch wird jegliches benutzerdefinierte Styling entfernt.",
"respect_global_waiting_time": "Projektweite Wartezeit verwenden",
"respect_global_waiting_time_description": "Diese Umfrage folgt der in der Projektkonfiguration festgelegten Wartezeit. Sie wird nur angezeigt, wenn in diesem Zeitraum keine andere Umfrage erschienen ist.",
"response_limit_can_t_be_set_to_0": "Das Antwortlimit kann nicht auf 0 gesetzt werden",
"response_limit_needs_to_exceed_number_of_received_responses": "Antwortlimit muss die Anzahl der erhaltenen Antworten ({responseCount}) überschreiten.",
"response_limits_redirections_and_more": "Antwort Limits, Weiterleitungen und mehr.",
@@ -1588,7 +1575,7 @@
"show_advanced_settings": "Erweiterte Einstellungen anzeigen",
"show_button": "Button anzeigen",
"show_language_switch": "Sprachwechsel anzeigen",
"show_multiple_times": "Begrenzte Anzahl von Malen anzeigen",
"show_multiple_times": "Mehrfach anzeigen",
"show_only_once": "Nur einmal anzeigen",
"show_survey_maximum_of": "Umfrage maximal anzeigen von",
"show_survey_to_users": "Umfrage % der Nutzer anzeigen",
@@ -1618,12 +1605,13 @@
"switch_multi_lanugage_on_to_get_started": "Schalte Mehrsprachigkeit ein, um loszulegen 👉",
"targeted": "Gezielt",
"ten_points": "10 Punkte",
"the_survey_will_be_shown_multiple_times_until_they_respond": "Höchstens die angegebene Anzahl von Malen anzeigen oder bis sie antworten (je nachdem, was zuerst eintritt).",
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Einmal anzeigen, auch wenn sie nicht antworten.",
"the_survey_will_be_shown_multiple_times_until_they_respond": "Die Umfrage wird mehrmals angezeigt, bis Du antwortest",
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Die Umfrage wird einmal angezeigt, auch wenn die Person nicht antwortet.",
"then": "dann",
"this_action_will_remove_all_the_translations_from_this_survey": "Diese Aktion entfernt alle Übersetzungen aus dieser Umfrage.",
"this_extension_is_already_added": "Diese Erweiterung ist bereits hinzugefügt.",
"this_file_type_is_not_supported": "Dieser Dateityp wird nicht unterstützt.",
"this_setting_overwrites_your": "Diese Einstellung überschreibt deine",
"three_points": "3 Punkte",
"times": "Zeiten",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Um die Platzierung über alle Umfragen hinweg konsistent zu halten, kannst du",
@@ -1634,7 +1622,8 @@
"unlock_targeting_description": "Spezifische Nutzergruppen basierend auf Attributen oder Geräteinformationen ansprechen",
"unlock_targeting_title": "Targeting mit einem höheren Plan freischalten",
"unsaved_changes_warning": "Du hast ungespeicherte Änderungen in deiner Umfrage. Möchtest Du sie speichern, bevor Du gehst?",
"until_they_submit_a_response": "Fragen, bis sie eine Antwort abgeben",
"until_they_submit_a_response": "Bis sie eine Antwort einreichen",
"untitled_block": "Unbenannter Block",
"upgrade_notice_description": "Erstelle mehrsprachige Umfragen und entdecke viele weitere Funktionen",
"upgrade_notice_title": "Schalte mehrsprachige Umfragen mit einem höheren Plan frei",
"upload": "Hochladen",
@@ -1642,6 +1631,7 @@
"upper_label": "Oberes Label",
"url_filters": "URL-Filter",
"url_not_supported": "URL nicht unterstützt",
"use_with_caution": "Mit Vorsicht verwenden",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" wird in der \"{quotaName}\" Quote verwendet",
"variable_name_is_already_taken_please_choose_another": "Variablenname ist bereits vergeben, bitte wähle einen anderen.",
@@ -1651,13 +1641,11 @@
"variable_used_in_recall_welcome": "Variable \"{variable}\" wird in der Willkommenskarte abgerufen.",
"verify_email_before_submission": "E-Mail vor dem Absenden überprüfen",
"verify_email_before_submission_description": "Lass nur Leute mit einer echten E-Mail antworten.",
"visibility_and_recontact": "Sichtbarkeit & erneute Kontaktaufnahme",
"visibility_and_recontact_description": "Steuern Sie, wann diese Umfrage erscheinen kann und wie oft sie erneut erscheinen kann.",
"wait": "Warte",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Warte ein paar Sekunden nach dem Auslöser, bevor Du die Umfrage anzeigst",
"waiting_time_across_surveys": "Projektweite Wartezeit",
"waiting_time_across_surveys_description": "Um Umfragemüdigkeit zu vermeiden, wählen Sie aus, wie diese Umfrage mit der projektweiten Wartezeit interagiert.",
"waiting_period": "Wartezeit",
"welcome_message": "Willkommensnachricht",
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "Wenn die Bedingungen übereinstimmen, wird die Wartezeit ignoriert und die Umfrage angezeigt.",
"without_a_filter_all_of_your_users_can_be_surveyed": "Ohne Filter können alle deine Nutzer befragt werden.",
"you_have_not_created_a_segment_yet": "Du hast noch keinen Segment erstellt.",
"you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations": "Du musst zwei oder mehr Sprachen in deinem Projekt einrichten, um mit Übersetzungen zu arbeiten.",
@@ -1702,7 +1690,7 @@
"last_name": "Nachname",
"not_completed": "Nicht abgeschlossen ⏳",
"os": "Betriebssystem",
"person_attributes": "Personenattribute zum Zeitpunkt der Einreichung",
"person_attributes": "Personenattribute",
"phone": "Telefon",
"respondent_skipped_questions": "Der Befragte hat diese Fragen übersprungen.",
"response_deleted_successfully": "Antwort erfolgreich gelöscht.",
@@ -1815,7 +1803,6 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "Filter hinzugefügt für Antworten, bei denen die Antwort auf Frage {questionIdx} {filterComboBoxValue} - {filterValue} ist",
"added_filter_for_responses_where_answer_to_question_is_skipped": "Filter hinzugefügt für Antworten, bei denen die Frage {questionIdx} übersprungen wurde",
"aggregated": "Aggregiert",
"all_responses_csv": "Alle Antworten (CSV)",
"all_responses_excel": "Alle Antworten (Excel)",
"all_time": "Gesamt",
@@ -1839,6 +1826,7 @@
"filtered_responses_csv": "Gefilterte Antworten (CSV)",
"filtered_responses_excel": "Gefilterte Antworten (Excel)",
"generating_qr_code": "QR-Code wird generiert",
"go_to_setup_checklist": "Gehe zur Einrichtungs-Checkliste 👉",
"impressions": "Eindrücke",
"impressions_tooltip": "Anzahl der Aufrufe der Umfrage.",
"in_app": {
@@ -1872,7 +1860,7 @@
},
"includes_all": "Beinhaltet alles",
"includes_either": "Beinhaltet entweder",
"individual": "Individuell",
"install_widget": "Formbricks Widget installieren",
"is_equal_to": "Ist gleich",
"is_less_than": "ist weniger als",
"last_30_days": "Letzte 30 Tage",
@@ -1885,7 +1873,6 @@
"no_responses_found": "Keine Antworten gefunden",
"other_values_found": "Andere Werte gefunden",
"overall": "Insgesamt",
"promoters": "Promotoren",
"qr_code": "QR-Code",
"qr_code_description": "Antworten, die per QR-Code gesammelt werden, sind anonym.",
"qr_code_download_failed": "QR-Code-Download fehlgeschlagen",
@@ -1895,7 +1882,6 @@
"quotas_completed_tooltip": "Die Anzahl der von den Befragten abgeschlossenen Quoten.",
"reset_survey": "Umfrage zurücksetzen",
"reset_survey_warning": "Das Zurücksetzen einer Umfrage entfernt alle Antworten und Anzeigen, die mit dieser Umfrage verbunden sind. Dies kann nicht rückgängig gemacht werden.",
"satisfied": "Zufrieden",
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
"selected_responses_excel": "Ausgewählte Antworten (Excel)",
"setup_integrations": "Integrationen einrichten",
@@ -1911,6 +1897,7 @@
"ttc_tooltip": "Durchschnittliche Zeit zum Beantworten der Frage.",
"unknown_question_type": "Unbekannter Fragetyp",
"use_personal_links": "Nutze persönliche Links",
"waiting_for_response": "Warte auf eine Antwort 🧘‍♂️",
"whats_next": "Was kommt als Nächstes?",
"your_survey_is_public": "Deine Umfrage ist öffentlich",
"youre_not_plugged_in_yet": "Du bist noch nicht verbunden!"
@@ -2265,6 +2252,7 @@
"csat_survey_question_3_headline": "Ugh, sorry! Können wir irgendwas tun, um deine Erfahrung zu verbessern?",
"csat_survey_question_3_placeholder": "Tippe deine Antwort hier...",
"cta_description": "Information anzeigen und Benutzer auffordern, eine bestimmte Aktion auszuführen",
"custom_survey_block_1_name": "Block 1",
"custom_survey_description": "Erstelle eine Umfrage ohne Vorlage.",
"custom_survey_name": "Eigene Umfrage erstellen",
"custom_survey_question_1_headline": "Was möchtest Du wissen?",
@@ -2674,7 +2662,6 @@
"preview_survey_question_2_choice_2_label": "Nein, danke!",
"preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?",
"preview_survey_welcome_card_headline": "Willkommen!",
"preview_survey_welcome_card_html": "Danke für dein Feedback - los geht's!",
"prioritize_features_description": "Identifiziere die Funktionen, die deine Nutzer am meisten und am wenigsten brauchen.",
"prioritize_features_name": "Funktionen priorisieren",
"prioritize_features_question_1_choice_1": "Funktion 1",
+34 -47
View File
@@ -153,7 +153,6 @@
"clear_filters": "Clear filters",
"clear_selection": "Clear selection",
"click": "Click",
"click_to_filter": "Click to filter",
"clicks": "Clicks",
"close": "Close",
"code": "Code",
@@ -211,7 +210,6 @@
"error_rate_limit_description": "Maximum number of requests reached. Please try again later.",
"error_rate_limit_title": "Rate Limit Exceeded",
"expand_rows": "Expand rows",
"failed_to_copy_to_clipboard": "Failed to copy to clipboard",
"failed_to_load_organizations": "Failed to load organizations",
"failed_to_load_projects": "Failed to load projects",
"finish": "Finish",
@@ -220,7 +218,6 @@
"full_name": "Full name",
"gathering_responses": "Gathering responses",
"general": "General",
"generate": "Generate",
"go_back": "Go Back",
"go_to_dashboard": "Go to Dashboard",
"hidden": "Hidden",
@@ -428,7 +425,6 @@
"user_id": "User ID",
"user_not_found": "User not found",
"variable": "Variable",
"variable_ids": "Variable IDs",
"variables": "Variables",
"verified_email": "Verified Email",
"video": "Video",
@@ -527,7 +523,6 @@
"add_css_class_or_id": "Add CSS class or id",
"add_regular_expression_here": "Add a regular expression here",
"add_url": "Add URL",
"and": "AND",
"click": "Click",
"contains": "Contains",
"create_action": "Create action",
@@ -558,7 +553,6 @@
"limit_to_specific_pages": "Limit to specific pages",
"matches_regex": "Matches regex",
"on_all_pages": "On all pages",
"or": "OR",
"page_filter": "Page filter",
"page_view": "Page View",
"select_match_type": "Select match type",
@@ -599,18 +593,9 @@
"contacts_table_refresh_success": "Contacts refreshed successfully",
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.",
"delete_contact_confirmation_with_quotas": "{value, plural, one {This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost. If this contact has responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.} other {This will delete all survey responses and contact attributes associated with these contacts. Any targeting and personalization based on these contacts' data will be lost. If these contacts have responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.}}",
"generate_personal_link": "Generate Personal Link",
"generate_personal_link_description": "Select a published survey to generate a personalized link for this contact.",
"no_published_link_surveys_available": "No published link surveys available. Please publish a link survey first.",
"no_published_surveys": "No published surveys",
"no_responses_found": "No responses found",
"not_provided": "Not provided",
"personal_link_generated": "Personal link generated successfully",
"personal_link_generated_but_clipboard_failed": "Personal link generated but failed to copy to clipboard: {url}",
"personal_survey_link": "Personal Survey Link",
"please_select_a_survey": "Please select a survey",
"search_contact": "Search contact",
"select_a_survey": "Select a survey",
"select_attribute": "Select Attribute",
"unlock_contacts_description": "Manage contacts and send out targeted surveys",
"unlock_contacts_title": "Unlock contacts with a higher plan",
@@ -789,14 +774,14 @@
},
"app-connection": {
"app_connection": "App Connection",
"app_connection_description": "Connect your app or website to Formbricks.",
"cache_update_delay_description": "When you make updates to surveys, contacts, actions, or other data, it can take up to 1 minute for those changes to appear in your local app running the Formbricks SDK.",
"cache_update_delay_title": "Changes will be reflected after ~1 minute due to caching",
"app_connection_description": "Connect your app to Formbricks.",
"cache_update_delay_description": "When you make updates to surveys, contacts, actions, or other data, it can take up to 5 minutes for those changes to appear in your local app running the Formbricks SDK. This delay is due to a limitation in our current caching system. Were actively reworking the cache and will release a fix in Formbricks 4.0.",
"cache_update_delay_title": "Changes will be reflected after 5 minutes due to caching",
"environment_id": "Your Environment ID",
"environment_id_description": "This id uniquely identifies this Formbricks environment.",
"formbricks_sdk_connected": "Formbricks SDK is connected",
"formbricks_sdk_not_connected": "Formbricks SDK is not yet connected.",
"formbricks_sdk_not_connected_description": "Add the Formbricks SDK to your website or app to connect it with Formbricks",
"formbricks_sdk_not_connected_description": "Connect your website or app with Formbricks",
"how_to_setup": "How to setup",
"how_to_setup_description": "Follow these steps to setup the Formbricks widget within your app.",
"receiving_data": "Receiving data \uD83D\uDC83\uD83D\uDD7A",
@@ -815,7 +800,7 @@
"project_deleted_successfully": "Project deleted successfully",
"project_name_settings_description": "Change your projects name.",
"project_name_updated_successfully": "Project name updated successfully",
"recontact_waiting_time": "Project-wide Waiting Time Between Surveys",
"recontact_waiting_time": "Recontact Waiting Time",
"recontact_waiting_time_settings_description": "Control how frequently users can be surveyed across all app surveys.",
"this_action_cannot_be_undone": "This action cannot be undone.",
"wait_x_days_before_showing_next_survey": "Wait X days before showing next survey:",
@@ -884,6 +869,7 @@
"add_tag": "Add Tag",
"count": "Count",
"delete_tag_confirmation": "Are you sure you want to delete this tag?",
"empty_message": "Tag a submission to find your list of tags here.",
"manage_tags": "Manage Tags",
"manage_tags_description": "Merge and remove response tags.",
"merge": "Merge",
@@ -1202,9 +1188,9 @@
"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",
"add_a_new_question_to_your_survey": "Add a new question to your survey",
"add_a_variable_to_calculate": "Add a variable to calculate",
"add_action_below": "Add action below",
"add_block": "Add Block",
"add_choice_below": "Add choice below",
"add_color_coding": "Add color coding",
"add_color_coding_description": "Add red, orange and green color codes to the options.",
@@ -1225,7 +1211,6 @@
"add_other": "Add \"Other\"",
"add_photo_or_video": "Add photo or video",
"add_pin": "Add PIN",
"add_question": "Add question",
"add_question_below": "Add question below",
"add_row": "Add row",
"add_variable": "Add variable",
@@ -1240,6 +1225,7 @@
"allow_multi_select": "Allow multi-select",
"allow_multiple_files": "Allow multiple files",
"allow_users_to_select_more_than_one_image": "Allow users to select more than one image",
"always_show_survey": "Always show survey",
"and_launch_surveys_in_your_website_or_app": "and launch surveys in your website or app.",
"animation": "Animation",
"app_survey_description": "Embed a survey in your web app or website to collect responses.",
@@ -1252,6 +1238,8 @@
"automatically_mark_the_survey_as_complete_after": "Automatically mark the survey as complete after",
"back_button_label": "\"Back\" Button Label",
"background_styling": "Background Styling",
"block_deleted": "Block deleted.",
"block_duplicated": "Block duplicated.",
"bold": "Bold",
"brand_color": "Brand color",
"brightness": "Brightness",
@@ -1296,6 +1284,7 @@
"character_limit_toggle_title": "Add character limits",
"checkbox_label": "Checkbox Label",
"choose_the_actions_which_trigger_the_survey": "Choose the actions which trigger the survey.",
"choose_the_first_question_on_your_block": "Choose the first question on your Block",
"choose_where_to_run_the_survey": "Choose where to run the survey.",
"city": "City",
"close_survey_on_response_limit": "Close survey on response limit",
@@ -1322,7 +1311,9 @@
"custom_hostname": "Custom hostname",
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
"date_format": "Date format",
"days_before_showing_this_survey_again": "days after any survey is shown before this survey can appear.",
"days_before_showing_this_survey_again": "days before showing this survey again.",
"decide_how_often_people_can_answer_this_survey": "Decide how often people can answer this survey.",
"delete_block": "Delete block",
"delete_choice": "Delete choice",
"disable_the_visibility_of_survey_progress": "Disable the visibility of survey progress.",
"display_an_estimate_of_completion_time_for_survey": "Display an estimate of completion time for survey",
@@ -1334,6 +1325,7 @@
"does_not_include_all_of": "Does not include all of",
"does_not_include_one_of": "Does not include one of",
"does_not_start_with": "Does not start with",
"duplicate_block": "Duplicate block",
"edit_link": "Edit link",
"edit_recall": "Edit Recall",
"edit_translations": "Edit {lang} translations",
@@ -1350,7 +1342,7 @@
"equals_one_of": "Equals one of",
"error_publishing_survey": "An error occured while publishing the survey.",
"error_saving_changes": "Error saving changes",
"even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).",
"even_after_they_submitted_a_response_e_g_feedback_box": "Even after they submitted a response (e.g. Feedback Box)",
"everyone": "Everyone",
"external_urls_paywall_tooltip": "Please upgrade to customize external URL. Phishing prevention.",
"fallback_missing": "Fallback missing",
@@ -1421,9 +1413,8 @@
"hostname": "Hostname",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "How funky do you want your cards in {surveyTypeDerived} Surveys",
"if_you_need_more_please": "If you need more, please",
"if_you_really_want_that_answer_ask_until_you_get_it": "Keep showing whenever triggered until a response is submitted.",
"ignore_global_waiting_time": "Ignore project-wide waiting time",
"ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.",
"if_you_really_want_that_answer_ask_until_you_get_it": "If you really want that answer, ask until you get it.",
"ignore_waiting_time_between_surveys": "Ignore waiting time between surveys",
"image": "Image",
"includes_all_of": "Includes all of",
"includes_one_of": "Includes one of",
@@ -1490,10 +1481,9 @@
"optional": "Optional",
"options": "Options",
"override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.",
"overwrite_global_waiting_time": "Set custom waiting time",
"overwrite_global_waiting_time_description": "Override the project configuration for this survey only.",
"overwrite_placement": "Overwrite placement",
"overwrite_the_global_placement_of_the_survey": "Overwrite the global placement of the survey",
"overwrites_waiting_period_between_surveys_to_x_days": "Overwrites waiting period between surveys to {days} day(s).",
"pick_a_background_from_our_library_or_upload_your_own": "Pick a background from our library or upload your own.",
"picture_idx": "Picture {idx}",
"pin_can_only_contain_numbers": "PIN can only contain numbers.",
@@ -1552,8 +1542,7 @@
"range": "Range",
"recall_data": "Recall data",
"recall_information_from": "Recall information from ...",
"recontact_options_section": "Recontact options",
"recontact_options_section_description": "If the waiting time allows, choose how often this survey can be shown to a person.",
"recontact_options": "Recontact Options",
"redirect_thank_you_card": "Redirect thank you card",
"redirect_to_url": "Redirect to Url",
"remove_description": "Remove description",
@@ -1562,8 +1551,6 @@
"required": "Required",
"reset_to_theme_styles": "Reset to theme styles",
"reset_to_theme_styles_main_text": "Are you sure you want to reset the styling to the theme styles? This will remove all custom styling.",
"respect_global_waiting_time": "Use project-wide waiting time",
"respect_global_waiting_time_description": "This survey follows the waiting time set in project configuration. It only shows if no other survey has appeared during that period.",
"response_limit_can_t_be_set_to_0": "Response limit can't be set to 0",
"response_limit_needs_to_exceed_number_of_received_responses": "Response limit needs to exceed number of received responses ({responseCount}).",
"response_limits_redirections_and_more": "Response limits, redirections and more.",
@@ -1588,7 +1575,7 @@
"show_advanced_settings": "Show Advanced settings",
"show_button": "Show Button",
"show_language_switch": "Show language switch",
"show_multiple_times": "Show a limited number of times",
"show_multiple_times": "Show multiple times",
"show_only_once": "Show only once",
"show_survey_maximum_of": "Show survey maximum of",
"show_survey_to_users": "Show survey to % of users",
@@ -1618,12 +1605,13 @@
"switch_multi_lanugage_on_to_get_started": "Switch multi-lanugage on to get started \uD83D\uDC49",
"targeted": "Targeted",
"ten_points": "10 points",
"the_survey_will_be_shown_multiple_times_until_they_respond": "Show at most the specified number of times, or until they respond (whichever comes first).",
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Show a single time, even if they don't respond.",
"the_survey_will_be_shown_multiple_times_until_they_respond": "The survey will be shown multiple times until they respond",
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "The survey will be shown once, even if person doesn't respond.",
"then": "Then",
"this_action_will_remove_all_the_translations_from_this_survey": "This action will remove all the translations from this survey.",
"this_extension_is_already_added": "This extension is already added.",
"this_file_type_is_not_supported": "This file type is not supported.",
"this_setting_overwrites_your": "This setting overwrites your",
"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",
@@ -1634,7 +1622,8 @@
"unlock_targeting_description": "Target specific user groups based on attributes or device information",
"unlock_targeting_title": "Unlock targeting with a higher plan",
"unsaved_changes_warning": "You have unsaved changes in your survey. Would you like to save them before leaving?",
"until_they_submit_a_response": "Ask until they submit a response",
"until_they_submit_a_response": "Until they submit a response",
"untitled_block": "Untitled Block",
"upgrade_notice_description": "Create multilingual surveys and unlock many more features",
"upgrade_notice_title": "Unlock multi-language surveys with a higher plan",
"upload": "Upload",
@@ -1642,6 +1631,7 @@
"upper_label": "Upper Label",
"url_filters": "URL Filters",
"url_not_supported": "URL not supported",
"use_with_caution": "Use with caution",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} is used in logic of question {questionIndex}. Please remove it from logic first.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" is being used in \"{quotaName}\" quota",
"variable_name_is_already_taken_please_choose_another": "Variable name is already taken, please choose another.",
@@ -1651,13 +1641,11 @@
"variable_used_in_recall_welcome": "Variable \"{variable}\" is being recalled in Welcome Card.",
"verify_email_before_submission": "Verify email before submission",
"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.",
"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": "Project-wide waiting time",
"waiting_time_across_surveys_description": "To prevent survey fatigue, choose how this survey interacts with the project-wide waiting time.",
"waiting_period": "waiting period",
"welcome_message": "Welcome message",
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "When conditions match, waiting time will be ignored and survey shown.",
"without_a_filter_all_of_your_users_can_be_surveyed": "Without a filter, all of your users can be surveyed.",
"you_have_not_created_a_segment_yet": "You have not created a segment yet",
"you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations": "You need to have two or more languages set up in your project to work with translations.",
@@ -1702,7 +1690,7 @@
"last_name": "Last Name",
"not_completed": "Not Completed ⏳",
"os": "OS",
"person_attributes": "Person attributes at time of submission",
"person_attributes": "Person attributes",
"phone": "Phone",
"respondent_skipped_questions": "Respondent skipped these questions.",
"response_deleted_successfully": "Response deleted successfully.",
@@ -1815,7 +1803,6 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "Added filter for responses where answer to question {questionIdx} is {filterComboBoxValue} - {filterValue} ",
"added_filter_for_responses_where_answer_to_question_is_skipped": "Added filter for responses where answer to question {questionIdx} is skipped",
"aggregated": "Aggregated",
"all_responses_csv": "All responses (CSV)",
"all_responses_excel": "All responses (Excel)",
"all_time": "All time",
@@ -1839,6 +1826,7 @@
"filtered_responses_csv": "Filtered responses (CSV)",
"filtered_responses_excel": "Filtered responses (Excel)",
"generating_qr_code": "Generating QR code",
"go_to_setup_checklist": "Go to Setup Checklist \uD83D\uDC49",
"impressions": "Impressions",
"impressions_tooltip": "Number of times the survey has been viewed.",
"in_app": {
@@ -1872,7 +1860,7 @@
},
"includes_all": "Includes all",
"includes_either": "Includes either",
"individual": "Individual",
"install_widget": "Install Formbricks Widget",
"is_equal_to": "Is equal to",
"is_less_than": "Is less than",
"last_30_days": "Last 30 days",
@@ -1885,7 +1873,6 @@
"no_responses_found": "No responses found",
"other_values_found": "Other values found",
"overall": "Overall",
"promoters": "Promoters",
"qr_code": "QR code",
"qr_code_description": "Responses collected via QR code are anonymous.",
"qr_code_download_failed": "QR code download failed",
@@ -1895,7 +1882,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",
@@ -1911,6 +1897,7 @@
"ttc_tooltip": "Average time to complete the question.",
"unknown_question_type": "Unknown Question Type",
"use_personal_links": "Use personal links",
"waiting_for_response": "Waiting for a response \uD83E\uDDD8‍♂️",
"whats_next": "What's next?",
"your_survey_is_public": "Your survey is public",
"youre_not_plugged_in_yet": "You're not plugged in yet!"
@@ -2265,6 +2252,7 @@
"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...",
"cta_description": "Display information and prompt users to take a specific action",
"custom_survey_block_1_name": "Block 1",
"custom_survey_description": "Create a survey without template.",
"custom_survey_name": "Start from scratch",
"custom_survey_question_1_headline": "What would you like to know?",
@@ -2674,7 +2662,6 @@
"preview_survey_question_2_choice_2_label": "No, thank you!",
"preview_survey_question_2_headline": "Want to stay in the loop?",
"preview_survey_welcome_card_headline": "Welcome!",
"preview_survey_welcome_card_html": "Thanks for providing your feedback - let's go!",
"prioritize_features_description": "Identify features your users need most and least.",
"prioritize_features_name": "Prioritize Features",
"prioritize_features_question_1_choice_1": "Feature 1",
File diff suppressed because it is too large Load Diff
+34 -47
View File
@@ -153,7 +153,6 @@
"clear_filters": "Effacer les filtres",
"clear_selection": "Effacer la sélection",
"click": "Cliquez",
"click_to_filter": "Cliquer pour filtrer",
"clicks": "Clics",
"close": "Fermer",
"code": "Code",
@@ -211,7 +210,6 @@
"error_rate_limit_description": "Nombre maximal de demandes atteint. Veuillez réessayer plus tard.",
"error_rate_limit_title": "Limite de Taux Dépassée",
"expand_rows": "Développer les lignes",
"failed_to_copy_to_clipboard": "Échec de la copie dans le presse-papiers",
"failed_to_load_organizations": "Échec du chargement des organisations",
"failed_to_load_projects": "Échec du chargement des projets",
"finish": "Terminer",
@@ -220,7 +218,6 @@
"full_name": "Nom complet",
"gathering_responses": "Collecte des réponses",
"general": "Général",
"generate": "Générer",
"go_back": "Retourner",
"go_to_dashboard": "Aller au tableau de bord",
"hidden": "Caché",
@@ -428,7 +425,6 @@
"user_id": "Identifiant d'utilisateur",
"user_not_found": "Utilisateur non trouvé",
"variable": "Variable",
"variable_ids": "Identifiants variables",
"variables": "Variables",
"verified_email": "Email vérifié",
"video": "Vidéo",
@@ -527,7 +523,6 @@
"add_css_class_or_id": "Ajouter une classe ou un identifiant CSS",
"add_regular_expression_here": "Ajouter une expression régulière",
"add_url": "Ajouter une URL",
"and": "ET",
"click": "Cliquez",
"contains": "Contient",
"create_action": "Créer une action",
@@ -558,7 +553,6 @@
"limit_to_specific_pages": "Sur certaines pages",
"matches_regex": "Correspond à l'expression régulière",
"on_all_pages": "Sur toutes les pages",
"or": "OU",
"page_filter": "Filtrage des pages",
"page_view": "Vue de page",
"select_match_type": "Sélectionner le type de match",
@@ -599,18 +593,9 @@
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
"delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.",
"delete_contact_confirmation_with_quotas": "{value, plural, other {Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus. Si ce contact a des réponses qui comptent dans les quotas de l'enquête, les comptes de quotas seront réduits mais les limites de quota resteront inchangées.}}",
"generate_personal_link": "Générer un lien personnel",
"generate_personal_link_description": "Sélectionnez une enquête publiée pour générer un lien personnalisé pour ce contact.",
"no_published_link_surveys_available": "Aucune enquête par lien publiée n'est disponible. Veuillez d'abord publier une enquête par lien.",
"no_published_surveys": "Aucune enquête publiée",
"no_responses_found": "Aucune réponse trouvée",
"not_provided": "Non fourni",
"personal_link_generated": "Lien personnel généré avec succès",
"personal_link_generated_but_clipboard_failed": "Lien personnel généré mais échec de la copie dans le presse-papiers: {url}",
"personal_survey_link": "Lien vers le sondage personnel",
"please_select_a_survey": "Veuillez sélectionner une enquête",
"search_contact": "Rechercher un contact",
"select_a_survey": "Sélectionner une enquête",
"select_attribute": "Sélectionner un attribut",
"unlock_contacts_description": "Gérer les contacts et envoyer des enquêtes ciblées",
"unlock_contacts_title": "Débloquez des contacts avec un plan supérieur.",
@@ -789,14 +774,14 @@
},
"app-connection": {
"app_connection": "Connexion d'une application",
"app_connection_description": "Connectez votre application ou site web à Formbricks.",
"cache_update_delay_description": "Lorsque vous effectuez des mises à jour sur les sondages, contacts, actions ou autres données, ces changements peuvent prendre jusqu'à 1 minute pour apparaître dans votre application locale exécutant le SDK Formbricks.",
"cache_update_delay_title": "Les modifications seront visibles après environ 1 minute en raison de la mise en cache",
"app_connection_description": "Vous pouvez connecter une application à Formbricks.",
"cache_update_delay_description": "Lorsque vous effectuez des mises à jour sur les sondages, contacts, actions ou autres données, cela peut prendre jusqu'à 5 minutes pour que ces modifications apparaissent dans votre application locale exécutant le SDK Formbricks. Ce délai est dû à une limitation de notre système de mise en cache actuel. Nous retravaillons activement le cache et publierons une correction dans Formbricks 4.0.",
"cache_update_delay_title": "Les modifications seront reflétées après 5 minutes en raison de la mise en cache",
"environment_id": "Identifiant de votre environnement",
"environment_id_description": "Cet identifiant unique est attribué à votre environnement Formbricks.",
"formbricks_sdk_connected": "Le SDK Formbricks est connecté",
"formbricks_sdk_not_connected": "Le SDK Formbricks n'est pas encore connecté.",
"formbricks_sdk_not_connected_description": "Ajoutez le SDK Formbricks à votre site web ou application pour le connecter à Formbricks",
"formbricks_sdk_not_connected_description": "Connectez votre site Web ou votre application à Formbricks.",
"how_to_setup": "Comment configurer",
"how_to_setup_description": "Suivez ces étapes pour configurer le widget Formbricks dans votre application.",
"receiving_data": "Réception des données 💃🕺",
@@ -815,7 +800,7 @@
"project_deleted_successfully": "Projet supprimé avec succès",
"project_name_settings_description": "Vous pouvez modifier le nom de votre projet.",
"project_name_updated_successfully": "Le nom du projet a été mis à jour avec succès.",
"recontact_waiting_time": "Temps d'attente entre les enquêtes à l'échelle du projet",
"recontact_waiting_time": "Délai avant nouveau contact",
"recontact_waiting_time_settings_description": "Vous pouvez contrôler la fréquence à laquelle les utilisateurs sont sollicités pour répondre aux enquêtes.",
"this_action_cannot_be_undone": "Cette action ne peut pas être annulée.",
"wait_x_days_before_showing_next_survey": "Nombre de jours devant s'écouler avant une nouvelle sollicitation :",
@@ -884,6 +869,7 @@
"add_tag": "Ajouter une étiquette",
"count": "Compter",
"delete_tag_confirmation": "Êtes-vous sûr de vouloir supprimer cette étiquette ?",
"empty_message": "Ajoutez une balise à une réponse pour afficher votre liste de balises.",
"manage_tags": "Gérer les étiquettes",
"manage_tags_description": "Vous pouvez fusionner et supprimer des balises de réponse.",
"merge": "Fusionner",
@@ -1202,9 +1188,9 @@
"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.",
"add_a_new_question_to_your_survey": "Ajouter une nouvelle question à votre enquête",
"add_a_variable_to_calculate": "Ajouter une variable à calculer",
"add_action_below": "Ajouter une action ci-dessous",
"add_block": "Ajouter un bloc",
"add_choice_below": "Ajouter une option ci-dessous",
"add_color_coding": "Ajouter un code couleur",
"add_color_coding_description": "Ajoutez des codes de couleur rouge, orange et vert aux options.",
@@ -1225,7 +1211,6 @@
"add_other": "Ajouter \"Autre",
"add_photo_or_video": "Ajouter une photo ou une vidéo",
"add_pin": "Ajouter un code PIN",
"add_question": "Ajouter une question",
"add_question_below": "Ajouter une question ci-dessous",
"add_row": "Ajouter une ligne",
"add_variable": "Ajouter une variable",
@@ -1240,6 +1225,7 @@
"allow_multi_select": "Autoriser la sélection multiple",
"allow_multiple_files": "Autoriser plusieurs fichiers",
"allow_users_to_select_more_than_one_image": "Permettre aux utilisateurs de sélectionner plusieurs images",
"always_show_survey": "Afficher toujours l'enquête",
"and_launch_surveys_in_your_website_or_app": "et lancez des enquêtes sur votre site web ou votre application.",
"animation": "Animation",
"app_survey_description": "Intégrez une enquête dans votre application web ou votre site web pour collecter des réponses.",
@@ -1252,6 +1238,8 @@
"automatically_mark_the_survey_as_complete_after": "Marquer automatiquement l'enquête comme terminée après",
"back_button_label": "Label du bouton \"Retour''",
"background_styling": "Style de fond",
"block_deleted": "Bloc supprimé.",
"block_duplicated": "Bloc dupliqué.",
"bold": "Gras",
"brand_color": "Couleur de marque",
"brightness": "Luminosité",
@@ -1296,6 +1284,7 @@
"character_limit_toggle_title": "Ajouter des limites de caractères",
"checkbox_label": "Étiquette de case à cocher",
"choose_the_actions_which_trigger_the_survey": "Choisissez les actions qui déclenchent l'enquête.",
"choose_the_first_question_on_your_block": "Choisissez la première question de votre bloc",
"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",
@@ -1322,7 +1311,9 @@
"custom_hostname": "Nom d'hôte personnalisé",
"darken_or_lighten_background_of_your_choice": "Assombrir ou éclaircir l'arrière-plan de votre choix.",
"date_format": "Format de date",
"days_before_showing_this_survey_again": "jours après qu'une enquête soit affichée avant que cette enquête puisse apparaître.",
"days_before_showing_this_survey_again": "jours avant de montrer à nouveau cette enquête.",
"decide_how_often_people_can_answer_this_survey": "Décidez à quelle fréquence les gens peuvent répondre à cette enquête.",
"delete_block": "Supprimer le bloc",
"delete_choice": "Supprimer l'option",
"disable_the_visibility_of_survey_progress": "Désactiver la visibilité de la progression du sondage.",
"display_an_estimate_of_completion_time_for_survey": "Afficher une estimation du temps de complétion pour l'enquête.",
@@ -1334,6 +1325,7 @@
"does_not_include_all_of": "n'inclut pas tout",
"does_not_include_one_of": "n'inclut pas un de",
"does_not_start_with": "Ne commence pas par",
"duplicate_block": "Dupliquer le bloc",
"edit_link": "Modifier le lien",
"edit_recall": "Modifier le rappel",
"edit_translations": "Modifier les traductions {lang}",
@@ -1350,7 +1342,7 @@
"equals_one_of": "Égal à l'un de",
"error_publishing_survey": "Une erreur est survenue lors de la publication de l'enquête.",
"error_saving_changes": "Erreur lors de l'enregistrement des modifications",
"even_after_they_submitted_a_response_e_g_feedback_box": "Autoriser plusieurs réponses; continuer à afficher même après une réponse (par exemple, boîte de commentaires).",
"even_after_they_submitted_a_response_e_g_feedback_box": "Même après avoir soumis une réponse (par exemple, la boîte de feedback)",
"everyone": "Tout le monde",
"external_urls_paywall_tooltip": "Veuillez passer à la version supérieure pour personnaliser l'URL externe. Prévention contre l'hameçonnage.",
"fallback_missing": "Fallback manquant",
@@ -1421,9 +1413,8 @@
"hostname": "Nom d'hôte",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "À quel point voulez-vous que vos cartes soient funky dans les enquêtes {surveyTypeDerived}",
"if_you_need_more_please": "Si vous en avez besoin de plus, s'il vous plaît",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse soit soumise.",
"ignore_global_waiting_time": "Ignorer le temps d'attente à l'échelle du projet",
"ignore_global_waiting_time_description": "Cette enquête peut s'afficher chaque fois que ses conditions sont remplies, même si une autre enquête a été affichée récemment.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Si tu veux vraiment cette réponse, demande jusqu'à ce que tu l'obtiennes.",
"ignore_waiting_time_between_surveys": "Ignorer le temps d'attente entre les enquêtes",
"image": "Image",
"includes_all_of": "Comprend tous les",
"includes_one_of": "Comprend un de",
@@ -1490,10 +1481,9 @@
"optional": "Optionnel",
"options": "Options",
"override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.",
"overwrite_global_waiting_time": "Définir un temps d'attente personnalisé",
"overwrite_global_waiting_time_description": "Remplacer la configuration du projet pour cette enquête uniquement.",
"overwrite_placement": "Surcharge de placement",
"overwrite_the_global_placement_of_the_survey": "Surcharger le placement global de l'enquête",
"overwrites_waiting_period_between_surveys_to_x_days": "Remplace la période d'attente entre les enquêtes par {days} jour(s).",
"pick_a_background_from_our_library_or_upload_your_own": "Choisissez un arrière-plan dans notre bibliothèque ou téléchargez le vôtre.",
"picture_idx": "Image {idx}",
"pin_can_only_contain_numbers": "Le code PIN ne peut contenir que des chiffres.",
@@ -1552,8 +1542,7 @@
"range": "Plage",
"recall_data": "Rappel des données",
"recall_information_from": "Rappeler les informations de ...",
"recontact_options_section": "Options de recontact",
"recontact_options_section_description": "Si le temps d'attente le permet, choisissez la fréquence à laquelle cette enquête peut être présentée à une personne.",
"recontact_options": "Options de recontact",
"redirect_thank_you_card": "Carte de remerciement de redirection",
"redirect_to_url": "Rediriger vers l'URL",
"remove_description": "Supprimer la description",
@@ -1562,8 +1551,6 @@
"required": "Requis",
"reset_to_theme_styles": "Réinitialiser aux styles de thème",
"reset_to_theme_styles_main_text": "Êtes-vous sûr de vouloir réinitialiser le style aux styles du thème ? Cela supprimera tous les styles personnalisés.",
"respect_global_waiting_time": "Utiliser le temps d'attente à l'échelle du projet",
"respect_global_waiting_time_description": "Cette enquête respecte le temps d'attente défini dans la configuration du projet. Elle ne s'affiche que si aucune autre enquête n'est apparue pendant cette période.",
"response_limit_can_t_be_set_to_0": "La limite de réponse ne peut pas être fixée à 0.",
"response_limit_needs_to_exceed_number_of_received_responses": "La limite de réponses doit dépasser le nombre de réponses reçues ({responseCount}).",
"response_limits_redirections_and_more": "Limites de réponse, redirections et plus.",
@@ -1588,7 +1575,7 @@
"show_advanced_settings": "Afficher les paramètres avancés",
"show_button": "Afficher le bouton",
"show_language_switch": "Afficher le changement de langue",
"show_multiple_times": "Afficher un nombre limité de fois",
"show_multiple_times": "Afficher plusieurs fois",
"show_only_once": "Afficher une seule fois",
"show_survey_maximum_of": "Afficher le maximum du sondage de",
"show_survey_to_users": "Afficher l'enquête à % des utilisateurs",
@@ -1618,12 +1605,13 @@
"switch_multi_lanugage_on_to_get_started": "Activez le multilingue pour commencer 👉",
"targeted": "Ciblé",
"ten_points": "10 points",
"the_survey_will_be_shown_multiple_times_until_they_respond": "Afficher au maximum le nombre de fois spécifié, ou jusqu'à ce qu'ils répondent (selon la première éventualité).",
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Afficher une seule fois, même si la personne ne répond pas.",
"the_survey_will_be_shown_multiple_times_until_they_respond": "L'enquête sera affichée plusieurs fois jusqu'à ce qu'ils répondent.",
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "L'enquête sera affichée une 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_extension_is_already_added": "Cette extension est déjà ajoutée.",
"this_file_type_is_not_supported": "Ce type de fichier n'est pas pris en charge.",
"this_setting_overwrites_your": "Ce paramètre écrase votre",
"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",
@@ -1634,7 +1622,8 @@
"unlock_targeting_description": "Cibler des groupes d'utilisateurs spécifiques en fonction des attributs ou des informations sur l'appareil",
"unlock_targeting_title": "Débloquez le ciblage avec un plan supérieur.",
"unsaved_changes_warning": "Vous avez des modifications non enregistrées dans votre enquête. Souhaitez-vous les enregistrer avant de partir ?",
"until_they_submit_a_response": "Demander jusqu'à ce qu'ils soumettent une réponse",
"until_they_submit_a_response": "Jusqu'à ce qu'ils soumettent une réponse",
"untitled_block": "Bloc sans titre",
"upgrade_notice_description": "Créez des sondages multilingues et débloquez de nombreuses autres fonctionnalités",
"upgrade_notice_title": "Débloquez les sondages multilingues avec un plan supérieur",
"upload": "Télécharger",
@@ -1642,6 +1631,7 @@
"upper_label": "Étiquette supérieure",
"url_filters": "Filtres d'URL",
"url_not_supported": "URL non supportée",
"use_with_caution": "À utiliser avec précaution",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" est utilisée dans le quota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "Le nom de la variable est déjà pris, veuillez en choisir un autre.",
@@ -1651,13 +1641,11 @@
"variable_used_in_recall_welcome": "La variable \"{variable}\" est rappelée dans la carte de bienvenue.",
"verify_email_before_submission": "Vérifiez l'email avant la soumission",
"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.",
"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": "Temps d'attente à l'échelle du projet",
"waiting_time_across_surveys_description": "Pour éviter la lassitude face aux enquêtes, choisissez comment cette enquête interagit avec le temps d'attente à l'échelle du projet.",
"waiting_period": "période d'attente",
"welcome_message": "Message de bienvenue",
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "Lorsque les conditions correspondent, le temps d'attente sera ignoré et l'enquête sera affichée.",
"without_a_filter_all_of_your_users_can_be_surveyed": "Sans filtre, tous vos utilisateurs peuvent être sondés.",
"you_have_not_created_a_segment_yet": "Tu n'as pas encore créé de segment.",
"you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations": "Vous devez avoir deux langues ou plus configurées dans votre projet pour travailler avec des traductions.",
@@ -1702,7 +1690,7 @@
"last_name": "Nom de famille",
"not_completed": "Non terminé ⏳",
"os": "Système d'exploitation",
"person_attributes": "Attributs de la personne au moment de la soumission",
"person_attributes": "Attributs de la personne",
"phone": "Téléphone",
"respondent_skipped_questions": "Le répondant a sauté ces questions.",
"response_deleted_successfully": "Réponse supprimée avec succès.",
@@ -1815,7 +1803,6 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "Filtre ajouté pour les réponses où la réponse à la question '{'questionIdx'}' est '{'filterComboBoxValue'}' - '{'filterValue'}' ",
"added_filter_for_responses_where_answer_to_question_is_skipped": "Filtre ajouté pour les réponses où la réponse à la question '{'questionIdx'}' est ignorée",
"aggregated": "Agrégé",
"all_responses_csv": "Tous les réponses (CSV)",
"all_responses_excel": "Tous les réponses (Excel)",
"all_time": "Tout le temps",
@@ -1839,6 +1826,7 @@
"filtered_responses_csv": "Réponses filtrées (CSV)",
"filtered_responses_excel": "Réponses filtrées (Excel)",
"generating_qr_code": "Génération du code QR",
"go_to_setup_checklist": "Allez à la liste de contrôle de configuration 👉",
"impressions": "Impressions",
"impressions_tooltip": "Nombre de fois que l'enquête a été consultée.",
"in_app": {
@@ -1872,7 +1860,7 @@
},
"includes_all": "Comprend tous",
"includes_either": "Comprend soit",
"individual": "Individuel",
"install_widget": "Installer le widget Formbricks",
"is_equal_to": "Est égal à",
"is_less_than": "est inférieur à",
"last_30_days": "30 derniers jours",
@@ -1885,7 +1873,6 @@
"no_responses_found": "Aucune réponse trouvée",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",
"promoters": "Promoteurs",
"qr_code": "Code QR",
"qr_code_description": "Les réponses collectées via le code QR sont anonymes.",
"qr_code_download_failed": "Échec du téléchargement du code QR",
@@ -1895,7 +1882,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",
@@ -1911,6 +1897,7 @@
"ttc_tooltip": "Temps moyen pour compléter la question.",
"unknown_question_type": "Type de question inconnu",
"use_personal_links": "Utilisez des liens personnels",
"waiting_for_response": "En attente d'une réponse 🧘‍♂️",
"whats_next": "Qu'est-ce qui vient ensuite ?",
"your_survey_is_public": "Votre enquête est publique.",
"youre_not_plugged_in_yet": "Vous n'êtes pas encore branché !"
@@ -2265,6 +2252,7 @@
"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...",
"cta_description": "Afficher des informations et inciter les utilisateurs à effectuer une action spécifique",
"custom_survey_block_1_name": "Bloc 1",
"custom_survey_description": "Créez une enquête sans utiliser de modèle.",
"custom_survey_name": "Tout créer moi-même",
"custom_survey_question_1_headline": "Que voudriez-vous savoir ?",
@@ -2674,7 +2662,6 @@
"preview_survey_question_2_choice_2_label": "Non, merci !",
"preview_survey_question_2_headline": "Souhaitez-vous être informé ?",
"preview_survey_welcome_card_headline": "Bienvenue !",
"preview_survey_welcome_card_html": "Merci pour vos retours - allons-y !",
"prioritize_features_description": "Identifiez les fonctionnalités dont vos utilisateurs ont le plus et le moins besoin.",
"prioritize_features_name": "Prioriser les fonctionnalités",
"prioritize_features_question_1_choice_1": "Fonctionnalité 1",
+34 -47
View File
@@ -153,7 +153,6 @@
"clear_filters": "フィルターをクリア",
"clear_selection": "選択をクリア",
"click": "クリック",
"click_to_filter": "クリックしてフィルター",
"clicks": "クリック数",
"close": "閉じる",
"code": "コード",
@@ -211,7 +210,6 @@
"error_rate_limit_description": "リクエストの最大数に達しました。後でもう一度試してください。",
"error_rate_limit_title": "レート制限を超えました",
"expand_rows": "行を展開",
"failed_to_copy_to_clipboard": "クリップボードへのコピーに失敗しました",
"failed_to_load_organizations": "組織の読み込みに失敗しました",
"failed_to_load_projects": "プロジェクトの読み込みに失敗しました",
"finish": "完了",
@@ -220,7 +218,6 @@
"full_name": "氏名",
"gathering_responses": "回答を収集しています",
"general": "一般",
"generate": "生成",
"go_back": "戻る",
"go_to_dashboard": "ダッシュボードへ移動",
"hidden": "非表示",
@@ -428,7 +425,6 @@
"user_id": "ユーザーID",
"user_not_found": "ユーザーが見つかりません",
"variable": "変数",
"variable_ids": "変数ID",
"variables": "変数",
"verified_email": "認証済みメールアドレス",
"video": "動画",
@@ -527,7 +523,6 @@
"add_css_class_or_id": "CSSクラスまたはIDを追加",
"add_regular_expression_here": "ここに正規表現を追加",
"add_url": "URLを追加",
"and": "AND",
"click": "クリック",
"contains": "を含む",
"create_action": "アクションを作成",
@@ -558,7 +553,6 @@
"limit_to_specific_pages": "特定のページに制限",
"matches_regex": "正規表現に一致する",
"on_all_pages": "すべてのページで",
"or": "OR",
"page_filter": "ページフィルター",
"page_view": "ページビュー",
"select_match_type": "一致タイプを選択",
@@ -599,18 +593,9 @@
"contacts_table_refresh_success": "連絡先を正常に更新しました",
"delete_contact_confirmation": "これにより、この連絡先に関連付けられているすべてのフォーム回答と連絡先属性が削除されます。この連絡先のデータに基づいたターゲティングとパーソナライゼーションはすべて失われます。",
"delete_contact_confirmation_with_quotas": "{value, plural, one {これにより この連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。この連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。この連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。} other {これにより これらの連絡先に関連するすべてのアンケート応答と連絡先属性が削除されます。これらの連絡先のデータに基づくターゲティングとパーソナライゼーションが失われます。これらの連絡先がアンケートの割当量を考慮した回答を持っている場合、割当量カウントは減少しますが、割当量の制限は変更されません。}}",
"generate_personal_link": "個人リンクを生成",
"generate_personal_link_description": "公開されたフォームを選択して、この連絡先用のパーソナライズされたリンクを生成します。",
"no_published_link_surveys_available": "公開されたリンクフォームはありません。まずリンクフォームを公開してください。",
"no_published_surveys": "公開されたフォームはありません",
"no_responses_found": "回答が見つかりません",
"not_provided": "提供されていません",
"personal_link_generated": "個人リンクが正常に生成されました",
"personal_link_generated_but_clipboard_failed": "個人用リンクは生成されましたが、クリップボードへのコピーに失敗しました: {url}",
"personal_survey_link": "個人調査リンク",
"please_select_a_survey": "フォームを選択してください",
"search_contact": "連絡先を検索",
"select_a_survey": "フォームを選択",
"select_attribute": "属性を選択",
"unlock_contacts_description": "連絡先を管理し、特定のフォームを送信します",
"unlock_contacts_title": "上位プランで連絡先をアンロック",
@@ -789,14 +774,14 @@
},
"app-connection": {
"app_connection": "アプリ接続",
"app_connection_description": "アプリやウェブサイトをFormbricksに接続します。",
"cache_update_delay_description": "アンケート、連絡先アクション、またはその他のデータを更新した場合、Formbricks SDKを実行しているローカルアプリにそれらの変更が反映されるまで最大1分かかることがあります。",
"cache_update_delay_title": "キャッシュの影響により、変更反映されるまでに約1分かかります",
"app_connection_description": "あなたのアプリをFormbricksに接続します。",
"cache_update_delay_description": "フォーム・連絡先アクションなどを更新してから、Formbricks SDK を実行中のローカルアプリに反映されるまで最大5分かかる場合があります。これは現在のキャッシュ方式の制限によるものです。私たちはキャッシュを改修中で、Formbricks 4.0 で修正を提供予定です。",
"cache_update_delay_title": "キャッシュのため変更反映に最大5分かかります",
"environment_id": "あなたのEnvironmentId",
"environment_id_description": "このIDはこのFormbricks環境を一意に識別します。",
"formbricks_sdk_connected": "Formbricks SDK は接続されています",
"formbricks_sdk_not_connected": "Formbricks SDK はまだ接続されていません。",
"formbricks_sdk_not_connected_description": "FormbricksSDKをウェブサイトやアプリに追加して、Formbricks接続してください",
"formbricks_sdk_not_connected_description": "あなたのウェブサイトまたはアプリをFormbricks接続してください",
"how_to_setup": "セットアップ方法",
"how_to_setup_description": "アプリ内でFormbricksウィジェットを設定する手順に従ってください。",
"receiving_data": "データ受信中 💃🕺",
@@ -815,7 +800,7 @@
"project_deleted_successfully": "プロジェクトを削除しました",
"project_name_settings_description": "プロジェクト名を変更します。",
"project_name_updated_successfully": "プロジェクト名を更新しました",
"recontact_waiting_time": "フォーム間のプロジェクト全体の待機時間",
"recontact_waiting_time": "再接触の待機時間",
"recontact_waiting_time_settings_description": "アプリ内フォーム全体で、ユーザーにどの頻度で表示するかを制御します。",
"this_action_cannot_be_undone": "この操作は取り消せません。",
"wait_x_days_before_showing_next_survey": "次のフォームを表示するまでの待機日数:",
@@ -884,6 +869,7 @@
"add_tag": "タグを追加",
"count": "件数",
"delete_tag_confirmation": "このタグを削除してもよろしいですか?",
"empty_message": "送信にタグ付けすると、ここにタグ一覧が表示されます。",
"manage_tags": "タグを管理",
"manage_tags_description": "回答タグを統合・削除します。",
"merge": "統合",
@@ -1202,9 +1188,9 @@
"add": "追加 +",
"add_a_delay_or_auto_close_the_survey": "遅延を追加するか、フォームを自動的に閉じる",
"add_a_four_digit_pin": "4桁のPINを追加",
"add_a_new_question_to_your_survey": "フォームに新しい質問を追加",
"add_a_variable_to_calculate": "計算する変数を追加",
"add_action_below": "以下にアクションを追加",
"add_block": "ブロックを追加",
"add_choice_below": "以下に選択肢を追加",
"add_color_coding": "色分けを追加",
"add_color_coding_description": "オプションに赤、オレンジ、緑の色コードを追加します。",
@@ -1225,7 +1211,6 @@
"add_other": "「その他」を追加",
"add_photo_or_video": "写真または動画を追加",
"add_pin": "PINを追加",
"add_question": "質問を追加",
"add_question_below": "以下に質問を追加",
"add_row": "行を追加",
"add_variable": "変数を追加",
@@ -1240,6 +1225,7 @@
"allow_multi_select": "複数選択を許可",
"allow_multiple_files": "複数のファイルを許可",
"allow_users_to_select_more_than_one_image": "ユーザーが複数の画像を選択できるようにする",
"always_show_survey": "常にフォームを表示",
"and_launch_surveys_in_your_website_or_app": "ウェブサイトやアプリでフォームを公開できます。",
"animation": "アニメーション",
"app_survey_description": "回答を収集するために、ウェブアプリまたはウェブサイトにフォームを埋め込みます。",
@@ -1252,6 +1238,8 @@
"automatically_mark_the_survey_as_complete_after": "フォームを自動的に完了としてマークする",
"back_button_label": "「戻る」ボタンのラベル",
"background_styling": "背景のスタイル",
"block_deleted": "ブロックが削除されました。",
"block_duplicated": "ブロックが複製されました。",
"bold": "太字",
"brand_color": "ブランドカラー",
"brightness": "明るさ",
@@ -1296,6 +1284,7 @@
"character_limit_toggle_title": "文字数制限を追加",
"checkbox_label": "チェックボックスのラベル",
"choose_the_actions_which_trigger_the_survey": "フォームをトリガーするアクションを選択してください。",
"choose_the_first_question_on_your_block": "ブロックの最初の質問を選択してください",
"choose_where_to_run_the_survey": "フォームを実行する場所を選択してください。",
"city": "市区町村",
"close_survey_on_response_limit": "回答数の上限でフォームを閉じる",
@@ -1322,7 +1311,9 @@
"custom_hostname": "カスタムホスト名",
"darken_or_lighten_background_of_your_choice": "お好みの背景を暗くしたり明るくしたりします。",
"date_format": "日付形式",
"days_before_showing_this_survey_again": "任意のフォームが表示された後、このフォームが再表示されるまでの日数。",
"days_before_showing_this_survey_again": "日後にこのフォームを再度表示します。",
"decide_how_often_people_can_answer_this_survey": "このフォームに人々が何回回答できるかを決定します。",
"delete_block": "ブロックを削除",
"delete_choice": "選択肢を削除",
"disable_the_visibility_of_survey_progress": "フォームの進捗状況の表示を無効にする。",
"display_an_estimate_of_completion_time_for_survey": "フォームの完了時間の目安を表示",
@@ -1334,6 +1325,7 @@
"does_not_include_all_of": "のすべてを含まない",
"does_not_include_one_of": "のいずれも含まない",
"does_not_start_with": "で始まらない",
"duplicate_block": "ブロックを複製",
"edit_link": "編集 リンク",
"edit_recall": "リコールを編集",
"edit_translations": "{lang} 翻訳を編集",
@@ -1350,7 +1342,7 @@
"equals_one_of": "のいずれかと等しい",
"error_publishing_survey": "フォームの公開中にエラーが発生しました。",
"error_saving_changes": "変更の保存中にエラーが発生しました",
"even_after_they_submitted_a_response_e_g_feedback_box": "複数の回答を許可;回答後も表示を継続(例:フィードボックス)",
"even_after_they_submitted_a_response_e_g_feedback_box": "回答を送信した後でも(例:フィードバックボックス)",
"everyone": "全員",
"external_urls_paywall_tooltip": "外部 URL をカスタマイズするにはアップグレードしてください 。 フィッシング防止 。",
"fallback_missing": "フォールバックがありません",
@@ -1421,9 +1413,8 @@
"hostname": "ホスト名",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "{surveyTypeDerived} フォームのカードをどれくらいユニークにしますか",
"if_you_need_more_please": "さらに必要な場合は、",
"if_you_really_want_that_answer_ask_until_you_get_it": "回答が提出されるまで、トリガーされるたびに表示し続けます。",
"ignore_global_waiting_time": "プロジェクト全体の待機時間を無視する",
"ignore_global_waiting_time_description": "このフォームは、最近別のフォームが表示されていても、条件が満たされればいつでも表示できます。",
"if_you_really_want_that_answer_ask_until_you_get_it": "本当にその回答が欲しいなら、それを得るまで尋ねてください。",
"ignore_waiting_time_between_surveys": "フォーム間の待機時間を無視する",
"image": "画像",
"includes_all_of": "のすべてを含む",
"includes_one_of": "のいずれかを含む",
@@ -1490,10 +1481,9 @@
"optional": "オプション",
"options": "オプション",
"override_theme_with_individual_styles_for_this_survey": "このフォームの個別のスタイルでテーマを上書きします。",
"overwrite_global_waiting_time": "カスタム待機時間を設定する",
"overwrite_global_waiting_time_description": "このフォームのみプロジェクト設定を上書きします。",
"overwrite_placement": "配置を上書き",
"overwrite_the_global_placement_of_the_survey": "フォームのグローバルな配置を上書き",
"overwrites_waiting_period_between_surveys_to_x_days": "フォーム間の待機期間を {days} 日に上書きします。",
"pick_a_background_from_our_library_or_upload_your_own": "ライブラリから背景を選択するか、独自にアップロードしてください。",
"picture_idx": "写真 {idx}",
"pin_can_only_contain_numbers": "PINは数字のみでなければなりません。",
@@ -1552,8 +1542,7 @@
"range": "範囲",
"recall_data": "データを呼び出す",
"recall_information_from": "... からの情報を呼び戻す",
"recontact_options_section": "再接触オプション",
"recontact_options_section_description": "待機時間が許可する場合、このフォームを一人の人にどれくらいの頻度で表示できるかを選択します。",
"recontact_options": "再接触オプション",
"redirect_thank_you_card": "サンクスクカードをリダイレクト",
"redirect_to_url": "URLにリダイレクト",
"remove_description": "説明を削除",
@@ -1562,8 +1551,6 @@
"required": "必須",
"reset_to_theme_styles": "テーマのスタイルにリセット",
"reset_to_theme_styles_main_text": "スタイルをテーマのスタイルにリセットしてもよろしいですか?これにより、すべてのカスタムスタイルが削除されます。",
"respect_global_waiting_time": "プロジェクト全体の待機時間を使用する",
"respect_global_waiting_time_description": "このフォームはプロジェクト設定で設定された待機時間に従います。その期間中に他のフォームが表示されていない場合にのみ表示されます。",
"response_limit_can_t_be_set_to_0": "回答数の上限を0に設定することはできません",
"response_limit_needs_to_exceed_number_of_received_responses": "回答数の上限は、受信済みの回答数 ({responseCount}) を超える必要があります。",
"response_limits_redirections_and_more": "回答数の上限、リダイレクトなど。",
@@ -1588,7 +1575,7 @@
"show_advanced_settings": "詳細設定を表示",
"show_button": "ボタンを表示",
"show_language_switch": "言語切り替えを表示",
"show_multiple_times": "限られた回数表示する",
"show_multiple_times": "複数回表示",
"show_only_once": "一度だけ表示",
"show_survey_maximum_of": "フォームの最大表示回数",
"show_survey_to_users": "ユーザーの {percentage}% にフォームを表示",
@@ -1618,12 +1605,13 @@
"switch_multi_lanugage_on_to_get_started": "始めるには多言語をオンにしてください 👉",
"targeted": "ターゲット",
"ten_points": "10点",
"the_survey_will_be_shown_multiple_times_until_they_respond": "指定された回数まで、または回答があるまで表示します(どちらか先に達した方)。",
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "回答なくても1回だけ表示します。",
"the_survey_will_be_shown_multiple_times_until_they_respond": "回答するまで複数回フォームが表示されます",
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "回答なくても、一度だけフォームが表示されます。",
"then": "その後",
"this_action_will_remove_all_the_translations_from_this_survey": "このアクションは、このフォームからすべての翻訳を削除します。",
"this_extension_is_already_added": "この拡張機能はすでに追加されています。",
"this_file_type_is_not_supported": "このファイルタイプはサポートされていません。",
"this_setting_overwrites_your": "この設定はあなたの",
"three_points": "3点",
"times": "回",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "すべてのフォームの配置を一貫させるために、",
@@ -1634,7 +1622,8 @@
"unlock_targeting_description": "属性またはデバイス情報に基づいて、特定のユーザーグループをターゲットにします",
"unlock_targeting_title": "上位プランでターゲティングをアンロック",
"unsaved_changes_warning": "フォームに未保存の変更があります。離れる前に保存しますか?",
"until_they_submit_a_response": "回答が提出されるまで質問する",
"until_they_submit_a_response": "回答を送信するまで",
"untitled_block": "無題のブロック",
"upgrade_notice_description": "多言語フォームを作成し、さらに多くの機能をアンロック",
"upgrade_notice_title": "上位プランで多言語フォームをアンロック",
"upload": "アップロード",
@@ -1642,6 +1631,7 @@
"upper_label": "上限ラベル",
"url_filters": "URLフィルター",
"url_not_supported": "URLはサポートされていません",
"use_with_caution": "注意して使用",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "変数 \"{variableName}\" は \"{quotaName}\" クォータ で使用されています",
"variable_name_is_already_taken_please_choose_another": "変数名はすでに使用されています。別の名前を選択してください。",
@@ -1651,13 +1641,11 @@
"variable_used_in_recall_welcome": "変数 \"{variable}\" が ウェルカム カード で 呼び出され て います。",
"verify_email_before_submission": "送信前にメールアドレスを認証",
"verify_email_before_submission_description": "有効なメールアドレスを持つ人のみが回答できるようにする",
"visibility_and_recontact": "表示と再接触",
"visibility_and_recontact_description": "このフォームがいつ表示され、どのくらいの頻度で再表示できるかをコントロールします。",
"wait": "待つ",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "トリガーから数秒待ってからフォームを表示します",
"waiting_time_across_surveys": "プロジェクト全体の待機間",
"waiting_time_across_surveys_description": "フォーム疲れを防ぐため、このフォームがプロジェクト全体の待機時間とどのように相互作用するかを選択します。",
"waiting_period": "待機間",
"welcome_message": "ウェルカムメッセージ",
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "条件が一致すると、待機時間は無視され、フォームが表示されます。",
"without_a_filter_all_of_your_users_can_be_surveyed": "フィルターがなければ、すべてのユーザーがフォームに回答できます。",
"you_have_not_created_a_segment_yet": "まだセグメントを作成していません",
"you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations": "翻訳を操作するには、プロジェクトで2つ以上の言語を設定する必要があります。",
@@ -1702,7 +1690,7 @@
"last_name": "姓",
"not_completed": "未完了 ⏳",
"os": "OS",
"person_attributes": "回答時の個人属性",
"person_attributes": "人属性",
"phone": "電話",
"respondent_skipped_questions": "回答者はこれらの質問をスキップしました。",
"response_deleted_successfully": "回答を正常に削除しました。",
@@ -1815,7 +1803,6 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "質問 {questionIdx} の回答が {filterComboBoxValue} - {filterValue} である回答のフィルターを追加しました",
"added_filter_for_responses_where_answer_to_question_is_skipped": "質問 {questionIdx} の回答がスキップされた回答のフィルターを追加しました",
"aggregated": "集計済み",
"all_responses_csv": "すべての回答 (CSV)",
"all_responses_excel": "すべての回答 (Excel)",
"all_time": "全期間",
@@ -1839,6 +1826,7 @@
"filtered_responses_csv": "フィルター済み回答 (CSV)",
"filtered_responses_excel": "フィルター済み回答 (Excel)",
"generating_qr_code": "QRコードを生成中",
"go_to_setup_checklist": "セットアップチェックリストへ移動 👉",
"impressions": "表示回数",
"impressions_tooltip": "フォームが表示された回数。",
"in_app": {
@@ -1872,7 +1860,7 @@
},
"includes_all": "すべてを含む",
"includes_either": "どちらかを含む",
"individual": "個人",
"install_widget": "Formbricksウィジェットをインストール",
"is_equal_to": "と等しい",
"is_less_than": "より小さい",
"last_30_days": "過去30日間",
@@ -1885,7 +1873,6 @@
"no_responses_found": "回答が見つかりません",
"other_values_found": "他の値が見つかりました",
"overall": "全体",
"promoters": "推奨者",
"qr_code": "QRコード",
"qr_code_description": "QRコード経由で収集された回答は匿名です。",
"qr_code_download_failed": "QRコードのダウンロードに失敗しました",
@@ -1895,7 +1882,6 @@
"quotas_completed_tooltip": "回答者 によって 完了 した 定員 の 数。",
"reset_survey": "フォームをリセット",
"reset_survey_warning": "フォームをリセットすると、このフォームに関連付けられているすべての回答と表示が削除されます。この操作は元に戻せません。",
"satisfied": "満足",
"selected_responses_csv": "選択した回答 (CSV)",
"selected_responses_excel": "選択した回答 (Excel)",
"setup_integrations": "連携を設定",
@@ -1911,6 +1897,7 @@
"ttc_tooltip": "フォームを完了するまでの平均時間。",
"unknown_question_type": "不明な質問の種類",
"use_personal_links": "個人リンクを使用",
"waiting_for_response": "回答を待っています 🧘‍♂️",
"whats_next": "次は何をしますか?",
"your_survey_is_public": "あなたのフォームは公開されています",
"youre_not_plugged_in_yet": "まだ接続されていません!"
@@ -2265,6 +2252,7 @@
"csat_survey_question_3_headline": "申し訳ありません!体験を改善するために何かできることはありますか?",
"csat_survey_question_3_placeholder": "ここに回答を入力してください...",
"cta_description": "情報を表示し、特定の行動を促す",
"custom_survey_block_1_name": "ブロック1",
"custom_survey_description": "テンプレートを使わずにアンケートを作成する。",
"custom_survey_name": "最初から始める",
"custom_survey_question_1_headline": "何を知りたいですか?",
@@ -2674,7 +2662,6 @@
"preview_survey_question_2_choice_2_label": "いいえ、結構です!",
"preview_survey_question_2_headline": "最新情報を知りたいですか?",
"preview_survey_welcome_card_headline": "ようこそ!",
"preview_survey_welcome_card_html": "フィードバックありがとうございます - さあ、始めましょう!",
"prioritize_features_description": "ユーザーが最も必要とする機能と最も必要としない機能を特定する。",
"prioritize_features_name": "機能の優先順位付け",
"prioritize_features_question_1_choice_1": "機能1",
File diff suppressed because it is too large Load Diff
+34 -47
View File
@@ -153,7 +153,6 @@
"clear_filters": "Limpar filtros",
"clear_selection": "Limpar seleção",
"click": "Clica",
"click_to_filter": "Clique para filtrar",
"clicks": "cliques",
"close": "Fechar",
"code": "Código",
@@ -211,7 +210,6 @@
"error_rate_limit_description": "Número máximo de requisições atingido. Por favor, tente novamente mais tarde.",
"error_rate_limit_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas",
"failed_to_copy_to_clipboard": "Falha ao copiar para a área de transferência",
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_projects": "Falha ao carregar projetos",
"finish": "Terminar",
@@ -220,7 +218,6 @@
"full_name": "Nome completo",
"gathering_responses": "Recolhendo respostas",
"general": "Geral",
"generate": "Gerar",
"go_back": "Voltar",
"go_to_dashboard": "Ir para o Painel",
"hidden": "Escondido",
@@ -428,7 +425,6 @@
"user_id": "ID do usuário",
"user_not_found": "Usuário não encontrado",
"variable": "variável",
"variable_ids": "IDs de variáveis",
"variables": "Variáveis",
"verified_email": "Email Verificado",
"video": "vídeo",
@@ -527,7 +523,6 @@
"add_css_class_or_id": "Adicionar classe ou id CSS",
"add_regular_expression_here": "Adicionar uma expressão regular aqui",
"add_url": "Adicionar URL",
"and": "E",
"click": "Clica",
"contains": "contém",
"create_action": "criar ação",
@@ -558,7 +553,6 @@
"limit_to_specific_pages": "Limitar a páginas específicas",
"matches_regex": "Correspondência regex",
"on_all_pages": "Em todas as páginas",
"or": "OU",
"page_filter": "filtro de página",
"page_view": "Visualização de Página",
"select_match_type": "Selecionar tipo de partida",
@@ -599,18 +593,9 @@
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
"delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos. Se este contato tiver respostas que contam para cotas da pesquisa, as contagens das cotas serão reduzidas, mas os limites das cotas permanecerão inalterados.}}",
"generate_personal_link": "Gerar link pessoal",
"generate_personal_link_description": "Selecione uma pesquisa publicada para gerar um link personalizado para este contato.",
"no_published_link_surveys_available": "Não há pesquisas de link publicadas disponíveis. Por favor, publique uma pesquisa de link primeiro.",
"no_published_surveys": "Sem pesquisas publicadas",
"no_responses_found": "Nenhuma resposta encontrada",
"not_provided": "Não fornecido",
"personal_link_generated": "Link pessoal gerado com sucesso",
"personal_link_generated_but_clipboard_failed": "Link pessoal gerado, mas falha ao copiar para a área de transferência: {url}",
"personal_survey_link": "Link da pesquisa pessoal",
"please_select_a_survey": "Por favor, selecione uma pesquisa",
"search_contact": "Buscar contato",
"select_a_survey": "Selecione uma pesquisa",
"select_attribute": "Selecionar Atributo",
"unlock_contacts_description": "Gerencie contatos e envie pesquisas direcionadas",
"unlock_contacts_title": "Desbloqueie contatos com um plano superior",
@@ -789,14 +774,14 @@
},
"app-connection": {
"app_connection": "Conexão do App",
"app_connection_description": "Conecte seu app ou site ao Formbricks.",
"cache_update_delay_description": "Quando você faz atualizações em pesquisas, contatos, ações ou outros dados, pode levar até 1 minuto para que essas alterações apareçam no seu aplicativo local executando o SDK do Formbricks.",
"cache_update_delay_title": "As alterações serão refletidas após ~1 minuto devido ao cache",
"app_connection_description": "Conecte seu app ao Formbricks.",
"cache_update_delay_description": "Quando você faz atualizações em pesquisas, contatos, ações ou outros dados, pode levar até 5 minutos para que essas mudanças apareçam no seu app local rodando o SDK do Formbricks. Esse atraso é devido a uma limitação no nosso sistema de cache atual. Estamos ativamente retrabalhando o cache e planejamos lançar uma correção no Formbricks 4.0.",
"cache_update_delay_title": "As mudanças serão refletidas após 5 minutos devido ao cache",
"environment_id": "Seu Id do Ambiente",
"environment_id_description": "Este ID identifica exclusivamente este ambiente do Formbricks.",
"formbricks_sdk_connected": "O SDK do Formbricks está conectado",
"formbricks_sdk_not_connected": "O SDK do Formbricks ainda não está conectado.",
"formbricks_sdk_not_connected_description": "Adicione o SDK do Formbricks ao seu site ou app para conectá-lo ao Formbricks",
"formbricks_sdk_not_connected_description": "Conecte seu site ou app com o Formbricks",
"how_to_setup": "Como configurar",
"how_to_setup_description": "Siga esses passos para configurar o widget do Formbricks no seu app.",
"receiving_data": "Recebendo dados 💃🕺",
@@ -815,7 +800,7 @@
"project_deleted_successfully": "Projeto deletado com sucesso",
"project_name_settings_description": "Mude o nome do seu projeto.",
"project_name_updated_successfully": "Nome do projeto atualizado com sucesso",
"recontact_waiting_time": "Tempo de espera entre pesquisas em todo o projeto",
"recontact_waiting_time": "Tempo de Espera para Recontato",
"recontact_waiting_time_settings_description": "Controle com que frequência os usuários podem ser pesquisados em todas as pesquisas do app.",
"this_action_cannot_be_undone": "Essa ação não pode ser desfeita.",
"wait_x_days_before_showing_next_survey": "Espere X dias antes de mostrar a próxima pesquisa:",
@@ -884,6 +869,7 @@
"add_tag": "Adicionar Tag",
"count": "Contar",
"delete_tag_confirmation": "Tem certeza de que quer deletar essa tag?",
"empty_message": "Marque uma submissão para encontrar sua lista de tags aqui.",
"manage_tags": "Gerenciar Tags",
"manage_tags_description": "Mesclar e remover tags de resposta.",
"merge": "mesclar",
@@ -1202,9 +1188,9 @@
"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",
"add_a_new_question_to_your_survey": "Adicionar uma nova pergunta à sua pesquisa",
"add_a_variable_to_calculate": "Adicione uma variável para calcular",
"add_action_below": "Adicionar ação abaixo",
"add_block": "Adicionar bloco",
"add_choice_below": "Adicionar opção abaixo",
"add_color_coding": "Adicionar codificação por cores",
"add_color_coding_description": "Adicione os códigos de cores vermelho, laranja e verde às opções.",
@@ -1225,7 +1211,6 @@
"add_other": "Adicionar \"Outro",
"add_photo_or_video": "Adicionar foto ou video",
"add_pin": "Adicionar PIN",
"add_question": "Adicionar pergunta",
"add_question_below": "Adicione a pergunta abaixo",
"add_row": "Adicionar linha",
"add_variable": "Adicionar variável",
@@ -1240,6 +1225,7 @@
"allow_multi_select": "Permitir seleção múltipla",
"allow_multiple_files": "Permitir vários arquivos",
"allow_users_to_select_more_than_one_image": "Permitir que os usuários selecionem mais de uma imagem",
"always_show_survey": "Mostrar pesquisa sempre",
"and_launch_surveys_in_your_website_or_app": "e lançar pesquisas no seu site ou app.",
"animation": "animação",
"app_survey_description": "Embuta uma pesquisa no seu app ou site para coletar respostas.",
@@ -1252,6 +1238,8 @@
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente a pesquisa como concluída após",
"back_button_label": "Voltar",
"background_styling": "Estilo de Fundo",
"block_deleted": "Bloco excluído.",
"block_duplicated": "Bloco duplicado.",
"bold": "Negrito",
"brand_color": "Cor da marca",
"brightness": "brilho",
@@ -1296,6 +1284,7 @@
"character_limit_toggle_title": "Adicionar limites de caracteres",
"checkbox_label": "Rótulo da Caixa de Seleção",
"choose_the_actions_which_trigger_the_survey": "Escolha as ações que disparam a pesquisa.",
"choose_the_first_question_on_your_block": "Escolha a primeira pergunta do seu bloco",
"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",
@@ -1322,7 +1311,9 @@
"custom_hostname": "Hostname personalizado",
"darken_or_lighten_background_of_your_choice": "Escureça ou clareie o fundo da sua escolha.",
"date_format": "Formato de data",
"days_before_showing_this_survey_again": "dias após qualquer pesquisa ser mostrada antes que esta pesquisa possa aparecer.",
"days_before_showing_this_survey_again": "dias antes de mostrar essa pesquisa de novo.",
"decide_how_often_people_can_answer_this_survey": "Decida com que frequência as pessoas podem responder a essa pesquisa.",
"delete_block": "Excluir bloco",
"delete_choice": "Deletar opção",
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa de tempo de conclusão da pesquisa",
@@ -1334,6 +1325,7 @@
"does_not_include_all_of": "Não inclui todos de",
"does_not_include_one_of": "Não inclui um de",
"does_not_start_with": "Não começa com",
"duplicate_block": "Duplicar bloco",
"edit_link": "Editar link",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções de {lang}",
@@ -1350,7 +1342,7 @@
"equals_one_of": "É igual a um de",
"error_publishing_survey": "Ocorreu um erro ao publicar a pesquisa.",
"error_saving_changes": "Erro ao salvar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar mostrando mesmo após uma resposta (ex.: caixa de feedback).",
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de eles enviarem uma resposta (por exemplo, Caixa de Feedback)",
"everyone": "Todo mundo",
"external_urls_paywall_tooltip": "Por favor, faça upgrade para personalizar o URL externo. Prevenção de phishing.",
"fallback_missing": "Faltando alternativa",
@@ -1421,9 +1413,8 @@
"hostname": "nome do host",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão descoladas você quer suas cartas em Pesquisas {surveyTypeDerived}",
"if_you_need_more_please": "Se você precisar de mais, por favor",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar mostrando sempre que acionada até que uma resposta seja enviada.",
"ignore_global_waiting_time": "Ignorar tempo de espera do projeto",
"ignore_global_waiting_time_description": "Esta pesquisa pode ser mostrada sempre que suas condições forem atendidas, mesmo que outra pesquisa tenha sido mostrada recentemente.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Se você realmente quer essa resposta, pergunte até conseguir.",
"ignore_waiting_time_between_surveys": "Ignorar tempo de espera entre pesquisas",
"image": "imagem",
"includes_all_of": "Inclui tudo de",
"includes_one_of": "Inclui um de",
@@ -1490,10 +1481,9 @@
"optional": "Opcional",
"options": "Opções",
"override_theme_with_individual_styles_for_this_survey": "Substitua o tema com estilos individuais para essa pesquisa.",
"overwrite_global_waiting_time": "Definir tempo de espera personalizado",
"overwrite_global_waiting_time_description": "Substituir a configuração do projeto apenas para esta pesquisa.",
"overwrite_placement": "Substituir posicionamento",
"overwrite_the_global_placement_of_the_survey": "Substituir a posição global da pesquisa",
"overwrites_waiting_period_between_surveys_to_x_days": "Substitui o período de espera entre as pesquisas para {days} dia(s).",
"pick_a_background_from_our_library_or_upload_your_own": "Escolha um fundo da nossa biblioteca ou faça upload do seu próprio.",
"picture_idx": "Imagem {idx}",
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
@@ -1552,8 +1542,7 @@
"range": "alcance",
"recall_data": "Lembrar dados",
"recall_information_from": "Recuperar informações de ...",
"recontact_options_section": "Opções de recontato",
"recontact_options_section_description": "Se o tempo de espera permitir, escolha com que frequência esta pesquisa pode ser mostrada a uma pessoa.",
"recontact_options": "Opções de Recontato",
"redirect_thank_you_card": "Redirecionar cartão de agradecimento",
"redirect_to_url": "Redirecionar para URL",
"remove_description": "Remover descrição",
@@ -1562,8 +1551,6 @@
"required": "Obrigatório",
"reset_to_theme_styles": "Redefinir para estilos do tema",
"reset_to_theme_styles_main_text": "Tem certeza de que quer redefinir o estilo para o tema padrão? Isso vai remover todas as personalizações.",
"respect_global_waiting_time": "Usar tempo de espera do projeto",
"respect_global_waiting_time_description": "Esta pesquisa segue o tempo de espera definido na configuração do projeto. Ela só é mostrada se nenhuma outra pesquisa tiver aparecido durante esse período.",
"response_limit_can_t_be_set_to_0": "Limite de resposta não pode ser 0",
"response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).",
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
@@ -1588,7 +1575,7 @@
"show_advanced_settings": "Mostrar configurações avançadas",
"show_button": "Mostrar Botão",
"show_language_switch": "Mostrar troca de idioma",
"show_multiple_times": "Mostrar um número limitado de vezes",
"show_multiple_times": "Mostrar várias vezes",
"show_only_once": "Mostrar só uma vez",
"show_survey_maximum_of": "Mostrar no máximo",
"show_survey_to_users": "Mostrar pesquisa para % dos usuários",
@@ -1618,12 +1605,13 @@
"switch_multi_lanugage_on_to_get_started": "Ative o modo multilíngue para começar 👉",
"targeted": "direcionado",
"ten_points": "10 pontos",
"the_survey_will_be_shown_multiple_times_until_they_respond": "Mostrar no máximo o número especificado de vezes, ou até que respondam (o que ocorrer primeiro).",
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar uma única vez, mesmo que não respondam.",
"the_survey_will_be_shown_multiple_times_until_they_respond": "A pesquisa vai ser mostrada várias vezes até eles responderem",
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "A pesquisa será mostrada uma vez, mesmo se a pessoa não responder.",
"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_extension_is_already_added": "Essa extensão já foi adicionada.",
"this_file_type_is_not_supported": "Esse tipo de arquivo não é suportado.",
"this_setting_overwrites_your": "Essa configuração sobrescreve seu",
"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",
@@ -1634,7 +1622,8 @@
"unlock_targeting_description": "Direcione grupos específicos de usuários com base em atributos ou informações do dispositivo",
"unlock_targeting_title": "Desbloqueie o direcionamento com um plano superior",
"unsaved_changes_warning": "Você tem alterações não salvas na sua pesquisa. Quer salvar antes de sair?",
"until_they_submit_a_response": "Perguntar até que enviem uma resposta",
"until_they_submit_a_response": "Até eles enviarem uma resposta",
"untitled_block": "Bloco sem título",
"upgrade_notice_description": "Crie pesquisas multilíngues e desbloqueie muitas outras funcionalidades",
"upgrade_notice_title": "Desbloqueie pesquisas multilíngues com um plano superior",
"upload": "Enviar",
@@ -1642,6 +1631,7 @@
"upper_label": "Etiqueta Superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportada",
"use_with_caution": "Use com cuidado",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} está sendo usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
@@ -1651,13 +1641,11 @@
"variable_used_in_recall_welcome": "Variável \"{variable}\" está sendo recordada no Card de Boas-Vindas.",
"verify_email_before_submission": "Verifique o e-mail antes de enviar",
"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.",
"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": "Tempo de espera em todo o projeto",
"waiting_time_across_surveys_description": "Para evitar fadiga de pesquisas, escolha como esta pesquisa interage com o tempo de espera em todo o projeto.",
"waiting_period": "período de espera",
"welcome_message": "Mensagem de boas-vindas",
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "Quando as condições forem atendidas, o tempo de espera será ignorado e a pesquisa será exibida.",
"without_a_filter_all_of_your_users_can_be_surveyed": "Sem um filtro, todos os seus usuários podem ser pesquisados.",
"you_have_not_created_a_segment_yet": "Você ainda não criou um segmento.",
"you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations": "Você precisa ter dois ou mais idiomas configurados no seu projeto para trabalhar com traduções.",
@@ -1702,7 +1690,7 @@
"last_name": "Sobrenome",
"not_completed": "Não Concluído ⏳",
"os": "sistema operacional",
"person_attributes": "Atributos da pessoa no momento do envio",
"person_attributes": "Atributos da pessoa",
"phone": "Celular",
"respondent_skipped_questions": "Respondente pulou essas perguntas.",
"response_deleted_successfully": "Resposta deletada com sucesso.",
@@ -1815,7 +1803,6 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} é {filterComboBoxValue} - {filterValue} ",
"added_filter_for_responses_where_answer_to_question_is_skipped": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} foi pulada",
"aggregated": "Agregado",
"all_responses_csv": "Todas as respostas (CSV)",
"all_responses_excel": "Todas as respostas (Excel)",
"all_time": "Todo o tempo",
@@ -1839,6 +1826,7 @@
"filtered_responses_csv": "Respostas filtradas (CSV)",
"filtered_responses_excel": "Respostas filtradas (Excel)",
"generating_qr_code": "Gerando código QR",
"go_to_setup_checklist": "Vai para a Lista de Configuração 👉",
"impressions": "Impressões",
"impressions_tooltip": "Número de vezes que a pesquisa foi visualizada.",
"in_app": {
@@ -1872,7 +1860,7 @@
},
"includes_all": "Inclui tudo",
"includes_either": "Inclui ou",
"individual": "Individual",
"install_widget": "Instalar Widget do Formbricks",
"is_equal_to": "É igual a",
"is_less_than": "É menor que",
"last_30_days": "Últimos 30 dias",
@@ -1885,7 +1873,6 @@
"no_responses_found": "Nenhuma resposta encontrada",
"other_values_found": "Outros valores encontrados",
"overall": "No geral",
"promoters": "Promotores",
"qr_code": "Código QR",
"qr_code_description": "Respostas coletadas via código QR são anônimas.",
"qr_code_download_failed": "falha no download do código QR",
@@ -1895,7 +1882,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",
@@ -1911,6 +1897,7 @@
"ttc_tooltip": "Tempo médio para completar a pergunta.",
"unknown_question_type": "Tipo de pergunta desconhecido",
"use_personal_links": "Use links pessoais",
"waiting_for_response": "Aguardando uma resposta 🧘‍♂️",
"whats_next": "E agora?",
"your_survey_is_public": "Sua pesquisa é pública",
"youre_not_plugged_in_yet": "Você ainda não tá conectado!"
@@ -2265,6 +2252,7 @@
"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...",
"cta_description": "Mostrar informações e pedir para os usuários tomarem uma ação específica",
"custom_survey_block_1_name": "Bloco 1",
"custom_survey_description": "Crie uma pesquisa sem modelo.",
"custom_survey_name": "Começar do zero",
"custom_survey_question_1_headline": "O que você gostaria de saber?",
@@ -2674,7 +2662,6 @@
"preview_survey_question_2_choice_2_label": "Não, obrigado!",
"preview_survey_question_2_headline": "Quer ficar por dentro?",
"preview_survey_welcome_card_headline": "Bem-vindo!",
"preview_survey_welcome_card_html": "Valeu pelo feedback - bora lá!",
"prioritize_features_description": "Identifique os recursos que seus usuários mais e menos precisam.",
"prioritize_features_name": "Priorizar Funcionalidades",
"prioritize_features_question_1_choice_1": "Recurso 1",
+34 -47
View File
@@ -153,7 +153,6 @@
"clear_filters": "Limpar filtros",
"clear_selection": "Limpar seleção",
"click": "Clique",
"click_to_filter": "Clique para filtrar",
"clicks": "Cliques",
"close": "Fechar",
"code": "Código",
@@ -211,7 +210,6 @@
"error_rate_limit_description": "Número máximo de pedidos alcançado. Por favor, tente novamente mais tarde.",
"error_rate_limit_title": "Limite de Taxa Excedido",
"expand_rows": "Expandir linhas",
"failed_to_copy_to_clipboard": "Falha ao copiar para a área de transferência",
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_projects": "Falha ao carregar projetos",
"finish": "Concluir",
@@ -220,7 +218,6 @@
"full_name": "Nome completo",
"gathering_responses": "A recolher respostas",
"general": "Geral",
"generate": "Gerar",
"go_back": "Voltar",
"go_to_dashboard": "Ir para o Painel",
"hidden": "Oculto",
@@ -428,7 +425,6 @@
"user_id": "ID do Utilizador",
"user_not_found": "Utilizador não encontrado",
"variable": "Variável",
"variable_ids": "IDs de variáveis",
"variables": "Variáveis",
"verified_email": "Email verificado",
"video": "Vídeo",
@@ -527,7 +523,6 @@
"add_css_class_or_id": "Adicionar classe ou id CSS",
"add_regular_expression_here": "Adicione uma expressão regular aqui",
"add_url": "Adicionar URL",
"and": "E",
"click": "Clique",
"contains": "Contém",
"create_action": "Criar ação",
@@ -558,7 +553,6 @@
"limit_to_specific_pages": "Limitar a páginas específicas",
"matches_regex": "Coincide com regex",
"on_all_pages": "Em todas as páginas",
"or": "OU",
"page_filter": "Filtro de página",
"page_view": "Visualização de Página",
"select_match_type": "Selecionar tipo de correspondência",
@@ -599,18 +593,9 @@
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
"delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
"delete_contact_confirmation_with_quotas": "{value, plural, other {Isto irá eliminar todas as respostas das pesquisas e os atributos de contacto associados a este contacto. Qualquer segmentação e personalização baseados nos dados deste contacto serão perdidos. Se este contacto tiver respostas que contribuam para as quotas das pesquisas, as contagens de quotas serão reduzidas, mas os limites das quotas permanecerão inalterados.}}",
"generate_personal_link": "Gerar Link Pessoal",
"generate_personal_link_description": "Selecione um inquérito publicado para gerar um link personalizado para este contacto.",
"no_published_link_surveys_available": "Não existem inquéritos de link publicados disponíveis. Por favor, publique primeiro um inquérito de link.",
"no_published_surveys": "Sem inquéritos publicados",
"no_responses_found": "Nenhuma resposta encontrada",
"not_provided": "Não fornecido",
"personal_link_generated": "Link pessoal gerado com sucesso",
"personal_link_generated_but_clipboard_failed": "Link pessoal gerado mas falha ao copiar para a área de transferência: {url}",
"personal_survey_link": "Link do inquérito pessoal",
"please_select_a_survey": "Por favor, selecione um inquérito",
"search_contact": "Procurar contacto",
"select_a_survey": "Selecione um inquérito",
"select_attribute": "Selecionar Atributo",
"unlock_contacts_description": "Gerir contactos e enviar inquéritos direcionados",
"unlock_contacts_title": "Desbloqueie os contactos com um plano superior",
@@ -789,14 +774,14 @@
},
"app-connection": {
"app_connection": "Conexão de aplicação",
"app_connection_description": "Ligue a sua aplicação ou website ao Formbricks.",
"cache_update_delay_description": "Quando faz atualizações a inquéritos, contactos, ações ou outros dados, pode demorar até 1 minuto para que essas alterações apareçam na sua aplicação local que executa o SDK Formbricks.",
"cache_update_delay_title": "As alterações serão refletidas após ~1 minuto devido ao armazenamento em cache",
"app_connection_description": "Conecte a sua aplicação ao Formbricks",
"cache_update_delay_description": "Quando fizer atualizações para inquéritos, contactos, ações ou outros dados, pode demorar até 5 minutos para que essas alterações apareçam na sua aplicação local a correr o SDK do Formbricks. Este atraso deve-se a uma limitação no nosso atual sistema de cache. Estamos a trabalhar ativamente na reformulação da cache e lançaremos uma correção no Formbricks 4.0.",
"cache_update_delay_title": "As alterações serão refletidas após 5 minutos devido ao armazenamento em cache.",
"environment_id": "O seu identificador",
"environment_id_description": "Este id identifica o seu espaço Formbricks.",
"formbricks_sdk_connected": "O SDK do Formbricks está conectado",
"formbricks_sdk_not_connected": "O Formbricks SDK ainda não está conectado",
"formbricks_sdk_not_connected_description": "Adicione o SDK do Formbricks ao seu website ou aplicação para o ligar ao Formbricks",
"formbricks_sdk_not_connected_description": "Ligue o seu website ou aplicação ao Formbricks",
"how_to_setup": "Como configurar",
"how_to_setup_description": "Siga estes passos para configurar o widget Formbricks na sua aplicação.",
"receiving_data": "A receber dados 💃🕺",
@@ -815,7 +800,7 @@
"project_deleted_successfully": "Projeto eliminado com sucesso",
"project_name_settings_description": "Altere o nome dos seus projetos.",
"project_name_updated_successfully": "Nome do projeto atualizado com sucesso",
"recontact_waiting_time": "Tempo de Espera Entre Inquéritos em Todo o Projeto",
"recontact_waiting_time": "Tempo de espera de recontacto",
"recontact_waiting_time_settings_description": "Controle a regularidade com que os utilizadores podem ser inquiridos em todos os inquéritos da aplicação.",
"this_action_cannot_be_undone": "Esta ação não pode ser desfeita.",
"wait_x_days_before_showing_next_survey": "Dias de espera:",
@@ -884,6 +869,7 @@
"add_tag": "Adicionar Etiqueta",
"count": "Contagem",
"delete_tag_confirmation": "Tem a certeza de que deseja eliminar esta etiqueta?",
"empty_message": "Crie etiquetas para as suas submissões e veja-as aqui",
"manage_tags": "Gerir Etiquetas",
"manage_tags_description": "Junte e remova etiquetas de resposta",
"merge": "Fundir",
@@ -1202,9 +1188,9 @@
"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",
"add_a_new_question_to_your_survey": "Adicionar uma nova pergunta ao seu inquérito",
"add_a_variable_to_calculate": "Adicionar uma variável para calcular",
"add_action_below": "Adicionar ação abaixo",
"add_block": "Adicionar bloco",
"add_choice_below": "Adicionar escolha abaixo",
"add_color_coding": "Adicionar codificação de cores",
"add_color_coding_description": "Adicionar códigos de cores vermelho, laranja e verde às opções.",
@@ -1225,7 +1211,6 @@
"add_other": "Adicionar \"Outro\"",
"add_photo_or_video": "Adicionar foto ou vídeo",
"add_pin": "Adicionar PIN",
"add_question": "Adicionar pergunta",
"add_question_below": "Adicionar pergunta abaixo",
"add_row": "Adicionar linha",
"add_variable": "Adicionar variável",
@@ -1240,6 +1225,7 @@
"allow_multi_select": "Permitir seleção múltipla",
"allow_multiple_files": "Permitir vários ficheiros",
"allow_users_to_select_more_than_one_image": "Permitir aos utilizadores selecionar mais do que uma imagem",
"always_show_survey": "Mostrar sempre o inquérito",
"and_launch_surveys_in_your_website_or_app": "e lance inquéritos no seu site ou aplicação.",
"animation": "Animação",
"app_survey_description": "Incorpore um inquérito na sua aplicação web ou site para recolher respostas.",
@@ -1252,6 +1238,8 @@
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente o inquérito como concluído após",
"back_button_label": "Rótulo do botão \"Voltar\"",
"background_styling": "Estilo de Fundo",
"block_deleted": "Bloco eliminado.",
"block_duplicated": "Bloco duplicado.",
"bold": "Negrito",
"brand_color": "Cor da marca",
"brightness": "Brilho",
@@ -1296,6 +1284,7 @@
"character_limit_toggle_title": "Adicionar limites de caracteres",
"checkbox_label": "Rótulo da Caixa de Seleção",
"choose_the_actions_which_trigger_the_survey": "Escolha as ações que desencadeiam o inquérito.",
"choose_the_first_question_on_your_block": "Escolha a primeira pergunta no seu bloco",
"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",
@@ -1322,7 +1311,9 @@
"custom_hostname": "Nome do host personalizado",
"darken_or_lighten_background_of_your_choice": "Escurecer ou clarear o fundo da sua escolha.",
"date_format": "Formato da data",
"days_before_showing_this_survey_again": "dias após qualquer inquérito ser mostrado antes que este inquérito possa aparecer.",
"days_before_showing_this_survey_again": "dias antes de mostrar este inquérito novamente.",
"decide_how_often_people_can_answer_this_survey": "Decida com que frequência as pessoas podem responder a este inquérito.",
"delete_block": "Eliminar bloco",
"delete_choice": "Eliminar escolha",
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa do tempo de conclusão do inquérito",
@@ -1334,6 +1325,7 @@
"does_not_include_all_of": "Não inclui todos de",
"does_not_include_one_of": "Não inclui um de",
"does_not_start_with": "Não começa com",
"duplicate_block": "Duplicar bloco",
"edit_link": "Editar link",
"edit_recall": "Editar Lembrete",
"edit_translations": "Editar traduções {lang}",
@@ -1350,7 +1342,7 @@
"equals_one_of": "Igual a um de",
"error_publishing_survey": "Ocorreu um erro ao publicar o questionário.",
"error_saving_changes": "Erro ao guardar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar a mostrar mesmo após uma resposta (por exemplo, Caixa de Feedback).",
"even_after_they_submitted_a_response_e_g_feedback_box": "Mesmo depois de terem enviado uma resposta (por exemplo, Caixa de Feedback)",
"everyone": "Todos",
"external_urls_paywall_tooltip": "Por favor, atualize para personalizar o URL externo. Prevenção contra phishing.",
"fallback_missing": "Substituição em falta",
@@ -1421,9 +1413,8 @@
"hostname": "Nome do host",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão extravagantes quer os seus cartões em Inquéritos {surveyTypeDerived}",
"if_you_need_more_please": "Se precisar de mais, por favor",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar a mostrar sempre que acionado até que uma resposta seja submetida.",
"ignore_global_waiting_time": "Ignorar tempo de espera de todo o projeto",
"ignore_global_waiting_time_description": "Este inquérito pode ser mostrado sempre que as suas condições forem cumpridas, mesmo que outro inquérito tenha sido mostrado recentemente.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Se realmente quiser essa resposta, pergunte até obtê-la.",
"ignore_waiting_time_between_surveys": "Ignorar tempo de espera entre inquéritos",
"image": "Imagem",
"includes_all_of": "Inclui todos de",
"includes_one_of": "Inclui um de",
@@ -1490,10 +1481,9 @@
"optional": "Opcional",
"options": "Opções",
"override_theme_with_individual_styles_for_this_survey": "Substituir o tema com estilos individuais para este inquérito.",
"overwrite_global_waiting_time": "Definir tempo de espera personalizado",
"overwrite_global_waiting_time_description": "Substituir a configuração do projeto apenas para este inquérito.",
"overwrite_placement": "Substituir colocação",
"overwrite_the_global_placement_of_the_survey": "Substituir a colocação global do inquérito",
"overwrites_waiting_period_between_surveys_to_x_days": "Substitui o período de espera entre inquéritos para {days} dia(s).",
"pick_a_background_from_our_library_or_upload_your_own": "Escolha um fundo da nossa biblioteca ou carregue o seu próprio.",
"picture_idx": "Imagem {idx}",
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
@@ -1552,8 +1542,7 @@
"range": "Intervalo",
"recall_data": "Recuperar dados",
"recall_information_from": "Recordar informação de ...",
"recontact_options_section": "Opções de recontacto",
"recontact_options_section_description": "Se o tempo de espera permitir, escolha com que frequência este inquérito pode ser mostrado a uma pessoa.",
"recontact_options": "Opções de Recontacto",
"redirect_thank_you_card": "Redirecionar cartão de agradecimento",
"redirect_to_url": "Redirecionar para Url",
"remove_description": "Remover descrição",
@@ -1562,8 +1551,6 @@
"required": "Obrigatório",
"reset_to_theme_styles": "Repor para estilos do tema",
"reset_to_theme_styles_main_text": "Tem a certeza de que deseja repor o estilo para os estilos do tema? Isto irá remover todos os estilos personalizados.",
"respect_global_waiting_time": "Usar tempo de espera de todo o projeto",
"respect_global_waiting_time_description": "Este inquérito segue o tempo de espera definido na configuração do projeto. Só é mostrado se nenhum outro inquérito tiver aparecido durante esse período.",
"response_limit_can_t_be_set_to_0": "O limite de respostas não pode ser definido como 0",
"response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).",
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
@@ -1588,7 +1575,7 @@
"show_advanced_settings": "Mostrar definições avançadas",
"show_button": "Mostrar Botão",
"show_language_switch": "Mostrar alternador de idioma",
"show_multiple_times": "Mostrar um número limitado de vezes",
"show_multiple_times": "Mostrar várias vezes",
"show_only_once": "Mostrar apenas uma vez",
"show_survey_maximum_of": "Mostrar inquérito máximo de",
"show_survey_to_users": "Mostrar inquérito a % dos utilizadores",
@@ -1618,12 +1605,13 @@
"switch_multi_lanugage_on_to_get_started": "Ative o modo multilingue para começar 👉",
"targeted": "Alvo",
"ten_points": "10 pontos",
"the_survey_will_be_shown_multiple_times_until_they_respond": "Mostrar no máximo o número especificado de vezes, ou até que respondam (o que ocorrer primeiro).",
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar uma única vez, mesmo que não respondam.",
"the_survey_will_be_shown_multiple_times_until_they_respond": "O inquérito será mostrado várias vezes até que respondam",
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "O inquérito será mostrado uma vez, mesmo que a pessoa não responda.",
"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_extension_is_already_added": "Esta extensão já está adicionada.",
"this_file_type_is_not_supported": "Este tipo de ficheiro não é suportado.",
"this_setting_overwrites_your": "Esta configuração substitui o seu",
"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",
@@ -1634,7 +1622,8 @@
"unlock_targeting_description": "Alvo de grupos de utilizadores específicos com base em atributos ou informações do dispositivo",
"unlock_targeting_title": "Desbloqueie a segmentação com um plano superior",
"unsaved_changes_warning": "Tem alterações não guardadas no seu inquérito. Gostaria de as guardar antes de sair?",
"until_they_submit_a_response": "Perguntar até que submetam uma resposta",
"until_they_submit_a_response": "Até que enviem uma resposta",
"untitled_block": "Bloco sem título",
"upgrade_notice_description": "Crie inquéritos multilingues e desbloqueie muitas mais funcionalidades",
"upgrade_notice_title": "Desbloqueie inquéritos multilingues com um plano superior",
"upload": "Carregar",
@@ -1642,6 +1631,7 @@
"upper_label": "Etiqueta Superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportado",
"use_with_caution": "Usar com cautela",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está a ser utilizada na quota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
@@ -1651,13 +1641,11 @@
"variable_used_in_recall_welcome": "Variável \"{variable}\" está a ser recordada no cartão de boas-vindas.",
"verify_email_before_submission": "Verificar email antes da submissão",
"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.",
"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": "Tempo de espera em todo o projeto",
"waiting_time_across_surveys_description": "Para evitar a fadiga de inquéritos, escolha como este inquérito interage com o tempo de espera em todo o projeto.",
"waiting_period": "período de espera",
"welcome_message": "Mensagem de boas-vindas",
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "Quando as condições corresponderem, o tempo de espera será ignorado e o inquérito será mostrado.",
"without_a_filter_all_of_your_users_can_be_surveyed": "Sem um filtro, todos os seus utilizadores podem ser pesquisados.",
"you_have_not_created_a_segment_yet": "Ainda não criou um segmento",
"you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations": "Precisa de ter duas ou mais línguas configuradas no seu projeto para trabalhar com traduções.",
@@ -1702,7 +1690,7 @@
"last_name": "Apelido",
"not_completed": "Não Concluído ⏳",
"os": "SO",
"person_attributes": "Atributos da pessoa no momento da submissão",
"person_attributes": "Atributos da pessoa",
"phone": "Telefone",
"respondent_skipped_questions": "O respondente saltou estas perguntas.",
"response_deleted_successfully": "Resposta eliminada com sucesso.",
@@ -1815,7 +1803,6 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} é {filterComboBoxValue} - {filterValue} ",
"added_filter_for_responses_where_answer_to_question_is_skipped": "Adicionado filtro para respostas onde a resposta à pergunta {questionIdx} é ignorada",
"aggregated": "Agregado",
"all_responses_csv": "Todas as respostas (CSV)",
"all_responses_excel": "Todas as respostas (Excel)",
"all_time": "Todo o tempo",
@@ -1839,6 +1826,7 @@
"filtered_responses_csv": "Respostas filtradas (CSV)",
"filtered_responses_excel": "Respostas filtradas (Excel)",
"generating_qr_code": "A gerar código QR",
"go_to_setup_checklist": "Ir para a Lista de Verificação de Configuração 👉",
"impressions": "Impressões",
"impressions_tooltip": "Número de vezes que o inquérito foi visualizado.",
"in_app": {
@@ -1872,7 +1860,7 @@
},
"includes_all": "Inclui tudo",
"includes_either": "Inclui qualquer um",
"individual": "Individual",
"install_widget": "Instalar Widget Formbricks",
"is_equal_to": "É igual a",
"is_less_than": "É menos que",
"last_30_days": "Últimos 30 dias",
@@ -1885,7 +1873,6 @@
"no_responses_found": "Nenhuma resposta encontrada",
"other_values_found": "Outros valores encontrados",
"overall": "Geral",
"promoters": "Promotores",
"qr_code": "Código QR",
"qr_code_description": "Respostas recolhidas através de código QR são anónimas.",
"qr_code_download_failed": "Falha ao transferir o código QR",
@@ -1895,7 +1882,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",
@@ -1911,6 +1897,7 @@
"ttc_tooltip": "Tempo médio para concluir a pergunta.",
"unknown_question_type": "Tipo de Pergunta Desconhecido",
"use_personal_links": "Utilize links pessoais",
"waiting_for_response": "A aguardar uma resposta 🧘‍♂️",
"whats_next": "O que se segue?",
"your_survey_is_public": "O seu inquérito é público",
"youre_not_plugged_in_yet": "Ainda não está ligado!"
@@ -2265,6 +2252,7 @@
"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...",
"cta_description": "Exibir informações e solicitar aos utilizadores que tomem uma ação específica",
"custom_survey_block_1_name": "Bloco 1",
"custom_survey_description": "Crie um inquérito sem modelo.",
"custom_survey_name": "Começar do zero",
"custom_survey_question_1_headline": "O que gostaria de saber?",
@@ -2674,7 +2662,6 @@
"preview_survey_question_2_choice_2_label": "Não, obrigado!",
"preview_survey_question_2_headline": "Quer manter-se atualizado?",
"preview_survey_welcome_card_headline": "Bem-vindo!",
"preview_survey_welcome_card_html": "Obrigado por fornecer o seu feedback - vamos a isso!",
"prioritize_features_description": "Identifique as funcionalidades que os seus utilizadores precisam mais e menos.",
"prioritize_features_name": "Priorizar Funcionalidades",
"prioritize_features_question_1_choice_1": "Funcionalidade 1",
+33 -46
View File
@@ -153,7 +153,6 @@
"clear_filters": "Curăță filtrele",
"clear_selection": "Șterge selecția",
"click": "Click",
"click_to_filter": "Click pentru a filtra",
"clicks": "Clickuri",
"close": "Închide",
"code": "Cod",
@@ -211,7 +210,6 @@
"error_rate_limit_description": "Numărul maxim de cereri atins. Vă rugăm să încercați din nou mai târziu.",
"error_rate_limit_title": "Limită de cereri depășită",
"expand_rows": "Extinde rândurile",
"failed_to_copy_to_clipboard": "Nu s-a reușit copierea în clipboard",
"failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor",
"failed_to_load_projects": "Nu s-a reușit încărcarea proiectelor",
"finish": "Finalizează",
@@ -220,7 +218,6 @@
"full_name": "Nume complet",
"gathering_responses": "Culegere răspunsuri",
"general": "General",
"generate": "Generează",
"go_back": "Înapoi",
"go_to_dashboard": "Mergi la Tablou de Bord",
"hidden": "Ascuns",
@@ -428,7 +425,6 @@
"user_id": "ID Utilizator",
"user_not_found": "Utilizatorul nu a fost găsit",
"variable": "Variabilă",
"variable_ids": "ID-uri variabile",
"variables": "Variante",
"verified_email": "Email verificat",
"video": "Video",
@@ -527,7 +523,6 @@
"add_css_class_or_id": "Adăugați clasă CSS sau id",
"add_regular_expression_here": "Adăugați o expresie regulată aici",
"add_url": "Adaugă URL",
"and": "ȘI",
"click": "Click",
"contains": "Conține",
"create_action": "Creează acțiune",
@@ -558,7 +553,6 @@
"limit_to_specific_pages": "Limitează la pagini specifice",
"matches_regex": "Se potrivește cu regex",
"on_all_pages": "Pe toate paginile",
"or": "SAU",
"page_filter": "Filtru pagină",
"page_view": "Vizualizare Pagina",
"select_match_type": "Selectați tipul de potrivire",
@@ -599,18 +593,9 @@
"contacts_table_refresh_success": "Contactele au fost actualizate cu succes",
"delete_contact_confirmation": "Acest lucru va șterge toate răspunsurile la sondaj și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute.",
"delete_contact_confirmation_with_quotas": "{value, plural, one {Această acțiune va șterge toate răspunsurile chestionarului și atributele de contact asociate cu acest contact. Orice țintire și personalizare bazată pe datele acestui contact vor fi pierdute. Dacă acest contact are răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} other {Aceste acțiuni vor șterge toate răspunsurile chestionarului și atributele de contact asociate cu acești contacți. Orice țintire și personalizare bazată pe datele acestor contacți vor fi pierdute. Dacă acești contacți au răspunsuri care contează pentru cotele chestionarului, numărul cotelor va fi redus, dar limitele cotelor vor rămâne neschimbate.} }",
"generate_personal_link": "Generează link personal",
"generate_personal_link_description": "Selectați un sondaj publicat pentru a genera un link personalizat pentru acest contact.",
"no_published_link_surveys_available": "Nu există sondaje publicate pentru linkuri disponibile. Vă rugăm să publicați mai întâi un sondaj pentru linkuri.",
"no_published_surveys": "Nu există sondaje publicate",
"no_responses_found": "Nu s-au găsit răspunsuri",
"not_provided": "Nu a fost furnizat",
"personal_link_generated": "Linkul personal a fost generat cu succes",
"personal_link_generated_but_clipboard_failed": "Linkul personal a fost generat, dar nu s-a reușit copierea în clipboard: {url}",
"personal_survey_link": "Link către sondajul personal",
"please_select_a_survey": "Vă rugăm să selectați un sondaj",
"search_contact": "Căutați contact",
"select_a_survey": "Selectați un sondaj",
"select_attribute": "Selectează atributul",
"unlock_contacts_description": "Gestionează contactele și trimite sondaje țintite",
"unlock_contacts_title": "Deblocați contactele cu un plan superior.",
@@ -789,14 +774,14 @@
},
"app-connection": {
"app_connection": "Conectare aplicație",
"app_connection_description": "Conectează-ți aplicația sau site-ul la Formbricks.",
"cache_update_delay_description": "Când efectuați actualizări la sondaje, contacte, acțiuni sau alte date, poate dura până la 1 minut pentru ca aceste modificări să apară în aplicația locală care rulează SDK-ul Formbricks.",
"cache_update_delay_title": "Modificările vor fi vizibile după ~1 minut din cauza memoriei cache",
"app_connection_description": "Conectează aplicația ta la Formbricks.",
"cache_update_delay_description": "Când faci actualizări la sondaje, contacte, acțiuni sau alte date, poate dura până la 5 minute pentru ca aceste modificări să apară în aplicația locală care rulează SDK Formbricks. Această întârziere se datorează unei limitări în sistemul nostru actual de caching. Revedem activ cache-ul și vom lansa o soluție în Formbricks 4.0.",
"cache_update_delay_title": "Modificările vor fi reflectate după 5 minute datorită memorării în cache",
"environment_id": "ID-ul mediului tău",
"environment_id_description": "Acest id identifică în mod unic acest mediu Formbricks.",
"formbricks_sdk_connected": "SDK Formbricks este conectat",
"formbricks_sdk_not_connected": "Formbricks SDK nu este încă conectat.",
"formbricks_sdk_not_connected_description": "Adaugă SDK-ul Formbricks pe site-ul sau în aplicația ta pentru a-l conecta la Formbricks.",
"formbricks_sdk_not_connected_description": "Conectează-ți site-ul sau aplicația cu Formbricks",
"how_to_setup": "Cum să configurezi",
"how_to_setup_description": "Urmează acești pași pentru a configura widget-ul Formbricks în aplicația ta.",
"receiving_data": "Recepționare date 💃🕺",
@@ -815,7 +800,7 @@
"project_deleted_successfully": "Proiect șters cu succes!",
"project_name_settings_description": "Schimbați numele proiectului.",
"project_name_updated_successfully": "Numele proiectului actualizat cu succes",
"recontact_waiting_time": "Timp de așteptare la nivel de proiect între sondaje",
"recontact_waiting_time": "Timp de așteptare până la recontactare",
"recontact_waiting_time_settings_description": "Controlează cât de des pot fi utilizatorii chestionați în toate sondajele din aplicație.",
"this_action_cannot_be_undone": "Această acțiune nu poate fi anulată.",
"wait_x_days_before_showing_next_survey": "Așteaptă X zile înainte de a afișa următorul sondaj:",
@@ -884,6 +869,7 @@
"add_tag": "Adaugă Etichetă",
"count": "Număr",
"delete_tag_confirmation": "Sigur doriți să ștergeți această etichetă?",
"empty_message": "Marcați o trimitere pentru a găsi lista de etichete aici.",
"manage_tags": "Gestionați etichetele",
"manage_tags_description": "Îmbinați și eliminați etichetele de răspuns.",
"merge": "Îmbinare",
@@ -1202,9 +1188,9 @@
"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",
"add_a_new_question_to_your_survey": "Adaugă o nouă întrebare la sondajul tău",
"add_a_variable_to_calculate": "Adaugă o variabilă pentru calcul",
"add_action_below": "Adăugați acțiune mai jos",
"add_block": "Adaugă bloc",
"add_choice_below": "Adaugă opțiunea de mai jos",
"add_color_coding": "Adăugați codificare color",
"add_color_coding_description": "Adăugați coduri de culoare roșu, portocaliu și verde la opțiuni.",
@@ -1225,7 +1211,6 @@
"add_other": "Adăugați \"Altele\"",
"add_photo_or_video": "Adaugă fotografie sau video",
"add_pin": "Adaugă PIN",
"add_question": "Adaugă întrebare",
"add_question_below": "Adaugă întrebare mai jos",
"add_row": "Adăugați rând",
"add_variable": "Adaugă variabilă",
@@ -1240,6 +1225,7 @@
"allow_multi_select": "Permite selectare multiplă",
"allow_multiple_files": "Permite fișiere multiple",
"allow_users_to_select_more_than_one_image": "Permite utilizatorilor să selecteze mai mult de o imagine",
"always_show_survey": "Arată întotdeauna sondajul",
"and_launch_surveys_in_your_website_or_app": "și lansați chestionare pe site-ul sau în aplicația dvs.",
"animation": "Animație",
"app_survey_description": "Incorporați un chestionar în aplicația web sau pe site-ul dvs. pentru a colecta răspunsuri.",
@@ -1252,6 +1238,8 @@
"automatically_mark_the_survey_as_complete_after": "Marcați automat sondajul ca finalizat după",
"back_button_label": "Etichetă buton \"Înapoi\"",
"background_styling": "Stilizare fundal",
"block_deleted": "Bloc șters.",
"block_duplicated": "Bloc duplicat.",
"bold": "Îngroșat",
"brand_color": "Culoarea brandului",
"brightness": "Luminozitate",
@@ -1296,6 +1284,7 @@
"character_limit_toggle_title": "Adăugați limite de caractere",
"checkbox_label": "Etichetă casetă de selectare",
"choose_the_actions_which_trigger_the_survey": "Alegeți acțiunile care declanșează sondajul.",
"choose_the_first_question_on_your_block": "Alege prima întrebare din blocul tău",
"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",
@@ -1322,7 +1311,9 @@
"custom_hostname": "Gazdă personalizată",
"darken_or_lighten_background_of_your_choice": "Întunecați sau luminați fundalul după preferințe.",
"date_format": "Format dată",
"days_before_showing_this_survey_again": "zile după afișarea oricărui sondaj înainte ca acest sondaj să poată apărea din nou.",
"days_before_showing_this_survey_again": "zile înainte de a afișa din nou acest sondaj.",
"decide_how_often_people_can_answer_this_survey": "Decide cât de des pot răspunde oamenii la acest sondaj",
"delete_block": "Șterge blocul",
"delete_choice": "Șterge alegerea",
"disable_the_visibility_of_survey_progress": "Dezactivați vizibilitatea progresului sondajului",
"display_an_estimate_of_completion_time_for_survey": "Afișează o estimare a timpului de finalizare pentru sondaj",
@@ -1334,6 +1325,7 @@
"does_not_include_all_of": "Nu include toate",
"does_not_include_one_of": "Nu include una dintre",
"does_not_start_with": "Nu începe cu",
"duplicate_block": "Duplicați blocul",
"edit_link": "Editare legătură",
"edit_recall": "Editează Referințele",
"edit_translations": "Editează traducerile {lang}",
@@ -1350,7 +1342,7 @@
"equals_one_of": "Egal unu dintre",
"error_publishing_survey": "A apărut o eroare în timpul publicării sondajului.",
"error_saving_changes": "Eroare la salvarea modificărilor",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permite răspunsuri multiple; continuă afișarea chiar și după un răspuns (de exemplu, Caseta de Feedback).",
"even_after_they_submitted_a_response_e_g_feedback_box": "Chiar și după ce au furnizat un răspuns (de ex. Cutia de Feedback)",
"everyone": "Toată lumea",
"external_urls_paywall_tooltip": "Vă rugăm să faceți upgrade pentru a personaliza URL-ul extern. Prevenire phishing.",
"fallback_missing": "Rezerva lipsă",
@@ -1421,9 +1413,8 @@
"hostname": "Nume gazdă",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Cât de funky doriți să fie cardurile dumneavoastră în sondajele de tip {surveyTypeDerived}",
"if_you_need_more_please": "Dacă aveți nevoie de mai multe, vă rugăm să",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuă afișarea ori de câte ori este declanșat până când se trimite un răspuns.",
"ignore_global_waiting_time": "Ignoră timpul de așteptare la nivel de proiect",
"ignore_global_waiting_time_description": "Acest sondaj poate fi afișat ori de câte ori condițiile sale sunt îndeplinite, chiar dacă un alt sondaj a fost afișat recent.",
"if_you_really_want_that_answer_ask_until_you_get_it": "Dacă într-adevăr îți dorești acel răspuns, întreabă până îl primești.",
"ignore_waiting_time_between_surveys": "Ignoră perioada de așteptare între sondaje",
"image": "Imagine",
"includes_all_of": "Include toate\",\"contextDescription\":\"Part of a survey completion screen referencing conditions met when all items are included\"}",
"includes_one_of": "Include una dintre",
@@ -1490,10 +1481,9 @@
"optional": "Opțional",
"options": "Opțiuni",
"override_theme_with_individual_styles_for_this_survey": "Suprascrie tema cu stiluri individuale pentru acest sondaj.",
"overwrite_global_waiting_time": "Setează un timp de așteptare personalizat",
"overwrite_global_waiting_time_description": "Suprascrie configurația proiectului doar pentru acest sondaj.",
"overwrite_placement": "Suprascriere amplasare",
"overwrite_the_global_placement_of_the_survey": "Suprascrie amplasarea globală a sondajului",
"overwrites_waiting_period_between_surveys_to_x_days": "Suprascrie perioada de așteptare dintre sondaje la {days} zi(le).",
"pick_a_background_from_our_library_or_upload_your_own": "Alege un fundal din biblioteca noastră sau încarcă unul propriu.",
"picture_idx": "Poză {idx}",
"pin_can_only_contain_numbers": "PIN-ul poate conține doar numere.",
@@ -1552,8 +1542,7 @@
"range": "Interval",
"recall_data": "Reamintiți datele",
"recall_information_from": "Reamintiți informațiile din ...",
"recontact_options_section": "Opțiuni de recontactare",
"recontact_options_section_description": "Dacă timpul de așteptare permite, alege cât de des poate fi afișat acest sondaj unei persoane.",
"recontact_options": "Opțiuni de recontactare",
"redirect_thank_you_card": "Redirecționează cardul de mulțumire",
"redirect_to_url": "Redirecționează către URL",
"remove_description": "Eliminați descrierea",
@@ -1562,8 +1551,6 @@
"required": "Obligatoriu",
"reset_to_theme_styles": "Resetare la stilurile temei",
"reset_to_theme_styles_main_text": "Sigur doriți să resetați stilul la stilurile de temă? Acest lucru va elimina toate stilizările personalizate.",
"respect_global_waiting_time": "Folosește timpul de așteptare la nivel de proiect",
"respect_global_waiting_time_description": "Acest sondaj respectă timpul de așteptare setat în configurația proiectului. Este afișat doar dacă niciun alt sondaj nu a apărut în acea perioadă.",
"response_limit_can_t_be_set_to_0": "Limitul de răspunsuri nu poate fi setat la 0",
"response_limit_needs_to_exceed_number_of_received_responses": "Limita răspunsurilor trebuie să depășească numărul de răspunsuri primite ({responseCount}).",
"response_limits_redirections_and_more": "Limite de răspunsuri, redirecționări și altele.",
@@ -1618,12 +1605,13 @@
"switch_multi_lanugage_on_to_get_started": "Comutați pe modul multilingv pentru a începe 👉",
"targeted": "Ţintite",
"ten_points": "10 puncte",
"the_survey_will_be_shown_multiple_times_until_they_respond": "Afișează de cel mult numărul specificat de ori sau până când răspund (oricare dintre acestea survine prima).",
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Afișează o singură dată, chiar dacă persoana nu răspunde.",
"the_survey_will_be_shown_multiple_times_until_they_respond": "Sondajul va fi afișat de mai multe ori până când vor răspunde",
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Sondajul va fi afișat 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_extension_is_already_added": "Această extensie este deja adăugată.",
"this_file_type_is_not_supported": "Acest tip de fișier nu este acceptat.",
"this_setting_overwrites_your": "Această setare suprascrie",
"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",
@@ -1634,7 +1622,8 @@
"unlock_targeting_description": "Vizează grupuri specifice de utilizatori pe baza atributelor sau a informațiilor despre dispozitiv",
"unlock_targeting_title": "Deblocați țintirea cu un plan superior",
"unsaved_changes_warning": "Aveți modificări nesalvate în sondajul dumneavoastră. Doriți să le salvați înainte de a pleca?",
"until_they_submit_a_response": "Întreabă până când trimit un răspuns",
"until_they_submit_a_response": "Până când vor furniza un răspuns",
"untitled_block": "Bloc fără titlu",
"upgrade_notice_description": "Creați sondaje multilingve și deblocați multe alte caracteristici",
"upgrade_notice_title": "Deblocați sondajele multilingve cu un plan superior",
"upload": "Încărcați",
@@ -1642,6 +1631,7 @@
"upper_label": "Etichetă superioară",
"url_filters": "Filtre URL",
"url_not_supported": "URL nesuportat",
"use_with_caution": "Folosește cu precauție",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} este folosit în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabila \"{variableName}\" este folosită în cota \"{quotaName}\"",
"variable_name_is_already_taken_please_choose_another": "Numele variabilei este deja utilizat, vă rugăm să alegeți altul.",
@@ -1651,13 +1641,11 @@
"variable_used_in_recall_welcome": "Variabila \"{variable}\" este reamintită în cardul de bun venit.",
"verify_email_before_submission": "Verifică emailul înainte de trimitere",
"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.",
"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": "Timp de așteptare la nivel de proiect",
"waiting_time_across_surveys_description": "Pentru a preveni oboseala cauzată de sondaje, alege cum interacționează acest sondaj cu timpul de așteptare la nivel de proiect.",
"waiting_period": "perioada de așteptare",
"welcome_message": "Mesaj de bun venit",
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "Când condițiile se potrivesc, timpul de așteptare va fi ignorat și sondajul va fi afișat.",
"without_a_filter_all_of_your_users_can_be_surveyed": "Fără un filtru, toți utilizatorii pot fi chestionați.",
"you_have_not_created_a_segment_yet": "Nu ai creat încă un segment",
"you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations": "Trebuie să aveți două sau mai multe limbi configurate în proiectul dvs. pentru a lucra cu traducerile.",
@@ -1702,7 +1690,7 @@
"last_name": "Nume de familie",
"not_completed": "Necompletat ⏳",
"os": "SO",
"person_attributes": "Atributele persoanei la momentul trimiterii",
"person_attributes": "Atribute persoană",
"phone": "Telefon",
"respondent_skipped_questions": "Respondenții au sărit peste aceste întrebări.",
"response_deleted_successfully": "Răspuns șters cu succes.",
@@ -1815,7 +1803,6 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "Filtru adăugat pentru răspunsuri unde răspunsul la întrebarea {questionIdx} este {filterComboBoxValue} - {filterValue}",
"added_filter_for_responses_where_answer_to_question_is_skipped": "Filtru adăugat pentru răspunsuri unde răspunsul la întrebarea {questionIdx} este omis",
"aggregated": "Agregat",
"all_responses_csv": "Toate răspunsurile (CSV)",
"all_responses_excel": "Toate răspunsurile (Excel)",
"all_time": "Pe parcursul întregii perioade",
@@ -1839,6 +1826,7 @@
"filtered_responses_csv": "Răspunsuri filtrate (CSV)",
"filtered_responses_excel": "Răspunsuri filtrate (Excel)",
"generating_qr_code": "Se generează codul QR",
"go_to_setup_checklist": "Mergi la lista de verificare a configurării 👉",
"impressions": "Impresii",
"impressions_tooltip": "Număr de ori când sondajul a fost vizualizat.",
"in_app": {
@@ -1872,7 +1860,7 @@
},
"includes_all": "Include tot",
"includes_either": "Include fie",
"individual": "Individual",
"install_widget": "Instalați Widgetul Formbricks",
"is_equal_to": "Este egal cu",
"is_less_than": "Este mai puțin de",
"last_30_days": "Ultimele 30 de zile",
@@ -1885,7 +1873,6 @@
"no_responses_found": "Nu s-au găsit răspunsuri",
"other_values_found": "Alte valori găsite",
"overall": "General",
"promoters": "Promotori",
"qr_code": "Cod QR",
"qr_code_description": "Răspunsurile colectate prin cod QR sunt anonime.",
"qr_code_download_failed": "Descărcarea codului QR a eșuat",
@@ -1895,7 +1882,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",
@@ -1911,6 +1897,7 @@
"ttc_tooltip": "Timp mediu pentru a completa întrebarea.",
"unknown_question_type": "Tip de întrebare necunoscut",
"use_personal_links": "Folosește linkuri personale",
"waiting_for_response": "Așteptând un răspuns 🧘‍♂️",
"whats_next": "Ce urmează?",
"your_survey_is_public": "Sondajul tău este public",
"youre_not_plugged_in_yet": "Nu sunteţi încă conectat!"
@@ -2265,6 +2252,7 @@
"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...",
"cta_description": "Afișează informații și solicită utilizatorilor să ia o acțiune specifică",
"custom_survey_block_1_name": "Bloc 1",
"custom_survey_description": "Creează un sondaj fără șablon.",
"custom_survey_name": "Începe de la zero",
"custom_survey_question_1_headline": "Ce ați dori să știți?",
@@ -2674,7 +2662,6 @@
"preview_survey_question_2_choice_2_label": "Nu, mulţumesc!",
"preview_survey_question_2_headline": "Vrei să fii în temă?",
"preview_survey_welcome_card_headline": "Bun venit!",
"preview_survey_welcome_card_html": "Mulțumesc pentru feedback-ul dvs - să începem!",
"prioritize_features_description": "Identificați caracteristicile de care utilizatorii dumneavoastră au cel mai mult și cel mai puțin nevoie.",
"prioritize_features_name": "Prioritizați caracteristicile",
"prioritize_features_question_1_choice_1": "Caracteristica 1",
+34 -47
View File
@@ -153,7 +153,6 @@
"clear_filters": "清除 过滤器",
"clear_selection": "清除 选择",
"click": "点击",
"click_to_filter": "点击筛选",
"clicks": "点击",
"close": "关闭",
"code": "代码",
@@ -211,7 +210,6 @@
"error_rate_limit_description": "请求 达到 最大 上限 , 请 稍后 再试 。",
"error_rate_limit_title": "速率 限制 超过",
"expand_rows": "展开 行",
"failed_to_copy_to_clipboard": "复制到剪贴板失败",
"failed_to_load_organizations": "加载组织失败",
"failed_to_load_projects": "加载项目失败",
"finish": "完成",
@@ -220,7 +218,6 @@
"full_name": "全名",
"gathering_responses": "收集反馈",
"general": "通用",
"generate": "生成",
"go_back": "返回 ",
"go_to_dashboard": "转到 Dashboard",
"hidden": "隐藏",
@@ -428,7 +425,6 @@
"user_id": "用户 ID",
"user_not_found": "用户 不存在",
"variable": "变量",
"variable_ids": "变量 ID",
"variables": "变量",
"verified_email": "已验证 电子邮件",
"video": "视频",
@@ -527,7 +523,6 @@
"add_css_class_or_id": "添加 CSS class 或 id",
"add_regular_expression_here": "在 这里 添加 正则 表达式",
"add_url": "添加 URL",
"and": "与",
"click": "点击",
"contains": "包含",
"create_action": "创建 操作",
@@ -558,7 +553,6 @@
"limit_to_specific_pages": "限制 特定 页面",
"matches_regex": "匹配 正则表达式",
"on_all_pages": "在 所有 页面",
"or": "或",
"page_filter": "页面 过滤器",
"page_view": "页面 查看",
"select_match_type": "选择 匹配 类型",
@@ -599,18 +593,9 @@
"contacts_table_refresh_success": "联系人 已成功刷新",
"delete_contact_confirmation": "这将删除与此联系人相关的所有调查问卷回复和联系人属性。基于此联系人数据的任何定位和个性化将会丢失。",
"delete_contact_confirmation_with_quotas": "{value, plural, one {这将删除与此联系人相关的所有调查回复和联系人属性。基于此联系人数据的任何定位和个性化将丢失。如果此联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。} other {这将删除与这些联系人相关的所有调查回复和联系人属性。基于这些联系人数据的任何定位和个性化将丢失。如果这些联系人有影响调查配额的回复,配额数量将减少,但配额限制将保持不变。}}",
"generate_personal_link": "生成个人链接",
"generate_personal_link_description": "选择一个已发布的调查,为此联系人生成个性化链接。",
"no_published_link_surveys_available": "没有可用的已发布链接调查。请先发布一个链接调查。",
"no_published_surveys": "没有已发布的调查",
"no_responses_found": "未找到 响应",
"not_provided": "未提供",
"personal_link_generated": "个人链接生成成功",
"personal_link_generated_but_clipboard_failed": "个性化链接已生成,但复制到剪贴板失败:{url}",
"personal_survey_link": "个人调查链接",
"please_select_a_survey": "请选择一个调查",
"search_contact": "搜索 联系人",
"select_a_survey": "选择一个调查",
"select_attribute": "选择 属性",
"unlock_contacts_description": "管理 联系人 并 发送 定向 调查",
"unlock_contacts_title": "通过 更 高级 划解锁 联系人",
@@ -789,14 +774,14 @@
},
"app-connection": {
"app_connection": "应用程序 连接",
"app_connection_description": "将您的应用或网站连接到 Formbricks。",
"cache_update_delay_description": "当您更新问卷、联系人、操作或其他数据时,这些更改可能需要最多 1 分钟才能在运行 Formbricks SDK 的本地应用中显示。",
"cache_update_delay_title": "由于缓存,变更将在约 1 分钟后生效",
"app_connection_description": "连接 您 的 应用 与 Formbricks。",
"cache_update_delay_description": "当 你 对 调查 、 联系人 、 操作 或 其他 数据 进行 更新 时 可能 需要 最多 5 分钟 更改 才能 显示 在 你 本地 运行 Formbricks SDK 的 应用程序 中 。 这个 延迟 是 由于 我们 当前 缓存 系统 的 限制 。 我们 正在 积极 重新设计 缓存 并 将 在 Formbricks 4.0 中 发布 修复 。",
"cache_update_delay_title": "更改 将 在 5 分钟 后 由于 缓存 而 显示",
"environment_id": "你的 环境 ID",
"environment_id_description": "这个 id 独特地 标识 这个 Formbricks 环境。",
"formbricks_sdk_connected": "Formbricks SDK 已连接",
"formbricks_sdk_not_connected": "Formbricks SDK 尚未连接。",
"formbricks_sdk_not_connected_description": "将 Formbricks SDK 添加到您的网站或应用,以将其连接到 Formbricks",
"formbricks_sdk_not_connected_description": "连接 您 的 网站 或 应用 与 Formbricks",
"how_to_setup": "如何设置",
"how_to_setup_description": "遵循这些步骤在你的应用中设置 Formbricks 小部件。",
"receiving_data": "接收 数据 💃🕺",
@@ -815,7 +800,7 @@
"project_deleted_successfully": "项目 删除 成功",
"project_name_settings_description": "更改 您 的 项目 名称。",
"project_name_updated_successfully": "项目 名称 更新 成功",
"recontact_waiting_time": "项目范围内的调查等待时间",
"recontact_waiting_time": "再联系 等待 时间",
"recontact_waiting_time_settings_description": "控制用户可以通过所有应用程序 调查 的 频率。",
"this_action_cannot_be_undone": "此 操作 无法 撤消。",
"wait_x_days_before_showing_next_survey": "等待 X 天后再显示下一个 调查:",
@@ -884,6 +869,7 @@
"add_tag": "添加 标签",
"count": "数量",
"delete_tag_confirmation": "您 确定 要 删除 此 标签 吗?",
"empty_message": "标记一个提交以在此处找到您的标签列表。",
"manage_tags": "管理标签",
"manage_tags_description": "合并 和 删除 response 标签。",
"merge": "合并",
@@ -1202,9 +1188,9 @@
"add": "添加 +",
"add_a_delay_or_auto_close_the_survey": "添加 延迟 或 自动 关闭 调查",
"add_a_four_digit_pin": "添加 一个 四 位 数 PIN",
"add_a_new_question_to_your_survey": "添加一个新问题到您的调查中",
"add_a_variable_to_calculate": "添加 变量 以 计算",
"add_action_below": "在下面添加操作",
"add_block": "添加区块",
"add_choice_below": "在下方添加选项",
"add_color_coding": "添加 颜色 编码",
"add_color_coding_description": "添加 红色 、橙色 和 绿色 颜色 编码 到 选项。",
@@ -1225,7 +1211,6 @@
"add_other": "添加 \"其他\"",
"add_photo_or_video": "添加 照片 或 视频",
"add_pin": "添加 PIN",
"add_question": "添加问题",
"add_question_below": "在下面 添加 问题",
"add_row": "添加 行",
"add_variable": "添加 变量",
@@ -1240,6 +1225,7 @@
"allow_multi_select": "允许 多选",
"allow_multiple_files": "允许 多 个 文件",
"allow_users_to_select_more_than_one_image": "允许 用户 选择 多于 一个 图片",
"always_show_survey": "始终 显示 调查",
"and_launch_surveys_in_your_website_or_app": "并 在 你 的 网站 或 应用 中 启动 问卷 。",
"animation": "动画",
"app_survey_description": "在 你的 网络 应用 或 网站 中 嵌入 问卷 收集 反馈 。",
@@ -1252,6 +1238,8 @@
"automatically_mark_the_survey_as_complete_after": "自动 标记 调查 为 完成 在",
"back_button_label": "\"返回\" 按钮标签",
"background_styling": "背景 样式",
"block_deleted": "区块已删除。",
"block_duplicated": "区块已复制。",
"bold": "粗体",
"brand_color": "品牌 颜色",
"brightness": "亮度",
@@ -1296,6 +1284,7 @@
"character_limit_toggle_title": "添加 字符限制",
"checkbox_label": "复选框 标签",
"choose_the_actions_which_trigger_the_survey": "选择 触发 调查 的 动作 。",
"choose_the_first_question_on_your_block": "选择区块中的第一个问题",
"choose_where_to_run_the_survey": "选择 调查 运行 的 位置 。",
"city": "城市",
"close_survey_on_response_limit": "在响应限制时关闭 调查",
@@ -1322,7 +1311,9 @@
"custom_hostname": "自 定 义 主 机 名",
"darken_or_lighten_background_of_your_choice": "根据 您 的 选择 暗化 或 亮化 背景。",
"date_format": "日期格式",
"days_before_showing_this_survey_again": "显示调查之前,需等待的天数。",
"days_before_showing_this_survey_again": "显示调查 之前天数。",
"decide_how_often_people_can_answer_this_survey": "决定 人 可以 回答 这份 调查 的 频率 。",
"delete_block": "删除区块",
"delete_choice": "删除 选择",
"disable_the_visibility_of_survey_progress": "禁用问卷 进度 的可见性。",
"display_an_estimate_of_completion_time_for_survey": "显示 调查 预计 完成 时间",
@@ -1334,6 +1325,7 @@
"does_not_include_all_of": "不包括所有 ",
"does_not_include_one_of": "不包括一 个",
"does_not_start_with": "不 以 开头",
"duplicate_block": "复制区块",
"edit_link": "编辑 链接",
"edit_recall": "编辑 调用",
"edit_translations": "编辑 {lang} 翻译",
@@ -1350,7 +1342,7 @@
"equals_one_of": "等于 其中 一个",
"error_publishing_survey": "发布调查时发生了错误",
"error_saving_changes": "保存 更改 时 出错",
"even_after_they_submitted_a_response_e_g_feedback_box": "允许多次回应;即使已提交回应,仍会继续显示(例如反馈框)",
"even_after_they_submitted_a_response_e_g_feedback_box": "即使 他们 提交 了 回复(例如 反馈框)",
"everyone": "所有 人",
"external_urls_paywall_tooltip": "请升级 以自定义 外部 URL 。 网络钓鱼 预防 。",
"fallback_missing": "备用 缺失",
@@ -1421,9 +1413,8 @@
"hostname": "主 机 名",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "在 {surveyTypeDerived} 调查 中,您 想要 卡片 多么 有趣",
"if_you_need_more_please": "如果你需要更多,请",
"if_you_really_want_that_answer_ask_until_you_get_it": "每次触发时都会显示,直到提交回应为止。",
"ignore_global_waiting_time": "忽略项目范围内的等待时间",
"ignore_global_waiting_time_description": "只要满足条件,此调查即可显示,即使最近刚显示过其他调查。",
"if_you_really_want_that_answer_ask_until_you_get_it": "如果 你 真想 要 那个 答案,就 不断 询问 直到 得到。",
"ignore_waiting_time_between_surveys": "忽略 调查 之间 的 等待 时间",
"image": "图片",
"includes_all_of": "包括所有 ",
"includes_one_of": "包括一 个",
@@ -1490,10 +1481,9 @@
"optional": "可选",
"options": "选项",
"override_theme_with_individual_styles_for_this_survey": "使用 个性化 样式 替代 这份 问卷 的 主题。",
"overwrite_global_waiting_time": "设置自定义等待时间",
"overwrite_global_waiting_time_description": "仅为此调查覆盖项目配置。",
"overwrite_placement": "覆盖 放置",
"overwrite_the_global_placement_of_the_survey": "覆盖 全局 调查 放置",
"overwrites_waiting_period_between_surveys_to_x_days": "将 调查 之间 的 等待期 覆盖 为 {days} 天。",
"pick_a_background_from_our_library_or_upload_your_own": "从我们的库中选择一种 背景 或 上传您自己的。",
"picture_idx": "图片 {idx}",
"pin_can_only_contain_numbers": "PIN 只能包含数字。",
@@ -1552,8 +1542,7 @@
"range": "范围",
"recall_data": "调用 数据",
"recall_information_from": "从 ... 召回信息",
"recontact_options_section": "重新联系选项",
"recontact_options_section_description": "如果等待时间允许,请选择此调查可以向某人显示的频率。",
"recontact_options": "重新 联系 选项",
"redirect_thank_you_card": "重定向感谢卡",
"redirect_to_url": "重定向到 URL",
"remove_description": "移除 描述",
@@ -1562,8 +1551,6 @@
"required": "必需的",
"reset_to_theme_styles": "重置 为 主题 风格",
"reset_to_theme_styles_main_text": "您 确定 要 将 样式 重置 为 主题 样式吗?这 将 删除 所有 自定义 样式。",
"respect_global_waiting_time": "使用项目范围内的等待时间",
"respect_global_waiting_time_description": "此调查遵循项目配置中设置的等待时间。仅在该期间未显示其他调查时才会显示。",
"response_limit_can_t_be_set_to_0": "不 能 将 响应 限制 设置 为 0",
"response_limit_needs_to_exceed_number_of_received_responses": "限制 响应 需要 超过 收到 的 响应 数量 {responseCount})。",
"response_limits_redirections_and_more": "响应 限制 、 重定向 和 更多 。",
@@ -1588,7 +1575,7 @@
"show_advanced_settings": "显示 高级设置",
"show_button": "显示 按钮",
"show_language_switch": "显示 语言 切换",
"show_multiple_times": "显示有限次数",
"show_multiple_times": "显示 多次",
"show_only_once": "仅 显示 一次",
"show_survey_maximum_of": "显示 调查 最大 一次",
"show_survey_to_users": "显示 问卷 给 % 的 用户",
@@ -1618,12 +1605,13 @@
"switch_multi_lanugage_on_to_get_started": "打开多语言以开始 👉",
"targeted": "定位",
"ten_points": "10 分",
"the_survey_will_be_shown_multiple_times_until_they_respond": "最多显示指定次数,或直到他们回应(以先到者为准)。",
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "显示一次,即使他们未回应。",
"the_survey_will_be_shown_multiple_times_until_they_respond": "调查 将 显示 多次 直到 他们 回复",
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "调查 将 显示 一次,即使 你 不 回复。",
"then": "然后",
"this_action_will_remove_all_the_translations_from_this_survey": "此操作将删除该调查中的所有翻译。",
"this_extension_is_already_added": "此扩展已经添加。",
"this_file_type_is_not_supported": "此 文件 类型 不 支持。",
"this_setting_overwrites_your": "此 设置 覆盖 你的",
"three_points": "3 分",
"times": "次数",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "为了 保持 所有 调查 的 放置 一致,您 可以",
@@ -1634,7 +1622,8 @@
"unlock_targeting_description": "根据 属性 或 设备信息 定位 特定 用户组",
"unlock_targeting_title": "通过 更 高级 划解锁 定位",
"unsaved_changes_warning": "您在调查中有未保存的更改。离开前是否要保存?",
"until_they_submit_a_response": "持续显示直到提交回应",
"until_they_submit_a_response": "直到 他们 提交 回复",
"untitled_block": "未命名区块",
"upgrade_notice_description": "创建 多语言 调查 并 解锁 更多 功能",
"upgrade_notice_title": "解锁 更高 计划 中 的 多语言 调查",
"upload": "上传",
@@ -1642,6 +1631,7 @@
"upper_label": "上限标签",
"url_filters": "URL 过滤器",
"url_not_supported": "URL 不支持",
"use_with_caution": "谨慎 使用",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{variable} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "变量 \"{variableName}\" 正在 被 \"{quotaName}\" 配额 使用",
"variable_name_is_already_taken_please_choose_another": "变量名已被占用,请选择其他。",
@@ -1651,13 +1641,11 @@
"variable_used_in_recall_welcome": "变量 \"{variable}\" 正在召回于欢迎 卡 。",
"verify_email_before_submission": "提交 之前 验证电子邮件",
"verify_email_before_submission_description": "仅允许 拥有 有效 电子邮件 的 人 回应。",
"visibility_and_recontact": "可见性与重新联系",
"visibility_and_recontact_description": "控制此调查何时可以显示以及可以重新显示的频率。",
"wait": "等待",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "触发后等待几秒再显示问卷",
"waiting_time_across_surveys": "项目范围内的等待时间",
"waiting_time_across_surveys_description": "为防止调查疲劳,请选择此调查如何与项目范围内的等待时间交互。",
"waiting_period": "等待期",
"welcome_message": "欢迎 信息",
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "当 条件 匹配 时,等待 时间 将 被 忽略 并 显示 调查。",
"without_a_filter_all_of_your_users_can_be_surveyed": "没有 过滤器 时 ,所有 用户 都可以 被 调查 。",
"you_have_not_created_a_segment_yet": "您 还没有 创建 段落",
"you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations": "您 需要在您的项目中设置两种或更多语言才能进行翻译。",
@@ -1702,7 +1690,7 @@
"last_name": "姓",
"not_completed": "未完成 ⏳",
"os": "操作系统",
"person_attributes": "提交时的个人属性",
"person_attributes": "人属性",
"phone": "电话",
"respondent_skipped_questions": "受访者跳过 这些问题。",
"response_deleted_successfully": "响应 删除 成功",
@@ -1815,7 +1803,6 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "为 回答 问题 {questionIdx} 的 答复 增加 了 筛选器,筛选条件 是 {filterComboBoxValue} - {filterValue}",
"added_filter_for_responses_where_answer_to_question_is_skipped": "为 回答 问题 {questionIdx} 的 答复 增加 了 筛选器,筛选条件 是 略过",
"aggregated": "汇总",
"all_responses_csv": "所有 反馈 CSV",
"all_responses_excel": "所有 反馈 Excel",
"all_time": "所有 时间",
@@ -1839,6 +1826,7 @@
"filtered_responses_csv": "过滤 反馈 CSV",
"filtered_responses_excel": "过滤 反馈 Excel",
"generating_qr_code": "正在生成二维码",
"go_to_setup_checklist": "前往 设置 检查列表 👉",
"impressions": "印象",
"impressions_tooltip": "调查 被 查看 的 次数",
"in_app": {
@@ -1872,7 +1860,7 @@
},
"includes_all": "包括所有 ",
"includes_either": "包含 任意一个",
"individual": "个人",
"install_widget": "安装 Formbricks 小组件",
"is_equal_to": "等于",
"is_less_than": "少于",
"last_30_days": "最近 30 天",
@@ -1885,7 +1873,6 @@
"no_responses_found": "未找到响应",
"other_values_found": "找到其他值",
"overall": "整体",
"promoters": "推荐者",
"qr_code": "二维码",
"qr_code_description": "通过 QR 码 收集 的 响应 是 匿名 的。",
"qr_code_download_failed": "二维码下载失败",
@@ -1895,7 +1882,6 @@
"quotas_completed_tooltip": "受访者完成的配额数量。",
"reset_survey": "重置 调查",
"reset_survey_warning": "重置 一个调查 会移除与 此调查 相关 的 所有响应 和 展示 。此操作 不能 撤销 。",
"satisfied": "满意",
"selected_responses_csv": "选定 反馈 CSV",
"selected_responses_excel": "选定 反馈 Excel",
"setup_integrations": "设置 集成",
@@ -1911,6 +1897,7 @@
"ttc_tooltip": "完成 本 问题 的 平均 时间",
"unknown_question_type": "未知 问题 类型",
"use_personal_links": "使用 个人 链接",
"waiting_for_response": "等待回复 🧘‍♂️",
"whats_next": "接下来 是 什么?",
"your_survey_is_public": "您的 调查 是 公共 的",
"youre_not_plugged_in_yet": "您 还 没 有 连 接!"
@@ -2265,6 +2252,7 @@
"csat_survey_question_3_headline": "糟糕, 对不起!我们可以做些什么来改善您的体验?",
"csat_survey_question_3_placeholder": "在此输入您的答案...",
"cta_description": "显示 信息 并 提示用户采取 特定行动",
"custom_survey_block_1_name": "模块 1",
"custom_survey_description": "创建 一个 没有 模板 的 调查。",
"custom_survey_name": "从零开始",
"custom_survey_question_1_headline": "你 想 知道 什么?",
@@ -2674,7 +2662,6 @@
"preview_survey_question_2_choice_2_label": "不,谢谢!",
"preview_survey_question_2_headline": "想 了解 最新信息吗?",
"preview_survey_welcome_card_headline": "欢迎!",
"preview_survey_welcome_card_html": "感谢 提供 您 的 反馈 - 一起 出发!",
"prioritize_features_description": "确定 用户 最 需要 和 最 不 需要 的 功能。",
"prioritize_features_name": "优先 功能",
"prioritize_features_question_1_choice_1": "功能 1",
+34 -47
View File
@@ -153,7 +153,6 @@
"clear_filters": "清除篩選器",
"clear_selection": "清除選取",
"click": "點擊",
"click_to_filter": "點擊篩選",
"clicks": "點擊數",
"close": "關閉",
"code": "程式碼",
@@ -211,7 +210,6 @@
"error_rate_limit_description": "已達 到最大 請求 次數。請 稍後 再試。",
"error_rate_limit_title": "限流超過",
"expand_rows": "展開列",
"failed_to_copy_to_clipboard": "無法複製到剪貼簿",
"failed_to_load_organizations": "無法載入組織",
"failed_to_load_projects": "無法載入專案",
"finish": "完成",
@@ -220,7 +218,6 @@
"full_name": "全名",
"gathering_responses": "收集回應中",
"general": "一般",
"generate": "產生",
"go_back": "返回",
"go_to_dashboard": "前往儀表板",
"hidden": "隱藏",
@@ -428,7 +425,6 @@
"user_id": "使用者 ID",
"user_not_found": "找不到使用者",
"variable": "變數",
"variable_ids": "變數 ID",
"variables": "變數",
"verified_email": "已驗證的電子郵件",
"video": "影片",
@@ -527,7 +523,6 @@
"add_css_class_or_id": "新增 CSS 類別或 ID",
"add_regular_expression_here": "新增正則表達式在此",
"add_url": "新增網址",
"and": "且",
"click": "點擊",
"contains": "包含",
"create_action": "建立操作",
@@ -558,7 +553,6 @@
"limit_to_specific_pages": "限制為特定頁面",
"matches_regex": "符合 正則 表達式",
"on_all_pages": "在所有頁面上",
"or": "或",
"page_filter": "頁面篩選器",
"page_view": "頁面檢視",
"select_match_type": "選取比對類型",
@@ -599,18 +593,9 @@
"contacts_table_refresh_success": "聯絡人已成功重新整理",
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
"delete_contact_confirmation_with_quotas": "{value, plural, one {這將刪除與這個 contact 相關的所有調查響應和聯繫人屬性。基於這個 contact 數據的任何定向和個性化功能將會丟失。如果這個 contact 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。} other {這將刪除與這些 contacts 相關的所有調查響應和聯繫人屬性。基於這些 contacts 數據的任何定向和個性化功能將會丟失。如果這些 contacts 有作為調查配額依據的響應,配額計數將會減少,但配額限制將保持不變。}}",
"generate_personal_link": "產生個人連結",
"generate_personal_link_description": "選擇一個已發佈的問卷,為此聯絡人產生個人化連結。",
"no_published_link_surveys_available": "沒有可用的已發佈連結問卷。請先發佈一個連結問卷。",
"no_published_surveys": "沒有已發佈的問卷",
"no_responses_found": "找不到回應",
"not_provided": "未提供",
"personal_link_generated": "個人連結已成功產生",
"personal_link_generated_but_clipboard_failed": "已生成個人連結,但無法複製到剪貼簿:{url}",
"personal_survey_link": "個人調查連結",
"please_select_a_survey": "請選擇一個問卷",
"search_contact": "搜尋聯絡人",
"select_a_survey": "選擇問卷",
"select_attribute": "選取屬性",
"unlock_contacts_description": "管理聯絡人並發送目標問卷",
"unlock_contacts_title": "使用更高等級的方案解鎖聯絡人",
@@ -789,14 +774,14 @@
},
"app-connection": {
"app_connection": "應用程式連線",
"app_connection_description": "將您的應用程式或網站連接到 Formbricks。",
"cache_update_delay_description": "當您更新問卷調查、聯絡人、操作或其他資料時,這些變更可能需要最多 1 分鐘的時間,才會顯示在執行 Formbricks SDK 的本地應用程式中。",
"cache_update_delay_title": "由於快取,變更約需 1 分鐘後才會反映",
"app_connection_description": "將您的應用程式連線至 Formbricks。",
"cache_update_delay_description": "當您調查、聯絡人、操作或其他資料進行更新時,可能需要長達 5 分鐘這些變更才能顯示在執行 Formbricks SDK 的本地應用程式中。此延遲是因我們目前快取系統的限制。我們正積極重新設計快取,並將在 Formbricks 4.0 中發佈修補程式。",
"cache_update_delay_title": "更改將於 5 分鐘後因快取而反映",
"environment_id": "您的 EnvironmentId",
"environment_id_description": "此 ID 可唯一識別此 Formbricks 環境。",
"formbricks_sdk_connected": "Formbricks SDK 已連線",
"formbricks_sdk_not_connected": "Formbricks SDK 尚未連線。",
"formbricks_sdk_not_connected_description": "將 Formbricks SDK 添加到您的網站或應用程式,以連接到 Formbricks",
"formbricks_sdk_not_connected_description": "將您的網站或應用程式 Formbricks 連線",
"how_to_setup": "如何設定",
"how_to_setup_description": "請按照這些步驟在您的應用程式中設定 Formbricks 小工具。",
"receiving_data": "正在接收資料 💃🕺",
@@ -815,7 +800,7 @@
"project_deleted_successfully": "專案已成功刪除",
"project_name_settings_description": "變更您的專案名稱。",
"project_name_updated_successfully": "專案名稱已成功更新",
"recontact_waiting_time": "專案範圍內的問卷等待時間",
"recontact_waiting_time": "重新聯絡等待時間",
"recontact_waiting_time_settings_description": "控制使用者在所有應用程式問卷中可以被調查的頻率。",
"this_action_cannot_be_undone": "此操作無法復原。",
"wait_x_days_before_showing_next_survey": "在顯示下一個問卷之前等待 X 天:",
@@ -884,6 +869,7 @@
"add_tag": "新增標籤",
"count": "計數",
"delete_tag_confirmation": "您確定要刪除此標籤嗎?",
"empty_message": "標記提交內容,在此處找到您的標籤清單。",
"manage_tags": "管理標籤",
"manage_tags_description": "合併和移除回應標籤。",
"merge": "合併",
@@ -1202,9 +1188,9 @@
"add": "新增 +",
"add_a_delay_or_auto_close_the_survey": "新增延遲或自動關閉問卷",
"add_a_four_digit_pin": "新增四位數 PIN 碼",
"add_a_new_question_to_your_survey": "在您的問卷中新增一個新問題",
"add_a_variable_to_calculate": "新增要計算的變數",
"add_action_below": "在下方新增操作",
"add_block": "新增區塊",
"add_choice_below": "在下方新增選項",
"add_color_coding": "新增顏色編碼",
"add_color_coding_description": "為選項新增紅色、橘色和綠色顏色代碼。",
@@ -1225,7 +1211,6 @@
"add_other": "新增「其他」",
"add_photo_or_video": "新增照片或影片",
"add_pin": "新增 PIN 碼",
"add_question": "新增問題",
"add_question_below": "在下方新增問題",
"add_row": "新增列",
"add_variable": "新增變數",
@@ -1240,6 +1225,7 @@
"allow_multi_select": "允許多重選取",
"allow_multiple_files": "允許上傳多個檔案",
"allow_users_to_select_more_than_one_image": "允許使用者選取多張圖片",
"always_show_survey": "始終顯示問卷",
"and_launch_surveys_in_your_website_or_app": "並在您的網站或應用程式中啟動問卷。",
"animation": "動畫",
"app_survey_description": "將問卷嵌入您的 Web 應用程式或網站中以收集回應。",
@@ -1252,6 +1238,8 @@
"automatically_mark_the_survey_as_complete_after": "在指定時間後自動將問卷標記為完成",
"back_button_label": "「返回」按鈕標籤",
"background_styling": "背景樣式設定",
"block_deleted": "區塊已刪除。",
"block_duplicated": "區塊已複製。",
"bold": "粗體",
"brand_color": "品牌顏色",
"brightness": "亮度",
@@ -1296,6 +1284,7 @@
"character_limit_toggle_title": "新增字元限制",
"checkbox_label": "核取方塊標籤",
"choose_the_actions_which_trigger_the_survey": "選擇觸發問卷的操作。",
"choose_the_first_question_on_your_block": "選擇此區塊的第一個問題",
"choose_where_to_run_the_survey": "選擇在哪裡執行問卷。",
"city": "城市",
"close_survey_on_response_limit": "在回應次數上限關閉問卷",
@@ -1322,7 +1311,9 @@
"custom_hostname": "自訂主機名稱",
"darken_or_lighten_background_of_your_choice": "變暗或變亮您選擇的背景。",
"date_format": "日期格式",
"days_before_showing_this_survey_again": "顯示此問卷之前,需等待其他問卷顯示後的天數。",
"days_before_showing_this_survey_again": "天後再次顯示此問卷。",
"decide_how_often_people_can_answer_this_survey": "決定人們可以回答此問卷的頻率。",
"delete_block": "刪除區塊",
"delete_choice": "刪除選項",
"disable_the_visibility_of_survey_progress": "停用問卷進度的可見性。",
"display_an_estimate_of_completion_time_for_survey": "顯示問卷的估計完成時間",
@@ -1334,6 +1325,7 @@
"does_not_include_all_of": "不包含全部",
"does_not_include_one_of": "不包含其中之一",
"does_not_start_with": "不以...開頭",
"duplicate_block": "複製區塊",
"edit_link": "編輯 連結",
"edit_recall": "編輯回憶",
"edit_translations": "編輯 '{'language'}' 翻譯",
@@ -1350,7 +1342,7 @@
"equals_one_of": "等於其中之一",
"error_publishing_survey": "發布問卷時發生錯誤。",
"error_saving_changes": "儲存變更時發生錯誤",
"even_after_they_submitted_a_response_e_g_feedback_box": "允許多次回應;即使已提交回應仍繼續顯示(例如意見回饋框)。",
"even_after_they_submitted_a_response_e_g_feedback_box": "即使他們提交回應之後(例如意見反應方塊)",
"everyone": "所有人",
"external_urls_paywall_tooltip": "請升級以自訂 external URL 。 Phishing 預防。",
"fallback_missing": "遺失的回退",
@@ -1421,9 +1413,8 @@
"hostname": "主機名稱",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "您希望 '{'surveyTypeDerived'}' 問卷中的卡片有多酷炫",
"if_you_need_more_please": "如果您需要更多,請",
"if_you_really_want_that_answer_ask_until_you_get_it": "每次觸發時都顯示,直到提交回應為止。",
"ignore_global_waiting_time": "忽略專案範圍內的等待時間",
"ignore_global_waiting_time_description": "此問卷在符合條件時即可顯示,即使最近已顯示過其他問卷。",
"if_you_really_want_that_answer_ask_until_you_get_it": "如果您真的想要該答案,請詢問直到您獲得它。",
"ignore_waiting_time_between_surveys": "忽略問卷之間的等待時間",
"image": "圖片",
"includes_all_of": "包含全部",
"includes_one_of": "包含其中之一",
@@ -1490,10 +1481,9 @@
"optional": "選填",
"options": "選項",
"override_theme_with_individual_styles_for_this_survey": "使用此問卷的個別樣式覆寫主題。",
"overwrite_global_waiting_time": "設定自訂等待時間",
"overwrite_global_waiting_time_description": "僅覆蓋此問卷的專案設定。",
"overwrite_placement": "覆寫位置",
"overwrite_the_global_placement_of_the_survey": "覆寫問卷的整體位置",
"overwrites_waiting_period_between_surveys_to_x_days": "將問卷之間的等待時間覆寫為 '{'days'}' 天。",
"pick_a_background_from_our_library_or_upload_your_own": "從我們的媒體庫中選取背景或上傳您自己的背景。",
"picture_idx": "圖片 '{'idx'}'",
"pin_can_only_contain_numbers": "PIN 碼只能包含數字。",
@@ -1552,8 +1542,7 @@
"range": "範圍",
"recall_data": "回憶數據",
"recall_information_from": "從 ... 獲取 信息",
"recontact_options_section": "重新聯絡選項",
"recontact_options_section_description": "如果等待時間允許,選擇此問卷可以向同一人顯示的頻率。",
"recontact_options": "重新聯絡選項",
"redirect_thank_you_card": "重新導向感謝卡片",
"redirect_to_url": "重新導向至網址",
"remove_description": "移除描述",
@@ -1562,8 +1551,6 @@
"required": "必填",
"reset_to_theme_styles": "重設為主題樣式",
"reset_to_theme_styles_main_text": "您確定要將樣式重設為主題樣式嗎?這將移除所有自訂樣式。",
"respect_global_waiting_time": "使用專案範圍內的等待時間",
"respect_global_waiting_time_description": "此問卷遵循專案設定的等待時間。僅在該期間內未顯示其他問卷時才會顯示。",
"response_limit_can_t_be_set_to_0": "回應限制不能設定為 0",
"response_limit_needs_to_exceed_number_of_received_responses": "回應限制必須超過收到的回應數 ('{'responseCount'}')。",
"response_limits_redirections_and_more": "回應限制、重新導向等。",
@@ -1588,7 +1575,7 @@
"show_advanced_settings": "顯示進階設定",
"show_button": "顯示按鈕",
"show_language_switch": "顯示語言切換",
"show_multiple_times": "顯示有限次數",
"show_multiple_times": "多次顯示",
"show_only_once": "僅顯示一次",
"show_survey_maximum_of": "最多顯示問卷",
"show_survey_to_users": "將問卷顯示給 % 的使用者",
@@ -1618,12 +1605,13 @@
"switch_multi_lanugage_on_to_get_started": "開啟多語言以開始使用 👉",
"targeted": "目標",
"ten_points": "10 分",
"the_survey_will_be_shown_multiple_times_until_they_respond": "最多顯示指定次數,或直到他們回應(以先達成者為準)。",
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "僅顯示一次,即使他們未回應。",
"the_survey_will_be_shown_multiple_times_until_they_respond": "將多次顯示問卷,直到他們回應",
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "即使使用者沒有回應,也只會顯示一次問卷。",
"then": "然後",
"this_action_will_remove_all_the_translations_from_this_survey": "此操作將從此問卷中移除所有翻譯。",
"this_extension_is_already_added": "已新增此擴充功能。",
"this_file_type_is_not_supported": "不支援此檔案類型。",
"this_setting_overwrites_your": "此設定會覆寫您的",
"three_points": "3 分",
"times": "次",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "若要保持所有問卷的位置一致,您可以",
@@ -1634,7 +1622,8 @@
"unlock_targeting_description": "根據屬性或裝置資訊鎖定特定使用者群組",
"unlock_targeting_title": "使用更高等級的方案解鎖目標設定",
"unsaved_changes_warning": "您的問卷中有未儲存的變更。您要先儲存它們再離開嗎?",
"until_they_submit_a_response": "持續詢問直到提交回應",
"until_they_submit_a_response": "直到他們提交回應",
"untitled_block": "未命名區塊",
"upgrade_notice_description": "建立多語言問卷並解鎖更多功能",
"upgrade_notice_title": "使用更高等級的方案解鎖多語言問卷",
"upload": "上傳",
@@ -1642,6 +1631,7 @@
"upper_label": "上標籤",
"url_filters": "網址篩選器",
"url_not_supported": "不支援網址",
"use_with_caution": "謹慎使用",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'variable'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "變數 \"{variableName}\" 正被使用於 \"{quotaName}\" 配額中",
"variable_name_is_already_taken_please_choose_another": "已使用此變數名稱,請選擇另一個名稱。",
@@ -1651,13 +1641,11 @@
"variable_used_in_recall_welcome": "變數 \"{variable}\" 於 歡迎 Card 中被召回。",
"verify_email_before_submission": "提交前驗證電子郵件",
"verify_email_before_submission_description": "僅允許擁有真實電子郵件的人員回應。",
"visibility_and_recontact": "可見性與重新聯絡",
"visibility_and_recontact_description": "控制此問卷何時可以顯示以及可以重新顯示的頻率。",
"wait": "等待",
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "在觸發後等待幾秒鐘再顯示問卷",
"waiting_time_across_surveys": "專案範圍內的等待時間",
"waiting_time_across_surveys_description": "為避免問卷疲勞,選擇此問卷如何與專案範圍內的等待時間互動。",
"waiting_period": "等待時間",
"welcome_message": "歡迎訊息",
"when_conditions_match_waiting_time_will_be_ignored_and_survey_shown": "當條件符合時,等待時間將被忽略且顯示問卷。",
"without_a_filter_all_of_your_users_can_be_surveyed": "如果沒有篩選器,則可以調查您的所有使用者。",
"you_have_not_created_a_segment_yet": "您尚未建立區隔",
"you_need_to_have_two_or_more_languages_set_up_in_your_project_to_work_with_translations": "您需要在您的專案中設定兩個或更多語言,才能使用翻譯。",
@@ -1702,7 +1690,7 @@
"last_name": "姓氏",
"not_completed": "未完成 ⏳",
"os": "作業系統",
"person_attributes": "提交時的個人屬性",
"person_attributes": "人屬性",
"phone": "電話",
"respondent_skipped_questions": "回應者跳過這些問題。",
"response_deleted_successfully": "回應已成功刪除。",
@@ -1815,7 +1803,6 @@
"summary": {
"added_filter_for_responses_where_answer_to_question": "已新增回應的篩選器,其中問題 '{'questionIdx'}' 的答案為 '{'filterComboBoxValue'}' - '{'filterValue'}'",
"added_filter_for_responses_where_answer_to_question_is_skipped": "已新增回應的篩選器,其中問題 '{'questionIdx'}' 的答案被跳過",
"aggregated": "匯總",
"all_responses_csv": "所有回應 (CSV)",
"all_responses_excel": "所有回應 (Excel)",
"all_time": "全部時間",
@@ -1839,6 +1826,7 @@
"filtered_responses_csv": "篩選回應 (CSV)",
"filtered_responses_excel": "篩選回應 (Excel)",
"generating_qr_code": "正在生成 QR code",
"go_to_setup_checklist": "前往設定檢查清單 👉",
"impressions": "曝光數",
"impressions_tooltip": "問卷已檢視的次數。",
"in_app": {
@@ -1872,7 +1860,7 @@
},
"includes_all": "包含全部",
"includes_either": "包含其中一個",
"individual": "個人",
"install_widget": "安裝 Formbricks 小工具",
"is_equal_to": "等於",
"is_less_than": "小於",
"last_30_days": "過去 30 天",
@@ -1885,7 +1873,6 @@
"no_responses_found": "找不到回應",
"other_values_found": "找到其他值",
"overall": "整體",
"promoters": "推廣者",
"qr_code": "QR 碼",
"qr_code_description": "透過 QR code 收集的回應都是匿名的。",
"qr_code_download_failed": "QR code 下載失敗",
@@ -1895,7 +1882,6 @@
"quotas_completed_tooltip": "受訪者完成的 配額 數量。",
"reset_survey": "重設問卷",
"reset_survey_warning": "重置 調查 會 移除 與 此 調查 相關 的 所有 回應 和 顯示 。 這 是 不可 撤銷 的 。",
"satisfied": "滿意",
"selected_responses_csv": "選擇的回應 (CSV)",
"selected_responses_excel": "選擇的回應 (Excel)",
"setup_integrations": "設定整合",
@@ -1911,6 +1897,7 @@
"ttc_tooltip": "完成 問題 的 平均 時間。",
"unknown_question_type": "未知的問題類型",
"use_personal_links": "使用 個人 連結",
"waiting_for_response": "正在等待回應 🧘‍♂️",
"whats_next": "下一步是什麼?",
"your_survey_is_public": "您的問卷是公開的",
"youre_not_plugged_in_yet": "您尚未插入任何內容!"
@@ -2265,6 +2252,7 @@
"csat_survey_question_3_headline": "唉,抱歉!我們是否有任何可以改善您體驗的地方?",
"csat_survey_question_3_placeholder": "在此輸入您的答案...",
"cta_description": "顯示資訊並提示使用者採取特定操作",
"custom_survey_block_1_name": "區塊 1",
"custom_survey_description": "建立沒有範本的問卷。",
"custom_survey_name": "從頭開始",
"custom_survey_question_1_headline": "您想瞭解什麼?",
@@ -2674,7 +2662,6 @@
"preview_survey_question_2_choice_2_label": "不用了,謝謝!",
"preview_survey_question_2_headline": "想要緊跟最新動態嗎?",
"preview_survey_welcome_card_headline": "歡迎!",
"preview_survey_welcome_card_html": "感謝您提供回饋 - 開始吧!",
"prioritize_features_description": "找出您的使用者最需要和最不需要的功能。",
"prioritize_features_name": "優先排序功能",
"prioritize_features_question_1_choice_1": "功能 1",
+1 -1
View File
@@ -85,6 +85,6 @@ export const middleware = async (originalRequest: NextRequest) => {
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|js|css|images|fonts|icons|public|animated-bgs).*)",
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|js|css|images|fonts|icons|public).*)",
],
};

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