diff --git a/apps/demo/pages/app/index.tsx b/apps/demo/pages/app/index.tsx index 772da37b44..7dd858d363 100644 --- a/apps/demo/pages/app/index.tsx +++ b/apps/demo/pages/app/index.tsx @@ -34,7 +34,12 @@ export default function AppPage({}) { if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) { const isUserId = window.location.href.includes("userId=true"); - const attributes = isUserId ? { "Init Attribute 1": "eight", "Init Attribute 2": "two" } : undefined; + const defaultAttributes = { + language: "gu", + }; + const userInitAttributes = { "Init Attribute 1": "eight", "Init Attribute 2": "two" }; + + const attributes = isUserId ? { ...defaultAttributes, ...userInitAttributes } : defaultAttributes; const userId = isUserId ? "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING" : undefined; formbricks.init({ environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID, diff --git a/apps/formbricks-com/app/docs/self-hosting/enterprise/page.mdx b/apps/formbricks-com/app/docs/self-hosting/enterprise/page.mdx index 83d993fd74..4e0a9c5411 100644 --- a/apps/formbricks-com/app/docs/self-hosting/enterprise/page.mdx +++ b/apps/formbricks-com/app/docs/self-hosting/enterprise/page.mdx @@ -1,7 +1,6 @@ export const metadata = { title: "Enterprise License to unlock advanced functionality", - description: - "Request a self-hosting licenses to unlock advanced enterprise functionality", + description: "Request a enterprise licenses to unlock advanced enterprise functionality", }; #### Self-Hosting @@ -14,13 +13,17 @@ Additional to the AGPL licensed Formbricks core, the Formbricks repository conta **Please note:** Sooner than later we will introduce a enterprise license pricing. For a free beta key, fill out this form: -
+
+ style={{ position: "absolute", left: 0, top: 0, width: "100%", height: "100%", border: 0 }}>
- **Can’t figure it out?**: [Join our Discord!](https://formbricks.com/discord) diff --git a/apps/formbricks-com/components/dummyUI/CTAQuestion.tsx b/apps/formbricks-com/components/dummyUI/CTAQuestion.tsx index 2d7817e0fc..45f59082e0 100644 --- a/apps/formbricks-com/components/dummyUI/CTAQuestion.tsx +++ b/apps/formbricks-com/components/dummyUI/CTAQuestion.tsx @@ -1,7 +1,6 @@ -import { TSurveyCTAQuestion } from "@formbricks/types/surveys"; - import Headline from "./Headline"; import HtmlBody from "./HtmlBody"; +import { TSurveyCTAQuestion } from "./types"; interface CTAQuestionProps { question: TSurveyCTAQuestion; diff --git a/apps/formbricks-com/components/dummyUI/DemoPreview.tsx b/apps/formbricks-com/components/dummyUI/DemoPreview.tsx index bf2c6e08ec..91962b52fe 100644 --- a/apps/formbricks-com/components/dummyUI/DemoPreview.tsx +++ b/apps/formbricks-com/components/dummyUI/DemoPreview.tsx @@ -2,10 +2,9 @@ import React, { useEffect, useState } from "react"; -import { TTemplate } from "@formbricks/types/templates"; - import PreviewSurvey from "./PreviewSurvey"; import { findTemplateByName } from "./templates"; +import { TTemplate } from "./types"; interface DemoPreviewProps { template: string; diff --git a/apps/formbricks-com/components/dummyUI/DemoView.tsx b/apps/formbricks-com/components/dummyUI/DemoView.tsx index 7427269719..ed97a55367 100644 --- a/apps/formbricks-com/components/dummyUI/DemoView.tsx +++ b/apps/formbricks-com/components/dummyUI/DemoView.tsx @@ -1,10 +1,9 @@ import { useEffect, useState } from "react"; -import { TTemplate } from "@formbricks/types/templates"; - import PreviewSurvey from "./PreviewSurvey"; import TemplateList from "./TemplateList"; import { templates } from "./templates"; +import { TTemplate } from "./types"; export default function SurveyTemplatesPage({}) { const [activeTemplate, setActiveTemplate] = useState(null); diff --git a/apps/formbricks-com/components/dummyUI/MultipleChoiceMultiQuestion.tsx b/apps/formbricks-com/components/dummyUI/MultipleChoiceMultiQuestion.tsx index 806627ac24..c4765f9d3c 100644 --- a/apps/formbricks-com/components/dummyUI/MultipleChoiceMultiQuestion.tsx +++ b/apps/formbricks-com/components/dummyUI/MultipleChoiceMultiQuestion.tsx @@ -1,10 +1,10 @@ import { useEffect, useState } from "react"; import { cn } from "@formbricks/lib/cn"; -import { TSurveyMultipleChoiceMultiQuestion } from "@formbricks/types/surveys"; import Headline from "./Headline"; import Subheader from "./Subheader"; +import { TSurveyMultipleChoiceMultiQuestion } from "./types"; interface MultipleChoiceMultiProps { question: TSurveyMultipleChoiceMultiQuestion; diff --git a/apps/formbricks-com/components/dummyUI/MultipleChoiceSingleQuestion.tsx b/apps/formbricks-com/components/dummyUI/MultipleChoiceSingleQuestion.tsx index 1ea0112b93..cf6bdad352 100644 --- a/apps/formbricks-com/components/dummyUI/MultipleChoiceSingleQuestion.tsx +++ b/apps/formbricks-com/components/dummyUI/MultipleChoiceSingleQuestion.tsx @@ -1,10 +1,10 @@ import { useState } from "react"; import { cn } from "@formbricks/lib/cn"; -import { TSurveyMultipleChoiceSingleQuestion } from "@formbricks/types/surveys"; import Headline from "./Headline"; import Subheader from "./Subheader"; +import { TSurveyMultipleChoiceSingleQuestion } from "./types"; interface MultipleChoiceSingleProps { question: TSurveyMultipleChoiceSingleQuestion; @@ -20,6 +20,7 @@ export default function MultipleChoiceSingleQuestion({ brandColor, }: MultipleChoiceSingleProps) { const [selectedChoice, setSelectedChoice] = useState(null); + return (
{ diff --git a/apps/formbricks-com/components/dummyUI/NPSQuestion.tsx b/apps/formbricks-com/components/dummyUI/NPSQuestion.tsx index 1418f68230..f5ed7a45c4 100644 --- a/apps/formbricks-com/components/dummyUI/NPSQuestion.tsx +++ b/apps/formbricks-com/components/dummyUI/NPSQuestion.tsx @@ -1,10 +1,10 @@ import { useState } from "react"; import { cn } from "@formbricks/lib/cn"; -import { TSurveyNPSQuestion } from "@formbricks/types/surveys"; import Headline from "./Headline"; import Subheader from "./Subheader"; +import { TSurveyNPSQuestion } from "./types"; interface NPSQuestionProps { question: TSurveyNPSQuestion; diff --git a/apps/formbricks-com/components/dummyUI/OpenTextQuestion.tsx b/apps/formbricks-com/components/dummyUI/OpenTextQuestion.tsx index 887b8eaa38..25aeabcda5 100644 --- a/apps/formbricks-com/components/dummyUI/OpenTextQuestion.tsx +++ b/apps/formbricks-com/components/dummyUI/OpenTextQuestion.tsx @@ -1,9 +1,8 @@ import { useState } from "react"; -import { TSurveyOpenTextQuestion } from "@formbricks/types/surveys"; - import Headline from "./Headline"; import Subheader from "./Subheader"; +import { TSurveyOpenTextQuestion } from "./types"; interface OpenTextQuestionProps { question: TSurveyOpenTextQuestion; diff --git a/apps/formbricks-com/components/dummyUI/PreviewSurvey.tsx b/apps/formbricks-com/components/dummyUI/PreviewSurvey.tsx index 523b029478..8a6a2d11ca 100644 --- a/apps/formbricks-com/components/dummyUI/PreviewSurvey.tsx +++ b/apps/formbricks-com/components/dummyUI/PreviewSurvey.tsx @@ -1,10 +1,9 @@ import { useState } from "react"; -import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys"; - import Modal from "./Modal"; import QuestionConditional from "./QuestionConditional"; import ThankYouCard from "./ThankYouCard"; +import { TSurvey, TSurveyQuestion } from "./types"; interface PreviewSurveyProps { localSurvey?: TSurvey; @@ -67,8 +66,8 @@ export default function PreviewSurvey({ {activeQuestionId == "thank-you-card" ? ( ) : ( questions.map( diff --git a/apps/formbricks-com/components/dummyUI/QuestionConditional.tsx b/apps/formbricks-com/components/dummyUI/QuestionConditional.tsx index 4e02d5421c..a24a784d2a 100644 --- a/apps/formbricks-com/components/dummyUI/QuestionConditional.tsx +++ b/apps/formbricks-com/components/dummyUI/QuestionConditional.tsx @@ -1,11 +1,10 @@ -import { TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys"; - import CTAQuestion from "./CTAQuestion"; import MultipleChoiceMultiQuestion from "./MultipleChoiceMultiQuestion"; import MultipleChoiceSingleQuestion from "./MultipleChoiceSingleQuestion"; import NPSQuestion from "./NPSQuestion"; import OpenTextQuestion from "./OpenTextQuestion"; import RatingQuestion from "./RatingQuestion"; +import { TSurveyQuestion, TSurveyQuestionType } from "./types"; interface QuestionConditionalProps { question: TSurveyQuestion; diff --git a/apps/formbricks-com/components/dummyUI/RatingQuestion.tsx b/apps/formbricks-com/components/dummyUI/RatingQuestion.tsx index f1257a5095..6e8c72c584 100644 --- a/apps/formbricks-com/components/dummyUI/RatingQuestion.tsx +++ b/apps/formbricks-com/components/dummyUI/RatingQuestion.tsx @@ -1,10 +1,10 @@ import { useState } from "react"; import { cn } from "@formbricks/lib/cn"; -import { TSurveyRatingQuestion } from "@formbricks/types/surveys"; import Headline from "./Headline"; import Subheader from "./Subheader"; +import { TSurveyRatingQuestion } from "./types"; interface RatingQuestionProps { question: TSurveyRatingQuestion; diff --git a/apps/formbricks-com/components/dummyUI/TemplateList.tsx b/apps/formbricks-com/components/dummyUI/TemplateList.tsx index a886d9eca2..eec24c4173 100644 --- a/apps/formbricks-com/components/dummyUI/TemplateList.tsx +++ b/apps/formbricks-com/components/dummyUI/TemplateList.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from "react"; import { cn } from "@formbricks/lib/cn"; -import { TTemplate } from "@formbricks/types/templates"; import { templates } from "./templates"; +import { TTemplate } from "./types"; type TemplateList = { onTemplateClick: (template: TTemplate) => void; diff --git a/apps/formbricks-com/components/dummyUI/templates.ts b/apps/formbricks-com/components/dummyUI/templates.ts index e1a9994dd7..d8730efe7f 100644 --- a/apps/formbricks-com/components/dummyUI/templates.ts +++ b/apps/formbricks-com/components/dummyUI/templates.ts @@ -1,7 +1,6 @@ import { createId } from "@paralleldrive/cuid2"; import { TSurveyQuestionType } from "@formbricks/types/surveys"; -import { TTemplate } from "@formbricks/types/templates"; import { AppPieChartIcon, ArrowRightCircleIcon, @@ -27,14 +26,17 @@ import { VideoTabletAdjustIcon, } from "@formbricks/ui/icons"; +import { TTemplate } from "./types"; + const thankYouCardDefault = { enabled: true, headline: "Thank you!", - subheader: "We appreciate your feedback.", + subheader: "TWe appreciate your feedback.", }; const welcomeCardDefault = { enabled: true, + headline: "Welcome!", timeToFinish: false, showResponseCount: false, }; diff --git a/apps/formbricks-com/components/dummyUI/types.ts b/apps/formbricks-com/components/dummyUI/types.ts new file mode 100644 index 0000000000..44d3d91f7e --- /dev/null +++ b/apps/formbricks-com/components/dummyUI/types.ts @@ -0,0 +1,501 @@ +import z from "zod"; + +export enum TSurveyQuestionType { + FileUpload = "fileUpload", + OpenText = "openText", + MultipleChoiceSingle = "multipleChoiceSingle", + MultipleChoiceMulti = "multipleChoiceMulti", + NPS = "nps", + CTA = "cta", + Rating = "rating", + Consent = "consent", + PictureSelection = "pictureSelection", + Cal = "cal", + Date = "date", +} + +export const ZAllowedFileExtension = z.enum([ + "png", + "jpeg", + "jpg", + "pdf", + "doc", + "docx", + "xls", + "xlsx", + "ppt", + "pptx", + "plain", + "csv", + "mp4", + "mov", + "avi", + "mkv", + "webm", + "zip", + "rar", + "7z", + "tar", +]); + +export type TAllowedFileExtension = z.infer; + +export const ZUserObjective = z.enum([ + "increase_conversion", + "improve_user_retention", + "increase_user_adoption", + "sharpen_marketing_messaging", + "support_sales", + "other", +]); + +export type TUserObjective = z.infer; + +export const ZSurveyWelcomeCard = z.object({ + enabled: z.boolean(), + headline: z.optional(z.string()), + html: z.string().optional(), + fileUrl: z.string().optional(), + buttonLabel: z.string().optional(), + timeToFinish: z.boolean().default(true), + showResponseCount: z.boolean().default(false), +}); + +export type TSurveyWelcomeCard = z.infer; + +export const ZSurveyThankYouCard = z.object({ + enabled: z.boolean(), + headline: z.optional(z.string()), + subheader: z.optional(z.string()), + buttonLabel: z.optional(z.string()), + buttonLink: z.optional(z.string()), + imageUrl: z.string().optional(), +}); + +export type TSurveyThankYouCard = z.infer; + +export const ZSurveyHiddenFields = z.object({ + enabled: z.boolean(), + fieldIds: z.optional(z.array(z.string())), +}); + +export type TSurveyHiddenFields = z.infer; + +export const ZSurveyChoice = z.object({ + id: z.string(), + label: z.string(), +}); + +export type TSurveyChoice = z.infer; + +export const ZSurveyPictureChoice = z.object({ + id: z.string(), + imageUrl: z.string(), +}); + +export type TSurveyPictureChoice = z.infer; + +export const ZSurveyLogicCondition = z.enum([ + "accepted", + "clicked", + "submitted", + "skipped", + "equals", + "notEquals", + "lessThan", + "lessEqual", + "greaterThan", + "greaterEqual", + "includesAll", + "includesOne", + "uploaded", + "notUploaded", + "booked", +]); + +export type TSurveyLogicCondition = z.infer; + +export const ZSurveyLogicBase = z.object({ + condition: ZSurveyLogicCondition.optional(), + value: z.union([z.string(), z.array(z.string())]).optional(), + destination: z.union([z.string(), z.literal("end")]).optional(), +}); + +export const ZSurveyFileUploadLogic = ZSurveyLogicBase.extend({ + condition: z.enum(["uploaded", "notUploaded"]).optional(), + value: z.undefined(), +}); + +export const ZSurveyOpenTextLogic = ZSurveyLogicBase.extend({ + condition: z.enum(["submitted", "skipped"]).optional(), + value: z.undefined(), +}); + +export const ZSurveyConsentLogic = ZSurveyLogicBase.extend({ + condition: z.enum(["skipped", "accepted"]).optional(), + value: z.undefined(), +}); + +export const ZSurveyMultipleChoiceSingleLogic = ZSurveyLogicBase.extend({ + condition: z.enum(["submitted", "skipped", "equals", "notEquals"]).optional(), + value: z.string().optional(), +}); + +export const ZSurveyMultipleChoiceMultiLogic = ZSurveyLogicBase.extend({ + condition: z.enum(["submitted", "skipped", "includesAll", "includesOne", "equals"]).optional(), + value: z.union([z.array(z.string()), z.string()]).optional(), +}); + +export const ZSurveyNPSLogic = ZSurveyLogicBase.extend({ + condition: z + .enum([ + "equals", + "notEquals", + "lessThan", + "lessEqual", + "greaterThan", + "greaterEqual", + "submitted", + "skipped", + ]) + .optional(), + value: z.union([z.string(), z.number()]).optional(), +}); + +const ZSurveyCTALogic = ZSurveyLogicBase.extend({ + // "submitted" condition is legacy and should be removed later + condition: z.enum(["clicked", "submitted", "skipped"]).optional(), + value: z.undefined(), +}); + +const ZSurveyRatingLogic = ZSurveyLogicBase.extend({ + condition: z + .enum([ + "equals", + "notEquals", + "lessThan", + "lessEqual", + "greaterThan", + "greaterEqual", + "submitted", + "skipped", + ]) + .optional(), + value: z.union([z.string(), z.number()]).optional(), +}); + +const ZSurveyPictureSelectionLogic = ZSurveyLogicBase.extend({ + condition: z.enum(["submitted", "skipped"]).optional(), + value: z.undefined(), +}); + +const ZSurveyCalLogic = ZSurveyLogicBase.extend({ + condition: z.enum(["booked", "skipped"]).optional(), + value: z.undefined(), +}); + +export const ZSurveyLogic = z.union([ + ZSurveyOpenTextLogic, + ZSurveyConsentLogic, + ZSurveyMultipleChoiceSingleLogic, + ZSurveyMultipleChoiceMultiLogic, + ZSurveyNPSLogic, + ZSurveyCTALogic, + ZSurveyRatingLogic, + ZSurveyPictureSelectionLogic, + ZSurveyFileUploadLogic, + ZSurveyCalLogic, +]); + +export type TSurveyLogic = z.infer; + +const ZSurveyQuestionBase = z.object({ + id: z.string(), + type: z.string(), + headline: z.string(), + subheader: z.string().optional(), + imageUrl: z.string().optional(), + required: z.boolean(), + buttonLabel: z.string().optional(), + backButtonLabel: z.string().optional(), + scale: z.enum(["number", "smiley", "star"]).optional(), + range: z.union([z.literal(5), z.literal(3), z.literal(4), z.literal(7), z.literal(10)]).optional(), + logic: z.array(ZSurveyLogic).optional(), + isDraft: z.boolean().optional(), +}); + +export const ZSurveyOpenTextQuestionInputType = z.enum(["text", "email", "url", "number", "phone"]); +export type TSurveyOpenTextQuestionInputType = z.infer; + +export const ZSurveyOpenTextQuestion = ZSurveyQuestionBase.extend({ + type: z.literal(TSurveyQuestionType.OpenText), + placeholder: z.string().optional(), + longAnswer: z.boolean().optional(), + logic: z.array(ZSurveyOpenTextLogic).optional(), + inputType: ZSurveyOpenTextQuestionInputType.optional().default("text"), +}); + +export type TSurveyOpenTextQuestion = z.infer; + +export const ZSurveyConsentQuestion = ZSurveyQuestionBase.extend({ + type: z.literal(TSurveyQuestionType.Consent), + html: z.string().optional(), + label: z.string(), + dismissButtonLabel: z.string().optional(), + placeholder: z.string().optional(), + logic: z.array(ZSurveyConsentLogic).optional(), +}); + +export type TSurveyConsentQuestion = z.infer; + +export const ZSurveyMultipleChoiceSingleQuestion = ZSurveyQuestionBase.extend({ + type: z.literal(TSurveyQuestionType.MultipleChoiceSingle), + choices: z.array(ZSurveyChoice), + logic: z.array(ZSurveyMultipleChoiceSingleLogic).optional(), + shuffleOption: z.enum(["none", "all", "exceptLast"]).optional(), + otherOptionPlaceholder: z.string().optional(), +}); + +export type TSurveyMultipleChoiceSingleQuestion = z.infer; + +export const ZSurveyMultipleChoiceMultiQuestion = ZSurveyQuestionBase.extend({ + type: z.literal(TSurveyQuestionType.MultipleChoiceMulti), + choices: z.array(ZSurveyChoice), + logic: z.array(ZSurveyMultipleChoiceMultiLogic).optional(), + shuffleOption: z.enum(["none", "all", "exceptLast"]).optional(), + otherOptionPlaceholder: z.string().optional(), +}); + +export type TSurveyMultipleChoiceMultiQuestion = z.infer; + +export const ZSurveyNPSQuestion = ZSurveyQuestionBase.extend({ + type: z.literal(TSurveyQuestionType.NPS), + lowerLabel: z.string(), + upperLabel: z.string(), + logic: z.array(ZSurveyNPSLogic).optional(), +}); + +export type TSurveyNPSQuestion = z.infer; + +export const ZSurveyCTAQuestion = ZSurveyQuestionBase.extend({ + type: z.literal(TSurveyQuestionType.CTA), + html: z.string().optional(), + buttonUrl: z.string().optional(), + buttonExternal: z.boolean(), + dismissButtonLabel: z.string().optional(), + logic: z.array(ZSurveyCTALogic).optional(), +}); + +export type TSurveyCTAQuestion = z.infer; + +// export const ZSurveyWelcomeQuestion = ZSurveyQuestionBase.extend({ +// type: z.literal(TSurveyQuestionType.Welcome), +// html: z.string().optional(), +// fileUrl: z.string().optional(), +// buttonUrl: z.string().optional(), +// timeToFinish: z.boolean().default(false), +// logic: z.array(ZSurveyCTALogic).optional(), +// }); + +// export type TSurveyWelcomeQuestion = z.infer; + +export const ZSurveyRatingQuestion = ZSurveyQuestionBase.extend({ + type: z.literal(TSurveyQuestionType.Rating), + scale: z.enum(["number", "smiley", "star"]), + range: z.union([z.literal(5), z.literal(3), z.literal(4), z.literal(7), z.literal(10)]), + lowerLabel: z.string(), + upperLabel: z.string(), + logic: z.array(ZSurveyRatingLogic).optional(), +}); + +export const ZSurveyDateQuestion = ZSurveyQuestionBase.extend({ + type: z.literal(TSurveyQuestionType.Date), + html: z.string().optional(), + format: z.enum(["M-d-y", "d-M-y", "y-M-d"]), +}); + +export type TSurveyDateQuestion = z.infer; + +export type TSurveyRatingQuestion = z.infer; + +export const ZSurveyPictureSelectionQuestion = ZSurveyQuestionBase.extend({ + type: z.literal(TSurveyQuestionType.PictureSelection), + allowMulti: z.boolean().optional().default(false), + choices: z.array(ZSurveyPictureChoice), + logic: z.array(ZSurveyPictureSelectionLogic).optional(), +}); + +export type TSurveyPictureSelectionQuestion = z.infer; + +export const ZSurveyFileUploadQuestion = ZSurveyQuestionBase.extend({ + type: z.literal(TSurveyQuestionType.FileUpload), + allowMultipleFiles: z.boolean(), + maxSizeInMB: z.number().optional(), + allowedFileExtensions: z.array(ZAllowedFileExtension).optional(), + logic: z.array(ZSurveyFileUploadLogic).optional(), +}); + +export type TSurveyFileUploadQuestion = z.infer; + +export const ZSurveyCalQuestion = ZSurveyQuestionBase.extend({ + type: z.literal(TSurveyQuestionType.Cal), + calUserName: z.string(), + logic: z.array(ZSurveyCalLogic).optional(), +}); + +export type TSurveyCalQuestion = z.infer; + +export const ZSurveyQuestion = z.union([ + ZSurveyOpenTextQuestion, + ZSurveyConsentQuestion, + ZSurveyMultipleChoiceSingleQuestion, + ZSurveyMultipleChoiceMultiQuestion, + ZSurveyNPSQuestion, + ZSurveyCTAQuestion, + ZSurveyRatingQuestion, + ZSurveyPictureSelectionQuestion, + ZSurveyDateQuestion, + ZSurveyFileUploadQuestion, + ZSurveyCalQuestion, +]); + +export type TSurveyQuestion = z.infer; + +export const ZSurveyQuestions = z.array(ZSurveyQuestion); + +export type TSurveyQuestions = z.infer; + +export const ZSurveyClosedMessage = z + .object({ + enabled: z.boolean().optional(), + heading: z.string().optional(), + subheading: z.string().optional(), + }) + .nullable() + .optional(); + +export type TSurveyClosedMessage = z.infer; + +export const ZSurveyAttributeFilter = z.object({ + attributeClassId: z.string().cuid2(), + condition: z.enum(["equals", "notEquals"]), + value: z.string(), +}); + +export type TSurveyAttributeFilter = z.infer; + +export const ZSurveyType = z.enum(["web", "email", "link", "mobile"]); + +export type TSurveyType = z.infer; + +const ZSurveyStatus = z.enum(["draft", "inProgress", "paused", "completed"]); + +export type TSurveyStatus = z.infer; + +const ZSurveyDisplayOption = z.enum(["displayOnce", "displayMultiple", "respondMultiple"]); + +export type TSurveyDisplayOption = z.infer; + +export const ZColor = z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/); + +export const ZPlacement = z.enum(["bottomLeft", "bottomRight", "topLeft", "topRight", "center"]); + +export type TPlacement = z.infer; + +export const ZSurveyProductOverwrites = z.object({ + brandColor: ZColor.nullish(), + highlightBorderColor: ZColor.nullish(), + placement: ZPlacement.nullish(), + clickOutsideClose: z.boolean().nullish(), + darkOverlay: z.boolean().nullish(), +}); + +export type TSurveyProductOverwrites = z.infer; + +export const ZSurveyStylingBackground = z.object({ + bg: z.string().nullish(), + bgType: z.enum(["animation", "color", "image"]).nullish(), + brightness: z.number().nullish(), +}); + +export type TSurveyStylingBackground = z.infer; + +export const ZSurveyStyling = z.object({ + background: ZSurveyStylingBackground.nullish(), + hideProgressBar: z.boolean().nullish(), +}); + +export type TSurveyStyling = z.infer; + +export const ZSurveySingleUse = z + .object({ + enabled: z.boolean(), + heading: z.optional(z.string()), + subheading: z.optional(z.string()), + isEncrypted: z.boolean(), + }) + .nullable(); + +export type TSurveySingleUse = z.infer; + +export const ZSurveyVerifyEmail = z + .object({ + name: z.optional(z.string()), + subheading: z.optional(z.string()), + }) + .optional(); + +export type TSurveyVerifyEmail = z.infer; + +export const ZSurvey = z.object({ + id: z.string().cuid2(), + createdAt: z.date(), + updatedAt: z.date(), + name: z.string(), + type: ZSurveyType, + environmentId: z.string(), + createdBy: z.string().nullable(), + status: ZSurveyStatus, + attributeFilters: z.array(ZSurveyAttributeFilter), + displayOption: ZSurveyDisplayOption, + autoClose: z.number().nullable(), + triggers: z.array(z.string()), + redirectUrl: z.string().url().nullable(), + recontactDays: z.number().nullable(), + welcomeCard: ZSurveyWelcomeCard, + questions: ZSurveyQuestions, + thankYouCard: ZSurveyThankYouCard, + hiddenFields: ZSurveyHiddenFields, + delay: z.number(), + autoComplete: z.number().nullable(), + closeOnDate: z.date().nullable(), + productOverwrites: ZSurveyProductOverwrites.nullable(), + styling: ZSurveyStyling.nullable(), + surveyClosedMessage: ZSurveyClosedMessage.nullable(), + singleUse: ZSurveySingleUse.nullable(), + verifyEmail: ZSurveyVerifyEmail.nullable(), + pin: z.string().nullable().optional(), + resultShareKey: z.string().nullable(), + displayPercentage: z.number().min(1).max(100).nullable(), +}); + +export type TSurvey = z.infer; + +export const ZTemplate = z.object({ + name: z.string(), + description: z.string(), + icon: z.any().optional(), + category: z + .enum(["Product Experience", "Exploration", "Growth", "Increase Revenue", "Customer Success"]) + .optional(), + objectives: z.array(ZUserObjective).optional(), + preset: z.object({ + name: z.string(), + welcomeCard: ZSurveyWelcomeCard, + questions: ZSurveyQuestions, + thankYouCard: ZSurveyThankYouCard, + hiddenFields: ZSurveyHiddenFields, + }), +}); + +export type TTemplate = z.infer; diff --git a/apps/web/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/AttributesSection.tsx b/apps/web/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/AttributesSection.tsx index b8569f34c9..581f3d9fa1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/AttributesSection.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/AttributesSection.tsx @@ -25,6 +25,16 @@ export default async function AttributesSection({ personId }: { personId: string )}
+
+
Language
+
+ {person.attributes.language ? ( + {person.attributes.language} + ) : ( + Not provided + )} +
+
User Id
@@ -43,7 +53,7 @@ export default async function AttributesSection({ personId }: { personId: string
{Object.entries(person.attributes) - .filter(([key, _]) => key !== "email" && key !== "userId") + .filter(([key, _]) => key !== "email" && key !== "userId" && key !== "language") .map(([key, value]) => (
{capitalizeFirstLetter(key.toString())}
diff --git a/apps/web/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ResponsesFeed.tsx b/apps/web/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ResponsesFeed.tsx index 1755adeaba..84b0174fda 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ResponsesFeed.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ResponsesFeed.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole"; import { getAccessFlags } from "@formbricks/lib/membership/utils"; +import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall"; import { TEnvironment } from "@formbricks/types/environment"; import { TResponse } from "@formbricks/types/responses"; import { TSurvey } from "@formbricks/types/surveys"; @@ -92,7 +93,7 @@ const ResponseSurveyCard = ({ {survey && ( ; } + const isMultiLanguageAllowed = getMultiLanguagePermission(team); + const [products, environments] = await Promise.all([ getProducts(team.id), getEnvironments(environment.productId), @@ -46,6 +48,7 @@ export default async function EnvironmentsNavbar({ environmentId, session }: Env isFormbricksCloud={IS_FORMBRICKS_CLOUD} webAppUrl={WEBAPP_URL} membershipRole={currentUserMembership?.role} + isMultiLanguageAllowed={isMultiLanguageAllowed} /> ); } diff --git a/apps/web/app/(app)/environments/[environmentId]/components/Navigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/Navigation.tsx index 41953a4ad6..1a2186f784 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/Navigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/Navigation.tsx @@ -10,6 +10,7 @@ import { CreditCardIcon, FileCheckIcon, HeartIcon, + LanguagesIcon, LinkIcon, LogOutIcon, MailIcon, @@ -69,6 +70,7 @@ interface NavigationProps { isFormbricksCloud: boolean; webAppUrl: string; membershipRole?: TMembershipRole; + isMultiLanguageAllowed: boolean; } export default function Navigation({ @@ -81,6 +83,7 @@ export default function Navigation({ isFormbricksCloud, webAppUrl, membershipRole, + isMultiLanguageAllowed, }: NavigationProps) { const router = useRouter(); const pathname = usePathname(); @@ -166,6 +169,12 @@ export default function Navigation({ href: `/environments/${environment.id}/settings/lookandfeel`, hidden: isViewer, }, + { + icon: LanguagesIcon, + label: "Survey Languages", + href: `/environments/${environment.id}/settings/language`, + hidden: !isMultiLanguageAllowed, + }, ], }, { diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.tsx index d95666af73..879d7d2942 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal.tsx @@ -8,6 +8,7 @@ import { useEffect, useState } from "react"; import { Control, Controller, UseFormSetValue, useForm } from "react-hook-form"; import { toast } from "react-hot-toast"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall"; import { TIntegrationItem } from "@formbricks/types/integration"; import { @@ -334,7 +335,7 @@ export default function AddIntegrationModal(props: AddIntegrationModalProps) {
- {checkForRecallInHeadline(selectedSurvey)?.questions.map((question) => ( + {checkForRecallInHeadline(selectedSurvey, "default")?.questions.map((question) => ( value !== question.id)); }} /> - {question.headline} + {getLocalizedValue(question.headline, "default")}
)} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.tsx index 3c967f772c..af93fad1f6 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.tsx @@ -4,6 +4,7 @@ import { getAirtableTables } from "@formbricks/lib/airtable/service"; import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants"; import { getEnvironment } from "@formbricks/lib/environment/service"; import { getIntegrations } from "@formbricks/lib/integration/service"; +import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { getSurveys } from "@formbricks/lib/survey/service"; import { TIntegrationItem } from "@formbricks/types/integration"; import { TIntegrationAirtable } from "@formbricks/types/integration/airtable"; @@ -19,6 +20,10 @@ export default async function Airtable({ params }) { if (!environment) { throw new Error("Environment not found"); } + const product = await getProductByEnvironmentId(params.environmentId); + if (!product) { + throw new Error("Product not found"); + } const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find( (integration): integration is TIntegrationAirtable => integration.type === "airtable" diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx index 6ad6dc8e91..706370af26 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import toast from "react-hot-toast"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall"; import { TIntegrationItem } from "@formbricks/types/integration"; import { @@ -274,7 +275,7 @@ export default function AddIntegrationModal({
- {checkForRecallInHeadline(selectedSurvey)?.questions.map((question) => ( + {checkForRecallInHeadline(selectedSurvey, "default")?.questions.map((question) => (
))} diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx index e7c1097eb2..b4ce204274 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx @@ -9,6 +9,7 @@ import { import { getEnvironment } from "@formbricks/lib/environment/service"; import { getSpreadSheets } from "@formbricks/lib/googleSheet/service"; import { getIntegrations } from "@formbricks/lib/integration/service"; +import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { getSurveys } from "@formbricks/lib/survey/service"; import { TIntegrationItem } from "@formbricks/types/integration"; import { TIntegrationGoogleSheets } from "@formbricks/types/integration/googleSheet"; @@ -24,6 +25,10 @@ export default async function GoogleSheet({ params }) { if (!environment) { throw new Error("Environment not found"); } + const product = await getProductByEnvironmentId(params.environmentId); + if (!product) { + throw new Error("Product not found"); + } const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find( (integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets" diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.tsx index 57cc1815c3..2b071610d0 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal.tsx @@ -107,7 +107,7 @@ export default function AddIntegrationModal({ const questionItems = useMemo(() => { const questions = selectedSurvey - ? checkForRecallInHeadline(selectedSurvey)?.questions.map((q) => ({ + ? checkForRecallInHeadline(selectedSurvey, "default")?.questions.map((q) => ({ id: q.id, name: q.headline, type: q.type, @@ -226,7 +226,7 @@ export default function AddIntegrationModal({ return questionItems.filter((q) => !selectedQuestionIds.includes(q.id)); }; - const createCopy = (item) => JSON.parse(JSON.stringify(item)); + const createCopy = (item) => structuredClone(item); const MappingRow = ({ idx }: { idx: number }) => { const filteredQuestionItems = getFilteredQuestionItems(idx); diff --git a/apps/web/app/(app)/environments/[environmentId]/layout.tsx b/apps/web/app/(app)/environments/[environmentId]/layout.tsx index ad7f22a55c..3f1b8cfd58 100644 --- a/apps/web/app/(app)/environments/[environmentId]/layout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/layout.tsx @@ -4,7 +4,6 @@ import { getServerSession } from "next-auth"; import { redirect } from "next/navigation"; import { authOptions } from "@formbricks/lib/authOptions"; -import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth"; import { getTeamByEnvironmentId } from "@formbricks/lib/team/service"; import { AuthorizationError } from "@formbricks/types/errors"; @@ -42,11 +41,7 @@ export default async function EnvironmentLayout({ children, params }) { /> - +
{children}
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/billing/components/PricingTable.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/billing/components/PricingTable.tsx index 55016786a0..e6e99bda12 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/billing/components/PricingTable.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/billing/components/PricingTable.tsx @@ -113,7 +113,7 @@ export default function PricingTableComponent({ }, { title: "Multi-Language Surveys", - comingSoon: true, + comingSoon: false, }, { title: "Unlimited Responses", @@ -162,7 +162,7 @@ export default function PricingTableComponent({ }, { title: "Multi-Language Surveys", - comingSoon: true, + comingSoon: false, }, ]; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/components/SettingsNavbar.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/components/SettingsNavbar.tsx index ca4cb220cd..9e14086756 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/components/SettingsNavbar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/components/SettingsNavbar.tsx @@ -11,6 +11,7 @@ import { FileSearch2Icon, HashIcon, KeyIcon, + LanguagesIcon, LinkIcon, SlidersIcon, UserCircleIcon, @@ -28,19 +29,23 @@ import { TProduct } from "@formbricks/types/product"; import { TTeam } from "@formbricks/types/teams"; import { Popover, PopoverContent, PopoverTrigger } from "@formbricks/ui/Popover"; +interface SettingsNavbarProps { + environmentId: string; + isFormbricksCloud: boolean; + team: TTeam; + product: TProduct; + membershipRole?: TMembershipRole; + isMultiLanguageAllowed: boolean; +} + export default function SettingsNavbar({ environmentId, isFormbricksCloud, team, product, membershipRole, -}: { - environmentId: string; - isFormbricksCloud: boolean; - team: TTeam; - product: TProduct; - membershipRole?: TMembershipRole; -}) { + isMultiLanguageAllowed, +}: SettingsNavbarProps) { const pathname = usePathname(); const [mobileNavMenuOpen, setMobileNavMenuOpen] = useState(false); const { isAdmin, isOwner, isViewer } = getAccessFlags(membershipRole); @@ -100,6 +105,13 @@ export default function SettingsNavbar({ current: pathname?.includes("/lookandfeel"), hidden: isViewer, }, + { + name: "Survey Languages", + href: `/environments/${environmentId}/settings/language`, + icon: LanguagesIcon, + current: pathname?.includes("/language"), + hidden: !isMultiLanguageAllowed, + }, { name: "API Keys", href: `/environments/${environmentId}/settings/api-keys`, @@ -206,7 +218,7 @@ export default function SettingsNavbar({ hidden: false, }, ], - [environmentId, isFormbricksCloud, pathname, isPricingDisabled, isViewer] + [environmentId, pathname, isViewer, isMultiLanguageAllowed, isFormbricksCloud, isPricingDisabled] ); if (!navigation) return null; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/language/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/language/page.tsx new file mode 100644 index 0000000000..7e00152690 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/language/page.tsx @@ -0,0 +1,39 @@ +import SettingsCard from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard"; +import SettingsTitle from "@/app/(app)/environments/[environmentId]/settings/components/SettingsTitle"; +import { notFound } from "next/navigation"; + +import { getMultiLanguagePermission } from "@formbricks/ee/lib/service"; +import EditLanguage from "@formbricks/ee/multiLanguage/components/EditLanguage"; +import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; +import { getTeam } from "@formbricks/lib/team/service"; + +export default async function LanguageSettingsPage({ params }: { params: { environmentId: string } }) { + const product = await getProductByEnvironmentId(params.environmentId); + + if (!product) { + throw new Error("Product not found"); + } + + const team = await getTeam(product?.teamId); + + if (!team) { + throw new Error("Team not found"); + } + + const isMultiLanguageAllowed = getMultiLanguagePermission(team); + + if (!isMultiLanguageAllowed) { + notFound(); + } + + return ( +
+ + + + +
+ ); +} diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/layout.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/layout.tsx index 04d5e58bfb..58cc0a23dc 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/layout.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/layout.tsx @@ -1,6 +1,7 @@ import { Metadata } from "next"; import { getServerSession } from "next-auth"; +import { getMultiLanguagePermission } from "@formbricks/ee/lib/service"; import { authOptions } from "@formbricks/lib/authOptions"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service"; @@ -19,9 +20,11 @@ export default async function SettingsLayout({ children, params }) { getProductByEnvironmentId(params.environmentId), getServerSession(authOptions), ]); + if (!team) { throw new Error("Team not found"); } + if (!product) { throw new Error("Product not found"); } @@ -30,6 +33,8 @@ export default async function SettingsLayout({ children, params }) { throw new Error("Unauthenticated"); } + const isMultiLanguageAllowed = getMultiLanguagePermission(team); + const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id); return ( @@ -41,6 +46,7 @@ export default async function SettingsLayout({ children, params }) { team={team} product={product} membershipRole={currentUserMembership?.role} + isMultiLanguageAllowed={isMultiLanguageAllowed} />
diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/members/components/AddMemberModal.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/members/components/AddMemberModal.tsx index b5a2c475df..bacf738d7a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/members/components/AddMemberModal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/members/components/AddMemberModal.tsx @@ -89,7 +89,7 @@ export default function AddMemberModal({ ))}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx index 0307dd68ad..70c4249463 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx @@ -65,7 +65,7 @@ const ResponsePage = ({ const searchParams = useSearchParams(); survey = useMemo(() => { - return checkForRecallInHeadline(survey); + return checkForRecallInHeadline(survey, "default"); }, [survey]); const fetchNextPage = useCallback(async () => { @@ -133,7 +133,7 @@ const ResponsePage = ({ />
- +
{ - const session = await getServerSession(authOptions); - if (!session) throw new AuthorizationError("Not authorized"); - - const hasUserSurveyAccess = await canUserAccessSurvey(session.user.id, surveyId); - - if (!hasUserSurveyAccess) throw new AuthorizationError("Not authorized"); - - return generateSurveySingleUseId(isEncrypted); -} - export const sendEmailAction = async ({ html, subject, to }: TSendEmailActionArgs) => { const session = await getServerSession(authOptions); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary.tsx index 05c6db739f..776cb1ee02 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary.tsx @@ -2,6 +2,7 @@ import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId import { questionTypes } from "@/app/lib/questions"; import { InboxIcon } from "lucide-react"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { TSurveySummaryCta } from "@formbricks/types/responses"; import { ProgressBar } from "@formbricks/ui/ProgressBar"; @@ -15,7 +16,7 @@ export default function CTASummary({ questionSummary }: CTASummaryProps) { return (
- +
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary.tsx index 6f9d270bd6..770438565d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary.tsx @@ -2,6 +2,7 @@ import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId import { questionTypes } from "@/app/lib/questions"; import { InboxIcon } from "lucide-react"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { TSurveySummaryCal } from "@formbricks/types/responses"; import { ProgressBar } from "@formbricks/ui/ProgressBar"; @@ -16,7 +17,7 @@ export default function CalSummary({ questionSummary }: CalSummaryProps) { return (
- +
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx index 7ded55ce76..c2e8a9fe49 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx @@ -2,6 +2,7 @@ import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId import { questionTypes } from "@/app/lib/questions"; import { InboxIcon } from "lucide-react"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { TSurveySummaryConsent } from "@formbricks/types/responses"; import { ProgressBar } from "@formbricks/ui/ProgressBar"; @@ -15,7 +16,7 @@ export default function ConsentSummary({ questionSummary }: ConsentSummaryProps) return (
- +
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary.tsx index 6da94588ca..7dbe624ab7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/DateQuestionSummary.tsx @@ -3,6 +3,7 @@ import { questionTypes } from "@/app/lib/questions"; import { InboxIcon } from "lucide-react"; import Link from "next/link"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { getPersonIdentifier } from "@formbricks/lib/person/util"; import { timeSince } from "@formbricks/lib/time"; import { formatDateWithOrdinal } from "@formbricks/lib/utils/datetime"; @@ -20,8 +21,7 @@ export default function DateQuestionSummary({ questionSummary, environmentId }: return (
- - +
{questionTypeInfo && } diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx index 1b4c3c4070..5b7b015436 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary.tsx @@ -3,6 +3,7 @@ import { questionTypes } from "@/app/lib/questions"; import { DownloadIcon, FileIcon, InboxIcon } from "lucide-react"; import Link from "next/link"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { getPersonIdentifier } from "@formbricks/lib/person/util"; import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils"; import { timeSince } from "@formbricks/lib/time"; @@ -20,7 +21,7 @@ export default function FileUploadSummary({ questionSummary, environmentId }: Fi return (
- +
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx index 973479ca0a..a5c7f7afe1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx @@ -3,6 +3,7 @@ import { questionTypes } from "@/app/lib/questions"; import { InboxIcon } from "lucide-react"; import Link from "next/link"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { getPersonIdentifier } from "@formbricks/lib/person/util"; import { TSurveySummaryMultipleChoice } from "@formbricks/types/responses"; import { PersonAvatar } from "@formbricks/ui/Avatars"; @@ -33,7 +34,7 @@ export default function MultipleChoiceSummary({ return (
- +
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx index 37ad95c34c..4bbcd66124 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx @@ -2,6 +2,7 @@ import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId import { questionTypes } from "@/app/lib/questions"; import { InboxIcon } from "lucide-react"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { TSurveySummaryNps } from "@formbricks/types/responses"; import { HalfCircle, ProgressBar } from "@formbricks/ui/ProgressBar"; @@ -15,7 +16,7 @@ export default function NPSSummary({ questionSummary }: NPSSummaryProps) { return (
- +
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.tsx index baaff5817e..d47f447a8c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/OpenTextSummary.tsx @@ -3,6 +3,7 @@ import { questionTypes } from "@/app/lib/questions"; import { InboxIcon } from "lucide-react"; import Link from "next/link"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { getPersonIdentifier } from "@formbricks/lib/person/util"; import { timeSince } from "@formbricks/lib/time"; import { TSurveySummaryOpenText } from "@formbricks/types/responses"; @@ -19,7 +20,8 @@ export default function OpenTextSummary({ questionSummary, environmentId }: Open return (
- + +
{questionTypeInfo && } diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx index fe8e492f68..6e644a4a44 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx @@ -3,6 +3,7 @@ import { questionTypes } from "@/app/lib/questions"; import { InboxIcon } from "lucide-react"; import Image from "next/image"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { TSurveySummaryPictureSelection } from "@formbricks/types/responses"; import { ProgressBar } from "@formbricks/ui/ProgressBar"; @@ -19,7 +20,7 @@ export default function PictureChoiceSummary({ questionSummary }: PictureChoiceS return (
- +
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx index 2581dd82fe..efc1c23b6c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx @@ -3,6 +3,7 @@ import { questionTypes } from "@/app/lib/questions"; import { CircleSlash2, InboxIcon, SmileIcon, StarIcon } from "lucide-react"; import { useMemo } from "react"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { TSurveySummaryRating } from "@formbricks/types/responses"; import { ProgressBar } from "@formbricks/ui/ProgressBar"; import { RatingResponse } from "@formbricks/ui/RatingResponse"; @@ -24,7 +25,7 @@ export default function RatingSummary({ questionSummary }: RatingSummaryProps) { return (
- +
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx index 6ca3cc1e9f..453de03967 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey.tsx @@ -1,27 +1,16 @@ "use client"; -import { generateSingleUseIdAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions"; -import { - ArrowLeftIcon, - BellRing, - BlocksIcon, - Code2Icon, - CopyIcon, - LinkIcon, - MailIcon, - RefreshCcw, -} from "lucide-react"; +import { ArrowLeftIcon, BellRing, BlocksIcon, Code2Icon, LinkIcon, MailIcon } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useRef, useState } from "react"; -import toast from "react-hot-toast"; +import { useState } from "react"; import { cn } from "@formbricks/lib/cn"; -import { TProduct } from "@formbricks/types/product"; import { TSurvey } from "@formbricks/types/surveys"; import { TUser } from "@formbricks/types/user"; import { Button } from "@formbricks/ui/Button"; import { Dialog, DialogContent } from "@formbricks/ui/Dialog"; +import { ShareSurveyLink } from "@formbricks/ui/ShareSurveyLink"; import EmailTab from "./shareEmbedTabs/EmailTab"; import LinkTab from "./shareEmbedTabs/LinkTab"; @@ -32,7 +21,6 @@ interface ShareEmbedSurveyProps { open: boolean; setOpen: React.Dispatch>; webAppUrl: string; - product: TProduct; user: TUser; } export default function ShareEmbedSurvey({ survey, open, setOpen, webAppUrl, user }: ShareEmbedSurveyProps) { @@ -49,35 +37,8 @@ export default function ShareEmbedSurvey({ survey, open, setOpen, webAppUrl, use const [activeId, setActiveId] = useState(tabs[0].id); const [showInitialPage, setShowInitialPage] = useState(true); - const linkTextRef = useRef(null); const [surveyUrl, setSurveyUrl] = useState(""); - const getUrl = useCallback(async () => { - let url = webAppUrl + "/s/" + survey.id; - if (survey.singleUse?.enabled) { - const singleUseId = await generateSingleUseIdAction(survey.id, survey.singleUse.isEncrypted); - url += "?suId=" + singleUseId; - } - setSurveyUrl(url); - }, [survey, webAppUrl]); - - useEffect(() => { - getUrl(); - }, [survey, webAppUrl, getUrl]); - - const handleTextSelection = () => { - if (linkTextRef.current) { - const range = document.createRange(); - range.selectNodeContents(linkTextRef.current); - - const selection = window.getSelection(); - if (selection) { - selection.removeAllRanges(); - selection.addRange(range); - } - } - }; - const handleOpenChange = (open: boolean) => { setActiveId(tabs[0].id); setOpen(open); @@ -91,11 +52,6 @@ export default function ShareEmbedSurvey({ survey, open, setOpen, webAppUrl, use setShowInitialPage(!showInitialPage); }; - const generateNewSingleUseLink = () => { - getUrl(); - toast.success("New single use link generated"); - }; - return ( @@ -103,39 +59,12 @@ export default function ShareEmbedSurvey({ survey, open, setOpen, webAppUrl, use

Your survey is public 🎉

-
-
handleTextSelection()}> - {surveyUrl} -
-
- - {survey.singleUse?.enabled && ( - - )} -
-
+

What's next?

@@ -202,10 +131,10 @@ export default function ShareEmbedSurvey({ survey, open, setOpen, webAppUrl, use ) : activeId === "link" ? ( ) : null}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage.tsx index ea5e935270..19e6b964a1 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage.tsx @@ -5,7 +5,6 @@ import { useEffect, useState } from "react"; import toast from "react-hot-toast"; import { TEnvironment } from "@formbricks/types/environment"; -import { TProduct } from "@formbricks/types/product"; import { TSurvey } from "@formbricks/types/surveys"; import { TUser } from "@formbricks/types/user"; import { Confetti } from "@formbricks/ui/Confetti"; @@ -16,18 +15,10 @@ interface SummaryMetadataProps { environment: TEnvironment; survey: TSurvey; webAppUrl: string; - product: TProduct; user: TUser; - singleUseIds?: string[]; } -export default function SuccessMessage({ - environment, - survey, - webAppUrl, - product, - user, -}: SummaryMetadataProps) { +export default function SuccessMessage({ environment, survey, webAppUrl, user }: SummaryMetadataProps) { const searchParams = useSearchParams(); const [showLinkModal, setShowLinkModal] = useState(false); const [confetti, setConfetti] = useState(false); @@ -63,7 +54,6 @@ export default function SuccessMessage({ open={showLinkModal} setOpen={setShowLinkModal} webAppUrl={webAppUrl} - product={product} user={user} /> {confetti && } diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx index 403fedf035..adfd2aa644 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx @@ -2,6 +2,7 @@ import EmptyInAppSurveys from "@/app/(app)/environments/[environmentId]/surveys/ import CalSummary from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary"; import ConsentSummary from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary"; import HiddenFieldsSummary from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/HiddenFieldsSummary"; +import PictureChoiceSummary from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary"; import { TEnvironment } from "@formbricks/types/environment"; import { TSurveySummary } from "@formbricks/types/responses"; @@ -15,7 +16,6 @@ import FileUploadSummary from "./FileUploadSummary"; import MultipleChoiceSummary from "./MultipleChoiceSummary"; import NPSSummary from "./NPSSummary"; import OpenTextSummary from "./OpenTextSummary"; -import PictureChoiceSummary from "./PictureChoiceSummary"; import RatingSummary from "./RatingSummary"; interface SummaryListProps { diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx index fbc9d01243..b2430c3f5f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata.tsx @@ -103,7 +103,7 @@ export default function SummaryMetadata({
- Last updated: {timeSinceConditionally(survey.updatedAt.toISOString())} + Last updated: {timeSinceConditionally(survey.updatedAt.toString())}
- {isSingleUseLinkSurvey && ( - - )} -
-
+

You can do a lot more with links surveys 💡

diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.tsx index 54e8bbc07d..536c77c0be 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate.tsx @@ -15,6 +15,7 @@ import { CalendarDaysIcon } from "lucide-react"; import { cn } from "@formbricks/lib/cn"; import { WEBAPP_URL } from "@formbricks/lib/constants"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { getSurvey } from "@formbricks/lib/survey/service"; import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys"; @@ -50,17 +51,17 @@ export const getEmailTemplateHtml = async (surveyId) => { const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) => { const url = `${surveyUrl}?preview=true`; const urlWithPrefilling = `${surveyUrl}?preview=true&`; - + const defaultLanguageCode = "default"; const firstQuestion = survey.questions[0]; switch (firstQuestion.type) { case TSurveyQuestionType.OpenText: return ( - {firstQuestion.headline} + {getLocalizedValue(firstQuestion.headline, defaultLanguageCode)} - {firstQuestion.subheader} + {getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
@@ -70,14 +71,20 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) => return ( - {firstQuestion.headline} + {getLocalizedValue(firstQuestion.headline, defaultLanguageCode)} - + - {firstQuestion.label} + + {getLocalizedValue(firstQuestion.label, defaultLanguageCode)} + {!firstQuestion.required && ( @@ -104,10 +111,10 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) =>
- {firstQuestion.headline} + {getLocalizedValue(firstQuestion.headline, defaultLanguageCode)} - {firstQuestion.subheader} + {getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
@@ -123,10 +130,14 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) =>
- {firstQuestion.lowerLabel} + + {getLocalizedValue(firstQuestion.lowerLabel, defaultLanguageCode)} + - {firstQuestion.upperLabel} + + {getLocalizedValue(firstQuestion.upperLabel, defaultLanguageCode)} +
@@ -139,10 +150,14 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) => return ( - {firstQuestion.headline} + {getLocalizedValue(firstQuestion.headline, defaultLanguageCode)} - + @@ -150,7 +165,7 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) => - {firstQuestion.dismissButtonLabel || "Skip"} + {getLocalizedValue(firstQuestion.dismissButtonLabel, defaultLanguageCode) || "Skip"} )} "bg-brand-color inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium", isLight(brandColor) ? "text-black" : "text-white" )}> - {firstQuestion.buttonLabel} + {getLocalizedValue(firstQuestion.buttonLabel, defaultLanguageCode)} @@ -170,10 +185,10 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) =>
- {firstQuestion.headline} + {getLocalizedValue(firstQuestion.headline, defaultLanguageCode)} - {firstQuestion.subheader} + {getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
- {firstQuestion.lowerLabel} + + {getLocalizedValue(firstQuestion.lowerLabel, defaultLanguageCode)} + - {firstQuestion.upperLabel} + + {getLocalizedValue(firstQuestion.upperLabel, defaultLanguageCode)} +
@@ -221,17 +240,17 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) => return ( - {firstQuestion.headline} + {getLocalizedValue(firstQuestion.headline, defaultLanguageCode)} - {firstQuestion.subheader} + {getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)} {firstQuestion.choices.map((choice) => (
- {choice.label} + {getLocalizedValue(choice.label, defaultLanguageCode)}
))}
@@ -242,10 +261,10 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) => return ( - {firstQuestion.headline} + {getLocalizedValue(firstQuestion.headline, defaultLanguageCode)} - {firstQuestion.subheader} + {getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)} {firstQuestion.choices @@ -255,7 +274,7 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) => key={choice.id} className="mt-2 block rounded-lg border border-solid border-slate-200 bg-slate-50 p-4 text-slate-800 hover:bg-slate-100" href={`${urlWithPrefilling}${firstQuestion.id}=${choice.label}`}> - {choice.label} + {getLocalizedValue(choice.label, defaultLanguageCode)} ))} @@ -266,10 +285,10 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) => return ( - {firstQuestion.headline} + {getLocalizedValue(firstQuestion.headline, defaultLanguageCode)} - {firstQuestion.subheader} + {getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
{firstQuestion.choices.map((choice) => @@ -295,7 +314,7 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) => return ( - {firstQuestion.subheader} + {getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)} You have been invited to schedule a meet via cal.com Open Survey to continue{" "} @@ -307,10 +326,10 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) => return ( - {firstQuestion.headline} + {getLocalizedValue(firstQuestion.headline, defaultLanguageCode)} - {firstQuestion.subheader} + {getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx index d8b825b852..5bc3d477a2 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/page.tsx @@ -51,7 +51,6 @@ export default async function Page({ params }) { if (!team) { throw new Error("Team not found"); } - const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id); const tags = await getTagsByEnvironmentId(params.environmentId); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx index 613ae4e9f7..1ca8efe997 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx @@ -1,9 +1,11 @@ "use client"; +import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox"; import clsx from "clsx"; import { ChevronDown, ChevronUp, X } from "lucide-react"; import * as React from "react"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import useClickOutside from "@formbricks/lib/useClickOutside"; import { TSurveyQuestionType } from "@formbricks/types/surveys"; import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "@formbricks/ui/Command"; @@ -21,7 +23,7 @@ type QuestionFilterComboBoxProps = { filterComboBoxValue: string | string[] | undefined; onChangeFilterValue: (o: string) => void; onChangeFilterComboBoxValue: (o: string | string[]) => void; - type: TSurveyQuestionType | "Attributes" | "Tags" | undefined; + type: OptionsType.METADATA | TSurveyQuestionType | OptionsType.ATTRIBUTES | OptionsType.TAGS | undefined; handleRemoveMultiSelect: (value: string[]) => void; disabled?: boolean; }; @@ -40,6 +42,7 @@ const QuestionFilterComboBox = ({ const [open, setOpen] = React.useState(false); const [openFilterValue, setOpenFilterValue] = React.useState(false); const commandRef = React.useRef(null); + const defaultLanguageCode = "default"; useClickOutside(commandRef, () => setOpen(false)); // multiple when question type is multi selection @@ -150,14 +153,21 @@ const QuestionFilterComboBox = ({ { !isMultiple - ? onChangeFilterComboBoxValue(o) + ? onChangeFilterComboBoxValue( + typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o + ) : onChangeFilterComboBoxValue( - Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, o] : [o] + Array.isArray(filterComboBoxValue) + ? [ + ...filterComboBoxValue, + typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o, + ] + : [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o] ); !isMultiple && setOpen(false); }} className="cursor-pointer"> - {o} + {typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o} ))} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx index 24ab48c775..303a9b00ce 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx @@ -8,14 +8,16 @@ import { HashIcon, HelpCircleIcon, ImageIcon, + LanguagesIcon, ListIcon, MousePointerClickIcon, Rows3Icon, + SmartphoneIcon, StarIcon, - TagIcon, } from "lucide-react"; import * as React from "react"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import useClickOutside from "@formbricks/lib/useClickOutside"; import { TSurveyQuestionType } from "@formbricks/types/surveys"; import { @@ -32,6 +34,7 @@ export enum OptionsType { QUESTIONS = "Questions", TAGS = "Tags", ATTRIBUTES = "Attributes", + METADATA = "Metadata", } export type QuestionOption = { @@ -53,31 +56,37 @@ interface QuestionComboBoxProps { const SelectedCommandItem = ({ label, questionType, type }: Partial) => { const getIconType = () => { - if (type === OptionsType.QUESTIONS) { - switch (questionType) { - case TSurveyQuestionType.Rating: - return ; - case TSurveyQuestionType.CTA: - return ; - case TSurveyQuestionType.OpenText: - return ; - case TSurveyQuestionType.MultipleChoiceMulti: - return ; - case TSurveyQuestionType.MultipleChoiceSingle: - return ; - case TSurveyQuestionType.NPS: - return ; - case TSurveyQuestionType.Consent: - return ; - case TSurveyQuestionType.PictureSelection: - return ; - } - } - if (type === OptionsType.ATTRIBUTES) { - return ; - } - if (type === OptionsType.TAGS) { - return ; + switch (type) { + case OptionsType.QUESTIONS: + switch (questionType) { + case TSurveyQuestionType.Rating: + return ; + case TSurveyQuestionType.CTA: + return ; + case TSurveyQuestionType.OpenText: + return ; + case TSurveyQuestionType.MultipleChoiceMulti: + return ; + case TSurveyQuestionType.MultipleChoiceSingle: + return ; + case TSurveyQuestionType.NPS: + return ; + case TSurveyQuestionType.Consent: + return ; + case TSurveyQuestionType.PictureSelection: + return ; + } + case OptionsType.ATTRIBUTES: + return ; + case OptionsType.METADATA: + switch (label) { + case "Language": + return ; + case "Device Type": + return ; + } + case OptionsType.TAGS: + return ; } }; @@ -86,6 +95,8 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial {getIconType()} -

{label}

+

+ {typeof label === "string" ? label : getLocalizedValue(label, "default")} +

); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx index c284e198fd..8c389bd1dd 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter.tsx @@ -19,7 +19,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@formbricks/ui/Popover" import QuestionsComboBox, { OptionsType, QuestionOption } from "./QuestionsComboBox"; export type QuestionFilterOptions = { - type: TSurveyTSurveyQuestionType | "Attributes" | "Tags"; + type: TSurveyTSurveyQuestionType | "Attributes" | "Tags" | "Languages"; filterOptions: string[]; filterComboBoxOptions: string[]; id: string; @@ -200,7 +200,9 @@ const ResponseFilter = () => { key={`${s.questionType.id}-${i}`} filterOptions={ selectedOptions.questionFilterOptions.find( - (q) => q.type === s.questionType.type || q.type === s.questionType.questionType + (q) => + (q.type === s.questionType.questionType || q.type === s.questionType.type) && + q.id === s.questionType.id )?.filterOptions } filterComboBoxOptions={ @@ -240,15 +242,15 @@ const ResponseFilter = () => { ))}
-
- -
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton.tsx index 6f3c98699a..202824a1f3 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton.tsx @@ -10,7 +10,6 @@ import { DownloadIcon } from "lucide-react"; import { useEffect, useState } from "react"; import toast from "react-hot-toast"; -import { TProduct } from "@formbricks/types/product"; import { TSurvey } from "@formbricks/types/surveys"; import { TUser } from "@formbricks/types/user"; import { @@ -25,13 +24,11 @@ import ShareSurveyResults from "../(analysis)/summary/components/ShareSurveyResu interface ResultsShareButtonProps { survey: TSurvey; - className?: string; webAppUrl: string; - product: TProduct; user: TUser; } -export default function ResultsShareButton({ survey, webAppUrl, product, user }: ResultsShareButtonProps) { +export default function ResultsShareButton({ survey, webAppUrl, user }: ResultsShareButtonProps) { const [showLinkModal, setShowLinkModal] = useState(false); const [showResultsLinkModal, setShowResultsLinkModal] = useState(false); @@ -146,7 +143,6 @@ export default function ResultsShareButton({ survey, webAppUrl, product, user }: survey={survey} open={showLinkModal} setOpen={setShowLinkModal} - product={product} webAppUrl={webAppUrl} user={user} /> diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SummaryHeader.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SummaryHeader.tsx index 3c075bdf4f..6fb88a08d8 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SummaryHeader.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SummaryHeader.tsx @@ -104,13 +104,7 @@ const SummaryHeader = ({ {survey.type === "link" && ( <> - + )} @@ -189,19 +183,12 @@ const SummaryHeader = ({
- + {showShareSurveyModal && ( diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddQuestionButton.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddQuestionButton.tsx index f7115d4585..e07a53bdf4 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddQuestionButton.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddQuestionButton.tsx @@ -45,10 +45,10 @@ export default function AddQuestionButton({ addQuestion, product }: AddQuestionB className="mx-2 inline-flex items-center rounded p-0.5 px-4 py-2 font-medium text-slate-700 last:mb-2 hover:bg-slate-100 hover:text-slate-800" onClick={() => { addQuestion({ - id: createId(), - type: questionType.id, ...universalQuestionPresets, ...getQuestionDefaults(questionType.id, product), + id: createId(), + type: questionType.id, }); setOpen(false); }}> diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CTAQuestionForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CTAQuestionForm.tsx index 37b0a000e1..6c3db99413 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CTAQuestionForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CTAQuestionForm.tsx @@ -1,14 +1,12 @@ "use client"; -import { BackButtonInput } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard"; import { useState } from "react"; -import { md } from "@formbricks/lib/markdownIt"; +import { LocalizedEditor } from "@formbricks/ee/multiLanguage/components/LocalizedEditor"; import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys"; -import { Editor } from "@formbricks/ui/Editor"; import { Input } from "@formbricks/ui/Input"; import { Label } from "@formbricks/ui/Label"; -import QuestionFormInput from "@formbricks/ui/QuestionFormInput"; +import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput"; import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup"; interface CTAQuestionFormProps { @@ -17,6 +15,8 @@ interface CTAQuestionFormProps { questionIdx: number; updateQuestion: (questionIdx: number, updatedAttributes: any) => void; lastQuestion: boolean; + selectedLanguageCode: string; + setSelectedLanguageCode: (languageCode: string) => void; isInvalid: boolean; } @@ -27,38 +27,38 @@ export default function CTAQuestionForm({ lastQuestion, isInvalid, localSurvey, + selectedLanguageCode, + setSelectedLanguageCode, }: CTAQuestionFormProps): JSX.Element { const [firstRender, setFirstRender] = useState(true); - const environmentId = localSurvey.environmentId; return (
- - md.render( - question.html || "We would love to talk to you and learn more about how you use our product." - ) - } - setText={(value: string) => { - updateQuestion(questionIdx, { html: value }); - }} - excludedToolbarItems={["blockType"]} - disableLists +
@@ -82,24 +82,33 @@ export default function CTAQuestionForm({
-
+
-
- -
- updateQuestion(questionIdx, { buttonLabel: e.target.value })} - /> -
-
+ + {questionIdx !== 0 && ( - updateQuestion(questionIdx, { backButtonLabel: e.target.value })} + localSurvey={localSurvey} + questionIdx={questionIdx} + maxLength={48} + placeholder={"Back"} + isInvalid={isInvalid} + updateQuestion={updateQuestion} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} /> )}
@@ -124,12 +133,16 @@ export default function CTAQuestionForm({
- updateQuestion(questionIdx, { dismissButtonLabel: e.target.value })} + localSurvey={localSurvey} + questionIdx={questionIdx} + placeholder={"skip"} + isInvalid={isInvalid} + updateQuestion={updateQuestion} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} />
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CalQuestionForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CalQuestionForm.tsx index 353bd1244b..c1ca777b5e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CalQuestionForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CalQuestionForm.tsx @@ -1,11 +1,12 @@ import { PlusIcon, TrashIcon } from "lucide-react"; import { useState } from "react"; +import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils"; import { TSurvey, TSurveyCalQuestion } from "@formbricks/types/surveys"; import { Button } from "@formbricks/ui/Button"; import { Input } from "@formbricks/ui/Input"; import { Label } from "@formbricks/ui/Label"; -import QuestionFormInput from "@formbricks/ui/QuestionFormInput"; +import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput"; interface CalQuestionFormProps { localSurvey: TSurvey; @@ -13,6 +14,8 @@ interface CalQuestionFormProps { questionIdx: number; updateQuestion: (questionIdx: number, updatedAttributes: any) => void; lastQuestion: boolean; + selectedLanguageCode: string; + setSelectedLanguageCode: (language: string) => void; isInvalid: boolean; } @@ -21,43 +24,49 @@ export default function CalQuestionForm({ question, questionIdx, updateQuestion, + selectedLanguageCode, + setSelectedLanguageCode, isInvalid, }: CalQuestionFormProps): JSX.Element { const [showSubheader, setShowSubheader] = useState(!!question.subheader); + const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages); return (
{showSubheader && ( - <> -
+
+
- { - setShowSubheader(false); - updateQuestion(questionIdx, { subheader: "" }); - }} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} />
- + + { + setShowSubheader(false); + updateQuestion(questionIdx, { subheader: undefined }); + }} + /> +
)} {!showSubheader && ( diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConsentQuestionForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConsentQuestionForm.tsx index 29f68355f3..4a012a6ba3 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConsentQuestionForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConsentQuestionForm.tsx @@ -2,18 +2,18 @@ import { useState } from "react"; -import { md } from "@formbricks/lib/markdownIt"; +import { LocalizedEditor } from "@formbricks/ee/multiLanguage/components/LocalizedEditor"; import { TSurvey, TSurveyConsentQuestion } from "@formbricks/types/surveys"; -import { Editor } from "@formbricks/ui/Editor"; -import { Input } from "@formbricks/ui/Input"; import { Label } from "@formbricks/ui/Label"; -import QuestionFormInput from "@formbricks/ui/QuestionFormInput"; +import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput"; interface ConsentQuestionFormProps { localSurvey: TSurvey; question: TSurveyConsentQuestion; questionIdx: number; updateQuestion: (questionIdx: number, updatedAttributes: any) => void; + selectedLanguageCode: string; + setSelectedLanguageCode: (languageCode: string) => void; isInvalid: boolean; } @@ -23,65 +23,54 @@ export default function ConsentQuestionForm({ updateQuestion, isInvalid, localSurvey, + selectedLanguageCode, + setSelectedLanguageCode, }: ConsentQuestionFormProps): JSX.Element { const [firstRender, setFirstRender] = useState(true); - const environmentId = localSurvey.environmentId; return (
- - md.render( - question.html || "We would love to talk to you and learn more about how you use our product." - ) - } - setText={(value: string) => { - updateQuestion(questionIdx, { html: value }); - }} - excludedToolbarItems={["blockType"]} - disableLists +
-
- - updateQuestion(questionIdx, { label: e.target.value })} - isInvalid={isInvalid && question.label.trim() === ""} - /> -
- {/*
- - updateQuestion(questionIdx, { buttonLabel: e.target.value })} - /> -
*/} + ); } diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/DateQuestionForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/DateQuestionForm.tsx index 9d3eae26b1..4def47f06d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/DateQuestionForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/DateQuestionForm.tsx @@ -1,10 +1,11 @@ import { PlusIcon, TrashIcon } from "lucide-react"; import { useState } from "react"; +import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils"; import { TSurvey, TSurveyDateQuestion } from "@formbricks/types/surveys"; import { Button } from "@formbricks/ui/Button"; import { Label } from "@formbricks/ui/Label"; -import QuestionFormInput from "@formbricks/ui/QuestionFormInput"; +import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput"; import { OptionsSwitcher } from "@formbricks/ui/QuestionTypeSelector"; interface IDateQuestionFormProps { @@ -13,6 +14,8 @@ interface IDateQuestionFormProps { questionIdx: number; updateQuestion: (questionIdx: number, updatedAttributes: any) => void; lastQuestion: boolean; + selectedLanguageCode: string; + setSelectedLanguageCode: (language: string) => void; isInvalid: boolean; } @@ -37,42 +40,48 @@ export default function DateQuestionForm({ updateQuestion, isInvalid, localSurvey, + selectedLanguageCode, + setSelectedLanguageCode, }: IDateQuestionFormProps): JSX.Element { const [showSubheader, setShowSubheader] = useState(!!question.subheader); + const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages); return (
{showSubheader && ( - <> -
+
+
- { - setShowSubheader(false); - updateQuestion(questionIdx, { subheader: "" }); - }} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} />
- + + { + setShowSubheader(false); + updateQuestion(questionIdx, { subheader: undefined }); + }} + /> +
)} {!showSubheader && ( @@ -81,7 +90,13 @@ export default function DateQuestionForm({ className="mt-3" variant="minimal" type="button" - onClick={() => setShowSubheader(true)}> + onClick={() => { + updateQuestion(questionIdx, { + subheader: createI18nString("", surveyLanguageCodes), + }); + setShowSubheader(true); + }}> + {" "} Add Description diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditThankYouCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditThankYouCard.tsx index eedc4359ef..555ae68dae 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditThankYouCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditThankYouCard.tsx @@ -4,10 +4,11 @@ import * as Collapsible from "@radix-ui/react-collapsible"; import { useState } from "react"; import { cn } from "@formbricks/lib/cn"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { TSurvey } from "@formbricks/types/surveys"; import { Input } from "@formbricks/ui/Input"; import { Label } from "@formbricks/ui/Label"; -import QuestionFormInput from "@formbricks/ui/QuestionFormInput"; +import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput"; import { Switch } from "@formbricks/ui/Switch"; interface EditThankYouCardProps { @@ -15,6 +16,9 @@ interface EditThankYouCardProps { setLocalSurvey: (survey: TSurvey) => void; setActiveQuestionId: (id: string | null) => void; activeQuestionId: string | null; + isInvalid: boolean; + selectedLanguageCode: string; + setSelectedLanguageCode: (languageCode: string) => void; } export default function EditThankYouCard({ @@ -22,11 +26,16 @@ export default function EditThankYouCard({ setLocalSurvey, setActiveQuestionId, activeQuestionId, + isInvalid, + selectedLanguageCode, + setSelectedLanguageCode, }: EditThankYouCardProps) { // const [open, setOpen] = useState(false); let open = activeQuestionId == "end"; const [showThankYouCardCTA, setshowThankYouCardCTA] = useState( - localSurvey.thankYouCard.buttonLabel || localSurvey.thankYouCard.buttonLink ? true : false + getLocalizedValue(localSurvey.thankYouCard.buttonLabel, "default") || localSurvey.thankYouCard.buttonLink + ? true + : false ); const setOpen = (e) => { if (e) { @@ -37,13 +46,14 @@ export default function EditThankYouCard({ }; const updateSurvey = (data) => { - setLocalSurvey({ + const updatedSurvey = { ...localSurvey, thankYouCard: { ...localSurvey.thankYouCard, ...data, }, - }); + }; + setLocalSurvey(updatedSurvey); }; return ( @@ -54,8 +64,9 @@ export default function EditThankYouCard({ )}>

🙏

@@ -97,28 +108,26 @@ export default function EditThankYouCard({ -
-
- -
-
+
{showThankYouCardCTA && ( -
+
- - updateSurvey({ buttonLabel: e.target.value })} + localSurvey={localSurvey} + questionIdx={localSurvey.questions.length} + isInvalid={isInvalid} + updateSurvey={updateSurvey} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} />
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditWelcomeCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditWelcomeCard.tsx index 36e841808e..6b024cfecc 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditWelcomeCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditWelcomeCard.tsx @@ -4,13 +4,12 @@ import * as Collapsible from "@radix-ui/react-collapsible"; import { usePathname } from "next/navigation"; import { useState } from "react"; +import { LocalizedEditor } from "@formbricks/ee/multiLanguage/components/LocalizedEditor"; import { cn } from "@formbricks/lib/cn"; -import { md } from "@formbricks/lib/markdownIt"; import { TSurvey } from "@formbricks/types/surveys"; -import { Editor } from "@formbricks/ui/Editor"; import FileInput from "@formbricks/ui/FileInput"; -import { Input } from "@formbricks/ui/Input"; import { Label } from "@formbricks/ui/Label"; +import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput"; import { Switch } from "@formbricks/ui/Switch"; interface EditWelcomeCardProps { @@ -18,6 +17,9 @@ interface EditWelcomeCardProps { setLocalSurvey: (survey: TSurvey) => void; setActiveQuestionId: (id: string | null) => void; activeQuestionId: string | null; + isInvalid: boolean; + selectedLanguageCode: string; + setSelectedLanguageCode: (languageCode: string) => void; } export default function EditWelcomeCard({ @@ -25,6 +27,9 @@ export default function EditWelcomeCard({ setLocalSurvey, setActiveQuestionId, activeQuestionId, + isInvalid, + selectedLanguageCode, + setSelectedLanguageCode, }: EditWelcomeCardProps) { const [firstRender, setFirstRender] = useState(true); const path = usePathname(); @@ -59,8 +64,9 @@ export default function EditWelcomeCard({ )}>

@@ -84,7 +90,7 @@ export default function EditWelcomeCard({
- +
- -
- { - updateSurvey({ headline: e.target.value }); - }} - /> -
+
- - md.render( - localSurvey?.welcomeCard?.html || "Thanks for providing your feedback - let's go!" - ) - } - setText={(value: string) => { - updateSurvey({ html: value }); - }} - excludedToolbarItems={["blockType"]} - disableLists +
@@ -149,15 +153,18 @@ export default function EditWelcomeCard({
- -
- updateSurvey({ buttonLabel: e.target.value })} - /> -
+
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm.tsx index eadca1aa52..cbfc420345 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm.tsx @@ -4,6 +4,8 @@ import { PlusIcon, TrashIcon, XCircleIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { toast } from "react-hot-toast"; +import { extractLanguageCodes } from "@formbricks/lib/i18n/utils"; +import { createI18nString } from "@formbricks/lib/i18n/utils"; import { useGetBillingInfo } from "@formbricks/lib/team/hooks/useGetBillingInfo"; import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/common"; import { TProduct } from "@formbricks/types/product"; @@ -11,7 +13,7 @@ import { TSurvey, TSurveyFileUploadQuestion } from "@formbricks/types/surveys"; import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle"; import { Button } from "@formbricks/ui/Button"; import { Input } from "@formbricks/ui/Input"; -import QuestionFormInput from "@formbricks/ui/QuestionFormInput"; +import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput"; interface FileUploadFormProps { localSurvey: TSurvey; @@ -20,6 +22,8 @@ interface FileUploadFormProps { questionIdx: number; updateQuestion: (questionIdx: number, updatedAttributes: any) => void; lastQuestion: boolean; + selectedLanguageCode: string; + setSelectedLanguageCode: (languageCode: string) => void; isInvalid: boolean; } @@ -30,6 +34,8 @@ export default function FileUploadQuestionForm({ updateQuestion, isInvalid, product, + selectedLanguageCode, + setSelectedLanguageCode, }: FileUploadFormProps): JSX.Element { const [showSubheader, setShowSubheader] = useState(!!question.subheader); const [extension, setExtension] = useState(""); @@ -38,6 +44,7 @@ export default function FileUploadQuestionForm({ error: billingInfoError, isLoading: billingInfoLoading, } = useGetBillingInfo(product?.teamId ?? ""); + const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages); const handleInputChange = (event) => { setExtension(event.target.value); @@ -103,41 +110,42 @@ export default function FileUploadQuestionForm({ return 10; }, [billingInfo, billingInfoError, billingInfoLoading]); - const environmentId = localSurvey.environmentId; - return (
{showSubheader && ( - <> -
+
+
- { - setShowSubheader(false); - updateQuestion(questionIdx, { subheader: "" }); - }} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} />
- + + { + setShowSubheader(false); + updateQuestion(questionIdx, { subheader: undefined }); + }} + /> +
)} {!showSubheader && ( diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard.tsx index 0728228297..94c83ed9df 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard.tsx @@ -74,7 +74,9 @@ const HiddenFieldsCard: FC = ({
- + = ({ ); }) ) : ( -

No hidden fields yet. Add the first one below.

+

+ No hidden fields yet. Add the first one below. +

)}
q.id === field) !== -1 ) { return "Question not allowed"; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LoadingSkeleton.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LoadingSkeleton.tsx new file mode 100644 index 0000000000..64a1bdd5d8 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LoadingSkeleton.tsx @@ -0,0 +1,25 @@ +export const LoadingSkeleton = () => ( +
+ {/* Top Part - Loading Navbar */} +
+ + {/* Bottom Part - Divided into Left and Right */} +
+ {/* Left Part - 7 Horizontal Bars */} +
+
+
+
+
+
+
+
+
+ + {/* Right Part - Simple Box */} +
+
+
+
+
+); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor.tsx index 85af84ff73..0c5a8c004a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor.tsx @@ -4,6 +4,7 @@ import { useMemo } from "react"; import { toast } from "react-hot-toast"; import { BsArrowDown, BsArrowReturnRight } from "react-icons/bs"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall"; import { TSurvey, @@ -46,12 +47,12 @@ export default function LogicEditor({ updateQuestion, }: LogicEditorProps): JSX.Element { localSurvey = useMemo(() => { - return checkForRecallInHeadline(localSurvey); + return checkForRecallInHeadline(localSurvey, "default"); }, [localSurvey]); const questionValues = useMemo(() => { if ("choices" in question) { - return question.choices.map((choice) => choice.label); + return question.choices.map((choice) => getLocalizedValue(choice.label, "default")); } else if ("range" in question) { return Array.from({ length: question.range ? question.range : 0 }, (_, i) => (i + 1).toString()); } else if (question.type === TSurveyQuestionType.NPS) { @@ -238,7 +239,7 @@ export default function LogicEditor({ }; const deleteLogic = (logicIdx: number) => { - const updatedLogic = !question.logic ? [] : JSON.parse(JSON.stringify(question.logic)); + const updatedLogic = !question.logic ? [] : structuredClone(question.logic); updatedLogic.splice(logicIdx, 1); updateQuestion(questionIdx, { logic: updatedLogic }); }; @@ -348,9 +349,14 @@ export default function LogicEditor({ {localSurvey.questions.map( (question, idx) => idx !== questionIdx && ( - -
-

{question.headline}

+ +
+

+ {getLocalizedValue(question.headline, "default")} +

) diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceMultiForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceMultiForm.tsx index 364abbcc15..eb48b707f5 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceMultiForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceMultiForm.tsx @@ -3,13 +3,18 @@ import { createId } from "@paralleldrive/cuid2"; import { PlusIcon, TrashIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { toast } from "react-hot-toast"; -import { cn } from "@formbricks/lib/cn"; -import { TSurvey, TSurveyMultipleChoiceMultiQuestion } from "@formbricks/types/surveys"; +import { + createI18nString, + extractLanguageCodes, + isLabelValidForAllLanguages, +} from "@formbricks/lib/i18n/utils"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; +import { TI18nString, TSurvey, TSurveyMultipleChoiceMultiQuestion } from "@formbricks/types/surveys"; import { Button } from "@formbricks/ui/Button"; -import { Input } from "@formbricks/ui/Input"; import { Label } from "@formbricks/ui/Label"; -import QuestionFormInput from "@formbricks/ui/QuestionFormInput"; +import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select"; interface OpenQuestionFormProps { @@ -18,6 +23,8 @@ interface OpenQuestionFormProps { questionIdx: number; updateQuestion: (questionIdx: number, updatedAttributes: any) => void; lastQuestion: boolean; + selectedLanguageCode: string; + setSelectedLanguageCode: (languageCode: string) => void; isInvalid: boolean; } @@ -27,12 +34,15 @@ export default function MultipleChoiceMultiForm({ updateQuestion, isInvalid, localSurvey, + selectedLanguageCode, + setSelectedLanguageCode, }: OpenQuestionFormProps): JSX.Element { const lastChoiceRef = useRef(null); const [isNew, setIsNew] = useState(true); const [showSubheader, setShowSubheader] = useState(!!question.subheader); const questionRef = useRef(null); const [isInvalidValue, setisInvalidValue] = useState(null); + const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages); const shuffleOptionsTypes = { none: { @@ -52,8 +62,8 @@ export default function MultipleChoiceMultiForm({ }, }; - const updateChoice = (choiceIdx: number, updatedAttributes: { label: string }) => { - const newLabel = updatedAttributes.label; + const updateChoice = (choiceIdx: number, updatedAttributes: { label: TI18nString }) => { + const newLabel = updatedAttributes.label.en; const oldLabel = question.choices[choiceIdx].label; let newChoices: any[] = []; if (question.choices) { @@ -67,9 +77,11 @@ export default function MultipleChoiceMultiForm({ question.logic?.forEach((logic) => { let newL: string | string[] | undefined = logic.value; if (Array.isArray(logic.value)) { - newL = logic.value.map((value) => (value === oldLabel ? newLabel : value)); + newL = logic.value.map((value) => + newLabel && value === oldLabel[selectedLanguageCode] ? newLabel : value + ); } else { - newL = logic.value === oldLabel ? newLabel : logic.value; + newL = logic.value === oldLabel[selectedLanguageCode] ? newLabel : logic.value; } newLogic.push({ ...logic, value: newL }); }); @@ -79,21 +91,17 @@ export default function MultipleChoiceMultiForm({ const findDuplicateLabel = () => { for (let i = 0; i < question.choices.length; i++) { for (let j = i + 1; j < question.choices.length; j++) { - if (question.choices[i].label.trim() === question.choices[j].label.trim()) { - return question.choices[i].label.trim(); // Return the duplicate label + if ( + getLocalizedValue(question.choices[i].label, selectedLanguageCode).trim() === + getLocalizedValue(question.choices[j].label, selectedLanguageCode).trim() + ) { + return getLocalizedValue(question.choices[i].label, selectedLanguageCode).trim(); // Return the duplicate label } } } return null; }; - const findEmptyLabel = () => { - for (let i = 0; i < question.choices.length; i++) { - if (question.choices[i].label.trim() === "") return true; - } - return false; - }; - const addChoice = (choiceIdx?: number) => { setIsNew(false); // This question is no longer new. let newChoices = !question.choices ? [] : question.choices; @@ -101,7 +109,10 @@ export default function MultipleChoiceMultiForm({ if (otherChoice) { newChoices = newChoices.filter((choice) => choice.id !== "other"); } - const newChoice = { id: createId(), label: "" }; + const newChoice = { + id: createId(), + label: createI18nString("", surveyLanguageCodes), + }; if (choiceIdx !== undefined) { newChoices.splice(choiceIdx + 1, 0, newChoice); } else { @@ -116,7 +127,10 @@ export default function MultipleChoiceMultiForm({ const addOther = () => { if (question.choices.filter((c) => c.id === "other").length === 0) { const newChoices = !question.choices ? [] : question.choices.filter((c) => c.id !== "other"); - newChoices.push({ id: "other", label: "Other" }); + newChoices.push({ + id: "other", + label: createI18nString("Other", surveyLanguageCodes), + }); updateQuestion(questionIdx, { choices: newChoices, ...(question.shuffleOption === shuffleOptionsTypes.all.id && { @@ -129,7 +143,7 @@ export default function MultipleChoiceMultiForm({ const deleteChoice = (choiceIdx: number) => { const newChoices = !question.choices ? [] : question.choices.filter((_, idx) => idx !== choiceIdx); - const choiceValue = question.choices[choiceIdx].label; + const choiceValue = question.choices[choiceIdx].label[selectedLanguageCode]; if (isInvalidValue === choiceValue) { setisInvalidValue(null); } @@ -160,43 +174,43 @@ export default function MultipleChoiceMultiForm({ } }, [isNew]); - const environmentId = localSurvey.environmentId; - return (
{showSubheader && ( - <> -
+
+
- { - setShowSubheader(false); - updateQuestion(questionIdx, { subheader: "" }); - }} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} />
- + + { + setShowSubheader(false); + updateQuestion(questionIdx, { subheader: undefined }); + }} + /> +
)} {!showSubheader && ( @@ -213,45 +233,55 @@ export default function MultipleChoiceMultiForm({
-
+
{question.choices && question.choices.map((choice, choiceIdx) => (
-
- + updateChoice(choiceIdx, { label: e.target.value })} + questionIdx={questionIdx} + value={choice.label} onBlur={() => { const duplicateLabel = findDuplicateLabel(); if (duplicateLabel) { + toast.error("Duplicate choices"); setisInvalidValue(duplicateLabel); - } else if (findEmptyLabel()) { - setisInvalidValue(""); } else { setisInvalidValue(null); } }} + updateChoice={updateChoice} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} isInvalid={ - (isInvalidValue === "" && choice.label.trim() === "") || - (isInvalidValue !== null && choice.label.trim() === isInvalidValue.trim()) + isInvalid && + !isLabelValidForAllLanguages(question.choices[choiceIdx].label, surveyLanguageCodes) } + className={`${choice.id === "other" ? "border border-dashed" : ""}`} /> {choice.id === "other" && ( - { - if (e.target.value.trim() == "") e.target.value = ""; - updateQuestion(questionIdx, { otherOptionPlaceholder: e.target.value }); - }} + )}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceSingleForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceSingleForm.tsx index 28ef507134..2d9a9d71ef 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceSingleForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceSingleForm.tsx @@ -1,15 +1,17 @@ "use client"; +import { isLabelValidForAllLanguages } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/Validation"; import { createId } from "@paralleldrive/cuid2"; import { PlusIcon, TrashIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { toast } from "react-hot-toast"; -import { cn } from "@formbricks/lib/cn"; -import { TSurvey, TSurveyMultipleChoiceSingleQuestion } from "@formbricks/types/surveys"; +import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; +import { TI18nString, TSurvey, TSurveyMultipleChoiceSingleQuestion } from "@formbricks/types/surveys"; import { Button } from "@formbricks/ui/Button"; -import { Input } from "@formbricks/ui/Input"; import { Label } from "@formbricks/ui/Label"; -import QuestionFormInput from "@formbricks/ui/QuestionFormInput"; +import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select"; interface OpenQuestionFormProps { @@ -18,6 +20,8 @@ interface OpenQuestionFormProps { questionIdx: number; updateQuestion: (questionIdx: number, updatedAttributes: any) => void; lastQuestion: boolean; + selectedLanguageCode: string; + setSelectedLanguageCode: (language: string) => void; isInvalid: boolean; } @@ -27,13 +31,16 @@ export default function MultipleChoiceSingleForm({ updateQuestion, isInvalid, localSurvey, + selectedLanguageCode, + setSelectedLanguageCode, }: OpenQuestionFormProps): JSX.Element { const lastChoiceRef = useRef(null); const [isNew, setIsNew] = useState(true); const [showSubheader, setShowSubheader] = useState(!!question.subheader); const [isInvalidValue, setisInvalidValue] = useState(null); const questionRef = useRef(null); - + const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages); + const surveyLanguages = localSurvey.languages ?? []; const shuffleOptionsTypes = { none: { id: "none", @@ -55,23 +62,19 @@ export default function MultipleChoiceSingleForm({ const findDuplicateLabel = () => { for (let i = 0; i < question.choices.length; i++) { for (let j = i + 1; j < question.choices.length; j++) { - if (question.choices[i].label.trim() === question.choices[j].label.trim()) { - return question.choices[i].label.trim(); // Return the duplicate label + if ( + getLocalizedValue(question.choices[i].label, selectedLanguageCode).trim() === + getLocalizedValue(question.choices[j].label, selectedLanguageCode).trim() + ) { + return getLocalizedValue(question.choices[i].label, selectedLanguageCode).trim(); // Return the duplicate label } } } return null; }; - const findEmptyLabel = () => { - for (let i = 0; i < question.choices.length; i++) { - if (question.choices[i].label.trim() === "") return true; - } - return false; - }; - - const updateChoice = (choiceIdx: number, updatedAttributes: { label: string }) => { - const newLabel = updatedAttributes.label; + const updateChoice = (choiceIdx: number, updatedAttributes: { label: TI18nString }) => { + const newLabel = updatedAttributes.label.en; const oldLabel = question.choices[choiceIdx].label; let newChoices: any[] = []; if (question.choices) { @@ -87,7 +90,7 @@ export default function MultipleChoiceSingleForm({ if (Array.isArray(logic.value)) { newL = logic.value.map((value) => (value === oldLabel ? newLabel : value)); } else { - newL = logic.value === oldLabel ? newLabel : logic.value; + newL = logic.value === getLocalizedValue(oldLabel, selectedLanguageCode) ? newLabel : logic.value; } newLogic.push({ ...logic, value: newL }); }); @@ -101,7 +104,10 @@ export default function MultipleChoiceSingleForm({ if (otherChoice) { newChoices = newChoices.filter((choice) => choice.id !== "other"); } - const newChoice = { id: createId(), label: "" }; + const newChoice = { + id: createId(), + label: createI18nString("", surveyLanguageCodes), + }; if (choiceIdx !== undefined) { newChoices.splice(choiceIdx + 1, 0, newChoice); } else { @@ -116,7 +122,10 @@ export default function MultipleChoiceSingleForm({ const addOther = () => { if (question.choices.filter((c) => c.id === "other").length === 0) { const newChoices = !question.choices ? [] : question.choices.filter((c) => c.id !== "other"); - newChoices.push({ id: "other", label: "Other" }); + newChoices.push({ + id: "other", + label: createI18nString("Other", surveyLanguageCodes), + }); updateQuestion(questionIdx, { choices: newChoices, ...(question.shuffleOption === shuffleOptionsTypes.all.id && { @@ -128,8 +137,7 @@ export default function MultipleChoiceSingleForm({ const deleteChoice = (choiceIdx: number) => { const newChoices = !question.choices ? [] : question.choices.filter((_, idx) => idx !== choiceIdx); - - const choiceValue = question.choices[choiceIdx].label; + const choiceValue = question.choices[choiceIdx].label[selectedLanguageCode]; if (isInvalidValue === choiceValue) { setisInvalidValue(null); } @@ -160,43 +168,43 @@ export default function MultipleChoiceSingleForm({ } }, [isNew]); - const environmentId = localSurvey.environmentId; - return (
{showSubheader && ( - <> -
+
+
- { - setShowSubheader(false); - updateQuestion(questionIdx, { subheader: "" }); - }} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} />
- + + { + setShowSubheader(false); + updateQuestion(questionIdx, { subheader: undefined }); + }} + /> +
)} {!showSubheader && ( @@ -213,45 +227,55 @@ export default function MultipleChoiceSingleForm({
-
+
{question.choices && question.choices.map((choice, choiceIdx) => ( -
+
- { const duplicateLabel = findDuplicateLabel(); if (duplicateLabel) { + toast.error("Duplicate choices"); setisInvalidValue(duplicateLabel); - } else if (findEmptyLabel()) { - setisInvalidValue(""); } else { setisInvalidValue(null); } }} - onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })} + updateChoice={updateChoice} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} isInvalid={ - (isInvalidValue === "" && choice.label.trim() === "") || - (isInvalidValue !== null && choice.label.trim() === isInvalidValue.trim()) + isInvalid && + !isLabelValidForAllLanguages(question.choices[choiceIdx].label, surveyLanguages) } + className={`${choice.id === "other" ? "border border-dashed" : ""}`} /> {choice.id === "other" && ( - { - if (e.target.value.trim() == "") e.target.value = ""; - updateQuestion(questionIdx, { otherOptionPlaceholder: e.target.value }); - }} + )}
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/NPSQuestionForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/NPSQuestionForm.tsx index 53e488d711..36a4a14951 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/NPSQuestionForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/NPSQuestionForm.tsx @@ -3,11 +3,10 @@ import { PlusIcon, TrashIcon } from "lucide-react"; import { useState } from "react"; +import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils"; import { TSurvey, TSurveyNPSQuestion } from "@formbricks/types/surveys"; import { Button } from "@formbricks/ui/Button"; -import { Input } from "@formbricks/ui/Input"; -import { Label } from "@formbricks/ui/Label"; -import QuestionFormInput from "@formbricks/ui/QuestionFormInput"; +import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput"; interface NPSQuestionFormProps { localSurvey: TSurvey; @@ -15,6 +14,8 @@ interface NPSQuestionFormProps { questionIdx: number; updateQuestion: (questionIdx: number, updatedAttributes: any) => void; lastQuestion: boolean; + selectedLanguageCode: string; + setSelectedLanguageCode: (languageCode: string) => void; isInvalid: boolean; } @@ -25,44 +26,48 @@ export default function NPSQuestionForm({ lastQuestion, isInvalid, localSurvey, + selectedLanguageCode, + setSelectedLanguageCode, }: NPSQuestionFormProps): JSX.Element { const [showSubheader, setShowSubheader] = useState(!!question.subheader); - const environmentId = localSurvey.environmentId; - + const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages); return (
{showSubheader && ( - <> -
+
+
- { - setShowSubheader(false); - updateQuestion(questionIdx, { subheader: "" }); - }} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} />
- + + { + setShowSubheader(false); + updateQuestion(questionIdx, { subheader: undefined }); + }} + /> +
)} {!showSubheader && ( )}
-
-
- -
- updateQuestion(questionIdx, { lowerLabel: e.target.value })} - /> -
+
+
+
-
- -
- updateQuestion(questionIdx, { upperLabel: e.target.value })} - /> -
+
+
{!question.required && (
- -
- updateQuestion(questionIdx, { buttonLabel: e.target.value })} - /> -
+
)} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/OpenQuestionForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/OpenQuestionForm.tsx index 68e62dd393..a97234b1d3 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/OpenQuestionForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/OpenQuestionForm.tsx @@ -11,15 +11,15 @@ import { } from "lucide-react"; import { useState } from "react"; +import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils"; import { TSurvey, TSurveyOpenTextQuestion, TSurveyOpenTextQuestionInputType, } from "@formbricks/types/surveys"; import { Button } from "@formbricks/ui/Button"; -import { Input } from "@formbricks/ui/Input"; import { Label } from "@formbricks/ui/Label"; -import QuestionFormInput from "@formbricks/ui/QuestionFormInput"; +import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput"; import { OptionsSwitcher } from "@formbricks/ui/QuestionTypeSelector"; const questionTypes = [ @@ -36,6 +36,8 @@ interface OpenQuestionFormProps { questionIdx: number; updateQuestion: (questionIdx: number, updatedAttributes: any) => void; lastQuestion: boolean; + selectedLanguageCode: string; + setSelectedLanguageCode: (language: string) => void; isInvalid: boolean; } @@ -45,55 +47,58 @@ export default function OpenQuestionForm({ updateQuestion, isInvalid, localSurvey, + selectedLanguageCode, + setSelectedLanguageCode, }: OpenQuestionFormProps): JSX.Element { const [showSubheader, setShowSubheader] = useState(!!question.subheader); const defaultPlaceholder = getPlaceholderByInputType(question.inputType ?? "text"); - + const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages ?? []); const handleInputChange = (inputType: TSurveyOpenTextQuestionInputType) => { const updatedAttributes = { inputType: inputType, - placeholder: getPlaceholderByInputType(inputType), + placeholder: createI18nString(getPlaceholderByInputType(inputType), surveyLanguageCodes), longAnswer: inputType === "text" ? question.longAnswer : false, }; updateQuestion(questionIdx, updatedAttributes); }; - const environmentId = localSurvey.environmentId; - return (
{showSubheader && ( - <> -
+
+
- { - setShowSubheader(false); - updateQuestion(questionIdx, { subheader: "" }); - }} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} />
- + + { + setShowSubheader(false); + updateQuestion(questionIdx, { subheader: undefined }); + }} + /> +
)} {!showSubheader && ( )}
- -
- -
- updateQuestion(questionIdx, { placeholder: e.target.value })} - /> -
+
+
{/* Add a dropdown to select the question type */} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm.tsx index 366a385f6b..49cdf56a40 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm.tsx @@ -3,11 +3,12 @@ import { PlusIcon, TrashIcon } from "lucide-react"; import { useState } from "react"; import { cn } from "@formbricks/lib/cn"; +import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils"; import { TSurvey, TSurveyPictureSelectionQuestion } from "@formbricks/types/surveys"; import { Button } from "@formbricks/ui/Button"; import FileInput from "@formbricks/ui/FileInput"; import { Label } from "@formbricks/ui/Label"; -import QuestionFormInput from "@formbricks/ui/QuestionFormInput"; +import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput"; import { Switch } from "@formbricks/ui/Switch"; interface PictureSelectionFormProps { @@ -16,6 +17,8 @@ interface PictureSelectionFormProps { questionIdx: number; updateQuestion: (questionIdx: number, updatedAttributes: any) => void; lastQuestion: boolean; + selectedLanguageCode: string; + setSelectedLanguageCode: (language: string) => void; isInvalid: boolean; } @@ -24,44 +27,50 @@ export default function PictureSelectionForm({ question, questionIdx, updateQuestion, + selectedLanguageCode, + setSelectedLanguageCode, isInvalid, }: PictureSelectionFormProps): JSX.Element { const [showSubheader, setShowSubheader] = useState(!!question.subheader); const environmentId = localSurvey.environmentId; + const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages); return (
{showSubheader && ( - <> -
+
+
- { - setShowSubheader(false); - updateQuestion(questionIdx, { subheader: "" }); - }} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} />
- + + { + setShowSubheader(false); + updateQuestion(questionIdx, { subheader: undefined }); + }} + /> +
)} {!showSubheader && ( diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx index 0b613e858c..51ca917e2c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionCard.tsx @@ -26,9 +26,9 @@ import { Draggable } from "react-beautiful-dnd"; import { cn } from "@formbricks/lib/cn"; import { recallToHeadline } from "@formbricks/lib/utils/recall"; import { TProduct } from "@formbricks/types/product"; -import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys"; -import { Input } from "@formbricks/ui/Input"; +import { TI18nString, TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys"; import { Label } from "@formbricks/ui/Label"; +import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput"; import { Switch } from "@formbricks/ui/Switch"; import CTAQuestionForm from "./CTAQuestionForm"; @@ -44,7 +44,7 @@ import RatingQuestionForm from "./RatingQuestionForm"; interface QuestionCardProps { localSurvey: TSurvey; - product?: TProduct; + product: TProduct; questionIdx: number; moveQuestion: (questionIndex: number, up: boolean) => void; updateQuestion: (questionIdx: number, updatedAttributes: any) => void; @@ -53,35 +53,11 @@ interface QuestionCardProps { activeQuestionId: string | null; setActiveQuestionId: (questionId: string | null) => void; lastQuestion: boolean; + selectedLanguageCode: string; + setSelectedLanguageCode: (language: string) => void; isInvalid: boolean; } -export function BackButtonInput({ - value, - onChange, - className, -}: { - value: string | undefined; - onChange: (e: any) => void; - className?: string; -}) { - return ( -
- -
- -
-
- ); -} - export default function QuestionCard({ localSurvey, product, @@ -93,6 +69,8 @@ export default function QuestionCard({ activeQuestionId, setActiveQuestionId, lastQuestion, + selectedLanguageCode, + setSelectedLanguageCode, isInvalid, }: QuestionCardProps) { const question = localSurvey.questions[questionIdx]; @@ -118,10 +96,10 @@ export default function QuestionCard({ }); }; - const updateEmptyNextButtonLabels = (labelValue: string) => { + const updateEmptyNextButtonLabels = (labelValue: TI18nString) => { localSurvey.questions.forEach((q, index) => { if (index === localSurvey.questions.length - 1) return; - if (!q.buttonLabel || q.buttonLabel?.trim() === "") { + if (!q.buttonLabel || q.buttonLabel[selectedLanguageCode]?.trim() === "") { updateQuestion(index, { buttonLabel: labelValue }); } }); @@ -188,8 +166,14 @@ export default function QuestionCard({

- {recallToHeadline(question.headline, localSurvey, true) - ? formatTextWithSlashes(recallToHeadline(question.headline, localSurvey, true)) + {recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[ + selectedLanguageCode + ] + ? formatTextWithSlashes( + recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[ + selectedLanguageCode + ] ?? "" + ) : getTSurveyQuestionTypeName(question.type)}

{!open && question?.required && ( @@ -219,6 +203,8 @@ export default function QuestionCard({ questionIdx={questionIdx} updateQuestion={updateQuestion} lastQuestion={lastQuestion} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} isInvalid={isInvalid} /> ) : question.type === TSurveyQuestionType.MultipleChoiceSingle ? ( @@ -228,6 +214,8 @@ export default function QuestionCard({ questionIdx={questionIdx} updateQuestion={updateQuestion} lastQuestion={lastQuestion} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} isInvalid={isInvalid} /> ) : question.type === TSurveyQuestionType.MultipleChoiceMulti ? ( @@ -237,6 +225,8 @@ export default function QuestionCard({ questionIdx={questionIdx} updateQuestion={updateQuestion} lastQuestion={lastQuestion} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} isInvalid={isInvalid} /> ) : question.type === TSurveyQuestionType.NPS ? ( @@ -246,6 +236,8 @@ export default function QuestionCard({ questionIdx={questionIdx} updateQuestion={updateQuestion} lastQuestion={lastQuestion} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} isInvalid={isInvalid} /> ) : question.type === TSurveyQuestionType.CTA ? ( @@ -255,6 +247,8 @@ export default function QuestionCard({ questionIdx={questionIdx} updateQuestion={updateQuestion} lastQuestion={lastQuestion} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} isInvalid={isInvalid} /> ) : question.type === TSurveyQuestionType.Rating ? ( @@ -264,6 +258,8 @@ export default function QuestionCard({ questionIdx={questionIdx} updateQuestion={updateQuestion} lastQuestion={lastQuestion} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} isInvalid={isInvalid} /> ) : question.type === TSurveyQuestionType.Consent ? ( @@ -272,6 +268,8 @@ export default function QuestionCard({ question={question} questionIdx={questionIdx} updateQuestion={updateQuestion} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} isInvalid={isInvalid} /> ) : question.type === TSurveyQuestionType.Date ? ( @@ -281,6 +279,8 @@ export default function QuestionCard({ questionIdx={questionIdx} updateQuestion={updateQuestion} lastQuestion={lastQuestion} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} isInvalid={isInvalid} /> ) : question.type === TSurveyQuestionType.PictureSelection ? ( @@ -290,6 +290,8 @@ export default function QuestionCard({ questionIdx={questionIdx} updateQuestion={updateQuestion} lastQuestion={lastQuestion} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} isInvalid={isInvalid} /> ) : question.type === TSurveyQuestionType.FileUpload ? ( @@ -300,6 +302,8 @@ export default function QuestionCard({ questionIdx={questionIdx} updateQuestion={updateQuestion} lastQuestion={lastQuestion} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} isInvalid={isInvalid} /> ) : question.type === TSurveyQuestionType.Cal ? ( @@ -309,6 +313,8 @@ export default function QuestionCard({ questionIdx={questionIdx} updateQuestion={updateQuestion} lastQuestion={lastQuestion} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} isInvalid={isInvalid} /> ) : null} @@ -327,34 +333,43 @@ export default function QuestionCard({ {question.type !== TSurveyQuestionType.NPS && question.type !== TSurveyQuestionType.Rating && question.type !== TSurveyQuestionType.CTA ? ( -
+
- -
- { - updateQuestion(questionIdx, { buttonLabel: e.target.value }); - }} - onBlur={(e) => { - //If it is the last question then do not update labels - if (questionIdx === localSurvey.questions.length - 1) return; - updateEmptyNextButtonLabels(e.target.value); - }} - /> -
+ { + if (!question.buttonLabel) return; + let translatedNextButtonLabel = { + ...question.buttonLabel, + [selectedLanguageCode]: e.target.value, + }; + + if (questionIdx === localSurvey.questions.length - 1) return; + updateEmptyNextButtonLabels(translatedNextButtonLabel); + }} + />
{questionIdx !== 0 && ( - { - if (e.target.value.trim() == "") e.target.value = ""; - updateQuestion(questionIdx, { backButtonLabel: e.target.value }); - }} + localSurvey={localSurvey} + questionIdx={questionIdx} + maxLength={48} + placeholder={"Back"} + isInvalid={isInvalid} + updateQuestion={updateQuestion} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} /> )}
@@ -363,12 +378,17 @@ export default function QuestionCard({ question.type === TSurveyQuestionType.NPS) && questionIdx !== 0 && (
- { - if (e.target.value.trim() == "") e.target.value = ""; - updateQuestion(questionIdx, { backButtonLabel: e.target.value }); - }} + localSurvey={localSurvey} + questionIdx={questionIdx} + maxLength={48} + placeholder={"Back"} + isInvalid={isInvalid} + updateQuestion={updateQuestion} + selectedLanguageCode={selectedLanguageCode} + setSelectedLanguageCode={setSelectedLanguageCode} />
)} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsSettingsTabs.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsSettingsTabs.tsx index 305e088eba..2442c09759 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsSettingsTabs.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsSettingsTabs.tsx @@ -28,7 +28,7 @@ interface QuestionsAudienceTabsProps { export default function QuestionsAudienceTabs({ activeId, setActiveId }: QuestionsAudienceTabsProps) { return ( -
+
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/TemplateContainer.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/TemplateContainer.tsx index 510db20320..b2d93a81e0 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/TemplateContainer.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/TemplateContainer.tsx @@ -77,6 +77,7 @@ export default function TemplateContainerWithPreview({ product={product} environment={environment} setActiveQuestionId={setActiveQuestionId} + languageCode={"default"} onFileUpload={async (file) => file.name} />
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts index c746a87010..33ac95807a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/templates/templates.ts @@ -5,18 +5,18 @@ import { TSurveyCTAQuestion, TSurveyDisplayOption, TSurveyHiddenFields, + TSurveyOpenTextQuestion, TSurveyQuestionType, TSurveyStatus, TSurveyType, - TSurveyWelcomeCard, } from "@formbricks/types/surveys"; import { TTemplate } from "@formbricks/types/templates"; const thankYouCardDefault = { enabled: true, - headline: "Thank you!", - subheader: "We appreciate your feedback.", - buttonLabel: "Create your own Survey", + headline: { default: "Thank you!" }, + subheader: { default: "We appreciate your feedback." }, + buttonLabel: { default: "Create your own Survey" }, buttonLink: "https://formbricks.com/signup", }; @@ -25,11 +25,11 @@ const hiddenFieldsDefault: TSurveyHiddenFields = { fieldIds: [], }; -const welcomeCardDefault: TSurveyWelcomeCard = { +const welcomeCardDefault = { enabled: false, - headline: "Welcome!", - html: "Thanks for providing your feedback - let's go!", - timeToFinish: true, + headline: { default: "Welcome!" }, + html: { default: "Thanks for providing your feedback - let's go!" }, + timeToFinish: false, showResponseCount: false, }; @@ -43,8 +43,8 @@ export const testTemplate: TTemplate = { { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "This is a required open text question", - subheader: "Please enter some text:", + headline: { default: "This is an open text question" }, + subheader: { default: "Please enter some text:" }, required: true, inputType: "text", longAnswer: false, @@ -52,26 +52,18 @@ export const testTemplate: TTemplate = { { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "This is an optional open text question", - subheader: "Please enter some text:", + headline: { default: "This is an open text question" }, + subheader: { default: "Please enter some text:" }, required: false, inputType: "text", longAnswer: false, }, + { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "This is an optional open text question (long)", - subheader: "Please enter some text:", - required: false, - inputType: "text", - longAnswer: true, - }, - { - id: createId(), - type: TSurveyQuestionType.OpenText, - headline: "This is a required open text question (email)", - subheader: "Please enter an email", + headline: { default: "This is an open text question" }, + subheader: { default: "Please enter an email" }, required: true, inputType: "email", longAnswer: false, @@ -79,8 +71,8 @@ export const testTemplate: TTemplate = { { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "This is an optional open text question (email)", - subheader: "Please enter an email", + headline: { default: "This is an open text question" }, + subheader: { default: "Please enter an email" }, required: false, inputType: "email", longAnswer: false, @@ -88,8 +80,8 @@ export const testTemplate: TTemplate = { { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "This is a required open text question (number)", - subheader: "Please enter a number", + headline: { default: "This is an open text question" }, + subheader: { default: "Please enter a number" }, required: true, inputType: "number", longAnswer: false, @@ -97,8 +89,8 @@ export const testTemplate: TTemplate = { { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "This is an optional open text question (number)", - subheader: "Please enter a number", + headline: { default: "This is an open text question" }, + subheader: { default: "Please enter a number" }, required: false, inputType: "number", longAnswer: false, @@ -106,8 +98,8 @@ export const testTemplate: TTemplate = { { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "This is a required open text question (phone)", - subheader: "Please enter a phone number", + headline: { default: "This is an open text question" }, + subheader: { default: "Please enter a phone number" }, required: true, inputType: "phone", longAnswer: false, @@ -115,8 +107,8 @@ export const testTemplate: TTemplate = { { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "This is an optional open text question (phone)", - subheader: "Please enter a phone number", + headline: { default: "This is an open text question" }, + subheader: { default: "Please enter a phone number" }, required: false, inputType: "phone", longAnswer: false, @@ -124,8 +116,8 @@ export const testTemplate: TTemplate = { { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "This is a required open text question (url)", - subheader: "Please enter a url", + headline: { default: "This is an open text question" }, + subheader: { default: "Please enter a url" }, required: true, inputType: "url", longAnswer: false, @@ -133,8 +125,8 @@ export const testTemplate: TTemplate = { { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "This is an optional open text question (url)", - subheader: "Please enter a url", + headline: { default: "This is an open text question" }, + subheader: { default: "Please enter a url" }, required: false, inputType: "url", longAnswer: false, @@ -142,161 +134,162 @@ export const testTemplate: TTemplate = { { id: createId(), type: TSurveyQuestionType.MultipleChoiceSingle, - headline: "This ia a Multiple choice Single question", - subheader: "Please select one of the following", + headline: { default: "This ia a Multiple choice Single question" }, + subheader: { default: "Please select one of the following" }, required: true, shuffleOption: "none", choices: [ { id: createId(), - label: "Option1", + label: { default: "Option1" }, }, { id: createId(), - label: "Option2", + label: { default: "Option2" }, }, ], }, { id: createId(), type: TSurveyQuestionType.MultipleChoiceSingle, - headline: "This ia a Multiple choice Single question", - subheader: "Please select one of the following", + headline: { default: "This ia a Multiple choice Single question" }, + subheader: { default: "Please select one of the following" }, required: false, shuffleOption: "none", choices: [ { id: createId(), - label: "Option 1", + label: { default: "Option 1" }, }, { id: createId(), - label: "Option 2", + label: { default: "Option 2" }, }, ], }, { id: createId(), type: TSurveyQuestionType.MultipleChoiceMulti, - headline: "This ia a Multiple choice Multiple question", - subheader: "Please select some from the following", + headline: { default: "This ia a Multiple choice Multiple question" }, + subheader: { default: "Please select some from the following" }, required: true, shuffleOption: "none", choices: [ { id: createId(), - label: "Option1", + label: { default: "Option1" }, }, { id: createId(), - label: "Option2", + label: { default: "Option2" }, }, ], }, { id: createId(), type: TSurveyQuestionType.MultipleChoiceMulti, - headline: "This ia a Multiple choice Multiple question", - subheader: "Please select some from the following", + headline: { default: "This ia a Multiple choice Multiple question" }, + subheader: { default: "Please select some from the following" }, required: false, shuffleOption: "none", choices: [ { id: createId(), - label: "Option1", + label: { default: "Option1" }, }, { id: createId(), - label: "Option2", + label: { default: "Option2" }, }, ], }, { id: createId(), type: TSurveyQuestionType.Rating, - headline: "This is a rating question", + headline: { default: "This is a rating question" }, required: true, - lowerLabel: "Low", - upperLabel: "High", + lowerLabel: { default: "Low" }, + upperLabel: { default: "High" }, range: 5, scale: "number", }, { id: createId(), type: TSurveyQuestionType.Rating, - headline: "This is a rating question", + headline: { default: "This is a rating question" }, required: false, - lowerLabel: "Low", - upperLabel: "High", + lowerLabel: { default: "Low" }, + upperLabel: { default: "High" }, range: 5, scale: "number", }, { id: createId(), type: TSurveyQuestionType.Rating, - headline: "This is a rating question", + headline: { default: "This is a rating question" }, required: true, - lowerLabel: "Low", - upperLabel: "High", + lowerLabel: { default: "Low" }, + upperLabel: { default: "High" }, range: 5, scale: "smiley", }, { id: createId(), type: TSurveyQuestionType.Rating, - headline: "This is a rating question", + headline: { default: "This is a rating question" }, required: false, - lowerLabel: "Low", - upperLabel: "High", + lowerLabel: { default: "Low" }, + upperLabel: { default: "High" }, range: 5, scale: "smiley", }, { id: createId(), type: TSurveyQuestionType.Rating, - headline: "This is a rating question", + headline: { default: "This is a rating question" }, required: true, - lowerLabel: "Low", - upperLabel: "High", + lowerLabel: { default: "Low" }, + upperLabel: { default: "High" }, range: 5, scale: "star", }, { id: createId(), type: TSurveyQuestionType.Rating, - headline: "This is a rating question", + headline: { default: "This is a rating question" }, required: false, - lowerLabel: "Low", - upperLabel: "High", + lowerLabel: { default: "Low" }, + upperLabel: { default: "High" }, range: 5, scale: "star", }, + { id: createId(), type: TSurveyQuestionType.CTA, - headline: "This is a CTA question", - html: "This is a test CTA", - buttonLabel: "Click", + headline: { default: "This is a CTA question" }, + html: { default: "This is a test CTA" }, + buttonLabel: { default: "Click" }, buttonUrl: "https://formbricks.com", buttonExternal: true, required: true, - dismissButtonLabel: "Maybe later", + dismissButtonLabel: { default: "Maybe later" }, }, { id: createId(), type: TSurveyQuestionType.CTA, - headline: "This is a CTA question", - html: "This is a test CTA", - buttonLabel: "Click", + headline: { default: "This is a CTA question" }, + html: { default: "This is a test CTA" }, + buttonLabel: { default: "Click" }, buttonUrl: "https://formbricks.com", buttonExternal: true, required: false, - dismissButtonLabel: "Maybe later", + dismissButtonLabel: { default: "Maybe later" }, }, { id: createId(), type: TSurveyQuestionType.PictureSelection, - headline: "This is a Picture select", + headline: { default: "This is a Picture select" }, allowMulti: true, required: true, choices: [ @@ -313,7 +306,7 @@ export const testTemplate: TTemplate = { { id: createId(), type: TSurveyQuestionType.PictureSelection, - headline: "This is a Picture select", + headline: { default: "This is a Picture select" }, allowMulti: true, required: false, choices: [ @@ -330,26 +323,22 @@ export const testTemplate: TTemplate = { { id: createId(), type: TSurveyQuestionType.Consent, - headline: "This is a Consent question", + headline: { default: "This is a Consent question" }, required: true, - label: "I agree to the terms and conditions", + label: { default: "I agree to the terms and conditions" }, dismissButtonLabel: "Skip", }, { id: createId(), type: TSurveyQuestionType.Consent, - headline: "This is a Consent question", + headline: { default: "This is a Consent question" }, required: false, - label: "I agree to the terms and conditions", + label: { default: "I agree to the terms and conditions" }, dismissButtonLabel: "Skip", }, ], thankYouCard: thankYouCardDefault, - welcomeCard: { - enabled: false, - timeToFinish: false, - showResponseCount: false, - }, + welcomeCard: welcomeCardDefault, hiddenFields: { enabled: false, }, @@ -368,86 +357,89 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - html: '

We would love to understand your user experience better. Sharing your insight helps a lot!

', + html: { + default: + '

We would love to understand your user experience better. Sharing your insight helps a lot!

', + }, type: TSurveyQuestionType.CTA, logic: [{ condition: "skipped", destination: "end" }], - headline: "You are one of our power users! Do you have 5 minutes?", + headline: { default: "You are one of our power users! Do you have 5 minutes?" }, required: false, - buttonLabel: "Happy to help!", + buttonLabel: { default: "Happy to help!" }, buttonExternal: false, - dismissButtonLabel: "No, thanks.", + dismissButtonLabel: { default: "No, thanks." }, }, { id: createId(), type: TSurveyQuestionType.MultipleChoiceSingle, - headline: "How disappointed would you be if you could no longer use {{productName}}?", - subheader: "Please select one of the following options:", + headline: { default: "How disappointed would you be if you could no longer use {{productName}}?" }, + subheader: { default: "Please select one of the following options:" }, required: true, shuffleOption: "none", choices: [ { id: createId(), - label: "Not at all disappointed", + label: { default: "Not at all disappointed" }, }, { id: createId(), - label: "Somewhat disappointed", + label: { default: "Somewhat disappointed" }, }, { id: createId(), - label: "Very disappointed", + label: { default: "Very disappointed" }, }, ], }, { id: createId(), type: TSurveyQuestionType.MultipleChoiceSingle, - headline: "What is your role?", - subheader: "Please select one of the following options:", + headline: { default: "What is your role?" }, + subheader: { default: "Please select one of the following options:" }, required: true, shuffleOption: "none", choices: [ { id: createId(), - label: "Founder", + label: { default: "Founder" }, }, { id: createId(), - label: "Executive", + label: { default: "Executive" }, }, { id: createId(), - label: "Product Manager", + label: { default: "Product Manager" }, }, { id: createId(), - label: "Product Owner", + label: { default: "Product Owner" }, }, { id: createId(), - label: "Software Engineer", + label: { default: "Software Engineer" }, }, ], }, { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "What type of people do you think would most benefit from {{productName}}?", + headline: { default: "What type of people do you think would most benefit from {{productName}}?" }, required: true, inputType: "text", }, { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "What is the main benefit you receive from {{productName}}?", + headline: { default: "What is the main benefit you receive from {{productName}}?" }, required: true, inputType: "text", }, { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "How can we improve {{productName}} for you?", - subheader: "Please be as specific as possible.", + headline: { default: "How can we improve {{productName}} for you?" }, + subheader: { default: "Please be as specific as possible." }, required: true, inputType: "text", }, @@ -468,90 +460,90 @@ export const templates: TTemplate[] = [ { id: createId(), type: TSurveyQuestionType.MultipleChoiceSingle, - headline: "What is your role?", - subheader: "Please select one of the following options:", + headline: { default: "What is your role?" }, + subheader: { default: "Please select one of the following options:" }, required: true, shuffleOption: "none", choices: [ { id: createId(), - label: "Founder", + label: { default: "Founder" }, }, { id: createId(), - label: "Executive", + label: { default: "Executive" }, }, { id: createId(), - label: "Product Manager", + label: { default: "Product Manager" }, }, { id: createId(), - label: "Product Owner", + label: { default: "Product Owner" }, }, { id: createId(), - label: "Software Engineer", + label: { default: "Software Engineer" }, }, ], }, { id: createId(), type: TSurveyQuestionType.MultipleChoiceSingle, - headline: "What's your company size?", - subheader: "Please select one of the following options:", + headline: { default: "What's your company size?" }, + subheader: { default: "Please select one of the following options:" }, required: true, shuffleOption: "none", choices: [ { id: createId(), - label: "only me", + label: { default: "only me" }, }, { id: createId(), - label: "1-5 employees", + label: { default: "1-5 employees" }, }, { id: createId(), - label: "6-10 employees", + label: { default: "6-10 employees" }, }, { id: createId(), - label: "11-100 employees", + label: { default: "11-100 employees" }, }, { id: createId(), - label: "over 100 employees", + label: { default: "over 100 employees" }, }, ], }, { id: createId(), type: TSurveyQuestionType.MultipleChoiceSingle, - headline: "How did you hear about us first?", - subheader: "Please select one of the following options:", + headline: { default: "How did you hear about us first?" }, + subheader: { default: "Please select one of the following options:" }, required: true, shuffleOption: "none", choices: [ { id: createId(), - label: "Recommendation", + label: { default: "Recommendation" }, }, { id: createId(), - label: "Social Media", + label: { default: "Social Media" }, }, { id: createId(), - label: "Ads", + label: { default: "Ads" }, }, { id: createId(), - label: "Google Search", + label: { default: "Google Search" }, }, { id: createId(), - label: "In a Podcast", + label: { default: "In a Podcast" }, }, ], }, @@ -576,72 +568,69 @@ export const templates: TTemplate[] = [ logic: [ { value: "Difficult to use", condition: "equals", destination: "sxwpskjgzzpmkgfxzi15inif" }, { value: "It's too expensive", condition: "equals", destination: "mao94214zoo6c1at5rpuz7io" }, - { - value: "I am missing features", - condition: "equals", - destination: "l054desub14syoie7n202vq4", - }, - { - value: "Poor customer service", - condition: "equals", - destination: "hdftsos1odzjllr7flj4m3j9", - }, + { value: "I am missing features", condition: "equals", destination: "l054desub14syoie7n202vq4" }, + { value: "Poor customer service", condition: "equals", destination: "hdftsos1odzjllr7flj4m3j9" }, { value: "I just didn't need it anymore", condition: "equals", destination: "end" }, ], choices: [ - { id: createId(), label: "Difficult to use" }, - { id: createId(), label: "It's too expensive" }, - { id: createId(), label: "I am missing features" }, - { id: createId(), label: "Poor customer service" }, - { id: createId(), label: "I just didn't need it anymore" }, + { id: createId(), label: { default: "Difficult to use" } }, + { id: createId(), label: { default: "It's too expensive" } }, + { id: createId(), label: { default: "I am missing features" } }, + { id: createId(), label: { default: "Poor customer service" } }, + { id: createId(), label: { default: "I just didn't need it anymore" } }, ], - headline: "Why did you cancel your subscription?", + headline: { default: "Why did you cancel your subscription?" }, required: true, - subheader: "We're sorry to see you leave. Help us do better:", + subheader: { default: "We're sorry to see you leave. Help us do better:" }, }, { id: "sxwpskjgzzpmkgfxzi15inif", type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], - headline: "What would have made {{productName}} easier to use?", + headline: { default: "What would have made {{productName}} easier to use?" }, required: true, - subheader: "", - buttonLabel: "Send", + subheader: { default: "" }, + buttonLabel: { default: "Send" }, inputType: "text", }, { id: "mao94214zoo6c1at5rpuz7io", - html: '

We\'d love to keep you as a customer. Happy to offer a 30% discount for the next year.

', + html: { + default: + '

We\'d love to keep you as a customer. Happy to offer a 30% discount for the next year.

', + }, type: TSurveyQuestionType.CTA, logic: [{ condition: "clicked", destination: "end" }], - headline: "Get 30% off for the next year!", + headline: { default: "Get 30% off for the next year!" }, required: true, buttonUrl: "https://formbricks.com", - buttonLabel: "Get 30% off", + buttonLabel: { default: "Get 30% off" }, buttonExternal: true, - dismissButtonLabel: "Skip", + dismissButtonLabel: { default: "Skip" }, }, { id: "l054desub14syoie7n202vq4", type: TSurveyQuestionType.OpenText, - logic: [{ condition: "submitted", destination: "end" }], - headline: "What features are you missing?", + headline: { default: "What features are you missing?" }, required: true, - subheader: "", + subheader: { default: "" }, inputType: "text", }, { id: "hdftsos1odzjllr7flj4m3j9", - html: '

We aim to provide the best possible customer service. Please email our CEO and she will personally handle your issue.

', + html: { + default: + '

We aim to provide the best possible customer service. Please email our CEO and she will personally handle your issue.

', + }, type: TSurveyQuestionType.CTA, logic: [{ condition: "clicked", destination: "end" }], - headline: "So sorry to hear 😔 Talk to our CEO directly!", + headline: { default: "So sorry to hear 😔 Talk to our CEO directly!" }, required: true, buttonUrl: "mailto:ceo@company.com", - buttonLabel: "Send email to CEO", + buttonLabel: { default: "Send email to CEO" }, buttonExternal: true, - dismissButtonLabel: "Skip", + dismissButtonLabel: { default: "Skip" }, }, ], thankYouCard: thankYouCardDefault, @@ -664,28 +653,28 @@ export const templates: TTemplate[] = [ logic: [{ value: "No", condition: "equals", destination: "duz2qp8eftix9wty1l221x1h" }], shuffleOption: "none", choices: [ - { id: createId(), label: "Yes" }, - { id: createId(), label: "No" }, + { id: createId(), label: { default: "Yes" } }, + { id: createId(), label: { default: "No" } }, ], - headline: "Have you actively recommended {{productName}} to others?", + headline: { default: "Have you actively recommended {{productName}} to others?" }, required: true, - subheader: "", + subheader: { default: "" }, }, { id: createId(), type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "yhfew1j3ng6luy7t7qynwj79" }], - headline: "Great to hear! Why did you recommend us?", + headline: { default: "Great to hear! Why did you recommend us?" }, required: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "duz2qp8eftix9wty1l221x1h", type: TSurveyQuestionType.OpenText, - headline: "So sad. Why not?", + headline: { default: "So sad. Why not?" }, required: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { @@ -694,19 +683,19 @@ export const templates: TTemplate[] = [ logic: [{ value: "No", condition: "equals", destination: "end" }], shuffleOption: "none", choices: [ - { id: createId(), label: "Yes" }, - { id: createId(), label: "No" }, + { id: createId(), label: { default: "Yes" } }, + { id: createId(), label: { default: "No" } }, ], - headline: "Have you actively discouraged others from choosing {{productName}}?", + headline: { default: "Have you actively discouraged others from choosing {{productName}}?" }, required: true, - subheader: "", + subheader: { default: "" }, }, { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "What made you discourage them?", + headline: { default: "What made you discourage them?" }, required: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, ], @@ -751,54 +740,57 @@ export const templates: TTemplate[] = [ { value: "I was just looking around", condition: "equals", destination: "end" }, ], choices: [ - { id: createId(), label: "I didn't get much value out of it" }, - { id: createId(), label: "I expected something else" }, - { id: createId(), label: "It's too expensive for what it does" }, - { id: createId(), label: "I am missing a feature" }, - { id: createId(), label: "I was just looking around" }, + { id: createId(), label: { default: "I didn't get much value out of it" } }, + { id: createId(), label: { default: "I expected something else" } }, + { id: createId(), label: { default: "It's too expensive for what it does" } }, + { id: createId(), label: { default: "I am missing a feature" } }, + { id: createId(), label: { default: "I was just looking around" } }, ], - headline: "Why did you stop your trial?", + headline: { default: "Why did you stop your trial?" }, required: true, - subheader: "Help us understand you better:", + subheader: { default: "Help us understand you better:" }, }, { id: "aew2ymg51mffnt9db7duz9t3", type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "bqiyml1ym74ggx6htwdo7rlu" }], - headline: "Sorry to hear. What was the biggest problem using {{productName}}?", + headline: { default: "Sorry to hear. What was the biggest problem using {{productName}}?" }, required: true, - buttonLabel: "Next", + buttonLabel: { default: "Next" }, inputType: "text", }, { id: "rnrfydttavtsf2t2nfx1df7m", type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "bqiyml1ym74ggx6htwdo7rlu" }], - headline: "What did you expect {{productName}} would do for you?", + headline: { default: "What did you expect {{productName}} would do for you?" }, required: true, - buttonLabel: "Next", + buttonLabel: { default: "Next" }, inputType: "text", }, { id: "x760wga1fhtr1i80cpssr7af", - html: '

We\'re happy to offer you a 20% discount on a yearly plan.

', + html: { + default: + '

We\'re happy to offer you a 20% discount on a yearly plan.

', + }, type: TSurveyQuestionType.CTA, logic: [{ condition: "clicked", destination: "end" }], - headline: "Sorry to hear! Get 20% off the first year.", + headline: { default: "Sorry to hear! Get 20% off the first year." }, required: true, buttonUrl: "https://formbricks.com/github", - buttonLabel: "Get 20% off", + buttonLabel: { default: "Get 20% off" }, buttonExternal: true, - dismissButtonLabel: "Skip", + dismissButtonLabel: { default: "Skip" }, }, { id: "rbhww1pix03r6sl4xc511wqg", type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "bqiyml1ym74ggx6htwdo7rlu" }], - headline: "Which features are you missing?", + headline: { default: "Which features are you missing?" }, required: true, - subheader: "What would you like to achieve?", - buttonLabel: "Next", + subheader: { default: "What would you like to achieve?" }, + buttonLabel: { default: "Next" }, inputType: "text", }, { @@ -808,9 +800,9 @@ export const templates: TTemplate[] = [ { condition: "submitted", destination: "end" }, { condition: "skipped", destination: "end" }, ], - headline: "How are you solving your problem now?", + headline: { default: "How are you solving your problem now?" }, required: false, - subheader: "Please name alternative solutions:", + subheader: { default: "Please name alternative solutions:" }, inputType: "text", }, ], @@ -834,31 +826,31 @@ export const templates: TTemplate[] = [ logic: [{ value: 3, condition: "lessEqual", destination: "tk9wpw2gxgb8fa6pbpp3qq5l" }], range: 5, scale: "star", - headline: "How do you like {{productName}}?", + headline: { default: "How do you like {{productName}}?" }, required: true, - subheader: "", - lowerLabel: "Not good", - upperLabel: "Very satisfied", + subheader: { default: "" }, + lowerLabel: { default: "Not good" }, + upperLabel: { default: "Very satisfied" }, }, { id: createId(), - html: '

This helps us a lot.

', + html: { default: '

This helps us a lot.

' }, type: TSurveyQuestionType.CTA, logic: [{ condition: "clicked", destination: "end" }], - headline: "Happy to hear 🙏 Please write a review for us!", + headline: { default: "Happy to hear 🙏 Please write a review for us!" }, required: true, buttonUrl: "https://formbricks.com/github", - buttonLabel: "Write review", + buttonLabel: { default: "Write review" }, buttonExternal: true, }, { id: "tk9wpw2gxgb8fa6pbpp3qq5l", type: TSurveyQuestionType.OpenText, - headline: "Sorry to hear! What is ONE thing we can do better?", + headline: { default: "Sorry to hear! What is ONE thing we can do better?" }, required: true, - subheader: "Help us improve your experience.", - buttonLabel: "Send", - placeholder: "Type your answer here...", + subheader: { default: "Help us improve your experience." }, + buttonLabel: { default: "Send" }, + placeholder: { default: "Type your answer here..." }, inputType: "text", }, ], @@ -879,9 +871,9 @@ export const templates: TTemplate[] = [ { id: createId(), type: TSurveyQuestionType.CTA, - headline: "Do you have 15 min to talk to us? 🙏", - html: "You're one of our power users. We would love to interview you briefly!", - buttonLabel: "Book slot", + headline: { default: "Do you have 15 min to talk to us? 🙏" }, + html: { default: "You're one of our power users. We would love to interview you briefly!" }, + buttonLabel: { default: "Book slot" }, buttonUrl: "https://cal.com/johannes", buttonExternal: true, required: false, @@ -920,67 +912,73 @@ export const templates: TTemplate[] = [ condition: "equals", destination: "gn6298zogd2ipdz7js17qy5i", }, - { value: "Something else", condition: "equals", destination: "c0exdyri3erugrv0ezkyseh6" }, + { + value: "Something else", + condition: "equals", + destination: "c0exdyri3erugrv0ezkyseh6", + }, ], choices: [ - { id: createId(), label: "Didn't seem useful to me" }, - { id: createId(), label: "Difficult to set up or use" }, - { id: createId(), label: "Lacked features/functionality" }, - { id: createId(), label: "Just haven't had the time" }, - { id: createId(), label: "Something else" }, + { id: createId(), label: { default: "Didn't seem useful to me" } }, + { id: createId(), label: { default: "Difficult to set up or use" } }, + { id: createId(), label: { default: "Lacked features/functionality" } }, + { id: createId(), label: { default: "Just haven't had the time" } }, + { id: createId(), label: { default: "Something else" } }, ], - headline: "What's the main reason why you haven't finished setting up {{productName}}?", + headline: { + default: "What's the main reason why you haven't finished setting up {{productName}}?", + }, required: true, - subheader: "", + subheader: { default: "" }, }, { id: createId(), type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], - headline: "What made you think {{productName}} wouldn't be useful?", + headline: { default: "What made you think {{productName}} wouldn't be useful?" }, required: true, - subheader: "", - placeholder: "Type your answer here...", + subheader: { default: "" }, + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "r0zvi3vburf4hm7qewimzjux", type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], - headline: "What was difficult about setting up or using {{productName}}?", + headline: { default: "What was difficult about setting up or using {{productName}}?" }, required: true, - subheader: "", - placeholder: "Type your answer here...", + subheader: { default: "" }, + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "rbwz3y6y9avzqcfj30nu0qj4", type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], - headline: "What features or functionality were missing?", + headline: { default: "What features or functionality were missing?" }, required: true, - subheader: "", - placeholder: "Type your answer here...", + subheader: { default: "" }, + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "gn6298zogd2ipdz7js17qy5i", type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], - headline: "How could we make it easier for you to get started?", + headline: { default: "How could we make it easier for you to get started?" }, required: true, - subheader: "", - placeholder: "Type your answer here...", + subheader: { default: "" }, + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "c0exdyri3erugrv0ezkyseh6", type: TSurveyQuestionType.OpenText, logic: [], - headline: "What was it? Please explain:", + headline: { default: "What was it? Please explain:" }, required: false, - subheader: "We're eager to fix it asap.", - placeholder: "Type your answer here...", + subheader: { default: "We're eager to fix it asap." }, + placeholder: { default: "Type your answer here..." }, inputType: "text", }, ], @@ -1002,36 +1000,36 @@ export const templates: TTemplate[] = [ type: TSurveyQuestionType.MultipleChoiceSingle, shuffleOption: "none", choices: [ - { id: createId(), label: "Ease of use" }, - { id: createId(), label: "Good value for money" }, - { id: createId(), label: "It's open-source" }, - { id: createId(), label: "The founders are cute" }, - { id: "other", label: "Other" }, + { id: createId(), label: { default: "Ease of use" } }, + { id: createId(), label: { default: "Good value for money" } }, + { id: createId(), label: { default: "It's open-source" } }, + { id: createId(), label: { default: "The founders are cute" } }, + { id: "other", label: { default: "Other" } }, ], - headline: "What do you value most about {{productName}}?", + headline: { default: "What do you value most about {{productName}}?" }, required: true, - subheader: "", + subheader: { default: "" }, }, { id: createId(), type: TSurveyQuestionType.MultipleChoiceSingle, shuffleOption: "none", choices: [ - { id: createId(), label: "Documentation" }, - { id: createId(), label: "Customizability" }, - { id: createId(), label: "Pricing" }, - { id: "other", label: "Other" }, + { id: createId(), label: { default: "Documentation" } }, + { id: createId(), label: { default: "Customizability" } }, + { id: createId(), label: { default: "Pricing" } }, + { id: "other", label: { default: "Other" } }, ], - headline: "What should we improve on?", + headline: { default: "What should we improve on?" }, required: true, - subheader: "Please select one of the following options:", + subheader: { default: "Please select one of the following options:" }, }, { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "Would you like to add something?", + headline: { default: "Would you like to add something?" }, required: false, - subheader: "Feel free to speak your mind, we do too.", + subheader: { default: "Feel free to speak your mind, we do too." }, inputType: "text", }, ], @@ -1050,30 +1048,30 @@ export const templates: TTemplate[] = [ { id: createId(), type: TSurveyQuestionType.MultipleChoiceSingle, - headline: "How disappointed would you be if you could no longer use {{productName}}?", - subheader: "Please select one of the following options:", + headline: { default: "How disappointed would you be if you could no longer use {{productName}}?" }, + subheader: { default: "Please select one of the following options:" }, required: true, shuffleOption: "none", choices: [ { id: createId(), - label: "Not at all disappointed", + label: { default: "Not at all disappointed" }, }, { id: createId(), - label: "Somewhat disappointed", + label: { default: "Somewhat disappointed" }, }, { id: createId(), - label: "Very disappointed", + label: { default: "Very disappointed" }, }, ], }, { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "How can we improve {{productName}} for you?", - subheader: "Please be as specific as possible.", + headline: { default: "How can we improve {{productName}} for you?" }, + subheader: { default: "Please be as specific as possible." }, required: true, inputType: "text", }, @@ -1095,30 +1093,30 @@ export const templates: TTemplate[] = [ { id: createId(), type: TSurveyQuestionType.MultipleChoiceSingle, - headline: "How did you hear about us first?", - subheader: "Please select one of the following options:", + headline: { default: "How did you hear about us first?" }, + subheader: { default: "Please select one of the following options:" }, required: true, shuffleOption: "none", choices: [ { id: createId(), - label: "Recommendation", + label: { default: "Recommendation" }, }, { id: createId(), - label: "Social Media", + label: { default: "Social Media" }, }, { id: createId(), - label: "Ads", + label: { default: "Ads" }, }, { id: createId(), - label: "Google Search", + label: { default: "Google Search" }, }, { id: createId(), - label: "In a Podcast", + label: { default: "In a Podcast" }, }, ], }, @@ -1140,50 +1138,50 @@ export const templates: TTemplate[] = [ { id: createId(), type: TSurveyQuestionType.MultipleChoiceSingle, - headline: "How easy was it to change your plan?", + headline: { default: "How easy was it to change your plan?" }, required: true, shuffleOption: "none", choices: [ { id: createId(), - label: "Extremely difficult", + label: { default: "Extremely difficult" }, }, { id: createId(), - label: "It took a while, but I got it", + label: { default: "It took a while, but I got it" }, }, { id: createId(), - label: "It was alright", + label: { default: "It was alright" }, }, { id: createId(), - label: "Quite easy", + label: { default: "Quite easy" }, }, { id: createId(), - label: "Very easy, love it!", + label: { default: "Very easy, love it!" }, }, ], }, { id: createId(), type: TSurveyQuestionType.MultipleChoiceSingle, - headline: "Is the pricing information easy to understand?", + headline: { default: "Is the pricing information easy to understand?" }, required: true, shuffleOption: "none", choices: [ { id: createId(), - label: "Yes, very clear.", + label: { default: "Yes, very clear." }, }, { id: createId(), - label: "I was confused at first, but found what I needed.", + label: { default: "I was confused at first, but found what I needed." }, }, { id: createId(), - label: "Quite complicated.", + label: { default: "Quite complicated." }, }, ], }, @@ -1207,25 +1205,25 @@ export const templates: TTemplate[] = [ { id: createId(), type: TSurveyQuestionType.MultipleChoiceSingle, - headline: "What's your primary goal for using {{productName}}?", + headline: { default: "What's your primary goal for using {{productName}}?" }, required: true, shuffleOption: "none", choices: [ { id: createId(), - label: "Understand my user base deeply", + label: { default: "Understand my user base deeply" }, }, { id: createId(), - label: "Identify upselling opportunities", + label: { default: "Identify upselling opportunities" }, }, { id: createId(), - label: "Build the best possible product", + label: { default: "Build the best possible product" }, }, { id: createId(), - label: "Rule the world to make everyone breakfast brussels sprouts.", + label: { default: "Rule the world to make everyone breakfast brussels sprouts." }, }, ], }, @@ -1249,24 +1247,24 @@ export const templates: TTemplate[] = [ type: TSurveyQuestionType.Rating, range: 5, scale: "number", - headline: "How important is [ADD FEATURE] for you?", + headline: { default: "How important is [ADD FEATURE] for you?" }, required: true, - lowerLabel: "Not important", - upperLabel: "Very important", + lowerLabel: { default: "Not important" }, + upperLabel: { default: "Very important" }, }, { id: createId(), type: TSurveyQuestionType.MultipleChoiceSingle, shuffleOption: "none", choices: [ - { id: createId(), label: "Aspect 1" }, - { id: createId(), label: "Aspect 2" }, - { id: createId(), label: "Aspect 3" }, - { id: createId(), label: "Aspect 4" }, + { id: createId(), label: { default: "Aspect 1" } }, + { id: createId(), label: { default: "Aspect 2" } }, + { id: createId(), label: { default: "Aspect 3" } }, + { id: createId(), label: { default: "Aspect 4" } }, ], - headline: "Which aspect is most important?", + headline: { default: "Which aspect is most important?" }, required: true, - subheader: "", + subheader: { default: "" }, }, ], thankYouCard: thankYouCardDefault, @@ -1286,35 +1284,35 @@ export const templates: TTemplate[] = [ { id: createId(), type: TSurveyQuestionType.Rating, - headline: "How important is this feature for you?", + headline: { default: "How important is this feature for you?" }, required: true, - lowerLabel: "Not important", - upperLabel: "Very important", + lowerLabel: { default: "Not important" }, + upperLabel: { default: "Very important" }, range: 5, scale: "number", }, { id: createId(), type: TSurveyQuestionType.MultipleChoiceMulti, - headline: "What should be definitely include building this?", + headline: { default: "What should be definitely include building this?" }, required: false, shuffleOption: "none", choices: [ { id: createId(), - label: "Aspect 1", + label: { default: "Aspect 1" }, }, { id: createId(), - label: "Aspect 2", + label: { default: "Aspect 2" }, }, { id: createId(), - label: "Aspect 3", + label: { default: "Aspect 3" }, }, { id: createId(), - label: "Aspect 4", + label: { default: "Aspect 4" }, }, ], }, @@ -1342,44 +1340,47 @@ export const templates: TTemplate[] = [ { value: "Feature Request 💡", condition: "equals", destination: "en9nuuevbf7g9oa9rzcs1l50" }, ], choices: [ - { id: createId(), label: "Bug report 🐞" }, - { id: createId(), label: "Feature Request 💡" }, + { id: createId(), label: { default: "Bug report 🐞" } }, + { id: createId(), label: { default: "Feature Request 💡" } }, ], - headline: "What's on your mind, boss?", + headline: { default: "What's on your mind, boss?" }, required: true, - subheader: "Thanks for sharing. We'll get back to you asap.", + subheader: { default: "Thanks for sharing. We'll get back to you asap." }, }, { id: "dnbiuq4l33l7jypcf2cg6vhh", type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "a6c76m5oocw6xp9agf3d2tam" }], - headline: "What's broken?", + headline: { default: "What's broken?" }, required: true, - subheader: "The more detail, the better :)", + subheader: { default: "The more detail, the better :)" }, inputType: "text", }, { id: "a6c76m5oocw6xp9agf3d2tam", - html: '

We will fix this as soon as possible. Do you want to be notified when we did?

', + html: { + default: + '

We will fix this as soon as possible. Do you want to be notified when we did?

', + }, type: TSurveyQuestionType.CTA, logic: [ { condition: "clicked", destination: "end" }, { condition: "skipped", destination: "end" }, ], - headline: "Want to stay in the loop?", + headline: { default: "Want to stay in the loop?" }, required: false, - buttonLabel: "Yes, notify me", + buttonLabel: { default: "Yes, notify me" }, buttonExternal: false, - dismissButtonLabel: "No, thanks", + dismissButtonLabel: { default: "No, thanks" }, }, { id: "en9nuuevbf7g9oa9rzcs1l50", type: TSurveyQuestionType.OpenText, - headline: "Lovely, tell us more!", + headline: { default: "Lovely, tell us more!" }, required: true, - subheader: "What problem do you want us to solve?", - buttonLabel: "Request feature", - placeholder: "Type your answer here...", + subheader: { default: "What problem do you want us to solve?" }, + buttonLabel: { default: "Request feature" }, + placeholder: { default: "Type your answer here..." }, inputType: "text", }, ], @@ -1403,26 +1404,26 @@ export const templates: TTemplate[] = [ logic: [{ value: 4, condition: "greaterEqual", destination: "ef0qo3l8iisd517ikp078u1p" }], range: 5, scale: "number", - headline: "How easy was it to set this integration up?", + headline: { default: "How easy was it to set this integration up?" }, required: true, - subheader: "", - lowerLabel: "Not easy", - upperLabel: "Very easy", + subheader: { default: "" }, + lowerLabel: { default: "Not easy" }, + upperLabel: { default: "Very easy" }, }, { id: "mko13ptjj6tpi5u2pl7a5drz", type: TSurveyQuestionType.OpenText, - headline: "Why was it hard?", + headline: { default: "Why was it hard?" }, required: false, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "ef0qo3l8iisd517ikp078u1p", type: TSurveyQuestionType.OpenText, - headline: "What other tools would you like to use with {{productName}}?", + headline: { default: "What other tools would you like to use with {{productName}}?" }, required: false, - subheader: "We keep building integrations, yours can be next:", + subheader: { default: "We keep building integrations, yours can be next:" }, inputType: "text", }, ], @@ -1443,27 +1444,30 @@ export const templates: TTemplate[] = [ { id: createId(), type: TSurveyQuestionType.MultipleChoiceSingle, - headline: "Which other tools are you using?", + headline: { default: "Which other tools are you using?" }, required: true, shuffleOption: "none", choices: [ { id: createId(), - label: "PostHog", + label: { default: "PostHog" }, }, { id: createId(), - label: "Segment", + label: { default: "Segment" }, }, { id: createId(), - label: "Hubspot", + label: { default: "Hubspot" }, }, { id: createId(), - label: "Twilio", + label: { default: "Twilio" }, + }, + { + id: "other", + label: { default: "Other" }, }, - { id: "other", label: "Other" }, ], }, ], @@ -1484,31 +1488,31 @@ export const templates: TTemplate[] = [ { id: createId(), type: TSurveyQuestionType.MultipleChoiceSingle, - headline: "Was this page helpful?", + headline: { default: "Was this page helpful?" }, required: true, shuffleOption: "none", choices: [ { id: createId(), - label: "Yes 👍", + label: { default: "Yes 👍" }, }, { id: createId(), - label: "No 👎", + label: { default: "No 👎" }, }, ], }, { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "Please elaborate:", + headline: { default: "Please elaborate:" }, required: false, inputType: "text", }, { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "Page URL", + headline: { default: "Page URL" }, required: false, inputType: "text", }, @@ -1530,15 +1534,15 @@ export const templates: TTemplate[] = [ { id: createId(), type: TSurveyQuestionType.NPS, - headline: "How likely are you to recommend {{productName}} to a friend or colleague?", + headline: { default: "How likely are you to recommend {{productName}} to a friend or colleague?" }, required: false, - lowerLabel: "Not likely", - upperLabel: "Very likely", + lowerLabel: { default: "Not likely" }, + upperLabel: { default: "Very likely" }, }, { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "What made you give that rating?", + headline: { default: "What made you give that rating?" }, required: false, inputType: "text", }, @@ -1562,27 +1566,27 @@ export const templates: TTemplate[] = [ logic: [{ value: 3, condition: "lessEqual", destination: "vyo4mkw4ln95ts4ya7qp2tth" }], range: 5, scale: "smiley", - headline: "How satisfied are you with your {{productName}} experience?", + headline: { default: "How satisfied are you with your {{productName}} experience?" }, required: true, - subheader: "", - lowerLabel: "Not satisfied", - upperLabel: "Very satisfied", + subheader: { default: "" }, + lowerLabel: { default: "Not satisfied" }, + upperLabel: { default: "Very satisfied" }, }, { id: createId(), type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], - headline: "Lovely! Is there anything we can do to improve your experience?", + headline: { default: "Lovely! Is there anything we can do to improve your experience?" }, required: false, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "vyo4mkw4ln95ts4ya7qp2tth", type: TSurveyQuestionType.OpenText, - headline: "Ugh, sorry! Is there anything we can do to improve your experience?", + headline: { default: "Ugh, sorry! Is there anything we can do to improve your experience?" }, required: false, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, ], @@ -1605,29 +1609,29 @@ export const templates: TTemplate[] = [ logic: [{ value: "3", condition: "lessEqual", destination: "dlpa0371pe7rphmggy2sgbap" }], range: 5, scale: "star", - headline: "How do you rate your overall experience?", + headline: { default: "How do you rate your overall experience?" }, required: true, - subheader: "Don't worry, be honest.", - lowerLabel: "Not good", - upperLabel: "Very good", + subheader: { default: "Don't worry, be honest." }, + lowerLabel: { default: "Not good" }, + upperLabel: { default: "Very good" }, }, { id: createId(), type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "gwo0fq5kug13e83fcour4n1w" }], - headline: "Lovely! What did you like about it?", + headline: { default: "Lovely! What did you like about it?" }, required: true, longAnswer: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "dlpa0371pe7rphmggy2sgbap", type: TSurveyQuestionType.OpenText, - headline: "Thanks for sharing! What did you not like?", + headline: { default: "Thanks for sharing! What did you not like?" }, required: true, longAnswer: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { @@ -1635,18 +1639,18 @@ export const templates: TTemplate[] = [ type: TSurveyQuestionType.Rating, range: 5, scale: "smiley", - headline: "How do you rate our communication?", + headline: { default: "How do you rate our communication?" }, required: true, - lowerLabel: "Not good", - upperLabel: "Very good", + lowerLabel: { default: "Not good" }, + upperLabel: { default: "Very good" }, }, { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "Anything else you'd like to share with our team?", + headline: { default: "Anything else you'd like to share with our team?" }, required: false, longAnswer: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { @@ -1654,24 +1658,24 @@ export const templates: TTemplate[] = [ type: TSurveyQuestionType.MultipleChoiceSingle, logic: [], choices: [ - { id: createId(), label: "Google" }, - { id: createId(), label: "Social Media" }, - { id: createId(), label: "Friends" }, - { id: createId(), label: "Podcast" }, - { id: "other", label: "Other" }, + { id: createId(), label: { default: "Google" } }, + { id: createId(), label: { default: "Social Media" } }, + { id: createId(), label: { default: "Friends" } }, + { id: createId(), label: { default: "Podcast" } }, + { id: "other", label: { dfault: "Other" } }, ], - headline: "How did you hear about us?", + headline: { default: "How did you hear about us?" }, required: true, shuffleOption: "none", }, { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "Lastly, we'd love to respond to your feedback. Please share your email:", + headline: { default: "Lastly, we'd love to respond to your feedback. Please share your email:" }, required: false, inputType: "email", longAnswer: false, - placeholder: "example@email.com", + placeholder: { default: "example@email.com" }, }, ], thankYouCard: thankYouCardDefault, @@ -1691,25 +1695,25 @@ export const templates: TTemplate[] = [ { id: createId(), type: TSurveyQuestionType.MultipleChoiceSingle, - headline: "How many hours does your team save per week by using {{productName}}?", + headline: { default: "How many hours does your team save per week by using {{productName}}?" }, required: true, shuffleOption: "none", choices: [ { id: createId(), - label: "Less than 1 hour", + label: { default: "Less than 1 hour" }, }, { id: createId(), - label: "1 to 2 hours", + label: { default: "1 to 2 hours" }, }, { id: createId(), - label: "3 to 5 hours", + label: { default: "3 to 5 hours" }, }, { id: createId(), - label: "5+ hours", + label: { default: "5+ hours" }, }, ], }, @@ -1735,14 +1739,14 @@ export const templates: TTemplate[] = [ logic: [], shuffleOption: "none", choices: [ - { id: createId(), label: "Feature 1" }, - { id: createId(), label: "Feature 2" }, - { id: createId(), label: "Feature 3" }, - { id: "other", label: "Other" }, + { id: createId(), label: { default: "Feature 1" } }, + { id: createId(), label: { default: "Feature 2" } }, + { id: createId(), label: { default: "Feature 3" } }, + { id: "other", label: { default: "Other" } }, ], - headline: "Which of these features would be MOST valuable to you?", + headline: { default: "Which of these features would be MOST valuable to you?" }, required: true, - subheader: "", + subheader: { default: "" }, }, { id: createId(), @@ -1750,20 +1754,20 @@ export const templates: TTemplate[] = [ logic: [], shuffleOption: "none", choices: [ - { id: createId(), label: "Feature 1" }, - { id: createId(), label: "Feature 2" }, - { id: createId(), label: "Feature 3" }, + { id: createId(), label: { default: "Feature 1" } }, + { id: createId(), label: { default: "Feature 2" } }, + { id: createId(), label: { default: "Feature 3" } }, ], - headline: "Which of these features would be LEAST valuable to you?", + headline: { default: "Which of these features would be LEAST valuable to you?" }, required: true, - subheader: "", + subheader: { default: "" }, }, { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "How else could we improve you experience with {{productName}}?", + headline: { default: "How else could we improve you experience with {{productName}}?" }, required: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, ], @@ -1784,17 +1788,17 @@ export const templates: TTemplate[] = [ { id: createId(), type: TSurveyQuestionType.Rating, - headline: "How easy was it to achieve ... ?", + headline: { default: "How easy was it to achieve ... ?" }, required: true, - lowerLabel: "Not easy", - upperLabel: "Very easy", + lowerLabel: { default: "Not easy" }, + upperLabel: { default: "Very easy" }, scale: "number", range: 5, }, { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "What is one thing we could do better?", + headline: { default: "What is one thing we could do better?" }, required: false, inputType: "text", }, @@ -1816,37 +1820,37 @@ export const templates: TTemplate[] = [ { id: createId(), type: TSurveyQuestionType.MultipleChoiceSingle, - headline: "Do you have all the info you need to give {{productName}} a try?", + headline: { default: "Do you have all the info you need to give {{productName}} a try?" }, required: true, shuffleOption: "none", choices: [ { id: createId(), - label: "Yes, totally", + label: { default: "Yes, totally" }, }, { id: createId(), - label: "Kind of...", + label: { default: "Kind of..." }, }, { id: createId(), - label: "No, not at all", + label: { default: "No, not at all" }, }, ], }, { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "What’s missing or unclear to you about {{productName}}?", + headline: { default: "What’s missing or unclear to you about {{productName}}?" }, required: false, inputType: "text", }, { id: createId(), type: TSurveyQuestionType.CTA, - headline: "Thanks for your answer! Get 25% off your first 6 months:", + headline: { default: "Thanks for your answer! Get 25% off your first 6 months:" }, required: false, - buttonLabel: "Get discount", + buttonLabel: { default: "Get discount" }, buttonUrl: "https://app.formbricks.com/auth/signup", buttonExternal: true, }, @@ -1870,18 +1874,18 @@ export const templates: TTemplate[] = [ type: TSurveyQuestionType.Rating, range: 5, scale: "number", - headline: "{{productName}} makes it easy for me to [ADD GOAL]", + headline: { default: "{{productName}} makes it easy for me to [ADD GOAL]" }, required: true, - subheader: "", - lowerLabel: "Disagree strongly", - upperLabel: "Agree strongly", + subheader: { default: "" }, + lowerLabel: { default: "Disagree strongly" }, + upperLabel: { default: "Agree strongly" }, }, { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "Thanks! How could we make it easier for you to [ADD GOAL]?", + headline: { default: "Thanks! How could we make it easier for you to [ADD GOAL]?" }, required: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, ], @@ -1906,27 +1910,27 @@ export const templates: TTemplate[] = [ logic: [{ value: 4, condition: "greaterEqual", destination: "lpof3d9t9hmnqvyjlpksmxd7" }], range: 5, scale: "number", - headline: "How easy or difficult was it to complete the checkout?", + headline: { default: "How easy or difficult was it to complete the checkout?" }, required: true, - subheader: "", - lowerLabel: "Very difficult", - upperLabel: "Very easy", + subheader: { default: "" }, + lowerLabel: { default: "Very difficult" }, + upperLabel: { default: "Very easy" }, }, { id: createId(), type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], - headline: "Sorry about that! What would have made it easier for you?", + headline: { default: "Sorry about that! What would have made it easier for you?" }, required: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "lpof3d9t9hmnqvyjlpksmxd7", type: TSurveyQuestionType.OpenText, - headline: "Lovely! Is there anything we can do to improve your experience?", + headline: { default: "Lovely! Is there anything we can do to improve your experience?" }, required: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, ], @@ -1950,27 +1954,27 @@ export const templates: TTemplate[] = [ logic: [{ value: 4, condition: "greaterEqual", destination: "adcs3d9t9hmnqvyjlpksmxd7" }], range: 5, scale: "number", - headline: "How relevant are these search results?", + headline: { default: "How relevant are these search results?" }, required: true, - subheader: "", - lowerLabel: "Not at all relevant", - upperLabel: "Very relevant", + subheader: { default: "" }, + lowerLabel: { default: "Not at all relevant" }, + upperLabel: { default: "Very relevant" }, }, { id: createId(), type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], - headline: "Ugh! What makes the results irrelevant for you?", + headline: { default: "Ugh! What makes the results irrelevant for you?" }, required: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "adcs3d9t9hmnqvyjlpksmxd7", type: TSurveyQuestionType.OpenText, - headline: "Lovely! Is there anything we can do to improve your experience?", + headline: { default: "Lovely! Is there anything we can do to improve your experience?" }, required: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, ], @@ -1994,27 +1998,27 @@ export const templates: TTemplate[] = [ logic: [{ value: 4, condition: "greaterEqual", destination: "adcs3d9t9hmnqvyjlpkswi38" }], range: 5, scale: "number", - headline: "How well did this article address what you were hoping to learn?", + headline: { default: "How well did this article address what you were hoping to learn?" }, required: true, - subheader: "", - lowerLabel: "Not at all well", - upperLabel: "Extremely well", + subheader: { default: "" }, + lowerLabel: { default: "Not at all well" }, + upperLabel: { default: "Extremely well" }, }, { id: createId(), type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], - headline: "Hmpft! What were you hoping for?", + headline: { default: "Hmpft! What were you hoping for?" }, required: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "adcs3d9t9hmnqvyjlpkswi38", type: TSurveyQuestionType.OpenText, - headline: "Lovely! Is there anything else you would like us to cover?", + headline: { default: "Lovely! Is there anything else you would like us to cover?" }, required: true, - placeholder: "Topics, trends, tutorials...", + placeholder: { default: "Topics, trends, tutorials..." }, inputType: "text", }, ], @@ -2042,11 +2046,11 @@ export const templates: TTemplate[] = [ { value: "No", condition: "equals", destination: "u83zhr66knyfozccoqojx7bc" }, ], choices: [ - { id: createId(), label: "Yes" }, - { id: createId(), label: "Working on it, boss" }, - { id: createId(), label: "No" }, + { id: createId(), label: { default: "Yes" } }, + { id: createId(), label: { default: "Working on it, boss" } }, + { id: createId(), label: { default: "No" } }, ], - headline: "Were you able to accomplish what you came here to do today?", + headline: { default: "Were you able to accomplish what you came here to do today?" }, required: true, }, { @@ -2055,10 +2059,10 @@ export const templates: TTemplate[] = [ logic: [{ value: 4, condition: "greaterEqual", destination: "nq88udm0jjtylr16ax87xlyc" }], range: 5, scale: "number", - headline: "How easy was it to achieve your goal?", + headline: { default: "How easy was it to achieve your goal?" }, required: true, - lowerLabel: "Very difficult", - upperLabel: "Very easy", + lowerLabel: { default: "Very difficult" }, + upperLabel: { default: "Very easy" }, }, { id: "s0999bhpaz8vgf7ps264piek", @@ -2067,9 +2071,9 @@ export const templates: TTemplate[] = [ { condition: "submitted", destination: "end" }, { condition: "skipped", destination: "end" }, ], - headline: "What made it hard?", + headline: { default: "What made it hard?" }, required: false, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { @@ -2079,18 +2083,18 @@ export const templates: TTemplate[] = [ { condition: "skipped", destination: "end" }, { condition: "submitted", destination: "end" }, ], - headline: "Great! What did you come here to do today?", + headline: { default: "Great! What did you come here to do today?" }, required: false, - buttonLabel: "Send", + buttonLabel: { default: "Send" }, inputType: "text", }, { id: "u83zhr66knyfozccoqojx7bc", type: TSurveyQuestionType.OpenText, - headline: "What stopped you?", + headline: { default: "What stopped you?" }, required: true, - buttonLabel: "Send", - placeholder: "Type your answer here...", + buttonLabel: { default: "Send" }, + placeholder: { default: "Type your answer here..." }, inputType: "text", }, ], @@ -2110,14 +2114,17 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - html: '

You seem to be considering signing up. Answer four questions and get 10% on any plan.

', + html: { + default: + '

You seem to be considering signing up. Answer four questions and get 10% on any plan.

', + }, type: TSurveyQuestionType.CTA, logic: [{ condition: "skipped", destination: "end" }], - headline: "Answer this short survey, get 10% off!", + headline: { default: "Answer this short survey, get 10% off!" }, required: false, - buttonLabel: "Get 10% discount", + buttonLabel: { default: "Get 10% discount" }, buttonExternal: false, - dismissButtonLabel: "No, thanks", + dismissButtonLabel: { default: "No, thanks" }, }, { id: createId(), @@ -2125,11 +2132,11 @@ export const templates: TTemplate[] = [ logic: [{ value: "5", condition: "equals", destination: "end" }], range: 5, scale: "number", - headline: "How likely are you to sign up for {{productName}}?", + headline: { default: "How likely are you to sign up for {{productName}}?" }, required: true, - subheader: "", - lowerLabel: "Not at all likely", - upperLabel: "Very likely", + subheader: { default: "" }, + lowerLabel: { default: "Not at all likely" }, + upperLabel: { default: "Very likely" }, }, { id: createId(), @@ -2151,70 +2158,74 @@ export const templates: TTemplate[] = [ { value: "Something else", condition: "equals", destination: "v0pq1qcnm6ohiry5ywcd91qq" }, ], choices: [ - { id: createId(), label: "May not have what I'm looking for" }, - { id: createId(), label: "Still comparing options" }, - { id: createId(), label: "Seems complicated" }, - { id: createId(), label: "Pricing is a concern" }, - { id: createId(), label: "Something else" }, + { id: createId(), label: { default: "May not have what I'm looking for" } }, + { id: createId(), label: { default: "Still comparing options" } }, + { id: createId(), label: { default: "Seems complicated" } }, + { id: createId(), label: { default: "Pricing is a concern" } }, + { id: createId(), label: { default: "Something else" } }, ], - headline: "What is holding you back from trying {{productName}}?", + headline: { default: "What is holding you back from trying {{productName}}?" }, required: true, - subheader: "", + subheader: { default: "" }, }, { id: "atiw0j1oykb77zr0b7q4tixu", type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "k3q0vt1ko0bzbsq076p7lnys" }], - headline: "What do you need but {{productName}} does not offer?", + headline: { default: "What do you need but {{productName}} does not offer?" }, required: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, + { id: "j7jkpolm5xl7u0zt3g0e4z7d", type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "k3q0vt1ko0bzbsq076p7lnys" }], - headline: "What options are you looking at?", + headline: { default: "What options are you looking at?" }, required: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "t5gvag2d7kq311szz5iyiy79", type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "k3q0vt1ko0bzbsq076p7lnys" }], - headline: "What seems complicated to you?", + headline: { default: "What seems complicated to you?" }, required: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "or0yhhrof753sq9ug4mdavgz", type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "k3q0vt1ko0bzbsq076p7lnys" }], - headline: "What are you concerned about regarding pricing?", + headline: { default: "What are you concerned about regarding pricing?" }, required: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "v0pq1qcnm6ohiry5ywcd91qq", type: TSurveyQuestionType.OpenText, - headline: "Please explain:", + headline: { default: "Please explain:" }, required: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "k3q0vt1ko0bzbsq076p7lnys", - html: '

Thanks a lot for taking the time to share feedback 🙏

', + html: { + default: + '

Thanks a lot for taking the time to share feedback 🙏

', + }, type: TSurveyQuestionType.CTA, - headline: "Thanks! Here is your code: SIGNUPNOW10", + headline: { default: "Thanks! Here is your code: SIGNUPNOW10" }, required: false, buttonUrl: "https://app.formbricks.com/auth/signup", - buttonLabel: "Sign Up", + buttonLabel: { default: "Sign Up" }, buttonExternal: true, - dismissButtonLabel: "Skip for now", + dismissButtonLabel: { default: "Skip for now" }, }, ], thankYouCard: thankYouCardDefault, @@ -2236,19 +2247,23 @@ export const templates: TTemplate[] = [ type: TSurveyQuestionType.Rating, range: 5, scale: "number", - headline: "How satisfied are you with the features and functionality of {{productName}}?", + headline: { + default: "How satisfied are you with the features and functionality of {{productName}}?", + }, required: true, - subheader: "", - lowerLabel: "Not at all satisfied", - upperLabel: "Extremely satisfied", + subheader: { default: "" }, + lowerLabel: { default: "Not at all satisfied" }, + upperLabel: { default: "Extremely satisfied" }, }, { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "What's ONE change we could make to improve your {{productName}} experience most?", + headline: { + default: "What's ONE change we could make to improve your {{productName}} experience most?", + }, required: true, - subheader: "", - placeholder: "Type your answer here...", + subheader: { default: "" }, + placeholder: { default: "Type your answer here..." }, inputType: "text", }, ], @@ -2277,11 +2292,11 @@ export const templates: TTemplate[] = [ ], range: 5, scale: "number", - headline: "How likely are you to subscribe to {{productName}} today?", + headline: { default: "How likely are you to subscribe to {{productName}} today?" }, required: true, - subheader: "", - lowerLabel: "Not at all likely", - upperLabel: "Extremely likely", + subheader: { default: "" }, + lowerLabel: { default: "Not at all likely" }, + upperLabel: { default: "Extremely likely" }, }, { id: "y19mwcmstlc7pi7s4izxk1ll", @@ -2290,17 +2305,17 @@ export const templates: TTemplate[] = [ { condition: "submitted", destination: "end" }, { condition: "skipped", destination: "end" }, ], - headline: "Got it. What's your primary reason for visiting today?", + headline: { default: "Got it. What's your primary reason for visiting today?" }, required: false, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "zm1hs8qkeuidh3qm0hx8pnw7", type: TSurveyQuestionType.OpenText, - headline: "What, if anything, is holding you back from making a purchase today?", + headline: { default: "What, if anything, is holding you back from making a purchase today?" }, required: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, ], @@ -2326,11 +2341,11 @@ export const templates: TTemplate[] = [ ], range: 5, scale: "smiley", - headline: "How would you rate this weeks newsletter?", + headline: { default: "How would you rate this weeks newsletter?" }, required: true, - subheader: "", - lowerLabel: "Meh", - upperLabel: "Great", + subheader: { default: "" }, + lowerLabel: { default: "Meh" }, + upperLabel: { default: "Great" }, }, { id: "k3s6gm5ivkc5crpycdbpzkpa", @@ -2339,21 +2354,24 @@ export const templates: TTemplate[] = [ { condition: "submitted", destination: "end" }, { condition: "skipped", destination: "end" }, ], - headline: "What would have made this weeks newsletter more helpful?", + headline: { default: "What would have made this weeks newsletter more helpful?" }, required: false, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "l2q1chqssong8n0xwaagyl8g", - html: '

Who thinks like you? You\'d do us a huge favor if you\'d share this weeks episode with your brain friend!

', + html: { + default: + '

Who thinks like you? You\'d do us a huge favor if you\'d share this weeks episode with your brain friend!

', + }, type: TSurveyQuestionType.CTA, - headline: "Thanks! ❤️ Spread the love with ONE friend.", + headline: { default: "Thanks! ❤️ Spread the love with ONE friend." }, required: false, buttonUrl: "https://formbricks.com", - buttonLabel: "Happy to help!", + buttonLabel: { default: "Happy to help!" }, buttonExternal: true, - dismissButtonLabel: "Find your own friends", + dismissButtonLabel: { default: "Find your own friends" }, }, ], thankYouCard: thankYouCardDefault, @@ -2372,14 +2390,19 @@ export const templates: TTemplate[] = [ questions: [ { id: createId(), - html: '

We respect your time and kept it short 🤸

', + html: { + default: + '

We respect your time and kept it short 🤸

', + }, type: TSurveyQuestionType.CTA, - headline: - "We love how you use {{productName}}! We'd love to pick your brain on a feature idea. Got a minute?", + headline: { + default: + "We love how you use {{productName}}! We'd love to pick your brain on a feature idea. Got a minute?", + }, required: true, - buttonLabel: "Let's do it!", + buttonLabel: { default: "Let's do it!" }, buttonExternal: false, - dismissButtonLabel: "Skip", + dismissButtonLabel: { default: "Skip" }, }, { id: createId(), @@ -2390,30 +2413,33 @@ export const templates: TTemplate[] = [ ], range: 5, scale: "number", - headline: "Thanks! How difficult or easy is it for you to [PROBLEM AREA] today?", + headline: { default: "Thanks! How difficult or easy is it for you to [PROBLEM AREA] today?" }, required: true, - subheader: "", - lowerLabel: "Very difficult", - upperLabel: "Very easy", + subheader: { default: "" }, + lowerLabel: { default: "Very difficult" }, + upperLabel: { default: "Very easy" }, }, { id: "ndacjg9lqf5jcpq9w8ote666", type: TSurveyQuestionType.OpenText, - headline: "What's most difficult for you when it comes to [PROBLEM AREA]?", + headline: { default: "What's most difficult for you when it comes to [PROBLEM AREA]?" }, required: true, - subheader: "", - placeholder: "Type your answer here...", + subheader: { default: "" }, + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "jmzgbo73cfjswlvhoynn7o0q", - html: '


Read the text below, then answer 2 questions:


Insert concept brief here. Add neccessary details but keep it concise and easy to understand.

', + html: { + default: + '


Read the text below, then answer 2 questions:


Insert concept brief here. Add neccessary details but keep it concise and easy to understand.

', + }, type: TSurveyQuestionType.CTA, - headline: "We're working on an idea to help with [PROBLEM AREA].", + headline: { default: "We're working on an idea to help with [PROBLEM AREA]." }, required: true, - buttonLabel: "Next", + buttonLabel: { default: "Next" }, buttonExternal: false, - dismissButtonLabel: "Skip", + dismissButtonLabel: { default: "Skip" }, }, { id: createId(), @@ -2424,35 +2450,35 @@ export const templates: TTemplate[] = [ ], range: 5, scale: "number", - headline: "How valuable would this feature be to you?", + headline: { default: "How valuable would this feature be to you?" }, required: true, - subheader: "", - lowerLabel: "Not valuable", - upperLabel: "Very valuable", + subheader: { default: "" }, + lowerLabel: { default: "Not valuable" }, + upperLabel: { default: "Very valuable" }, }, { id: "mmiuun3z4e7gk4ufuwh8lq8q", type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "bqmnpyku9etsgbtb322luzb2" }], - headline: "Got it. Why wouldn't this feature be valuable to you?", + headline: { default: "Got it. Why wouldn't this feature be valuable to you?" }, required: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "gvzevzw4hkqd6dmlkcly6kd1", type: TSurveyQuestionType.OpenText, - headline: "Got it. What would be most valuable to you in this feature?", + headline: { default: "Got it. What would be most valuable to you in this feature?" }, required: true, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "bqmnpyku9etsgbtb322luzb2", type: TSurveyQuestionType.OpenText, - headline: "Anything else we should keep in mind?", + headline: { default: "Anything else we should keep in mind?" }, required: false, - placeholder: "Type your answer here...", + placeholder: { default: "Type your answer here..." }, inputType: "text", }, ], @@ -2494,64 +2520,64 @@ export const templates: TTemplate[] = [ { value: "Other", condition: "equals", destination: "c0exdyri3erugrv0ezkyseh6" }, ], choices: [ - { id: "xoqb0wjjsk4t0lx0i7jrhx26", label: "Difficult to use" }, - { id: "p768nlw47ndehtgzx6m82dr6", label: "Found a better alternative" }, - { id: "izt28ma5ep3s92531owxj1vg", label: "Just haven't had the time" }, - { id: "dhkp2wb9e1tv7kfu8csjhzbh", label: "Lacked features I need" }, - { id: "other", label: "Other" }, + { id: "xoqb0wjjsk4t0lx0i7jrhx26", label: { default: "Difficult to use" } }, + { id: "p768nlw47ndehtgzx6m82dr6", label: { default: "Found a better alternative" } }, + { id: "izt28ma5ep3s92531owxj1vg", label: { default: "Just haven't had the time" } }, + { id: "dhkp2wb9e1tv7kfu8csjhzbh", label: { default: "Lacked features I need" } }, + { id: "other", label: { default: "Other" } }, ], - headline: "What's the main reason you haven't been back to {{productName}} recently?", + headline: { default: "What's the main reason you haven't been back to {{productName}} recently?" }, required: true, - subheader: "", + subheader: { default: "" }, }, { id: "r0zvi3vburf4hm7qewimzjux", type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], - headline: "What's difficult about using {{productName}}?", + headline: { default: "What's difficult about using {{productName}}?" }, required: true, - subheader: "", - placeholder: "Type your answer here...", + subheader: { default: "" }, + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "g92s5wetp51ps6afmc6y7609", type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], - headline: "Got it. Which alternative are you using instead?", + headline: { default: "Got it. Which alternative are you using instead?" }, required: true, - subheader: "", - placeholder: "Type your answer here...", + subheader: { default: "" }, + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "gn6298zogd2ipdz7js17qy5i", type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], - headline: "Got it. How could we make it easier for you to get started?", + headline: { default: "Got it. How could we make it easier for you to get started?" }, required: true, - subheader: "", - placeholder: "Type your answer here...", + subheader: { default: "" }, + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "rbwz3y6y9avzqcfj30nu0qj4", type: TSurveyQuestionType.OpenText, logic: [{ condition: "submitted", destination: "end" }], - headline: "Got it. What features or functionality were missing?", + headline: { default: "Got it. What features or functionality were missing?" }, required: true, - subheader: "", - placeholder: "Type your answer here...", + subheader: { default: "" }, + placeholder: { default: "Type your answer here..." }, inputType: "text", }, { id: "c0exdyri3erugrv0ezkyseh6", type: TSurveyQuestionType.OpenText, logic: [], - headline: "Please add more details:", + headline: { default: "Please add more details:" }, required: false, - subheader: "", - placeholder: "Type your answer here...", + subheader: { default: "" }, + placeholder: { default: "Type your answer here..." }, inputType: "text", }, ], @@ -2586,7 +2612,7 @@ questions: [ }, */ ]; -export const customSurvey: TTemplate = { +export const customSurvey = { name: "Start from scratch", description: "Create a survey without template.", preset: { @@ -2596,12 +2622,12 @@ export const customSurvey: TTemplate = { { id: createId(), type: TSurveyQuestionType.OpenText, - headline: "What would you like to know?", - subheader: "This is an example survey.", - placeholder: "Type your answer here...", + headline: { default: "What would you like to know?" }, + subheader: { default: "This is an example survey." }, + placeholder: { default: "Type your answer here..." }, required: true, inputType: "text", - }, + } as TSurveyOpenTextQuestion, ], thankYouCard: thankYouCardDefault, hiddenFields: hiddenFieldsDefault, @@ -2643,6 +2669,7 @@ export const minimalSurvey: TSurvey = { styling: null, resultShareKey: null, segment: null, + languages: [], }; export const getFirstSurvey = (webAppUrl: string) => ({ @@ -2652,9 +2679,11 @@ export const getFirstSurvey = (webAppUrl: string) => ({ ({ ...question, type: TSurveyQuestionType.CTA, - headline: "You did it 🎉", - html: "You're all set up. Create your own survey to gather exactly the feedback you need :)", - buttonLabel: "Create survey", + headline: { default: "You did it 🎉" }, + html: { + default: "You're all set up. Create your own survey to gather exactly the feedback you need :)", + }, + buttonLabel: { default: "Create survey" }, buttonExternal: true, imageUrl: `${webAppUrl}/onboarding/meme.png`, }) as TSurveyCTAQuestion diff --git a/apps/web/app/(auth)/auth/signup/components/SignupForm.tsx b/apps/web/app/(auth)/auth/signup/components/SignupForm.tsx index 161cb1b49a..3fe78973ed 100644 --- a/apps/web/app/(auth)/auth/signup/components/SignupForm.tsx +++ b/apps/web/app/(auth)/auth/signup/components/SignupForm.tsx @@ -46,6 +46,7 @@ export const SignupForm = ({ const [error, setError] = useState(""); const [signingUp, setSigningUp] = useState(false); const nameRef = useRef(null); + const [isPasswordFocused, setIsPasswordFocused] = useState(false); const inviteToken = searchParams?.get("inviteToken"); const callbackUrl = useMemo(() => { @@ -85,7 +86,6 @@ export const SignupForm = ({ const [showLogin, setShowLogin] = useState(false); const [isButtonEnabled, setButtonEnabled] = useState(true); - const [isPasswordFocused, setIsPasswordFocused] = useState(false); const formRef = useRef(null); const [password, setPassword] = useState(null); const [isValid, setIsValid] = useState(false); diff --git a/apps/web/app/api/cron/weekly_summary/route.ts b/apps/web/app/api/cron/weekly_summary/route.ts index 4a2f599ec4..33684bfd09 100644 --- a/apps/web/app/api/cron/weekly_summary/route.ts +++ b/apps/web/app/api/cron/weekly_summary/route.ts @@ -3,6 +3,7 @@ import { headers } from "next/headers"; import { prisma } from "@formbricks/database"; import { CRON_SECRET } from "@formbricks/lib/constants"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { sendNoLiveSurveyNotificationEmail, sendWeeklySummaryNotificationEmail } from "./email"; import { EnvironmentData, NotificationResponse, ProductData, Survey, SurveyResponse } from "./types"; @@ -172,7 +173,6 @@ const getNotificationResponse = (environment: EnvironmentData, productName: stri }; const surveys: Survey[] = []; - // iterate through the surveys and calculate the overall insights for (const survey of environment.surveys) { const surveyData: Survey = { @@ -195,7 +195,7 @@ const getNotificationResponse = (environment: EnvironmentData, productName: stri if (answer === null || answer === "" || answer?.length === 0) { continue; } - surveyResponse[headline] = answer; + surveyResponse[getLocalizedValue(headline, "default")] = answer; } surveyData.responses.push(surveyResponse); } diff --git a/apps/web/app/api/pipeline/lib/handleIntegrations.ts b/apps/web/app/api/pipeline/lib/handleIntegrations.ts index cb47b5d1ae..2a33b9f924 100644 --- a/apps/web/app/api/pipeline/lib/handleIntegrations.ts +++ b/apps/web/app/api/pipeline/lib/handleIntegrations.ts @@ -1,5 +1,6 @@ import { writeData as airtableWriteData } from "@formbricks/lib/airtable/service"; import { writeData } from "@formbricks/lib/googleSheet/service"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { writeData as writeNotionData } from "@formbricks/lib/notion/service"; import { getSurvey } from "@formbricks/lib/survey/service"; import { TIntegration } from "@formbricks/types/integration"; @@ -67,7 +68,7 @@ async function extractResponses(data: TPipelineInput, questionIds: string[]): Pr } const question = survey?.questions.find((q) => q.id === questionId); - questions.push(question?.headline || ""); + questions.push(getLocalizedValue(question?.headline, "default") || ""); } return [responses, questions]; diff --git a/apps/web/app/api/pipeline/route.ts b/apps/web/app/api/pipeline/route.ts index d30f4988e6..fb41908b20 100644 --- a/apps/web/app/api/pipeline/route.ts +++ b/apps/web/app/api/pipeline/route.ts @@ -6,6 +6,7 @@ import { prisma } from "@formbricks/database"; import { INTERNAL_SECRET } from "@formbricks/lib/constants"; import { sendResponseFinishedEmail } from "@formbricks/lib/emails/emails"; import { getIntegrations } from "@formbricks/lib/integration/service"; +import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { getResponseCountBySurveyId } from "@formbricks/lib/response/service"; import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service"; import { convertDatesInObject } from "@formbricks/lib/time"; @@ -36,6 +37,8 @@ export async function POST(request: Request) { } const { environmentId, surveyId, event, response } = inputValidation.data; + const product = await getProductByEnvironmentId(environmentId); + if (!product) return; // get all webhooks of this environment where event in triggers const webhooks = await prisma.webhook.findMany({ @@ -153,7 +156,7 @@ export async function POST(request: Request) { const survey = { id: surveyData.id, name: surveyData.name, - questions: JSON.parse(JSON.stringify(surveyData.questions)) as TSurveyQuestion[], + questions: structuredClone(surveyData.questions) as TSurveyQuestion[], }; // send email to all users await Promise.all( diff --git a/apps/web/app/api/v1/(legacy)/client/[environmentId]/people/[userId]/set-attribute/route.ts b/apps/web/app/api/v1/(legacy)/client/[environmentId]/people/[userId]/set-attribute/route.ts index 9706ec061c..5184050fe4 100644 --- a/apps/web/app/api/v1/(legacy)/client/[environmentId]/people/[userId]/set-attribute/route.ts +++ b/apps/web/app/api/v1/(legacy)/client/[environmentId]/people/[userId]/set-attribute/route.ts @@ -8,6 +8,7 @@ import { getPerson, updatePersonAttribute } from "@formbricks/lib/person/service import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { surveyCache } from "@formbricks/lib/survey/cache"; import { getSyncSurveys } from "@formbricks/lib/survey/service"; +import { getTeamByEnvironmentId } from "@formbricks/lib/team/service"; import { TJsStateSync, ZJsPeopleAttributeInput } from "@formbricks/types/js"; interface Context { @@ -69,6 +70,12 @@ export async function POST(req: Request, context: Context): Promise { environmentId, }); + const team = await getTeamByEnvironmentId(environmentId); + + if (!team) { + throw new Error("Team not found"); + } + const [surveys, noCodeActionClasses, product] = await Promise.all([ getSyncSurveys(environmentId, person.id), getActionClasses(environmentId), diff --git a/apps/web/app/api/v1/(legacy)/client/people/[personId]/set-attribute/route.ts b/apps/web/app/api/v1/(legacy)/client/people/[personId]/set-attribute/route.ts index b4977dcd14..d49d9b2e89 100644 --- a/apps/web/app/api/v1/(legacy)/client/people/[personId]/set-attribute/route.ts +++ b/apps/web/app/api/v1/(legacy)/client/people/[personId]/set-attribute/route.ts @@ -8,6 +8,7 @@ import { getPerson, updatePersonAttribute } from "@formbricks/lib/person/service import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { surveyCache } from "@formbricks/lib/survey/cache"; import { getSyncSurveys } from "@formbricks/lib/survey/service"; +import { getTeamByEnvironmentId } from "@formbricks/lib/team/service"; import { TJsStateSync, ZJsPeopleAttributeInput } from "@formbricks/types/js"; interface Context { @@ -68,6 +69,12 @@ export async function POST(req: Request, context: Context): Promise { environmentId, }); + const team = await getTeamByEnvironmentId(environmentId); + + if (!team) { + throw new Error("Team not found"); + } + const [surveys, noCodeActionClasses, product] = await Promise.all([ getSyncSurveys(environmentId, person.id), getActionClasses(environmentId), diff --git a/apps/web/app/api/v1/(legacy)/js/sync/lib/sync.ts b/apps/web/app/api/v1/(legacy)/js/sync/lib/sync.ts index d971c9d35a..359a244faf 100644 --- a/apps/web/app/api/v1/(legacy)/js/sync/lib/sync.ts +++ b/apps/web/app/api/v1/(legacy)/js/sync/lib/sync.ts @@ -6,6 +6,7 @@ import { PRICING_USERTARGETING_FREE_MTU, } from "@formbricks/lib/constants"; import { getEnvironment } from "@formbricks/lib/environment/service"; +import { reverseTranslateSurvey } from "@formbricks/lib/i18n/reverseTranslation"; import { getPerson } from "@formbricks/lib/person/service"; import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { getSurveys, getSyncSurveys } from "@formbricks/lib/survey/service"; @@ -21,7 +22,7 @@ import { TSurvey } from "@formbricks/types/surveys"; export const transformLegacySurveys = (surveys: TSurvey[]): TSurveyWithTriggers[] => { const updatedSurveys = surveys.map((survey) => { - const updatedSurvey: any = { ...survey }; + const updatedSurvey: any = { ...reverseTranslateSurvey(survey) }; updatedSurvey.triggers = updatedSurvey.triggers.map((trigger) => ({ name: trigger })); return updatedSurvey; }); @@ -91,6 +92,7 @@ export const getUpdatedState = async (environmentId: string, personId?: string): const isPerson = Object.keys(person).length > 0; let surveys; + if (isAppSurveyLimitReached) { surveys = []; } else if (isPerson) { diff --git a/apps/web/app/api/v1/client/[environmentId]/in-app/sync/[userId]/route.ts b/apps/web/app/api/v1/client/[environmentId]/in-app/sync/[userId]/route.ts index f45d1c8fc6..3142ffe3a7 100644 --- a/apps/web/app/api/v1/client/[environmentId]/in-app/sync/[userId]/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/in-app/sync/[userId]/route.ts @@ -12,14 +12,17 @@ import { import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service"; import { createPerson, getIsPersonMonthlyActive, getPersonByUserId } from "@formbricks/lib/person/service"; import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; -import { getSyncSurveys } from "@formbricks/lib/survey/service"; +import { getSyncSurveys, transformToLegacySurvey } from "@formbricks/lib/survey/service"; import { getMonthlyActiveTeamPeopleCount, getMonthlyTeamResponseCount, getTeamByEnvironmentId, } from "@formbricks/lib/team/service"; +import { isVersionGreaterThanOrEqualTo } from "@formbricks/lib/utils/version"; +import { TLegacySurvey } from "@formbricks/types/LegacySurvey"; import { TEnvironment } from "@formbricks/types/environment"; import { TJsStateSync, ZJsPeopleUserIdInput } from "@formbricks/types/js"; +import { TSurvey } from "@formbricks/types/surveys"; export async function OPTIONS(): Promise { return responses.successResponse({}, true); @@ -38,9 +41,10 @@ export async function GET( ): Promise { try { const { device } = userAgent(request); - const apiVersion = request.nextUrl.searchParams.get("version"); + const version = request.nextUrl.searchParams.get("version"); // validate using zod + const inputValidation = ZJsPeopleUserIdInput.safeParse({ environmentId: params.environmentId, userId: params.userId, @@ -66,17 +70,17 @@ export async function GET( if (!environment?.widgetSetupCompleted) { await updateEnvironment(environment.id, { widgetSetupCompleted: true }); } + // check team subscriptons + const team = await getTeamByEnvironmentId(environmentId); + + if (!team) { + throw new Error("Team does not exist"); + } // check if MAU limit is reached let isMauLimitReached = false; let isInAppSurveyLimitReached = false; if (IS_FORMBRICKS_CLOUD) { - // check team subscriptons - const team = await getTeamByEnvironmentId(environmentId); - - if (!team) { - throw new Error("Team does not exist"); - } // check userTargeting subscription const hasUserTargetingSubscription = team.billing.features.userTargeting.status && @@ -121,13 +125,14 @@ export async function GET( } } } + if (isInAppSurveyLimitReached) { await sendFreeLimitReachedEventToPosthogBiWeekly(environmentId, "inAppSurvey"); } const [surveys, noCodeActionClasses, product] = await Promise.all([ getSyncSurveys(environmentId, person.id, device.type === "mobile" ? "phone" : "desktop", { - version: apiVersion ?? undefined, + version: version ?? undefined, }), getActionClasses(environmentId), getProductByEnvironmentId(environmentId), @@ -136,16 +141,41 @@ export async function GET( if (!product) { throw new Error("Product not found"); } + const languageAttribute = person.attributes.language; + const isLanguageAvailable = Boolean(languageAttribute); + + const personData = version + ? { + ...(isLanguageAvailable && { attributes: { language: languageAttribute } }), + } + : { + id: person.id, + userId: person.userId, + ...(isLanguageAvailable && { attributes: { language: languageAttribute } }), + }; + + // Define 'transformedSurveys' which can be an array of either TLegacySurvey or TSurvey. + let transformedSurveys: TLegacySurvey[] | TSurvey[]; + + // Backwards compatibility for versions less than 1.7.0 (no multi-language support). + if (version && isVersionGreaterThanOrEqualTo(version, "1.7.0")) { + // Scenario 1: Multi language supported + // Use the surveys as they are. + transformedSurveys = surveys; + } else { + // Scenario 2: Multi language not supported + // Convert to legacy surveys with default language. + transformedSurveys = await Promise.all( + surveys.map((survey) => { + const languageCode = "default"; + return transformToLegacySurvey(survey, languageCode); + }) + ); + } - // return state const state: TJsStateSync = { - person: apiVersion - ? undefined - : { - id: person.id, - userId: person.userId, - }, - surveys: !isInAppSurveyLimitReached ? surveys : [], + person: personData, + surveys: !isInAppSurveyLimitReached ? transformedSurveys : [], noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"), product, }; diff --git a/apps/web/app/api/v1/client/[environmentId]/in-app/sync/route.ts b/apps/web/app/api/v1/client/[environmentId]/in-app/sync/route.ts index 0146050d30..dfb09220e0 100644 --- a/apps/web/app/api/v1/client/[environmentId]/in-app/sync/route.ts +++ b/apps/web/app/api/v1/client/[environmentId]/in-app/sync/route.ts @@ -12,35 +12,46 @@ import { } from "@formbricks/lib/constants"; import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service"; import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; -import { createSurvey, getSurveys } from "@formbricks/lib/survey/service"; +import { createSurvey, getSurveys, transformToLegacySurvey } from "@formbricks/lib/survey/service"; import { getMonthlyTeamResponseCount, getTeamByEnvironmentId } from "@formbricks/lib/team/service"; +import { isVersionGreaterThanOrEqualTo } from "@formbricks/lib/utils/version"; +import { TLegacySurvey } from "@formbricks/types/LegacySurvey"; import { TJsStateSync, ZJsPublicSyncInput } from "@formbricks/types/js"; +import { TSurvey } from "@formbricks/types/surveys"; export async function OPTIONS(): Promise { return responses.successResponse({}, true); } export async function GET( - _: NextRequest, + request: NextRequest, { params }: { params: { environmentId: string } } ): Promise { try { - // validate using zod - const environmentIdValidation = ZJsPublicSyncInput.safeParse({ + const searchParams = request.nextUrl.searchParams; + const version = + searchParams.get("version") === "undefined" || searchParams.get("version") === null + ? undefined + : searchParams.get("version"); + const syncInputValidation = ZJsPublicSyncInput.safeParse({ environmentId: params.environmentId, }); - if (!environmentIdValidation.success) { + if (!syncInputValidation.success) { return responses.badRequestResponse( "Fields are missing or incorrectly formatted", - transformErrorToDetails(environmentIdValidation.error), + transformErrorToDetails(syncInputValidation.error), true ); } - const { environmentId } = environmentIdValidation.data; + const { environmentId } = syncInputValidation.data; const environment = await getEnvironment(environmentId); + const team = await getTeamByEnvironmentId(environmentId); + if (!team) { + throw new Error("Team does not exist"); + } if (!environment) { throw new Error("Environment does not exist"); @@ -50,11 +61,7 @@ export async function GET( let isInAppSurveyLimitReached = false; if (IS_FORMBRICKS_CLOUD) { // check team subscriptons - const team = await getTeamByEnvironmentId(environmentId); - if (!team) { - throw new Error("Team does not exist"); - } // check inAppSurvey subscription const hasInAppSurveySubscription = team.billing.features.inAppSurvey.status && @@ -83,15 +90,36 @@ export async function GET( throw new Error("Product not found"); } + // Common filter condition for selecting surveys that are in progress, are of type 'web' and have no active segment filtering. + let filteredSurveys = surveys.filter( + (survey) => + survey.status === "inProgress" && + survey.type === "web" && + (!survey.segment || survey.segment.filters.length === 0) + ); + + // Define 'transformedSurveys' which can be an array of either TLegacySurvey or TSurvey. + let transformedSurveys: TLegacySurvey[] | TSurvey[]; + + // Backwards compatibility for versions less than 1.7.0 (no multi-language support). + if (version && isVersionGreaterThanOrEqualTo(version, "1.7.0")) { + // Scenario 1: Multi language supported + // Use the surveys as they are. + transformedSurveys = filteredSurveys; + } else { + // Scenario 2: Multi language not supported + // Convert to legacy surveys with default language. + transformedSurveys = await Promise.all( + filteredSurveys.map((survey) => { + const languageCode = "default"; + return transformToLegacySurvey(survey, languageCode); + }) + ); + } + + // Create the 'state' object with surveys, noCodeActionClasses, product, and person. const state: TJsStateSync = { - surveys: !isInAppSurveyLimitReached - ? surveys.filter( - (survey) => - survey.status === "inProgress" && - survey.type === "web" && - (!survey.segment || survey.segment.filters.length === 0) - ) - : [], + surveys: isInAppSurveyLimitReached ? [] : transformedSurveys, noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"), product, person: null, diff --git a/apps/web/app/api/v1/management/surveys/route.ts b/apps/web/app/api/v1/management/surveys/route.ts index f7dc67187b..8db275dc14 100644 --- a/apps/web/app/api/v1/management/surveys/route.ts +++ b/apps/web/app/api/v1/management/surveys/route.ts @@ -2,6 +2,7 @@ import { authenticateRequest } from "@/app/api/v1/auth"; import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; +import { translateSurvey } from "@formbricks/lib/i18n/utils"; import { createSurvey, getSurveys } from "@formbricks/lib/survey/service"; import { DatabaseError } from "@formbricks/types/errors"; import { ZSurveyInput } from "@formbricks/types/surveys"; @@ -29,7 +30,14 @@ export async function POST(request: Request): Promise { try { const authentication = await authenticateRequest(request); if (!authentication) return responses.notAuthenticatedResponse(); - const surveyInput = await request.json(); + let surveyInput = await request.json(); + if (surveyInput?.questions && surveyInput.questions[0].headline) { + const questionHeadline = surveyInput.questions[0].headline; + if (typeof questionHeadline === "string") { + // its a legacy survey + surveyInput = translateSurvey(surveyInput, []); + } + } const inputValidation = ZSurveyInput.safeParse(surveyInput); if (!inputValidation.success) { diff --git a/apps/web/app/lib/questions.ts b/apps/web/app/lib/questions.ts index 4eb7e30e93..8738282a94 100644 --- a/apps/web/app/lib/questions.ts +++ b/apps/web/app/lib/questions.ts @@ -32,9 +32,9 @@ export const questionTypes: TSurveyQuestionType[] = [ description: "Ask for a text-based answer", icon: MessageSquareTextIcon, preset: { - headline: "Who let the dogs out?", - subheader: "Who? Who? Who?", - placeholder: "Type your answer here...", + headline: { default: "Who let the dogs out?" }, + subheader: { default: "Who? Who? Who?" }, + placeholder: { default: "Type your answer here..." }, longAnswer: true, inputType: "text", }, @@ -45,11 +45,11 @@ export const questionTypes: TSurveyQuestionType[] = [ description: "A single choice from a list of options (radio buttons)", icon: Rows3Icon, preset: { - headline: "What do you do?", - subheader: "Can't do both.", + headline: { default: "What do you do?" }, + subheader: { default: "Can't do both." }, choices: [ - { id: createId(), label: "Eat the cake 🍰" }, - { id: createId(), label: "Have the cake 🎂" }, + { id: createId(), label: { default: "Eat the cake 🍰" } }, + { id: createId(), label: { default: "Have the cake 🎂" } }, ], shuffleOption: "none", }, @@ -60,11 +60,11 @@ export const questionTypes: TSurveyQuestionType[] = [ description: "Number of choices from a list of options (checkboxes)", icon: ListIcon, preset: { - headline: "What's important on vacay?", + headline: { default: "What's important on vacay?" }, choices: [ - { id: createId(), label: "Sun ☀️" }, - { id: createId(), label: "Ocean 🌊" }, - { id: createId(), label: "Palms 🌴" }, + { id: createId(), label: { default: "Sun ☀️" } }, + { id: createId(), label: { default: "Ocean 🌊" } }, + { id: createId(), label: { default: "Palms 🌴" } }, ], shuffleOption: "none", }, @@ -75,8 +75,8 @@ export const questionTypes: TSurveyQuestionType[] = [ description: "Ask respondents to select one or more pictures", icon: ImageIcon, preset: { - headline: "Which is the cutest puppy?", - subheader: "You can also pick both.", + headline: { default: "Which is the cutest puppy?" }, + subheader: { default: "You can also pick both." }, allowMulti: true, choices: [ { @@ -96,12 +96,12 @@ export const questionTypes: TSurveyQuestionType[] = [ description: "Ask respondents for a rating", icon: StarIcon, preset: { - headline: "How would you rate {{productName}}", - subheader: "Don't worry, be honest.", + headline: { default: "How would you rate {{productName}}" }, + subheader: { default: "Don't worry, be honest." }, scale: "star", range: 5, - lowerLabel: "Not good", - upperLabel: "Very good", + lowerLabel: { default: "Not good" }, + upperLabel: { default: "Very good" }, }, }, { @@ -110,9 +110,9 @@ export const questionTypes: TSurveyQuestionType[] = [ description: "Rate satisfaction on a 0-10 scale", icon: PresentationIcon, preset: { - headline: "How likely are you to recommend {{productName}} to a friend or colleague?", - lowerLabel: "Not at all likely", - upperLabel: "Extremely likely", + headline: { default: "How likely are you to recommend {{productName}} to a friend or colleague?" }, + lowerLabel: { default: "Not at all likely" }, + upperLabel: { default: "Extremely likely" }, }, }, { @@ -121,8 +121,9 @@ export const questionTypes: TSurveyQuestionType[] = [ description: "Prompt respondents to perform an action", icon: MousePointerClickIcon, preset: { - headline: "You are one of our power users!", - buttonLabel: "Book interview", + headline: { default: "You are one of our power users!" }, + html: { default: "" }, + buttonLabel: { default: "Book interview" }, buttonExternal: false, dismissButtonLabel: "Skip", }, @@ -133,8 +134,9 @@ export const questionTypes: TSurveyQuestionType[] = [ description: "Ask respondents for consent", icon: CheckIcon, preset: { - headline: "Terms and Conditions", - label: "I agree to the terms and conditions", + headline: { default: "Terms and Conditions" }, + html: { default: "" }, + label: { default: "I agree to the terms and conditions" }, dismissButtonLabel: "Skip", }, }, @@ -144,7 +146,7 @@ export const questionTypes: TSurveyQuestionType[] = [ description: "Ask your users to select a date", icon: CalendarDaysIcon, preset: { - headline: "When is your birthday?", + headline: { default: "When is your birthday?" }, format: "M-d-y", }, }, @@ -154,7 +156,7 @@ export const questionTypes: TSurveyQuestionType[] = [ description: "Allow respondents to upload a file", icon: ArrowUpFromLine, preset: { - headline: "File Upload", + headline: { default: "File Upload" }, allowMultipleFiles: false, }, }, @@ -164,8 +166,7 @@ export const questionTypes: TSurveyQuestionType[] = [ description: "Allow respondents to schedule a meet", icon: PhoneIcon, preset: { - headline: "Schedule a call with me", - buttonLabel: "Skip", + headline: { default: "Schedule a call with me" }, calUserName: "rick/get-rick-rolled", }, }, diff --git a/apps/web/app/lib/responses/questionResponseMapping.ts b/apps/web/app/lib/responses/questionResponseMapping.ts deleted file mode 100644 index 6b84e9173b..0000000000 --- a/apps/web/app/lib/responses/questionResponseMapping.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { TResponse } from "@formbricks/types/responses"; -import { TSurveyQuestion } from "@formbricks/types/surveys"; - -export const getQuestionResponseMapping = ( - survey: { questions: TSurveyQuestion[] }, - response: TResponse -): { question: string; answer: string }[] => { - const questionResponseMapping: { question: string; answer: string }[] = []; - - for (const question of survey.questions) { - const answer = response.data[question.id]; - - questionResponseMapping.push({ - question: question.headline, - answer: typeof answer !== "undefined" ? answer.toString() : "", - }); - } - - return questionResponseMapping; -}; diff --git a/apps/web/app/lib/surveys/surveys.ts b/apps/web/app/lib/surveys/surveys.ts index b77341317a..ca9a442deb 100644 --- a/apps/web/app/lib/surveys/surveys.ts +++ b/apps/web/app/lib/surveys/surveys.ts @@ -5,6 +5,7 @@ import { } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"; import { OptionsType, + QuestionOption, QuestionOptions, } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox"; import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter"; @@ -22,6 +23,7 @@ const conditionOptions = { rating: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"], cta: ["is"], tags: ["is"], + languages: ["Equals", "Not equals"], pictureSelection: ["Includes all", "Includes either"], userAttributes: ["Equals", "Not equals"], consent: ["is"], @@ -35,7 +37,7 @@ const filterOptions = { consent: ["Accepted", "Dismissed"], }; -// creating the options for the filtering to be selected there are three types questions, attributes and tags +// creating the options for the filtering to be selected there are 4 types questions, attributes, tags and metadata export const generateQuestionAndFilterOptions = ( survey: TSurvey, environmentTags: TTag[] | undefined, @@ -44,7 +46,7 @@ export const generateQuestionAndFilterOptions = ( questionOptions: QuestionOptions[]; questionFilterOptions: QuestionFilterOptions[]; } => { - let questionOptions: any = []; + let questionOptions: QuestionOptions[] = []; let questionFilterOptions: any = []; let questionsOptions: any = []; @@ -125,6 +127,20 @@ export const generateQuestionAndFilterOptions = ( }); } + let metadataOptions: QuestionOption[] = []; + //can be extended to include more properties + if (survey.languages?.length > 0) { + metadataOptions.push({ label: "Language", type: OptionsType.METADATA, id: "language" }); + const languageOptions = survey.languages.map((sl) => sl.language.code); + questionFilterOptions.push({ + type: "Metadata", + filterOptions: conditionOptions.languages, + filterComboBoxOptions: languageOptions, + id: "language", + }); + } + questionOptions = [...questionOptions, { header: OptionsType.METADATA, option: metadataOptions }]; + return { questionOptions: [...questionOptions], questionFilterOptions: [...questionFilterOptions] }; }; @@ -135,19 +151,20 @@ export const getFormattedFilters = ( dateRange: DateRange ): TResponseFilterCriteria => { const filters: TResponseFilterCriteria = {}; - - const [questions, tags, attributes] = selectedFilter.filter.reduce( - (result: [FilterValue[], FilterValue[], FilterValue[]], filter) => { + const [questions, tags, attributes, metadata] = selectedFilter.filter.reduce( + (result: [FilterValue[], FilterValue[], FilterValue[], FilterValue[]], filter) => { if (filter.questionType?.type === "Questions") { result[0].push(filter); } else if (filter.questionType?.type === "Tags") { result[1].push(filter); } else if (filter.questionType?.type === "Attributes") { result[2].push(filter); + } else if (filter.questionType?.type === "Metadata") { + result[3].push(filter); } return result; }, - [[], [], []] + [[], [], [], []] ); // for completed responses @@ -306,6 +323,24 @@ export const getFormattedFilters = ( }); } + // for metadata + if (metadata.length) { + metadata.forEach(({ filterType, questionType }) => { + if (!filters.metadata) filters.metadata = {}; + + if (filterType.filterValue === "Equals") { + filters.metadata[questionType.label ?? ""] = { + op: "equals", + value: filterType.filterComboBoxValue as string, + }; + } else if (filterType.filterValue === "Not equals") { + filters.metadata[questionType.label ?? ""] = { + op: "notEquals", + value: filterType.filterComboBoxValue as string, + }; + } + }); + } return filters; }; diff --git a/apps/web/app/lib/templates.ts b/apps/web/app/lib/templates.ts index 65561840f9..c720875336 100644 --- a/apps/web/app/lib/templates.ts +++ b/apps/web/app/lib/templates.ts @@ -1,22 +1,33 @@ +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; +import { TProduct } from "@formbricks/types/product"; import { TSurveyQuestion } from "@formbricks/types/surveys"; import { TTemplate } from "@formbricks/types/templates"; -export const replaceQuestionPresetPlaceholders = (question: TSurveyQuestion, product) => { - if (!question) return; +export const replaceQuestionPresetPlaceholders = ( + question: TSurveyQuestion, + product: TProduct +): TSurveyQuestion => { if (!product) return question; - const newQuestion = JSON.parse(JSON.stringify(question)); + const newQuestion = structuredClone(question); + const defaultLanguageCode = "default"; if (newQuestion.headline) { - newQuestion.headline = newQuestion.headline.replace("{{productName}}", product.name); + newQuestion.headline[defaultLanguageCode] = getLocalizedValue( + newQuestion.headline, + defaultLanguageCode + ).replace("{{productName}}", product.name); } if (newQuestion.subheader) { - newQuestion.subheader = newQuestion.subheader?.replace("{{productName}}", product.name); + newQuestion.subheader[defaultLanguageCode] = getLocalizedValue( + newQuestion.subheader, + defaultLanguageCode + )?.replace("{{productName}}", product.name); } return newQuestion; }; // replace all occurences of productName with the actual product name in the current template export const replacePresetPlaceholders = (template: TTemplate, product: any) => { - const preset = JSON.parse(JSON.stringify(template.preset)); + const preset = structuredClone(template.preset); preset.name = preset.name.replace("{{productName}}", product.name); preset.questions = preset.questions.map((question) => { return replaceQuestionPresetPlaceholders(question, product); diff --git a/apps/web/app/s/[surveyId]/components/InvalidLanguage.tsx b/apps/web/app/s/[surveyId]/components/InvalidLanguage.tsx new file mode 100644 index 0000000000..114b082368 --- /dev/null +++ b/apps/web/app/s/[surveyId]/components/InvalidLanguage.tsx @@ -0,0 +1,12 @@ +import { StackedCardsContainer } from "@formbricks/ui/StackedCardsContainer"; + +export default function InvalidLanguage() { + return ( +
+ + 🈂️ +

Survey not available in specified language

+
+
+ ); +} diff --git a/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx b/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx index f36db9e4a5..bd01a4b7d9 100644 --- a/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx +++ b/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx @@ -31,6 +31,7 @@ interface LinkSurveyProps { webAppUrl: string; responseCount?: number; verifiedEmail?: string; + languageCode: string; } export default function LinkSurvey({ @@ -44,14 +45,18 @@ export default function LinkSurvey({ webAppUrl, responseCount, verifiedEmail, + languageCode, }: LinkSurveyProps) { const responseId = singleUseResponse?.id; const searchParams = useSearchParams(); const isPreview = searchParams?.get("preview") === "true"; const sourceParam = searchParams?.get("source"); const suId = searchParams?.get("suId"); - const startAt = searchParams?.get("startAt"); + const defaultLanguageCode = survey.languages?.find((surveyLanguage) => { + return surveyLanguage.default === true; + })?.language.code; + const startAt = searchParams?.get("startAt"); const isStartAtValid = useMemo(() => { if (!startAt) return false; if (survey?.welcomeCard.enabled && startAt === "start") return true; @@ -75,7 +80,7 @@ export default function LinkSurvey({ ); const prefillResponseData: TResponseData | undefined = prefillAnswer - ? getPrefillResponseData(survey.questions[0], survey, prefillAnswer) + ? getPrefillResponseData(survey.questions[0], survey, prefillAnswer, languageCode) : undefined; const brandColor = survey.productOverwrites?.brandColor || product.brandColor; @@ -175,6 +180,7 @@ export default function LinkSurvey({ void) => { setIsError = f; @@ -219,6 +225,8 @@ export default function LinkSurvey({ }, ttc: responseUpdate.ttc, finished: responseUpdate.finished, + language: + languageCode === "default" && defaultLanguageCode ? defaultLanguageCode : languageCode, meta: { url: window.location.href, source: sourceParam || "", diff --git a/apps/web/app/s/[surveyId]/components/PinScreen.tsx b/apps/web/app/s/[surveyId]/components/PinScreen.tsx index 5b4fb11d2a..febcb54ff9 100644 --- a/apps/web/app/s/[surveyId]/components/PinScreen.tsx +++ b/apps/web/app/s/[surveyId]/components/PinScreen.tsx @@ -27,6 +27,7 @@ interface LinkSurveyPinScreenProps { PRIVACY_URL?: string; IS_FORMBRICKS_CLOUD: boolean; verifiedEmail?: string; + languageCode: string; } const LinkSurveyPinScreen: NextPage = (props) => { @@ -43,6 +44,7 @@ const LinkSurveyPinScreen: NextPage = (props) => { PRIVACY_URL, IS_FORMBRICKS_CLOUD, verifiedEmail, + languageCode, } = props; const [localPinEntry, setLocalPinEntry] = useState(""); @@ -123,6 +125,7 @@ const LinkSurveyPinScreen: NextPage = (props) => { singleUseResponse={singleUseResponse} webAppUrl={webAppUrl} verifiedEmail={verifiedEmail} + languageCode={languageCode} /> { - return checkForRecallInHeadline(survey); + return checkForRecallInHeadline(survey, "default"); }, [survey]); const [showPreviewQuestions, setShowPreviewQuestions] = useState(false); diff --git a/apps/web/app/s/[surveyId]/lib/prefilling.ts b/apps/web/app/s/[surveyId]/lib/prefilling.ts index a5c548abe7..54c33a1253 100644 --- a/apps/web/app/s/[surveyId]/lib/prefilling.ts +++ b/apps/web/app/s/[surveyId]/lib/prefilling.ts @@ -5,7 +5,8 @@ import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys"; export function getPrefillResponseData( currentQuestion: TSurveyQuestion, survey: TSurvey, - firstQuestionPrefill: string + firstQuestionPrefill: string, + languageId: string ): TResponseData | undefined { try { if (firstQuestionPrefill) { @@ -15,7 +16,7 @@ export function getPrefillResponseData( const question = survey?.questions.find((q: any) => q.id === firstQuestionId); if (!question) throw new Error("Question not found"); - const answer = transformAnswer(question, firstQuestionPrefill || ""); + const answer = transformAnswer(question, firstQuestionPrefill || "", languageId); const answerObj = { [firstQuestionId]: answer }; if ( @@ -35,7 +36,7 @@ export function getPrefillResponseData( // eslint-disable-next-line react-hooks/exhaustive-deps } -export const checkValidity = (question: TSurveyQuestion, answer: any): boolean => { +export const checkValidity = (question: TSurveyQuestion, answer: any, language: string): boolean => { if (question.required && (!answer || answer === "")) return false; try { switch (question.type) { @@ -54,7 +55,9 @@ export const checkValidity = (question: TSurveyQuestion, answer: any): boolean = answer = answer.split(","); const hasOther = question.choices[question.choices.length - 1].id === "other"; if (!hasOther) { - if (!answer.every((ans: string) => question.choices.find((choice) => choice.label === ans))) + if ( + !answer.every((ans: string) => question.choices.find((choice) => choice.label[language] === ans)) + ) return false; return true; } @@ -98,7 +101,11 @@ export const checkValidity = (question: TSurveyQuestion, answer: any): boolean = } }; -export const transformAnswer = (question: TSurveyQuestion, answer: string): string | number | string[] => { +export const transformAnswer = ( + question: TSurveyQuestion, + answer: string, + language: string +): string | number | string[] => { switch (question.type) { case TSurveyQuestionType.OpenText: case TSurveyQuestionType.MultipleChoiceSingle: @@ -124,8 +131,8 @@ export const transformAnswer = (question: TSurveyQuestion, answer: string): stri // answer can be "a,b,c,d" and options can be a,c,others so we are filtering out the options that are not in the options list and sending these non-existing values as a single string(representing others) like "a", "c", "b,d" const options = question.choices.map((o) => o.label); - const others = ansArr.filter((a: string) => !options.includes(a)); - if (others.length > 0) ansArr = ansArr.filter((a: string) => options.includes(a)); + const others = ansArr.filter((a: string) => !options.includes(a[language])); + if (others.length > 0) ansArr = ansArr.filter((a: string) => options.includes(a[language])); if (others.length > 0) ansArr.push(others.join(",")); return ansArr; } diff --git a/apps/web/app/s/[surveyId]/page.tsx b/apps/web/app/s/[surveyId]/page.tsx index 5a82eb1163..7a789692a1 100644 --- a/apps/web/app/s/[surveyId]/page.tsx +++ b/apps/web/app/s/[surveyId]/page.tsx @@ -8,11 +8,13 @@ import { checkValidity } from "@/app/s/[surveyId]/lib/prefilling"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; +import { getMultiLanguagePermission } from "@formbricks/ee/lib/service"; import { IMPRINT_URL, IS_FORMBRICKS_CLOUD, PRIVACY_URL, WEBAPP_URL } from "@formbricks/lib/constants"; import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service"; import { getProductByEnvironmentId } from "@formbricks/lib/product/service"; import { getResponseBySingleUseId, getResponseCountBySurveyId } from "@formbricks/lib/response/service"; import { getSurvey } from "@formbricks/lib/survey/service"; +import { getTeamByEnvironmentId } from "@formbricks/lib/team/service"; import { ZId } from "@formbricks/types/environment"; import { TResponse } from "@formbricks/types/responses"; @@ -26,6 +28,7 @@ interface LinkSurveyPageProps { suId?: string; userId?: string; verify?: string; + lang?: string; }; } @@ -89,6 +92,7 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve const survey = await getSurvey(params.surveyId); const suId = searchParams.suId; + const langParam = searchParams.lang; //can either be language code or alias const isSingleUseSurvey = survey?.singleUse?.enabled; const isSingleUseSurveyEncrypted = survey?.singleUse?.isEncrypted; @@ -96,9 +100,11 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve notFound(); } - // question pre filling: Check if the first question is prefilled and if it is valid - const prefillAnswer = searchParams[survey.questions[0].id]; - const isPrefilledAnswerValid = prefillAnswer ? checkValidity(survey!.questions[0], prefillAnswer) : false; + const team = await getTeamByEnvironmentId(survey?.environmentId); + if (!team) { + throw new Error("Team not found"); + } + const isMultiLanguageAllowed = getMultiLanguagePermission(team); if (survey && survey.status !== "inProgress") { return ( @@ -162,6 +168,21 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve throw new Error("Product not found"); } + const getLanguageCode = (): string => { + if (!langParam || !isMultiLanguageAllowed) return "default"; + else { + const selectedLanguage = survey.languages.find((surveyLanguage) => { + return surveyLanguage.language.code === langParam || surveyLanguage.language.alias === langParam; + }); + if (selectedLanguage?.default || !selectedLanguage?.enabled) { + return "default"; + } + return selectedLanguage ? selectedLanguage.language.code : "default"; + } + }; + + const languageCode = getLanguageCode(); + const userId = searchParams.userId; if (userId) { // make sure the person exists or get's created @@ -173,6 +194,13 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve const isSurveyPinProtected = Boolean(!!survey && survey.pin); const responseCount = await getResponseCountBySurveyId(survey.id); + + // question pre filling: Check if the first question is prefilled and if it is valid + const prefillAnswer = searchParams[survey.questions[0].id]; + const isPrefilledAnswerValid = prefillAnswer + ? checkValidity(survey!.questions[0], prefillAnswer, languageCode) + : false; + if (isSurveyPinProtected) { return ( ); } @@ -206,6 +235,7 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve webAppUrl={WEBAPP_URL} responseCount={survey.welcomeCard.showResponseCount ? responseCount : undefined} verifiedEmail={verifiedEmail} + languageCode={languageCode} /> { - return checkForRecallInHeadline(survey); + return checkForRecallInHeadline(survey, "default"); }, [survey]); const fetchNextPage = useCallback(async () => { diff --git a/apps/web/app/share/[sharingKey]/(analysis)/summary/components/SummaryPage.tsx b/apps/web/app/share/[sharingKey]/(analysis)/summary/components/SummaryPage.tsx index a696a9d37e..adb5700240 100644 --- a/apps/web/app/share/[sharingKey]/(analysis)/summary/components/SummaryPage.tsx +++ b/apps/web/app/share/[sharingKey]/(analysis)/summary/components/SummaryPage.tsx @@ -84,7 +84,7 @@ const SummaryPage = ({ }, [filters, sharingKey]); survey = useMemo(() => { - return checkForRecallInHeadline(survey); + return checkForRecallInHeadline(survey, "default"); }, [survey]); const searchParams = useSearchParams(); diff --git a/apps/web/playwright/survey.spec.ts b/apps/web/playwright/survey.spec.ts index 576ffda34c..bd8ab075d8 100644 --- a/apps/web/playwright/survey.spec.ts +++ b/apps/web/playwright/survey.spec.ts @@ -1,6 +1,8 @@ import { surveys, users } from "@/playwright/utils/mock"; import { expect, test } from "@playwright/test"; +import { signUpAndLogin } from "./utils/helper"; +import { finishOnboarding } from "./utils/helper"; import { createSurvey } from "./utils/helper"; test.describe("Survey Create & Submit Response", async () => { @@ -176,3 +178,206 @@ test.describe("Survey Create & Submit Response", async () => { await expect(page.getByText(surveys.createAndSubmit.thankYouCard.description)).toBeVisible(); }); }); + +test.describe("Multi Language Survey Create", async () => { + test.describe.configure({ mode: "serial" }); + const { name, email, password } = users.survey[2]; + test("Create Survey", async ({ page }) => { + await signUpAndLogin(page, name, email, password); + await finishOnboarding(page); + + //add a new language + await page.getByRole("link", { name: "Settings" }).click(); + await page.getByRole("link", { name: "Survey Languages" }).click(); + await page.getByRole("button", { name: "Edit Languages" }).click(); + await page.getByRole("button", { name: "Add Language" }).click(); + await page.getByRole("button", { name: "Select" }).click(); + await page.getByPlaceholder("Search items").click(); + await page.getByPlaceholder("Search items").fill("Eng"); + await page.getByText("English").click(); + await page.getByRole("button", { name: "Save Changes" }).click(); + await page.getByRole("button", { name: "Edit Languages" }).click(); + await page.getByRole("button", { name: "Add Language" }).click(); + await page.getByRole("button", { name: "Select" }).click(); + await page.getByRole("textbox", { name: "Search items" }).click(); + await page.getByRole("textbox", { name: "Search items" }).fill("German"); + await page.getByText("German").nth(1).click(); + await page.getByRole("button", { name: "Save Changes" }).click(); + await new Promise((resolve) => setTimeout(resolve, 2000)); + await page.getByRole("link", { name: "Surveys" }).click(); + await page.getByRole("button", { name: "Start from scratch Create a" }).click(); + await page.locator("#multi-lang-toggle").click(); + await page.getByText("Multiple LanguagesOn").click(); + await page.getByRole("combobox").click(); + await page.getByLabel("English (en)").click(); + await page.getByRole("button", { name: "Set English as default" }).click(); + await page.getByLabel("German").click(); + await page.locator("#welcome-toggle").click(); + await page.getByText("Welcome CardShownOn").click(); + + // Add questions in default language + await page.getByText("Add Question").click(); + await page.getByRole("button", { name: "Single-Select" }).click(); + await page + .locator("div") + .filter({ hasText: /^Add QuestionAdd a new question to your survey$/ }) + .nth(1) + .click(); + await page.getByRole("button", { name: "Multi-Select" }).click(); + await page + .locator("div") + .filter({ hasText: /^Add QuestionAdd a new question to your survey$/ }) + .nth(1) + .click(); + await page.getByRole("button", { name: "Picture Selection" }).click(); + await page + .locator("div") + .filter({ hasText: /^Add QuestionAdd a new question to your survey$/ }) + .nth(1) + .click(); + await page.getByRole("button", { name: "Rating" }).click(); + await page + .locator("div") + .filter({ hasText: /^Add QuestionAdd a new question to your survey$/ }) + .nth(1) + .click(); + await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).click(); + await page + .locator("div") + .filter({ hasText: /^Add QuestionAdd a new question to your survey$/ }) + .nth(1) + .click(); + await page.getByRole("button", { name: "Date" }).click(); + await page + .locator("div") + .filter({ hasText: /^Add QuestionAdd a new question to your survey$/ }) + .nth(1) + .click(); + await page.getByRole("button", { name: "File Upload" }).click(); + + // Enable translation in german + await page.getByText("Welcome CardShownOn").click(); + await page.getByRole("button", { name: "English" }).first().click(); + await page.getByRole("button", { name: "German" }).click(); + + // Fill welcome card in german + await page.getByLabel("Headline").click(); + await page.getByLabel("Headline").fill(surveys.germanCreate.welcomeCard.headline); + await page.locator(".editor-input").click(); + await page.locator(".editor-input").fill(surveys.germanCreate.welcomeCard.description); + + // Fill Open text question in german + await page.getByRole("button", { name: "Free text Required" }).click(); + await page.getByPlaceholder("Your question here. Recall").click(); + await page + .getByPlaceholder("Your question here. Recall") + .fill(surveys.germanCreate.openTextQuestion.question); + await page.getByPlaceholder("Your question here. Recall").press("Tab"); + await page + .getByPlaceholder("Your description here. Recall") + .fill(surveys.germanCreate.openTextQuestion.description); + await page.getByLabel("Placeholder").click(); + await page.getByLabel("Placeholder").fill(surveys.germanCreate.openTextQuestion.placeholder); + + // Fill Single select question in german + await page.getByRole("button", { name: "Single-Select Required" }).click(); + await page.getByPlaceholder("Your question here. Recall").click(); + await page + .getByPlaceholder("Your question here. Recall") + .fill(surveys.germanCreate.singleSelectQuestion.question); + await page.getByPlaceholder("Your description here. Recall").click(); + await page + .getByPlaceholder("Your description here. Recall") + .fill(surveys.germanCreate.singleSelectQuestion.description); + await page.getByPlaceholder("Option 1").click(); + await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.singleSelectQuestion.options[0]); + await page.getByPlaceholder("Option 2").click(); + await page.getByPlaceholder("Option 2").fill(surveys.germanCreate.singleSelectQuestion.options[1]); + + // Fill Multi select question in german + await page.getByRole("button", { name: "Multi-Select Required" }).click(); + await page.getByPlaceholder("Your question here. Recall").click(); + await page + .getByPlaceholder("Your question here. Recall") + .fill(surveys.germanCreate.multiSelectQuestion.question); + await page.getByPlaceholder("Option 1").click(); + await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.multiSelectQuestion.options[0]); + await page.getByPlaceholder("Option 2").click(); + await page.getByPlaceholder("Option 2").fill(surveys.germanCreate.multiSelectQuestion.options[1]); + await page.getByPlaceholder("Option 3").click(); + await page.getByPlaceholder("Option 3").fill(surveys.germanCreate.multiSelectQuestion.options[2]); + + // Fill Picture select question in german + await page.getByRole("button", { name: "Picture Selection Required" }).click(); + await page.getByPlaceholder("Your question here. Recall").click(); + await page + .getByPlaceholder("Your question here. Recall") + .fill(surveys.germanCreate.pictureSelectQuestion.question); + await page.getByPlaceholder("Your description here. Recall").click(); + await page + .getByPlaceholder("Your description here. Recall") + .fill(surveys.germanCreate.pictureSelectQuestion.description); + + // Fill Rating question in german + await page.getByRole("button", { name: "Rating Required" }).click(); + await page.getByRole("button", { name: "5 Rating Question Question" }).click(); + await page.getByPlaceholder("Your question here. Recall").click(); + await page + .getByPlaceholder("Your question here. Recall") + .fill(surveys.germanCreate.ratingQuestion.question); + await page.getByPlaceholder("Your description here. Recall").click(); + await page + .getByPlaceholder("Your description here. Recall") + .fill(surveys.germanCreate.ratingQuestion.description); + await page.getByPlaceholder("Not good").click(); + await page.getByPlaceholder("Not good").fill(surveys.germanCreate.ratingQuestion.lowLabel); + await page.getByPlaceholder("Very satisfied").click(); + await page.getByPlaceholder("Very satisfied").fill(surveys.germanCreate.ratingQuestion.highLabel); + + // Fill NPS question in german + await page.getByRole("button", { name: "Net Promoter Score (NPS) Required" }).click(); + await page.getByPlaceholder("Your question here. Recall").click(); + await page.getByPlaceholder("Your question here. Recall").fill(surveys.germanCreate.npsQuestion.question); + await page.getByLabel("Lower Label").click(); + await page.getByLabel("Lower Label").fill(surveys.germanCreate.npsQuestion.lowLabel); + await page.getByLabel("Upper Label").click(); + await page.getByLabel("Upper Label").fill(surveys.germanCreate.npsQuestion.highLabel); + + // Fill Date question in german + await page.getByRole("button", { name: "Date Required" }).click(); + await page.getByPlaceholder("Your question here. Recall").click(); + await page + .getByPlaceholder("Your question here. Recall") + .fill(surveys.germanCreate.dateQuestion.question); + + // Fill File upload question in german + await page.getByRole("button", { name: "File Upload Required" }).click(); + await page.getByPlaceholder("Your question here. Recall").click(); + await page + .getByPlaceholder("Your question here. Recall") + .fill(surveys.germanCreate.fileUploadQuestion.question); + + // Fill Thank you card in german + await page + .locator("div") + .filter({ hasText: /^Thank You CardShown$/ }) + .first() + .click(); + await page.getByPlaceholder("Your question here. Recall").click(); + await page + .getByPlaceholder("Your question here. Recall") + .fill(surveys.germanCreate.thankYouCard.headline); + await page.getByPlaceholder("Your description here. Recall").click(); + await page + .getByPlaceholder("Your description here. Recall") + .fill(surveys.germanCreate.thankYouCard.description); + await page.getByPlaceholder("Create your own Survey").click(); + await page.getByPlaceholder("Create your own Survey").fill(surveys.germanCreate.thankYouCard.buttonLabel); + await page.getByRole("button", { name: "Continue to Settings" }).click(); + await page.getByRole("button", { name: "Publish" }).click(); + + await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary$/); + await page.getByLabel("Select Language").click(); + await page.getByText("German").click(); + }); +}); diff --git a/apps/web/playwright/utils/helper.ts b/apps/web/playwright/utils/helper.ts index 6f4c8d2e03..4d84642dda 100644 --- a/apps/web/playwright/utils/helper.ts +++ b/apps/web/playwright/utils/helper.ts @@ -141,7 +141,7 @@ export const createSurvey = async ( await page.locator("#welcome-toggle").check(); await page.getByLabel("Headline").fill(params.welcomeCard.headline); await page.locator("form").getByText("Thanks for providing your").fill(params.welcomeCard.description); - await page.getByText("Welcome CardEnabled").click(); + await page.getByText("Welcome CardOn").click(); // Open Text Question await page.getByRole("button", { name: "1 What would you like to know" }).click(); @@ -198,11 +198,7 @@ export const createSurvey = async ( await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).click(); await page.getByLabel("Question").fill(params.npsQuestion.question); await page.getByLabel("Lower label").fill(params.npsQuestion.lowLabel); - await page - .locator("div") - .filter({ hasText: /^Upper label$/ }) - .locator("#subheader") - .fill(params.npsQuestion.highLabel); + await page.getByLabel("Upper label").fill(params.npsQuestion.highLabel); // CTA Question await page diff --git a/apps/web/playwright/utils/mock.ts b/apps/web/playwright/utils/mock.ts index fce6743d86..398808cc78 100644 --- a/apps/web/playwright/utils/mock.ts +++ b/apps/web/playwright/utils/mock.ts @@ -145,6 +145,64 @@ export const surveys = { description: "This is my Thank you Card Description!", }, }, + germanCreate: { + welcomeCard: { + headline: "Willkommen zu meiner Testumfrage Willkommenskarte!", // German translation + description: "Dies ist die Beschreibung meiner Willkommenskarte!", // German translation + }, + openTextQuestion: { + question: "Dies ist meine offene Textfrage", // German translation + description: "Dies ist meine Beschreibung zum offenen Text", // German translation + placeholder: "Dies ist mein Platzhalter", // German translation + }, + singleSelectQuestion: { + question: "Dies ist meine Einzelauswahlfrage", // German translation + description: "Dies ist meine Beschreibung zur Einzelauswahl", // German translation + options: ["Option 1", "Option 2"], // Translated options + }, + multiSelectQuestion: { + question: "Dies ist meine Mehrfachauswahlfrage", // German translation + description: "Dies ist die Beschreibung zur Mehrfachauswahl", // German translation + options: ["Option 1", "Option 2", "Option 3"], // Translated options + }, + ratingQuestion: { + question: "Dies ist meine Bewertungsfrage", // German translation + description: "Dies ist die Beschreibung zur Bewertung", // German translation + lowLabel: "Mein unteres Label", // German translation + highLabel: "Mein oberes Label", // German translation + }, + npsQuestion: { + question: "Dies ist meine NPS-Frage", // German translation + lowLabel: "Mein unteres Label", // German translation + highLabel: "Mein oberes Label", // German translation + }, + ctaQuestion: { + question: "Dies ist meine CTA-Frage", // German translation + buttonLabel: "Mein Knopfetikett", // German translation + }, + consentQuestion: { + question: "Dies ist meine Zustimmungsfrage", // German translation + checkboxLabel: "Mein Kontrollkästchen-Label", // German translation + }, + pictureSelectQuestion: { + question: "Dies ist meine Bildauswahlfrage", // German translation + description: "Dies ist die Beschreibung zur Bildauswahl", // German translation + }, + fileUploadQuestion: { + question: "Dies ist meine Datei-Upload-Frage", // German translation + }, + dateQuestion: { + question: "Dies ist date question", // German translation + }, + calQuestion: { + question: "Dies ist cal question", // German translation + }, + thankYouCard: { + headline: "Dies ist meine Dankeskarte Überschrift!", // German translation + description: "Dies ist meine Beschreibung zur Dankeskarte!", // German translation + buttonLabel: "Erstellen Sie Ihre eigene Umfrage", + }, + }, }; export type CreateSurveyParams = typeof surveys.createAndSubmit; diff --git a/packages/database/migrations/20240318050527_add_languages_and_survey_languages/data-migration.ts b/packages/database/migrations/20240318050527_add_languages_and_survey_languages/data-migration.ts new file mode 100644 index 0000000000..0e422cab60 --- /dev/null +++ b/packages/database/migrations/20240318050527_add_languages_and_survey_languages/data-migration.ts @@ -0,0 +1,92 @@ +import { PrismaClient } from "@prisma/client"; +import { AttributeType } from "@prisma/client"; + +import { translateSurvey } from "./lib/i18n"; + +const prisma = new PrismaClient(); + +async function main() { + await prisma.$transaction(async (tx) => { + const surveys = await tx.survey.findMany({ + select: { + id: true, + questions: true, + thankYouCard: true, + welcomeCard: true, + }, + }); + + if (!surveys) { + // stop the migration if there are no surveys + return; + } + + for (const survey of surveys) { + if (survey.questions.length > 0 && typeof survey.questions[0].headline === "string") { + const translatedSurvey = translateSurvey(survey, []); + + // Save the translated survey + await tx.survey.update({ + where: { id: survey.id }, + data: { ...translatedSurvey }, + }); + } + } + }); + + await prisma.$transaction(async (tx) => { + const environments = await tx.environment.findMany({ + select: { + id: true, + attributeClasses: true, + }, + }); + + if (!environments) { + // stop the migration if there are no environments + return; + } + + for (const environment of environments) { + const languageAttributeClass = environment.attributeClasses.find((attributeClass) => { + return attributeClass.name === "language"; + }); + if (languageAttributeClass) { + // Update existing attributeClass if needed + if ( + languageAttributeClass.type === AttributeType.automatic && + languageAttributeClass.description === "The language used by the person" + ) { + continue; + } + + await tx.attributeClass.update({ + where: { id: languageAttributeClass.id }, + data: { + type: AttributeType.automatic, + description: "The language used by the person", + }, + }); + } else { + // Create new attributeClass + await tx.attributeClass.create({ + data: { + name: "language", + type: AttributeType.automatic, + description: "The language used by the person", + environment: { + connect: { id: environment.id }, + }, + }, + }); + } + } + }); +} + +main() + .catch(async (e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => await prisma.$disconnect()); diff --git a/packages/database/migrations/20240318050527_add_languages_and_survey_languages/lib/i18n.ts b/packages/database/migrations/20240318050527_add_languages_and_survey_languages/lib/i18n.ts new file mode 100644 index 0000000000..153c84a94c --- /dev/null +++ b/packages/database/migrations/20240318050527_add_languages_and_survey_languages/lib/i18n.ts @@ -0,0 +1,190 @@ +import { TLanguage } from "@formbricks/types/product"; +import { + TI18nString, + TSurveyCTAQuestion, + TSurveyConsentQuestion, + TSurveyNPSQuestion, + TSurveyOpenTextQuestion, + TSurveyRatingQuestion, + TSurveyThankYouCard, + TSurveyWelcomeCard, +} from "@formbricks/types/surveys"; +import { TSurvey, TSurveyMultipleChoiceMultiQuestion, TSurveyQuestion } from "@formbricks/types/surveys"; + +// Helper function to create an i18nString from a regular string. +export const createI18nString = (text: string | TI18nString, languages: string[]): TI18nString => { + if (typeof text === "object") { + // It's already an i18n object, so clone it + const i18nString: TI18nString = structuredClone(text); + // Add new language keys with empty strings if they don't exist + languages?.forEach((language) => { + if (!(language in i18nString)) { + i18nString[language] = ""; + } + }); + + // Remove language keys that are not in the languages array + Object.keys(i18nString).forEach((key) => { + if (key !== "default" && languages && !languages.includes(key)) { + delete i18nString[key]; + } + }); + + return i18nString; + } else { + // It's a regular string, so create a new i18n object + const i18nString: any = { + ["default"]: text as string, // Type assertion to assure TypeScript `text` is a string + }; + + // Initialize all provided languages with empty strings + languages?.forEach((language) => { + if (language !== "default") { + i18nString[language] = ""; + } + }); + + return i18nString; + } +}; + +// Function to translate a choice label +const translateChoice = (choice: any, languages: string[]) => { + // Assuming choice is a simple object and choice.label is a string. + return { + ...choice, + label: createI18nString(choice.label, languages), + }; +}; +export const translateWelcomeCard = ( + welcomeCard: TSurveyWelcomeCard, + languages: string[] +): TSurveyWelcomeCard => { + const clonedWelcomeCard = structuredClone(welcomeCard); + clonedWelcomeCard.headline = createI18nString(welcomeCard.headline, languages); + clonedWelcomeCard.html = createI18nString(welcomeCard.html ?? "", languages); + if (clonedWelcomeCard.buttonLabel) { + clonedWelcomeCard.buttonLabel = createI18nString(clonedWelcomeCard.buttonLabel, languages); + } + + return clonedWelcomeCard; +}; + +const translateThankYouCard = ( + thankYouCard: TSurveyThankYouCard, + languages: string[] +): TSurveyThankYouCard => { + const clonedThankYouCard = structuredClone(thankYouCard); + clonedThankYouCard.headline = createI18nString( + thankYouCard.headline ? thankYouCard.headline : "", + languages + ); + if (clonedThankYouCard.subheader) { + clonedThankYouCard.subheader = createI18nString( + thankYouCard.subheader ? thankYouCard.subheader : "", + languages + ); + } + + return clonedThankYouCard; +}; + +// Function that will translate a single question +const translateQuestion = (question: TSurveyQuestion, languages: string[]) => { + // Clone the question to avoid mutating the original + const clonedQuestion = structuredClone(question); + + clonedQuestion.headline = createI18nString(question.headline, languages); + if (clonedQuestion.subheader) { + clonedQuestion.subheader = createI18nString(question.subheader ?? "", languages); + } + + if (clonedQuestion.buttonLabel) { + clonedQuestion.buttonLabel = createI18nString(question.buttonLabel ?? "", languages); + } + + if (clonedQuestion.backButtonLabel) { + clonedQuestion.backButtonLabel = createI18nString(question.backButtonLabel ?? "", languages); + } + + if (question.type === "multipleChoiceSingle" || question.type === "multipleChoiceMulti") { + (clonedQuestion as TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceMultiQuestion).choices = + question.choices.map((choice) => translateChoice(structuredClone(choice), languages)); + ( + clonedQuestion as TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceMultiQuestion + ).otherOptionPlaceholder = question.otherOptionPlaceholder + ? createI18nString(question.otherOptionPlaceholder, languages) + : undefined; + } + if (question.type === "openText") { + (clonedQuestion as TSurveyOpenTextQuestion).placeholder = createI18nString( + question.placeholder ?? "", + languages + ); + } + if (question.type === "cta") { + if (question.dismissButtonLabel) { + (clonedQuestion as TSurveyCTAQuestion).dismissButtonLabel = createI18nString( + question.dismissButtonLabel, + languages + ); + } + if (question.html) { + (clonedQuestion as TSurveyCTAQuestion).html = createI18nString(question.html, languages); + } + } + if (question.type === "consent") { + if (question.html) { + (clonedQuestion as TSurveyConsentQuestion).html = createI18nString(question.html, languages); + } + + if (question.label) { + (clonedQuestion as TSurveyConsentQuestion).label = createI18nString(question.label, languages); + } + } + if (question.type === "nps") { + (clonedQuestion as TSurveyNPSQuestion).lowerLabel = createI18nString( + question.lowerLabel ?? "", + languages + ); + (clonedQuestion as TSurveyNPSQuestion).upperLabel = createI18nString( + question.upperLabel ?? "", + languages + ); + } + if (question.type === "rating") { + (clonedQuestion as TSurveyRatingQuestion).lowerLabel = createI18nString( + question.lowerLabel ?? "", + languages + ); + (clonedQuestion as TSurveyRatingQuestion).upperLabel = createI18nString( + question.upperLabel ?? "", + languages + ); + } + return clonedQuestion; +}; + +export const extractLanguageIds = (languages: TLanguage[]): string[] => { + return languages.map((language) => language.id); +}; + +// Function to translate an entire survey +export const translateSurvey = ( + survey: Pick, + surveyLanguages: TLanguage[] +): Pick => { + const languages = extractLanguageIds(surveyLanguages); + const translatedQuestions = survey.questions.map((question) => { + return translateQuestion(question, languages); + }); + const translatedWelcomeCard = translateWelcomeCard(survey.welcomeCard, languages); + const translatedThankYouCard = translateThankYouCard(survey.thankYouCard, languages); + const translatedSurvey = structuredClone(survey); + return { + ...translatedSurvey, + questions: translatedQuestions, + welcomeCard: translatedWelcomeCard, + thankYouCard: translatedThankYouCard, + }; +}; diff --git a/packages/database/migrations/20240318050527_add_languages_and_survey_languages/migration.sql b/packages/database/migrations/20240318050527_add_languages_and_survey_languages/migration.sql new file mode 100644 index 0000000000..bb62b4fc86 --- /dev/null +++ b/packages/database/migrations/20240318050527_add_languages_and_survey_languages/migration.sql @@ -0,0 +1,42 @@ +-- AlterTable +ALTER TABLE "Response" ADD COLUMN "language" TEXT; + +-- CreateTable +CREATE TABLE "Language" ( + "id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "code" TEXT NOT NULL, + "alias" TEXT, + "productId" TEXT NOT NULL, + + CONSTRAINT "Language_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SurveyLanguage" ( + "languageId" TEXT NOT NULL, + "surveyId" TEXT NOT NULL, + "default" BOOLEAN NOT NULL DEFAULT false, + "enabled" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "SurveyLanguage_pkey" PRIMARY KEY ("languageId","surveyId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Language_productId_code_key" ON "Language"("productId", "code"); + +-- CreateIndex +CREATE INDEX "SurveyLanguage_surveyId_idx" ON "SurveyLanguage"("surveyId"); + +-- CreateIndex +CREATE INDEX "SurveyLanguage_languageId_idx" ON "SurveyLanguage"("languageId"); + +-- AddForeignKey +ALTER TABLE "Language" ADD CONSTRAINT "Language_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SurveyLanguage" ADD CONSTRAINT "SurveyLanguage_languageId_fkey" FOREIGN KEY ("languageId") REFERENCES "Language"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SurveyLanguage" ADD CONSTRAINT "SurveyLanguage_surveyId_fkey" FOREIGN KEY ("surveyId") REFERENCES "Survey"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/package.json b/packages/database/package.json index 4a38660875..ecc976a73a 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -23,7 +23,8 @@ "lint": "eslint ./src --fix", "post-install": "pnpm generate", "predev": "pnpm generate", - "data-migration:v1.6": "ts-node ./migrations/20240207041922_advanced_targeting/data-migration.ts" + "data-migration:v1.6": "ts-node ./migrations/20240207041922_advanced_targeting/data-migration.ts", + "data-migration:mls": "ts-node ./migrations/20240318050527_add_languages_and_survey_languages/data-migration.ts" }, "dependencies": { "@prisma/client": "^5.11.0", diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index 43ce652aed..06930b4934 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -131,6 +131,7 @@ model Response { personAttributes Json? // singleUseId, used to prevent multiple responses singleUseId String? + language String? @@unique([surveyId, singleUseId]) @@index([surveyId, createdAt]) // to determine monthly response count @@ -289,9 +290,8 @@ model Survey { /// @zod.custom(imports.ZSurveyClosedMessage) /// [SurveyClosedMessage] surveyClosedMessage Json? - - segmentId String? - segment Segment? @relation(fields: [segmentId], references: [id]) + segmentId String? + segment Segment? @relation(fields: [segmentId], references: [id]) /// @zod.custom(imports.ZSurveyProductOverwrites) /// [SurveyProductOverwrites] @@ -309,8 +309,9 @@ model Survey { /// [SurveyVerifyEmail] verifyEmail Json? pin String? - resultShareKey String? @unique + resultShareKey String? @unique displayPercentage Int? + languages SurveyLanguage[] @@index([environmentId]) @@index([segmentId]) @@ -426,6 +427,7 @@ model Product { placement WidgetPlacement @default(bottomRight) clickOutsideClose Boolean @default(true) darkOverlay Boolean @default(false) + languages Language[] @@unique([teamId, name]) @@index([teamId]) @@ -607,3 +609,29 @@ model Segment { @@unique([environmentId, title]) @@index([environmentId]) } + +model Language { + id String @id @default(cuid()) + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @updatedAt @map(name: "updated_at") + code String + alias String? + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + productId String + surveyLanguages SurveyLanguage[] + + @@unique([productId, code]) +} + +model SurveyLanguage { + language Language @relation(fields: [languageId], references: [id], onDelete: Cascade) + languageId String + surveyId String + survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade) + default Boolean @default(false) + enabled Boolean @default(true) + + @@id([languageId, surveyId]) + @@index([surveyId]) + @@index([languageId]) +} diff --git a/packages/database/zod-utils.ts b/packages/database/zod-utils.ts index 14432218a8..97e0257c28 100644 --- a/packages/database/zod-utils.ts +++ b/packages/database/zod-utils.ts @@ -26,4 +26,5 @@ export { export { ZSegmentFilters } from "@formbricks/types/segment"; export { ZTeamBilling } from "@formbricks/types/teams"; +export { ZLanguages } from "@formbricks/types/product"; export { ZUserNotificationSettings } from "@formbricks/types/user"; diff --git a/packages/ee/lib/service.ts b/packages/ee/lib/service.ts index e0529c5759..790419c971 100644 --- a/packages/ee/lib/service.ts +++ b/packages/ee/lib/service.ts @@ -33,3 +33,9 @@ export const getAdvancedTargetingPermission = (team: TTeam): boolean => { else if (!IS_FORMBRICKS_CLOUD) return getIsEnterpriseEdition(); else return false; }; + +export const getMultiLanguagePermission = (team: TTeam): boolean => { + if (IS_FORMBRICKS_CLOUD) return team.billing.features.inAppSurvey.status !== "inactive"; + else if (!IS_FORMBRICKS_CLOUD) return getIsEnterpriseEdition(); + else return false; +}; diff --git a/packages/ee/multiLanguage/components/DefaultLanguageSelect.tsx b/packages/ee/multiLanguage/components/DefaultLanguageSelect.tsx new file mode 100644 index 0000000000..7d176cde90 --- /dev/null +++ b/packages/ee/multiLanguage/components/DefaultLanguageSelect.tsx @@ -0,0 +1,59 @@ +import { TLanguage, TProduct } from "@formbricks/types/product"; +import { DefaultTag } from "@formbricks/ui/DefaultTag"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select"; + +import { getLanguageLabel } from "../lib/isoLanguages"; +import { ConfirmationModalProps } from "./MultiLanguageCard"; + +interface DefaultLanguageSelectProps { + defaultLanguage?: TLanguage; + handleDefaultLanguageChange: (languageCode: string) => void; + product: TProduct; + setConfirmationModalInfo: (confirmationModal: ConfirmationModalProps) => void; +} + +export const DefaultLanguageSelect = ({ + defaultLanguage, + handleDefaultLanguageChange, + product, + setConfirmationModalInfo, +}: DefaultLanguageSelectProps) => { + return ( +
+

1. Choose the default language for this survey:

+
+
+ +
+ +
+
+ ); +}; diff --git a/packages/ee/multiLanguage/components/EditLanguage.tsx b/packages/ee/multiLanguage/components/EditLanguage.tsx new file mode 100644 index 0000000000..df59874342 --- /dev/null +++ b/packages/ee/multiLanguage/components/EditLanguage.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { InfoIcon, PlusIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "react-hot-toast"; + +import { TLanguage, TProduct } from "@formbricks/types/product"; +import { Button } from "@formbricks/ui/Button"; +import { ConfirmationModal } from "@formbricks/ui/ConfirmationModal"; +import { Label } from "@formbricks/ui/Label"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip"; + +import { + createLanguageAction, + deleteLanguageAction, + getSurveysUsingGivenLanguageAction, + updateLanguageAction, +} from "../lib/actions"; +import { iso639Languages } from "../lib/isoLanguages"; +import { LanguageRow } from "./LanguageRow"; + +interface EditLanguageProps { + product: TProduct; + environmentId: string; +} + +const checkIfDuplicateExists = (arr: string[]) => { + return new Set(arr).size !== arr.length; +}; + +const validateLanguages = (languages: TLanguage[]) => { + const languageCodes = languages.map((language) => language.code.toLowerCase().trim()); + const languageAliases = languages + .filter((language) => language.alias) + .map((language) => language.alias!.toLowerCase().trim()); + + if (languageCodes.includes("")) { + toast.error("Please select a Language", { duration: 2000 }); + return false; + } + + // Check for duplicates within the languageCodes and languageAliases + if (checkIfDuplicateExists(languageAliases) || checkIfDuplicateExists(languageCodes)) { + toast.error("Duplicate language or language ID", { duration: 4000 }); + return false; + } + + // Check if any alias matches the identifier of any added languages + if (languageCodes.some((code) => languageAliases.includes(code))) { + toast.error( + "There is a conflict between the identifier of an added language and one for your aliases. Aliases and identifiers cannot be identical.", + { duration: 6000 } + ); + return false; + } + + // Check if the chosen alias matches an ISO identifier of a language that hasn’t been added + for (let alias of languageAliases) { + if (iso639Languages.some((language) => language.alpha2 === alias && !languageCodes.includes(alias))) { + toast.error( + "There is a conflict between the selected alias and another language that has this identifier. Please add the language with this identifier to your product instead to avoid inconsistencies.", + { duration: 6000 } + ); + return false; + } + } + + return true; +}; + +export default function EditLanguage({ product, environmentId }: EditLanguageProps) { + const [languages, setLanguages] = useState(product.languages); + const [isEditing, setIsEditing] = useState(false); + const [confirmationModal, setConfirmationModal] = useState({ + isOpen: false, + text: "", + languageId: "", + isButtonDisabled: false, + }); + + useEffect(() => { + setLanguages(product.languages); + }, [product.languages]); + + const handleAddLanguage = () => { + const newLanguage = { id: "new", createdAt: new Date(), updatedAt: new Date(), code: "", alias: "" }; + setLanguages((prev) => [...prev, newLanguage]); + setIsEditing(true); + }; + + const handleDeleteLanguage = async (languageId: string) => { + try { + const surveysUsingLanguage = await getSurveysUsingGivenLanguageAction(product.id, languageId); + + if (surveysUsingLanguage.length > 0) { + const surveyList = surveysUsingLanguage.map((surveyName) => `• ${surveyName}`).join("\n"); + setConfirmationModal({ + isOpen: true, + languageId, + text: `You cannot remove this language since it’s still used in these surveys:\n\n${surveyList}\n\nPlease remove the language from these surveys in order to remove it from the product.`, + isButtonDisabled: true, + }); + } else { + setConfirmationModal({ + isOpen: true, + languageId, + text: "Are you sure you want to delete this language? This action cannot be undone.", + isButtonDisabled: false, + }); + } + } catch (err) { + toast.error("Something went wrong. Please try again later."); + } + }; + + const performLanguageDeletion = async (languageId: string) => { + try { + await deleteLanguageAction(product.id, environmentId, languageId); + setLanguages((prev) => prev.filter((lang) => lang.id !== languageId)); + toast.success("Language deleted successfully."); + // Close the modal after deletion + setConfirmationModal((prev) => ({ ...prev, isOpen: false })); + } catch (err) { + toast.error("Something went wrong. Please try again later."); + setConfirmationModal((prev) => ({ ...prev, isOpen: false })); + } + }; + + const handleCancelChanges = async () => { + setLanguages(product.languages); + setIsEditing(false); + }; + + const handleSaveChanges = async () => { + if (!validateLanguages(languages)) return; + await Promise.all( + languages.map((lang) => { + return lang.id === "new" + ? createLanguageAction(product.id, environmentId, { code: lang.code, alias: lang.alias }) + : updateLanguageAction(product.id, environmentId, lang.id, { code: lang.code, alias: lang.alias }); + }) + ); + toast.success("Languages updated successfully."); + setIsEditing(false); + }; + + const AddLanguageButton: React.FC<{ onClick: () => void }> = ({ onClick }) => + isEditing && languages.length === product.languages.length ? ( + + ) : null; + + return ( +
+
+ {languages.length > 0 ? ( + <> + + {languages.map((language, index) => ( + { + const updatedLanguages = [...languages]; + updatedLanguages[index] = newLanguage; + setLanguages(updatedLanguages); + }} + onDelete={() => handleDeleteLanguage(language.id)} + /> + ))} + + ) : ( +

No language found. Add your first language below.

+ )} + +
+ setIsEditing(true)} + /> + setConfirmationModal((prev) => ({ ...prev, isOpen: !prev.isOpen }))} + text={confirmationModal.text} + onConfirm={() => performLanguageDeletion(confirmationModal.languageId)} + isButtonDisabled={confirmationModal.isButtonDisabled} + /> +
+ ); +} + +const AliasTooltip = () => { + return ( + + + +
+ +
+
+ + The alias is an alternate name to identify the language in link surveys and the SDK (optional) + +
+
+ ); +}; + +const LanguageLabels = () => ( +
+ + + +
+); + +const EditSaveButtons: React.FC<{ + isEditing: boolean; + onSave: () => void; + onCancel: () => void; + onEdit: () => void; +}> = ({ isEditing, onEdit, onSave, onCancel }) => + isEditing ? ( +
+ + +
+ ) : ( + + ); diff --git a/packages/ee/multiLanguage/components/LanguageIndicator.tsx b/packages/ee/multiLanguage/components/LanguageIndicator.tsx new file mode 100644 index 0000000000..9096c56d36 --- /dev/null +++ b/packages/ee/multiLanguage/components/LanguageIndicator.tsx @@ -0,0 +1,74 @@ +import { ChevronDown } from "lucide-react"; +import { useRef, useState } from "react"; + +import useClickOutside from "@formbricks/lib/useClickOutside"; +import { TSurveyLanguage } from "@formbricks/types/surveys"; + +import { getLanguageLabel } from "../lib/isoLanguages"; + +interface LanguageIndicatorProps { + selectedLanguageCode: string; + surveyLanguages: TSurveyLanguage[]; + setSelectedLanguageCode: (languageCode: string) => void; + setFirstRender?: (firstRender: boolean) => void; +} +export function LanguageIndicator({ + surveyLanguages, + selectedLanguageCode, + setSelectedLanguageCode, + setFirstRender, +}: LanguageIndicatorProps) { + const [showLanguageDropdown, setShowLanguageDropdown] = useState(false); + const toggleDropdown = () => setShowLanguageDropdown((prev) => !prev); + const languageDropdownRef = useRef(null); + + const changeLanguage = (language: TSurveyLanguage) => { + setSelectedLanguageCode(language.language.code); + if (setFirstRender) { + //for lexical editor + setFirstRender(true); + } + setShowLanguageDropdown(false); + }; + + const langaugeToBeDisplayed = surveyLanguages.find((language) => { + return selectedLanguageCode === "default" + ? language.default === true + : language.language.code === selectedLanguageCode; + }); + + useClickOutside(languageDropdownRef, () => setShowLanguageDropdown(false)); + + return ( +
+ + {showLanguageDropdown && ( +
+ {surveyLanguages.map( + (language) => + language.language.code !== langaugeToBeDisplayed?.language.code && ( + + ) + )} +
+ )} +
+ ); +} diff --git a/packages/ee/multiLanguage/components/LanguageRow.tsx b/packages/ee/multiLanguage/components/LanguageRow.tsx new file mode 100644 index 0000000000..30e2cb552a --- /dev/null +++ b/packages/ee/multiLanguage/components/LanguageRow.tsx @@ -0,0 +1,37 @@ +import { TLanguage } from "@formbricks/types/product"; +import { Button } from "@formbricks/ui/Button"; +import { Input } from "@formbricks/ui/Input"; + +import { LanguageSelect } from "./LanguageSelect"; + +interface LanguageRowProps { + language: TLanguage; + isEditing: boolean; + index: number; + onLanguageChange: (newLanguage: TLanguage) => void; + onDelete: () => void; +} + +export const LanguageRow = ({ language, isEditing, onLanguageChange, onDelete }: LanguageRowProps) => { + return ( +
+ + + onLanguageChange({ ...language, alias: e.target.value })} + /> + {language.id !== "new" && isEditing && ( + + )} +
+ ); +}; diff --git a/packages/ee/multiLanguage/components/LanguageSelect.tsx b/packages/ee/multiLanguage/components/LanguageSelect.tsx new file mode 100644 index 0000000000..fd3b0eda26 --- /dev/null +++ b/packages/ee/multiLanguage/components/LanguageSelect.tsx @@ -0,0 +1,82 @@ +import { ChevronDown } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import useClickOutside from "@formbricks/lib/useClickOutside"; +import { TLanguage } from "@formbricks/types/product"; +import { Button } from "@formbricks/ui/Button"; +import { Input } from "@formbricks/ui/Input"; + +import { TIso639Language, iso639Languages } from "../lib/isoLanguages"; + +interface LanguageSelectProps { + language: TLanguage; + onLanguageChange: (newLanguage: TLanguage) => void; + disabled: boolean; +} + +export const LanguageSelect = ({ language, onLanguageChange, disabled }: LanguageSelectProps) => { + const [isOpen, setIsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedOption, setSelectedOption] = useState( + iso639Languages.find((isoLang) => isoLang.alpha2 === language.code) + ); + const items = iso639Languages; + + const languageSelectRef = useRef(null); + const inputRef = useRef(null); + + const toggleDropdown = () => { + setIsOpen(!isOpen); + }; + + useClickOutside(languageSelectRef, () => setIsOpen(false)); + + const handleOptionSelect = (option: TIso639Language) => { + setSelectedOption(option); + onLanguageChange({ ...language, code: option?.alpha2 || "" }); + setIsOpen(false); + }; + + const filteredItems = items.filter((item) => item.english.toLowerCase().includes(searchTerm.toLowerCase())); + + // Focus the input when the dropdown is opened + useEffect(() => { + if (isOpen) { + inputRef.current?.focus(); + } + }, [isOpen]); + + return ( +
+ +
+ setSearchTerm(e.target.value)} + autoComplete="off" + ref={inputRef} + /> +
+ {filteredItems.map((item, index) => ( +
handleOptionSelect(item)} + className="block cursor-pointer rounded-md px-4 py-2 text-gray-700 hover:bg-gray-100 active:bg-blue-100"> + {item.english} +
+ ))} +
+
+
+ ); +}; diff --git a/packages/ee/multiLanguage/components/LanguageSwitch.tsx b/packages/ee/multiLanguage/components/LanguageSwitch.tsx new file mode 100644 index 0000000000..985d501465 --- /dev/null +++ b/packages/ee/multiLanguage/components/LanguageSwitch.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { ChevronDownIcon } from "@heroicons/react/24/solid"; + +import { TSurveyLanguage } from "@formbricks/types/surveys"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@formbricks/ui/DropdownMenu"; + +import { getLanguageLabel } from "../lib/isoLanguages"; + +interface LanguageSwitchProps { + surveyLanguages: TSurveyLanguage[]; + selectedLanguageCode: string; + setSelectedLanguageCode: (language: string) => void; +} +export default function LanguageSwitch({ + surveyLanguages, + selectedLanguageCode, + setSelectedLanguageCode, +}: LanguageSwitchProps) { + if (selectedLanguageCode === "default") { + selectedLanguageCode = + surveyLanguages.find((surveyLanguage) => { + return surveyLanguage.default === true; + })?.language.code ?? "default"; + } + return ( +
+ + +
+
+ Select Language + +
+
+
+ + {surveyLanguages.length > 0 ? ( + surveyLanguages.map((surveyLanguage) => ( + { + setSelectedLanguageCode(surveyLanguage.language.code); + }}> +
+ +

{getLanguageLabel(surveyLanguage.language.code)}

+
+
+ )) + ) : ( +

No languages to select

+ )} +
+
+
+ ); +} diff --git a/packages/ee/multiLanguage/components/LanguageToggle.tsx b/packages/ee/multiLanguage/components/LanguageToggle.tsx new file mode 100644 index 0000000000..c188da70d4 --- /dev/null +++ b/packages/ee/multiLanguage/components/LanguageToggle.tsx @@ -0,0 +1,37 @@ +import { TLanguage } from "@formbricks/types/product"; +import { Label } from "@formbricks/ui/Label"; +import { Switch } from "@formbricks/ui/Switch"; + +import { getLanguageLabel } from "../lib/isoLanguages"; + +interface LanguageToggleProps { + language: TLanguage; + isChecked: boolean; + onToggle: () => void; + onEdit: () => void; +} + +export const LanguageToggle = ({ language, isChecked, onToggle, onEdit }: LanguageToggleProps) => { + return ( +
+
+ { + e.stopPropagation(); + onToggle(); + }} + /> + + {isChecked && ( +

+ Edit {getLanguageLabel(language.code)} translations +

+ )} +
+
+ ); +}; diff --git a/packages/ee/multiLanguage/components/LocalizedEditor.tsx b/packages/ee/multiLanguage/components/LocalizedEditor.tsx new file mode 100644 index 0000000000..3bc1fa3210 --- /dev/null +++ b/packages/ee/multiLanguage/components/LocalizedEditor.tsx @@ -0,0 +1,109 @@ +import DOMPurify from "dompurify"; +import type { Dispatch, SetStateAction } from "react"; +import { useMemo } from "react"; + +import { extractLanguageCodes, isLabelValidForAllLanguages } from "@formbricks/lib/i18n/utils"; +import { md } from "@formbricks/lib/markdownIt"; +import { recallToHeadline } from "@formbricks/lib/utils/recall"; +import { TI18nString, TSurvey } from "@formbricks/types/surveys"; +import { Editor } from "@formbricks/ui/Editor"; + +import { LanguageIndicator } from "./LanguageIndicator"; + +interface LocalizedEditorProps { + id: string; + value: TI18nString | undefined; + localSurvey: TSurvey; + isInvalid: boolean; + updateQuestion: any; + selectedLanguageCode: string; + setSelectedLanguageCode: (languageCode: string) => void; + questionIdx: number; + firstRender: boolean; + setFirstRender?: Dispatch>; +} + +const checkIfValueIsIncomplete = ( + id: string, + isInvalid: boolean, + surveyLanguageCodes: string[], + value?: TI18nString +) => { + const labelIds = ["subheader"]; + if (value === undefined) return false; + const isDefaultIncomplete = labelIds.includes(id) ? value["default"]?.trim() !== "" : false; + return isInvalid && !isLabelValidForAllLanguages(value, surveyLanguageCodes) && isDefaultIncomplete; +}; + +export const LocalizedEditor = ({ + id, + value, + localSurvey, + isInvalid, + updateQuestion, + selectedLanguageCode, + setSelectedLanguageCode, + questionIdx, + firstRender, + setFirstRender, +}: LocalizedEditorProps) => { + const surveyLanguageCodes = useMemo( + () => extractLanguageCodes(localSurvey.languages), + [localSurvey.languages] + ); + const isInComplete = useMemo( + () => checkIfValueIsIncomplete(id, isInvalid, surveyLanguageCodes, value), + [id, isInvalid, surveyLanguageCodes, value, selectedLanguageCode] + ); + + return ( +
+ md.render(value ? value[selectedLanguageCode] ?? "" : "")} + setText={(v: string) => { + if (!value) return; + let translatedHtml = { + ...value, + [selectedLanguageCode]: v, + }; + if (questionIdx === -1) { + // welcome card + updateQuestion({ html: translatedHtml }); + return; + } + updateQuestion(questionIdx, { html: translatedHtml }); + }} + excludedToolbarItems={["blockType"]} + disableLists + firstRender={firstRender} + setFirstRender={setFirstRender} + /> + {localSurvey.languages?.length > 1 && ( +
+ + + {value && selectedLanguageCode !== "default" && value["default"] && ( +
+ Translate: + +
+ )} +
+ )} + + {isInComplete &&
Contains Incomplete translations
} +
+ ); +}; diff --git a/packages/ee/multiLanguage/components/MultiLanguageCard.tsx b/packages/ee/multiLanguage/components/MultiLanguageCard.tsx new file mode 100644 index 0000000000..4d015b6949 --- /dev/null +++ b/packages/ee/multiLanguage/components/MultiLanguageCard.tsx @@ -0,0 +1,269 @@ +"use client"; + +import * as Collapsible from "@radix-ui/react-collapsible"; +import { ArrowUpRight, Languages } from "lucide-react"; +import Link from "next/link"; +import { FC, useState } from "react"; + +import { cn } from "@formbricks/lib/cn"; +import { TLanguage, TProduct } from "@formbricks/types/product"; +import { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys"; +import { Button } from "@formbricks/ui/Button"; +import { ConfirmationModal } from "@formbricks/ui/ConfirmationModal"; +import { Label } from "@formbricks/ui/Label"; +import { Switch } from "@formbricks/ui/Switch"; +import { UpgradePlanNotice } from "@formbricks/ui/UpgradePlanNotice"; + +import { DefaultLanguageSelect } from "./DefaultLanguageSelect"; +import { SecondaryLanguageSelect } from "./SecondaryLanguageSelect"; + +interface MultiLanguageCardProps { + localSurvey: TSurvey; + product: TProduct; + setLocalSurvey: (survey: TSurvey) => void; + activeQuestionId: string | null; + setActiveQuestionId: (questionId: string | null) => void; + isMultiLanguageAllowed?: boolean; + isFormbricksCloud: boolean; + setSelectedLanguageCode: (language: string) => void; +} + +export interface ConfirmationModalProps { + text: string; + open: boolean; + title: string; + buttonText: string; + buttonVariant?: "darkCTA" | "warn"; + onConfirm: () => void; +} + +export const MultiLanguageCard: FC = ({ + activeQuestionId, + product, + localSurvey, + setActiveQuestionId, + setLocalSurvey, + isMultiLanguageAllowed, + isFormbricksCloud, + setSelectedLanguageCode, +}) => { + const environmentId = localSurvey.environmentId; + const open = activeQuestionId == "multiLanguage"; + const [isMultiLanguageActivated, setIsMultiLanguageActivated] = useState(localSurvey.languages?.length > 1); + const [confirmationModalInfo, setConfirmationModalInfo] = useState({ + title: "", + open: false, + text: "", + buttonText: "", + onConfirm: () => {}, + }); + + const [defaultLanguage, setDefaultLanguage] = useState( + localSurvey.languages?.find((language) => { + return language.default === true; + })?.language + ); + + const setOpen = (open: boolean) => { + if (open) { + setActiveQuestionId("multiLanguage"); + } else { + setActiveQuestionId(null); + } + }; + + const updateSurveyLanguages = (language: TLanguage) => { + let updatedLanguages = localSurvey.languages; + const languageIndex = localSurvey.languages.findIndex( + (surveyLanguage) => surveyLanguage.language.code === language.code + ); + if (languageIndex >= 0) { + // Toggle the 'enabled' property of the existing language + updatedLanguages = updatedLanguages.map((surveyLanguage, index) => + index === languageIndex ? { ...surveyLanguage, enabled: !surveyLanguage.enabled } : surveyLanguage + ); + } else { + // Add the new language + updatedLanguages = [...updatedLanguages, { enabled: true, default: false, language }]; + } + updateSurvey({ languages: updatedLanguages }); + }; + + const updateSurvey = (data: { languages: TSurveyLanguage[] }) => { + setLocalSurvey({ ...localSurvey, ...data }); + }; + + const handleDefaultLanguageChange = (languageCode: string) => { + const language = product.languages.find((lang) => lang.code === languageCode); + if (language) { + let languageExists = false; + + // Update all languages and check if the new default language already exists + const newLanguages = + localSurvey.languages?.map((lang) => { + if (lang.language.code === language.code) { + languageExists = true; + return { ...lang, default: true }; + } else { + return { ...lang, default: false }; + } + }) ?? []; + + if (!languageExists) { + // If the language doesn't exist, add it as the default + newLanguages.push({ enabled: true, default: true, language }); + } + + setDefaultLanguage(language); + setConfirmationModalInfo({ ...confirmationModalInfo, open: false }); + updateSurvey({ languages: newLanguages }); + } + }; + + const handleActivationSwitchLogic = () => { + if (isMultiLanguageActivated) { + if (localSurvey.languages?.length > 0) { + setConfirmationModalInfo({ + open: true, + title: "Remove translations", + text: "This action will remove all the translations from this survey.", + buttonText: "Remove translations", + buttonVariant: "warn", + onConfirm: () => { + setLocalSurvey({ ...localSurvey, languages: [] }); + setIsMultiLanguageActivated(false); + setDefaultLanguage(undefined); + setConfirmationModalInfo({ ...confirmationModalInfo, open: false }); + }, + }); + } else { + setIsMultiLanguageActivated(false); + } + } else { + setIsMultiLanguageActivated(true); + } + }; + + return ( +
+
+

+ +

+
+ + +
+
+
+

Multiple Languages

+
+
+ +
+ + + { + e.stopPropagation(); + handleActivationSwitchLogic(); + }} + disabled={!isMultiLanguageAllowed || product.languages.length === 0} + /> +
+
+
+ +
+ {!isMultiLanguageAllowed && !isFormbricksCloud && !isMultiLanguageActivated ? ( + + ) : !isMultiLanguageAllowed && isFormbricksCloud && !isMultiLanguageActivated ? ( + + ) : ( + <> + {product.languages.length <= 1 && ( +
+ {product.languages.length === 0 + ? "No languages found. Add the first one to get started:" + : "You need to have two or more languages set up in your product to work with translations."} +
+ )} + {product.languages.length > 1 && ( +
+
+ {isMultiLanguageAllowed && !isMultiLanguageActivated && ( +
+ Switch multi-lanugage on to get started 👉 +
+ )} +
+ + {isMultiLanguageActivated && ( +
+ + {defaultLanguage && ( + + )} +
+ )} +
+ )} + + + + + + )} + + setConfirmationModalInfo((prev) => ({ ...prev, open: !prev.open }))} + text={confirmationModalInfo.text} + onConfirm={confirmationModalInfo.onConfirm} + buttonText={confirmationModalInfo.buttonText} + buttonVariant={confirmationModalInfo.buttonVariant} + /> +
+
+
+
+ ); +}; diff --git a/packages/ee/multiLanguage/components/SecondaryLanguageSelect.tsx b/packages/ee/multiLanguage/components/SecondaryLanguageSelect.tsx new file mode 100644 index 0000000000..f5f700a981 --- /dev/null +++ b/packages/ee/multiLanguage/components/SecondaryLanguageSelect.tsx @@ -0,0 +1,48 @@ +import { TLanguage, TProduct } from "@formbricks/types/product"; +import { TSurvey } from "@formbricks/types/surveys"; + +import { LanguageToggle } from "./LanguageToggle"; + +interface secondaryLanguageSelectProps { + product: TProduct; + defaultLanguage: TLanguage; + setSelectedLanguageCode: (languageCode: string) => void; + setActiveQuestionId: (questionId: string) => void; + localSurvey: TSurvey; + updateSurveyLanguages: (language: TLanguage) => void; +} + +export const SecondaryLanguageSelect = ({ + product, + defaultLanguage, + setSelectedLanguageCode, + setActiveQuestionId, + localSurvey, + updateSurveyLanguages, +}: secondaryLanguageSelectProps) => { + const isLanguageToggled = (language: TLanguage) => { + return localSurvey.languages.some( + (surveyLanguage) => surveyLanguage.language.code === language.code && surveyLanguage.enabled + ); + }; + + return ( +
+

2. Activate translation for specific languages:

+ {product.languages + .filter((lang) => lang.id !== defaultLanguage.id) + .map((language) => ( + updateSurveyLanguages(language)} + onEdit={() => { + setSelectedLanguageCode(language.code); + setActiveQuestionId(localSurvey.questions[0]?.id); + }} + /> + ))} +
+ ); +}; diff --git a/packages/ee/multiLanguage/lib/actions.ts b/packages/ee/multiLanguage/lib/actions.ts new file mode 100644 index 0000000000..08aa3e352a --- /dev/null +++ b/packages/ee/multiLanguage/lib/actions.ts @@ -0,0 +1,79 @@ +"use server"; + +import { getServerSession } from "next-auth"; + +import { authOptions } from "@formbricks/lib/authOptions"; +import { + createLanguage, + deleteLanguage, + getSurveysUsingGivenLanguage, + updateLanguage, +} from "@formbricks/lib/language/service"; +import { canUserAccessProduct, verifyUserRoleAccess } from "@formbricks/lib/product/auth"; +import { getProduct } from "@formbricks/lib/product/service"; +import { AuthorizationError } from "@formbricks/types/errors"; +import { TLanguageInput } from "@formbricks/types/product"; + +export async function createLanguageAction( + productId: string, + environmentId: string, + languageInput: TLanguageInput +) { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const isAuthorized = await canUserAccessProduct(session.user?.id, productId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); + + const product = await getProduct(productId); + + const { hasCreateOrUpdateAccess } = await verifyUserRoleAccess(product!.teamId, session.user?.id); + if (!hasCreateOrUpdateAccess) throw new AuthorizationError("Not authorized"); + + return await createLanguage(productId, environmentId, languageInput); +} + +export async function deleteLanguageAction(productId: string, environmentId: string, languageId: string) { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const isAuthorized = await canUserAccessProduct(session.user?.id, productId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); + + const product = await getProduct(productId); + + const { hasCreateOrUpdateAccess } = await verifyUserRoleAccess(product!.teamId, session.user?.id); + if (!hasCreateOrUpdateAccess) throw new AuthorizationError("Not authorized"); + + return await deleteLanguage(environmentId, languageId); +} + +export async function getSurveysUsingGivenLanguageAction(productId: string, languageId: string) { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const isAuthorized = await canUserAccessProduct(session.user?.id, productId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); + + return await getSurveysUsingGivenLanguage(languageId); +} + +export async function updateLanguageAction( + productId: string, + environmentId: string, + languageId: string, + languageInput: TLanguageInput +) { + const session = await getServerSession(authOptions); + if (!session) throw new AuthorizationError("Not authorized"); + + const isAuthorized = await canUserAccessProduct(session.user?.id, productId); + if (!isAuthorized) throw new AuthorizationError("Not authorized"); + + const product = await getProduct(productId); + + const { hasCreateOrUpdateAccess } = await verifyUserRoleAccess(product!.teamId, session.user?.id); + if (!hasCreateOrUpdateAccess) throw new AuthorizationError("Not authorized"); + + return await updateLanguage(environmentId, languageId, languageInput); +} diff --git a/packages/ee/multiLanguage/lib/isoLanguages.ts b/packages/ee/multiLanguage/lib/isoLanguages.ts new file mode 100644 index 0000000000..660d60584c --- /dev/null +++ b/packages/ee/multiLanguage/lib/isoLanguages.ts @@ -0,0 +1,750 @@ +export interface TIso639Language { + alpha2: string; + english: string; +} + +export const iso639Languages = [ + { + alpha2: "aa", + english: "Afar", + }, + { + alpha2: "ab", + english: "Abkhazian", + }, + { + alpha2: "ae", + english: "Avestan", + }, + { + alpha2: "af", + english: "Afrikaans", + }, + { + alpha2: "ak", + english: "Akan", + }, + { + alpha2: "am", + english: "Amharic", + }, + { + alpha2: "an", + english: "Aragonese", + }, + { + alpha2: "ar", + english: "Arabic", + }, + { + alpha2: "as", + english: "Assamese", + }, + { + alpha2: "av", + english: "Avaric", + }, + { + alpha2: "ay", + english: "Aymara", + }, + { + alpha2: "az", + english: "Azerbaijani", + }, + { + alpha2: "ba", + english: "Bashkir", + }, + { + alpha2: "be", + english: "Belarusian", + }, + { + alpha2: "bg", + english: "Bulgarian", + }, + { + alpha2: "bh", + english: "Bihari languages", + }, + { + alpha2: "bi", + english: "Bislama", + }, + { + alpha2: "bm", + english: "Bambara", + }, + { + alpha2: "bn", + english: "Bengali", + }, + { + alpha2: "bo", + english: "Tibetan", + }, + { + alpha2: "br", + english: "Breton", + }, + { + alpha2: "bs", + english: "Bosnian", + }, + { + alpha2: "ca", + english: "Catalan; Valencian", + }, + { + alpha2: "ce", + english: "Chechen", + }, + { + alpha2: "ch", + english: "Chamorro", + }, + { + alpha2: "co", + english: "Corsican", + }, + { + alpha2: "cr", + english: "Cree", + }, + { + alpha2: "cs", + english: "Czech", + }, + { + alpha2: "cu", + english: "Church Slavic; Old Slavonic; Church Slavonic; Old Bulgarian; Old Church Slavonic", + }, + { + alpha2: "cv", + english: "Chuvash", + }, + { + alpha2: "cy", + english: "Welsh", + }, + { + alpha2: "da", + english: "Danish", + }, + { + alpha2: "de", + english: "German", + }, + { + alpha2: "dv", + english: "Divehi; Dhivehi; Maldivian", + }, + { + alpha2: "dz", + english: "Dzongkha", + }, + { + alpha2: "ee", + english: "Ewe", + }, + { + alpha2: "el", + english: "Greek, Modern (1453-)", + }, + { + alpha2: "en", + english: "English", + }, + { + alpha2: "eo", + english: "Esperanto", + }, + { + alpha2: "es", + english: "Spanish; Castilian", + }, + { + alpha2: "et", + english: "Estonian", + }, + { + alpha2: "eu", + english: "Basque", + }, + { + alpha2: "fa", + english: "Persian", + }, + { + alpha2: "ff", + english: "Fulah", + }, + { + alpha2: "fi", + english: "Finnish", + }, + { + alpha2: "fj", + english: "Fijian", + }, + { + alpha2: "fo", + english: "Faroese", + }, + { + alpha2: "fr", + english: "French", + }, + { + alpha2: "fy", + english: "Western Frisian", + }, + { + alpha2: "ga", + english: "Irish", + }, + { + alpha2: "gd", + english: "Gaelic; Scottish Gaelic", + }, + { + alpha2: "gl", + english: "Galician", + }, + { + alpha2: "gn", + english: "Guarani", + }, + { + alpha2: "gu", + english: "Gujarati", + }, + { + alpha2: "gv", + english: "Manx", + }, + { + alpha2: "ha", + english: "Hausa", + }, + { + alpha2: "he", + english: "Hebrew", + }, + { + alpha2: "hi", + english: "Hindi", + }, + { + alpha2: "ho", + english: "Hiri Motu", + }, + { + alpha2: "hr", + english: "Croatian", + }, + { + alpha2: "ht", + english: "Haitian; Haitian Creole", + }, + { + alpha2: "hu", + english: "Hungarian", + }, + { + alpha2: "hy", + english: "Armenian", + }, + { + alpha2: "hz", + english: "Herero", + }, + { + alpha2: "ia", + english: "Interlingua (International Auxiliary Language Association)", + }, + { + alpha2: "id", + english: "Indonesian", + }, + { + alpha2: "ie", + english: "Interlingue; Occidental", + }, + { + alpha2: "ig", + english: "Igbo", + }, + { + alpha2: "ii", + english: "Sichuan Yi; Nuosu", + }, + { + alpha2: "ik", + english: "Inupiaq", + }, + { + alpha2: "io", + english: "Ido", + }, + { + alpha2: "is", + english: "Icelandic", + }, + { + alpha2: "it", + english: "Italian", + }, + { + alpha2: "iu", + english: "Inuktitut", + }, + { + alpha2: "ja", + english: "Japanese", + }, + { + alpha2: "jv", + english: "Javanese", + }, + { + alpha2: "ka", + english: "Georgian", + }, + { + alpha2: "kg", + english: "Kongo", + }, + { + alpha2: "ki", + english: "Kikuyu; Gikuyu", + }, + { + alpha2: "kj", + english: "Kuanyama; Kwanyama", + }, + { + alpha2: "kk", + english: "Kazakh", + }, + { + alpha2: "kl", + english: "Kalaallisut; Greenlandic", + }, + { + alpha2: "km", + english: "Central Khmer", + }, + { + alpha2: "kn", + english: "Kannada", + }, + { + alpha2: "ko", + english: "Korean", + }, + { + alpha2: "kr", + english: "Kanuri", + }, + { + alpha2: "ks", + english: "Kashmiri", + }, + { + alpha2: "ku", + english: "Kurdish", + }, + { + alpha2: "kv", + english: "Komi", + }, + { + alpha2: "kw", + english: "Cornish", + }, + { + alpha2: "ky", + english: "Kirghiz; Kyrgyz", + }, + { + alpha2: "la", + english: "Latin", + }, + { + alpha2: "lb", + english: "Luxembourgish; Letzeburgesch", + }, + { + alpha2: "lg", + english: "Ganda", + }, + { + alpha2: "li", + english: "Limburgan; Limburger; Limburgish", + }, + { + alpha2: "ln", + english: "Lingala", + }, + { + alpha2: "lo", + english: "Lao", + }, + { + alpha2: "lt", + english: "Lithuanian", + }, + { + alpha2: "lu", + english: "Luba-Katanga", + }, + { + alpha2: "lv", + english: "Latvian", + }, + { + alpha2: "mg", + english: "Malagasy", + }, + { + alpha2: "mh", + english: "Marshallese", + }, + { + alpha2: "mi", + english: "Maori", + }, + { + alpha2: "mk", + english: "Macedonian", + }, + { + alpha2: "ml", + english: "Malayalam", + }, + { + alpha2: "mn", + english: "Mongolian", + }, + { + alpha2: "mr", + english: "Marathi", + }, + { + alpha2: "ms", + english: "Malay", + }, + { + alpha2: "mt", + english: "Maltese", + }, + { + alpha2: "my", + english: "Burmese", + }, + { + alpha2: "na", + english: "Nauru", + }, + { + alpha2: "nb", + english: "Bokmål, Norwegian; Norwegian Bokmål", + }, + { + alpha2: "nd", + english: "Ndebele, North; North Ndebele", + }, + { + alpha2: "ne", + english: "Nepali", + }, + { + alpha2: "ng", + english: "Ndonga", + }, + { + alpha2: "nl", + english: "Dutch; Flemish", + }, + { + alpha2: "nn", + english: "Norwegian Nynorsk; Nynorsk, Norwegian", + }, + { + alpha2: "no", + english: "Norwegian", + }, + { + alpha2: "nr", + english: "Ndebele, South; South Ndebele", + }, + { + alpha2: "nv", + english: "Navajo; Navaho", + }, + { + alpha2: "ny", + english: "Chichewa; Chewa; Nyanja", + }, + { + alpha2: "oc", + english: "Occitan (post 1500)", + }, + { + alpha2: "oj", + english: "Ojibwa", + }, + { + alpha2: "om", + english: "Oromo", + }, + { + alpha2: "or", + english: "Oriya", + }, + { + alpha2: "os", + english: "Ossetian; Ossetic", + }, + { + alpha2: "pa", + english: "Panjabi; Punjabi", + }, + { + alpha2: "pi", + english: "Pali", + }, + { + alpha2: "pl", + english: "Polish", + }, + { + alpha2: "ps", + english: "Pushto; Pashto", + }, + { + alpha2: "pt", + english: "Portuguese", + }, + { + alpha2: "qu", + english: "Quechua", + }, + { + alpha2: "rm", + english: "Romansh", + }, + { + alpha2: "rn", + english: "Rundi", + }, + { + alpha2: "ro", + english: "Romanian; Moldavian; Moldovan", + }, + { + alpha2: "ru", + english: "Russian", + }, + { + alpha2: "rw", + english: "Kinyarwanda", + }, + { + alpha2: "sa", + english: "Sanskrit", + }, + { + alpha2: "sc", + english: "Sardinian", + }, + { + alpha2: "sd", + english: "Sindhi", + }, + { + alpha2: "se", + english: "Northern Sami", + }, + { + alpha2: "sg", + english: "Sango", + }, + { + alpha2: "si", + english: "Sinhala; Sinhalese", + }, + { + alpha2: "sk", + english: "Slovak", + }, + { + alpha2: "sl", + english: "Slovenian", + }, + { + alpha2: "sm", + english: "Samoan", + }, + { + alpha2: "sn", + english: "Shona", + }, + { + alpha2: "so", + english: "Somali", + }, + { + alpha2: "sq", + english: "Albanian", + }, + { + alpha2: "sr", + english: "Serbian", + }, + { + alpha2: "ss", + english: "Swati", + }, + { + alpha2: "st", + english: "Sotho, Southern", + }, + { + alpha2: "su", + english: "Sundanese", + }, + { + alpha2: "sv", + english: "Swedish", + }, + { + alpha2: "sw", + english: "Swahili", + }, + { + alpha2: "ta", + english: "Tamil", + }, + { + alpha2: "te", + english: "Telugu", + }, + { + alpha2: "tg", + english: "Tajik", + }, + { + alpha2: "th", + english: "Thai", + }, + { + alpha2: "ti", + english: "Tigrinya", + }, + { + alpha2: "tk", + english: "Turkmen", + }, + { + alpha2: "tl", + english: "Tagalog", + }, + { + alpha2: "tn", + english: "Tswana", + }, + { + alpha2: "to", + english: "Tonga (Tonga Islands)", + }, + { + alpha2: "tr", + english: "Turkish", + }, + { + alpha2: "ts", + english: "Tsonga", + }, + { + alpha2: "tt", + english: "Tatar", + }, + { + alpha2: "tw", + english: "Twi", + }, + { + alpha2: "ty", + english: "Tahitian", + }, + { + alpha2: "ug", + english: "Uighur; Uyghur", + }, + { + alpha2: "uk", + english: "Ukrainian", + }, + { + alpha2: "ur", + english: "Urdu", + }, + { + alpha2: "uz", + english: "Uzbek", + }, + { + alpha2: "ve", + english: "Venda", + }, + { + alpha2: "vi", + english: "Vietnamese", + }, + { + alpha2: "vo", + english: "Volapük", + }, + { + alpha2: "wa", + english: "Walloon", + }, + { + alpha2: "wo", + english: "Wolof", + }, + { + alpha2: "xh", + english: "Xhosa", + }, + { + alpha2: "yi", + english: "Yiddish", + }, + { + alpha2: "yo", + english: "Yoruba", + }, + { + alpha2: "za", + english: "Zhuang; Chuang", + }, + { + alpha2: "zh", + english: "Chinese", + }, + { + alpha2: "zu", + english: "Zulu", + }, +]; + +export const iso639Identifiers = iso639Languages.map((language) => language.alpha2); + +export const getLanguageLabel = (languageCode: string) => { + const language = iso639Languages.find((lang) => lang.alpha2 === languageCode); + return `${language?.english}`; +}; diff --git a/packages/js/index.html b/packages/js/index.html index 80d433d695..2545ec6801 100644 --- a/packages/js/index.html +++ b/packages/js/index.html @@ -7,7 +7,7 @@ e.parentNode.insertBefore(t, e), setTimeout(function () { window.formbricks.init({ - environmentId: "clsja4yzr00c1jyj8buxwmyds", + environmentId: "cltflogdl000aiw7m8esl9kjc", apiHost: "http://localhost:3000", }); }, 500); diff --git a/packages/js/package.json b/packages/js/package.json index 64214e6ad9..97863c7578 100644 --- a/packages/js/package.json +++ b/packages/js/package.json @@ -1,7 +1,7 @@ { "name": "@formbricks/js", "license": "MIT", - "version": "1.6.4", + "version": "1.7.0", "description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.", "homepage": "https://formbricks.com", "repository": { diff --git a/packages/js/src/lib/initialize.ts b/packages/js/src/lib/initialize.ts index a195d55c7b..d904fd61f6 100644 --- a/packages/js/src/lib/initialize.ts +++ b/packages/js/src/lib/initialize.ts @@ -86,23 +86,20 @@ export const initialize = async ( logger.debug("Adding widget container to DOM"); addWidgetContainer(); - if (!c.userId && c.attributes) { - logger.error("No userId provided but attributes. Cannot update attributes without userId."); - return err({ - code: "missing_field", - field: "userId", - }); - } - - // if userId and attributes are available, set them in backend let updatedAttributes: TPersonAttributes | null = null; - if (c.userId && c.attributes) { - const res = await updatePersonAttributes(c.apiHost, c.environmentId, c.userId, c.attributes); - - if (res.ok !== true) { - return err(res.error); + if (c.attributes) { + if (!c.userId) { + // Allow setting attributes for unidentified users + updatedAttributes = { ...c.attributes }; + } + // If userId is available, update attributes in backend + else { + const res = await updatePersonAttributes(c.apiHost, c.environmentId, c.userId, c.attributes); + if (res.ok !== true) { + return err(res.error); + } + updatedAttributes = res.value; } - updatedAttributes = res.value; } if ( @@ -149,7 +146,6 @@ export const initialize = async ( // and track the new session event await trackAction("New Session"); } - // update attributes in config if (updatedAttributes && Object.keys(updatedAttributes).length > 0) { config.update({ diff --git a/packages/js/src/lib/person.ts b/packages/js/src/lib/person.ts index 91092a5e3d..a3f1f792ed 100644 --- a/packages/js/src/lib/person.ts +++ b/packages/js/src/lib/person.ts @@ -23,7 +23,22 @@ export const updatePersonAttribute = async ( value: string ): Promise> => { const { apiHost, environmentId, userId } = config.get(); + if (!userId) { + const previousConfig = config.get(); + if (key === "language") { + config.update({ + ...previousConfig, + state: { + ...previousConfig.state, + attributes: { + ...previousConfig.state.attributes, + language: value, + }, + }, + }); + return okVoid(); + } return err({ code: "missing_person", message: "Unable to update attribute. User identification deactivated. No userId set.", @@ -66,15 +81,9 @@ export const updatePersonAttributes = async ( userId: string, attributes: TPersonAttributes ): Promise> => { - if (!userId) { - return err({ - code: "missing_person", - message: "Unable to update attribute. User identification deactivated. No userId set.", - }); - } - // clean attributes and remove existing attributes if config already exists const updatedAttributes = { ...attributes }; + try { const existingAttributes = config.get()?.state?.attributes; if (existingAttributes) { @@ -180,6 +189,7 @@ export const resetPerson = async (): Promise> => { environmentId: config.get().environmentId, apiHost: config.get().apiHost, userId: config.get().userId, + attributes: config.get().state.attributes, }; await logoutPerson(); try { diff --git a/packages/js/src/lib/sync.ts b/packages/js/src/lib/sync.ts index 1c3bacce46..cf822ebb44 100644 --- a/packages/js/src/lib/sync.ts +++ b/packages/js/src/lib/sync.ts @@ -1,5 +1,6 @@ import { diffInDays } from "@formbricks/lib/utils/datetime"; import { TJsState, TJsStateSync, TJsSyncParams } from "@formbricks/types/js"; +import { TSurvey } from "@formbricks/types/surveys"; import { Config } from "./config"; import { NetworkError, Result, err, ok } from "./errors"; @@ -88,9 +89,8 @@ export const sync = async (params: TJsSyncParams, noCache = false): Promise window.location.search.includes("formbricksDebug=true"); + +export const getLanguageCode = (survey: TSurvey, attributes: TPersonAttributes): string | undefined => { + const language = attributes.language; + const availableLanguageCodes = survey.languages.map((surveyLanguage) => surveyLanguage.language.code); + if (!language) return "default"; + else { + const selectedLanguage = survey.languages.find((surveyLanguage) => { + return surveyLanguage.language.code === language || surveyLanguage.language.alias === language; + }); + if (selectedLanguage?.default) { + return "default"; + } + if ( + !selectedLanguage || + !selectedLanguage?.enabled || + !availableLanguageCodes.includes(selectedLanguage.language.code) + ) { + return undefined; + } + return selectedLanguage.language.code; + } +}; + +export const getDefaultLanguageCode = (survey: TSurvey) => { + const defaultSurveyLanguage = survey.languages?.find((surveyLanguage) => { + return surveyLanguage.default === true; + }); + if (defaultSurveyLanguage) return defaultSurveyLanguage.language.code; +}; diff --git a/packages/js/src/lib/widget.ts b/packages/js/src/lib/widget.ts index 953cc37ebf..de0f12eb15 100644 --- a/packages/js/src/lib/widget.ts +++ b/packages/js/src/lib/widget.ts @@ -10,6 +10,7 @@ import { ErrorHandler } from "./errors"; import { putFormbricksInErrorState } from "./initialize"; import { Logger } from "./logger"; import { filterPublicSurveys, sync } from "./sync"; +import { getDefaultLanguageCode, getLanguageCode } from "./utils"; const containerId = "formbricks-web-container"; @@ -36,6 +37,21 @@ export const renderWidget = async (survey: TSurvey) => { } const product = config.get().state.product; + const attributes = config.get().state.attributes; + + const isMultiLanguageSurvey = survey.languages.length > 1; + let languageCode = "default"; + + if (isMultiLanguageSurvey) { + const displayLanguage = getLanguageCode(survey, attributes); + //if survey is not available in selected language, survey wont be shown + if (!displayLanguage) { + logger.debug("Survey not available in specified language."); + setIsSurveyRunning(true); + return; + } + languageCode = displayLanguage; + } const surveyState = new SurveyState(survey.id, null, null, config.get().userId); @@ -53,7 +69,6 @@ export const renderWidget = async (survey: TSurvey) => { }, surveyState ); - const productOverwrites = survey.productOverwrites ?? {}; const brandColor = productOverwrites.brandColor ?? product.brandColor; const highlightBorderColor = productOverwrites.highlightBorderColor ?? product.highlightBorderColor; @@ -70,6 +85,7 @@ export const renderWidget = async (survey: TSurvey) => { isBrandingEnabled: isBrandingEnabled, clickOutside, darkOverlay, + languageCode, highlightBorderColor, placement, getSetIsError: (f: (value: boolean) => void) => { @@ -148,6 +164,7 @@ export const renderWidget = async (survey: TSurvey) => { data: responseUpdate.data, ttc: responseUpdate.ttc, finished: responseUpdate.finished, + language: languageCode === "default" ? getDefaultLanguageCode(survey) : languageCode, }); }, onClose: closeSurvey, diff --git a/packages/lib/actionClass/service.ts b/packages/lib/actionClass/service.ts index 94d3fd2ca8..5372ff50d7 100644 --- a/packages/lib/actionClass/service.ts +++ b/packages/lib/actionClass/service.ts @@ -162,9 +162,7 @@ export const createActionClass = async ( name: actionClass.name, description: actionClass.description, type: actionClass.type, - noCodeConfig: actionClass.noCodeConfig - ? JSON.parse(JSON.stringify(actionClass.noCodeConfig)) - : undefined, + noCodeConfig: actionClass.noCodeConfig ? structuredClone(actionClass.noCodeConfig) : undefined, environment: { connect: { id: environmentId } }, }, select, @@ -200,7 +198,7 @@ export const updateActionClass = async ( description: inputActionClass.description, type: inputActionClass.type, noCodeConfig: inputActionClass.noCodeConfig - ? JSON.parse(JSON.stringify(inputActionClass.noCodeConfig)) + ? structuredClone(inputActionClass.noCodeConfig) : undefined, }, select, diff --git a/packages/lib/emails/emails.ts b/packages/lib/emails/emails.ts index 13d7d2ad7b..acac378b15 100644 --- a/packages/lib/emails/emails.ts +++ b/packages/lib/emails/emails.ts @@ -12,6 +12,7 @@ import { WEBAPP_URL, } from "../constants"; import { createInviteToken, createToken, createTokenForLinkSurvey } from "../jwt"; +import { getProductByEnvironmentId } from "../product/service"; import { getQuestionResponseMapping } from "../responses"; import { getOriginalFileNameFromUrl } from "../storage/utils"; import { getTeamByEnvironmentId } from "../team/service"; @@ -189,6 +190,8 @@ export const sendResponseFinishedEmail = async ( ) => { const personEmail = response.person?.attributes["email"]; const team = await getTeamByEnvironmentId(environmentId); + const product = await getProductByEnvironmentId(environmentId); + if (!product) return; await sendEmail({ to: email, subject: personEmail diff --git a/packages/lib/environment/service.ts b/packages/lib/environment/service.ts index 87a81e629b..164acf7efd 100644 --- a/packages/lib/environment/service.ts +++ b/packages/lib/environment/service.ts @@ -189,6 +189,7 @@ export const createEnvironment = async ( create: [ // { name: "userId", description: "The internal ID of the person", type: "automatic" }, { name: "email", description: "The email of the person", type: "automatic" }, + { name: "language", description: "The language used by the person", type: "automatic" }, ], }, }, diff --git a/packages/lib/i18n/i18n.mock.ts b/packages/lib/i18n/i18n.mock.ts new file mode 100644 index 0000000000..b86f074d4b --- /dev/null +++ b/packages/lib/i18n/i18n.mock.ts @@ -0,0 +1,546 @@ +import { mockSegment } from "segment/tests/__mocks__/segment.mock"; + +import { mockSurveyLanguages } from "survey/tests/__mock__/survey.mock"; + +import { + TSurvey, + TSurveyCTAQuestion, + TSurveyCalQuestion, + TSurveyConsentQuestion, + TSurveyDateQuestion, + TSurveyFileUploadQuestion, + TSurveyMultipleChoiceMultiQuestion, + TSurveyMultipleChoiceSingleQuestion, + TSurveyNPSQuestion, + TSurveyOpenTextQuestion, + TSurveyPictureSelectionQuestion, + TSurveyQuestionType, + TSurveyRatingQuestion, + TSurveyThankYouCard, + TSurveyWelcomeCard, +} from "@formbricks/types/surveys"; + +export const mockWelcomeCard: TSurveyWelcomeCard = { + html: { + default: + '


Thanks for providing your feedback - let\'s go!

', + }, + enabled: true, + headline: { + default: "Welcome!", + }, + timeToFinish: false, + showResponseCount: false, +} as unknown as TSurveyWelcomeCard; + +export const mockOpenTextQuestion: TSurveyOpenTextQuestion = { + id: "lqht9sj5s6andjkmr9k1n54q", + type: TSurveyQuestionType.OpenText, + headline: { + default: "What would you like to know?", + }, + + required: true, + inputType: "text", + subheader: { + default: "This is an example survey.", + }, + placeholder: { + default: "Type your answer here...", + }, +}; + +export const mockSingleSelectQuestion: TSurveyMultipleChoiceSingleQuestion = { + id: "mvqx8t90np6isb6oel9eamzc", + type: TSurveyQuestionType.MultipleChoiceSingle, + choices: [ + { + id: "r52sul8ag19upaicit0fyqzo", + label: { + default: "Eat the cake 🍰", + }, + }, + { + id: "es0gc12hrpk12x13rlqm59rg", + label: { + default: "Have the cake 🎂", + }, + }, + ], + isDraft: true, + headline: { + default: "What do you do?", + }, + required: true, + subheader: { + default: "Can't do both.", + }, + shuffleOption: "none", +}; + +export const mockMultiSelectQuestion: TSurveyMultipleChoiceMultiQuestion = { + required: true, + headline: { + default: "What's important on vacay?", + }, + choices: [ + { + id: "mgjk3i967ject4mezs4cjadj", + label: { + default: "Sun ☀️", + }, + }, + { + id: "m1wmzagcle4bzmkmgru4ol0w", + label: { + default: "Ocean 🌊", + }, + }, + { + id: "h12xs1v3w7s579p4upb5vnzp", + label: { + default: "Palms 🌴", + }, + }, + ], + shuffleOption: "none", + id: "cpydxgsmjg8q9iwfa8wj4ida", + type: TSurveyQuestionType.MultipleChoiceMulti, + isDraft: true, +}; + +export const mockPictureSelectQuestion: TSurveyPictureSelectionQuestion = { + required: true, + headline: { + default: "Which is the cutest puppy?", + }, + subheader: { + default: "You can also pick both.", + }, + allowMulti: true, + choices: [ + { + id: "bdz471uu4ut7ox38b5aprzkq", + imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg", + }, + { + id: "t10v5rkqw32si3orlkt9mrdw", + imageUrl: "https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-2-small.jpg", + }, + ], + id: "a8monbe8hq0mivh3irfhd3i5", + type: TSurveyQuestionType.PictureSelection, + isDraft: true, +}; + +export const mockRatingQuestion: TSurveyRatingQuestion = { + required: true, + headline: { + default: "How would you rate My Product", + }, + subheader: { + default: "Don't worry, be honest.", + }, + scale: "star", + range: 5, + lowerLabel: { + default: "Not good", + }, + upperLabel: { + default: "Very good", + }, + id: "waldsboahjtgqhg5p18d1awz", + type: TSurveyQuestionType.Rating, + isDraft: true, +}; + +export const mockNpsQuestion: TSurveyNPSQuestion = { + required: true, + headline: { + default: "How likely are you to recommend My Product to a friend or colleague?", + }, + lowerLabel: { + default: "Not at all likely", + }, + upperLabel: { + default: "Extremely likely", + }, + id: "m9pemgdih2p4exvkmeeqq6jf", + type: TSurveyQuestionType.NPS, + isDraft: true, +}; + +export const mockCtaQuestion: TSurveyCTAQuestion = { + required: true, + headline: { + default: "You are one of our power users!", + }, + buttonLabel: { + default: "Book interview", + }, + buttonExternal: false, + dismissButtonLabel: { + default: "Skip", + }, + id: "gwn15urom4ffnhfimwbz3vgc", + type: TSurveyQuestionType.CTA, + isDraft: true, +}; + +export const mockConsentQuestion: TSurveyConsentQuestion = { + required: true, + headline: { + default: "Terms and Conditions", + }, + label: { + default: "I agree to the terms and conditions", + }, + dismissButtonLabel: "Skip", + id: "av561aoif3i2hjlsl6krnsfm", + type: TSurveyQuestionType.Consent, + isDraft: true, +}; + +export const mockDateQuestion: TSurveyDateQuestion = { + required: true, + headline: { + default: "When is your birthday?", + }, + format: "M-d-y", + id: "ts2f6v2oo9jfmfli9kk6lki9", + type: TSurveyQuestionType.Date, + isDraft: true, +}; + +export const mockFileUploadQuestion: TSurveyFileUploadQuestion = { + required: true, + headline: { + default: "File Upload", + }, + allowMultipleFiles: false, + id: "ozzxo2jj1s6mj56c79q8pbef", + type: TSurveyQuestionType.FileUpload, + isDraft: true, +}; + +export const mockCalQuestion: TSurveyCalQuestion = { + required: true, + headline: { + default: "Schedule a call with me", + }, + buttonLabel: { + default: "Skip", + }, + calUserName: "rick/get-rick-rolled", + id: "o3bnux6p42u9ew9d02l14r26", + type: TSurveyQuestionType.Cal, + isDraft: true, +}; + +export const mockThankYouCard: TSurveyThankYouCard = { + enabled: true, + headline: { + default: "Thank you!", + }, + subheader: { + default: "We appreciate your feedback.", + }, + buttonLink: "https://formbricks.com/signup", + buttonLabel: { default: "Create your own Survey" }, +} as unknown as TSurveyThankYouCard; + +export const mockSurvey: TSurvey = { + id: "eddb4fbgaml6z52eomejy77w", + createdAt: new Date("2024-02-06T20:12:03.521Z"), + updatedAt: new Date("2024-02-06T20:12:03.521Z"), + name: "New Survey", + type: "link", + environmentId: "envId", + createdBy: "creatorId", + status: "draft", + welcomeCard: mockWelcomeCard, + questions: [ + mockOpenTextQuestion, + mockSingleSelectQuestion, + mockMultiSelectQuestion, + mockPictureSelectQuestion, + mockRatingQuestion, + mockNpsQuestion, + mockCtaQuestion, + mockConsentQuestion, + mockDateQuestion, + mockFileUploadQuestion, + mockCalQuestion, + ], + thankYouCard: { + enabled: true, + headline: { + default: "Thank you!", + }, + subheader: { + default: "We appreciate your feedback.", + }, + buttonLink: "https://formbricks.com/signup", + buttonLabel: { default: "Create your own Survey" }, + }, + hiddenFields: { + enabled: true, + fieldIds: [], + }, + displayOption: "displayOnce", + recontactDays: null, + autoClose: null, + closeOnDate: null, + delay: 0, + displayPercentage: null, + autoComplete: null, + verifyEmail: null, + redirectUrl: null, + productOverwrites: null, + styling: null, + surveyClosedMessage: null, + singleUse: { + enabled: false, + isEncrypted: true, + }, + pin: null, + resultShareKey: null, + inlineTriggers: {}, + triggers: [], + languages: mockSurveyLanguages, + segment: mockSegment, +} as unknown as TSurvey; + +export const mockTranslatedWelcomeCard = { + html: { + default: + '


Thanks for providing your feedback - let\'s go!

', + de: "", + }, + enabled: true, + headline: { default: "Welcome!", de: "" }, + timeToFinish: false, + showResponseCount: false, +}; + +export const mockLegacyWelcomeCard = { + html: '


Thanks for providing your feedback - let\'s go!

', + enabled: true, + headline: "Welcome!", + timeToFinish: false, + showResponseCount: false, +}; + +export const mockTranslatedOpenTextQuestion = { + ...mockOpenTextQuestion, + headline: { default: "What would you like to know?", de: "" }, + subheader: { default: "This is an example survey.", de: "" }, + placeholder: { default: "Type your answer here...", de: "" }, +}; + +export const mockLegacyOpenTextQuestion = { + ...mockOpenTextQuestion, + headline: "What would you like to know?", + subheader: "This is an example survey.", + placeholder: "Type your answer here...", +}; + +export const mockTranslatedSingleSelectQuestion = { + ...mockSingleSelectQuestion, + headline: { default: "What do you do?", de: "" }, + subheader: { default: "Can't do both.", de: "" }, + choices: mockSingleSelectQuestion.choices.map((choice) => ({ + ...choice, + label: { default: choice.label.default, de: "" }, + })), + otherOptionPlaceholder: undefined, +}; + +export const mockLegacySingleSelectQuestion = { + ...mockSingleSelectQuestion, + headline: "What do you do?", + subheader: "Can't do both.", + otherOptionPlaceholder: undefined, + choices: mockSingleSelectQuestion.choices.map((choice) => ({ + ...choice, + label: choice.label.default, + })), +}; + +export const mockTranslatedMultiSelectQuestion = { + ...mockMultiSelectQuestion, + headline: { default: "What's important on vacay?", de: "" }, + choices: mockMultiSelectQuestion.choices.map((choice) => ({ + ...choice, + label: { default: choice.label.default, de: "" }, + })), + otherOptionPlaceholder: undefined, +}; + +export const mockLegacyMultiSelectQuestion = { + ...mockMultiSelectQuestion, + headline: "What's important on vacay?", + otherOptionPlaceholder: undefined, + choices: mockMultiSelectQuestion.choices.map((choice) => ({ + ...choice, + label: choice.label.default, + })), +}; + +export const mockTranslatedPictureSelectQuestion = { + ...mockPictureSelectQuestion, + headline: { default: "Which is the cutest puppy?", de: "" }, + subheader: { default: "You can also pick both.", de: "" }, +}; +export const mockLegacyPictureSelectQuestion = { + ...mockPictureSelectQuestion, + headline: "Which is the cutest puppy?", + subheader: "You can also pick both.", +}; + +export const mockTranslatedRatingQuestion = { + ...mockRatingQuestion, + headline: { default: "How would you rate My Product", de: "" }, + subheader: { default: "Don't worry, be honest.", de: "" }, + lowerLabel: { default: "Not good", de: "" }, + upperLabel: { default: "Very good", de: "" }, +}; + +export const mockLegacyRatingQuestion = { + ...mockRatingQuestion, + headline: "How would you rate My Product", + subheader: "Don't worry, be honest.", + lowerLabel: "Not good", + upperLabel: "Very good", +}; + +export const mockTranslatedNpsQuestion = { + ...mockNpsQuestion, + headline: { + default: "How likely are you to recommend My Product to a friend or colleague?", + de: "", + }, + lowerLabel: { default: "Not at all likely", de: "" }, + upperLabel: { default: "Extremely likely", de: "" }, +}; + +export const mockLegacyNpsQuestion = { + ...mockNpsQuestion, + headline: "How likely are you to recommend My Product to a friend or colleague?", + lowerLabel: "Not at all likely", + upperLabel: "Extremely likely", +}; + +export const mockTranslatedCtaQuestion = { + ...mockCtaQuestion, + headline: { default: "You are one of our power users!", de: "" }, + buttonLabel: { default: "Book interview", de: "" }, + dismissButtonLabel: { default: "Skip", de: "" }, +}; + +export const mockLegacyCtaQuestion = { + ...mockCtaQuestion, + headline: "You are one of our power users!", + buttonLabel: "Book interview", + dismissButtonLabel: "Skip", +}; + +export const mockTranslatedConsentQuestion = { + ...mockConsentQuestion, + headline: { default: "Terms and Conditions", de: "" }, + label: { default: "I agree to the terms and conditions", de: "" }, + dismissButtonLabel: "Skip", +}; + +export const mockLegacyConsentQuestion = { + ...mockConsentQuestion, + headline: "Terms and Conditions", + label: "I agree to the terms and conditions", + dismissButtonLabel: "Skip", +}; + +export const mockTranslatedDateQuestion = { + ...mockDateQuestion, + headline: { default: "When is your birthday?", de: "" }, +}; + +export const mockLegacyDateQuestion = { + ...mockDateQuestion, + headline: "When is your birthday?", +}; + +export const mockTranslatedFileUploadQuestion = { + ...mockFileUploadQuestion, + headline: { default: "File Upload", de: "" }, +}; + +export const mockLegacyFileUploadQuestion = { + ...mockFileUploadQuestion, + headline: "File Upload", +}; + +export const mockTranslatedCalQuestion = { + ...mockCalQuestion, + headline: { default: "Schedule a call with me", de: "" }, + buttonLabel: { default: "Skip", de: "" }, +}; + +export const mockLegacyCalQuestion = { + ...mockCalQuestion, + headline: "Schedule a call with me", + buttonLabel: "Skip", +}; + +export const mockTranslatedThankYouCard = { + ...mockThankYouCard, + headline: { default: "Thank you!", de: "" }, + subheader: { default: "We appreciate your feedback.", de: "" }, + buttonLabel: { default: "Create your own Survey", de: "" }, +}; + +export const mockLegacyThankYouCard = { + ...mockThankYouCard, + headline: "Thank you!", + subheader: "We appreciate your feedback.", + buttonLabel: "Create your own Survey", +}; + +export const mockTranslatedSurvey = { + ...mockSurvey, + questions: [ + mockTranslatedOpenTextQuestion, + mockTranslatedSingleSelectQuestion, + mockTranslatedMultiSelectQuestion, + mockTranslatedPictureSelectQuestion, + mockTranslatedRatingQuestion, + mockTranslatedNpsQuestion, + mockTranslatedCtaQuestion, + mockTranslatedConsentQuestion, + mockTranslatedDateQuestion, + mockTranslatedFileUploadQuestion, + mockTranslatedCalQuestion, + ], + welcomeCard: mockTranslatedWelcomeCard, + thankYouCard: mockTranslatedThankYouCard, +}; + +export const mockLegacySurvey = { + ...mockSurvey, + createdAt: new Date("2024-02-06T20:12:03.521Z"), + updatedAt: new Date("2024-02-06T20:12:03.521Z"), + questions: [ + mockLegacyOpenTextQuestion, + mockLegacySingleSelectQuestion, + mockLegacyMultiSelectQuestion, + mockLegacyPictureSelectQuestion, + mockLegacyRatingQuestion, + mockLegacyNpsQuestion, + mockLegacyCtaQuestion, + mockLegacyConsentQuestion, + mockLegacyDateQuestion, + mockLegacyFileUploadQuestion, + mockLegacyCalQuestion, + ], + welcomeCard: mockLegacyWelcomeCard, + thankYouCard: mockLegacyThankYouCard, +}; diff --git a/packages/lib/i18n/i18n.test.ts b/packages/lib/i18n/i18n.test.ts new file mode 100644 index 0000000000..e9097bf8db --- /dev/null +++ b/packages/lib/i18n/i18n.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import { TSurveyLanguage } from "@formbricks/types/surveys"; + +import { + mockLegacySurvey, + mockSurvey, + mockThankYouCard, + mockTranslatedSurvey, + mockTranslatedThankYouCard, + mockTranslatedWelcomeCard, + mockWelcomeCard, +} from "./i18n.mock"; +import { reverseTranslateSurvey } from "./reverseTranslation"; +import { createI18nString, translateSurvey, translateThankYouCard, translateWelcomeCard } from "./utils"; + +describe("createI18nString", () => { + it("should create an i18n string from a regular string", () => { + const result = createI18nString("Hello", ["default"]); + expect(result).toEqual({ default: "Hello" }); + }); + + it("should create a new i18n string with i18n enabled from a previous i18n string", () => { + const result = createI18nString({ default: "Hello" }, ["default", "es"]); + expect(result).toEqual({ default: "Hello", es: "" }); + }); + + it("should add a new field key value pair when a new language is added", () => { + const i18nObject = { default: "Hello", es: "Hola" }; + const newLanguages = ["default", "es", "de"]; + const result = createI18nString(i18nObject, newLanguages); + expect(result).toEqual({ + default: "Hello", + es: "Hola", + de: "", + }); + }); + + it("should remove the translation that are not present in newLanguages", () => { + const i18nObject = { default: "Hello", es: "hola" }; + const newLanguages = ["default"]; + const result = createI18nString(i18nObject, newLanguages); + expect(result).toEqual({ + default: "Hello", + }); + }); +}); + +describe("translateWelcomeCard", () => { + it("should translate all text fields of a welcome card", () => { + const languages = ["default", "de"]; + const translatedWelcomeCard = translateWelcomeCard(mockWelcomeCard, languages); + expect(translatedWelcomeCard).toEqual(mockTranslatedWelcomeCard); + }); +}); + +describe("translateThankYouCard", () => { + it("should translate all text fields of a Thank you card", () => { + const languages = ["default", "de"]; + const translatedThankYouCard = translateThankYouCard(mockThankYouCard, languages); + expect(translatedThankYouCard).toEqual(mockTranslatedThankYouCard); + }); +}); + +describe("translateSurvey", () => { + it("should translate all questions of a Survey", () => { + const languages: TSurveyLanguage[] = [ + { + default: true, + enabled: true, + language: { + id: "rp2di001zicbm3mk8je1ue9u", + code: "en", + alias: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + { + default: false, + enabled: true, + language: { + id: "cuuxfzls09sjkueg6lm6n7i0", + code: "de", + alias: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + ]; + const translatedSurvey = translateSurvey(mockSurvey, languages); + expect(translatedSurvey).toEqual(mockTranslatedSurvey); + }); +}); + +describe("translate to Legacy Survey", () => { + it("should translate all questions of a normal survey to Legacy Survey", () => { + const translatedSurvey = reverseTranslateSurvey(mockTranslatedSurvey, "default"); + expect(translatedSurvey).toEqual(mockLegacySurvey); + }); +}); diff --git a/packages/lib/i18n/reverseTranslation.ts b/packages/lib/i18n/reverseTranslation.ts new file mode 100644 index 0000000000..4e1f7953a1 --- /dev/null +++ b/packages/lib/i18n/reverseTranslation.ts @@ -0,0 +1,41 @@ +import "server-only"; + +import { TLegacySurvey, ZLegacySurvey } from "@formbricks/types/LegacySurvey"; +import { TI18nString, TSurvey } from "@formbricks/types/surveys"; + +import { isI18nObject } from "./utils"; + +// Helper function to extract a regular string from an i18nString. +const extractStringFromI18n = (i18nString: TI18nString, languageCode: string): string => { + if (typeof i18nString === "object" && i18nString !== null) { + return i18nString[languageCode] || ""; + } + return i18nString; +}; + +// Assuming I18nString and extraction logic are defined +const reverseTranslateObject = >(obj: T, languageCode: string): T => { + const clonedObj = structuredClone(obj); + for (let key in clonedObj) { + const value = clonedObj[key]; + if (isI18nObject(value)) { + // Now TypeScript knows `value` is I18nString, treat it accordingly + clonedObj[key] = extractStringFromI18n(value, languageCode) as T[Extract]; + } else if (typeof value === "object" && value !== null) { + // Recursively handle nested objects + clonedObj[key] = reverseTranslateObject(value, languageCode); + } + } + return clonedObj; +}; + +export const reverseTranslateSurvey = (survey: TSurvey, languageCode: string = "default"): TLegacySurvey => { + const reversedSurvey = structuredClone(survey); + reversedSurvey.questions = reversedSurvey.questions.map((question) => + reverseTranslateObject(question, languageCode) + ); + reversedSurvey.welcomeCard = reverseTranslateObject(reversedSurvey.welcomeCard, languageCode); + reversedSurvey.thankYouCard = reverseTranslateObject(reversedSurvey.thankYouCard, languageCode); + // validate the type with zod + return ZLegacySurvey.parse(reversedSurvey); +}; diff --git a/packages/lib/i18n/utils.ts b/packages/lib/i18n/utils.ts new file mode 100644 index 0000000000..70b397eddf --- /dev/null +++ b/packages/lib/i18n/utils.ts @@ -0,0 +1,276 @@ +import { + TI18nString, + TSurvey, + TSurveyCTAQuestion, + TSurveyConsentQuestion, + TSurveyLanguage, + TSurveyMultipleChoiceMultiQuestion, + TSurveyNPSQuestion, + TSurveyOpenTextQuestion, + TSurveyQuestion, + TSurveyRatingQuestion, + TSurveyThankYouCard, + TSurveyWelcomeCard, +} from "@formbricks/types/surveys"; + +// Helper function to create an i18nString from a regular string. +export const createI18nString = ( + text: string | TI18nString, + languages: string[], + targetLanguageCode?: string +): TI18nString => { + if (typeof text === "object") { + // It's already an i18n object, so clone it + const i18nString: TI18nString = structuredClone(text); + // Add new language keys with empty strings if they don't exist + languages?.forEach((language) => { + if (!(language in i18nString)) { + i18nString[language] = ""; + } + }); + + // Remove language keys that are not in the languages array + Object.keys(i18nString).forEach((key) => { + if (key !== (targetLanguageCode ?? "default") && languages && !languages.includes(key)) { + delete i18nString[key]; + } + }); + + return i18nString; + } else { + // It's a regular string, so create a new i18n object + const i18nString: any = { + [targetLanguageCode ?? "default"]: text as string, // Type assertion to assure TypeScript `text` is a string + }; + + // Initialize all provided languages with empty strings + languages?.forEach((language) => { + if (language !== (targetLanguageCode ?? "default")) { + i18nString[language] = ""; + } + }); + + return i18nString; + } +}; + +// Type guard to check if an object is an I18nString +export function isI18nObject(obj: any): obj is TI18nString { + return ( + obj !== null && + typeof obj === "object" && + Object.values(obj).every((value) => typeof value === "string") && + Object.keys(obj).includes("default") + ); +} + +export const isLabelValidForAllLanguages = (label: TI18nString, languages: string[]): boolean => { + return languages.every((language) => label[language] && label[language].trim() !== ""); +}; + +export const getLocalizedValue = (value: TI18nString | undefined, languageId: string): string => { + if (!value) { + return ""; + } + if (isI18nObject(value)) { + if (value[languageId]) { + return value[languageId]; + } + return ""; + } + return ""; +}; + +export const extractLanguageCodes = (surveyLanguages: TSurveyLanguage[]): string[] => { + if (!surveyLanguages) return []; + return surveyLanguages.map((surveyLanguage) => + surveyLanguage.default ? "default" : surveyLanguage.language.code + ); +}; + +export const getEnabledLanguages = (surveyLanguages: TSurveyLanguage[]) => { + return surveyLanguages.filter((surveyLanguage) => surveyLanguage.enabled); +}; + +// LGEGACY +// Helper function to maintain backwards compatibility for old survey objects before Multi Language +export const translateWelcomeCard = ( + welcomeCard: TSurveyWelcomeCard, + languages: string[], + targetLanguageCode?: string +): TSurveyWelcomeCard => { + const clonedWelcomeCard = structuredClone(welcomeCard); + if (welcomeCard.headline) { + clonedWelcomeCard.headline = createI18nString(welcomeCard.headline, languages, targetLanguageCode); + } + if (welcomeCard.html) { + clonedWelcomeCard.html = createI18nString(welcomeCard.html, languages, targetLanguageCode); + } + if (clonedWelcomeCard.buttonLabel) { + clonedWelcomeCard.buttonLabel = createI18nString( + clonedWelcomeCard.buttonLabel, + languages, + targetLanguageCode + ); + } + + return clonedWelcomeCard; +}; + +// LGEGACY +// Helper function to maintain backwards compatibility for old survey objects before Multi Language +export const translateThankYouCard = ( + thankYouCard: TSurveyThankYouCard, + languages: string[], + targetLanguageCode?: string +): TSurveyThankYouCard => { + const clonedThankYouCard = structuredClone(thankYouCard); + if (thankYouCard.headline) { + clonedThankYouCard.headline = createI18nString(thankYouCard.headline, languages, targetLanguageCode); + } + if (thankYouCard.subheader) { + clonedThankYouCard.subheader = createI18nString(thankYouCard.subheader, languages, targetLanguageCode); + } + if (thankYouCard.buttonLabel) { + clonedThankYouCard.buttonLabel = createI18nString( + thankYouCard.buttonLabel, + languages, + targetLanguageCode + ); + } + + return clonedThankYouCard; +}; + +// LGEGACY +// Helper function to maintain backwards compatibility for old survey objects before Multi Language +export const translateQuestion = ( + question: TSurveyQuestion, + languages: string[], + targetLanguageCode?: string +) => { + // Clone the question to avoid mutating the original + const clonedQuestion = structuredClone(question); + + clonedQuestion.headline = createI18nString(question.headline, languages, targetLanguageCode); + if (clonedQuestion.subheader) { + clonedQuestion.subheader = createI18nString(question.subheader ?? "", languages, targetLanguageCode); + } + + if (clonedQuestion.buttonLabel) { + clonedQuestion.buttonLabel = createI18nString(question.buttonLabel ?? "", languages, targetLanguageCode); + } + + if (clonedQuestion.backButtonLabel) { + clonedQuestion.backButtonLabel = createI18nString( + question.backButtonLabel ?? "", + languages, + targetLanguageCode + ); + } + + if (question.type === "multipleChoiceSingle" || question.type === "multipleChoiceMulti") { + (clonedQuestion as TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceMultiQuestion).choices = + question.choices.map((choice) => ({ + ...choice, + label: createI18nString(choice.label, languages, targetLanguageCode), + })); + ( + clonedQuestion as TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceMultiQuestion + ).otherOptionPlaceholder = question.otherOptionPlaceholder + ? createI18nString(question.otherOptionPlaceholder, languages, targetLanguageCode) + : undefined; + } + if (question.type === "openText") { + if (question.placeholder) { + (clonedQuestion as TSurveyOpenTextQuestion).placeholder = createI18nString( + question.placeholder, + languages, + targetLanguageCode + ); + } + } + if (question.type === "cta") { + if (question.dismissButtonLabel) { + (clonedQuestion as TSurveyCTAQuestion).dismissButtonLabel = createI18nString( + question.dismissButtonLabel, + languages, + targetLanguageCode + ); + } + if (question.html) { + (clonedQuestion as TSurveyCTAQuestion).html = createI18nString( + question.html, + languages, + targetLanguageCode + ); + } + } + if (question.type === "consent") { + if (question.html) { + (clonedQuestion as TSurveyConsentQuestion).html = createI18nString( + question.html, + languages, + targetLanguageCode + ); + } + + if (question.label) { + (clonedQuestion as TSurveyConsentQuestion).label = createI18nString( + question.label, + languages, + targetLanguageCode + ); + } + } + if (question.type === "nps") { + (clonedQuestion as TSurveyNPSQuestion).lowerLabel = createI18nString( + question.lowerLabel ?? "", + languages, + targetLanguageCode + ); + (clonedQuestion as TSurveyNPSQuestion).upperLabel = createI18nString( + question.upperLabel ?? "", + languages, + targetLanguageCode + ); + } + if (question.type === "rating") { + (clonedQuestion as TSurveyRatingQuestion).lowerLabel = createI18nString( + question.lowerLabel ?? "", + languages, + targetLanguageCode + ); + (clonedQuestion as TSurveyRatingQuestion).upperLabel = createI18nString( + question.upperLabel ?? "", + languages, + targetLanguageCode + ); + } + return clonedQuestion; +}; + +// LGEGACY +// Helper function to maintain backwards compatibility for old survey objects before Multi Language +export const translateSurvey = ( + survey: TSurvey, + surveyLanguages: TSurveyLanguage[], + targetLanguageCode?: string +): TSurvey => { + const languages = extractLanguageCodes(surveyLanguages); + + const translatedQuestions = survey.questions.map((question) => { + return translateQuestion(question, languages, targetLanguageCode); + }); + const translatedWelcomeCard = + survey.welcomeCard && translateWelcomeCard(survey.welcomeCard, languages, targetLanguageCode); + const translatedThankYouCard = + survey.thankYouCard && translateThankYouCard(survey.thankYouCard, languages, targetLanguageCode); + const translatedSurvey = structuredClone(survey); + return { + ...translatedSurvey, + questions: translatedQuestions, + welcomeCard: translatedWelcomeCard, + thankYouCard: translatedThankYouCard, + }; +}; diff --git a/packages/lib/language/service.ts b/packages/lib/language/service.ts new file mode 100644 index 0000000000..fbb5f7df7c --- /dev/null +++ b/packages/lib/language/service.ts @@ -0,0 +1,164 @@ +import { Prisma } from "@prisma/client"; + +import { prisma } from "@formbricks/database"; +import { ZId } from "@formbricks/types/environment"; +import { DatabaseError, ValidationError } from "@formbricks/types/errors"; +import { + TLanguage, + TLanguageInput, + TLanguageUpdate, + ZLanguageInput, + ZLanguageUpdate, +} from "@formbricks/types/product"; + +import { productCache } from "../product/cache"; +import { surveyCache } from "../survey/cache"; +import { validateInputs } from "../utils/validate"; + +const languageSelect = { + id: true, + code: true, + alias: true, + productId: true, + createdAt: true, + updatedAt: true, +}; + +export const createLanguage = async ( + productId: string, + environmentId: string, + languageInput: TLanguageInput +): Promise => { + try { + validateInputs([productId, ZId], [environmentId, ZId], [languageInput, ZLanguageInput]); + if (!languageInput.code) { + throw new ValidationError("Language code is required"); + } + + const language = await prisma.language.create({ + data: { + ...languageInput, + product: { + connect: { id: productId }, + }, + }, + select: languageSelect, + }); + + productCache.revalidate({ + id: productId, + environmentId, + }); + + return language; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + throw error; + } +}; + +export const getSurveysUsingGivenLanguage = async (languageId: string): Promise => { + try { + // Check if the language is used in any survey + const surveys = await prisma.surveyLanguage.findMany({ + where: { + languageId: languageId, + }, + select: { + survey: { + select: { + name: true, + }, + }, + }, + }); + + // Extracting survey names + const surveyNames = surveys.map((s) => s.survey.name); + return surveyNames; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + throw error; + } +}; + +export const deleteLanguage = async (environmentId: string, languageId: string): Promise => { + try { + validateInputs([languageId, ZId]); + + const prismaLanguage = await prisma.language.delete({ + where: { id: languageId }, + select: { ...languageSelect, surveyLanguages: { select: { surveyId: true } } }, + }); + + productCache.revalidate({ + id: prismaLanguage.productId, + environmentId, + }); + + // revalidate cache of all connected surveys + prismaLanguage.surveyLanguages.forEach((surveyLanguage) => { + surveyCache.revalidate({ + id: surveyLanguage.surveyId, + environmentId, + }); + }); + + // delete unused surveyLanguages + const language = { ...prismaLanguage, surveyLanguages: undefined }; + + return language; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + throw error; + } +}; + +export const updateLanguage = async ( + environmentId: string, + languageId: string, + languageInput: TLanguageUpdate +): Promise => { + try { + validateInputs([languageId, ZId], [languageInput, ZLanguageUpdate]); + + const prismaLanguage = await prisma.language.update({ + where: { id: languageId }, + data: { ...languageInput, updatedAt: new Date() }, + select: { ...languageSelect, surveyLanguages: { select: { surveyId: true } } }, + }); + + productCache.revalidate({ + id: prismaLanguage.productId, + environmentId, + }); + + // revalidate cache of all connected surveys + prismaLanguage.surveyLanguages.forEach((surveyLanguage) => { + surveyCache.revalidate({ + id: surveyLanguage.surveyId, + environmentId, + }); + }); + + // delete unused surveyLanguages + const language = { ...prismaLanguage, surveyLanguages: undefined }; + + return language; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + console.error(error); + throw new DatabaseError(error.message); + } + throw error; + } +}; diff --git a/packages/lib/language/tests/__mocks__/data.mock.ts b/packages/lib/language/tests/__mocks__/data.mock.ts new file mode 100644 index 0000000000..054184a48e --- /dev/null +++ b/packages/lib/language/tests/__mocks__/data.mock.ts @@ -0,0 +1,25 @@ +export const mockProductId = "clt2h1ant000f08l36qmx2dy2"; +export const mockLanguageId = "rp2di001zicbm3mk8je1ue9u"; +export const mockLanguage = { + id: mockLanguageId, + code: "en", + alias: null, + createdAt: new Date(), + updatedAt: new Date(), + productId: mockProductId, +}; + +export const mockLanguageUpdate = { + alias: "en-US", +}; + +export const mockUpdatedLanguage = { + ...mockLanguage, + alias: "en-US", +}; + +export const mockLanguageInput = { + code: "en", + alias: null, +}; +export const mockEnvironmentId = "clt2h31iz000h08l3acuwcqvp"; diff --git a/packages/lib/language/tests/language.unit.ts b/packages/lib/language/tests/language.unit.ts new file mode 100644 index 0000000000..65f3f3b877 --- /dev/null +++ b/packages/lib/language/tests/language.unit.ts @@ -0,0 +1,131 @@ +import { + mockEnvironmentId, + mockLanguage, + mockLanguageId, + mockLanguageInput, + mockLanguageUpdate, + mockProductId, + mockUpdatedLanguage, +} from "./__mocks__/data.mock"; + +import { Prisma } from "@prisma/client"; + +import { prismaMock } from "@formbricks/database/src/jestClient"; +import { DatabaseError, ValidationError } from "@formbricks/types/errors"; + +import { createLanguage, deleteLanguage, updateLanguage } from "../service"; + +const testInputValidation = async (service: Function, ...args: any[]): Promise => { + it("it should throw a ValidationError if the inputs are invalid", async () => { + await expect(service(...args)).rejects.toThrow(ValidationError); + }); +}; + +describe("Tests for createLanguage service", () => { + describe("Happy Path", () => { + it("Creates a new Language", async () => { + prismaMock.language.create.mockResolvedValue(mockLanguage); + + const language = await createLanguage(mockProductId, mockEnvironmentId, mockLanguageInput); + expect(language).toEqual(mockLanguage); + }); + }); + + describe("Sad Path", () => { + testInputValidation(createLanguage, "123"); + + it("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => { + const mockErrorMessage = "Mock error message"; + const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { + code: "P2002", + clientVersion: "0.0.1", + }); + + prismaMock.language.create.mockRejectedValue(errToThrow); + + await expect(createLanguage(mockProductId, mockEnvironmentId, mockLanguageInput)).rejects.toThrow( + DatabaseError + ); + }); + + it("Throws a generic Error for other exceptions", async () => { + const mockErrorMessage = "Mock error message"; + prismaMock.language.create.mockRejectedValue(new Error(mockErrorMessage)); + + await expect(createLanguage(mockProductId, mockEnvironmentId, mockLanguageInput)).rejects.toThrow( + Error + ); + }); + }); +}); + +describe("Tests for updateLanguage Service", () => { + describe("Happy Path", () => { + it("Updates a language", async () => { + prismaMock.language.update.mockResolvedValue(mockUpdatedLanguage); + + const language = await updateLanguage(mockEnvironmentId, mockLanguageId, mockLanguageUpdate); + expect(language).toEqual(mockUpdatedLanguage); + }); + }); + + describe("Sad Path", () => { + testInputValidation(updateLanguage, "123", "123"); + + it("Throws DatabaseError on PrismaClientKnownRequestError", async () => { + const mockErrorMessage = "Mock error message"; + const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { + code: "P2002", + clientVersion: "0.0.1", + }); + + prismaMock.language.update.mockRejectedValue(errToThrow); + + await expect(updateLanguage(mockEnvironmentId, mockLanguageId, mockLanguageUpdate)).rejects.toThrow( + DatabaseError + ); + }); + + it("Throws a generic Error for other unexpected issues", async () => { + const mockErrorMessage = "Mock error message"; + prismaMock.language.update.mockRejectedValue(new Error(mockErrorMessage)); + + await expect(updateLanguage(mockEnvironmentId, mockLanguageId, mockLanguageUpdate)).rejects.toThrow( + Error + ); + }); + }); +}); + +describe("Tests for deleteLanguage", () => { + describe("Happy Path", () => { + it("Deletes a Language", async () => { + prismaMock.language.delete.mockResolvedValue(mockLanguage); + + const language = await deleteLanguage(mockEnvironmentId, mockLanguageId); + expect(language).toEqual(mockLanguage); + }); + }); + describe("Sad Path", () => { + testInputValidation(deleteLanguage, "123"); + + it("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => { + const mockErrorMessage = "Mock error message"; + const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, { + code: "P2002", + clientVersion: "0.0.1", + }); + + prismaMock.language.delete.mockRejectedValue(errToThrow); + + await expect(deleteLanguage(mockEnvironmentId, mockLanguageId)).rejects.toThrow(DatabaseError); + }); + + it("Throws a generic Error for other exceptions", async () => { + const mockErrorMessage = "Mock error message"; + prismaMock.language.delete.mockRejectedValue(new Error(mockErrorMessage)); + + await expect(deleteLanguage(mockEnvironmentId, mockLanguageId)).rejects.toThrow(Error); + }); + }); +}); diff --git a/packages/lib/product/service.ts b/packages/lib/product/service.ts index 9d116aaf87..d73d1fe6ab 100644 --- a/packages/lib/product/service.ts +++ b/packages/lib/product/service.ts @@ -26,6 +26,7 @@ const selectProduct = { name: true, teamId: true, brandColor: true, + languages: true, highlightBorderColor: true, recontactDays: true, linkSurveyBranding: true, @@ -110,7 +111,6 @@ export const updateProduct = async ( inputProduct: TProductUpdateInput ): Promise => { validateInputs([productId, ZId], [inputProduct, ZProductUpdateInput]); - const { environments, ...data } = inputProduct; let updatedProduct; try { diff --git a/packages/lib/response/service.ts b/packages/lib/response/service.ts index ff8c9b1fad..9976ad329c 100644 --- a/packages/lib/response/service.ts +++ b/packages/lib/response/service.ts @@ -61,6 +61,7 @@ export const responseSelection = { ttc: true, personAttributes: true, singleUseId: true, + language: true, person: { select: { id: true, @@ -226,8 +227,7 @@ export const createResponse = async (responseInput: TResponseInput): Promise { const whereClause: Record[] = []; - // For finished if (filterCriteria?.finished !== undefined) { whereClause.push({ @@ -122,6 +128,31 @@ export const buildWhereClause = (filterCriteria?: TResponseFilterCriteria) => { }); } + // For Metadata + if (filterCriteria?.metadata) { + const metadata: Prisma.ResponseWhereInput[] = []; + + Object.entries(filterCriteria.metadata).forEach(([key, val]) => { + switch (val.op) { + case "equals": + metadata.push({ + [key.toLocaleLowerCase()]: val.value, + }); + break; + case "notEquals": + metadata.push({ + [key.toLocaleLowerCase()]: { + not: val.value, + }, + }); + break; + } + }); + whereClause.push({ + AND: metadata, + }); + } + // For Questions Data if (filterCriteria?.data) { const data: Prisma.ResponseWhereInput[] = []; @@ -559,7 +590,7 @@ export const getSurveySummaryDropOff = ( const dropOff = survey.questions.map((question, index) => { return { questionId: question.id, - headline: question.headline, + headline: getLocalizedValue(question.headline, "default"), ttc: convertFloatTo2Decimal(totalTtc[question.id]) || 0, views: viewsArr[index] || 0, dropOffCount: dropOffArr[index] || 0, @@ -570,6 +601,43 @@ export const getSurveySummaryDropOff = ( return dropOff; }; +const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => { + if (!surveyLanguages?.length || !languageCode) return "default"; + const language = surveyLanguages.find((surveyLanguage) => surveyLanguage.language.code === languageCode); + return language?.default ? "default" : language?.language.code || "default"; +}; + +const checkForI18n = (response: TResponse, id: string, survey: TSurvey, languageCode: string) => { + const question = survey.questions.find((question) => question.id === id); + + if (question?.type === "multipleChoiceMulti") { + // Initialize an array to hold the choice values + let choiceValues = [] as string[]; + + (typeof response.data[id] === "string" + ? ([response.data[id]] as string[]) + : (response.data[id] as string[]) + ).forEach((data) => { + choiceValues.push( + getLocalizedValue( + question.choices.find((choice) => choice.label[languageCode] === data)?.label, + "default" + ) || data + ); + }); + + // Return the array of localized choice values of multiSelect multi questions + return choiceValues; + } + + // Return the localized value of the choice fo multiSelect single question + const choice = ( + question as TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceSingleQuestion + )?.choices.find((choice) => choice.label[languageCode] === response.data[id]); + + return getLocalizedValue(choice?.label, "default") || response.data[id]; +}; + export const getQuestionWiseSummary = ( survey: TSurvey, responses: TResponse[] @@ -610,7 +678,7 @@ export const getQuestionWiseSummary = ( const lastChoice = question.choices[question.choices.length - 1]; const isOthersEnabled = lastChoice.id === "other"; - const questionChoices = question.choices.map((choice) => choice.label); + const questionChoices = question.choices.map((choice) => getLocalizedValue(choice.label, "default")); if (isOthersEnabled) { questionChoices.pop(); } @@ -621,9 +689,13 @@ export const getQuestionWiseSummary = ( return acc; }, {}); const otherValues: { value: string; person: TPerson | null }[] = []; - responses.forEach((response) => { - const answer = response.data[question.id]; + const responseLanguageCode = getLanguageCode(survey.languages, response.language); + + const answer = + responseLanguageCode === "default" + ? response.data[question.id] + : checkForI18n(response, question.id, survey, responseLanguageCode); if (Array.isArray(answer)) { answer.forEach((value) => { @@ -661,7 +733,7 @@ export const getQuestionWiseSummary = ( if (isOthersEnabled) { values.push({ - value: lastChoice.label || "Other", + value: getLocalizedValue(lastChoice.label, "default") || "Other", count: otherValues.length, percentage: convertFloatTo2Decimal((otherValues.length / totalResponseCount) * 100), others: otherValues.slice(0, VALUES_LIMIT), diff --git a/packages/lib/responses.ts b/packages/lib/responses.ts index 7cc83a5d9c..5b17a05d90 100644 --- a/packages/lib/responses.ts +++ b/packages/lib/responses.ts @@ -1,6 +1,8 @@ import { TResponse } from "@formbricks/types/responses"; import { TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys"; +import { getLocalizedValue } from "./i18n/utils"; + export const getQuestionResponseMapping = ( survey: { questions: TSurveyQuestion[] }, response: TResponse @@ -31,7 +33,7 @@ export const getQuestionResponseMapping = ( }; questionResponseMapping.push({ - question: question.headline, + question: getLocalizedValue(question.headline, "default"), answer: getAnswer(), type: question.type, }); diff --git a/packages/lib/survey/service.ts b/packages/lib/survey/service.ts index dec6840c76..09ae3b7353 100644 --- a/packages/lib/survey/service.ts +++ b/packages/lib/survey/service.ts @@ -4,6 +4,7 @@ import { Prisma } from "@prisma/client"; import { unstable_cache } from "next/cache"; import { prisma } from "@formbricks/database"; +import { TLegacySurvey, ZLegacySurvey } from "@formbricks/types/LegacySurvey"; import { TActionClass } from "@formbricks/types/actionClasses"; import { ZOptionalNumber } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/environment"; @@ -17,6 +18,8 @@ import { getActionClasses } from "../actionClass/service"; import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants"; import { displayCache } from "../display/cache"; import { getDisplaysByPersonId } from "../display/service"; +import { reverseTranslateSurvey } from "../i18n/reverseTranslation"; +import { translateSurvey } from "../i18n/utils"; import { personCache } from "../person/cache"; import { getPerson } from "../person/service"; import { productCache } from "../product/cache"; @@ -68,6 +71,21 @@ export const selectSurvey = { singleUse: true, pin: true, resultShareKey: true, + languages: { + select: { + default: true, + enabled: true, + language: { + select: { + id: true, + code: true, + alias: true, + createdAt: true, + updatedAt: true, + }, + }, + }, + }, triggers: { select: { actionClass: { @@ -330,6 +348,48 @@ export const getSurveys = async ( return surveys.map((survey) => formatDateFields(survey, ZSurvey)); }; +export const transformToLegacySurvey = async ( + survey: TSurvey, + languageCode?: string +): Promise => { + const transformedSurvey = await unstable_cache( + async () => { + const targetLanguage = languageCode ?? "default"; + return reverseTranslateSurvey(survey, targetLanguage); + }, + [`transformToLegacySurvey-${survey}`], + { + tags: [surveyCache.tag.byId(survey.id)], + revalidate: SERVICES_REVALIDATION_INTERVAL, + } + )(); + return formatDateFields(transformedSurvey, ZLegacySurvey); +}; + +export const transformSurveyToSpecificLanguage = async ( + survey: TSurvey, + targetLanguageCode?: string +): Promise => { + // if target language code is not available, it will be transformed to default language + const transformedSurvey = await unstable_cache( + async () => { + if (!survey.languages || survey.languages.length === 0) { + //survey do not have any translations + return survey; + } + return translateSurvey(survey, [], targetLanguageCode); + }, + [`transformSurveyToSpecificLanguage-${survey}-${targetLanguageCode}`], + { + tags: [surveyCache.tag.byId(survey.id)], + revalidate: SERVICES_REVALIDATION_INTERVAL, + } + )(); + // since the unstable_cache function does not support deserialization of dates, we need to manually deserialize them + // https://github.com/vercel/next.js/issues/51613 + return formatDateFields(transformedSurvey, ZSurvey); +}; + export const getSurveyCount = async (environmentId: string): Promise => { const count = await unstable_cache( async () => { @@ -374,7 +434,54 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise => throw new ResourceNotFoundError("Survey", surveyId); } - const { triggers, environmentId, segment, ...surveyData } = updatedSurvey; + const { triggers, environmentId, segment, languages, ...surveyData } = updatedSurvey; + + if (languages) { + // Process languages update logic here + // Extract currentLanguageIds and updatedLanguageIds + const currentLanguageIds = currentSurvey.languages + ? currentSurvey.languages.map((l) => l.language.id) + : []; + const updatedLanguageIds = languages.length > 1 ? updatedSurvey.languages.map((l) => l.language.id) : []; + const enabledLangaugeIds = languages.map((language) => { + if (language.enabled) return language.language.id; + }); + + // Determine languages to add and remove + const languagesToAdd = updatedLanguageIds.filter((id) => !currentLanguageIds.includes(id)); + const languagesToRemove = currentLanguageIds.filter((id) => !updatedLanguageIds.includes(id)); + + const defaultLanguageId = updatedSurvey.languages.find((l) => l.default)?.language.id; + + // Prepare data for Prisma update + data.languages = {}; + + // Update existing languages for default value changes + data.languages.updateMany = currentSurvey.languages.map((surveyLanguage) => ({ + where: { languageId: surveyLanguage.language.id }, + data: { + default: surveyLanguage.language.id === defaultLanguageId, + enabled: enabledLangaugeIds.includes(surveyLanguage.language.id), + }, + })); + + // Add new languages + if (languagesToAdd.length > 0) { + data.languages.create = languagesToAdd.map((languageId) => ({ + languageId: languageId, + default: languageId === defaultLanguageId, + enabled: enabledLangaugeIds.includes(languageId), + })); + } + + // Remove languages no longer associated with the survey + if (languagesToRemove.length > 0) { + data.languages.deleteMany = languagesToRemove.map((languageId) => ({ + languageId: languageId, + enabled: enabledLangaugeIds.includes(languageId), + })); + } + } if (triggers) { data.triggers = processTriggerUpdates(triggers, currentSurvey.triggers, actionClasses); @@ -427,7 +534,6 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise => environmentId: modifiedSurvey.environmentId, segmentId: modifiedSurvey.segment?.id, }); - return modifiedSurvey; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { @@ -487,7 +593,6 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp const actionClasses = await getActionClasses(environmentId); revalidateSurveyByActionClassName(actionClasses, surveyBody.triggers); } - const createdBy = surveyBody.createdBy; delete surveyBody.createdBy; @@ -501,8 +606,8 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp }; if (surveyBody.type === "web" && data.thankYouCard) { - data.thankYouCard.buttonLabel = ""; - data.thankYouCard.buttonLink = ""; + data.thankYouCard.buttonLabel = undefined; + data.thankYouCard.buttonLink = undefined; } if (createdBy) { @@ -549,6 +654,8 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string, u throw new ResourceNotFoundError("Survey", surveyId); } + const defaultLanguageId = existingSurvey.languages.find((l) => l.default)?.language.id; + const actionClasses = await getActionClasses(environmentId); // create new survey with the data of the existing survey @@ -562,8 +669,14 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string, u createdBy: undefined, name: `${existingSurvey.name} (copy)`, status: "draft", - questions: JSON.parse(JSON.stringify(existingSurvey.questions)), - thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)), + questions: structuredClone(existingSurvey.questions), + thankYouCard: structuredClone(existingSurvey.thankYouCard), + languages: { + create: existingSurvey.languages?.map((surveyLanguage) => ({ + languageId: surveyLanguage.language.id, + default: surveyLanguage.language.id === defaultLanguageId, + })), + }, triggers: { create: existingSurvey.triggers.map((trigger) => ({ actionClassId: getActionClassIdFromName(actionClasses, trigger), @@ -581,18 +694,14 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string, u }, }, surveyClosedMessage: existingSurvey.surveyClosedMessage - ? JSON.parse(JSON.stringify(existingSurvey.surveyClosedMessage)) - : Prisma.JsonNull, - singleUse: existingSurvey.singleUse - ? JSON.parse(JSON.stringify(existingSurvey.singleUse)) + ? structuredClone(existingSurvey.surveyClosedMessage) : Prisma.JsonNull, + singleUse: existingSurvey.singleUse ? structuredClone(existingSurvey.singleUse) : Prisma.JsonNull, productOverwrites: existingSurvey.productOverwrites - ? JSON.parse(JSON.stringify(existingSurvey.productOverwrites)) - : Prisma.JsonNull, - styling: existingSurvey.styling ? JSON.parse(JSON.stringify(existingSurvey.styling)) : Prisma.JsonNull, - verifyEmail: existingSurvey.verifyEmail - ? JSON.parse(JSON.stringify(existingSurvey.verifyEmail)) + ? structuredClone(existingSurvey.productOverwrites) : Prisma.JsonNull, + styling: existingSurvey.styling ? structuredClone(existingSurvey.styling) : Prisma.JsonNull, + verifyEmail: existingSurvey.verifyEmail ? structuredClone(existingSurvey.verifyEmail) : Prisma.JsonNull, // we'll update the segment later segment: undefined, }, @@ -665,7 +774,7 @@ export const getSyncSurveys = async ( options?: { version?: string; } -): Promise => { +): Promise => { validateInputs([environmentId, ZId]); const surveys = await unstable_cache( @@ -681,7 +790,7 @@ export const getSyncSurveys = async ( throw new Error("Person not found"); } - let surveys = await getSurveys(environmentId); + let surveys: TSurvey[] | TLegacySurvey[] = await getSurveys(environmentId); // filtered surveys for running and web surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "web"); @@ -799,7 +908,6 @@ export const getSyncSurveys = async ( if (!surveys) { throw new ResourceNotFoundError("Survey", environmentId); } - return surveys; }, [`getSyncSurveys-${environmentId}-${personId}`], @@ -815,7 +923,7 @@ export const getSyncSurveys = async ( } )(); - return surveys.map((survey) => formatDateFields(survey, ZSurvey)); + return surveys.map((survey) => formatDateFields(survey as TSurvey, ZSurvey)); }; export const getSurveyIdByResultShareKey = async (resultShareKey: string): Promise => { diff --git a/packages/lib/survey/tests/__mock__/survey.mock.ts b/packages/lib/survey/tests/__mock__/survey.mock.ts index 52087418bc..cbd85e2301 100644 --- a/packages/lib/survey/tests/__mock__/survey.mock.ts +++ b/packages/lib/survey/tests/__mock__/survey.mock.ts @@ -6,6 +6,7 @@ import { TProduct } from "@formbricks/types/product"; import { TSurvey, TSurveyInput, + TSurveyLanguage, TSurveyQuestion, TSurveyQuestionType, TSurveyWelcomeCard, @@ -31,6 +32,31 @@ type SurveyMock = Prisma.SurveyGetPayload<{ include: typeof selectSurvey; }>; +export const mockSurveyLanguages: TSurveyLanguage[] = [ + { + default: true, + enabled: true, + language: { + id: "rp2di001zicbm3mk8je1ue9u", + code: "en", + alias: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + { + default: false, + enabled: true, + language: { + id: "cuuxfzls09sjkueg6lm6n7i0", + code: "de", + alias: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, +]; + export const mockProduct: TProduct = { id: mockId, createdAt: currentDate, @@ -46,6 +72,7 @@ export const mockProduct: TProduct = { clickOutsideClose: false, darkOverlay: false, environments: [], + languages: [], }; export const mockDisplay = { @@ -77,17 +104,17 @@ export const mockUser: TUser = { }, }; -export const mockPerson: Prisma.PersonGetPayload<{ +export const mockPrismaPerson: Prisma.PersonGetPayload<{ include: typeof selectPerson; }> = { id: mockId, userId: mockId, attributes: [ { - value: "value", + value: "de", attributeClass: { id: mockId, - name: "test", + name: "language", }, }, ], @@ -115,14 +142,14 @@ export const mockAttributeClass: TAttributeClass = { const mockQuestion: TSurveyQuestion = { id: mockId, type: TSurveyQuestionType.OpenText, - headline: "Question Text", + headline: { default: "Question Text", de: "Fragetext" }, required: false, inputType: "text", }; const mockWelcomeCard: TSurveyWelcomeCard = { enabled: false, - headline: "My welcome card", + headline: { default: "My welcome card", de: "Meine Willkommenskarte" }, timeToFinish: false, showResponseCount: false, }; @@ -171,6 +198,10 @@ export const mockTeamOutput: TTeam = { status: "inactive", unlimited: false, }, + multiLanguage: { + status: "inactive", + unlimited: false, + }, }, }, }; @@ -190,6 +221,7 @@ export const mockSurveyOutput: SurveyMock = { segmentId: null, resultShareKey: null, inlineTriggers: null, + languages: mockSurveyLanguages, ...baseSurveyProperties, }; @@ -215,6 +247,7 @@ export const updateSurveyInput: TSurvey = { resultShareKey: null, segment: null, inlineTriggers: null, + languages: [], ...commonMockProperties, ...baseSurveyProperties, }; diff --git a/packages/lib/survey/tests/survey.test.ts b/packages/lib/survey/tests/survey.test.ts index 05540f5df2..06ca55b61e 100644 --- a/packages/lib/survey/tests/survey.test.ts +++ b/packages/lib/survey/tests/survey.test.ts @@ -23,7 +23,7 @@ import { mockAttributeClass, mockDisplay, mockId, - mockPerson, + mockPrismaPerson, mockProduct, mockSurveyOutput, mockTeamOutput, @@ -286,15 +286,19 @@ describe("Tests for getSyncedSurveys", () => { it("Returns synced surveys", async () => { prisma.survey.findMany.mockResolvedValueOnce([mockSurveyOutput]); - prisma.person.findUnique.mockResolvedValueOnce(mockPerson); - const surveys = await getSyncSurveys(mockId, mockPerson.id); + prisma.person.findUnique.mockResolvedValueOnce(mockPrismaPerson); + const surveys = await getSyncSurveys(mockId, mockPrismaPerson.id, "desktop", { + version: "1.7.0", + }); expect(surveys).toEqual([mockTransformedSurveyOutput]); }); it("Returns an empty array if no surveys are found", async () => { prisma.survey.findMany.mockResolvedValueOnce([]); - prisma.person.findUnique.mockResolvedValueOnce(mockPerson); - const surveys = await getSyncSurveys(mockId, mockPerson.id); + prisma.person.findUnique.mockResolvedValueOnce(mockPrismaPerson); + const surveys = await getSyncSurveys(mockId, mockPrismaPerson.id, "desktop", { + version: "1.7.0", + }); expect(surveys).toEqual([]); }); }); @@ -305,14 +309,18 @@ describe("Tests for getSyncedSurveys", () => { it("does not find a Product", async () => { prisma.product.findFirst.mockResolvedValueOnce(null); - await expect(getSyncSurveys(mockId, mockPerson.id)).rejects.toThrow(Error); + await expect( + getSyncSurveys(mockId, mockPrismaPerson.id, "desktop", { version: "1.7.0" }) + ).rejects.toThrow(Error); }); it("should throw an error if there is an unknown error", async () => { const mockErrorMessage = "Unknown error occurred"; prisma.actionClass.findMany.mockResolvedValueOnce([mockActionClass]); prisma.survey.create.mockRejectedValue(new Error(mockErrorMessage)); - await expect(getSyncSurveys(mockId, mockPerson.id)).rejects.toThrow(Error); + await expect( + getSyncSurveys(mockId, mockPrismaPerson.id, "desktop", { version: "1.7.0" }) + ).rejects.toThrow(Error); }); }); }); diff --git a/packages/lib/survey/util.ts b/packages/lib/survey/util.ts index 6700afcace..48bcc39e14 100644 --- a/packages/lib/survey/util.ts +++ b/packages/lib/survey/util.ts @@ -1,5 +1,7 @@ import "server-only"; +import { TLegacySurvey } from "@formbricks/types/LegacySurvey"; +import { TPerson } from "@formbricks/types/people"; import { TSurvey } from "@formbricks/types/surveys"; export const formatSurveyDateFields = (survey: TSurvey): TSurvey => { @@ -23,8 +25,43 @@ export const formatSurveyDateFields = (survey: TSurvey): TSurvey => { } } + if (survey.languages) { + survey.languages.forEach((surveyLanguage) => { + if (typeof surveyLanguage.language.createdAt === "string") { + surveyLanguage.language.createdAt = new Date(surveyLanguage.language.createdAt); + } + if (typeof surveyLanguage.language.updatedAt === "string") { + surveyLanguage.language.updatedAt = new Date(surveyLanguage.language.updatedAt); + } + }); + } + return survey; }; -export const anySurveyHasFilters = (surveys: TSurvey[]) => - !surveys.every((survey) => !survey.segment?.filters?.length); +export const anySurveyHasFilters = (surveys: TSurvey[] | TLegacySurvey[]): boolean => { + return surveys.some((survey) => { + if ("segment" in survey && survey.segment) { + return survey.segment.filters && survey.segment.filters.length > 0; + } + return false; + }); +}; + +export const determineLanguageCode = (person: TPerson, survey: TSurvey) => { + // Default to 'default' if person.attributes.language is not set or not a string + if (!person.attributes?.language) return "default"; + const languageCodeOrAlias = + typeof person.attributes?.language === "string" ? person.attributes.language : "default"; + + // Find the matching language in the survey + const selectedLanguage = survey.languages.find( + (surveyLanguage) => + surveyLanguage.language.code === languageCodeOrAlias || + surveyLanguage.language.alias === languageCodeOrAlias + ); + if (!selectedLanguage) return; + + // Determine and return the language code to use + return selectedLanguage.default ? "default" : selectedLanguage.language.code; +}; diff --git a/packages/lib/useDocumentVisibility.ts b/packages/lib/useDocumentVisibility.ts new file mode 100644 index 0000000000..385495bd26 --- /dev/null +++ b/packages/lib/useDocumentVisibility.ts @@ -0,0 +1,21 @@ +import { useEffect } from "react"; + +// This hook will listen to the visibilitychange event and run the provided function whenever the document's visibility state changes to visible +export const useDocumentVisibility = (onVisible: () => void) => { + useEffect(() => { + const listener = () => { + if (document.visibilityState === "visible") { + onVisible(); + } + }; + + document.addEventListener("visibilitychange", listener); + + return () => { + document.removeEventListener("visibilitychange", listener); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +}; + +export default useDocumentVisibility; diff --git a/packages/lib/utils/multiLanguage.ts b/packages/lib/utils/multiLanguage.ts new file mode 100644 index 0000000000..0ea898c25c --- /dev/null +++ b/packages/lib/utils/multiLanguage.ts @@ -0,0 +1,17 @@ +import { ENTERPRISE_LICENSE_KEY, IS_FORMBRICKS_CLOUD } from "../constants"; +import { getTeam } from "../team/service"; + +export const getIsEnterpriseEdition = (): boolean => { + if (ENTERPRISE_LICENSE_KEY) { + return ENTERPRISE_LICENSE_KEY.length > 0; + } + return false; +}; + +export const getMultiLanguagePermission = async (teamId: string): Promise => { + const team = await getTeam(teamId); + if (!team) return false; + if (IS_FORMBRICKS_CLOUD) return team.billing.features.inAppSurvey.status !== "inactive"; + else if (!IS_FORMBRICKS_CLOUD) return getIsEnterpriseEdition(); + else return false; +}; diff --git a/packages/lib/utils/recall.ts b/packages/lib/utils/recall.ts index 6d0f37cd74..34d503c3e3 100644 --- a/packages/lib/utils/recall.ts +++ b/packages/lib/utils/recall.ts @@ -1,6 +1,8 @@ import { RefObject, useEffect } from "react"; -import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys"; +import { TI18nString, TSurvey, TSurveyQuestion } from "@formbricks/types/surveys"; + +import { getLocalizedValue } from "../i18n/utils"; export interface fallbacks { [id: string]: string; @@ -50,15 +52,23 @@ export const findRecallInfoById = (text: string, id: string): string | null => { }; // Converts recall information in a headline to a corresponding recall question headline, with or without a slash. -export const recallToHeadline = (headline: string, survey: TSurvey, withSlash: boolean): string => { - let newHeadline = headline; - if (!headline.includes("#recall:")) return headline; +export const recallToHeadline = ( + headline: TI18nString, + survey: TSurvey, + withSlash: boolean, + language: string +): TI18nString => { + let newHeadline = structuredClone(headline); + if (!newHeadline[language]?.includes("#recall:")) return headline; - while (newHeadline.includes("#recall:")) { - const recallInfo = extractRecallInfo(newHeadline); + while (newHeadline[language].includes("#recall:")) { + const recallInfo = extractRecallInfo(getLocalizedValue(newHeadline, language)); if (recallInfo) { const questionId = extractId(recallInfo); - let questionHeadline = survey.questions.find((question) => question.id === questionId)?.headline; + let questionHeadline = getLocalizedValue( + survey.questions.find((question) => question.id === questionId)?.headline, + language + ); while (questionHeadline?.includes("#recall:")) { const recallInfo = extractRecallInfo(questionHeadline); if (recallInfo) { @@ -66,9 +76,9 @@ export const recallToHeadline = (headline: string, survey: TSurvey, withSlash: b } } if (withSlash) { - newHeadline = newHeadline.replace(recallInfo, `/${questionHeadline}\\`); + newHeadline[language] = newHeadline[language].replace(recallInfo, `/${questionHeadline}\\`); } else { - newHeadline = newHeadline.replace(recallInfo, `@${questionHeadline}`); + newHeadline[language] = newHeadline[language].replace(recallInfo, `@${questionHeadline}`); } } } @@ -76,24 +86,33 @@ export const recallToHeadline = (headline: string, survey: TSurvey, withSlash: b }; // Replaces recall information in a survey question's headline with an ___. -export const replaceRecallInfoWithUnderline = (recallQuestion: TSurveyQuestion): TSurveyQuestion => { - while (recallQuestion.headline.includes("#recall:")) { - const recallInfo = extractRecallInfo(recallQuestion.headline); +export const replaceRecallInfoWithUnderline = ( + recallQuestion: TSurveyQuestion, + language: string +): TSurveyQuestion => { + while (getLocalizedValue(recallQuestion.headline, language).includes("#recall:")) { + const recallInfo = extractRecallInfo(getLocalizedValue(recallQuestion.headline, language)); if (recallInfo) { - recallQuestion.headline = recallQuestion.headline.replace(recallInfo, "___"); + recallQuestion.headline[language] = getLocalizedValue(recallQuestion.headline, language).replace( + recallInfo, + "___" + ); } } return recallQuestion; }; // Checks for survey questions with a "recall" pattern but no fallback value. -export const checkForEmptyFallBackValue = (survey: TSurvey): TSurveyQuestion | null => { +export const checkForEmptyFallBackValue = (survey: TSurvey, langauge: string): TSurveyQuestion | null => { const findRecalls = (text: string) => { const recalls = text.match(/#recall:[^ ]+/g); return recalls && recalls.some((recall) => !extractFallbackValue(recall)); }; for (const question of survey.questions) { - if (findRecalls(question.headline) || (question.subheader && findRecalls(question.subheader))) { + if ( + findRecalls(getLocalizedValue(question.headline, langauge)) || + (question.subheader && findRecalls(getLocalizedValue(question.subheader, langauge))) + ) { return question; } } @@ -101,16 +120,16 @@ export const checkForEmptyFallBackValue = (survey: TSurvey): TSurveyQuestion | n }; // Processes each question in a survey to ensure headlines are formatted correctly for recall and return the modified survey. -export const checkForRecallInHeadline = (survey: TSurvey): TSurvey => { - const modifiedSurvey = structuredClone(survey); +export const checkForRecallInHeadline = (survey: TSurvey, langauge: string): TSurvey => { + const modifiedSurvey: TSurvey = structuredClone(survey); modifiedSurvey.questions.forEach((question) => { - question.headline = recallToHeadline(question.headline, modifiedSurvey, false); + question.headline = recallToHeadline(question.headline, modifiedSurvey, false, langauge); }); return modifiedSurvey; }; // Retrieves an array of survey questions referenced in a text containing recall information. -export const getRecallQuestions = (text: string, survey: TSurvey): TSurveyQuestion[] => { +export const getRecallQuestions = (text: string, survey: TSurvey, langauge: string): TSurveyQuestion[] => { if (!text.includes("#recall:")) return []; const ids = extractIds(text); @@ -118,8 +137,8 @@ export const getRecallQuestions = (text: string, survey: TSurvey): TSurveyQuesti ids.forEach((questionId) => { let recallQuestion = survey.questions.find((question) => question.id === questionId); if (recallQuestion) { - let recallQuestionTemp = { ...recallQuestion }; - recallQuestionTemp = replaceRecallInfoWithUnderline(recallQuestionTemp); + let recallQuestionTemp = structuredClone(recallQuestion); + recallQuestionTemp = replaceRecallInfoWithUnderline(recallQuestionTemp, langauge); recallQuestionArray.push(recallQuestionTemp); } }); @@ -145,11 +164,12 @@ export const getFallbackValues = (text: string): fallbacks => { export const headlineToRecall = ( text: string, recallQuestions: TSurveyQuestion[], - fallbacks: fallbacks + fallbacks: fallbacks, + langauge: string ): string => { recallQuestions.forEach((recallQuestion) => { const recallInfo = `#recall:${recallQuestion.id}/fallback:${fallbacks[recallQuestion.id]}#`; - text = text.replace(`@${recallQuestion.headline}`, recallInfo); + text = text.replace(`@${recallQuestion.headline[langauge]}`, recallInfo); }); return text; }; diff --git a/packages/lib/utils/version.ts b/packages/lib/utils/version.ts new file mode 100644 index 0000000000..bd9072a1fa --- /dev/null +++ b/packages/lib/utils/version.ts @@ -0,0 +1,15 @@ +export const isVersionGreaterThanOrEqualTo = (version, specificVersion) => { + // return true; // uncomment when testing in demo app + const parts1 = version.split(".").map(Number); + const parts2 = specificVersion.split(".").map(Number); + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const num1 = parts1[i] || 0; + const num2 = parts2[i] || 0; + + if (num1 > num2) return true; + if (num1 < num2) return false; + } + + return true; +}; diff --git a/packages/package.json b/packages/package.json new file mode 100644 index 0000000000..c76a88df8e --- /dev/null +++ b/packages/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "react-select": "^5.8.0" + } +} diff --git a/packages/surveys/package.json b/packages/surveys/package.json index 612206003c..dd3b8c07a7 100644 --- a/packages/surveys/package.json +++ b/packages/surveys/package.json @@ -1,7 +1,7 @@ { "name": "@formbricks/surveys", "license": "MIT", - "version": "1.6.2", + "version": "1.7.0", "description": "Formbricks-surveys is a helper library to embed surveys into your application", "homepage": "https://formbricks.com", "repository": { diff --git a/packages/surveys/src/components/general/Headline.tsx b/packages/surveys/src/components/general/Headline.tsx index 19742f3012..656af76361 100644 --- a/packages/surveys/src/components/general/Headline.tsx +++ b/packages/surveys/src/components/general/Headline.tsx @@ -1,5 +1,7 @@ +import { TI18nString } from "@formbricks/types/surveys"; + interface HeadlineProps { - headline?: string; + headline?: TI18nString | string; questionId: string; required?: boolean; alignTextCenter?: boolean; diff --git a/packages/surveys/src/components/general/QuestionConditional.tsx b/packages/surveys/src/components/general/QuestionConditional.tsx index aa0e1d629b..e55cef6895 100644 --- a/packages/surveys/src/components/general/QuestionConditional.tsx +++ b/packages/surveys/src/components/general/QuestionConditional.tsx @@ -23,6 +23,7 @@ interface QuestionConditionalProps { onFileUpload: (file: File, config?: TUploadFileConfig) => Promise; isFirstQuestion: boolean; isLastQuestion: boolean; + languageCode: string; autoFocus?: boolean; ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; @@ -37,6 +38,7 @@ export default function QuestionConditional({ onBack, isFirstQuestion, isLastQuestion, + languageCode, autoFocus = true, ttc, setTtc, @@ -53,6 +55,7 @@ export default function QuestionConditional({ isFirstQuestion={isFirstQuestion} isLastQuestion={isLastQuestion} autoFocus={autoFocus} + languageCode={languageCode} ttc={ttc} setTtc={setTtc} /> @@ -65,6 +68,7 @@ export default function QuestionConditional({ onBack={onBack} isFirstQuestion={isFirstQuestion} isLastQuestion={isLastQuestion} + languageCode={languageCode} ttc={ttc} setTtc={setTtc} /> @@ -77,6 +81,7 @@ export default function QuestionConditional({ onBack={onBack} isFirstQuestion={isFirstQuestion} isLastQuestion={isLastQuestion} + languageCode={languageCode} ttc={ttc} setTtc={setTtc} /> @@ -89,6 +94,7 @@ export default function QuestionConditional({ onBack={onBack} isFirstQuestion={isFirstQuestion} isLastQuestion={isLastQuestion} + languageCode={languageCode} ttc={ttc} setTtc={setTtc} /> @@ -101,6 +107,7 @@ export default function QuestionConditional({ onBack={onBack} isFirstQuestion={isFirstQuestion} isLastQuestion={isLastQuestion} + languageCode={languageCode} ttc={ttc} setTtc={setTtc} /> @@ -113,6 +120,7 @@ export default function QuestionConditional({ onBack={onBack} isFirstQuestion={isFirstQuestion} isLastQuestion={isLastQuestion} + languageCode={languageCode} ttc={ttc} setTtc={setTtc} /> @@ -125,6 +133,7 @@ export default function QuestionConditional({ onBack={onBack} isFirstQuestion={isFirstQuestion} isLastQuestion={isLastQuestion} + languageCode={languageCode} ttc={ttc} setTtc={setTtc} /> @@ -137,6 +146,7 @@ export default function QuestionConditional({ onBack={onBack} isFirstQuestion={isFirstQuestion} isLastQuestion={isLastQuestion} + languageCode={languageCode} ttc={ttc} setTtc={setTtc} /> @@ -149,6 +159,7 @@ export default function QuestionConditional({ onBack={onBack} isFirstQuestion={isFirstQuestion} isLastQuestion={isLastQuestion} + languageCode={languageCode} ttc={ttc} setTtc={setTtc} /> @@ -163,6 +174,7 @@ export default function QuestionConditional({ isFirstQuestion={isFirstQuestion} isLastQuestion={isLastQuestion} onFileUpload={onFileUpload} + languageCode={languageCode} ttc={ttc} setTtc={setTtc} /> @@ -175,6 +187,7 @@ export default function QuestionConditional({ onBack={onBack} isFirstQuestion={isFirstQuestion} isLastQuestion={isLastQuestion} + languageCode={languageCode} ttc={ttc} setTtc={setTtc} /> diff --git a/packages/surveys/src/components/general/Survey.tsx b/packages/surveys/src/components/general/Survey.tsx index 53c6f31fb5..a0a72acf47 100644 --- a/packages/surveys/src/components/general/Survey.tsx +++ b/packages/surveys/src/components/general/Survey.tsx @@ -6,6 +6,7 @@ import { evaluateCondition } from "@/lib/logicEvaluator"; import { cn } from "@/lib/utils"; import { useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { formatDateWithOrdinal, isValidDateString } from "@formbricks/lib/utils/datetime"; import { extractFallbackValue, extractId, extractRecallInfo } from "@formbricks/lib/utils/recall"; import { SurveyBaseProps } from "@formbricks/types/formbricksSurveys"; @@ -28,6 +29,7 @@ export function Survey({ onRetry = () => {}, isRedirectDisabled = false, prefillResponseData, + languageCode, getSetIsError, getSetIsResponseSendingFinished, onFileUpload, @@ -61,7 +63,7 @@ export function Survey({ const showProgressBar = !survey.styling?.hideProgressBar; useEffect(() => { - if (activeQuestionId === "hidden") return; + if (activeQuestionId === "hidden" || activeQuestionId === "multiLanguage") return; if (activeQuestionId === "start" && !survey.welcomeCard.enabled) { setQuestionId(survey?.questions[0]?.id); return; @@ -120,13 +122,45 @@ export function Survey({ if (currQuesTemp?.logic && currQuesTemp?.logic.length > 0 && currentQuestion) { for (let logic of currQuesTemp.logic) { if (!logic.destination) continue; + // Check if the current question is of type 'multipleChoiceSingle' or 'multipleChoiceMulti' if ( currentQuestion.type === "multipleChoiceSingle" || currentQuestion.type === "multipleChoiceMulti" ) { - const choice = currentQuestion.choices.find((choice) => choice.label === responseValue); - // if choice is undefined we can determine that, "other" option is selected - if (!choice) { + let choice; + + // Check if the response is a string (applies to single choice questions) + // Sonne -> sun + if (typeof responseValue === "string") { + // Find the choice in currentQuestion.choices that matches the responseValue after localization + choice = currentQuestion.choices.find((choice) => { + return getLocalizedValue(choice.label, languageCode) === responseValue; + })?.label; + + // If a matching choice is found, get its default localized value + if (choice) { + choice = getLocalizedValue(choice, "default"); + } + } + // Check if the response is an array (applies to multiple choices questions) + // ["Sonne","Mond"]->["sun","moon"] + else if (Array.isArray(responseValue)) { + // Filter and map the choices in currentQuestion.choices that are included in responseValue after localization + choice = currentQuestion.choices + .filter((choice) => { + return responseValue.includes(getLocalizedValue(choice.label, languageCode)); + }) + .map((choice) => getLocalizedValue(choice.label, "default")); + } + + // If a choice is determined (either single or multiple), evaluate the logic condition with that choice + if (choice) { + if (evaluateCondition(logic, choice)) { + return logic.destination; + } + } + // If choice is undefined, it implies an "other" option is selected. Evaluate the logic condition for "Other" + else { if (evaluateCondition(logic, "Other")) { return logic.destination; } @@ -163,7 +197,7 @@ export function Survey({ onActiveQuestionChange(nextQuestionId); }; - const replaceRecallInfo = (text: string) => { + const replaceRecallInfo = (text: string): string => { while (text.includes("recall:")) { const recallInfo = extractRecallInfo(text); if (recallInfo) { @@ -184,12 +218,20 @@ export function Survey({ }; const parseRecallInformation = (question: TSurveyQuestion) => { - const modifiedQuestion = { ...question }; - if (question.headline.includes("recall:")) { - modifiedQuestion.headline = replaceRecallInfo(modifiedQuestion.headline); + const modifiedQuestion = structuredClone(question); + if (question.headline && question.headline[languageCode]?.includes("recall:")) { + modifiedQuestion.headline[languageCode] = replaceRecallInfo( + getLocalizedValue(modifiedQuestion.headline, languageCode) + ); } - if (question.subheader && question.subheader.includes("recall:")) { - modifiedQuestion.subheader = replaceRecallInfo(modifiedQuestion.subheader as string); + if ( + question.subheader && + question.subheader[languageCode]?.includes("recall:") && + modifiedQuestion.subheader + ) { + modifiedQuestion.subheader[languageCode] = replaceRecallInfo( + getLocalizedValue(modifiedQuestion.subheader, languageCode) + ); } return modifiedQuestion; }; @@ -226,28 +268,23 @@ export function Survey({ buttonLabel={survey.welcomeCard.buttonLabel} onSubmit={onSubmit} survey={survey} + languageCode={languageCode} responseCount={responseCount} /> ); } else if (questionId === "end" && survey.thankYouCard.enabled) { return ( ); } else { @@ -269,6 +306,7 @@ export function Survey({ : currentQuestion.id === survey?.questions[0]?.id } isLastQuestion={currentQuestion.id === survey.questions[survey.questions.length - 1].id} + languageCode={languageCode} /> ) ); diff --git a/packages/surveys/src/components/general/SurveyModal.tsx b/packages/surveys/src/components/general/SurveyModal.tsx index a9dc53aa08..2b3b296316 100644 --- a/packages/surveys/src/components/general/SurveyModal.tsx +++ b/packages/surveys/src/components/general/SurveyModal.tsx @@ -23,6 +23,7 @@ export function SurveyModal({ onFileUpload, onRetry, isRedirectDisabled = false, + languageCode, responseCount, }: SurveyModalProps) { const [isOpen, setIsOpen] = useState(true); @@ -53,6 +54,7 @@ export function SurveyModal({ getSetIsResponseSendingFinished={getSetIsResponseSendingFinished} onActiveQuestionChange={onActiveQuestionChange} onResponse={onResponse} + languageCode={languageCode} onClose={close} onFinished={() => { onFinished(); diff --git a/packages/surveys/src/components/general/ThankYouCard.tsx b/packages/surveys/src/components/general/ThankYouCard.tsx index f92031c91c..c2f9de8196 100644 --- a/packages/surveys/src/components/general/ThankYouCard.tsx +++ b/packages/surveys/src/components/general/ThankYouCard.tsx @@ -5,14 +5,19 @@ import RedirectCountDown from "@/components/general/RedirectCountdown"; import Subheader from "@/components/general/Subheader"; import { useEffect } from "preact/hooks"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; +import { TI18nString } from "@formbricks/types/surveys"; + interface ThankYouCardProps { - headline?: string; - subheader?: string; + headline?: TI18nString; + subheader?: TI18nString; redirectUrl: string | null; isRedirectDisabled: boolean; - buttonLabel?: string; + languageCode: string; + buttonLabel?: TI18nString; buttonLink?: string; imageUrl?: string; + replaceRecallInfo: (text: string) => string; isResponseSendingFinished: boolean; } @@ -21,9 +26,11 @@ export default function ThankYouCard({ subheader, redirectUrl, isRedirectDisabled, + languageCode, buttonLabel, buttonLink, imageUrl, + replaceRecallInfo, isResponseSendingFinished, }: ThankYouCardProps) { useEffect(() => { @@ -65,13 +72,20 @@ export default function ThankYouCard({ )}
- - + + {buttonLabel && isResponseSendingFinished && (
)} { diff --git a/packages/surveys/src/components/questions/CalQuestion.tsx b/packages/surveys/src/components/questions/CalQuestion.tsx index eebc872fde..d8c3af82c8 100644 --- a/packages/surveys/src/components/questions/CalQuestion.tsx +++ b/packages/surveys/src/components/questions/CalQuestion.tsx @@ -7,6 +7,7 @@ import Subheader from "@/components/general/Subheader"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { useCallback, useState } from "preact/hooks"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { TResponseData } from "@formbricks/types/responses"; import { TResponseTtc } from "@formbricks/types/responses"; import { TSurveyCalQuestion } from "@formbricks/types/surveys"; @@ -19,6 +20,7 @@ interface CalQuestionProps { onBack: () => void; isFirstQuestion: boolean; isLastQuestion: boolean; + languageCode: string; ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; } @@ -31,6 +33,7 @@ export default function CalQuestion({ onBack, isFirstQuestion, isLastQuestion, + languageCode, ttc, setTtc, }: CalQuestionProps) { @@ -64,10 +67,15 @@ export default function CalQuestion({ }} className="w-full"> {question.imageUrl && } - - - - + + <> {errorMessage && {errorMessage}} @@ -76,7 +84,7 @@ export default function CalQuestion({
{!isFirstQuestion && ( { onBack(); }} @@ -85,7 +93,7 @@ export default function CalQuestion({
{!question.required && ( {}} /> diff --git a/packages/surveys/src/components/questions/ConsentQuestion.tsx b/packages/surveys/src/components/questions/ConsentQuestion.tsx index 67a1dd16e8..d0a28a938f 100644 --- a/packages/surveys/src/components/questions/ConsentQuestion.tsx +++ b/packages/surveys/src/components/questions/ConsentQuestion.tsx @@ -6,6 +6,7 @@ import QuestionImage from "@/components/general/QuestionImage"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { useState } from "preact/hooks"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { TResponseData, TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyConsentQuestion } from "@formbricks/types/surveys"; @@ -17,6 +18,7 @@ interface ConsentQuestionProps { onBack: () => void; isFirstQuestion: boolean; isLastQuestion: boolean; + languageCode: string; ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; } @@ -29,6 +31,7 @@ export default function ConsentQuestion({ onBack, isFirstQuestion, isLastQuestion, + languageCode, ttc, setTtc, }: ConsentQuestionProps) { @@ -39,9 +42,12 @@ export default function ConsentQuestion({ return (
{question.imageUrl && } - - - + + { e.preventDefault(); @@ -61,7 +67,7 @@ export default function ConsentQuestion({ type="checkbox" id={question.id} name={question.id} - value={question.label} + value={getLocalizedValue(question.label, languageCode)} onChange={(e) => { if (e.target instanceof HTMLInputElement && e.target.checked) { onChange({ [question.id]: "accepted" }); @@ -75,7 +81,7 @@ export default function ConsentQuestion({ required={question.required} /> - {question.label} + {getLocalizedValue(question.label, languageCode)} @@ -83,7 +89,7 @@ export default function ConsentQuestion({ {!isFirstQuestion && ( { const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); setTtc(updatedTtcObj); @@ -95,7 +101,7 @@ export default function ConsentQuestion({
{}} /> diff --git a/packages/surveys/src/components/questions/DateQuestion.tsx b/packages/surveys/src/components/questions/DateQuestion.tsx index 3aefabae60..e6e66b83ad 100644 --- a/packages/surveys/src/components/questions/DateQuestion.tsx +++ b/packages/surveys/src/components/questions/DateQuestion.tsx @@ -7,6 +7,7 @@ import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { cn } from "@/lib/utils"; import { useEffect, useState } from "preact/hooks"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { TResponseData, TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyDateQuestion } from "@formbricks/types/surveys"; @@ -19,6 +20,7 @@ interface DateQuestionProps { isFirstQuestion: boolean; isLastQuestion: boolean; autoFocus?: boolean; + languageCode: string; ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; } @@ -31,6 +33,7 @@ export default function DateQuestion({ isFirstQuestion, isLastQuestion, onChange, + languageCode, setTtc, ttc, }: DateQuestionProps) { @@ -121,8 +124,15 @@ export default function DateQuestion({ }} className="w-full"> {question.imageUrl && } - - + +
{errorMessage} @@ -142,7 +152,7 @@ export default function DateQuestion({
{!isFirstQuestion && ( { const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); setTtc(updatedTtcObj); @@ -152,7 +162,11 @@ export default function DateQuestion({ )}
- {}} buttonLabel={question.buttonLabel} /> + {}} + buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)} + />
); diff --git a/packages/surveys/src/components/questions/FileUploadQuestion.tsx b/packages/surveys/src/components/questions/FileUploadQuestion.tsx index 4c2f6872ae..cf08045ed3 100644 --- a/packages/surveys/src/components/questions/FileUploadQuestion.tsx +++ b/packages/surveys/src/components/questions/FileUploadQuestion.tsx @@ -2,6 +2,7 @@ import QuestionImage from "@/components/general/QuestionImage"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { useState } from "preact/hooks"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { TResponseData, TResponseTtc } from "@formbricks/types/responses"; import { TUploadFileConfig } from "@formbricks/types/storage"; import type { TSurveyFileUploadQuestion } from "@formbricks/types/surveys"; @@ -22,6 +23,7 @@ interface FileUploadQuestionProps { isFirstQuestion: boolean; isLastQuestion: boolean; surveyId: string; + languageCode: string; ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; } @@ -36,6 +38,7 @@ export default function FileUploadQuestion({ isLastQuestion, surveyId, onFileUpload, + languageCode, ttc, setTtc, }: FileUploadQuestionProps) { @@ -66,9 +69,15 @@ export default function FileUploadQuestion({ }} className="w-full "> {question.imageUrl && } - - - + + {!isFirstQuestion && ( { onBack(); }} /> )}
- {}} /> + {}} + />
); diff --git a/packages/surveys/src/components/questions/MultipleChoiceMultiQuestion.tsx b/packages/surveys/src/components/questions/MultipleChoiceMultiQuestion.tsx index 6b62b30bb6..6629c1287c 100644 --- a/packages/surveys/src/components/questions/MultipleChoiceMultiQuestion.tsx +++ b/packages/surveys/src/components/questions/MultipleChoiceMultiQuestion.tsx @@ -7,6 +7,7 @@ import { getUpdatedTtc, useTtc } from "@/lib/ttc"; import { cn, shuffleQuestions } from "@/lib/utils"; import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { getLocalizedValue } from "@formbricks/lib/i18n/utils"; import { TResponseData, TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyMultipleChoiceMultiQuestion } from "@formbricks/types/surveys"; @@ -18,6 +19,7 @@ interface MultipleChoiceMultiProps { onBack: () => void; isFirstQuestion: boolean; isLastQuestion: boolean; + languageCode: string; ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; } @@ -30,6 +32,7 @@ export default function MultipleChoiceMultiQuestion({ onBack, isFirstQuestion, isLastQuestion, + languageCode, ttc, setTtc, }: MultipleChoiceMultiProps) { @@ -38,12 +41,15 @@ export default function MultipleChoiceMultiQuestion({ useTtc(question.id, ttc, setTtc, startTime, setStartTime); const getChoicesWithoutOtherLabels = useCallback( - () => question.choices.filter((choice) => choice.id !== "other").map((item) => item.label), - [question] + () => + question.choices + .filter((choice) => choice.id !== "other") + .map((item) => getLocalizedValue(item.label, languageCode)), + [question, languageCode] ); const [otherSelected, setOtherSelected] = useState(false); - const [otherValue, setOtherValue] = useState(""); + useEffect(() => { setOtherSelected( !!value && @@ -52,9 +58,11 @@ export default function MultipleChoiceMultiQuestion({ }) ); setOtherValue( - (Array.isArray(value) && value.filter((v) => !question.choices.find((c) => c.label === v))[0]) || "" + (Array.isArray(value) && + value.filter((v) => !question.choices.find((c) => c.label[languageCode] === v))[0]) || + "" ); - }, [question.id, getChoicesWithoutOtherLabels, question.choices, value]); + }, [question.id, getChoicesWithoutOtherLabels, question.choices, value, languageCode]); const questionChoices = useMemo(() => { if (!question.choices) { @@ -68,7 +76,7 @@ export default function MultipleChoiceMultiQuestion({ }, [question.choices, question.shuffleOption]); const questionChoiceLabels = questionChoices.map((questionChoice) => { - return questionChoice.label; + return questionChoice.label[languageCode]; }); const otherOption = useMemo( @@ -124,8 +132,15 @@ export default function MultipleChoiceMultiQuestion({ }} className="w-full"> {question.imageUrl && } - - + +
Options @@ -162,18 +177,20 @@ export default function MultipleChoiceMultiQuestion({ aria-labelledby={`${choice.id}-label`} onChange={(e) => { if ((e.target as HTMLInputElement)?.checked) { - addItem(choice.label); + addItem(getLocalizedValue(choice.label, languageCode)); } else { - removeItem(choice.label); + removeItem(getLocalizedValue(choice.label, languageCode)); } }} - checked={Array.isArray(value) && value.includes(choice.label)} + checked={ + Array.isArray(value) && value.includes(getLocalizedValue(choice.label, languageCode)) + } required={ question.required && Array.isArray(value) && value.length ? false : question.required } /> - {choice.label} + {getLocalizedValue(choice.label, languageCode)} @@ -182,7 +199,7 @@ export default function MultipleChoiceMultiQuestion({