feat: Multi language Surveys (#1630)

Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Dhruwang Jariwala
2024-03-19 00:32:18 +05:30
committed by GitHub
parent 5c9e59b136
commit 8b5328aa74
202 changed files with 8510 additions and 2245 deletions

View File

@@ -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,

View File

@@ -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:
<div style={{ position: 'relative', height: '100vh', maxHeight: '100vh', overflow: 'auto', borderRadius:'12px' }}>
<div
style={{
position: "relative",
height: "100vh",
maxHeight: "100vh",
overflow: "auto",
borderRadius: "12px",
}}>
<iframe
src="https://app.formbricks.com/s/clrf4z8zg1u3912250j7shqb5"
style={{ position: 'absolute', left: 0, top: 0, width: '100%', height: '100%', border: 0 }}
>
</iframe>
style={{ position: "absolute", left: 0, top: 0, width: "100%", height: "100%", border: 0 }}></iframe>
</div>
**Cant figure it out?**: [Join our Discord!](https://formbricks.com/discord)

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<TTemplate | null>(null);

View File

@@ -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;

View File

@@ -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<string | null>(null);
return (
<form
onSubmit={(e) => {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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" ? (
<ThankYouCard
brandColor={brandColor}
headline={localSurvey?.thankYouCard?.headline || ""}
subheader={localSurvey?.thankYouCard?.subheader || ""}
headline={localSurvey?.thankYouCard?.headline!}
subheader={localSurvey?.thankYouCard?.subheader!}
/>
) : (
questions.map(

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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<typeof ZAllowedFileExtension>;
export const ZUserObjective = z.enum([
"increase_conversion",
"improve_user_retention",
"increase_user_adoption",
"sharpen_marketing_messaging",
"support_sales",
"other",
]);
export type TUserObjective = z.infer<typeof ZUserObjective>;
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<typeof ZSurveyWelcomeCard>;
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<typeof ZSurveyThankYouCard>;
export const ZSurveyHiddenFields = z.object({
enabled: z.boolean(),
fieldIds: z.optional(z.array(z.string())),
});
export type TSurveyHiddenFields = z.infer<typeof ZSurveyHiddenFields>;
export const ZSurveyChoice = z.object({
id: z.string(),
label: z.string(),
});
export type TSurveyChoice = z.infer<typeof ZSurveyChoice>;
export const ZSurveyPictureChoice = z.object({
id: z.string(),
imageUrl: z.string(),
});
export type TSurveyPictureChoice = z.infer<typeof ZSurveyPictureChoice>;
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<typeof ZSurveyLogicCondition>;
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<typeof ZSurveyLogic>;
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<typeof ZSurveyOpenTextQuestionInputType>;
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<typeof ZSurveyOpenTextQuestion>;
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<typeof ZSurveyConsentQuestion>;
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<typeof ZSurveyMultipleChoiceSingleQuestion>;
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<typeof ZSurveyMultipleChoiceMultiQuestion>;
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<typeof ZSurveyNPSQuestion>;
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<typeof ZSurveyCTAQuestion>;
// 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<typeof ZSurveyWelcomeQuestion>;
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<typeof ZSurveyDateQuestion>;
export type TSurveyRatingQuestion = z.infer<typeof ZSurveyRatingQuestion>;
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<typeof ZSurveyPictureSelectionQuestion>;
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<typeof ZSurveyFileUploadQuestion>;
export const ZSurveyCalQuestion = ZSurveyQuestionBase.extend({
type: z.literal(TSurveyQuestionType.Cal),
calUserName: z.string(),
logic: z.array(ZSurveyCalLogic).optional(),
});
export type TSurveyCalQuestion = z.infer<typeof ZSurveyCalQuestion>;
export const ZSurveyQuestion = z.union([
ZSurveyOpenTextQuestion,
ZSurveyConsentQuestion,
ZSurveyMultipleChoiceSingleQuestion,
ZSurveyMultipleChoiceMultiQuestion,
ZSurveyNPSQuestion,
ZSurveyCTAQuestion,
ZSurveyRatingQuestion,
ZSurveyPictureSelectionQuestion,
ZSurveyDateQuestion,
ZSurveyFileUploadQuestion,
ZSurveyCalQuestion,
]);
export type TSurveyQuestion = z.infer<typeof ZSurveyQuestion>;
export const ZSurveyQuestions = z.array(ZSurveyQuestion);
export type TSurveyQuestions = z.infer<typeof ZSurveyQuestions>;
export const ZSurveyClosedMessage = z
.object({
enabled: z.boolean().optional(),
heading: z.string().optional(),
subheading: z.string().optional(),
})
.nullable()
.optional();
export type TSurveyClosedMessage = z.infer<typeof ZSurveyClosedMessage>;
export const ZSurveyAttributeFilter = z.object({
attributeClassId: z.string().cuid2(),
condition: z.enum(["equals", "notEquals"]),
value: z.string(),
});
export type TSurveyAttributeFilter = z.infer<typeof ZSurveyAttributeFilter>;
export const ZSurveyType = z.enum(["web", "email", "link", "mobile"]);
export type TSurveyType = z.infer<typeof ZSurveyType>;
const ZSurveyStatus = z.enum(["draft", "inProgress", "paused", "completed"]);
export type TSurveyStatus = z.infer<typeof ZSurveyStatus>;
const ZSurveyDisplayOption = z.enum(["displayOnce", "displayMultiple", "respondMultiple"]);
export type TSurveyDisplayOption = z.infer<typeof ZSurveyDisplayOption>;
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<typeof ZPlacement>;
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<typeof ZSurveyProductOverwrites>;
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<typeof ZSurveyStylingBackground>;
export const ZSurveyStyling = z.object({
background: ZSurveyStylingBackground.nullish(),
hideProgressBar: z.boolean().nullish(),
});
export type TSurveyStyling = z.infer<typeof ZSurveyStyling>;
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<typeof ZSurveySingleUse>;
export const ZSurveyVerifyEmail = z
.object({
name: z.optional(z.string()),
subheading: z.optional(z.string()),
})
.optional();
export type TSurveyVerifyEmail = z.infer<typeof ZSurveyVerifyEmail>;
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<typeof ZSurvey>;
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<typeof ZTemplate>;

View File

@@ -25,6 +25,16 @@ export default async function AttributesSection({ personId }: { personId: string
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Language</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{person.attributes.language ? (
<span>{person.attributes.language}</span>
) : (
<span className="text-slate-300">Not provided</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">User Id</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
@@ -43,7 +53,7 @@ export default async function AttributesSection({ personId }: { personId: string
</div>
{Object.entries(person.attributes)
.filter(([key, _]) => key !== "email" && key !== "userId")
.filter(([key, _]) => key !== "email" && key !== "userId" && key !== "language")
.map(([key, value]) => (
<div key={key}>
<dt className="text-sm font-medium text-slate-500">{capitalizeFirstLetter(key.toString())}</dt>

View File

@@ -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 && (
<SingleResponseCard
response={response}
survey={survey}
survey={checkForRecallInHeadline(survey, "default")}
user={user}
pageType="people"
environmentTags={environmentTags}

View File

@@ -4,11 +4,16 @@ import HeadingSection from "@/app/(app)/environments/[environmentId]/(peopleAndS
import ResponseSection from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ResponseSection";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
export default async function PersonPage({ params }) {
const environment = await getEnvironment(params.environmentId);
const environmentTags = await getTagsByEnvironmentId(params.environmentId);
const product = await getProductByEnvironmentId(params.environmentId);
if (!product) {
throw new Error("Product not found");
}
if (!environment) {
throw new Error("Environment not found");
}

View File

@@ -1,6 +1,7 @@
import Navigation from "@/app/(app)/environments/[environmentId]/components/Navigation";
import type { Session } from "next-auth";
import { getMultiLanguagePermission } from "@formbricks/ee/lib/service";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
@@ -11,7 +12,6 @@ import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
interface EnvironmentsNavbarProps {
environmentId: string;
session: Session;
isFormbricksCloud: boolean;
}
export default async function EnvironmentsNavbar({ environmentId, session }: EnvironmentsNavbarProps) {
@@ -25,6 +25,8 @@ export default async function EnvironmentsNavbar({ environmentId, session }: Env
return <ErrorComponent />;
}
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}
/>
);
}

View File

@@ -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,
},
],
},
{

View File

@@ -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) {
<Label htmlFor="Surveys">Questions</Label>
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{checkForRecallInHeadline(selectedSurvey)?.questions.map((question) => (
{checkForRecallInHeadline(selectedSurvey, "default")?.questions.map((question) => (
<Controller
key={question.id}
control={control}
@@ -354,7 +355,7 @@ export default function AddIntegrationModal(props: AddIntegrationModalProps) {
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">{question.headline}</span>
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
</label>
</div>
)}

View File

@@ -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"

View File

@@ -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({
<Label htmlFor="Surveys">Questions</Label>
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{checkForRecallInHeadline(selectedSurvey)?.questions.map((question) => (
{checkForRecallInHeadline(selectedSurvey, "default")?.questions.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
@@ -287,7 +288,7 @@ export default function AddIntegrationModal({
handleCheckboxChange(question.id);
}}
/>
<span className="ml-2">{question.headline}</span>
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
</label>
</div>
))}

View File

@@ -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"

View File

@@ -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);

View File

@@ -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 }) {
/>
<FormbricksClient session={session} />
<ToasterClient />
<EnvironmentsNavbar
environmentId={params.environmentId}
session={session}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/>
<EnvironmentsNavbar environmentId={params.environmentId} session={session} />
<main className="h-full flex-1 overflow-y-auto bg-slate-50">
{children}
<main />

View File

@@ -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,
},
];

View File

@@ -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;

View File

@@ -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 (
<div>
<SettingsTitle title="Survey Languages" />
<SettingsCard
title="Multi-language surveys"
description="Add languages to create multi-language surveys.">
<EditLanguage product={product} environmentId={params.environmentId} />
</SettingsCard>
</div>
);
}

View File

@@ -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}
/>
<div className="w-full md:ml-64">
<div className="max-w-4xl px-20 pb-6 pt-14 md:pt-6">

View File

@@ -89,7 +89,7 @@ export default function AddMemberModal({
<UpgradePlanNotice
message="To manage access roles for your team,"
url="https://formbricks.com/docs/self-hosting/enterprise"
textForUrl="get a self-hosting license (free)."
textForUrl="get a enterprise license."
/>
))}
</div>

View File

@@ -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 = ({
/>
<div className="flex gap-1.5">
<CustomFilter environmentTags={environmentTags} attributes={attributes} survey={survey} />
<ResultsShareButton survey={survey} webAppUrl={webAppUrl} product={product} user={user} />
<ResultsShareButton survey={survey} webAppUrl={webAppUrl} user={user} />
</div>
<SurveyResultsTabs
activeId="responses"

View File

@@ -1,7 +1,6 @@
"use server";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { generateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import { customAlphabet } from "nanoid";
import { getServerSession } from "next-auth";
@@ -18,17 +17,6 @@ type TSendEmailActionArgs = {
html: string;
};
export async function generateSingleUseIdAction(surveyId: string, isEncrypted: boolean): Promise<string> {
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);

View File

@@ -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 (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<Headline headline={questionSummary.question.headline} />
<Headline headline={getLocalizedValue(questionSummary.question.headline, "default")} />
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className=" flex items-center rounded-lg bg-slate-100 p-2 ">

View File

@@ -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 (
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<Headline headline={questionSummary.question.headline} />
<Headline headline={getLocalizedValue(questionSummary.question.headline, "default")} />
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex items-center rounded-lg bg-slate-100 p-2 ">

View File

@@ -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 (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<Headline headline={questionSummary.question.headline} />
<Headline headline={getLocalizedValue(questionSummary.question.headline, "default")} />
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className=" flex items-center rounded-lg bg-slate-100 p-2">

View File

@@ -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 (
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<Headline headline={questionSummary.question.headline} />
<Headline headline={getLocalizedValue(questionSummary.question.headline, "default")} />
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex items-center rounded-lg bg-slate-100 p-2 ">
{questionTypeInfo && <questionTypeInfo.icon className="mr-2 h-4 w-4 " />}

View File

@@ -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 (
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<Headline headline={questionSummary.question.headline} />
<Headline headline={getLocalizedValue(questionSummary.question.headline, "default")} />
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex items-center rounded-lg bg-slate-100 p-2 ">

View File

@@ -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 (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<Headline headline={questionSummary.question.headline} />
<Headline headline={getLocalizedValue(questionSummary.question.headline, "default")} />
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex items-center rounded-lg bg-slate-100 p-2">

View File

@@ -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 (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<Headline headline={questionSummary.question.headline} />
<Headline headline={getLocalizedValue(questionSummary.question.headline, "default")} />
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex items-center rounded-lg bg-slate-100 p-2">

View File

@@ -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 (
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<Headline headline={questionSummary.question.headline} />
<Headline headline={getLocalizedValue(questionSummary.question.headline, "default")} />
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex items-center rounded-lg bg-slate-100 p-2 ">
{questionTypeInfo && <questionTypeInfo.icon className="mr-2 h-4 w-4 " />}

View File

@@ -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 (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<Headline headline={questionSummary.question.headline} />
<Headline headline={getLocalizedValue(questionSummary.question.headline, "default")} />
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex items-center rounded-lg bg-slate-100 p-2">

View File

@@ -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 (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<Headline headline={questionSummary.question.headline} />
<Headline headline={getLocalizedValue(questionSummary.question.headline, "default")} />
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex items-center rounded-lg bg-slate-100 p-2">

View File

@@ -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<React.SetStateAction<boolean>>;
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className=" w-full max-w-xl bg-white p-0 md:max-w-3xl lg:h-[700px] lg:max-w-5xl">
@@ -103,39 +59,12 @@ export default function ShareEmbedSurvey({ survey, open, setOpen, webAppUrl, use
<div className="h-full max-w-full overflow-hidden">
<div className="flex h-[200px] w-full flex-col items-center justify-center space-y-6 p-8 text-center lg:h-2/5">
<p className="pt-2 text-xl font-semibold text-slate-800">Your survey is public 🎉</p>
<div className="flex max-w-full flex-col items-center justify-center space-x-2 lg:flex-row">
<div
ref={linkTextRef}
className="mt-2 max-w-[80%] overflow-hidden rounded-lg border border-slate-300 bg-slate-50 px-3 py-2 text-slate-800"
style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}
onClick={() => handleTextSelection()}>
{surveyUrl}
</div>
<div className="mt-2 flex items-center justify-center space-x-2">
<Button
variant="darkCTA"
className="inline"
title="Copy survey link to clipboard"
aria-label="Copy survey link to clipboard"
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success("URL copied to clipboard!");
}}
EndIcon={CopyIcon}>
Copy Link
</Button>
{survey.singleUse?.enabled && (
<Button
variant="darkCTA"
className="inline"
title="Regenerate single use survey link"
aria-label="Regenerate single use survey link"
onClick={() => generateNewSingleUseLink()}>
<RefreshCcw className="h-5 w-5" />
</Button>
)}
</div>
</div>
<ShareSurveyLink
survey={survey}
webAppUrl={webAppUrl}
surveyUrl={surveyUrl}
setSurveyUrl={setSurveyUrl}
/>
</div>
<div className="flex h-[300px] flex-col items-center justify-center gap-8 rounded-b-lg bg-slate-50 px-8 lg:h-3/5">
<p className="-mt-8 text-sm text-slate-500">What&apos;s next?</p>
@@ -202,10 +131,10 @@ export default function ShareEmbedSurvey({ survey, open, setOpen, webAppUrl, use
<WebpageTab surveyUrl={surveyUrl} />
) : activeId === "link" ? (
<LinkTab
surveyUrl={surveyUrl}
survey={survey}
webAppUrl={webAppUrl}
generateNewSingleUseLink={generateNewSingleUseLink}
isSingleUseLinkSurvey={isSingleUseLinkSurvey}
surveyUrl={surveyUrl}
setSurveyUrl={setSurveyUrl}
/>
) : null}
</div>

View File

@@ -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 && <Confetti />}

View File

@@ -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 {

View File

@@ -103,7 +103,7 @@ export default function SummaryMetadata({
</div>
<div className="flex flex-col justify-between gap-2 lg:col-span-1">
<div className="text-right text-xs text-slate-400">
Last updated: {timeSinceConditionally(survey.updatedAt.toISOString())}
Last updated: {timeSinceConditionally(survey.updatedAt.toString())}
</div>
<Button
variant="minimal"

View File

@@ -91,8 +91,9 @@ const SummaryPage = ({
}, [filters, surveyId]);
const searchParams = useSearchParams();
survey = useMemo(() => {
return checkForRecallInHeadline(survey);
return checkForRecallInHeadline(survey, "default");
}, [survey]);
useEffect(() => {
@@ -114,7 +115,7 @@ const SummaryPage = ({
/>
<div className="flex gap-1.5">
<CustomFilter environmentTags={environmentTags} attributes={attributes} survey={survey} />
<ResultsShareButton survey={survey} webAppUrl={webAppUrl} product={product} user={user} />
<ResultsShareButton survey={survey} webAppUrl={webAppUrl} user={user} />
</div>
<SurveyResultsTabs
activeId="summary"

View File

@@ -1,40 +1,17 @@
import UrlShortenerForm from "@/app/(app)/environments/[environmentId]/components/UrlShortenerForm";
import { CopyIcon } from "lucide-react";
import { RefreshCcw } from "lucide-react";
import Link from "next/link";
import { useRef } from "react";
import toast from "react-hot-toast";
import { Button } from "@formbricks/ui/Button";
import { TSurvey } from "@formbricks/types/surveys";
import { ShareSurveyLink } from "@formbricks/ui/ShareSurveyLink";
interface LinkTabProps {
surveyUrl: string;
survey: TSurvey;
webAppUrl: string;
generateNewSingleUseLink: () => void;
isSingleUseLinkSurvey: boolean;
surveyUrl: string;
setSurveyUrl: (url: string) => void;
}
export default function LinkTab({
surveyUrl,
webAppUrl,
generateNewSingleUseLink,
isSingleUseLinkSurvey,
}: LinkTabProps) {
const linkTextRef = useRef(null);
const handleTextSelection = () => {
if (linkTextRef.current) {
const range = document.createRange();
range.selectNodeContents(linkTextRef.current);
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
}
};
export default function LinkTab({ survey, webAppUrl, surveyUrl, setSurveyUrl }: LinkTabProps) {
const docsLinks = [
{
title: "Identify users",
@@ -62,39 +39,12 @@ export default function LinkTab({
<div className="flex h-full grow flex-col gap-6">
<div>
<p className="text-lg font-semibold text-slate-800">Share the link to get responses</p>
<div className="mt-2 flex max-w-full flex-col items-center space-x-2 lg:flex-row">
<div
ref={linkTextRef}
className="mt-2 max-w-[65%] overflow-hidden rounded-lg border border-slate-300 bg-white px-3 py-3 text-sm text-slate-800"
style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}
onClick={() => handleTextSelection()}>
{surveyUrl}
</div>
<div className="mt-2 flex items-center justify-center space-x-2">
<Button
variant="darkCTA"
className="inline"
title="Copy survey link to clipboard"
aria-label="Copy survey link to clipboard"
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success("URL copied to clipboard!");
}}
EndIcon={CopyIcon}>
Copy Link
</Button>
{isSingleUseLinkSurvey && (
<Button
variant="darkCTA"
className="inline"
title="Regenerate single use survey link"
aria-label="Regenerate single use survey link"
onClick={() => generateNewSingleUseLink()}>
<RefreshCcw className="h-5 w-5" />
</Button>
)}
</div>
</div>
<ShareSurveyLink
survey={survey}
webAppUrl={webAppUrl}
surveyUrl={surveyUrl}
setSurveyUrl={setSurveyUrl}
/>
</div>
<div className="flex flex-wrap justify-between gap-2">
<p className="pt-2 font-semibold text-slate-700">You can do a lot more with links surveys 💡</p>

View File

@@ -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 (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<Section className="mt-4 block h-20 w-full rounded-lg border border-solid border-slate-200 bg-slate-50" />
<EmailFooter />
@@ -70,14 +71,20 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) =>
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Container className="m-0 text-sm font-normal leading-6 text-slate-500">
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
<Text
className="m-0 p-0"
dangerouslySetInnerHTML={{
__html: getLocalizedValue(firstQuestion.html, defaultLanguageCode) || "",
}}></Text>
</Container>
<Container className="m-0 mt-4 block w-full max-w-none rounded-lg border border-solid border-slate-200 bg-slate-50 p-4 font-medium text-slate-800">
<Text className="m-0 inline-block">{firstQuestion.label}</Text>
<Text className="m-0 inline-block">
{getLocalizedValue(firstQuestion.label, defaultLanguageCode)}
</Text>
</Container>
<Container className="mx-0 mt-4 flex max-w-none justify-end">
{!firstQuestion.required && (
@@ -104,10 +111,10 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) =>
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Section>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<Container className="mx-0 mt-4 flex w-max flex-col">
<Section className="block overflow-hidden rounded-md border border-slate-200">
@@ -123,10 +130,14 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) =>
<Section className="mt-2 px-1.5 text-xs leading-6 text-slate-500">
<Row>
<Column>
<Text className="m-0 inline-block w-max p-0">{firstQuestion.lowerLabel}</Text>
<Text className="m-0 inline-block w-max p-0">
{getLocalizedValue(firstQuestion.lowerLabel, defaultLanguageCode)}
</Text>
</Column>
<Column className="text-right">
<Text className="m-0 inline-block w-max p-0 text-right">{firstQuestion.upperLabel}</Text>
<Text className="m-0 inline-block w-max p-0 text-right">
{getLocalizedValue(firstQuestion.upperLabel, defaultLanguageCode)}
</Text>
</Column>
</Row>
</Section>
@@ -139,10 +150,14 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) =>
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Container className="mt-2 text-sm font-normal leading-6 text-slate-500">
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
<Text
className="m-0 p-0"
dangerouslySetInnerHTML={{
__html: getLocalizedValue(firstQuestion.html, defaultLanguageCode) || "",
}}></Text>
</Container>
<Container className="mx-0 mt-4 max-w-none">
@@ -150,7 +165,7 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) =>
<EmailButton
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
{firstQuestion.dismissButtonLabel || "Skip"}
{getLocalizedValue(firstQuestion.dismissButtonLabel, defaultLanguageCode) || "Skip"}
</EmailButton>
)}
<EmailButton
@@ -159,7 +174,7 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) =>
"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)}
</EmailButton>
</Container>
<EmailFooter />
@@ -170,10 +185,10 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) =>
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Section className=" w-full">
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<Container className="mx-0 mt-4 w-full items-center justify-center">
<Section
@@ -205,10 +220,14 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) =>
<Section className="m-0 px-1.5 text-xs leading-6 text-slate-500">
<Row>
<Column>
<Text className="m-0 inline-block p-0">{firstQuestion.lowerLabel}</Text>
<Text className="m-0 inline-block p-0">
{getLocalizedValue(firstQuestion.lowerLabel, defaultLanguageCode)}
</Text>
</Column>
<Column className="text-right">
<Text className="m-0 inline-block p-0 text-right">{firstQuestion.upperLabel}</Text>
<Text className="m-0 inline-block p-0 text-right">
{getLocalizedValue(firstQuestion.upperLabel, defaultLanguageCode)}
</Text>
</Column>
</Row>
</Section>
@@ -221,17 +240,17 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) =>
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<Container className="mx-0 max-w-none">
{firstQuestion.choices.map((choice) => (
<Section
className="mt-2 block w-full rounded-lg border border-solid border-slate-200 bg-slate-50 p-4 text-slate-800"
key={choice.id}>
{choice.label}
{getLocalizedValue(choice.label, defaultLanguageCode)}
</Section>
))}
</Container>
@@ -242,10 +261,10 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) =>
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<Container className="mx-0 max-w-none">
{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)}
</Link>
))}
</Container>
@@ -266,10 +285,10 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) =>
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<Section className="mx-0">
{firstQuestion.choices.map((choice) =>
@@ -295,7 +314,7 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) =>
return (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
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 (
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
{firstQuestion.headline}
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
{firstQuestion.subheader}
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<Section className="mt-4 flex h-12 w-full items-center justify-center rounded-lg border border-solid border-slate-200 bg-white">
<CalendarDaysIcon className="mb-1 inline h-4 w-4" />

View File

@@ -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);

View File

@@ -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<boolean>(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 = ({
<CommandItem
onSelect={() => {
!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}
</CommandItem>
))}
</CommandGroup>

View File

@@ -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<QuestionOption>) => {
const getIconType = () => {
if (type === OptionsType.QUESTIONS) {
switch (questionType) {
case TSurveyQuestionType.Rating:
return <StarIcon width={18} className="text-white" />;
case TSurveyQuestionType.CTA:
return <MousePointerClickIcon width={18} className="text-white" />;
case TSurveyQuestionType.OpenText:
return <HelpCircleIcon width={18} className="text-white" />;
case TSurveyQuestionType.MultipleChoiceMulti:
return <ListIcon width={18} className="text-white" />;
case TSurveyQuestionType.MultipleChoiceSingle:
return <Rows3Icon width={18} className="text-white" />;
case TSurveyQuestionType.NPS:
return <NetPromoterScoreIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionType.Consent:
return <CheckIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionType.PictureSelection:
return <ImageIcon width={18} className="text-white" />;
}
}
if (type === OptionsType.ATTRIBUTES) {
return <HashIcon width={18} className="text-white" />;
}
if (type === OptionsType.TAGS) {
return <TagIcon width={18} className="text-white" />;
switch (type) {
case OptionsType.QUESTIONS:
switch (questionType) {
case TSurveyQuestionType.Rating:
return <StarIcon width={18} className="text-white" />;
case TSurveyQuestionType.CTA:
return <MousePointerClickIcon width={18} className="text-white" />;
case TSurveyQuestionType.OpenText:
return <HelpCircleIcon width={18} className="text-white" />;
case TSurveyQuestionType.MultipleChoiceMulti:
return <ListIcon width={18} className="text-white" />;
case TSurveyQuestionType.MultipleChoiceSingle:
return <Rows3Icon width={18} className="text-white" />;
case TSurveyQuestionType.NPS:
return <NetPromoterScoreIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionType.Consent:
return <CheckIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionType.PictureSelection:
return <ImageIcon width={18} className="text-white" />;
}
case OptionsType.ATTRIBUTES:
return <HashIcon width={18} className="text-white" />;
case OptionsType.METADATA:
switch (label) {
case "Language":
return <LanguagesIcon width={18} height={18} className="text-white" />;
case "Device Type":
return <SmartphoneIcon width={18} height={18} className="text-white" />;
}
case OptionsType.TAGS:
return <HashIcon width={18} className="text-white" />;
}
};
@@ -86,6 +95,8 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOpti
return "bg-indigo-500";
} else if (type === OptionsType.QUESTIONS) {
return "bg-brand-dark";
} else if (type === OptionsType.TAGS) {
return "bg-indigo-500";
} else {
return "bg-amber-500";
}
@@ -93,7 +104,9 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOpti
return (
<div className="flex h-5 w-[12rem] items-center sm:w-4/5">
<span className={clsx("rounded-md p-1", getColor())}>{getIconType()}</span>
<p className="ml-3 truncate text-base text-slate-600">{label}</p>
<p className="ml-3 truncate text-base text-slate-600">
{typeof label === "string" ? label : getLocalizedValue(label, "default")}
</p>
</div>
);
};

View File

@@ -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 = () => {
</>
))}
<div className="mt-8 flex items-center justify-between">
<Button size="sm" variant="darkCTA" onClick={handleAddNewFilter}>
<Button size="sm" variant="secondary" onClick={handleAddNewFilter}>
Add filter
<Plus width={18} height={18} className="ml-2" />
</Button>
<div className="flex gap-2">
<Button size="sm" variant="primary" onClick={handleApplyFilters}>
Apply Filters
<Button size="sm" variant="darkCTA" onClick={handleApplyFilters}>
Apply filters
</Button>
<Button size="sm" variant="secondary" onClick={handleClearAllFilters}>
<Button size="sm" variant="minimal" onClick={handleClearAllFilters}>
Clear all
</Button>
</div>

View File

@@ -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}
/>

View File

@@ -104,13 +104,7 @@ const SummaryHeader = ({
<DropdownMenuContent align="end" className="p-2">
{survey.type === "link" && (
<>
<ResultsShareButton
className="flex w-full justify-center p-1"
survey={survey}
webAppUrl={webAppUrl}
product={product}
user={user}
/>
<ResultsShareButton survey={survey} webAppUrl={webAppUrl} user={user} />
<DropdownMenuSeparator />
</>
)}
@@ -189,19 +183,12 @@ const SummaryHeader = ({
</DropdownMenuContent>
</DropdownMenu>
</div>
<SuccessMessage
environment={environment}
survey={survey}
webAppUrl={webAppUrl}
product={product}
user={user}
/>
<SuccessMessage environment={environment} survey={survey} webAppUrl={webAppUrl} user={user} />
{showShareSurveyModal && (
<ShareEmbedSurvey
survey={survey}
open={showShareSurveyModal}
setOpen={setShowShareSurveyModal}
product={product}
webAppUrl={webAppUrl}
user={user}
/>

View File

@@ -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);
}}>

View File

@@ -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 (
<form>
<QuestionFormInput
id="headline"
value={question.headline}
localSurvey={localSurvey}
environmentId={environmentId}
isInvalid={isInvalid}
questionId={question.id}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
type="headline"
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
<div className="mt-3">
<Label htmlFor="subheader">Description</Label>
<div className="mt-2">
<Editor
getText={() =>
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
<LocalizedEditor
id="subheader"
value={question.html}
localSurvey={localSurvey}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
firstRender={firstRender}
setFirstRender={setFirstRender}
questionIdx={questionIdx}
/>
</div>
</div>
@@ -82,24 +82,33 @@ export default function CTAQuestionForm({
</div>
</RadioGroup>
<div className="mt-3 flex justify-between gap-8">
<div className="mt-2 flex justify-between gap-8">
<div className="flex w-full space-x-2">
<div className="w-full">
<Label htmlFor="buttonLabel">Button Label</Label>
<div className="mt-2">
<Input
id="buttonLabel"
name="buttonLabel"
value={question.buttonLabel}
placeholder={lastQuestion ? "Finish" : "Next"}
onChange={(e) => updateQuestion(questionIdx, { buttonLabel: e.target.value })}
/>
</div>
</div>
<QuestionFormInput
id="buttonLabel"
value={question.buttonLabel}
localSurvey={localSurvey}
questionIdx={questionIdx}
maxLength={48}
placeholder={lastQuestion ? "Finish" : "Next"}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
{questionIdx !== 0 && (
<BackButtonInput
<QuestionFormInput
id="backButtonLabel"
value={question.backButtonLabel}
onChange={(e) => updateQuestion(questionIdx, { backButtonLabel: e.target.value })}
localSurvey={localSurvey}
questionIdx={questionIdx}
maxLength={48}
placeholder={"Back"}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
)}
</div>
@@ -124,12 +133,16 @@ export default function CTAQuestionForm({
<div className="mt-3 flex-1">
<Label htmlFor="buttonLabel">Skip Button Label</Label>
<div className="mt-2">
<Input
<QuestionFormInput
id="dismissButtonLabel"
name="dismissButtonLabel"
value={question.dismissButtonLabel}
placeholder="Skip"
onChange={(e) => updateQuestion(questionIdx, { dismissButtonLabel: e.target.value })}
localSurvey={localSurvey}
questionIdx={questionIdx}
placeholder={"skip"}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
</div>

View File

@@ -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 (
<form>
<QuestionFormInput
id="headline"
value={question.headline}
localSurvey={localSurvey}
environmentId={localSurvey.environmentId}
isInvalid={isInvalid}
questionId={question.id}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
type="headline"
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
<div>
{showSubheader && (
<>
<div className="flex w-full items-center">
<div className="inline-flex w-full items-center">
<div className="w-full">
<QuestionFormInput
id="subheader"
value={question.subheader}
localSurvey={localSurvey}
environmentId={localSurvey.environmentId}
isInvalid={isInvalid}
questionId={question.id}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
type="subheader"
/>
<TrashIcon
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: "" });
}}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
</>
<TrashIcon
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: undefined });
}}
/>
</div>
)}
{!showSubheader && (
<Button
@@ -65,7 +74,13 @@ export default function CalQuestionForm({
className="mt-3"
variant="minimal"
type="button"
onClick={() => setShowSubheader(true)}>
onClick={() => {
updateQuestion(questionIdx, {
subheader: createI18nString("", surveyLanguageCodes),
});
setShowSubheader(true);
}}>
{" "}
<PlusIcon className="mr-1 h-4 w-4" />
Add Description
</Button>

View File

@@ -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 (
<form>
<QuestionFormInput
id="headline"
value={question.headline}
localSurvey={localSurvey}
environmentId={environmentId}
isInvalid={isInvalid}
questionId={question.id}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
type="headline"
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
<div className="mt-3">
<Label htmlFor="subheader">Description</Label>
<div className="mt-2">
<Editor
getText={() =>
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
<LocalizedEditor
id="subheader"
value={question.html}
localSurvey={localSurvey}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
firstRender={firstRender}
setFirstRender={setFirstRender}
questionIdx={questionIdx}
/>
</div>
</div>
<div className="mt-3">
<Label htmlFor="label">Checkbox Label</Label>
<Input
id="label"
name="label"
className="mt-2"
value={question.label}
placeholder="I agree to the terms and conditions"
onChange={(e) => updateQuestion(questionIdx, { label: e.target.value })}
isInvalid={isInvalid && question.label.trim() === ""}
/>
</div>
{/* <div className="mt-3">
<Label htmlFor="buttonLabel">Button Label</Label>
<Input
id="buttonLabel"
name="buttonLabel"
className="mt-2"
value={question.buttonLabel}
placeholder={lastQuestion ? "Finish" : "Next"}
onChange={(e) => updateQuestion(questionIdx, { buttonLabel: e.target.value })}
/>
</div> */}
<QuestionFormInput
id="label"
label="Checkbox Label"
placeholder="I agree to the terms and conditions"
value={question.label}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</form>
);
}

View File

@@ -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 (
<form>
<QuestionFormInput
environmentId={localSurvey.environmentId}
isInvalid={isInvalid}
questionId={question.id}
questionIdx={questionIdx}
updateQuestion={updateQuestion}
id="headline"
value={question.headline}
localSurvey={localSurvey}
type="headline"
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
<div>
{showSubheader && (
<>
<div className="flex w-full items-center">
<div className="mt-2 inline-flex w-full items-center">
<div className="w-full">
<QuestionFormInput
id="subheader"
value={question.subheader}
localSurvey={localSurvey}
environmentId={localSurvey.environmentId}
isInvalid={isInvalid}
questionId={question.id}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
type="subheader"
/>
<TrashIcon
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: "" });
}}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
</>
<TrashIcon
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: undefined });
}}
/>
</div>
)}
{!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);
}}>
{" "}
<PlusIcon className="mr-1 h-4 w-4" />
Add Description
</Button>

View File

@@ -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<boolean>(
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({
)}>
<div
className={cn(
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
open ? "bg-slate-50" : "",
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none",
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
)}>
<p>🙏</p>
</div>
@@ -97,28 +108,26 @@ export default function EditThankYouCard({
<Collapsible.CollapsibleContent className="px-4 pb-6">
<form>
<QuestionFormInput
id="headline"
value={localSurvey?.thankYouCard?.headline}
localSurvey={localSurvey}
environmentId={localSurvey.environmentId}
isInvalid={false}
questionId="end"
questionIdx={localSurvey.questions.length}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
type="headline"
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
<div>
<div className="flex w-full items-center">
<QuestionFormInput
localSurvey={localSurvey}
environmentId={localSurvey.environmentId}
isInvalid={false}
questionId="end"
questionIdx={localSurvey.questions.length}
updateSurvey={updateSurvey}
type="subheader"
/>
</div>
</div>
<QuestionFormInput
id="subheader"
value={localSurvey.thankYouCard.subheader}
localSurvey={localSurvey}
questionIdx={localSurvey.questions.length}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
<div className="mt-4">
<div className="flex items-center space-x-1">
<Switch
@@ -129,7 +138,7 @@ export default function EditThankYouCard({
updateSurvey({ buttonLabel: undefined, buttonLink: undefined });
} else {
updateSurvey({
buttonLabel: "Create your own Survey",
buttonLabel: { default: "Create your own Survey" },
buttonLink: "https://formbricks.com/signup",
});
}
@@ -146,16 +155,20 @@ export default function EditThankYouCard({
</Label>
</div>
{showThankYouCardCTA && (
<div className="border-1 mt-4 space-y-4 rounded-md border bg-slate-100 p-4">
<div className="border-1 mt-4 space-y-4 rounded-md border bg-slate-100 p-4 pt-2">
<div className="space-y-2">
<Label>Button Label</Label>
<Input
<QuestionFormInput
id="buttonLabel"
name="buttonLabel"
className="bg-white"
label="Button Label"
placeholder="Create your own Survey"
className="bg-white"
value={localSurvey.thankYouCard.buttonLabel}
onChange={(e) => updateSurvey({ buttonLabel: e.target.value })}
localSurvey={localSurvey}
questionIdx={localSurvey.questions.length}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
<div className="space-y-2">

View File

@@ -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({
)}>
<div
className={cn(
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
open ? "bg-slate-50" : "",
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none",
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
)}>
<p></p>
</div>
@@ -84,7 +90,7 @@ export default function EditWelcomeCard({
</div>
<div className="flex items-center space-x-2">
<Label htmlFor="welcome-toggle">Enabled</Label>
<Label htmlFor="welcome-toggle">{localSurvey?.welcomeCard?.enabled ? "On" : "Off"}</Label>
<Switch
id="welcome-toggle"
@@ -114,34 +120,32 @@ export default function EditWelcomeCard({
/>
</div>
<div className="mt-3">
<Label htmlFor="headline">Headline</Label>
<div className="mt-2">
<Input
id="headline"
name="headline"
defaultValue={localSurvey?.welcomeCard?.headline}
onChange={(e) => {
updateSurvey({ headline: e.target.value });
}}
/>
</div>
<QuestionFormInput
id="headline"
value={localSurvey.welcomeCard.headline}
label="Headline"
localSurvey={localSurvey}
questionIdx={-1}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
<div className="mt-3">
<Label htmlFor="subheader">Welcome Message</Label>
<div className="mt-2">
<Editor
getText={() =>
md.render(
localSurvey?.welcomeCard?.html || "Thanks for providing your feedback - let's go!"
)
}
setText={(value: string) => {
updateSurvey({ html: value });
}}
excludedToolbarItems={["blockType"]}
disableLists
<LocalizedEditor
id="html"
value={localSurvey.welcomeCard.html}
localSurvey={localSurvey}
isInvalid={isInvalid}
updateQuestion={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
firstRender={firstRender}
setFirstRender={setFirstRender}
questionIdx={-1}
/>
</div>
</div>
@@ -149,15 +153,18 @@ export default function EditWelcomeCard({
<div className="mt-3 flex justify-between gap-8">
<div className="flex w-full space-x-2">
<div className="w-full">
<Label htmlFor="buttonLabel">Button Label</Label>
<div className="mt-2">
<Input
id="buttonLabel"
name="buttonLabel"
defaultValue={localSurvey?.welcomeCard?.buttonLabel || "Next"}
onChange={(e) => updateSurvey({ buttonLabel: e.target.value })}
/>
</div>
<QuestionFormInput
id="buttonLabel"
value={localSurvey.welcomeCard.buttonLabel}
localSurvey={localSurvey}
questionIdx={-1}
maxLength={48}
placeholder={"Next"}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
</div>
</div>

View File

@@ -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 (
<form>
<QuestionFormInput
id="headline"
value={question.headline}
localSurvey={localSurvey}
environmentId={environmentId}
isInvalid={isInvalid}
questionId={question.id}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
type="headline"
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
<div>
{showSubheader && (
<>
<div className="flex w-full items-center">
<div className="inline-flex w-full items-center">
<div className="w-full">
<QuestionFormInput
id="subheader"
value={question.subheader}
localSurvey={localSurvey}
environmentId={environmentId}
isInvalid={isInvalid}
questionId={question.id}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
type="subheader"
/>
<TrashIcon
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: "" });
}}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
</>
<TrashIcon
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: undefined });
}}
/>
</div>
)}
{!showSubheader && (
<Button
@@ -145,7 +153,13 @@ export default function FileUploadQuestionForm({
className="mt-3"
variant="minimal"
type="button"
onClick={() => setShowSubheader(true)}>
onClick={() => {
updateQuestion(questionIdx, {
subheader: createI18nString("", surveyLanguageCodes),
});
setShowSubheader(true);
}}>
{" "}
<PlusIcon className="mr-1 h-4 w-4" />
Add Description
</Button>

View File

@@ -74,7 +74,9 @@ const HiddenFieldsCard: FC<HiddenFieldsCardProps> = ({
</div>
<div className="flex items-center space-x-2">
<Label htmlFor="hidden-fields-toggle">Enabled</Label>
<Label htmlFor="hidden-fields-toggle">
{localSurvey?.hiddenFields?.enabled ? "On" : "Off"}
</Label>
<Switch
id="hidden-fields-toggle"
@@ -106,7 +108,9 @@ const HiddenFieldsCard: FC<HiddenFieldsCardProps> = ({
);
})
) : (
<p className="text-sm italic text-slate-500">No hidden fields yet. Add the first one below.</p>
<p className="mt-2 text-sm italic text-slate-500">
No hidden fields yet. Add the first one below.
</p>
)}
</div>
<form
@@ -173,7 +177,17 @@ const validateHiddenField = (
}
// no key words -- userId & suid & existing question ids
if (
["userId", "source", "suid", "end", "start", "welcomeCard", "hidden", "verifiedEmail"].includes(field) ||
[
"userId",
"source",
"suid",
"end",
"start",
"welcomeCard",
"hidden",
"verifiedEmail",
"multiLanguage",
].includes(field) ||
existingQuestions.findIndex((q) => q.id === field) !== -1
) {
return "Question not allowed";

View File

@@ -0,0 +1,25 @@
export const LoadingSkeleton = () => (
<div className="flex h-full w-full flex-col items-center justify-between p-6">
{/* Top Part - Loading Navbar */}
<div className="flex h-[10vh] w-full animate-pulse rounded-lg bg-slate-200 font-medium text-slate-900"></div>
{/* Bottom Part - Divided into Left and Right */}
<div className="mt-4 flex h-[85%] w-full flex-row">
{/* Left Part - 7 Horizontal Bars */}
<div className="flex h-full w-1/2 flex-col justify-between space-y-2">
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-slate-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-slate-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-slate-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-slate-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-slate-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-slate-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-slate-200"></div>
</div>
{/* Right Part - Simple Box */}
<div className="ml-4 flex h-full w-1/2 flex-col">
<div className="ph-no-capture h-full animate-pulse rounded-lg bg-slate-200"></div>
</div>
</div>
</div>
);

View File

@@ -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 && (
<SelectItem key={question.id} value={question.id} title={question.headline}>
<div className="max-w-[6rem]">
<p className="truncate text-left">{question.headline}</p>
<SelectItem
key={question.id}
value={question.id}
title={getLocalizedValue(question.headline, "default")}>
<div className="w-40">
<p className="truncate text-left">
{getLocalizedValue(question.headline, "default")}
</p>
</div>
</SelectItem>
)

View File

@@ -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<HTMLInputElement>(null);
const [isNew, setIsNew] = useState(true);
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
const questionRef = useRef<HTMLInputElement>(null);
const [isInvalidValue, setisInvalidValue] = useState<string | null>(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 (
<form>
<QuestionFormInput
id="headline"
value={question.headline}
localSurvey={localSurvey}
environmentId={environmentId}
isInvalid={isInvalid}
ref={questionRef}
questionId={question.id}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
type="headline"
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
<div>
{showSubheader && (
<>
<div className="flex w-full items-center">
<div className="inline-flex w-full items-center">
<div className="w-full">
<QuestionFormInput
id="subheader"
value={question.subheader}
localSurvey={localSurvey}
environmentId={environmentId}
isInvalid={isInvalid}
questionId={question.id}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
type="subheader"
/>
<TrashIcon
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: "" });
}}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
</>
<TrashIcon
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: undefined });
}}
/>
</div>
)}
{!showSubheader && (
<Button
@@ -204,7 +218,13 @@ export default function MultipleChoiceMultiForm({
variant="minimal"
className="mt-3"
type="button"
onClick={() => setShowSubheader(true)}>
onClick={() => {
updateQuestion(questionIdx, {
subheader: createI18nString("", surveyLanguageCodes),
});
setShowSubheader(true);
}}>
{" "}
<PlusIcon className="mr-1 h-4 w-4" />
Add Description
</Button>
@@ -213,45 +233,55 @@ export default function MultipleChoiceMultiForm({
<div className="mt-3">
<Label htmlFor="choices">Options</Label>
<div className="mt-2 space-y-2" id="choices">
<div className="mt-2 -space-y-2" id="choices">
{question.choices &&
question.choices.map((choice, choiceIdx) => (
<div key={choiceIdx} className="inline-flex w-full items-center">
<div className="flex w-full space-x-2">
<Input
ref={choiceIdx === question.choices.length - 1 ? lastChoiceRef : null}
id={choice.id}
name={choice.id}
value={choice.label}
className={cn(choice.id === "other" && "border-dashed")}
<div className="w-full space-x-2">
<QuestionFormInput
key={choice.id}
id={`choice-${choiceIdx}`}
localSurvey={localSurvey}
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
onChange={(e) => 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" && (
<Input
id="otherInputLabel"
name="otherInputLabel"
value={question.otherOptionPlaceholder ?? "Please specify"}
placeholder={question.otherOptionPlaceholder ?? "Please specify"}
className={cn(choice.id === "other" && "border-dashed")}
onChange={(e) => {
if (e.target.value.trim() == "") e.target.value = "";
updateQuestion(questionIdx, { otherOptionPlaceholder: e.target.value });
}}
<QuestionFormInput
id="otherOptionPlaceholder"
localSurvey={localSurvey}
placeholder={"Please specify"}
questionIdx={questionIdx}
value={
question.otherOptionPlaceholder
? question.otherOptionPlaceholder
: createI18nString("Please specify", surveyLanguageCodes)
}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={
isInvalid &&
!isLabelValidForAllLanguages(question.choices[choiceIdx].label, surveyLanguageCodes)
}
className="border border-dashed"
/>
)}
</div>

View File

@@ -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<HTMLInputElement>(null);
const [isNew, setIsNew] = useState(true);
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
const [isInvalidValue, setisInvalidValue] = useState<string | null>(null);
const questionRef = useRef<HTMLInputElement>(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 (
<form>
<QuestionFormInput
id="headline"
value={question.headline}
localSurvey={localSurvey}
environmentId={environmentId}
isInvalid={isInvalid}
ref={questionRef}
questionId={question.id}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
type="headline"
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
<div>
{showSubheader && (
<>
<div className="flex w-full items-center">
<div className="inline-flex w-full items-center">
<div className="w-full">
<QuestionFormInput
id="subheader"
value={question.subheader}
localSurvey={localSurvey}
environmentId={environmentId}
isInvalid={isInvalid}
questionId={question.id}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
type="subheader"
/>
<TrashIcon
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: "" });
}}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
</>
<TrashIcon
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: undefined });
}}
/>
</div>
)}
{!showSubheader && (
<Button
@@ -204,7 +212,13 @@ export default function MultipleChoiceSingleForm({
variant="minimal"
className="mt-3"
type="button"
onClick={() => setShowSubheader(true)}>
onClick={() => {
updateQuestion(questionIdx, {
subheader: createI18nString("", surveyLanguageCodes),
});
setShowSubheader(true);
}}>
{" "}
<PlusIcon className="mr-1 h-4 w-4" />
Add Description
</Button>
@@ -213,45 +227,55 @@ export default function MultipleChoiceSingleForm({
<div className="mt-3">
<Label htmlFor="choices">Options</Label>
<div className="mt-2 space-y-2" id="choices">
<div className="mt-2 -space-y-2" id="choices">
{question.choices &&
question.choices.map((choice, choiceIdx) => (
<div key={choiceIdx} className="flex w-full items-center">
<div className="inline-flex w-full items-center">
<div className="flex w-full space-x-2">
<Input
ref={choiceIdx === question.choices.length - 1 ? lastChoiceRef : null}
id={choice.id}
name={choice.id}
value={choice.label}
className={cn(choice.id === "other" && "border-dashed")}
<QuestionFormInput
key={choice.id}
id={`choice-${choiceIdx}`}
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
localSurvey={localSurvey}
questionIdx={questionIdx}
value={choice.label}
onBlur={() => {
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" && (
<Input
id="otherInputLabel"
name="otherInputLabel"
value={question.otherOptionPlaceholder ?? "Please specify"}
placeholder={question.otherOptionPlaceholder ?? "Please specify"}
className={cn(choice.id === "other" && "border-dashed")}
onChange={(e) => {
if (e.target.value.trim() == "") e.target.value = "";
updateQuestion(questionIdx, { otherOptionPlaceholder: e.target.value });
}}
<QuestionFormInput
id="otherOptionPlaceholder"
localSurvey={localSurvey}
placeholder={"Please specify"}
questionIdx={questionIdx}
value={
question.otherOptionPlaceholder
? question.otherOptionPlaceholder
: createI18nString("Please specify", surveyLanguageCodes)
}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={
isInvalid &&
!isLabelValidForAllLanguages(question.choices[choiceIdx].label, surveyLanguages)
}
className="border border-dashed"
/>
)}
</div>

View File

@@ -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 (
<form>
<QuestionFormInput
id="headline"
value={question.headline}
localSurvey={localSurvey}
environmentId={environmentId}
isInvalid={isInvalid}
questionId={question.id}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
type="headline"
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
<div>
{showSubheader && (
<>
<div className=" flex w-full items-center">
<div className="mt-2 inline-flex w-full items-center">
<div className="w-full">
<QuestionFormInput
id="subheader"
value={question.subheader}
localSurvey={localSurvey}
environmentId={environmentId}
isInvalid={isInvalid}
questionId={question.id}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
type="subheader"
/>
<TrashIcon
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: "" });
}}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
</>
<TrashIcon
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: undefined });
}}
/>
</div>
)}
{!showSubheader && (
<Button
@@ -70,50 +75,60 @@ export default function NPSQuestionForm({
variant="minimal"
className="mt-3"
type="button"
onClick={() => setShowSubheader(true)}>
onClick={() => {
updateQuestion(questionIdx, {
subheader: createI18nString("", surveyLanguageCodes),
});
setShowSubheader(true);
}}>
{" "}
<PlusIcon className="mr-1 h-4 w-4" />
Add Description
</Button>
)}
</div>
<div className="mt-3 flex justify-between">
<div>
<Label htmlFor="subheader">Lower label</Label>
<div className="mt-2">
<Input
id="subheader"
name="subheader"
value={question.lowerLabel}
onChange={(e) => updateQuestion(questionIdx, { lowerLabel: e.target.value })}
/>
</div>
<div className="mt-3 flex justify-between space-x-2">
<div className="w-full">
<QuestionFormInput
id="lowerLabel"
value={question.lowerLabel}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
<div>
<Label htmlFor="subheader">Upper label</Label>
<div className="mt-2">
<Input
id="subheader"
name="subheader"
value={question.upperLabel}
onChange={(e) => updateQuestion(questionIdx, { upperLabel: e.target.value })}
/>
</div>
<div className="w-full">
<QuestionFormInput
id="upperLabel"
value={question.upperLabel}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
</div>
{!question.required && (
<div className="mt-3">
<Label htmlFor="buttonLabel">Button Label</Label>
<div className="mt-2">
<Input
id="buttonLabel"
name="buttonLabel"
value={question.buttonLabel}
placeholder={lastQuestion ? "Finish" : "Next"}
onChange={(e) => updateQuestion(questionIdx, { buttonLabel: e.target.value })}
/>
</div>
<QuestionFormInput
id="buttonLabel"
value={question.buttonLabel}
localSurvey={localSurvey}
questionIdx={questionIdx}
maxLength={48}
placeholder={lastQuestion ? "Finish" : "Next"}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
)}
</form>

View File

@@ -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 (
<form>
<QuestionFormInput
id="headline"
value={question.headline}
localSurvey={localSurvey}
environmentId={environmentId}
isInvalid={isInvalid}
questionId={question.id}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
type="headline"
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
<div>
{showSubheader && (
<>
<div className="flex w-full items-center">
<div className="inline-flex w-full items-center">
<div className="w-full">
<QuestionFormInput
id="subheader"
value={question.subheader}
localSurvey={localSurvey}
environmentId={environmentId}
isInvalid={isInvalid}
questionId={question.id}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
type="subheader"
/>
<TrashIcon
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: "" });
}}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
</>
<TrashIcon
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: undefined });
}}
/>
</div>
)}
{!showSubheader && (
<Button
@@ -101,23 +106,32 @@ export default function OpenQuestionForm({
variant="minimal"
className="mt-3"
type="button"
onClick={() => setShowSubheader(true)}>
onClick={() => {
updateQuestion(questionIdx, {
subheader: createI18nString("", surveyLanguageCodes),
});
setShowSubheader(true);
}}>
<PlusIcon className="mr-1 h-4 w-4" />
Add Description
</Button>
)}
</div>
<div className="mt-3">
<Label htmlFor="placeholder">Placeholder</Label>
<div className="mt-2">
<Input
id="placeholder"
name="placeholder"
value={question.placeholder ?? defaultPlaceholder}
onChange={(e) => updateQuestion(questionIdx, { placeholder: e.target.value })}
/>
</div>
<div className="mt-2">
<QuestionFormInput
id="placeholder"
value={
question.placeholder
? question.placeholder
: createI18nString(defaultPlaceholder, surveyLanguageCodes)
}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
{/* Add a dropdown to select the question type */}

View File

@@ -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 (
<form>
<QuestionFormInput
id="headline"
value={question.headline}
localSurvey={localSurvey}
environmentId={environmentId}
isInvalid={isInvalid}
questionId={question.id}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
type="headline"
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
<div>
{showSubheader && (
<>
<div className="flex w-full items-center">
<div className="mt-2 inline-flex w-full items-center">
<div className="w-full">
<QuestionFormInput
id="subheader"
value={question.subheader}
localSurvey={localSurvey}
environmentId={environmentId}
isInvalid={isInvalid}
questionId={question.id}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
type="subheader"
/>
<TrashIcon
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: "" });
}}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
</>
<TrashIcon
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: undefined });
}}
/>
</div>
)}
{!showSubheader && (
<Button
@@ -69,7 +78,13 @@ export default function PictureSelectionForm({
variant="minimal"
className="mt-3"
type="button"
onClick={() => setShowSubheader(true)}>
onClick={() => {
updateQuestion(questionIdx, {
subheader: createI18nString("", surveyLanguageCodes),
});
setShowSubheader(true);
}}>
{" "}
<PlusIcon className="mr-1 h-4 w-4" />
Add Description
</Button>

View File

@@ -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 (
<div className="w-full">
<Label htmlFor="backButtonLabel">&quot;Back&quot; Button Label</Label>
<div className="mt-2">
<Input
id="backButtonLabel"
name="backButtonLabel"
value={value}
placeholder="Back"
onChange={onChange}
className={className}
/>
</div>
</div>
);
}
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({
</div>
<div>
<p className="text-sm font-semibold">
{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)}
</p>
{!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 ? (
<div className="mt-4 flex space-x-2">
<div className="mt-2 flex space-x-2">
<div className="w-full">
<Label htmlFor="buttonLabel">&quot;Next&quot; Button Label</Label>
<div className="mt-2">
<Input
id="buttonLabel"
name="buttonLabel"
value={question.buttonLabel}
maxLength={48}
placeholder={lastQuestion ? "Finish" : "Next"}
onChange={(e) => {
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);
}}
/>
</div>
<QuestionFormInput
id="buttonLabel"
value={question.buttonLabel}
localSurvey={localSurvey}
questionIdx={questionIdx}
maxLength={48}
placeholder={lastQuestion ? "Finish" : "Next"}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
onBlur={(e) => {
if (!question.buttonLabel) return;
let translatedNextButtonLabel = {
...question.buttonLabel,
[selectedLanguageCode]: e.target.value,
};
if (questionIdx === localSurvey.questions.length - 1) return;
updateEmptyNextButtonLabels(translatedNextButtonLabel);
}}
/>
</div>
{questionIdx !== 0 && (
<BackButtonInput
<QuestionFormInput
id="backButtonLabel"
value={question.backButtonLabel}
onChange={(e) => {
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}
/>
)}
</div>
@@ -363,12 +378,17 @@ export default function QuestionCard({
question.type === TSurveyQuestionType.NPS) &&
questionIdx !== 0 && (
<div className="mt-4">
<BackButtonInput
<QuestionFormInput
id="backButtonLabel"
value={question.backButtonLabel}
onChange={(e) => {
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}
/>
</div>
)}

View File

@@ -28,7 +28,7 @@ interface QuestionsAudienceTabsProps {
export default function QuestionsAudienceTabs({ activeId, setActiveId }: QuestionsAudienceTabsProps) {
return (
<div className="fixed z-10 flex h-14 w-full items-center justify-center border bg-white md:w-1/2">
<div className="fixed z-20 flex h-14 w-full items-center justify-center border bg-white md:w-1/2">
<nav className="flex h-full items-center space-x-4" aria-label="Tabs">
{tabs.map((tab) => (
<button

View File

@@ -6,6 +6,8 @@ import { useEffect, useMemo, useState } from "react";
import { DragDropContext } from "react-beautiful-dnd";
import toast from "react-hot-toast";
import { MultiLanguageCard } from "@formbricks/ee/multiLanguage/components/MultiLanguageCard";
import { extractLanguageCodes, getLocalizedValue, translateQuestion } from "@formbricks/lib/i18n/utils";
import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall";
import { TProduct } from "@formbricks/types/product";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys";
@@ -15,7 +17,7 @@ import EditThankYouCard from "./EditThankYouCard";
import EditWelcomeCard from "./EditWelcomeCard";
import QuestionCard from "./QuestionCard";
import { StrictModeDroppable } from "./StrictModeDroppable";
import { validateQuestion } from "./Validation";
import { isCardValid, validateQuestion, validateSurveyQuestionsInBatch } from "./Validation";
interface QuestionsViewProps {
localSurvey: TSurvey;
@@ -23,8 +25,12 @@ interface QuestionsViewProps {
activeQuestionId: string | null;
setActiveQuestionId: (questionId: string | null) => void;
product: TProduct;
invalidQuestions: String[] | null;
setInvalidQuestions: (invalidQuestions: String[] | null) => void;
invalidQuestions: string[] | null;
setInvalidQuestions: (invalidQuestions: string[] | null) => void;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
isMultiLanguageAllowed?: boolean;
isFormbricksCloud: boolean;
}
export default function QuestionsView({
@@ -35,6 +41,10 @@ export default function QuestionsView({
product,
invalidQuestions,
setInvalidQuestions,
setSelectedLanguageCode,
selectedLanguageCode,
isMultiLanguageAllowed,
isFormbricksCloud,
}: QuestionsViewProps) {
const internalQuestionIdMap = useMemo(() => {
return localSurvey.questions.reduce((acc, question) => {
@@ -42,13 +52,15 @@ export default function QuestionsView({
return acc;
}, {});
}, [localSurvey.questions]);
const surveyLanguages = localSurvey.languages;
const [backButtonLabel, setbackButtonLabel] = useState(null);
const handleQuestionLogicChange = (survey: TSurvey, compareId: string, updatedId: string): TSurvey => {
survey.questions.forEach((question) => {
if (question.headline.includes(`recall:${compareId}`)) {
question.headline = question.headline.replaceAll(`recall:${compareId}`, `recall:${updatedId}`);
if (question.headline[selectedLanguageCode].includes(`recall:${compareId}`)) {
question.headline[selectedLanguageCode] = question.headline[selectedLanguageCode].replaceAll(
`recall:${compareId}`,
`recall:${updatedId}`
);
}
if (!question.logic) return;
question.logic.forEach((rule) => {
@@ -61,13 +73,13 @@ export default function QuestionsView({
};
// function to validate individual questions
const validateSurvey = (question: TSurveyQuestion) => {
const validateSurveyQuestion = (question: TSurveyQuestion) => {
// prevent this function to execute further if user hasnt still tried to save the survey
if (invalidQuestions === null) {
return;
}
let temp = JSON.parse(JSON.stringify(invalidQuestions));
if (validateQuestion(question)) {
let temp = structuredClone(invalidQuestions);
if (validateQuestion(question, surveyLanguages)) {
temp = invalidQuestions.filter((id) => id !== question.id);
setInvalidQuestions(temp);
} else if (!invalidQuestions.includes(question.id)) {
@@ -94,7 +106,6 @@ export default function QuestionsView({
delete internalQuestionIdMap[localSurvey.questions[questionIdx].id];
setActiveQuestionId(updatedAttributes.id);
}
updatedSurvey.questions[questionIdx] = {
...updatedSurvey.questions[questionIdx],
...updatedAttributes,
@@ -107,7 +118,7 @@ export default function QuestionsView({
setbackButtonLabel(updatedAttributes.backButtonLabel);
}
setLocalSurvey(updatedSurvey);
validateSurvey(updatedSurvey.questions[questionIdx]);
validateSurveyQuestion(updatedSurvey.questions[questionIdx]);
};
const deleteQuestion = (questionIdx: number) => {
@@ -117,16 +128,18 @@ export default function QuestionsView({
// check if we are recalling from this question
updatedSurvey.questions.forEach((question) => {
if (question.headline.includes(`recall:${questionId}`)) {
const recallInfo = extractRecallInfo(question.headline);
if (question.headline[selectedLanguageCode].includes(`recall:${questionId}`)) {
const recallInfo = extractRecallInfo(getLocalizedValue(question.headline, selectedLanguageCode));
if (recallInfo) {
question.headline = question.headline.replace(recallInfo, "");
question.headline[selectedLanguageCode] = question.headline[selectedLanguageCode].replace(
recallInfo,
""
);
}
}
});
updatedSurvey.questions.splice(questionIdx, 1);
updatedSurvey = handleQuestionLogicChange(updatedSurvey, questionId, "end");
setLocalSurvey(updatedSurvey);
delete internalQuestionIdMap[questionId];
if (questionId === activeQuestionIdTemp) {
@@ -140,7 +153,7 @@ export default function QuestionsView({
};
const duplicateQuestion = (questionIdx: number) => {
const questionToDuplicate = JSON.parse(JSON.stringify(localSurvey.questions[questionIdx]));
const questionToDuplicate = structuredClone(localSurvey.questions[questionIdx]);
const newQuestionId = createId();
@@ -166,8 +179,9 @@ export default function QuestionsView({
if (backButtonLabel) {
question.backButtonLabel = backButtonLabel;
}
updatedSurvey.questions.push({ ...question, isDraft: true });
const languageSymbols = extractLanguageCodes(localSurvey.languages);
const translatedQuestion = translateQuestion(question, languageSymbols);
updatedSurvey.questions.push({ ...translatedQuestion, isDraft: true });
setLocalSurvey(updatedSurvey);
setActiveQuestionId(question.id);
@@ -195,7 +209,67 @@ export default function QuestionsView({
};
useEffect(() => {
const questionWithEmptyFallback = checkForEmptyFallBackValue(localSurvey);
if (invalidQuestions === null) return;
const updateInvalidQuestions = (card, cardId, currentInvalidQuestions) => {
if (card.enabled && !isCardValid(card, cardId, surveyLanguages)) {
return currentInvalidQuestions.includes(cardId)
? currentInvalidQuestions
: [...currentInvalidQuestions, cardId];
}
return currentInvalidQuestions.filter((id) => id !== cardId);
};
const updatedQuestionsStart = updateInvalidQuestions(localSurvey.welcomeCard, "start", invalidQuestions);
const updatedQuestionsEnd = updateInvalidQuestions(
localSurvey.thankYouCard,
"end",
updatedQuestionsStart
);
setInvalidQuestions(updatedQuestionsEnd);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSurvey.welcomeCard, localSurvey.thankYouCard]);
//useEffect to validate survey when changes are made to languages
useEffect(() => {
if (!invalidQuestions) return;
let updatedInvalidQuestions: string[] = invalidQuestions;
// Validate each question
localSurvey.questions.forEach((question) => {
updatedInvalidQuestions = validateSurveyQuestionsInBatch(
question,
updatedInvalidQuestions,
surveyLanguages
);
});
// Check welcome card
if (localSurvey.welcomeCard.enabled && !isCardValid(localSurvey.welcomeCard, "start", surveyLanguages)) {
if (!updatedInvalidQuestions.includes("start")) {
updatedInvalidQuestions.push("start");
}
} else {
updatedInvalidQuestions = updatedInvalidQuestions.filter((questionId) => questionId !== "start");
}
// Check thank you card
if (localSurvey.thankYouCard.enabled && !isCardValid(localSurvey.thankYouCard, "end", surveyLanguages)) {
if (!updatedInvalidQuestions.includes("end")) {
updatedInvalidQuestions.push("end");
}
} else {
updatedInvalidQuestions = updatedInvalidQuestions.filter((questionId) => questionId !== "end");
}
if (JSON.stringify(updatedInvalidQuestions) !== JSON.stringify(invalidQuestions)) {
setInvalidQuestions(updatedInvalidQuestions);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSurvey.languages, localSurvey.questions]);
useEffect(() => {
const questionWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, selectedLanguageCode);
if (questionWithEmptyFallback) {
setActiveQuestionId(questionWithEmptyFallback.id);
if (activeQuestionId === questionWithEmptyFallback.id) {
@@ -206,13 +280,16 @@ export default function QuestionsView({
}, [activeQuestionId, setActiveQuestionId]);
return (
<div className="mt-12 px-5 py-4">
<div className="mt-16 px-5 py-4">
<div className="mb-5 flex flex-col gap-5">
<EditWelcomeCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setActiveQuestionId={setActiveQuestionId}
activeQuestionId={activeQuestionId}
isInvalid={invalidQuestions ? invalidQuestions.includes("start") : false}
setSelectedLanguageCode={setSelectedLanguageCode}
selectedLanguageCode={selectedLanguageCode}
/>
</div>
<DragDropContext onDragEnd={onDragEnd}>
@@ -230,6 +307,8 @@ export default function QuestionsView({
moveQuestion={moveQuestion}
updateQuestion={updateQuestion}
duplicateQuestion={duplicateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
deleteQuestion={deleteQuestion}
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
@@ -250,6 +329,9 @@ export default function QuestionsView({
setLocalSurvey={setLocalSurvey}
setActiveQuestionId={setActiveQuestionId}
activeQuestionId={activeQuestionId}
isInvalid={invalidQuestions ? invalidQuestions.includes("end") : false}
setSelectedLanguageCode={setSelectedLanguageCode}
selectedLanguageCode={selectedLanguageCode}
/>
{localSurvey.type === "link" ? (
@@ -260,6 +342,17 @@ export default function QuestionsView({
activeQuestionId={activeQuestionId}
/>
) : null}
<MultiLanguageCard
localSurvey={localSurvey}
product={product}
setLocalSurvey={setLocalSurvey}
setActiveQuestionId={setActiveQuestionId}
activeQuestionId={activeQuestionId}
isMultiLanguageAllowed={isMultiLanguageAllowed}
isFormbricksCloud={isFormbricksCloud}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
</div>
);

View File

@@ -1,11 +1,11 @@
import { HashIcon, PlusIcon, SmileIcon, StarIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import { TSurvey, TSurveyRatingQuestion } 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 Dropdown from "./RatingTypeDropdown";
@@ -15,6 +15,8 @@ interface RatingQuestionFormProps {
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
isInvalid: boolean;
}
@@ -22,47 +24,51 @@ export default function RatingQuestionForm({
question,
questionIdx,
updateQuestion,
lastQuestion,
isInvalid,
localSurvey,
selectedLanguageCode,
setSelectedLanguageCode,
}: RatingQuestionFormProps) {
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
const environmentId = localSurvey.environmentId;
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
return (
<form>
<QuestionFormInput
id="headline"
value={question.headline}
localSurvey={localSurvey}
environmentId={environmentId}
isInvalid={isInvalid}
questionId={question.id}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
type="headline"
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
<div>
{showSubheader && (
<>
<div className="flex w-full items-center">
<div className="mt-2 inline-flex w-full items-center">
<div className="w-full">
<QuestionFormInput
id="subheader"
value={question.subheader}
localSurvey={localSurvey}
environmentId={environmentId}
isInvalid={isInvalid}
questionId={question.id}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
type="subheader"
/>
<TrashIcon
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: "" });
}}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
</>
<TrashIcon
className="ml-2 mt-10 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
onClick={() => {
setShowSubheader(false);
updateQuestion(questionIdx, { subheader: undefined });
}}
/>
</div>
)}
{!showSubheader && (
<Button
@@ -70,7 +76,13 @@ export default function RatingQuestionForm({
variant="minimal"
className="mt-3"
type="button"
onClick={() => setShowSubheader(true)}>
onClick={() => {
updateQuestion(questionIdx, {
subheader: createI18nString("", surveyLanguageCodes),
});
setShowSubheader(true);
}}>
{" "}
<PlusIcon className="mr-1 h-4 w-4" />
Add Description
</Button>
@@ -113,44 +125,47 @@ export default function RatingQuestionForm({
<div className="mt-3 flex justify-between gap-8">
<div className="flex-1">
<Label htmlFor="lowerLabel">Lower label</Label>
<div className="mt-2">
<Input
id="lowerLabel"
name="lowerLabel"
placeholder="Not good"
value={question.lowerLabel}
onChange={(e) => updateQuestion(questionIdx, { lowerLabel: e.target.value })}
/>
</div>
<QuestionFormInput
id="lowerLabel"
placeholder="Not good"
value={question.lowerLabel}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
<div className="flex-1">
<Label htmlFor="upperLabel">Upper label</Label>
<div className="mt-2">
<Input
id="upperLabel"
name="upperLabel"
placeholder="Very satisfied"
value={question.upperLabel}
onChange={(e) => updateQuestion(questionIdx, { upperLabel: e.target.value })}
/>
</div>
<QuestionFormInput
id="upperLabel"
placeholder="Very satisfied"
value={question.upperLabel}
localSurvey={localSurvey}
questionIdx={questionIdx}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
</div>
<div className="mt-3">
{!question.required && (
<div className="flex-1">
<Label htmlFor="buttonLabel">Dismiss Button Label</Label>
<div className="mt-2">
<Input
id="dismissButtonLabel"
name="dismissButtonLabel"
value={question.buttonLabel}
placeholder={lastQuestion ? "Finish" : "Next"}
onChange={(e) => updateQuestion(questionIdx, { buttonLabel: e.target.value })}
/>
</div>
<QuestionFormInput
id="buttonLabel"
value={question.buttonLabel}
localSurvey={localSurvey}
questionIdx={questionIdx}
placeholder={"skip"}
isInvalid={isInvalid}
updateQuestion={updateQuestion}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
</div>
)}
</div>

View File

@@ -1,10 +1,12 @@
"use client";
import { refetchProduct } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
import Loading from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/loading";
import { useEffect, useState } from "react";
import { LoadingSkeleton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LoadingSkeleton";
import { useCallback, useEffect, useRef, useState } from "react";
import { createSegmentAction } from "@formbricks/ee/advancedTargeting/lib/actions";
import { extractLanguageCodes, getEnabledLanguages } from "@formbricks/lib/i18n/utils";
import useDocumentVisibility from "@formbricks/lib/useDocumentVisibility";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TEnvironment } from "@formbricks/types/environment";
@@ -30,6 +32,7 @@ interface SurveyEditorProps {
membershipRole?: TMembershipRole;
colours: string[];
isUserTargetingAllowed?: boolean;
isMultiLanguageAllowed?: boolean;
isFormbricksCloud: boolean;
}
@@ -42,6 +45,7 @@ export default function SurveyEditor({
segments,
responseCount,
membershipRole,
isMultiLanguageAllowed,
colours,
isUserTargetingAllowed = false,
isFormbricksCloud,
@@ -49,9 +53,30 @@ export default function SurveyEditor({
const [activeView, setActiveView] = useState<"questions" | "settings">("questions");
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
const [localSurvey, setLocalSurvey] = useState<TSurvey | null>(survey);
const [invalidQuestions, setInvalidQuestions] = useState<String[] | null>(null);
const [invalidQuestions, setInvalidQuestions] = useState<string[] | null>(null);
const [selectedLanguageCode, setSelectedLanguageCode] = useState<string>("default");
const surveyEditorRef = useRef(null);
const [localProduct, setLocalProduct] = useState<TProduct>(product);
const fetchLatestProduct = useCallback(async () => {
const latestProduct = await refetchProduct(localProduct.id);
if (latestProduct) {
setLocalProduct(latestProduct);
}
}, [localProduct.id]);
useDocumentVisibility(fetchLatestProduct);
useEffect(() => {
if (survey) {
const surveyClone = structuredClone(survey);
setLocalSurvey(surveyClone);
if (survey.questions.length > 0) {
setActiveQuestionId(survey.questions[0].id);
}
}
}, [survey]);
useEffect(() => {
const listener = () => {
if (document.visibilityState === "visible") {
@@ -75,7 +100,6 @@ export default function SurveyEditor({
if (localSurvey?.questions?.length && localSurvey.questions.length > 0) {
setActiveQuestionId(localSurvey.questions[0].id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSurvey?.type, survey?.questions]);
@@ -84,7 +108,6 @@ export default function SurveyEditor({
if (!localSurvey) {
return;
}
// do nothing if its not an in-app survey
if (localSurvey.type !== "web") {
return;
@@ -117,8 +140,16 @@ export default function SurveyEditor({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [environment.id, isUserTargetingAllowed, localSurvey?.type, survey.id]);
useEffect(() => {
if (!localSurvey?.languages) return;
const enabledLanguageCodes = extractLanguageCodes(getEnabledLanguages(localSurvey.languages ?? []));
if (!enabledLanguageCodes.includes(selectedLanguageCode)) {
setSelectedLanguageCode("default");
}
}, [localSurvey?.languages, selectedLanguageCode]);
if (!localSurvey) {
return <Loading />;
return <LoadingSkeleton />;
}
return (
@@ -134,10 +165,13 @@ export default function SurveyEditor({
setInvalidQuestions={setInvalidQuestions}
product={localProduct}
responseCount={responseCount}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
/>
<div className="relative z-0 flex flex-1 overflow-hidden">
<main className="relative z-0 flex-1 overflow-y-auto focus:outline-none">
<main className="relative z-0 flex-1 overflow-y-auto focus:outline-none" ref={surveyEditorRef}>
<QuestionsAudienceTabs activeId={activeView} setActiveId={setActiveView} />
{activeView === "questions" ? (
<QuestionsView
localSurvey={localSurvey}
@@ -147,6 +181,10 @@ export default function SurveyEditor({
product={localProduct}
invalidQuestions={invalidQuestions}
setInvalidQuestions={setInvalidQuestions}
selectedLanguageCode={selectedLanguageCode ? selectedLanguageCode : "default"}
setSelectedLanguageCode={setSelectedLanguageCode}
isMultiLanguageAllowed={isMultiLanguageAllowed}
isFormbricksCloud={isFormbricksCloud}
/>
) : (
<SettingsView
@@ -164,6 +202,7 @@ export default function SurveyEditor({
/>
)}
</main>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 py-6 md:flex md:flex-col">
<PreviewSurvey
survey={localSurvey}
@@ -172,6 +211,7 @@ export default function SurveyEditor({
product={localProduct}
environment={environment}
previewType={localSurvey.type === "web" ? "modal" : "fullwidth"}
languageCode={selectedLanguageCode}
onFileUpload={async (file) => file.name}
/>
</aside>

View File

@@ -23,7 +23,7 @@ import { Input } from "@formbricks/ui/Input";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
import { updateSurveyAction } from "../actions";
import { isValidUrl, validateQuestion } from "./Validation";
import { isCardValid, validateQuestion } from "./Validation";
interface SurveyMenuBarProps {
localSurvey: TSurvey;
@@ -32,9 +32,11 @@ interface SurveyMenuBarProps {
environment: TEnvironment;
activeId: "questions" | "settings";
setActiveId: (id: "questions" | "settings") => void;
setInvalidQuestions: (invalidQuestions: String[]) => void;
setInvalidQuestions: (invalidQuestions: string[]) => void;
product: TProduct;
responseCount: number;
selectedLanguageCode: string;
setSelectedLanguageCode: (selectedLanguage: string) => void;
}
export default function SurveyMenuBar({
@@ -47,6 +49,8 @@ export default function SurveyMenuBar({
setInvalidQuestions,
product,
responseCount,
selectedLanguageCode,
setSelectedLanguageCode,
}: SurveyMenuBarProps) {
const router = useRouter();
const [audiencePrompt, setAudiencePrompt] = useState(true);
@@ -55,7 +59,7 @@ export default function SurveyMenuBar({
const [isSurveySaving, setIsSurveySaving] = useState(false);
const cautionText = "This survey received responses, make changes with caution.";
let faultyQuestions: String[] = [];
let faultyQuestions: string[] = [];
useEffect(() => {
if (audiencePrompt && activeId === "settings") {
@@ -116,42 +120,33 @@ export default function SurveyMenuBar({
const validateSurvey = (survey: TSurvey) => {
const existingQuestionIds = new Set();
faultyQuestions = [];
if (survey.questions.length === 0) {
toast.error("Please add at least one question");
return;
}
if (survey.welcomeCard.enabled) {
if (!isCardValid(survey.welcomeCard, "start", survey.languages)) {
faultyQuestions.push("start");
}
}
if (survey.thankYouCard.enabled) {
if (!isCardValid(survey.thankYouCard, "end", survey.languages)) {
faultyQuestions.push("end");
}
}
let pin = survey?.pin;
if (pin !== null && pin!.toString().length !== 4) {
toast.error("PIN must be a four digit number.");
return;
}
const { thankYouCard } = localSurvey;
if (thankYouCard.enabled) {
const { buttonLabel, buttonLink } = thankYouCard;
if (buttonLabel && !buttonLink) {
toast.error("Button Link missing on Thank you card.");
return;
}
if (!buttonLabel && buttonLink) {
toast.error("Button Label missing on Thank you card.");
return;
}
if (buttonLink && !isValidUrl(buttonLink)) {
toast.error("Invalid URL on Thank You card.");
return;
}
}
faultyQuestions = [];
for (let index = 0; index < survey.questions.length; index++) {
const question = survey.questions[index];
const isValid = validateQuestion(question);
const isValid = validateQuestion(question, survey.languages);
if (!isValid) {
faultyQuestions.push(question.id);
@@ -161,6 +156,7 @@ export default function SurveyMenuBar({
// if there are any faulty questions, the user won't be allowed to save the survey
if (faultyQuestions.length > 0) {
setInvalidQuestions(faultyQuestions);
setSelectedLanguageCode("default");
toast.error("Please fill all required fields.");
return false;
}
@@ -179,11 +175,15 @@ export default function SurveyMenuBar({
question.type === TSurveyQuestionType.MultipleChoiceMulti
) {
const haveSameChoices =
question.choices.some((element) => element.label.trim() === "") ||
question.choices.some((element) => element.label[selectedLanguageCode]?.trim() === "") ||
question.choices.some((element, index) =>
question.choices
.slice(index + 1)
.some((nextElement) => nextElement.label.trim() === element.label.trim())
.some(
(nextElement) =>
nextElement.label[selectedLanguageCode]?.trim() ===
element.label[selectedLanguageCode].trim()
)
);
if (haveSameChoices) {
@@ -237,7 +237,7 @@ export default function SurveyMenuBar({
toast.error("Please add at least one question.");
return;
}
const questionWithEmptyFallback = checkForEmptyFallBackValue(localSurvey);
const questionWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, selectedLanguageCode);
if (questionWithEmptyFallback) {
toast.error("Fallback missing");
return;

View File

@@ -42,9 +42,17 @@ export default function UpdateQuestionId({
toast.error("ID should not be empty.");
return;
} else if (
["userId", "source", "suid", "end", "start", "welcomeCard", "hidden", "verifiedEmail"].includes(
currentValue
)
[
"userId",
"source",
"suid",
"end",
"start",
"welcomeCard",
"hidden",
"verifiedEmail",
"multiLanguage",
].includes(currentValue)
) {
setCurrentValue(prevValue);
updateQuestion(questionIdx, { id: prevValue });

View File

@@ -1,42 +1,130 @@
// extend this object in order to add more validation rules
import { extractLanguageCodes } from "@formbricks/lib/i18n/utils";
import {
TI18nString,
TSurveyConsentQuestion,
TSurveyLanguage,
TSurveyMultipleChoiceMultiQuestion,
TSurveyMultipleChoiceSingleQuestion,
TSurveyPictureSelectionQuestion,
TSurveyQuestion,
TSurveyThankYouCard,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys";
// Utility function to check if label is valid for all required languages
const isLabelValidForAllLanguages = (label: TI18nString, surveyLanguages: TSurveyLanguage[]): boolean => {
const filteredLanguages = surveyLanguages.filter((surveyLanguages) => {
return surveyLanguages.enabled;
});
const languageCodes = extractLanguageCodes(filteredLanguages);
const languages = languageCodes.length === 0 ? ["default"] : languageCodes;
return languages.every((language) => label && label[language] && label[language].trim() !== "");
};
// Validation logic for multiple choice questions
const handleI18nCheckForMultipleChoice = (
question: TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceSingleQuestion,
languages: TSurveyLanguage[]
): boolean => {
return question.choices.every((choice) => isLabelValidForAllLanguages(choice.label, languages));
};
// Validation rules
const validationRules = {
multipleChoiceMulti: (question: TSurveyMultipleChoiceMultiQuestion) => {
return !question.choices.some((element) => element.label.trim() === "");
multipleChoiceMulti: (question: TSurveyMultipleChoiceMultiQuestion, languages: TSurveyLanguage[]) => {
return handleI18nCheckForMultipleChoice(question, languages);
},
multipleChoiceSingle: (question: TSurveyMultipleChoiceSingleQuestion) => {
return !question.choices.some((element) => element.label.trim() === "");
multipleChoiceSingle: (question: TSurveyMultipleChoiceSingleQuestion, languages: TSurveyLanguage[]) => {
return handleI18nCheckForMultipleChoice(question, languages);
},
consent: (question: TSurveyConsentQuestion) => {
return question.label.trim() !== "";
consent: (question: TSurveyConsentQuestion, languages: TSurveyLanguage[]) => {
return isLabelValidForAllLanguages(question.label, languages);
},
pictureSelection: (question: TSurveyPictureSelectionQuestion) => {
return question.choices.length >= 2;
},
defaultValidation: (question: TSurveyQuestion) => {
return question.headline.trim() !== "";
// Assuming headline is of type TI18nString
defaultValidation: (question: TSurveyQuestion, languages: TSurveyLanguage[]) => {
let isValid = isLabelValidForAllLanguages(question.headline, languages);
let isValidCTADismissLabel = true;
const defaultLanguageCode = "default";
if (question.type === "cta" && !question.required && question.dismissButtonLabel) {
isValidCTADismissLabel = isLabelValidForAllLanguages(question.dismissButtonLabel, languages);
}
const fieldsToValidate = [
"subheader",
"html",
"buttonLabel",
"upperLabel",
"backButtonLabel",
"lowerLabel",
"placeholder",
];
for (const field of fieldsToValidate) {
if (question[field] && question[field][defaultLanguageCode]) {
isValid =
isValid && isLabelValidForAllLanguages(question[field], languages) && isValidCTADismissLabel;
}
}
return isValid;
},
};
const validateQuestion = (question) => {
// Main validation function
const validateQuestion = (question: TSurveyQuestion, surveyLanguages: TSurveyLanguage[]): boolean => {
const specificValidation = validationRules[question.type];
const defaultValidation = validationRules.defaultValidation;
const specificValidationResult = specificValidation ? specificValidation(question) : true;
const defaultValidationResult = defaultValidation(question);
const specificValidationResult = specificValidation ? specificValidation(question, surveyLanguages) : true;
const defaultValidationResult = defaultValidation(question, surveyLanguages);
// Return true only if both specific and default validation pass
return specificValidationResult && defaultValidationResult;
};
export { validateQuestion };
export const validateSurveyQuestionsInBatch = (
question: TSurveyQuestion,
invalidQuestions: string[] | null,
surveyLanguages: TSurveyLanguage[]
) => {
if (invalidQuestions === null) {
return [];
}
if (validateQuestion(question, surveyLanguages)) {
return invalidQuestions.filter((id) => id !== question.id);
} else if (!invalidQuestions.includes(question.id)) {
return [...invalidQuestions, question.id];
}
return invalidQuestions;
};
export const isCardValid = (
card: TSurveyWelcomeCard | TSurveyThankYouCard,
cardType: "start" | "end",
surveyLanguages: TSurveyLanguage[]
): boolean => {
const defaultLanguageCode = "default";
const isContentValid = (content: Record<string, string> | undefined) => {
return (
!content || content[defaultLanguageCode] === "" || isLabelValidForAllLanguages(content, surveyLanguages)
);
};
return (
(card.headline ? isLabelValidForAllLanguages(card.headline, surveyLanguages) : true) &&
isContentValid(
cardType === "start" ? (card as TSurveyWelcomeCard).html : (card as TSurveyThankYouCard).subheader
) &&
isContentValid(card.buttonLabel)
);
};
export { validateQuestion, isLabelValidForAllLanguages };
export const isValidUrl = (string: string): boolean => {
try {

View File

@@ -1,27 +1,5 @@
import { LoadingSkeleton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LoadingSkeleton";
export default function Loading() {
return (
<div className="flex h-full w-full flex-col items-center justify-between p-6">
{/* Top Part - Loading Navbar */}
<div className="flex h-[10vh] w-full animate-pulse rounded-lg bg-slate-200 font-medium text-slate-900"></div>
{/* Bottom Part - Divided into Left and Right */}
<div className="mt-4 flex h-[85%] w-full flex-row">
{/* Left Part - 7 Horizontal Bars */}
<div className="flex h-full w-1/2 flex-col justify-between space-y-2">
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-slate-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-slate-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-slate-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-slate-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-slate-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-slate-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-slate-200"></div>
</div>
{/* Right Part - Simple Box */}
<div className="ml-4 flex h-full w-1/2 flex-col">
<div className="ph-no-capture h-full animate-pulse rounded-lg bg-slate-200"></div>
</div>
</div>
</div>
);
return <LoadingSkeleton />;
}

View File

@@ -1,6 +1,6 @@
import { getServerSession } from "next-auth";
import { getAdvancedTargetingPermission } from "@formbricks/ee/lib/service";
import { getAdvancedTargetingPermission, getMultiLanguagePermission } from "@formbricks/ee/lib/service";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import { authOptions } from "@formbricks/lib/authOptions";
@@ -60,6 +60,7 @@ export default async function SurveysEditPage({ params }) {
const isSurveyCreationDeletionDisabled = isViewer;
const isUserTargetingAllowed = getAdvancedTargetingPermission(team);
const isMultiLanguageAllowed = getMultiLanguagePermission(team);
if (
!survey ||
@@ -84,6 +85,7 @@ export default async function SurveysEditPage({ params }) {
colours={colours}
segments={segments}
isUserTargetingAllowed={isUserTargetingAllowed}
isMultiLanguageAllowed={isMultiLanguageAllowed}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/>
);

View File

@@ -24,6 +24,7 @@ interface PreviewSurveyProps {
previewType?: TPreviewType;
product: TProduct;
environment: TEnvironment;
languageCode: string;
onFileUpload: (file: File, config?: TUploadFileConfig) => Promise<string>;
}
@@ -62,6 +63,7 @@ export default function PreviewSurvey({
previewType,
product,
environment,
languageCode,
onFileUpload,
}: PreviewSurveyProps) {
const [isModalOpen, setIsModalOpen] = useState(true);
@@ -213,6 +215,7 @@ export default function PreviewSurvey({
isBrandingEnabled={product.inAppSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
languageCode={languageCode}
onFileUpload={onFileUpload}
onClose={handlePreviewModalClose}
/>
@@ -227,6 +230,7 @@ export default function PreviewSurvey({
isBrandingEnabled={product.linkSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
onFileUpload={onFileUpload}
languageCode={languageCode}
responseCount={42}
/>
</div>
@@ -284,6 +288,7 @@ export default function PreviewSurvey({
isBrandingEnabled={product.inAppSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
languageCode={languageCode}
onFileUpload={onFileUpload}
onClose={handlePreviewModalClose}
/>
@@ -299,6 +304,7 @@ export default function PreviewSurvey({
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
onFileUpload={onFileUpload}
languageCode={languageCode}
responseCount={42}
/>
</div>

View File

@@ -77,6 +77,7 @@ export default function TemplateContainerWithPreview({
product={product}
environment={environment}
setActiveQuestionId={setActiveQuestionId}
languageCode={"default"}
onFileUpload={async (file) => file.name}
/>
</div>

View File

@@ -46,6 +46,7 @@ export const SignupForm = ({
const [error, setError] = useState<string>("");
const [signingUp, setSigningUp] = useState(false);
const nameRef = useRef<HTMLInputElement>(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<HTMLFormElement>(null);
const [password, setPassword] = useState<string | null>(null);
const [isValid, setIsValid] = useState(false);

View File

@@ -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);
}

View File

@@ -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];

View File

@@ -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(

View File

@@ -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<Response> {
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),

View File

@@ -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<Response> {
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),

View File

@@ -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) {

View File

@@ -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<Response> {
return responses.successResponse({}, true);
@@ -38,9 +41,10 @@ export async function GET(
): Promise<Response> {
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,
};

View File

@@ -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<Response> {
return responses.successResponse({}, true);
}
export async function GET(
_: NextRequest,
request: NextRequest,
{ params }: { params: { environmentId: string } }
): Promise<Response> {
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,

View File

@@ -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<Response> {
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) {

View File

@@ -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",
},
},

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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);

View File

@@ -0,0 +1,12 @@
import { StackedCardsContainer } from "@formbricks/ui/StackedCardsContainer";
export default function InvalidLanguage() {
return (
<div className="flex h-full w-full flex-col items-center justify-center text-center">
<StackedCardsContainer>
<span className="h-24 w-24 rounded-full bg-slate-200 p-6 text-5xl">🈂</span>
<p className="mt-8 text-4xl font-bold">Survey not available in specified language</p>
</StackedCardsContainer>
</div>
);
}

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